From b2ddd9895672a32d509131dfe61acee5fd6b5a48 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 10 Jun 2026 11:58:54 -0400 Subject: [PATCH] =?UTF-8?q?Set=20the=20Game=20Clock=20=E2=80=94=20incremen?= =?UTF-8?q?t=202:=20placements=20broadcast=20LIVE=20over=20the=20room=20WS?= =?UTF-8?q?=20+=20the=20turn=20hands=20off=205=E2=86=921=20(Saturn=20next)?= =?UTF-8?q?=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - place_clock_planet now fans _notify_clock_placement out to the room group on every landed placement: full placements (open SEED MAP felts repaint — ONE shared map per room, updating asynchronously) + the next turn (next_planet, next_slot); nothing broadcast on rejections - RoomConsumer clock_placement pass-through; CLOCK_SLOT_BY_PLANET inverse map - seed overlay: data-clock-slot embeds the viewer's position circle; the room:clock_placement window listener adopts the placements, repaints, and when next_slot is THIS circle gains the placement affordance live (_ensurePrompt + _placeable) — no reload between turns - turn progression 6→1 was already general server-side (increment 1's _clock_placeable_for); now pinned by ITs (circle 5 blocked before Uranus / Saturn after / full roster walk Uranus→Saturn→Jupiter→Mars→ Sun→Moon + post-ritual 403) + the circle-5 reload-path FT - new SeedMapClockBroadcastTest (channels, two browsers): circle 6 places Uranus → circle 5's open felt live-gains the glyph + the "Place Saturn" prompt → Saturn flows back; _seed_clock_room / _tap_sign FT helpers shared across clock classes [[project-voronoi-spec]] [[feedback-channels-broadcast-must-originate-in-daphne]] Co-Authored-By: Claude Fable 5 --- src/apps/epic/consumers.py | 3 + .../epic/tests/integrated/test_consumers.py | 16 ++ src/apps/epic/tests/integrated/test_views.py | 103 +++++++ src/apps/epic/views.py | 22 ++ .../test_game_room_seed_map.py | 256 +++++++++++++++--- .../_partials/_seed_map_overlay.html | 33 ++- 6 files changed, 389 insertions(+), 44 deletions(-) diff --git a/src/apps/epic/consumers.py b/src/apps/epic/consumers.py index 3078185..7f9e8e5 100644 --- a/src/apps/epic/consumers.py +++ b/src/apps/epic/consumers.py @@ -119,6 +119,9 @@ class RoomConsumer(AsyncJsonWebsocketConsumer): async def sky_confirmed(self, event): await self.send_json(event) + async def clock_placement(self, event): + await self.send_json(event) + async def cursor_move(self, event): await self.send_json(event) diff --git a/src/apps/epic/tests/integrated/test_consumers.py b/src/apps/epic/tests/integrated/test_consumers.py index 4feeefc..dbfa36f 100644 --- a/src/apps/epic/tests/integrated/test_consumers.py +++ b/src/apps/epic/tests/integrated/test_consumers.py @@ -242,6 +242,22 @@ class MissingConsumerHandlersTest(SimpleTestCase): ) self.assertEqual(response["type"], "pick_sky_available") + async def test_receives_clock_placement_broadcast(self): + """Set the Game Clock increment 2: a landed placement relays to every + room socket with the full placements + the next turn (planet, circle) + so open SEED MAP felts repaint + hand the affordance off live.""" + room_id = "00000000-0000-0000-0000-000000000002" + response = await self._send_and_receive( + f"/ws/room/{room_id}/", + f"room_{room_id}", + {"type": "clock_placement", "placements": {"Uranus": "Aquarius"}, + "next_planet": "Saturn", "next_slot": 5}, + ) + self.assertEqual(response["type"], "clock_placement") + self.assertEqual(response["placements"], {"Uranus": "Aquarius"}) + self.assertEqual(response["next_planet"], "Saturn") + self.assertEqual(response["next_slot"], 5) + @tag('channels') @override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 4aa9f2f..da3eb26 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -3106,6 +3106,86 @@ class PlaceClockPlanetTest(TestCase): self.client.force_login(outsider) self.assertEqual(self._post().status_code, 403) + # ── increment 2: turn progression 5→1 + the live broadcast ────────────── + + def _seat_circle(self, slot_number, role): + """Seat a fresh gamer at `slot_number` and log them in.""" + gamer = User.objects.create(email=f"p{slot_number}@clock.io") + slot = self.room.gate_slots.get(slot_number=slot_number) + slot.gamer = gamer + slot.status = GateSlot.FILLED + slot.save() + TableSeat.objects.create( + room=self.room, gamer=gamer, slot_number=slot_number, role=role, + deck_variant=self.earthman, + ) + self.client.force_login(gamer) + + def test_position_5_blocked_before_uranus(self): + """Circle 5's Saturn turn opens only AFTER circle 6's Uranus is down.""" + self._seat_circle(5, "NC") + response = self._post(planet="Saturn", sign="Pisces") + self.assertEqual(response.status_code, 403) + self.room.refresh_from_db() + self.assertEqual(self.room.clock_placements, {}) + + def test_position_5_places_saturn_after_uranus(self): + self.room.clock_placements = {"Uranus": "Aquarius"} + self.room.save(update_fields=["clock_placements"]) + self._seat_circle(5, "NC") + response = self._post(planet="Saturn", sign="Pisces") + self.assertEqual(response.status_code, 200) + self.room.refresh_from_db() + self.assertEqual( + self.room.clock_placements, + {"Uranus": "Aquarius", "Saturn": "Pisces"}, + ) + + def test_full_ritual_walks_circles_6_to_1(self): + """The whole roster lands turn by turn — Uranus→Saturn→Jupiter→Mars→ + Sun→Moon for circles 6→1 (decreasing orbital period; Mercury + Venus + are DERIVED, never placed) — and a seventh attempt finds no turn.""" + roster = [ + (6, None, "Uranus", "Aquarius"), # circle 6 seated in setUp + (5, "NC", "Saturn", "Pisces"), + (4, "SC", "Jupiter", "Leo"), + (3, "EC", "Mars", "Aries"), + (2, "AC", "Sun", "Gemini"), + (1, "BC", "Moon", "Cancer"), + ] + for slot_number, role, planet, sign in roster: + if role: + self._seat_circle(slot_number, role) + response = self._post(planet=planet, sign=sign) + self.assertEqual( + response.status_code, 200, f"circle {slot_number} / {planet}" + ) + self.room.refresh_from_db() + self.assertEqual( + self.room.clock_placements, + {"Uranus": "Aquarius", "Saturn": "Pisces", "Jupiter": "Leo", + "Mars": "Aries", "Sun": "Gemini", "Moon": "Cancer"}, + ) + # Ritual complete — no circle holds a turn any more. + self.assertEqual(self._post(planet="Moon", sign="Leo").status_code, 403) + + def test_placement_broadcasts_clock_placement(self): + """A landed placement broadcasts to the room group — ONE shared map per + room, updating asynchronously on every open felt — carrying the new + placements so clients repaint + hand the turn off without a reload.""" + with patch("apps.epic.views._notify_clock_placement") as mock_notify: + response = self._post() + self.assertEqual(response.status_code, 200) + mock_notify.assert_called_once_with( + self.room.id, {"Uranus": "Aquarius"} + ) + + def test_rejected_placement_does_not_broadcast(self): + with patch("apps.epic.views._notify_clock_placement") as mock_notify: + self._post(planet="Saturn", sign="Pisces") # not circle 6's planet + self._post(sign="Notasign") # bad sign + mock_notify.assert_not_called() + # ── tarot_deal ──────────────────────────────────────────────────────────────── @@ -4860,6 +4940,29 @@ class PickSeaUnifiedFeltTest(TestCase): self.assertIn("data-clock-place-url", content) self.assertNotIn('id="id_clock_prompt"', content) + def test_seed_map_overlay_carries_viewer_circle_for_live_handoff(self): + """Increment 2: the overlay embeds the VIEWER's position circle + (data-clock-slot) so the room:clock_placement WS handler can compare + the broadcast's next_slot against it and hand the placement affordance + off LIVE — no reload between turns. The founder is PC at circle 1.""" + self._complete_hand() + content = self.client.get(self.url).content.decode() + self.assertIn('data-clock-slot="1"', content) + + def test_seed_map_overlay_affordance_reaches_circle_1_for_the_moon(self): + """Turn progression is reload-safe at the FAR end of the roster: with + the five outer planets down, circle 1 (the founder PC) holds the final + turn — the Moon, the fastest body, pins the moment.""" + self._complete_hand() + self.room.clock_placements = { + "Uranus": "Aquarius", "Saturn": "Pisces", "Jupiter": "Leo", + "Mars": "Aries", "Sun": "Gemini", + } + self.room.save(update_fields=["clock_placements"]) + content = self.client.get(self.url).content.decode() + self.assertIn('id="id_clock_prompt"', content) + self.assertIn("Place Moon", 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 felt is up): opening the Sea Select felt must NOT grey the burger Sky diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index dbb5f39..1f011c6 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -157,6 +157,22 @@ def _notify_sky_confirmed(room_id, seat_role): ) +def _notify_clock_placement(room_id, placements): + """Set the Game Clock: a landed placement fans out to the room group — ONE + shared map per room, updating asynchronously — carrying the full placements + (open SEED MAP felts repaint) + the next turn (planet, position circle) so + the next gamer's felt hands the placement affordance off LIVE, no reload + between turns. next_* are None once the ritual is complete. + [[project-voronoi-spec]]""" + next_planet = next((p for p in CLOCK_ORDER if p not in placements), None) + next_slot = CLOCK_SLOT_BY_PLANET.get(next_planet) + async_to_sync(get_channel_layer().group_send)( + f'room_{room_id}', + {'type': 'clock_placement', 'placements': placements, + 'next_planet': next_planet, 'next_slot': next_slot}, + ) + + SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} # Sea Select affinity — each Role is correlated with one Celtic-Cross position @@ -704,6 +720,10 @@ def _role_select_context(room, user, seat_param=None): _placements = room.clock_placements or {} ctx["clock_placements_json"] = json.dumps(_placements) ctx["clock_placeable"] = _clock_placeable_for(_sky_seat, _placements) + # The viewer's position circle, embedded on the overlay (data-clock-slot) + # so the room:clock_placement WS handler can compare the broadcast's + # next_slot against it and hand the affordance off live (increment 2). + ctx["clock_slot"] = _sky_seat.slot_number if _sky_seat else "" 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 @@ -1883,6 +1903,7 @@ def table_sky(request, room_id): # 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 +CLOCK_SLOT_BY_PLANET = {planet: slot for slot, planet in CLOCK_PLANET_BY_SLOT.items()} _ZODIAC_SIGNS = { "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", @@ -1930,6 +1951,7 @@ def place_clock_planet(request, room_id): placements[planet] = sign room.clock_placements = placements room.save(update_fields=["clock_placements"]) + _notify_clock_placement(room.id, placements) return JsonResponse({"ok": True, "placements": placements}) diff --git a/src/functional_tests/test_game_room_seed_map.py b/src/functional_tests/test_game_room_seed_map.py index cb62e28..d269274 100644 --- a/src/functional_tests/test_game_room_seed_map.py +++ b/src/functional_tests/test_game_room_seed_map.py @@ -20,21 +20,84 @@ 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. """ +import os + from django.conf import settings +from django.test import tag from django.urls import reverse from django.utils import timezone +from selenium import webdriver from selenium.webdriver.common.by import By from apps.epic.models import Character, DeckVariant, GateSlot, Room, TableSeat, TarotCard from apps.lyric.models import User -from .base import FunctionalTest +from .base import ChannelsFunctionalTest, FunctionalTest +from .management.commands.create_session import create_pre_authenticated_session from .test_game_room_select_sea import _make_sky_confirmed_room _HAND_POSITIONS = ["cover", "cross", "crown", "lay", "leave", "loom"] +def _seed_clock_room(card, earthman, gamers, placements=None): + """SKY_SELECT room with each `(email, slot_number, role)` gamer seated at + their POSITION CIRCLE with a complete sea hand (the SEED MAP felt — the + clock ritual's host surface — is reachable for every one of them). + `placements` pre-seeds Room.clock_placements (prior turns already taken). + Roles deliberately need not match the slot defaults — the ritual keys on + the position, not the role.""" + owner, _ = User.objects.get_or_create(email=gamers[0][0]) + room = Room.objects.create( + name="Clock Room", table_status=Room.SKY_SELECT, owner=owner + ) + sig = (TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR") + .first() or card) + hand = [ + {"position": p, "card_id": card.id, "reversed": False, "polarity": "gravity"} + for p in _HAND_POSITIONS + ] + for email, slot_number, role in gamers: + gamer, _ = User.objects.get_or_create(email=email) + gamer.unlocked_decks.add(earthman) + gamer.equipped_deck = earthman + gamer.save(update_fields=["equipped_deck"]) + slot = room.gate_slots.get(slot_number=slot_number) + slot.gamer = gamer + slot.status = GateSlot.FILLED + slot.save() + seat = TableSeat.objects.create( + room=room, gamer=gamer, role=role, slot_number=slot_number, + deck_variant=earthman, significator=sig, + ) + Character.objects.create( + seat=seat, significator=sig, chart_data={"planets": {}}, + confirmed_at=timezone.now(), + celtic_cross={"spread": "waite-smith", "hand": hand}, + ) + room.gate_status = Room.OPEN + if placements: + room.clock_placements = dict(placements) + room.save() + return room + + +def _tap_sign(browser, sign): + """Tap a sign wedge on the shared wheel. Placement rides the pointerdown→ + pointerup pair, not click (iOS withholds the tap-synthesized click — see + SkyWheelSpec R7); SVG elements don't expose .click() in Firefox anyway + (TDD skill).""" + browser.execute_script( + "var el = arguments[0];" + "['pointerdown', 'pointerup'].forEach(function (t) {" + " el.dispatchEvent(new PointerEvent(t," + " {bubbles: true, pointerId: 1, clientX: 10, clientY: 10}));" + "})", + browser.find_element( + By.CSS_SELECTOR, f"#id_seed_wheel_svg [data-sign-name='{sign}']"), + ) + + class SeedMapFeltTest(FunctionalTest): def setUp(self): super().setUp() @@ -223,31 +286,9 @@ class SeedMapClockTest(FunctionalTest): 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 + return _seed_clock_room( + self.card, self.earthman, [("founder@test.io", 6, "PC")] ) - 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}) @@ -266,10 +307,10 @@ class SeedMapClockTest(FunctionalTest): )) self.wait_for(_click_and_assert_open) - def _uranus_count(self): + def _planet_count(self, planet): return self.browser.execute_script( "return document.querySelectorAll(" - " '#id_seed_wheel_svg [data-planet=\"Uranus\"]').length" + f" '#id_seed_wheel_svg [data-planet=\"{planet}\"]').length" ) def test_position_6_gamer_places_uranus_in_a_sign(self): @@ -284,22 +325,11 @@ class SeedMapClockTest(FunctionalTest): "Uranus", self.browser.find_element(By.ID, "id_clock_prompt").text, )) - self.assertEqual(self._uranus_count(), 0) + self.assertEqual(self._planet_count("Uranus"), 0) - # Tapping the Aquarius sign wedge places Uranus there. Placement rides - # the pointerdown→pointerup pair, not click (iOS withholds the tap- - # synthesized click — see SkyWheelSpec R7). SVG elements don't - # expose .click() in Firefox anyway — dispatch the events (TDD skill). - self.browser.execute_script( - "var el = arguments[0];" - "['pointerdown', 'pointerup'].forEach(function (t) {" - " el.dispatchEvent(new PointerEvent(t," - " {bubbles: true, pointerId: 1, clientX: 10, clientY: 10}));" - "})", - 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)) + # Tapping the Aquarius sign wedge places Uranus there. + _tap_sign(self.browser, "Aquarius") + self.wait_for(lambda: self.assertEqual(self._planet_count("Uranus"), 1)) self.assertEqual( self.browser.find_element( By.CSS_SELECTOR, "#id_seed_wheel_svg [data-planet='Uranus']" @@ -314,5 +344,145 @@ class SeedMapClockTest(FunctionalTest): self.browser.get(self._room_url(room)) self._open_seed_felt() - self.wait_for(lambda: self.assertEqual(self._uranus_count(), 1)) + self.wait_for(lambda: self.assertEqual(self._planet_count("Uranus"), 1)) self.assertEqual(self.browser.find_elements(By.ID, "id_clock_prompt"), []) + + def test_position_5_gamer_places_saturn_after_uranus(self): + """Turn 2 of the ritual (increment 2): once Uranus is down, POSITION + CIRCLE 5 holds the turn and is prompted for SATURN — the roster proceeds + 6→1 by decreasing orbital period. Reload path: the prior placement + arrives server-rendered; circle 5 taps a sign and Saturn joins it.""" + room = _seed_clock_room( + self.card, self.earthman, [("founder@test.io", 5, "NC")], + placements={"Uranus": "Aquarius"}, + ) + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self._room_url(room)) + self._open_seed_felt() + + # Uranus (turn 1) already rings the wheel; circle 5 is prompted for Saturn. + self.wait_for(lambda: self.assertEqual(self._planet_count("Uranus"), 1)) + self.wait_for(lambda: self.assertIn( + "Saturn", + self.browser.find_element(By.ID, "id_clock_prompt").text, + )) + + _tap_sign(self.browser, "Pisces") + self.wait_for(lambda: self.assertEqual(self._planet_count("Saturn"), 1)) + self.assertEqual( + self.browser.find_element( + By.CSS_SELECTOR, "#id_seed_wheel_svg [data-planet='Saturn']" + ).get_attribute("data-sign"), + "Pisces", + ) + + room.refresh_from_db() + self.assertEqual( + room.clock_placements, {"Uranus": "Aquarius", "Saturn": "Pisces"} + ) + # Circle 5's turn is done — the affordance drops. + self.wait_for(lambda: self.assertEqual( + self.browser.find_elements(By.ID, "id_clock_prompt"), [])) + + +@tag("channels") +class SeedMapClockBroadcastTest(ChannelsFunctionalTest): + """Set the Game Clock — increment 2 (project_voronoi_spec): ONE shared map + per room, updating ASYNCHRONOUSLY. A placement broadcasts over the room WS + so every open felt repaints LIVE, and the TURN HANDS OFF: when circle 6's + Uranus lands, circle 5's already-open felt gains the glyph AND the + "Place Saturn" affordance without a reload — then circle 5's Saturn flows + back to circle 6's felt the same way.""" + + 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"}, + ) + + def _make_browser_for(self, email): + session_key = create_pre_authenticated_session(email) + options = webdriver.FirefoxOptions() + if os.environ.get("HEADLESS"): + options.add_argument("--headless") + b = webdriver.Firefox(options=options) + b.set_window_size(800, 1200) + b.get(self.live_server_url + "/404_no_such_url/") + b.add_cookie(dict( + name=settings.SESSION_COOKIE_NAME, + value=session_key, + path="/", + )) + return b + + def _open_seed_felt(self, b): + self.wait_for_slow(lambda: self.assertNotIn( + "hex-phase-btn--out", + b.find_element(By.ID, "id_seed_map_btn").get_attribute("class"), + )) + + def _click_and_assert_open(): + btn = b.find_element(By.ID, "id_seed_map_btn") + b.execute_script("arguments[0].click()", btn) + self.assertTrue(b.execute_script( + "return document.documentElement.classList.contains('seed-open')" + )) + self.wait_for_slow(_click_and_assert_open) + + def _planet_count(self, b, planet): + return b.execute_script( + "return document.querySelectorAll(" + f" '#id_seed_wheel_svg [data-planet=\"{planet}\"]').length" + ) + + def test_placement_broadcasts_live_and_hands_the_turn_to_circle_5(self): + room = _seed_clock_room( + self.card, self.earthman, + [("founder@test.io", 6, "PC"), ("amigo@test.io", 5, "NC")], + ) + room_url = self.live_server_url + reverse( + "epic:room", kwargs={"room_id": room.id}) + + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + self._open_seed_felt(self.browser) + + b5 = self._make_browser_for("amigo@test.io") + try: + b5.get(room_url) + self._open_seed_felt(b5) + + # Circle 5 starts spectating: signs-only wheel, no affordance. + self.assertEqual(self._planet_count(b5, "Uranus"), 0) + self.assertEqual(b5.find_elements(By.ID, "id_clock_prompt"), []) + + # Circle 6 places Uranus → circle 5's OPEN felt live-gains the + # glyph + the Saturn affordance (turn handoff). No reload. + _tap_sign(self.browser, "Aquarius") + self.wait_for_slow(lambda: self.assertEqual( + self._planet_count(self.browser, "Uranus"), 1)) + self.wait_for_slow(lambda: self.assertEqual( + self._planet_count(b5, "Uranus"), 1)) + self.wait_for_slow(lambda: self.assertIn( + "Saturn", b5.find_element(By.ID, "id_clock_prompt").text)) + + # The handed-off turn is live-usable: circle 5 places Saturn and it + # flows back onto circle 6's felt the same way. + _tap_sign(b5, "Pisces") + self.wait_for_slow(lambda: self.assertEqual( + self._planet_count(self.browser, "Saturn"), 1)) + + room.refresh_from_db() + self.assertEqual( + room.clock_placements, + {"Uranus": "Aquarius", "Saturn": "Pisces"}, + ) + finally: + b5.quit() diff --git a/src/templates/apps/gameboard/_partials/_seed_map_overlay.html b/src/templates/apps/gameboard/_partials/_seed_map_overlay.html index d10c566..d6f88dd 100644 --- a/src/templates/apps/gameboard/_partials/_seed_map_overlay.html +++ b/src/templates/apps/gameboard/_partials/_seed_map_overlay.html @@ -11,7 +11,8 @@ {# hub. See project_voronoi_spec. #}
+ data-clock-placeable="{{ clock_placeable|default:'' }}" + data-clock-slot="{{ clock_slot|default:'' }}">
@@ -42,6 +43,9 @@ 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 || ''; + // The viewer's own position circle — compared against a broadcast's + // next_slot to receive the live turn handoff. + var _mySlot = parseInt(overlay.dataset.clockSlot, 10) || null; var SIGN_ORDER = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']; @@ -83,6 +87,33 @@ }).catch(function () {}); } + // Live shared-map update + turn handoff (increment 2): every landed placement + // broadcasts clock_placement over the room WS (room.js re-dispatches each + // frame as a room: window event). Every open felt adopts the new + // placements + repaints; the gamer whose circle holds the NEXT turn gains the + // placement affordance (prompt + clickable wedges) without a reload. The + // placing gamer's own echo is idempotent — their fetch handler already + // adopted the placements, and next_slot is no longer theirs. + function _ensurePrompt(planet) { + if (document.getElementById('id_clock_prompt')) return; + var prompt = document.createElement('div'); + prompt.id = 'id_clock_prompt'; + prompt.className = 'clock-prompt'; + prompt.innerHTML = 'Place ' + planet + '
in a sign'; + overlay.appendChild(prompt); + } + window.addEventListener('room:clock_placement', function (e) { + var d = (e && e.detail) || {}; + _placements = d.placements || _placements; + if (d.next_planet && d.next_slot && d.next_slot === _mySlot) { + _placeable = d.next_planet; + _ensurePrompt(d.next_planet); + } + // Repaint even while closed — visibility:hidden retains the felt's layout + // box, and openSeed repaints again anyway. + _paint(); + }); + // 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. var _disabled = [];