diff --git a/package.json b/package.json
index b9ae800..7491e16 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,10 @@
},
"scripts": {
"typecheck": "tsc --noEmit",
- "test": "node --test test/**/*.test.ts"
+ "test": "node --test test/**/*.test.ts",
+ "build:web": "node scripts/build-web.ts",
+ "serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
+ "deploy:web": "node scripts/deploy-web.ts"
},
"devDependencies": {
"@types/node": "^26.1.2",
diff --git a/scripts/build-web.ts b/scripts/build-web.ts
new file mode 100644
index 0000000..376d924
--- /dev/null
+++ b/scripts/build-web.ts
@@ -0,0 +1,91 @@
+/**
+ * Build the static solitaire site into `dist/`.
+ *
+ * Everything here is BUILD-time. The output is plain files on disk — upload them anywhere that
+ * serves static content and the game runs entirely in the visitor's browser. No server, no API, no
+ * network call at runtime.
+ *
+ * The one thing a browser cannot do is run TypeScript: Node 22's type-stripping is a Node feature.
+ * So `tsc` emits ES2022 modules, rewriting the `.ts` import extensions the engine uses into `.js`.
+ */
+
+import { execFileSync } from 'node:child_process';
+import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const dist = join(root, 'dist');
+
+rmSync(dist, { recursive: true, force: true });
+mkdirSync(dist, { recursive: true });
+
+execFileSync(
+ 'npx',
+ [
+ 'tsc',
+ '--ignoreConfig',
+ 'src/web/main.ts',
+ '--outDir', dist,
+ '--rootDir', 'src',
+ '--target', 'es2022',
+ '--module', 'es2022',
+ '--moduleResolution', 'bundler',
+ '--lib', 'es2022,dom',
+ '--types', '',
+ '--strict',
+ '--allowImportingTsExtensions',
+ '--rewriteRelativeImportExtensions',
+ ],
+ { cwd: root, stdio: 'inherit' },
+);
+
+/**
+ * Stamp the build into the page.
+ *
+ * Once this is published, "what is actually deployed?" stops being answerable by looking at the
+ * source. The stamp is written into index.html at copy time rather than generated into `src/`, so
+ * no generated file has to be ignored, imported, or kept in step.
+ *
+ * `-dirty` marks a build made from an uncommitted tree — the honest state of most test deploys, and
+ * exactly the thing you want to know when a fix appears to have had no effect.
+ */
+function buildStamp(): string {
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
+ let git = 'nogit';
+ try {
+ const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
+ .toString()
+ .trim();
+ const dirty =
+ execFileSync('git', ['status', '--porcelain'], { cwd: root }).toString().trim().length > 0;
+ git = sha + (dirty ? '-dirty' : '');
+ } catch {
+ // A build from a tarball with no git history is still a valid build.
+ }
+ const when = new Date().toISOString().replace('T', ' ').slice(0, 16);
+ return `v${pkg.version} · ${git} · ${when}Z`;
+}
+
+const stamp = buildStamp();
+const page = readFileSync(join(root, 'src/web/index.html'), 'utf8').replaceAll('__BUILD__', stamp);
+writeFileSync(join(dist, 'index.html'), page);
+
+// A tiny note for whoever unzips this later and wonders what it needs.
+writeFileSync(
+ join(dist, 'README.txt'),
+ [
+ 'Station Master — solitaire, static build.',
+ '',
+ 'Upload the contents of this folder to any static host and open index.html.',
+ 'There is no server component. The game runs entirely in the browser and saves',
+ 'to localStorage. Add ?seed=1234 to the URL for a reproducible deal.',
+ '',
+ 'Note: ES modules require the files to be SERVED over http(s). Opening',
+ 'index.html directly from the filesystem will be blocked by the browser.',
+ 'To try it locally: npx http-server dist (or any static file server)',
+ '',
+ ].join('\n'),
+);
+
+console.log(`built -> ${dist}\n ${stamp}`);
diff --git a/scripts/deploy-web.ts b/scripts/deploy-web.ts
new file mode 100644
index 0000000..cf49c64
--- /dev/null
+++ b/scripts/deploy-web.ts
@@ -0,0 +1,155 @@
+/**
+ * Build the solitaire site and push it to a File Browser instance.
+ *
+ * Written against File Browser v2.63's REST API, read from its own bundle rather than guessed:
+ *
+ * POST /api/login {username, password, recaptcha} -> JWT as plain text
+ * POST /api/resources/
/ X-Auth: -> create a directory
+ * POST /api/resources/?override=true X-Auth: , body = bytes -> upload
+ *
+ * File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site.
+ * So the job here is simply to land the built files in the right folder, intact.
+ *
+ * CREDENTIALS COME FROM THE ENVIRONMENT and are never written anywhere. Putting a password in a
+ * repo is how it ends up in a commit, and this repo is going to be pushed.
+ *
+ * FB_USER=jesse FB_PASS='…' npm run deploy:web
+ *
+ * Optional:
+ * FB_URL default https://phoenix.local:58157
+ * FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
+ * FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host)
+ * --dry-run list what would be sent, contact nothing
+ */
+
+import { execFileSync } from 'node:child_process';
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { dirname, join, posix, relative, sep } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const dist = join(root, 'dist');
+
+const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replace(/\/+$/, '');
+const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`;
+const USER = process.env['FB_USER'] ?? '';
+const PASS = process.env['FB_PASS'] ?? '';
+const DRY = process.argv.includes('--dry-run');
+
+/**
+ * A .local StartOS host presents a certificate the system store does not know. Disabling
+ * verification is opt-in and announced rather than silent: it is the right call on a LAN box you
+ * own and the wrong one everywhere else, and that judgment is not the script's to make quietly.
+ */
+if (process.env['FB_INSECURE'] === '1') {
+ process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
+ console.warn('! TLS verification disabled (FB_INSECURE=1)');
+}
+
+/** Every file in `dir`, as paths relative to it, with POSIX separators. */
+function walk(dir: string, base = dir): string[] {
+ const out: string[] = [];
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ if (statSync(full).isDirectory()) out.push(...walk(full, base));
+ else out.push(relative(base, full).split(sep).join('/'));
+ }
+ return out.sort();
+}
+
+const CONTENT_TYPES: Record = {
+ '.html': 'text/html',
+ '.js': 'text/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+ '.txt': 'text/plain',
+};
+
+async function login(): Promise {
+ const res = await fetch(`${URL_BASE}/api/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username: USER, password: PASS, recaptcha: '' }),
+ });
+ const body = await res.text();
+ if (!res.ok) throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
+ if (!body.trim()) throw new Error('login returned an empty token');
+ return body.trim();
+}
+
+async function makeDir(jwt: string, path: string): Promise {
+ // Trailing slash is what marks a directory in this API. A 409 means it already exists, which is
+ // the normal case on every deploy after the first.
+ const res = await fetch(`${URL_BASE}/api/resources${encodePath(path)}/`, {
+ method: 'POST',
+ headers: { 'X-Auth': jwt },
+ });
+ if (!res.ok && res.status !== 409) {
+ throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
+ }
+}
+
+async function upload(jwt: string, localPath: string, remotePath: string): Promise {
+ const bytes = readFileSync(localPath);
+ const ext = remotePath.slice(remotePath.lastIndexOf('.'));
+ const res = await fetch(`${URL_BASE}/api/resources${encodePath(remotePath)}?override=true`, {
+ method: 'POST',
+ headers: {
+ 'X-Auth': jwt,
+ 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
+ 'Content-Length': String(bytes.byteLength),
+ },
+ body: new Uint8Array(bytes),
+ });
+ if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`);
+}
+
+/** Encode each segment but keep the separators, so a path stays a path. */
+function encodePath(p: string): string {
+ return p.split('/').map(encodeURIComponent).join('/');
+}
+
+// ---------------------------------------------------------------------------
+
+console.log('building…');
+execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'inherit' });
+
+const files = walk(dist);
+if (files.length === 0) throw new Error('dist/ is empty — nothing to deploy');
+
+const dirs = [...new Set(files.map((f) => posix.dirname(f)).filter((d) => d !== '.'))].sort();
+const total = files.reduce((n, f) => n + statSync(join(dist, f)).size, 0);
+
+console.log(`\n${files.length} files, ${(total / 1024).toFixed(0)} KB`);
+console.log(` from ${dist}`);
+console.log(` to ${URL_BASE}${DEST}\n`);
+
+if (DRY) {
+ for (const d of dirs) console.log(` dir ${DEST}/${d}`);
+ for (const f of files) console.log(` file ${DEST}/${f}`);
+ console.log('\ndry run — nothing was sent');
+} else {
+ if (!USER || !PASS) {
+ throw new Error(
+ 'set FB_USER and FB_PASS.\n' +
+ " e.g. FB_USER=me FB_PASS='…' FB_INSECURE=1 npm run deploy:web\n" +
+ ' or run with --dry-run to see what would be sent',
+ );
+ }
+
+ const jwt = await login();
+ console.log('logged in');
+
+ await makeDir(jwt, DEST);
+ for (const d of dirs) await makeDir(jwt, `${DEST}/${d}`);
+
+ let done = 0;
+ for (const f of files) {
+ await upload(jwt, join(dist, f), `${DEST}/${f}`);
+ done++;
+ console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
+ }
+
+ console.log(`\ndeployed to ${URL_BASE}${DEST}`);
+ console.log('Start9 Pages serves these files as the site; File Browser is only the store.');
+}
diff --git a/src/engine/advance.ts b/src/engine/advance.ts
index 0cef4e1..e009cee 100644
--- a/src/engine/advance.ts
+++ b/src/engine/advance.ts
@@ -33,7 +33,7 @@ import {
} from './content.ts';
import type { Direction } from './content.ts';
import type { GameEvent } from './events.ts';
-import { areaOf } from './apply.ts';
+import { acceptsCar, areaOf } from './apply.ts';
import { legalActions } from './legal.ts';
import type { CrewTray, DivisionNode, GameState, PlayerIndex, TrayId } from './state.ts';
import { coordKey, freshTurn, totalRevenue } from './state.ts';
@@ -295,10 +295,9 @@ function trainNeedingCars(s: GameState): TrayId | null {
if (tray.consist.length >= want) 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.
- const allowed = profile.consist.freightTypes;
- const suitable = s.yards.divisionYard.some(
- (c) => c.type === 'caboose' || c.type === 'coach' || !allowed || allowed.includes(c.type),
- );
+ // Ask the SAME predicate `check` uses. A separate copy of this test stalled the game: the phase
+ // believed a car could be added while check rejected every option, so the Stage never ended.
+ const suitable = s.yards.divisionYard.some((c) => acceptsCar(tray, c.type));
if (suitable) return id;
}
return null;
diff --git a/src/engine/apply.ts b/src/engine/apply.ts
index 62e1fa9..53e2958 100644
--- a/src/engine/apply.ts
+++ b/src/engine/apply.ts
@@ -27,12 +27,14 @@ import {
modifierProfile,
nextOfficeTier,
officeProfile,
+ trainProfile,
} from './content.ts';
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
import type { GameEvent } from './events.ts';
import type { Intent, RejectionCode } from './intents.ts';
import type {
CardId,
+ CrewTray,
Facility,
GameState,
GridCoord,
@@ -468,7 +470,15 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
return 'NO_SUITABLE_CAR';
}
- return null;
+
+ // §8.2 — "the train must be in the order listed on the train's card (engine on the front,
+ // Rolling Stock, and possibly a Caboose). It may depart with FEWER Rolling Stock than listed,
+ // but not out of order."
+ //
+ // This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
+ // Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
+ // no caboose. Fewer is allowed; more, or of the wrong category, is not.
+ return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR';
}
case 'newTrain.secondSection': {
@@ -613,8 +623,15 @@ function checkPlay(
// nothing, which is exactly the bug that made Modifiers dead weight for weeks.
return 'NOT_IMPLEMENTED';
+ case 'timetabledTrain':
+ case 'extraTrain':
+ // A train card goes to the TIMETABLE, not onto the board. Accepting a placement made the UI
+ // offer the same play at six different grid squares with six rotations apiece — all identical,
+ // because the placement was then ignored. Same reasoning as the Office upgrade above: silently
+ // discarding a placement makes the event log claim the card was laid somewhere it was not.
+ return placement ? 'NO_PLACEMENT' : null;
+
default:
- // Train cards go to the timetable; the phase driver schedules them.
return null;
}
}
@@ -1528,10 +1545,76 @@ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKin
}
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
+/**
+ * §2.1 Gap 4a — "when you extend your Running Track, the Limits sign MOVES outwards with it".
+ *
+ * The sign is a physical card, not just a recorded column. Moving only the coordinate left the
+ * Limits card stranded mid-track: a district grew to
+ * (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2)=Freight House …
+ * with everything beyond (0,-2) built OUTSIDE a sign that never moved. That is not cosmetic —
+ * §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
+ * an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
+ */
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
if (placed.row !== area.runningRow) return;
- if (placed.col <= area.limitsWest.col) area.limitsWest = { row: placed.row, col: placed.col - 1 };
- if (placed.col >= area.limitsEast.col) area.limitsEast = { row: placed.row, col: placed.col + 1 };
+
+ if (placed.col <= area.limitsWest.col) {
+ area.limitsWest = { row: placed.row, col: placed.col - 1 };
+ area.grid.set(coordKey(area.limitsWest), limitsCard());
+ }
+ if (placed.col >= area.limitsEast.col) {
+ area.limitsEast = { row: placed.row, col: placed.col + 1 };
+ area.grid.set(coordKey(area.limitsEast), limitsCard());
+ }
+}
+
+/**
+ * §8.2 — may this car be coupled onto this train right now?
+ *
+ * "The train must be in the order listed on the train's card (engine on the front, Rolling Stock,
+ * and possibly a Caboose). It may depart with FEWER Rolling Stock than listed, but not out of
+ * order." Fewer is allowed; more, or of the wrong category, is not.
+ *
+ * SHARED. Both `check` and the New Train Phase's "is there a suitable car in the yard" test call
+ * this. A second copy stalled the game outright: the phase believed a car could be added while
+ * `check` rejected every option, so the Stage never completed.
+ */
+export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
+ const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
+ if (!profile) return true;
+
+ const cat = (t: CarType): 'coach' | 'caboose' | 'freight' =>
+ t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
+ const adding = cat(carType);
+ const already = tray.consist.filter((c) => cat(c.type) === adding).length;
+ const allowed =
+ adding === 'coach'
+ ? profile.consist.coach
+ : adding === 'caboose'
+ ? profile.consist.caboose
+ : profile.consist.freight;
+ if (already >= allowed) return false;
+
+ // The caboose rides last (§A.3), so nothing may be coupled behind one.
+ if (adding !== 'caboose' && tray.consist.some((c) => c.type === 'caboose')) return false;
+
+ // The card also narrows WHICH freight types it will take.
+ const types = profile.consist.freightTypes;
+ if (adding === 'freight' && types && !types.includes(carType)) return false;
+
+ return true;
+}
+
+/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
+function limitsCard(): TrackCard {
+ return {
+ geometry: { kind: 'limits' },
+ baseOperationalRail: true,
+ standing: [],
+ facility: null,
+ modifiers: [],
+ enhancements: [],
+ };
}
// ---------------------------------------------------------------------------
diff --git a/src/engine/legal.ts b/src/engine/legal.ts
index 04530c5..fa0f719 100644
--- a/src/engine/legal.ts
+++ b/src/engine/legal.ts
@@ -252,7 +252,11 @@ function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
// Q7 — a district hangs BELOW the Running Track; there is nothing above it.
if (c.row > area.runningRow) continue;
const k = `${c.row},${c.col}`;
- if (area.grid.has(k) || seen.has(k)) continue;
+ if (seen.has(k)) continue;
+ // The Limits signs are candidates even though they are occupied: laying track there is how
+ // the Running Track grows, and the sign moves outward (§2.1). `check` still has the final say.
+ const occupied = area.grid.get(k);
+ if (occupied && !(occupied.geometry.kind === 'limits' && c.row === area.runningRow)) continue;
seen.add(k);
out.push(c);
}
diff --git a/src/engine/track.ts b/src/engine/track.ts
index a9357dd..5170230 100644
--- a/src/engine/track.ts
+++ b/src/engine/track.ts
@@ -330,7 +330,16 @@ export function allReachable(
* stubs above and below so this is satisfiable from the opening Stage.
*/
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
- if (cardAt(area, coord)) return false;
+ const existing = cardAt(area, coord);
+ // A Limits sign on the Running Track is the GROWTH POINT, not an obstacle. Extending means laying
+ // the card where the sign stands and moving the sign outward (§2.1, Gap 4a) — the physical act at
+ // the table. Treating the sign as occupied forced players to build PAST it, stranding the sign
+ // mid-track with everything beyond it nominally outside their own Limits.
+ const isMovableSign =
+ existing?.geometry.kind === 'limits' &&
+ coord.row === area.runningRow &&
+ existing.standing.length === 0;
+ if (existing && !isMovableSign) return false;
const ports: Port[] = ['n', 's', 'e', 'w'];
for (const p of ports) {
diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts
index 4fe5531..9532f63 100644
--- a/src/sim/narrate.ts
+++ b/src/sim/narrate.ts
@@ -34,6 +34,9 @@ export function clockTime(stage: number): string {
}
export function carLabel(c: RollingStock): string {
+ // A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even
+ // though the supply marks every caboose loaded. Name it plainly.
+ if (c.type === 'caboose') return 'caboose';
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
}
diff --git a/src/sim/replay.ts b/src/sim/replay.ts
index 7469972..4f228b5 100644
--- a/src/sim/replay.ts
+++ b/src/sim/replay.ts
@@ -28,391 +28,19 @@ import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
import { developerBot, lastChoiceReason } from './bot.ts';
-import type { Impediment } from './narrate.ts';
-import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } from './narrate.ts';
+import { carLabel, idleNote, isVisible, narrate } from './narrate.ts';
+// The view-model lives in its own module so the browser build can import it without dragging in
+// this file's Node dependencies. Re-exported because tests and the web app import it from here.
+export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
+export { cardName, describeIntent, snapshot } from './view.ts';
+import type { Frame } from './view.ts';
+import type { Decision } from './view.ts';
+import { cardName, describeDecision, snapshot } from './view.ts';
// ---------------------------------------------------------------------------
// Frame shape — only what the viewer draws
// ---------------------------------------------------------------------------
-export type CellView = {
- row: number;
- col: number;
- kind: string;
- label: string;
- running: boolean;
- tray: string | null;
- cars: string[];
- facility: FacilityView | null;
-};
-
-export type FacilityView = {
- name: string;
- commodity: string;
- flow: string;
- green: string[];
- greenCap: number;
- maw: (string | null)[];
- red: string[];
- redCap: number;
- track: string[];
- trackCap: number;
- laborers: string;
- porters: string;
- /**
- * Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
- * WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
- * that held freight to a 3% completion rate.
- */
- canFinish: boolean;
- /** A load is sitting on WORK with no spotted car to receive it. */
- jammed: boolean;
-};
-
-export type TrainChip = { label: string; consist: string[] };
-export type DivisionView = { kind: string; label: string; trains: TrainChip[][] };
-
-export type Frame = {
- day: number;
- stage: number;
- clock: string;
- phase: string;
- actor: number | null;
- superintendent: number;
- revenue: number;
- lines: { text: string; tone: string }[];
- where: { row: number; col: number } | null;
- /** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
- whereFrom: { row: number; col: number } | null;
- division: DivisionView[];
- cells: CellView[];
- facilities: FacilityView[];
- hand: string[];
- deck: number;
- departments: string[];
- /** 12 slots; the train number due out at each Stage, or null. */
- timetable: (number | null)[];
- blocked: Impediment[];
- trains: { label: string; where: string }[];
- /** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */
- decision: Decision | null;
- /** A Local Operations turn that changed nothing — the frames worth your attention. */
- wasted: boolean;
-};
-
-/**
- * The choice behind a frame.
- *
- * The whole point is `rejected`: the replay could always show what happened, never what COULD have
- * happened, so a daft move was visible but the alternatives it passed over were not — which is
- * exactly what you need to say what it should have done instead.
- */
-export type Decision = {
- actor: number;
- chose: string;
- why: string;
- /** Every legal option not taken, grouped by kind with a count. */
- rejected: { kind: string; count: number; detail: string }[];
- totalOptions: number;
-};
-
-// ---------------------------------------------------------------------------
-// Snapshotting
-// ---------------------------------------------------------------------------
-
-const FACILITY_NAMES: Record = {
- mineTipple: 'Mine Tipple',
- produceShed: 'Produce Shed',
- grocersWarehouse: "Grocer's Warehouse",
- oilRefinery: 'Oil Refinery',
- powerPlant: 'Power Plant',
-};
-
-function facilityView(
- card: { geometry: { kind: string; facility?: string }; facility: unknown },
- officeName: string,
-): FacilityView | null {
- const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
- // Passenger facilities were excluded entirely, so the Office's green and red slots never
- // appeared — which is why stocking a coach into the green box looked like nothing happening.
- if (!f) return null;
- if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
- const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
- return {
- name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? key),
- commodity: facilityCarType(f) ?? '?',
- flow: f.kind === 'passenger'
- ? 'passengers on and off'
- : f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
- green: f.outboundBox.map(carLabel),
- greenCap: f.capacity.outbound,
- maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
- red: f.inboundBox.map(carLabel),
- redCap: f.capacity.inbound,
- track: f.industryTrack.cars.map(carLabel),
- trackCap: f.industryTrack.length,
- laborers: `${laborersLeft(f)}/${f.laborers}`,
- porters: `${portersLeft(f)}/${f.porters}`,
- canFinish: canFinishHere(f),
- jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
- };
-}
-
-/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
-function canFinishHere(f: Facility): boolean {
- const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
- if (!pending) return false;
- return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type);
-}
-
-/**
- * Summarise the choice: what was taken, why, and what was passed over.
- *
- * Options are grouped by kind because a switching turn can offer 40 destinations, and a list that
- * long hides the shape of the decision rather than showing it.
- */
-function describeDecision(
- s: GameState,
- actor: number,
- chosen: Intent,
- options: Intent[],
- why: string,
-): Decision {
- const groups = new Map();
- for (const o of options) {
- if (o === chosen) continue;
- const list = groups.get(o.type) ?? [];
- list.push(o);
- groups.set(o.type, list);
- }
- const rejected = [...groups.entries()]
- .map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) }))
- .sort((a, b) => b.count - a.count);
-
- return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length };
-}
-
-/** A short, concrete example of what a group of rejected options would have done. */
-function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
- // Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe
- // identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the
- // actual range of choices — the opposite of what this panel is for.
- const seen = new Set();
- for (const i of list) seen.add(describeIntent(s, i));
- const unique = [...seen];
- const shown = unique.slice(0, 4);
- const more = unique.length - shown.length;
- return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
-}
-
-/** One readable line for a single intent. */
-function describeIntent(s: GameState, i: Intent): string {
- const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
- switch (i.type) {
- case 'localOps.choose':
- return `choose ${i.option}`;
- case 'card.play':
- return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
- case 'card.discard':
- return `discard ${cardName(s, i.cardId)}`;
- case 'track.lay':
- return `lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${i.geometry} at ${at(i.placement)}`;
- case 'switch.move':
- return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
- case 'switch.dropCars':
- return `drop ${i.count} car(s)`;
- case 'switch.sortConsist':
- return `re-order consist [${i.order.join(',')}]`;
- case 'freightAgent.stockOutbound':
- return `stock a ${i.carType} at ${at(i.at)}`;
- case 'freightAgent.unjam':
- return `unjam ${i.from} at ${at(i.at)}`;
- case 'freightAgent.clearInbound':
- return `clear red box at ${at(i.at)}`;
- case 'laborer.startLoad':
- return `start a load at ${at(i.at)}`;
- case 'laborer.advanceLoad':
- return `advance load in box ${i.box} at ${at(i.at)}`;
- case 'laborer.beginUnload':
- return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
- case 'porter.board':
- return `board passengers at ${at(i.at)}`;
- case 'porter.detrain':
- return `detrain passengers at ${at(i.at)}`;
- case 'newTrain.placeCar':
- return `add ${i.loaded ? 'loaded' : 'empty'} ${i.carType}`;
- case 'mainline.modify':
- return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
- case 'maneuver.redFlags':
- return `Red Flags on ${i.trayId}`;
- case 'maneuver.flyingSwitch':
- return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
- case 'mainline.clearance':
- return i.allow ? 'grant clearance' : 'refuse clearance';
- case 'draw.fromDepartment':
- return `draw the face-up card in slot ${i.slot + 1}`;
- default:
- return i.type;
- }
-}
-
-function snapshot(
- s: GameState,
- lines: { text: string; tone: string }[],
- where: { row: number; col: number } | null,
- whereFrom: { row: number; col: number } | null = null,
- decision: Decision | null = null,
- wasted = false,
-): Frame {
- const area = areaOf(s, 0);
- const trayAt = new Map();
- for (const [id, tray] of s.trays) {
- if (tray.position.at === 'grid') {
- const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
- const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
- trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
- }
- }
-
- const cells: CellView[] = [];
- const facilities: FacilityView[] = [];
- for (const [key, card] of area.grid) {
- const [row, col] = key.split(',').map(Number);
- const g = card.geometry;
- const kind = g.kind;
- let label: string;
- if (g.kind === 'office') label = officeProfile(area.tier).name;
- else if (g.kind === 'limits') label = 'Limits';
- else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
- else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier;
- else if (g.kind === 'spaceUse') label = prettyKey(g.key);
- else label = g.geometry === 'sharpCurved' ? 'sharp curve' : g.geometry;
-
- const fv = facilityView(card as never, officeProfile(area.tier).name);
- if (fv) facilities.push(fv);
-
- cells.push({
- row: row!,
- col: col!,
- kind,
- label,
- running: row === area.runningRow,
- tray: trayAt.get(key) ?? null,
- cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
- facility: fv,
- });
- }
-
- const division: DivisionView[] = s.division.nodes.map((n) => {
- if (n.kind === 'divisionPoint') {
- return {
- kind: 'dp',
- label: n.side === 'west' ? 'West DP' : 'East DP',
- trains: [n.holding.map((id) => trainChip(s, id))],
- };
- }
- if (n.kind === 'mainline') {
- // Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it
- // with how long each still has to run.
- const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
- return {
- kind: 'ml',
- label: name,
- trains: [n.transits.map((t) => {
- const chip = trainChip(s, t.tray);
- return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
- })],
- };
- }
- return {
- kind: 'office',
- label: officeProfile(areaOf(s, n.owner).tier).name,
- trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
- };
- });
-
- return {
- day: s.clock.day,
- stage: s.clock.stage,
- clock: clockTime(s.clock.stage),
- phase: phaseLabel(s.clock.phase),
- actor: s.clock.currentActor,
- superintendent: s.clock.superintendent,
- revenue: s.players[0]?.revenue ?? 0,
- lines,
- where,
- whereFrom,
- division,
- cells,
- facilities,
- hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
- deck: s.decks.homeOffice.length,
- departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
- timetable: [...s.timetable],
- decision,
- wasted,
- blocked: impediments(s, 0),
- trains: [...s.trays.values()].map((t) => ({
- label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
- where:
- t.position.at === 'divisionPoint'
- ? `${t.position.side} Division Point`
- : t.position.at === 'mainline'
- ? `Mainline card ${t.position.index}`
- : `Office Area (${t.position.coord.row},${t.position.coord.col})`,
- })),
- };
-}
-
-/** A card id turned into something a person can read. */
-export function cardName(s: GameState, id: string): string {
- const k = s.cards.get(id)?.kind;
- if (!k) return 'a card';
- switch (k.kind) {
- case 'timetabledTrain':
- return `Train ${k.number}`;
- case 'extraTrain':
- return `Extra X${k.number}`;
- case 'office':
- return `${k.tier.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())} upgrade`;
- case 'freightFacility':
- return FACILITY_NAMES[k.facility] ?? k.facility;
- case 'modifier':
- return MODIFIER_NAMES[k.modifier] ?? k.modifier;
- case 'track':
- return k.geometry === 'sharpCurved' ? 'Sharp curve' : `${k.geometry} track`;
- case 'spaceUse':
- case 'enhancement':
- case 'mainlineModifier':
- case 'maneuver':
- case 'action':
- return prettyKey(k.key);
- }
-}
-
-/** camelCase key → readable name, for the card categories that carry only a key. */
-function prettyKey(key: string): string {
- return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
-}
-
-const MODIFIER_NAMES: Record = {
- teamTrack: 'Team Track',
- loadingDock: 'Loading Dock',
- storageShed: 'Storage Shed',
- extraPlatform: 'Extra Platform',
- sectionGang: 'Section Gang',
-};
-
-/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
-function trainChip(s: GameState, id: string): TrainChip {
- const t = s.trays.get(id);
- if (!t) return { label: id, consist: [] };
- return {
- label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
- consist: t.consist.map(carLabel),
- };
-}
-
// ---------------------------------------------------------------------------
// Recording
// ---------------------------------------------------------------------------
@@ -510,6 +138,27 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
// HTML
// ---------------------------------------------------------------------------
+/**
+ * Drop repeated board state from the serialised frames.
+ *
+ * Only the wire format changes: `record()` still returns complete frames, so tests and any other
+ * consumer are unaffected. The page resolves a null by walking back to the last frame that carried
+ * the field.
+ */
+function compress(frames: Frame[]): unknown[] {
+ const keys = ['cells', 'facilities', 'division'] as const;
+ let prev: Record = {};
+ return frames.map((f, i) => {
+ const out: Record = { ...f };
+ for (const k of keys) {
+ const json = JSON.stringify(f[k]);
+ if (i > 0 && prev[k] === json) out[k] = null;
+ prev[k] = json;
+ }
+ return out;
+ });
+}
+
export function renderHtml(rec: Recording): string {
const target = lengthProfile(rec.length);
return `
@@ -590,6 +239,10 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
.fstat.bad{background:rgba(190,50,50,.38);font-weight:bold}
.fstat.idle{opacity:.6}
.wastedmark{color:#e06060;font-weight:bold}
+
+.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
+.mlmods{font-size:10px;background:rgba(200,150,60,.28);border-radius:3px;padding:1px 4px;margin-top:2px}
+.grade{font-size:10px;background:rgba(200,90,60,.35);border-radius:3px;padding:1px 4px;font-weight:normal}
@@ -657,7 +310,14 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
+
+