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:
+82
-9
@@ -17,6 +17,7 @@ import {
|
||||
facilityCarType,
|
||||
laborersLeft,
|
||||
movesFor,
|
||||
ownCutFor,
|
||||
portersLeft,
|
||||
} from '../engine/apply.ts';
|
||||
import {
|
||||
@@ -39,10 +40,11 @@ import {
|
||||
officeProfile,
|
||||
trainProfile,
|
||||
houseRules,
|
||||
mainlineDescription,
|
||||
} from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import { playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||
import { carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||
@@ -103,6 +105,18 @@ export type CellView = {
|
||||
*/
|
||||
ad: { used: number; of: number } | null;
|
||||
cars: string[];
|
||||
/**
|
||||
* Cars in `cars` that stand WEST of the train on this card — 0 when no train is here.
|
||||
*
|
||||
* `cars` runs west to east like the state it comes from, and a train standing on the card sits
|
||||
* somewhere IN that row rather than beside it. Reported from play: "right after dropping my cars I
|
||||
* need to be able to see if those cars are ahead or behind the train" — and the board drew the
|
||||
* whole cut in one left-aligned strip at the bottom of the card, which cannot answer that at all.
|
||||
*
|
||||
* Combined with `train.facing` it is the answer: for an east-facing engine the cars east of it are
|
||||
* the ones ahead, and the split is drawn as a gap the chip sits in.
|
||||
*/
|
||||
standingWest: number;
|
||||
facility: FacilityView | null;
|
||||
/**
|
||||
* The port pairs this card joins, as two-letter codes — 'ew' for the through track, and 'ne',
|
||||
@@ -125,7 +139,8 @@ export type FacilityView = {
|
||||
/**
|
||||
* Freight or passengers — the one thing the renderers could not previously ask.
|
||||
*
|
||||
* Without it they guessed from side effects (`trackCap > 0`, `laborers` starting "0/0"), and one
|
||||
* Without it they guessed from side effects (a non-empty industry track, `laborers` starting
|
||||
* "0/0"), and one
|
||||
* of the three simply did not guess at all, so a Depot drew three MEN | AT | WORK boxes it has no
|
||||
* Laborer to work. Reported from playtesting.
|
||||
*/
|
||||
@@ -138,8 +153,15 @@ export type FacilityView = {
|
||||
maw: (string | null)[];
|
||||
red: string[];
|
||||
redCap: number;
|
||||
/**
|
||||
* Cars standing on the industry's track, and nothing about how many more will fit.
|
||||
*
|
||||
* `trackCap` used to ride alongside this and every renderer drew that many empty squares — a
|
||||
* printed siding, as long as the industry's box count. No industry card prints one, and the
|
||||
* number was wrong anyway: a card holds four cars like any other. Both the field and the squares
|
||||
* are gone; the room left is `spaceOn`'s business, not a drawing's.
|
||||
*/
|
||||
track: string[];
|
||||
trackCap: number;
|
||||
laborers: string;
|
||||
porters: string;
|
||||
/**
|
||||
@@ -258,6 +280,14 @@ export type DivisionView = {
|
||||
* on that grade has no visible meaning.
|
||||
*/
|
||||
gradeUp: string | null;
|
||||
/**
|
||||
* Mainline cards only: what the card does to a train, from `mainlineDescription`.
|
||||
*
|
||||
* "I have no idea the impact Hilly and Uncontrolled Siding have on game play" — and there was
|
||||
* nowhere to find out: the tip carried the card's NAME and its modifiers and nothing about the
|
||||
* crossing time or whether trains may pass.
|
||||
*/
|
||||
what?: string;
|
||||
/** Mainline cards only: how many regions the card is divided into (§2.1 — two). */
|
||||
regions?: number;
|
||||
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
|
||||
@@ -479,7 +509,6 @@ function facilityView(
|
||||
red: f.inboundBox.map(carLabel),
|
||||
redCap: f.capacity.inbound,
|
||||
track: f.industryTrack.cars.map(carLabel),
|
||||
trackCap: f.industryTrack.length,
|
||||
laborers: `${laborersLeft(f)}/${f.laborers}`,
|
||||
porters: `${portersLeft(f)}/${f.porters}`,
|
||||
canFinish: canFinishHere(f),
|
||||
@@ -546,6 +575,21 @@ function baseOf(
|
||||
}
|
||||
|
||||
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
|
||||
/**
|
||||
* Where the train standing on this card sits among the cars standing on it, clamped to the row.
|
||||
*
|
||||
* Zero when there is no train, which is also the right answer for a bare cut: with nobody standing
|
||||
* there, a cut has no near or far side and the whole row simply reads west to east.
|
||||
*/
|
||||
function standingWestOn(s: GameState, key: string, cars: number): number {
|
||||
for (const t of s.trays.values()) {
|
||||
if (t.position.at !== 'grid') continue;
|
||||
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
||||
return Math.max(0, Math.min(cars, t.standingWest ?? 0));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function trainOnCard(s: GameState, key: string): CellView['train'] {
|
||||
for (const [id, t] of s.trays) {
|
||||
if (t.position.at !== 'grid') continue;
|
||||
@@ -687,7 +731,20 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
const dest = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse)
|
||||
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
||||
if (dest && dest.couples.length > 0) {
|
||||
picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`;
|
||||
/**
|
||||
* NAME THE CARS THE CREW SET OUT HERE SEPARATELY. They are at the front of `couples` — the
|
||||
* walk seeds itself with the cut at the end the train pulls out through — and a button
|
||||
* reading "couples 2 cars" over cars the player put down thirty seconds ago is exactly the
|
||||
* surprise this label exists to prevent, running the other way.
|
||||
*/
|
||||
const own = ownCutFor(s, playerAtSeat(s, tray!.position.at === 'grid' ? tray!.position.seat : 0), i.trayId, i.reverse).length;
|
||||
const end = i.reverse ? ' (behind)' : ' (onto the nose)';
|
||||
picks =
|
||||
own > 0
|
||||
? ` — takes your own ${carsLabel(dest.couples.slice(0, own))} back off this card` +
|
||||
(dest.couples.length > own ? `, then couples ${carsLabel(dest.couples.slice(own))} on the way` : '') +
|
||||
end
|
||||
: ` — couples ${carsLabel(dest.couples)} on the way${end}`;
|
||||
}
|
||||
}
|
||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}${picks}`;
|
||||
@@ -768,6 +825,16 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
return `board passengers at ${at(i.at)}`;
|
||||
case 'porter.detrain':
|
||||
return `detrain passengers at ${at(i.at)}`;
|
||||
case 'newTrain.startExtra': {
|
||||
const runs = i.trainNumber % 2 === 0 ? 'east' : 'west';
|
||||
if (i.atSeat === null) {
|
||||
const end = i.trainNumber % 2 === 0 ? 'Western' : 'Eastern';
|
||||
return `start Extra X${i.trainNumber} at the ${end} Division Point — it runs ${runs}, so that is the end it starts from`;
|
||||
}
|
||||
const tier = officeProfile(areaAtSeat(s, i.atSeat).tier).name;
|
||||
return `start Extra X${i.trainNumber} at the ${tier} in seat ${i.atSeat} — a Control Point, so it may begin its ${runs}bound run there instead`;
|
||||
}
|
||||
|
||||
case 'newTrain.placeCar':
|
||||
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
|
||||
// "add loaded caboose" on a button.
|
||||
@@ -815,9 +882,13 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
// ABS Signals exists to prevent, but no such collision is implemented — granting clearance is
|
||||
// currently free. Saying otherwise invents a consequence the engine will never deliver.
|
||||
// See implications.md §10 Q13.
|
||||
// THE TRAIN BEING RULED ON GOES ON THE BUTTON, not in the tooltip. `actionButton` splits a
|
||||
// label at the first em-dash and shows only the head, so "ALLOW — Train 7 follows…" left the
|
||||
// one thing the ruling is ABOUT — which train — behind a hover. Reported from play: the
|
||||
// Superintendent could not tell which train he was clearing without pointing at the button.
|
||||
return i.allow
|
||||
? `ALLOW — ${who} follows ${ahead} onto the same Mainline card, closing up behind it`
|
||||
: `HOLD — ${who} waits where it is, losing the Stage but keeping the line clear`;
|
||||
? `ALLOW ${who} to follow ${ahead} — onto the same Mainline card, closing up behind it`
|
||||
: `HOLD ${who} — it waits where it is, losing the Stage but keeping the line clear`;
|
||||
}
|
||||
case 'draw.fromDepartment': {
|
||||
// Naming the card is the whole point of a FACE-UP pile: "Department 2" tells a player nothing,
|
||||
@@ -933,7 +1004,8 @@ export function snapshot(
|
||||
card.geometry.kind === 'office'
|
||||
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
|
||||
: null,
|
||||
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
|
||||
cars: carsOn(card).map(carLabel),
|
||||
standingWest: standingWestOn(s, key, carsOn(card).length),
|
||||
facility: fv,
|
||||
});
|
||||
}
|
||||
@@ -1006,6 +1078,7 @@ export function snapshot(
|
||||
...(n.absSignals ? ['ABS Signals'] : []),
|
||||
],
|
||||
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
|
||||
what: mainlineDescription(n.card, n.gradeUp ?? 'east'),
|
||||
};
|
||||
}
|
||||
const oa = areaAtSeat(s, n.seat);
|
||||
@@ -1458,7 +1531,7 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
const where = onRunning
|
||||
? ' · ON THE RUNNING TRACK — industries belong on a stub; a car left standing here is hit by the next arrival'
|
||||
: '';
|
||||
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${progress}${where}`;
|
||||
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its track to work a load${progress}${where}`;
|
||||
}
|
||||
case 'track':
|
||||
switch (g.geometry) {
|
||||
|
||||
Reference in New Issue
Block a user