room gate-view: reuse the gatekeeper token-slot modal — CONT GAME → hex when satisfied / rails-renew when lapsed — TDD
Redesign of the room gate-view per user-spec 2026-05-31: drop the custom
seat-circle + countdown; render the EXACT gatekeeper modal instead
(title panel + animated status-dots + token-slot rails + roles panel).
- roles-panel .btn-primary is CONT GAME (→ table hex, same target as the
gear NVM) while the viewer's seat cost is current; absent once it
lapses, reappears after renewal re-satisfies the cost
- .gate-status-text: "<n> Token(s) Deposited" (literal "(s)" + the shared
. . . . dots loop) when satisfied; "Please Deposit Token" when not.
<n> = the room's deposited (FILLED) slot count
- token slot: .claimed (static rails) when current; .active rails that
POST to renew_token when lapsed
- seat circle + time-remaining removed — the hex's own .fa-chair carries
seat status & user/seat tooltips land next sprint
- room_gate view trimmed to {room, cost_current, deposited_count,
page_class}
- tests: RoomGateViewTest reworked (9) — CONT GAME→hex + deposited-count
status + no renew-form when current; "Please Deposit Token" + renew
rails + no CONT GAME when lapsed; NVM→hex; page-room; no seat/countdown
markup. 510 epic tests 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:
@@ -2633,26 +2633,46 @@ class RoomGateViewTest(TestCase):
|
||||
self.client.force_login(self.owner)
|
||||
self.url = reverse("epic:room_gate", args=[self.room.id])
|
||||
|
||||
def _lapse(self):
|
||||
# Backdate the seat into the renewal-grace window (cost lapsed, seat
|
||||
# still FILLED) so the gate-view renders its renew state.
|
||||
self.slot.filled_at = timezone.now() - timedelta(days=8)
|
||||
self.slot.save()
|
||||
|
||||
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):
|
||||
def test_cost_current_shows_cont_game_to_hex(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, 'data-slot="1"')
|
||||
self.assertContains(response, "PC") # slot 1 role label
|
||||
self.assertContains(response, "id_room_cont_game_btn")
|
||||
self.assertContains(response, "CONT")
|
||||
self.assertContains(response, reverse("epic:room", args=[self.room.id]))
|
||||
|
||||
def test_shows_time_remaining_data_attr(self):
|
||||
def test_cost_current_status_shows_deposited_count(self):
|
||||
# One filled slot (the owner's) → "1 Token(s) Deposited" (literal "(s)").
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "data-cost-until=")
|
||||
self.assertContains(response, "id_room_gate_remaining")
|
||||
self.assertContains(response, "1 Token(s) Deposited")
|
||||
|
||||
def test_renew_button_posts_to_renew_endpoint(self):
|
||||
def test_cost_current_has_no_renew_form(self):
|
||||
# Rails are static (claimed) while the cost is current — renewing is a
|
||||
# lapsed-state affordance only.
|
||||
response = self.client.get(self.url)
|
||||
self.assertNotContains(
|
||||
response, reverse("epic:renew_token", args=[self.room.id]))
|
||||
|
||||
def test_cost_lapsed_shows_please_deposit_and_renew_rails(self):
|
||||
self._lapse()
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "Please Deposit Token")
|
||||
self.assertContains(
|
||||
response, reverse("epic:renew_token", args=[self.room.id]))
|
||||
self.assertContains(response, "RENEW")
|
||||
|
||||
def test_cost_lapsed_hides_cont_game(self):
|
||||
self._lapse()
|
||||
response = self.client.get(self.url)
|
||||
self.assertNotContains(response, "id_room_cont_game_btn")
|
||||
|
||||
def test_nvm_returns_to_room_hex(self):
|
||||
response = self.client.get(self.url)
|
||||
@@ -2663,12 +2683,13 @@ class RoomGateViewTest(TestCase):
|
||||
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)
|
||||
def test_no_seat_circle_or_countdown_rendered(self):
|
||||
# Seat circle + countdown removed (user-spec) — the hex .fa-chair + the
|
||||
# next-sprint user/seat tooltips carry that info.
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, "id_room_renew_btn")
|
||||
self.assertNotContains(response, "room-gate-seat")
|
||||
self.assertNotContains(response, "data-cost-until")
|
||||
self.assertNotContains(response, "id_room_gate_remaining")
|
||||
|
||||
|
||||
class RoomRenewTokenTest(TestCase):
|
||||
|
||||
@@ -464,24 +464,22 @@ def room_view(request, room_id):
|
||||
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."""
|
||||
(navbar + center supersession) routes here. Reuses the gatekeeper's
|
||||
token-slot modal: when the viewer's seat cost is current the roles
|
||||
panel shows CONT GAME (→ table hex, same target as the gear NVM) and
|
||||
the status reads "<n> Token(s) Deposited"; when the cost has lapsed the
|
||||
rails go active to RENEW and the status reads "Please Deposit Token"
|
||||
(no CONT GAME until the cost is satisfied again). The seat circle +
|
||||
time-remaining live on the table hex / next-sprint user-seat tooltips,
|
||||
so they're intentionally absent here (user-spec 2026-05-31)."""
|
||||
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 ""
|
||||
),
|
||||
"deposited_count": room.gate_slots.filter(status=GateSlot.FILLED).count(),
|
||||
"page_class": "page-gameboard page-room page-room-gate",
|
||||
})
|
||||
|
||||
|
||||
@@ -6,14 +6,18 @@
|
||||
{% 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.#}
|
||||
{# Room renewal gate-view (sprint 2026-05-31) — reuses the EXACT gatekeeper #}
|
||||
{# token-slot modal (`_gatekeeper.html` chrome: title panel + status-dots + #}
|
||||
{# token-slot rails + roles panel), 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 renew. #}
|
||||
{# • cost current → roles-panel `.btn-primary` is CONT GAME (→ table hex, #}
|
||||
{# same target as the gear NVM); status "<n> Token(s) Deposited . . . .".#}
|
||||
{# • cost lapsed → no CONT GAME; the rails go active to RENEW; status #}
|
||||
{# "Please Deposit Token . . . .". Renewing re-satisfies the cost and #}
|
||||
{# the CONT GAME btn reappears. #}
|
||||
{# No seat circle / countdown here — the table hex's own `.fa-chair` shows #}
|
||||
{# seat status, and user/seat tooltips land next sprint (user-spec). #}
|
||||
<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>
|
||||
@@ -24,40 +28,50 @@
|
||||
<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>
|
||||
<span class="gate-status-text">{% if cost_current %}{{ deposited_count }} Token(s) Deposited{% else %}Please Deposit Token{% endif %}</span>
|
||||
<span class="status-dots" aria-hidden="true">
|
||||
<span></span><span></span><span></span><span></span>
|
||||
</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>
|
||||
{# Cost current → claimed (static rails, token in the slot). #}
|
||||
{# Cost lapsed → active rails that POST to renew_token. #}
|
||||
<div class="token-slot{% if cost_current %} claimed{% else %} active{% endif %}">
|
||||
{% if not cost_current %}
|
||||
<form method="POST" action="{% url 'epic:renew_token' room.id %}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="token-rails" aria-label="Insert token to renew">
|
||||
<span class="rail"></span>
|
||||
<span class="rail"></span>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="token-rails">
|
||||
<span class="rail"></span>
|
||||
<span class="rail"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="token-panel">
|
||||
<div class="token-denomination">1</div>
|
||||
<span class="token-insert-label">INSERT TOKEN TO PLAY</span>
|
||||
<span class="token-return-label">PUSH TO RETURN</span>
|
||||
</div>
|
||||
</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>
|
||||
{% if cost_current %}
|
||||
{# CONT GAME — same destination as the gear NVM (the table #}
|
||||
{# hex). Non-destructive nav, so no confirm guard. Only #}
|
||||
{# rendered while the token cost is satisfied. #}
|
||||
<button type="button"
|
||||
id="id_room_cont_game_btn"
|
||||
class="launch-game-btn btn btn-primary"
|
||||
onclick="window.location.href='{% url 'epic:room' room.id %}'">CONT<br>GAME</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,33 +91,20 @@
|
||||
<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. #}
|
||||
{# Status-dots animation — identical loop to _gatekeeper.html so the #}
|
||||
{# "Token(s) Deposited . . . ." / "Please Deposit Token . . . ." status #}
|
||||
{# pulses the same way as the gather-flow gatekeeper. #}
|
||||
(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);
|
||||
clearInterval(window._gateDots);
|
||||
var wrap = document.querySelector('.status-dots');
|
||||
if (!wrap) return;
|
||||
var dots = wrap.querySelectorAll('span');
|
||||
var n = 0;
|
||||
window._gateDots = setInterval(function () {
|
||||
if (!document.contains(wrap)) { clearInterval(window._gateDots); return; }
|
||||
dots.forEach(function (d, i) { d.textContent = i < n ? '.' : ''; });
|
||||
n = (n + 1) % 5;
|
||||
}, 400);
|
||||
}());
|
||||
</script>
|
||||
{% endblock scripts %}
|
||||
|
||||
Reference in New Issue
Block a user