From 4b3dc91e7f6badc7c0bc85c575d5371e223f7d92 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sun, 31 May 2026 23:29:43 -0400 Subject: [PATCH] =?UTF-8?q?room=20center:=20GATE=20VIEW=20supersedes=20SCA?= =?UTF-8?q?N=20SIGS=20/=20CAST=20SKY=20/=20DRAW=20SEA=20/=20sig=20overlay?= =?UTF-8?q?=20when=20token=20cost=20lapses=20=E2=80=94=20ROLE=20pick=20sur?= =?UTF-8?q?vives=20grace=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the room GATE VIEW + seat-renewal sprint. When the viewer's own FILLED gate-slot cost has lapsed (filled_at past the cost-current window), the center hex shows a GATE VIEW button (→ room gate-view) instead of the phase affordances, so they must renew before advancing. - _role_select_context: adds viewer_cost_current / viewer_in_grace from the viewer's FILLED slot (no slot → current, defensive) - room.html: the ROLE card-stack renders OUTSIDE the cost gate (the gamer's own role pick survives the renewal grace — deposit privilege); GATE VIEW supersedes the rest of .table-center; #id_pick_sigs_wrap (SCAN SIGS, advancing the whole table) is gated on viewer_cost_current; the SIG/SKY/SEA overlays are gated too (they embed their trigger-btn ids in JS, so they must not render alongside GATE VIEW) - per user-spec: only the ROLE pick stays in grace; SCAN SIGS + every later phase get GATE VIEW Tests: RoomCenterSupersessionTest (9) — GATE VIEW supersedes sig overlay / CAST SKY / DRAW SEA / SCAN SIGS when lapsed, normal buttons when current; RoomRoleStackGraceTest (1) — card-stack (eligible) kept alongside GATE VIEW when lapsed. 838 epic+gameboard ITs green. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apps/epic/tests/integrated/test_views.py | 110 +++++++++++++++++++ src/apps/epic/views.py | 13 +++ src/templates/apps/gameboard/room.html | 76 ++++++++----- 3 files changed, 172 insertions(+), 27 deletions(-) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 96c3ae1..aa14028 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -2783,3 +2783,113 @@ class RoomNavbarGateViewTest(TestCase): def test_room_page_carries_page_room_marker(self): response = self.client.get(reverse("epic:room", args=[self.room.id])) self.assertIn("page-room", response.context["page_class"]) + + +class RoomCenterSupersessionTest(TestCase): + """When the viewer's seat token cost lapses (filled_at past the cost- + current window), GATE VIEW supersedes the center-hex phase buttons — + SCAN SIGS, CAST SKY, DRAW SEA, the sig overlay — EXCEPT the gamer's own + ROLE card-stack pick (covered in RoomRoleStackGraceTest).""" + + def setUp(self): + self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) + self.founder = self.gamers[0] + self.url = reverse("epic:room", kwargs={"room_id": self.room.id}) + + def _lapse_viewer(self): + slot = self.room.gate_slots.get(slot_number=1) # founder = slot 1 + slot.filled_at = timezone.now() - timedelta(days=8) # grace (S=7d) + slot.save() + + def test_viewer_cost_current_true_by_default(self): + # _full_sig_setUp leaves filled_at None → never-expires → current. + self.assertTrue(self.client.get(self.url).context["viewer_cost_current"]) + + def test_cost_current_no_gate_view_btn_in_center(self): + self.assertNotContains(self.client.get(self.url), "id_room_gate_view_btn") + + def test_cost_lapsed_supersedes_sig_overlay(self): + self._lapse_viewer() + response = self.client.get(self.url) # _full_sig_setUp room is SIG_SELECT + self.assertContains(response, "id_room_gate_view_btn") + self.assertNotContains(response, "id_sig_deck") + + def test_cost_current_shows_sig_overlay(self): + response = self.client.get(self.url) + self.assertContains(response, "id_sig_deck") + self.assertNotContains(response, "id_room_gate_view_btn") + + def test_cost_lapsed_supersedes_cast_sky(self): + self.room.table_status = Room.SKY_SELECT + self.room.save() + self._lapse_viewer() + response = self.client.get(self.url) + self.assertContains(response, "id_room_gate_view_btn") + self.assertNotContains(response, "id_pick_sky_btn") + + def test_cost_current_shows_cast_sky(self): + self.room.table_status = Room.SKY_SELECT + self.room.save() + response = self.client.get(self.url) + self.assertContains(response, "id_pick_sky_btn") + self.assertNotContains(response, "id_room_gate_view_btn") + + def test_cost_lapsed_supersedes_draw_sea(self): + self.room.table_status = Room.SKY_SELECT + self.room.save() + pc = TableSeat.objects.get(room=self.room, gamer=self.founder) + Character.objects.create(seat=pc, confirmed_at=timezone.now()) + self._lapse_viewer() + response = self.client.get(self.url) + self.assertContains(response, "id_room_gate_view_btn") + self.assertNotContains(response, "id_pick_sea_btn") + + def test_cost_lapsed_supersedes_scan_sigs(self): + self.room.table_status = Room.ROLE_SELECT # roles assigned → SCAN SIGS + self.room.save() + self._lapse_viewer() + response = self.client.get(self.url) + self.assertContains(response, "id_room_gate_view_btn") + self.assertNotContains(response, "id_pick_sigs_btn") + + def test_cost_current_shows_scan_sigs(self): + self.room.table_status = Room.ROLE_SELECT + self.room.save() + response = self.client.get(self.url) + self.assertContains(response, "id_pick_sigs_btn") + self.assertNotContains(response, "id_room_gate_view_btn") + + +class RoomRoleStackGraceTest(TestCase): + """The gamer's own ROLE card-stack pick survives a lapsed token cost + (deposit-privilege grace) — only SCAN SIGS + later phases get GATE VIEW + (user-spec 2026-05-31).""" + + def setUp(self): + self.founder = User.objects.create(email="founder@test.io") + self.room = Room.objects.create(name="Role Room", owner=self.founder) + gamers = [self.founder] + [ + User.objects.create(email=f"g{i}@test.io") for i in range(2, 7) + ] + for i, gamer in enumerate(gamers, start=1): + slot = self.room.gate_slots.get(slot_number=i) + slot.gamer = gamer + slot.status = GateSlot.FILLED + slot.save() + TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i) + self.room.gate_status = Room.OPEN + self.room.table_status = Room.ROLE_SELECT + self.room.save() + self.client.force_login(self.founder) + self.url = reverse("epic:room", kwargs={"room_id": self.room.id}) + + def test_card_stack_kept_when_cost_lapsed(self): + slot = self.room.gate_slots.get(slot_number=1) + slot.filled_at = timezone.now() - timedelta(days=8) + slot.save() + response = self.client.get(self.url) + # ROLE pick (the gamer's own turn) stays available within grace… + self.assertContains(response, "card-stack") + self.assertContains(response, 'data-state="eligible"') + # …alongside the GATE VIEW supersession of the non-ROLE affordances. + self.assertContains(response, "id_room_gate_view_btn") diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index f05a94a..b4b297c 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -345,6 +345,19 @@ def _role_select_context(room, user): "gate_positions": _gate_positions(room), "slots": room.gate_slots.order_by("slot_number"), } + # Viewer's seat token-cost state — drives the center-hex GATE VIEW + # supersession (room.html). When the viewer's FILLED slot's cost has + # lapsed (filled_at past the cost-current window), GATE VIEW replaces + # SCAN SIGS / CAST SKY / DRAW SEA / the sig overlay; the gamer's own + # ROLE card-stack pick survives the renewal grace. No filled slot → + # treated as current (defensive — non-seated viewers see the normal UI). + viewer_slot = ( + room.gate_slots.filter(gamer=user, status=GateSlot.FILLED).first() + if user.is_authenticated else None + ) + ctx["viewer_cost_current"] = viewer_slot.cost_current if viewer_slot else True + ctx["viewer_in_grace"] = viewer_slot.in_renewal_grace if viewer_slot else False + # Tray cell 2: sig card (set once polarity group confirms) _canonical_seat = _canonical_user_seat(room, user) if user.is_authenticated else None ctx["my_tray_sig"] = _canonical_seat.significator if _canonical_seat else None diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 8d8d68f..8328d81 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -10,7 +10,10 @@
- {% if room.table_status == "ROLE_SELECT" %} + {# SCAN SIGS advances the whole table past role-select — gated on #} + {# the viewer's token cost being current (a lapsed gamer gets GATE #} + {# VIEW in the center instead; only their own ROLE pick survives). #} + {% if room.table_status == "ROLE_SELECT" and viewer_cost_current %}