Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions static/js/animations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions static/js/game-render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions static/js/net.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions static/js/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down