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).