diff --git a/src/apps/gameboard/consumers.py b/src/apps/gameboard/consumers.py index 53e3f34..8ef75ce 100644 --- a/src/apps/gameboard/consumers.py +++ b/src/apps/gameboard/consumers.py @@ -32,11 +32,15 @@ class MySeaSpectateConsumer(AsyncJsonWebsocketConsumer): if self.group: await self.channel_layer.group_discard(self.group, self.channel_name) - # ── room-group fan-out handler ────────────────────────────────────── + # ── 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", [])}) + async def sea_seats(self, event): + """Relay the seat ring (a deposit took / a BYE freed a seat).""" + await self.send_json({"type": "sea_seats", "seats": event.get("seats", [])}) + # ── membership gate ───────────────────────────────────────────────── @database_sync_to_async def _can_watch(self): diff --git a/src/apps/gameboard/tests/integrated/test_sea_visit.py b/src/apps/gameboard/tests/integrated/test_sea_visit.py index 8674fc6..b444a14 100644 --- a/src/apps/gameboard/tests/integrated/test_sea_visit.py +++ b/src/apps/gameboard/tests/integrated/test_sea_visit.py @@ -283,6 +283,13 @@ class MySeaVisitInsertTokenTest(TestCase): self.assertIsNone(self.invite.token_deposited_at) self.assertFalse(self.invite.is_present) + def test_deposit_broadcasts_the_seat_ring(self): + from unittest.mock import patch + self.client.force_login(self.bud) + with patch("apps.gameboard.views._notify_sea_seats") as mock: + self.client.post(self.url) + mock.assert_called_once_with(self.owner.id) + class MySeaVisitLeaveTest(TestCase): def setUp(self): @@ -308,6 +315,13 @@ class MySeaVisitLeaveTest(TestCase): resp, reverse("gameboard"), fetch_redirect_response=False, ) + def test_leave_broadcasts_the_seat_ring(self): + from unittest.mock import patch + self.client.force_login(self.bud) + with patch("apps.gameboard.views._notify_sea_seats") as mock: + self.client.post(self.url) + mock.assert_called_once_with(self.owner.id) + def test_non_invitee_cannot_leave(self): stranger = User.objects.create(email="x@test.io", username="x") self.client.force_login(stranger) diff --git a/src/apps/gameboard/tests/integrated/test_spectate_consumer.py b/src/apps/gameboard/tests/integrated/test_spectate_consumer.py index e52b8b0..1259d31 100644 --- a/src/apps/gameboard/tests/integrated/test_spectate_consumer.py +++ b/src/apps/gameboard/tests/integrated/test_spectate_consumer.py @@ -79,3 +79,16 @@ class MySeaSpectateConsumerTest(TransactionTestCase): msg = await comm.receive_json_from() self.assertEqual(msg, {"type": "sea_draw", "hand": hand}) await comm.disconnect() + + async def test_present_invitee_receives_seat_updates(self): + await database_sync_to_async(self._invite)(present=True) + comm = self._comm(self.bud) + connected, _ = await comm.connect() + self.assertTrue(connected) + from channels.layers import get_channel_layer + seats = [{"n": 1, "label": "1C", "present": True, "token": "owner-x"}] + await get_channel_layer().group_send( + f"mysea_{self.owner.id}", {"type": "sea_seats", "seats": seats}) + msg = await comm.receive_json_from() + self.assertEqual(msg, {"type": "sea_seats", "seats": seats}) + await comm.disconnect() diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index c07803d..db5c1b0 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -469,6 +469,60 @@ def _notify_sea_draw(owner_id, hand): pass +def _my_sea_seats(owner): + """Table-ring seat list for `owner`'s sea — owner 1C + present invitees + 2C-6C by deposit order (capped at MY_SEA_MAX_VISITORS). Each entry is + {n, label, present, token, invitee_id?}; the per-viewer `is_self` is layered + on by the caller. Shared by the spectator render (my_sea_visit) AND the live + seat broadcast (`sea_seats`).""" + from .models import SeaInvite, MY_SEA_MAX_VISITORS + owner_draw = active_draw_for(owner) + owner_seated = owner_draw is not None and ( + bool(owner_draw.hand) + or owner_draw.deposit_token_id is not None + or owner_draw.paid_through_at is not None + ) + owner_token = (f"owner-{owner.id}-{owner_draw.id}" + if owner_draw is not None else "") + seats = [{"n": 1, "label": "1C", "present": owner_seated, "token": owner_token}] + present = ( + SeaInvite.objects + .filter(owner=owner, status=SeaInvite.ACCEPTED, + token_deposited_at__isnull=False, left_at__isnull=True) + .order_by("token_deposited_at")[:MY_SEA_MAX_VISITORS] + ) + for idx, inv in enumerate(present): + seats.append({"n": idx + 2, "label": f"{idx + 2}C", "present": True, + "token": f"visit-{inv.id}", + "invitee_id": str(inv.invitee_id)}) + while len(seats) < 6: + n = len(seats) + 1 + seats.append({"n": n, "label": f"{n}C", "present": False, "token": ""}) + return seats + + +def _notify_sea_seats(owner_id): + """Best-effort push of the seat ring to watching invitees when presence + changes (a deposit takes a seat / a BYE frees one) so the hex updates live. + Guarded, same as `_notify_sea_draw`.""" + try: + from asgiref.sync import async_to_sync + from channels.layers import get_channel_layer + from apps.lyric.models import User + layer = get_channel_layer() + if layer is None: + return + owner = User.objects.filter(id=owner_id).first() + if owner is None: + return + async_to_sync(layer.group_send)( + f"mysea_{owner_id}", + {"type": "sea_seats", "seats": _my_sea_seats(owner)}, + ) + except Exception: + pass + + @login_required(login_url="/") @require_POST def my_sea_lock(request): @@ -918,27 +972,12 @@ def my_sea_visit(request, owner_id): ) owner_seated = owner_hand_non_empty or owner_paid # Multi-seat hex (2026-05-29): every present member shows on the ring — - # owner in 1C, then each present invitee in 2C–6C by deposit order (the - # same seats everyone sees). `seats` drives the table-seat loop; an empty - # seat renders the .fa-ban default. Capped at MY_SEA_MAX_VISITORS visitors. - owner_token = (f"owner-{owner.id}-{owner_draw.id}" - if owner_draw is not None else "") - seats = [{"n": 1, "label": "1C", "present": owner_seated, "token": owner_token}] - present_invitees = ( - SeaInvite.objects - .filter(owner=owner, status=SeaInvite.ACCEPTED, - token_deposited_at__isnull=False, left_at__isnull=True) - .order_by("token_deposited_at")[:MY_SEA_MAX_VISITORS] - ) - for idx, inv in enumerate(present_invitees): - seats.append({ - "n": idx + 2, "label": f"{idx + 2}C", "present": True, - "token": f"visit-{inv.id}", - "is_self": inv.invitee_id == request.user.id, - }) - while len(seats) < 6: - n = len(seats) + 1 - seats.append({"n": n, "label": f"{n}C", "present": False, "token": ""}) + # owner 1C + present invitees 2C-6C by deposit order (the same seats + # everyone sees), built by the shared `_my_sea_seats` helper that the live + # `sea_seats` broadcast also uses. Layer the per-viewer `is_self` on here. + seats = _my_sea_seats(owner) + for seat in seats: + seat["is_self"] = seat.get("invitee_id") == str(request.user.id) # Read-only spectator render parity (Phase 1, 2026-05-29): the visitor's # VIEW DRAW renders the SAME `.my-sea-cross` picker + `_sea_stage` the # owner sees, populated from the owner's draw. `saved_by_position` fills @@ -1041,6 +1080,7 @@ def my_sea_visit_insert_token(request, owner_id): invite.token_deposited_at = now invite.voice_until = now + timedelta(hours=24) invite.save(update_fields=["token_deposited_at", "voice_until"]) + _notify_sea_seats(owner.id) # live: this visitor takes a seat return redirect("my_sea_visit", owner_id=owner.id) @@ -1063,6 +1103,7 @@ def my_sea_visit_leave(request, owner_id): invite.left_at = timezone.now() invite.voice_until = None invite.save(update_fields=["status", "left_at", "voice_until"]) + _notify_sea_seats(owner.id) # live: this visitor frees their seat return redirect("gameboard") diff --git a/src/templates/apps/gameboard/my_sea_visit.html b/src/templates/apps/gameboard/my_sea_visit.html index 52ca954..de2ce24 100644 --- a/src/templates/apps/gameboard/my_sea_visit.html +++ b/src/templates/apps/gameboard/my_sea_visit.html @@ -143,6 +143,39 @@ try { deck = JSON.parse((deckEl && deckEl.textContent) || '{}'); } catch (e) {} var byId = {}; (deck.levity || []).concat(deck.gravity || []).forEach(function (c) { byId[c.id] = c; }); + // This viewer's own seat token — so a live seat re-render can re-mark + // their --self chair without the server knowing who's watching. + var MY_SEAT_TOKEN = 'visit-{{ sea_invite.id }}'; + + // Re-render the table-seat ring when presence changes (a deposit takes + // a seat / a BYE frees one), so other watchers see members come + go + // without a refresh. Mirrors the server seat loop in markup. + function _renderSeats(seats) { + 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 = seat.token && seat.token === MY_SEAT_TOKEN; + 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). + if (window.playSeatGlow) { + scene.querySelectorAll('.table-seat.seated[data-seat-token]') + .forEach(window.playSeatGlow); + } + } + window._mySeaRenderSeats = _renderSeats; // test seam function _applyHand(hand) { if (!window.SeaDeal || !window.SeaDeal.register) return; @@ -192,7 +225,9 @@ ws.onmessage = function (ev) { var msg; try { msg = JSON.parse(ev.data); } catch (e) { return; } - if (msg && msg.type === 'sea_draw') _applyHand(msg.hand); + if (!msg) return; + if (msg.type === 'sea_draw') _applyHand(msg.hand); + else if (msg.type === 'sea_seats') _renderSeats(msg.seats); }; // Brief capped reconnect for transient blips (no infinite loop if // the spectate server/Redis is down).