diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e90c0..de8dfab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 - **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 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. --- diff --git a/src/web/main.ts b/src/web/main.ts index f0aef78..07f3196 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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 = ''); } diff --git a/test/web.test.ts b/test/web.test.ts index 8e1a150..28a8d02 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -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'); }); + 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 () => { /** * WHAT CANCEL USED TO BE. The dialog had a Cancel button and an Esc key, and this pinned that