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:
co-authored by
Claude Opus 5
parent
ad277fb994
commit
a6657241de
+95
-16
@@ -974,6 +974,16 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const seen = new Set(i.order);
|
||||
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
|
||||
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
|
||||
/**
|
||||
* The engine may finish anywhere in the train, including with cars ahead of it (Jesse,
|
||||
* 2026-09-17). `engineAt` indexes the SORTED consist, so `consist.length` is legal and means
|
||||
* the engine on the tail with everything ahead of it — the shoving case a Small Yard exists to
|
||||
* set up. Refused outside that range rather than clamped: a clamp would silently build a
|
||||
* different train from the one the player asked for.
|
||||
*/
|
||||
if (i.engineAt !== undefined && (i.engineAt < 0 || i.engineAt > tray.consist.length)) {
|
||||
return 'CONSIST_ORDER';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1426,17 +1436,30 @@ function checkPlay(
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
||||
/**
|
||||
* NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both
|
||||
* halves.
|
||||
* NEITHER ON THE RUNNING TRACK ROW NOR OUTSIDE THE LIMITS — Jesse's call, both halves, the
|
||||
* second REVERSED on 2026-09-17 after a Day 3 playtest.
|
||||
*
|
||||
* A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility
|
||||
* standing at the limit has three of its nine spots out there, and refusing them would make
|
||||
* the card unplayable exactly where the district ends. What it may NOT do is stand in the row
|
||||
* the Running Track grows along. Inside the Limits that row is always full, so this bites only
|
||||
* beyond the sign — which is the ground the main extends onto, and a Modifier parked there
|
||||
* would block your own sign from moving outward (§2.1) with nothing on screen to warn you.
|
||||
* It used to read the other way: a Modifier is not track (§9), so unlike a siding it could
|
||||
* hang outside the Limits, because a Facility standing at the limit has three of its nine
|
||||
* spots out there and refusing them would make the card unplayable exactly where the district
|
||||
* ends. What that argument missed is what the board then shows — Transmission Lines at (-2,4)
|
||||
* with the sign at column 3 — which reads as building outside your own territory, and §8.1 and
|
||||
* §10 both reason about what lies inside a player's Limits.
|
||||
*
|
||||
* THE UNPLAYABLE CASE WAS CHECKED ON THE REPORTED MOVE, not assumed away: the Power Plant was
|
||||
* at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were free, legal and inside. Six
|
||||
* of the nine spots survive a Facility at the limit, and the sign moves outward as the Running
|
||||
* Track grows (§2.1, Gap 4a), so the ground arrives with the district.
|
||||
*
|
||||
* The Running Track row stays barred for its own reason: inside the Limits that row is always
|
||||
* full, so it bit only beyond the sign, where a Modifier would block the sign from moving
|
||||
* outward with nothing on screen to warn you. That ground is now out of bounds anyway, which
|
||||
* makes this the narrower rule rather than a redundant one — the row is barred INSIDE the
|
||||
* Limits too, where a square can fall vacant if the main is rebuilt around it.
|
||||
*/
|
||||
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
||||
// §2.1 — a district's cards belong inside its own sign, Modifiers included since 2026-09-17.
|
||||
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
|
||||
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
|
||||
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||
@@ -1680,6 +1703,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
from,
|
||||
to: i.to,
|
||||
movesRemaining: turnOf(s, player).movesRemaining - 1,
|
||||
// "3 of 6" was written with the 6 hardcoded in the narrator, which is wrong on a night
|
||||
// Stage under Reduced Visibility, where a turn gets five. The turn knows; the event carries.
|
||||
movesAllowed: turnOf(s, player).movesAllowed,
|
||||
/**
|
||||
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
|
||||
*
|
||||
@@ -1775,11 +1801,29 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
at: here,
|
||||
before: tray.consist.map((c) => ({ ...c })),
|
||||
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
||||
// Absent means the nose, which is what every sort did before 2026-09-17 — so an older save
|
||||
// replays to exactly the train it built.
|
||||
engineAt: i.engineAt ?? 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'switch.end':
|
||||
/**
|
||||
* The closing summary comes BEFORE `phaseEnded`, so the history reads as the turn ending rather
|
||||
* than as a postscript to it. Split out of the shared case below for that one line.
|
||||
*/
|
||||
case 'switch.end': {
|
||||
const turn = turnOf(s, player);
|
||||
const ended: GameEvent = {
|
||||
type: 'switchingEnded',
|
||||
player,
|
||||
movesUsed: turn.movesAllowed - turn.movesRemaining,
|
||||
movesAllowed: turn.movesAllowed,
|
||||
...(turn.lastMove ? { lastMove: turn.lastMove } : {}),
|
||||
};
|
||||
return [ended, { type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
}
|
||||
|
||||
case 'draw.end':
|
||||
case 'freightAgent.end':
|
||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
@@ -1956,18 +2000,41 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.placeCar':
|
||||
/**
|
||||
* THE TRAIN'S NUMBER RIDES ALONG (playtest, 2026-09-16: "I did not see anything in the history
|
||||
* about making up train 10 and how each person added each car to it").
|
||||
*
|
||||
* It was all there — a MADE UP line and one line per car — but every one of those lines read
|
||||
* "the train being made up", so a player scanning the history for train 10 found nothing under
|
||||
* that name. The tray id is no use to a reader and the narrator has no state to look it up in,
|
||||
* so the number travels with the event, exactly as `owner` does on `trainArrived`.
|
||||
*/
|
||||
case 'newTrain.placeCar': {
|
||||
const placeTray = s.trays.get(i.trayId);
|
||||
return [
|
||||
{
|
||||
type: 'carPlacedOnTrain',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
stock: { type: i.carType, loaded: i.loaded },
|
||||
trainNumber: placeTray?.trainNumber ?? null,
|
||||
isExtra: placeTray?.trainIsExtra ?? false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.passCar':
|
||||
return [{ type: 'carPassed', player, trayId: i.trayId }];
|
||||
case 'newTrain.passCar': {
|
||||
const passTray = s.trays.get(i.trayId);
|
||||
return [
|
||||
{
|
||||
type: 'carPassed',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
trainNumber: passTray?.trainNumber ?? null,
|
||||
isExtra: passTray?.trainIsExtra ?? false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.secondSection':
|
||||
return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];
|
||||
@@ -2141,7 +2208,10 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
}
|
||||
// Only the player sitting in this district can be switching this tray, so the Moves come off
|
||||
// their turn. The event carries no player of its own.
|
||||
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
|
||||
const mover = turnOf(s, playerAtSeat(s, seat));
|
||||
mover.movesRemaining = e.movesRemaining;
|
||||
// Where the crew was left, for the line that closes the turn — see `switchingEnded`.
|
||||
mover.lastMove = { trayId: e.trayId, to: e.to };
|
||||
|
||||
const area = areaAtSeat(s, seat);
|
||||
|
||||
@@ -2219,9 +2289,18 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'consistSorted': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
tray.consist = e.after.map((c) => ({ ...c }));
|
||||
// A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason
|
||||
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
|
||||
tray.engineAt = 0;
|
||||
/**
|
||||
* WHERE THE SORT PUT THE ENGINE — 0 on every sort before 2026-09-17, and on most of them
|
||||
* since, because putting the engine back on the nose is what a Small Yard is usually for:
|
||||
* §8.2 will not let a train leave the Office with cars in front of its engine.
|
||||
*
|
||||
* It is no longer forced. The design source (`implications.md`) has always said a train here
|
||||
* "may sort itself into any order, INCLUDING cars ahead of the engine", against a v0.4.5 card
|
||||
* text that says the engine ends at the nose; Jesse settled it for the source. A numbered
|
||||
* train left nose-loaded is held at the Office by §8.2 until it sorts again — see
|
||||
* `departureRefusal`.
|
||||
*/
|
||||
tray.engineAt = e.engineAt;
|
||||
// "Spends one move in the yard" — the sort costs a Move.
|
||||
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray)));
|
||||
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
|
||||
|
||||
Reference in New Issue
Block a user