various fixes. added version#s and added a playable browser build of the solitaire game (including deploy to filebrowser script)

This commit is contained in:
Jesse
2026-07-31 22:03:13 -04:00
parent 160190da3f
commit 2eca9de09f
16 changed files with 2399 additions and 401 deletions
+4 -1
View File
@@ -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",
+91
View File
@@ -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}`);
+155
View File
@@ -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/<dir>/ X-Auth: <jwt> -> create a directory
* POST /api/resources/<file>?override=true X-Auth: <jwt>, 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<string, string> = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.txt': 'text/plain',
};
async function login(): Promise<string> {
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<void> {
// 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<void> {
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.');
}
+4 -5
View File
@@ -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;
+87 -4
View File
@@ -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: [],
};
}
// ---------------------------------------------------------------------------
+5 -1
View File
@@ -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);
}
+10 -1
View File
@@ -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) {
+3
View File
@@ -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}`;
}
+57 -389
View File
@@ -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<string, string> = {
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<string, Intent[]>();
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<string>();
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<string, string>();
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<string, string> = {
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<string, string> = {};
return frames.map((f, i) => {
const out: Record<string, unknown> = { ...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 `<!doctype html>
@@ -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}
</style></head><body>
<header>
@@ -657,7 +310,14 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
</footer>
<script>
const FRAMES = ${JSON.stringify(rec.frames)};
const FRAMES = ${JSON.stringify(compress(rec.frames))};
/* The board changes rarely, so cells/facilities/division are emitted as null when identical to the
previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of
a 5.2 MB page. The scrubber jumps anywhere, hence the walk rather than a running pointer. */
function carry(i, key) {
for (let k = i; k >= 0; k--) if (FRAMES[k][key] !== null) return FRAMES[k][key];
return [];
}
let i = 0, timer = null;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
@@ -704,8 +364,13 @@ function render() {
$('fno').textContent = i;
$('scrub').value = i;
$('division').innerHTML = f.division.map((n) =>
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) + '</div>' +
const CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
$('division').innerHTML = DIV.map((n) =>
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) +
(n.gradeUp ? ' <span class="grade">' + (n.gradeUp === 'east' ? 'climbs E ▲' : '▲ W climbs') + '</span>' : '') +
'</div>' +
(n.modifiers.length ? '<div class="mlmods">' + n.modifiers.map(esc).join(' · ') + '</div>' : '') +
'<div class="regions">' + n.trains.map((r) =>
'<div class="region">' + r.map((t) =>
'<span class="chip">' + esc(t.label) + '</span>' +
@@ -713,14 +378,14 @@ function render() {
).join('') + '</div>').join('') +
'</div></div>').join('');
const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col);
const rows = CELLS.map((c) => c.row), cols = CELLS.map((c) => c.col);
const r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols);
const g = $('grid');
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
let cellsHtml = '';
for (let r = r1; r >= r0; r--) {
for (let c = c0; c <= c1; c++) {
const cell = f.cells.find((x) => x.row === r && x.col === c);
const cell = CELLS.find((x) => x.row === r && x.col === c);
if (!cell) { cellsHtml += '<div></div>'; continue; }
const hl = f.where && f.where.row === r && f.where.col === c;
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
@@ -728,6 +393,9 @@ function render() {
(cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
'<div class="nm">' + esc(cell.label) + '</div>' +
'<div class="dim" style="font-size:10px">(' + r + ',' + c + ')</div>' +
(cell.enhancements.length
? '<div class="enh">' + cell.enhancements.map(esc).join(' · ') + '</div>'
: '') +
(cell.tray ? '<div class="crew">🚂 ' + esc(cell.tray) + '</div>' : '') +
(cell.facility ? miniFacility(cell.facility) : '') +
(cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
@@ -736,9 +404,9 @@ function render() {
}
g.innerHTML = cellsHtml;
$('facs').innerHTML = f.facilities.length === 0
$('facs').innerHTML = FACS.length === 0
? '<span class="dim">no freight facilities built yet</span>'
: f.facilities.map((x) =>
: FACS.map((x) =>
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
@@ -790,7 +458,7 @@ function render() {
}
function go(n) { i = Math.max(0, Math.min(FRAMES.length - 1, n)); render(); }
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1])) return go(k); go(FRAMES.length - 1); }
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1], k)) return go(k); go(FRAMES.length - 1); }
$('ftot').textContent = FRAMES.length - 1;
$('scrub').max = FRAMES.length - 1;
@@ -802,7 +470,7 @@ $('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
$('waste').onclick = () => findNext((f) => f.wasted);
$('jam').onclick = () => findNext((f) => f.facilities.some((x) => x.jammed));
$('jam').onclick = () => findNext((f, p, idx) => carry(idx, 'facilities').some((x) => x.jammed));
$('play').onclick = () => {
if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
$('play').textContent = '⏸ pause';
+638
View File
@@ -0,0 +1,638 @@
/**
* The view-model: what a Station Master position LOOKS like.
*
* Split out of `replay.ts` so the playable browser build can use it. `replay.ts` writes files and
* reads `process.argv`, so importing it pulled `node:fs` into the bundle — and the engine's whole
* claim to run client-side rests on nothing in the import graph needing Node.
*
* Nothing here decides anything. It turns a `GameState` into boxes and labels, and turns an
* `Intent` into a sentence. The live game and the replay both render from this, so they cannot
* drift into two different pictures of the same board.
*/
import { areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import {
ACTION_CARDS,
ENHANCEMENT_CARDS,
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
SPACE_USE_CARDS,
industryProfile,
modifierProfile,
lengthProfile,
officeProfile,
trainProfile,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState } from '../engine/state.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
export type CellView = {
row: number;
col: number;
kind: string;
label: string;
running: boolean;
/**
* Enhancements laid ON this card. They were invisible: playing an Overpass onto the Office
* announced itself in the log and then changed nothing on the board, so a permanent change to how
* the district works left no trace you could see.
*/
enhancements: string[];
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[][];
/** Modifiers laid on a Mainline card (Brakeman, Helpers, Realignment …). */
modifiers: string[];
/**
* Which way a Heavy Grade climbs. The card prints "(Up)" and "Player sets orientation", so the
* direction is a property of the placed card — and without showing it, a Brakeman or Helpers card
* on that grade has no visible meaning.
*/
gradeUp: string | null;
};
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[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
handWhat: string[];
deck: number;
departments: string[];
/** What each face-up Department card does. */
departmentsWhat: string[];
/** 12 slots; the train number due out at each Stage, or null. */
timetable: (number | null)[];
blocked: Impediment[];
trains: { label: string; where: string }[];
/**
* Where you stand against the target. Nothing on screen said what the game was FOR, so a player
* had the score but no way to know whether it was good.
*/
objective: { target: number; days: number; daysLeft: number; onPace: boolean; note: string };
/**
* What is left of the player's personal track supply (§12.2). Track is NOT drawn from the deck —
* each player starts with 26 pieces and lays at most one a turn, so "how many straights have I
* got left" is a real planning question the board could not answer.
*/
trackSupply: { piece: string; left: number }[];
/** 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<string, string> = {
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.
*/
export function describeDecision(
s: GameState,
actor: number,
chosen: Intent,
options: Intent[],
why: string,
): Decision {
const groups = new Map<string, Intent[]>();
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<string>();
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. */
export 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':
// The most consequential decision of the Stage, and it was labelled "choose switch". Say what
// each option actually spends and buys.
return i.option === 'switch'
? 'SWITCH — six Moves to shunt cars: spot empties at industries, collect loads'
: i.option === 'draw'
? 'DRAW — take a card and play one, and you may lay a piece of track'
: 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
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 '}${geometryLabel(i.geometry)} ` +
`at ${at(i.placement)}${variantLabel(i.geometry, i.variant)}`
);
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': {
// Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and
// the choice between a visible card and a blind draw is unmakeable without it.
const id = s.decks.departments[i.slot];
return id ? `take ${cardName(s, id)} (face-up slot ${i.slot + 1})` : `slot ${i.slot + 1} (empty)`;
}
case 'draw.fromHomeOffice':
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
case 'draw.end':
return 'done drawing — end my turn';
case 'switch.end':
return 'done switching — end my turn';
case 'loadUnload.end':
return 'done working — end the Load/Unload phase';
case 'newTrain.passCar':
return 'add no more cars to this train';
case 'newTrain.secondSection':
return `run a Second Section behind Train ${i.trainNumber}`;
case 'redFlag.play':
return 'play your red flag';
case 'maneuver.redFlags':
return `Red Flags on ${i.trayId}`;
default: {
// Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes
// that a COMPILE error the day someone adds an intent without describing it — the playable UI
// labels its buttons from this function, so a missing case ships as a button reading
// "maneuver.poling".
const unhandled: never = i;
return String((unhandled as { type: string }).type);
}
}
}
/**
* Build the view-model for a state. Shared with the playable web app so the live game and the
* replay cannot drift into two different pictures of the same board.
*/
export 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<string, string>();
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 = geometryLabel(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,
enhancements: card.enhancements.map(prettyKey),
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))],
modifiers: [],
gradeUp: null,
};
}
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;
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
return {
kind: 'ml',
label: name,
trains: [n.transits.map((t) => {
const chip = trainChip(s, t.tray);
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
})],
modifiers: [
...(n.modifiers ?? []).map(prettyKey),
...(n.absSignals ? ['ABS Signals'] : []),
],
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
};
}
return {
kind: 'office',
label: officeProfile(areaOf(s, n.owner).tier).name,
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
modifiers: [],
gradeUp: null,
};
});
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)),
handWhat: (s.decks.hands.get(0) ?? []).map((id) => cardDescription(s, id)),
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
timetable: [...s.timetable],
decision,
wasted,
objective: objectiveOf(s),
trackSupply: [...area.trackSupply.entries()]
.map(([key, left]) => {
const [geometry, hand] = key.split(':');
return { piece: `${hand === 'none' ? '' : hand + '-hand '}${geometryLabel(geometry ?? '')}`, left };
})
.sort((a, b) => a.piece.localeCompare(b.piece)),
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`;
// Fall back to prettyKey, never to the raw key: an unnamed card showed as "rotaryDumps" on a
// button a player is meant to read. The lookup tables are for names prettyKey cannot guess
// (Grocer's Warehouse), not the only source of a readable label.
case 'freightFacility':
return FACILITY_NAMES[k.facility] ?? prettyKey(k.facility);
case 'modifier':
return MODIFIER_NAMES[k.modifier] ?? prettyKey(k.modifier);
case 'track':
return `${geometryLabel(k.geometry)} track`;
case 'spaceUse':
case 'enhancement':
case 'mainlineModifier':
case 'maneuver':
case 'action':
return prettyKey(k.key);
}
}
/**
* What a card actually DOES, in one line.
*
* The hand showed names only, so "Steam Turbines" or "Facing Point Locks" told a player nothing
* about the effect — the difference between playing the game and clicking hopefully. Every fact
* here already existed in the content tables; none of it was reaching the screen.
*/
export function cardDescription(s: GameState, id: string): string {
const k = s.cards.get(id)?.kind;
if (!k) return '';
switch (k.kind) {
case 'timetabledTrain':
case 'extraTrain': {
const t = trainProfile(k.number, k.kind === 'extraTrain');
if (!t) return '';
const parts: string[] = [];
if (t.consist.freight > 0) {
const types = t.consist.freightTypes;
parts.push(`${t.consist.freight} freight${types ? ` (${types.join('/')})` : ''}`);
}
if (t.consist.coach > 0) parts.push(`${t.consist.coach} coach`);
if (t.consist.caboose > 0) parts.push('caboose');
const extra = k.kind === 'extraTrain' ? 'runs ONCE, unscheduled' : 'runs every Day once scheduled';
// Several Extras leave the direction to the player, so "playerChoicebound" is not a word.
const dir = t.direction === 'playerChoice' ? 'either direction' : `${t.direction}bound`;
const rule = t.rules.note ? ` · ${t.rules.note}` : '';
return `${t.name} · ${t.speed}, ${dir} · ${parts.join(' + ') || 'no cars'} · ${extra}${rule}`;
}
case 'office': {
const p = officeProfile(k.tier);
return (
`upgrade the Office: ${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'}, ` +
`${p.porters} porter${p.porters === 1 ? '' : 's'}, ` +
`${p.passengerOut} passenger out / ${p.passengerIn} in` +
(p.isControlPoint ? ' · a Control Point' : '')
);
}
case 'freightFacility': {
const f = industryProfile(k.facility);
const flow = f.flow === 'both' ? 'ships out AND receives' : f.flow === 'outbound' ? 'ships out' : 'receives';
const lock = f.lockouts.length
? ` · cannot share a district with ${f.lockouts.map(facilityLabel).join(', ')}`
: '';
return `${flow} ${f.carTypes.join('/')} · ${f.baseLoaders} laborer${f.baseLoaders === 1 ? '' : 's'}${lock}`;
}
case 'modifier': {
const m = modifierProfile(k.modifier);
const adds: string[] = [];
if (m.addOut) adds.push(`+${m.addOut} out`);
if (m.addIn) adds.push(`+${m.addIn} in`);
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}`;
}
default: {
// The recovered categories carry their own prose — effect plus where it may be played.
const card = SIMPLE_CARDS.find((c) => c.key === (k as { key: string }).key);
return card ? `${card.effect} · played on ${card.placement}` : '';
}
}
}
/** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */
function facilityLabel(key: string): string {
return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key));
}
/** Every card category that carries a written effect rather than a profile. */
const SIMPLE_CARDS = [
...ENHANCEMENT_CARDS,
...MAINLINE_MODIFIER_CARDS,
...MANEUVER_CARDS,
...SPACE_USE_CARDS,
...ACTION_CARDS,
];
/** The goal, and whether the current score is keeping up with the clock. */
function objectiveOf(s: GameState): Frame['objective'] {
const profile = lengthProfile(s.config.length);
const revenue = s.players[0]?.revenue ?? 0;
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
const elapsed = profile.days - daysLeft + 1;
// Straight-line pace: by the end of Day N you want N/days of the target.
const expected = (profile.target * elapsed) / profile.days;
const onPace = revenue >= expected;
const note =
daysLeft === 0
? 'the last Day is over'
: `${revenue} of ${profile.target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
(onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`);
return { target: profile.target, days: profile.days, daysLeft, onPace, note };
}
/**
* Which way a piece will point, in words.
*
* "rotation 2" is not a choice anyone can make — for a curve or a turnout the orientation IS the
* decision. Read from `variantsFor`, the same list the placement uses, so the label cannot describe
* one rotation while the engine lays another.
*/
export function variantLabel(geometry: TrackGeometry, variant: number | undefined): string {
const v = variantsFor(geometry)[variant ?? 0];
if (!v) return '';
if (v.axis) return v.axis === 'ew' ? ' — eastwest' : ' — northsouth';
if (v.turnout) {
const dir = (p: string): string =>
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
return ` — stem ${dir(v.turnout.stem)}, through ${dir(v.turnout.through)}, diverges ${dir(v.turnout.diverge)}`;
}
return '';
}
/**
* A track shape in words. One place, because it is written on the board, in the action buttons and
* in the supply list — and `sharpCurved` was reaching the screen raw in two of the three.
*/
export function geometryLabel(geometry: string): string {
switch (geometry) {
case 'sharpCurved':
return 'sharp curve';
case 'curved':
return 'curve';
case 'turnout':
return 'turnout';
case 'straight':
return 'straight';
default:
return prettyKey(geometry);
}
}
/** 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<string, string> = {
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),
};
}
+306
View File
@@ -0,0 +1,306 @@
/**
* Station Master — solitaire, playable in a browser.
*
* The whole game runs client-side. There is no server and no network call at any point: the engine
* is pure, imports nothing outside itself, and never touches `Math.random`, `Date` or `crypto`, so
* a static host is all this needs. (Proven, not assumed — a test runs full games with every Node
* global replaced by a throwing stub.)
*
* WHAT THIS MODULE IS. Everything here is presentation and input. It builds no rules of its own:
*
* - what you may do -> `legalActions(state, actor)`
* - what it means -> `describeIntent()`, shared with the replay
* - what the board looks like -> `snapshot()`, shared with the replay
* - what just happened -> `narrate()`, shared with the replay
*
* That is the same discipline the engine holds itself to. A second opinion about which moves are
* legal would eventually disagree with `check`, and the failure mode is a UI that offers an illegal
* move or refuses a legal one.
*
* SAVING. The event log is the game (`state = fold(events)`), and the RNG is seeded, so a save is
* the seed plus the list of intents submitted. Replaying them reconstructs the position exactly,
* which is far smaller and far more robust than serialising the state graph.
*/
import { pump } from '../engine/advance.ts';
import { applyIntent } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
import { narrate } from '../sim/narrate.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
import { cardName, describeIntent, geometryLabel, snapshot, variantLabel } from '../sim/view.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts';
import type { Frame } from '../sim/view.ts';
export const SOLO_CONFIG: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/** A group of legal actions of one kind, ready to put on screen. */
export type ActionGroup = {
kind: string;
title: string;
actions: { index: number; label: string }[];
};
export type Game = {
state: GameState;
seed: number;
/** Every intent submitted, in order — the save file. */
history: Intent[];
/** Narrated lines, newest last. */
log: { text: string; tone: string }[];
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
{ prefix: 'switch.', title: 'Switching' },
{ prefix: 'draw.', title: 'Draw' },
{ prefix: 'card.', title: 'Cards' },
{ prefix: 'track.lay', title: 'Lay track from your supply' },
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
{ prefix: 'maneuver.', title: 'Manoeuvres' },
{ prefix: 'freightAgent.', title: 'Freight Agent' },
{ prefix: 'newTrain.', title: 'Making up the train' },
{ prefix: 'porter.', title: 'Porters' },
{ prefix: 'laborer.', title: 'Laborers' },
{ prefix: 'loadUnload.', title: 'Finish' },
{ prefix: 'redFlag.', title: 'Red flag' },
];
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: ['You'] });
const game: Game = { state, seed, history: [], log: [] };
drain(game);
return game;
}
/**
* Run the engine forward until it needs a decision.
*
* Most of a Stage is automatic — the Mainline Phase moves trains, the clock turns over — so the
* player is only ever asked when `advance` genuinely stops.
*/
export function drain(game: Game): void {
record(game, pump(game.state));
}
/** Whose turn it is, or null if the game is over or waiting on nothing. */
export function currentActor(game: Game): PlayerIndex | null {
if (game.state.status !== 'active') return null;
return game.state.clock.pendingDecision !== null
? game.state.clock.superintendent
: game.state.clock.currentActor;
}
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
export function actionGroups(game: Game): { options: Intent[]; groups: ActionGroup[] } {
const actor = currentActor(game);
if (actor === null) return { options: [], groups: [] };
const options = legalActions(game.state, actor);
const byKind = new Map<string, { index: number; label: string }[]>();
options.forEach((intent, index) => {
const label = describeIntent(game.state, intent);
const list = byKind.get(intent.type) ?? [];
// Orientation variants and duplicate copies describe identically; showing one is enough, and a
// list of forty identical rows hides the real choice rather than presenting it.
if (!list.some((a) => a.label === label)) list.push({ index, label });
byKind.set(intent.type, list);
});
const groups: ActionGroup[] = [];
const used = new Set<string>();
for (const { prefix, title } of GROUP_ORDER) {
const kinds = [...byKind.keys()].filter((k) => k.startsWith(prefix) && !used.has(k));
const actions = kinds.flatMap((k) => {
used.add(k);
return byKind.get(k) ?? [];
});
if (actions.length > 0) groups.push({ kind: prefix, title, actions });
}
// Anything the table above does not name still has to be offered — silently dropping a legal
// action would make the game unplayable in a way that is very hard to notice.
const leftovers = [...byKind.entries()].filter(([k]) => !used.has(k));
for (const [kind, actions] of leftovers) groups.push({ kind, title: kind, actions });
return { options, groups };
}
/**
* A thing you might do, and — if it goes on the board — where it could go.
*
* Placeable actions are presented as SUBJECT then LOCATION rather than as one flat list of every
* (card x square x rotation) combination. A single turn offered 29 track buttons and 7 card buttons
* with no way to tell which square each referred to; picking the card first and the square second is
* how the choice is actually made at the table.
*/
export type Placeable = {
/** Groups every option that plays the same card or lays the same piece. */
subjectKey: string;
subject: string;
/**
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
* piece has more than one legal rotation there, which is why the label carries the rotation too.
*/
spots: { label: string; index: number; coord: { row: number; col: number } }[];
};
export type Menu = {
options: Intent[];
/** Actions with no further choice to make. */
direct: ActionGroup[];
/** Actions needing a location, grouped under their card or track piece. */
placeable: { title: string; items: Placeable[] }[];
};
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
export function actionMenu(game: Game): Menu {
const { options, groups } = actionGroups(game);
const direct: ActionGroup[] = [];
const placeableByTitle = new Map<string, Map<string, Placeable>>();
for (const g of groups) {
const plain: ActionGroup['actions'] = [];
for (const a of g.actions) {
const intent = options[a.index]!;
const key = subjectOf(game, intent);
if (key === null) {
plain.push(a);
continue;
}
const bucket = placeableByTitle.get(g.title) ?? new Map<string, Placeable>();
const entry = bucket.get(key.subjectKey) ?? {
subjectKey: key.subjectKey,
subject: key.subject,
spots: [],
};
if (!entry.spots.some((sp) => sp.label === key.spot)) {
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord });
}
bucket.set(key.subjectKey, entry);
placeableByTitle.set(g.title, bucket);
}
if (plain.length > 0) direct.push({ ...g, actions: plain });
}
const placeable = [...placeableByTitle.entries()].map(([title, m]) => ({
title,
items: [...m.values()],
}));
return { options, direct, placeable };
}
/** Split an intent into "what" and "where", or null if it needs no location. */
function subjectOf(
game: Game,
i: Intent,
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
if (i.type === 'card.play' && i.placement) {
return {
subjectKey: `card:${i.cardId}`,
subject: cardName(game.state, i.cardId),
spot: `${at(i.placement)}${rotationNote(null, i.variant)}`,
coord: i.placement,
};
}
if (i.type === 'track.lay') {
const hand = i.hand === 'none' ? '' : `${i.hand}-hand `;
return {
subjectKey: `track:${i.geometry}:${i.hand}`,
subject: `${hand}${geometryLabel(i.geometry)}`,
spot: `${at(i.placement)}${rotationNote(i.geometry, i.variant)}`,
coord: i.placement,
};
}
return null;
}
/**
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
* decision. Only shown when there is more than one way to lay the piece.
*/
function rotationNote(geometry: TrackGeometry | null, variant: number | undefined): string {
if (geometry === null) return variant === undefined || variant === 0 ? '' : ` — option ${variant + 1}`;
return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : '';
}
/** Submit an action. Returns false and changes nothing if the engine rejects it. */
export function submit(game: Game, intent: Intent): boolean {
const actor = currentActor(game);
if (actor === null) return false;
const result = applyIntent(game.state, actor, intent);
if (!result.ok) {
game.log.push({ text: `That is not allowed: ${result.code}`, tone: 'bad' });
return false;
}
game.history.push(intent);
record(game, result.events);
drain(game);
return true;
}
/** The board as the replay draws it, so the live game and the replay agree. */
export function view(game: Game): Frame {
return snapshot(game.state, [], null);
}
function record(game: Game, events: GameEvent[]): void {
for (const e of events) {
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
if (e.type === 'actorChanged') continue;
const n = narrate(e, { cardName: (id) => cardName(game.state, id) });
game.log.push({ text: n.text, tone: n.tone });
}
// Keep the log bounded; the full history lives in `history` and can be replayed.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
}
// ---------------------------------------------------------------------------
// Saving — seed plus intents, replayed
// ---------------------------------------------------------------------------
export type Save = { seed: number; history: Intent[] };
export function toSave(game: Game): Save {
return { seed: game.seed, history: game.history };
}
/**
* Rebuild a game from a save.
*
* Replays the intents through the real engine rather than restoring a serialised state, so a save
* can never describe a position the rules could not have produced. An intent that no longer applies
* stops the replay rather than being forced — better a short game than a corrupt one.
*/
export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
const game = newGame(save.seed, config);
for (const intent of save.history) {
const actor = currentActor(game);
if (actor === null) break;
const result = applyIntent(game.state, actor, intent);
if (!result.ok) break;
game.history.push(intent);
record(game, result.events);
drain(game);
}
return game;
}
+128
View File
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — solitaire</title>
<style>
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
h1{font-size:17px;margin:0}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);
margin:0 0 6px;font-weight:600}
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
.dim{color:var(--dim)}
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
header b{font-size:16px}
.build{margin-left:auto;font-size:11px;opacity:.7}
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
.pace.good{background:rgba(40,140,60,.32)}
.pace.behind{background:rgba(190,120,40,.28)}
.handcard{border-top:1px solid var(--line);padding:4px 0}
.handcard:first-child{border-top:0}
.handcard .what{font-size:11px;line-height:1.35}
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
padding:10px 12px;margin-bottom:12px}
/* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
.node.office{border-color:#4d6fa8;background:rgba(60,100,170,.13)}
.node .nm{font-weight:600;font-size:12px}
.chip{display:inline-block;background:#2f6b3d;border-radius:4px;padding:1px 6px;margin:3px 3px 0 0;font-size:11px}
.consist{font-size:10px;color:var(--dim)}
.grade{font-size:10px;background:rgba(200,90,60,.35);border-radius:3px;padding:1px 4px;font-weight:400}
.mlmods{font-size:10px;background:rgba(200,150,60,.28);border-radius:3px;padding:1px 4px;margin-top:2px}
/* board */
#grid{display:grid;gap:5px;overflow-x:auto}
.cell{border:1px solid var(--line);border-radius:5px;padding:5px 6px;min-height:64px;background:#161b21}
.cell.run{background:var(--run)}
.cell.office{border-color:#4d6fa8}
.cell.modifier{background:#2b2333;border-style:dashed}
.cell.legal{outline:2px solid #5aa9e6;outline-offset:-2px;cursor:pointer;
box-shadow:0 0 0 3px rgba(90,169,230,.18) inset}
.cell.legal:hover{background:#233246}
.cell.ghost{border-style:dashed;background:rgba(90,169,230,.07);
display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
.cell.ghost .nm{font-weight:400;color:#5aa9e6;font-size:11px}
.cell .nm{font-size:12px;font-weight:600}
.coord{font-size:10px}
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
.cars{font-size:10px;color:var(--dim)}
/* facilities */
.fac{border-top:1px solid var(--line);padding:7px 0}
.fac:first-child{border-top:0}
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
.box.empty{background:#242a32;color:#5a6472}
.box.f{background:#2f6b3d}
.box.m{background:#8a6d1f}
.fstat{margin-top:4px;font-size:11px;padding:2px 5px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
.fstat.idle{opacity:.6}
/* actions */
#actions{max-height:none}
.grp{margin-bottom:6px}
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
button:hover{background:#39424e;border-color:#4d6fa8}
button.act{display:inline-block}
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
.over.win{background:rgba(40,140,60,.35)}
.over.loss{background:rgba(160,60,60,.3)}
/* cards, log, blocked */
.card.gone{opacity:.35;text-decoration:line-through}
.subj{display:block;width:100%;margin:2px 0}
.subj.open{border-color:#4d6fa8;background:#39424e}
.spots{margin:0 0 6px 12px;padding-left:8px;border-left:2px solid #4d6fa8}
.card{display:inline-block;background:#242c36;border:1px solid var(--line);border-radius:4px;
padding:2px 6px;margin:2px 2px 0 0;font-size:11px}
#log{max-height:230px;overflow:auto;font-size:12px}
.line{padding:1px 0}
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
ul.blocked li{padding:2px 0}
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
</style>
</head>
<body>
<header>
<b>Station Master</b>
<span id="clock"></span>
<span>phase: <b id="phase"></b></span>
<span>Revenue <b id="revenue">0</b></span>
<span id="objective" class="pace"></span>
<span class="dim">seed <span id="seed"></span></span>
<span class="dim">saved in this browser · add ?seed=1234 to the address for a set deal</span>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></div></section>
<section><h2>Your Office Area</h2><div id="grid"></div></section>
<section><h2>History</h2><div id="log"></div></section>
</div>
<div>
<section><h2>Your move</h2><div id="actions"></div></section>
<section><h2>Cards</h2>
<div><span class="dim">hand:</span> <span id="hand"></span></div>
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts"></span></div>
</section>
<section><h2>Your track supply</h2><div id="supply"></div></section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Facilities</h2><div id="facs"></div></section>
</div>
</main>
<script type="module" src="./web/main.js"></script>
</body>
</html>
+357
View File
@@ -0,0 +1,357 @@
/**
* Browser entry point — wires the DOM to `game.ts`.
*
* Presentation only. Every question of what is legal, what it means, or what the board looks like
* is answered by the engine or by the shared view helpers.
*/
import type { Game } from './game.ts';
import {
actionMenu,
currentActor,
fromSave,
newGame,
submit,
toSave,
view,
} from './game.ts';
const SAVE_KEY = 'station-master.save.v1';
let game: Game;
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/** A square picked on the board, waiting for a rotation. */
let pendingAt: string | null = null;
const $ = (id: string): HTMLElement => {
const el = document.getElementById(id);
if (!el) throw new Error(`missing element: ${id}`);
return el as HTMLElement;
};
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
// ---------------------------------------------------------------------------
function start(): void {
const params = new URLSearchParams(location.search);
const requested = params.get('seed');
const saved = load();
if (saved && requested === null) {
game = fromSave(saved);
} else {
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
game = newGame(seed);
}
render();
}
function render(): void {
const f = view(game);
const menu = actionMenu(game);
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected);
const spotsAt = new Map<string, { label: string; index: number }[]>();
for (const sp of chosen?.spots ?? []) {
const key = `${sp.coord.row},${sp.coord.col}`;
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
}
$('clock').textContent = `Day ${f.day} · Stage ${f.stage}${f.clock}`;
$('phase').textContent = f.phase;
$('revenue').textContent = String(f.revenue);
const obj = $('objective');
obj.textContent = f.objective.note;
obj.className = f.objective.onPace ? 'pace good' : 'pace behind';
$('seed').textContent = String(game.seed);
// -- division
$('division').innerHTML = f.division
.map(
(n) =>
`<div class="node ${n.kind === 'office' ? 'office' : ''}">` +
`<div class="nm">${esc(n.label)}` +
(n.gradeUp
? ` <span class="grade">${n.gradeUp === 'east' ? 'climbs E ▲' : '▲ W climbs'}</span>`
: '') +
`</div>` +
(n.modifiers.length ? `<div class="mlmods">${n.modifiers.map(esc).join(' · ')}</div>` : '') +
`<div class="regions">` +
n.trains
.map(
(r) =>
`<div class="region">` +
r
.map(
(t) =>
`<span class="chip">${esc(t.label)}</span>` +
`<div class="consist">${t.consist.length ? esc(t.consist.join(', ')) : 'empty'}</div>`,
)
.join('') +
`</div>`,
)
.join('') +
`</div></div>`,
)
.join('');
// -- grid
// Include the legal squares in the bounds. A placement is usually just OUTSIDE the current
// extent — that is what extending the district means — so a grid sized to the existing cards
// would highlight nothing at exactly the moment highlighting matters.
const spotCoords = [...spotsAt.keys()].map((k) => k.split(',').map(Number));
const rows = [...f.cells.map((c) => c.row), ...spotCoords.map((c) => c[0]!)];
const cols = [...f.cells.map((c) => c.col), ...spotCoords.map((c) => c[1]!)];
const r0 = Math.min(...rows);
const r1 = Math.max(...rows);
const c0 = Math.min(...cols);
const c1 = Math.max(...cols);
const grid = $('grid');
grid.style.gridTemplateColumns = `repeat(${c1 - c0 + 1}, minmax(104px, 1fr))`;
let cells = '';
for (let r = r1; r >= r0; r--) {
for (let c = c0; c <= c1; c++) {
const cell = f.cells.find((x) => x.row === r && x.col === c);
const spots = spotsAt.get(`${r},${c}`);
if (!cell) {
// An empty square is still a legal destination — render it as a target rather than a gap,
// or the most common placement of all would have nothing to click.
cells += spots
? `<div class="cell ghost legal" data-at="${r},${c}">` +
`<div class="nm">place here</div><div class="dim coord">(${r},${c})</div>` +
(spots.length > 1 ? `<div class="dim">${spots.length} rotations</div>` : '') +
`</div>`
: '<div></div>';
continue;
}
cells +=
`<div class="cell ${cell.running ? 'run ' : ''}${cell.kind === 'office' ? 'office ' : ''}` +
`${cell.kind === 'modifier' ? 'modifier ' : ''}${spots ? 'legal' : ''}"` +
`${spots ? ` data-at="${r},${c}"` : ''}>` +
`<div class="nm">${esc(cell.label)}</div>` +
`<div class="dim coord">(${r},${c})</div>` +
(cell.enhancements.length
? `<div class="enh">${cell.enhancements.map(esc).join(' · ')}</div>`
: '') +
(cell.tray ? `<div class="crew">🚂 ${esc(cell.tray)}</div>` : '') +
(cell.cars.length ? `<div class="cars">${esc(cell.cars.join(', '))}</div>` : '') +
`</div>`;
}
}
grid.innerHTML = cells;
// Clicking a highlighted square plays there. With more than one rotation available it narrows to
// that square instead, so the rotation is a second click rather than a guess.
for (const el of Array.from(grid.querySelectorAll('[data-at]'))) {
const node = el as HTMLElement;
node.onclick = () => {
const spots = spotsAt.get(node.dataset['at'] ?? '') ?? [];
if (spots.length === 1) {
const intent = menu.options[spots[0]!.index];
if (intent) submit(game, intent);
selected = null;
pendingAt = null;
} else if (spots.length > 1) {
pendingAt = node.dataset['at'] ?? null;
}
render();
};
}
// -- facilities
$('facs').innerHTML =
f.facilities.length === 0
? '<div class="dim">no facilities yet</div>'
: f.facilities
.map(
(x) =>
`<div class="fac"><div class="nm">${esc(x.name)}</div>` +
`<div class="dim">${esc(x.commodity)} · ${esc(x.flow)} · laborers ${esc(x.laborers)}</div>` +
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
x.maw
.map(
(m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`,
)
.join('') +
`</div>` +
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}">` +
(x.jammed
? 'JAMMED — a load is on WORK with no car spotted; the siding is locked'
: x.canFinish
? 'ready — a matching empty car is spotted'
: 'no car spotted — a load started here would jam') +
`</div></div>`,
)
.join('');
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
const cardRow = (name: string, why: string): string =>
`<div class="handcard"><b>${esc(name)}</b>` +
(why ? `<div class="dim what">${esc(why)}</div>` : '') +
`</div>`;
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '')).join('')
: '<span class="dim">empty</span>';
$('depts').innerHTML = f.departments.length
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '')).join('')
: '<span class="dim">none</span>';
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
$('supply').innerHTML =
`<div class="dim">${total} pieces left · one may be laid per turn, during the DRAW option</div>` +
f.trackSupply
.map(
(t) =>
`<span class="card ${t.left === 0 ? 'gone' : ''}">${esc(t.piece)} <b>${t.left}</b></span>`,
)
.join(' ');
$('blocked').innerHTML =
f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
.join('');
// -- log
const log = $('log');
log.innerHTML = game.log
.slice(-60)
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
.join('');
log.scrollTop = log.scrollHeight;
renderActions(menu);
save();
}
function boxes(items: string[], cap: number): string {
let out = '';
for (let i = 0; i < Math.max(cap, items.length); i++) {
const v = items[i];
out += `<span class="box ${v ? 'f' : 'empty'}">${v ? esc(v) : '·'}</span>`;
}
return out || '<span class="dim">—</span>';
}
function renderActions(menu: ReturnType<typeof actionMenu>): void {
const el = $('actions');
if (game.state.status !== 'active') {
const o = game.state.outcome;
el.innerHTML =
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'}${esc(String(o?.reason ?? ''))}<br>` +
`final Revenue ${game.state.players[0]?.revenue ?? 0} against a target of ${view(game).objective.target}</div>` +
`<button id="again">new game</button>`;
$('again').onclick = () => {
clearSave();
location.search = '';
};
return;
}
if (menu.direct.length === 0 && menu.placeable.length === 0) {
el.innerHTML = '<div class="dim">nothing to decide — the engine is running the Division</div>';
return;
}
const apply = (index: number): void => {
const intent = menu.options[index];
if (intent) submit(game, intent);
selected = null;
pendingAt = null;
render();
};
let html = menu.direct
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
g.actions.map((a) => `<button class="act" data-i="${a.index}">${esc(a.label)}</button>`).join('') +
`</div>`,
)
.join('');
// Subject first, location second. Picking a card then a square is how the choice is actually made;
// one flat list of every card-square-rotation combination was unreadable.
for (const group of menu.placeable) {
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
for (const item of group.items) {
const open = selected === item.subjectKey;
html +=
`<button class="subj ${open ? 'open' : ''}" data-subj="${esc(item.subjectKey)}">` +
`${esc(item.subject)} <span class="dim">${item.spots.length} spot${item.spots.length === 1 ? '' : 's'}</span>` +
`</button>`;
if (open) {
// Once a square is picked, show only that square's rotations — the rest is noise.
const shown = pendingAt
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
: item.spots;
html +=
`<div class="spots"><div class="dim">` +
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
`</div>` +
shown.map((sp) => `<button class="act" data-i="${sp.index}">${esc(sp.label)}</button>`).join('') +
`</div>`;
}
}
html += `</div>`;
}
el.innerHTML = html;
for (const b of Array.from(el.querySelectorAll('button.act'))) {
const node = b as HTMLElement;
node.onclick = () => apply(Number(node.dataset['i']));
}
for (const b of Array.from(el.querySelectorAll('button.subj'))) {
const node = b as HTMLElement;
node.onclick = () => {
const key = node.dataset['subj'] ?? null;
selected = selected === key ? null : key;
pendingAt = null;
render();
};
}
}
// ---------------------------------------------------------------------------
// Saving. localStorage only — nothing leaves the browser.
// ---------------------------------------------------------------------------
function save(): void {
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game)));
} catch {
// A full or disabled localStorage must not take the game down with it.
}
}
function load(): ReturnType<typeof toSave> | null {
try {
const raw = localStorage.getItem(SAVE_KEY);
return raw ? (JSON.parse(raw) as ReturnType<typeof toSave>) : null;
} catch {
return null;
}
}
function clearSave(): void {
try {
localStorage.removeItem(SAVE_KEY);
} catch {
/* nothing to do */
}
}
start();
+82
View File
@@ -412,3 +412,85 @@ describe('card coverage', () => {
assert.match(poling!.effect, /TBD/i);
});
});
// ---------------------------------------------------------------------------
describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
it('keeps the Limits at the ends, never stranded mid-track', () => {
// Found by watching a replay: a district grew to
// (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2) …
// with everything beyond (0,-2) built OUTSIDE a sign that never moved. Not cosmetic — §8.1 and
// §10 both reason about "the track between the train and the Limits", and running past a
// player's Limits is what makes a collision his fault.
const s = game();
const area = areaOf(s, 0);
s.turn.option = 'draw';
for (let n = 0; n < 4; n++) {
s.turn.laidThisTurn = false;
const target = { row: area.runningRow, col: area.limitsWest.col };
const r = applyIntent(s, 0, {
type: 'track.lay', geometry: 'straight', hand: 'none', placement: target, variant: 0,
});
assert.ok(r.ok, `extending onto the Limits should be legal (attempt ${n + 1})`);
}
const onRunning = [...area.grid.entries()]
.map(([k, c]) => ({ col: Number(k.split(',')[1]), kind: c.geometry.kind }))
.filter((x) => !Number.isNaN(x.col));
const limits = onRunning.filter((x) => x.kind === 'limits').map((x) => x.col).sort((a, b) => a - b);
const cols = onRunning.map((x) => x.col);
assert.equal(limits.length, 2, 'there must be exactly two Limits signs');
assert.equal(limits[0], Math.min(...cols), 'the west sign must be the westernmost card');
assert.equal(limits[1], Math.max(...cols), 'the east sign must be the easternmost card');
});
});
describe("a train is made up to its card's consist (§8.2)", () => {
it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => {
// Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and
// no caboose, because placeCar checked only MAX_CONSIST and yard availability.
const s = game();
s.clock.phase = 'newTrain';
s.trays.set('t', {
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
});
for (let n = 0; n < 3; n++) {
const r = applyIntent(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true });
assert.ok(r.ok, `hopper ${n + 1} of 3 should be accepted`);
}
assert.equal(
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }),
'NO_SUITABLE_CAR',
'a fourth freight car exceeds the card',
);
assert.equal(
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'coach', loaded: true }),
'NO_SUITABLE_CAR',
'this card carries no coaches',
);
assert.equal(
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'caboose', loaded: true }),
null,
'the card calls for a caboose',
);
});
it('couples nothing behind the caboose', () => {
// §A.3 — the caboose rides last.
const s = game();
s.clock.phase = 'newTrain';
s.trays.set('t', {
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
consist: [{ type: 'caboose', loaded: true }],
direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
});
assert.equal(
check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }),
'NO_SUITABLE_CAR',
);
});
});
+24
View File
@@ -268,6 +268,30 @@ describe('replay HTML', () => {
}
});
it('renders a LATER frame, where the board state is carried forward', () => {
// The page omits cells/facilities/division when unchanged and resolves them by walking back —
// 63% of a 5.2 MB payload was the same grid re-serialised every frame. Rendering only frame 0
// would never exercise that path, because frame 0 always carries everything.
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
const els = new Map<string, Record<string, unknown>>();
const stub = {
getElementById: (id: string) => {
if (!els.has(id)) els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0 });
return els.get(id);
},
};
// Append a jump to a late frame so render() runs against carried-forward state.
const run = new Function('document', 'setInterval', 'clearInterval', js + '\n;go(FRAMES.length - 1);');
assert.doesNotThrow(
() => run(stub, () => 0, () => undefined),
'the page threw rendering a frame whose board state was carried forward',
);
assert.ok(
String(els.get('grid')?.innerHTML ?? '').length > 0,
'the grid rendered empty on a carried-forward frame',
);
});
it('stays a sane size', () => {
const mb = Buffer.byteLength(html) / 1024 / 1024;
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
+448
View File
@@ -0,0 +1,448 @@
/**
* The playable browser build.
*
* These tests drive `game.ts` exactly as the page does — take the offered actions, submit one, look
* at the new position — so "it compiles" is never mistaken for "it plays".
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { cardDescription, describeIntent } from '../src/sim/view.ts';
import {
actionGroups,
actionMenu,
currentActor,
fromSave,
newGame,
submit,
toSave,
view,
} from '../src/web/game.ts';
/** Play a whole game by always taking the first offered action. */
function playThrough(seed: number, maxTurns = 20_000) {
const game = newGame(seed);
let turns = 0;
for (; turns < maxTurns; turns++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0 || options.length === 0) break;
const first = groups[0]!.actions[0]!;
if (!submit(game, options[first.index]!)) break;
}
return { game, turns };
}
describe('the browser game plays', () => {
it('reaches the end of a game through the same calls the page makes', () => {
const { game, turns } = playThrough(77);
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
assert.equal(game.state.status, 'finished');
assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
});
it('never offers an action the engine then refuses', () => {
// The action list comes from legalActions, which delegates to check — so every button on the
// page must be accepted. A rejection here means the UI and the rules disagree.
const game = newGame(21);
for (let i = 0; i < 300; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0) break;
const pick = groups[groups.length - 1]!.actions[0]!;
assert.ok(submit(game, options[pick.index]!), 'the engine refused an offered action');
}
});
it('offers every legal action somewhere, dropping none', () => {
// Grouping is presentation. If a kind is not named in GROUP_ORDER it must still appear, or a
// legal move becomes unreachable in a way that is very hard to notice.
const game = newGame(5);
for (let i = 0; i < 120; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (options.length === 0) break;
const shown = new Set(groups.flatMap((g) => g.actions.map((a) => a.index)));
const kinds = new Set(options.map((o) => o.type));
const shownKinds = new Set([...shown].map((i2) => options[i2]!.type));
assert.deepEqual(shownKinds, kinds, 'a kind of legal action was not offered at all');
submit(game, options[[...shown][0]!]!);
}
});
it('restores a saved game to the same position', () => {
// A save is the seed plus the intents; replaying them must reproduce the game exactly. That is
// the payoff of event sourcing, and it is only true while the RNG stays seeded.
const game = newGame(909);
for (let i = 0; i < 80; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0) break;
submit(game, options[groups[0]!.actions[0]!.index]!);
}
const restored = fromSave(toSave(game));
assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
assert.equal(restored.state.clock.day, game.state.clock.day);
assert.equal(restored.state.clock.stage, game.state.clock.stage);
assert.equal(restored.state.status, game.state.status);
assert.deepEqual(view(restored).cells, view(game).cells, 'the board differs after restore');
});
it('is deterministic — the same seed deals the same game', () => {
const a = playThrough(4242).game;
const b = playThrough(4242).game;
assert.equal(a.state.players[0]!.revenue, b.state.players[0]!.revenue);
assert.deepEqual(view(a).cells, view(b).cells);
});
});
describe('the action menu presents choices the way they are made', () => {
it('offers a card ONCE, with its locations underneath', () => {
// A single turn offered 29 track buttons and 7 card buttons in one flat list, with no way to
// tell which square each referred to. Subject first, location second.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const menu = actionMenu(game);
const items = menu.placeable.flatMap((g) => g.items);
assert.ok(items.length > 0, 'nothing placeable was offered');
const keys = items.map((i) => i.subjectKey);
assert.equal(new Set(keys).size, keys.length, 'the same card appeared as two subjects');
for (const item of items) {
const labels = item.spots.map((sp) => sp.label);
assert.equal(new Set(labels).size, labels.length, `${item.subject} lists a spot twice`);
assert.ok(item.spots.length > 0, `${item.subject} has nowhere to go but was offered`);
}
});
it('loses no legal action in the reshuffle', () => {
// Splitting into direct + placeable must not drop anything: every option must still be reachable.
const game = newGame(88);
for (let i = 0; i < 120; i++) {
if (currentActor(game) === null) break;
const menu = actionMenu(game);
if (menu.options.length === 0) break;
const reachable = new Set([
...menu.direct.flatMap((g) => g.actions.map((a) => a.index)),
...menu.placeable.flatMap((g) => g.items.flatMap((it) => it.spots.map((sp) => sp.index))),
]);
const kinds = new Set(menu.options.map((o) => o.type));
const shown = new Set([...reachable].map((k) => menu.options[k]!.type));
assert.deepEqual(shown, kinds, 'a kind of legal action became unreachable');
submit(game, menu.options[[...reachable][0]!]!);
}
});
it('names the card in every face-up slot', () => {
// "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const draw = actionMenu(game).direct.find((g) => g.title === 'Draw');
assert.ok(draw, 'no draw group');
for (const a of draw!.actions) {
assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
}
assert.ok(
draw!.actions.some((a) => /face-up slot/.test(a.label)),
'face-up slots are not named',
);
});
it('never offers a train card a place on the board', () => {
// A train card goes to the TIMETABLE. Seed 555 offered Extra X15 at six squares with six
// rotations each — all identical, because the placement was accepted and then ignored.
const game = newGame(555);
for (let i = 0; i < 200; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
for (const o of options) {
if (o.type !== 'card.play') continue;
const kind = game.state.cards.get(o.cardId)?.kind.kind;
if (kind === 'timetabledTrain' || kind === 'extraTrain') {
assert.equal(o.placement, undefined, 'a train card was offered a board square');
}
}
submit(game, options[0]!);
}
});
it('reports what is left of the personal track supply', () => {
// Track is not drawn from the deck — 26 pieces per player, at most one laid a turn — so the
// board alone could never answer "how many straights have I got left".
const game = newGame(31);
const supply = view(game).trackSupply;
assert.ok(supply.length > 0, 'no track supply reported');
assert.equal(supply.reduce((n, t) => n + t.left, 0), 26, 'the opening supply should be 26');
assert.ok(supply.every((t) => !/none/.test(t.piece)), 'un-handed pieces should not say "none"');
});
});
describe('board highlighting', () => {
it('gives every spot a coordinate to highlight', () => {
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const items = actionMenu(game).placeable.flatMap((g) => g.items);
assert.ok(items.length > 0);
for (const it of items) {
for (const sp of it.spots) {
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
}
}
});
it('highlights squares OUTSIDE the current district', () => {
// The commonest placement is just beyond the existing cards — that is what extending means. A
// grid sized only to the cards already down would highlight nothing exactly when it matters.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const cells = view(game).cells;
const minCol = Math.min(...cells.map((c) => c.col));
const maxCol = Math.max(...cells.map((c) => c.col));
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots);
assert.ok(
spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0),
'no legal spot lies outside the current cards — the bounds test would be vacuous',
);
});
});
describe('the page explains itself', () => {
it('says what every card in hand and every face-up slot does', () => {
// A hand of bare names is unplayable: "Steam Turbines" carries no hint of its effect, and all of
// this text already existed in the content tables without reaching the screen.
for (const seed of [555, 430, 7, 99]) {
const f = view(newGame(seed));
assert.equal(f.handWhat.length, f.hand.length, 'a hand card has no description slot');
f.hand.forEach((name, i) => {
const what = f.handWhat[i]!;
assert.ok(what.length > 0, `${name} has no description`);
// camelCase leaking to the screen is the recurring failure here.
assert.doesNotMatch(what, /[a-z][A-Z]/, `raw camelCase in "${name}": ${what}`);
assert.doesNotMatch(what, /playerChoicebound|undefined|NaN/, `broken text: ${what}`);
});
f.departments.forEach((name, i) => {
if (name === '—') return;
assert.ok(f.departmentsWhat[i]!.length > 0, `face-up ${name} has no description`);
});
}
});
it('describes every card kind in the deck, not just the easy ones', () => {
// Walk the whole deck rather than one hand: the categories differ, and a missing branch would
// show as a blank line under a card name only for the seeds that happen to deal it.
const game = newGame(1);
const seen = new Set<string>();
for (const [id, card] of game.state.cards) {
const what = cardDescription(game.state, id);
assert.ok(what.length > 0, `${card.kind.kind} has no description`);
seen.add(card.kind.kind);
}
assert.ok(seen.size >= 7, `only ${seen.size} card kinds seen — the deck should hold more`);
});
it('states the objective and whether you are keeping up', () => {
const f = view(newGame(430));
assert.equal(f.objective.target, 20);
assert.equal(f.objective.days, 5);
assert.match(f.objective.note, /of 20/);
assert.match(f.objective.note, /Days? left/);
});
it('explains what each Local Operations option spends', () => {
// The most consequential decision of the Stage, previously labelled "choose switch".
const game = newGame(555);
const choices = actionGroups(game)
.options.filter((o) => o.type === 'localOps.choose')
.map((o) => describeIntent(game.state, o));
assert.ok(choices.length > 0);
for (const c of choices) {
assert.ok(c.length > 25, `too terse to be an explanation: "${c}"`);
assert.match(c, /—/, `no explanation after the option name: "${c}"`);
}
});
it('says which way a piece will point, never "rotation 2"', () => {
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const track = actionMenu(game)
.placeable.flatMap((g) => g.items)
.filter((i) => /straight|curve|turnout/.test(i.subject));
assert.ok(track.length > 0, 'no track pieces offered');
for (const item of track) {
for (const sp of item.spots) {
assert.doesNotMatch(sp.label, /rotation \d/, `opaque rotation label: ${sp.label}`);
}
// A piece with more than one orientation must say which is which, or the spots are ambiguous.
const perSquare = new Map<string, number>();
for (const sp of item.spots) {
const k = `${sp.coord.row},${sp.coord.col}`;
perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
}
if ([...perSquare.values()].some((n) => n > 1)) {
assert.ok(
item.spots.some((sp) => /east|west|north|south/.test(sp.label)),
`${item.subject} offers two orientations on one square without naming them`,
);
}
}
});
});
describe('the static build', () => {
const root = join(import.meta.dirname, '..');
const dist = join(root, 'dist');
it('builds, and needs nothing but a static host', () => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
assert.ok(existsSync(join(dist, 'index.html')), 'no index.html');
assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
});
it('emits no import that a browser cannot resolve', () => {
// Node's type-stripping lets the source import './x.ts'; a browser cannot. The build rewrites
// those to .js, and nothing may reach for a bare module or a node: builtin.
const files: string[] = [];
const walk = (dir: string): void => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) walk(join(dir, e.name));
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
}
};
walk(dist);
assert.ok(files.length > 5, 'suspiciously few emitted files');
for (const f of files) {
const src = readFileSync(f, 'utf8');
// Anchor to real import/export statements. A loose `from '...'` also matches PROSE — the
// turnout comment "a train entering a turnout from \"A\"" was read as importing a module
// called A, which is the kind of false alarm that trains you to ignore a failing test.
const imports = /^\s*(?:import|export)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm;
for (const m of src.matchAll(imports)) {
const spec = m[1]!;
assert.ok(!spec.endsWith('.ts'), `${f} imports a .ts path: ${spec}`);
assert.ok(!spec.startsWith('node:'), `${f} imports a Node builtin: ${spec}`);
assert.ok(spec.startsWith('.') || spec.startsWith('/'), `${f} imports bare module: ${spec}`);
}
assert.ok(!/^[^/*]*\bprocess\.\w/m.test(src), `${f} references process`);
assert.ok(!/\brequire\(/.test(src), `${f} uses require()`);
}
});
it('renders clickable highlights on the board when a card is picked', async () => {
// The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
// nothing about whether the page draws them.
const els = new Map<string, Record<string, unknown>>();
const make = (): Record<string, unknown> => {
let html = '';
// Cache per selector and clear it when innerHTML changes. Returning fresh objects each call
// would drop the handlers the page assigns — the test would then report "no button" for a
// page that works perfectly.
let cache = new Map<string, Record<string, unknown>[]>();
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
querySelectorAll(sel: string) {
const hit = cache.get(sel);
if (hit) return hit;
const out: Record<string, unknown>[] = [];
const re = sel.startsWith('[')
? /data-at="([^"]*)"/g
: new RegExp(`<button class="${sel.split('.')[1]}[^"]*"([^>]*)>`, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(html)) !== null) {
const data: Record<string, string> = {};
if (sel.startsWith('[')) data['at'] = m[1]!;
else {
const attr = /data-(\w+)="([^"]*)"/g;
let a: RegExpExecArray | null;
while ((a = attr.exec(m[1]!)) !== null) data[a[1]!] = a[2]!;
}
out.push({ dataset: data, onclick: null });
}
cache.set(sel, out);
return out;
},
};
Object.defineProperty(node, 'innerHTML', {
get: () => html,
set: (v: string) => {
html = v;
cache = new Map();
},
});
return node;
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
};
g['location'] = { search: '?seed=555' };
const store = new Map<string, string>();
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
// No parameter properties — `erasableSyntaxOnly` forbids them, since Node strips types rather
// than compiling them.
g['URLSearchParams'] = class {
search: string;
constructor(search: string) {
this.search = search;
}
get(k: string): string | null {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
}
};
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
const actions = els.get('actions')!;
const grid = els.get('grid')!;
// Take the draw option, then pick the first placeable subject.
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
const nodes = fn.call(el, sel);
const target = nodes[0];
if (!target) return false;
(target['onclick'] as (() => void) | null)?.();
return true;
};
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
const html = String(grid['innerHTML']);
assert.match(html, /class="cell[^"]*legal/, 'picking a card highlighted nothing');
assert.match(html, /data-at="/, 'highlighted squares carry no click target');
assert.match(html, /ghost/, 'no empty square was offered as a destination');
});
it('serves a page that loads the game as a module', () => {
const html = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(html, /<script type="module" src="\.\/web\/main\.js">/);
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), 'page fetches something external');
for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {
assert.ok(html.includes(`id="${id}"`), `page is missing #${id}`);
}
});
});