/** * 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 { fromSave, newGame, 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', '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}`); }); });