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:
@@ -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)
|
||||
|
||||
@@ -8,6 +8,8 @@ urlpatterns = [
|
||||
path('rooms/create_room', views.create_room, name='create_room'),
|
||||
path('room/<uuid:room_id>/', views.room_view, name='room'),
|
||||
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
|
||||
path('room/<uuid:room_id>/gate/view/', views.room_gate, name='room_gate'),
|
||||
path('room/<uuid:room_id>/gate/renew', views.renew_token, name='renew_token'),
|
||||
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
|
||||
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
|
||||
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 %}
|
||||
<div id="id_room_menu" style="display:none">
|
||||
<a href="/gameboard/" class="btn btn-cancel">NVM</a>
|
||||
<a href="{{ nvm_url }}" class="btn btn-cancel">NVM</a>
|
||||
{% if request.user == room.owner %}
|
||||
<form method="POST" action="{% url 'epic:delete_room' room.id %}">
|
||||
{% csrf_token %}
|
||||
|
||||
109
src/templates/apps/gameboard/room_gate.html
Normal file
109
src/templates/apps/gameboard/room_gate.html
Normal file
@@ -0,0 +1,109 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
{% load lyric_extras %}
|
||||
|
||||
{% block title_text %}Game Gate{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span><span>Gate</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
{# Room renewal gate-view (sprint 2026-05-31) — the 3rd-person mirror of #}
|
||||
{# `my_sea_gate.html`. Reachable mid-game: unlike the gatekeeper (which #}
|
||||
{# redirects to the table once table_status is set), GATE VIEW routes here #}
|
||||
{# at any time so a seated gamer can check their token TIME REMAINING or #}
|
||||
{# RENEW. Reuses the room gatekeeper's `.gate-overlay` / `.gate-modal` #}
|
||||
{# chrome (hand-rolled, not `{% include _gatekeeper %}` — the inner content #}
|
||||
{# differs: one seat circle + countdown + RENEW, no rails/PICK ROLES). The #}
|
||||
{# gear-menu NVM returns to the table hex (passed `nvm_url`), not /gameboard.#}
|
||||
<div class="room-page room-gate-page" data-room-id="{{ room.id }}">
|
||||
<div id="id_gate_wrapper" class="room-gate-wrapper">
|
||||
<div class="gate-backdrop"></div>
|
||||
<div class="gate-overlay room-gate-overlay">
|
||||
<div class="gate-modal room-gate-modal" role="dialog" aria-label="Renewal Gate">
|
||||
|
||||
<div class="gate-title-panel">
|
||||
<header class="gate-header">
|
||||
<h1>{{ room.name }}</h1>
|
||||
<div class="gate-status-wrap">
|
||||
<span class="gate-status-text">{% if not user_filled_slot %}No Seat{% elif cost_current %}Token Current{% elif in_renewal_grace %}Renewal Due{% else %}Grace Expired{% endif %}</span>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="gate-top-row">
|
||||
<div class="gate-main-panel">
|
||||
{% if user_filled_slot %}
|
||||
{# The viewer's own seat + position circle — held while #}
|
||||
{# they hold the slot (through the renewal-grace window).#}
|
||||
<div class="room-gate-seat table-seat seated" data-slot="{{ user_filled_slot.slot_number }}">
|
||||
<i class="fa-solid fa-chair"></i>
|
||||
<span class="seat-position-label">{{ slot_role_label }}</span>
|
||||
<i class="position-status-icon fa-solid fa-circle-check"></i>
|
||||
</div>
|
||||
{# Live time-remaining — ticks to cost_current_until while #}
|
||||
{# current, then to grace_expires_at once in renewal grace. #}
|
||||
<p id="id_room_gate_remaining"
|
||||
class="room-gate-remaining"
|
||||
data-cost-until="{{ cost_current_until|date:'c' }}"
|
||||
data-grace-until="{{ grace_expires_at|date:'c' }}"></p>
|
||||
{% else %}
|
||||
<p class="room-gate-remaining">You hold no seat in this room.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="gate-roles-panel">
|
||||
{% if user_filled_slot %}
|
||||
<form method="POST" action="{% url 'epic:renew_token' room.id %}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
id="id_room_renew_btn"
|
||||
class="launch-game-btn btn btn-primary">RENEW</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# NVM nav-backs one step to the table hex (not out to /gameboard/). #}
|
||||
{% url 'epic:room' room.id as nvm_url %}
|
||||
{% include "apps/gameboard/_partials/_room_gear.html" with nvm_url=nvm_url %}
|
||||
{% include "apps/gameboard/_partials/_burger.html" %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
||||
<script>
|
||||
{# Time-remaining ticker — mirrors the status-dots IIFE pattern in #}
|
||||
{# _gatekeeper.html. Counts down to cost_current_until while the token #}
|
||||
{# cost is current, then to grace_expires_at once in renewal grace. #}
|
||||
(function () {
|
||||
var el = document.getElementById('id_room_gate_remaining');
|
||||
if (!el) return;
|
||||
var costUntil = el.dataset.costUntil ? new Date(el.dataset.costUntil) : null;
|
||||
var graceUntil = el.dataset.graceUntil ? new Date(el.dataset.graceUntil) : null;
|
||||
function fmt(ms) {
|
||||
if (ms <= 0) return '0h';
|
||||
var d = Math.floor(ms / 86400000);
|
||||
var h = Math.floor((ms % 86400000) / 3600000);
|
||||
return (d > 0 ? d + 'd ' : '') + h + 'h';
|
||||
}
|
||||
function tick() {
|
||||
var now = new Date();
|
||||
if (costUntil && now < costUntil) {
|
||||
el.textContent = 'Token current — ' + fmt(costUntil - now) + ' remaining';
|
||||
} else if (graceUntil && now < graceUntil) {
|
||||
el.textContent = 'Renewal due — ' + fmt(graceUntil - now) + ' to renew';
|
||||
} else {
|
||||
el.textContent = 'Renewal grace expired';
|
||||
}
|
||||
}
|
||||
tick();
|
||||
clearInterval(window._roomGateTick);
|
||||
window._roomGateTick = setInterval(tick, 60000);
|
||||
}());
|
||||
</script>
|
||||
{% endblock scripts %}
|
||||
Reference in New Issue
Block a user