Files
station-master/test/events.test.ts
T
Jesse.MarkowitzandClaude Opus 5 19a6a47ab6 v0.7.4 — Red Flags hold a train out of your Limits (Gitea#19)
"If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your
limits from that direction (i.e. Flag East holds westbound trains). You can do this
if you see a problem or wish to complete switching."

REPLACES the old rule outright, per Jesse's call. Red Flags used to be played on a
stopped train out on the Mainline and protected it from a rear-ender: offered 4,212
times and played 4 across 600 games, a mechanic nobody used, and ABS Signals already
does that job better. The flag is now planted on one side of your own district and
holds the next train arriving from that side.

SPENT ON THE TRAIN IT STOPS. One card, one train, so there is no lifting action to
build, nothing to forget, and a flag cannot quietly strangle the Division. The held
train loses one Mainline Phase and comes in on the next — it buys a Stage to clear
the lead, which is what "wish to complete switching" asks for.

PLAYABLE OUT OF PHASE, which is the other half of the issue: when an arrival would
certainly collide and the district's owner holds the card, the phase breaks in and
asks. Offered ONLY to somebody holding one — a prompt with a single button is not a
choice, and it would leak that a collision is coming. The danger is read from §8.3's
own two triggers rather than restated, so the prompt cannot offer a flag against a
collision that will not happen.

Built on the decision union Gitea#5 introduced: this adds a `redFlag` case and
nothing else structural.

THE BOT STILL NEVER PLAYS IT, AND I MEASURED RATHER THAN ASSUMED. It now takes the
out-of-phase prompt unconditionally — the engine has already established the danger,
so there is nothing left to judge — and over 200 solitaire games `redFlagsSet` fires
ZERO times. The prompt needs an arrival that would collide (0.14 per game, about one
game in seven) to coincide with holding the card from a three-card hand out of 121.
So the anomaly exemption in sim.test.ts stays, but its comment no longer claims the
bot is unwilling: it is measuring deck luck. What is left to fix is the half of the
card a human would use, planting a flag on purpose to buy switching time, and TODO.md
now says that instead of the old finding.

A BUG WORTH RECORDING, because the next interruption will meet it too: the flag was
originally taken down in a `reduce` case, which never fires for an event advance.ts
emits — the phase driver mutates state and then describes it. The flag stayed up and
held every train that came. test/events.test.ts's unreduced-event registry is what
makes that class of mistake visible, and `redFlagSpent` is on it deliberately now,
with the reasoning.

858 tests pass.

Closes #19

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
2026-08-29 07:12:36 -04:00

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