v0.7.3 — a game that asks before it ends, and a results screen worth reading
Two issues off the tracker, and they are halves of one thing: the end of a game. Neither ships on the 0.4.9 line — Jesse's call, that line may be complete and these are not fixes people mid-playtest need. EXTENDED PLAY (#11). The official result is settled at the original game length and never changes: in a five-Day game extended to eight, the winner is whoever led at the end of Day 5. Extending grants exactly one Day and the question is put again at the end of it — solitaire the player decides alone, multiplayer it is unanimous and one refusal ends it there. Only days-based endings offer it; a §3.4 collision breach is final, during an extended Day exactly as during the scheduled game. It could not be a client-side change. `check` refused every intent once `status` left `active`; the server never loads a `finished` game back into memory; and a save is `{ seed, config, history }` replayed through the engine, so a "continue" the history does not record did not happen. Hence a fourth status, `awaitingExtension`, and a `game.extend` intent. `config.days` never moves — `extraDays` counts the borrowed Days and `official` freezes the outcome, the standings and the statistics at the first ending. THE RESULTS SCREEN (#16). `GAME OVER — revenueFloor` was `outcome.reason`, an internal enum interpolated into the page at the one moment the game has the player's whole attention. Every reason now has a sentence with the game's own numbers in it. Around it: the result and winner, standings, the rules the game was dealt under, a per-player breakdown, and the railroad — trains through the Division and how many worked en route, loads made up and broken, passengers, cars switched, trains destroyed. It shares the Day-end dialog's blocks rather than reimplementing them, and stays reopenable so continuing does not cost you the results. Statistics are folded, not recorded: `state.tally` counts what the event stream says happened, hooked at `applyIntent` and `advance` because `reduce` never sees the phase driver's events — and those are the interesting ones. Nothing in the rules reads it, and it rides the Frame, so multiplayer gets the same numbers as solitaire from one implementation. THREE BUGS FOUND IN TESTING, all of which would have shipped: - a saved game containing a vote could not be resumed (NO_ACTOR). A history is a flat Intent[] with no seat recorded; the replay derives who acted from the turn order, which cannot work for an intent every seat may send in any order. `game.extend` carries its voter, checked against the authenticated seat. - an all-bot game hung on the question for ever. `driveBots` loops on `currentActor`, null the moment the game stops, so it cannot cast a vote, and the bot-vote driver returned early with no humans to follow. - the balance harness became unbounded — `test/sim.test.ts` went from under a second to never finishing. `randomBot` took another Day about half the time, so every seeded game ran to playGame's 50,000-turn cap. Fixed in the driver, not in a policy, so it holds for bots not yet written. All three have regression tests. 832 tests pass, against 793 before this change. NOT BUILT, and a correction. #16's own comment said `trainStoodStill` "is emitted per Stage, so a run of them is exactly the sat-on-a-siding streak". It is not: reading advance.ts, it fires once per game and only for a train whose profile sets `stopEarnsPoint` — the X18 Circus — with `stopPointClaimed` preventing a second. The streak was built, rendered "1 Stage at (0,0)", and was taken out again. There is no per-Stage "this train did not move" signal in the engine, so "longest an engine sat on a siding" needs one first; TODO.md #36 records what it would take, and the Circus set-up is reported instead. Badges remain the second pass #16 asks for (TODO.md #33), and because the statistics are derived rather than recorded, that pass can add any of them retroactively to games already played and saved. Extended play has not yet been played at a real table (TODO.md #35): the multiplayer vote has only been driven through `session.intent`, never through two browsers. Closes #11 Closes #16 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
This commit is contained in:
co-authored by
Claude Opus 5
parent
510e33bac7
commit
45580d8b61
+123
-12
@@ -10,7 +10,7 @@ import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import { seatLabel } from '../sim/view.ts';
|
||||
import type { Menu, Save } from './game.ts';
|
||||
import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||
import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import { playCue } from './sound.ts';
|
||||
import {
|
||||
@@ -916,7 +916,16 @@ function render(): void {
|
||||
* figure came from a target that is itself an open question.
|
||||
*/
|
||||
const obj = $('objective');
|
||||
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
|
||||
/**
|
||||
* Past the original timetable this has to stop saying "N Days left" of a Day count that no longer
|
||||
* applies (Gitea#11). `objective.daysLeft` already counts against the EXTENDED timetable; what it
|
||||
* cannot say on its own is that the Days being counted are borrowed ones.
|
||||
*/
|
||||
const left = `${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
|
||||
obj.textContent =
|
||||
f.extraDays > 0
|
||||
? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable`
|
||||
: `${f.revenue} of ${f.objective.target} · ${left}`;
|
||||
obj.className = 'pace';
|
||||
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
|
||||
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
|
||||
@@ -1392,6 +1401,114 @@ function wirePointing(node: HTMLElement, key: string, routeKeys: readonly string
|
||||
node.onblur = off;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE END OF THE GAME — the action area once there is nothing left to decide, or only one thing.
|
||||
*
|
||||
* Two states share this, and they are genuinely different (Gitea#11):
|
||||
*
|
||||
* - `awaitingExtension` — the timetable ran out on an ending the table MAY play past. The result
|
||||
* is already recorded and already readable; the only question open is whether to run one more
|
||||
* Day. In multiplayer that is a unanimous vote, so this also has to show who is still to answer.
|
||||
* - `finished` — over for good. The results screen, and a new game.
|
||||
*
|
||||
* The results are reachable in BOTH, and stay reachable after play continues, which is the
|
||||
* constraint Gitea#16 and Gitea#11 put on each other: continuing must not cost you the results
|
||||
* screen, so it is a button that reopens rather than a screen you get one look at.
|
||||
*/
|
||||
function renderEnding(el: HTMLElement, f: Frame): void {
|
||||
const o = f.official?.outcome ?? f.outcome;
|
||||
const won = o?.result === 'win';
|
||||
|
||||
/**
|
||||
* Put the results up once per ending, unasked.
|
||||
*
|
||||
* "Once per ENDING" rather than once per game is the extended-play case: an extended game ends,
|
||||
* is played on, and ends again, and each of those is a moment worth reading. `renderActions`
|
||||
* clears the flag whenever the game is running again, so the next ending gets its own showing —
|
||||
* while a redraw during the same ending does not reopen a dialog the player has dismissed.
|
||||
*/
|
||||
if (!resultsShown) {
|
||||
resultsShown = true;
|
||||
showResults(f);
|
||||
}
|
||||
|
||||
if (f.status === 'awaitingExtension') {
|
||||
const mine = f.extensionVotes[f.viewer];
|
||||
// Only seats that exist are counted; `extensionVotes` is per PLAYER and so is `players`.
|
||||
const waiting = f.players.filter((p) => f.extensionVotes[p.index] === null);
|
||||
const tally = f.players.length > 1
|
||||
? '<div class="vote-tally">' +
|
||||
f.players
|
||||
.map((p) => {
|
||||
const v = f.extensionVotes[p.index];
|
||||
const mark = v === true ? '✓' : v === false ? '✗' : '·';
|
||||
return `<span class="vote ${v === true ? 'yes' : v === false ? 'no' : 'wait'}">` +
|
||||
`${mark} ${esc(p.name)}${p.index === f.viewer ? ' (you)' : ''}</span>`;
|
||||
})
|
||||
.join('') +
|
||||
'</div>'
|
||||
: '';
|
||||
|
||||
el.innerHTML =
|
||||
`<div class="over ${won ? 'win' : 'loss'}">` +
|
||||
`${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'} — Day ${f.official?.day ?? f.days} is scored.<br>` +
|
||||
'The result above is final. Play one more Day?</div>' +
|
||||
tally +
|
||||
(mine === null
|
||||
? '<button id="extend-yes">play one more Day</button>' +
|
||||
'<button id="extend-no">end the game here</button>'
|
||||
: `<div class="dim">You voted ${mine ? 'to play on' : 'to end it'}. ` +
|
||||
(waiting.length
|
||||
? `Waiting on ${waiting.map((p) => esc(p.name)).join(', ')}.`
|
||||
: 'Settling…') +
|
||||
'</div>') +
|
||||
'<button id="results">see the full results</button>';
|
||||
|
||||
if (mine === null) {
|
||||
const vote = (agree: boolean) => () =>
|
||||
void session.submit({ type: 'game.extend', player: f.viewer, agree });
|
||||
$('extend-yes').onclick = vote(true);
|
||||
$('extend-no').onclick = vote(false);
|
||||
}
|
||||
$('results').onclick = () => showResults(f);
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML =
|
||||
`<div class="over ${won ? 'win' : 'loss'}">` +
|
||||
`${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'}<br>` +
|
||||
`final Revenue ${f.revenue}${f.objective.target > 0 ? ` against a target of ${f.objective.target}` : ''}</div>` +
|
||||
'<button id="results">see the full results</button>' +
|
||||
(session.capabilities.newGame ? '<button id="again">new game</button>' : '');
|
||||
|
||||
$('results').onclick = () => showResults(f);
|
||||
if (session.capabilities.newGame) {
|
||||
$('again').onclick = () => {
|
||||
clearSave();
|
||||
location.search = '';
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the results have been put up by themselves for the ending currently on screen.
|
||||
*
|
||||
* Cleared by `renderActions` the moment the game is running again, so an extended game gets a fresh
|
||||
* showing at each of its endings while a redraw during one ending does not reopen a dialog the
|
||||
* player has just dismissed.
|
||||
*/
|
||||
let resultsShown = false;
|
||||
|
||||
function showResults(f: Frame): void {
|
||||
const dlg = document.getElementById('resultsdlg') as HTMLDialogElement | null;
|
||||
const body = document.getElementById('resultsbody');
|
||||
if (!dlg || !body) return;
|
||||
body.innerHTML = resultsHtml(f);
|
||||
// A redraw can arrive while it is open — `showModal` throws on an already-open dialog rather
|
||||
// than doing nothing (the same trap `noteDayEnd` documents).
|
||||
if (!dlg.open) dlg.showModal();
|
||||
}
|
||||
|
||||
function renderActions(
|
||||
menu: Menu,
|
||||
f: Frame,
|
||||
@@ -1400,18 +1517,12 @@ function renderActions(
|
||||
const el = $('actions');
|
||||
|
||||
if (f.status !== 'active') {
|
||||
const o = f.outcome;
|
||||
el.innerHTML =
|
||||
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
|
||||
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
|
||||
`final Revenue ${f.revenue} against a target of ${f.objective.target}</div>` +
|
||||
`<button id="again">new game</button>`;
|
||||
$('again').onclick = () => {
|
||||
clearSave();
|
||||
location.search = '';
|
||||
};
|
||||
renderEnding(el, f);
|
||||
return;
|
||||
}
|
||||
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
|
||||
// put its results up unasked again (Gitea#11).
|
||||
resultsShown = false;
|
||||
|
||||
if (menu.direct.length === 0 && menu.placeable.length === 0) {
|
||||
el.innerHTML = '<div class="dim">nothing to decide — the engine is running the Division</div>';
|
||||
|
||||
Reference in New Issue
Block a user