v0.4.7 — the switching game: track order, the cut on your own card, and four rules

Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.

TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD

Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.

  - Setting out is batch-invariant. Four cars at once, four singles and two pairs
    parked three different orders, one of them physically impossible. Successive
    cuts off the same end stack up towards the engine, so the insertion point is the
    train's own place in the row.
  - Approaching a cut from either end now mirrors. `couples` is built nearest-first
    along the direction of travel and reverses onto the nose, so the farthest car met
    ends up nose-most — which is what makes a run-around worth its Move.
  - A train no longer drives through its own cut. The walk began at the neighbour of
    the start square and never read the start card, so a crew could set cars out and
    pull straight away from them. Coupling is mandatory (§A.4) and your own square is
    no exception; the cut counts against the four-car limit. Setting out off the end
    you are not leaving by still works.

`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.

On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.

Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.

Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.

THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT

Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.

AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD

Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.

THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE

§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.

THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY

+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.

Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.

570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
This commit is contained in:
Jesse
2026-08-19 12:12:30 -04:00
parent 08339effba
commit 9f3b92d08e
44 changed files with 9739 additions and 4130 deletions
+120 -13
View File
@@ -18,7 +18,7 @@ import type {
OfficeTier,
TrackGeometry,
} from './content.ts';
import { officeProfile } from './content.ts';
import { MAX_CONSIST, officeProfile } from './content.ts';
// ---------------------------------------------------------------------------
// Identifiers
@@ -119,7 +119,26 @@ export type TrackCard = {
* Operational Rail (§9.3). Use isOperationalRail() rather than reading a stored flag.
*/
baseOperationalRail: boolean;
/** Uncoupled cars left here, in track order (§A.3). */
/**
* Uncoupled cars left standing here, **ordered WEST to EAST** (§A.3).
*
* §A.3 states the requirement outright: "cars are loaded into and unloaded from the Crew Tray, and
* occupy the track, in the same order they originally held, **left-to-right**". Left-to-right is
* west-to-east, and this array had no defined orientation at all — the doc comment said "in track
* order" and named no direction, so nothing performed the conversion.
*
* That is one bug wearing three faces, all reported from play. Setting out four cars at once
* parked them in a different order than setting out one car four times, because `carsDropped`
* always pushed onto the end. A train that ran onto a parked cut got the SAME consist whichever
* way it approached, when the two must mirror. And the board drew the tray strip flipped for an
* east-facing train and the card's cars unflipped, so the same cut read one way in the tray and
* the other on the card.
*
* WEST-TO-EAST IS THE BOARD'S ORIENTATION, not the train's, which is exactly why it is the right
* one: it is a property of the card, so it does not change when a different train touches it. The
* two conversions are stated once, in `apply.ts`'s `carsDropped` and `carsCoupled` reducers, and
* `CrewTray.standingWest` records where a train standing here sits among them.
*/
standing: RollingStock[];
facility: Facility | null;
modifiers: ModifierKind[];
@@ -179,8 +198,19 @@ export type Facility = {
* reported from playtesting. Typing it away means a fourth renderer cannot reintroduce the bug.
*/
menAtWork: [Load | null, Load | null, Load | null] | null;
/** Where cars are spotted for loading and unloading. */
industryTrack: { length: number; cars: RollingStock[] };
/**
* Where cars are spotted for loading and unloading.
*
* NO LENGTH. This used to carry `length: baseOut + baseIn`, which quietly made an industry's
* SIDING as long as its box count — so a Mine Tipple (one green box) had room for exactly one
* car, and a crew standing on it with two hoppers to set out could only put down one. Reported
* from play at undo 188. The box count is how much WORK an industry can hold, not how much RAIL
* it has; conflating the two invented a printed siding that no industry card actually has.
*
* An industry track is ordinary Operating Rail and holds what any card holds — see `spaceOn`.
* Modifiers raise `capacity` (another red or green box) and never the room for cars.
*/
industryTrack: { cars: RollingStock[] };
laborers: number;
porters: number;
/** Resets at the start of each Stage (§9.1). */
@@ -192,25 +222,35 @@ export type Facility = {
* Operational Rail status. This is why isOperationalRail is a function, not a stored field.
*/
/**
* The cars physically standing on a card.
* The cars physically standing on a card, **ordered WEST to EAST**.
*
* On a Facility card the industry track IS where cars stand — spotting a car there and leaving a
* car there are the same act (§9.3). Modelling them as two separate places was a bug: cars dropped
* at a facility went into `standing`, while loading looked for them on `industryTrack`, so no
* freight load could ever complete and freight revenue was structurally zero.
*
* THE ORIENTATION IS THE BOARD'S, NOT THE TRAIN'S, and it is `standing`'s alone to keep — see the
* comment on that field. `industryTrack.cars` inherits it through this function, so every reader of
* either place gets the same convention and the two can never drift apart.
*/
export function carsOn(card: TrackCard): RollingStock[] {
return card.facility && card.facility.industryTrack.length > 0
? card.facility.industryTrack.cars
: card.standing;
// Gated on the facility being FREIGHT, not on a track length that no longer exists. A Passenger
// Facility has no industry track at all — passengers board off the platform — so cars left on a
// Depot stand on the card like they would anywhere else.
return card.facility && card.facility.kind === 'freight' ? card.facility.industryTrack.cars : card.standing;
}
/** How many more cars this card can hold. Ordinary track is unbounded; an industry track is not. */
/**
* How many more cars this card can hold.
*
* ONE RULE FOR EVERY OPERATING TRACK CARD, industry or not: a card holds up to `MAX_CONSIST` cars,
* because a consist may not exceed four and four is therefore all that can ever be shoved onto one.
* An industry used to be the exception — its room came from its box count, so a one-box industry
* refused a second car — and that exception was the bug. Ordinary track used to be the other
* exception, unbounded, which let a pile build up that no train could then legally couple.
*/
export function spaceOn(card: TrackCard): number {
if (card.facility && card.facility.industryTrack.length > 0) {
return card.facility.industryTrack.length - card.facility.industryTrack.cars.length;
}
return Number.MAX_SAFE_INTEGER;
return MAX_CONSIST - carsOn(card).length;
}
export function isOperationalRail(card: TrackCard): boolean {
@@ -305,6 +345,25 @@ export type CrewTray = {
* puts the engine on the other end, which is the one thing the arrow exists to show.
*/
railFacing?: 'e' | 'w';
/**
* HOW MANY OF THIS CARD'S STANDING CARS LIE WEST OF THIS TRAIN.
*
* `carsOn(card)` runs west to east and a train standing on the card sits somewhere IN that row:
* cars `[0, standingWest)` are west of the engine, cars `[standingWest, end)` are east of it. A
* train can set out off both ends on the same square — §A.3 lets a cut come off either outer end —
* so "which side is this cut on" is not recoverable from the array alone, and it is the question
* every remaining rule asks.
*
* It answers all three: which cars are AHEAD of the engine and which BEHIND (combine with
* `railFacing`), which cut a departing train must couple back up (the one at the end it leaves
* by), and which side of the chip the renderer draws the cut on.
*
* Kept on the TRAY rather than the card because it describes a relationship, not the card: with no
* train standing there, a card's cars are simply a cut with no near or far side, and the next
* train to arrive couples all of them regardless. Reset to 0 on every move, which is sound because
* coupling is mandatory — a train arrives on a card it has just emptied.
*/
standingWest?: number;
position: NodeRef;
movesUsed: number;
/**
@@ -570,6 +629,54 @@ export function railFacingOf(tray: Pick<CrewTray, 'railFacing' | 'facing' | 'dir
return tray.direction === 'west' ? 'w' : 'e';
}
/**
* THE ONE CONVERSION BETWEEN A TRAY AND A TRACK, stated once and used by both directions.
*
* `consist` is ordered NOSE FIRST and the nose points `facing`; `standing` is ordered WEST TO EAST.
* So for an east-facing train the tray reads east-to-west and has to be reversed to go onto the
* card; for a west-facing train it already reads west-to-east and goes on as it stands.
*
* It is its own inverse, which is why lifting cars off a card uses the same function.
*/
export function trackOrder(cut: readonly RollingStock[], facing: 'e' | 'w'): RollingStock[] {
return facing === 'e' ? [...cut].reverse() : [...cut];
}
/**
* The cars standing west and east of a train, split at `standingWest`.
*
* Clamped rather than trusted: a tray that has never dropped anything carries no `standingWest` at
* all, and a saved game from before this field existed carries none either — both read as 0, which
* puts every car on the card east of the engine. That is the same answer the engine gave before the
* split existed, so an old save degrades to the old behaviour instead of throwing.
*/
export function standingSides(
tray: { standingWest?: number | undefined },
cars: readonly RollingStock[],
): { west: RollingStock[]; east: RollingStock[] } {
const k = Math.max(0, Math.min(cars.length, tray.standingWest ?? 0));
return { west: cars.slice(0, k), east: cars.slice(k) };
}
/**
* The cut a train would run into if it left this card through `exit` — the cars between it and that
* end of the card.
*
* Only 'e' and 'w' can hold a cut: the array is a west-to-east row, so a train leaving north or
* south off a curve or a spur is not running along it and meets nothing. Returned in the order the
* train MEETS them, nearest first, which is what `carsCoupled` wants.
*/
export function cutTowards(
tray: { standingWest?: number | undefined },
cars: readonly RollingStock[],
exit: 'n' | 's' | 'e' | 'w',
): RollingStock[] {
const { west, east } = standingSides(tray, cars);
if (exit === 'e') return east;
if (exit === 'w') return [...west].reverse();
return [];
}
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
const t = s.turns.get(player);
if (!t) throw new Error(`no turn state for player ${player}`);