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

@@ -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');

View File

@@ -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