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
+133 -22
View File
@@ -20,6 +20,7 @@ import {
LABORER_ACTIONS_PER_LOAD,
MAX_CONSIST,
REALIGNMENTS,
consistSize,
enhancementRule,
industryProfile,
mainlineModifierRule,
@@ -46,11 +47,12 @@ import type {
} from './state.ts';
import { createRng } from './rng.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
import type { Occupancy, Port } from './track.ts';
import type { MoveBlock, Occupancy, Port } from './track.ts';
import {
canDropCarsAt,
canPlaceAt,
carriesThroughTrack,
exploreMoves,
facilityVariants,
opposite,
reachableDestinations,
@@ -351,7 +353,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
const hand = s.decks.hands.get(player) ?? [];
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
return checkPlay(s, player, i.cardId, i.placement, i.variant);
return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node);
}
case 'card.discard': {
@@ -482,6 +484,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
// §7 — cars are added to the train being ASSEMBLED, at a Division Point. Any other tray is a
// train that is running, and loading one from the yard is teleporting cars onto it.
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL';
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
return 'NO_SUITABLE_CAR';
@@ -509,6 +514,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
// Same scope as placeCar: only the train being assembled has a make-up round to finish.
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
// §7 — "must make every effort to find a suitable car". A pass is only legal when none exists.
return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null;
}
@@ -572,10 +579,14 @@ function checkPlay(
cardId: string,
placement: GridCoord | undefined,
variant: number | undefined,
node?: number,
): RejectionCode | null {
const card = s.cards.get(cardId);
if (!card) return 'NO_SUCH_CARD';
const area = areaOf(s, player);
// A Division node and an Office Area square are different boards. Naming both is not a placement
// with extra detail, it is two contradictory answers to "where?".
if (node !== undefined && placement) return 'NO_PLACEMENT';
switch (card.kind.kind) {
case 'office': {
@@ -629,6 +640,12 @@ function checkPlay(
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
}
case 'enhancement': {
// ABS Signals goes out on the Mainline, so it takes a Division node and no square.
if (enhancementRule(card.kind.key)?.placement === 'mainlineCard') {
if (node === undefined) return 'NO_PLACEMENT';
const target = s.division.nodes[node];
return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED';
}
if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, card.kind.key, placement);
}
@@ -696,6 +713,46 @@ function destinationsFor(
);
}
/**
* WHERE THIS CREW MAY GO, AND WHY IT MAY NOT GO ELSEWHERE.
*
* Both directions at once, because a player is not thinking in terms of "forward" and "reverse" when
* looking at a card two squares away — a square reachable only by backing up is still reachable, and
* a reason that applies in one direction should not be reported when the other direction works.
*
* Straight out of the movement walk (`exploreMoves`), so the reasons cannot drift from the rules
* that produced them.
*/
export function movesFor(
s: GameState,
player: PlayerIndex,
trayId: TrayId,
): { to: GridCoord[]; blocked: MoveBlock[] } {
const tray = s.trays.get(trayId);
if (!tray || tray.position.at !== 'grid') return { to: [], blocked: [] };
const from = tray.position.coord;
const ctx = {
area: areaOf(s, player),
occupancy: occupancyFor(s, player, trayId),
consistSize: tray.consist.length,
self: trayId,
};
const facing = facingPort(s, trayId);
const forward = exploreMoves(ctx, from, facing);
const back = exploreMoves(ctx, from, opposite(facing));
const to = new Map<string, GridCoord>();
for (const d of [...forward.destinations, ...back.destinations]) to.set(coordKey(d.coord), d.coord);
const blocked = new Map<string, MoveBlock>();
for (const b of [...forward.blocked, ...back.blocked]) {
if (to.has(coordKey(b.coord))) continue; // reachable the other way round; not a blocker
if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b);
}
return { to: [...to.values()], blocked: [...blocked.values()] };
}
// ---------------------------------------------------------------------------
// execute — reads state, emits events, never mutates
// ---------------------------------------------------------------------------
@@ -850,13 +907,12 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
to: card.kind.tier,
});
}
if (card.kind.kind === 'enhancement' && i.placement) {
events.push({
type: 'enhancementPlaced',
player,
key: card.kind.key,
at: i.placement,
});
if (card.kind.kind === 'enhancement' && (i.placement || i.node !== undefined)) {
events.push(
i.node !== undefined
? { type: 'enhancementPlaced', player, key: card.kind.key, node: i.node }
: { type: 'enhancementPlaced', player, key: card.kind.key, at: i.placement! },
);
}
if (card.kind.kind === 'extraTrain') {
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
@@ -1205,13 +1261,32 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'officeUpgraded': {
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
const area = areaOf(s, e.player);
const from = officeProfile(e.from);
const to = officeProfile(e.to);
area.tier = e.to;
const officeCard = area.grid.get(coordKey(area.officeCoord));
if (officeCard?.facility) {
const p = officeProfile(e.to);
officeCard.facility.porters = p.porters;
officeCard.facility.capacity = { outbound: p.passengerOut, inbound: p.passengerIn };
officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility };
/**
* THE TIER IS A DELTA, NOT AN OVERWRITE.
*
* This wrote the new tier's printed numbers straight over the facility, which silently
* deleted everything a Modifier had added: a Waiting Area, Restaurant or Hotel beside the
* Office is +1 passenger out and +1 porter, and upgrading Depot → Station threw both away
* with no message, after the card had been spent. Reported from a playtest where a
* Restaurant's porter never appeared — it had appeared and then been erased.
*
* Applying the DIFFERENCE between the two tiers raises the Office by exactly what the
* upgrade is worth and leaves anything standing beside it untouched.
*/
const f = officeCard.facility;
f.porters += to.porters - from.porters;
f.capacity = {
outbound: f.capacity.outbound + (to.passengerOut - from.passengerOut),
inbound: f.capacity.inbound + (to.passengerIn - from.passengerIn),
};
// Becoming a Passenger Facility at all is a state change, not a delta (Gap 8): a Whistle
// Post has no passenger boxes to add to.
f.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
}
break;
}
@@ -1263,12 +1338,11 @@ export function reduce(s: GameState, e: GameEvent): void {
}
case 'enhancementPlaced': {
const rule = enhancementRule(e.key);
if (rule?.placement === 'mainlineCard') {
const node = s.division.nodes[e.at.col];
if (e.node !== undefined) {
const node = s.division.nodes[e.node];
// ABS Signals: trains on this card stop short rather than rear-ending each other.
if (node?.kind === 'mainline') node.absSignals = true;
} else {
} else if (e.at) {
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
if (card) card.enhancements.push(e.key);
}
@@ -1564,11 +1638,9 @@ export function checkEnhancementPlacement(
const rule = enhancementRule(key);
if (!rule) return 'NOT_IMPLEMENTED';
// ABS Signals goes on a Mainline card; `placement.col` names which one.
if (rule.placement === 'mainlineCard') {
const node = s.division.nodes[placement.col];
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
}
// A Mainline-card enhancement never reaches here: it has no grid square, and `checkPlay` answers
// it against `division.nodes` directly. This function is only ever asked about the Office Area.
if (rule.placement === 'mainlineCard') return 'WRONG_INTENT';
const card = area.grid.get(coordKey(placement));
if (!card) return 'NOT_CONNECTED';
@@ -1741,6 +1813,45 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
return true;
}
/**
* §7 — IS THIS TRAY THE ONE BEING MADE UP?
*
* A train is made up where it is built, standing at a Division Point, and only until its consist
* matches its card. Everything else with a Crew Tray — a train working your district, a train
* halfway across the Division — is running, not being assembled.
*
* SHARED with the New Train Phase, which uses it to decide whether to stop and ask. It has to be:
* `check` accepted any tray with room in its consist, so during a New Train Phase the Division Yard
* would hand cars to a train standing on your own siding or out on the Mainline — cars appearing on
* a train nobody was making up. Measured before the fix: 50 such offers across 8 solitaire games,
* including Train 9 mid-crossing with three cars already aboard.
*/
export function isBeingMadeUp(tray: CrewTray): boolean {
if (tray.trainNumber === null) return false;
if (tray.position.at !== 'divisionPoint') return false;
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
if (!profile) return false;
return tray.consist.length < consistSize(profile.consist);
}
/**
* The tray the New Train Phase is waiting on, or null.
*
* `isBeingMadeUp` plus "and there is something in the yard it will take" — the phase must not stop
* to ask for a car that cannot be supplied.
*/
export function trainNeedingCars(s: GameState): TrayId | null {
for (const [id, tray] of s.trays) {
if (!isBeingMadeUp(tray)) continue;
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
// uses: a separate copy of this test stalled the game, because the phase believed a car could
// be added while `check` rejected every option, so the Stage never ended.
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
}
return null;
}
/**
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
*