diff --git a/static/js/animations.js b/static/js/animations.js index a4165db..fa6e3ae 100644 --- a/static/js/animations.js +++ b/static/js/animations.js @@ -77,7 +77,14 @@ export function updateDiceInPlace(snap, onComplete, winForMe = false) { const player = state.myId ? snap.players[state.myId] : undefined; const wrappers = /** @type {HTMLElement[]} */ ([...document.querySelectorAll('.zone-unmatched .die-wrapper')]); - if (!player || wrappers.length === 0) { + // If the board on screen was built for a different round, we fell behind the + // server — a round-advance broadcast was lost (flaky link) — and are only now + // catching up through this roll response. Animating in place would paint the + // new round's dice onto the stale board: old locks kept, new target stacking + // on top (the dropped-broadcast frankenboard). Hard-rebuild to the round the + // snapshot actually describes. + const staleBoard = state.boardRound != null && snap.round_num !== state.boardRound; + if (!player || wrappers.length === 0 || staleBoard) { renderMyArea(snap); renderPlayersBar(snap); if (onComplete) onComplete(); @@ -161,11 +168,28 @@ export function updateDiceInPlace(snap, onComplete, winForMe = false) { return; } - const matchedZone = document.querySelector('.zone-matched'); if (newlyMatchedCount > 0) { const popT = setTimeout(() => { + // A pop the round has moved past must not drop its now-stale matched + // dice into the next round's fresh zone, so bail once a later snapshot + // (a round advance) has replaced ours. + if (state.currentState && state.currentState.round_num !== snap.round_num) { + if (onComplete) onComplete(); + return; + } + // Reconcile the locked zone to exactly this snapshot's matched dice + // instead of blindly appending prevMatchedCount→length. Under a + // reveal/rebuild race the live zone can already hold a different count + // than prevMatchedCount assumed, and a blind append then stacks it past + // 10 (the "locked dice keep stacking beyond 10" bug). Re-query the live + // zone, trim any excess, then pop in only the genuinely-missing dice so + // the animation still plays. + const matchedZone = document.querySelector('.zone-matched'); if (matchedZone) { - for (let i = state.prevMatchedCount; i < newMatched.length; i++) { + while (matchedZone.children.length > newMatched.length) { + matchedZone.lastElementChild?.remove(); + } + for (let i = matchedZone.children.length; i < newMatched.length; i++) { const scene = makeDie(newMatched[i], effectiveTarget); scene.classList.add('popping'); matchedZone.appendChild(scene); diff --git a/static/js/game-render.js b/static/js/game-render.js index 534263b..29dc504 100644 --- a/static/js/game-render.js +++ b/static/js/game-render.js @@ -97,6 +97,9 @@ export function renderPlayersBar(snap) { export function renderMyArea(snap) { const player = state.myId ? snap.players[state.myId] : undefined; if (!player) return; + // Record the round this board belongs to so a later reveal can tell whether + // the board is still current (see updateDiceInPlace's stale-board guard). + state.boardRound = snap.round_num; const effectiveTarget = player.has_rolled ? snap.target : -1; const matched = player.dice.filter((d) => d === effectiveTarget); diff --git a/static/js/net.js b/static/js/net.js index ec438db..df9a87d 100644 --- a/static/js/net.js +++ b/static/js/net.js @@ -276,6 +276,19 @@ function handleMessage(msg) { return; case 'state': if (msg.qr) state.qr = msg.qr; // re-sent on a lobby reconnect + // Authoritative catch-up: a frame for a round AHEAD of the one we're + // showing means we missed the round-advance broadcast (dropped on a flaky + // link). A newer round supersedes any in-flight roll, so apply it now + // rather than stashing it behind a reveal that might never run — otherwise + // a client that also stops rolling stays parked on the old round while the + // server and everyone else move on. + if (msg.started && state.currentState + && typeof msg.round_num === 'number' + && msg.round_num > (state.currentState.round_num ?? 0)) { + resetRollState(); + showFor(msg); + return; + } // My own roll response (private, pre-broadcast): hold it for tryReveal // so the shake/reveal animation drives the change instead of a hard // re-render. A newer broadcast landing mid-reveal is stashed separately diff --git a/static/js/state.js b/static/js/state.js index b4bb77d..ba0cd36 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -41,6 +41,10 @@ export const state = { // ── Game board / roll choreography (driven by the game view) ── /** @type {string | null} Fingerprint to skip needless my-area re-renders. */ lastMyDiceKey: null, + /** @type {number | null} Round the my-area board was last (re)built for. Lets + * the reveal detect a stale board — a round-advance broadcast we never got — + * and hard-rebuild instead of animating new dice onto the old round. */ + boardRound: null, /** True while the shake animation is running. */ rolling: false, /** True while waiting on the server's roll response. */