A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the first real audio beyond a placeholder. - Coordinate labels read X,Y everywhere shown to a player, not the internal Y,X storage order. Display-only. - "No switching" now means may not add or drop cars, not "never touch it" — these trains can still be moved onto Secondary Track to clear the mainline. - Q3 corrected: Expedite governs WHERE a train may be left standing, not WHEN it leaves. The forced same-Stage departure is gone; a new fault costs 1 Revenue if an expedited train is left off the station when a Mainline Phase begins. Resolves "3/4 Express prints a rule it can never use" as a side effect. - evaluateClearance now checks every occupant on a Mainline card before offering a judgment call, instead of returning on whichever it found first — found while explaining a playtest report, fixed with a regression test. - Three new synthesised sounds: arrive, depart, crash. - The splash page shows the box art. Full detail, measurements and reasoning in CHANGELOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvU99NEakShRMg3nN3fHAZ
134 lines
5.7 KiB
TypeScript
134 lines
5.7 KiB
TypeScript
/**
|
|
* 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',
|
|
'expediteFault',
|
|
'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 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']);
|
|
});
|
|
});
|