my-sea spectate: live spread-sync + owner seat-ring push + visit caption fix — TDD

Three fixes to the my-sea spectator (bud-sea), all flowing over the existing
`mysea_<owner>` spectate consumer:

VISIT CAPTIONS (.sea-pos-label) — two bugs left every CROWN/COVER/… caption
blank on my_sea_visit:
- empty-hand: `label_by_position` was built from `latest_draw_slots`, which
  returns [] when the owner's hand is empty (only a significator placed) — so
  an owner mid-setup showed no captions, 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], drawn-cards-independent.
- `--seciUser` typo (used once, never defined) → invalid colour dropped → the
  labels inherited the body colour, contrasting on some palettes but blending
  into the felt on others (read as "missing"). → `--secUser`.

SPREAD-SYNC — the owner's live draw pushed only the hand, not the spread, so a
post-DEL spread switch landed the new cards into the OLD spread's cells (the
asymmetry the user hit: owner on desire-obstacle-solution, visitor still laid
out as escape-velocity). The spread now rides each `sea_draw` broadcast;
`_applySpread` re-sets `data-spread` (CSS keys cell visibility off it),
re-captions from a server-sourced POSITION_LABELS json_script, + clears stale
fills before `_applyHand` repopulates against the right layout.

OWNER-SIDE LIVE SEAT PUSH — the owner's my_sea now subscribes to her own
spectate WS for `sea_seats`, so visitors arriving (deposit → 2C-6C) / leaving
(BYE) appear without a refresh, same broadcast the spectators get. The visit
page's inline `_renderSeats` is hoisted into my-sea-seats.js as the shared
`mySeaRenderSeats(seats, myToken)` (+ `mySeaConnectSeatRing`); each page passes
its own self-token (owner page passes '' — her 1C isn't --self server-side).

Coverage:
- ITs: MySeaVisitEmptyHandLabelsTest (captions present + rendered for an empty
  hand); MySeaLockHandViewTest broadcast test asserts the spread arg; spectate
  consumer test asserts the hand+spread relay (channels).
- Jasmine: 6 new MySeaSeatsSpec cases for mySeaRenderSeats (per-seat rebuild,
  --self by token, owner-page no-self, no-duplicate re-render, one-shot flare).
- Live-verified in Firefox: captions paint khaki on the brown palette; a
  desire-obstacle-solution sync flips data-spread + relabels Solution/Obstacle/
  Desire + hides leave/cover/lay.

[[feedback-jsonfield-exclude-sqlite-null]] not implicated; spread map is a plain
dict lookup. 304 gameboard ITs + Jasmine green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-30 00:35:18 -04:00
parent 877e0f544a
commit 9678d187b4
11 changed files with 339 additions and 48 deletions

View File

@@ -34,8 +34,14 @@ class MySeaSpectateConsumer(AsyncJsonWebsocketConsumer):
# ── room-group fan-out handlers ───────────────────────────────────── # ── 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 (+ current spread) to the watching client. The
await self.send_json({"type": "sea_draw", "hand": event.get("hand", [])}) 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): async def sea_seats(self, event):
"""Relay the seat ring (a deposit took / a BYE freed a seat).""" """Relay the seat ring (a deposit took / a BYE freed a seat)."""

View File

@@ -47,6 +47,66 @@
// seat 1C transitions to `.seated` client-side (my_sea.html). // seat 1C transitions to `.seated` client-side (my_sea.html).
window.playSeatGlow = playSeatGlow; 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-<id>-<draw>` token, a visitor's `visit-<inv>`).
// 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 =
'<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 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_<ownerId>`; 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 () { document.addEventListener('DOMContentLoaded', function () {
var seats = document.querySelectorAll('.table-seat.seated[data-seat-token]'); var seats = document.querySelectorAll('.table-seat.seated[data-seat-token]');
Array.prototype.forEach.call(seats, function (seat) { Array.prototype.forEach.call(seats, function (seat) {

View File

@@ -216,6 +216,47 @@ class MySeaVisitContextTest(TestCase):
html.count('class="position-status-icon fa-solid fa-circle-check"'), 2) 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}</span>', html)
class MySeaVisitOwnerSeatedTest(TestCase): class MySeaVisitOwnerSeatedTest(TestCase):
"""Phase 2 — the owner shows seated in 1C on the spectator hex whenever """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 she's committed to a draw cycle (drawn OR paid), not only once a card

View File

@@ -66,7 +66,7 @@ class MySeaSpectateConsumerTest(TransactionTestCase):
connected, _ = await comm.connect() connected, _ = await comm.connect()
self.assertFalse(connected) 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) await database_sync_to_async(self._invite)(present=True)
comm = self._comm(self.bud) comm = self._comm(self.bud)
connected, _ = await comm.connect() connected, _ = await comm.connect()
@@ -74,10 +74,16 @@ class MySeaSpectateConsumerTest(TransactionTestCase):
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
hand = [{"position": "lay", "card_id": 1, hand = [{"position": "lay", "card_id": 1,
"reversed": False, "polarity": "gravity"}] "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( 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() 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() await comm.disconnect()
async def test_present_invitee_receives_seat_updates(self): async def test_present_invitee_receives_seat_updates(self):

View File

@@ -1861,8 +1861,9 @@ class MySeaLockHandViewTest(TestCase):
self.assertEqual(response.status_code, 405) self.assertEqual(response.status_code, 405)
def test_lock_broadcasts_hand_to_spectators(self): def test_lock_broadcasts_hand_to_spectators(self):
# The owner's draw pushes the full hand to watching invitees (async # The owner's draw pushes the full hand + current spread to watching
# witness, 2026-05-29) — my_sea_lock calls _notify_sea_draw(owner, hand). # invitees (async witness, 2026-05-29; spread added 2026-05-30) —
# my_sea_lock calls _notify_sea_draw(owner, hand, spread).
import json import json
from unittest.mock import patch from unittest.mock import patch
payload = self._build_payload() payload = self._build_payload()
@@ -1870,9 +1871,11 @@ class MySeaLockHandViewTest(TestCase):
self.client.post(self.url, data=json.dumps(payload), self.client.post(self.url, data=json.dumps(payload),
content_type="application/json") content_type="application/json")
mock_notify.assert_called_once() 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(owner_id, self.user.id)
self.assertEqual(hand, payload["hand"]) 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): def test_lock_succeeds_even_when_the_broadcast_fails(self):
# The broadcast is best-effort — a down/missing channel layer must NOT # The broadcast is best-effort — a down/missing channel layer must NOT

View File

@@ -8,8 +8,8 @@ from django.views.decorators.http import require_POST
from apps.applets.utils import applet_context, apply_applet_toggle from apps.applets.utils import applet_context, apply_applet_toggle
from .models import ( from .models import (
HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for, latest_draw_slots, HAND_SIZE_BY_SPREAD, POSITION_LABELS, MySeaDraw, active_draw_for,
_select_my_sea_token, debit_my_sea_token, 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 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 """Best-effort push of the owner's current hand to watching invitees (the
my-sea spectate WS, `mysea_<owner_id>`) so they witness each card land my-sea spectate WS, `mysea_<owner_id>`) so they witness each card land
without refreshing. Guarded — a missing/down channel layer must never break without refreshing. The `spread` rides along so a spectator who loaded under
the solo draw, since the spectate is an enhancement, not a hard dependency.""" 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: try:
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
@@ -466,7 +470,7 @@ def _notify_sea_draw(owner_id, hand):
return return
async_to_sync(layer.group_send)( async_to_sync(layer.group_send)(
f"mysea_{owner_id}", f"mysea_{owner_id}",
{"type": "sea_draw", "hand": hand}, {"type": "sea_draw", "hand": hand, "spread": spread},
) )
except Exception: except Exception:
pass pass
@@ -588,7 +592,8 @@ def my_sea_lock(request):
existing.paid_through_at = None existing.paid_through_at = None
update_fields.append("paid_through_at") update_fields.append("paid_through_at")
existing.save(update_fields=update_fields) 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({ return JsonResponse({
"ok": True, "ok": True,
"next_free_draw_at": ( "next_free_draw_at": (
@@ -626,7 +631,7 @@ def my_sea_lock(request):
significator_id=sig_id, significator_id=sig_id,
significator_reversed=request.user.significator_reversed, 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 # Append the @taxman ledger entry + spawn the Brief. Response carries
# the Brief payload so the picker IIFE can surface the banner in-place # the Brief payload so the picker IIFE can surface the banner in-place
# w.o. a page reload — same affordance the prior in-template # 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: if draw is not None:
draw.hand = [] draw.hand = []
draw.save(update_fields=["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) 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 # `sea_deck_data` is the OWNER's deck so sea.js can resolve each clicked
# slot's full card face for the magnified stage. # slot's full card face for the magnified stage.
owner_slots = latest_draw_slots(owner) 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", { return render(request, "apps/gameboard/my_sea_visit.html", {
"spectator": True, "spectator": True,
"is_owner": False, "is_owner": False,
@@ -1005,11 +1013,19 @@ def my_sea_visit(request, owner_id):
"my_sea_slots": owner_slots, "my_sea_slots": owner_slots,
"owner_hand_non_empty": owner_hand_non_empty, "owner_hand_non_empty": owner_hand_non_empty,
# Read-only cross-stage parity payload. # Read-only cross-stage parity payload.
"default_spread": (owner_draw.spread if owner_draw is not None "default_spread": owner_spread,
else "situation-action-outcome"),
"saved_by_position": _saved_by_position( "saved_by_position": _saved_by_position(
owner_draw.hand if owner_draw is not None else []), 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": ( "sea_deck_data": (
_my_sea_deck_data(owner, exclude_id=sig_card.id if sig_card else None) _my_sea_deck_data(owner, exclude_id=sig_card.id if sig_card else None)
if sig_card is not None else {"levity": [], "gravity": []} if sig_card is not None else {"levity": [], "gravity": []}

View File

@@ -55,3 +55,72 @@ describe('my-sea-seats one-shot seated glow', function () {
jasmine.clock().uninstall(); 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);
});
});

View File

@@ -432,7 +432,7 @@ body.page-gameboard {
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
opacity: 1; opacity: 1;
color: rgba(var(--seciUser), 1); color: rgba(var(--secUser), 1);
text-shadow: 0 0 0.25rem rgba(var(--priUser), 1); text-shadow: 0 0 0.25rem rgba(var(--priUser), 1);
text-align: center; text-align: center;
pointer-events: none; pointer-events: none;

View File

@@ -55,3 +55,72 @@ describe('my-sea-seats one-shot seated glow', function () {
jasmine.clock().uninstall(); 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);
});
});

View File

@@ -947,6 +947,18 @@
{# the spectator page. Exposes window.playSeatGlow + auto-plays on #} {# the spectator page. Exposes window.playSeatGlow + auto-plays on #}
{# load for any server-rendered .seated[data-seat-token] seat. #} {# load for any server-rendered .seated[data-seat-token] seat. #}
<script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script> <script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script>
{# Live seat ring — subscribe the OWNER's page to her own spectate WS #}
{# (`mysea_<id>`) 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. #}
<script>
(function () {
if (window.mySeaConnectSeatRing) {
window.mySeaConnectSeatRing('{{ request.user.id }}', '');
}
}());
</script>
<script> <script>
(function () { (function () {
var page = document.querySelector('.my-sea-page'); var page = document.querySelector('.my-sea-page');

View File

@@ -68,6 +68,7 @@
{# SPIN, FYI) off the owner's draw payload — see _my_sea_visit_cross.html. #} {# SPIN, FYI) off the owner's draw payload — see _my_sea_visit_cross.html. #}
<div id="id_my_sea_visit_draw" class="my-sea-visit-draw" style="display:none"> <div id="id_my_sea_visit_draw" class="my-sea-visit-draw" style="display:none">
{{ sea_deck_data|json_script:"id_my_sea_deck" }} {{ sea_deck_data|json_script:"id_my_sea_deck" }}
{{ position_labels|json_script:"id_my_sea_position_labels" }}
{% include "apps/gameboard/_partials/_my_sea_visit_cross.html" %} {% include "apps/gameboard/_partials/_my_sea_visit_cross.html" %}
</div> </div>
{% endif %} {% endif %}
@@ -147,37 +148,45 @@
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; });
// Per-spread caption map (server-sourced from POSITION_LABELS) — used to
// re-caption the cross live when the owner switches spreads.
var POSITION_LABELS = {};
var plEl = document.getElementById('id_my_sea_position_labels');
try { POSITION_LABELS = JSON.parse((plEl && plEl.textContent) || '{}'); } catch (e) {}
// This viewer's own seat token — so a live seat re-render can re-mark // 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. // their --self chair without the server knowing who's watching.
var MY_SEAT_TOKEN = 'visit-{{ sea_invite.id }}'; var MY_SEAT_TOKEN = 'visit-{{ sea_invite.id }}';
// Re-render the table-seat ring when presence changes (a deposit takes // Re-sync the cross to the owner's CURRENT spread (carried on each
// a seat / a BYE frees one), so other watchers see members come + go // `sea_draw`). A post-DEL spread switch changes both which cells are
// without a refresh. Mirrors the server seat loop in markup. // active (CSS keys off `[data-spread]`) AND their captions — without
function _renderSeats(seats) { // this, the owner's new cards land into the OLD spread's cells, the
var scene = document.querySelector('.room-table-scene'); // asymmetry the user reported 2026-05-30. Clears any stale fill so the
if (!scene || !seats) return; // subsequent _applyHand repopulates against the right layout.
scene.querySelectorAll('.table-seat').forEach(function (s) { s.remove(); }); function _applySpread(spread) {
seats.forEach(function (seat) { var cross = document.querySelector('.my-sea-cross');
var isSelf = seat.token && seat.token === MY_SEAT_TOKEN; if (!cross || !spread || cross.getAttribute('data-spread') === spread) return;
var div = document.createElement('div'); cross.setAttribute('data-spread', spread);
div.className = 'table-seat' + (seat.present ? ' seated' : '') var labels = POSITION_LABELS[spread] || {};
+ (isSelf ? ' table-seat--self' : ''); cross.querySelectorAll('.sea-pos-label').forEach(function (el) {
div.setAttribute('data-slot', seat.n); el.textContent = labels[el.dataset.position] || '';
if (seat.present && seat.token) div.setAttribute('data-seat-token', seat.token); });
div.innerHTML = // Wipe filled slots — the active position set just changed.
'<i class="fa-solid fa-chair"></i>' + cross.querySelectorAll('.sea-card-slot.sea-card-slot--filled').forEach(function (slot) {
'<span class="seat-position-label">' + seat.label + '</span>' + var crossing = slot.classList.contains('sea-card-slot--crossing');
'<i class="position-status-icon fa-solid ' + slot.className = 'sea-card-slot sea-card-slot--empty' + (crossing ? ' sea-card-slot--crossing' : '');
(seat.present ? 'fa-circle-check' : 'fa-ban') + '"></i>'; slot.innerHTML = '';
scene.appendChild(div); slot.removeAttribute('data-card-id');
slot.removeAttribute('data-pos-key');
}); });
// 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._mySeaApplySpread = _applySpread; // test seam
// Seat-ring re-render is shared with the owner's my_sea (DRY) — see
// `mySeaRenderSeats` in my-sea-seats.js. Pass this viewer's own token so
// their deposited chair re-marks `--self` on a live re-render.
function _renderSeats(seats) {
if (window.mySeaRenderSeats) window.mySeaRenderSeats(seats, MY_SEAT_TOKEN);
} }
window._mySeaRenderSeats = _renderSeats; // test seam window._mySeaRenderSeats = _renderSeats; // test seam
@@ -230,7 +239,7 @@
var msg; var msg;
try { msg = JSON.parse(ev.data); } catch (e) { return; } try { msg = JSON.parse(ev.data); } catch (e) { return; }
if (!msg) return; if (!msg) return;
if (msg.type === 'sea_draw') _applyHand(msg.hand); if (msg.type === 'sea_draw') { _applySpread(msg.spread); _applyHand(msg.hand); }
else if (msg.type === 'sea_seats') _renderSeats(msg.seats); 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