/** * What the event log is, and what it is not. * * The README, four architecture documents and six source comments all claimed `state = fold(events)` * until v0.4.0. It was never true, and the claim was load-bearing — it was the stated justification * for reconnection, restart recovery and persistence, none of which were built yet. This file makes * the real shape checkable so the claim cannot quietly come back. * * The decision (`docs/architecture/protocol.md` §3): **the intents are canonical.** A game is * `{ seed, history: Intent[] }`, `fromSave` replays it exactly, and events narrate. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { LOG_LIMIT, fromSave, newGame, newMultiplayerGame, pushLine, submit, toSave, view } from '../src/web/game.ts'; import { actionGroups, currentActor } from '../src/web/game.ts'; import { narrate } from '../src/sim/narrate.ts'; const src = (rel: string): string => readFileSync(join(import.meta.dirname, '..', 'src', rel), 'utf8'); /** Every `type: 'x'` in the `GameEvent` union. */ function eventTypes(): Set { const text = src('engine/events.ts'); const union = text.slice(text.indexOf('export type GameEvent =')); return new Set([...union.matchAll(/type: '([a-zA-Z]+)'/g)].map((m) => m[1]!)); } /** Every `case 'x':` inside `reduce`. */ function reducedTypes(): Set { const text = src('engine/apply.ts'); const body = text.slice(text.indexOf('export function reduce')); return new Set([...body.matchAll(/case '([a-zA-Z]+)':/g)].map((m) => m[1]!)); } /** * The event types the reducer does not handle, as of v0.4.0. * * Every one is emitted by the phase driver in `advance.ts`, which mutates state and then describes * what it did. Read the list: it is the clock, plus the entire Mainline phase — which is to say every * train movement in the game. That is why folding the log rebuilds a district and not a railroad. */ const KNOWN_UNREDUCED = [ 'actorChanged', 'carPassed', 'clearanceRequested', 'dispatchBonusUsed', 'expediteFault', /** * The Freight Agent chose to do nothing (§6.3 requires no action). Emitted by `freightAgent.end` * beside the `phaseEnded` that ends the turn, and reduces to nothing itself — exactly the * `switchingEnded` pattern below. */ 'freightAgentIdled', /** * The New Train Phase's report that it could give a train nothing (playtest, 2026-09-16, the * Sparrow running empty). Emitted by the phase driver after the make-up round has nothing left to * offer, so it describes rather than reduces, like every entry on this list. */ 'makeUpShort', 'phaseBegan', /** * §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so * this is described rather than reduced like everything else here. * * ADDED DELIBERATELY, and it cost a bug first: the flag was originally taken down in a `reduce` * case, which never fires for an event `advance.ts` emits — so it stayed up and held every train * that came. That is precisely the failure this list exists to make visible. */ 'redFlagSpent', // Employee Rotation moves `seating` in the phase driver and then describes what it did, which is // the pattern every entry on this list follows. 'seatsRotated', 'stageBegan', /** * §5's Fedora handover, emitted by `shiftChange` on the same mutate-then-describe path as its * neighbours here: the clock moves the Superintendent and then says so. Added 2026-09-16 because * riding on `actorChanged` meant the log dropped it as turn bookkeeping. */ 'superintendentChanged', /** * The line that closes a switching turn. Emitted by `switch.end` beside the `phaseEnded` that * actually ends the turn, and reduces to nothing itself: it reports what the Moves were spent on * and where the crew was left, both of which the state already holds. */ 'switchingEnded', 'trainArrived', 'trainCompleted', 'trainDiverted', 'trainHeld', 'trainHighballed', 'trainMadeUp', /** * A train the Interlocking held at the Limits taking the A/D track that just freed. Emitted by * `arriveAtOffice` in the phase driver, which moves the tray itself — so this describes rather * than reduces, like every entry on this list. */ 'trainReleasedFromLimits', 'trainStoodStill', 'trainsDestroyed', ]; describe('the event log narrates but does not reconstruct', () => { it('has exactly the unreduced event types it is documented to have', () => { /** * A CHANGE-DETECTOR ON PURPOSE. If this fails because the list shrank, someone has made the * phase driver reduce — good, and the docs in `protocol.md` §3, `events.ts` and the README now * understate the engine and should be corrected in the same change. If it fails because the list * GREW, a new event was added on the mutate-then-describe path, which is worth knowing before it * becomes another thing the log cannot rebuild. */ const all = eventTypes(); const reduced = reducedTypes(); const unreduced = [...all].filter((t) => !reduced.has(t)).sort(); assert.deepEqual( unreduced, KNOWN_UNREDUCED, 'the set of events the reducer ignores has changed — see the comment above KNOWN_UNREDUCED', ); }); it('never folds events in the phase driver, which is what makes the above true', () => { // `advance.ts` mutating directly is the whole mechanism. If it ever starts calling `reduce`, // the claim becomes recoverable and this file should be rewritten rather than relaxed. assert.doesNotMatch( src('engine/advance.ts'), /\breduce\(s[,)]/, 'the phase driver now folds events — reconsider the canonical-record decision', ); }); }); describe('the intents are what reconstructs a game', () => { it('replays a partly-played game to exactly the same position', () => { // The property that actually holds, stated as a test rather than as a comment. This is what // save, share, undo, restart recovery and post-game replay all rest on. const game = newGame(31337); for (let i = 0; i < 120; i++) { if (currentActor(game) === null) break; const { options } = actionGroups(game); if (options.length === 0) break; if (!submit(game, options[0]!)) break; } assert.ok(game.history.length > 20, 'the driver did not play far enough to be a real test'); const back = fromSave(toSave(game)); assert.deepEqual(view(back).cells, view(game).cells, 'the board differs after replay'); assert.deepEqual( back.state.players.map((p) => p.revenue), game.state.players.map((p) => p.revenue), 'the score differs after replay', ); assert.equal(back.state.clock.day, game.state.clock.day); assert.equal(back.state.clock.stage, game.state.clock.stage); assert.equal(back.state.clock.phase, game.state.clock.phase); // The trains are the part folding the log would have lost, so check them specifically. assert.deepEqual( [...back.state.trays.keys()].sort(), [...game.state.trays.keys()].sort(), 'the crews differ after replay', ); }); it('carries no POSITION in the save — the seed, the rules dealt under, and the intents', () => { // If anything else ever creeps into `Save`, the claim above weakens: the game would no longer be // reconstructible from decisions alone, and persistence would have a schema to migrate. // // `rules` is the one addition, and it is not position: it is the other half of the seed. A seed // only names a game together with the rules it was dealt under, which is why two published // replays went dead when the rules moved (`TODO.md`) — the intents were fine, the deal was not. const game = newGame(7); assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']); }); }); /** * A FREIGHT AGENT TURN, AS THE TABLE READS IT. * * Reported on Day 1 Stage 3 of v0.8.0.16, watching a bot: "one car moved to or from a facility — * can we tell what car, what facility and whether it was to or from." Two separate faults sat * behind that. The choice line ASSERTED a car had moved, which §6.3 does not require and the bot * deliberately declines; and the three lines that do report the work named the grid coordinate * rather than the industry standing on it. */ describe('a Freight Agent turn says what it did, to what, and where', () => { const ctx = { playerName: () => 'Bot 1', facilityAt: (_p: number, c: { row: number; col: number }) => c.row === -1 && c.col === 1 ? 'the Freight House' : null, }; it('announces the OPTION without claiming a car moved', () => { const line = narrate({ type: 'localOpsOptionChosen', player: 0, option: 'freightAgent' } as never, ctx); assert.ok( !/one car moved/.test(line.text), `the choice line still asserts an outcome: ${line.text}`, ); // It must still say what the Freight Agent is FOR, or the option is a bare name. assert.match(line.text, /Outbound|Inbound|jam/, `the choice line says nothing about the work: ${line.text}`); }); it('says so when the Freight Agent deliberately does nothing', () => { // The bot takes this route on purpose: unjamming a healthy box destroys a stocked load. Silence // here is what made the turn read as a dropped turn. const line = narrate({ type: 'freightAgentIdled', player: 0 } as never, ctx); assert.match(line.text, /nothing worth doing/, `an idle Freight Agent is silent: ${line.text}`); }); it('names the industry and the direction, not a coordinate', () => { const at = { row: -1, col: 1 }; const stocked = narrate( { type: 'stockToOutbound', player: 0, at, stock: { type: 'boxcar', loaded: true } } as never, ctx, ); assert.match(stocked.text, /the Freight House/, `no industry named: ${stocked.text}`); assert.ok(!/\(1, -1\)/.test(stocked.text), `still speaking in coordinates: ${stocked.text}`); assert.match(stocked.text, /boxcar/, `the car is not named: ${stocked.text}`); assert.match(stocked.text, /INTO/, `the direction is not stated: ${stocked.text}`); const cleared = narrate( { type: 'inboundCleared', player: 0, at, stock: { type: 'hopper', loaded: true } } as never, ctx, ); assert.match(cleared.text, /the Freight House/, `no industry named: ${cleared.text}`); assert.match(cleared.text, /OUT of/, `the direction is not stated: ${cleared.text}`); const jam = narrate( { type: 'facilityUnjammed', player: 0, at, from: 'menAtWork', stock: { type: 'tank', loaded: true } } as never, ctx, ); assert.match(jam.text, /the Freight House/, `no industry named: ${jam.text}`); /** * Where there is no industry, the coordinate is still the honest fallback rather than "nowhere". * * AND IT IS SPELLED THE WAY THE ACTION MENU SPELLS IT — X,Y, east/west then north/south. The * log printed the internal row/col order until 2026-09-21, so the same square read "(1,-1)" in * the menu and "(-1,1)" in the log, side by side. `test/web.test.ts` pins the two against each * other; this pins the order itself. */ const plain = narrate( { type: 'stockToOutbound', player: 0, at: { row: -2, col: 4 }, stock: { type: 'boxcar', loaded: true } } as never, ctx, ); assert.match(plain.text, /\(4,-2\)/, `the fallback coordinate is not in X,Y order: ${plain.text}`); }); }); /** * WHICH MAINLINE CARD, AND WHAT THE CARD IS CALLED. * * Reported 2026-09-22, playing ABS Signals: "In the history, it referred to it as Mainline card 7, * but didn't give the actual card type, which was plains. It should specify both. Note there were * two plains cards dealt in this hand." Both halves matter for that reason — the name informs, the * slot is the only thing telling two Plains apart. */ describe('the log names the Mainline card an Enhancement was built on', () => { const ctx = { mainlineAt: (node: number) => (node === 7 ? 'Plains' : node === 3 ? 'Heavy Grade' : null), enhancementName: (key: string) => (key === 'absSignals' ? 'ABS Signals' : null), }; it('gives the slot AND the card type', () => { const line = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 7 } as never, ctx); assert.match(line.text, /Mainline card 7/, `the slot is gone: ${line.text}`); assert.match(line.text, /Plains/, `the card type is missing: ${line.text}`); }); it('calls the card by its printed name, not a split key', () => { const line = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 7 } as never, ctx); assert.match(line.text, /ABS Signals/, `not the printed name: ${line.text}`); assert.ok(!/abs Signals/.test(line.text), `still splitting the key: ${line.text}`); }); it('still says something useful when the card cannot be resolved', () => { // No resolver at all — an engine test narrating events has no division to ask. const bare = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 2 } as never, {}); assert.match(bare.text, /Mainline card 2/, `the slot must survive with no resolver: ${bare.text}`); }); it('leaves a grid-square Enhancement naming its coordinate', () => { const line = narrate( { type: 'enhancementPlaced', player: 0, key: 'smallYard', at: { row: -1, col: 2 } } as never, { enhancementName: (k: string) => (k === 'smallYard' ? 'Small Yard' : null) }, ); assert.match(line.text, /Small Yard/, `not the printed name: ${line.text}`); assert.match(line.text, /\(2,-1\)/, `the square is gone or in the wrong order: ${line.text}`); }); }); /** * WHOSE REVENUE IT IS. * * Reported from a table: "in the history on +1 revenue and gives the current score, it doesn't list * the player name." The history prefixes lines with the ACTOR, and revenue is not always the * actor's — a train completing its run pays every player with no actor at all, so those lines * carried no name whatsoever. */ describe('a Revenue line names the player who earned it', () => { const ctx = { playerName: (p: number) => ['Alice', 'Bob'][p] ?? `Seat ${p}` }; it('names the earner on a gain', () => { const line = narrate({ type: 'revenueChanged', player: 1, delta: 1, total: 12, reason: 'boarding' } as never, ctx); assert.match(line.text, /Bob/, `no player named: ${line.text}`); assert.match(line.text, /\+1 Revenue/, `the change is gone: ${line.text}`); assert.match(line.text, /now 12/, `the running total is gone: ${line.text}`); }); it('names the earner on a loss', () => { const line = narrate({ type: 'revenueChanged', player: 0, delta: -5, total: 7, reason: 'collision' } as never, ctx); assert.match(line.text, /Alice/, `no player named: ${line.text}`); assert.match(line.text, /now 7/, `the running total is gone: ${line.text}`); }); it('names the player it belongs to, not the one who acted', () => { // The distinction that matters: a train completing its run pays everybody. const a = narrate({ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'a train completed its run' } as never, ctx); const b = narrate({ type: 'revenueChanged', player: 1, delta: 1, total: 9, reason: 'a train completed its run' } as never, ctx); assert.match(a.text, /Alice/, `the first payee is unnamed: ${a.text}`); assert.match(b.text, /Bob/, `the second payee is unnamed: ${b.text}`); assert.notEqual(a.text, b.text, 'both payees produced the same line'); }); it('is excluded from the history prefix, so no line names a player twice', () => { // Gitea#31. `record` prefixes `Player ` onto events carrying a `player`; this one // resolves its own name, so it must be on the exclusion list or it reads "Player Bob Bob +1". const src = readFileSync(join(import.meta.dirname, '..', 'src', 'web', 'game.ts'), 'utf8'); const guard = src.slice(src.indexOf('const SELF_NAMED'), src.indexOf('const SELF_NAMED') + 400); assert.match(guard, /'revenueChanged'/, 'revenueChanged is not excluded from the actor prefix'); assert.match(guard, /!SELF_NAMED\.includes\(e\.type\)/, 'the exclusion is not applied to `mine`'); }); }); /** * THE HISTORY MUST NOT FREEZE WHEN THE LOG IS TRIMMED. * * Reported from a two-player game that did not reach Day 5: one player's history stopped gaining * lines at Day 2 Stage 8 and the other's at Day 2 Stage 4. The log is trimmed to a limit, and each * seat's "what have I sent you" bookmark was an INDEX into that array — so once a seat's bookmark * reached the limit, the array never grew past it again and the slice returned nothing for the rest * of the game. Different moments per seat because each holds its own bookmark. */ describe('a trimmed log still delivers every line', () => { const config = { mode: 'competitive' as const, days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }; it('numbers lines by sequence, which survives trimming', () => { const g = newMultiplayerGame(7, config as never, ['A', 'B']); const first = g.log[g.log.length - 1]!.seq; pushLine(g, 'one', 'plain'); pushLine(g, 'two', 'plain'); assert.equal(g.log[g.log.length - 1]!.seq, first + 2, 'sequence numbers do not advance'); // Trim the front; the survivors keep the numbers they were given. const keep = g.log[g.log.length - 1]!.seq; g.log.splice(0, g.log.length - 1); assert.equal(g.log[0]!.seq, keep, 'trimming renumbered the lines'); }); it('delivers every line to a seat across many trims', () => { const g = newMultiplayerGame(7, config as never, ['A', 'B']); // The same bookmark the server keeps per seat (`linesSince` in server/session.ts). let bookmark = -1; const since = (): number => { const fresh = g.log.filter((l) => l.seq > bookmark); const last = g.log[g.log.length - 1]; if (last) bookmark = last.seq; return fresh.length; }; since(); const pushes = LOG_LIMIT * 2; let delivered = 0; for (let i = 0; i < pushes; i++) { pushLine(g, `line ${i}`, 'plain'); if (g.log.length > LOG_LIMIT) g.log.splice(0, g.log.length - LOG_LIMIT); delivered += since(); } // Every line reaches the seat, though the log holds only the last LOG_LIMIT of them. assert.equal(delivered, pushes, `only ${delivered} of ${pushes} lines were delivered`); assert.equal(g.log.length, LOG_LIMIT, 'the log is not being trimmed at all'); }); });