room center: GATE VIEW supersedes SCAN SIGS / CAST SKY / DRAW SEA / sig overlay when token cost lapses — ROLE pick survives grace — TDD
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 <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2783,3 +2783,113 @@ class RoomNavbarGateViewTest(TestCase):
|
|||||||
def test_room_page_carries_page_room_marker(self):
|
def test_room_page_carries_page_room_marker(self):
|
||||||
response = self.client.get(reverse("epic:room", args=[self.room.id]))
|
response = self.client.get(reverse("epic:room", args=[self.room.id]))
|
||||||
self.assertIn("page-room", response.context["page_class"])
|
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")
|
||||||
|
|||||||
@@ -345,6 +345,19 @@ def _role_select_context(room, user):
|
|||||||
"gate_positions": _gate_positions(room),
|
"gate_positions": _gate_positions(room),
|
||||||
"slots": room.gate_slots.order_by("slot_number"),
|
"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)
|
# Tray cell 2: sig card (set once polarity group confirms)
|
||||||
_canonical_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
_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
|
ctx["my_tray_sig"] = _canonical_seat.significator if _canonical_seat else None
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
<div id="id_aperture_fill"></div>
|
<div id="id_aperture_fill"></div>
|
||||||
<div class="room-shell">
|
<div class="room-shell">
|
||||||
<div id="id_game_table" class="room-table">
|
<div id="id_game_table" class="room-table">
|
||||||
{% 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 %}
|
||||||
<div id="id_pick_sigs_wrap"{% if starter_roles|length < 6 %} style="display:none"{% endif %}>
|
<div id="id_pick_sigs_wrap"{% if starter_roles|length < 6 %} style="display:none"{% endif %}>
|
||||||
<form method="POST" action="{% url 'epic:pick_sigs' room.id %}">
|
<form method="POST" action="{% url 'epic:pick_sigs' room.id %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -22,29 +25,45 @@
|
|||||||
<div class="table-hex-border">
|
<div class="table-hex-border">
|
||||||
<div class="table-hex">
|
<div class="table-hex">
|
||||||
<div class="table-center">
|
<div class="table-center">
|
||||||
{% if room.table_status == "ROLE_SELECT" %}
|
{# ROLE card-stack — the gamer's own role pick stays #}
|
||||||
{% if card_stack_state %}
|
{# available even when their token cost has lapsed #}
|
||||||
<div class="card-stack" data-state="{{ card_stack_state }}"
|
{# (deposit-privilege grace, 7d), so it renders OUTSIDE #}
|
||||||
data-starter-roles="{{ starter_roles|join:',' }}"
|
{# the cost gate below. #}
|
||||||
data-user-slots="{{ user_slots|join:',' }}"
|
{% if room.table_status == "ROLE_SELECT" and card_stack_state %}
|
||||||
data-active-slot="{{ active_slot }}"
|
<div class="card-stack" data-state="{{ card_stack_state }}"
|
||||||
data-equipped-deck="{{ equipped_deck_id|default:'' }}">
|
data-starter-roles="{{ starter_roles|join:',' }}"
|
||||||
{% if card_stack_state == "ineligible" %}
|
data-user-slots="{{ user_slots|join:',' }}"
|
||||||
<i class="fa-solid fa-ban"></i>
|
data-active-slot="{{ active_slot }}"
|
||||||
{% endif %}
|
data-equipped-deck="{{ equipped_deck_id|default:'' }}">
|
||||||
</div>
|
{% if card_stack_state == "ineligible" %}
|
||||||
{% endif %}
|
<i class="fa-solid fa-ban"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if room.table_status == "SKY_SELECT" %}
|
{% if not viewer_cost_current %}
|
||||||
{% if sky_confirmed %}
|
{# Token cost lapsed → GATE VIEW supersedes SCAN SIGS #}
|
||||||
<button id="id_pick_sea_btn" class="btn btn-primary">DRAW<br>SEA</button>
|
{# / CAST SKY / DRAW SEA / the sig waiting msg. The #}
|
||||||
{% else %}
|
{# gamer keeps their seat through the renewal grace; #}
|
||||||
<button id="id_pick_sky_btn" class="btn btn-primary">CAST<br>SKY</button>
|
{# GATE VIEW routes to the renewal gate-view. Only #}
|
||||||
{% endif %}
|
{# the ROLE pick (above) survives. `<button>` + #}
|
||||||
{% elif room.table_status == "SIG_SELECT" %}
|
{# onclick (not `<a>`) — `.btn` doesn't reset serif #}
|
||||||
<button id="id_pick_sky_btn" class="btn btn-primary" style="display:none">CAST<br>SKY</button>
|
{# font on anchors. [[feedback-btn-vs-anchor-font- #}
|
||||||
{% if polarity_done %}
|
{# family]] #}
|
||||||
<p id="id_hex_waiting_msg">{% if user_polarity == "levity" %}Gravity settling . . .{% else %}Levity appraising . . .{% endif %}</p>
|
<button id="id_room_gate_view_btn" type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick="window.location.href='{% url 'epic:room_gate' room.id %}'">GATE<br>VIEW</button>
|
||||||
|
{% else %}
|
||||||
|
{% if room.table_status == "SKY_SELECT" %}
|
||||||
|
{% if sky_confirmed %}
|
||||||
|
<button id="id_pick_sea_btn" class="btn btn-primary">DRAW<br>SEA</button>
|
||||||
|
{% else %}
|
||||||
|
<button id="id_pick_sky_btn" class="btn btn-primary">CAST<br>SKY</button>
|
||||||
|
{% endif %}
|
||||||
|
{% elif room.table_status == "SIG_SELECT" %}
|
||||||
|
<button id="id_pick_sky_btn" class="btn btn-primary" style="display:none">CAST<br>SKY</button>
|
||||||
|
{% if polarity_done %}
|
||||||
|
<p id="id_hex_waiting_msg">{% if user_polarity == "levity" %}Gravity settling . . .{% else %}Levity appraising . . .{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,23 +86,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Phase overlays are gated on `viewer_cost_current` too: a lapsed gamer #}
|
||||||
|
{# gets GATE VIEW in the center, so the SIG/SKY/SEA modals (which embed #}
|
||||||
|
{# their trigger-btn ids in JS) must not render alongside it. #}
|
||||||
{# Sig Select overlay — suppressed once this gamer's polarity sigs are assigned #}
|
{# Sig Select overlay — suppressed once this gamer's polarity sigs are assigned #}
|
||||||
{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done %}
|
{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done and viewer_cost_current %}
|
||||||
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
|
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Sky (Pick Sky) overlay — natal chart entry #}
|
{# Sky (Pick Sky) overlay — natal chart entry #}
|
||||||
{% if room.table_status == "SKY_SELECT" and not sky_confirmed %}
|
{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %}
|
||||||
{% include "apps/gameboard/_partials/_sky_overlay.html" %}
|
{% include "apps/gameboard/_partials/_sky_overlay.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Sky tooltip: sibling of .sky-overlay, not inside .sky-modal-wrap (which has transform) #}
|
{# Sky tooltip: sibling of .sky-overlay, not inside .sky-modal-wrap (which has transform) #}
|
||||||
{% if room.table_status == "SKY_SELECT" and not sky_confirmed %}
|
{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %}
|
||||||
<div id="id_sky_tooltip" class="tt" style="display:none;"></div>
|
<div id="id_sky_tooltip" class="tt" style="display:none;"></div>
|
||||||
<div id="id_sky_tooltip_2" class="tt" style="display:none;"></div>
|
<div id="id_sky_tooltip_2" class="tt" style="display:none;"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Sea (Pick Sea) overlay — Celtic Cross spread entry #}
|
{# Sea (Pick Sea) overlay — Celtic Cross spread entry #}
|
||||||
{% if room.table_status == "SKY_SELECT" and sky_confirmed %}
|
{% if room.table_status == "SKY_SELECT" and sky_confirmed and viewer_cost_current %}
|
||||||
{% include "apps/gameboard/_partials/_sea_overlay.html" %}
|
{% include "apps/gameboard/_partials/_sea_overlay.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user