Set the Game Clock — increment 2: placements broadcast LIVE over the room WS + the turn hands off 5→1 (Saturn next) — TDD
- 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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user