v0.4.1 - bug fixes. initial d12 rolls determine what player in which seat.

This commit is contained in:
Jesse
2026-08-13 15:28:53 -04:00
parent 49f8504b05
commit a7221dcf20
22 changed files with 736 additions and 237 deletions
+128
View File
@@ -0,0 +1,128 @@
/**
* 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<string> {
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<string> {
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',
'phaseBegan',
'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 state in the save beyond the seed 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.
const game = newGame(7);
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'seed']);
});
});