Compare commits
2
Commits
d261ad7af8
...
2eca9de09f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eca9de09f | ||
|
|
160190da3f |
+4
-1
@@ -9,7 +9,10 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
|
|||||||
@@ -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}`);
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
} from './content.ts';
|
} from './content.ts';
|
||||||
import type { Direction } from './content.ts';
|
import type { Direction } from './content.ts';
|
||||||
import type { GameEvent } from './events.ts';
|
import type { GameEvent } from './events.ts';
|
||||||
import { areaOf } from './apply.ts';
|
import { acceptsCar, areaOf } from './apply.ts';
|
||||||
import { legalActions } from './legal.ts';
|
import { legalActions } from './legal.ts';
|
||||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, TrayId } from './state.ts';
|
import type { CrewTray, DivisionNode, GameState, PlayerIndex, TrayId } from './state.ts';
|
||||||
import { coordKey, freshTurn, totalRevenue } 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;
|
if (tray.consist.length >= want) continue;
|
||||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
// 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.
|
// the yard is potentially suitable unless the card narrows it.
|
||||||
const allowed = profile.consist.freightTypes;
|
// Ask the SAME predicate `check` uses. A separate copy of this test stalled the game: the phase
|
||||||
const suitable = s.yards.divisionYard.some(
|
// believed a car could be added while check rejected every option, so the Stage never ended.
|
||||||
(c) => c.type === 'caboose' || c.type === 'coach' || !allowed || allowed.includes(c.type),
|
const suitable = s.yards.divisionYard.some((c) => acceptsCar(tray, c.type));
|
||||||
);
|
|
||||||
if (suitable) return id;
|
if (suitable) return id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+87
-4
@@ -27,12 +27,14 @@ import {
|
|||||||
modifierProfile,
|
modifierProfile,
|
||||||
nextOfficeTier,
|
nextOfficeTier,
|
||||||
officeProfile,
|
officeProfile,
|
||||||
|
trainProfile,
|
||||||
} from './content.ts';
|
} from './content.ts';
|
||||||
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
|
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
|
||||||
import type { GameEvent } from './events.ts';
|
import type { GameEvent } from './events.ts';
|
||||||
import type { Intent, RejectionCode } from './intents.ts';
|
import type { Intent, RejectionCode } from './intents.ts';
|
||||||
import type {
|
import type {
|
||||||
CardId,
|
CardId,
|
||||||
|
CrewTray,
|
||||||
Facility,
|
Facility,
|
||||||
GameState,
|
GameState,
|
||||||
GridCoord,
|
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)) {
|
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
|
||||||
return 'NO_SUITABLE_CAR';
|
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': {
|
case 'newTrain.secondSection': {
|
||||||
@@ -613,8 +623,15 @@ function checkPlay(
|
|||||||
// nothing, which is exactly the bug that made Modifiers dead weight for weeks.
|
// nothing, which is exactly the bug that made Modifiers dead weight for weeks.
|
||||||
return 'NOT_IMPLEMENTED';
|
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:
|
default:
|
||||||
// Train cards go to the timetable; the phase driver schedules them.
|
|
||||||
return null;
|
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 — 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 {
|
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||||
if (placed.row !== area.runningRow) return;
|
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
@@ -252,7 +252,11 @@ function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
|
|||||||
// Q7 — a district hangs BELOW the Running Track; there is nothing above it.
|
// Q7 — a district hangs BELOW the Running Track; there is nothing above it.
|
||||||
if (c.row > area.runningRow) continue;
|
if (c.row > area.runningRow) continue;
|
||||||
const k = `${c.row},${c.col}`;
|
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);
|
seen.add(k);
|
||||||
out.push(c);
|
out.push(c);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -330,7 +330,16 @@ export function allReachable(
|
|||||||
* stubs above and below so this is satisfiable from the opening Stage.
|
* stubs above and below so this is satisfiable from the opening Stage.
|
||||||
*/
|
*/
|
||||||
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
|
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'];
|
const ports: Port[] = ['n', 's', 'e', 'w'];
|
||||||
for (const p of ports) {
|
for (const p of ports) {
|
||||||
|
|||||||
+96
-42
@@ -56,17 +56,37 @@ function ruleOnClearance(options: Intent[]): Intent | null {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the developer bot made its last choice, for the replay's decision panel.
|
||||||
|
*
|
||||||
|
* WRITE-ONLY DIAGNOSTIC. Nothing reads this to make a decision, so it cannot affect play or
|
||||||
|
* determinism — it exists so the viewer can show the branch that fired instead of re-deriving the
|
||||||
|
* reasoning, which would drift from the bot exactly as a second copy of the rules would.
|
||||||
|
*/
|
||||||
|
let lastReason = '';
|
||||||
|
|
||||||
|
export function lastChoiceReason(): string {
|
||||||
|
return lastReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records the branch that fired and returns the intent unchanged. */
|
||||||
|
function because(reason: string, intent: Intent): Intent {
|
||||||
|
lastReason = reason;
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
|
|
||||||
export const developerBot: BotPolicy = {
|
export const developerBot: BotPolicy = {
|
||||||
name: 'developer',
|
name: 'developer',
|
||||||
|
|
||||||
choose(s, player, options) {
|
choose(s, player, options) {
|
||||||
|
lastReason = 'no specific reason — first legal option';
|
||||||
const clearance = ruleOnClearance(options);
|
const clearance = ruleOnClearance(options);
|
||||||
if (clearance) return clearance;
|
if (clearance) return because('the Superintendent must rule on a following train (§8.1)', clearance);
|
||||||
|
|
||||||
// Red Flags come before anything else — protection is only worth playing at the moment the
|
// Red Flags come before anything else — protection is only worth playing at the moment the
|
||||||
// collision is actually pending, and that moment passes.
|
// collision is actually pending, and that moment passes.
|
||||||
const flags = worthFlagging(s, options);
|
const flags = worthFlagging(s, options);
|
||||||
if (flags) return flags;
|
if (flags) return because('a train of ours is stopped on a Mainline card with another train on it — Red Flags now or not at all', flags);
|
||||||
|
|
||||||
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
|
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
|
||||||
//
|
//
|
||||||
@@ -87,7 +107,8 @@ export const developerBot: BotPolicy = {
|
|||||||
// jam costs a Freight Agent action to clear AND locks the industry track until it is cleared.
|
// jam costs a Freight Agent action to clear AND locks the industry track until it is cleared.
|
||||||
// A first attempt kept a last-resort `startLoad` here "rather than idle", and the measured
|
// A first attempt kept a last-resort `startLoad` here "rather than idle", and the measured
|
||||||
// result was identical to having no gate at all — 415 starts, 413 unjams, 13 completions.
|
// result was identical to having no gate at all — 415 starts, 413 unjams, 13 completions.
|
||||||
return work ?? options.find((i) => i.type === 'loadUnload.end') ?? options[0]!;
|
if (work) return because(loadReason(work), work);
|
||||||
|
return because('every worker is spent or has nothing it can finish', options.find((i) => i.type === 'loadUnload.end') ?? options[0]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- New Train: fill the consist, but with the RIGHT cars.
|
// --- New Train: fill the consist, but with the RIGHT cars.
|
||||||
@@ -102,9 +123,9 @@ export const developerBot: BotPolicy = {
|
|||||||
const match = options.find(
|
const match = options.find(
|
||||||
(i) => i.type === 'newTrain.placeCar' && i.carType === w.type && i.loaded === w.loaded,
|
(i) => i.type === 'newTrain.placeCar' && i.carType === w.type && i.loaded === w.loaded,
|
||||||
);
|
);
|
||||||
if (match) return match;
|
if (match) return because(`the ${w.loaded ? 'loaded' : 'empty'} ${w.type} is what a facility is short of`, match);
|
||||||
}
|
}
|
||||||
return pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!;
|
return because('no car on offer is one our facilities need', pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Local Operations.
|
// --- Local Operations.
|
||||||
@@ -140,43 +161,51 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
|
|||||||
// An Office upgrade outranks even a train card: a train that arrives with nowhere to stand is a
|
// An Office upgrade outranks even a train card: a train that arrives with nowhere to stand is a
|
||||||
// collision, and collisions are the largest single drain on revenue.
|
// collision, and collisions are the largest single drain on revenue.
|
||||||
if (can('draw') && hand.some((id) => s.cards.get(id)?.kind.kind === 'office')) {
|
if (can('draw') && hand.some((id) => s.cards.get(id)?.kind.kind === 'office')) {
|
||||||
return can('draw')!;
|
return because('an Office upgrade is in hand — more A/D track means fewer collisions', can('draw')!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!;
|
if (can('draw') && hand.some((id) => isTrainCard(s, id))) {
|
||||||
|
return because('a train card is in hand and its value compounds every Day', can('draw')!);
|
||||||
|
}
|
||||||
|
|
||||||
// A Mainline modifier is worth the option too: Realignment permanently converts a 30 card into a
|
// A Mainline modifier is worth the option too: Realignment permanently converts a 30 card into a
|
||||||
// 60, and Helpers/Brakeman take a Stage off every future crossing. Like a train card, the value
|
// 60, and Helpers/Brakeman take a Stage off every future crossing. Like a train card, the value
|
||||||
// compounds — but only if it can be laid right now, so check for a legal target rather than for
|
// compounds — but only if it can be laid right now, so check for a legal target rather than for
|
||||||
// the card sitting in hand.
|
// the card sitting in hand.
|
||||||
if (can('draw') && options.some((i) => i.type === 'mainline.modify')) return can('draw')!;
|
if (can('draw') && options.some((i) => i.type === 'mainline.modify')) {
|
||||||
|
return because('a Mainline modifier can be laid — every later crossing pays less', can('draw')!);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. A train standing at the Office is a fleeting chance to spot cars — but ONLY if there is
|
// 2. A train standing at the Office is a fleeting chance to spot cars — but ONLY if there is
|
||||||
// actually a car to spot or collect. Choosing to switch merely because a train is present
|
// actually a car to spot or collect. Choosing to switch merely because a train is present
|
||||||
// wasted the whole Local Operations action shuttling back and forth: a passenger train needs
|
// wasted the whole Local Operations action shuttling back and forth: a passenger train needs
|
||||||
// no switching at all, because §9.2 works coaches straight off the A/D track.
|
// no switching at all, because §9.2 works coaches straight off the A/D track.
|
||||||
if (can('switch') && area.adOccupancy.length > 0 && usefulSwitching(s, player)) {
|
if (can('switch') && area.adOccupancy.length > 0 && usefulSwitching(s, player)) {
|
||||||
return can('switch')!;
|
return because('a train is at the Office and there is switching worth doing', can('switch')!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A crew stranded away from the Office is worth a whole turn on its own. A train may only
|
// A crew stranded away from the Office is worth a whole turn on its own. A train may only
|
||||||
// highball from the Office square, so one sitting anywhere else is out of the game permanently —
|
// highball from the Office square, so one sitting anywhere else is out of the game permanently —
|
||||||
// and the condition above never fires for it, because a crew down in the district has no work
|
// and the condition above never fires for it, because a crew down in the district has no work
|
||||||
// left and would never claim the option needed to walk back.
|
// left and would never claim the option needed to walk back.
|
||||||
if (can('switch') && strandedFromOffice(s, player)) return can('switch')!;
|
if (can('switch') && strandedFromOffice(s, player)) {
|
||||||
|
return because('the crew is away from the Office and can never depart from where it stands', can('switch')!);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Stock a green box only when the load can actually finish — an empty car of the right type
|
// 3. Stock a green box only when the load can actually finish — an empty car of the right type
|
||||||
// is already spotted. Stocking without one just fills the box.
|
// is already spotted. Stocking without one just fills the box.
|
||||||
if (can('freightAgent') && canStockProductively(s, player)) return can('freightAgent')!;
|
if (can('freightAgent') && canStockProductively(s, player)) {
|
||||||
|
return because('a green box can be stocked with a load that can actually finish', can('freightAgent')!);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Rescue a jammed facility, or clear a full red box blocking further unloading.
|
// 4. Rescue a jammed facility, or clear a full red box blocking further unloading.
|
||||||
if (can('freightAgent') && (hasStuckLoad(s, player) || needsClearing(s, player))) {
|
if (can('freightAgent') && (hasStuckLoad(s, player) || needsClearing(s, player))) {
|
||||||
return can('freightAgent')!;
|
return because('a facility is jammed or its red box is full, blocking the pipeline', can('freightAgent')!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay.
|
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay.
|
||||||
if (can('draw')) return can('draw')!;
|
if (can('draw')) return because('nothing urgent — develop the district instead', can('draw')!);
|
||||||
return can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!;
|
return because('no option has a clear purpose this turn', can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A face-up card worth spending the draw on rather than gambling on the deck. */
|
/** A face-up card worth spending the draw on rather than gambling on the deck. */
|
||||||
@@ -317,6 +346,24 @@ function worthFlagging(s: GameState, options: Intent[]): Intent | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A one-line account of which Load/Unload action was taken, and why it ranked first. */
|
||||||
|
function loadReason(i: Intent): string {
|
||||||
|
switch (i.type) {
|
||||||
|
case 'porter.board':
|
||||||
|
return 'a Porter earns a point in ONE action — always the best use of a worker';
|
||||||
|
case 'porter.detrain':
|
||||||
|
return 'detraining passengers is a point for a single Porter action';
|
||||||
|
case 'laborer.advanceLoad':
|
||||||
|
return 'finish work already started — a load parked on WORK locks the industry track';
|
||||||
|
case 'laborer.startLoad':
|
||||||
|
return 'a matching empty car is spotted, so this load can actually finish';
|
||||||
|
case 'laborer.beginUnload':
|
||||||
|
return 'a loaded car is spotted and the red box has room';
|
||||||
|
default:
|
||||||
|
return 'the best remaining use of a worker';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is there already an empty car of the right type spotted to receive this load (§9.3)? Without one
|
* Is there already an empty car of the right type spotted to receive this load (§9.3)? Without one
|
||||||
* the load can be started and walked across MEN|AT|WORK but can never come off.
|
* the load can be started and walked across MEN|AT|WORK but can never come off.
|
||||||
@@ -376,11 +423,11 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
const useful = options.find(
|
const useful = options.find(
|
||||||
(i) => i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
|
(i) => i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
|
||||||
);
|
);
|
||||||
if (useful) return useful;
|
if (useful) return because('a face-up card is worth more than a blind draw right now', useful);
|
||||||
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
|
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
|
||||||
if (blind) return blind;
|
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
|
||||||
const any = options.find((i) => i.type === 'draw.fromDepartment');
|
const any = options.find((i) => i.type === 'draw.fromDepartment');
|
||||||
if (any) return any;
|
if (any) return because('the deck is empty, so take a face-up card', any);
|
||||||
}
|
}
|
||||||
// UPGRADE THE OFFICE FIRST. A Whistle Post has ONE A/D track, so a second arrival is an
|
// UPGRADE THE OFFICE FIRST. A Whistle Post has ONE A/D track, so a second arrival is an
|
||||||
// automatic collision (§8.3, Gap 2a) — and measured, 25 of 26 collisions happened at Whistle
|
// automatic collision (§8.3, Gap 2a) — and measured, 25 of 26 collisions happened at Whistle
|
||||||
@@ -391,13 +438,13 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
const upgrade = options.find(
|
const upgrade = options.find(
|
||||||
(i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind === 'office',
|
(i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind === 'office',
|
||||||
);
|
);
|
||||||
if (upgrade) return upgrade;
|
if (upgrade) return because('upgrade the Office — a Whistle Post has ONE A/D track and a second arrival collides', upgrade);
|
||||||
|
|
||||||
// Train cards next — they take no placement and their value compounds every Day.
|
// Train cards next — they take no placement and their value compounds every Day.
|
||||||
const train = options.find(
|
const train = options.find(
|
||||||
(i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
|
(i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
|
||||||
);
|
);
|
||||||
if (train) return train;
|
if (train) return because('a scheduled train runs every Day thereafter — the only card whose value compounds', train);
|
||||||
|
|
||||||
// Mainline modifiers rank with train cards: every train that crosses afterwards pays the
|
// Mainline modifiers rank with train cards: every train that crosses afterwards pays the
|
||||||
// lower price. Realignment first — converting Curves to Plains halves the crossing for
|
// lower price. Realignment first — converting Curves to Plains halves the crossing for
|
||||||
@@ -405,9 +452,9 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
const realign = options.find(
|
const realign = options.find(
|
||||||
(i) => i.type === 'mainline.modify' && isMainlineKey(s, i.cardId, 'realignment'),
|
(i) => i.type === 'mainline.modify' && isMainlineKey(s, i.cardId, 'realignment'),
|
||||||
);
|
);
|
||||||
if (realign) return realign;
|
if (realign) return because('Realignment converts this Mainline card into a faster one for every future crossing', realign);
|
||||||
const grade = options.find((i) => i.type === 'mainline.modify');
|
const grade = options.find((i) => i.type === 'mainline.modify');
|
||||||
if (grade) return grade;
|
if (grade) return because('a grade modifier takes a Stage off every crossing in that direction', grade);
|
||||||
|
|
||||||
// Enhancements next: Small Yard makes switching solvable, Interlocking stops the Office
|
// Enhancements next: Small Yard makes switching solvable, Interlocking stops the Office
|
||||||
// overflowing into a collision, and the dispatch devices win meets. All are worth more than
|
// overflowing into a collision, and the dispatch devices win meets. All are worth more than
|
||||||
@@ -420,12 +467,12 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
i.placement !== undefined &&
|
i.placement !== undefined &&
|
||||||
isEnhancementKey(s, i.cardId, 'interlocking'),
|
isEnhancementKey(s, i.cardId, 'interlocking'),
|
||||||
);
|
);
|
||||||
if (interlock) return interlock;
|
if (interlock) return because('Interlocking holds an arrival at the Limits instead of colliding into a full Office', interlock);
|
||||||
|
|
||||||
const enh = options.find(
|
const enh = options.find(
|
||||||
(i) => i.type === 'card.play' && i.placement !== undefined && isEnhancement(s, i.cardId),
|
(i) => i.type === 'card.play' && i.placement !== undefined && isEnhancement(s, i.cardId),
|
||||||
);
|
);
|
||||||
if (enh) return enh;
|
if (enh) return because('an Enhancement is permanent and changes how the district works', enh);
|
||||||
|
|
||||||
// Lay track before spending a card on the grid. Track is the scarce enabler, not the
|
// Lay track before spending a card on the grid. Track is the scarce enabler, not the
|
||||||
// consolation prize: nothing else in the deck can create the Running-Track and Secondary-Track
|
// consolation prize: nothing else in the deck can create the Running-Track and Secondary-Track
|
||||||
@@ -433,16 +480,16 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
// district exists. Measured with track absent, the hand held a playable Enhancement on 4,778
|
// district exists. Measured with track absent, the hand held a playable Enhancement on 4,778
|
||||||
// turns and could legally place one on 33.
|
// turns and could legally place one on 33.
|
||||||
const track = bestTrackLay(s, player, options);
|
const track = bestTrackLay(s, player, options);
|
||||||
if (track) return track;
|
if (track) return because('lay track — nothing else creates the straights Enhancements need or the spurs freight needs', track);
|
||||||
|
|
||||||
// Then real development: a card actually laid into the grid.
|
// Then real development: a card actually laid into the grid.
|
||||||
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
|
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
|
||||||
if (placed) return placed;
|
if (placed) return because('develop the district with a card that goes on the board', placed);
|
||||||
const play = options.find((i) => i.type === 'card.play');
|
const play = options.find((i) => i.type === 'card.play');
|
||||||
if (play) return play;
|
if (play) return because('play what is in hand', play);
|
||||||
const end = options.find((i) => i.type === 'draw.end');
|
const end = options.find((i) => i.type === 'draw.end');
|
||||||
if (end) return end;
|
if (end) return because('nothing in hand can be played anywhere legal', end);
|
||||||
return pickFirst(options, 'card.discard') ?? options[0]!;
|
return because('nothing playable and nothing to draw — discard to a Department slot', pickFirst(options, 'card.discard') ?? options[0]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'switch': {
|
case 'switch': {
|
||||||
@@ -457,7 +504,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
i.count === 1 &&
|
i.count === 1 &&
|
||||||
facilityWantsAt(s, player, i.to, trayOf(s, player)?.consist.slice(-1)[0]),
|
facilityWantsAt(s, player, i.to, trayOf(s, player)?.consist.slice(-1)[0]),
|
||||||
);
|
);
|
||||||
if (flyingFirst) return flyingFirst;
|
if (flyingFirst) return because('a Flying Switch rolls the back car straight into an industry that wants it', flyingFirst);
|
||||||
|
|
||||||
if (!usefulSwitching(s, player)) {
|
if (!usefulSwitching(s, player)) {
|
||||||
// GO HOME. A train may only highball from the Office square itself (§8.1, Gap 2b) — from
|
// GO HOME. A train may only highball from the Office square itself (§8.1, Gap 2b) — from
|
||||||
@@ -467,9 +514,9 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
// the wrong column and 43 down in the district. A parked train earns nothing, holds its A/D
|
// the wrong column and 43 down in the district. A parked train earns nothing, holds its A/D
|
||||||
// track, and is unavailable for the next load.
|
// track, and is unavailable for the next load.
|
||||||
const home = moveTowardOffice(s, player, options);
|
const home = moveTowardOffice(s, player, options);
|
||||||
if (home) return home;
|
if (home) return because('no switching left worth doing — head back to the Office so the train can depart', home);
|
||||||
const stop = options.find((i) => i.type === 'switch.end');
|
const stop = options.find((i) => i.type === 'switch.end');
|
||||||
if (stop) return stop;
|
if (stop) return because('no switching left worth doing, and the crew is already at the Office', stop);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spotting the RIGHT car at the RIGHT facility is the whole point of switching.
|
// Spotting the RIGHT car at the RIGHT facility is the whole point of switching.
|
||||||
@@ -499,7 +546,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
const sort = options.find(
|
const sort = options.find(
|
||||||
(i) => i.type === 'switch.sortConsist' && i.order.join() === order.join(),
|
(i) => i.type === 'switch.sortConsist' && i.order.join() === order.join(),
|
||||||
);
|
);
|
||||||
if (sort) return sort;
|
if (sort) return because('standing on a Small Yard — re-order so a car a facility wants ends up droppable', sort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,7 +559,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
// Standing on a facility that wants the back car: put it down. This is the payoff move.
|
// Standing on a facility that wants the back car: put it down. This is the payoff move.
|
||||||
if (hereFacility && facilityWants(hereFacility, endCar)) {
|
if (hereFacility && facilityWants(hereFacility, endCar)) {
|
||||||
const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
|
const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
|
||||||
if (drop) return drop;
|
if (drop) return because('standing on a facility that wants the back car — spot it', drop);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SET OUT — and do it BEFORE travelling.
|
// SET OUT — and do it BEFORE travelling.
|
||||||
@@ -538,7 +585,14 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
const isSpur = here.row !== area.runningRow && !hereFacility;
|
const isSpur = here.row !== area.runningRow && !hereFacility;
|
||||||
if (isSpur) {
|
if (isSpur) {
|
||||||
const setOut = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
|
const setOut = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
|
||||||
if (setOut) return setOut;
|
if (setOut) {
|
||||||
|
return because(
|
||||||
|
tray.consist.length >= MAX_CONSIST
|
||||||
|
? 'the tray is full, so nothing can be coupled — set a car out here'
|
||||||
|
: 'the back car is dead weight hiding a car a facility wants — set it out to expose it',
|
||||||
|
setOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const toSpur = options.find(
|
const toSpur = options.find(
|
||||||
(i) =>
|
(i) =>
|
||||||
@@ -546,7 +600,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
i.to.row !== area.runningRow &&
|
i.to.row !== area.runningRow &&
|
||||||
!area.grid.get(`${i.to.row},${i.to.col}`)?.facility,
|
!area.grid.get(`${i.to.row},${i.to.col}`)?.facility,
|
||||||
);
|
);
|
||||||
if (toSpur) return toSpur;
|
if (toSpur) return because('looking for a plain spur to set out on — an industry track would silt with the wrong commodity', toSpur);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now travel — to a facility that wants SOMETHING aboard, not only the back car. Weighing
|
// Now travel — to a facility that wants SOMETHING aboard, not only the back car. Weighing
|
||||||
@@ -562,7 +616,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
i.type === 'switch.move' &&
|
i.type === 'switch.move' &&
|
||||||
wanted.some((t) => t.row === i.to.row && t.col === i.to.col),
|
wanted.some((t) => t.row === i.to.row && t.col === i.to.col),
|
||||||
);
|
);
|
||||||
if (toward) return toward;
|
if (toward) return because('heading for a facility that wants a car we are carrying', toward);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never take an arbitrary Move. Picking "the first legal move" is what produced the original
|
// Never take an arbitrary Move. Picking "the first legal move" is what produced the original
|
||||||
@@ -571,8 +625,8 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
// Moves oscillating between two cells. If there is no move with a purpose, the only useful
|
// Moves oscillating between two cells. If there is no move with a purpose, the only useful
|
||||||
// thing left is to head for the Office, because a train that is not on it can never depart.
|
// thing left is to head for the Office, because a train that is not on it can never depart.
|
||||||
const goHome = moveTowardOffice(s, player, options);
|
const goHome = moveTowardOffice(s, player, options);
|
||||||
if (goHome) return goHome;
|
if (goHome) return because('nothing productive left — head for the Office rather than shuttle aimlessly', goHome);
|
||||||
return options.find((i) => i.type === 'switch.end') ?? options[0]!;
|
return because('out of useful switching moves', options.find((i) => i.type === 'switch.end') ?? options[0]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'freightAgent': {
|
case 'freightAgent': {
|
||||||
@@ -581,13 +635,13 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
// rescue it. §6.3's unjam exists for exactly this. Clear it before anything else.
|
// rescue it. §6.3's unjam exists for exactly this. Clear it before anything else.
|
||||||
if (hasStuckLoad(s, player)) {
|
if (hasStuckLoad(s, player)) {
|
||||||
const unjam = options.find((i) => i.type === 'freightAgent.unjam' && i.from === 'menAtWork');
|
const unjam = options.find((i) => i.type === 'freightAgent.unjam' && i.from === 'menAtWork');
|
||||||
if (unjam) return unjam;
|
if (unjam) return because('a jammed load blocks the pipeline AND strips the industry track of Operational Rail', unjam);
|
||||||
}
|
}
|
||||||
const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
|
const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
|
||||||
if (clear) return clear;
|
if (clear) return because('the red Inbound box is full and blocking further unloading', clear);
|
||||||
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
|
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
|
||||||
if (stock) return stock;
|
if (stock) return because('stock a green box so a Laborer has work next Stage', stock);
|
||||||
return pickFirst(options, 'freightAgent.unjam') ?? options[0]!;
|
return because('nothing productive at any facility — clear whatever is stuck', pickFirst(options, 'freightAgent.unjam') ?? options[0]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export function clockTime(stage: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function carLabel(c: RollingStock): 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}`;
|
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+119
-261
@@ -23,263 +23,24 @@ import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from
|
|||||||
import type { GameLength } from '../engine/content.ts';
|
import type { GameLength } from '../engine/content.ts';
|
||||||
import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts';
|
import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts';
|
||||||
import type { GameEvent } from '../engine/events.ts';
|
import type { GameEvent } from '../engine/events.ts';
|
||||||
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import { legalActions } from '../engine/legal.ts';
|
import { legalActions } from '../engine/legal.ts';
|
||||||
import { createGame } from '../engine/setup.ts';
|
import { createGame } from '../engine/setup.ts';
|
||||||
import type { GameConfig, GameState } from '../engine/state.ts';
|
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
||||||
import { developerBot } from './bot.ts';
|
import { developerBot, lastChoiceReason } from './bot.ts';
|
||||||
import type { Impediment } from './narrate.ts';
|
import { carLabel, idleNote, isVisible, narrate } from './narrate.ts';
|
||||||
import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } 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
|
// 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshot(
|
|
||||||
s: GameState,
|
|
||||||
lines: { text: string; tone: string }[],
|
|
||||||
where: { row: number; col: number } | null,
|
|
||||||
whereFrom: { row: number; col: number } | null = null,
|
|
||||||
): 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],
|
|
||||||
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
|
// Recording
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -304,6 +65,8 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
|||||||
|
|
||||||
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
|
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
|
||||||
let didSomething = false;
|
let didSomething = false;
|
||||||
|
// The choice that produced the events about to be pushed.
|
||||||
|
let pendingDecision: Decision | null = null;
|
||||||
|
|
||||||
const push = (events: GameEvent[]): void => {
|
const push = (events: GameEvent[]): void => {
|
||||||
const visible = events.filter(isVisible);
|
const visible = events.filter(isVisible);
|
||||||
@@ -328,7 +91,16 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
|||||||
const where = visible.map((e) => narrate(e, narrateCtx)).find((n) => n.where)?.where ?? null;
|
const where = visible.map((e) => narrate(e, narrateCtx)).find((n) => n.where)?.where ?? null;
|
||||||
const moved = visible.find((e) => e.type === 'trayMoved');
|
const moved = visible.find((e) => e.type === 'trayMoved');
|
||||||
const from = moved && moved.type === 'trayMoved' ? moved.from : null;
|
const from = moved && moved.type === 'trayMoved' ? moved.from : null;
|
||||||
frames.push(snapshot(s, lines, where ?? null, from));
|
const d = pendingDecision;
|
||||||
|
pendingDecision = null;
|
||||||
|
// A Local Operations turn that ended with nothing but the turn ending is a wasted action —
|
||||||
|
// six Moves and a card play spent on nothing. Among 600+ frames these are invisible unless
|
||||||
|
// marked, and they are the ones worth reviewing.
|
||||||
|
const wasted =
|
||||||
|
d !== null &&
|
||||||
|
(d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') &&
|
||||||
|
visible.every((e) => e.type === 'phaseEnded' || e.type === 'actorChanged');
|
||||||
|
frames.push(snapshot(s, lines, where ?? null, from, d, wasted));
|
||||||
};
|
};
|
||||||
|
|
||||||
frames.push(
|
frames.push(
|
||||||
@@ -345,8 +117,11 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
|||||||
if (actor === null) break;
|
if (actor === null) break;
|
||||||
const options = legalActions(s, actor);
|
const options = legalActions(s, actor);
|
||||||
if (options.length === 0) break;
|
if (options.length === 0) break;
|
||||||
const applied = applyIntent(s, actor, developerBot.choose(s, actor, options));
|
const chosen = developerBot.choose(s, actor, options);
|
||||||
|
const decision = describeDecision(s, actor, chosen, options, lastChoiceReason());
|
||||||
|
const applied = applyIntent(s, actor, chosen);
|
||||||
if (!applied.ok) break;
|
if (!applied.ok) break;
|
||||||
|
pendingDecision = decision;
|
||||||
push(applied.events);
|
push(applied.events);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,6 +138,27 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
|||||||
// HTML
|
// 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 {
|
export function renderHtml(rec: Recording): string {
|
||||||
const target = lengthProfile(rec.length);
|
const target = lengthProfile(rec.length);
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
@@ -432,6 +228,21 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
|||||||
.mini .box{padding:0 4px;font-size:9.5px}
|
.mini .box{padding:0 4px;font-size:9.5px}
|
||||||
.cell.office{border-color:var(--clock)}
|
.cell.office{border-color:var(--clock)}
|
||||||
.cell.modifier{border-style:dashed;opacity:.85}
|
.cell.modifier{border-style:dashed;opacity:.85}
|
||||||
|
|
||||||
|
.chose{font-size:15px;margin-bottom:4px}
|
||||||
|
.why{font-style:italic;opacity:.85;margin-bottom:2px}
|
||||||
|
.rejected{list-style:none;margin:0;padding:0;max-height:210px;overflow:auto}
|
||||||
|
.rejected li{padding:3px 0;border-top:1px solid rgba(128,128,128,.25);font-size:12px}
|
||||||
|
.wasted{background:#b03030;color:#fff;font-weight:bold;padding:3px 6px;margin-bottom:5px;border-radius:3px}
|
||||||
|
.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: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>
|
</style></head><body>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
@@ -455,6 +266,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<section><h2>What just happened</h2><div id="now"></div></section>
|
<section><h2>What just happened</h2><div id="now"></div></section>
|
||||||
|
<section><h2>Decision — what it chose, and what it passed over</h2><div id="decision"></div></section>
|
||||||
<section><h2>Cards</h2>
|
<section><h2>Cards</h2>
|
||||||
<div><span class="dim">your hand:</span> <span id="hand">—</span></div>
|
<div><span class="dim">your 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>
|
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
|
||||||
@@ -468,7 +280,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
|||||||
<div class="legend">
|
<div class="legend">
|
||||||
<b>Keyboard:</b> <kbd>←</kbd>/<kbd>→</kbd> step · <kbd>space</kbd> play/pause ·
|
<b>Keyboard:</b> <kbd>←</kbd>/<kbd>→</kbd> step · <kbd>space</kbd> play/pause ·
|
||||||
<kbd>Home</kbd>/<kbd>End</kbd> first/last · <kbd>S</kbd> next Stage · <kbd>R</kbd> next revenue change ·
|
<kbd>Home</kbd>/<kbd>End</kbd> first/last · <kbd>S</kbd> next Stage · <kbd>R</kbd> next revenue change ·
|
||||||
<kbd>C</kbd> next collision.<br>
|
<kbd>C</kbd> next collision · <kbd>W</kbd> next wasted turn · <kbd>J</kbd> next jam.<br>
|
||||||
Running Track is the lighter row. <span class="chip">T4</span> is a train on the Division;
|
Running Track is the lighter row. <span class="chip">T4</span> is a train on the Division;
|
||||||
<span style="background:var(--warn);color:#0d1117;font-weight:700;border-radius:3px;padding:0 4px">🚂 T4</span>
|
<span style="background:var(--warn);color:#0d1117;font-weight:700;border-radius:3px;padding:0 4px">🚂 T4</span>
|
||||||
is the crew in your yard — it carries the whole train with it as it moves, and a dashed outline
|
is the crew in your yard — it carries the whole train with it as it moves, and a dashed outline
|
||||||
@@ -485,6 +297,8 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
|||||||
<button id="stage">next Stage ⏭</button>
|
<button id="stage">next Stage ⏭</button>
|
||||||
<button id="money">next revenue 💰</button>
|
<button id="money">next revenue 💰</button>
|
||||||
<button id="crash">next collision ⚠</button>
|
<button id="crash">next collision ⚠</button>
|
||||||
|
<button id="waste">next wasted turn 🗑</button>
|
||||||
|
<button id="jam">next jam 🔒</button>
|
||||||
<input type="range" id="scrub" min="0" value="0">
|
<input type="range" id="scrub" min="0" value="0">
|
||||||
<select id="speed">
|
<select id="speed">
|
||||||
<option value="3000">extra slow</option>
|
<option value="3000">extra slow</option>
|
||||||
@@ -496,7 +310,14 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<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;
|
let i = 0, timer = null;
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
||||||
@@ -543,8 +364,13 @@ function render() {
|
|||||||
$('fno').textContent = i;
|
$('fno').textContent = i;
|
||||||
$('scrub').value = i;
|
$('scrub').value = i;
|
||||||
|
|
||||||
$('division').innerHTML = f.division.map((n) =>
|
const CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
||||||
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) + '</div>' +
|
|
||||||
|
$('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="regions">' + n.trains.map((r) =>
|
||||||
'<div class="region">' + r.map((t) =>
|
'<div class="region">' + r.map((t) =>
|
||||||
'<span class="chip">' + esc(t.label) + '</span>' +
|
'<span class="chip">' + esc(t.label) + '</span>' +
|
||||||
@@ -552,14 +378,14 @@ function render() {
|
|||||||
).join('') + '</div>').join('') +
|
).join('') + '</div>').join('') +
|
||||||
'</div></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 r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols);
|
||||||
const g = $('grid');
|
const g = $('grid');
|
||||||
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
|
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
|
||||||
let cellsHtml = '';
|
let cellsHtml = '';
|
||||||
for (let r = r1; r >= r0; r--) {
|
for (let r = r1; r >= r0; r--) {
|
||||||
for (let c = c0; c <= c1; c++) {
|
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; }
|
if (!cell) { cellsHtml += '<div></div>'; continue; }
|
||||||
const hl = f.where && f.where.row === r && f.where.col === c;
|
const hl = f.where && f.where.row === r && f.where.col === c;
|
||||||
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
|
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
|
||||||
@@ -567,6 +393,9 @@ function render() {
|
|||||||
(cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
|
(cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
|
||||||
'<div class="nm">' + esc(cell.label) + '</div>' +
|
'<div class="nm">' + esc(cell.label) + '</div>' +
|
||||||
'<div class="dim" style="font-size:10px">(' + r + ',' + c + ')</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.tray ? '<div class="crew">🚂 ' + esc(cell.tray) + '</div>' : '') +
|
||||||
(cell.facility ? miniFacility(cell.facility) : '') +
|
(cell.facility ? miniFacility(cell.facility) : '') +
|
||||||
(cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
|
(cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
|
||||||
@@ -575,9 +404,9 @@ function render() {
|
|||||||
}
|
}
|
||||||
g.innerHTML = cellsHtml;
|
g.innerHTML = cellsHtml;
|
||||||
|
|
||||||
$('facs').innerHTML = f.facilities.length === 0
|
$('facs').innerHTML = FACS.length === 0
|
||||||
? '<span class="dim">no freight facilities built yet</span>'
|
? '<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) +
|
'<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>' +
|
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
|
||||||
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
|
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
|
||||||
@@ -585,9 +414,34 @@ function render() {
|
|||||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>' +
|
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, 'r') + '</div>' +
|
'<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
|
||||||
'<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 'g') + '</div>' +
|
'<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 'g') + '</div>' +
|
||||||
|
'<div class="fstat ' + (x.jammed ? 'bad' : (x.canFinish ? 'good' : 'idle')) + '">' +
|
||||||
|
(x.jammed
|
||||||
|
? 'JAMMED — a load is on WORK with no car spotted to receive it; the industry track is locked'
|
||||||
|
: (x.canFinish
|
||||||
|
? 'ready — a matching empty car is spotted, so a load can finish'
|
||||||
|
: 'no car spotted — starting a load here would jam it')) +
|
||||||
|
'</div>' +
|
||||||
'</div>').join('');
|
'</div>').join('');
|
||||||
|
|
||||||
$('now').innerHTML = f.lines.map((l) => '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>').join('');
|
$('now').innerHTML = f.lines.map((l) => '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>').join('');
|
||||||
|
|
||||||
|
var d = f.decision;
|
||||||
|
if (!d) {
|
||||||
|
$('decision').innerHTML = '<span class="dim">no choice here — the engine advanced on its own</span>';
|
||||||
|
} else {
|
||||||
|
var h = '';
|
||||||
|
if (f.wasted) h += '<div class="wasted">WASTED TURN — the whole action produced no change</div>';
|
||||||
|
h += '<div class="chose"><span class="dim">chose:</span> <b>' + esc(d.chose) + '</b></div>';
|
||||||
|
h += '<div class="why">why: ' + esc(d.why) + '</div>';
|
||||||
|
h += '<div class="dim" style="margin:6px 0 3px">passed over ' + (d.totalOptions - 1) +
|
||||||
|
' other legal option' + ((d.totalOptions - 1) === 1 ? '' : 's') + ':</div>';
|
||||||
|
if (d.rejected.length === 0) h += '<div class="dim">none — this was the only legal action</div>';
|
||||||
|
else h += '<ul class="rejected">' + d.rejected.map(function (r) {
|
||||||
|
return '<li><b>' + esc(r.kind) + '</b> <span class="dim">x' + r.count + '</span><br>' +
|
||||||
|
'<span class="dim">' + esc(r.detail) + '</span></li>';
|
||||||
|
}).join('') + '</ul>';
|
||||||
|
$('decision').innerHTML = h;
|
||||||
|
}
|
||||||
$('blocked').innerHTML = f.blocked.length === 0
|
$('blocked').innerHTML = f.blocked.length === 0
|
||||||
? '<li class="dim">nothing blocked</li>'
|
? '<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('');
|
: f.blocked.map((b) => '<li class="sev-' + b.severity + '"><b>' + esc(b.where) + '</b> — ' + esc(b.why) + '</li>').join('');
|
||||||
@@ -604,7 +458,7 @@ function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function go(n) { i = Math.max(0, Math.min(FRAMES.length - 1, n)); 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;
|
$('ftot').textContent = FRAMES.length - 1;
|
||||||
$('scrub').max = FRAMES.length - 1;
|
$('scrub').max = FRAMES.length - 1;
|
||||||
@@ -615,6 +469,8 @@ $('fwd').onclick = () => go(i + 1);
|
|||||||
$('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p.day);
|
$('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p.day);
|
||||||
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
|
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
|
||||||
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
|
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
|
||||||
|
$('waste').onclick = () => findNext((f) => f.wasted);
|
||||||
|
$('jam').onclick = () => findNext((f, p, idx) => carry(idx, 'facilities').some((x) => x.jammed));
|
||||||
$('play').onclick = () => {
|
$('play').onclick = () => {
|
||||||
if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
|
if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
|
||||||
$('play').textContent = '⏸ pause';
|
$('play').textContent = '⏸ pause';
|
||||||
@@ -632,6 +488,8 @@ document.onkeydown = (e) => {
|
|||||||
else if (e.key === 's' || e.key === 'S') $('stage').click();
|
else if (e.key === 's' || e.key === 'S') $('stage').click();
|
||||||
else if (e.key === 'r' || e.key === 'R') $('money').click();
|
else if (e.key === 'r' || e.key === 'R') $('money').click();
|
||||||
else if (e.key === 'c' || e.key === 'C') $('crash').click();
|
else if (e.key === 'c' || e.key === 'C') $('crash').click();
|
||||||
|
else if (e.key === 'w' || e.key === 'W') $('waste').click();
|
||||||
|
else if (e.key === 'j' || e.key === 'J') $('jam').click();
|
||||||
else if (e.key === ' ') { e.preventDefault(); $('play').click(); }
|
else if (e.key === ' ') { e.preventDefault(); $('play').click(); }
|
||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
|
|||||||
+638
@@ -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' ? ' — east–west' : ' — north–south';
|
||||||
|
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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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<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();
|
||||||
@@ -412,3 +412,85 @@ describe('card coverage', () => {
|
|||||||
assert.match(poling!.effect, /TBD/i);
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+61
-2
@@ -191,7 +191,7 @@ describe('replay HTML', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('offers the controls that make it usable', () => {
|
it('offers the controls that make it usable', () => {
|
||||||
for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash']) {
|
for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash', 'waste', 'jam', 'decision']) {
|
||||||
assert.ok(html.includes(`id="${id}"`), `missing control: ${id}`);
|
assert.ok(html.includes(`id="${id}"`), `missing control: ${id}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -207,7 +207,7 @@ describe('replay HTML', () => {
|
|||||||
|
|
||||||
it('wires a handler to every control', () => {
|
it('wires a handler to every control', () => {
|
||||||
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
|
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
|
||||||
for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash']) {
|
for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash', 'waste', 'jam']) {
|
||||||
assert.match(js, new RegExp(`\\$\\('${id}'\\)\\.onclick`), `no handler bound to ${id}`);
|
assert.match(js, new RegExp(`\\$\\('${id}'\\)\\.onclick`), `no handler bound to ${id}`);
|
||||||
}
|
}
|
||||||
assert.match(js, /\$\('scrub'\)\.oninput/, 'no handler bound to scrub');
|
assert.match(js, /\$\('scrub'\)\.oninput/, 'no handler bound to scrub');
|
||||||
@@ -233,6 +233,65 @@ describe('replay HTML', () => {
|
|||||||
assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
|
assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records what the bot chose, why, and what it passed over', () => {
|
||||||
|
// The replay could always show what happened, never what COULD have happened — so a daft move
|
||||||
|
// was visible but the alternatives it declined were not, which is what you need to say what it
|
||||||
|
// should have done instead.
|
||||||
|
const rec = record(202, 'standard');
|
||||||
|
const decided = rec.frames.filter((f) => f.decision !== null);
|
||||||
|
assert.ok(decided.length > 0, 'no frame carried a decision');
|
||||||
|
|
||||||
|
for (const f of decided) {
|
||||||
|
assert.ok(f.decision!.chose.length > 0, 'a decision with no chosen action');
|
||||||
|
assert.ok(f.decision!.why.length > 0, 'a decision with no stated reason');
|
||||||
|
assert.ok(f.decision!.totalOptions >= 1, 'a decision offering nothing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reasons must come from the bot's own branches, not a generic fallback for everything.
|
||||||
|
const reasons = new Set(decided.map((f) => f.decision!.why));
|
||||||
|
assert.ok(reasons.size > 5, `only ${reasons.size} distinct reasons — the branches are not reporting`);
|
||||||
|
|
||||||
|
// Turns with real alternatives must show them, or the panel is decoration.
|
||||||
|
assert.ok(
|
||||||
|
decided.some((f) => f.decision!.rejected.length > 0),
|
||||||
|
'no frame ever recorded a rejected option',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags jammed facilities and can tell them from ready ones', () => {
|
||||||
|
const rec = record(202, 'standard');
|
||||||
|
for (const f of rec.frames) {
|
||||||
|
for (const x of f.facilities) {
|
||||||
|
// A jam is precisely "work on WORK that cannot come off", so it can never be 'ready'.
|
||||||
|
assert.ok(!(x.jammed && x.canFinish), `${x.name} reported both jammed and ready`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
it('stays a sane size', () => {
|
||||||
const mb = Buffer.byteLength(html) / 1024 / 1024;
|
const mb = Buffer.byteLength(html) / 1024 / 1024;
|
||||||
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
|
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user