From 14afb108c04bbb68b3365415febd27953afcc072 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 10 Jun 2026 01:06:14 -0400 Subject: [PATCH] =?UTF-8?q?Set=20the=20Game=20Clock=20=E2=80=94=20incremen?= =?UTF-8?q?t=201:=20the=20position-circle-6=20gamer=20places=20Uranus=20in?= =?UTF-8?q?=20a=20sign=20on=20the=20shared=20game=20wheel=20=E2=80=94=20TD?= =?UTF-8?q?D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migrations/0020_room_clock_placements.py | 18 ++ src/apps/epic/models.py | 5 + src/apps/epic/tests/integrated/test_views.py | 127 +++++++++++-- src/apps/epic/urls.py | 1 + src/apps/epic/views.py | 70 ++++++- .../static/apps/gameboard/sky-wheel.js | 17 +- .../test_game_room_seed_map.py | 175 +++++++++++++++--- src/static/tests/SkyWheelSpec.js | 42 +++++ src/static_src/scss/_sky.scss | 43 ++++- src/static_src/tests/SkyWheelSpec.js | 42 +++++ .../_partials/_seed_map_overlay.html | 113 +++++++---- 11 files changed, 557 insertions(+), 96 deletions(-) create mode 100644 src/apps/epic/migrations/0020_room_clock_placements.py diff --git a/src/apps/epic/migrations/0020_room_clock_placements.py b/src/apps/epic/migrations/0020_room_clock_placements.py new file mode 100644 index 0000000..05ba788 --- /dev/null +++ b/src/apps/epic/migrations/0020_room_clock_placements.py @@ -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), + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 0fcec00..a25aae7 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -64,6 +64,11 @@ class Room(models.Model): # LAZILY by the table_sky view — legacy rooms key off created_at. convened_at = models.DateTimeField(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): """Get-or-create this room's single game-table thread Post (the POST diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index dc3b034..4aa9f2f 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -3016,6 +3016,97 @@ class TableSkyViewTest(TestCase): 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 ──────────────────────────────────────────────────────────────── class TarotDealViewTest(TestCase): @@ -4741,33 +4832,33 @@ class PickSeaUnifiedFeltTest(TestCase): content = self.client.get(self.url).content.decode() self.assertGreaterEqual(content.count("closeSeedMapFelt"), 3) - def test_seed_map_overlay_renders_shared_room_sky_rim(self): - """Step 2's shared frame: the felt carries the wheel-rim svg + the - ROOM's own sky (one canonical frame for all six gamers — NOT the - viewing seat's natal chart), drawn via SkyWheel.drawRim. sky-wheel.js - must NOT be re-included — the sky overlay already loads it, and a - second top-level `const SkyWheel` declaration throws.""" + def test_seed_map_overlay_renders_shared_wheel_rim(self): + """Step 2's shared frame: the felt carries the wheel-rim svg, drawn via + SkyWheel.drawRim from the room's PLACED planets (Room.clock_placements, + the Game Clock ritual — one canonical frame for all six gamers, NOT the + viewing seat's natal chart). sky-wheel.js must NOT be re-included — the + sky overlay already loads it, and a second top-level `const SkyWheel` + declaration throws.""" self._complete_hand() - self.room.sky_chart = { - "planets": {"Sun": {"sign": "Pisces", "degree": 338.4, "retrograde": False}}, - "aspects": [], - } - self.room.save(update_fields=["sky_chart"]) + self.room.clock_placements = {"Uranus": "Aquarius"} + self.room.save(update_fields=["clock_placements"]) content = self.client.get(self.url).content.decode() self.assertIn('id="id_seed_wheel_svg"', content) self.assertIn("SkyWheel.drawRim", content) - self.assertIn("338.4", content) # the room sky, embedded - self.assertIn("data-table-sky-url", content) # lazy compute endpoint + self.assertIn("Aquarius", content) # the placements, embedded + self.assertIn("data-clock-place-url", content) # the placement endpoint self.assertEqual(content.count("apps/gameboard/sky-wheel.js"), 1) - def test_seed_map_overlay_rim_survives_missing_room_sky(self): - """No stored room sky yet → the rim svg + lazy fetch URL still render: - the felt fetches table_sky on open (which computes + caches), and the - canonical signs-only frame draws while the planets are absent.""" + def test_seed_map_overlay_no_placement_affordance_for_non_circle_6(self): + """The placement prompt shows only for the gamer whose turn it is. The + founder here is PC (circle 1 = the Moon, last), so with nothing placed + 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() content = self.client.get(self.url).content.decode() 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): """Symmetric with Sky Select (which leaves the sea btn lit while the sky diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index 86035cf..3dc8f99 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -33,6 +33,7 @@ urlpatterns = [ path('room//sky/save', views.sky_save, name='sky_save'), path('room//sky/delete', views.sky_delete, name='sky_delete'), path('room//sky/table', views.table_sky, name='table_sky'), + path('room//clock/place', views.place_clock_planet, name='place_clock_planet'), path('room//sea/partial', views.sea_partial, name='sea_partial'), path('room//sea/deck', views.sea_deck, name='sea_deck'), path('room//sea/save', views.sea_save, name='sea_save'), diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 689cd8e..dbb5f39 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -696,11 +696,14 @@ def _role_select_context(room, user, seat_param=None): if (sky_confirmed and confirmed_char.chart_data) else "null" ) - # The table's OWN sky — the shared SEED MAP rim frame (one canonical - # frame for all six gamers, NOT this seat's natal chart). None until - # the lazy table_sky endpoint computes + caches it; the felt then - # fetches it on open. - ctx["room_sky_json"] = json.dumps(room.sky_chart) if room.sky_chart else None + # Set the Game Clock — the shared SEED MAP wheel's planets are PLACED by + # the gamers (Room.clock_placements), one canonical frame for all six. + # The rim renders the placements; the gamer whose POSITION CIRCLE's turn + # it is gets the placement affordance (`clock_placeable` = their circle's + # 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: # Fall back to seat.significator for Characters created before the sync was added 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) +# 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 def sky_save(request, room_id): """Create or update the draft Character for the requesting gamer's seat. diff --git a/src/apps/gameboard/static/apps/gameboard/sky-wheel.js b/src/apps/gameboard/static/apps/gameboard/sky-wheel.js index 31187fa..dc62131 100644 --- a/src/apps/gameboard/static/apps/gameboard/sky-wheel.js +++ b/src/apps/gameboard/static/apps/gameboard/sky-wheel.js @@ -1460,12 +1460,19 @@ const SkyWheel = (() => { * survive a rim draw untouched. Reads the module's _signPaths cache * (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 * the radius the felt sizes the tessellation svg into (2 × hubR square + * circle clip), or null without an svg/d3. */ - function drawRim(svgEl, data) { + function drawRim(svgEl, data, opts) { if (!svgEl || !window.d3) return null; + opts = opts || {}; const sel = d3.select(svgEl); sel.selectAll('*').remove(); @@ -1495,6 +1502,14 @@ const SkyWheel = (() => { const slice = sigGroup.append('g') .attr('class', 'nw-sign-group') .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') .attr('transform', `translate(${cx},${cy})`) .attr('d', arc({ diff --git a/src/functional_tests/test_game_room_seed_map.py b/src/functional_tests/test_game_room_seed_map.py index b7574b6..a2c6494 100644 --- a/src/functional_tests/test_game_room_seed_map.py +++ b/src/functional_tests/test_game_room_seed_map.py @@ -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), 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 -at canonical orientation + planet glyphs; NO element ring, centre disc, houses -or 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 -six gamers). The map svg shrinks into the wheel's freed hub. The fixture -pre-stores Room.sky_chart so the lazy PySwiss compute path never fires HTTP. +Step 2's frame — the SHARED WHEEL RIM: the stripped sky wheel (signs ring at +canonical orientation + planet glyphs; NO element ring, centre disc, houses or +axes — a virtual table has a convened TIME but no birth LOCATION) rings the +tessellation. The rim's planets are no longer auto-computed: they are PLACED by +the gamers in the "Set the Game Clock" ritual (Room.clock_placements), so the +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. 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. """ +from django.conf import settings from django.urls import reverse from django.utils import timezone 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 .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"] -# 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): def setUp(self): @@ -71,10 +62,6 @@ class SeedMapFeltTest(FunctionalTest): celtic_cross holds `hand_len` placed cards. hand_len>=6 => hand_complete => SEED MAP is the live phase btn + the felt renders.""" 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 = [ {"position": p, "card_id": self.card.id, "reversed": False, "polarity": "gravity"} for p in _HAND_POSITIONS[:hand_len] @@ -131,15 +118,17 @@ class SeedMapFeltTest(FunctionalTest): ), 1, ) - def test_seed_map_felt_rings_tessellation_with_room_sky_rim(self): - """Step 2's shared frame: the stripped sky wheel — canonical signs ring + - the ROOM's own planets, NOTHING location-bound (no houses/axes) and none - of the stripped chrome (element ring / centre disc / aspect web) — rings - the tessellation, which shrinks into the wheel's freed hub.""" - room = self._seed_room(hand_len=6) + def test_seed_map_rim_is_signs_only_until_placement(self): + """Step 2's shared frame: the stripped sky wheel rings the tessellation — + canonical signs ring only, NO planets until the Game Clock ritual places + them (the wheel starts planet-eliminated), none of the stripped chrome + (element ring / centre disc / aspect web) and nothing location-bound + (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) - # 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.browser.execute_script( "return document.querySelectorAll('#id_seed_wheel_svg .nw-sign-group').length" @@ -148,7 +137,7 @@ class SeedMapFeltTest(FunctionalTest): self.assertEqual( self.browser.execute_script( "return document.querySelectorAll('#id_seed_wheel_svg .nw-planet-group').length" - ), 3, + ), 0, ) # 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.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 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"), []) diff --git a/src/static/tests/SkyWheelSpec.js b/src/static/tests/SkyWheelSpec.js index 9af20e2..3d269b8 100644 --- a/src/static/tests/SkyWheelSpec.js +++ b/src/static/tests/SkyWheelSpec.js @@ -1145,4 +1145,46 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => { expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0); 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); + }); }); diff --git a/src/static_src/scss/_sky.scss b/src/static_src/scss/_sky.scss index 4be6530..3dda764 100644 --- a/src/static_src/scss/_sky.scss +++ b/src/static_src/scss/_sky.scss @@ -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, -// canonical signs frame) drawn by SkyWheel.drawRim. Sits ABOVE the map svg in -// source order; pointer-events none so territory interaction falls through. -// The .nw-* ring/planet styling rules below apply to it unchanged. +// The shared wheel rim — the stripped sky wheel (canonical signs frame + the +// gamer-PLACED planets, Set the Game Clock) drawn by SkyWheel.drawRim. Sits +// ABOVE the map svg in source order; pointer-events none so territory +// interaction falls through. The .nw-* ring/planet styling rules below apply +// to it unchanged. .seed-page--room svg.seed-wheel { display: block; position: absolute; @@ -292,6 +293,40 @@ html.seed-open .seed-page.seed-page--room { width: 100%; height: 100%; 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). diff --git a/src/static_src/tests/SkyWheelSpec.js b/src/static_src/tests/SkyWheelSpec.js index 9af20e2..3d269b8 100644 --- a/src/static_src/tests/SkyWheelSpec.js +++ b/src/static_src/tests/SkyWheelSpec.js @@ -1145,4 +1145,46 @@ describe("SkyWheel — drawRim (SEED MAP shared rim)", () => { expect(rimSvg.querySelectorAll(".nw-planet-group").length).toBe(0); 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); + }); }); diff --git a/src/templates/apps/gameboard/_partials/_seed_map_overlay.html b/src/templates/apps/gameboard/_partials/_seed_map_overlay.html index 2546d5c..d10c566 100644 --- a/src/templates/apps/gameboard/_partials/_seed_map_overlay.html +++ b/src/templates/apps/gameboard/_partials/_seed_map_overlay.html @@ -2,20 +2,26 @@ {# SEED MAP felt — d3-delaunay dual graph (Voronoi cells = territory + Delaunay #} {# 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 #} -{# #id_seed_map_btn (html.seed-open). STEP 1 = placeholder seeds; Step 2's first #} -{# piece is live: the SHARED wheel rim — the ROOM's own sky (planets only; no #} -{# location => no houses/axes), one canonical frame for all six gamers — rings #} -{# the tessellation, which _paint() sizes into the wheel's freed hub. #} -{# Card-driven seeding (the 6 Celtic-Cross cards) is the next piece. #} -{# See project_voronoi_spec. #} +{# #id_seed_map_btn (html.seed-open). The dual-graph map is ringed by the SHARED #} +{# game wheel — the stripped sky-wheel rim whose planets are PLACED by the #} +{# gamers in the "Set the Game Clock" ritual (Room.clock_placements), one #} +{# canonical frame for all six. The wheel starts SIGNS-ONLY; the gamer whose #} +{# POSITION CIRCLE's turn it is (circle 6 places Uranus first) clicks a sign #} +{# wedge to place their planet. _paint() sizes the map into the wheel's freed #} +{# hub. See project_voronoi_spec. #}
+ data-clock-place-url="{% url 'epic:place_clock_planet' room.id %}" + data-clock-placeable="{{ clock_placeable|default:'' }}">
+ {% if clock_placeable %} + {# Placement prompt — only for the gamer whose position circle's turn it is. #} +
Place {{ clock_placeable }}
in a sign
+ {% endif %}
@@ -31,11 +37,51 @@ var wheelSvg = document.getElementById('id_seed_wheel_svg'); if (!overlay || !svgEl) return; - // The table's OWN sky (the shared rim frame) — embedded when already - // computed, else lazily fetched on first open (the endpoint computes via - // PySwiss at the convened moment + caches on the Room). - var _tableSky = {{ room_sky_json|default:"null"|safe }}; - var _skyFetched = false; + // The shared wheel's planets — PLACED by the gamers, {planet: sign}. The rim + // renders these (sign granularity); empty => signs-only (planets eliminated). + var _placements = {{ clock_placements_json|default:"{}"|safe }}; + // The planet THIS gamer may place now (their position circle's turn), or ''. + 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); // restore on close. The completed CAST SKY / DRAW SEA reopen btns stay lit. @@ -52,20 +98,22 @@ _disabled = []; } - // Paint/repaint the rim + dual graph at the felt's CURRENT box. The rim - // draws first and hands back its hub geometry; the map svg is then sized - // into the freed hub (2×hubR square, circle-clipped) so the wheel RINGS the - // tessellation — both svgs centre on the same point, so they stay - // concentric. No rim (SkyWheel absent) -> the map keeps its full-pane CSS - // size, the Step-1 behaviour (also the Jasmine fixture path). + // Paint/repaint the rim + dual graph at the felt's CURRENT box. The rim draws + // the placed planets (+ placement targets if it's this gamer's turn) and hands + // back its hub geometry; the map svg is then sized into the freed hub (2×hubR + // square, circle-clipped) so the wheel RINGS the tessellation — both svgs + // centre on the same point, so they stay concentric. No rim (SkyWheel absent) + // -> the map keeps its full-pane CSS size (the Jasmine fixture path). function _paint() { if (!window.SeedMap) return; - // NB: sky-wheel.js declares `const SkyWheel` at top-level script scope — - // a global LEXICAL binding, not a window property — so probe it with - // typeof, never window.SkyWheel (always undefined). - var geo = (typeof SkyWheel !== 'undefined' && SkyWheel.drawRim) - ? SkyWheel.drawRim(wheelSvg, _tableSky) - : null; + // NB: sky-wheel.js declares `const SkyWheel` at top-level script scope — a + // global LEXICAL binding, not a window property — so probe it with typeof, + // never window.SkyWheel (always undefined). + var geo = null; + if (typeof SkyWheel !== 'undefined' && SkyWheel.drawRim) { + var opts = _placeable ? { placeable: _placeable, onPickSign: _onPickSign } : undefined; + geo = SkyWheel.drawRim(wheelSvg, _rimData(), opts); + } if (geo) { var side = Math.round(geo.hubR * 2); svgEl.style.width = side + 'px'; @@ -83,9 +131,8 @@ // 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 // 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 - // sky embedded server-side, so _fetchTableSky no-ops), no repaint ever heals - // it. preload() re-fetches are browser-cached + idempotent on the cache, so + // sign slices WITHOUT their zodiac glyphs — and nothing repaints to heal it. + // preload() re-fetches are browser-cached + idempotent on the cache, so // kicking it once + repainting on resolve is the cheap established fix. var _preloadKicked = false; function _ensureGlyphs() { @@ -96,15 +143,6 @@ }).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 / // rotate). Observe the COL, not the map svg — _paint() sizes that svg // 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 // box is already the full pane), then keep it responsive to resize. _paint(); - _fetchTableSky(); _observeResize(); 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 // 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'); if (seedBtn) seedBtn.addEventListener('click', openSeed); }());