diff --git a/src/apps/gameboard/consumers.py b/src/apps/gameboard/consumers.py index 8ef75ce..148025f 100644 --- a/src/apps/gameboard/consumers.py +++ b/src/apps/gameboard/consumers.py @@ -34,8 +34,14 @@ class MySeaSpectateConsumer(AsyncJsonWebsocketConsumer): # ── room-group fan-out handlers ───────────────────────────────────── async def sea_draw(self, event): - """Relay the owner's hand to the watching client.""" - await self.send_json({"type": "sea_draw", "hand": event.get("hand", [])}) + """Relay the owner's hand (+ current spread) to the watching client. The + spread lets a spectator re-sync its cross layout/captions if the owner + switched spreads (post-DEL) since the page loaded.""" + await self.send_json({ + "type": "sea_draw", + "hand": event.get("hand", []), + "spread": event.get("spread", ""), + }) async def sea_seats(self, event): """Relay the seat ring (a deposit took / a BYE freed a seat).""" diff --git a/src/apps/gameboard/static/apps/gameboard/my-sea-seats.js b/src/apps/gameboard/static/apps/gameboard/my-sea-seats.js index 1fa6b9f..27d9a22 100644 --- a/src/apps/gameboard/static/apps/gameboard/my-sea-seats.js +++ b/src/apps/gameboard/static/apps/gameboard/my-sea-seats.js @@ -47,6 +47,66 @@ // seat 1C transitions to `.seated` client-side (my_sea.html). window.playSeatGlow = playSeatGlow; + // Re-render the table-seat ring from a `sea_seats` broadcast payload when + // presence changes (a deposit takes a seat / a BYE frees one), so EVERY + // member — the owner on my_sea AND each spectator on my_sea_visit — sees + // members come + go live, no refresh. Shared by both pages (DRY); each + // passes its own `myToken` so the viewer's own chair gets the `--self` + // marker (the owner's `owner--` token, a visitor's `visit-`). + // Mirrors the server seat loop (`_my_sea_seats`) in markup. + function renderSeats(seats, myToken) { + var scene = document.querySelector('.room-table-scene'); + if (!scene || !seats) return; + scene.querySelectorAll('.table-seat').forEach(function (s) { s.remove(); }); + seats.forEach(function (seat) { + var isSelf = myToken && seat.token && seat.token === myToken; + var div = document.createElement('div'); + div.className = 'table-seat' + (seat.present ? ' seated' : '') + + (isSelf ? ' table-seat--self' : ''); + div.setAttribute('data-slot', seat.n); + if (seat.present && seat.token) div.setAttribute('data-seat-token', seat.token); + div.innerHTML = + '' + + '' + seat.label + '' + + ''; + scene.appendChild(div); + }); + // Re-fire the one-shot "just seated" glow for fresh occupancies + // (localStorage-gated per token, so it only flares once per viewer). + scene.querySelectorAll('.table-seat.seated[data-seat-token]') + .forEach(playSeatGlow); + } + window.mySeaRenderSeats = renderSeats; + + // Subscribe a page to the my-sea spectate WS for live `sea_seats` ring + // updates. The owner (my_sea) AND spectators (my_sea_visit) both connect to + // `mysea_`; the consumer gate admits the owner + present invitees. + // `myToken` marks the caller's own chair on re-render. Capped reconnect so a + // down spectate server/Redis can't spin forever. Spectators keep their own + // richer socket (it also carries `sea_draw`); this helper is for the OWNER + // page, which only needs the seat ring (it draws its own hand locally). + function connectSeatRing(ownerId, myToken) { + if (!('WebSocket' in window) || !ownerId) return; + var tries = 0; + function open() { + var scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; + var ws; + try { + ws = new WebSocket(scheme + '://' + window.location.host + + '/ws/my-sea-spectate/' + ownerId + '/'); + } catch (e) { return; } + ws.onmessage = function (ev) { + var msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + if (msg && msg.type === 'sea_seats') renderSeats(msg.seats, myToken); + }; + ws.onclose = function () { if (tries++ < 5) setTimeout(open, 3000); }; + } + open(); + } + window.mySeaConnectSeatRing = connectSeatRing; + document.addEventListener('DOMContentLoaded', function () { var seats = document.querySelectorAll('.table-seat.seated[data-seat-token]'); Array.prototype.forEach.call(seats, function (seat) { diff --git a/src/apps/gameboard/tests/integrated/test_sea_visit.py b/src/apps/gameboard/tests/integrated/test_sea_visit.py index b444a14..8818ed1 100644 --- a/src/apps/gameboard/tests/integrated/test_sea_visit.py +++ b/src/apps/gameboard/tests/integrated/test_sea_visit.py @@ -216,6 +216,47 @@ class MySeaVisitContextTest(TestCase): html.count('class="position-status-icon fa-solid fa-circle-check"'), 2) +class MySeaVisitEmptyHandLabelsTest(TestCase): + """Regression (2026-05-30): the spread captions (CROWN/COVER/…) must show + on the spectator cross even before the owner has drawn a single card — + they key off the SPREAD, not the drawn hand. Previously `label_by_position` + was built from `latest_draw_slots`, which returns [] for an empty hand, so + an owner who'd placed only a significator left every caption blank while her + OWN my_sea (whose JS seeds labels from the POSITION_LABELS constant) showed + them. Now the view pulls captions straight from POSITION_LABELS[spread].""" + + def setUp(self): + self.owner = _owner_with_sig() + self.bud = User.objects.create(email="bud@test.io", username="budster") + self.invite = SeaInvite.objects.create( + owner=self.owner, invitee=self.bud, invitee_email=self.bud.email, + status=SeaInvite.ACCEPTED, accepted_at=timezone.now(), + token_deposited_at=timezone.now(), + voice_until=timezone.now() + timedelta(hours=24), + ) + # Owner has chosen a spread + placed a significator but drawn NO cards. + MySeaDraw.objects.create( + user=self.owner, spread="escape-velocity", + significator_id=1, hand=[], + ) + self.client.force_login(self.bud) + self.url = reverse("my_sea_visit", args=[self.owner.id]) + + def test_captions_present_for_empty_hand(self): + from apps.gameboard.models import POSITION_LABELS + ctx = self.client.get(self.url).context + self.assertEqual(ctx["default_spread"], "escape-velocity") + # All six escape-velocity captions present despite the empty hand. + self.assertEqual( + ctx["label_by_position"], POSITION_LABELS["escape-velocity"]) + + def test_caption_text_rendered_in_cross(self): + html = self.client.get(self.url).content.decode() + for caption in ("Crown", "Leave", "Cover", "Cross", "Loom", "Lay"): + self.assertIn( + f'data-position="{caption.lower()}">{caption}', html) + + class MySeaVisitOwnerSeatedTest(TestCase): """Phase 2 — the owner shows seated in 1C on the spectator hex whenever she's committed to a draw cycle (drawn OR paid), not only once a card diff --git a/src/apps/gameboard/tests/integrated/test_spectate_consumer.py b/src/apps/gameboard/tests/integrated/test_spectate_consumer.py index 1259d31..9317e27 100644 --- a/src/apps/gameboard/tests/integrated/test_spectate_consumer.py +++ b/src/apps/gameboard/tests/integrated/test_spectate_consumer.py @@ -66,7 +66,7 @@ class MySeaSpectateConsumerTest(TransactionTestCase): connected, _ = await comm.connect() self.assertFalse(connected) - async def test_present_invitee_receives_the_owners_hand(self): + async def test_present_invitee_receives_the_owners_hand_and_spread(self): await database_sync_to_async(self._invite)(present=True) comm = self._comm(self.bud) connected, _ = await comm.connect() @@ -74,10 +74,16 @@ class MySeaSpectateConsumerTest(TransactionTestCase): from channels.layers import get_channel_layer hand = [{"position": "lay", "card_id": 1, "reversed": False, "polarity": "gravity"}] + # The spread rides along so a spectator under a stale spread re-syncs its + # cross layout/captions before filling the hand (asymmetry fix + # 2026-05-30). await get_channel_layer().group_send( - f"mysea_{self.owner.id}", {"type": "sea_draw", "hand": hand}) + f"mysea_{self.owner.id}", + {"type": "sea_draw", "hand": hand, "spread": "escape-velocity"}) msg = await comm.receive_json_from() - self.assertEqual(msg, {"type": "sea_draw", "hand": hand}) + self.assertEqual( + msg, + {"type": "sea_draw", "hand": hand, "spread": "escape-velocity"}) await comm.disconnect() async def test_present_invitee_receives_seat_updates(self): diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 63c2c04..ab9f8ca 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -1861,8 +1861,9 @@ class MySeaLockHandViewTest(TestCase): self.assertEqual(response.status_code, 405) def test_lock_broadcasts_hand_to_spectators(self): - # The owner's draw pushes the full hand to watching invitees (async - # witness, 2026-05-29) — my_sea_lock calls _notify_sea_draw(owner, hand). + # The owner's draw pushes the full hand + current spread to watching + # invitees (async witness, 2026-05-29; spread added 2026-05-30) — + # my_sea_lock calls _notify_sea_draw(owner, hand, spread). import json from unittest.mock import patch payload = self._build_payload() @@ -1870,9 +1871,11 @@ class MySeaLockHandViewTest(TestCase): self.client.post(self.url, data=json.dumps(payload), content_type="application/json") mock_notify.assert_called_once() - owner_id, hand = mock_notify.call_args[0] + owner_id, hand, spread = mock_notify.call_args[0] self.assertEqual(owner_id, self.user.id) self.assertEqual(hand, payload["hand"]) + # Spread rides along so a spectator under a stale spread re-syncs. + self.assertEqual(spread, payload["spread"]) def test_lock_succeeds_even_when_the_broadcast_fails(self): # The broadcast is best-effort — a down/missing channel layer must NOT diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index f21602c..6bf6bb8 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -8,8 +8,8 @@ from django.views.decorators.http import require_POST from apps.applets.utils import applet_context, apply_applet_toggle from .models import ( - HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for, latest_draw_slots, - _select_my_sea_token, debit_my_sea_token, + HAND_SIZE_BY_SPREAD, POSITION_LABELS, MySeaDraw, active_draw_for, + latest_draw_slots, _select_my_sea_token, debit_my_sea_token, ) @@ -453,11 +453,15 @@ def _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url): return payload -def _notify_sea_draw(owner_id, hand): +def _notify_sea_draw(owner_id, hand, spread): """Best-effort push of the owner's current hand to watching invitees (the my-sea spectate WS, `mysea_`) so they witness each card land - without refreshing. Guarded — a missing/down channel layer must never break - the solo draw, since the spectate is an enhancement, not a hard dependency.""" + without refreshing. The `spread` rides along so a spectator who loaded under + a different spread re-syncs its cross layout + captions before filling the + hand — otherwise a post-DEL spread switch lands the new cards into the old + spread's cells (user-reported asymmetry 2026-05-30). Guarded — a missing/ + down channel layer must never break the solo draw, since the spectate is an + enhancement, not a hard dependency.""" try: from asgiref.sync import async_to_sync from channels.layers import get_channel_layer @@ -466,7 +470,7 @@ def _notify_sea_draw(owner_id, hand): return async_to_sync(layer.group_send)( f"mysea_{owner_id}", - {"type": "sea_draw", "hand": hand}, + {"type": "sea_draw", "hand": hand, "spread": spread}, ) except Exception: pass @@ -588,7 +592,8 @@ def my_sea_lock(request): existing.paid_through_at = None update_fields.append("paid_through_at") existing.save(update_fields=update_fields) - _notify_sea_draw(request.user.id, existing.hand) # live to spectators + # live to spectators — hand + spread (a post-DEL re-draw may switch it) + _notify_sea_draw(request.user.id, existing.hand, existing.spread) return JsonResponse({ "ok": True, "next_free_draw_at": ( @@ -626,7 +631,7 @@ def my_sea_lock(request): significator_id=sig_id, significator_reversed=request.user.significator_reversed, ) - _notify_sea_draw(request.user.id, draw.hand) # live to spectators + _notify_sea_draw(request.user.id, draw.hand, draw.spread) # live to spectators # Append the @taxman ledger entry + spawn the Brief. Response carries # the Brief payload so the picker IIFE can surface the banner in-place # w.o. a page reload — same affordance the prior in-template @@ -662,7 +667,8 @@ def my_sea_delete(request): if draw is not None: draw.hand = [] draw.save(update_fields=["hand"]) - _notify_sea_draw(request.user.id, []) # clear the spectators' cross + # clear the spectators' cross (spread preserved on the row → keep layout) + _notify_sea_draw(request.user.id, [], draw.spread) return HttpResponse(status=204) @@ -988,6 +994,8 @@ def my_sea_visit(request, owner_id): # `sea_deck_data` is the OWNER's deck so sea.js can resolve each clicked # slot's full card face for the magnified stage. owner_slots = latest_draw_slots(owner) + owner_spread = (owner_draw.spread if owner_draw is not None + else "situation-action-outcome") return render(request, "apps/gameboard/my_sea_visit.html", { "spectator": True, "is_owner": False, @@ -1005,11 +1013,19 @@ def my_sea_visit(request, owner_id): "my_sea_slots": owner_slots, "owner_hand_non_empty": owner_hand_non_empty, # Read-only cross-stage parity payload. - "default_spread": (owner_draw.spread if owner_draw is not None - else "situation-action-outcome"), + "default_spread": owner_spread, "saved_by_position": _saved_by_position( owner_draw.hand if owner_draw is not None else []), - "label_by_position": {s["position"]: s["label"] for s in owner_slots}, + # Captions key off the SPREAD, not the drawn cards — the owner page's + # syncLabels() sets them from the POSITION_LABELS JS constant the moment + # a spread is chosen, so an owner who's placed only a significator (empty + # hand) still shows CROWN/COVER/… On the spectator side `latest_draw_slots` + # returns [] for an empty hand, so deriving labels from it left every + # caption blank (2026-05-30). Pull straight from POSITION_LABELS instead. + "label_by_position": POSITION_LABELS.get(owner_spread, {}), + # Full per-spread caption map — lets the spectate WS re-caption the + # cross live if the owner switches spreads (post-DEL) after page load. + "position_labels": POSITION_LABELS, "sea_deck_data": ( _my_sea_deck_data(owner, exclude_id=sig_card.id if sig_card else None) if sig_card is not None else {"levity": [], "gravity": []} diff --git a/src/static/tests/MySeaSeatsSpec.js b/src/static/tests/MySeaSeatsSpec.js index c5e37c8..8bfd7c8 100644 --- a/src/static/tests/MySeaSeatsSpec.js +++ b/src/static/tests/MySeaSeatsSpec.js @@ -55,3 +55,72 @@ describe('my-sea-seats one-shot seated glow', function () { jasmine.clock().uninstall(); }); }); + +// mySeaRenderSeats — the shared seat-ring re-render driven by a `sea_seats` +// broadcast. Used by BOTH the owner's my_sea AND the spectator's my_sea_visit +// (DRY, 2026-05-30); each passes its own `myToken` to mark its own chair. +describe('my-sea-seats mySeaRenderSeats shared ring re-render', function () { + var scene; + + beforeEach(function () { + window.localStorage.clear(); + scene = document.createElement('div'); + scene.className = 'room-table-scene'; + document.body.appendChild(scene); + }); + + afterEach(function () { + if (scene && scene.parentNode) scene.parentNode.removeChild(scene); + window.localStorage.clear(); + }); + + var SEATS = [ + { n: 1, label: '1C', present: true, token: 'owner-9-3' }, + { n: 2, label: '2C', present: true, token: 'visit-42' }, + { n: 3, label: '3C', present: false, token: '' }, + ]; + + it('exposes mySeaRenderSeats globally', function () { + expect(typeof window.mySeaRenderSeats).toBe('function'); + }); + + it('rebuilds one .table-seat per seat with the present/seated state', function () { + window.mySeaRenderSeats(SEATS, ''); + var seats = scene.querySelectorAll('.table-seat'); + expect(seats.length).toBe(3); + expect(seats[0].classList.contains('seated')).toBe(true); + expect(seats[2].classList.contains('seated')).toBe(false); + // Present seats get the check icon; absent seats the ban icon. + expect(seats[0].querySelector('.fa-circle-check')).not.toBeNull(); + expect(seats[2].querySelector('.fa-ban')).not.toBeNull(); + expect(seats[1].querySelector('.seat-position-label').textContent).toBe('2C'); + }); + + it('marks only the seat matching myToken as --self', function () { + window.mySeaRenderSeats(SEATS, 'visit-42'); + var self = scene.querySelectorAll('.table-seat--self'); + expect(self.length).toBe(1); + expect(self[0].getAttribute('data-slot')).toBe('2'); + }); + + it('marks no seat --self when myToken is empty (owner page)', function () { + window.mySeaRenderSeats(SEATS, ''); + expect(scene.querySelectorAll('.table-seat--self').length).toBe(0); + }); + + it('clears prior seats before re-rendering (no duplicates)', function () { + window.mySeaRenderSeats(SEATS, ''); + window.mySeaRenderSeats(SEATS, ''); + expect(scene.querySelectorAll('.table-seat').length).toBe(3); + }); + + it('flares freshly-seated tokened seats once (localStorage-gated)', function () { + window.mySeaRenderSeats(SEATS, ''); + var owner = scene.querySelector('.table-seat[data-slot="1"]'); + expect(owner.classList.contains('seat-just-seated')).toBe(true); + // Second render for the same token does not replay the flare. + window.mySeaRenderSeats(SEATS, ''); + owner = scene.querySelector('.table-seat[data-slot="1"]'); + expect(owner.classList.contains('seat-just-seated')).toBe(false); + }); +}); diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 671858e..e89ddfe 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -432,7 +432,7 @@ body.page-gameboard { text-transform: uppercase; font-weight: 600; opacity: 1; - color: rgba(var(--seciUser), 1); + color: rgba(var(--secUser), 1); text-shadow: 0 0 0.25rem rgba(var(--priUser), 1); text-align: center; pointer-events: none; diff --git a/src/static_src/tests/MySeaSeatsSpec.js b/src/static_src/tests/MySeaSeatsSpec.js index c5e37c8..8bfd7c8 100644 --- a/src/static_src/tests/MySeaSeatsSpec.js +++ b/src/static_src/tests/MySeaSeatsSpec.js @@ -55,3 +55,72 @@ describe('my-sea-seats one-shot seated glow', function () { jasmine.clock().uninstall(); }); }); + +// mySeaRenderSeats — the shared seat-ring re-render driven by a `sea_seats` +// broadcast. Used by BOTH the owner's my_sea AND the spectator's my_sea_visit +// (DRY, 2026-05-30); each passes its own `myToken` to mark its own chair. +describe('my-sea-seats mySeaRenderSeats shared ring re-render', function () { + var scene; + + beforeEach(function () { + window.localStorage.clear(); + scene = document.createElement('div'); + scene.className = 'room-table-scene'; + document.body.appendChild(scene); + }); + + afterEach(function () { + if (scene && scene.parentNode) scene.parentNode.removeChild(scene); + window.localStorage.clear(); + }); + + var SEATS = [ + { n: 1, label: '1C', present: true, token: 'owner-9-3' }, + { n: 2, label: '2C', present: true, token: 'visit-42' }, + { n: 3, label: '3C', present: false, token: '' }, + ]; + + it('exposes mySeaRenderSeats globally', function () { + expect(typeof window.mySeaRenderSeats).toBe('function'); + }); + + it('rebuilds one .table-seat per seat with the present/seated state', function () { + window.mySeaRenderSeats(SEATS, ''); + var seats = scene.querySelectorAll('.table-seat'); + expect(seats.length).toBe(3); + expect(seats[0].classList.contains('seated')).toBe(true); + expect(seats[2].classList.contains('seated')).toBe(false); + // Present seats get the check icon; absent seats the ban icon. + expect(seats[0].querySelector('.fa-circle-check')).not.toBeNull(); + expect(seats[2].querySelector('.fa-ban')).not.toBeNull(); + expect(seats[1].querySelector('.seat-position-label').textContent).toBe('2C'); + }); + + it('marks only the seat matching myToken as --self', function () { + window.mySeaRenderSeats(SEATS, 'visit-42'); + var self = scene.querySelectorAll('.table-seat--self'); + expect(self.length).toBe(1); + expect(self[0].getAttribute('data-slot')).toBe('2'); + }); + + it('marks no seat --self when myToken is empty (owner page)', function () { + window.mySeaRenderSeats(SEATS, ''); + expect(scene.querySelectorAll('.table-seat--self').length).toBe(0); + }); + + it('clears prior seats before re-rendering (no duplicates)', function () { + window.mySeaRenderSeats(SEATS, ''); + window.mySeaRenderSeats(SEATS, ''); + expect(scene.querySelectorAll('.table-seat').length).toBe(3); + }); + + it('flares freshly-seated tokened seats once (localStorage-gated)', function () { + window.mySeaRenderSeats(SEATS, ''); + var owner = scene.querySelector('.table-seat[data-slot="1"]'); + expect(owner.classList.contains('seat-just-seated')).toBe(true); + // Second render for the same token does not replay the flare. + window.mySeaRenderSeats(SEATS, ''); + owner = scene.querySelector('.table-seat[data-slot="1"]'); + expect(owner.classList.contains('seat-just-seated')).toBe(false); + }); +}); diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index 54127b7..14e9d86 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -947,6 +947,18 @@ {# the spectator page. Exposes window.playSeatGlow + auto-plays on #} {# load for any server-rendered .seated[data-seat-token] seat. #} + {# Live seat ring — subscribe the OWNER's page to her own spectate WS #} + {# (`mysea_`) so visitors arriving (deposit → 2C-6C) / leaving #} + {# (BYE) appear without a refresh, the same `sea_seats` broadcast the #} + {# spectators receive. Empty self-token: the owner's 1C isn't `--self` #} + {# server-side, so the live re-render shouldn't add it either. #} +