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
+68 -6
View File
@@ -91,17 +91,79 @@ function switchCandidates(s: GameState, player: PlayerIndex): Intent[] {
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
}
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
// so a UI may submit an arbitrary permutation.
/**
* Small Yard: enumerating every permutation would explode, so offer the useful ones — bringing
* each car to the droppable end, plus a full reversal. `check` validates any order, so a UI may
* submit an arbitrary permutation.
*
* NOTHING THAT RE-ORDERS NOTHING. Bringing the LAST car to the end is the identity, and a
* two-car train's reversal repeats its only real option — so the menu carried a move that spent
* one of six Moves to leave the train exactly as it was, beside a duplicate of the move next to
* it. Both were invisible while the labels were index lists (playtest, 2026-09-17); both are
* plainly wrong once the label reads as a train. Filtered by the ORDER rather than by the case
* that produced it, so a new generator cannot reintroduce either.
*/
const n = tray.consist.length;
const identity = [...Array(n).keys()];
if (n > 1) {
const orders: number[][] = [];
for (let k = 0; k < n; k++) {
const order = [...Array(n).keys()].filter((x) => x !== k);
const order = identity.filter((x) => x !== k);
order.push(k);
out.push({ type: 'switch.sortConsist', trayId, order });
orders.push(order);
}
orders.push([...identity].reverse());
// Nothing that re-orders nothing: the current train is the one thing on offer that costs a
// Move and changes the board not at all. Keyed by (order, engine position) together, since
// since 2026-09-17 the same car order at a different engine position is a different train.
const seen = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
const offer = (order: number[], engineAt: number): void => {
const key = `${order.join(',')}|${engineAt}`;
if (seen.has(key)) return;
seen.add(key);
out.push({ type: 'switch.sortConsist', trayId, order, engineAt });
};
// The car orders, each leaving the engine on the nose — the Small Yard's ordinary use.
for (const order of orders) offer(order, 0);
/**
* A MADE-UP ORDER IS ALWAYS AMONG THESE, which is worth saying because it looks as though it
* might not be (Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the
* back").
*
* A yard sort serves two errands — pulling one car out to an end so it can be spotted, and
* putting the train back together to leave — and the orders above are written for the first.
* They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car,
* so bringing the CABOOSE to the tail is always one of them, and with the engine on the nose
* that is a train §8.2 will let out of the Office.
*
* An explicit "make it up to leave" option was written here and deleted: it produced exactly
* the k-is-the-caboose order and was dropped by the dedupe every time. The one case where no
* made-up order appears is a train that is ALREADY made up, where such an option would be the
* identity — and the labels say which is which, so a player can see that every offer would
* break a train that is currently fit to run.
*/
}
/**
* WHERE THE ENGINE GOES, as its own short list rather than multiplied through the one above
* (Jesse's call, 2026-09-17: "a separate engine control").
*
* Offering every car order at every engine position is the honest enumeration and it is
* unreadable: a four-car consist would go from four options to twenty, which is the labelling
* problem that prompted all of this. So the engine positions are offered against the consist AS
* IT STANDS — pick an order, or pick where the engine sits, each one Move. A player who wants
* both spends two, which is the same price the yard charges for any second sort.
*
* OFFERED FOR A ONE-CAR TRAIN TOO, unlike the car orders: a single car ahead of the engine or
* behind it is exactly the difference between shoving it into a facing industry and pulling it.
*/
if (n >= 1) {
const seenEngine = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
for (let k = 0; k <= n; k++) {
const key = `${identity.join(',')}|${k}`;
if (seenEngine.has(key)) continue;
seenEngine.add(key);
out.push({ type: 'switch.sortConsist', trayId, order: identity, engineAt: k });
}
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
}
}
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.