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

    ${esc(g.title)}

    ` + + g.actions.map((a) => ``).join('') + + `
    `, + ) + .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 += `

    ${esc(group.title)}

    `; + for (const item of group.items) { + const open = selected === item.subjectKey; + html += + ``; + 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 += + `
    ` + + (pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') + + `
    ` + + shown.map((sp) => ``).join('') + + `
    `; + } + } + html += `
    `; + } + 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 | null { + try { + const raw = localStorage.getItem(SAVE_KEY); + return raw ? (JSON.parse(raw) as ReturnType) : null; + } catch { + return null; + } +} + +function clearSave(): void { + try { + localStorage.removeItem(SAVE_KEY); + } catch { + /* nothing to do */ + } +} + +start(); diff --git a/test/mainline-cards.test.ts b/test/mainline-cards.test.ts index 4c1884c..92272e8 100644 --- a/test/mainline-cards.test.ts +++ b/test/mainline-cards.test.ts @@ -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', + ); + }); +}); diff --git a/test/replay.test.ts b/test/replay.test.ts index 1166aed..41b7bc8 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -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('')); + const els = new Map>(); + 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`); diff --git a/test/web.test.ts b/test/web.test.ts new file mode 100644 index 0000000..5cc8b49 --- /dev/null +++ b/test/web.test.ts @@ -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(); + 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(); + 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>(); + const make = (): Record => { + 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[]>(); + const node: Record = { + textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0, + querySelectorAll(sel: string) { + const hit = cache.get(sel); + if (hit) return hit; + const out: Record[] = []; + const re = sel.startsWith('[') + ? /data-at="([^"]*)"/g + : new RegExp(`