my-sea spectate: broadcast spread on modal-close + sequence the spectator's AUTO DRAW reveal — TDD

Two follow-ons to the spectate spread-sync, both over the `mysea_<owner>` consumer:

SPREAD ON MODAL-CLOSE — the spread only reached spectators piggy-backed on the
first `sea_draw`, so a visitor sat on a stale layout until a card landed. The
owner's SPREAD modal now broadcasts her chosen spread the moment she closes it
(backdrop / Escape / guard-OK) — before any draw:
- new hand-less `sea_spread` event: view `_notify_sea_spread` → consumer relay →
  visitor `_applySpread` (re-lays-out `[data-spread]` + re-captions, no hand
  touched).
- new POST `/gameboard/my-sea/spread` the modal-close handler calls, guarded by
  `_lastSpread` so re-opening + closing without a change doesn't re-broadcast.
  When an active row has an EMPTY hand it also persists the spread onto the row
  (so a fresh spectator load lands right too) — stays within the "spread locks
  at first card" policy; never overwrites a drawn hand's spread.

SEQUENCED AUTO DRAW — AUTO DRAW commits all six cards in ONE POST (navigate-away
safety) → one `sea_draw` carrying the whole hand, so the spectator saw them pop
in at once ("async as intended, but not in sequence"). The visitor's `_applyHand`
now reveals only the freshly-added entries, one per ~420ms tick (in DRAW_ORDER,
first immediately) — a lone manual-draw card still reveals instantly. Already-
shown cards (`_isShown` by slot card-id) are left untouched, so a cumulative
re-broadcast never re-animates.

Coverage:
- ITs: MySeaSpreadBroadcastViewTest — login/405/unknown-spread guards, broadcast
  call, empty-hand persist, no-overwrite-of-drawn-spread, broadcast-failure
  resilience.
- channels: spectate consumer relays the hand-less `sea_spread` event.
- Live-verified in Firefox: a 3-card hand fills 1 slot synchronously then the
  rest after the stagger; user visually confirmed the full deal sequence + the
  modal-close spread propagation.

311 gameboard ITs + 7 spectate channels 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:45:00 -04:00
parent 9678d187b4
commit 7e876557aa
7 changed files with 213 additions and 20 deletions

View File

@@ -1126,8 +1126,34 @@
function openModal() {
modal.removeAttribute('hidden');
}
// On close, push the owner's CURRENT spread to watching spectators
// so their cross re-lays-out immediately — not only once the first
// card lands (user-spec 2026-05-30). Fires on backdrop / Escape /
// guard-OK close. Guarded by `_lastSpread` so re-opening + closing
// without a change doesn't re-broadcast. The select's change handler
// already mirrors the choice onto `.my-sea-cross[data-spread]`, so
// that's the live source of truth at close time.
var crossEl = document.querySelector('.my-sea-cross');
var _lastSpread = crossEl ? crossEl.getAttribute('data-spread') : null;
function _broadcastSpread() {
if (!crossEl) return;
var spread = crossEl.getAttribute('data-spread');
if (!spread || spread === _lastSpread) return;
_lastSpread = spread;
var m = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
fetch('{% url "my_sea_spread" %}', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': m ? decodeURIComponent(m[1]) : '',
},
body: JSON.stringify({ spread: spread }),
}).catch(function () { /* best-effort — spectate is an enhancement */ });
}
function closeModal() {
modal.setAttribute('hidden', '');
_broadcastSpread();
}
seaBtn.addEventListener('click', function () {

View File

@@ -190,31 +190,52 @@
}
window._mySeaRenderSeats = _renderSeats; // test seam
// Reveal a single hand entry on the cross (register + force --visible).
function _revealCard(cross, e) {
var card = byId[e.card_id];
if (!card) return;
var c = {};
for (var k in card) { if (Object.prototype.hasOwnProperty.call(card, k)) c[k] = card[k]; }
c.reversed = !!e.reversed;
window.SeaDeal.register(c, '.sea-pos-' + e.position, e.polarity === 'levity');
// register's _fillSlot sets --filled (opacity:0) but not --visible
// (the owner adds that on stage-dismiss; the spectator has no modal).
// Add it here so the card lands at its final opacity — matching the
// refreshed state — instead of racing the empty→filled transition
// (the long-standing my_sea ease-in/ease-out glitch).
var cell = cross.querySelector('.sea-pos-' + e.position);
var filled = cell && cell.querySelector('.sea-card-slot--filled');
if (filled) filled.classList.add('sea-card-slot--visible');
}
// True when this exact card is already shown in its slot — so a
// cumulative re-broadcast doesn't re-animate cards already on the cross.
function _isShown(cross, e) {
var cell = cross.querySelector('.sea-pos-' + e.position);
var slot = cell && cell.querySelector('.sea-card-slot--filled');
return !!(slot && String(slot.dataset.cardId) === String(e.card_id));
}
// Visitor witnesses cards land ONE AT A TIME. AUTO DRAW commits all six
// in a single POST (navigate-away safety) → one `sea_draw` carrying the
// whole hand; revealing them all in that tick made them pop in at once
// (user-reported 2026-05-30: "async as intended, but not in sequence").
// So we stagger the freshly-added entries — already-shown cards stay put
// (a manual single-card add reveals immediately, fresh.length === 1).
var REVEAL_STAGGER_MS = 420;
var _revealTimer = null;
function _applyHand(hand) {
if (!window.SeaDeal || !window.SeaDeal.register) return;
var cross = document.querySelector('.my-sea-cross');
if (!cross) return;
if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; }
var inHand = {};
var fresh = [];
(hand || []).forEach(function (e) {
if (!e || !e.position) return;
inHand[e.position] = true;
var card = byId[e.card_id];
if (!card) return;
var c = {};
for (var k in card) { if (Object.prototype.hasOwnProperty.call(card, k)) c[k] = card[k]; }
c.reversed = !!e.reversed;
window.SeaDeal.register(c, '.sea-pos-' + e.position, e.polarity === 'levity');
// register's _fillSlot sets --filled (opacity:0) but not
// --visible (the owner adds that on stage-dismiss; the
// spectator has no modal). Add it here in the SAME tick so the
// card lands at its final opacity — matching the refreshed
// state — instead of racing the empty→filled opacity transition
// (the long-standing my_sea ease-in/ease-out glitch).
var cell = cross.querySelector('.sea-pos-' + e.position);
var filled = cell && cell.querySelector('.sea-card-slot--filled');
if (filled) filled.classList.add('sea-card-slot--visible');
if (!_isShown(cross, e)) fresh.push(e); // queue only new cards
});
// Re-empty any slot the owner cleared (DEL → empty-hand broadcast).
// Re-empty any slot the owner cleared (DEL / spread switch).
cross.querySelectorAll('.sea-card-slot.sea-card-slot--filled').forEach(function (slot) {
var pos = slot.dataset.posKey;
if (pos && !inHand[pos]) {
@@ -225,6 +246,15 @@
slot.removeAttribute('data-pos-key');
}
});
// Reveal fresh entries in draw order (the server stores the hand in
// DRAW_ORDER), first immediately then one per stagger tick.
var idx = 0;
function next() {
if (idx >= fresh.length) { _revealTimer = null; return; }
_revealCard(cross, fresh[idx++]);
_revealTimer = (idx < fresh.length) ? setTimeout(next, REVEAL_STAGGER_MS) : null;
}
next();
}
window._mySeaApplyHand = _applyHand; // test seam
@@ -240,6 +270,7 @@
try { msg = JSON.parse(ev.data); } catch (e) { return; }
if (!msg) return;
if (msg.type === 'sea_draw') { _applySpread(msg.spread); _applyHand(msg.hand); }
else if (msg.type === 'sea_spread') _applySpread(msg.spread);
else if (msg.type === 'sea_seats') _renderSeats(msg.seats);
};
// Brief capped reconnect for transient blips (no infinite loop if