The extension question was hidden behind the results screen (Gitea#11)

Jesse, playing v0.7.8: "Solitaire game ended. I did not have an option
to extend the game by a day."

The engine and the Frame were right — checked before changing anything.
A solitaire game at the end of its timetable reaches awaitingExtension
with extensionVotes [null], and renderEnding writes "play one more Day"
into #actions. It then opens #resultsdlg, which is MODAL, so those
buttons were directly underneath a dialog whose only control was Close.

The results dialog now carries the question itself, hidden unless a vote
is pending: Play One More Day / End the Game Here, casting the same
game.extend intent. The #actions buttons stay as the fallback once it is
closed.

TODO.md #35 recorded extended play as verified on phoenix.local — over
the HTTP API, which renders no dialog. What was proven was that the
server supports it, not that a player can reach it. Noted there.

Rides along in the unshipped v0.7.9. 870 tests pass, one 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 01:26:03 -04:00
co-authored by Claude Sonnet 5
parent f2c87b6871
commit e035dda5a3
5 changed files with 101 additions and 2 deletions
+26 -2
View File
@@ -52,6 +52,30 @@ when `start.seat !== seatOf(s, player)`, which cannot happen — so this is a la
gameplay effect and nothing to re-measure. The permissive label described a permission a lone player gameplay effect and nothing to re-measure. The permissive label described a permission a lone player
was never being granted, and named an "any player" they have no contrast with. was never being granted, and named an "any player" they have no contrast with.
### The extension question was hidden behind the results screen
Jesse, 2026-08-30: "Solitaire game ended. I did not have an option to extend the game by a day."
**The engine and the Frame were right the whole time** — checked before changing anything: a
solitaire game at the end of its timetable reaches `awaitingExtension` with `extensionVotes: [null]`,
and `isExtendable` covers both `revenueFloor` and `daysElapsed`. `renderActions` reaches
`renderEnding` for any non-active status, and `renderEnding` writes "play one more Day" and "end the
game here" into `#actions`.
Then it opens `#resultsdlg`, **which is modal**. So the two buttons were rendered directly underneath
a dialog whose only control was Close, and the player read a results screen that offered nothing but
Close and concluded the game was over — which is precisely what it looked like.
The results dialog now carries the question itself, hidden unless a vote is actually pending:
**Play One More Day** and **End the Game Here**, casting the same `game.extend` intent. The
`#actions` buttons stay, because they are what remains once the dialog is closed and what a player
who reopens it with "see the full results" comes back to.
**Why this survived being "verified".** `TODO.md` #35 recorded extended play as checked on
`phoenix.local` — over the HTTP API, which renders no dialog. The browser path had never been run to
the end of a timetable. Same shape as the three attempts before it: the thing that was verified was
not the thing the player uses.
### 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
@@ -66,8 +90,8 @@ was never being granted, and named an "any player" they have no contrast with.
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".
869 tests pass, two of them new; one existing test asserted the opposite of the ruling above and 870 tests pass, three of them new; one existing test asserted the opposite of the collision ruling
says so where it was reversed. above and says so where it was reversed.
--- ---
+9
View File
@@ -246,6 +246,15 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main
"waiting on Carol" is legible once Carol has closed her laptop, are still unanswered. Worth being "waiting on Carol" is legible once Carol has closed her laptop, are still unanswered. Worth being
the first thing the next play session does. the first thing the next play session does.
**A HUMAN DID REACH IT ON 2026-08-30, AND IT WAS UNUSABLE — fixed in v0.7.9.** Jesse played a
solitaire game to the end and was never offered the extra Day. Nothing was wrong with the engine
or the Frame: `renderEnding` wrote the two buttons into `#actions` and then opened `#resultsdlg`,
which is **modal**, so the question sat underneath a dialog whose only control was Close. The
dialog asks it now. **The verification recorded above is exactly why this survived** — it was
driven over the HTTP API, which renders no dialog, so what was proven was that the SERVER
supports extended play, not that a player can reach it. Read that distinction into every "verified
on phoenix.local" line in this file.
36. **There is no per-Stage "this train did not move" signal, so "longest an engine sat on a siding" 36. **There is no per-Stage "this train did not move" signal, so "longest an engine sat on a siding"
cannot be answered.** Gitea#16 asks for it and the comment on that issue said `trainStoodStill` cannot be answered.** Gitea#16 asks for it and the comment on that issue said `trainStoodStill`
would supply it, "emitted per Stage, so a run of them is exactly the streak you describe". That would supply it, "emitted per Stage, so a run of them is exactly the streak you describe". That
+27
View File
@@ -1554,6 +1554,33 @@ function showResults(f: Frame): void {
const body = document.getElementById('resultsbody'); const body = document.getElementById('resultsbody');
if (!dlg || !body) return; if (!dlg || !body) return;
body.innerHTML = resultsHtml(f); body.innerHTML = resultsHtml(f);
/**
* ASK THE EXTENSION QUESTION ON THE THING THAT IS ACTUALLY IN FRONT OF THE PLAYER.
*
* This dialog opens itself at every ending and it is MODAL, so `renderEnding`'s own "play one more
* Day" buttons — written into `#actions` — are behind it. The player read a results screen offering
* nothing but Close and concluded the game was over, which is exactly what it looked like (Jesse,
* 2026-08-30). Gitea#11 was verified over the HTTP API, where there is no dialog to be behind.
*
* The buttons in `#actions` stay, and are still correct: they are what remains after this is
* closed, and what a player who reopened the results with "see the full results" comes back to.
* Voting from either place submits the same intent.
*/
const yes = document.getElementById('rs-extend-yes') as HTMLButtonElement | null;
const no = document.getElementById('rs-extend-no') as HTMLButtonElement | null;
const asking = f.status === 'awaitingExtension' && f.extensionVotes[f.viewer] === null;
if (yes && no) {
yes.hidden = !asking;
no.hidden = !asking;
if (asking) {
// `method="dialog"` closes it on click; the vote rides along. Assigned every time rather than
// once, because `f` is a fresh Frame on each ending.
yes.onclick = () => void session.submit({ type: 'game.extend', player: f.viewer, agree: true });
no.onclick = () => void session.submit({ type: 'game.extend', player: f.viewer, agree: false });
}
}
// A redraw can arrive while it is open — `showModal` throws on an already-open dialog rather // A redraw can arrive while it is open — `showModal` throws on an already-open dialog rather
// than doing nothing (the same trap `noteDayEnd` documents). // than doing nothing (the same trap `noteDayEnd` documents).
if (!dlg.open) dlg.showModal(); if (!dlg.open) dlg.showModal();
+9
View File
@@ -1099,10 +1099,19 @@ ul.blocked li{padding:2px 0}
<!-- THE END-OF-GAME RESULTS (Gitea#16). Filled by `resultsHtml` and opened from `renderEnding`, <!-- THE END-OF-GAME RESULTS (Gitea#16). Filled by `resultsHtml` and opened from `renderEnding`,
which puts it up once per ending unasked and leaves a button to reopen it. Reopenable matters: which puts it up once per ending unasked and leaves a button to reopen it. Reopenable matters:
Gitea#11 lets a table play past the end, and continuing must not cost you the results screen. --> Gitea#11 lets a table play past the end, and continuing must not cost you the results screen. -->
<!-- THE EXTENSION QUESTION IS ASKED HERE, not only behind this dialog (Gitea#11 + #16).
This opens ITSELF at every ending, and it is modal — so `renderEnding`'s "play one more Day"
buttons, which it writes into `#actions`, sit underneath it. A player saw a results screen whose
only control was Close and reasonably concluded the game was over: reported by Jesse
2026-08-30, "Solitaire game ended. I did not have an option to extend the game by a day."
Gitea#11 was verified over the HTTP API, which renders no dialog, so the browser never was.
The two extension buttons are hidden unless the game is actually awaiting a vote. -->
<dialog id="resultsdlg" aria-labelledby="rs-title"> <dialog id="resultsdlg" aria-labelledby="rs-title">
<form method="dialog"> <form method="dialog">
<div id="resultsbody"></div> <div id="resultsbody"></div>
<menu class="ng-buttons"> <menu class="ng-buttons">
<button value="extend-yes" id="rs-extend-yes" type="submit" hidden>Play One More Day</button>
<button value="extend-no" id="rs-extend-no" type="submit" hidden>End the Game Here</button>
<button value="ok" id="rs-ok" type="submit">Close</button> <button value="ok" id="rs-ok" type="submit">Close</button>
</menu> </menu>
</form> </form>
+30
View File
@@ -3362,6 +3362,36 @@ describe('the end-of-game results screen (Gitea#16)', () => {
return snapshot(s, [], null); return snapshot(s, [], null);
}; };
it('asks the extension question ON the results dialog, not only behind it (Gitea#11)', () => {
/**
* REPORTED BY JESSE 2026-08-30: "Solitaire game ended. I did not have an option to extend the
* game by a day."
*
* The engine and the Frame were right all along — a solitaire game at the end of its timetable
* reaches `awaitingExtension` with an uncast vote, asserted below. What went wrong is that
* `renderEnding` writes its two buttons into `#actions` and then opens `#resultsdlg`, which is
* MODAL: the question was underneath a dialog whose only control was Close. Gitea#11 was
* verified over the HTTP API, which renders no dialog, so the browser path never was.
*/
const f = finished();
assert.equal(f.status, 'awaitingExtension', 'a solitaire game no longer pauses to ask');
assert.equal(f.extensionVotes[f.viewer], null, 'the viewer has somehow already voted');
const html = readFileSync(join(dist, 'play.html'), 'utf8');
for (const id of ['rs-extend-yes', 'rs-extend-no']) {
assert.ok(html.includes(`id="${id}"`), `the results dialog cannot ask: no #${id}`);
}
// And they are wired: shown only while a vote is pending, and casting the same intent the
// `#actions` buttons do. Read from the source, since the dialog needs a DOM to drive.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const fn = /function showResults\(f: Frame\): void \{[\s\S]*?\n\}/.exec(src)?.[0] ?? '';
assert.ok(fn !== '', 'showResults moved and this test cannot see it');
assert.match(fn, /awaitingExtension/, 'the dialog does not know the game is asking');
assert.match(fn, /'game\.extend'/, 'the dialog offers no way to answer');
assert.match(fn, /yes\.hidden = !asking/, 'the buttons are not hidden on a game that is over');
});
it('never prints a raw enum at the player', () => { it('never prints a raw enum at the player', () => {
// The bug the issue opens on: `GAME OVER — revenueFloor` is an internal identifier, shown at // The bug the issue opens on: `GAME OVER — revenueFloor` is an internal identifier, shown at
// the one moment the game has the player's whole attention. // the one moment the game has the player's whole attention.