Set the Game Clock — increment 2: placements broadcast LIVE over the room WS + the turn hands off 5→1 (Saturn next) — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

- 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 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-10 11:58:54 -04:00
parent 080d44e10c
commit b2ddd98956
6 changed files with 389 additions and 44 deletions

View File

@@ -119,6 +119,9 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
async def sky_confirmed(self, event): async def sky_confirmed(self, event):
await self.send_json(event) await self.send_json(event)
async def clock_placement(self, event):
await self.send_json(event)
async def cursor_move(self, event): async def cursor_move(self, event):
await self.send_json(event) await self.send_json(event)

View File

@@ -242,6 +242,22 @@ class MissingConsumerHandlersTest(SimpleTestCase):
) )
self.assertEqual(response["type"], "pick_sky_available") 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') @tag('channels')
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS) @override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)

View File

@@ -3106,6 +3106,86 @@ class PlaceClockPlanetTest(TestCase):
self.client.force_login(outsider) self.client.force_login(outsider)
self.assertEqual(self._post().status_code, 403) 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 ──────────────────────────────────────────────────────────────── # ── tarot_deal ────────────────────────────────────────────────────────────────
@@ -4860,6 +4940,29 @@ class PickSeaUnifiedFeltTest(TestCase):
self.assertIn("data-clock-place-url", content) self.assertIn("data-clock-place-url", content)
self.assertNotIn('id="id_clock_prompt"', 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): 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
felt is up): opening the Sea Select felt must NOT grey the burger Sky felt is up): opening the Sea Select felt must NOT grey the burger Sky

View File

@@ -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"} 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 # 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 {} _placements = room.clock_placements or {}
ctx["clock_placements_json"] = json.dumps(_placements) ctx["clock_placements_json"] = json.dumps(_placements)
ctx["clock_placeable"] = _clock_placeable_for(_sky_seat, _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: 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
@@ -1883,6 +1903,7 @@ def table_sky(request, room_id):
# time). The CSP sign-narrowing + resolving placements → a datetime are later. # 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_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_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 = { _ZODIAC_SIGNS = {
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
@@ -1930,6 +1951,7 @@ def place_clock_planet(request, room_id):
placements[planet] = sign placements[planet] = sign
room.clock_placements = placements room.clock_placements = placements
room.save(update_fields=["clock_placements"]) room.save(update_fields=["clock_placements"])
_notify_clock_placement(room.id, placements)
return JsonResponse({"ok": True, "placements": placements}) return JsonResponse({"ok": True, "placements": placements})

View File

@@ -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. is no WebSocket sky-confirm flow to drive — unlike the sky/sea FTs.
""" """
import os
from django.conf import settings from django.conf import settings
from django.test import tag
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from selenium import webdriver
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from apps.epic.models import Character, DeckVariant, GateSlot, Room, TableSeat, 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 ChannelsFunctionalTest, FunctionalTest
from .management.commands.create_session import create_pre_authenticated_session
from .test_game_room_select_sea import _make_sky_confirmed_room 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"]
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 <g> 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): class SeedMapFeltTest(FunctionalTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@@ -223,31 +286,9 @@ class SeedMapClockTest(FunctionalTest):
sea hand (so the SEED MAP felt — the clock ritual's host surface — is 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 reachable). Role is PC, NOT the slot-6 default BC, to prove the ritual
keys on the position. No clock placements yet.""" keys on the position. No clock placements yet."""
room = Room.objects.create( return _seed_clock_room(
name="Clock Room", table_status=Room.SKY_SELECT, owner=self.gamer 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): def _room_url(self, room):
return self.live_server_url + reverse("epic:room", kwargs={"room_id": room.id}) 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) self.wait_for(_click_and_assert_open)
def _uranus_count(self): def _planet_count(self, planet):
return self.browser.execute_script( return self.browser.execute_script(
"return document.querySelectorAll(" "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): def test_position_6_gamer_places_uranus_in_a_sign(self):
@@ -284,22 +325,11 @@ class SeedMapClockTest(FunctionalTest):
"Uranus", "Uranus",
self.browser.find_element(By.ID, "id_clock_prompt").text, 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 # Tapping the Aquarius sign wedge places Uranus there.
# the pointerdown→pointerup pair, not click (iOS withholds the tap- _tap_sign(self.browser, "Aquarius")
# synthesized click — see SkyWheelSpec R7). SVG <g> elements don't self.wait_for(lambda: self.assertEqual(self._planet_count("Uranus"), 1))
# 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))
self.assertEqual( self.assertEqual(
self.browser.find_element( self.browser.find_element(
By.CSS_SELECTOR, "#id_seed_wheel_svg [data-planet='Uranus']" 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.browser.get(self._room_url(room))
self._open_seed_felt() 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"), []) 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()

View File

@@ -11,7 +11,8 @@
{# hub. See project_voronoi_spec. #} {# 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-clock-place-url="{% url 'epic:place_clock_planet' room.id %}" data-clock-place-url="{% url 'epic:place_clock_planet' room.id %}"
data-clock-placeable="{{ clock_placeable|default:'' }}"> data-clock-placeable="{{ clock_placeable|default:'' }}"
data-clock-slot="{{ clock_slot|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>
@@ -42,6 +43,9 @@
var _placements = {{ clock_placements_json|default:"{}"|safe }}; var _placements = {{ clock_placements_json|default:"{}"|safe }};
// The planet THIS gamer may place now (their position circle's turn), or ''. // The planet THIS gamer may place now (their position circle's turn), or ''.
var _placeable = overlay.dataset.clockPlaceable || ''; 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', var SIGN_ORDER = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']; 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'];
@@ -83,6 +87,33 @@
}).catch(function () {}); }).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:<type> 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 + '<br><small>in a sign</small>';
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); // 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.
var _disabled = []; var _disabled = [];