v0.8.0.14 — the coaches that never come back, and a district that ends at its own sign

Six reports from the Day 2-3 playtest of v0.8.0.13.

GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the
Limits, which makes a once-legal move illegal, so a save holding one is refused
at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call,
knowing it strands the game on the box. The file is untouched and v0.8.0.13
still finishes it.

The Sparrow running empty and Tom unable to unload his passengers are the same
shortage from opposite ends, and both are the rules working as printed. §9.2
boarding discards the emptied coach into the CLASSIFICATION yard, detraining
draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back
only when the Division Yard runs bare — so coaches move one way. Measured over
the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the
end, fifteen piled in Classification, the Division Yard steady at 46-47 freight
cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage
stays and the game says so. A train made up short now reports what its card
wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null
for "done" and for "cannot be done" alike, so the phase moved on in silence); the
yard panel warns while the condition lasts; the Depot's blocked panel was right
all along.

The modifier outside the Limits was working as designed and the design was
Jesse's own call, now reversed. What decided it is what the board shows — a card
beyond your own sign, in territory §8.1 and §10 reason about. The case that
motivated the exemption was checked on the reported move rather than argued away:
the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside
were free, legal and adjacent.

Switching filled the history with coordinates — a line per move, plus one per
mandatory coupling. It is still LOGGED in full; what the panel draws is the line
saying somebody switched, the first move, work at an INDUSTRY (named, not a
coordinate), the Small Yard sort, and a closing summary. The suppressed lines are
still WRITTEN, marked `trace`: dropping them outright was the first attempt and
the step-queue suite caught it, because dwellForStep pays nothing for a step that
said nothing, so the board stopped replaying switching at all. The last move
rides in the closing line rather than being kept in place — nothing knows a move
was the last until the turn is over, by which time the line has been streamed to
every client and cannot be revised. Two things fell out of reading those lines:
every move ended with a tutorial sentence the opener already gives, and the move
count said "of 6" with the six hardcoded, which is wrong on a night Stage.

Make-up lines name their train — they all read "the train being made up", so
looking back for train 10 found nothing under that name — and "a empty tank" is
now "an empty tank". The Small Yard's options read as the train they would build
instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable.
Two of those five were junk: bringing the last car to the end is the identity and
would have spent a Move, and a two-car reversal duplicated its only real option.
Both are filtered by the resulting order, not by the case that made them.

A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open
question. Two sources disagreed and the design notes won: the v0.4.5 card text
says the sort puts the engine at the nose, implications.md says "any order,
including cars ahead of the engine". `engineAt` is optional on the intent, so
older saves replay to the same train. The menu did not multiply — the engine is a
separate short list against the consist as it stands, eight options for a
four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is
deliberately direction-free, so a PUSHING train is fit to run and only a
broken-backed one is held. The button warns by asking that predicate rather than
copying it, and immediately earned itself — every one of train 10's eight options
is refused, the one asked for at the table included, because that train carries a
caboose and each sort moves it off the rear. That is the right answer rather than
a gap: the train is already made up, so every offer would break it, and the labels
say which is which. A made-up order is always on the menu for a train that needs
one, because "bring car k to the tail" is enumerated for every car and the caboose
is one of them.

Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow.
"Front to back" is not a direction a table can read — which end is the front
depends on which way the train points — and board-svg has reversed east-facing
consists since v0.8.0, so the button now describes the same train as the picture.

The Freight Agent, Porter and Laborer groups now say what the role is for, where
the role is chosen. Tom reached for the Freight Agent to detrain passengers,
which is a Porter's action in the Cargo phase; both halves were working and
neither was visible.

TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the
measurement, to revisit on a second game's data).

999 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
This commit is contained in:
Jesse.Markowitz
2026-09-17 20:49:31 -04:00
co-authored by Claude Opus 5
parent ad277fb994
commit a6657241de
23 changed files with 1367 additions and 69 deletions
+119 -1
View File
@@ -7,7 +7,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { advance, badlyMadeUp, pump } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.ts';
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
@@ -1582,3 +1582,121 @@ describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () =>
);
});
});
// ---------------------------------------------------------------------------
/**
* §7's make-up round is silent about what it could NOT do, and that silence was reported from a
* table (2026-09-16): "train 5, the sparrow, has no coaches, which seems strange."
*
* The Sparrow's card calls for three coaches and nothing else. Every coach in that game had ended
* up in the Classification Yard, which §2.2 returns only when the Division Yard runs bare — and the
* Division Yard still held 46 freight cars, so it never would. `trainNeedingCars` therefore answered
* "nothing to ask for" exactly as it answers "everything is full", the phase moved on, and the train
* ran the length of the Division empty with no line anywhere saying why.
*/
describe('a train the Division Yard cannot supply says so', () => {
/** Puts one train in the yard-filling state, with a yard holding only `types`. */
const readyToFill = (yard: { type: string; loaded: boolean }[]) => {
const s = game();
// Stage 1's train is 1/2, the Crack Limited — coaches only, which is the shape that goes short.
s.timetable = Array(STAGES_PER_DAY).fill(null);
s.timetable[0] = 1;
s.yards.divisionYard = yard as never;
s.yards.classificationYard = [{ type: 'coach', loaded: false }] as never;
s.clock.stage = 1;
s.clock.phase = 'newTrain';
return s;
};
it('reports the shortfall, with the cars, the waiting pile and why it will not come back', () => {
// A yard of pure freight: nothing the Crack Limited will take.
const s = readyToFill([
{ type: 'boxcar', loaded: true },
{ type: 'hopper', loaded: false },
]);
const { events } = advance(s);
const short = events.find((e) => e.type === 'makeUpShort');
assert.ok(short, 'a train that could be given nothing reported nothing');
assert.equal(short.type === 'makeUpShort' && short.placed, 0);
assert.ok(
short.type === 'makeUpShort' && short.missing.includes('coach'),
'the report did not name the category the yard could not supply',
);
assert.ok(
short.type === 'makeUpShort' && short.waiting === 1,
'the report did not count the cars waiting in the Classification Yard',
);
assert.ok(
short.type === 'makeUpShort' && short.divisionYardHolds === 2,
'the report did not say how far the Division Yard is from bare, which is what §2.2 turns on',
);
});
it('says nothing when the round can still be asked for a car', () => {
// The same train, with coaches available: the phase must STOP for them rather than report.
const s = readyToFill([
{ type: 'coach', loaded: true },
{ type: 'coach', loaded: true },
]);
const { events, needsInput } = advance(s);
assert.equal(needsInput, true, 'the phase should be waiting for a car to be placed');
assert.equal(
events.some((e) => e.type === 'makeUpShort'),
false,
'a train that can still be filled was reported short',
);
});
});
// ---------------------------------------------------------------------------
/**
* §8.2 and the Small Yard's nose sort, together — Jesse's ruling of 2026-09-17 was "any train may
* sort to the nose, and it is held if it cannot depart".
*
* The holding half already existed and needed no new code, which is worth pinning precisely because
* it is easy to assume otherwise: `badlyMadeUp` is deliberately DIRECTION-FREE, so a train with its
* whole consist ahead of the engine is a PUSHING train and fit to run. What §8.2 refuses is a
* broken-backed train — the engine buried among its own cars — and a caboose anywhere but the end
* away from the engine.
*/
describe('a train sorted to the nose is judged by §8.2, not by where the engine is', () => {
const tray = (consist: { type: string; loaded: boolean }[], engineAt: number) =>
({ consist, engineAt }) as unknown as CrewTray;
it('lets a pushing train run — the whole consist ahead of the engine is made up', () => {
const pushing = tray([{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }], 2);
assert.equal(
badlyMadeUp(pushing),
null,
'a pushing train was refused; §8.2 is enforced direction-free on purpose',
);
});
it('holds a train whose engine is buried among its own cars', () => {
const buried = tray(
[{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: true }, { type: 'caboose', loaded: true }],
1,
);
const why = badlyMadeUp(buried);
assert.ok(why !== null, 'a broken-backed train was allowed to leave the Office');
assert.match(why, /engine is buried/, `the hold did not say why: ${why}`);
});
it('holds a train whose caboose is not at the end away from the engine', () => {
// Exactly the arrangement asked for at the table: caboose second from the rear.
const wanted = tray(
[
{ type: 'hopper', loaded: true },
{ type: 'tank', loaded: false },
{ type: 'caboose', loaded: true },
{ type: 'boxcar', loaded: true },
],
0,
);
const why = badlyMadeUp(wanted);
assert.ok(why !== null, 'a train with its caboose mid-consist was allowed to leave');
assert.match(why, /caboose must be at the rear/, `the hold did not say why: ${why}`);
});
});
+30 -9
View File
@@ -1361,6 +1361,10 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
const under = district(s);
buildFacility(s, 'packingSheds', under);
const cardId = modifierOf(s, 'iceHouse');
// `district` leaves the host ON the sign's column, which since 2026-09-17 puts its three eastern
// spots outside the Limits. The question here is whether DIAGONALS are offered at all, so the
// sign goes out one more column and the host keeps all nine.
areaOf(s, 0).limitsEast = at(areaOf(s, 0).runningRow, under.col + 1);
const offered = legalActions(s, 0)
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
@@ -1375,13 +1379,18 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
});
it('lets a Modifier hang outside the Limits, but never in the Running Track row', () => {
it('keeps a Modifier inside the Limits, and out of the Running Track row', () => {
/**
* Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps
* all nine of its spots — refusing the outer three would make the card unplayable exactly where
* the district ends. The Running Track ROW is the exception: inside the Limits that row is
* always full, so this bites only beyond the sign, and that is the ground the main grows onto —
* a Modifier parked there would block the player's own sign from moving outward (§2.1).
* Jesse's call, both halves — and the FIRST half reversed on 2026-09-17 after a Day 3 playtest
* put Transmission Lines at (-2,4) with the sign at column 3. It used to read the other way: a
* Modifier is not track (§9), so a host at the limit kept all nine of its spots, because
* refusing the outer three looked like it would make the card unplayable where the district
* ends. What decided it was the board — a card standing outside your own sign, in territory
* §8.1 and §10 reason about — and a count of what is actually lost: six of the nine spots
* survive, and the sign moves outward as the Running Track grows (§2.1, Gap 4a).
*
* The Running Track ROW stays barred for its own reason: it is the ground the main grows onto,
* and a Modifier parked there would block the player's own sign from moving outward.
*/
const s = game();
const under = district(s);
@@ -1390,9 +1399,16 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
const area = areaOf(s, 0);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, area.limitsEast.col + 1) }),
'OUTSIDE_LIMITS',
'a Modifier was allowed to stand outside the district it belongs to',
);
// The spot inside the sign, beside the same host, is the one a player actually has — free,
// adjacent, and on the sign's own column, which `withinLimits` includes.
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row - 1, under.col) }),
null,
'a Modifier beside a host at the limit was refused the spot outside it',
'a Modifier was refused a free, connected spot inside the Limits',
);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
@@ -1425,6 +1441,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
const area = areaOf(s, 0);
const at = { row: -1, col: 4 };
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
// This test is about what a Modifier GRANTS, not about where it may stand, and it arranges a
// host well east of the opening sign. Modifiers have been bounded by the Limits since
// 2026-09-17, so the district has to reach the square the fixture uses or every case here would
// fail as OUTSIDE_LIMITS and prove nothing about flow.
area.limitsEast = { row: area.runningRow, col: at.col + 2 };
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const card = [...s.cards.entries()].find(
@@ -1881,7 +1902,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
*/
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, movesAllowed: 6, facing }) as const;
it('carries the east-west sense across north-south track', () => {
const s = game();
+161
View File
@@ -12,6 +12,9 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { legalActions } from '../src/engine/legal.ts';
import { describeIntent } from '../src/sim/view.ts';
import { badlyMadeUp } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts';
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
@@ -461,3 +464,161 @@ describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)'
assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card');
});
});
// ---------------------------------------------------------------------------
/**
* The Small Yard's re-order menu, reported from Day 3 of the 2026-09-17 playtest.
*
* Jesse had train 10 on his Small Yard — nose first, `[loaded boxcar, loaded hopper, empty tank,
* caboose]` — wanted the boxcar on the tail, and could not tell which button did it: every option
* read `re-order consist [1,2,3,0]`, the engine's own array indices offered to a person. The move he
* wanted was the FIRST of the five. One of the other four re-ordered nothing at all and would still
* have spent one of his six Moves.
*/
describe('the Small Yard says what each re-order would build', () => {
const smallYard = (standing: RollingStock[] = []): TrackCard => ({
...straight(standing),
enhancements: ['smallYard'],
});
/** Train 10's consist as it actually stood, on a card carrying a Small Yard. */
const onTheYard = (): { s: GameState; trayId: string } => {
const s = game();
row(s, 3);
addCard(s, at(1, 1), smallYard());
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true), car('tank'), car('caboose', true)], 'e');
switching(s);
return { s, trayId };
};
it('never offers a sort that changes nothing', () => {
/**
* THE PAIR IS WHAT COUNTS, since the engine position became part of a sort (2026-09-17). The
* identity car order is now a perfectly good option when it moves the ENGINE — that is the whole
* of the separate engine control — so what must never be offered is the pair that reproduces the
* train already standing there, and no pair may appear twice.
*/
const { s, trayId } = onTheYard();
const tray = s.trays.get(trayId)!;
const offered = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.map((i) => {
const sort = i as { order: number[]; engineAt?: number };
return `${sort.order.join(',')}|${sort.engineAt ?? 0}`;
});
assert.ok(offered.length > 0, 'no sort was offered at all, so this proved nothing');
const unchanged = `${[...tray.consist.keys()].join(',')}|${tray.engineAt}`;
assert.ok(
!offered.includes(unchanged),
'the menu offered the train as it already stands — a Move spent to change nothing',
);
assert.equal(new Set(offered).size, offered.length, 'the menu offered the same sort twice');
});
it('offers the engine every position, including ahead of its own cars', () => {
// Jesse's ruling, 2026-09-17, following `implications.md` against the v0.4.5 card text: a train
// in a Small Yard "may sort itself into any order, INCLUDING cars ahead of the engine".
const { s, trayId } = onTheYard();
const n = s.trays.get(trayId)!.consist.length;
const positions = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.map((i) => (i as { engineAt?: number }).engineAt ?? 0);
for (let k = 1; k <= n; k++) {
assert.ok(positions.includes(k), `the engine was never offered position ${k} of ${n}`);
}
});
it('always offers a made-up order to a train that is not in one', () => {
/**
* Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the back… sorting all
* the cars back in, ready to leave the station."
*
* A train with its caboose mid-consist is one §8.2 will not let out of the Office, so at least
* one offer has to put it right. It comes out of the ordinary curated set — "bring car k to the
* tail" is enumerated for every car, and the caboose is one of them.
*/
const s = game();
row(s, 3);
addCard(s, at(1, 1), smallYard());
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('caboose', true), car('hopper', true)], 'e');
const tray = s.trays.get(trayId)!;
tray.trainNumber = 10;
switching(s);
const fit = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.filter((i) => {
const sort = i as { order: number[]; engineAt?: number };
const after = sort.order.map((n) => tray.consist[n]!);
return badlyMadeUp({ ...tray, consist: after, engineAt: sort.engineAt ?? 0 }) === null;
});
assert.ok(
fit.length > 0,
'a train that cannot leave the Office was offered no sort that would make it up',
);
// And the one that does it says so on the button, rather than leaving it to be discovered.
assert.ok(
fit.some((i) => describeIntent(s, i).includes('MADE UP, ready to leave')),
'the sort that makes the train up does not say so',
);
});
it('lays the train out west to east, with the engine where it will be', () => {
/**
* "You specify above that the order is front to back, but on the screen, if it's eastbound or
* westbound, it may look different" — Jesse, 2026-09-17.
*
* `board-svg.ts` draws the crew strip west on the left, reversing a consist for an east-facing
* train so its nose lands at the east end. The button has to read the same way or it describes a
* different train from the one on the board.
*/
const s = game();
row(s, 3);
addCard(s, at(1, 1), smallYard());
// Engine points WEST: the consist is stored nose first, so west to east reads engine first.
const west = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true)], 'w');
switching(s);
const westLabel = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.map((i) => describeIntent(s, i))
.find((l) => l.startsWith('re-order'));
assert.ok(westLabel, 'no re-order was offered for the west-facing train');
assert.match(westLabel, /west to east: ◀ ENGINE · /, `a west-facing engine was not drawn leading: ${westLabel}`);
// The same train pointing EAST puts the engine at the far end of the same sentence.
s.trays.get(west)!.railFacing = 'e';
s.trays.get(west)!.facing = 'e';
const eastLabel = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.map((i) => describeIntent(s, i))
.find((l) => l.startsWith('re-order'));
assert.ok(eastLabel, 'no re-order was offered for the east-facing train');
assert.match(eastLabel, / · ENGINE ▶($| ·)/, `an east-facing engine was not drawn at the east end: ${eastLabel}`);
});
it('labels each option with the train it would make, not with array indices', () => {
const { s } = onTheYard();
const labels = legalActions(s, 0)
.filter((i) => i.type === 'switch.sortConsist')
.map((i) => describeIntent(s, i));
assert.ok(
labels.every((l) => !/\[\d(,\d)*\]/.test(l)),
`a re-order option still reads as a permutation: ${labels.find((l) => /\[\d(,\d)*\]/.test(l))}`,
);
/**
* The one Jesse wanted, written the way the board draws it: this crew faces EAST, so the strip
* runs west to east and the engine sits at the east end with the boxcar now furthest west.
*/
assert.ok(
labels.includes('re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶'),
`the move that puts the boxcar on the tail was not offered in words — got: ${labels.join(' | ')}`,
);
// And the engine's own positions read as what they do, not as an index.
assert.ok(
labels.some((l) => l.startsWith('put the whole consist ahead of the engine —')),
`the shoving sort was not offered in words — got: ${labels.join(' | ')}`,
);
});
});
+12
View File
@@ -47,6 +47,12 @@ const KNOWN_UNREDUCED = [
'clearanceRequested',
'dispatchBonusUsed',
'expediteFault',
/**
* 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
@@ -67,6 +73,12 @@ const KNOWN_UNREDUCED = [
* 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',
+14 -3
View File
@@ -48,7 +48,8 @@ const SAMPLES: GameEvent[] = [
// the table every third Stage, so its text is worth exercising.
{ type: 'superintendentChanged', player: 1, stage: 6 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5, movesAllowed: 6 },
{ type: 'switchingEnded', player: 0, movesUsed: 3, movesAllowed: 6, lastMove: { trayId: 't0', to: { row: 0, col: 1 } } },
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
@@ -61,8 +62,18 @@ const SAMPLES: GameEvent[] = [
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false } },
{ type: 'carPassed', player: 0, trayId: 't0' },
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false }, trainNumber: 10, isExtra: false },
{ type: 'carPassed', player: 0, trayId: 't0', trainNumber: 10, isExtra: false },
{
type: 'makeUpShort',
trainNumber: 5,
isExtra: false,
placed: 0,
wanted: 3,
missing: ['coach'],
waiting: 14,
divisionYardHolds: 46,
},
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
{ type: 'clearanceGiven', trainId: 't1', allow: false },
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
+166 -3
View File
@@ -12,9 +12,10 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent } from '../src/engine/apply.ts';
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';
@@ -55,7 +56,18 @@ const config: GameConfig = {
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 \d+ car|set out |used the SMALL YARD)/;
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', () => {
@@ -124,7 +136,10 @@ describe('switching is attributed — TODO #13', () => {
`an unattributed switching line survived: ${line.text}`,
);
if (SWITCHING_LINE.test(line.text)) {
assert.equal(line.tone, 'act', `a switching line must read as somebody's move: ${line.text}`);
assert.ok(
SWITCHING_TONES.includes(line.tone),
`a switching line must read as somebody's move: ${line.text} (tone ${line.tone})`,
);
lines++;
}
}
@@ -404,3 +419,151 @@ describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', (
);
});
});
// ---------------------------------------------------------------------------
/**
* 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.
assert.match(moved[0]!.text, /→ the Freight House|→ \(1,2\)/, `unexpected move line: ${moved[0]!.text}`);
});
});
+29
View File
@@ -5480,3 +5480,32 @@ describe('the Superintendent ruling names the train it is ruling on', () => {
assert.match(title, /Train 4/);
});
});
// ---------------------------------------------------------------------------
/**
* §2.2 and §9.2 together strand every coach in the Classification Yard, and the board does not say
* so — Jesse's ruling of 2026-09-17 is that the rule stands and the game says it loudly.
*
* Tested on the FRAME rather than the DOM, because the counts are what the warning is derived from
* and they are what could go wrong: the panel asks for a per-type count the Frame already carries.
*/
describe('a Division Yard with no coaches is a reportable condition', () => {
it('carries per-type counts for both yards, so the panel can tell coaches from cars', () => {
const game = newGame(4242);
game.state.yards.divisionYard = [{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: false }] as never;
game.state.yards.classificationYard = [
{ type: 'coach', loaded: false },
{ type: 'coach', loaded: true },
] as never;
const f = view(game);
const coachesInDivision = f.yards.division.find((c) => c.type === 'coach');
assert.equal(coachesInDivision, undefined, 'the fixture put no coach in the Division Yard');
assert.notEqual(f.yards.divisionTotal, 0, 'a yard holding freight is not bare, which is the whole point');
const waiting = f.yards.classification.find((c) => c.type === 'coach');
assert.ok(waiting, 'the coaches in Classification were not reported by type');
assert.equal(waiting.loaded + waiting.empty, 2, 'the waiting coaches were miscounted');
});
});