Files
station-master/test/events.test.ts
T
Jesse.MarkowitzandClaude Opus 5 b90c0413d2 v0.8.0.17 — four things the game knew and the screen did not say
All four reported from a table on Day 1 of v0.8.0.16, and all the same shape.

ABS SIGNALS COULD ONLY BE PLAYED ON ONE MAINLINE CARD, while its tooltip said
"any Mainline card". The engine was never wrong: check accepts any node whose
kind is mainline and legalActions filters by check, so all of them were legal.
The failure was the LABEL — describeIntent named i.placement and never i.node,
so every placement described itself as plain "play ABS Signals", and the action
list drops duplicate labels. All but the lowest-index node were discarded before
the menu saw them. This is the THIRD time that trap has fired and the file
documents the other two three lines apart: a turnout's two rotations, and three
Department discards. Same fix — name what distinguishes them.

The card is also called what the card face calls it. prettyKey rendered
absSignals as "Abs Signals" beside a tooltip saying ABS, an acronym no
key-splitter can recover, so the authored names now win. Three of those names
were transcribed in sentence case and were CORRECTED rather than adopted: the
repository says "Yard Office" 36 times against "Yard office" twice. A lookup
that imports its own source's typos is the drift it exists to prevent.

NOTHING ON A MAINLINE CARD SHOWED WHAT WAS STANDING ON IT. Played, ABS left no
mark and you found out by hovering — the same complaint the Heavy Grade wedge
answered, and it matters more here because ABS decides whether a second train on
that card is safe. It draws a signal mast with a lit lamp now; a signal is the
literal object and needs no room for words, which is what lets it sit clear of a
name as long as "Uncontrolled Siding" on a 152px cell. The Mainline modifiers
draw as BRK, AIR and HLP. Realignment is deliberately not among them: reduce
takes the `became` branch and changes node.card, so a realigned Trestle IS an
Uncontrolled Siding afterwards. Asserted, so the absence reads as a finding.

A FREIGHT AGENT TURN SAID A CAR MOVED WHEN NONE HAD. Three faults behind one
line. It asserted an outcome, where §6.3 requires no action and the bot declines
deliberately — unjamming a healthy box destroys a load that cost a whole action
to stock. An idle Agent was then silent, which read as a dropped turn; a new
freightAgentIdled event says so and why, reducing to nothing exactly like
switchingEnded. And the work named a coordinate rather than the industry, though
a `place` helper has existed for precisely that since the switching lines moved
to it. "Loaded a loaded boxcar INTO the green Outbound box at the Freight House",
with the direction in capitals because to-or-from was the question asked.

THE LOG AND THE ACTION MENU SPELLED THE SAME SQUARE DIFFERENTLY. view.ts wrote
(col,row) — X,Y, east/west then north/south — with a comment saying why;
narrate.ts wrote the internal storage order with no comment at all. So the menu
offered a move to "(1,-1)" and the log reported it at "(-1,1)", side by side.
Pinned by a test that renders one square through BOTH describers and compares
them to each other: a test written against either file alone would have passed.

THE DOCUMENTATION IS REACHABLE FROM A RUNNING GAME, AND ALL OF IT IS PUBLISHED.
v0.8.0.16 published the Quickstart and nothing it points at — its §8 links five
documents by relative path and every one 404'd on the package, verified against
the running container. The build publishes the full set, and the test reads the
links OUT OF the guide rather than listing them. They are linked from the This
Game card, where reference already lives, rather than the header that must not
wrap; no mode awareness is needed, because solitaire and multiplayer are the
same page on the same origin.

THE REFERENCES DROPPED THE VERSION FROM THEIR NAMES. Four described v0.8.0.16
and had since the v0.8.0.15 audit; the v0.4.5 was the prototype edition they
were first written against, kept only because 36 citations pointed at it — and
it read as documentation five minor versions stale. They are quickstart.md,
rules.md, home-deck.md, mainline-deck.md and components.md now, kept current
with each release rather than published as editions. Two errors surfaced while
checking them against this release, which is the argument for doing it:
home-deck.md filed ABS Signals under Enhancements "played into your district"
that "change what a square does" — it does neither, this release's bug written
down — and mainline-deck.md, which lists everything playable onto a Mainline
card, never mentioned it at all.

1010 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-21 05:53:52 -04:00

244 lines
11 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';
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<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',
/**
* 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}`);
});
});