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
+110 -15
View File
@@ -19,9 +19,17 @@
* complete at all.
*/
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
import {
applyIntent,
areaOf,
canAdvanceLoad,
destinationsFor,
facilityCarTypes,
laborersLeft,
ownCutFor,
} from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { CarType, Hand, TrackGeometry } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
@@ -364,8 +372,10 @@ function chooseLocalOption(
return because('the crew is away from the Office and can never depart from where it stands', can('switch')!);
}
// 3. Stock a green box only when the load can actually finish — an empty car of the right type
// is already spotted. Stocking without one just fills the box.
// 3. Stock a green box when the load can actually be worked — an empty car of the right type is
// already spotted. Staging ahead of the car is legal (§6.3) and cannot jam anything, but it
// spends the Stage's one Freight Agent action on a load no Laborer can touch yet, so it is not
// worth CHOOSING the option for; it is taken only as a fallback once the option is chosen.
if (can('freightAgent') && canStockProductively(s, player)) {
return because('a green box can be stocked with a load that can actually finish', can('freightAgent')!);
}
@@ -463,9 +473,13 @@ function isTrainCard(s: GameState, cardId: string): boolean {
/**
* A facility with room in its green box AND an empty car OF ITS OWN TYPE already spotted.
*
* The type match matters: a load that reaches WORK with no matching car to go onto is stuck, and a
* stuck load strips the industry track of Operational Rail status (§9.3), so no car can be brought
* in to rescue it. Stocking speculatively jams the facility.
* A PRIORITY TEST, NOT A LEGALITY TEST — the engine stocks either way (§6.3). What it decides is
* whether stocking is worth the Local Operations action THIS Stage: with a car spotted the load
* moves next Stage, without one it sits.
*
* The type match matters when it does move: a load that reaches WORK with no matching car to go
* onto is stuck, and a stuck load strips the industry track of Operational Rail status (§9.3), so
* no car can be brought in to rescue it. `loadCanFinish` keeps the Laborers off such a load.
*/
function canStockProductively(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
@@ -1036,6 +1050,25 @@ function loadReason(i: Intent): string {
}
}
/**
* Would a load stocked here be workable straight away — is there a spotted empty car of that type
* not already promised to a load in the box or on the sign?
*
* A PREFERENCE, NOT A RULE. §6.3 stocking has no car requirement; this only ranks one legal stock
* above another, so the Stage's single Freight Agent action goes where a Laborer can use it.
*/
function stockCanBeWorked(s: GameState, player: PlayerIndex, at: GridCoord, carType: CarType): boolean {
const f = areaOf(s, player).grid.get(coordKey(at))?.facility;
if (!f) return false;
// Passengers board off the platform with no car spotted at all (§9.2), so a stocked coach is
// always immediately workable.
if (f.kind !== 'freight') return true;
const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === carType).length;
const staged = f.outboundBox.filter((c) => c.type === carType).length;
const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === carType).length;
return spotted - staged - working > 0;
}
/**
* Is there already an empty car of the right type spotted to receive this load (§9.3)? Without one
* the load can be started and walked across MEN|AT|WORK but can never come off.
@@ -1077,10 +1110,30 @@ function consistAfterMove(
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
);
if (!dest) return null;
// Forward means the engine leads and meets the cars head-on (§A.3).
return move.reverse ? [...tray.consist, ...dest.couples] : [...dest.couples, ...tray.consist];
/**
* Forward means the engine leads and meets the cars head-on (§A.3), and `couples` is ordered
* NEAREST FIRST — so the last car met ends up nose-most and the cut reverses on its way into the
* tray. Predicting it unreversed made the bot's run-around test read the wrong car as the tail
* whenever it approached a cut of two or more forwards.
*/
return move.reverse
? [...tray.consist, ...dest.couples]
: [...[...dest.couples].reverse(), ...tray.consist];
}
/**
* Would this move drag the cars the crew has just set out back onto the train?
*
* Coupling is mandatory, so pulling out through the end a cut is standing at picks it up again —
* which usually undoes the set-out the crew made this turn. The bot cannot make an ILLEGAL move
* (legality is enforced upstream), so this costs it nothing to ignore; it simply wastes the Move,
* and the crew arrives with the car it was trying to leave behind.
*/
function recouplesOwnCut(s: GameState, player: PlayerIndex, i: Intent): boolean {
return i.type === 'switch.move' && ownCutFor(s, player, i.trayId, i.reverse).length > 0;
}
/** Is this player's crew sitting somewhere it can never depart from? */
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
const area = areaOf(s, player);
@@ -1238,6 +1291,26 @@ function followThrough(
}
case 'switch': {
/**
* A MOVE THAT DRAGS THE CREW'S OWN CUT BACK ON IS A WASTED MOVE, so take those off the table
* before any heuristic gets to choose one.
*
* Coupling is mandatory (§A.4), so pulling out through the end a cut is standing at picks it
* straight back up — which undoes the set-out the crew just made. Measured before this filter:
* **625 of 1,029 set-outs across 60 games were recoupled by the very next move**, 219 of them
* cars that had just been spotted at an industry. The bot was not doing anything illegal; it
* was spending two Moves to end up where it started, over and over.
*
* Filtered here rather than at each `find` below because there are six of them and a rule that
* has to be remembered at six call sites is a rule that will be forgotten at the seventh. The
* originals come back if the filter would leave the crew with no move at all — backed into a
* stub behind its own cars, the only way out IS through them, and standing still is worse.
*/
const clear = options.filter((i) => !recouplesOwnCut(s, player, i));
if (options.some((i) => i.type === 'switch.move') && clear.some((i) => i.type === 'switch.move')) {
options = clear;
}
// Re-check every Move, not just when choosing the option. Conditions change mid-turn — a car
// gets coupled, a siding fills — and once there is nothing left to do the fall-through would
// pick an arbitrary legal move and burn the remaining Moves shuttling.
@@ -1457,8 +1530,30 @@ function followThrough(
}
const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
if (clear) return because('the red Inbound box is full and blocking further unloading', clear);
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
if (stock) return because('stock a green box so a Laborer has work next Stage', stock);
/**
* PREFER THE BOX WHOSE LOAD CAN ACTUALLY BE WORKED.
*
* §6.3 lets the Freight Agent stage a load with no car spotted — cargo may wait on the dock —
* so every facility with room and a matching car in the Division Yard is now offered, not just
* the ready ones. `canStockProductively` chose the option believing a ready facility existed;
* taking the first offer regardless would spend the Stage's one Freight Agent action on some
* other industry that cannot move its load for want of a car. Staging ahead is still taken as
* a fallback: it is legal, it cannot jam anything, and it saves an action later.
*/
const stockable = options.filter(
(i): i is Extract<Intent, { type: 'freightAgent.stockOutbound' }> =>
i.type === 'freightAgent.stockOutbound',
);
const workable = stockable.find((i) => stockCanBeWorked(s, player, i.at, i.carType));
const stock = workable ?? stockable[0];
if (stock) {
return because(
workable
? 'stock a green box whose load a Laborer can start next Stage'
: 'stage a load ahead of the car — it waits in the green box until one is spotted',
stock,
);
}
/**
* NEVER UNJAM A HEALTHY BOX.
*
@@ -1508,7 +1603,7 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
const spotted = f.industryTrack.cars.length;
if (spotted >= f.industryTrack.length) continue;
if (spotted >= MAX_CONSIST) continue;
// Every commodity the industry handles, asked of its profile. A hand-written switch here had
// drifted from the sheet: it named two industries that do not exist (`produceShed`,
// `oilRefinery`) and omitted three that do (`freightHouse`, `refinery`, `packingSheds`), so
@@ -1537,12 +1632,12 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
* Would spotting this car here do any work?
*
* An OUTBOUND facility needs an EMPTY car of its own commodity to load onto; an INBOUND one needs
* a LOADED car of its commodity to unload. Anything else merely consumes a slot on a finite
* industry track (§9.3).
* a LOADED car of its commodity to unload. Anything else merely consumes room on the industry's
* track, which holds four cars like any other card (§9.3).
*/
function facilityWants(f: Facility, car: RollingStock): boolean {
if (f.kind !== 'freight') return false;
if (f.industryTrack.cars.length >= f.industryTrack.length) return false;
if (f.industryTrack.cars.length >= MAX_CONSIST) return false;
if (!facilityCarTypes(f).includes(car.type)) return false;
if (!car.loaded && f.allows.outbound) return true;
if (car.loaded && f.allows.inbound) return true;