The missed-moves banner stops counting games watched live

Once a gap opened — a backgrounded tab during the bots' turns was
enough — the mark-seen condition (exactly one move behind) could
never hold again, so the banner grew with every move a player watched
happen in front of them. Now only the first state after arriving (or
reconnecting) announces a gap; while present and visible, play marks
itself seen, an announced gap stays frozen until watched or skipped,
and a hidden tab still accumulates its gap honestly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 22:23:25 -04:00
co-authored by Claude Fable 5
parent 4a4e164e88
commit a60467c6fc
+16 -6
View File
@@ -255,6 +255,9 @@ class Net {
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null); openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null); catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
private seen: Record<string, number> = loadSeen(); private seen: Record<string, number> = loadSeen();
/** Room whose live stream this connection has already shown once: states
* after the first mark themselves seen while the tab is visible. */
private watching: string | null = null;
private currentSeq = 0; private currentSeq = 0;
private lastYourTurn = new Map<string, boolean>(); private lastYourTurn = new Map<string, boolean>();
private pollTimer: ReturnType<typeof setInterval> | null = null; private pollTimer: ReturnType<typeof setInterval> | null = null;
@@ -268,6 +271,7 @@ class Net {
const ws = new WebSocket(SERVER_URL); const ws = new WebSocket(SERVER_URL);
this.ws = ws; this.ws = ws;
ws.onopen = () => { ws.onopen = () => {
this.watching = null; // the first state after (re)connecting may carry a gap
this.status = "connected"; this.status = "connected";
// Mid-game reconnects (the socket dropped, not the page) walk straight // Mid-game reconnects (the socket dropped, not the page) walk straight
// back to the table; otherwise the lobby ledger is the front door. // back to the table; otherwise the lobby ledger is the front door.
@@ -317,13 +321,19 @@ class Net {
this.view = msg.view; this.view = msg.view;
if (typeof msg.seq === "number" && this.roomId) { if (typeof msg.seq === "number" && this.roomId) {
this.currentSeq = msg.seq; this.currentSeq = msg.seq;
const last = this.seen[this.roomId] ?? 0; // Only the FIRST state after arriving carries a gap worth
this.missedMoves = Math.max(0, msg.seq - last); // announcing. Later states were watched live: a caught-up
// Watching live counts as seeing; only a fresh arrival has a gap. // watcher stays caught up, and an announced gap stays FROZEN
if (this.missedMoves === 0 || document.visibilityState === "visible") { // (not grown, not wiped) until watched or skipped. A hidden
// A live update while present marks itself seen. // tab accumulates its gap honestly.
if (last >= msg.seq - 1) this.markSeen(); if (this.watching === this.roomId && document.visibilityState === "visible") {
if (this.missedMoves === 0) this.markSeen();
} else {
const last = this.seen[this.roomId] ?? 0;
this.missedMoves = Math.max(0, msg.seq - last);
if (this.missedMoves === 0) this.markSeen();
} }
if (document.visibilityState === "visible") this.watching = this.roomId;
} }
break; break;
} }