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:
co-authored by
Claude Sonnet 5
parent
788e5f2eec
commit
bb1b661211
+20
-1
@@ -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
@@ -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 = '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user