Set the Game Clock — increment 1: the position-circle-6 gamer places Uranus in a sign on the shared game wheel — TDD
The SEED MAP rim's planets are no longer auto-computed — they are PLACED by the
gamers in turn (circle 6→1, Uranus→Saturn→Jupiter→Mars→Sun→Moon; Mercury/Venus
Sun-derived; Neptune/Pluto excluded). This is the first turn: the wheel starts
SIGNS-ONLY (planets eliminated) and the gamer at POSITION CIRCLE 6 places Uranus
by clicking a sign wedge. Roadmap step 21. See project_voronoi_spec.
KEYED ON THE POSITION CIRCLE (slot_number), NOT the role — select_role assigns
roles freely, so position 6 can hold any role; in a generic room BC merely
defaults there. Tests INVERT the slot→role defaults (position 6 = PC, position 1
= BC) so a role-keyed bug would fail them.
- Room.clock_placements JSONField (migration 0020): {planet: sign}, the ritual
state. The 9ed8771 auto-sky infra (table_sky / sky_chart / convened_at) stays
DORMANT — future home of the resolved sky; the rim no longer reads it.
- epic.place_clock_planet POST {planet, sign}: the acting seat must be the circle
whose turn it is (its circle's planet, every earlier planet placed, this one
not yet) + a real zodiac sign → persists. _clock_placeable_for shared by the
endpoint + the seed-felt ctx so gate & affordance can't drift. 403 wrong
circle/turn/seat, 400 bad sign.
- SkyWheel.drawRim(svg, data, opts): opts.placeable turns the sign wedges into
placement targets (.nw-sign--placeable + click → opts.onPickSign(sign)); still
singleton-pure (no module writes — Jasmine R9).
- _seed_map_overlay.html: rim renders the placements (sign-midpoint glyph);
empty → signs-only; the gamer whose turn it is gets the #id_clock_prompt + the
clickable wedges → POST → adopt the server's placements + repaint (reload-safe).
- _sky.scss: .nw-sign--placeable re-enables pointer-events on the click-through
rim + a hover brighten; the .clock-prompt label.
- ctx: clock_placements_json + clock_placeable in _role_select_context (drops the
now-unused room_sky_json). Repointed the 9ed8771 rim FT/ITs (auto-sky →
placements, empty = signs-only).
- Coverage: Jasmine drawRim R7–R9 (placement clickable / inert without opts /
singleton-safe); epic PlaceClockPlanetTest (position-keyed, role-inverted) +
repointed rim ITs; FT position-6 places Uranus → glyph + persist. 1021
epic+gameboard ITs green; live-verified in Firefox.
DEFERRED: turn progression 5→1 + WS live-broadcast (increment 2); the CSP
ephemeris narrowing + resolving placements → a datetime (later).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
18
src/apps/epic/migrations/0020_room_clock_placements.py
Normal file
18
src/apps/epic/migrations/0020_room_clock_placements.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-06-10 04:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0019_room_convened_at_room_sky_chart'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='room',
|
||||||
|
name='clock_placements',
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -64,6 +64,11 @@ class Room(models.Model):
|
|||||||
# LAZILY by the table_sky view — legacy rooms key off created_at.
|
# LAZILY by the table_sky view — legacy rooms key off created_at.
|
||||||
convened_at = models.DateTimeField(null=True, blank=True)
|
convened_at = models.DateTimeField(null=True, blank=True)
|
||||||
sky_chart = models.JSONField(null=True, blank=True)
|
sky_chart = models.JSONField(null=True, blank=True)
|
||||||
|
# Set the Game Clock ritual ([[project-voronoi-spec]]): {planet: sign} placed
|
||||||
|
# by the gamers in turn (circle 6→1, Uranus→…→Moon) — the shared game wheel's
|
||||||
|
# planet source. Resolving these to a datetime → the official sky is a later
|
||||||
|
# increment; for now the rim renders the placements directly (sign granularity).
|
||||||
|
clock_placements = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
def get_thread_post(self):
|
def get_thread_post(self):
|
||||||
"""Get-or-create this room's single game-table thread Post (the POST
|
"""Get-or-create this room's single game-table thread Post (the POST
|
||||||
|
|||||||
@@ -3016,6 +3016,97 @@ class TableSkyViewTest(TestCase):
|
|||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceClockPlanetTest(TestCase):
|
||||||
|
"""Set the Game Clock — increment 1 ([[project-voronoi-spec]]): the six seats
|
||||||
|
set the game's start time by placing planets in signs on the shared wheel, in
|
||||||
|
turn order circle 6 → 1 (Uranus→Saturn→Jupiter→Mars→Sun→Moon). This covers
|
||||||
|
the placement endpoint's gating (right circle for the planet, that planet's
|
||||||
|
turn, valid sign, not re-placeable) + persistence to Room.clock_placements.
|
||||||
|
|
||||||
|
The ritual keys on the POSITION CIRCLE (slot_number), NOT the role — roles are
|
||||||
|
freely chosen in select_role, so position 6 can hold any role. The fixture
|
||||||
|
INVERTS the slot→role defaults (position 6 = PC, position 1 = BC) to prove it:
|
||||||
|
the position-6 gamer places Uranus first regardless of being 'PC'."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
|
self.p6 = User.objects.create(email="p6@clock.io")
|
||||||
|
self.room = Room.objects.create(
|
||||||
|
name="Clock", owner=self.p6, table_status=Room.SKY_SELECT
|
||||||
|
)
|
||||||
|
slot = self.room.gate_slots.get(slot_number=6)
|
||||||
|
slot.gamer = self.p6
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
self.room.gate_status = Room.OPEN
|
||||||
|
self.room.save()
|
||||||
|
# Position circle 6, role deliberately PC (not the slot-6 default BC) —
|
||||||
|
# the ritual must key on the position, not the role.
|
||||||
|
self.seat = TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=self.p6, slot_number=6, role="PC",
|
||||||
|
deck_variant=self.earthman,
|
||||||
|
)
|
||||||
|
self.url = reverse("epic:place_clock_planet", kwargs={"room_id": self.room.id})
|
||||||
|
self.client.force_login(self.p6)
|
||||||
|
|
||||||
|
def _post(self, planet="Uranus", sign="Aquarius"):
|
||||||
|
return self.client.post(self.url, {"planet": planet, "sign": sign})
|
||||||
|
|
||||||
|
def test_position_6_places_uranus(self):
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.clock_placements, {"Uranus": "Aquarius"})
|
||||||
|
|
||||||
|
def test_get_not_allowed(self):
|
||||||
|
self.assertEqual(self.client.get(self.url).status_code, 405)
|
||||||
|
|
||||||
|
def test_invalid_sign_rejected(self):
|
||||||
|
response = self._post(sign="Notasign")
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.clock_placements, {})
|
||||||
|
|
||||||
|
def test_wrong_planet_for_circle_rejected(self):
|
||||||
|
"""Position circle 6 places Uranus, not Saturn (circle 5's planet)."""
|
||||||
|
response = self._post(planet="Saturn", sign="Aquarius")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.clock_placements, {})
|
||||||
|
|
||||||
|
def test_keys_on_position_not_role(self):
|
||||||
|
"""The BC role at POSITION 1 still can't open the ritual — Uranus is
|
||||||
|
position 6's, whoever sits there. (Inverted defaults: a 'BC' gamer who is
|
||||||
|
NOT at circle 6 is powerless; the 'PC' gamer AT circle 6 is the one who
|
||||||
|
places Uranus, proven by test_position_6_places_uranus.)"""
|
||||||
|
bc_at_1 = User.objects.create(email="bc-at-1@clock.io")
|
||||||
|
slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
slot.gamer = bc_at_1
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=bc_at_1, slot_number=1, role="BC",
|
||||||
|
deck_variant=self.earthman,
|
||||||
|
)
|
||||||
|
self.client.force_login(bc_at_1)
|
||||||
|
self.assertEqual(self._post().status_code, 403)
|
||||||
|
|
||||||
|
def test_uranus_not_replaceable_once_placed(self):
|
||||||
|
self._post() # Uranus → Aquarius
|
||||||
|
response = self._post(sign="Aries") # try to move it
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.clock_placements, {"Uranus": "Aquarius"})
|
||||||
|
|
||||||
|
def test_unseated_gamer_403(self):
|
||||||
|
outsider = User.objects.create(email="out@clock.io")
|
||||||
|
self.client.force_login(outsider)
|
||||||
|
self.assertEqual(self._post().status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
# ── tarot_deal ────────────────────────────────────────────────────────────────
|
# ── tarot_deal ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TarotDealViewTest(TestCase):
|
class TarotDealViewTest(TestCase):
|
||||||
@@ -4741,33 +4832,33 @@ class PickSeaUnifiedFeltTest(TestCase):
|
|||||||
content = self.client.get(self.url).content.decode()
|
content = self.client.get(self.url).content.decode()
|
||||||
self.assertGreaterEqual(content.count("closeSeedMapFelt"), 3)
|
self.assertGreaterEqual(content.count("closeSeedMapFelt"), 3)
|
||||||
|
|
||||||
def test_seed_map_overlay_renders_shared_room_sky_rim(self):
|
def test_seed_map_overlay_renders_shared_wheel_rim(self):
|
||||||
"""Step 2's shared frame: the felt carries the wheel-rim svg + the
|
"""Step 2's shared frame: the felt carries the wheel-rim svg, drawn via
|
||||||
ROOM's own sky (one canonical frame for all six gamers — NOT the
|
SkyWheel.drawRim from the room's PLACED planets (Room.clock_placements,
|
||||||
viewing seat's natal chart), drawn via SkyWheel.drawRim. sky-wheel.js
|
the Game Clock ritual — one canonical frame for all six gamers, NOT the
|
||||||
must NOT be re-included — the sky overlay already loads it, and a
|
viewing seat's natal chart). sky-wheel.js must NOT be re-included — the
|
||||||
second top-level `const SkyWheel` declaration throws."""
|
sky overlay already loads it, and a second top-level `const SkyWheel`
|
||||||
|
declaration throws."""
|
||||||
self._complete_hand()
|
self._complete_hand()
|
||||||
self.room.sky_chart = {
|
self.room.clock_placements = {"Uranus": "Aquarius"}
|
||||||
"planets": {"Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False}},
|
self.room.save(update_fields=["clock_placements"])
|
||||||
"aspects": [],
|
|
||||||
}
|
|
||||||
self.room.save(update_fields=["sky_chart"])
|
|
||||||
content = self.client.get(self.url).content.decode()
|
content = self.client.get(self.url).content.decode()
|
||||||
self.assertIn('id="id_seed_wheel_svg"', content)
|
self.assertIn('id="id_seed_wheel_svg"', content)
|
||||||
self.assertIn("SkyWheel.drawRim", content)
|
self.assertIn("SkyWheel.drawRim", content)
|
||||||
self.assertIn("338.4", content) # the room sky, embedded
|
self.assertIn("Aquarius", content) # the placements, embedded
|
||||||
self.assertIn("data-table-sky-url", content) # lazy compute endpoint
|
self.assertIn("data-clock-place-url", content) # the placement endpoint
|
||||||
self.assertEqual(content.count("apps/gameboard/sky-wheel.js"), 1)
|
self.assertEqual(content.count("apps/gameboard/sky-wheel.js"), 1)
|
||||||
|
|
||||||
def test_seed_map_overlay_rim_survives_missing_room_sky(self):
|
def test_seed_map_overlay_no_placement_affordance_for_non_circle_6(self):
|
||||||
"""No stored room sky yet → the rim svg + lazy fetch URL still render:
|
"""The placement prompt shows only for the gamer whose turn it is. The
|
||||||
the felt fetches table_sky on open (which computes + caches), and the
|
founder here is PC (circle 1 = the Moon, last), so with nothing placed
|
||||||
canonical signs-only frame draws while the planets are absent."""
|
it is NOT their turn → no #id_clock_prompt — but the rim + the placement
|
||||||
|
endpoint URL still render (any seated gamer sees the shared wheel)."""
|
||||||
self._complete_hand()
|
self._complete_hand()
|
||||||
content = self.client.get(self.url).content.decode()
|
content = self.client.get(self.url).content.decode()
|
||||||
self.assertIn('id="id_seed_wheel_svg"', content)
|
self.assertIn('id="id_seed_wheel_svg"', content)
|
||||||
self.assertIn("data-table-sky-url", content)
|
self.assertIn("data-clock-place-url", content)
|
||||||
|
self.assertNotIn('id="id_clock_prompt"', content)
|
||||||
|
|
||||||
def test_sea_felt_leaves_sky_btn_lit_and_swaps_cleanly(self):
|
def test_sea_felt_leaves_sky_btn_lit_and_swaps_cleanly(self):
|
||||||
"""Symmetric with Sky Select (which leaves the sea btn lit while the sky
|
"""Symmetric with Sky Select (which leaves the sea btn lit while the sky
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ urlpatterns = [
|
|||||||
path('room/<uuid:room_id>/sky/save', views.sky_save, name='sky_save'),
|
path('room/<uuid:room_id>/sky/save', views.sky_save, name='sky_save'),
|
||||||
path('room/<uuid:room_id>/sky/delete', views.sky_delete, name='sky_delete'),
|
path('room/<uuid:room_id>/sky/delete', views.sky_delete, name='sky_delete'),
|
||||||
path('room/<uuid:room_id>/sky/table', views.table_sky, name='table_sky'),
|
path('room/<uuid:room_id>/sky/table', views.table_sky, name='table_sky'),
|
||||||
|
path('room/<uuid:room_id>/clock/place', views.place_clock_planet, name='place_clock_planet'),
|
||||||
path('room/<uuid:room_id>/sea/partial', views.sea_partial, name='sea_partial'),
|
path('room/<uuid:room_id>/sea/partial', views.sea_partial, name='sea_partial'),
|
||||||
path('room/<uuid:room_id>/sea/deck', views.sea_deck, name='sea_deck'),
|
path('room/<uuid:room_id>/sea/deck', views.sea_deck, name='sea_deck'),
|
||||||
path('room/<uuid:room_id>/sea/save', views.sea_save, name='sea_save'),
|
path('room/<uuid:room_id>/sea/save', views.sea_save, name='sea_save'),
|
||||||
|
|||||||
@@ -696,11 +696,14 @@ def _role_select_context(room, user, seat_param=None):
|
|||||||
if (sky_confirmed and confirmed_char.chart_data)
|
if (sky_confirmed and confirmed_char.chart_data)
|
||||||
else "null"
|
else "null"
|
||||||
)
|
)
|
||||||
# The table's OWN sky — the shared SEED MAP rim frame (one canonical
|
# Set the Game Clock — the shared SEED MAP wheel's planets are PLACED by
|
||||||
# frame for all six gamers, NOT this seat's natal chart). None until
|
# the gamers (Room.clock_placements), one canonical frame for all six.
|
||||||
# the lazy table_sky endpoint computes + caches it; the felt then
|
# The rim renders the placements; the gamer whose POSITION CIRCLE's turn
|
||||||
# fetches it on open.
|
# it is gets the placement affordance (`clock_placeable` = their circle's
|
||||||
ctx["room_sky_json"] = json.dumps(room.sky_chart) if room.sky_chart else None
|
# planet, keyed on slot_number not role). [[project-voronoi-spec]]
|
||||||
|
_placements = room.clock_placements or {}
|
||||||
|
ctx["clock_placements_json"] = json.dumps(_placements)
|
||||||
|
ctx["clock_placeable"] = _clock_placeable_for(_sky_seat, _placements)
|
||||||
if sky_confirmed:
|
if sky_confirmed:
|
||||||
# Fall back to seat.significator for Characters created before the sync was added
|
# Fall back to seat.significator for Characters created before the sync was added
|
||||||
ctx["my_tray_sig"] = confirmed_char.significator or _sky_seat.significator
|
ctx["my_tray_sig"] = confirmed_char.significator or _sky_seat.significator
|
||||||
@@ -1873,6 +1876,63 @@ def table_sky(request, room_id):
|
|||||||
return JsonResponse(room.sky_chart)
|
return JsonResponse(room.sky_chart)
|
||||||
|
|
||||||
|
|
||||||
|
# Set the Game Clock ritual ([[project-voronoi-spec]]): the six seats place a
|
||||||
|
# planet each in turn, circle 6 → 1, by decreasing orbital period so each turn
|
||||||
|
# narrows the start-time window. Mercury + Venus are Sun-bound (≤28°/≤48°
|
||||||
|
# elongation) → DERIVED, not placed; Neptune/Pluto excluded (too slow to pin a
|
||||||
|
# time). The CSP sign-narrowing + resolving placements → a datetime are later.
|
||||||
|
CLOCK_PLANET_BY_SLOT = {6: "Uranus", 5: "Saturn", 4: "Jupiter", 3: "Mars", 2: "Sun", 1: "Moon"}
|
||||||
|
CLOCK_ORDER = ["Uranus", "Saturn", "Jupiter", "Mars", "Sun", "Moon"] # placement order
|
||||||
|
_ZODIAC_SIGNS = {
|
||||||
|
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||||
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clock_placeable_for(seat, placements):
|
||||||
|
"""The planet `seat`'s gamer may place RIGHT NOW — its circle's planet, when
|
||||||
|
it's that planet's turn (every earlier planet placed, this one not yet) — or
|
||||||
|
None. Shared by the placement endpoint + the seed-felt context so the gate
|
||||||
|
and the affordance can't drift apart."""
|
||||||
|
if seat is None:
|
||||||
|
return None
|
||||||
|
planet = CLOCK_PLANET_BY_SLOT.get(seat.slot_number)
|
||||||
|
if planet is None or planet in placements:
|
||||||
|
return None
|
||||||
|
idx = CLOCK_ORDER.index(planet)
|
||||||
|
if any(p not in placements for p in CLOCK_ORDER[:idx]):
|
||||||
|
return None
|
||||||
|
return planet
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def place_clock_planet(request, room_id):
|
||||||
|
"""Set the Game Clock — place a planet in a sign on the shared game wheel
|
||||||
|
([[project-voronoi-spec]]). POST {planet, sign}. The acting seat must be the
|
||||||
|
circle whose turn it is (circle 6 places Uranus first, then 5→1); the posted
|
||||||
|
planet must be exactly the one that seat may place now; the sign must be a
|
||||||
|
real zodiac sign. Persists to Room.clock_placements. Returns {ok, placements}
|
||||||
|
200 · 400 bad sign · 403 not your seat / not your turn / not seated.
|
||||||
|
"""
|
||||||
|
if request.method != "POST":
|
||||||
|
return HttpResponse(status=405)
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
seat = _acting_seat(room, request.user, request.GET.get("seat"))
|
||||||
|
if seat is None:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
planet = (request.POST.get("planet") or "").strip()
|
||||||
|
sign = (request.POST.get("sign") or "").strip()
|
||||||
|
if sign not in _ZODIAC_SIGNS:
|
||||||
|
return JsonResponse({"error": "invalid_sign"}, status=400)
|
||||||
|
placements = dict(room.clock_placements or {})
|
||||||
|
if planet != _clock_placeable_for(seat, placements):
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
placements[planet] = sign
|
||||||
|
room.clock_placements = placements
|
||||||
|
room.save(update_fields=["clock_placements"])
|
||||||
|
return JsonResponse({"ok": True, "placements": placements})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def sky_save(request, room_id):
|
def sky_save(request, room_id):
|
||||||
"""Create or update the draft Character for the requesting gamer's seat.
|
"""Create or update the draft Character for the requesting gamer's seat.
|
||||||
|
|||||||
@@ -1460,12 +1460,19 @@ const SkyWheel = (() => {
|
|||||||
* survive a rim draw untouched. Reads the module's _signPaths cache
|
* survive a rim draw untouched. Reads the module's _signPaths cache
|
||||||
* (preload()ed by the sky overlay, which renders on every SKY_SELECT page).
|
* (preload()ed by the sky overlay, which renders on every SKY_SELECT page).
|
||||||
*
|
*
|
||||||
|
* opts (optional) — Set the Game Clock placement mode:
|
||||||
|
* {placeable: 'Uranus', onPickSign: fn(signName)} turns the sign wedges
|
||||||
|
* into placement targets (.nw-sign--placeable + cursor); clicking one calls
|
||||||
|
* onPickSign(name). Still singleton-pure — the handler is local, no module
|
||||||
|
* state is written.
|
||||||
|
*
|
||||||
* Returns {size, cx, cy, r, hubR} — hubR (just inside the planet band) is
|
* Returns {size, cx, cy, r, hubR} — hubR (just inside the planet band) is
|
||||||
* the radius the felt sizes the tessellation svg into (2 × hubR square +
|
* the radius the felt sizes the tessellation svg into (2 × hubR square +
|
||||||
* circle clip), or null without an svg/d3.
|
* circle clip), or null without an svg/d3.
|
||||||
*/
|
*/
|
||||||
function drawRim(svgEl, data) {
|
function drawRim(svgEl, data, opts) {
|
||||||
if (!svgEl || !window.d3) return null;
|
if (!svgEl || !window.d3) return null;
|
||||||
|
opts = opts || {};
|
||||||
const sel = d3.select(svgEl);
|
const sel = d3.select(svgEl);
|
||||||
sel.selectAll('*').remove();
|
sel.selectAll('*').remove();
|
||||||
|
|
||||||
@@ -1495,6 +1502,14 @@ const SkyWheel = (() => {
|
|||||||
const slice = sigGroup.append('g')
|
const slice = sigGroup.append('g')
|
||||||
.attr('class', 'nw-sign-group')
|
.attr('class', 'nw-sign-group')
|
||||||
.attr('data-sign-name', sign.name);
|
.attr('data-sign-name', sign.name);
|
||||||
|
// Placement mode (Set the Game Clock): the wedge is a placement target.
|
||||||
|
if (opts.placeable) {
|
||||||
|
slice.classed('nw-sign--placeable', true).style('cursor', 'pointer')
|
||||||
|
.on('click', function (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (opts.onPickSign) opts.onPickSign(sign.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
slice.append('path')
|
slice.append('path')
|
||||||
.attr('transform', `translate(${cx},${cy})`)
|
.attr('transform', `translate(${cx},${cy})`)
|
||||||
.attr('d', arc({
|
.attr('d', arc({
|
||||||
|
|||||||
@@ -7,23 +7,25 @@ d3-delaunay DUAL GRAPH — a Voronoi cell layer (territory) + a Delaunay edge
|
|||||||
layer (adjacency). STEP 1 is placeholder-seeded (card-driven seeding is Step 2),
|
layer (adjacency). STEP 1 is placeholder-seeded (card-driven seeding is Step 2),
|
||||||
so we assert only that the dual graph renders both layers.
|
so we assert only that the dual graph renders both layers.
|
||||||
|
|
||||||
Step 2's first piece — the SHARED WHEEL RIM: the stripped sky wheel (signs ring
|
Step 2's frame — the SHARED WHEEL RIM: the stripped sky wheel (signs ring at
|
||||||
at canonical orientation + planet glyphs; NO element ring, centre disc, houses
|
canonical orientation + planet glyphs; NO element ring, centre disc, houses or
|
||||||
or axes — a virtual table has a convened TIME but no birth LOCATION) rings the
|
axes — a virtual table has a convened TIME but no birth LOCATION) rings the
|
||||||
tessellation, drawn from the ROOM'S OWN sky (Room.sky_chart, identical for all
|
tessellation. The rim's planets are no longer auto-computed: they are PLACED by
|
||||||
six gamers). The map svg shrinks into the wheel's freed hub. The fixture
|
the gamers in the "Set the Game Clock" ritual (Room.clock_placements), so the
|
||||||
pre-stores Room.sky_chart so the lazy PySwiss compute path never fires HTTP.
|
wheel starts SIGNS-ONLY (planets eliminated) and gains a glyph per placement.
|
||||||
|
The map svg shrinks into the wheel's freed hub.
|
||||||
|
|
||||||
Plain FunctionalTest (NOT @tag("channels")): the felt-open is pure client-side.
|
Plain FunctionalTest (NOT @tag("channels")): the felt-open is pure client-side.
|
||||||
We seed a CONFIRMED Character with a complete hand directly in the DB, so there
|
We seed a CONFIRMED Character with a complete hand directly in the DB, so there
|
||||||
is no WebSocket sky-confirm flow to drive — unlike the sky/sea FTs.
|
is no WebSocket sky-confirm flow to drive — unlike the sky/sea FTs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
from apps.epic.models import Character, DeckVariant, TarotCard
|
from apps.epic.models import Character, DeckVariant, GateSlot, Room, TableSeat, TarotCard
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
|
|
||||||
from .base import FunctionalTest
|
from .base import FunctionalTest
|
||||||
@@ -32,17 +34,6 @@ from .test_game_room_select_sea import _make_sky_confirmed_room
|
|||||||
|
|
||||||
_HAND_POSITIONS = ["cover", "cross", "crown", "lay", "leave", "loom"]
|
_HAND_POSITIONS = ["cover", "cross", "crown", "lay", "leave", "loom"]
|
||||||
|
|
||||||
# The room's own sky — planets only (location-independent), mirroring the
|
|
||||||
# Jasmine ROOM_SKY fixture. Mercury retrograde exercises the ℞ badge.
|
|
||||||
_ROOM_SKY = {
|
|
||||||
"planets": {
|
|
||||||
"Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False},
|
|
||||||
"Moon": {"sign": "Capricorn", "degree": 295.1, "retrograde": False},
|
|
||||||
"Mercury": {"sign": "Aquarius", "degree": 312.8, "retrograde": True},
|
|
||||||
},
|
|
||||||
"aspects": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SeedMapFeltTest(FunctionalTest):
|
class SeedMapFeltTest(FunctionalTest):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -71,10 +62,6 @@ class SeedMapFeltTest(FunctionalTest):
|
|||||||
celtic_cross holds `hand_len` placed cards. hand_len>=6 => hand_complete
|
celtic_cross holds `hand_len` placed cards. hand_len>=6 => hand_complete
|
||||||
=> SEED MAP is the live phase btn + the felt renders."""
|
=> SEED MAP is the live phase btn + the felt renders."""
|
||||||
room, seat = _make_sky_confirmed_room(self.live_server_url, self.gamer, self.earthman)
|
room, seat = _make_sky_confirmed_room(self.live_server_url, self.gamer, self.earthman)
|
||||||
# Pre-store the table's own sky so the rim renders without the lazy
|
|
||||||
# PySwiss compute (no live HTTP from FTs).
|
|
||||||
room.sky_chart = _ROOM_SKY
|
|
||||||
room.save(update_fields=["sky_chart"])
|
|
||||||
hand = [
|
hand = [
|
||||||
{"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"}
|
{"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"}
|
||||||
for p in _HAND_POSITIONS[:hand_len]
|
for p in _HAND_POSITIONS[:hand_len]
|
||||||
@@ -131,15 +118,17 @@ class SeedMapFeltTest(FunctionalTest):
|
|||||||
), 1,
|
), 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_seed_map_felt_rings_tessellation_with_room_sky_rim(self):
|
def test_seed_map_rim_is_signs_only_until_placement(self):
|
||||||
"""Step 2's shared frame: the stripped sky wheel — canonical signs ring +
|
"""Step 2's shared frame: the stripped sky wheel rings the tessellation —
|
||||||
the ROOM's own planets, NOTHING location-bound (no houses/axes) and none
|
canonical signs ring only, NO planets until the Game Clock ritual places
|
||||||
of the stripped chrome (element ring / centre disc / aspect web) — rings
|
them (the wheel starts planet-eliminated), none of the stripped chrome
|
||||||
the tessellation, which shrinks into the wheel's freed hub."""
|
(element ring / centre disc / aspect web) and nothing location-bound
|
||||||
room = self._seed_room(hand_len=6)
|
(houses, ASC/MC axes). The map shrinks into the wheel's freed hub."""
|
||||||
|
room = self._seed_room(hand_len=6) # no clock placements yet
|
||||||
self._open_seed_felt(room)
|
self._open_seed_felt(room)
|
||||||
|
|
||||||
# The rim paints: 12 canonical sign slices + the room sky's 3 planets.
|
# The rim paints the 12 canonical sign slices, but ZERO planets — they
|
||||||
|
# are placed by gamers in the ritual, not auto-computed.
|
||||||
self.wait_for(lambda: self.assertEqual(
|
self.wait_for(lambda: self.assertEqual(
|
||||||
self.browser.execute_script(
|
self.browser.execute_script(
|
||||||
"return document.querySelectorAll('#id_seed_wheel_svg .nw-sign-group').length"
|
"return document.querySelectorAll('#id_seed_wheel_svg .nw-sign-group').length"
|
||||||
@@ -148,7 +137,7 @@ class SeedMapFeltTest(FunctionalTest):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.browser.execute_script(
|
self.browser.execute_script(
|
||||||
"return document.querySelectorAll('#id_seed_wheel_svg .nw-planet-group').length"
|
"return document.querySelectorAll('#id_seed_wheel_svg .nw-planet-group').length"
|
||||||
), 3,
|
), 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# The strip: no element ring / centre disc / aspect web, and no
|
# The strip: no element ring / centre disc / aspect web, and no
|
||||||
@@ -195,3 +184,129 @@ class SeedMapFeltTest(FunctionalTest):
|
|||||||
self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"),
|
self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"),
|
||||||
)
|
)
|
||||||
self.assertEqual(self.browser.find_elements(By.ID, "id_seed_map_overlay"), [])
|
self.assertEqual(self.browser.find_elements(By.ID, "id_seed_map_overlay"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class SeedMapClockTest(FunctionalTest):
|
||||||
|
"""Set the Game Clock — increment 1 (project_voronoi_spec): the six seats
|
||||||
|
collaboratively set the game's start time by placing planets in signs on the
|
||||||
|
shared game wheel, in turn order circle 6 → 1. This covers the FIRST turn:
|
||||||
|
the position-circle-6 gamer places URANUS. The wheel starts signs-only;
|
||||||
|
clicking a sign wedge places Uranus there → Room.clock_placements → a glyph
|
||||||
|
on every gamer's wheel (reload-safe). Turn progression 5→1, WS live-broadcast
|
||||||
|
and the ephemeris narrowing are later increments.
|
||||||
|
|
||||||
|
The ritual keys on the POSITION CIRCLE (slot_number), not the role — so the
|
||||||
|
fixture seats position 6 with role PC (not the slot-6 default BC) to prove it.
|
||||||
|
|
||||||
|
Plain FunctionalTest: the placement is a POST + repaint, no WS flow yet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
|
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman", "card_count": 106, "is_default": True,
|
||||||
|
"is_polarized": True, "has_card_images": False},
|
||||||
|
)
|
||||||
|
self.card, _ = TarotCard.objects.get_or_create(
|
||||||
|
deck_variant=self.earthman, slug="clock-fixture-em",
|
||||||
|
defaults={"arcana": "MAJOR", "suit": None, "number": 0, "name": "Fixture"},
|
||||||
|
)
|
||||||
|
self.gamer, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
|
self.gamer.unlocked_decks.add(self.earthman)
|
||||||
|
self.gamer.equipped_deck = self.earthman
|
||||||
|
self.gamer.save(update_fields=["equipped_deck"])
|
||||||
|
|
||||||
|
def _seed_position6_room(self):
|
||||||
|
"""SKY_SELECT room, founder seated at POSITION CIRCLE 6 with a complete
|
||||||
|
sea hand (so the SEED MAP felt — the clock ritual's host surface — is
|
||||||
|
reachable). Role is PC, NOT the slot-6 default BC, to prove the ritual
|
||||||
|
keys on the position. No clock placements yet."""
|
||||||
|
room = Room.objects.create(
|
||||||
|
name="Clock Room", table_status=Room.SKY_SELECT, owner=self.gamer
|
||||||
|
)
|
||||||
|
slot = room.gate_slots.get(slot_number=6)
|
||||||
|
slot.gamer = self.gamer
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
room.gate_status = Room.OPEN
|
||||||
|
room.save()
|
||||||
|
sig = (TarotCard.objects.filter(deck_variant=self.earthman, arcana="MAJOR")
|
||||||
|
.first() or self.card)
|
||||||
|
seat = TableSeat.objects.create(
|
||||||
|
room=room, gamer=self.gamer, role="PC", slot_number=6,
|
||||||
|
deck_variant=self.earthman, significator=sig,
|
||||||
|
)
|
||||||
|
hand = [
|
||||||
|
{"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"}
|
||||||
|
for p in _HAND_POSITIONS
|
||||||
|
]
|
||||||
|
Character.objects.create(
|
||||||
|
seat=seat, significator=sig, chart_data={"planets": {}},
|
||||||
|
confirmed_at=timezone.now(),
|
||||||
|
celtic_cross={"spread": "waite-smith", "hand": hand},
|
||||||
|
)
|
||||||
|
return room
|
||||||
|
|
||||||
|
def _room_url(self, room):
|
||||||
|
return self.live_server_url + reverse("epic:room", kwargs={"room_id": room.id})
|
||||||
|
|
||||||
|
def _open_seed_felt(self):
|
||||||
|
self.wait_for(lambda: self.assertNotIn(
|
||||||
|
"hex-phase-btn--out",
|
||||||
|
self.browser.find_element(By.ID, "id_seed_map_btn").get_attribute("class"),
|
||||||
|
))
|
||||||
|
|
||||||
|
def _click_and_assert_open():
|
||||||
|
btn = self.browser.find_element(By.ID, "id_seed_map_btn")
|
||||||
|
self.browser.execute_script("arguments[0].click()", btn)
|
||||||
|
self.assertTrue(self.browser.execute_script(
|
||||||
|
"return document.documentElement.classList.contains('seed-open')"
|
||||||
|
))
|
||||||
|
self.wait_for(_click_and_assert_open)
|
||||||
|
|
||||||
|
def _uranus_count(self):
|
||||||
|
return self.browser.execute_script(
|
||||||
|
"return document.querySelectorAll("
|
||||||
|
" '#id_seed_wheel_svg [data-planet=\"Uranus\"]').length"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_position_6_gamer_places_uranus_in_a_sign(self):
|
||||||
|
room = self._seed_position6_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
self._open_seed_felt()
|
||||||
|
|
||||||
|
# The position-6 gamer is prompted to place Uranus, and the wheel starts
|
||||||
|
# with NO planet placed.
|
||||||
|
self.wait_for(lambda: self.assertIn(
|
||||||
|
"Uranus",
|
||||||
|
self.browser.find_element(By.ID, "id_clock_prompt").text,
|
||||||
|
))
|
||||||
|
self.assertEqual(self._uranus_count(), 0)
|
||||||
|
|
||||||
|
# Clicking the Aquarius sign wedge places Uranus there. SVG <g> elements
|
||||||
|
# don't expose .click() in Firefox — dispatch the event (TDD skill).
|
||||||
|
self.browser.execute_script(
|
||||||
|
"arguments[0].dispatchEvent(new MouseEvent('click', {bubbles: true}))",
|
||||||
|
self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_seed_wheel_svg [data-sign-name='Aquarius']"),
|
||||||
|
)
|
||||||
|
self.wait_for(lambda: self.assertEqual(self._uranus_count(), 1))
|
||||||
|
self.assertEqual(
|
||||||
|
self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_seed_wheel_svg [data-planet='Uranus']"
|
||||||
|
).get_attribute("data-sign"),
|
||||||
|
"Aquarius",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persisted to the room: a reload re-renders Uranus in Aquarius, and the
|
||||||
|
# prompt is gone (Uranus is placed — circle 6's turn is done).
|
||||||
|
room.refresh_from_db()
|
||||||
|
self.assertEqual(room.clock_placements, {"Uranus": "Aquarius"})
|
||||||
|
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
self._open_seed_felt()
|
||||||
|
self.wait_for(lambda: self.assertEqual(self._uranus_count(), 1))
|
||||||
|
self.assertEqual(self.browser.find_elements(By.ID, "id_clock_prompt"), [])
|
||||||
|
|||||||
@@ -1145,4 +1145,46 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => {
|
|||||||
expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0);
|
expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0);
|
||||||
expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6);
|
expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Placement mode (Set the Game Clock ritual) ──────────────────────────
|
||||||
|
// drawRim(svg, data, {placeable, onPickSign}) turns the sign wedges into
|
||||||
|
// placement targets: the gamer whose turn it is clicks a sign to place the
|
||||||
|
// active planet there. Still singleton-pure — no SkyWheel module writes.
|
||||||
|
|
||||||
|
it("R7: placement mode makes the sign wedges clickable and reports the picked sign", () => {
|
||||||
|
let picked = null;
|
||||||
|
SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: (s) => { picked = s; } });
|
||||||
|
|
||||||
|
const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']");
|
||||||
|
expect(aqua.classList.contains("nw-sign--placeable")).toBe(true);
|
||||||
|
aqua.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(picked).toBe("Aquarius");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("R8: without placement opts the sign wedges stay inert (no placeable class)", () => {
|
||||||
|
SkyWheel.drawRim(rimSvg, ROOM_SKY);
|
||||||
|
const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']");
|
||||||
|
expect(aqua.classList.contains("nw-sign--placeable")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("R9: a placement click never touches the interactive singleton", () => {
|
||||||
|
skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
skySvg.setAttribute("id", "id_sky_svg");
|
||||||
|
skySvg.style.width = "400px";
|
||||||
|
skySvg.style.height = "400px";
|
||||||
|
document.body.appendChild(skySvg);
|
||||||
|
tooltipEl = document.createElement("div");
|
||||||
|
tooltipEl.id = "id_sky_tooltip";
|
||||||
|
tooltipEl.style.display = "none";
|
||||||
|
document.body.appendChild(tooltipEl);
|
||||||
|
SkyWheel.draw(skySvg, CONJUNCTION_CHART);
|
||||||
|
|
||||||
|
SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: () => {} });
|
||||||
|
rimSvg.querySelector("[data-sign-name='Aries']")
|
||||||
|
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
|
||||||
|
// The interactive wheel's tooltip controls survive; no sign locked active.
|
||||||
|
expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull();
|
||||||
|
expect(skySvg.querySelectorAll(".nw-sign--active").length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -281,10 +281,11 @@ html.seed-open .seed-page.seed-page--room {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The shared wheel rim — the stripped sky wheel (the ROOM's planets-only sky,
|
// The shared wheel rim — the stripped sky wheel (canonical signs frame + the
|
||||||
// canonical signs frame) drawn by SkyWheel.drawRim. Sits ABOVE the map svg in
|
// gamer-PLACED planets, Set the Game Clock) drawn by SkyWheel.drawRim. Sits
|
||||||
// source order; pointer-events none so territory interaction falls through.
|
// ABOVE the map svg in source order; pointer-events none so territory
|
||||||
// The .nw-* ring/planet styling rules below apply to it unchanged.
|
// interaction falls through. The .nw-* ring/planet styling rules below apply
|
||||||
|
// to it unchanged.
|
||||||
.seed-page--room svg.seed-wheel {
|
.seed-page--room svg.seed-wheel {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -292,6 +293,40 @@ html.seed-open .seed-page.seed-page--room {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
// Placement mode: the sign wedges are clickable targets for the gamer whose
|
||||||
|
// position circle's turn it is. Re-enable pointer-events ONLY on those
|
||||||
|
// wedges (the rest of the rim stays click-through); brighten on hover (the
|
||||||
|
// cursor:pointer is set inline by drawRim). [[project-voronoi-spec]]
|
||||||
|
.nw-sign--placeable {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nw-sign--placeable:hover > path[class*="nw-sign--"] {
|
||||||
|
fill: rgba(var(--ninUser), 0.55);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the Game Clock — the placement prompt ("Place Uranus in a sign"), shown
|
||||||
|
// only to the gamer whose position circle's turn it is. Pinned to the top of
|
||||||
|
// the felt, above the wheel + map (both pointer-events-light), click-through.
|
||||||
|
.seed-page--room .clock-prompt {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(var(--secUser), 1);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the z-130 position strip while the felt is up (mirrors sky/sea).
|
// Hide the z-130 position strip while the felt is up (mirrors sky/sea).
|
||||||
|
|||||||
@@ -1145,4 +1145,46 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => {
|
|||||||
expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0);
|
expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0);
|
||||||
expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6);
|
expect(geo.hubR).toBeCloseTo(400 * 0.46 * 0.52, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Placement mode (Set the Game Clock ritual) ──────────────────────────
|
||||||
|
// drawRim(svg, data, {placeable, onPickSign}) turns the sign wedges into
|
||||||
|
// placement targets: the gamer whose turn it is clicks a sign to place the
|
||||||
|
// active planet there. Still singleton-pure — no SkyWheel module writes.
|
||||||
|
|
||||||
|
it("R7: placement mode makes the sign wedges clickable and reports the picked sign", () => {
|
||||||
|
let picked = null;
|
||||||
|
SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: (s) => { picked = s; } });
|
||||||
|
|
||||||
|
const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']");
|
||||||
|
expect(aqua.classList.contains("nw-sign--placeable")).toBe(true);
|
||||||
|
aqua.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(picked).toBe("Aquarius");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("R8: without placement opts the sign wedges stay inert (no placeable class)", () => {
|
||||||
|
SkyWheel.drawRim(rimSvg, ROOM_SKY);
|
||||||
|
const aqua = rimSvg.querySelector("[data-sign-name='Aquarius']");
|
||||||
|
expect(aqua.classList.contains("nw-sign--placeable")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("R9: a placement click never touches the interactive singleton", () => {
|
||||||
|
skySvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
skySvg.setAttribute("id", "id_sky_svg");
|
||||||
|
skySvg.style.width = "400px";
|
||||||
|
skySvg.style.height = "400px";
|
||||||
|
document.body.appendChild(skySvg);
|
||||||
|
tooltipEl = document.createElement("div");
|
||||||
|
tooltipEl.id = "id_sky_tooltip";
|
||||||
|
tooltipEl.style.display = "none";
|
||||||
|
document.body.appendChild(tooltipEl);
|
||||||
|
SkyWheel.draw(skySvg, CONJUNCTION_CHART);
|
||||||
|
|
||||||
|
SkyWheel.drawRim(rimSvg, null, { placeable: "Uranus", onPickSign: () => {} });
|
||||||
|
rimSvg.querySelector("[data-sign-name='Aries']")
|
||||||
|
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
|
||||||
|
// The interactive wheel's tooltip controls survive; no sign locked active.
|
||||||
|
expect(tooltipEl.querySelector(".nw-tt-prv")).not.toBeNull();
|
||||||
|
expect(skySvg.querySelectorAll(".nw-sign--active").length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,20 +2,26 @@
|
|||||||
{# SEED MAP felt — d3-delaunay dual graph (Voronoi cells = territory + Delaunay #}
|
{# SEED MAP felt — d3-delaunay dual graph (Voronoi cells = territory + Delaunay #}
|
||||||
{# edges = adjacency) on a my_sea-style --duoUser felt filling .room-hex-pane. #}
|
{# edges = adjacency) on a my_sea-style --duoUser felt filling .room-hex-pane. #}
|
||||||
{# Inline sibling of CAST SKY (_sky_overlay) / DRAW SEA (_sea_overlay). Opens on #}
|
{# Inline sibling of CAST SKY (_sky_overlay) / DRAW SEA (_sea_overlay). Opens on #}
|
||||||
{# #id_seed_map_btn (html.seed-open). STEP 1 = placeholder seeds; Step 2's first #}
|
{# #id_seed_map_btn (html.seed-open). The dual-graph map is ringed by the SHARED #}
|
||||||
{# piece is live: the SHARED wheel rim — the ROOM's own sky (planets only; no #}
|
{# game wheel — the stripped sky-wheel rim whose planets are PLACED by the #}
|
||||||
{# location => no houses/axes), one canonical frame for all six gamers — rings #}
|
{# gamers in the "Set the Game Clock" ritual (Room.clock_placements), one #}
|
||||||
{# the tessellation, which _paint() sizes into the wheel's freed hub. #}
|
{# canonical frame for all six. The wheel starts SIGNS-ONLY; the gamer whose #}
|
||||||
{# Card-driven seeding (the 6 Celtic-Cross cards) is the next piece. #}
|
{# POSITION CIRCLE's turn it is (circle 6 places Uranus first) clicks a sign #}
|
||||||
{# See project_voronoi_spec. #}
|
{# wedge to place their planet. _paint() sizes the map into the wheel's freed #}
|
||||||
|
{# hub. See project_voronoi_spec. #}
|
||||||
<div class="seed-page seed-page--room" id="id_seed_map_overlay"
|
<div class="seed-page seed-page--room" id="id_seed_map_overlay"
|
||||||
data-table-sky-url="{% url 'epic:table_sky' room.id %}">
|
data-clock-place-url="{% url 'epic:place_clock_planet' room.id %}"
|
||||||
|
data-clock-placeable="{{ clock_placeable|default:'' }}">
|
||||||
<div class="seed-map-body">
|
<div class="seed-map-body">
|
||||||
<div class="seed-map-col">
|
<div class="seed-map-col">
|
||||||
<svg id="id_seed_map_svg" class="voronoi-map"></svg>
|
<svg id="id_seed_map_svg" class="voronoi-map"></svg>
|
||||||
<svg id="id_seed_wheel_svg" class="seed-wheel"></svg>
|
<svg id="id_seed_wheel_svg" class="seed-wheel"></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if clock_placeable %}
|
||||||
|
{# Placement prompt — only for the gamer whose position circle's turn it is. #}
|
||||||
|
<div id="id_clock_prompt" class="clock-prompt">Place {{ clock_placeable }}<br><small>in a sign</small></div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
|
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
|
||||||
<script src="{% static 'apps/gameboard/voronoi-map.js' %}"></script>
|
<script src="{% static 'apps/gameboard/voronoi-map.js' %}"></script>
|
||||||
@@ -31,11 +37,51 @@
|
|||||||
var wheelSvg = document.getElementById('id_seed_wheel_svg');
|
var wheelSvg = document.getElementById('id_seed_wheel_svg');
|
||||||
if (!overlay || !svgEl) return;
|
if (!overlay || !svgEl) return;
|
||||||
|
|
||||||
// The table's OWN sky (the shared rim frame) — embedded when already
|
// The shared wheel's planets — PLACED by the gamers, {planet: sign}. The rim
|
||||||
// computed, else lazily fetched on first open (the endpoint computes via
|
// renders these (sign granularity); empty => signs-only (planets eliminated).
|
||||||
// PySwiss at the convened moment + caches on the Room).
|
var _placements = {{ clock_placements_json|default:"{}"|safe }};
|
||||||
var _tableSky = {{ room_sky_json|default:"null"|safe }};
|
// The planet THIS gamer may place now (their position circle's turn), or ''.
|
||||||
var _skyFetched = false;
|
var _placeable = overlay.dataset.clockPlaceable || '';
|
||||||
|
|
||||||
|
var SIGN_ORDER = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||||||
|
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'];
|
||||||
|
// Sign-granularity placement → the sign's MIDPOINT degree (no degree precision
|
||||||
|
// yet); drawRim renders the glyph there.
|
||||||
|
function _midDeg(sign) { var i = SIGN_ORDER.indexOf(sign); return i < 0 ? 0 : i * 30 + 15; }
|
||||||
|
function _rimData() {
|
||||||
|
var planets = {};
|
||||||
|
Object.keys(_placements).forEach(function (p) {
|
||||||
|
planets[p] = { sign: _placements[p], degree: _midDeg(_placements[p]), retrograde: false };
|
||||||
|
});
|
||||||
|
return { planets: planets, aspects: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _csrf() {
|
||||||
|
var m = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
|
||||||
|
return m ? decodeURIComponent(m[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place the active planet in the picked sign: POST, then adopt the server's
|
||||||
|
// authoritative placements + drop this gamer's affordance (their turn is
|
||||||
|
// done — turn handoff 5→1 is a later increment) + repaint.
|
||||||
|
function _onPickSign(sign) {
|
||||||
|
if (!_placeable || !overlay.dataset.clockPlaceUrl) return;
|
||||||
|
var planet = _placeable;
|
||||||
|
window.fetch(overlay.dataset.clockPlaceUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': _csrf() },
|
||||||
|
body: 'planet=' + encodeURIComponent(planet) + '&sign=' + encodeURIComponent(sign),
|
||||||
|
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (j) {
|
||||||
|
if (!j) return;
|
||||||
|
_placements = j.placements || _placements;
|
||||||
|
_placeable = '';
|
||||||
|
var prompt = document.getElementById('id_clock_prompt');
|
||||||
|
if (prompt) prompt.parentNode.removeChild(prompt);
|
||||||
|
_paint();
|
||||||
|
}).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
// Phase-btn shed: drop #id_text_btn .active while the felt is up (sea-style);
|
// Phase-btn shed: drop #id_text_btn .active while the felt is up (sea-style);
|
||||||
// restore on close. The completed CAST SKY / DRAW SEA reopen btns stay lit.
|
// restore on close. The completed CAST SKY / DRAW SEA reopen btns stay lit.
|
||||||
@@ -52,20 +98,22 @@
|
|||||||
_disabled = [];
|
_disabled = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paint/repaint the rim + dual graph at the felt's CURRENT box. The rim
|
// Paint/repaint the rim + dual graph at the felt's CURRENT box. The rim draws
|
||||||
// draws first and hands back its hub geometry; the map svg is then sized
|
// the placed planets (+ placement targets if it's this gamer's turn) and hands
|
||||||
// into the freed hub (2×hubR square, circle-clipped) so the wheel RINGS the
|
// back its hub geometry; the map svg is then sized into the freed hub (2×hubR
|
||||||
// tessellation — both svgs centre on the same point, so they stay
|
// square, circle-clipped) so the wheel RINGS the tessellation — both svgs
|
||||||
// concentric. No rim (SkyWheel absent) -> the map keeps its full-pane CSS
|
// centre on the same point, so they stay concentric. No rim (SkyWheel absent)
|
||||||
// size, the Step-1 behaviour (also the Jasmine fixture path).
|
// -> the map keeps its full-pane CSS size (the Jasmine fixture path).
|
||||||
function _paint() {
|
function _paint() {
|
||||||
if (!window.SeedMap) return;
|
if (!window.SeedMap) return;
|
||||||
// NB: sky-wheel.js declares `const SkyWheel` at top-level script scope —
|
// NB: sky-wheel.js declares `const SkyWheel` at top-level script scope — a
|
||||||
// a global LEXICAL binding, not a window property — so probe it with
|
// global LEXICAL binding, not a window property — so probe it with typeof,
|
||||||
// typeof, never window.SkyWheel (always undefined).
|
// never window.SkyWheel (always undefined).
|
||||||
var geo = (typeof SkyWheel !== 'undefined' && SkyWheel.drawRim)
|
var geo = null;
|
||||||
? SkyWheel.drawRim(wheelSvg, _tableSky)
|
if (typeof SkyWheel !== 'undefined' && SkyWheel.drawRim) {
|
||||||
: null;
|
var opts = _placeable ? { placeable: _placeable, onPickSign: _onPickSign } : undefined;
|
||||||
|
geo = SkyWheel.drawRim(wheelSvg, _rimData(), opts);
|
||||||
|
}
|
||||||
if (geo) {
|
if (geo) {
|
||||||
var side = Math.round(geo.hubR * 2);
|
var side = Math.round(geo.hubR * 2);
|
||||||
svgEl.style.width = side + 'px';
|
svgEl.style.width = side + 'px';
|
||||||
@@ -83,9 +131,8 @@
|
|||||||
// populates via 12 async SVG fetches fired at page parse by _sky_overlay.html.
|
// populates via 12 async SVG fetches fired at page parse by _sky_overlay.html.
|
||||||
// Every other draw site (sky.html, the SkyDrive applet, _sky_overlay) awaits
|
// Every other draw site (sky.html, the SkyDrive applet, _sky_overlay) awaits
|
||||||
// that preload; the rim must too, else a cold-cache fast felt-open paints the
|
// that preload; the rim must too, else a cold-cache fast felt-open paints the
|
||||||
// sign slices WITHOUT their zodiac glyphs — and in the steady state (the room
|
// sign slices WITHOUT their zodiac glyphs — and nothing repaints to heal it.
|
||||||
// sky embedded server-side, so _fetchTableSky no-ops), no repaint ever heals
|
// preload() re-fetches are browser-cached + idempotent on the cache, so
|
||||||
// it. preload() re-fetches are browser-cached + idempotent on the cache, so
|
|
||||||
// kicking it once + repainting on resolve is the cheap established fix.
|
// kicking it once + repainting on resolve is the cheap established fix.
|
||||||
var _preloadKicked = false;
|
var _preloadKicked = false;
|
||||||
function _ensureGlyphs() {
|
function _ensureGlyphs() {
|
||||||
@@ -96,15 +143,6 @@
|
|||||||
}).catch(function () {});
|
}).catch(function () {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _fetchTableSky() {
|
|
||||||
if (_tableSky || _skyFetched || !overlay.dataset.tableSkyUrl) return;
|
|
||||||
_skyFetched = true; // one attempt per page — the signs-only frame stands on failure
|
|
||||||
window.fetch(overlay.dataset.tableSkyUrl)
|
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (j) { if (j) { _tableSky = j; _paint(); } })
|
|
||||||
.catch(function () {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-tessellate to fill the felt when its box changes (window resize /
|
// Re-tessellate to fill the felt when its box changes (window resize /
|
||||||
// rotate). Observe the COL, not the map svg — _paint() sizes that svg
|
// rotate). Observe the COL, not the map svg — _paint() sizes that svg
|
||||||
// itself, and self-observation would re-fire on every paint.
|
// itself, and self-observation would re-fire on every paint.
|
||||||
@@ -129,7 +167,6 @@
|
|||||||
// Paint at the felt's current box (visibility:hidden retains layout, so the
|
// Paint at the felt's current box (visibility:hidden retains layout, so the
|
||||||
// box is already the full pane), then keep it responsive to resize.
|
// box is already the full pane), then keep it responsive to resize.
|
||||||
_paint();
|
_paint();
|
||||||
_fetchTableSky();
|
|
||||||
_observeResize();
|
_observeResize();
|
||||||
if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear();
|
if (window.RoomViews && window.RoomViews.syncGear) window.RoomViews.syncGear();
|
||||||
}
|
}
|
||||||
@@ -143,7 +180,7 @@
|
|||||||
|
|
||||||
// PHASE btn #id_seed_map_btn lives in _room_hex_center.html (parsed BEFORE this
|
// PHASE btn #id_seed_map_btn lives in _room_hex_center.html (parsed BEFORE this
|
||||||
// overlay via _table_hex.html) -> bind directly, no defer. Trap T4 only bites
|
// overlay via _table_hex.html) -> bind directly, no defer. Trap T4 only bites
|
||||||
// _burger.html elements (included after); STEP 1 has no burger seed sub-btn.
|
// _burger.html elements (included after); there is no burger seed sub-btn.
|
||||||
var seedBtn = document.getElementById('id_seed_map_btn');
|
var seedBtn = document.getElementById('id_seed_map_btn');
|
||||||
if (seedBtn) seedBtn.addEventListener('click', openSeed);
|
if (seedBtn) seedBtn.addEventListener('click', openSeed);
|
||||||
}());
|
}());
|
||||||
|
|||||||
Reference in New Issue
Block a user