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
+20 -1
View File
@@ -235,6 +235,25 @@ Three more from Jesse reading the two screens side by side.
train may be discarded" — ships **on** in all four types, not just Co-op. The note now says only train may be discarded" — ships **on** in all four types, not just Co-op. The note now says only
what is true of all of them: "Each one changes how the game plays." what is true of all of them: "Each one changes how the game plays."
### A game you come back to has not begun
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 them apart. It flashes "The game has resumed —
Day 3, Stage 7" now, on both ways back: 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.
### Three wording and layout fixes ### Three wording and layout fixes
- **The collision entries** on all three screens now read "The game ends immediately and results in - **The collision entries** on all three screens now read "The game ends immediately and results in
@@ -249,7 +268,7 @@ Three more from Jesse reading the two screens side by side.
cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue
Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal". Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal".
877 tests pass, ten of them new; one existing test asserted the opposite of the collision ruling 878 tests pass, eleven of them new; one existing test asserted the opposite of the collision ruling
above and says so where it was reversed. above and says so where it was reversed.
--- ---
+35 -7
View File
@@ -506,7 +506,7 @@ const lobbyHandlers = {
const record = readStore().games[gameId]; const record = readStore().games[gameId];
if (!record) return; if (!record) return;
if (record.stage === 'game' && record.seat !== undefined) { if (record.stage === 'game' && record.seat !== undefined) {
beginRemote({ ...record, seat: record.seat }); beginRemote({ ...record, seat: record.seat }, true);
return; return;
} }
// Still seated in a lobby that had not started: the stream puts us back on the seating screen, // 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 handoffOpenedAt = 0;
let handoffStall: number | null = null; let handoffStall: number | null = null;
let firstFrameSeen = false; 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 { function setText(id: string, text: string): void {
const el = document.getElementById(id); 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 * 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. * 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; if (firstFrameSeen || isLocal(session)) return;
firstFrameSeen = true; firstFrameSeen = true;
const held = Date.now() - handoffOpenedAt; const held = Date.now() - handoffOpenedAt;
@@ -621,14 +623,34 @@ function noteFirstFrame(f: Frame): void {
window.setTimeout( window.setTimeout(
() => { () => {
closeHandoff(); 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( 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), 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), /** 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 * `#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, * 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), * 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. * 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' }); saveRemote({ token: ready.token, gameId: ready.gameId, gameCode: ready.gameCode, seat: ready.seat, stage: 'game' });
gameCode = ready.gameCode; gameCode = ready.gameCode;
showScreen('gameui'); showScreen('gameui');
@@ -656,6 +678,7 @@ function beginRemote(ready: LobbyReady): void {
// banner (`#presence`), and it holds a beat so the game visibly begins. // banner (`#presence`), and it holds a beat so the game visibly begins.
openHandoff(); openHandoff();
session = createRemoteSession(ready.token, ready.seat, abandonRemote); session = createRemoteSession(ready.token, ready.seat, abandonRemote);
rejoiningRemote = rejoining;
applyCapabilities(); applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first // 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 // 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. // session reports a dead game through `abandonRemote`, which lands in the lobby.
const remembered = wantsSolitaire ? null : loadRemote(); const remembered = wantsSolitaire ? null : loadRemote();
if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) { if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) {
beginRemote({ ...remembered, seat: remembered.seat }); beginRemote({ ...remembered, seat: remembered.seat }, true);
return; return;
} }
/** /**
@@ -799,13 +822,17 @@ function start(): void {
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9); const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params))); const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
session = local; session = local;
if (saved && requested === null) local.restore(saved); const restored = Boolean(saved) && requested === null;
if (saved && restored) local.restore(saved);
applyCapabilities(); applyCapabilities();
// Every render goes through the session, so the page redraws whenever the game says it changed — // 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. // which is what a remote session will use to push. Locally it fires on each accepted intent.
session.subscribe(render); session.subscribe(render);
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 { function render(): void {
const f = session.view(); const f = session.view();
const menu = session.menu(); 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 // 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. // 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'); showScreen('gameui');
render(); render();
announceResumed(session.view());
} }
: () => void (location.search = ''); : () => void (location.search = '');
} }
+24
View File
@@ -4348,6 +4348,30 @@ describe('the solitaire setup screen', () => {
assert.match(n.search, /coltotal=0/, 'unticking the total-collision condition did not switch it off'); assert.match(n.search, /coltotal=0/, 'unticking the total-collision condition did not switch it off');
}); });
it('says the game has RESUMED, not begun, when you come back to one', async () => {
/**
* 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, log already deep — so
* nothing distinguished the two. Checked in both directions here: the wording itself, and that
* the multiplayer line which DOES say "begun" cannot be said to somebody rejoining.
*/
const save = JSON.stringify({ seed: 12345, history: [] });
const { els } = await load('', { 'station-master.save.v1': save });
assert.match(String(els.get('announce')!['textContent']), /has resumed/, 'a restored game said nothing');
assert.doesNotMatch(String(els.get('announce')!['textContent']), /has begun/, 'a restored game claimed to be new');
// A freshly dealt game must NOT claim to be resumed — the announcement has to mean something.
const fresh = await load('?hand=sixRandom');
assert.doesNotMatch(String(fresh.els.get('announce')?.['textContent'] ?? ''), /resumed/,
'a brand new game announced itself as a resume');
// And the multiplayer first-frame line is conditional now rather than always "begun".
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
assert.match(src, /rejoining \? 'resumed' : 'begun'/, 'rejoining a table still says the game has begun');
});
it('backing out deals nothing and puts the same game back on screen', async () => { it('backing out deals nothing and puts the same game back on screen', async () => {
/** /**
* WHAT CANCEL USED TO BE. The dialog had a Cancel button and an Esc key, and this pinned that * WHAT CANCEL USED TO BE. The dialog had a Cancel button and an Esc key, and this pinned that