A game you come back to has not begun

Reported by Jesse, 2026-08-30: "when you are continuing the saved game
out of that screen, do not post a message that says 'The game has
begun.' … it needs to say 'The game has resumed.'"

A restored game draws exactly like a dealt one — mid-Day, mid-phase,
with a log already several turns deep — and solitaire said nothing at
all to tell the two apart. It flashes "The game has resumed — Day 3,
Stage 7" now, on both ways back in: the setup screen's Continue saved
game, and a bare reload that restores the save.

THE SAME LINE WAS WRONG ON THE MULTIPLAYER SIDE, IN THE OTHER
DIRECTION. noteFirstFrame guards on firstFrameSeen, which is per
page-load — so re-entering a game this browser already held a seat in,
by reloading mid-game or picking it out of the lobby's list, announced
that the game had BEGUN to somebody who had been playing it for an
hour. beginRemote carries whether this is a rejoin now, and the line
reads "resumed" when it is.

Both halves are pinned, including that a brand-new game does not claim
to be a resume — an announcement that fires either way says nothing.

Stays in the unshipped v0.7.9. 878 tests pass, eleven new.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
This commit is contained in:
Jesse.Markowitz
2026-08-30 16:10:17 -04:00
co-authored by Claude Sonnet 5
parent 788e5f2eec
commit bb1b661211
3 changed files with 79 additions and 8 deletions
+35 -7
View File
@@ -506,7 +506,7 @@ const lobbyHandlers = {
const record = readStore().games[gameId];
if (!record) return;
if (record.stage === 'game' && record.seat !== undefined) {
beginRemote({ ...record, seat: record.seat });
beginRemote({ ...record, seat: record.seat }, true);
return;
}
// Still seated in a lobby that had not started: the stream puts us back on the seating screen,
@@ -533,6 +533,8 @@ const HANDOFF_STALL_MS = 8000;
let handoffOpenedAt = 0;
let handoffStall: number | null = null;
let firstFrameSeen = false;
/** Set when this page entered a game it was already seated in, so the first frame says so. */
let rejoiningRemote = false;
function setText(id: string, text: string): void {
const el = document.getElementById(id);
@@ -612,7 +614,7 @@ function noteDayEnd(f: Frame): void {
* nothing saying this was the game just set up. `#phasenote` cannot help: it announces a CHANGE of
* phase, and there is no previous phase to have changed from.
*/
function noteFirstFrame(f: Frame): void {
function noteFirstFrame(f: Frame, rejoining = false): void {
if (firstFrameSeen || isLocal(session)) return;
firstFrameSeen = true;
const held = Date.now() - handoffOpenedAt;
@@ -621,14 +623,34 @@ function noteFirstFrame(f: Frame): void {
window.setTimeout(
() => {
closeHandoff();
/**
* "BEGUN" IS ONLY TRUE ONCE. `firstFrameSeen` is per page-load, so re-entering a game this
* browser already holds a seat in — a reload mid-game, or picking it out of the lobby's list
* — announced that the game had begun, to a player who had been playing it for an hour.
* Reported for the solitaire side by Jesse, 2026-08-30; the same line was wrong here.
*/
flashAnnounce(
`The game has begun — ${type} · ${f.players.length} players · Day ${f.day}, Stage ${f.stage}`,
`The game has ${rejoining ? 'resumed' : 'begun'} — ${type} · ${f.players.length} players · ` +
`Day ${f.day}, Stage ${f.stage}`,
);
},
Math.max(0, HANDOFF_BEAT_MS - held),
);
}
/**
* COMING BACK TO A GAME, said out loud — the solitaire counterpart to `noteFirstFrame`.
*
* A restored game draws exactly like a dealt one: mid-Day, mid-phase, with a log already several
* turns deep. Nothing distinguished "this is the game you left" from "this is a game that has just
* started", and the multiplayer path had the opposite problem — it announced that the game had
* BEGUN to a player rejoining one (Jesse, 2026-08-30: "do not post a message that says 'The game
* has begun.' … it needs to say 'The game has resumed.'").
*/
function announceResumed(f: Frame): void {
flashAnnounce(`The game has resumed — Day ${f.day}, Stage ${f.stage}`);
}
/** Toggles the three mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4),
* `#gameui` (the board, whether local or remote), and `#solitairesetup` (asked before the first
* solitaire deal, the same way `#lobby` is asked before the first multiplayer one — Jesse,
@@ -646,7 +668,7 @@ function showScreen(which: 'lobby' | 'gameui' | 'solitairesetup'): void {
* earlier visit. Either way the token is what makes reconnection work (`lobby-and-sessions.md` §1),
* so it is always written back here before anything else happens.
*/
function beginRemote(ready: LobbyReady): void {
function beginRemote(ready: LobbyReady, rejoining = false): void {
saveRemote({ token: ready.token, gameId: ready.gameId, gameCode: ready.gameCode, seat: ready.seat, stage: 'game' });
gameCode = ready.gameCode;
showScreen('gameui');
@@ -656,6 +678,7 @@ function beginRemote(ready: LobbyReady): void {
// banner (`#presence`), and it holds a beat so the game visibly begins.
openHandoff();
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
rejoiningRemote = rejoining;
applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
@@ -742,7 +765,7 @@ function start(): void {
// session reports a dead game through `abandonRemote`, which lands in the lobby.
const remembered = wantsSolitaire ? null : loadRemote();
if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) {
beginRemote({ ...remembered, seat: remembered.seat });
beginRemote({ ...remembered, seat: remembered.seat }, true);
return;
}
/**
@@ -799,13 +822,17 @@ function start(): void {
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
session = local;
if (saved && requested === null) local.restore(saved);
const restored = Boolean(saved) && requested === null;
if (saved && restored) local.restore(saved);
applyCapabilities();
// Every render goes through the session, so the page redraws whenever the game says it changed —
// which is what a remote session will use to push. Locally it fires on each accepted intent.
session.subscribe(render);
render();
// Coming back to a game is not the same event as being dealt one, and the board looks identical
// either way — mid-Day, mid-phase, with a log already deep (Jesse, 2026-08-30).
if (restored) announceResumed(session.view());
}
/**
@@ -934,7 +961,7 @@ function rulesSummary(f: Frame): string {
function render(): void {
const f = session.view();
const menu = session.menu();
noteFirstFrame(f);
noteFirstFrame(f, rejoiningRemote);
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
@@ -2224,6 +2251,7 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame
? () => {
showScreen('gameui');
render();
announceResumed(session.view());
}
: () => void (location.search = '');
}