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
+102 -38
View File
@@ -45,7 +45,7 @@ import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts'
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
import { areaOf } from '../engine/apply.ts';
import { areaOf, trainNeedingCars } from '../engine/apply.ts';
import type { Frame } from '../sim/view.ts';
export const SOLO_CONFIG: GameConfig = {
@@ -189,7 +189,10 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
// "Pee-Dee" — a per-diem train whose consist is one caboose and nothing else — offers a
// single button to add a caboose and no reason why, which reads as a broken game rather than
// as the card doing exactly what it prints.
const headed = prefix === 'newTrain.' ? consistTitle(game, options, actions) ?? title : title;
// The tray the phase is waiting on, so the heading names the train the yard chips will load
// rather than whichever tray happened to come first out of the map.
const filling = prefix === 'newTrain.' ? trainNeedingCars(game.state) : null;
const headed = filling !== null ? (consistTitle(game, filling) ?? title) : title;
groups.push({ kind: prefix, title: headed, actions });
}
}
@@ -220,7 +223,17 @@ export type Placeable = {
spots: {
label: string;
index: number;
coord: { row: number; col: number };
/**
* The square on the board this spot would fill, or **null** when the placement is not on the
* board at all — ABS Signals goes out on the Mainline, and `node` names which card.
*
* Null rather than a stand-in coordinate. A Mainline placement used to travel as
* `{ row: -1, col: node }`, and row −1 is an ordinary district row, so the board lit up a
* district card for a placement that was never going there.
*/
coord: { row: number; col: number } | null;
/** Division node index, for a placement out on the Mainline. */
node?: number;
/**
* The rails this placement would put on the card, as `connectionsFor` codes.
*
@@ -288,7 +301,7 @@ export type Menu = {
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
* these key each chip to the option that adds it.
*/
makeUp: { title: string; cars: MakeUpAction[]; pass: number | null } | null;
makeUp: { trayId: string; title: string; cars: MakeUpAction[]; pass: number | null } | null;
};
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
@@ -313,7 +326,13 @@ export function actionMenu(game: Game): Menu {
spots: [],
};
if (!entry.spots.some((sp) => sp.label === key.spot)) {
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord, links: key.links });
entry.spots.push({
label: key.spot,
index: a.index,
coord: key.coord,
links: key.links,
...(key.node === undefined ? {} : { node: key.node }),
});
}
bucket.set(key.subjectKey, entry);
placeableByTitle.set(g.title, bucket);
@@ -364,23 +383,42 @@ export function actionMenu(game: Game): Menu {
};
});
// Making up a train. `newTrain.placeCar` carries the car; the Division Yard chip that shows that
// car is where the click belongs.
/**
* MAKING UP A TRAIN — ONE TRAIN.
*
* `newTrain.placeCar` carries the car, and the Division Yard chip showing that car is where the
* click belongs. But two trains can be built in the same Stage — a timetabled train and a Second
* Section, or an Extra — and this collected every option from every tray into one panel titled
* with whichever tray came first. Reproduced at seed 99, Day 3 Stage 12: eighteen car chips under
* "Making up Train 8", covering two different trains.
*
* Worse than a wrong caption: the yard chip binds to the FIRST matching option, so clicking a
* hopper could couple it to the other train entirely.
*
* So the panel is scoped to ONE tray — the one the engine is actually waiting on
* (`trainNeedingCars`, the same predicate the New Train Phase stops for). The second train comes
* up as soon as the first is done, which is how the phase runs anyway.
*/
const filling =
trainNeedingCars(game.state) ??
options.find((i): i is Extract<Intent, { type: 'newTrain.placeCar' | 'newTrain.passCar' }> =>
i.type === 'newTrain.placeCar' || i.type === 'newTrain.passCar',
)?.trayId ??
null;
const makeUpCars: MakeUpAction[] = [];
let pass: number | null = null;
options.forEach((i, index) => {
if (i.type === 'newTrain.placeCar') makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
if (i.type === 'newTrain.passCar') pass = index;
if (i.type === 'newTrain.placeCar' && i.trayId === filling) {
makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
}
if (i.type === 'newTrain.passCar' && i.trayId === filling) pass = index;
});
const makeUp =
makeUpCars.length > 0 || pass !== null
filling !== null && (makeUpCars.length > 0 || pass !== null)
? {
title:
consistTitle(
game,
options,
options.map((_, index) => ({ index })).filter((a) => options[a.index]?.type.startsWith('newTrain.')),
) ?? 'Making up the train',
trayId: filling,
title: consistTitle(game, filling) ?? 'Making up the train',
cars: makeUpCars,
pass,
}
@@ -397,28 +435,31 @@ function subjectOf(
subjectKey: string;
subject: string;
spot: string;
coord: { row: number; col: number };
coord: { row: number; col: number } | null;
node?: number;
links: string[];
} | null {
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
/**
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
* row −1 is an ordinary district row: an enhancement laid on a real card one row below the
* Running Track was described as being "out on the Mainline", and ABS Signals itself lit up
* whichever district card sat at that column.
*/
if (i.type === 'card.play' && i.node !== undefined) {
const node = game.state.division.nodes[i.node];
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `Mainline card ${i.node}`;
return {
subjectKey: `card:${i.cardId}`,
subject: cardName(game.state, i.cardId),
spot: `on the ${where}, out on the Mainline`,
coord: null,
node: i.node,
links: [],
};
}
if (i.type === 'card.play' && i.placement) {
/**
* ABS Signals is placed on a MAINLINE card, and the engine carries which one in `placement.col`
* as a Division node index. Showing that as a grid coordinate read as an Office Area square —
* "(0, 1)" — which is the wrong half of the board entirely.
*/
const k = game.state.cards.get(i.cardId)?.kind;
if (k?.kind === 'enhancement' && i.placement.row === -1) {
const node = game.state.division.nodes[i.placement.col];
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `node ${i.placement.col}`;
return {
subjectKey: `card:${i.cardId}`,
subject: cardName(game.state, i.cardId),
spot: `on the ${where}, out on the Mainline`,
coord: i.placement,
links: [],
};
}
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
// the decision, so they ride on the spot rather than being left for the player to work out.
const kind = game.state.cards.get(i.cardId)?.kind;
@@ -521,10 +562,8 @@ function joinsNote(
* the consist is the reason a button is missing. Naming it turns an unexplained restriction into a
* card the player can read.
*/
function consistTitle(game: Game, options: Intent[], actions: { index: number }[]): string | null {
const first = actions.map((a) => options[a.index]).find((i) => i && 'trayId' in i);
if (!first || !('trayId' in first)) return null;
const tray = game.state.trays.get(first.trayId);
function consistTitle(game: Game, trayId: string): string | null {
const tray = game.state.trays.get(trayId);
if (!tray) return null;
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
if (!p) return null;
@@ -628,6 +667,31 @@ export function toSave(game: Game): Save {
return { seed: game.seed, history: game.history };
}
/**
* TAKE THE LAST ACTION BACK.
*
* The save IS the game — a seed and the intents submitted — so undo is "replay everything except the
* last one". That is why this is a few lines rather than a feature: there is no undo stack to keep,
* no inverse of each action to write, and no way for it to produce a position the rules could not
* have reached, because the position is reached by the rules.
*
* WHAT IT COSTS. Replaying is O(history), which at a few hundred intents is imperceptible, and the
* whole log is rebuilt with it — so the history panel matches the board afterwards rather than still
* describing the move that was taken back.
*
* WHAT IT ALLOWS, deliberately: the RNG advances with the replay, so playing the same train card
* again rolls the same Stage — you cannot undo your way to a better die. You CAN see the roll and
* then spend the turn differently, which is an ordinary solitaire take-back and is the reason this
* is solitaire-only. `TODO.md` carries the open question of whether a Stage boundary should become a
* commit point.
*
* Returns null when there is nothing to undo, so the caller can leave the button disabled.
*/
export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null {
if (game.history.length === 0) return null;
return fromSave({ seed: game.seed, history: game.history.slice(0, -1) }, config);
}
/**
* Rebuild a game from a save.
*