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:
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user