From 516b9174201c53d285fab25d3d1ce0b6518d48b6 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sun, 31 May 2026 22:55:17 -0400 Subject: [PATCH] =?UTF-8?q?room=20gate-view:=20mid-game=20renewal=20area?= =?UTF-8?q?=20(room=5Fgate)=20+=20renew=5Ftoken=20endpoint=20+=20=5Froom?= =?UTF-8?q?=5Fgear=20nvm=5Furl=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the room GATE VIEW + seat-renewal sprint. The 3rd-person mirror of my_sea_gate: a gate-view a seated gamer can open at any time to check token TIME REMAINING or RENEW, reachable even mid-game (the gatekeeper redirects to the table once table_status is set — this view does not). - `room_gate` view + `room//gate/view/` URL — renders the viewer's own seat/position circle, a live time-remaining ticker (counts down to cost_current_until, then to grace_expires_at in renewal grace), and a RENEW affordance. page_class carries `page-room` (drives the navbar GATE VIEW in Phase 0). No seat → "no seat" copy, no RENEW btn. - `renew_token` view + `room//gate/renew` URL — re-deposits a token into the viewer's already-FILLED slot via the existing `debit_token` (resets filled_at=now → restarts the cost-current window). Reuses select_token / debit_token wholesale; distinct from confirm_token, which needs a RESERVED slot. 402 when token-depleted; no-op redirect when the user holds no filled slot (already auto-BYE'd). - `room_gate.html` — reuses the gatekeeper's .gate-overlay/.gate-modal chrome (hand-rolled like my_sea_gate, inner content differs) + an inline countdown ticker mirroring the status-dots IIFE. - DRY: `_room_gear.html` now takes an `nvm_url` param (default the gameboard listing — room.html's own gear unchanged); the gate-view passes the table-hex URL so NVM returns to the hex, mirroring _my_sea_gear's contract. Tests: RoomGateViewTest (7) + RoomRenewTokenTest (6) — renders mid-game, own seat circle, data-cost-until, RENEW posts to renew_token, NVM→hex, page-room marker, no-seat render; renew resets filled_at + consumes FREE + records SLOT_FILLED, no-slot/GET redirects, 402 when depleted. 504 epic tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apps/epic/tests/integrated/test_views.py | 121 ++++++++++++++++++ src/apps/epic/urls.py | 2 + src/apps/epic/views.py | 56 ++++++++ .../apps/gameboard/_partials/_room_gear.html | 6 +- src/templates/apps/gameboard/room_gate.html | 109 ++++++++++++++++ 5 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/templates/apps/gameboard/room_gate.html diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index ca58b0e..6da3d59 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -2609,3 +2609,124 @@ class RoomBurgerBtnRenderTest(TestCase): self.room.save() response = self.client.get(self.url) self.assertContains(response, 'id="id_burger_btn"') + + +class RoomGateViewTest(TestCase): + """Room renewal gate-view (sprint 2026-05-31) — reachable mid-game (the + gatekeeper redirects to the table once table_status is set; this view + does not). Shows the viewer's own seat/circle + token time-remaining + a + RENEW affordance; the gear-menu NVM returns to the table hex.""" + + def setUp(self): + self.owner = User.objects.create(email="owner@test.io", username="owner") + self.room = Room.objects.create( + name="Renewal Room", owner=self.owner, + renewal_period=timedelta(days=7), + gate_status=Room.OPEN, table_status=Room.ROLE_SELECT, + ) + self.slot = self.room.gate_slots.get(slot_number=1) + self.slot.gamer = self.owner + self.slot.status = GateSlot.FILLED + self.slot.filled_at = timezone.now() + self.slot.debited_token_type = Token.FREE + self.slot.save() + self.client.force_login(self.owner) + self.url = reverse("epic:room_gate", args=[self.room.id]) + + def test_renders_200_even_when_table_status_set(self): + # gatekeeper would 302 to the room; the gate-view must render mid-game. + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_shows_viewer_own_seat_circle(self): + response = self.client.get(self.url) + self.assertContains(response, 'data-slot="1"') + self.assertContains(response, "PC") # slot 1 role label + + def test_shows_time_remaining_data_attr(self): + response = self.client.get(self.url) + self.assertContains(response, "data-cost-until=") + self.assertContains(response, "id_room_gate_remaining") + + def test_renew_button_posts_to_renew_endpoint(self): + response = self.client.get(self.url) + self.assertContains( + response, reverse("epic:renew_token", args=[self.room.id])) + self.assertContains(response, "RENEW") + + def test_nvm_returns_to_room_hex(self): + response = self.client.get(self.url) + self.assertContains( + response, f'href="{reverse("epic:room", args=[self.room.id])}"') + + def test_page_class_carries_page_room(self): + response = self.client.get(self.url) + self.assertIn("page-room", response.context["page_class"]) + + def test_no_seat_viewer_still_renders(self): + stranger = User.objects.create(email="stranger@test.io") + self.client.force_login(stranger) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, "id_room_renew_btn") + + +class RoomRenewTokenTest(TestCase): + """The RENEW endpoint — re-deposit a token into the viewer's own FILLED + slot, resetting filled_at=now (via debit_token) so the cost-current + window restarts. Distinct from confirm_token (which needs a RESERVED + slot). 402 when token-depleted.""" + + def setUp(self): + self.owner = User.objects.create(email="owner@test.io", username="owner") + self.owner.equipped_trinket = None + self.owner.save(update_fields=["equipped_trinket"]) + self.owner.tokens.exclude(token_type=Token.FREE).delete() # keep FREE only + self.room = Room.objects.create( + name="Renewal Room", owner=self.owner, + renewal_period=timedelta(days=7), + gate_status=Room.OPEN, table_status=Room.ROLE_SELECT, + ) + self.slot = self.room.gate_slots.get(slot_number=1) + self.slot.gamer = self.owner + self.slot.status = GateSlot.FILLED + self.slot.filled_at = timezone.now() - timedelta(days=8) # in grace + self.slot.debited_token_type = Token.FREE + self.slot.save() + self.client.force_login(self.owner) + self.url = reverse("epic:renew_token", args=[self.room.id]) + + def test_renew_resets_filled_at(self): + self.client.post(self.url) + self.slot.refresh_from_db() + self.assertGreater(self.slot.filled_at, timezone.now() - timedelta(minutes=1)) + self.assertTrue(self.slot.cost_current) + + def test_renew_consumes_free_token(self): + self.client.post(self.url) + self.assertFalse( + self.owner.tokens.filter(token_type=Token.FREE).exists()) + self.slot.refresh_from_db() + self.assertEqual(self.slot.debited_token_type, Token.FREE) + + def test_renew_records_slot_filled_event(self): + self.client.post(self.url) + self.assertTrue( + self.room.events.filter( + actor=self.owner, verb=GameEvent.SLOT_FILLED).exists()) + + def test_renew_without_filled_slot_redirects(self): + self.slot.status = GateSlot.EMPTY + self.slot.gamer = None + self.slot.save() + response = self.client.post(self.url) + self.assertEqual(response.status_code, 302) + + def test_renew_402_when_token_depleted(self): + self.owner.tokens.all().delete() + response = self.client.post(self.url) + self.assertEqual(response.status_code, 402) + + def test_renew_get_redirects(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index c018bd1..576c603 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -8,6 +8,8 @@ urlpatterns = [ path('rooms/create_room', views.create_room, name='create_room'), path('room//', views.room_view, name='room'), path('room//gate/', views.gatekeeper, name='gatekeeper'), + path('room//gate/view/', views.room_gate, name='room_gate'), + path('room//gate/renew', views.renew_token, name='renew_token'), path('room//gate/drop_token', views.drop_token, name='drop_token'), path('room//gate/confirm_token', views.confirm_token, name='confirm_token'), path('room//gate/return_token', views.return_token, name='return_token'), diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index eb54daf..dd5ec71 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -456,6 +456,62 @@ def room_view(request, room_id): return render(request, "apps/gameboard/room.html", ctx) +@login_required +def room_gate(request, room_id): + """Room renewal gate-view — reachable mid-game (unlike `gatekeeper`, + which redirects to the table once `table_status` is set). GATE VIEW + (navbar + center supersession) routes here. Shows the viewer's own + seat/position circle, their token time-remaining, and a RENEW + affordance; the gear-menu NVM returns to the table hex, not /gameboard/. + Mirrors the my-sea gate-view (`my_sea_gate`) for the 3rd-person table.""" + room = Room.objects.get(id=room_id) + user_slot = room.gate_slots.filter( + gamer=request.user, status=GateSlot.FILLED + ).first() + return render(request, "apps/gameboard/room_gate.html", { + "room": room, + "user_filled_slot": user_slot, + "cost_current": user_slot.cost_current if user_slot else True, + "cost_current_until": user_slot.cost_current_until if user_slot else None, + "grace_expires_at": user_slot.grace_expires_at if user_slot else None, + "in_renewal_grace": user_slot.in_renewal_grace if user_slot else False, + "slot_role_label": ( + SLOT_ROLE_LABELS.get(user_slot.slot_number, "") if user_slot else "" + ), + "page_class": "page-gameboard page-room page-room-gate", + }) + + +@login_required +def renew_token(request, room_id): + """Renew the viewer's seat — re-deposit a token into their already-FILLED + slot, resetting `filled_at=now` (via `debit_token`) so the cost-current + window restarts. Distinct from `confirm_token` (which transitions a + RESERVED slot); renewal is a FILLED→FILLED refresh of an occupied seat. + 402 when the user is token-depleted; no-op redirect when the user holds + no filled slot (e.g. already auto-BYE'd out of the room).""" + if request.method != "POST": + return redirect("epic:room_gate", room_id=room_id) + room = Room.objects.get(id=room_id) + slot = room.gate_slots.filter( + gamer=request.user, status=GateSlot.FILLED + ).first() + if slot is None: + return redirect("epic:room_gate", room_id=room_id) + token_id = request.POST.get("token_id") + token = (request.user.tokens.filter(id=token_id).first() + if token_id else select_token(request.user)) + if token is None: + return HttpResponse(status=402) + debit_token(request.user, slot, token) # resets filled_at=now → A=now + record(room, GameEvent.SLOT_FILLED, actor=request.user, + slot_number=slot.slot_number, token_type=token.token_type, + token_display=token.get_token_type_display(), + renewal_days=(room.renewal_period.days if room.renewal_period else 7)) + _notify_gate_update(room_id) + return redirect("epic:room_gate", room_id=room_id) + + @login_required def drop_token(request, room_id): if request.method == "POST": diff --git a/src/templates/apps/gameboard/_partials/_room_gear.html b/src/templates/apps/gameboard/_partials/_room_gear.html index eb542c5..9910065 100644 --- a/src/templates/apps/gameboard/_partials/_room_gear.html +++ b/src/templates/apps/gameboard/_partials/_room_gear.html @@ -1,5 +1,9 @@ +{# `nvm_url` lets a caller redirect NVM elsewhere (the room gate-view passes #} +{# the table-hex URL so NVM returns to the hex, not out to /gameboard/). #} +{# Defaults to the gameboard listing — room.html's own gear menu is unchanged.#} +{% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %}