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
+110 -9
View File
@@ -20,6 +20,8 @@ import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
import { badlyMadeUp } from '../engine/advance.ts';
import type { CrewTray } from '../engine/state.ts';
// ---------------------------------------------------------------------------
// Small formatters
@@ -50,6 +52,23 @@ export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string {
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
}
/**
* "a loaded boxcar", "an empty tank" — the article the word actually takes.
*
* The make-up line hard-coded "a" and produced "a empty tank" at the table. Vowel-initial is the
* whole rule here: every car word is ordinary English ('empty', 'loaded', and the car types), so
* there is no 'an hour' case to special-case and inventing one would be the more fragile choice.
*/
export function indefinite(label: string): string {
return `${/^[aeiou]/i.test(label) ? 'an' : 'a'} ${label}`;
}
/** "Train 10" / "Extra X18" — one spelling of a train's name for every line that mentions one. */
export function trainLabel(trainNumber: number | null, isExtra: boolean): string {
if (trainNumber === null) return 'the local crew';
return isExtra ? `Extra X${trainNumber}` : `Train ${trainNumber}`;
}
export function carsLabel(cars: RollingStock[]): string {
if (cars.length === 0) return 'nothing';
return cars.map(carLabel).join(', ');
@@ -108,11 +127,22 @@ export type NarrateContext = {
* events has no roster, and "Player 2" is a truthful fallback rather than a broken one.
*/
playerName?: (player: PlayerIndex) => string;
/**
* Names the Facility standing on one of a player's squares, or null where there is none.
*
* Switching lines used to give the bare coordinate — "Set out a loaded hopper at (-1,1)" — which
* is the grid's own notation and means nothing at a table where people are looking at cards. The
* industry is the whole point of the move, so it is what the line should say.
*/
facilityAt?: (player: PlayerIndex, at: GridCoord) => string | null;
};
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
// The industry on a square when there is one, and the coordinate when there is not — a crew works
// plain track too, and "at nowhere" would be worse than the notation.
const place = (player: PlayerIndex, c: GridCoord): string => ctx.facilityAt?.(player, c) ?? at(c);
switch (e.type) {
// -- clock
@@ -167,7 +197,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
return {
tone: 'plain',
where: e.to,
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
// NO TUTORIAL TAIL. "The crew chip on the grid carries the whole train with it" was appended
// to EVERY move — six times a turn, and the opener (`localOpsOptionChosen`) already says it
// once. It also pushed the useful half of the line out of the caption row, which shows one
// step at a time and is the place a player reads a move as it happens.
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${place(e.player, e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of ${e.movesAllowed} Moves left`,
};
case 'carsCoupled': {
/**
@@ -179,22 +213,59 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
const own = e.recoupled?.stock.length ?? 0;
const found = e.stock.length - own;
const parts: string[] = [];
if (own > 0) parts.push(`picked its own ${carsLabel(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
if (found > 0) parts.push(`coupled ${carsLabel(e.stock.slice(own))} standing on the line`);
// `a loaded tank` for one, a bare list for several — "took loaded tank" reads as a telegram.
const some = (cars: RollingStock[]): string =>
cars.length === 1 ? indefinite(carLabel(cars[0]!)) : carsLabel(cars);
if (own > 0) parts.push(`picked its own ${some(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
if (found > 0) parts.push(`took ${some(e.stock.slice(own))} standing there`);
return {
tone: 'plain',
where: e.at,
text:
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
// "1 car(s)" was the plural of a machine. The count is already implied by the cars named
// in `parts`, so the sentence leads with where and which end instead.
`Coupled at ${place(e.player, e.at)}, ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
` — ${parts.join(', and ')}`,
};
}
case 'consistSorted':
case 'consistSorted': {
/**
* WHERE THE ENGINE ENDED UP, and whether the train can still run.
*
* ASKED OF `badlyMadeUp` RATHER THAN RE-DECIDED HERE, which matters because the obvious guess
* is wrong: §8.2 is enforced direction-free, so a train with its WHOLE consist ahead of the
* engine is a pushing train and perfectly fit to leave. What it may not be is broken-backed,
* with the engine buried among its own cars. A copy of that rule in the narrator would have
* told a player their pushing train was stranded when it was not.
*/
const ahead = e.engineAt;
const unfit = badlyMadeUp({ consist: e.after, engineAt: e.engineAt } as CrewTray);
const where =
ahead === 0
? 'so the right car is now on the end and can be spotted'
: unfit === null
? `with the whole consist AHEAD of the engine — it runs as a pushing train`
: `with ${ahead} car${ahead === 1 ? '' : 's'} ahead of the engine — ${unfit}, so it is held at the Office until it is sorted again (§8.2)`;
return {
tone: 'good',
where: e.at,
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], ${where}`,
};
}
case 'switchingEnded': {
/**
* The line that closes a switching turn, and the only one the history keeps from the middle of
* it: what it cost, and where the crew was left standing.
*/
const used = `${e.movesUsed} of ${e.movesAllowed} Move${e.movesAllowed === 1 ? '' : 's'} used`;
if (e.movesUsed === 0) return { tone: 'quiet', text: 'Finished switching without moving a car' };
if (!e.lastMove) return { tone: 'plain', text: `Finished switching — ${used}` };
return {
tone: 'plain',
where: e.lastMove.to,
text: `Finished switching — ${used}, leaving ${train(e.lastMove.trayId)} at ${place(e.player, e.lastMove.to)}`,
};
}
case 'carsDropped':
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
// the train may pull away from cars set out behind it and must couple back up to cars set out
@@ -203,7 +274,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'plain',
where: e.at,
text:
`Set out ${carsLabel(e.stock)} at ${at(e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
`Set out ${carsLabel(e.stock)} at ${place(e.player, e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
};
// -- cards
@@ -427,9 +498,39 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
};
}
case 'carPlacedOnTrain':
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
// NAMES THE TRAIN. "the train being made up" was true and useless: a player looking back for
// what happened to train 10 found four lines that never said 10 (playtest, 2026-09-16).
return {
tone: 'plain',
text: `Added ${indefinite(carLabel(e.stock))} to ${trainLabel(e.trainNumber, e.isExtra)}`,
};
case 'carPassed':
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
return {
tone: 'quiet',
text: `Passed on ${trainLabel(e.trainNumber, e.isExtra)} — no suitable car in the Division Yard`,
};
case 'makeUpShort': {
// What it wanted, in the words the card uses, so the line can be checked against the card.
const names: Record<'freight' | 'coach' | 'caboose', string> = {
freight: 'freight car',
coach: 'coach',
caboose: 'caboose',
};
const wants = e.missing.map((m) => names[m]).join(' or ');
const got = e.placed === 0 ? 'NO CARS AT ALL' : `only ${e.placed} of the ${e.wanted} its card calls for`;
// §2.2 is the whole explanation and it is not guessable from the board: the cars are visible
// in the Classification Yard, and why they will not come back is not.
const why =
e.waiting > 0
? ` ${e.waiting} sit in the Classification Yard, which comes back only when the Division Yard is bare — and it still holds ${e.divisionYardHolds} cars.`
: ' There are none in the Classification Yard either.';
return {
tone: 'bad',
text:
`${trainLabel(e.trainNumber, e.isExtra).toUpperCase()} WAS MADE UP WITH ${got} — the ` +
`Division Yard holds no ${wants} it can take, so nobody was asked for one.${why}`,
};
}
case 'dispatchBonusUsed':
return {
tone: 'good',
+60 -3
View File
@@ -10,7 +10,7 @@
* drift into two different pictures of the same board.
*/
import { isExpedited, regionOfTransit } from '../engine/advance.ts';
import { badlyMadeUp, isExpedited, regionOfTransit } from '../engine/advance.ts';
import {
areaAtSeat,
areaOf,
@@ -1036,8 +1036,65 @@ export function describeIntent(s: GameState, i: Intent): string {
const end = i.fromNose ? 'off the front' : 'off the back';
return `set out ${carsLabel(cut)} ${end}`;
}
case 'switch.sortConsist':
return `re-order consist [${i.order.join(',')}]`;
case 'switch.sortConsist': {
/**
* THE TRAIN IT WOULD MAKE, DRAWN THE WAY THE BOARD DRAWS IT.
*
* This read `re-order consist [1,2,3,0]` — the engine's own array indices offered to a person
* — and the option Jesse wanted was the first of five and unidentifiable (playtest,
* 2026-09-17). Naming the cars fixed that and left a second ambiguity he caught immediately:
* a list "front to back" means nothing at a table looking at a map, because which end is the
* front depends on which way the train is pointed.
*
* SO IT IS LAID OUT WEST TO EAST, exactly as `board-svg.ts` lays the crew strip: the consist
* is stored nose first, and a train facing EAST is reversed so its nose lands at the east end
* where it actually is. The engine is the same ◀ / ▶ arrow the board uses, seated where it
* will be, so "ahead of the engine" and "behind the engine" are read off the picture rather
* than asserted in words — and the button and the board cannot disagree.
*/
const sorting = s.trays.get(i.trayId);
if (!sorting) return `re-order consist [${i.order.join(',')}]`;
const after = i.order.map((n) => sorting.consist[n]!).filter((c) => c !== undefined);
const engineAt = i.engineAt ?? 0;
const facing = railFacingOf(sorting);
const items = after.map((c) => carLabel(c));
items.splice(engineAt, 0, facing === 'w' ? '◀ ENGINE' : 'ENGINE ▶');
// West on the left, like the map and like the crew strip on the board.
const strip = (facing === 'e' ? [...items].reverse() : items).join(' · ');
/**
* WHAT IT WOULD MEAN, from §8.2's own predicate rather than a copy of it: a train with its
* whole consist ahead of the engine is a PUSHING train and fit to run, a buried engine is not,
* and a caboose has to ride at the end away from the engine. Numbered trains only — a local
* crew has no card and never departs, so a departure verdict on one is noise.
*/
const unfit =
sorting.trainNumber === null
? null
: badlyMadeUp({ ...sorting, consist: after, engineAt });
// `badlyMadeUp` leads with "not made up — ", which reads as a stutter in front of HELD. The
// reason after it is the part worth showing, so the prefix comes off.
const because = unfit?.replace(/^not made up — /, '') ?? '';
const verdict =
sorting.trainNumber === null
? ''
: unfit === null
? ' · MADE UP, ready to leave'
: ` · HELD at the Office: ${because}`;
// A sort that only moves the engine says which errand it is running, rather than reprinting a
// car order that has not changed.
const sameOrder = i.order.every((n, at) => n === at);
const lead = sameOrder
? engineAt === 0
? 'pull the engine back to the front'
: engineAt === after.length
? 'put the whole consist ahead of the engine'
: `move the engine behind ${engineAt} car${engineAt === 1 ? '' : 's'}`
: 're-order';
return `${lead} — west to east: ${strip}${verdict}`;
}
case 'freightAgent.stockOutbound': {
/**
* "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly