Compare commits

..
7 Commits
Author SHA1 Message Date
Jesse e23d81eade Implement nose coupling so run-arounds work, and stop coupling wiping the district 2026-08-01 15:15:28 -04:00
Jesse f52ff0e9ac Draw the board as track, split the site into three pages, and fix curve geometry
Adds SVG board rendering shared by the game and the replay, a splash page with a
shareable replay directory, hover tooltips for reference detail, and makes curves
two-port arcs so sidings and run-arounds can finally be built.
2026-08-01 13:35:53 -04:00
Jesse f00255ce31 more fixes and tweaks 2026-08-01 12:00:27 -04:00
Jesse d1f689d4fc Name the caboose, the train and the Running Track hazard 2026-08-01 07:03:21 -04:00
Jesse dd300ac154 Fix caching, Limits placement, and explain the board better 2026-07-31 23:34:18 -04:00
Jesse 2eca9de09f various fixes. added version#s and added a playable browser build of the solitaire game (including deploy to filebrowser script) 2026-07-31 22:03:13 -04:00
Jesse 160190da3f Show the bot's reasoning and rejected options in the replay 2026-07-31 15:22:45 -04:00
34 changed files with 8672 additions and 387 deletions
+183
View File
@@ -0,0 +1,183 @@
# Changelog
Detail behind each commit. Commit messages stay high level; the reasoning, the measurements, and
the things that turned out to be wrong live here.
Measured figures are 100 solitaire Standard games with the developer bot unless stated otherwise.
The target is 20 Revenue over 5 Days.
---
## Unreleased
### Teaching the bot to use a siding
**Nose coupling, which the rules had and the engine did not.** §A.3: "engines also have couplers on
the front end, so a train can pick cars up onto its nose". Coupling always appended to the back, so
which end cars landed on did not exist — and that is precisely what a run-around is for. Cars met
running FORWARD now couple in front, cars met BACKING couple behind, so the approach decides which
car is next off the tail:
running forward -> reefer, boxcar, hopper next off: hopper
backing up -> boxcar, hopper, reefer next off: reefer
The bot prefers a run-around to setting a car down, since it keeps the car — but only when the drop
can actually follow. Without that guard it ran the loop for its own sake (8 a game, 63 Moves), which
the shuttling regression correctly failed.
**A serious bug found on the way.** The `carsCoupled` reducer cleared **every card in the Office
Area**, not the ones the crew ran over — its own comment said "every card along the path" while the
code iterated the whole grid. One coupling anywhere deleted every standing car and every industry
track in the district, so loads worked over several Stages vanished when a crew picked up an
unrelated boxcar somewhere else. The event now names the cards it lifted from.
**A test replaced rather than relaxed.** "Under 60 Moves a game" started failing. That threshold was
calibrated when the crew coupled ~0.4 cars a game; it now couples ~8 and drops ~11, and a run-around
is *supposed* to cost several Moves. Counting Moves was always a proxy for aimlessness, so the test
now measures the thing itself — Moves per drop-or-coupling — which still fails when the bot is made
to wander.
| | before sidings | now |
| --- | --- | --- |
| revenue | 3.9 | 3.2 |
| freight | 0.9 | 1.0 |
| drops per game | 3.3 | **11.1** |
| couplings per game | ~0.4 | **7.9** |
Switching is transformed; revenue is not. The crew now does real work — roughly 4 Moves per
productive act, which is what running a loop costs — but that work is not yet converting into
Revenue. Where it goes next is an open question rather than a known fix.
---
## Board rendering, three-page site, curve geometry
Drawing the board as track, splitting the site, tooltips, and the curve fix.
### Board rendering
Two renderers in `src/sim/board-svg.ts`, chosen because they fail in opposite places:
- **Office Area** — the district stays a map. Cards on a grid with the rails drawn edge to edge, so
a join is rail meeting rail rather than two descriptions that happen to agree. The through rail
sits at a constant height on every card, which is the alignment the printed cards use.
- **The Division** — not a map but a queue of sections with hard capacities, so it is a dispatcher's
diagram: one line per track, `1 of 2 free` under each section, `no limit — trains queue` at the
Division Points.
Both are **self-contained** — no imports, no module-level helpers — because the playable app imports
them normally while the replay is a single HTML file with an inline script that cannot import
anything, and embeds them via `Function.toString()`. One implementation either way; a second copy
would eventually draw a different board from the same state.
Rails come from the engine's own `connectionsFor`, now exported. A drawn rail cannot claim a
connection the rules do not have — visible in that **Modifier cards draw no rails at all**.
Capacity, confirmed from the rules and now shown: Division Points unlimited, most Mainline cards 1,
Double Track and Uncontrolled Siding 2, Offices 1/2/3/4 by tier.
### Three pages
`index.html` is now a splash with two doors. The game moved to `play.html`; `replays.html` is a
directory.
**A replay is a save**, and a save is `{seed, history}`. The engine is deterministic and runs in the
browser, so re-submitting the same moves rebuilds the position exactly — **18 KB instead of 3.4 MB**,
about 190× smaller, small enough to email. A save that would describe an impossible position cannot
be replayed at all, because every step goes back through `applyIntent`; the viewer stops and says so
rather than showing a board the rules could not produce.
Static hosting cannot list a directory, so `replays/manifest.json` is generated at build time from
whatever is in `public/replays/`, with each file validated first.
### Tooltips
Reference detail is read once and printing it costs the space the board and action list need. A
single delegated tooltip (`src/web/tooltip.ts`) now carries card effects, action reasoning, and
facility state. Not the native `title`: that waits a second, cannot be styled, and never appears for
keyboard users.
### Curve geometry — the significant fix
A curve was modelled as a through track **plus** a diverging leg, which is a turnout. Consequences:
- Curves were **topologically identical duplicates of turnouts** — same connections, no distinction
beyond the sharp curve's 2-Move cost.
- Every diverging leg went south, so **no piece anywhere reached north** except an n-s straight,
which connects n↔s and nothing else.
- Therefore a district could only ever be a **vertical column**: no siding, no parallel track, no
run-around, no second turnout back to the main.
The printed cards (`docs/tracks.png`, rows 34) show a curve as a single arc from edge to edge with
no through track. A curve is now a **two-port arc**, rotatable to `ne`/`nw`/`se`/`sw`. A sharp curve
is geometrically identical and costs 2 Moves. Turnouts keep §A.1 unchanged — a two-port arc has no
third port, so the absent-edge rule cannot apply to it.
Verified through the engine, not the types: a crew leaves the main at a turnout, runs a siding
parallel, and rejoins at the far end.
### Measurements
| | revenue | freight | sidings built |
| --- | --- | --- | --- |
| before | 4.1 | 0.5 | impossible |
| geometry only | **3.9** | 0.9 | possible, not sought |
| bot builds sidings | 3.3 | **1.1** | **59/60 games** |
Freight more than doubled and the harness recorded **its first win**, but overall revenue is down
from geometry-alone and the bad tail worsened (29 → 59).
Capping turnouts at one or two measured **worse** (revenue 2.5, freight 0.6) — more ways off the main
means more industries the crew can reach, and that beats a tidy Running Track. The uncapped version
stands, with that measurement recorded at the code.
**The bot builds sidings but does not exploit them.** It pays about 20 of its 26 track pieces for
them while its switching logic still sets out dead weight on a spur rather than planning a
run-around. That is the next piece of work and where the revenue should appear.
---
## Earlier commits
Recorded from memory of the work rather than written at the time; detail thins going back.
### `f00255c` — more fixes and tweaks
Card descriptions on every card in hand and every face-up slot, the three Local Operations options
explained, the objective and pace in the header, and rotations named in words rather than
"rotation 2". Build stamp added (version, git SHA, `-dirty` for an uncommitted tree) because nothing
tracked what was deployed. Extra X22 was **not** a bug — a per-diem train whose card calls for a
caboose and nothing else — but "loaded caboose" was.
### `d1f689d` — name the caboose, the train and the Running Track hazard
`maneuver.redFlags` was labelled "Red Flags on tray3": the earlier tray-id fix checked the history
log, and the action list was a surface it missed. An industry on the Running Track does not block
traffic (§11.2 gives it rails) but a car left standing there is hit by the next arrival (§10).
### `dd300ac` — fix caching, Limits placement, and explain the board better
The first deploy served a fresh `index.html` against a **cached** `main.js`, which threw
`missing element: target` and never started. Every module import now carries the build tag. The
Limits fix was half-done: the sign could move, but nothing forbade building past it, so one straight
at (0,2) produced `limits · Whistle Post · limits · straight · limits`.
### `2eca9de` — playable browser build
The solitaire game as a static site, proven to need no server: full games run with every Node global
replaced by a throwing stub. Train cards stopped accepting a board placement — seed 555 had offered
Extra X15 at six squares with six rotations, all identical.
### `160190d` — the bot's reasoning in the replay
Decision panel showing what the bot chose, why, and every option it passed over, with the reasons
reported by the bot itself rather than re-derived by the viewer.
### `d261ad7` — parking trains, freight deadlock, Whistle Post lock-in
Three faults found by measurement rather than failing tests. Trains parked because `destinationsFor`
computed reverse as `facing === 'e' ? 'w' : 'e'`, so a crew facing south reversed to east — a port a
north-south card does not have. Freight ran at a **3% load completion rate** because a load started
with no spotted car parks on WORK and locks the industry track, blocking the very car that would
clear it. Office density doubled after 25 of 100 games never drew a Depot and never escaped a
one-track Whistle Post.
+12 -3
View File
@@ -24,15 +24,24 @@ single browser.
``` ```
station-master/ station-master/
├── CHANGELOG.md ← what changed and why, in detail, commit to commit
├── TODO.md ← open questions, provisional numbers, things to come back to
├── docs/ ├── docs/
│ ├── rules/ ← the ruleset, card reference, glossary, decision record │ ├── rules/ ← the ruleset, card reference, glossary, decision record
── architecture/ ← how it is built, and what the pieces are ── architecture/ ← how it is built, and what the pieces are
│ └── design/ ← board layout studies and rendering samples
├── public/replays/ ← saved games published to the site's replay directory
├── scripts/ ← build and deploy the static site
├── src/ ├── src/
── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed ── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
│ ├── sim/ ← bot, harness, replay, board rendering
│ └── web/ ← the playable site: splash, game, replay viewer
└── test/ └── test/
``` ```
Start with [`docs/design.md`](docs/design.md) — it indexes everything. Start with [`docs/design.md`](docs/design.md) — it indexes everything. Commit messages stay high
level; [`CHANGELOG.md`](CHANGELOG.md) carries the reasoning and the measurements, and
[`TODO.md`](TODO.md) is what we have decided not to forget.
## Development ## Development
+97
View File
@@ -0,0 +1,97 @@
# To do
Things worth coming back to. Anything noted here should either get done or get an explicit decision
not to — the point is that nothing quietly evaporates.
Ordered within each section by how much it is currently costing us.
---
## Next
- [ ] **Find out why switching work does not become Revenue.** The crew now drops 11 cars a game and
couples 8 (was 3.3 and ~0.4), at about 4 Moves per productive act — real railroading. Revenue
did not move (3.9 → 3.2). Either the freight it is now shuffling is not the freight the
industries want, or the loads finish too late in the Day to be worked, or the Revenue is going
somewhere and being lost again. Measure the freight chain end to end before changing anything.
---
## Open questions for Jesse
Blocked on a decision, not on work.
- [ ] **Q13 — rear-end collisions on a Mainline card.** §10 says a Mainline collision is the
Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders —
but §8.3's trigger list does not include one, and **none is implemented**. Granting clearance is
currently free: verified, both trains survive, no penalty. Three candidates: collide on entry
(clearance becomes a gamble), collide on catching up (rewards judging the gap), or accept that
clearance is safe and ABS Signals is worth less than it reads. The buttons currently describe
only what the engine does, so nothing promises a consequence that cannot happen.
- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect
as "TBD in the source". A test asserts it stays TBD so nobody invents one.
- [ ] **Heavy Grade orientation at setup.** The card says "Player sets orientation", but `createGame`
is synchronous and has no decision point, so it is currently rolled from the seed. Should become
a real choice when setup gains an interactive phase.
---
## Balance, provisional
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
target is settled and freight carries its intended share.
- [ ] **Office card density** (Depot 4→8, Station 2→4, Terminal 1→2). Chosen to remove a 25% chance
of an unwinnable opening deal. Blunt: it lifts the whole ladder and dilutes every other
category. The better answer may be fewer Terminals, a cheaper first upgrade, or more A/D
capacity at the Whistle Post itself.
- [ ] **Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio; freight still only
1318% of gross revenue.
- [ ] **Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often
than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density
changes.
- [ ] **The victory target itself** (20 over 5 Days). Still only 1 win in 100. Worth revisiting once
the bot exploits sidings, since that is a known unclaimed gain.
---
## Not yet built
- [ ] **Action cards (10) and Space-use cards (12).** Genuinely multiplayer-only — they are played AT
an opponent. Rejected with `NOT_IMPLEMENTED`.
- [ ] **Multiplayer proper.** The engine runs 25 player games and the bot plays them, but there is no
server, no turn submission, and no per-player view.
---
## Smaller things
- [ ] **Carry `links` forward in replay frames.** Static per card and never changes once laid, but
re-sent whenever anything on the board changes. The replay is 3.4 MB, mostly board state.
- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every
frame — but the number itself deserves a reason.
- [ ] **Curves are drawn as two straight segments meeting**, not true arcs. Fine at this size, angular
close up.
- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal
scrolling. Legible, not compact.
- [ ] **No way to start a fresh game from inside the page.** `?seed=` gives a reproducible deal and
"new game" only appears once a game has ended, so abandoning a bad opening means editing the URL.
- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
failing loudly, which is the safe direction but says little about what changed.
---
## Done, kept for the reasoning
- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction
decides which car is droppable; the bot runs around rather than setting out, when the drop can
follow. Switching activity transformed, revenue unchanged.
- [x] **Curve geometry.** Curves were topologically identical duplicates of turnouts, and nothing
reached north, so a district could only be a vertical column. Now two-port rotatable arcs.
- [x] **Q10 — when track may be laid.** During the "draw a card" option, one piece a turn. Track was a
card when §6.2 was written; a 26-piece supply has no hand to bound it.
- [x] **Q11 — which way a Heavy Grade climbs.** Answered from the card: it prints "(Up)" and "Player
sets orientation", so it is a property of the placed card, not a compass constant.
- [x] **Q12 — Whistle Post lock-in.** Players always start at a Whistle Post; office density doubled
instead.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
View File
@@ -726,3 +726,38 @@ The temptation is to rebuild everything at once. I would not.
**Do not retune anything against current measurements.** Gap 12's numbers describe a placeholder **Do not retune anything against current measurements.** Gap 12's numbers describe a placeholder
ruleset. Re-measure after step 5 and treat everything before that as void. ruleset. Re-measure after step 5 and treat everything before that as void.
---
## §10 Q13 — rear-end collisions on a Mainline card are not implemented
**Found by playing.** The Superintendent's §8.1 ruling asks whether one train may follow another onto
an occupied Mainline card. Granting it is currently **free**: both trains cross, nothing collides, no
penalty. Verified directly — two trains on one card, clearance granted, both alive and revenue
unchanged.
Two places in the rules say it should not be free:
- **§10** — "A collision on a Mainline card is the fault of the Superintendent who cleared the trains
out; he loses 5 Revenue points" and "**Train-on-train.** Both trains are immediately removed."
- **ABS Signals** — "Trains on this card will not rear-end each other. They will stop short of a
collision." A card whose entire purpose is preventing rear-enders presupposes that rear-enders
happen.
But **§8.3's trigger list does not include it.** The three automatic triggers are all about arriving
at an Office or highballing past fouled track; none covers a follower catching a leader mid-card. So
the rule is asserted in §10 and priced by ABS Signals, yet never given a trigger.
The missing piece is **timing**, and it is a design decision rather than a transcription gap.
Crossing is counted in Stages (Q1), so both trains simply run their counters down. Candidates:
1. **On entry** — following onto an occupied card wrecks both at once. Makes clearance a pure gamble
and ABS Signals very strong.
2. **On catching up** — collide only when the follower's remaining Stages would take it past the
leader. Rewards judging the gap, which is what a Superintendent actually does.
3. **Never** — clearance is genuinely safe, §10's Mainline clause is vestigial, and ABS Signals is
worth less than it reads.
Until this is settled the clearance buttons describe only what the engine actually does — they say
the train "closes up behind" rather than promising a 5 risk that cannot occur. Wording that invents
a consequence is worse than wording that under-sells one.
+4 -1
View File
@@ -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",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
/**
* 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 { existsSync, mkdirSync, readFileSync, readdirSync, 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',
'src/web/splash.ts',
'src/web/replays.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();
/**
* Cache-bust every module.
*
* A returning visitor gets a fresh index.html and STALE JavaScript, because a static host caches
* .js and the filenames never change. That is not a cosmetic staleness: it mixes new HTML with old
* code, and the first mismatch is fatal. It happened on the first real deploy — index.html had
* dropped an element that the cached main.js still asked for, so the page threw
* `missing element: target` and the game never started.
*
* Appending the build tag to every relative import makes each deploy a new set of URLs, so a
* browser cannot serve half of one build and half of another.
*/
const tag = encodeURIComponent(stamp.split(' · ')[1] ?? stamp);
function bustImports(dir: string): number {
let count = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
count += bustImports(full);
continue;
}
if (!entry.name.endsWith('.js')) continue;
const src = readFileSync(full, 'utf8');
const next = src.replace(
/^(\s*(?:import|export)[^'"\n]*?from\s+['"])(\.[^'"]+\.js)(['"])/gm,
(_m, head: string, spec: string, tail: string) => `${head}${spec}?v=${tag}${tail}`,
);
if (next !== src) {
writeFileSync(full, next);
count++;
}
}
return count;
}
const busted = bustImports(dist);
// Three pages now: the splash, the game, and the replay directory. Each is stamped and each has
// its entry script cache-busted, or a returning visitor gets new HTML against old modules.
for (const name of ['index.html', 'play.html', 'replays.html']) {
const page = readFileSync(join(root, 'src/web', name), 'utf8')
.replaceAll('__BUILD__', stamp)
.replace(/(<script type="module" src="[^"]+?\.js)(")/, `$1?v=${tag}$2`);
writeFileSync(join(dist, name), page);
}
/**
* Publish whatever replays are in `public/replays/`, plus an index of them.
*
* Static hosting cannot list a directory, so the page needs a manifest. A replay is a save — seed
* plus moves — so these are a few hundred bytes each, not megabytes.
*/
const replaySrc = join(root, 'public/replays');
const replayOut = join(dist, 'replays');
mkdirSync(replayOut, { recursive: true });
const manifest: { file: string; title: string; note?: string; seed?: number }[] = [];
if (existsSync(replaySrc)) {
for (const f of readdirSync(replaySrc).filter((n) => n.endsWith('.json') && n !== 'manifest.json')) {
const raw = readFileSync(join(replaySrc, f), 'utf8');
try {
const save = JSON.parse(raw) as { seed?: number; title?: string; note?: string; history?: unknown[] };
if (typeof save.seed !== 'number' || !Array.isArray(save.history)) {
console.warn(` skipped ${f} — not a Station Master save`);
continue;
}
writeFileSync(join(replayOut, f), raw);
manifest.push({
file: f,
title: save.title ?? f.replace(/\.json$/, ''),
...(save.note ? { note: save.note } : {}),
seed: save.seed,
});
} catch {
console.warn(` skipped ${f} — could not be parsed`);
}
}
}
writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1));
// 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}\n cache-busted ${busted} modules with ?v=${tag}` +
`\n ${manifest.length} replay(s) published`,
);
+155
View File
@@ -0,0 +1,155 @@
/**
* Build the solitaire site and push it to a File Browser instance.
*
* Written against File Browser v2.63's REST API, read from its own bundle rather than guessed:
*
* POST /api/login {username, password, recaptcha} -> JWT as plain text
* POST /api/resources/<dir>/ X-Auth: <jwt> -> create a directory
* POST /api/resources/<file>?override=true X-Auth: <jwt>, body = bytes -> upload
*
* File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site.
* So the job here is simply to land the built files in the right folder, intact.
*
* CREDENTIALS COME FROM THE ENVIRONMENT and are never written anywhere. Putting a password in a
* repo is how it ends up in a commit, and this repo is going to be pushed.
*
* FB_USER=jesse FB_PASS='…' npm run deploy:web
*
* Optional:
* FB_URL default https://phoenix.local:58157
* FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
* FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host)
* --dry-run list what would be sent, contact nothing
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, posix, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const dist = join(root, 'dist');
const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replace(/\/+$/, '');
const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`;
const USER = process.env['FB_USER'] ?? '';
const PASS = process.env['FB_PASS'] ?? '';
const DRY = process.argv.includes('--dry-run');
/**
* A .local StartOS host presents a certificate the system store does not know. Disabling
* verification is opt-in and announced rather than silent: it is the right call on a LAN box you
* own and the wrong one everywhere else, and that judgment is not the script's to make quietly.
*/
if (process.env['FB_INSECURE'] === '1') {
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
console.warn('! TLS verification disabled (FB_INSECURE=1)');
}
/** Every file in `dir`, as paths relative to it, with POSIX separators. */
function walk(dir: string, base = dir): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walk(full, base));
else out.push(relative(base, full).split(sep).join('/'));
}
return out.sort();
}
const CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.txt': 'text/plain',
};
async function login(): Promise<string> {
const res = await fetch(`${URL_BASE}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: USER, password: PASS, recaptcha: '' }),
});
const body = await res.text();
if (!res.ok) throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
if (!body.trim()) throw new Error('login returned an empty token');
return body.trim();
}
async function makeDir(jwt: string, path: string): Promise<void> {
// Trailing slash is what marks a directory in this API. A 409 means it already exists, which is
// the normal case on every deploy after the first.
const res = await fetch(`${URL_BASE}/api/resources${encodePath(path)}/`, {
method: 'POST',
headers: { 'X-Auth': jwt },
});
if (!res.ok && res.status !== 409) {
throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
}
}
async function upload(jwt: string, localPath: string, remotePath: string): Promise<void> {
const bytes = readFileSync(localPath);
const ext = remotePath.slice(remotePath.lastIndexOf('.'));
const res = await fetch(`${URL_BASE}/api/resources${encodePath(remotePath)}?override=true`, {
method: 'POST',
headers: {
'X-Auth': jwt,
'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
'Content-Length': String(bytes.byteLength),
},
body: new Uint8Array(bytes),
});
if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`);
}
/** Encode each segment but keep the separators, so a path stays a path. */
function encodePath(p: string): string {
return p.split('/').map(encodeURIComponent).join('/');
}
// ---------------------------------------------------------------------------
console.log('building…');
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'inherit' });
const files = walk(dist);
if (files.length === 0) throw new Error('dist/ is empty — nothing to deploy');
const dirs = [...new Set(files.map((f) => posix.dirname(f)).filter((d) => d !== '.'))].sort();
const total = files.reduce((n, f) => n + statSync(join(dist, f)).size, 0);
console.log(`\n${files.length} files, ${(total / 1024).toFixed(0)} KB`);
console.log(` from ${dist}`);
console.log(` to ${URL_BASE}${DEST}\n`);
if (DRY) {
for (const d of dirs) console.log(` dir ${DEST}/${d}`);
for (const f of files) console.log(` file ${DEST}/${f}`);
console.log('\ndry run — nothing was sent');
} else {
if (!USER || !PASS) {
throw new Error(
'set FB_USER and FB_PASS.\n' +
" e.g. FB_USER=me FB_PASS='…' FB_INSECURE=1 npm run deploy:web\n" +
' or run with --dry-run to see what would be sent',
);
}
const jwt = await login();
console.log('logged in');
await makeDir(jwt, DEST);
for (const d of dirs) await makeDir(jwt, `${DEST}/${d}`);
let done = 0;
for (const f of files) {
await upload(jwt, join(dist, f), `${DEST}/${f}`);
done++;
console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
}
console.log(`\ndeployed to ${URL_BASE}${DEST}`);
console.log('Start9 Pages serves these files as the site; File Browser is only the store.');
}
+23 -8
View File
@@ -33,9 +33,9 @@ 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, RollingStock, TrayId } from './state.ts';
import { coordKey, freshTurn, totalRevenue } from './state.ts'; import { coordKey, freshTurn, totalRevenue } from './state.ts';
export type AdvanceResult = { export type AdvanceResult = {
@@ -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;
@@ -631,7 +630,7 @@ function arriveAtOffice(
}); });
return 'moved'; return 'moved';
} }
collide(s, owner, [id], events, 'no free A/D track'); collide(s, owner, [id], events, 'no free A/D track', 'the Office');
return 'moved'; return 'moved';
} }
@@ -648,7 +647,7 @@ function arriveAtOffice(
// is not expecting them (§A.4), so this is a collision too, not a coupling. // is not expecting them (§A.4), so this is a collision too, not a coupling.
const officeCard = area.grid.get(coordKey(area.officeCoord)); const officeCard = area.grid.get(coordKey(area.officeCoord));
if (officeCard && officeCard.standing.length > 0) { if (officeCard && officeCard.standing.length > 0) {
collide(s, owner, [id], events, 'cars fouling the Running Track'); collide(s, owner, [id], events, 'cars fouling the Running Track', 'the Running Track');
return 'moved'; return 'moved';
} }
@@ -673,10 +672,22 @@ function collide(
trains: TrayId[], trains: TrayId[],
events: GameEvent[], events: GameEvent[],
reason: string, reason: string,
where = 'the Office',
): void { ): void {
// Record WHAT was lost before removing it. The log said only "COLLISION: NO FREE A/D TRACK", so a
// player could see the 5 points go without learning which train had just been written off, or
// what it was carrying.
const lost: { label: string; consist: RollingStock[] }[] = [];
for (const id of trains) { for (const id of trains) {
const tray = s.trays.get(id); const tray = s.trays.get(id);
if (!tray) continue; if (!tray) continue;
lost.push({
label:
tray.trainNumber === null
? 'the local crew'
: `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`,
consist: [...tray.consist],
});
// Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification. // Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
for (const car of tray.consist) { for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(car); if (car.type === 'caboose') s.yards.divisionYard.push(car);
@@ -686,6 +697,10 @@ function collide(
s.freeTrays.push(id); s.freeTrays.push(id);
} }
if (lost.length > 0) {
events.push({ type: 'trainsDestroyed', player: faultPlayer, trains: lost, reason, where });
}
s.collisionsToday += 1; s.collisionsToday += 1;
const player = s.players[faultPlayer]; const player = s.players[faultPlayer];
if (player) { if (player) {
+129 -9
View File
@@ -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;
} }
} }
@@ -672,7 +689,26 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
}, },
]; ];
if (dest.couples.length > 0) { if (dest.couples.length > 0) {
events.push({ type: 'carsCoupled', trayId: i.trayId, at: i.to, stock: dest.couples }); // Name the cards the cars came from. The reducer used to clear every card in the district
// instead, so one coupling anywhere deleted every standing car and every industry track in
// the Office Area — loads worked over several Stages vanished when a crew picked up a single
// boxcar somewhere else entirely.
const lifted = [
...dest.path.map((step) => step.coord),
i.to,
].filter((c) => carsOn(areaOf(s, player).grid.get(coordKey(c)) ?? emptyCard()).length > 0);
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
// up, they couple behind. Which end they land on is the whole point of a run-around: it
// decides which car is next to come off.
events.push({
type: 'carsCoupled',
trayId: i.trayId,
at: i.to,
stock: dest.couples,
from: lifted,
toNose: !i.reverse,
});
} }
return events; return events;
} }
@@ -975,10 +1011,14 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'carsCoupled': { case 'carsCoupled': {
const tray = s.trays.get(e.trayId)!; const tray = s.trays.get(e.trayId)!;
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0); const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
tray.consist.push(...e.stock); if (e.toNose) tray.consist.unshift(...e.stock);
// Every card along the path is cleared; the event carries what was taken. else tray.consist.push(...e.stock);
for (const card of area.grid.values()) { // ONLY the cards the crew ran over. Clearing the whole grid emptied industry tracks the crew
if (card.standing.length > 0) card.standing = []; // never went near.
for (const coord of e.from) {
const card = area.grid.get(coordKey(coord));
if (!card) continue;
card.standing = [];
if (card.facility) card.facility.industryTrack.cars = []; if (card.facility) card.facility.industryTrack.cars = [];
} }
break; break;
@@ -1309,6 +1349,7 @@ function protoCard(
kind: 'track', kind: 'track',
geometry, geometry,
...(v.axis ? { axis: v.axis } : {}), ...(v.axis ? { axis: v.axis } : {}),
...(v.arc ? { arc: v.arc } : {}),
...(v.turnout ? { turnout: v.turnout } : {}), ...(v.turnout ? { turnout: v.turnout } : {}),
...(v.bypass ? { bypass: v.bypass } : {}), ...(v.bypass ? { bypass: v.bypass } : {}),
}, },
@@ -1467,6 +1508,18 @@ export function checkEnhancementPlacement(
} }
} }
/** A stand-in with nothing on it, so a missing card reads as "no cars here" rather than throwing. */
function emptyCard(): TrackCard {
return {
geometry: { kind: 'limits' },
baseOperationalRail: false,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
}
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */ /** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void { function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId)); s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
@@ -1487,6 +1540,7 @@ function protoTrackCard(
kind: 'track', kind: 'track',
geometry, geometry,
...(v.axis ? { axis: v.axis } : {}), ...(v.axis ? { axis: v.axis } : {}),
...(v.arc ? { arc: v.arc } : {}),
...(v.turnout ? { turnout: v.turnout } : {}), ...(v.turnout ? { turnout: v.turnout } : {}),
...(hand !== 'none' ? { hand } : {}), ...(hand !== 'none' ? { hand } : {}),
}, },
@@ -1528,10 +1582,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: [],
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+20 -1
View File
@@ -22,7 +22,19 @@ export type GameEvent =
// -- local operations // -- local operations
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption } | { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' } | { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] } | {
type: 'carsCoupled';
trayId: TrayId;
at: GridCoord;
stock: RollingStock[];
/** The cards the cars were lifted from — the ones the crew actually ran over. */
from: GridCoord[];
/**
* Coupled onto the ENGINE'S NOSE rather than behind the train (§A.3). True when the crew was
* running forward: the engine meets the cars head-on and takes them on the front.
*/
toNose: boolean;
}
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] } | { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] } | { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId } | { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
@@ -31,6 +43,13 @@ export type GameEvent =
| { type: 'trackLaid'; player: PlayerIndex; geometry: string; hand: string; at: GridCoord; variant: number; remaining: number } | { type: 'trackLaid'; player: PlayerIndex; geometry: string; hand: string; at: GridCoord; variant: number; remaining: number }
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string } | { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number } | { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number }
| {
type: 'trainsDestroyed';
player: PlayerIndex;
trains: { label: string; consist: RollingStock[] }[];
reason: string;
where: string;
}
| { type: 'flyingSwitch'; player: PlayerIndex; cardId: CardId; trayId: TrayId; to: GridCoord; stock: RollingStock[] } | { type: 'flyingSwitch'; player: PlayerIndex; cardId: CardId; trayId: TrayId; to: GridCoord; stock: RollingStock[] }
| { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier } | { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier }
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number } | { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number }
+5 -1
View File
@@ -252,7 +252,11 @@ function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
// Q7 — a district hangs BELOW the Running Track; there is nothing above it. // 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);
} }
+9
View File
@@ -58,6 +58,13 @@ export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | '
* how many run each way, yet a layout with no north-south straight can never use the Office's * how many run each way, yet a layout with no north-south straight can never use the Office's
* junction stubs at all. * junction stubs at all.
*/ */
/**
* A curve joins two ADJACENT edges. There are exactly four such arcs, and a card can be turned to
* any of them — which is what makes a siding possible: an arc reaching NORTH is the only way back
* up to the Running Track from the district below.
*/
export type TrackArc = 'ne' | 'nw' | 'se' | 'sw';
export type TrackAxis = 'ew' | 'ns'; export type TrackAxis = 'ew' | 'ns';
export type CardGeometry = export type CardGeometry =
@@ -66,6 +73,8 @@ export type CardGeometry =
geometry: TrackGeometry; geometry: TrackGeometry;
turnout?: TurnoutOrientation; turnout?: TurnoutOrientation;
axis?: TrackAxis; axis?: TrackAxis;
/** Which two edges a CURVE joins — chosen on placement, since the card can be turned. */
arc?: TrackArc;
/** Curves are printed left- or right-handed (design supply); not chosen on placement. */ /** Curves are printed left- or right-handed (design supply); not chosen on placement. */
hand?: 'left' | 'right'; hand?: 'left' | 'right';
} }
+46 -15
View File
@@ -23,6 +23,7 @@ import type {
RollingStock, RollingStock,
TrackCard, TrackCard,
TrayId, TrayId,
TrackArc,
TurnoutOrientation, TurnoutOrientation,
} from './state.ts'; } from './state.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts'; import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
@@ -77,7 +78,12 @@ type PortPair = readonly [Port, Port];
* - **facility** — a through track; the industry spur is the card's own spotting capacity rather * - **facility** — a through track; the industry spur is the card's own spotting capacity rather
* than a separate port. * than a separate port.
*/ */
function connectionsFor(card: TrackCard): readonly PortPair[] { /**
* Exported so the board can DRAW what the engine believes. Deriving the rails from anything else
* would let a picture disagree with the rules about whether two cards join — which is the one thing
* a track diagram exists to settle.
*/
export function connectionsFor(card: TrackCard): readonly PortPair[] {
switch (card.geometry.kind) { switch (card.geometry.kind) {
// Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter. // Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter.
case 'modifier': case 'modifier':
@@ -108,18 +114,22 @@ function connectionsFor(card: TrackCard): readonly PortPair[] {
[o.stem, o.diverge], [o.stem, o.diverge],
]; ];
} }
// A CURVE joins the through track to one diverging side — handedness is printed on the /**
// card, not chosen (the design supplies both hands). A SHARP curve is geometrically the * A CURVE IS AN ARC between two adjacent edges — one connection, no through track. The
// same but costs two Moves to cross. * printed cards show exactly this (docs/tracks.png, rows 3-4): a single sweep from edge to
// Curves and turnouts diverge DOWNWARD from the through track (Q7); handedness decides * edge with nothing running past it.
// which end of the through track the leg leaves from, not whether it goes up or down. *
* It was previously modelled as a through track PLUS a diverging leg, which is a turnout —
* and made curves topologically identical duplicates of them. Worse, every leg diverged
* south, so no piece anywhere reached NORTH except an n-s straight. A district could
* therefore only ever be a vertical column: no siding, no parallel track, no run-around.
*
* A SHARP curve is the same shape and costs two Moves to cross.
*/
case 'curved': case 'curved':
case 'sharpCurved': { case 'sharpCurved': {
const stem = card.geometry.hand === 'left' ? 'w' : 'e'; const arc = card.geometry.arc ?? (card.geometry.hand === 'left' ? 'sw' : 'se');
return [ return [[arc[0] as Port, arc[1] as Port]];
['e', 'w'],
[stem, 's'],
];
} }
} }
} }
@@ -144,7 +154,10 @@ export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', di
* §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's * §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's
* rotation is free. * rotation is free.
*/ */
const ARCS: readonly TrackArc[] = ['ne', 'nw', 'se', 'sw'];
export type TrackVariant = { export type TrackVariant = {
arc?: TrackArc;
axis?: 'ew' | 'ns'; axis?: 'ew' | 'ns';
turnout?: TurnoutOrientation; turnout?: TurnoutOrientation;
bypass?: Port; bypass?: Port;
@@ -167,9 +180,9 @@ export function variantsFor(geometry: TrackGeometry): TrackVariant[] {
return TURNOUT_VARIANTS.map((t) => ({ turnout: t })); return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
case 'curved': case 'curved':
case 'sharpCurved': case 'sharpCurved':
// Handedness is printed on the card, and the leg always goes down (Q7), so nothing is left // All four rotations. A two-port arc has no handedness that survives turning its mirror IS
// to choose — one variant. // one of its rotations — so the printed hand governs supply, not what can be built.
return [{ axis: 'ew' }]; return ARCS.map((arc) => ({ arc }));
} }
} }
@@ -330,7 +343,25 @@ 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;
// §2.1 — the Running Track runs BETWEEN the Limits. Nothing on that row may sit outside them, so
// the sign itself is the only growth point. Allowing a placement beyond it built track on the far
// side of the sign and then planted a SECOND sign further out, leaving the board reading
// limits · Whistle Post · limits · straight · limits
// with a Limits card stranded mid-track. The district below the Running Track is unbounded.
if (coord.row === area.runningRow) {
if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) 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) {
+294
View File
@@ -0,0 +1,294 @@
/**
* Drawing the board.
*
* Two renderers, chosen because they fail in opposite places (docs/design/board-layout-studies.html):
*
* - `officeSvg` — the district stays a MAP. Cards on a grid with the rails actually drawn, so a
* join is rail meeting rail rather than two descriptions that happen to agree.
* - `divisionSvg`— the Division is not a map, it is a queue of sections with hard capacities, so
* it is drawn as a dispatcher's diagram: one line per track, count them to know
* what fits.
*
* SELF-CONTAINED ON PURPOSE. Both functions reference nothing outside their own bodies — no imports,
* no module-level helpers. The playable app imports them normally; the replay is a single HTML file
* with an inline script and cannot import anything, so it embeds these via `Function.toString()`.
* That keeps ONE implementation: a second copy would eventually draw a different board from the same
* state, which is the exact failure a track diagram exists to prevent.
*
* Rails come from `CellView.links`, which is the engine's own `connectionsFor`. A drawn rail can
* therefore never claim a connection the rules do not have.
*/
import type { CellView, DivisionView } from './view.ts';
/** Anything the renderers need to know about a train standing somewhere. */
export type BoardTrain = { label: string; consist: string[] };
/**
* The Division as a dispatcher would see it: one continuous line per running track, sections
* separated by thin seams, capacity legible because the lines can be counted.
*/
export function divisionSvg(nodes: DivisionView[]): string {
const COL = 190;
const TOP = 34;
const LANE = 30;
const width = Math.max(1, nodes.length) * COL;
const maxCap = nodes.reduce((n, d) => Math.max(n, d.capacity ?? 3), 1);
const height = TOP + maxCap * LANE + 54;
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
const rail = (x1: number, y: number, x2: number): string => {
let out =
`<line class="bs-rail" x1="${x1}" y1="${y - 2.5}" x2="${x2}" y2="${y - 2.5}"/>` +
`<line class="bs-rail" x1="${x1}" y1="${y + 2.5}" x2="${x2}" y2="${y + 2.5}"/>`;
const n = Math.max(2, Math.floor(Math.abs(x2 - x1) / 9));
for (let i = 0; i <= n; i++) {
const tx = x1 + ((x2 - x1) * i) / n;
out += `<line class="bs-tie" x1="${tx}" y1="${y - 4.5}" x2="${tx}" y2="${y + 4.5}"/>`;
}
return out;
};
const chip = (x: number, y: number, t: BoardTrain): string => {
const w = 30 + Math.min(t.consist.length, 4) * 9;
return (
`<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}">` +
`<rect x="${x - w / 2}" y="${y - 10}" width="${w}" height="20" rx="3"/>` +
`<text class="bs-tlab" x="${x}" y="${y + 4}" text-anchor="middle">${esc(t.label)}</text></g>`
);
};
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
nodes.forEach((node, i) => {
const x0 = i * COL;
const x1 = x0 + COL;
const cap = node.capacity;
const lanes = cap ?? 3;
const kind = node.kind === 'dp' ? 'dp' : node.kind === 'office' ? 'office' : 'ml';
out += `<rect class="bs-sec bs-${kind}" x="${x0 + 2}" y="${TOP - 16}" width="${COL - 4}" height="${lanes * LANE + 30}" rx="5"/>`;
out += `<text class="bs-name" x="${x0 + 10}" y="${TOP - 3}">${esc(node.label)}</text>`;
if (node.gradeUp) {
out += `<text class="bs-grade" x="${x1 - 10}" y="${TOP - 3}" text-anchor="end">climbs ${node.gradeUp === 'east' ? 'E ▲' : '▲ W'}</text>`;
}
// One rail per track this section can hold. Unlimited sections are drawn as three faded lines
// and labelled, rather than pretending to a number they do not have.
for (let k = 0; k < lanes; k++) {
out += `<g class="${cap === null ? 'bs-open' : ''}">${rail(x0 + 12, TOP + 14 + k * LANE, x1 - 12)}</g>`;
}
const here = node.trains.flat();
here.forEach((t, k) => {
const lane = Math.min(k, lanes - 1);
const across = here.length > lanes ? (k - lane) * 34 : 0;
out += chip(x0 + COL / 2 + across, TOP + 14 + lane * LANE, t);
});
const free = cap === null ? '∞' : `${Math.max(0, cap - here.length)} of ${cap} free`;
const warn = cap !== null && here.length >= cap ? ' bs-full' : '';
out += `<text class="bs-cap${warn}" x="${x0 + 10}" y="${TOP + lanes * LANE + 8}">${cap === null ? 'no limit — trains queue' : free}</text>`;
if (node.modifiers.length > 0) {
out += `<text class="bs-mod" x="${x0 + 10}" y="${TOP + lanes * LANE + 22}">${esc(node.modifiers.join(' · '))}</text>`;
}
});
out += '</svg>';
return out;
}
/**
* The Office Area as a map: one card per grid square, rails drawn edge to edge.
*
* The through rail sits at a constant height on every card — the alignment the printed cards use —
* so abutting cards produce one unbroken line and a gap is visibly a gap.
*/
export function officeSvg(cells: CellView[], runningRow: number): string {
const W = 132;
const H = 96;
const PAD = 3;
const RAIL = 30;
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
if (cells.length === 0) return '<svg class="bs" viewBox="0 0 10 10"></svg>';
const rows = cells.map((c) => c.row);
const cols = cells.map((c) => c.col);
const r0 = Math.min(...rows);
const r1 = Math.max(...rows);
const c0 = Math.min(...cols);
const c1 = Math.max(...cols);
const width = (c1 - c0 + 1) * (W + PAD);
const height = (r1 - r0 + 1) * (H + PAD) + 4;
// Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest
// and the district hangs beneath it, as the rules describe it.
const px = (col: number): number => (col - c0) * (W + PAD);
const py = (row: number): number => (r1 - row) * (H + PAD);
const rail = (x1: number, y1: number, x2: number, y2: number): string => {
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.hypot(dx, dy);
const nx = (-dy / len) * 2.5;
const ny = (dx / len) * 2.5;
let out =
`<line class="bs-rail" x1="${x1 + nx}" y1="${y1 + ny}" x2="${x2 + nx}" y2="${y2 + ny}"/>` +
`<line class="bs-rail" x1="${x1 - nx}" y1="${y1 - ny}" x2="${x2 - nx}" y2="${y2 - ny}"/>`;
const n = Math.max(2, Math.floor(len / 9));
for (let i = 0; i <= n; i++) {
const tx = x1 + (dx * i) / n;
const ty = y1 + (dy * i) / n;
out += `<line class="bs-tie" x1="${tx + nx * 1.8}" y1="${ty + ny * 1.8}" x2="${tx - nx * 1.8}" y2="${ty - ny * 1.8}"/>`;
}
return out;
};
// Where each port meets the card edge. East and west sit at the rail height so a straight run
// stays straight across the whole row; north and south are centred.
const port = (p: string): { x: number; y: number } => {
if (p === 'e') return { x: W, y: RAIL };
if (p === 'w') return { x: 0, y: RAIL };
if (p === 'n') return { x: W / 2, y: 0 };
return { x: W / 2, y: H };
};
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
for (const cell of cells) {
const x = px(cell.col);
const y = py(cell.row);
const kind =
cell.kind === 'office'
? 'office'
: cell.kind === 'limits'
? 'lim'
: cell.kind === 'facility'
? 'fac'
: cell.kind === 'modifier'
? 'mod'
: 'trk';
// Addressable so a caller can outline a square as a legal destination without re-rendering.
// The explanation rides on the group as a tooltip rather than being printed: it is reference
// detail, read once, and printing it costs the space the board itself needs.
out += `<g data-cell="${cell.row},${cell.col}" data-tip="${esc(cell.label)}${esc(cell.what)}" transform="translate(${x},${y})">`;
out += `<rect class="bs-card bs-${kind}${cell.running ? ' bs-run' : ''}" x="0" y="0" width="${W}" height="${H}" rx="4"/>`;
// The rails. A Modifier is not track and deliberately gets none — that is why nothing can be
// routed through it, and the picture should say so.
for (const link of cell.links) {
const a = port(link[0] ?? 'e');
const b = port(link[1] ?? 'w');
const straight = (link === 'ew' || link === 'we') || (link === 'ns' || link === 'sn');
if (straight) {
out += rail(a.x, a.y, b.x, b.y);
} else {
// A diverging leg: run out from each edge and meet, which reads as a curve at this size.
const mid = { x: W / 2, y: RAIL + (H - RAIL) / 2 };
out += rail(a.x, a.y, mid.x, mid.y) + rail(mid.x, mid.y, b.x, b.y);
}
}
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
// Standing room, drawn as the printed squares: filled means occupied.
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
for (let i = 0; i < Math.min(spots, 4); i++) {
const filled = i < cell.cars.length;
out += `<rect class="bs-slot${filled ? ' bs-occ' : ''}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
if (filled) {
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc((cell.cars[i] ?? '').slice(0, 3))}</text>`;
}
}
if (cell.enhancements.length > 0) {
out += `<text class="bs-enh" x="6" y="${H - 30}">${esc(cell.enhancements.join(' · '))}</text>`;
}
if (cell.tray) {
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
`<text class="bs-tlab" x="${W / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(cell.tray.split(' ')[0] ?? 'crew')}</text></g>`;
}
out += '</g>';
}
// Mark the Running Track so the spine of the district is unmistakable.
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
out += '</svg>';
return out;
}
/**
* Targets for legal EMPTY squares. They have no card to outline, so the board draws a dashed
* placeholder — the commonest placement of all is onto a blank square, and without this the
* highlight would have nothing to attach to.
*/
export function ghostSvg(
spots: { row: number; col: number }[],
cells: CellView[],
runningRow: number,
): string {
const W = 132;
const H = 96;
const PAD = 3;
const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...spots];
if (all.length === 0) return '';
const r1 = Math.max(...all.map((c) => c.row));
const c0 = Math.min(...all.map((c) => c.col));
let out = '';
for (const s of spots) {
const x = (s.col - c0) * (W + PAD);
const y = (r1 - s.row) * (H + PAD);
out +=
`<g data-ghost="${s.row},${s.col}" transform="translate(${x},${y})" class="bs-ghost">` +
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${s.row},${s.col}</text>` +
`</g>`;
}
void runningRow;
return out;
}
/** Styling for both renderers. Shared so the replay and the app cannot drift apart visually. */
export const BOARD_CSS = `
.bs{width:100%;height:auto;background:#0e1116;border-radius:6px;padding:6px;overflow:visible}
.bs-rail{stroke:#cfd6e0;stroke-width:1.6}
.bs-tie{stroke:#8d97a5;stroke-width:1.1}
.bs-open .bs-rail{stroke:#6b7686;stroke-dasharray:6 4}
.bs-open .bs-tie{stroke:#59626f}
.bs-card{fill:#161b21;stroke:#39424e;stroke-width:1}
.bs-card.bs-run{fill:#1d232c}
.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
.bs-lim{fill:#241d1d;stroke:#8a5a5a}
.bs-fac{fill:#201c26;stroke:#7a5f9a}
.bs-mod{fill:#231d2b;stroke:#5c4a70;stroke-dasharray:4 3}
.bs-sec{fill:#161b21;stroke:#39424e}
.bs-sec.bs-dp{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3}
.bs-sec.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
.bs-mod{font:10px ui-monospace,monospace}
text.bs-mod{fill:#c8a04a}
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
.bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4}
.bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace}
.bs-ghost,g[data-cell].bs-legal{cursor:pointer}
g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5}
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
`;
+232 -49
View File
@@ -19,13 +19,14 @@
* complete at all. * complete at all.
*/ */
import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts'; import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarType, laborersLeft } from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts'; import { MAX_CONSIST, nextOfficeTier } 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 type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts'; import { legalActions } from '../engine/legal.ts';
import { variantsFor } from '../engine/track.ts';
import { coordKey } from '../engine/state.ts'; import { coordKey } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock } from '../engine/state.ts'; import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
export type BotPolicy = { export type BotPolicy = {
name: string; name: string;
@@ -56,17 +57,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 +108,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 +124,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 +162,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. */
@@ -260,17 +290,83 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null { function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player); const area = s.officeAreas.get(player);
if (!area) return null; if (!area) return null;
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
const below = area.runningRow - 1;
/** Does this card send a leg DOWN off the Running Track? */
const divergesSouth = (c: TrackCard | undefined): boolean =>
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'turnout';
/** A card on the siding row that runs east-west — the siding itself. */
const runsAcross = (c: TrackCard | undefined): boolean =>
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'straight' && c.geometry.axis === 'ew';
/** An arc that reaches up to the Running Track. */
const reachesUp = (c: TrackCard | undefined): boolean =>
!!c &&
c.geometry.kind === 'track' &&
(c.geometry.geometry === 'curved' || c.geometry.geometry === 'sharpCurved') &&
(c.geometry.arc === 'ne' || c.geometry.arc === 'nw');
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
// Where the district already turns down off the main.
const turnouts = [...area.grid.entries()]
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c))
.map(([k]) => Number(k.split(',')[1]));
let best: Intent | null = null; let best: Intent | null = null;
let bestScore = -Infinity; let bestScore = -Infinity;
for (const i of options) { for (const i of options) {
if (i.type !== 'track.lay') continue; if (i.type !== 'track.lay') continue;
const dRow = Math.abs(i.placement.row - area.officeCoord.row); const { row, col } = i.placement;
const dCol = Math.abs(i.placement.col - area.officeCoord.col); const dRow = Math.abs(row - area.officeCoord.row);
const dCol = Math.abs(col - area.officeCoord.col);
let score = -(dRow * 2 + dCol); let score = -(dRow * 2 + dCol);
if (i.geometry === 'straight') score += 6;
// A district needs depth to hold facilities; a Running Track that only grows sideways gives /**
// Enhancements somewhere to live but Freight nowhere. * BUILD A SIDING, not a stub.
*
* A turnout dropping into a dead-end column is worth nothing: the crew can shove cars down it
* but never get past them. What makes switching solvable is a siding — down off the main, along
* beside it, and ideally back up — because that is what lets a crew run around its own train and
* change the order of the cars. Scored as a sequence, so each piece is laid because the previous
* one asked for it.
*/
if (row === area.runningRow && i.geometry === 'turnout') {
// A first way down is the most valuable single piece on the board; a second closes the
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
// siding they were supposed to serve.
// Measured: capping this to one or two ways down cost more than the spare turnouts did
// (revenue 3.3 -> 2.5, freight 1.1 -> 0.6). More ways off the main means more industries the
// crew can actually reach, which matters more than a tidy Running Track.
score += turnouts.length === 0 ? 14 : 3;
} else if (row === below) {
const arc = arcOf(i);
const turnoutAbove = divergesSouth(at(area.runningRow, col));
if (arc && (arc === 'ne' || arc === 'nw') && turnoutAbove) {
// Turn along beneath the turnout — this is what a bare n-s stub could never do.
score += 13;
} else if (i.geometry === 'straight' && variantsFor('straight')[i.variant ?? 0]?.axis === 'ew') {
// Extend the siding beside the main, but only from something that already turned.
if (reachesUp(at(row, col - 1)) || reachesUp(at(row, col + 1)) ||
runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1))) {
score += 11;
}
} else if (arc && (arc === 'ne' || arc === 'nw') &&
(runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1)))) {
// Close the loop back up to the main: the run-around is complete.
score += 12;
}
}
if (i.geometry === 'straight') score += 2;
if (dRow > 0) score += 3; if (dRow > 0) score += 3;
if (score > bestScore) { if (score > bestScore) {
bestScore = score; bestScore = score;
best = i; best = i;
@@ -317,6 +413,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.
@@ -328,6 +442,40 @@ function loadCanFinish(s: GameState, player: PlayerIndex, at: GridCoord): boolea
return f.industryTrack.cars.some((c) => !c.loaded && c.type === next.type); return f.industryTrack.cars.some((c) => !c.loaded && c.type === next.type);
} }
/** Where the crew could go NEXT from a square, so a plan can be checked one move ahead. */
function reachableFrom(
s: GameState,
player: PlayerIndex,
trayId: string,
from: GridCoord,
): GridCoord[] {
return [
...destinationsFor(s, player, trayId, from, false),
...destinationsFor(s, player, trayId, from, true),
].map((d) => d.coord);
}
/**
* The consist this move would leave, coupling included.
*
* Asks the engine's own reachability for what the crew would pick up, so the prediction cannot
* disagree with what actually happens when the move is submitted.
*/
function consistAfterMove(
s: GameState,
player: PlayerIndex,
move: Extract<Intent, { type: 'switch.move' }>,
): RollingStock[] | null {
const tray = s.trays.get(move.trayId);
if (!tray || tray.position.at !== 'grid') return null;
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, move.reverse).find(
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
);
if (!dest) return null;
// Forward means the engine leads and meets the cars head-on (§A.3).
return move.reverse ? [...tray.consist, ...dest.couples] : [...dest.couples, ...tray.consist];
}
/** Is this player's crew sitting somewhere it can never depart from? */ /** Is this player's crew sitting somewhere it can never depart from? */
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean { function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
const area = areaOf(s, player); const area = areaOf(s, player);
@@ -376,11 +524,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 +539,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 +553,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 +568,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 +581,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 +605,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 +615,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 +647,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,9 +660,37 @@ 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);
} }
/**
* RUN AROUND. §A.3 gives the engine couplers on its nose, so cars met while running
* FORWARD land in front of the train and cars met while BACKING land behind — which decides
* which car is next off the tail.
*
* That is what a siding is for. Rather than setting a useless car down and coming back for
* it, the crew can approach from the side that leaves the car it actually wants droppable.
* Both directions are offered on every move; this picks the one that ends with useful work
* at the tail.
*/
const runAround = options.find((i) => {
if (i.type !== 'switch.move') return false;
const after = consistAfterMove(s, player, i);
if (!after || after.length === 0) return false;
const tail = after[after.length - 1]!;
// The approach must change the answer: a useless car at the tail now, a wanted one after.
if (facilitiesWanting(s, player, endCar).length > 0) return false;
const wants = facilitiesWanting(s, player, tail);
if (wants.length === 0) return false;
// AND the drop has to be able to follow. Without this the crew ran the loop for its own
// sake — 8 run-arounds a game and 63 Moves, which the shuttling guard correctly failed.
return wants.some((w) => w.row === i.to.row && w.col === i.to.col) ||
reachableFrom(s, player, i.trayId, i.to).some((c) =>
wants.some((w) => w.row === c.row && w.col === c.col),
);
});
if (runAround) return because('running around: approach from the side that leaves a wanted car droppable', runAround);
// SET OUT — and do it BEFORE travelling. // SET OUT — and do it BEFORE travelling.
// //
// Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations // Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations
@@ -538,7 +714,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 +729,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 +745,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 +754,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 +764,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:
+41 -4
View File
@@ -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}`;
} }
@@ -84,10 +87,17 @@ export type Narration = {
export type NarrateContext = { export type NarrateContext = {
/** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */ /** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */
cardName?: (id: string) => string; cardName?: (id: string) => string;
/**
* Resolves a Crew Tray id to the train riding it. Without this the §8.1 clearance question read
* "may tray2 follow tray3 into the next Subdivision?" — the most consequential decision in the
* game, phrased in internal identifiers.
*/
trainName?: (trayId: TrayId) => string;
}; };
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card'; const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
switch (e.type) { switch (e.type) {
// -- clock // -- clock
@@ -122,7 +132,9 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
return { return {
tone: 'plain', tone: 'plain',
where: e.at, where: e.at,
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`, text:
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
`: ${carsLabel(e.stock)}`,
}; };
case 'consistSorted': case 'consistSorted':
return { return {
@@ -284,17 +296,42 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'good', tone: 'good',
text: `${e.key.toUpperCase()} used (+${e.bonus}) — Train ${e.trainNumber} wins the meet against Train ${e.againstTrain}, which now counts as number ${e.againstTrain + e.bonus}. Once a Day only.`, text: `${e.key.toUpperCase()} used (+${e.bonus}) — Train ${e.trainNumber} wins the meet against Train ${e.againstTrain}, which now counts as number ${e.againstTrain + e.bonus}. Once a Day only.`,
}; };
case 'trainsDestroyed': {
// Say what hit what, where, and what was written off. "COLLISION: NO FREE A/D TRACK" told a
// player the score had changed and nothing else.
const why =
e.reason === 'no free A/D track'
? `it arrived at ${e.where} with every A/D track already occupied — there was nowhere to put it (§8.3)`
: e.reason === 'cars fouling the Running Track'
? `it ran into cars left standing on ${e.where} between the Limits and the Office (§8.3)`
: e.reason;
const wrecked = e.trains
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
.join(' and ');
return {
tone: 'bad',
text:
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
`Yard, all other cars to the Classification Yard (§10). A Timetabled train card returns ` +
`to its slot and runs again next Day; an Extra is gone for good.`,
};
}
case 'clearanceRequested': case 'clearanceRequested':
return { return {
tone: 'bad', tone: 'bad',
text: `SUPERINTENDENT ASKED: may ${e.trainId} follow ${e.occupiedBy} into the next Subdivision?`, text:
`SUPERINTENDENT MUST RULE (§8.1): ${train(e.trainId)} wants to enter the Mainline card ` +
`that ${train(e.occupiedBy)} is still crossing. Allow it and ${train(e.trainId)} may run ` +
`into the back of ${train(e.occupiedBy)} — a collision costs 5 Revenue. Hold it and it ` +
`waits where it is, losing time but safe.`,
}; };
case 'clearanceGiven': case 'clearanceGiven':
return { return {
tone: e.allow ? 'bad' : 'plain', tone: e.allow ? 'bad' : 'plain',
text: e.allow text: e.allow
? `Clearance GRANTED to ${e.trainId} — it follows into an occupied Subdivision` ? `Clearance GRANTED ${train(e.trainId)} follows into the occupied Subdivision`
: `Clearance DENIED to ${e.trainId} — it holds where it is`, : `Clearance DENIED ${train(e.trainId)} holds where it is`,
}; };
// -- passengers // -- passengers
+131 -287
View File
@@ -23,263 +23,25 @@ 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, trainName } from './view.ts';
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from './board-svg.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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -300,10 +62,15 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
}; };
const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] }); const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] });
const frames: Frame[] = []; const frames: Frame[] = [];
const narrateCtx = { cardName: (id: string) => cardName(s, id) }; const narrateCtx = {
cardName: (id: string) => cardName(s, id),
trainName: (id: string) => trainName(s, id),
};
// 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 +95,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 +121,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 +142,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>
@@ -370,6 +170,8 @@ export function renderHtml(rec: Recording): string {
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — replay seed ${rec.seed}</title> <title>Station Master — replay seed ${rec.seed}</title>
<style> <style>
${BOARD_CSS}
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25; :root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b} --good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
*{box-sizing:border-box} *{box-sizing:border-box}
@@ -432,6 +234,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 +272,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 +286,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 +303,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 +316,20 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
</footer> </footer>
<script> <script>
const FRAMES = ${JSON.stringify(rec.frames)}; /* The board renderers, shared with the playable app. Embedded as SOURCE because this page has an
inline script and cannot import a module — a second copy would eventually draw a different board
from the same state, which is the one failure a track diagram exists to prevent. */
const divisionSvg = ${divisionSvg.toString()};
const officeSvg = ${officeSvg.toString()};
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) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
@@ -543,41 +376,23 @@ 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>' +
'<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('');
const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col); $('division').innerHTML = divisionSvg(DIV);
const r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols); // The same office renderer the playable app uses, so replay and game draw one board.
const g = $('grid'); $('grid').innerHTML = officeSvg(CELLS, f.runningRow);
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))'; if (f.where) {
let cellsHtml = ''; const hit = $('grid').querySelector('g[data-cell="' + f.where.row + ',' + f.where.col + '"]');
for (let r = r1; r >= r0; r--) { if (hit) hit.classList.add('bs-focus');
for (let c = c0; c <= c1; c++) { }
const cell = f.cells.find((x) => x.row === r && x.col === c); if (f.whereFrom) {
if (!cell) { cellsHtml += '<div></div>'; continue; } const was = $('grid').querySelector('g[data-cell="' + f.whereFrom.row + ',' + f.whereFrom.col + '"]');
const hl = f.where && f.where.row === r && f.where.col === c; if (was) was.classList.add('bs-from');
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
cellsHtml += '<div class="cell ' + (cell.running ? 'run ' : '') + (cell.kind === 'office' ? 'office ' : '') +
(cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
'<div class="nm">' + esc(cell.label) + '</div>' +
'<div class="dim" style="font-size:10px">(' + r + ',' + c + ')</div>' +
(cell.tray ? '<div class="crew">🚂 ' + esc(cell.tray) + '</div>' : '') +
(cell.facility ? miniFacility(cell.facility) : '') +
(cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
'</div>';
}
} }
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 +400,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 +444,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 +455,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 +474,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();
+763
View File
@@ -0,0 +1,763 @@
/**
* 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,
OFFICE_ORDER,
SPACE_USE_CARDS,
industryProfile,
modifierProfile,
lengthProfile,
officeProfile,
trainProfile,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, TrackCard } from '../engine/state.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { connectionsFor, 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;
/**
* The port pairs this card joins, as two-letter codes — 'ew', 'ns', 'es' and so on. Taken from
* the engine's own `connectionsFor`, so a drawn rail can never claim a connection the rules do
* not have.
*/
links: string[];
/**
* What this card DOES, now that it is on the board.
*
* A played card becomes a cell with a name on it and nothing else — "turnout", "Freight House",
* "waiting area" — so the explanation that was visible while it sat in hand disappears at exactly
* the moment it starts mattering.
*/
what: string;
};
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[][];
/**
* How many trains may stand here, or null for no limit. 1 on most Mainline cards, 2 where the
* card prints "trains may pass", the A/D count at an Office, unlimited at a Division Point.
*/
capacity: number | null;
/** 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[];
/** Which grid row is the Running Track — the spine the district hangs beneath. */
runningRow: number;
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] ?? prettyKey(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':
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
// "add loaded caboose" on a button.
return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`;
case 'mainline.modify':
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
case 'maneuver.redFlags':
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
case 'maneuver.flyingSwitch':
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
case 'mainline.clearance': {
// The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint
// that granting it risks a rear-ender, or that refusing merely costs time.
const pending = s.clock.pendingDecision;
const who = pending ? trainName(s, pending.train) : 'the train';
const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead';
// NOT "risks a collision, 5". A rear-end on a Mainline card is described by §10 and is what
// ABS Signals exists to prevent, but no such collision is implemented — granting clearance is
// currently free. Saying otherwise invents a consequence the engine will never deliver.
// See implications.md §10 Q13.
return i.allow
? `ALLOW — ${who} follows ${ahead} onto the same Mainline card, closing up behind it`
: `HOLD — ${who} waits where it is, losing the Stage but keeping the line clear`;
}
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 `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
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';
// Fall back to prettyKey, never the raw key. The lookup tables exist for names prettyKey cannot
// guess ("Grocer's Warehouse"), not as the only route to a readable label — leaving the raw key
// as the fallback put `refinery` and `viscosityBreakers` on the board.
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility);
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(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,
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
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))],
capacity: null,
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})` };
})],
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
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))],
capacity: officeProfile(areaOf(s, n.owner).tier).adTracks,
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)),
runningRow: area.runningRow,
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);
// Upgrades are strictly sequential (Gap 3b), so a Station card is dead weight until the
// Office is a Depot. Without saying so, the card looks playable and simply never is.
const needs = OFFICE_ORDER[OFFICE_ORDER.indexOf(k.tier) - 1];
const prereq = needs ? ` · requires the Office to be a ${prettyKey(needs)} first` : '';
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' : '') +
prereq
);
}
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}` : '';
}
}
}
/** The train riding a Crew Tray, for anything that has to talk about it. */
export function trainName(s: GameState, trayId: string): string {
const tray = s.trays.get(trayId);
if (!tray) return trayId;
if (tray.trainNumber === null) return 'the local crew';
return `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
}
/** What a card on the board does, in one short line. */
function cellDescription(card: TrackCard, officeName: string, onRunning: boolean): string {
const g = card.geometry;
switch (g.kind) {
case 'limits':
return 'the edge of your control area — lay track HERE to extend the Running Track';
case 'office': {
const p = officeProfile(
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
);
return (
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
(p.isPassengerFacility
? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
: 'not a Passenger Facility — no porters, no passenger boxes')
);
}
case 'modifier': {
const m = modifierProfile(g.modifier);
const adds: string[] = [];
if (m.addOut) adds.push(`+${m.addOut} outbound slot`);
if (m.addIn) adds.push(`+${m.addIn} inbound slot`);
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
return `${adds.join(', ') || 'no effect'} for the adjacent ${m.hosts.map(facilityLabel).join('/')}`;
}
case 'spaceUse':
return SIMPLE_CARDS.find((c) => c.key === g.key)?.effect ?? 'takes up space';
case 'facility': {
const f = card.facility;
if (!f) return 'a facility';
if (f.kind === 'passenger') return 'passengers board and detrain here';
const p = industryProfile(g.facility as never);
const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives';
// §11.2 — "Facility cards carry their own rails", so an industry on the Running Track does not
// block anything. It does inherit the Running Track's hazard: §10 makes cars left standing
// between the Limits and the Office a collision when a train arrives.
const where = onRunning
? ' · ON THE RUNNING TRACK — trains pass straight through, but a car left standing here is hit by the next arrival'
: '';
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${where}`;
}
case 'track':
switch (g.geometry) {
case 'straight':
return g.axis === 'ns' ? 'through track, northsouth' : 'through track, eastwest';
case 'curved':
case 'sharpCurved': {
const stem = g.hand === 'left' ? 'west' : 'east';
const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
return `curve — joins the ${stem} end to a leg running south${cost}`;
}
case 'turnout': {
const t = g.turnout;
if (!t) return 'turnout';
const dir = (p: string): string =>
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
// §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street.
return (
`turnout — stem ${dir(t.stem)}, through ${dir(t.through)}, diverges ${dir(t.diverge)} · ` +
`a train may run stem↔through or stem↔diverge, but NEVER between ${dir(t.through)} and ${dir(t.diverge)}`
);
}
}
}
}
/** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */
function facilityLabel(key: string): string {
return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key));
}
/** Every card category that carries a written effect rather than a profile. */
const SIMPLE_CARDS = [
...ENHANCEMENT_CARDS,
...MAINLINE_MODIFIER_CARDS,
...MANEUVER_CARDS,
...SPACE_USE_CARDS,
...ACTION_CARDS,
];
/** The goal, and whether the current score is keeping up with the clock. */
function objectiveOf(s: GameState): Frame['objective'] {
const profile = lengthProfile(s.config.length);
const revenue = s.players[0]?.revenue ?? 0;
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
const elapsed = profile.days - daysLeft + 1;
// Straight-line pace: by the end of Day N you want N/days of the target.
const expected = (profile.target * elapsed) / profile.days;
const onPace = revenue >= expected;
const note =
daysLeft === 0
? 'the last Day is over'
: `${revenue} of ${profile.target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
(onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`);
return { target: profile.target, days: profile.days, daysLeft, onPace, note };
}
/**
* Which way a piece will point, in words.
*
* "rotation 2" is not a choice anyone can make — for a curve or a turnout the orientation IS the
* decision. Read from `variantsFor`, the same list the placement uses, so the label cannot describe
* one rotation while the engine lays another.
*/
export function variantLabel(geometry: TrackGeometry, variant: number | undefined): string {
const v = variantsFor(geometry)[variant ?? 0];
if (!v) return '';
if (v.axis) return v.axis === 'ew' ? ' — eastwest' : ' — northsouth';
if (v.turnout) {
const dir = (p: string): string =>
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
return ` — stem ${dir(v.turnout.stem)}, through ${dir(v.turnout.through)}, diverges ${dir(v.turnout.diverge)}`;
}
return '';
}
/**
* A track shape in words. One place, because it is written on the board, in the action buttons and
* in the supply list — and `sharpCurved` was reaching the screen raw in two of the three.
*/
export function geometryLabel(geometry: string): string {
switch (geometry) {
case 'sharpCurved':
return 'sharp curve';
case 'curved':
return 'curve';
case 'turnout':
return 'turnout';
case 'straight':
return 'straight';
default:
return prettyKey(geometry);
}
}
/** camelCase key → readable name, for the card categories that carry only a key. */
function prettyKey(key: string): string {
return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
}
const MODIFIER_NAMES: Record<string, string> = {
teamTrack: 'Team Track',
loadingDock: 'Loading Dock',
storageShed: 'Storage Shed',
extraPlatform: 'Extra Platform',
sectionGang: 'Section Gang',
};
/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
function trainChip(s: GameState, id: string): TrainChip {
const t = s.trays.get(id);
if (!t) return { label: id, consist: [] };
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
consist: t.consist.map(carLabel),
};
}
+336
View File
@@ -0,0 +1,336 @@
/**
* 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,
trainName,
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' },
// American spelling throughout, to match MANEUVER_CARDS and the source deck.
{ prefix: 'maneuver.', title: 'Maneuvers' },
{ 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;
}
/**
* Which cards in hand can be played RIGHT NOW, in hand order.
*
* A hand where some cards are simply unplayable — a Station upgrade before the Office is a Depot, a
* Modifier with no Facility to sit beside — looks identical to one where everything is available.
* Derived from `legalActions`, so it cannot disagree with what the buttons offer.
*/
export function handPlayable(game: Game): boolean[] {
const hand = game.state.decks.hands.get(0) ?? [];
const actor = currentActor(game);
if (actor === null) return hand.map(() => false);
const playable = new Set(
legalActions(game.state, actor)
.filter((i) => i.type === 'card.play')
.map((i) => (i as Extract<Intent, { type: 'card.play' }>).cardId),
);
return hand.map((id) => playable.has(id));
}
/** 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),
trainName: (id) => trainName(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;
}
+77
View File
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master</title>
<style>
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
main{width:100%;max-width:760px}
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
.tag{color:var(--dim);margin:0;font-size:14px}
.blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch}
.rule{height:1px;background:var(--line);margin:22px 0}
.doors{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:640px){.doors{grid-template-columns:1fr}}
a.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
border:1px solid var(--line);border-radius:9px;padding:18px 18px 16px;transition:.12s}
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
a.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
a.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
a.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
/* the rule between title and blurb is drawn as rail, not decorated */
svg.trackline{width:100%;height:24px;display:block;margin:10px 0 0}
.rail{stroke:#4a5361;stroke-width:1.6}
.tie{stroke:#39424e;stroke-width:1.1}
footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;flex-wrap:wrap}
</style>
</head>
<body>
<main>
<h1>Station Master</h1>
<p class="tag">Timetable-and-train-order railroading, 1840&ndash;1950.</p>
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
<g id="ties"></g>
</svg>
<p class="blurb">
You run a division: a Running Track between your Limits, a district of industries hanging beneath
it, and trains that arrive whether or not you are ready for them. Spot the right cars, work the
loads, and get every train away again &mdash; a train with nowhere to stand is a collision, and a
collision costs more than the freight was worth.
</p>
<div class="doors">
<a class="door" href="./play.html">
<h2>Play solitaire</h2>
<p>Five Days, twelve Stages each, and a target of 20 Revenue. Your game saves in this browser,
so you can close the tab and come back to it.</p>
<span class="go">Start a game &rarr;</span>
</a>
<a class="door" href="./replays.html">
<h2>Replays</h2>
<p>Watch a finished game back, Stage by Stage. Open one saved on this site, or a save file
somebody has sent you.</p>
<span class="go">Browse replays &rarr;</span>
</a>
</div>
<div class="rule"></div>
<footer>
<span>build <span id="build">__BUILD__</span></span>
<span>runs entirely in your browser &mdash; nothing is sent anywhere</span>
</footer>
</main>
<script type="module" src="./web/splash.js"></script>
</body>
</html>
+355
View File
@@ -0,0 +1,355 @@
/**
* 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 { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from '../sim/board-svg.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import type { Game } from './game.ts';
import {
actionMenu,
handPlayable,
currentActor,
fromSave,
newGame,
submit,
toSave,
view,
} from './game.ts';
const SAVE_KEY = 'station-master.save.v1';
let game: Game;
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/** A square picked on the board, waiting for a rotation. */
let pendingAt: string | null = null;
const $ = (id: string): HTMLElement => {
const el = document.getElementById(id);
if (!el) throw new Error(`missing element: ${id}`);
return el as HTMLElement;
};
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
// ---------------------------------------------------------------------------
function start(): void {
const params = new URLSearchParams(location.search);
const requested = params.get('seed');
const saved = load();
if (saved && requested === null) {
game = fromSave(saved);
} else {
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
game = newGame(seed);
}
render();
}
function render(): void {
const f = view(game);
const menu = actionMenu(game);
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected);
const spotsAt = new Map<string, { label: string; index: number }[]>();
for (const sp of chosen?.spots ?? []) {
const key = `${sp.coord.row},${sp.coord.col}`;
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
}
$('clock').textContent = `Day ${f.day} · Stage ${f.stage}${f.clock}`;
$('phase').textContent = f.phase;
$('revenue').textContent = String(f.revenue);
const obj = $('objective');
obj.textContent = f.objective.note;
obj.className = f.objective.onPace ? 'pace good' : 'pace behind';
$('seed').textContent = String(game.seed);
// -- division
$('division').innerHTML = divisionSvg(f.division);
// -- board. Both renderers are shared with the replay so the two can never draw different
// pictures of the same position.
const grid = $('grid');
grid.innerHTML = officeSvg(f.cells, f.runningRow);
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
for (const [key, list] of spotsAt) {
const [gr, gc] = key.split(',').map(Number);
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
if (g) {
g.classList.add('bs-legal');
(g as unknown as HTMLElement).onclick = () => pick(key, list);
}
}
// A legal EMPTY square has no card to outline, so draw a target for it.
const ghosts = [...spotsAt.entries()].filter(([k]) => !f.cells.some((c) => `${c.row},${c.col}` === k));
if (ghosts.length > 0) {
const coords = ghosts.map(([k]) => {
const [gr, gc] = k.split(',').map(Number);
return { row: gr!, col: gc! };
});
grid.innerHTML = grid.innerHTML.replace('</svg>', ghostSvg(coords, f.cells, f.runningRow) + '</svg>');
for (const [key, list] of ghosts) {
const g = grid.querySelector(`g[data-ghost="${key}"]`);
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
}
}
function pick(key: string, list: { label: string; index: number }[]): void {
if (list.length === 1) {
const intent = menu.options[list[0]!.index];
if (intent) submit(game, intent);
selected = null;
pendingAt = null;
} else {
pendingAt = key;
}
render();
}
// -- facilities
$('facs').innerHTML =
f.facilities.length === 0
? '<div class="dim">no facilities yet</div>'
: f.facilities
.map(
(x) =>
`<div class="fac" data-tip="${esc(x.name)}${esc(x.flow)} ${esc(x.commodity)} · laborers ${esc(x.laborers)} · porters ${esc(x.porters)}" tabindex="0">` +
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></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'}" data-tip="${
x.jammed
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
: x.canFinish
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
}">` +
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
`</div></div>`,
)
.join('');
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
const cardRow = (name: string, why: string, playable: boolean | null): string =>
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b>` +
(playable === false ? ' <span class="tag">not yet</span>' : '') +
`</div>`;
const canPlay = handPlayable(game);
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
: '<span class="dim">empty</span>';
$('depts').innerHTML = f.departments.length
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).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();
};
// A long label is two things: the action, and why it is offered. Put the first on the button and
// the second on the tooltip, or the list crowds out the board.
const actionButton = (label: string, index: number): string => {
const cut = label.indexOf(' — ');
const head = cut > 0 ? label.slice(0, cut) : label;
const rest = cut > 0 ? label.slice(cut + 3) : '';
return (
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
};
let html = menu.direct
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
g.actions.map((a) => actionButton(a.label, a.index)).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.
// ---------------------------------------------------------------------------
/**
* Write the game out as a file.
*
* The save IS the replay: a seed and the moves made, which the engine can replay exactly. A few
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
* where a rendered page would have been megabytes.
*/
function downloadSave(): void {
const data = JSON.stringify(toSave(game), null, 1);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `station-master-seed${game.seed}-day${game.state.clock.day}.json`;
a.click();
URL.revokeObjectURL(url);
}
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 */
}
}
// BOTH stylesheets. The board is SVG built by the shared renderers, and every shape it draws is
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
// background, so the cards are drawn correctly and are simply invisible.
const pageStyle = document.createElement('style');
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS;
document.head.appendChild(pageStyle);
installTooltips();
const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave;
start();
+139
View File
@@ -0,0 +1,139 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — play</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}
header button{background:#2a3038;color:inherit;border:1px solid #2c333d;border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px}
header button:hover{border-color:#4d6fa8}
.build{margin-left:auto;font-size:11px;opacity:.7}
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
.home:hover{color:#5aa9e6}
.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{padding:3px 0}
.handcard:focus{outline:1px solid #4d6fa8;border-radius:3px}
.handcard.unplayable{opacity:.55}
.tag{font-size:10px;background:rgba(190,120,40,.3);border-radius:3px;padding:0 5px}
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}
.cell .what{font-size:10px;line-height:1.25;margin-top:2px}
.cell{cursor:help}
.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:3px;font-size:10px;padding:1px 6px;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><a href="./index.html" class="home">Station Master</a></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 for a set deal</span>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<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 <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span></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>
+241
View File
@@ -0,0 +1,241 @@
/**
* The replay directory, and the viewer that plays one back.
*
* A REPLAY IS A SAVE. `{ seed, history }` — a few hundred bytes — because the engine is
* deterministic and runs in the browser: re-submitting the same intents against the same seed
* reconstructs the position exactly. So sharing a game means sharing a small JSON file, not a
* multi-megabyte page, and a save that would describe an impossible position simply cannot be
* replayed, because every step goes through `applyIntent`.
*
* Static hosting cannot list a directory, so site-hosted replays are enumerated by
* `replays/manifest.json`, written at build time from whatever is in `public/replays/`.
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { narrate } from '../sim/narrate.ts';
import { cardName, trainName } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts';
import { applyIntent } from '../engine/apply.ts';
import { pump } from '../engine/advance.ts';
import { createGame } from '../engine/setup.ts';
import { snapshot } from '../sim/view.ts';
import type { Intent } from '../engine/intents.ts';
import { SOLO_CONFIG } from './game.ts';
type Save = { seed: number; history: Intent[] };
type Entry = { file: string; title: string; note?: string; seed?: number };
const $ = (id: string): HTMLElement => {
const el = document.getElementById(id);
if (!el) throw new Error(`missing element: ${id}`);
return el;
};
const esc = (s: string): string =>
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/** Every position the save passes through, with the lines narrated along the way. */
type Step = { frame: Frame; lines: { text: string; tone: string }[] };
/**
* Rebuild a game from a save, keeping a snapshot at each step.
*
* An intent that no longer applies stops the rebuild rather than being forced — better a short
* replay than one showing a position the rules could not produce.
*/
function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] });
const steps: Step[] = [];
const ctx = { cardName: (id: string) => cardName(s, id), trainName: (id: string) => trainName(s, id) };
const push = (events: ReturnType<typeof pump>): void => {
const lines = events
.filter((e) => e.type !== 'actorChanged')
.map((e) => {
const n = narrate(e, ctx);
return { text: n.text, tone: n.tone };
});
steps.push({ frame: snapshot(s, [], null), lines });
};
push(pump(s));
let stoppedEarly = false;
for (const intent of save.history) {
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null || s.status !== 'active') break;
const r = applyIntent(s, actor, intent);
if (!r.ok) {
stoppedEarly = true;
break;
}
const events = [...r.events, ...pump(s)];
push(events);
}
return { steps, stoppedEarly };
}
// ---------------------------------------------------------------------------
// Viewer
// ---------------------------------------------------------------------------
let steps: Step[] = [];
let at = 0;
let timer: ReturnType<typeof setInterval> | null = null;
function show(i: number): void {
at = Math.max(0, Math.min(steps.length - 1, i));
const step = steps[at];
if (!step) return;
const f = step.frame;
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage}${f.clock}`;
$('vphase').textContent = f.phase;
$('vrev').textContent = String(f.revenue);
$('vpos').textContent = `${at} / ${steps.length - 1}`;
($('vscrub') as HTMLInputElement).value = String(at);
$('vdivision').innerHTML = divisionSvg(f.division);
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
// A window of history rather than only this step, so a jump lands in context.
let log = '';
for (let k = Math.max(0, at - 18); k <= at; k++) {
for (const l of steps[k]?.lines ?? []) {
log += `<div class="line t-${l.tone}${k === at ? ' now' : ''}">${esc(l.text)}</div>`;
}
}
$('vlog').innerHTML = log;
const logEl = $('vlog');
logEl.scrollTop = logEl.scrollHeight;
}
function openSave(save: Save, title: string): void {
const built = rebuild(save);
steps = built.steps;
$('picker').style.display = 'none';
$('viewer').style.display = 'block';
$('vtitle').textContent = title;
$('vnote').textContent = built.stoppedEarly
? 'this save stopped early — an action in it is no longer legal under the current rules'
: `${steps.length} steps · seed ${save.seed}`;
const scrub = $('vscrub') as HTMLInputElement;
scrub.max = String(Math.max(0, steps.length - 1));
show(0);
}
function parseSave(text: string, from: string): Save | null {
try {
const raw = JSON.parse(text) as Partial<Save>;
if (typeof raw.seed !== 'number' || !Array.isArray(raw.history)) {
throw new Error('not a Station Master save');
}
return { seed: raw.seed, history: raw.history as Intent[] };
} catch (e) {
$('perr').textContent = `Could not read ${from}: ${(e as Error).message}`;
return null;
}
}
// ---------------------------------------------------------------------------
// Directory
// ---------------------------------------------------------------------------
async function listHosted(): Promise<void> {
const el = $('hosted');
try {
const res = await fetch('./replays/manifest.json', { cache: 'no-cache' });
if (!res.ok) throw new Error(String(res.status));
const entries = (await res.json()) as Entry[];
if (entries.length === 0) {
el.innerHTML = '<p class="dim">No replays have been published to this site yet.</p>';
return;
}
el.innerHTML = entries
.map(
(e, i) =>
`<button class="entry" data-i="${i}"><b>${esc(e.title)}</b>` +
(e.note ? `<span class="dim"> — ${esc(e.note)}</span>` : '') +
`<div class="dim sm">${esc(e.file)}</div></button>`,
)
.join('');
for (const b of Array.from(el.querySelectorAll('button.entry'))) {
(b as HTMLElement).onclick = async () => {
const entry = entries[Number((b as HTMLElement).dataset['i'])];
if (!entry) return;
const r = await fetch(`./replays/${entry.file}`, { cache: 'no-cache' });
const save = parseSave(await r.text(), entry.file);
if (save) openSave(save, entry.title);
};
}
} catch {
el.innerHTML =
'<p class="dim">No replay index on this site yet. You can still open a save file from your computer below.</p>';
}
}
function wire(): void {
const file = $('file') as HTMLInputElement;
file.onchange = () => {
const f = file.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
const save = parseSave(String(reader.result), f.name);
if (save) openSave(save, f.name.replace(/\.json$/i, ''));
};
reader.readAsText(f);
};
$('vback').onclick = () => show(at - 1);
$('vfwd').onclick = () => show(at + 1);
$('vfirst').onclick = () => show(0);
$('vlast').onclick = () => show(steps.length - 1);
($('vscrub') as HTMLInputElement).oninput = (e) => show(Number((e.target as HTMLInputElement).value));
$('vstage').onclick = () => {
const now = steps[at]?.frame;
for (let k = at + 1; k < steps.length; k++) {
const f = steps[k]!.frame;
if (f.stage !== now?.stage || f.day !== now?.day) return show(k);
}
show(steps.length - 1);
};
$('vplay').onclick = () => {
if (timer) {
clearInterval(timer);
timer = null;
$('vplay').textContent = '▶ play';
return;
}
$('vplay').textContent = '⏸ pause';
timer = setInterval(() => {
if (at >= steps.length - 1) {
clearInterval(timer!);
timer = null;
$('vplay').textContent = '▶ play';
return;
}
show(at + 1);
}, Number(($('vspeed') as HTMLSelectElement).value));
};
$('vclose').onclick = () => {
if (timer) clearInterval(timer);
timer = null;
$('viewer').style.display = 'none';
$('picker').style.display = 'block';
};
document.onkeydown = (e) => {
if ($('viewer').style.display === 'none') return;
if (e.key === 'ArrowRight') show(at + 1);
else if (e.key === 'ArrowLeft') show(at - 1);
else if (e.key === ' ') {
e.preventDefault();
$('vplay').click();
}
};
}
const style = document.createElement('style');
style.textContent = BOARD_CSS + TOOLTIP_CSS;
document.head.appendChild(style);
installTooltips();
wire();
void listHosted();
+9
View File
@@ -0,0 +1,9 @@
/** The splash page. Draws ties on the rule so the line reads as track rather than a border. */
const ties = document.getElementById('ties');
if (ties) {
let out = '';
for (let x = 4; x < 700; x += 9) {
out += `<line class="tie" x1="${x}" y1="6" x2="${x}" y2="19"/>`;
}
ties.innerHTML = out;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* One floating tooltip, shared by every page.
*
* Reference detail — what a card does, why an action is offered, what a limit means — is needed
* occasionally and read once. Printing all of it costs the space that the board, the hand and the
* action list actually need. So anything carrying `data-tip` shows its text on hover or focus and
* nothing the rest of the time.
*
* Not the browser's native `title`: that waits about a second, cannot be styled, wraps badly at
* this length, and never appears for keyboard users.
*/
let tip: HTMLElement | null = null;
function ensure(): HTMLElement {
if (tip) return tip;
const el = document.createElement('div');
el.id = 'tip';
el.setAttribute('role', 'tooltip');
document.body.appendChild(el);
tip = el;
return el;
}
function place(el: HTMLElement, text: string): void {
const t = ensure();
t.textContent = text;
t.style.display = 'block';
// Measure, then keep it on screen: flip above when it would fall off the bottom, and pull back
// from the right edge rather than letting the text run off it.
const r = el.getBoundingClientRect();
const box = t.getBoundingClientRect();
const margin = 8;
let left = r.left + r.width / 2 - box.width / 2;
left = Math.max(margin, Math.min(left, window.innerWidth - box.width - margin));
let top = r.bottom + 6;
if (top + box.height > window.innerHeight - margin) top = r.top - box.height - 6;
t.style.left = `${Math.round(left + window.scrollX)}px`;
t.style.top = `${Math.round(top + window.scrollY)}px`;
}
function hide(): void {
if (tip) tip.style.display = 'none';
}
/**
* Start listening. Delegated from the document, so markup replaced later — the board redraws on
* every action — needs no re-binding.
*/
export function installTooltips(): void {
const find = (node: EventTarget | null): HTMLElement | null => {
let el = node as HTMLElement | null;
while (el && el !== document.body) {
// SVG elements have no closest() in older engines, so walk manually.
if (el.getAttribute && el.getAttribute('data-tip')) return el;
el = (el.parentNode as HTMLElement) ?? null;
}
return null;
};
document.addEventListener('mouseover', (e) => {
const el = find(e.target);
if (el) place(el, el.getAttribute('data-tip') ?? '');
else hide();
});
document.addEventListener('mouseout', (e) => {
if (!find((e as MouseEvent).relatedTarget)) hide();
});
document.addEventListener('focusin', (e) => {
const el = find(e.target);
if (el) place(el, el.getAttribute('data-tip') ?? '');
});
document.addEventListener('focusout', hide);
document.addEventListener('scroll', hide, true);
// Escape closes it, which matters when one is pinned open by focus.
document.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape') hide();
});
}
/** Styling, kept here so every page that installs tooltips gets the same one. */
export const TOOLTIP_CSS = `
#tip{position:absolute;z-index:9999;display:none;max-width:340px;
background:#0b0e12;color:#e6e9ee;border:1px solid #4d6fa8;border-radius:6px;
padding:7px 10px;font:12px/1.45 ui-monospace,Menlo,Consolas,monospace;
box-shadow:0 6px 22px rgba(0,0,0,.55);pointer-events:none;white-space:pre-wrap}
[data-tip]{cursor:help}
button[data-tip],a[data-tip]{cursor:pointer}
`;
+122
View File
@@ -412,3 +412,125 @@ 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('refuses to build beyond the Limits sign', () => {
// §2.1 — the Running Track runs BETWEEN the Limits, so the sign is the only growth point.
// Offering the square PAST it produced, from one straight laid at (0,2):
// limits · Whistle Post · limits · straight · limits
// — track on the far side of the sign, and a second sign planted beyond that.
const s = game();
const area = areaOf(s, 0);
s.turn.option = 'draw';
const beyondEast = { row: area.runningRow, col: area.limitsEast.col + 1 };
const beyondWest = { row: area.runningRow, col: area.limitsWest.col - 1 };
for (const placement of [beyondEast, beyondWest]) {
assert.notEqual(
check(s, 0, { type: 'track.lay', geometry: 'straight', hand: 'none', placement, variant: 0 }),
null,
`(${placement.row},${placement.col}) is outside the Limits and must not be placeable`,
);
}
// The sign itself IS legal — that is how the Running Track grows.
assert.equal(
check(s, 0, {
type: 'track.lay', geometry: 'straight', hand: 'none',
placement: { row: area.runningRow, col: area.limitsEast.col }, variant: 0,
}),
null,
'laying on the Limits sign must be how the track extends',
);
// The district hangs below the Running Track and is not bounded by the Limits at all.
assert.equal(
check(s, 0, {
type: 'track.lay', geometry: 'straight', hand: 'none',
placement: { row: area.runningRow - 1, col: 0 }, variant: 1,
}),
null,
'Secondary Track below the Office must stay legal',
);
});
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',
);
});
});
+62 -3
View File
@@ -36,7 +36,7 @@ const SAMPLES: GameEvent[] = [
{ type: 'actorChanged', player: 0 }, { type: 'actorChanged', player: 0 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' }, { type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 }, { type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }] }, { type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] }, { type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' }, { type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 }, { type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
@@ -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`);
+19 -6
View File
@@ -276,15 +276,28 @@ describe('switching accomplishes something (regression)', () => {
assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`); assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`);
}); });
it('does not spend its whole Local Operations budget on motion', () => { it('does not shuttle aimlessly — Moves have to buy something', () => {
// The absolute ratio is no longer a fair test: with only 9 industries in 115 cards there is // Counting Moves alone stopped being a fair test once the crew could do real work. Run-arounds
// often nothing to switch, so `work` is legitimately near zero. What still must hold is that // and sidings are SUPPOSED to cost several Moves each: the crew leaves the main, runs the loop,
// the crew is not burning all six Moves every Stage — the shuttling bug. // and comes back with a different car droppable. Measured before sidings existed the crew
// coupled ~0.4 cars a game; it now couples ~8 and drops ~11.
//
// So test the thing the old threshold was a proxy for: motion must convert into work. A crew
// that shuttles for its own sake shows a high ratio here, however few or many Moves it makes.
const report = simulate({ const report = simulate({
games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot, games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
}); });
const moves = report.perGame.reduce((n, g) => n + g.actions.moves, 0) / report.perGame.length; const per = (f: (g: (typeof report.perGame)[number]) => number): number =>
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`); report.perGame.reduce((n, g) => n + f(g), 0) / report.perGame.length;
const moves = per((g) => g.actions.moves);
const work = per((g) => g.actions.drops + g.actions.couples);
assert.ok(work > 2, `only ${work.toFixed(1)} productive acts a game — the crew is doing nothing`);
assert.ok(
moves / work < 8,
`${(moves / work).toFixed(1)} Moves per drop or coupling suggests aimless shuttling ` +
`(${moves.toFixed(0)} Moves, ${work.toFixed(1)} productive acts)`,
);
}); });
}); });
+159
View File
@@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { import type {
GameState,
GridCoord, GridCoord,
OfficeArea, OfficeArea,
RollingStock, RollingStock,
@@ -14,6 +15,8 @@ import type {
TurnoutOrientation, TurnoutOrientation,
} from '../src/engine/state.ts'; } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts'; import { coordKey } from '../src/engine/state.ts';
import { createGame } from '../src/engine/setup.ts';
import { applyIntent, areaOf } from '../src/engine/apply.ts';
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts'; import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
import { import {
allReachable, allReachable,
@@ -24,6 +27,8 @@ import {
neighbour, neighbour,
opposite, opposite,
reachableDestinations, reachableDestinations,
connectionsFor,
variantsFor,
} from '../src/engine/track.ts'; } from '../src/engine/track.ts';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -93,6 +98,27 @@ const lockedFacility = (): TrackCard => ({
const car = (): RollingStock => ({ type: 'boxcar', loaded: false }); const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
function straightCard(): TrackCard {
return {
geometry: { kind: 'track', geometry: 'straight', axis: 'ew' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
};
}
/** A minimal game wrapped around a prepared Office Area, for engine-level switching tests. */
function gameWith(area: OfficeArea): GameState {
const s = createGame({
id: 'g', seed: 5,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['p'],
});
s.officeAreas.set(0, area);
return s;
}
function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): OfficeArea { function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): OfficeArea {
const grid = new Map<string, TrackCard>(); const grid = new Map<string, TrackCard>();
for (const [k, v] of Object.entries(cards)) grid.set(k, v); for (const [k, v] of Object.entries(cards)) grid.set(k, v);
@@ -408,3 +434,136 @@ describe('placement and drop-off', () => {
assert.ok(!canDropCarsAt(area, at(0, 1))); assert.ok(!canDropCarsAt(area, at(0, 1)));
}); });
}); });
describe('curves are arcs, and arcs make sidings possible', () => {
const arcCard = (arc: 'ne' | 'nw' | 'se' | 'sw', geometry: 'curved' | 'sharpCurved' = 'curved'): TrackCard => ({
geometry: { kind: 'track', geometry, arc },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
it('joins exactly two adjacent edges, with no through track', () => {
// The printed cards (docs/tracks.png, rows 3-4) are a single sweep from edge to edge. Modelling
// a curve as through-track PLUS a diverging leg made it a turnout, and an exact duplicate of one.
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
const links = connectionsFor(arcCard(arc));
assert.equal(links.length, 1, `${arc} should be ONE connection, not a through track plus a leg`);
assert.deepEqual([...links[0]!].sort(), [...arc].sort(), `${arc} joins the wrong edges`);
}
});
it('is no longer a duplicate of a turnout', () => {
const norm = (c: readonly (readonly string[])[]): string =>
c.map((p) => [...p].sort().join('')).sort().join(' ');
const turnout: TrackCard = {
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
};
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)),
`a ${arc} curve still has the same connections as a turnout`);
}
});
it('can reach NORTH, which nothing but an n-s straight could before', () => {
// This is the whole point. With every leg diverging south, a district could only ever be a
// vertical column — no siding, no parallel track, no way back up to the Running Track.
const reachesNorth = (['ne', 'nw'] as const).every((arc) =>
connectionsFor(arcCard(arc)).some((p) => p.includes('n')),
);
assert.ok(reachesNorth, 'a curve still cannot reach north');
});
it('offers all four rotations when placed', () => {
// A two-port arc has no handedness that survives turning — its mirror IS one of its rotations —
// so the printed hand governs supply, not what can be built.
for (const g of ['curved', 'sharpCurved'] as const) {
const arcs = variantsFor(g).map((v) => v.arc).sort();
assert.deepEqual(arcs, ['ne', 'nw', 'se', 'sw'], `${g} does not offer all four rotations`);
}
});
it('a sharp curve differs from a curve only in the Moves it costs', () => {
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
assert.deepEqual(
connectionsFor(arcCard(arc, 'sharpCurved')),
connectionsFor(arcCard(arc, 'curved')),
'a sharp curve should be geometrically identical',
);
}
});
it('lets a crew run a siding and rejoin the main', () => {
// The shape the whole change exists for: leave the Running Track, run parallel, come back up.
// A crew must ENTER a turnout through its stem to take the diverging leg (§A.1).
const grid: Record<string, TrackCard> = {
'0,-2': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'-1,-1': arcCard('ne'),
'-1,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'-1,1': arcCard('nw'),
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
};
const area = areaFrom(grid, { row: 0, col: 0 });
const ctx = { area, occupancy: { trayAt: () => null, freeAdTracks: () => 2 }, consistSize: 0, self: 'crew' as const };
const seen = new Set<string>(['0,-2']);
const queue = [{ row: 0, col: -2 }];
for (let i = 0; i < 40 && queue.length > 0; i++) {
const at = queue.shift()!;
for (const facing of ['e', 'w', 'n', 's'] as const) {
for (const d of reachableDestinations(ctx, at, facing)) {
const key = `${d.coord.row},${d.coord.col}`;
if (!seen.has(key)) {
seen.add(key);
queue.push(d.coord);
}
}
}
}
for (const cell of ['-1,-1', '-1,0', '-1,1']) {
assert.ok(seen.has(cell), `the siding cell ${cell} is unreachable — no run-around is possible`);
}
assert.ok(seen.has('0,1'), 'the siding does not rejoin the Running Track');
});
});
describe('coupling lifts only the cars the crew ran over', () => {
it('leaves cars standing elsewhere in the district alone', () => {
// The reducer cleared EVERY card in the Office Area on any coupling, so picking up one boxcar
// deleted every car standing anywhere — including loads worked over several Stages onto an
// industry track the crew never went near.
const grid: Record<string, TrackCard> = {
'0,-1': straightCard(),
'0,0': straightCard(),
'0,1': straightCard(),
'0,2': straightCard(),
};
grid['0,-1']!.standing.push({ type: 'boxcar', loaded: false }); // on the path
grid['0,2']!.standing.push({ type: 'hopper', loaded: true }); // nowhere near it
const area = areaFrom(grid, { row: 0, col: 0 });
const s = gameWith(area);
s.trays.set('crew', {
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true, consist: [],
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
s.turn.movesRemaining = 6;
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: { row: 0, col: -1 }, reverse: false });
assert.ok(r.ok, 'the move should be legal');
assert.deepEqual(
s.trays.get('crew')!.consist.map((c: RollingStock) => c.type),
['boxcar'],
'the crew should have picked up the car it ran over',
);
assert.equal(
areaOf(s, 0).grid.get('0,2')!.standing.length,
1,
'a car standing off the path was deleted by an unrelated coupling',
);
});
});
+810
View File
@@ -0,0 +1,810 @@
/**
* 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 { dirname, join, resolve } from 'node:path';
import { cardDescription, describeIntent } from '../src/sim/view.ts';
import {
actionGroups,
actionMenu,
handPlayable,
currentActor,
fromSave,
newGame,
submit,
toSave,
view,
} from '../src/web/game.ts';
const root = join(import.meta.dirname, '..');
const dist = join(root, 'dist');
/** 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('says what every card on the BOARD does, in readable words', () => {
// A played card becomes a cell with a name on it — "turnout", "Freight House", "waiting area" —
// and the explanation that was visible while it sat in hand disappears exactly when it starts
// mattering. Play a long way in so every card kind reaches the grid.
const game = newGame(111);
for (let i = 0; i < 400; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
const pick =
options.find((o) => o.type === 'card.play' && o.placement) ??
options.find((o) => o.type === 'track.lay') ??
options[0]!;
if (!submit(game, pick)) break;
}
const cells = view(game).cells;
assert.ok(cells.length > 4, 'not enough of the board was built to be a real check');
for (const c of cells) {
assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`);
// camelCase on the board is the failure that keeps recurring — labels AND descriptions.
assert.doesNotMatch(c.label, /[a-z][A-Z]/, `raw camelCase label: ${c.label}`);
assert.doesNotMatch(c.what, /[a-z][A-Z]/, `raw camelCase in "${c.label}": ${c.what}`);
}
});
it('spells out which way a turnout will and will not let a train run', () => {
// §A.1 is an ABSENT edge, not a one-way street, and it is invisible on a card that just says
// "turnout". Getting it wrong is how a crew ends up somewhere it cannot leave.
const game = newGame(3);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
assert.match(cell.what, /stem east/);
assert.match(cell.what, /diverges south/);
assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out');
});
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('marks cards that cannot be played yet, and says why', () => {
// A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential
// (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an
// action that did nothing.
const game = newGame(111);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
submit(game, actionGroups(game).options.find((o) => o.type === 'draw.fromDepartment' && o.slot === 0)!);
const f = view(game);
const playable = handPlayable(game);
assert.equal(playable.length, f.hand.length, 'a hand card has no playable flag');
const upgrades = f.hand
.map((name, i) => ({ name, what: f.handWhat[i]!, can: playable[i]! }))
.filter((c) => /upgrade/.test(c.name));
assert.ok(upgrades.length > 0, 'this seed should deal an Office upgrade');
for (const u of upgrades) {
// Only Depot is reachable from a Whistle Post.
if (/Depot/.test(u.name)) continue;
assert.equal(u.can, false, `${u.name} should not be playable from a Whistle Post`);
assert.match(u.what, /requires the Office to be a/, `${u.name} does not say what it needs`);
}
});
it('agrees with the buttons about what is playable', () => {
// The flag is derived from legalActions, so it must never disagree with the offered actions.
const game = newGame(7);
for (let i = 0; i < 60; i++) {
if (currentActor(game) === null) break;
const hand = game.state.decks.hands.get(0) ?? [];
const flags = handPlayable(game);
const offered = new Set(
actionGroups(game)
.options.filter((o) => o.type === 'card.play')
.map((o) => (o as { cardId: string }).cardId),
);
hand.forEach((id, k) => {
assert.equal(flags[k], offered.has(id), 'playable flag disagrees with the action list');
});
const { options } = actionGroups(game);
if (options.length === 0) break;
submit(game, options[0]!);
}
});
it('never puts an internal tray id on a BUTTON either', () => {
// The history check below missed this: `maneuver.redFlags` was labelled "Red Flags on tray3",
// so the id leaked through the action list rather than the log. Every button, every turn.
const game = newGame(2345);
for (let i = 0; i < 400; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
for (const o of options) {
const label = describeIntent(game.state, o);
assert.doesNotMatch(label, /\btray\d+\b/, `internal tray id on a button: ${label}`);
}
if (!submit(game, options[0]!)) break;
}
});
it('never offers to load a caboose', () => {
// A caboose carries the crew, not freight. "add loaded caboose" appeared because the button was
// formatted by hand instead of using the labeller that already knew.
const game = newGame(2345);
for (let i = 0; i < 400; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
for (const o of options) {
assert.doesNotMatch(
describeIntent(game.state, o),
/(loaded|empty) caboose/,
'a caboose was described as loaded or empty',
);
}
if (!submit(game, options[0]!)) break;
}
});
it('warns that an industry on the Running Track is in the way of arrivals', () => {
// §11.2 — a Facility carries its own rails, so it does NOT block traffic. But §10 makes a car
// left standing between the Limits and the Office a collision, and nothing said so.
const game = newGame(9);
const area = game.state.officeAreas.get(0)!;
const industry = {
geometry: { kind: 'facility' as const, facility: 'grocersWarehouse' as const, axis: 'ew' as const },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight' as const, subtype: 'grocersWarehouse',
allows: { outbound: false, inbound: true },
outboundBox: [], inboundBox: [], capacity: { outbound: 0, inbound: 1 },
menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
};
area.grid.set(`${area.runningRow},2`, industry as never);
area.grid.set(`${area.runningRow - 1},0`, JSON.parse(JSON.stringify(industry)) as never);
const cells = view(game).cells;
const onRunning = cells.find((c) => c.row === area.runningRow && c.col === 2)!;
const below = cells.find((c) => c.row === area.runningRow - 1 && c.col === 0)!;
assert.match(onRunning.what, /RUNNING TRACK/, 'no warning on a Running Track industry');
assert.match(onRunning.what, /pass straight through/, 'does not say traffic still passes');
assert.doesNotMatch(below.what, /RUNNING TRACK/, 'a Secondary Track industry must not warn');
});
it('never puts an internal tray id in front of a player', () => {
// The §8.1 clearance question read "may tray2 follow tray3 into the next Subdivision?" — the
// sharpest decision in the game, phrased in internal identifiers.
const game = newGame(430);
for (let i = 0; i < 600; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
submit(game, options[0]!);
}
for (const line of game.log) {
assert.doesNotMatch(line.text, /\btray\d+\b/, `internal tray id shown to the player: ${line.text}`);
}
});
it('spells out what each clearance ruling costs', () => {
const game = newGame(5);
const s = game.state;
s.trays.set('tray2', {
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineFront: true,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.trays.set('tray3', {
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineFront: true,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };
const allow = describeIntent(s, { type: 'mainline.clearance', allow: true });
const hold = describeIntent(s, { type: 'mainline.clearance', allow: false });
for (const label of [allow, hold]) {
assert.match(label, /Train 4/, `the ruling does not name the train: ${label}`);
assert.doesNotMatch(label, /tray\d/, `internal id on a button: ${label}`);
}
// Deliberately NOT asserting that granting clearance mentions a collision: no rear-end is
// implemented, and promising one would describe a consequence the engine never delivers.
assert.match(allow, /same Mainline card/, 'granting clearance does not say what happens');
assert.match(hold, /losing the Stage/, 'holding does not mention the cost');
});
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('replays are saves', () => {
it('rebuilds the exact position a save describes', () => {
// A replay is `{seed, history}` — a few hundred bytes — because the engine is deterministic and
// runs in the browser. Re-submitting the same intents must land on the same board, or a shared
// replay shows something the player never saw.
const game = newGame(430);
for (let i = 0; i < 250; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
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.deepEqual(view(restored).cells, view(game).cells, 'the rebuilt board differs');
assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs');
});
it('stays small enough to email', () => {
const game = newGame(202);
for (let i = 0; i < 400; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
const kb = Buffer.byteLength(JSON.stringify(toSave(game))) / 1024;
assert.ok(kb < 200, `a save is ${kb.toFixed(0)} KB — too big to be worth sharing as a file`);
});
it('publishes every save in public/replays with an index', () => {
// Static hosting cannot list a directory, so the page needs a manifest or it shows nothing.
const manifestPath = join(dist, 'replays/manifest.json');
assert.ok(existsSync(manifestPath), 'no replay manifest was built');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { file: string; title: string }[];
for (const entry of manifest) {
assert.ok(existsSync(join(dist, 'replays', entry.file)), `${entry.file} is indexed but not published`);
assert.ok(entry.title.length > 0, `${entry.file} has no title`);
const save = JSON.parse(readFileSync(join(dist, 'replays', entry.file), 'utf8')) as Record<string, unknown>;
assert.equal(typeof save['seed'], 'number', `${entry.file} has no seed`);
assert.ok(Array.isArray(save['history']), `${entry.file} has no history`);
}
});
});
describe('the static build', () => {
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]!.split('?')[0]!;
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 injected: 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 found = new Map<string, Record<string, unknown>>();
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
// The board is SVG now, and highlighting works by finding a card group and adding a class.
// The stub has to model that much or it cannot see whether highlighting happened at all.
querySelector(sel: string) {
const m = /\[data-(cell|ghost)="([^"]+)"\]/.exec(sel);
if (!m) return null;
const key = `${m[1]}:${m[2]}`;
if (!html.includes(`data-${m[1]}="${m[2]}"`)) return null;
if (!found.has(key)) {
const classes = new Set<string>();
found.set(key, {
onclick: null,
classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) },
classes,
});
}
return found.get(key);
},
highlighted: () => [...found.values()].filter((f) => (f['classes'] as Set<string>).size > 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;
};
// STRICT: only ids that really exist in the served page. The stub used to conjure an element
// for any id asked for, so a `$('target')` left behind after removing #target from the HTML
// would pass here and throw on load in a browser — the page would simply never start.
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
(m) => m[1]!,
),
);
const g = globalThis as Record<string, unknown>;
// The page installs a tooltip layer on load, so the stub needs enough DOM to let it.
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(),
addEventListener: () => {},
body: { appendChild: () => {} },
// Capture injected stylesheets. The board is SVG styled entirely by class, so a page that
// renders every card correctly and never loads BOARD_CSS draws them black on black — visibly
// broken, and invisible to a stub that ignores CSS.
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
};
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');
// Without the board stylesheet every shape is drawn black on a near-black background: the page
// looks empty even though the markup is perfect.
const styles = injected.map((n) => String(n['textContent'] ?? '')).join('\n');
assert.match(styles, /\.bs-card\s*\{/, 'the page never loaded the board styles — cards would be invisible');
assert.match(styles, /\.bs-rail\s*\{/, 'the page never loaded the rail styles');
const html = String(grid['innerHTML']);
// The board draws cards as addressable groups; legality is an added class or a drawn ghost.
assert.match(html, /data-cell="/, 'the board drew no addressable cards');
const lit = (grid['highlighted'] as () => unknown[])();
const ghosts = /data-ghost="/.test(html);
assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board');
});
it('busts the cache on every module, so a deploy cannot half-load', () => {
// The first real deploy served a fresh index.html against a CACHED main.js: the HTML had
// dropped an element the old script still asked for, so the page threw `missing element:
// target` and never started. Filenames never change, so without a version query a static host
// will happily mix two builds.
const html = readFileSync(join(dist, 'index.html'), 'utf8');
const entry = /<script type="module" src="([^"]+)"/.exec(html)?.[1] ?? '';
assert.match(entry, /\?v=/, 'the entry script is not cache-busted');
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);
let checked = 0;
for (const f of files) {
const src = readFileSync(f, 'utf8');
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
assert.match(m[1]!, /\.js\?v=/, `${f} imports ${m[1]} without a version`);
checked++;
}
}
assert.ok(checked > 5, `only ${checked} relative imports seen — the check is too weak`);
});
it('resolves every busted import to a real file', () => {
// Appending a query must not break resolution: the path before `?` still has to exist.
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);
for (const f of files) {
const src = readFileSync(f, 'utf8');
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
const target = resolve(dirname(f), m[1]!.split('?')[0]!);
assert.ok(existsSync(target), `${f} imports ${m[1]}, which does not exist`);
}
}
});
it('asks only for elements the page actually has', () => {
// Cheap and total: compare every $('id') in the source against the ids in the served HTML.
// Getting this wrong does not degrade the page, it stops the game starting at all.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z]+)'\)/g)].map((m) => m[1]!));
const html = readFileSync(join(dist, 'play.html'), 'utf8');
const present = new Set([...html.matchAll(/id="([a-zA-Z]+)"/g)].map((m) => m[1]!));
// `again` is created by the game-over screen before it is looked up.
present.add('again');
for (const id of asked) {
assert.ok(present.has(id), `main.ts asks for #${id}, which the page does not contain`);
}
});
it('serves all three pages, each stamped and cache-busted', () => {
// The splash is the front door now; the game and the replay directory are separate pages.
for (const [name, entry] of [
['index.html', 'splash'],
['play.html', 'main'],
['replays.html', 'replays'],
] as const) {
const html = readFileSync(join(dist, name), 'utf8');
assert.match(html, new RegExp(`src="\\./web/${entry}\\.js\\?v=`), `${name} is not cache-busted`);
assert.doesNotMatch(html, /__BUILD__/, `${name} was published without a build stamp`);
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), `${name} fetches something external`);
}
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(splash, /href="\.\/play\.html"/, 'the splash does not link to the game');
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
});
it('serves a page that loads the game as a module', () => {
const html = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
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}`);
}
});
});