v0.1.0 — undo, the crew's reach on the board, workers on the card, and four engine bugs

Fifteen items from two playtest sessions. Three that read as drawing faults were engine
bugs: cars could be added to a train that was not being made up (50 offers in 8 games),
the make-up panel merged two trains and could couple a car to the wrong one, and an
Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel
inside a coordinate's own value range — a Mainline placement travelling as row -1, which
is an ordinary district row.

Trains are now drawn the way they stand: west on the left, nose toward the way the engine
faces, on both the district card and the Division chip. Undo steps back through the game
by replaying the save without its last intent. The switching walk keeps its rejections, so
the board can say why a square is not offered. Laborers and Porters are on the card, and
the rule that a district only grows outwards is finally written down.

Versions start here: third digit for fixes, second for a feature set, 1.0 for a release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
This commit is contained in:
Jesse
2026-08-08 05:23:17 -04:00
co-authored by Claude Opus 5
parent f4c0f49604
commit 5d825b97d2
20 changed files with 1604 additions and 191 deletions
+87 -10
View File
@@ -323,12 +323,57 @@ export function reachableDestinations(
start: GridCoord,
initialExit: Port,
): MoveDestination[] {
const { area, occupancy } = ctx;
const startCard = cardAt(area, start);
if (!startCard) return [];
if (!hasPort(startCard, initialExit)) return [];
return exploreMoves(ctx, start, initialExit).destinations;
}
/**
* A card the walk reached and refused, with the rule that refused it, in a player's words.
*
* `kind` separates the OBSTRUCTIONS — another train, a locked industry, a consist that would
* overfill, rails that do not meet — from `noStopping`, which is not an obstruction at all: a train
* runs through a turnout freely and simply may not finish a Move on one. They are drawn differently
* and only the obstructions are worth listing in the "why nothing is moving" panel, where every
* turnout in the district would otherwise appear.
*/
export type MoveBlockKind = 'noJoin' | 'occupied' | 'locked' | 'tooManyCars' | 'noStopping';
export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
/**
* THE SAME WALK, KEEPING ITS REJECTIONS.
*
* `reachableDestinations` answers "where may I go", and the UI could show that much — but a player
* looking at a siding two cards away and no button for it is asking the opposite question, and the
* answer was nowhere on screen. Reported as "trains seem to be blocked from moving onto industry in
* certain conditions; make it clear what those conditions are".
*
* Both answers come out of ONE traversal on purpose. A second function that worked out why a square
* was missing would be a second implementation of the movement rules, and the failure mode is a
* reason that does not match the refusal — worse than no reason at all.
*
* A block is recorded for every card the walk actually touched and turned down, plus every card
* beyond a reachable one whose rails do not meet. Squares the walk never came near are not listed:
* "there is no track between here and there" is not news.
*/
export function exploreMoves(
ctx: MoveContext,
start: GridCoord,
initialExit: Port,
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
const { area, occupancy } = ctx;
const results: MoveDestination[] = [];
const blocked: MoveBlock[] = [];
const noted = new Set<string>();
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
const k = coordKey(coord);
if (noted.has(k)) return;
noted.add(k);
blocked.push({ coord, kind, why });
};
const startCard = cardAt(area, start);
if (!startCard) return { destinations: results, blocked };
if (!hasPort(startCard, initialExit)) return { destinations: results, blocked };
const seen = new Set<string>();
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
@@ -337,7 +382,10 @@ export function reachableDestinations(
// hop is checked at the push site below, where both sides of the edge are in hand.
const first = neighbour(start, initialExit);
const firstCard = cardAt(area, first);
if (!firstCard || !joins(startCard, initialExit, firstCard)) return [];
if (!firstCard || !joins(startCard, initialExit, firstCard)) {
if (firstCard) block(first, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
return { destinations: results, blocked };
}
const queue: Frontier[] = [
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
];
@@ -352,7 +400,16 @@ export function reachableDestinations(
const occupant = occupancy.trayAt(node.coord);
if (occupant !== null && occupant !== ctx.self) {
const isOffice = sameCoord(node.coord, area.officeCoord);
if (!isOffice || occupancy.freeAdTracks() <= 0) continue;
if (!isOffice || occupancy.freeAdTracks() <= 0) {
block(
node.coord,
'occupied',
isOffice
? 'the Office is full — every A/D track is taken, so there is no room to enter or pass through (§A.4)'
: 'another train is standing here — two trains may not share a card, or move through each other (§A.4)',
);
continue;
}
}
if (!hasPort(card, node.entry)) continue;
@@ -361,12 +418,22 @@ export function reachableDestinations(
// stops a train FINISHING here; a turnout is not Operational Rail either and trains run through
// one all day. Without this a crew rolled straight over a locked industry, and coupled the cars
// spotted on it on the way past — the two things the safety lockout exists to prevent.
if (isLockedByWork(card)) continue;
if (isLockedByWork(card)) {
block(node.coord, 'locked', 'MEN AT WORK — a load is on the sign, so this industry track is locked for safety: no train may enter it or cross it, and no car may be picked up or set out (§9.3)');
continue;
}
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
// overfill the tray is illegal, not a move that picks up fewer cars.
const couples = [...node.couples, ...carsOn(card)];
if (ctx.consistSize + couples.length > MAX_CONSIST) continue;
if (ctx.consistSize + couples.length > MAX_CONSIST) {
block(
node.coord,
'tooManyCars',
`too many cars — running here couples ${couples.length} standing car(s) onto a train already holding ${ctx.consistSize}, and ${MAX_CONSIST} is the limit. Coupling is mandatory: you may not run past a car and leave it (§A.4)`,
);
continue;
}
const key = `${coordKey(node.coord)}|${node.entry}`;
if (seen.has(key)) continue;
@@ -374,6 +441,10 @@ export function reachableDestinations(
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
} else if (!sameCoord(node.coord, start)) {
// Reached, crossable, but not somewhere a train may STOP — a turnout carries no wheel icon
// (§A.1). Trains run through one all day; they just cannot finish a Move on it.
block(node.coord, 'noStopping', 'a train may run through here but not stop — this is not Operational Rail (§A.1), so it cannot be the end of a Move');
}
for (const exit of exitsFrom(card, node.entry)) {
@@ -381,13 +452,19 @@ export function reachableDestinations(
// hand. Enqueueing on `hasPort` alone routed trains across a 45° leg that bent back on itself.
const to = neighbour(node.coord, exit);
const next = cardAt(area, to);
if (!next || !joins(card, exit, next)) continue;
if (!next || !joins(card, exit, next)) {
if (next) block(to, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
continue;
}
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
}
}
return results;
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
// from a bad angle first and a good one later.
const reached = new Set(results.map((r) => coordKey(r.coord)));
return { destinations: results, blocked: blocked.filter((b) => !reached.has(coordKey(b.coord))) };
}
/** Both directions at once — what the UI highlights when a tray is selected. */