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
+156 -19
View File
@@ -10,7 +10,14 @@
* drift into two different pictures of the same board.
*/
import { areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import {
areaOf,
destinationsFor,
facilityCarType,
laborersLeft,
movesFor,
portersLeft,
} from '../engine/apply.ts';
import {
ACTION_CARDS,
ENHANCEMENT_CARDS,
@@ -29,7 +36,7 @@ import {
trainProfile,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
@@ -108,7 +115,7 @@ export type FacilityView = {
* happened. Reported after playing an Ice House and Local Small Groceries and seeing no change
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
*/
base: { out: number; in: number; laborers: number };
base: { out: number; in: number; laborers: number; porters: number };
/**
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
*
@@ -133,7 +140,19 @@ export type FacilityView = {
export type TrainChip = {
label: string;
/** Nose first, with `ENG` seated where the engine actually is. The words, for a tooltip. */
consist: string[];
/**
* THE SAME TRAIN THE OFFICE CARD DRAWS, so the Division map can draw it the same way.
*
* `cars` is nose first and carries no engine; `engineAt` is where the engine sits among them and
* `facing` is the port it points at. The Division chip used to be a name and a number — and the
* number was Stages left to cross, which reads as redundant beside the position already drawn on
* the card. A train is worth drawing: what it is carrying, loaded or empty, and which end leads.
*/
cars: string[];
engineAt: number;
facing: string;
/**
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
@@ -141,6 +160,15 @@ export type TrainChip = {
*/
region?: number;
direction?: string;
/**
* Stages still to run before it is off this Mainline card — NOT the same as regions left.
*
* A card is two regions of fixed distance; the Stages are how long this train takes over them
* (`crossingStages`): a 60 card is one Stage, a 30 card two, a Slow train adds one. So they
* coincide only in the middle case. It rode on the chip as "· 2⧗" and was read as a car count;
* it belongs in the tooltip, where there is room to say which it is.
*/
stagesLeft?: number;
};
/**
* One card of a player's Running Track, as the Division sees it.
@@ -223,6 +251,22 @@ export type Frame = {
* moves themselves.
*/
movesLeft: number | null;
/**
* WHERE THE CREW CAN GO, AND WHY NOT ELSEWHERE — while it is switching, and null otherwise.
*
* The switching game was played off a list of coordinates: "move to (0, -2)" as a button, with
* nothing on the board and no account of the squares that were missing from the list. Reported as
* trains being blocked from entering an industry "in certain conditions", with no way to see what
* the conditions were.
*
* `blocked` comes out of the movement walk itself (`movesFor`), so a reason on screen is the rule
* that actually refused the square rather than a second guess at it.
*/
moves: {
from: { row: number; col: number };
to: { row: number; col: number }[];
blocked: { coord: { row: number; col: number }; kind: string; why: string }[];
} | null;
facilities: FacilityView[];
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
@@ -334,7 +378,7 @@ function facilityView(
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
allowsOut: f.allows.outbound,
allowsIn: f.allows.inbound,
base: baseOf(card),
base: baseOf(card, officeName),
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
};
}
@@ -345,13 +389,29 @@ function facilityView(
* Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card
* the player is holding. A passenger facility takes its numbers from the Office tier instead.
*/
function baseOf(card: { geometry: { kind: string; facility?: string } }): { out: number; in: number; laborers: number } {
function baseOf(
card: { geometry: { kind: string; facility?: string } },
officeName: string,
): { out: number; in: number; laborers: number; porters: number } {
const g = card.geometry;
if (g.kind === 'facility' && g.facility) {
const p = industryProfile(g.facility as never);
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders };
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders, porters: 0 };
}
return { out: 0, in: 0, laborers: 0 };
/**
* A PASSENGER FACILITY TAKES ITS NUMBERS FROM THE OFFICE TIER.
*
* The comment above this function has said so for a long time and the code returned zeros, so the
* panel worked out its "+N from a Modifier" against a base of nothing: a plain Depot with no
* Modifier anywhere near it displayed `out 1 +1`, crediting a card that had never been played.
* The tier is the printed number here, exactly as the industry card is for an industry.
*/
if (g.kind === 'office') {
const tier = OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost';
const p = officeProfile(tier);
return { out: p.passengerOut, in: p.passengerIn, laborers: 0, porters: p.porters };
}
return { out: 0, in: 0, laborers: 0, porters: 0 };
}
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
@@ -459,8 +519,30 @@ export function describeIntent(s: GameState, i: Intent): string {
const onto = top ? `, burying ${cardName(s, top)}` : ' (empty)';
return `discard ${cardName(s, i.cardId)} onto Department ${i.toSlot + 1}${onto}`;
}
case 'switch.move':
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
case 'switch.move': {
/**
* SAY WHAT THE MOVE WILL PICK UP.
*
* Coupling is mandatory (§A.4): run over a card with cars standing on it and they join the
* train, whether or not you wanted them. The button said "move to (0, -2)" and the only
* account of the coupling was a line in the history panel — which is how a playtester ended up
* reporting that "cars magically appeared on my train".
*
* Taken from the engine's own destination list, so the count on the button is the count that
* will actually couple.
*/
const tray = s.trays.get(i.trayId);
const here = tray?.position.at === 'grid' ? tray.position.coord : null;
let picks = '';
if (here) {
const dest = destinationsFor(s, tray!.position.at === 'grid' ? tray!.position.owner : 0, i.trayId, here, i.reverse)
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
if (dest && dest.couples.length > 0) {
picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`;
}
}
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}${picks}`;
}
case 'switch.dropCars': {
/**
* NAME THE CARS AND THE END THEY COME OFF.
@@ -708,13 +790,14 @@ export function snapshot(
return {
...chip,
/**
* SAY WHAT THE NUMBER IS.
* THE NUMBER COMES OFF THE CHIP.
*
* This read "TX14 (2)", a bare figure next to a train's name — and it was read as the
* car count twice, by the same player, because that is the obvious guess. It is Stages
* left to cross this Mainline card. Naming the unit costs three characters.
* It read "TX14 (2)" and was taken for the car count — twice, by the same player — so
* it was named "· 2⧗", Stages left to cross. Named, it was then correctly read as
* redundant: the card already draws WHERE the train is, and how many Stages it still
* needs is a detail for the tooltip. The chip draws the train instead.
*/
label: `${chip.label} · ${t.stagesRemaining}⧗`,
stagesLeft: t.stagesRemaining,
region: place(t),
direction: t.direction,
};
@@ -826,6 +909,7 @@ export function snapshot(
objective: objectiveOf(s),
runningRow: area.runningRow,
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
moves: switchingMoves(s, 0),
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
@@ -978,15 +1062,44 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
const g = card.geometry;
switch (g.kind) {
case 'limits':
return 'the edge of your control area — lay track HERE to extend the Running Track';
/**
* THE WHOLE RULE, not half of it.
*
* This said "lay track HERE to extend the Running Track", which is true and leaves out the
* part a player has to know: the sign is the ONLY growth point on the main, it moves outward
* with the card, and nothing anywhere on the board can be inserted between two cards already
* down. Asked for directly after a playtest — "somewhere it should be clear that track can
* only be added to expand outwards".
*/
return (
'the edge of your control area. Lay track HERE and the sign moves one card further out — ' +
'this is the only way the Running Track grows, and nothing may be built beyond the sign. ' +
'A district only ever expands: no card can be inserted between two cards already down.'
);
case 'office': {
const p = officeProfile(
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
);
/**
* READ THE OFFICE AS IT STANDS, not as its card was printed.
*
* This took the porter count and the passenger slots from the TIER PROFILE, so a Waiting Area,
* Restaurant or Hotel standing beside the Office — each +1 porter and +1 passenger out —
* changed the Office and left this line saying what a bare Depot has. Reported as an extra
* porter with "no indication of it anywhere": the Modifier had worked and nothing said so.
*
* The facility record is the Office; the profile is only what it started as.
*/
const f = card.facility;
const porters = f ? f.porters : p.porters;
const out = f ? f.capacity.outbound : p.passengerOut;
const inb = f ? f.capacity.inbound : p.passengerIn;
const added = porters - p.porters + (out - p.passengerOut) + (inb - p.passengerIn);
return (
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
(p.isPassengerFacility
? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
? `${porters} porter${porters === 1 ? '' : 's'}, passengers ${out} out / ${inb} in` +
(added > 0 ? ` (the card prints ${p.porters}/${p.passengerOut}/${p.passengerIn}; the Modifiers beside it add the rest)` : '')
: 'not a Passenger Facility — no porters, no passenger boxes')
);
}
@@ -1193,9 +1306,29 @@ function countStock(
return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
}
/**
* The switching crew's reach, for the board to draw.
*
* Only while a switching turn is actually running and only while Moves remain: a highlight that
* survives into the Cargo phase is an invitation to click something that is no longer offered.
*
* One crew. Solitaire has one, and with more the answer would depend on which is selected — a
* question the page does not yet ask.
*/
function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
if (s.clock.phase !== 'localOps' || s.turn.option !== 'switch') return null;
if (s.turn.movesRemaining < 1) return null;
for (const [id, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
const { to, blocked } = movesFor(s, player, id);
return { from: tray.position.coord, to, blocked };
}
return null;
}
function trainChip(s: GameState, id: string): TrainChip {
const t = s.trays.get(id);
if (!t) return { label: id, consist: [] };
if (!t) return { label: id, consist: [], cars: [], engineAt: 0, facing: 'e' };
/**
* The engine is drawn IN the consist, at the position it occupies.
*
@@ -1206,10 +1339,14 @@ function trainChip(s: GameState, id: string): TrainChip {
*/
const cars = t.consist.map(carLabel);
const at = Math.max(0, Math.min(cars.length, t.engineAt));
cars.splice(at, 0, 'ENG');
const seated = [...cars];
seated.splice(at, 0, 'ENG');
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
consist: cars,
consist: seated,
cars,
engineAt: at,
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
};
}