/** * 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'; 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', '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', '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']); }); });