Files
station-master/test/watchable.test.ts
T
Jesse.MarkowitzandClaude Opus 5 3befc420da v0.8.2 — every district opens on a Depot, and the docs are pages now
A second-digit bump for a playtest read back against the save file. Nine questions
were asked of one three-Day game; three were bugs, three were the rules working
and undocumented, three were decisions. Every save on the test server was replayed
against this build BEFORE release, which is how the cost of each rule was known
before it was chosen rather than discovered after.

EVERY DISTRICT OPENS ON A DEPOT. A Whistle Post has one A/D track and is not a
Passenger Facility, so the opening of every game was spent unable to work a
passenger and one arrival away from a collision. Two A/D tracks and passengers
from Stage 1 now; "Players start with Whistle Posts, not Depots" is the harder
game, set when the game is created. The deck follows the choice — starting on
Depots the four Depot upgrade cards are left out, because an upgrade must be to
the next tier and a Depot card at a table of Depots is a dead draw. How much
easier it is showed up as a test failure rather than an argument: the cue-coverage
pool needed widening from 24 seeded games to 60 before it held one collision.

NO SAVE WAS STRANDED BY IT, which took care. This is the one house rule that
changes how a game is DEALT rather than how it plays, so replaying a save under
the wrong opening is a different railroad from intent one — silently, with no
error. `withSavedOpening` fills it on the replay paths ONLY. Putting it in the
resolver instead made a fresh Cutthroat game deal Whistle Posts and read as
Custom, which is how the distinction was found.

THREE BUGS, ALL REPORTED FROM ONE GAME AND ALL CONFIRMED ON ITS SAVE.

An Office held TWO TRAINS ON ONE A/D TRACK. The capacity test passed with nothing
standing, the train the Interlocking had been holding at the Limits was moved into
the free slot, and the arriving train was pushed in after it without anyone asking
again whether there was room — so the collision §8.3 calls for never happened. The
held train keeps priority; the newcomer now takes the consequence it would have
met had the held train arrived first.

THE HISTORY FROZE, permanently, and the log cap was not really the cause. Each
seat's "what have I sent you" bookmark was an INDEX into an array the game trims,
so once a seat's bookmark reached the limit the slice returned nothing for the
rest of the game — at a different moment per seat, because each holds its own.
That game's log ended at exactly the cap. Lines carry a sequence number now, which
survives trimming; proven by pushing twice the cap through a simulated seat.

§8.1 ASKED THE WRONG QUESTION TWICE. "Trains may pass" returned `clear` before the
Subdivision was looked at, so a train entering a Double Track was released however
busy the rest of it was — that, not anything about Control Points, is what let
Train 8 out with no ruling. And a train standing at an Office was invisible to the
scan, so one about to re-enter the very Subdivision being entered counted for
nothing. Capacity is the test, not presence: a Depot with a track free is not in
the way; a Whistle Post with its one track taken is.

THINGS THAT HAPPENED SILENTLY NOW SAY SO — a train held against a facing one, a
train released from the Limits (a side effect of somebody else's arrival, so it
simply appeared at the Office), and the train an Interlocking is holding, whose
explanatory tooltip has existed since #99 with NO renderer ever reading the flag.

WHERE A MOVE IS REFUSED, AND WHY. `exploreMoves` decides where the rails go and the
pick-up restrictions are enforced afterwards in `check`, so a square the rails
reached and the card forbade was reachable, un-offered, and absent from the block
list with nothing said. Those squares are blocked with the rule that blocks them
now, and the reasons are got by ASKING `check` rather than re-deriving: a second
implementation of the rules is exactly the failure the block list exists to avoid.
A train may also always recover its own caboose — X13 prints "may drop but not
pick up anything", and a train needs its caboose to be made up, so one that parted
with it could never legally leave again.

RULES DECIDED IN SEPTEMBER AND APPLIED HERE. A Modifier must sit square against its
host, no diagonals. A passenger Modifier may not be played at a Whistle Post. Both
were built, measured, held back for a fortnight so a playtest could finish, and
applied now. A Second Section costs its card: `SECOND_SECTION` was declared in
content.ts and never dealt, so the action was free and the bot ordered 26
accidental ones in a measured round. The card is dealt and spent — gating on a card
the deck never holds would have deleted the mechanic rather than fixed it.

THE DOCUMENTATION IS A SET OF PAGES, not five text files served as text/plain — a
card reference is mostly tables, and as plain text a table is rows of pipes.
Markdown is still the one copy; the build renders it, and publishes the .md beside
each page. No Markdown library: this project has no runtime dependencies and one
would be a poor first. The pages add what Markdown cannot carry without drifting —
a nav across the set, a contents list built from the headings actually rendered,
an anchor on every heading, a 70-character measure, and tables that are tables.
They print as ink on paper.

The references caught up with the rules, checked rather than assumed: two
statements had gone from stale to misleading (the Quickstart told a new player to
"get a Depot down as soon as one appears"), and four rules nobody could look up
are written down — the Office tier table, §8.1 in practice, what the Circus Train
pays for, and that a Realignment can be a card with no legal target.

Adding one card to the deck reshuffles every seeded deal, which broke five
fixtures. Each was a seed meaning "a game like this" — TODO #84, exactly — so
seeds moved and pools widened rather than assertions weakening, and the clearance
fixture pins its terrain the way `enhancements.test.ts` already does. The three
published replays were re-recorded.

Closes TODO #40, #42a, #108, #109 and #110.

1046 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-23 07:07:21 -04:00

588 lines
28 KiB
TypeScript

/**
* THE WATCHABLE TABLE — v0.8.0, Gitea#20 / TODO #13, #15, #18.
*
* One shared, ordered presentation of everyone else's turns, on a seated player's own screen. The
* design is `docs/plans/jitsi-common-board.md` § v0.8.0; this file is its tests.
*
* Starting with ATTRIBUTION, because the caption row and the history panel both read these lines
* and a line that does not say who acted is useless on a screen built to answer "what did they
* just do?".
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, areaOf } from '../src/engine/apply.ts';
import { legalActions } from '../src/engine/legal.ts';
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
import { turnOf } from '../src/engine/state.ts';
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
import { currentActor } from '../src/web/game.ts';
import { applyPublicDelta } from '../src/sim/public-delta.ts';
import { publicSnapshot } from '../src/sim/view.ts';
import type { PublicFrame } from '../src/sim/view.ts';
import { takeSteps } from '../src/sim/display-step.ts';
import { createSession } from '../src/server/session.ts';
import { kindOf } from '../src/sim/pacing.ts';
const config: GameConfig = {
mode: 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/**
* The four events a switching turn is made of. Every one of them used to arrive in the shared log
* unattributed: `record()` (`web/game.ts`) prefixes a line with the player's name only when the
* event itself carries `player`, and these four were the only events in their class that did not
* — `cardDrawn`, `cardPlayed`, `cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`,
* `flyingSwitch` and `localOpsOptionChosen` all did. So a switching turn read as an attributed
* bracket around anonymous contents:
*
* Player Alice chose to switch ← attributed
* CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
* Player Alice finished Local Operations ← attributed
*
* Measured 2026-09-09 and fixed with the feature that reads them, not filed.
*/
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
/** How each of those four reads in the log, so the assertions can find them by text. */
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled at |set out |used the SMALL YARD)/;
/**
* The two tones a switching line may carry, and why attribution matters in BOTH.
*
* Since 2026-09-17 a plain move along your own track is written `trace`: the line still exists, so
* its display step has narration to caption the board with and a dwell to be watched for, but the
* history panel does not draw it (`web/game.ts` § inHistory). What must never happen either way is
* the line failing to say whose crew it was — the caption is read by the whole table while the move
* goes up, which is if anything the more public of the two places.
*/
const SWITCHING_TONES = ['act', 'trace'];
describe('switching is attributed — TODO #13', () => {
it('every switching event carries the player who acted', () => {
/**
* Driven by PREFERRING switch moves rather than taking the first legal action, because bot
* switching is clustered rather than spread: two of the three published replays contain no
* `switch.move` at all, so a game driven by `options[0]` can finish without ever exercising
* this. The counter below then guards against the test passing vacuously.
*/
let seen = 0;
for (const seed of [1917398, 191056, 4242]) {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 800; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
// Read the events this intent produces before applying it for real, so the assertion sees
// exactly what `record()` will be handed.
const preview = applyIntent(structuredClone(game.state), actor, chosen);
if (preview.ok) {
for (const e of preview.events) {
if ((SWITCHING_EVENTS as readonly string[]).includes(e.type)) {
assert.ok(
'player' in e,
`${e.type} carries no player, so the log cannot say whose crew it was`,
);
assert.equal(
(e as { player: PlayerIndex }).player,
actor,
`${e.type} names the wrong player`,
);
seen++;
}
}
}
if (!submit(game, chosen)) break;
}
}
assert.ok(seen > 0, 'no switching event was produced, so this test proved nothing');
});
it('reads as a player action in the log, not as anonymous plain text', () => {
let lines = 0;
for (const seed of [1917398, 4242]) {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 800; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
}
for (const line of game.log) {
// The old wording. `uncapitalise` deliberately leaves an acronym alone (`^[A-Z][a-z]` only),
// so "CREW moved" and "SMALL YARD —" would have survived the prefix and read as
// "Player Alice CREW moved …". Both were reworded to compose.
assert.doesNotMatch(
line.text,
/^CREW moved|^SMALL YARD —/,
`an unattributed switching line survived: ${line.text}`,
);
if (SWITCHING_LINE.test(line.text)) {
assert.ok(
SWITCHING_TONES.includes(line.tone),
`a switching line must read as somebody's move: ${line.text} (tone ${line.tone})`,
);
lines++;
}
}
}
assert.ok(lines > 0, 'no switching line reached the log, so this test proved nothing');
});
});
describe('the display-step collector — TODO #13', () => {
it('emits one step per accepted intent plus one per automatic phase, in order', () => {
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
let accepted = 0;
for (let i = 0; i < 120; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
accepted++;
}
assert.ok(accepted > 30, `only ${accepted} intents accepted — this proved little`);
const steps = takeSteps(game.display);
/**
* TWO KINDS OF STEP SINCE TODO #18: one per accepted intent, and one per automatic phase that
* did anything. So the count is no longer `accepted` — but every intent must still have exactly
* one step, which is the invariant that matters.
*/
const byIntent = steps.filter((s) => s.cause !== 'phase');
const byPhase = steps.filter((s) => s.cause === 'phase');
assert.equal(byIntent.length, accepted, 'one step per accepted intent, no more and no fewer');
assert.ok(byPhase.length > 0, 'no phase produced a step — TODO #18 is not being served');
steps.forEach((s, i) => {
assert.equal(s.seq, i, 'sequence numbers must be dense and in order');
assert.equal(s.protocolVersion, 1);
assert.ok(kindOf(s.cause), `step ${i} carries a cause pacing cannot classify`);
// A phase is nobody's move; an intent is always somebody's.
assert.equal(s.player === null, s.cause === 'phase', `step ${i} disagrees about who acted`);
assert.equal(s.seat === null, s.cause === 'phase');
});
assert.equal(takeSteps(game.display).length, 0, 'draining must empty the collector');
});
it('a rejected intent produces no step', () => {
const game = newMultiplayerGame(4242, config, ['Alice', 'Bob', 'Carol']);
takeSteps(game.display);
// Somebody else's turn: refused before the engine is touched, so nothing to present.
const notMyTurn = ((currentActor(game) ?? 0) + 1) % 3;
assert.equal(submit(game, { type: 'draw.end' }, notMyTurn as PlayerIndex), false);
assert.equal(takeSteps(game.display).length, 0, 'a refused intent must not be presented');
});
it('the step deltas reconstruct the public board exactly', () => {
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
let held: PublicFrame | null = null;
for (let i = 0; i < 150; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
for (const s of takeSteps(game.display)) held = applyPublicDelta(held, s.frame);
}
assert.deepEqual(held, publicSnapshot(game.state), 'the animated board drifted from the real one');
});
/**
* THE PROPERTY THAT IS CURRENTLY FREE AND MUST STAY THAT WAY.
*
* `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
* than `submit`, so a resumed server does not re-emit the whole game as steps and burn the
* sequence. The plan expected this to need an explicit guard. It does not — but move a replay
* path onto `submit()` and it silently becomes a real bug, which is why this is pinned.
*/
it('replaying a save emits no steps at all', () => {
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 80; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
assert.ok(game.history.length > 20, 'need a real history to replay');
const rebuilt = fromMultiplayerSave(game.seed, config, ['Alice', 'Bob', 'Carol'], game.history);
assert.equal(
rebuilt.game.display.steps.length,
0,
'a replay re-emitted the whole game as display steps',
);
assert.equal(rebuilt.game.display.seq, 0, 'a replay burned display sequence numbers');
});
it('solitaire collects the same way multiplayer does', () => {
// The standing design direction: solitaire is a special case of multiplayer, not a second
// implementation. Both go through one `submit()`, so this needs no separate code path — and
// that is exactly what makes TODO #18 fall out of TODO #13's mechanism.
const game = newGame(4242);
let accepted = 0;
for (let i = 0; i < 60; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
accepted++;
}
assert.ok(accepted > 10, 'the solitaire game did not get going');
const collected = takeSteps(game.display);
assert.equal(
collected.filter((s) => s.cause !== 'phase').length,
accepted,
'solitaire must collect a step per intent too',
);
// And solitaire is where TODO #18 lives — its phases must earn beats on the same path.
assert.ok(collected.some((s) => s.cause === 'phase'), 'solitaire got no phase steps');
});
});
describe('steps reach a seated player — TODO #13', () => {
it('never replays the opening bot turns at the first client to connect', () => {
/**
* `buildSession` runs `driveBotTurns()` at construction, so with bots ahead of you in the order
* the game has already moved before anybody can connect. Those steps must be DROPPED, not
* queued: a connecting client's `publicReset` is the board as it stands after those very moves,
* so replaying them onto it would draw positions the game had already left.
*
* Found by review 2026-09-09 rather than by a failing test, which is why this one exists.
*/
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
const push = session.connect(0 as PlayerIndex);
assert.ok(push.publicReset, 'a connecting client needs a baseline');
assert.equal(push.steps, undefined, 'the connect push must carry no steps at all');
// And the first real broadcast must carry only what THIS move produced — nothing older.
const option = push.menu?.options[0];
assert.ok(option, 'seat 0 should have something to do');
const r = session.intent(0 as PlayerIndex, 1, option);
assert.ok(r.accepted);
const steps = [...r.pushes.values()][0]?.steps ?? [];
assert.ok(steps.length > 0, 'the move produced no steps');
/**
* The first step delivered must be THIS seat's move — not a bot's, which is what a replayed
* opening turn would look like. The sequence does NOT restart at 0: `takeSteps` empties the
* collector without rewinding the counter, so the first thing a client sees may be seq 14. That
* is fine and deliberate — what 0.8.1's gap detection needs is monotonic and dense, not
* zero-based.
*/
assert.equal(steps[0]!.player, 0, 'the first delivered step was not the move just made');
assert.equal(steps[0]!.cause, option.type);
steps.forEach((st, i) => {
if (i > 0) assert.equal(st.seq, steps[i - 1]!.seq + 1, 'sequence must stay dense');
});
});
it('every seat gets the same public steps, and a connect gets a baseline to merge onto', () => {
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
const connected = session.connect(0 as PlayerIndex);
assert.ok(connected.publicReset, 'a connecting client needs a baseline for its step queue');
let seen = 0;
for (let i = 0; i < 60; i++) {
const menu = session.connect(0 as PlayerIndex).menu;
const option = menu?.options[0];
if (!option) break;
const r = session.intent(0 as PlayerIndex, i, option);
if (!r.accepted) break;
const pushes = [...r.pushes.values()];
if (pushes.length === 0) continue;
const first = pushes[0]!.steps ?? [];
if (first.length === 0) continue;
seen += first.length;
for (const p of pushes) {
assert.deepEqual(p.steps, first, 'every seat must receive the identical public steps');
}
}
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
});
});
describe('the Fedora passing is visible (playtest 2026-09-16)', () => {
it('names the new Superintendent in the history at the Stage it happens', async () => {
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
const { legalActions } = await import('../src/engine/legal.ts');
/**
* It used to ride on `actorChanged`, which `record()` drops as turn bookkeeping — so the one
* moment that event meant something never reached a player. Driven far enough to cross a shift
* boundary (Stages 3, 6, 9, 12) rather than asserted on a hand-built event, because the point is
* that a real game produces the line.
*/
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 900; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
if (game.state.clock.stage > 3 || game.state.clock.day > 1) break;
}
const handover = game.log.filter((l) => /SUPERINTENDENT — the Fedora passes to/.test(l.text));
assert.ok(handover.length > 0, 'the game crossed a shift change and the log never said so');
assert.match(handover[0]!.text, /Alice|Bob|Carol/, 'the handover did not name the new Superintendent');
});
});
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
const { legalActions } = await import('../src/engine/legal.ts');
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < 400; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
}
assert.ok(game.log.length > 50, 'the game barely ran, so this proved little');
for (const line of game.log) {
// `record()` prefixes the acting player's NAME; a narration that also named them read
// "Player Alice player 0 finished Local Operations" (playtest, 2026-09-15).
assert.doesNotMatch(
line.text,
/\bplayer \d+\b/i,
`a line still carries a bare player index: ${line.text}`,
);
}
});
it('attributes a clearance ruling to the office, not to the seat\'s own turn', async () => {
const { newMultiplayerGame, drain, submit } = await import('../src/web/game.ts');
const { areaOf } = await import('../src/engine/apply.ts');
const game = newMultiplayerGame(7, config, ['Alice', 'Bob', 'Carol']);
const s = game.state;
const area = areaOf(s, 0);
// A westbound train at seat 0's Office, and another westbound AHEAD of it — west of the Office —
// which is §8.1's fourth condition and the Superintendent's to rule on (see Gitea#26).
s.trays.set('departing', {
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'west', facing: 'w',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
} as never);
area.adOccupancy.push('departing');
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
const card = s.division.nodes.findIndex((n, i) => i < office && n.kind === 'mainline');
const node = s.division.nodes[card];
assert.equal(node?.kind, 'mainline');
s.trays.set('ahead', {
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
} as never);
if (node?.kind === 'mainline') {
/**
* PIN THE TERRAIN, as `enhancements.test.ts` does for the same reason. Mainline types come
* from the SHUFFLED deck, so deck composition decides them — and Double Track and Uncontrolled
* Siding print "trains may pass", which legitimately removes the §8.1 bar this test is about.
* Adding the Second Section card (Q9) reshuffled seed 7 into one of those and the ruling
* stopped being called for.
*/
node.card = 'plains';
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
s.clock.phase = 'mainline';
drain(game);
assert.equal(s.clock.pendingDecision?.kind, 'clearance', 'no ruling was called for, so nothing was tested');
const before = game.log.length;
assert.ok(submit(game, { type: 'mainline.clearance', allow: false }, s.clock.superintendent));
const said = game.log.slice(before).map((l) => l.text);
assert.ok(
said.some((text) => text.startsWith('Superintendent Player ')),
`a ruling did not read as the office's: ${said.join(' | ')}`,
);
});
});
// ---------------------------------------------------------------------------
/**
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL — Jesse's ruling, 2026-09-17, asked as a question
* from the table: *"Does switching show up in history at all? Should it? … I don't think I want all
* six moves showing up… maybe dropping off or picking up cars in industries should be recorded."*
*
* It all showed up. A six-Move turn wrote a line per move, every one of them a pair of coordinates,
* and two players shunting pushed everything else off the panel. What stays is the line saying
* somebody switched, work at an INDUSTRY, and the Small Yard sort.
*
* THE LINE IS STILL WRITTEN, MARKED `trace`. `dwellForStep` gives a step no dwell when it produced
* no narration, so dropping these outright stopped the board replaying switching at all — the first
* attempt at this did exactly that and the step-queue suite caught it. The tone is the seam: the
* caption still has its text, the panel filters the tone out.
*/
describe('the history panel keeps the switching that matters', () => {
const play = (seed: number, steps: number) => {
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
for (let i = 0; i < steps; i++) {
const actor = currentActor(game);
if (actor === null) break;
const options = legalActions(game.state, actor);
if (options.length === 0) break;
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
}
return game;
};
it('keeps the first move of a turn and traces the ones after it', () => {
const game = play(1917398, 800);
const moves = game.log.filter((l) => / moved (Train|the local crew)/.test(l.text));
assert.ok(moves.length > 0, 'no crew move reached the log, so this proved nothing');
// The opener of a turn is the move that leaves `movesAllowed - 1` behind it, which the line
// prints — "5 of 6 Moves left". Those are drawn; everything after them is caption-only.
const opening = moves.filter((l) => / 5 of 6 Moves left/.test(l.text));
const later = moves.filter((l) => !/ 5 of 6 Moves left/.test(l.text));
assert.ok(opening.length > 0, 'no turn opened with a move, so this proved nothing');
assert.ok(later.length > 0, 'no turn made a second move, so this proved nothing');
assert.ok(
opening.every((l) => l.tone !== 'trace'),
`the first move of a switching turn was hidden: ${opening.find((l) => l.tone === 'trace')?.text}`,
);
assert.ok(
later.every((l) => l.tone === 'trace'),
`a move from the middle of a turn is still drawn: ${later.find((l) => l.tone !== 'trace')?.text}`,
);
// Every one keeps its text, because that is what captions the board as the move goes up.
assert.ok(moves.every((l) => /^Player /.test(l.text)), 'a trace line lost its attribution');
});
it('closes a switching turn with what it cost and where the crew was left', () => {
/**
* BUILT, NOT PLAYED — the driver above never submits `switch.end`: it finds a move or another
* option every time, so 800 turns produced no closed switching turn at all and the assertion
* would have been vacuous.
*/
const { game, trayId } = crewOnAnIndustry();
assert.ok(submit(game, { type: 'switch.move', trayId, to: { row: 1, col: 2 }, reverse: false }));
assert.ok(submit(game, { type: 'switch.end' }), 'the turn would not end');
const closing = game.log.filter((l) => / finished switching/.test(l.text));
assert.equal(closing.length, 1, `expected one closing line, got ${closing.length}`);
assert.notEqual(closing[0]!.tone, 'trace', 'the closing summary was hidden from the history');
assert.match(
closing[0]!.text,
/1 of 6 Moves used, leaving .* at /,
`the closing line did not say what it cost and where the crew was left: ${closing[0]!.text}`,
);
});
/**
* A crew standing on a Freight House with a loaded boxcar, and plain track to its east.
*
* BUILT RATHER THAN PLAYED. The bot prefers moves over couplings, so 2400 driven turns across
* three seeds produced not one set-out at an industry, and none of them ever ended a switching
* turn — a driver that cannot reach the case cannot test it.
*/
const crewOnAnIndustry = (): { game: ReturnType<typeof newGame>; trayId: string } => {
const game = newGame(77);
const area = areaOf(game.state, 0);
const spot = { row: 1, col: 1 };
const track = (over: object = {}) => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
...over,
});
area.grid.set('1,1', track({
geometry: { kind: 'facility', facility: 'freightHouse', axis: 'ew' },
facility: {
kind: 'freight', subtype: 'freightHouse',
allows: { outbound: true, inbound: true },
outboundBox: [], inboundBox: [],
capacity: { outbound: 1, inbound: 1 },
menAtWork: [null, null, null],
industryTrack: { cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
}) as never);
area.grid.set('1,2', track() as never);
const trayId = game.state.freeTrays.pop()!;
game.state.trays.set(trayId, {
id: trayId, trainNumber: null, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: true }],
direction: 'east', facing: 'e', railFacing: 'e',
position: { at: 'grid', seat: 0, coord: spot }, movesUsed: 0,
} as never);
game.state.clock.phase = 'localOps';
game.state.clock.currentActor = 0;
turnOf(game.state, 0).option = 'switch';
return { game, trayId };
};
it('keeps work at an industry, and drops the same move on plain track', () => {
const { game, trayId } = crewOnAnIndustry();
const plain = { row: 1, col: 2 };
assert.ok(submit(game, { type: 'switch.dropCars', trayId, count: 1 }), 'the set-out was refused');
const dropped = game.log.filter((l) => /set out/i.test(l.text));
assert.equal(dropped.length, 1, `expected one set-out line, got ${dropped.length}`);
assert.notEqual(
dropped[0]!.tone,
'trace',
'work at an industry was hidden from the history — it is the point of switching',
);
assert.match(
dropped[0]!.text,
/at the Freight House/,
`the line named a coordinate instead of the industry: ${dropped[0]!.text}`,
);
// The same crew moving onto ordinary track is the noise this ruling was about.
assert.ok(submit(game, { type: 'switch.move', trayId, to: plain, reverse: false }), 'the move was refused');
const moved = game.log.filter((l) => / moved the local crew/.test(l.text));
assert.equal(moved.length, 1, `expected one move line, got ${moved.length}`);
/**
* The FIRST move of a turn is kept, and this crew's first move is this one — so what is being
* checked here is that it names the square by what stands on it rather than by its coordinates.
*
* The coordinate branch is `(2,1)` for `{ row: 1, col: 2 }`: X,Y, east/west then north/south,
* which is how `view.ts` has always written a square and how `narrate.ts` writes one since
* 2026-09-21. It read `(1,2)` here while the log still printed the internal storage order.
*/
assert.match(moved[0]!.text, /→ the Freight House|→ \(2,1\)/, `unexpected move line: ${moved[0]!.text}`);
});
});