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
+154 -11
View File
@@ -230,10 +230,37 @@ export type IndustryProfile = {
export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
/**
* BOTH DIRECTIONS, per the card reference — this was `outbound` and it contradicted the rules.
*
* `card-reference.md`: "Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4", and in prose — "'Freight
* House' is not a card. It is the collective term for a freight facility that loads *and* unloads
* — the Grocer's Warehouse and the Oil Refinery." §9.3's "Passenger Facilities and Freight Houses
* permit cars to move each direction" therefore names exactly these two, and the engine had both
* of them one-way.
*
* The consequence was silent: `usableGrant` drops a Modifier's grant on a direction its host
* cannot use, so every +1 inbound beside a Refinery went nowhere.
*
* The base numbers stay at the engine's own scale (1 per direction it allows) rather than the card
* reference's 2/2 — every industry here is scaled down the same way, Mine Tipple included, and
* raising one of them alone would be a balance change rather than a correction. Flagged in TODO.
*/
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
/**
* BOTH DIRECTIONS — see the Refinery above; "Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3".
*
* Reported from play: "grocer's warehouse didn't get extra outbound slot for truck dock." It could
* not: the Truck Dock printed +1 outbound at the time and this was `flow: 'inbound'`, so the grant
* was dropped on a direction the facility did not have. The same trap still swallows an Ice House
* set beside a Grocer's that has been left one-way.
*
* `TODO.md` had previously recorded this as "checked, and there is no bug" on the reasoning that a
* Grocer's is inbound-only. That premise was the bug.
*/
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
];
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
@@ -280,7 +307,20 @@ export const MODIFIER_PROFILES: readonly ModifierProfile[] = [
{ kind: 'restaurant', name: 'Restaurant', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 2 },
{ kind: 'hotel', name: 'Hotel', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 1 },
// Freight House / Packing Sheds / Grocer's
{ kind: 'truckDock', name: 'Truck dock', hosts: ['freightHouse', 'packingSheds', 'grocersWarehouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 },
/**
* INBOUND, AND NO LABORER — the one Modifier that helps a facility RECEIVE.
*
* It printed "+1 outbound, +1 Laborer" like every other freight Modifier, which made it a
* duplicate of Forklifts with a longer host list. A dock is where a truck backs up to take
* delivery, so it earns its own line in the deck by adding the red box instead of the green one —
* and pays for it by bringing no man to work it.
*
* Two consequences, both intended. `usableGrant` drops an inbound grant on a host that only ships,
* so beside **Packing Sheds** this card now does nothing at all — the hand tooltip says so before
* it is played. And it is the first Modifier that grants no worker, so a facility's laborer count
* no longer rises with every card set beside it.
*/
{ kind: 'truckDock', name: 'Truck dock', hosts: ['freightHouse', 'packingSheds', 'grocersWarehouse'], addOut: 0, addIn: 1, addLoaders: 0, addPorters: 0, copies: 2 },
{ kind: 'railroadExpressAgency', name: 'Railroad Express Agency', hosts: ['freightHouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
{ kind: 'forklifts', name: 'Forklifts', hosts: ['freightHouse', 'packingSheds'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 },
// Mine Tipple
@@ -402,6 +442,22 @@ export const EXTRA_TRAINS: readonly TrainProfile[] = [
export const ALL_TRAINS: readonly TrainProfile[] = [...TIMETABLED_TRAINS, ...EXTRA_TRAINS];
/**
* §2.3 — ODD RUNS WEST, EVEN RUNS EAST. The number is the direction, for an Extra as much as for a
* timetabled train, and the Division Point it starts at is therefore the one it runs away from.
*
* Extras print `direction: 'playerChoice'`, which the engine read as "always eastbound from the West
* Division Point". Jesse's ruling: the number decides, like everything else on the timetable.
*/
export function runDirection(trainNumber: number): Direction {
return trainNumber % 2 === 0 ? 'east' : 'west';
}
/** The Division Point a train of this number starts from — the opposite end to the way it runs. */
export function startingDivisionPoint(trainNumber: number): Direction {
return runDirection(trainNumber) === 'east' ? 'west' : 'east';
}
export function trainProfile(number: number, isExtra: boolean): TrainProfile | null {
return ALL_TRAINS.find((t) => t.number === number && t.isExtra === isExtra) ?? null;
}
@@ -503,6 +559,59 @@ export function crossingStages(
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
}
/**
* WHAT THIS MAINLINE CARD DOES TO A TRAIN, in a sentence.
*
* Reported from play: "mainline cards need a tooltip stating what they do. Hilly and Uncontrolled
* Siding — I have no idea the impact they have on game play." Both are invisible without one: Hilly
* charges freight double what it charges passengers, and Uncontrolled Siding is one of only two
* cards where a following train is not stuck behind a slower one.
*
* The crossing times are COMPUTED by `crossingStages` rather than written out, so a tooltip cannot
* drift from the rule it describes — including the Hilly split, which is the whole point of the
* card and is decided by whether the train carries a coach.
*/
export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'east'): string {
const p = mainlineProfile(kind);
const stages = (n: number): string => `${n} Stage${n === 1 ? '' : 's'}`;
const parts: string[] = [];
if (p.speed.kind === 'byTrainType') {
// Hilly. The split is by CONSIST, not by the train's speed class: anything with a coach on it
// takes the passenger figure.
parts.push(
`P${p.speed.passenger} / F${p.speed.freight} — a train carrying ANY coach crosses as a ` +
`${p.speed.passenger} (${stages(crossingStages(kind, 'fast', true))} for a fast train), and a ` +
`freight-only train as a ${p.speed.freight} (${stages(crossingStages(kind, 'fast', false))}). ` +
`A slow train adds one Stage either way.`,
);
} else if (p.speed.kind === 'grade') {
parts.push(
`A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. It crosses as a 30 — ` +
`${stages(crossingStages(kind, 'fast', false, [], gradeUp, gradeUp))} for a fast train, and one ` +
`more for a slow one. Brakeman and Airbrakes each take a Stage off a train running DOWNHILL; ` +
`Helpers takes one off a train running UPHILL. Never below one Stage.`,
);
} else {
parts.push(
`${p.speed.value} — ${stages(crossingStages(kind, 'fast', false))} for a fast train, ` +
`${stages(crossingStages(kind, 'slow', false))} for a slow one.`,
);
}
if (p.trainsMayPass) {
parts.push(
'TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held ' +
'behind a slower one. Only this and the Double Track allow it.',
);
} else {
parts.push('One train at a time — anything following has to wait for it to clear.');
}
if (p.sortsCars) parts.push('Cars may be sorted into any new order here.');
return parts.join(' · ');
}
/**
* Q11, answered: the Heavy Grade card prints "(Up)" and "Player sets orientation", so which way it
* climbs is a property of the placed card, not a constant. `gradeUp` is the direction a train is
@@ -565,7 +674,22 @@ export const REALIGNMENTS: readonly { from: MainlineKind; to: MainlineKind }[] =
// The four new card categories
// ---------------------------------------------------------------------------
export type SimpleCard = { key: string; name: string; copies: number; placement: string; effect: string };
export type SimpleCard = {
key: string;
name: string;
copies: number;
placement: string;
effect: string;
/**
* The opponent-directed card this exists SOLELY to answer.
*
* A defence with nothing to defend against is a dead draw, exactly as the attack itself would be.
* The 22 Space-use and Action cards are held out of every deck until they are implemented (Q6),
* and these go with them — named here rather than in a list somewhere else so the pairing is
* visible on the card, and so they come back together when their attacker does.
*/
answers?: string;
};
/** "Burns tablespace" — occupies a grid cell and does nothing useful. Semantics pending §10 Q6. */
export const SPACE_USE_CARDS: readonly SimpleCard[] = [
@@ -672,11 +796,11 @@ export function enhancementRule(key: string): EnhancementRule | null {
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
{ key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.' },
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
{ key: 'waterColumn', name: 'Water column', copies: 2, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.' },
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.' },
{ key: 'waterColumn', name: 'Water column', copies: 2, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
{ key: 'telegraph', name: 'Telegraph', copies: 3, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' },
{ key: 'telephone', name: 'Telephone', copies: 2, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' },
{ key: 'radio', name: 'Radio', copies: 2, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' },
@@ -688,7 +812,7 @@ export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
{ key: 'airbrakes', name: 'Airbrakes', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill. Brakeman must be in effect.' },
{ key: 'helpers', name: 'Helpers', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage uphill.' },
{ key: 'realignment', name: 'Realignment', copies: 2, placement: 'a Mainline card', effect: 'Convert one Mainline type to another. Not while a train is on it.' },
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.' },
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.', answers: 'Derail' },
];
/**
@@ -915,6 +1039,12 @@ export function isOpponentOnly(category: string): boolean {
return OPPONENT_ONLY_CATEGORIES.includes(category);
}
/** How many cards `DEFENCE_ONLY_CARDS` accounts for — 7: two Facing Point Locks of each kind, two
* Water Columns and one Overpass. */
export const DEFENCE_ONLY_COPIES =
ENHANCEMENT_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0) +
MAINLINE_MODIFIER_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0);
export function deckComposition(): { category: string; count: number }[] {
const sum = (xs: readonly { copies: number }[]): number => xs.reduce((n, x) => n + x.copies, 0);
return [
@@ -945,6 +1075,19 @@ export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make ~9%
* of draws reject. See `buildDeck`.
*/
export const SOLITAIRE_DECK_SIZE = deckComposition()
.filter((c) => !isOpponentOnly(c.category))
.reduce((n, c) => n + c.count, 0);
export const SOLITAIRE_DECK_SIZE =
deckComposition()
.filter((c) => !isOpponentOnly(c.category))
.reduce((n, c) => n + c.count, 0) - DEFENCE_ONLY_COPIES;
/**
* Every card held back BECAUSE its attacker is held back — see `SimpleCard.answers`.
*
* Reported from play: "just like the opponent directed cards are removed from the solitaire game,
* remove any of the defensive cards whose only purpose is to answer them. No need to have them in
* the deck when they can never be used."
*/
export const DEFENCE_ONLY_CARDS: readonly SimpleCard[] = [
...ENHANCEMENT_CARDS.filter((c) => c.answers),
...MAINLINE_MODIFIER_CARDS.filter((c) => c.answers),
];