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:
@@ -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)."""
|
||||
|
||||
@@ -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-<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 () {
|
||||
var seats = document.querySelectorAll('.table-seat.seated[data-seat-token]');
|
||||
Array.prototype.forEach.call(seats, function (seat) {
|
||||
|
||||
@@ -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}</span>', 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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_<owner_id>`) 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": []}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -947,6 +947,18 @@
|
||||
{# the spectator page. Exposes window.playSeatGlow + auto-plays on #}
|
||||
{# load for any server-rendered .seated[data-seat-token] seat. #}
|
||||
<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>
|
||||
(function () {
|
||||
var page = document.querySelector('.my-sea-page');
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
{# 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">
|
||||
{{ 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" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -147,37 +148,45 @@
|
||||
try { deck = JSON.parse((deckEl && deckEl.textContent) || '{}'); } catch (e) {}
|
||||
var byId = {};
|
||||
(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
|
||||
// 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-sync the cross to the owner's CURRENT spread (carried on each
|
||||
// `sea_draw`). A post-DEL spread switch changes both which cells are
|
||||
// active (CSS keys off `[data-spread]`) AND their captions — without
|
||||
// this, the owner's new cards land into the OLD spread's cells, the
|
||||
// asymmetry the user reported 2026-05-30. Clears any stale fill so the
|
||||
// subsequent _applyHand repopulates against the right layout.
|
||||
function _applySpread(spread) {
|
||||
var cross = document.querySelector('.my-sea-cross');
|
||||
if (!cross || !spread || cross.getAttribute('data-spread') === spread) return;
|
||||
cross.setAttribute('data-spread', spread);
|
||||
var labels = POSITION_LABELS[spread] || {};
|
||||
cross.querySelectorAll('.sea-pos-label').forEach(function (el) {
|
||||
el.textContent = labels[el.dataset.position] || '';
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
// Wipe filled slots — the active position set just changed.
|
||||
cross.querySelectorAll('.sea-card-slot.sea-card-slot--filled').forEach(function (slot) {
|
||||
var crossing = slot.classList.contains('sea-card-slot--crossing');
|
||||
slot.className = 'sea-card-slot sea-card-slot--empty' + (crossing ? ' sea-card-slot--crossing' : '');
|
||||
slot.innerHTML = '';
|
||||
slot.removeAttribute('data-card-id');
|
||||
slot.removeAttribute('data-pos-key');
|
||||
});
|
||||
}
|
||||
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
|
||||
|
||||
@@ -230,7 +239,7 @@
|
||||
var msg;
|
||||
try { msg = JSON.parse(ev.data); } catch (e) { 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);
|
||||
};
|
||||
// Brief capped reconnect for transient blips (no infinite loop if
|
||||
|
||||
Reference in New Issue
Block a user