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 ─────────────────────────────────────
|
# ── 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)."""
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": []}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user