Compare commits
7
Commits
d261ad7af8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e23d81eade | ||
|
|
f52ff0e9ac | ||
|
|
f00255ce31 | ||
|
|
d1f689d4fc | ||
|
|
dd300ac154 | ||
|
|
2eca9de09f | ||
|
|
160190da3f |
+183
@@ -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 3–4) 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.
|
||||
@@ -24,15 +24,24 @@ single browser.
|
||||
|
||||
```
|
||||
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/
|
||||
│ ├── 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/
|
||||
│ └── 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/
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
13–18% 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 2–5 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
@@ -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
|
||||
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
@@ -9,7 +9,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/**/*.test.ts"
|
||||
"test": "node --test test/**/*.test.ts",
|
||||
"build:web": "node scripts/build-web.ts",
|
||||
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
|
||||
"deploy:web": "node scripts/deploy-web.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.2",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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`,
|
||||
);
|
||||
@@ -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
@@ -33,9 +33,9 @@ import {
|
||||
} from './content.ts';
|
||||
import type { Direction } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import { areaOf } from './apply.ts';
|
||||
import { acceptsCar, areaOf } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, TrayId } from './state.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurn, totalRevenue } from './state.ts';
|
||||
|
||||
export type AdvanceResult = {
|
||||
@@ -295,10 +295,9 @@ function trainNeedingCars(s: GameState): TrayId | null {
|
||||
if (tray.consist.length >= want) continue;
|
||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||
// the yard is potentially suitable unless the card narrows it.
|
||||
const allowed = profile.consist.freightTypes;
|
||||
const suitable = s.yards.divisionYard.some(
|
||||
(c) => c.type === 'caboose' || c.type === 'coach' || !allowed || allowed.includes(c.type),
|
||||
);
|
||||
// Ask the SAME predicate `check` uses. A separate copy of this test stalled the game: the phase
|
||||
// believed a car could be added while check rejected every option, so the Stage never ended.
|
||||
const suitable = s.yards.divisionYard.some((c) => acceptsCar(tray, c.type));
|
||||
if (suitable) return id;
|
||||
}
|
||||
return null;
|
||||
@@ -631,7 +630,7 @@ function arriveAtOffice(
|
||||
});
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -648,7 +647,7 @@ function arriveAtOffice(
|
||||
// is not expecting them (§A.4), so this is a collision too, not a coupling.
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -673,10 +672,22 @@ function collide(
|
||||
trains: TrayId[],
|
||||
events: GameEvent[],
|
||||
reason: string,
|
||||
where = 'the Office',
|
||||
): 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) {
|
||||
const tray = s.trays.get(id);
|
||||
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.
|
||||
for (const car of tray.consist) {
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
||||
@@ -686,6 +697,10 @@ function collide(
|
||||
s.freeTrays.push(id);
|
||||
}
|
||||
|
||||
if (lost.length > 0) {
|
||||
events.push({ type: 'trainsDestroyed', player: faultPlayer, trains: lost, reason, where });
|
||||
}
|
||||
|
||||
s.collisionsToday += 1;
|
||||
const player = s.players[faultPlayer];
|
||||
if (player) {
|
||||
|
||||
+129
-9
@@ -27,12 +27,14 @@ import {
|
||||
modifierProfile,
|
||||
nextOfficeTier,
|
||||
officeProfile,
|
||||
trainProfile,
|
||||
} from './content.ts';
|
||||
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import type { Intent, RejectionCode } from './intents.ts';
|
||||
import type {
|
||||
CardId,
|
||||
CrewTray,
|
||||
Facility,
|
||||
GameState,
|
||||
GridCoord,
|
||||
@@ -468,7 +470,15 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
|
||||
return 'NO_SUITABLE_CAR';
|
||||
}
|
||||
return null;
|
||||
|
||||
// §8.2 — "the train must be in the order listed on the train's card (engine on the front,
|
||||
// Rolling Stock, and possibly a Caboose). It may depart with FEWER Rolling Stock than listed,
|
||||
// but not out of order."
|
||||
//
|
||||
// This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
|
||||
// Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
|
||||
// no caboose. Fewer is allowed; more, or of the wrong category, is not.
|
||||
return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR';
|
||||
}
|
||||
|
||||
case 'newTrain.secondSection': {
|
||||
@@ -613,8 +623,15 @@ function checkPlay(
|
||||
// nothing, which is exactly the bug that made Modifiers dead weight for weeks.
|
||||
return 'NOT_IMPLEMENTED';
|
||||
|
||||
case 'timetabledTrain':
|
||||
case 'extraTrain':
|
||||
// A train card goes to the TIMETABLE, not onto the board. Accepting a placement made the UI
|
||||
// offer the same play at six different grid squares with six rotations apiece — all identical,
|
||||
// because the placement was then ignored. Same reasoning as the Office upgrade above: silently
|
||||
// discarding a placement makes the event log claim the card was laid somewhere it was not.
|
||||
return placement ? 'NO_PLACEMENT' : null;
|
||||
|
||||
default:
|
||||
// Train cards go to the timetable; the phase driver schedules them.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -672,7 +689,26 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
},
|
||||
];
|
||||
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;
|
||||
}
|
||||
@@ -975,10 +1011,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'carsCoupled': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
|
||||
tray.consist.push(...e.stock);
|
||||
// Every card along the path is cleared; the event carries what was taken.
|
||||
for (const card of area.grid.values()) {
|
||||
if (card.standing.length > 0) card.standing = [];
|
||||
if (e.toNose) tray.consist.unshift(...e.stock);
|
||||
else tray.consist.push(...e.stock);
|
||||
// ONLY the cards the crew ran over. Clearing the whole grid emptied industry tracks the crew
|
||||
// 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 = [];
|
||||
}
|
||||
break;
|
||||
@@ -1309,6 +1349,7 @@ function protoCard(
|
||||
kind: 'track',
|
||||
geometry,
|
||||
...(v.axis ? { axis: v.axis } : {}),
|
||||
...(v.arc ? { arc: v.arc } : {}),
|
||||
...(v.turnout ? { turnout: v.turnout } : {}),
|
||||
...(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. */
|
||||
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
|
||||
@@ -1487,6 +1540,7 @@ function protoTrackCard(
|
||||
kind: 'track',
|
||||
geometry,
|
||||
...(v.axis ? { axis: v.axis } : {}),
|
||||
...(v.arc ? { arc: v.arc } : {}),
|
||||
...(v.turnout ? { turnout: v.turnout } : {}),
|
||||
...(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 — "when you extend your Running Track, the Limits sign MOVES outwards with it".
|
||||
*
|
||||
* The sign is a physical card, not just a recorded column. Moving only the coordinate left the
|
||||
* Limits card stranded mid-track: a district grew to
|
||||
* (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2)=Freight House …
|
||||
* with everything beyond (0,-2) built OUTSIDE a sign that never moved. That is not cosmetic —
|
||||
* §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
|
||||
* an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
|
||||
*/
|
||||
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||
if (placed.row !== area.runningRow) return;
|
||||
if (placed.col <= area.limitsWest.col) area.limitsWest = { row: placed.row, col: placed.col - 1 };
|
||||
if (placed.col >= area.limitsEast.col) area.limitsEast = { row: placed.row, col: placed.col + 1 };
|
||||
|
||||
if (placed.col <= area.limitsWest.col) {
|
||||
area.limitsWest = { row: placed.row, col: placed.col - 1 };
|
||||
area.grid.set(coordKey(area.limitsWest), limitsCard());
|
||||
}
|
||||
if (placed.col >= area.limitsEast.col) {
|
||||
area.limitsEast = { row: placed.row, col: placed.col + 1 };
|
||||
area.grid.set(coordKey(area.limitsEast), limitsCard());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.2 — may this car be coupled onto this train right now?
|
||||
*
|
||||
* "The train must be in the order listed on the train's card (engine on the front, Rolling Stock,
|
||||
* and possibly a Caboose). It may depart with FEWER Rolling Stock than listed, but not out of
|
||||
* order." Fewer is allowed; more, or of the wrong category, is not.
|
||||
*
|
||||
* SHARED. Both `check` and the New Train Phase's "is there a suitable car in the yard" test call
|
||||
* this. A second copy stalled the game outright: the phase believed a car could be added while
|
||||
* `check` rejected every option, so the Stage never completed.
|
||||
*/
|
||||
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!profile) return true;
|
||||
|
||||
const cat = (t: CarType): 'coach' | 'caboose' | 'freight' =>
|
||||
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
||||
const adding = cat(carType);
|
||||
const already = tray.consist.filter((c) => cat(c.type) === adding).length;
|
||||
const allowed =
|
||||
adding === 'coach'
|
||||
? profile.consist.coach
|
||||
: adding === 'caboose'
|
||||
? profile.consist.caboose
|
||||
: profile.consist.freight;
|
||||
if (already >= allowed) return false;
|
||||
|
||||
// The caboose rides last (§A.3), so nothing may be coupled behind one.
|
||||
if (adding !== 'caboose' && tray.consist.some((c) => c.type === 'caboose')) return false;
|
||||
|
||||
// The card also narrows WHICH freight types it will take.
|
||||
const types = profile.consist.freightTypes;
|
||||
if (adding === 'freight' && types && !types.includes(carType)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
|
||||
function limitsCard(): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+20
-1
@@ -22,7 +22,19 @@ export type GameEvent =
|
||||
// -- local operations
|
||||
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
|
||||
| { 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: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { 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: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
||||
| { 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: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier }
|
||||
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number }
|
||||
|
||||
+5
-1
@@ -252,7 +252,11 @@ function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
|
||||
// Q7 — a district hangs BELOW the Running Track; there is nothing above it.
|
||||
if (c.row > area.runningRow) continue;
|
||||
const k = `${c.row},${c.col}`;
|
||||
if (area.grid.has(k) || seen.has(k)) continue;
|
||||
if (seen.has(k)) continue;
|
||||
// The Limits signs are candidates even though they are occupied: laying track there is how
|
||||
// the Running Track grows, and the sign moves outward (§2.1). `check` still has the final say.
|
||||
const occupied = area.grid.get(k);
|
||||
if (occupied && !(occupied.geometry.kind === 'limits' && c.row === area.runningRow)) continue;
|
||||
seen.add(k);
|
||||
out.push(c);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* 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 CardGeometry =
|
||||
@@ -66,6 +73,8 @@ export type CardGeometry =
|
||||
geometry: TrackGeometry;
|
||||
turnout?: TurnoutOrientation;
|
||||
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. */
|
||||
hand?: 'left' | 'right';
|
||||
}
|
||||
|
||||
+46
-15
@@ -23,6 +23,7 @@ import type {
|
||||
RollingStock,
|
||||
TrackCard,
|
||||
TrayId,
|
||||
TrackArc,
|
||||
TurnoutOrientation,
|
||||
} 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
|
||||
* 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) {
|
||||
// Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter.
|
||||
case 'modifier':
|
||||
@@ -108,18 +114,22 @@ function connectionsFor(card: TrackCard): readonly PortPair[] {
|
||||
[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
|
||||
// same but costs two Moves to cross.
|
||||
// Curves and turnouts diverge DOWNWARD from the through track (Q7); handedness decides
|
||||
// which end of the through track the leg leaves from, not whether it goes up or down.
|
||||
/**
|
||||
* A CURVE IS AN ARC between two adjacent edges — one connection, no through track. The
|
||||
* printed cards show exactly this (docs/tracks.png, rows 3-4): a single sweep from edge to
|
||||
* edge with nothing running past it.
|
||||
*
|
||||
* 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 'sharpCurved': {
|
||||
const stem = card.geometry.hand === 'left' ? 'w' : 'e';
|
||||
return [
|
||||
['e', 'w'],
|
||||
[stem, 's'],
|
||||
];
|
||||
const arc = card.geometry.arc ?? (card.geometry.hand === 'left' ? 'sw' : 'se');
|
||||
return [[arc[0] as Port, arc[1] as Port]];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* rotation is free.
|
||||
*/
|
||||
const ARCS: readonly TrackArc[] = ['ne', 'nw', 'se', 'sw'];
|
||||
|
||||
export type TrackVariant = {
|
||||
arc?: TrackArc;
|
||||
axis?: 'ew' | 'ns';
|
||||
turnout?: TurnoutOrientation;
|
||||
bypass?: Port;
|
||||
@@ -167,9 +180,9 @@ export function variantsFor(geometry: TrackGeometry): TrackVariant[] {
|
||||
return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
|
||||
case 'curved':
|
||||
case 'sharpCurved':
|
||||
// Handedness is printed on the card, and the leg always goes down (Q7), so nothing is left
|
||||
// to choose — one variant.
|
||||
return [{ axis: 'ew' }];
|
||||
// All four rotations. 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.
|
||||
return ARCS.map((arc) => ({ arc }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +343,25 @@ export function allReachable(
|
||||
* stubs above and below so this is satisfiable from the opening Stage.
|
||||
*/
|
||||
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
|
||||
if (cardAt(area, coord)) return false;
|
||||
const existing = cardAt(area, coord);
|
||||
// A Limits sign on the Running Track is the GROWTH POINT, not an obstacle. Extending means laying
|
||||
// the card where the sign stands and moving the sign outward (§2.1, Gap 4a) — the physical act at
|
||||
// the table. Treating the sign as occupied forced players to build PAST it, stranding the sign
|
||||
// mid-track with everything beyond it nominally outside their own Limits.
|
||||
const isMovableSign =
|
||||
existing?.geometry.kind === 'limits' &&
|
||||
coord.row === area.runningRow &&
|
||||
existing.standing.length === 0;
|
||||
if (existing && !isMovableSign) return false;
|
||||
|
||||
// §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'];
|
||||
for (const p of ports) {
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
@@ -19,13 +19,14 @@
|
||||
* 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 type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { variantsFor } from '../engine/track.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 = {
|
||||
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 = {
|
||||
name: 'developer',
|
||||
|
||||
choose(s, player, options) {
|
||||
lastReason = 'no specific reason — first legal option';
|
||||
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
|
||||
// collision is actually pending, and that moment passes.
|
||||
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.
|
||||
//
|
||||
@@ -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.
|
||||
// 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.
|
||||
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.
|
||||
@@ -102,9 +124,9 @@ export const developerBot: BotPolicy = {
|
||||
const match = options.find(
|
||||
(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.
|
||||
@@ -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
|
||||
// collision, and collisions are the largest single drain on revenue.
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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)) {
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
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.
|
||||
if (can('draw')) return can('draw')!;
|
||||
return can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!;
|
||||
if (can('draw')) return because('nothing urgent — develop the district instead', can('draw')!);
|
||||
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. */
|
||||
@@ -260,17 +290,83 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
|
||||
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = s.officeAreas.get(player);
|
||||
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 bestScore = -Infinity;
|
||||
|
||||
for (const i of options) {
|
||||
if (i.type !== 'track.lay') continue;
|
||||
const dRow = Math.abs(i.placement.row - area.officeCoord.row);
|
||||
const dCol = Math.abs(i.placement.col - area.officeCoord.col);
|
||||
const { row, col } = i.placement;
|
||||
const dRow = Math.abs(row - area.officeCoord.row);
|
||||
const dCol = Math.abs(col - area.officeCoord.col);
|
||||
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 (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = i;
|
||||
@@ -317,6 +413,24 @@ function worthFlagging(s: GameState, options: Intent[]): Intent | 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
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** 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? */
|
||||
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
|
||||
const area = areaOf(s, player);
|
||||
@@ -376,11 +524,11 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
const useful = options.find(
|
||||
(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');
|
||||
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');
|
||||
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
|
||||
// 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(
|
||||
(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.
|
||||
const train = options.find(
|
||||
(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
|
||||
// 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(
|
||||
(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');
|
||||
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
|
||||
// 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 &&
|
||||
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(
|
||||
(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
|
||||
// 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
|
||||
// turns and could legally place one on 33.
|
||||
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.
|
||||
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');
|
||||
if (play) return play;
|
||||
if (play) return because('play what is in hand', play);
|
||||
const end = options.find((i) => i.type === 'draw.end');
|
||||
if (end) return end;
|
||||
return pickFirst(options, 'card.discard') ?? options[0]!;
|
||||
if (end) return because('nothing in hand can be played anywhere legal', end);
|
||||
return because('nothing playable and nothing to draw — discard to a Department slot', pickFirst(options, 'card.discard') ?? options[0]!);
|
||||
}
|
||||
|
||||
case 'switch': {
|
||||
@@ -457,7 +605,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
i.count === 1 &&
|
||||
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)) {
|
||||
// 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
|
||||
// track, and is unavailable for the next load.
|
||||
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');
|
||||
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.
|
||||
@@ -499,7 +647,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
const sort = options.find(
|
||||
(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.
|
||||
if (hereFacility && facilityWants(hereFacility, endCar)) {
|
||||
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.
|
||||
//
|
||||
// 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;
|
||||
if (isSpur) {
|
||||
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(
|
||||
(i) =>
|
||||
@@ -546,7 +729,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
i.to.row !== area.runningRow &&
|
||||
!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
|
||||
@@ -562,7 +745,7 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
i.type === 'switch.move' &&
|
||||
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
|
||||
@@ -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
|
||||
// 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);
|
||||
if (goHome) return goHome;
|
||||
return options.find((i) => i.type === 'switch.end') ?? options[0]!;
|
||||
if (goHome) return because('nothing productive left — head for the Office rather than shuttle aimlessly', goHome);
|
||||
return because('out of useful switching moves', options.find((i) => i.type === 'switch.end') ?? options[0]!);
|
||||
}
|
||||
|
||||
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.
|
||||
if (hasStuckLoad(s, player)) {
|
||||
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');
|
||||
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');
|
||||
if (stock) return stock;
|
||||
return pickFirst(options, 'freightAgent.unjam') ?? options[0]!;
|
||||
if (stock) return because('stock a green box so a Laborer has work next Stage', stock);
|
||||
return because('nothing productive at any facility — clear whatever is stuck', pickFirst(options, 'freightAgent.unjam') ?? options[0]!);
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
+41
-4
@@ -34,6 +34,9 @@ export function clockTime(stage: number): string {
|
||||
}
|
||||
|
||||
export function carLabel(c: RollingStock): string {
|
||||
// A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even
|
||||
// though the supply marks every caboose loaded. Name it plainly.
|
||||
if (c.type === 'caboose') return 'caboose';
|
||||
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
|
||||
}
|
||||
|
||||
@@ -84,10 +87,17 @@ export type Narration = {
|
||||
export type NarrateContext = {
|
||||
/** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */
|
||||
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 {
|
||||
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
|
||||
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
|
||||
|
||||
switch (e.type) {
|
||||
// -- clock
|
||||
@@ -122,7 +132,9 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'plain',
|
||||
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':
|
||||
return {
|
||||
@@ -284,17 +296,42 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
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.`,
|
||||
};
|
||||
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':
|
||||
return {
|
||||
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':
|
||||
return {
|
||||
tone: e.allow ? 'bad' : 'plain',
|
||||
text: e.allow
|
||||
? `Clearance GRANTED to ${e.trainId} — it follows into an occupied Subdivision`
|
||||
: `Clearance DENIED to ${e.trainId} — it holds where it is`,
|
||||
? `Clearance GRANTED — ${train(e.trainId)} follows into the occupied Subdivision`
|
||||
: `Clearance DENIED — ${train(e.trainId)} holds where it is`,
|
||||
};
|
||||
|
||||
// -- passengers
|
||||
|
||||
+131
-287
@@ -23,263 +23,25 @@ import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from
|
||||
import type { GameLength } from '../engine/content.ts';
|
||||
import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.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 } from '../engine/state.ts';
|
||||
import { developerBot } from './bot.ts';
|
||||
import type { Impediment } from './narrate.ts';
|
||||
import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } from './narrate.ts';
|
||||
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
||||
import { developerBot, lastChoiceReason } from './bot.ts';
|
||||
import { carLabel, idleNote, isVisible, narrate } from './narrate.ts';
|
||||
// The view-model lives in its own module so the browser build can import it without dragging in
|
||||
// this file's Node dependencies. Re-exported because tests and the web app import it from here.
|
||||
export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
|
||||
export { cardName, describeIntent, snapshot } from './view.ts';
|
||||
import type { Frame } from './view.ts';
|
||||
import type { Decision } from './view.ts';
|
||||
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
|
||||
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from './board-svg.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame shape — only what the viewer draws
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CellView = {
|
||||
row: number;
|
||||
col: number;
|
||||
kind: string;
|
||||
label: string;
|
||||
running: boolean;
|
||||
tray: string | null;
|
||||
cars: string[];
|
||||
facility: FacilityView | null;
|
||||
};
|
||||
|
||||
export type FacilityView = {
|
||||
name: string;
|
||||
commodity: string;
|
||||
flow: string;
|
||||
green: string[];
|
||||
greenCap: number;
|
||||
maw: (string | null)[];
|
||||
red: string[];
|
||||
redCap: number;
|
||||
track: string[];
|
||||
trackCap: number;
|
||||
laborers: string;
|
||||
porters: string;
|
||||
};
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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 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.
|
||||
let didSomething = false;
|
||||
// The choice that produced the events about to be pushed.
|
||||
let pendingDecision: Decision | null = null;
|
||||
|
||||
const push = (events: GameEvent[]): void => {
|
||||
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 moved = visible.find((e) => e.type === 'trayMoved');
|
||||
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(
|
||||
@@ -345,8 +121,11 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
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;
|
||||
pendingDecision = decision;
|
||||
push(applied.events);
|
||||
}
|
||||
|
||||
@@ -363,6 +142,27 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
// HTML
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Drop repeated board state from the serialised frames.
|
||||
*
|
||||
* Only the wire format changes: `record()` still returns complete frames, so tests and any other
|
||||
* consumer are unaffected. The page resolves a null by walking back to the last frame that carried
|
||||
* the field.
|
||||
*/
|
||||
function compress(frames: Frame[]): unknown[] {
|
||||
const keys = ['cells', 'facilities', 'division'] as const;
|
||||
let prev: Record<string, string> = {};
|
||||
return frames.map((f, i) => {
|
||||
const out: Record<string, unknown> = { ...f };
|
||||
for (const k of keys) {
|
||||
const json = JSON.stringify(f[k]);
|
||||
if (i > 0 && prev[k] === json) out[k] = null;
|
||||
prev[k] = json;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderHtml(rec: Recording): string {
|
||||
const target = lengthProfile(rec.length);
|
||||
return `<!doctype html>
|
||||
@@ -370,6 +170,8 @@ export function renderHtml(rec: Recording): string {
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Station Master — replay seed ${rec.seed}</title>
|
||||
<style>
|
||||
${BOARD_CSS}
|
||||
|
||||
: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}
|
||||
*{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}
|
||||
.cell.office{border-color:var(--clock)}
|
||||
.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>
|
||||
|
||||
<header>
|
||||
@@ -455,6 +272,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
@@ -468,7 +286,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
<div class="legend">
|
||||
<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>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;
|
||||
<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
|
||||
@@ -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="money">next revenue 💰</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">
|
||||
<select id="speed">
|
||||
<option value="3000">extra slow</option>
|
||||
@@ -496,7 +316,20 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
</footer>
|
||||
|
||||
<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;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
||||
@@ -543,41 +376,23 @@ function render() {
|
||||
$('fno').textContent = i;
|
||||
$('scrub').value = i;
|
||||
|
||||
$('division').innerHTML = f.division.map((n) =>
|
||||
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) + '</div>' +
|
||||
'<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 CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
||||
|
||||
const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col);
|
||||
const r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols);
|
||||
const g = $('grid');
|
||||
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
|
||||
let cellsHtml = '';
|
||||
for (let r = r1; r >= r0; r--) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const cell = f.cells.find((x) => x.row === r && x.col === c);
|
||||
if (!cell) { cellsHtml += '<div></div>'; continue; }
|
||||
const hl = f.where && f.where.row === r && f.where.col === c;
|
||||
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
|
||||
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>';
|
||||
}
|
||||
$('division').innerHTML = divisionSvg(DIV);
|
||||
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow);
|
||||
if (f.where) {
|
||||
const hit = $('grid').querySelector('g[data-cell="' + f.where.row + ',' + f.where.col + '"]');
|
||||
if (hit) hit.classList.add('bs-focus');
|
||||
}
|
||||
if (f.whereFrom) {
|
||||
const was = $('grid').querySelector('g[data-cell="' + f.whereFrom.row + ',' + f.whereFrom.col + '"]');
|
||||
if (was) was.classList.add('bs-from');
|
||||
}
|
||||
g.innerHTML = cellsHtml;
|
||||
|
||||
$('facs').innerHTML = f.facilities.length === 0
|
||||
$('facs').innerHTML = FACS.length === 0
|
||||
? '<span class="dim">no freight facilities built yet</span>'
|
||||
: f.facilities.map((x) =>
|
||||
: FACS.map((x) =>
|
||||
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
|
||||
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
|
||||
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
|
||||
@@ -585,9 +400,34 @@ function render() {
|
||||
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">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('');
|
||||
|
||||
$('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
|
||||
? '<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('');
|
||||
@@ -604,7 +444,7 @@ function render() {
|
||||
}
|
||||
|
||||
function go(n) { i = Math.max(0, Math.min(FRAMES.length - 1, n)); render(); }
|
||||
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1])) return go(k); go(FRAMES.length - 1); }
|
||||
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1], k)) return go(k); go(FRAMES.length - 1); }
|
||||
|
||||
$('ftot').textContent = FRAMES.length - 1;
|
||||
$('scrub').max = FRAMES.length - 1;
|
||||
@@ -615,6 +455,8 @@ $('fwd').onclick = () => go(i + 1);
|
||||
$('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p.day);
|
||||
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
|
||||
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
|
||||
$('waste').onclick = () => findNext((f) => f.wasted);
|
||||
$('jam').onclick = () => findNext((f, p, idx) => carry(idx, 'facilities').some((x) => x.jammed));
|
||||
$('play').onclick = () => {
|
||||
if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
|
||||
$('play').textContent = '⏸ pause';
|
||||
@@ -632,6 +474,8 @@ document.onkeydown = (e) => {
|
||||
else if (e.key === 's' || e.key === 'S') $('stage').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 === 'w' || e.key === 'W') $('waste').click();
|
||||
else if (e.key === 'j' || e.key === 'J') $('jam').click();
|
||||
else if (e.key === ' ') { e.preventDefault(); $('play').click(); }
|
||||
};
|
||||
render();
|
||||
|
||||
+763
@@ -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, north–south' : 'through track, east–west';
|
||||
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' ? ' — east–west' : ' — north–south';
|
||||
if (v.turnout) {
|
||||
const dir = (p: string): string =>
|
||||
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
|
||||
return ` — stem ${dir(v.turnout.stem)}, through ${dir(v.turnout.through)}, diverges ${dir(v.turnout.diverge)}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A track shape in words. One place, because it is written on the board, in the action buttons and
|
||||
* in the supply list — and `sharpCurved` was reaching the screen raw in two of the three.
|
||||
*/
|
||||
export function geometryLabel(geometry: string): string {
|
||||
switch (geometry) {
|
||||
case 'sharpCurved':
|
||||
return 'sharp curve';
|
||||
case 'curved':
|
||||
return 'curve';
|
||||
case 'turnout':
|
||||
return 'turnout';
|
||||
case 'straight':
|
||||
return 'straight';
|
||||
default:
|
||||
return prettyKey(geometry);
|
||||
}
|
||||
}
|
||||
|
||||
/** camelCase key → readable name, for the card categories that carry only a key. */
|
||||
function prettyKey(key: string): string {
|
||||
return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
const MODIFIER_NAMES: Record<string, string> = {
|
||||
teamTrack: 'Team Track',
|
||||
loadingDock: 'Loading Dock',
|
||||
storageShed: 'Storage Shed',
|
||||
extraPlatform: 'Extra Platform',
|
||||
sectionGang: 'Section Gang',
|
||||
};
|
||||
|
||||
/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
|
||||
function trainChip(s: GameState, id: string): TrainChip {
|
||||
const t = s.trays.get(id);
|
||||
if (!t) return { label: id, consist: [] };
|
||||
return {
|
||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
consist: t.consist.map(carLabel),
|
||||
};
|
||||
}
|
||||
|
||||
+336
@@ -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;
|
||||
}
|
||||
@@ -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–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 — 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 →</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 →</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="rule"></div>
|
||||
|
||||
<footer>
|
||||
<span>build <span id="build">__BUILD__</span></span>
|
||||
<span>runs entirely in your browser — nothing is sent anywhere</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./web/splash.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+355
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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();
|
||||
@@ -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>
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
`;
|
||||
@@ -412,3 +412,125 @@ describe('card coverage', () => {
|
||||
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
@@ -36,7 +36,7 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'actorChanged', player: 0 },
|
||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||
{ 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: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||
{ 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', () => {
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
@@ -207,7 +207,7 @@ describe('replay HTML', () => {
|
||||
|
||||
it('wires a handler to every control', () => {
|
||||
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, /\$\('scrub'\)\.oninput/, 'no handler bound to scrub');
|
||||
@@ -233,6 +233,65 @@ describe('replay HTML', () => {
|
||||
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', () => {
|
||||
const mb = Buffer.byteLength(html) / 1024 / 1024;
|
||||
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
|
||||
|
||||
+19
-6
@@ -276,15 +276,28 @@ describe('switching accomplishes something (regression)', () => {
|
||||
assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`);
|
||||
});
|
||||
|
||||
it('does not spend its whole Local Operations budget on motion', () => {
|
||||
// The absolute ratio is no longer a fair test: with only 9 industries in 115 cards there is
|
||||
// often nothing to switch, so `work` is legitimately near zero. What still must hold is that
|
||||
// the crew is not burning all six Moves every Stage — the shuttling bug.
|
||||
it('does not shuttle aimlessly — Moves have to buy something', () => {
|
||||
// Counting Moves alone stopped being a fair test once the crew could do real work. Run-arounds
|
||||
// and sidings are SUPPOSED to cost several Moves each: the crew leaves the main, runs the loop,
|
||||
// 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({
|
||||
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;
|
||||
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
|
||||
const per = (f: (g: (typeof report.perGame)[number]) => number): number =>
|
||||
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)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type {
|
||||
GameState,
|
||||
GridCoord,
|
||||
OfficeArea,
|
||||
RollingStock,
|
||||
@@ -14,6 +15,8 @@ import type {
|
||||
TurnoutOrientation,
|
||||
} 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 {
|
||||
allReachable,
|
||||
@@ -24,6 +27,8 @@ import {
|
||||
neighbour,
|
||||
opposite,
|
||||
reachableDestinations,
|
||||
connectionsFor,
|
||||
variantsFor,
|
||||
} from '../src/engine/track.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -93,6 +98,27 @@ const lockedFacility = (): TrackCard => ({
|
||||
|
||||
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 {
|
||||
const grid = new Map<string, TrackCard>();
|
||||
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)));
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user