/** * §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 => ({ mode: 'solitaire', days: 3, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, ...over, }); const game = (over: Partial = {}, 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'); }); });