Files
station-master/test/extended-play.test.ts
T
Jesse.MarkowitzandClaude Opus 5 45580d8b61 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
2026-08-29 04:23:26 -04:00

257 lines
12 KiB
TypeScript

/**
* §3.3, EXTENDED PLAY — Gitea#11, "when game ends allow players to continue playing if they wish".
*
* The rule as Jesse specified it (2026-08-28), which is what these tests are written against:
*
* - the OFFICIAL result is decided at the original game length and never changes. "In a five-day
* game, even if it's extended to eight or nine days, the winner and the official answer is the
* winner at the end of five days";
* - extending grants exactly ONE Day, and the question is put again at the end of it;
* - solitaire: the player decides alone. Multiplayer: unanimous, and one refusal ends it there;
* - only days-based endings offer it. A §3.4 collision breach is final, during an extended Day
* just as during the regular game.
*
* The persistence half matters as much as the rules half: a save is `{ seed, config, history }`
* replayed through the engine, so an extension that is not an INTENT does not survive a reload, an
* Undo, or a server restart. `replays the extension` below is the test that pins that.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent, check } from '../src/engine/apply.ts';
import { STAGES_PER_DAY } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
mode: 'solitaire',
days: 3,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
...over,
});
const game = (over: Partial<GameConfig> = {}, names = ['Jesse']): GameState =>
createGame({ id: 'g', seed: 1, config: baseConfig(over), playerNames: names });
/** Parks a game on the last Stage of its final Day, so one `advance` runs the clock off the end. */
function atTheEnd(s: GameState): GameState {
s.clock.day = s.config.days + s.extraDays + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
return s;
}
describe('§3.3 extended play — the ending pauses rather than stopping (Gitea#11)', () => {
it('offers another Day on a days-based ending, and records the result anyway', () => {
const s = atTheEnd(game());
advance(s);
assert.equal(s.status, 'awaitingExtension', 'a days-based ending did not offer another Day');
assert.ok(s.outcome, 'the result was not decided');
assert.ok(s.official, 'the official result was not frozen');
assert.equal(s.official!.day, s.config.days, 'the official Day is not the original game length');
});
it('does NOT offer another Day after a collision breach — §3.4 is final', () => {
const s = game({ mode: 'competitive', maxCollisionsPerDay: 2 });
s.collisionsToday = 2;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished', 'a railroad declared unsafe offered to carry on');
assert.equal(s.outcome!.reason, 'collisionFloor');
});
it('refuses every ordinary intent while the extension question is open', () => {
const s = atTheEnd(game());
advance(s);
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'WRONG_PHASE');
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), null, 'the vote itself was refused');
});
it('offers only the two votes, and only to a seat that has not voted', () => {
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
advance(s);
assert.deepEqual(
legalActions(s, 0).map((i) => i.type),
['game.extend', 'game.extend'],
'something other than the vote is legal while the game is stopped',
);
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
assert.equal(legalActions(s, 0).length, 0, 'a seat was offered a second vote');
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'ALREADY_VOTED');
assert.equal(legalActions(s, 1).length, 2, 'the seat still to vote was not offered the vote');
});
it('refuses the vote when no extension is pending', () => {
const s = game();
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'NOT_AWAITING_EXTENSION');
});
});
describe('§3.3 extended play — one Day at a time (Gitea#11)', () => {
it('grants exactly one Day in solitaire, then asks again at the end of it', () => {
const s = atTheEnd(game());
advance(s);
const official = { ...s.official!.outcome };
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
assert.equal(s.status, 'active', 'agreeing did not resume play');
assert.equal(s.extraDays, 1, 'more or less than one Day was granted');
assert.deepEqual(s.extensionVotes, [null], 'the votes were not cleared for the next question');
// Run the extra Day off the end: the question comes round again.
atTheEnd(s);
advance(s);
assert.equal(s.status, 'awaitingExtension', 'the second ending did not ask again');
assert.equal(s.extraDays, 1, 'a second Day was granted without being asked for');
assert.deepEqual(s.official!.outcome, official, 'the official result was rewritten');
assert.equal(s.official!.day, s.config.days, 'the official Day moved with the extension');
});
it('ends the moment one seat declines, without waiting for the rest', () => {
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
advance(s);
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
assert.equal(s.status, 'awaitingExtension', 'one yes ended the vote');
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
assert.equal(s.status, 'finished', 'a refusal did not end the game immediately');
assert.equal(s.extraDays, 0, 'a Day was granted despite a refusal');
assert.equal(check(s, 2, { type: 'game.extend', player: 2, agree: true }), 'NOT_AWAITING_EXTENSION',
'the seat that never voted is still being waited on');
});
it('needs every seat before it grants the Day', () => {
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
advance(s);
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
assert.equal(s.status, 'awaitingExtension', 'two of three votes granted the Day');
applyIntent(s, 2, { type: 'game.extend', player: 2, agree: true });
assert.equal(s.status, 'active', 'a unanimous table was not given its Day');
assert.equal(s.extraDays, 1);
});
it('keeps the official result when a collision ends an EXTENDED Day', () => {
// The case the freeze exists for: a railroad declared unsafe on the extra Day does not retract
// who won on the last scheduled one.
const s = atTheEnd(game({ mode: 'competitive', maxCollisionsPerDay: 2 }, ['A', 'B']));
s.players[0]!.revenue = 9;
s.players[1]!.revenue = 2;
advance(s);
const official = { ...s.official!.outcome };
assert.equal(official.result, 'win');
assert.equal(official.winner, 0);
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
assert.equal(s.status, 'active');
s.collisionsToday = 2;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.reason, 'collisionFloor', 'the current evaluation was not updated');
assert.deepEqual(s.official!.outcome, official, 'a late collision rewrote a recorded win');
});
it('decides the winner at the ORIGINAL game length, whoever leads afterwards', () => {
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
s.players[0]!.revenue = 9;
s.players[1]!.revenue = 2;
advance(s);
assert.equal(s.official!.outcome.winner, 0);
assert.deepEqual(s.official!.revenues, [9, 2], 'the official standings were not frozen');
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
// B overtakes A during the extra Day, and it changes nothing official.
s.players[1]!.revenue = 40;
atTheEnd(s);
advance(s);
assert.equal(s.official!.outcome.winner, 0, 'overtaking after the timetable took the win');
assert.deepEqual(s.official!.revenues, [9, 2], 'the frozen standings moved');
assert.equal(s.outcome!.winner, 1, 'the informational evaluation did not follow the new leader');
});
});
describe('§3.3 extended play — it survives being replayed (Gitea#11)', () => {
it('reproduces an extended game from seed and intents alone', () => {
// The reason the vote is an intent at all. A save is a replay, so a decision that is not in the
// history did not happen — an extended game would evaporate on the next reload.
const play = (): GameState => {
const s = atTheEnd(game());
advance(s);
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
return s;
};
const a = play();
const b = play();
assert.equal(a.extraDays, b.extraDays);
assert.equal(a.status, b.status);
assert.deepEqual(a.official!.outcome, b.official!.outcome);
});
it('narrates the vote, so a table can see who called time', () => {
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
advance(s);
const yes = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
assert.ok(yes.ok);
assert.deepEqual(yes.events.map((e) => e.type), ['extensionVoted']);
const no = applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
assert.ok(no.ok);
assert.deepEqual(no.events.map((e) => e.type), ['extensionVoted', 'playConcluded']);
});
it('announces the granted Day', () => {
const s = atTheEnd(game());
advance(s);
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
assert.ok(r.ok);
assert.deepEqual(r.events.map((e) => e.type), ['extensionVoted', 'dayExtended']);
const extended = r.events.find((e) => e.type === 'dayExtended');
assert.equal(extended && 'day' in extended ? extended.day : null, s.config.days + 1);
});
});
describe('§3.3 extended play — bots play the timetable they were dealt (Gitea#11)', () => {
it('REGRESSION: a simulated game terminates whatever the policy would vote', () => {
/**
* `playGame` declines on the driver's own account rather than leaving it to the policy, and this
* is why. `randomBot` picks uniformly among its legal options, so it takes another Day about
* half the time — and since a table may go on granting Days for ever, the game then runs to
* `maxTurns`. It did: `test/sim.test.ts` went from under a second to an unbounded hang, every
* seeded game in the harness playing fifty thousand turns instead of a couple of hundred.
*
* `randomBot` is the policy that exposes it, but the guarantee has to hold for any policy,
* including ones not written yet — hence the fix living in the driver and the test living here.
*/
const s = createGame({ id: 'g', seed: 9, config: baseConfig({ days: 1 }), playerNames: ['Jesse'] });
const out = playGame(s, randomBot(7), pump, 4_000);
assert.equal(s.status, 'finished', 'a simulated game did not finish');
assert.equal(s.extraDays, 0, 'the harness played Days the game was not dealt');
assert.ok(out.turns < 4_000, `ran to the turn cap (${out.turns}) instead of ending`);
});
it('developerBot declines, so a bot-only game ends on schedule', () => {
// "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28). The server votes
// yes on a bot's behalf once every human has already agreed; this policy is what is left when
// there are no humans to follow.
const s = atTheEnd(game());
advance(s);
const choice = developerBot.choose(s, 0, legalActions(s, 0));
assert.equal(choice.type, 'game.extend');
assert.equal('agree' in choice ? choice.agree : null, false, 'a bot asked for another Day');
});
});