my-sea: second spectate broadcast — seat ring updates live on deposit / BYE — TDD
Extends the async-witness WS so a visitor joining (deposit) or leaving (BYE) pushes the seat ring to the other watchers — they see members come + go without a refresh, same channel as the live draw. - views.py: `_my_sea_seats(owner)` extracted (owner 1C + present invitees 2C-6C by deposit order, sans per-viewer is_self) — used by BOTH the my_sea_visit render (layers is_self on) AND a new guarded `_notify_sea_seats(owner_id)` broadcast. Fired from my_sea_visit_insert_token (seat taken) + my_sea_visit_leave (seat freed). - consumers.py: MySeaSpectateConsumer gains a `sea_seats` handler. - my_sea_visit.html: the WS client re-renders the `.table-seat` ring from a `sea_seats` message, re-marking the viewer's own --self chair via the embedded seat token + re-firing the one-shot seated glow (localStorage-gated). Tests: +1 channels relay IT (sea_seats received) + 2 view ITs (deposit / BYE each broadcast the ring). Existing multi-seat ITs stay green on the refactored helper. Client re-render needs live 3-party verification on staging. 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:
@@ -32,11 +32,15 @@ class MySeaSpectateConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
if self.group:
|
if self.group:
|
||||||
await self.channel_layer.group_discard(self.group, self.channel_name)
|
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):
|
async def sea_draw(self, event):
|
||||||
"""Relay the owner's hand to the watching client."""
|
"""Relay the owner's hand to the watching client."""
|
||||||
await self.send_json({"type": "sea_draw", "hand": event.get("hand", [])})
|
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 ─────────────────────────────────────────────────
|
# ── membership gate ─────────────────────────────────────────────────
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def _can_watch(self):
|
def _can_watch(self):
|
||||||
|
|||||||
@@ -283,6 +283,13 @@ class MySeaVisitInsertTokenTest(TestCase):
|
|||||||
self.assertIsNone(self.invite.token_deposited_at)
|
self.assertIsNone(self.invite.token_deposited_at)
|
||||||
self.assertFalse(self.invite.is_present)
|
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):
|
class MySeaVisitLeaveTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -308,6 +315,13 @@ class MySeaVisitLeaveTest(TestCase):
|
|||||||
resp, reverse("gameboard"), fetch_redirect_response=False,
|
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):
|
def test_non_invitee_cannot_leave(self):
|
||||||
stranger = User.objects.create(email="x@test.io", username="x")
|
stranger = User.objects.create(email="x@test.io", username="x")
|
||||||
self.client.force_login(stranger)
|
self.client.force_login(stranger)
|
||||||
|
|||||||
@@ -79,3 +79,16 @@ class MySeaSpectateConsumerTest(TransactionTestCase):
|
|||||||
msg = await comm.receive_json_from()
|
msg = await comm.receive_json_from()
|
||||||
self.assertEqual(msg, {"type": "sea_draw", "hand": hand})
|
self.assertEqual(msg, {"type": "sea_draw", "hand": hand})
|
||||||
await comm.disconnect()
|
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()
|
||||||
|
|||||||
@@ -469,6 +469,60 @@ def _notify_sea_draw(owner_id, hand):
|
|||||||
pass
|
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="/")
|
@login_required(login_url="/")
|
||||||
@require_POST
|
@require_POST
|
||||||
def my_sea_lock(request):
|
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
|
owner_seated = owner_hand_non_empty or owner_paid
|
||||||
# Multi-seat hex (2026-05-29): every present member shows on the ring —
|
# 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
|
# owner 1C + present invitees 2C-6C by deposit order (the same seats
|
||||||
# same seats everyone sees). `seats` drives the table-seat loop; an empty
|
# everyone sees), built by the shared `_my_sea_seats` helper that the live
|
||||||
# seat renders the .fa-ban default. Capped at MY_SEA_MAX_VISITORS visitors.
|
# `sea_seats` broadcast also uses. Layer the per-viewer `is_self` on here.
|
||||||
owner_token = (f"owner-{owner.id}-{owner_draw.id}"
|
seats = _my_sea_seats(owner)
|
||||||
if owner_draw is not None else "")
|
for seat in seats:
|
||||||
seats = [{"n": 1, "label": "1C", "present": owner_seated, "token": owner_token}]
|
seat["is_self"] = seat.get("invitee_id") == str(request.user.id)
|
||||||
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": ""})
|
|
||||||
# Read-only spectator render parity (Phase 1, 2026-05-29): the visitor's
|
# 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
|
# VIEW DRAW renders the SAME `.my-sea-cross` picker + `_sea_stage` the
|
||||||
# owner sees, populated from the owner's draw. `saved_by_position` fills
|
# 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.token_deposited_at = now
|
||||||
invite.voice_until = now + timedelta(hours=24)
|
invite.voice_until = now + timedelta(hours=24)
|
||||||
invite.save(update_fields=["token_deposited_at", "voice_until"])
|
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)
|
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.left_at = timezone.now()
|
||||||
invite.voice_until = None
|
invite.voice_until = None
|
||||||
invite.save(update_fields=["status", "left_at", "voice_until"])
|
invite.save(update_fields=["status", "left_at", "voice_until"])
|
||||||
|
_notify_sea_seats(owner.id) # live: this visitor frees their seat
|
||||||
return redirect("gameboard")
|
return redirect("gameboard")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,39 @@
|
|||||||
try { deck = JSON.parse((deckEl && deckEl.textContent) || '{}'); } catch (e) {}
|
try { deck = JSON.parse((deckEl && deckEl.textContent) || '{}'); } catch (e) {}
|
||||||
var byId = {};
|
var byId = {};
|
||||||
(deck.levity || []).concat(deck.gravity || []).forEach(function (c) { byId[c.id] = c; });
|
(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 =
|
||||||
|
'<i class="fa-solid fa-chair"></i>' +
|
||||||
|
'<span class="seat-position-label">' + seat.label + '</span>' +
|
||||||
|
'<i class="position-status-icon fa-solid ' +
|
||||||
|
(seat.present ? 'fa-circle-check' : 'fa-ban') + '"></i>';
|
||||||
|
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) {
|
function _applyHand(hand) {
|
||||||
if (!window.SeaDeal || !window.SeaDeal.register) return;
|
if (!window.SeaDeal || !window.SeaDeal.register) return;
|
||||||
@@ -192,7 +225,9 @@
|
|||||||
ws.onmessage = function (ev) {
|
ws.onmessage = function (ev) {
|
||||||
var msg;
|
var msg;
|
||||||
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
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
|
// Brief capped reconnect for transient blips (no infinite loop if
|
||||||
// the spectate server/Redis is down).
|
// the spectate server/Redis is down).
|
||||||
|
|||||||
Reference in New Issue
Block a user