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
+106
-5
@@ -21,14 +21,16 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url';
|
||||
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||
import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
import type { Frame } from '../src/sim/view.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||
import { advance as advanceEngine } from '../src/engine/advance.ts';
|
||||
import type { GameConfig } from '../src/engine/state.ts';
|
||||
import {
|
||||
overHandLimit,
|
||||
actionGroups,
|
||||
@@ -104,6 +106,18 @@ describe('the browser game plays', () => {
|
||||
it('reaches the end of a game through the same calls the page makes', () => {
|
||||
const { game, turns } = playThrough(77);
|
||||
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
||||
|
||||
/**
|
||||
* The timetable runs out and the game STOPS TO ASK rather than ending (Gitea#11) — `currentActor`
|
||||
* is null there, which is where `playThrough` breaks. Declining through `submit` finishes it,
|
||||
* which is worth doing here rather than merely asserting the pause: this test exists to prove a
|
||||
* whole game is playable through the calls the page makes, and since Gitea#11 the last of those
|
||||
* calls is the vote.
|
||||
*/
|
||||
assert.equal(game.state.status, 'awaitingExtension');
|
||||
assert.ok(game.state.official !== null, 'an ended game must have recorded its official result');
|
||||
assert.ok(submit(game, { type: 'game.extend', player: 0, agree: false }, 0), 'the page cannot decline');
|
||||
|
||||
assert.equal(game.state.status, 'finished');
|
||||
assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
|
||||
});
|
||||
@@ -1932,8 +1946,10 @@ describe('the static build', () => {
|
||||
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
|
||||
// `again` is created by the game-over screen before it is looked up.
|
||||
present.add('again');
|
||||
// Created by the ending screen (`renderEnding`) before they are looked up, so they are never in
|
||||
// the served HTML: `again` starts a new game, `results` reopens the results dialog, and the two
|
||||
// `extend-*` buttons are the extension vote (Gitea#11).
|
||||
for (const id of ['again', 'results', 'extend-yes', 'extend-no']) present.add(id);
|
||||
for (const id of asked) {
|
||||
assert.ok(present.has(id), `main.ts asks for #${id}, which the page does not contain`);
|
||||
}
|
||||
@@ -3283,12 +3299,97 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
|
||||
it('gives the page the dialog to fill', () => {
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
for (const id of ['dayenddlg', 'dayendbody']) {
|
||||
for (const id of ['dayenddlg', 'dayendbody', 'resultsdlg', 'resultsbody']) {
|
||||
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the end-of-game results screen (Gitea#16)', () => {
|
||||
/**
|
||||
* A finished game, built by running the clock off the end of a real one rather than by hand — the
|
||||
* screen reads `official`, and only the engine writes that.
|
||||
*/
|
||||
const finished = (over: Partial<GameConfig> = {}, names = ['Solitaire']): Frame => {
|
||||
const s = createEngineGame({
|
||||
id: 'results', seed: 4021,
|
||||
config: {
|
||||
mode: 'solitaire', days: 5, minCombinedRevenue: 12,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
},
|
||||
playerNames: names,
|
||||
});
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advanceEngine(s);
|
||||
return snapshot(s, [], null);
|
||||
};
|
||||
|
||||
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 one moment the game has the player's whole attention.
|
||||
for (const f of [finished(), finished({ minCombinedRevenue: 0 })]) {
|
||||
const html = resultsHtml(f);
|
||||
for (const raw of ['revenueFloor', 'daysElapsed', 'collisionFloor']) {
|
||||
assert.ok(!html.includes(raw), `the results screen prints the raw reason "${raw}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('says why the game ended, in a sentence, with the game\'s own numbers in it', () => {
|
||||
const html = resultsHtml(finished());
|
||||
assert.ok(html.includes('closed short'), `the revenue-floor ending is not explained:\n${html}`);
|
||||
assert.ok(html.includes('<b>12</b>'), 'the floor that was missed is not named');
|
||||
});
|
||||
|
||||
it('reports the rules the game was actually dealt under', () => {
|
||||
const html = resultsHtml(finished({ mode: 'coop' }, ['Ada', 'Bo']));
|
||||
assert.ok(html.includes('The rules in play'), 'the rules section is missing');
|
||||
assert.ok(html.includes('Co-op'), 'the mode is not reported');
|
||||
assert.ok(html.includes('per load'), 'the pay rates are not reported');
|
||||
});
|
||||
|
||||
it('reports the railroad from the tally, not from nothing', () => {
|
||||
const html = resultsHtml(finished());
|
||||
assert.ok(html.includes('Trains through the Division'), 'the tally section is missing');
|
||||
});
|
||||
|
||||
it('names the winner the OFFICIAL result named, not whoever leads now (Gitea#11)', () => {
|
||||
const f = finished({ mode: 'competitive', minCombinedRevenue: 0 }, ['Ada', 'Bo']);
|
||||
// Ada won at the timetable; Bo overtakes during extended play. The frozen result stands.
|
||||
f.official = {
|
||||
day: 5,
|
||||
outcome: { result: 'win', winner: 0, reason: 'daysElapsed' },
|
||||
revenues: [9, 2],
|
||||
collisionsTotal: 0,
|
||||
tally: f.tally,
|
||||
};
|
||||
f.extraDays = 3;
|
||||
f.players = [
|
||||
{ index: 0, seat: 0, name: 'Ada', revenue: 9, hand: 3 },
|
||||
{ index: 1, seat: 1, name: 'Bo', revenue: 40, hand: 2 },
|
||||
];
|
||||
const html = resultsHtml(f);
|
||||
assert.ok(html.includes('Ada takes the Division'), `the official winner is not the headline:\n${html}`);
|
||||
assert.ok(html.includes('winner'), 'nobody is marked as the winner in the standings');
|
||||
assert.ok(html.includes('After the timetable'), 'extended play is not reported at all');
|
||||
assert.ok(html.includes('3 more Days'), 'the extra Days are not counted');
|
||||
assert.ok(
|
||||
html.indexOf('Ada takes the Division') < html.indexOf('After the timetable'),
|
||||
'the informational section is above the official result',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves out the extended-play section entirely when nothing was extended', () => {
|
||||
assert.ok(!resultsHtml(finished()).includes('After the timetable'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('every file the build needs is actually in the repo (regression)', () => {
|
||||
it('does not gitignore a source page', () => {
|
||||
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
||||
|
||||
Reference in New Issue
Block a user