diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js index 3224e43..f46c29f 100644 --- a/src/apps/epic/static/apps/epic/sig-select.js +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -713,11 +713,22 @@ var SigSelect = (function () { Object.keys(existing).forEach(function (cardId) { applyReservation(cardId, existing[cardId], true); }); - // Restore WAIT NVM state if gamer was already ready before page load + // Restore the ready-state button if the gamer was already + // ready before this page load. if (overlay.dataset.ready === 'true' && _takeSigBtn) { _isReady = true; - _takeSigBtn.textContent = 'WAIT NVM'; - _startWaitNoGlow(); + // If a polarity countdown is ALREADY running (its + // one-shot countdown_start WS event fired before this + // view loaded — e.g. a CARTE owner switching to an + // already-ready seat), restore the live flashing numeral + // from the server-seeded seconds-left. Else WAIT NVM. + var _rem = parseInt(overlay.dataset.countdownRemaining, 10); + if (_rem > 0) { + _showCountdown(_rem); + } else { + _takeSigBtn.textContent = 'WAIT NVM'; + _startWaitNoGlow(); + } } }; if (document.readyState === 'complete') { diff --git a/src/apps/epic/tasks.py b/src/apps/epic/tasks.py index 8268f37..a66a295 100644 --- a/src/apps/epic/tasks.py +++ b/src/apps/epic/tasks.py @@ -6,6 +6,7 @@ Single-process only — swap for a Celery task if production uses multiple web workers (gunicorn -w N with N > 1). """ import threading +import time import uuid from asgiref.sync import async_to_sync @@ -29,8 +30,13 @@ def _group_send(room_id, msg): def _fire(room_id, polarity, token): """Callback run by threading.Timer after the countdown expires.""" - # Token guard: if cancelled or superseded, cache entry will differ - if cache.get(_cache_key(room_id, polarity)) != token: + # Token guard: if cancelled or superseded, cache entry will differ. The + # entry is now a {token, deadline} dict (was a bare token string before the + # restore-on-load sprint) — read either shape defensively so a stale plain + # string left by an older deploy doesn't crash the timer callback. + entry = cache.get(_cache_key(room_id, polarity)) + stored_token = entry.get('token') if isinstance(entry, dict) else entry + if stored_token != token: return from apps.epic.models import Room, SigReservation @@ -79,7 +85,15 @@ def schedule_polarity_confirm(room_id, polarity, seconds): cancel_polarity_confirm(room_id, polarity) token = str(uuid.uuid4()) - cache.set(_cache_key(room_id, polarity), token, timeout=int(seconds) + 60) + # Store the absolute deadline alongside the token so a gamer loading a + # fresh seat view mid-countdown can derive the seconds left (the flashing + # numeral) instead of falling back to a static WAIT NVM. See + # countdown_remaining() + sig-select.js's restore-on-load. + cache.set( + _cache_key(room_id, polarity), + {'token': token, 'deadline': time.time() + seconds}, + timeout=int(seconds) + 60, + ) timer = threading.Timer(seconds, _fire, args=[str(room_id), polarity, token]) timer.daemon = True @@ -93,3 +107,16 @@ def cancel_polarity_confirm(room_id, polarity): if timer: timer.cancel() cache.delete(_cache_key(room_id, polarity)) + + +def countdown_remaining(room_id, polarity): + """Seconds left on the live polarity countdown, or None when no countdown + is running (or it has already elapsed). Lets a fresh page load restore the + flashing numeral mid-countdown instead of showing a static WAIT NVM — the + `countdown_start` WS broadcast is one-shot, so a view that loads after it + fired has nothing to seed `_showCountdown` with otherwise.""" + entry = cache.get(_cache_key(room_id, polarity)) + if not isinstance(entry, dict) or 'deadline' not in entry: + return None + remaining = int(round(entry['deadline'] - time.time())) + return remaining if remaining > 0 else None diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 65733dc..8e0dc08 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -919,6 +919,37 @@ class CarteTrayFollowsSelectedSeatTest(TestCase): self.assertTrue(by_slot[1]["is_me_also"]) self.assertEqual(by_slot[1]["state_class"], "tt-pos-me-also") + def test_gate_view_cont_game_and_nvm_carry_acting_seat(self): + # Regression (CONT GAME pos-1 shuttle): the gate-view CONT GAME btn + + # the gear NVM both linked to epic:room with NO ?seat, so a CARTE + # multi-seat gamer acting at pos 4 got shuttled back to owned[0] (pos 1) + # instead of staying on his acting seat. Both must carry the acting + # ?seat through to the table (same fix shape as the GATE VIEW nav btns). + gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id}) + response = self.client.get(gate_url + "?seat=4") + self.assertEqual(response.context["current_slot"], 4) + # CONT GAME btn returns to the table hex at the acting seat. + self.assertContains( + response, + f"window.location.href='{self.room_url}?seat=4'", + ) + # Gear NVM (back to the hex) carries the acting seat too. + self.assertContains( + response, + f'href="{self.room_url}?seat=4" class="btn btn-cancel"', + ) + + def test_gate_view_cont_game_default_targets_lowest_owned(self): + # No ?seat on the gate → CONT GAME + NVM carry the lowest owned slot + # (current_slot), keeping the gate + table views in agreement. + gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id}) + response = self.client.get(gate_url) + self.assertEqual(response.context["current_slot"], 1) + self.assertContains( + response, + f"window.location.href='{self.room_url}?seat=1'", + ) + class PickRolesViewTest(TestCase): def setUp(self): @@ -1630,6 +1661,37 @@ class SigSelectRenderingTest(TestCase): self.assertContains(response, "fyi-prev") self.assertContains(response, "fyi-next") + def test_overlay_carries_live_countdown_remaining_on_load(self): + # Restore-on-load: a gamer landing on his seat view mid-countdown must + # get the live flashing numeral, not a static WAIT NVM. countdown_start + # is a one-shot WS event, so the seconds-left are seeded server-side + # from the timer deadline stored in the cache. + from django.core.cache import cache + from apps.epic.tasks import _cache_key + import time + founder = self.gamers[0] # PC → levity + seat = self.room.table_seats.get(gamer=founder, role="PC") + card = TarotCard.objects.get( + deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11) + SigReservation.objects.create( + room=self.room, gamer=founder, card=card, + polarity=SigReservation.LEVITY, seat=seat, ready=True) + cache.set( + _cache_key(str(self.room.id), SigReservation.LEVITY), + {"token": "t", "deadline": time.time() + 10}, 60) + response = self.client.get(self.url) + self.assertTrue(8 <= response.context["countdown_remaining"] <= 10) + self.assertContains( + response, + f'data-countdown-remaining="{response.context["countdown_remaining"]}"') + + def test_overlay_countdown_remaining_zero_when_no_countdown(self): + # No live countdown → context None → attribute renders "0" (JS falls + # back to WAIT NVM on load). + response = self.client.get(self.url) + self.assertIsNone(response.context["countdown_remaining"]) + self.assertContains(response, 'data-countdown-remaining="0"') + class SigSelectUnifiedStageTest(TestCase): """Sig Select stage unified with the my_sign card-stage apparatus: @@ -3146,9 +3208,13 @@ class RoomGateViewTest(TestCase): self.assertNotContains(response, "id_room_cont_game_btn") def test_nvm_returns_to_room_hex(self): + # NVM lands the gamer back on the table hex at his acting seat (?seat), + # not owned[0] — so a CARTE multi-seat gamer keeps his switched seat. + # The owner holds slot 1, so current_slot=1. response = self.client.get(self.url) self.assertContains( - response, f'href="{reverse("epic:room", args=[self.room.id])}"') + response, + f'href="{reverse("epic:room", args=[self.room.id])}?seat=1"') def test_page_class_carries_page_room(self): response = self.client.get(self.url) diff --git a/src/apps/epic/tests/unit/test_tasks.py b/src/apps/epic/tests/unit/test_tasks.py index 44e8c0d..444f6a5 100644 --- a/src/apps/epic/tests/unit/test_tasks.py +++ b/src/apps/epic/tests/unit/test_tasks.py @@ -193,6 +193,51 @@ class SchedulePolarityConfirmTest(TestCase): second_timer.cancel() +class CountdownRemainingTest(TestCase): + """The polarity countdown stores a deadline alongside its timer token so a + gamer landing on a fresh seat view mid-countdown can restore the live + flashing numeral (not just WAIT NVM). `countdown_remaining` derives the + seconds left from that deadline.""" + + def setUp(self): + self.user = User.objects.create(email="cd@tasks.io") + self.room = Room.objects.create(name="R", owner=self.user) + + def tearDown(self): + cancel_polarity_confirm(str(self.room.id), SigReservation.LEVITY) + + def test_schedule_stores_deadline_alongside_token(self): + from django.core.cache import cache + schedule_polarity_confirm(str(self.room.id), SigReservation.LEVITY, 12) + entry = cache.get(_cache_key(str(self.room.id), SigReservation.LEVITY)) + self.assertIsInstance(entry, dict) + self.assertIn("token", entry) + self.assertIn("deadline", entry) + + def test_countdown_remaining_returns_seconds_left(self): + from apps.epic.tasks import countdown_remaining + schedule_polarity_confirm(str(self.room.id), SigReservation.LEVITY, 12) + remaining = countdown_remaining(str(self.room.id), SigReservation.LEVITY) + # Just-scheduled 12s countdown → ~12s left (allow scheduling slack). + self.assertTrue(10 <= remaining <= 12, remaining) + + def test_countdown_remaining_none_when_no_entry(self): + from apps.epic.tasks import countdown_remaining + self.assertIsNone( + countdown_remaining(str(self.room.id), SigReservation.LEVITY)) + + def test_countdown_remaining_none_when_deadline_passed(self): + from apps.epic.tasks import countdown_remaining + from django.core.cache import cache + import time + cache.set( + _cache_key(str(self.room.id), SigReservation.LEVITY), + {"token": "t", "deadline": time.time() - 5}, 60, + ) + self.assertIsNone( + countdown_remaining(str(self.room.id), SigReservation.LEVITY)) + + class GroupSendTest(TestCase): @patch("apps.epic.tasks.async_to_sync") def test_group_send_calls_async_to_sync(self, mock_a2s): diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 00aa99f..b126e6f 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -10,6 +10,7 @@ from django.contrib.auth.decorators import login_required from django.db import transaction from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import redirect, render +from django.urls import reverse from django.template.loader import render_to_string from django.utils import timezone @@ -610,8 +611,15 @@ def _role_select_context(room, user, seat_param=None): str(res.card_id): res.role for res in room.sig_reservations.filter(polarity=polarity_const) } + # Seconds left on a live polarity countdown, so a gamer landing on + # this seat view mid-countdown restores the flashing numeral instead + # of a static WAIT NVM (countdown_start is a one-shot WS broadcast). + # None when no countdown is running → template renders 0. + from apps.epic.tasks import countdown_remaining + ctx["countdown_remaining"] = countdown_remaining(room.id, polarity_const) else: reservations = {} + ctx["countdown_remaining"] = None ctx["sig_reservations_json"] = json.dumps(reservations) if user_polarity == 'levity': @@ -789,8 +797,16 @@ def room_gate(request, room_id): # partial's slot conditionals read. The renewal modal's own keys override # below. ctx = _gate_context(room, request.user, request.GET.get("seat")) + # CONT GAME + the gear NVM both return to the table hex — but they must land + # the gamer on his ACTING seat (the ?seat-selected pos-circle), not owned[0]. + # Without the ?seat a CARTE multi-seat gamer acting at pos 4 got shuttled + # back to pos 1. Mirrors the GATE VIEW nav buttons (bedc489). + table_url = reverse("epic:room", args=[room.id]) + if ctx.get("current_slot"): + table_url += f"?seat={ctx['current_slot']}" ctx.update({ "room": room, + "table_url": table_url, "cost_current": user_slot.cost_current if user_slot else True, "deposited_count": room.gate_slots.filter(status=GateSlot.FILLED).count(), "page_class": "page-gameboard page-room page-room-gate", diff --git a/src/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js index d24cea3..e3a57f8 100644 --- a/src/static/tests/SigSelectSpec.js +++ b/src/static/tests/SigSelectSpec.js @@ -1,7 +1,8 @@ describe("SigSelect", () => { let testDiv, stageCard, card, statBlock; - function makeFixture({ reservations = '{}', polarity = 'levity', userRole = 'PC' } = {}) { + function makeFixture({ reservations = '{}', polarity = 'levity', userRole = 'PC', + ready = false, countdownRemaining = 0 } = {}) { testDiv = document.createElement("div"); testDiv.innerHTML = `
{ data-reserve-url="/epic/room/test/sig-reserve" data-ready-url="/epic/room/test/sig-ready" + data-ready="${ready}" + data-countdown-remaining="${countdownRemaining}" data-reservations="${reservations.replace(/"/g, '"')}">
@@ -863,6 +866,42 @@ describe("SigSelect", () => { }); }); + // ── Countdown restore on load (mid-countdown page load) ────────────────── // + // + // The 12s polarity countdown's `countdown_start` is a ONE-SHOT WS event: a + // gamer (esp. a CARTE multi-seat owner switching to an already-ready seat) + // who loads the view after it fired never received it, so the button showed + // a static WAIT NVM instead of the live flashing numeral. The view now + // seeds data-countdown-remaining from the server-side timer deadline; on + // load _replayReservations restores _showCountdown(remaining) when a count + // is live, else falls back to WAIT NVM. + + describe("countdown restore on load", () => { + let takeSigBtn; + + beforeEach(() => jasmine.clock().install()); + afterEach(() => jasmine.clock().uninstall()); + + it("shows the live numeral (not WAIT NVM) when a countdown is running at load", () => { + makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 8 }); + takeSigBtn = document.getElementById("id_take_sig_btn"); + expect(takeSigBtn.textContent).toBe("8"); + }); + + it("the restored numeral counts down each second", () => { + makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 8 }); + takeSigBtn = document.getElementById("id_take_sig_btn"); + jasmine.clock().tick(1000); + expect(takeSigBtn.textContent).toBe("7"); + }); + + it("falls back to WAIT NVM when ready but no countdown is live (remaining 0)", () => { + makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 0 }); + takeSigBtn = document.getElementById("id_take_sig_btn"); + expect(takeSigBtn.textContent).toBe("WAIT NVM"); + }); + }); + // ── polarity_room_done → tray sequence ─────────────────────────────────── // // // After all 3 gamers in the user's polarity confirm SAVE SIG and the diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js index d24cea3..e3a57f8 100644 --- a/src/static_src/tests/SigSelectSpec.js +++ b/src/static_src/tests/SigSelectSpec.js @@ -1,7 +1,8 @@ describe("SigSelect", () => { let testDiv, stageCard, card, statBlock; - function makeFixture({ reservations = '{}', polarity = 'levity', userRole = 'PC' } = {}) { + function makeFixture({ reservations = '{}', polarity = 'levity', userRole = 'PC', + ready = false, countdownRemaining = 0 } = {}) { testDiv = document.createElement("div"); testDiv.innerHTML = `
{ data-reserve-url="/epic/room/test/sig-reserve" data-ready-url="/epic/room/test/sig-ready" + data-ready="${ready}" + data-countdown-remaining="${countdownRemaining}" data-reservations="${reservations.replace(/"/g, '"')}">
@@ -863,6 +866,42 @@ describe("SigSelect", () => { }); }); + // ── Countdown restore on load (mid-countdown page load) ────────────────── // + // + // The 12s polarity countdown's `countdown_start` is a ONE-SHOT WS event: a + // gamer (esp. a CARTE multi-seat owner switching to an already-ready seat) + // who loads the view after it fired never received it, so the button showed + // a static WAIT NVM instead of the live flashing numeral. The view now + // seeds data-countdown-remaining from the server-side timer deadline; on + // load _replayReservations restores _showCountdown(remaining) when a count + // is live, else falls back to WAIT NVM. + + describe("countdown restore on load", () => { + let takeSigBtn; + + beforeEach(() => jasmine.clock().install()); + afterEach(() => jasmine.clock().uninstall()); + + it("shows the live numeral (not WAIT NVM) when a countdown is running at load", () => { + makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 8 }); + takeSigBtn = document.getElementById("id_take_sig_btn"); + expect(takeSigBtn.textContent).toBe("8"); + }); + + it("the restored numeral counts down each second", () => { + makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 8 }); + takeSigBtn = document.getElementById("id_take_sig_btn"); + jasmine.clock().tick(1000); + expect(takeSigBtn.textContent).toBe("7"); + }); + + it("falls back to WAIT NVM when ready but no countdown is live (remaining 0)", () => { + makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 0 }); + takeSigBtn = document.getElementById("id_take_sig_btn"); + expect(takeSigBtn.textContent).toBe("WAIT NVM"); + }); + }); + // ── polarity_room_done → tray sequence ─────────────────────────────────── // // // After all 3 gamers in the user's polarity confirm SAVE SIG and the diff --git a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html index 9d51b35..c2b679a 100644 --- a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html @@ -13,6 +13,7 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_ data-ready-url="{% url 'epic:sig_ready' room.id %}{% if user_seat %}?seat={{ user_seat.slot_number }}{% endif %}" data-ready="{{ user_ready|yesno:'true,false' }}" + data-countdown-remaining="{{ countdown_remaining|default:0 }}" data-reservations="{{ sig_reservations_json }}">
diff --git a/src/templates/apps/gameboard/room_gate.html b/src/templates/apps/gameboard/room_gate.html index f74ec58..84f2930 100644 --- a/src/templates/apps/gameboard/room_gate.html +++ b/src/templates/apps/gameboard/room_gate.html @@ -71,7 +71,7 @@ + onclick="window.location.href='{{ table_url }}'">CONT
GAME {% endif %}
@@ -90,9 +90,9 @@ {% include "apps/gameboard/_partials/_table_positions.html" with persist_circles=True %} {% include "apps/gameboard/_partials/_position_tooltip.html" %} - {# 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 %} + {# NVM nav-backs one step to the table hex (not out to /gameboard/), #} + {# landing on the acting seat (?seat) like CONT GAME above. #} + {% include "apps/gameboard/_partials/_room_gear.html" with nvm_url=table_url %} {% include "apps/gameboard/_partials/_burger.html" %}
{% endblock content %}