room gate-view: mid-game renewal area (room_gate) + renew_token endpoint + _room_gear nvm_url — TDD

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/<uuid>/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/<uuid>/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) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-31 22:55:17 -04:00
parent 6fd515bc6d
commit 516b917420
5 changed files with 293 additions and 1 deletions

View File

@@ -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":