Compare commits
5
Commits
401b879140
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd300ac154 | ||
|
|
2eca9de09f | ||
|
|
160190da3f | ||
|
|
d261ad7af8 | ||
|
|
b085d5a0bb |
+241
-3
@@ -50,14 +50,252 @@ All nine answers are implemented, **170 tests passing**:
|
||||
| Q8 Crew Tray cap | Already correct. |
|
||||
| Q9 Second Section | `newTrain.secondSection` plays on a train due out; an identical train is made up behind it next New Train Phase. The **Sister trains optional rule is removed** from Appendix B. |
|
||||
|
||||
**Still not implemented**, and now the largest remaining block: the behaviour of the Enhancement
|
||||
(18), Mainline-modifier (7) and Maneuver (7) cards, plus the Action (10) and Space-use (12) cards in
|
||||
multiplayer. All are in the deck; playing them is rejected with `NOT_IMPLEMENTED`.
|
||||
**Enhancements (18) are now implemented** — placement rules, Small Yard consist re-ordering, ABS
|
||||
Signals, Yard Office diversion, Interlocking, the Telegraph/Telephone/Radio dispatch bonuses and the
|
||||
three defensive cards, covered by `test/enhancements.test.ts`.
|
||||
|
||||
**Mainline modifiers (7) and Maneuvers (6 of 7) are now implemented**, covered by
|
||||
`test/mainline-cards.test.ts`. They are *not* multiplayer-only — that was a mistaken impression
|
||||
given earlier. Brakeman, Airbrakes, Helpers and Realignment all change your own crossing times, and
|
||||
Red Flags prevents a rear-ender, which happens in solitaire too. The real reason they lagged was
|
||||
sequencing: Enhancements were done first because they change core loops.
|
||||
|
||||
| Card | Where it lives | Effect |
|
||||
| --- | --- | --- |
|
||||
| Brakeman, Airbrakes | `mainline.modify` on a grade | −1 Stage downhill each; Airbrakes needs Brakeman on the card |
|
||||
| Helpers | `mainline.modify` on a grade | −1 Stage uphill |
|
||||
| Realignment ×2 | `mainline.modify` on any Mainline card | converts per `REALIGNMENTS`; barred while a train is on the card |
|
||||
| Facing Point Locks ×2 | grid, as the Enhancement | the sheet prints this card in **both** lists — one card, not two |
|
||||
| Red Flags ×5 | `maneuver.redFlags` on a stopped train | an approaching train is held instead of colliding; flags come in when the train rolls |
|
||||
| Flying Switch | `maneuver.flyingSwitch` during switching | cuts cars into an adjacent industry without the engine entering; costs a Move |
|
||||
| Poling | — | **deliberately unimplemented** |
|
||||
|
||||
Crossing time never falls below one Stage — a train cannot cross in no time.
|
||||
|
||||
**Poling stays unimplemented.** The recovered sheet records its effect as "TBD in the source", so
|
||||
there is nothing to build. A test asserts it remains TBD, to stop anyone "fixing" it by inventing
|
||||
an effect; a silent no-op would be worse than a rejection.
|
||||
|
||||
**Q11, answered from the source.** The Heavy Grade card prints **"(Up)"** and **"Player sets
|
||||
orientation"**, so which way it climbs is a property of the placed card, not a fixed compass
|
||||
direction. `DivisionNode.gradeUp` records the direction a train travels when **climbing**; a train
|
||||
going the other way is descending. Brakeman and Airbrakes apply to the descent, Helpers to the
|
||||
climb, so turning the card around swaps which trains each card helps.
|
||||
|
||||
One gap remains, and it is a **setup** gap rather than a rules one: `createGame` is synchronous and
|
||||
returns a ready state, so there is no point at which a player can be asked. Orientation is currently
|
||||
**rolled** — deterministic from the seed, and roughly even (51/49 east/west across 400 games) — and
|
||||
should become a real player choice when setup gains an interactive phase.
|
||||
|
||||
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
|
||||
multiplayer-only. Playing them is rejected with `NOT_IMPLEMENTED`.
|
||||
|
||||
### Parking trains — two engine bugs and a bot defect
|
||||
|
||||
Trains reached an Office and never left: **668 arrivals against 515 departures**. A train may only
|
||||
highball from the Office square itself (§8.1, Gap 2b); from anywhere else `moveTrain` returns
|
||||
`held`, permanently. 77 of 93 leftover crews were sitting somewhere they could never depart from.
|
||||
|
||||
Three separate causes, found by following the stranded crews:
|
||||
|
||||
1. **Reverse was hardcoded to east/west.** `destinationsFor` computed the backing-out port as
|
||||
`facing === 'e' ? 'w' : 'e'`. A crew facing *south* reversed to *east* — a port a north-south
|
||||
card does not have — so it could never back out of a district spur. Now `opposite(facing)`.
|
||||
2. **Facing was derived from `direction`, which only carries east and west.** A crew standing on a
|
||||
north-south spur was given an exit port its card did not have, leaving it with no legal moves at
|
||||
all. `CrewTray.facing` now records the actual port, updated on every move from the port the crew
|
||||
entered through.
|
||||
3. **The bot never went home,** and its "no useful work" fallback took an *arbitrary* legal move —
|
||||
the same shuttling bug as before, re-entered by a different door. It now heads for the Office
|
||||
whenever nothing productive remains, and a crew stranded away from the Office can claim a whole
|
||||
Local Operations turn to walk back.
|
||||
|
||||
Flying Switch was also mis-modelled: it tested **orthogonal adjacency** when the cut *rolls along
|
||||
the track*, which both allowed a cut to cross to a cell with no rail between and refused a siding
|
||||
two cards along the same track. It made the card nearly unplayable — held for 1,400 turns across 60
|
||||
games and legal on 2. Now the target must be track-connected.
|
||||
|
||||
| | before | after |
|
||||
| --- | --- | --- |
|
||||
| arrivals / departures | 668 / 515 (77%) | **795 / 793 (99.7%)** |
|
||||
| leftover crews stranded off the Office | 77 of 93 | 24 of 53 |
|
||||
| collisions per game | 0.2 | **0.7** |
|
||||
| revenue per player | 1.0 | **−1.1** |
|
||||
|
||||
**Revenue got worse, and that is the honest result.** Parked trains were not colliding. With trains
|
||||
circulating properly, every collision is now `no free A/D track` — 35 of 35 across 60 games — and
|
||||
collisions cost **−3.3** of the −1.1 net. The previous figure was flattered by a bug.
|
||||
|
||||
### A/D congestion is not congestion — it is the Whistle Post
|
||||
|
||||
Chasing it found no congestion at all. The Office is **empty at 88% of Stage starts** (occupancy 0
|
||||
at 3,136 of 3,600) and mean dwell on an A/D track is **1.4 Stages**. Collisions are bursts, and they
|
||||
are concentrated in one place:
|
||||
|
||||
- **25 of 26 collisions happen at Whistle Post**, which has **one** A/D track, so any second arrival
|
||||
is an automatic collision (§8.3, Gap 2a). One collision occurred at Depot; none above it.
|
||||
- **No collision was ever recorded in a district holding an Interlocking.** The enhancement is a
|
||||
complete cure — it just arrives too late and too rarely.
|
||||
- **25 of 100 games never escape Whistle Post at all**, and those are exactly the games that never
|
||||
drew a Depot card. Mean first upgrade is Stage **9.3 of 60**.
|
||||
|
||||
The damage is concentrated, not spread:
|
||||
|
||||
| | games | mean revenue | worst |
|
||||
| --- | --- | --- | --- |
|
||||
| never upgraded past Whistle Post | 25 | **−6.0** | −54 |
|
||||
| upgraded at least once | 75 | **−0.4** | +9 (best) |
|
||||
|
||||
That is what makes the median **+1.0** while the mean is **−1.0**: a quarter of games are ruined by
|
||||
a draw that never comes. The bot now plays an Office upgrade ahead of everything else and prefers
|
||||
Interlocking over other Enhancements; both were previously unranked. Neither moves the mean, because
|
||||
the constraint is **draw luck, not policy** — escaping Whistle Post requires one of **4 Depot cards
|
||||
in 133**.
|
||||
|
||||
### Q12 answered — office density doubled
|
||||
|
||||
**Players always start at a Whistle Post**; that is settled and not up for change. The lever is
|
||||
therefore card density. Office `copiesInDeck` is **doubled**: Depot 4→8, Station 2→4, Terminal 1→2.
|
||||
Offices go 7 → 14 and the deck 133 → **140** (solitaire 111 → **118**).
|
||||
|
||||
Station and Terminal were doubled alongside Depot rather than lifting Depot alone, because upgrades
|
||||
are strictly sequential (Gap 3b): the ladder has to stay a pyramid or players reach a rung whose
|
||||
next card is scarcer than the one that got them there. The test now asserts that **property**
|
||||
instead of the literal counts, so it survives the next density change.
|
||||
|
||||
| | 7 offices / 133 | 14 offices / 140 |
|
||||
| --- | --- | --- |
|
||||
| revenue per player | −1.0 | **0.7** |
|
||||
| games never leaving Whistle Post | 25 | **13** |
|
||||
| mean revenue, games that did upgrade | −0.4 | **1.9** |
|
||||
| collisions per game | 0.7 | **0.3** |
|
||||
| lost to collisions | −3.3 | **−1.6** |
|
||||
| worst game | −48 | −28 |
|
||||
|
||||
All 37 remaining collisions are still at Whistle Post, which confirms the diagnosis rather than
|
||||
undermining it. Note the games that *still* never upgrade got worse (−6.0 → −10.5): they are now a
|
||||
smaller, unluckier group, so the mean is both more extreme and noisier. **Still 0 wins.**
|
||||
|
||||
> **PROVISIONAL — re-evaluate later.** These counts were chosen to remove a 25% chance of an
|
||||
> unwinnable opening deal, not taken from the recovered design, and the instrument is blunt: it
|
||||
> lifts the whole office ladder and dilutes every other category slightly. Revisit once the victory
|
||||
> target is settled and freight carries its intended share. The better answer may turn out to be
|
||||
> fewer Terminals, a cheaper first upgrade, or more A/D capacity at the Whistle Post itself.
|
||||
|
||||
### Measuring, after getting it wrong
|
||||
|
||||
A throwaway probe reported "60 of 60 games never upgraded past Whistle Post" while the Office-tier
|
||||
histogram from the same run plainly showed Depots, Stations and Terminals. Events reach the log from
|
||||
**two** sources — the phase driver (`pump`) and player intents (`applyIntent`) — and the probe
|
||||
watched only the first. Office upgrades arrive through the second.
|
||||
|
||||
The cause was hand-rolling the drive loop. `playGame` already tallied both sources correctly; it was
|
||||
re-implemented because it returns the event log but not the *state at the moment an event fired*,
|
||||
which is what questions like "what tier was the Office at this collision" need.
|
||||
|
||||
`playGame` now takes an optional `PlayObserver` — `(event, state) => void`, called from the single
|
||||
point both sources funnel through. Two tests guard it: one asserts the observer sees **exactly** the
|
||||
events the log records, one asserts office upgrades are visible in the log across 25 games.
|
||||
Reintroducing the original mistake fails both.
|
||||
|
||||
**A wrong measurement is worse than a missing one, because it gets believed and acted on.**
|
||||
|
||||
### Freight throughput — a self-inflicted deadlock
|
||||
|
||||
Freight was 10% of gross revenue with 27 industries on the board, so the Gap 12 tripling had bought
|
||||
facilities but not freight. The pipeline said why: **415 loads started, 413 unjams, 13 completions**
|
||||
across 60 games — a **3%** completion rate, with the Freight Agent spending more than half its
|
||||
actions clearing jams it had caused.
|
||||
|
||||
A load leaves MEN|AT|WORK only onto a **spotted empty car of its own type** (§9.3). Starting one with
|
||||
no car spotted parks it on WORK — and a load on WORK **locks the industry track**, which blocks the
|
||||
very car that would clear it. The bot started a load whenever the intent was legal, so it jammed its
|
||||
own facilities and then spent its turns unjamming them.
|
||||
|
||||
Two fixes, both in the bot; the engine was right throughout.
|
||||
|
||||
- **Only start a load that can finish.** `loadCanFinish` requires the receiving car to be spotted
|
||||
already. Worth recording that the first attempt kept a last-resort `startLoad` "rather than idle a
|
||||
Laborer" and measured *identical* to having no gate at all — the fallback was taken every time. An
|
||||
idle Laborer is strictly better than a jam, which costs an action to clear and locks the track
|
||||
meanwhile.
|
||||
- **Weigh the whole consist, not just the back car.** 744 switch turns produced 135 Moves and 741
|
||||
immediate ends: a crew holding wanted cars behind one unwanted car concluded there was nothing to
|
||||
do. §A.3 makes the back car the only *droppable* one; it does not make it the only one worth
|
||||
travelling for. The crew now sets out dead weight on a plain spur **before** travelling — ordering
|
||||
that matters, because doing it after produced the shuttling loop for the third time.
|
||||
|
||||
| | before | after |
|
||||
| --- | --- | --- |
|
||||
| loads started / completed | 415 / 13 (**3%**) | 44 / 38 (**86%**) |
|
||||
| unjams | 413 | 281 |
|
||||
| cars dropped | 151 | 197 |
|
||||
| cars coupled | 14 | 25 |
|
||||
| unloads completed | 40 | 74 |
|
||||
| **revenue per player** | 0.7 | **2.2** |
|
||||
| cards played | 12.4 | 16.9 |
|
||||
| trains scheduled | 2.0 | 2.7 |
|
||||
| passenger revenue | 1.3 | 2.7 |
|
||||
|
||||
Freeing the Laborers freed the whole turn economy — passenger revenue doubled without a single
|
||||
passenger rule changing. **Still 0 wins against a target of 20**, and freight is still only 13% of
|
||||
gross, so the loop runs now but does not yet carry the game.
|
||||
|
||||
### Track was unplayable (found while wiring up Enhancements)
|
||||
|
||||
Moving track out of the deck into a per-player supply (§12.2) left **no way to play it**. Districts
|
||||
could only grow through Freight-Facility and Modifier cards, so the Running-Track and
|
||||
Secondary-Track *straights* that Enhancements attach to essentially never existed. Two bugs
|
||||
compounded it: `legalActions` offered Enhancements only **empty** grid cells, when an Enhancement
|
||||
attaches to an **occupied** card.
|
||||
|
||||
Measured before the fix: a hand held a playable Enhancement on **4,778** turns and could legally
|
||||
place one on **33** — and those 33 were all ABS Signals, which targets a Mainline card rather than
|
||||
the grid.
|
||||
|
||||
Fixed by adding a `track.lay` intent drawing from `OfficeArea.trackSupply`, and by giving
|
||||
Enhancements the occupied cells as candidates. Enhancement placements went 21 → 98 per 40 games.
|
||||
|
||||
**Q10, answered.** Track is laid during the "draw a card" option, **one piece a turn** — which is
|
||||
how track behaved when it *was* a card.
|
||||
|
||||
### Measurement after the answers
|
||||
|
||||
100 solitaire Standard games: mean revenue **−1.3**, trains scheduled **2.8**, cards played **11.3**.
|
||||
|
||||
After Enhancements and the track fix: mean revenue **0.0**, trains scheduled **2.9**, cards played
|
||||
**13.9**, collisions **0.3**. Still **0 wins against a target of 20**.
|
||||
|
||||
### Gap 12 resolved — industry density
|
||||
|
||||
The shortfall was structural rather than a missing rule. Freight was 10% of gross revenue and
|
||||
`carsCoupled` fired **4 times in 100 games**: the chain needs a Freight Facility, an empty car of
|
||||
its exact type spotted on its industry track, three Laborer advances, and then a crew with spare
|
||||
capacity to lift the load — and there were only **1.6 freight facilities per game**, because the
|
||||
recovered sheet puts **9 industries in a 115-card deck** where the prototype had 10 in 52.
|
||||
|
||||
**Every industry's `copies` is tripled: 9 → 27, deck 115 → 133** (solitaire 93 → 111). Tripling
|
||||
uniformly restores roughly the prototype ratio while preserving the sheet's proportions exactly —
|
||||
the outbound/inbound balance and the lockout structure are untouched.
|
||||
|
||||
`ROLLING_STOCK_SUPPLY` scaled with it, from 62 pieces to **80**: worst-case demand at 27 industries
|
||||
is boxcar 15, hopper 12, tank 9, reefer 6, and a Division Yard that runs dry would starve the very
|
||||
loop the increase exists to feed. That supply was already flagged as provisional, not transcribed.
|
||||
|
||||
| | 9 industries / 115 | 27 industries / 133 |
|
||||
| --- | --- | --- |
|
||||
| revenue per player | 0.0 | **1.1** |
|
||||
| freight facilities per game | 1.6 | **3.6** |
|
||||
| freight share of gross | 10% | **18%** |
|
||||
| laborer actions | 9.8 | **16.9** |
|
||||
| collisions | 0.3 | **0.1** |
|
||||
| trains scheduled | 2.9 | **2.1** |
|
||||
|
||||
First positive mean revenue recorded, and the `carsCoupled` anomaly cleared. **Still 0 wins against
|
||||
a target of 20**, so this is progress on a diagnosis, not a balanced game. Note the side effect:
|
||||
trains scheduled fell 2.9 → 2.1, because 22 train cards in 133 are drawn less often than 22 in 115.
|
||||
Train density may need the same treatment.
|
||||
|
||||
Still no wins, and still **not a verdict** — roughly a third of the deck does nothing yet, and the
|
||||
Small Yard enhancement (which is what makes the switching puzzle solvable) is among the unimplemented
|
||||
cards. Gap 12 stays invalidated until the enhancements are in.
|
||||
|
||||
+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",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Build the static solitaire site into `dist/`.
|
||||
*
|
||||
* Everything here is BUILD-time. The output is plain files on disk — upload them anywhere that
|
||||
* serves static content and the game runs entirely in the visitor's browser. No server, no API, no
|
||||
* network call at runtime.
|
||||
*
|
||||
* The one thing a browser cannot do is run TypeScript: Node 22's type-stripping is a Node feature.
|
||||
* So `tsc` emits ES2022 modules, rewriting the `.ts` import extensions the engine uses into `.js`.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, 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',
|
||||
'--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);
|
||||
|
||||
const page = readFileSync(join(root, 'src/web/index.html'), 'utf8')
|
||||
.replaceAll('__BUILD__', stamp)
|
||||
.replace(/(<script type="module" src="[^"]+?\.js)(")/, `$1?v=${tag}$2`);
|
||||
writeFileSync(join(dist, 'index.html'), page);
|
||||
|
||||
// A tiny note for whoever unzips this later and wonders what it needs.
|
||||
writeFileSync(
|
||||
join(dist, 'README.txt'),
|
||||
[
|
||||
'Station Master — solitaire, static build.',
|
||||
'',
|
||||
'Upload the contents of this folder to any static host and open index.html.',
|
||||
'There is no server component. The game runs entirely in the browser and saves',
|
||||
'to localStorage. Add ?seed=1234 to the URL for a reproducible deal.',
|
||||
'',
|
||||
'Note: ES modules require the files to be SERVED over http(s). Opening',
|
||||
'index.html directly from the filesystem will be blocked by the browser.',
|
||||
'To try it locally: npx http-server dist (or any static file server)',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
console.log(`built -> ${dist}\n ${stamp}\n cache-busted ${busted} modules with ?v=${tag}`);
|
||||
@@ -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.');
|
||||
}
|
||||
+119
-7
@@ -19,6 +19,7 @@
|
||||
import {
|
||||
COLLISION_PENALTY,
|
||||
MAINLINE_PROFILES,
|
||||
enhancementRule,
|
||||
consistSize,
|
||||
crossingStages,
|
||||
trainProfile,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
} from './content.ts';
|
||||
import type { Direction } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import { areaOf } from './apply.ts';
|
||||
import { acceptsCar, areaOf } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurn, totalRevenue } from './state.ts';
|
||||
@@ -294,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;
|
||||
@@ -349,11 +349,58 @@ function enterMainline(
|
||||
): void {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
const carriesPassengers = tray.consist.some((c) => c.type === 'coach');
|
||||
const stages = crossingStages(node.card, profile?.speed ?? 'slow', carriesPassengers);
|
||||
const stages = crossingStages(
|
||||
node.card,
|
||||
profile?.speed ?? 'slow',
|
||||
carriesPassengers,
|
||||
node.modifiers ?? [],
|
||||
tray.direction,
|
||||
node.gradeUp ?? 'east',
|
||||
);
|
||||
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
|
||||
tray.position = { at: 'mainline', index };
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegraph / Telephone / Radio — "once a day, when dispatching facing trains, add +N to the other
|
||||
* train's number". Spends the best available device the owning player has, once per Day each.
|
||||
*
|
||||
* Returns the bonus applied to the opposing train's number (0 if none was available or needed).
|
||||
*/
|
||||
function spendDispatchBonus(
|
||||
s: GameState,
|
||||
tray: CrewTray,
|
||||
other: CrewTray,
|
||||
events: GameEvent[],
|
||||
): number {
|
||||
const mine = tray.trainNumber ?? 99;
|
||||
const theirs = other.trainNumber ?? 99;
|
||||
|
||||
const area = s.officeAreas.get(s.clock.superintendent);
|
||||
if (!area) return 0;
|
||||
|
||||
// Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
|
||||
for (const key of ['radio', 'telephone', 'telegraph'] as const) {
|
||||
if (area.dispatchUsedToday.includes(key)) continue;
|
||||
const present = [...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
if (!present) continue;
|
||||
const bonus = enhancementRule(key)?.dispatchBonus ?? 0;
|
||||
// Spend it only if it actually wins the meet — the device makes the OTHER train count as
|
||||
// junior, so ours may proceed.
|
||||
if (mine >= theirs + bonus) continue;
|
||||
area.dispatchUsedToday.push(key);
|
||||
events.push({
|
||||
type: 'dispatchBonusUsed',
|
||||
key,
|
||||
bonus,
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
againstTrain: other.trainNumber ?? 0,
|
||||
});
|
||||
return bonus;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
|
||||
function isExpedited(tray: CrewTray): boolean {
|
||||
return trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.expedite === true;
|
||||
@@ -449,6 +496,8 @@ function moveTrain(
|
||||
const target = index + dir;
|
||||
const dest = s.division.nodes[target];
|
||||
node.transits = node.transits.filter((t) => t.tray !== id);
|
||||
// Red Flags protect a train while it is stopped here; once it rolls, the flags come in.
|
||||
if (node.redFlagged) node.redFlagged = node.redFlagged.filter((t) => t !== id);
|
||||
|
||||
if (!dest) return 'held';
|
||||
|
||||
@@ -503,7 +552,26 @@ function evaluateClearance(
|
||||
const otherTray = s.trays.get(other);
|
||||
if (!otherTray) continue;
|
||||
|
||||
if (otherTray.direction !== tray.direction) return 'blocked';
|
||||
if (otherTray.direction !== tray.direction) {
|
||||
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
|
||||
//
|
||||
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
|
||||
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
|
||||
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
|
||||
// way to pass the order, so the train simply holds.
|
||||
if (spendDispatchBonus(s, tray, otherTray, events) > 0) return 'clear';
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
// Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
|
||||
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
||||
// where ABS Signals protects everything on the card.
|
||||
if ((node.redFlagged ?? []).includes(other)) return 'blocked';
|
||||
|
||||
// ABS Signals — "trains on this card will not rear-end each other; they stop short of a
|
||||
// collision". With signals in place a following train simply holds, and the Superintendent has
|
||||
// no judgment call to make. This is the amendment to Gap 2's unconditional collisions.
|
||||
if (node.absSignals) return 'blocked';
|
||||
|
||||
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
||||
@@ -526,13 +594,55 @@ function arriveAtOffice(
|
||||
): MoveOutcome {
|
||||
const area = areaOf(s, owner);
|
||||
const capacity = officeProfile(area.tier).adTracks;
|
||||
const hasEnhancement = (key: string): boolean =>
|
||||
[...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
|
||||
// Yard Office — "an inbound train with NO COACHES that can make a single move to the yard office
|
||||
// track may arrive there, not at the Train Order Office". It sidesteps the A/D track entirely.
|
||||
const noCoaches = !tray.consist.some((c) => c.type === 'coach');
|
||||
if (noCoaches && hasEnhancement('yardOffice')) {
|
||||
for (const [key, card] of area.grid) {
|
||||
if (!card.enhancements.includes('yardOffice')) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
tray.position = { at: 'grid', owner, coord: { row: row!, col: col! } };
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Yard Office',
|
||||
reason: 'no coaches, so it need not occupy the Train Order Office',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
}
|
||||
|
||||
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
|
||||
if (area.adOccupancy.length >= capacity) {
|
||||
// Interlocking — "may stop an inbound train on the Limit Track". Instead of an automatic
|
||||
// collision, the train is held at the Limits until an A/D track frees up. This is the designed
|
||||
// answer to the A/D overflow that Gap 2d made fatal.
|
||||
if (hasEnhancement('interlocking')) {
|
||||
area.heldAtLimits.push(id);
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Limits',
|
||||
reason: 'Interlocking held it clear of a full Office instead of a collision',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
collide(s, owner, [id], events, 'no free A/D track');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
// A train held at the Limits takes the first free A/D track before any newcomer.
|
||||
if (area.heldAtLimits.length > 0 && area.heldAtLimits[0] !== id) {
|
||||
const first = area.heldAtLimits.shift()!;
|
||||
area.adOccupancy.push(first);
|
||||
const held = s.trays.get(first);
|
||||
if (held) held.position = { at: 'grid', owner, coord: area.officeCoord };
|
||||
}
|
||||
area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id);
|
||||
|
||||
// §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
|
||||
// is not expecting them (§A.4), so this is a collision too, not a coupling.
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
@@ -627,6 +737,8 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
}
|
||||
|
||||
if (s.clock.stage >= STAGES_PER_DAY) {
|
||||
// Telegraph/Telephone/Radio are each usable once a Day.
|
||||
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
s.collisionsToday = 0;
|
||||
|
||||
+473
-9
@@ -19,15 +19,22 @@ import {
|
||||
HAND_LIMIT,
|
||||
LABORER_ACTIONS_PER_LOAD,
|
||||
MAX_CONSIST,
|
||||
REALIGNMENTS,
|
||||
enhancementRule,
|
||||
industryProfile,
|
||||
mainlineModifierRule,
|
||||
mainlineProfile,
|
||||
modifierProfile,
|
||||
nextOfficeTier,
|
||||
officeProfile,
|
||||
trainProfile,
|
||||
} from './content.ts';
|
||||
import type { CarType, FreightKind, ModifierKind, TrackGeometry } 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,
|
||||
@@ -44,6 +51,7 @@ import {
|
||||
canDropCarsAt,
|
||||
canPlaceAt,
|
||||
facilityVariants,
|
||||
opposite,
|
||||
reachableDestinations,
|
||||
variantsFor,
|
||||
} from './track.ts';
|
||||
@@ -98,6 +106,7 @@ function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupanc
|
||||
/** A tray's facing, expressed as the port it would leave by going forward. */
|
||||
function facingPort(s: GameState, trayId: TrayId): Port {
|
||||
const tray = s.trays.get(trayId);
|
||||
if (tray?.facing) return tray.facing;
|
||||
return tray?.direction === 'west' ? 'w' : 'e';
|
||||
}
|
||||
|
||||
@@ -272,6 +281,25 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'ILLEGAL_MOVE';
|
||||
// Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard".
|
||||
const card = areaOf(s, player).grid.get(coordKey(here));
|
||||
if (!card?.enhancements.includes('smallYard')) return 'NOT_CONNECTED';
|
||||
// The order must be a permutation of the current consist.
|
||||
if (i.order.length !== tray.consist.length) return 'CONSIST_ORDER';
|
||||
const seen = new Set(i.order);
|
||||
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
|
||||
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'switch.end':
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN';
|
||||
@@ -307,6 +335,86 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'track.lay': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.laidThisTurn) return 'OPTION_ALREADY_CHOSEN';
|
||||
const key = `${i.geometry}:${i.hand}`;
|
||||
if ((areaOf(s, player).trackSupply.get(key) ?? 0) < 1) return 'NO_SUCH_CARD';
|
||||
const proto = protoTrackCard(i.geometry, i.hand, i.variant);
|
||||
if (!proto) return 'NO_PLACEMENT';
|
||||
return canPlaceAt(areaOf(s, player), i.placement, proto) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
case 'mainline.modify': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'mainlineModifier') return 'WRONG_INTENT';
|
||||
const rule = mainlineModifierRule(card.kind.key);
|
||||
if (!rule) return 'NOT_IMPLEMENTED';
|
||||
const node = s.division.nodes[i.node];
|
||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
||||
const on = node.modifiers ?? [];
|
||||
if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN';
|
||||
if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
|
||||
if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
|
||||
// "Not while a train is on it" — realigning under a moving train is exactly the situation the
|
||||
// restriction exists to prevent.
|
||||
if (node.transits.length > 0) return 'TRAIN_ON_CARD';
|
||||
if (rule.key === 'realignment' && !REALIGNMENTS.some((r) => r.from === node.card)) {
|
||||
return 'NO_PLACEMENT';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'maneuver.redFlags': {
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
// "A STOPPED train is prevented from being hit" — it protects a train that is standing on a
|
||||
// Mainline card, which is the only place a rear-ender can happen.
|
||||
if (tray.position.at !== 'mainline') return 'NO_PLACEMENT';
|
||||
const node = s.division.nodes[tray.position.index];
|
||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
||||
if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN';
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'maneuver.flyingSwitch': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'flyingSwitch') return 'WRONG_INTENT';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'CANNOT_DROP_HERE';
|
||||
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
|
||||
// The cut is uncoupled and ROLLS to the industry under its own momentum, so the target must
|
||||
// be somewhere the train could itself have run to — track-connected, not merely a neighbouring
|
||||
// square. An earlier version tested orthogonal adjacency, which was wrong in both directions:
|
||||
// it would have allowed a cut to cross to a cell with no rail between, while refusing a siding
|
||||
// two cards along the same track. It also made the card effectively unplayable — held for
|
||||
// 1,400 turns across 60 games and legal on 2.
|
||||
const reachable = [
|
||||
...destinationsFor(s, player, i.trayId, here, false),
|
||||
...destinationsFor(s, player, i.trayId, here, true),
|
||||
];
|
||||
if (!reachable.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)) {
|
||||
return 'NOT_CONNECTED';
|
||||
}
|
||||
const fsArea = areaOf(s, player);
|
||||
const target = fsArea.grid.get(coordKey(i.to));
|
||||
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
|
||||
return canDropCarsAt(fsArea, i.to, i.count) ? null : 'CANNOT_DROP_HERE';
|
||||
}
|
||||
|
||||
case 'draw.end': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
@@ -362,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': {
|
||||
@@ -478,18 +594,44 @@ function checkPlay(
|
||||
// spots) or it does nothing at all, so placing it anywhere else is not a legal play.
|
||||
return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
case 'spaceUse':
|
||||
case 'enhancement':
|
||||
case 'enhancement': {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
return checkEnhancementPlacement(s, area, card.kind.key, placement);
|
||||
}
|
||||
|
||||
case 'mainlineModifier':
|
||||
// Facing Point Locks appears in BOTH the Enhancement and Mainline-modifier lists on the sheet,
|
||||
// with the same placement and the same effect. It is one card printed twice, so the mainline
|
||||
// copy uses the enhancement's grid placement rather than going onto a Mainline card.
|
||||
if (card.kind.key === 'facingPointLocksMainline') {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
return checkEnhancementPlacement(s, area, 'facingPointLocks', placement);
|
||||
}
|
||||
// The rest are laid on a Mainline card, which is not a grid coordinate — see
|
||||
// `mainline.modify`.
|
||||
return 'WRONG_INTENT';
|
||||
|
||||
case 'maneuver':
|
||||
// Red Flags and Flying Switch have their own intents; Poling's effect is recorded as "TBD in
|
||||
// the source", so there is nothing to implement.
|
||||
return 'WRONG_INTENT';
|
||||
|
||||
case 'spaceUse':
|
||||
case 'action':
|
||||
// Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and
|
||||
// §10. Rejecting is honest: silently accepting would make the card look playable while doing
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -503,7 +645,11 @@ function destinationsFor(
|
||||
) {
|
||||
const tray = s.trays.get(trayId)!;
|
||||
const facing = facingPort(s, trayId);
|
||||
const exit: Port = reverse ? (facing === 'e' ? 'w' : 'e') : facing;
|
||||
// Reversing is the OPPOSITE port, whichever it is. This was hardcoded to flip between east and
|
||||
// west, so a crew facing north or south reversed to 'e' — a port a north-south card does not
|
||||
// have — and could never back out of a district spur. Combined with a facing that was itself
|
||||
// derived from an east/west direction, it stranded 29 of 62 leftover crews on north-south track.
|
||||
const exit: Port = reverse ? opposite(facing) : facing;
|
||||
return reachableDestinations(
|
||||
{
|
||||
area: areaOf(s, player),
|
||||
@@ -529,6 +675,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const from = trayCoord(s, i.trayId)!;
|
||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
|
||||
// The crew leaves by the port opposite the one it entered through, which is what it will be
|
||||
// facing when it stops. Without this the facing stays 'e'/'w' forever and a crew that turns
|
||||
// onto a north-south spur can never move again.
|
||||
const events: GameEvent[] = [
|
||||
{
|
||||
type: 'trayMoved',
|
||||
@@ -536,6 +685,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
from,
|
||||
to: i.to,
|
||||
movesRemaining: s.turn.movesRemaining - 1,
|
||||
facing: opposite(dest.entry),
|
||||
},
|
||||
];
|
||||
if (dest.couples.length > 0) {
|
||||
@@ -552,6 +702,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }];
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
const here = trayCoord(s, i.trayId)!;
|
||||
return [
|
||||
{
|
||||
type: 'consistSorted',
|
||||
trayId: i.trayId,
|
||||
at: here,
|
||||
before: tray.consist.map((c) => ({ ...c })),
|
||||
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'switch.end':
|
||||
case 'draw.end':
|
||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
@@ -607,6 +771,14 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
to: card.kind.tier,
|
||||
});
|
||||
}
|
||||
if (card.kind.kind === 'enhancement' && i.placement) {
|
||||
events.push({
|
||||
type: 'enhancementPlaced',
|
||||
player,
|
||||
key: card.kind.key,
|
||||
at: i.placement,
|
||||
});
|
||||
}
|
||||
if (card.kind.kind === 'extraTrain') {
|
||||
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
|
||||
// to do nothing at all, which made all four Extra cards dead weight in the deck.
|
||||
@@ -635,6 +807,47 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
case 'card.discard':
|
||||
return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }];
|
||||
|
||||
case 'mainline.modify': {
|
||||
const card = s.cards.get(i.cardId)!;
|
||||
const key = card.kind.kind === 'mainlineModifier' ? card.kind.key : '';
|
||||
const node = s.division.nodes[i.node];
|
||||
const became =
|
||||
key === 'realignment' && node?.kind === 'mainline'
|
||||
? REALIGNMENTS.find((r) => r.from === node.card)?.to
|
||||
: undefined;
|
||||
return [
|
||||
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
|
||||
];
|
||||
}
|
||||
|
||||
case 'maneuver.redFlags': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
const index = tray.position.at === 'mainline' ? tray.position.index : -1;
|
||||
return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }];
|
||||
}
|
||||
|
||||
case 'maneuver.flyingSwitch': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
// §A.3 — cars come off the back, same as a normal drop.
|
||||
const stock = tray.consist.slice(tray.consist.length - i.count);
|
||||
return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }];
|
||||
}
|
||||
|
||||
case 'track.lay': {
|
||||
const key = `${i.geometry}:${i.hand}`;
|
||||
return [
|
||||
{
|
||||
type: 'trackLaid',
|
||||
player,
|
||||
geometry: i.geometry,
|
||||
hand: i.hand,
|
||||
at: i.placement,
|
||||
variant: i.variant ?? 0,
|
||||
remaining: (areaOf(s, player).trackSupply.get(key) ?? 1) - 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'freightAgent.stockOutbound':
|
||||
return [
|
||||
{ type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
|
||||
@@ -763,6 +976,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
|
||||
tray.position = { at: 'grid', owner, coord: e.to };
|
||||
if (e.facing) tray.facing = e.facing;
|
||||
s.turn.movesRemaining = e.movesRemaining;
|
||||
|
||||
// An A/D track is held only while the train is actually standing at the Office (§2.1).
|
||||
@@ -787,6 +1001,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'consistSorted': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
tray.consist = e.after.map((c) => ({ ...c }));
|
||||
// "Spends one move in the yard" — the sort costs a Move.
|
||||
s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'carsDropped': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
|
||||
@@ -833,6 +1055,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
applyModifier(area, e.placement, card.kind.modifier);
|
||||
} else if (card.kind.kind !== 'office') {
|
||||
@@ -841,6 +1064,52 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mainlineModified': {
|
||||
const node = s.division.nodes[e.node];
|
||||
if (node?.kind === 'mainline') {
|
||||
if (e.became) node.card = e.became as MainlineKind;
|
||||
else node.modifiers = [...(node.modifiers ?? []), e.key];
|
||||
}
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'redFlagsSet': {
|
||||
const node = s.division.nodes[e.node];
|
||||
if (node?.kind === 'mainline') {
|
||||
node.redFlagged = [...(node.redFlagged ?? []), e.trayId];
|
||||
}
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'flyingSwitch': {
|
||||
const tray = s.trays.get(e.trayId);
|
||||
const area = areaOf(s, e.player);
|
||||
const card = area.grid.get(coordKey(e.to));
|
||||
if (tray && card) {
|
||||
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
|
||||
const track = card.facility?.industryTrack;
|
||||
if (track) track.cars.push(...e.stock);
|
||||
else card.standing.push(...e.stock);
|
||||
}
|
||||
s.turn.movesRemaining -= 1;
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'trackLaid': {
|
||||
const area = areaOf(s, e.player);
|
||||
area.trackSupply.set(`${e.geometry}:${e.hand}`, e.remaining);
|
||||
s.turn.laidThisTurn = true;
|
||||
const built = protoTrackCard(e.geometry as never, e.hand as never, e.variant);
|
||||
if (built) {
|
||||
area.grid.set(coordKey(e.at), built);
|
||||
extendLimitsIfNeeded(area, e.at);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'officeUpgraded': {
|
||||
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
||||
const area = areaOf(s, e.player);
|
||||
@@ -897,6 +1166,19 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'enhancementPlaced': {
|
||||
const rule = enhancementRule(e.key);
|
||||
if (rule?.placement === 'mainlineCard') {
|
||||
const node = s.division.nodes[e.at.col];
|
||||
// ABS Signals: trains on this card stop short rather than rear-ending each other.
|
||||
if (node?.kind === 'mainline') node.absSignals = true;
|
||||
} else {
|
||||
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
|
||||
if (card) card.enhancements.push(e.key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'extraQueued':
|
||||
s.pendingExtras.push(e.trainNumber);
|
||||
break;
|
||||
@@ -1032,7 +1314,7 @@ function protoCard(
|
||||
kind: { kind: string; geometry?: string; facility?: string },
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
const base = { standing: [], facility: null, modifiers: [] };
|
||||
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
|
||||
|
||||
if (kind.kind === 'track') {
|
||||
const geometry = kind.geometry as TrackGeometry;
|
||||
@@ -1117,6 +1399,122 @@ function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord |
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three defensive Enhancements protect against cards that only exist in a multiplayer deck, so
|
||||
* their placement and state are implemented and their effect is read at the point of attack:
|
||||
*
|
||||
* - **Facing Point Locks** — prevents `Derail` being played on you (Action card).
|
||||
* - **Water Column** — lets you remove a Watertower from your district (Space-use card).
|
||||
* - **Overpass** — removes the restrictions of a played Railroad Crossing (Action card).
|
||||
*
|
||||
* In solitaire the opponent-directed cards are not in the deck (Q6), so these never fire. They are
|
||||
* queried here rather than being special-cased at each attack site.
|
||||
*/
|
||||
export function hasDistrictEnhancement(area: OfficeArea, key: string): boolean {
|
||||
return [...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
}
|
||||
|
||||
/** Facing Point Locks blocks a Derail played at this district. */
|
||||
export function isProtectedFromDerail(area: OfficeArea): boolean {
|
||||
return hasDistrictEnhancement(area, 'facingPointLocks');
|
||||
}
|
||||
|
||||
/** A Water Column lets its owner clear a Watertower off their own grid. */
|
||||
export function watertowersRemovable(area: OfficeArea): GridCoord[] {
|
||||
if (!hasDistrictEnhancement(area, 'waterColumn')) return [];
|
||||
const out: GridCoord[] = [];
|
||||
for (const [key, card] of area.grid) {
|
||||
if (card.geometry.kind !== 'spaceUse' || card.geometry.key !== 'watertower') continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
out.push({ row: row!, col: col! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhancements have per-card placement rules (implications.md §7):
|
||||
* - Interlocking, Water Column, Telegraph → a Running Track straight
|
||||
* - Yard Office, Small Yard → a Secondary Track straight
|
||||
* - Telephone / Radio → stacked on the card below them
|
||||
* - Facing Point Locks → needs an Interlocking in the district
|
||||
* - ABS Signals → a Mainline card, not the Office Area
|
||||
*/
|
||||
export function checkEnhancementPlacement(
|
||||
s: GameState,
|
||||
area: OfficeArea,
|
||||
key: string,
|
||||
placement: GridCoord,
|
||||
): RejectionCode | null {
|
||||
const rule = enhancementRule(key);
|
||||
if (!rule) return 'NOT_IMPLEMENTED';
|
||||
|
||||
// ABS Signals goes on a Mainline card; `placement.col` names which one.
|
||||
if (rule.placement === 'mainlineCard') {
|
||||
const node = s.division.nodes[placement.col];
|
||||
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
const card = area.grid.get(coordKey(placement));
|
||||
if (!card) return 'NOT_CONNECTED';
|
||||
if (card.enhancements.includes(key)) return 'OPTION_ALREADY_CHOSEN';
|
||||
|
||||
if (rule.requiresOnSameCard && !card.enhancements.includes(rule.requiresOnSameCard)) {
|
||||
return 'NOT_CONNECTED';
|
||||
}
|
||||
if (rule.requiresInDistrict) {
|
||||
const present = [...area.grid.values()].some((c) =>
|
||||
c.enhancements.includes(rule.requiresInDistrict!),
|
||||
);
|
||||
if (!present) return 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
const onRunning = placement.row === area.runningRow;
|
||||
const isStraight =
|
||||
card.geometry.kind === 'track' && card.geometry.geometry === 'straight';
|
||||
|
||||
switch (rule.placement) {
|
||||
case 'runningTrackStraight':
|
||||
return onRunning && isStraight ? null : 'NOT_CONNECTED';
|
||||
case 'secondaryTrackStraight':
|
||||
return !onRunning && isStraight ? null : 'NOT_CONNECTED';
|
||||
case 'onCard':
|
||||
return null;
|
||||
default:
|
||||
return 'NOT_CONNECTED';
|
||||
}
|
||||
}
|
||||
|
||||
/** 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));
|
||||
s.decks.salvageYard.push(cardId);
|
||||
}
|
||||
|
||||
/** A track piece from the player's own supply, at the chosen rotation. */
|
||||
function protoTrackCard(
|
||||
geometry: TrackGeometry,
|
||||
hand: Hand,
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
const options = variantsFor(geometry);
|
||||
const v = options[variant ?? 0];
|
||||
if (!v) return null;
|
||||
return {
|
||||
geometry: {
|
||||
kind: 'track',
|
||||
geometry,
|
||||
...(v.axis ? { axis: v.axis } : {}),
|
||||
...(v.turnout ? { turnout: v.turnout } : {}),
|
||||
...(hand !== 'none' ? { hand } : {}),
|
||||
},
|
||||
baseOperationalRail: geometry !== 'turnout',
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Q4 — would building `kind` here conflict with something already in the district?
|
||||
* The relation is symmetric, so checking either direction is enough.
|
||||
@@ -1147,10 +1545,76 @@ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKin
|
||||
}
|
||||
|
||||
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
|
||||
/**
|
||||
* §2.1 Gap 4a — "when you extend your Running Track, the Limits sign MOVES outwards with it".
|
||||
*
|
||||
* The sign is a physical card, not just a recorded column. Moving only the coordinate left the
|
||||
* Limits card stranded mid-track: a district grew to
|
||||
* (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2)=Freight House …
|
||||
* with everything beyond (0,-2) built OUTSIDE a sign that never moved. That is not cosmetic —
|
||||
* §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
|
||||
* an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
|
||||
*/
|
||||
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||
if (placed.row !== area.runningRow) return;
|
||||
if (placed.col <= area.limitsWest.col) area.limitsWest = { row: placed.row, col: placed.col - 1 };
|
||||
if (placed.col >= area.limitsEast.col) area.limitsEast = { row: placed.row, col: placed.col + 1 };
|
||||
|
||||
if (placed.col <= area.limitsWest.col) {
|
||||
area.limitsWest = { row: placed.row, col: placed.col - 1 };
|
||||
area.grid.set(coordKey(area.limitsWest), limitsCard());
|
||||
}
|
||||
if (placed.col >= area.limitsEast.col) {
|
||||
area.limitsEast = { row: placed.row, col: placed.col + 1 };
|
||||
area.grid.set(coordKey(area.limitsEast), limitsCard());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.2 — may this car be coupled onto this train right now?
|
||||
*
|
||||
* "The train must be in the order listed on the train's card (engine on the front, Rolling Stock,
|
||||
* and possibly a Caboose). It may depart with FEWER Rolling Stock than listed, but not out of
|
||||
* order." Fewer is allowed; more, or of the wrong category, is not.
|
||||
*
|
||||
* SHARED. Both `check` and the New Train Phase's "is there a suitable car in the yard" test call
|
||||
* this. A second copy stalled the game outright: the phase believed a car could be added while
|
||||
* `check` rejected every option, so the Stage never completed.
|
||||
*/
|
||||
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!profile) return true;
|
||||
|
||||
const cat = (t: CarType): 'coach' | 'caboose' | 'freight' =>
|
||||
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
||||
const adding = cat(carType);
|
||||
const already = tray.consist.filter((c) => cat(c.type) === adding).length;
|
||||
const allowed =
|
||||
adding === 'coach'
|
||||
? profile.consist.coach
|
||||
: adding === 'caboose'
|
||||
? profile.consist.caboose
|
||||
: profile.consist.freight;
|
||||
if (already >= allowed) return false;
|
||||
|
||||
// The caboose rides last (§A.3), so nothing may be coupled behind one.
|
||||
if (adding !== 'caboose' && tray.consist.some((c) => c.type === 'caboose')) return false;
|
||||
|
||||
// The card also narrows WHICH freight types it will take.
|
||||
const types = profile.consist.freightTypes;
|
||||
if (adding === 'freight' && types && !types.includes(carType)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
|
||||
function limitsCard(): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+150
-20
@@ -105,11 +105,30 @@ export type OfficeProfile = {
|
||||
* an earlier guess gave one more slot than porters at every tier. Capacity is instead grown by the
|
||||
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
|
||||
*/
|
||||
/**
|
||||
* Office cards. `copiesInDeck` was **doubled** (Depot 4→8, Station 2→4, Terminal 1→2) — Q12.
|
||||
*
|
||||
* Players always start at a Whistle Post, which has ONE A/D track, so a second arrival is an
|
||||
* automatic collision (§8.3, Gap 2a). Measured at the original density, 25 of 100 games never drew
|
||||
* a Depot and never escaped: they averaged **−6.0** revenue against **−0.4** for games that
|
||||
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post. Escaping needed one of
|
||||
* 4 Depot cards in 111, roughly a 59% chance across a game's draws.
|
||||
*
|
||||
* Upgrades are strictly sequential (Gap 3b, no skipping), so Station and Terminal are rarer than
|
||||
* their raw counts imply — Terminal needs all three cards in order. Station and Terminal were
|
||||
* doubled with Depot to keep that ladder in proportion rather than making Depot a special case.
|
||||
*
|
||||
* PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal,
|
||||
* not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and
|
||||
* dilutes every other category slightly (deck 133 → 140). Revisit once the victory target is
|
||||
* settled and freight is carrying its intended share; the right answer may instead be fewer
|
||||
* Terminals, a cheaper first upgrade, or more A/D capacity at Whistle Post.
|
||||
*/
|
||||
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
|
||||
{ tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 },
|
||||
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 4 },
|
||||
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 2 },
|
||||
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 1 },
|
||||
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 8 },
|
||||
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 4 },
|
||||
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 2 },
|
||||
];
|
||||
|
||||
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
|
||||
@@ -145,13 +164,23 @@ export type IndustryProfile = {
|
||||
copies: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Industry density (Gap 12). The recovered sheet lists 9 industries in a 115-card deck; the
|
||||
* prototype ran 10 in 52. At 9-in-115 a game saw 1.6 Freight Facilities, freight was 10% of gross
|
||||
* revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the point of
|
||||
* the game, effectively never ran.
|
||||
*
|
||||
* Each industry's `copies` is TRIPLED, giving 27 in 133. That restores roughly the prototype's
|
||||
* ratio while preserving the sheet's proportions exactly: the outbound/inbound balance and the
|
||||
* lockout structure are unchanged, because every kind scales by the same factor.
|
||||
*/
|
||||
export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 2 },
|
||||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 2 },
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
|
||||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 1 },
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 1 },
|
||||
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
|
||||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
|
||||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
|
||||
];
|
||||
|
||||
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
|
||||
@@ -381,6 +410,9 @@ export function crossingStages(
|
||||
kind: MainlineKind,
|
||||
trainSpeed: TrainSpeed,
|
||||
carriesPassengers: boolean,
|
||||
modifiers: readonly string[] = [],
|
||||
direction: Direction = 'east',
|
||||
gradeUp: Direction = 'east',
|
||||
): number {
|
||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
||||
@@ -394,14 +426,65 @@ export function crossingStages(
|
||||
mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight;
|
||||
break;
|
||||
case 'grade':
|
||||
// Heavy Grade has no printed number; the modifier cards (Brakeman/Airbrakes/Helpers) are what
|
||||
// improve it. Treated as a 30 until those are implemented.
|
||||
// Heavy Grade has no printed number; the modifier cards are what improve it, so it is a 30
|
||||
// until one is placed.
|
||||
mph = 30;
|
||||
break;
|
||||
}
|
||||
|
||||
const base = mph >= 60 ? 1 : 2;
|
||||
return base + (trainSpeed === 'slow' ? 1 : 0);
|
||||
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
|
||||
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
|
||||
}
|
||||
|
||||
/**
|
||||
* Q11, answered: the Heavy Grade card prints "(Up)" and "Player sets orientation", so which way it
|
||||
* climbs is a property of the placed card, not a constant. `gradeUp` is the direction a train is
|
||||
* travelling when it goes UPHILL; a train heading the other way is descending.
|
||||
*
|
||||
* Each applicable card takes a Stage off, never below one: a train cannot cross in no time.
|
||||
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces.
|
||||
*/
|
||||
function gradeReduction(
|
||||
profile: MainlineProfile,
|
||||
modifiers: readonly string[],
|
||||
direction: Direction,
|
||||
gradeUp: Direction,
|
||||
): number {
|
||||
if (profile.speed.kind !== 'grade') return 0;
|
||||
const downhill = direction !== gradeUp;
|
||||
let n = 0;
|
||||
if (downhill) {
|
||||
if (modifiers.includes('brakeman')) n++;
|
||||
if (modifiers.includes('airbrakes')) n++;
|
||||
} else if (modifiers.includes('helpers')) {
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function mainlineProfile(kind: MainlineKind): MainlineProfile {
|
||||
const p = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||
if (!p) throw new Error(`unknown mainline card: ${kind}`);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Which Mainline modifiers may sit on `kind`, and what each additionally requires. */
|
||||
export const MAINLINE_MODIFIER_RULES: readonly {
|
||||
key: string;
|
||||
/** Only placeable on a card whose speed is a grade. */
|
||||
gradeOnly: boolean;
|
||||
/** Another modifier that must already be on the same card. */
|
||||
requiresOnCard?: string;
|
||||
}[] = [
|
||||
{ key: 'brakeman', gradeOnly: true },
|
||||
{ key: 'airbrakes', gradeOnly: true, requiresOnCard: 'brakeman' },
|
||||
{ key: 'helpers', gradeOnly: true },
|
||||
{ key: 'realignment', gradeOnly: false },
|
||||
];
|
||||
|
||||
export function mainlineModifierRule(key: string) {
|
||||
return MAINLINE_MODIFIER_RULES.find((r) => r.key === key) ?? null;
|
||||
}
|
||||
|
||||
/** `Realignment` converts one Mainline card into another. */
|
||||
@@ -431,6 +514,45 @@ export const SPACE_USE_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'engineerCemetery', name: 'Engineer cemetery', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
|
||||
];
|
||||
|
||||
export type EnhancementKey =
|
||||
| 'interlocking' | 'facingPointLocks' | 'yardOffice' | 'smallYard'
|
||||
| 'waterColumn' | 'overpass' | 'telegraph' | 'telephone' | 'radio' | 'absSignals';
|
||||
|
||||
/** Where an Enhancement may be laid. */
|
||||
export type EnhancementPlacement =
|
||||
| 'runningTrackStraight'
|
||||
| 'secondaryTrackStraight'
|
||||
| 'mainlineCard'
|
||||
| 'onCard';
|
||||
|
||||
export type EnhancementRule = {
|
||||
key: EnhancementKey;
|
||||
placement: EnhancementPlacement;
|
||||
/** Must sit on a card already carrying this enhancement (Telephone on Telegraph, etc.). */
|
||||
requiresOnSameCard?: EnhancementKey;
|
||||
/** Must exist somewhere in the district (Facing Point Locks needs Interlocking). */
|
||||
requiresInDistrict?: EnhancementKey;
|
||||
/** Bonus added to an opposing train's number when resolving a meet, once a Day. */
|
||||
dispatchBonus?: number;
|
||||
};
|
||||
|
||||
export const ENHANCEMENT_RULES: readonly EnhancementRule[] = [
|
||||
{ key: 'interlocking', placement: 'runningTrackStraight' },
|
||||
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking' },
|
||||
{ key: 'yardOffice', placement: 'secondaryTrackStraight' },
|
||||
{ key: 'smallYard', placement: 'secondaryTrackStraight' },
|
||||
{ key: 'waterColumn', placement: 'runningTrackStraight' },
|
||||
{ key: 'overpass', placement: 'onCard' },
|
||||
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4 },
|
||||
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8 },
|
||||
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12 },
|
||||
{ key: 'absSignals', placement: 'mainlineCard' },
|
||||
];
|
||||
|
||||
export function enhancementRule(key: string): EnhancementRule | null {
|
||||
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
|
||||
}
|
||||
|
||||
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.' },
|
||||
@@ -484,13 +606,21 @@ export const ACTION_CARDS: readonly SimpleCard[] = [
|
||||
|
||||
export type StockSupply = { type: CarType; loaded: number; empty: number };
|
||||
|
||||
/** NOT in the recovered files — still the provisional figure. */
|
||||
/**
|
||||
* NOT in the recovered files — still the provisional figure.
|
||||
*
|
||||
* Scaled up alongside the Gap 12 industry increase. Worst-case demand (every copy of every
|
||||
* industry in play at full capacity) is boxcar 15, hopper 12, tank 9, reefer 6; the supply must
|
||||
* cover that, since a Division Yard that runs dry starves the freight loop the increase exists to
|
||||
* feed. Lockouts and district size mean the worst case cannot actually occur, so this carries
|
||||
* deliberate headroom.
|
||||
*/
|
||||
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||
{ type: 'coach', loaded: 8, empty: 8 },
|
||||
{ type: 'boxcar', loaded: 6, empty: 6 },
|
||||
{ type: 'hopper', loaded: 6, empty: 6 },
|
||||
{ type: 'reefer', loaded: 4, empty: 4 },
|
||||
{ type: 'tank', loaded: 4, empty: 4 },
|
||||
{ type: 'boxcar', loaded: 10, empty: 10 },
|
||||
{ type: 'hopper', loaded: 8, empty: 8 },
|
||||
{ type: 'reefer', loaded: 5, empty: 5 },
|
||||
{ type: 'tank', loaded: 6, empty: 6 },
|
||||
{ type: 'caboose', loaded: 6, empty: 0 },
|
||||
];
|
||||
|
||||
@@ -537,7 +667,7 @@ export function lengthProfile(length: GameLength): LengthProfile {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deck composition — 115 cards
|
||||
// Deck composition — 140 cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -565,9 +695,9 @@ export function deckComposition(): { category: string; count: number }[] {
|
||||
];
|
||||
}
|
||||
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 115
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 140
|
||||
|
||||
/** The solitaire deck drops the 22 opponent-directed cards, leaving 93. */
|
||||
/** The solitaire deck drops the 22 opponent-directed cards, leaving 118. */
|
||||
export const SOLITAIRE_DECK_SIZE = deckComposition()
|
||||
.filter((c) => !isOpponentOnly(c.category))
|
||||
.reduce((n, c) => n + c.count, 0);
|
||||
|
||||
@@ -21,12 +21,17 @@ export type GameEvent =
|
||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||
// -- local operations
|
||||
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number }
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
|
||||
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
|
||||
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'trackLaid'; player: PlayerIndex; geometry: string; hand: string; at: GridCoord; variant: number; remaining: number }
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number }
|
||||
| { 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 }
|
||||
| { type: 'deckReshuffled' }
|
||||
@@ -46,15 +51,18 @@ export type GameEvent =
|
||||
* which made the replay say "New Train" and nothing else. A train being made up, departing,
|
||||
* arriving or finishing its run are four distinct facts and deserve four event types.
|
||||
*/
|
||||
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at: GridCoord }
|
||||
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
||||
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
||||
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
|
||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string }
|
||||
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
||||
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
||||
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||
// -- load / unload
|
||||
|
||||
+33
-1
@@ -4,7 +4,7 @@
|
||||
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
|
||||
*/
|
||||
|
||||
import type { CarType, Direction, OfficeTier } from './content.ts';
|
||||
import type { CarType, Direction, Hand, OfficeTier, TrackGeometry } from './content.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, TrayId } from './state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -18,6 +18,12 @@ export type Intent =
|
||||
// -- switch (§6.1, Appendix A)
|
||||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
|
||||
| { type: 'switch.dropCars'; trayId: TrayId; count: number }
|
||||
/**
|
||||
* Small Yard enhancement — "a train that spends one move in the yard may sort itself in any
|
||||
* order, including cars in front of the engine". This is the designed answer to §A.3's
|
||||
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
|
||||
*/
|
||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
|
||||
| { type: 'switch.end' }
|
||||
// -- draw (§6.2)
|
||||
| { type: 'draw.fromHomeOffice' }
|
||||
@@ -25,6 +31,13 @@ export type Intent =
|
||||
/** `variant` indexes `variantsFor(geometry)` — Gap 11: orientation is chosen on placement. */
|
||||
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'card.discard'; cardId: CardId; toSlot: number }
|
||||
/**
|
||||
* Lay a piece from your personal track supply (§12.2 / content.ts TRACK_SUPPLY).
|
||||
*
|
||||
* Laid during the "draw a card" option, one piece a turn — how track behaved when it WAS a card.
|
||||
* Confirmed; see implications.md §10 Q10.
|
||||
*/
|
||||
| { type: 'track.lay'; geometry: TrackGeometry; hand: Hand; placement: GridCoord; variant?: number }
|
||||
| { type: 'draw.end' }
|
||||
// -- freight agent (§6.3)
|
||||
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
|
||||
@@ -37,6 +50,21 @@ export type Intent =
|
||||
| { type: 'newTrain.secondSection'; trainNumber: number }
|
||||
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
|
||||
| { type: 'mainline.clearance'; allow: boolean }
|
||||
/**
|
||||
* Lay a Mainline modifier (Brakeman / Airbrakes / Helpers / Realignment) on a Mainline card.
|
||||
* `node` indexes `division.nodes`.
|
||||
*/
|
||||
| { type: 'mainline.modify'; cardId: CardId; node: number }
|
||||
/**
|
||||
* Red Flags — protect a stopped train. The flagged train cannot be hit; an approaching train is
|
||||
* held instead of colliding.
|
||||
*/
|
||||
| { type: 'maneuver.redFlags'; cardId: CardId; trayId: TrayId }
|
||||
/**
|
||||
* Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without
|
||||
* the engine entering it.
|
||||
*/
|
||||
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
|
||||
| { type: 'redFlag.play' }
|
||||
// -- Load/Unload Phase (§9)
|
||||
| { type: 'porter.board'; at: GridCoord }
|
||||
@@ -83,6 +111,10 @@ export type RejectionCode =
|
||||
| 'NOT_UPGRADEABLE'
|
||||
| 'FACILITY_LOCKED'
|
||||
| 'NOT_IMPLEMENTED'
|
||||
| 'WRONG_INTENT'
|
||||
| 'NOT_A_GRADE'
|
||||
| 'TRAIN_ON_CARD'
|
||||
| 'CONSIST_ORDER'
|
||||
| 'NO_TRAIN_AT_OFFICE';
|
||||
|
||||
export type Rejection = { code: RejectionCode; message: string };
|
||||
|
||||
+69
-3
@@ -12,7 +12,7 @@
|
||||
* If you find yourself writing a rule here, it belongs in apply.ts.
|
||||
*/
|
||||
|
||||
import type { CarType } from './content.ts';
|
||||
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||
import type { Intent } from './intents.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||
@@ -56,6 +56,14 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
break;
|
||||
}
|
||||
|
||||
// Red Flags — "any time", so they are candidates in every phase, on any train standing out on
|
||||
// the Mainline (the player's own or another's: protecting a train is not an attack).
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue;
|
||||
for (const [trayId] of s.trays) out.push({ type: 'maneuver.redFlags', cardId, trayId });
|
||||
}
|
||||
|
||||
out.push({ type: 'redFlag.play' });
|
||||
return out;
|
||||
}
|
||||
@@ -82,6 +90,34 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
}
|
||||
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
|
||||
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
|
||||
// so a UI may submit an arbitrary permutation.
|
||||
const n = tray.consist.length;
|
||||
if (n > 1) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const order = [...Array(n).keys()].filter((x) => x !== k);
|
||||
order.push(k);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order });
|
||||
}
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
|
||||
}
|
||||
}
|
||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
for (let count = 1; count <= tray.consist.length; count++) {
|
||||
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ type: 'switch.end' });
|
||||
|
||||
@@ -90,17 +126,43 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
for (let slot = 0; slot < 3; slot++) out.push({ type: 'draw.fromDepartment', slot });
|
||||
|
||||
const placements = placementCandidates(s, player);
|
||||
// Enhancements ATTACH to a card already in the grid, so their candidates are the occupied cells,
|
||||
// not the empty ones every other placeable card wants. Offering them `placements` meant an
|
||||
// Enhancement was never once legal on a real card — 18 of 93 solitaire cards, permanently dead.
|
||||
const attachments = [...area.grid.keys()].map((k) => {
|
||||
const [row, col] = k.split(',').map(Number);
|
||||
return { row: row!, col: col! };
|
||||
});
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
out.push({ type: 'card.play', cardId });
|
||||
// Gap 11 — orientation is chosen on placement, so every rotation is a distinct candidate.
|
||||
// Six is the widest set (turnouts); `check` discards the ones whose ports do not meet.
|
||||
for (const placement of placements) {
|
||||
const targets = s.cards.get(cardId)?.kind.kind === 'enhancement' ? attachments : placements;
|
||||
for (const placement of targets) {
|
||||
for (let variant = 0; variant < 6; variant++) {
|
||||
out.push({ type: 'card.play', cardId, placement, variant });
|
||||
}
|
||||
}
|
||||
for (let slot = 0; slot < 3; slot++) out.push({ type: 'card.discard', cardId, toSlot: slot });
|
||||
}
|
||||
// Lay track from the personal supply — the only way a district grows now that track is not in
|
||||
// the deck.
|
||||
for (const [key, count] of s.turn.laidThisTurn ? [] : area.trackSupply) {
|
||||
if (count < 1) continue;
|
||||
const [geometry, hand] = key.split(':') as [TrackGeometry, Hand];
|
||||
for (const placement of placements) {
|
||||
for (let variant = 0; variant < 2; variant++) {
|
||||
out.push({ type: 'track.lay', geometry, hand, placement, variant });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mainline modifiers go on a Mainline card, not a grid cell, so `node` indexes division.nodes.
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
if (s.cards.get(cardId)?.kind.kind !== 'mainlineModifier') continue;
|
||||
for (let node = 0; node < s.division.nodes.length; node++) {
|
||||
out.push({ type: 'mainline.modify', cardId, node });
|
||||
}
|
||||
}
|
||||
out.push({ type: 'draw.end' });
|
||||
|
||||
// -- freight agent (§6.3)
|
||||
@@ -190,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);
|
||||
}
|
||||
|
||||
+29
-6
@@ -17,6 +17,8 @@ import {
|
||||
MODIFIER_PROFILES,
|
||||
OFFICE_PROFILES,
|
||||
MAINLINE_PROFILES,
|
||||
mainlineProfile,
|
||||
TRACK_SUPPLY,
|
||||
ROLLING_STOCK_SUPPLY,
|
||||
STAGES_PER_DAY,
|
||||
TIMETABLED_TRAINS,
|
||||
@@ -122,6 +124,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
standing: [],
|
||||
facility: buildPassengerFacility('whistlePost'),
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
|
||||
const limitsCard = (): TrackCard => ({
|
||||
@@ -130,6 +133,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const grid = new Map<string, TrackCard>();
|
||||
@@ -137,7 +141,19 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
grid.set(coordKey(limitsWest), limitsCard());
|
||||
grid.set(coordKey(limitsEast), limitsCard());
|
||||
|
||||
return { owner, tier: 'whistlePost', grid, officeCoord, runningRow: row, limitsWest, limitsEast, adOccupancy: [] };
|
||||
return {
|
||||
owner,
|
||||
tier: 'whistlePost',
|
||||
grid,
|
||||
officeCoord,
|
||||
runningRow: row,
|
||||
limitsWest,
|
||||
limitsEast,
|
||||
adOccupancy: [],
|
||||
heldAtLimits: [],
|
||||
dispatchUsedToday: [],
|
||||
trackSupply: new Map(TRACK_SUPPLY.map((t) => [`${t.geometry}:${t.hand}`, t.perPlayer])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,11 +188,18 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
||||
function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
||||
const nodes: DivisionNode[] = [];
|
||||
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
|
||||
const mainline = (): DivisionNode => ({
|
||||
kind: 'mainline',
|
||||
card: kinds[rng.nextInt(kinds.length)]!,
|
||||
transits: [],
|
||||
});
|
||||
const mainline = (): DivisionNode => {
|
||||
const card = kinds[rng.nextInt(kinds.length)]!;
|
||||
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
|
||||
// The Heavy Grade card says "Player sets orientation", but setup has no decision point yet —
|
||||
// createGame is synchronous and returns a ready state. Rolled for now so the orientation is at
|
||||
// least deterministic and varies between games; it should become a real player choice when
|
||||
// setup gains an interactive phase. See implications.md §10 Q11.
|
||||
if (mainlineProfile(card).speed.kind === 'grade') {
|
||||
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
|
||||
for (let p = 0; p < players; p++) {
|
||||
|
||||
+47
-2
@@ -73,7 +73,9 @@ export type CardGeometry =
|
||||
| { kind: 'limits' }
|
||||
| { kind: 'facility'; facility: FreightKind; axis?: TrackAxis }
|
||||
/** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */
|
||||
| { kind: 'modifier'; modifier: ModifierKind };
|
||||
| { kind: 'modifier'; modifier: ModifierKind }
|
||||
/** Not track — a Space-use card played at a district to consume a cell (Q6). */
|
||||
| { kind: 'spaceUse'; key: string };
|
||||
|
||||
export type TrackCard = {
|
||||
geometry: CardGeometry;
|
||||
@@ -86,6 +88,8 @@ export type TrackCard = {
|
||||
standing: RollingStock[];
|
||||
facility: Facility | null;
|
||||
modifiers: ModifierKind[];
|
||||
/** Enhancement cards laid on this card (§7 of implications.md). */
|
||||
enhancements: string[];
|
||||
};
|
||||
|
||||
export type OfficeArea = {
|
||||
@@ -99,6 +103,18 @@ export type OfficeArea = {
|
||||
limitsEast: GridCoord;
|
||||
/** Trays holding at the Office. Length must never exceed the tier's A/D track count. */
|
||||
adOccupancy: TrayId[];
|
||||
/**
|
||||
* Trains held at the Limits by an Interlocking rather than admitted to the Office. They are
|
||||
* inside the player's Limits but not occupying an A/D track.
|
||||
*/
|
||||
heldAtLimits: TrayId[];
|
||||
/** Dispatch bonuses spent this Day, by enhancement key — each is once a Day. */
|
||||
dispatchUsedToday: string[];
|
||||
/**
|
||||
* The player's personal track supply (26 pieces), keyed `geometry:hand`. Track is NOT in the
|
||||
* Home Office deck — it is laid from here.
|
||||
*/
|
||||
trackSupply: Map<string, number>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -182,6 +198,15 @@ export type CrewTray = {
|
||||
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
|
||||
consist: RollingStock[];
|
||||
direction: Direction;
|
||||
/**
|
||||
* Which way the engine points, as an actual port on the card beneath it.
|
||||
*
|
||||
* NOT derivable from `direction`, which only has east and west: a crew standing on a north-south
|
||||
* spur points north or south, and deriving 'e'/'w' gave it an exit port the card does not have —
|
||||
* so it had no legal moves at all and was stranded permanently. Left optional so a tray placed
|
||||
* without one falls back to `direction`.
|
||||
*/
|
||||
facing?: 'n' | 's' | 'e' | 'w';
|
||||
position: NodeRef;
|
||||
movesUsed: number;
|
||||
};
|
||||
@@ -198,7 +223,21 @@ export type Transit = { tray: TrayId; stagesRemaining: number; direction: Direct
|
||||
|
||||
export type DivisionNode =
|
||||
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
|
||||
| { kind: 'mainline'; card: MainlineKind; transits: Transit[] }
|
||||
| {
|
||||
kind: 'mainline';
|
||||
card: MainlineKind;
|
||||
transits: Transit[];
|
||||
absSignals?: boolean;
|
||||
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
|
||||
modifiers?: string[];
|
||||
/**
|
||||
* Which way a train is travelling when it climbs. The Heavy Grade card prints "(Up)" and
|
||||
* "Player sets orientation", so the direction is chosen when the card is placed.
|
||||
*/
|
||||
gradeUp?: Direction;
|
||||
/** Red Flags protecting a stopped train here, by tray. */
|
||||
redFlagged?: TrayId[];
|
||||
}
|
||||
| { kind: 'office'; owner: PlayerIndex };
|
||||
|
||||
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
|
||||
@@ -328,6 +367,11 @@ export type TurnState = {
|
||||
option: 'switch' | 'draw' | 'freightAgent' | null;
|
||||
movesRemaining: number;
|
||||
drawnThisTurn: boolean;
|
||||
/**
|
||||
* Track was a CARD before it became a per-player supply, so it was naturally limited to one play
|
||||
* a turn. The supply has 26 pieces and nothing else bounds it, so keep that limit explicitly.
|
||||
*/
|
||||
laidThisTurn: boolean;
|
||||
freightAgentUsed: boolean;
|
||||
/** Set when the actor finishes; the phase driver then moves to the next player. */
|
||||
done: boolean;
|
||||
@@ -338,6 +382,7 @@ export function freshTurn(moves: number): TurnState {
|
||||
option: null,
|
||||
movesRemaining: moves,
|
||||
drawnThisTurn: false,
|
||||
laidThisTurn: false,
|
||||
freightAgentUsed: false,
|
||||
done: false,
|
||||
};
|
||||
|
||||
+21
-2
@@ -79,8 +79,9 @@ type PortPair = readonly [Port, Port];
|
||||
*/
|
||||
function connectionsFor(card: TrackCard): readonly PortPair[] {
|
||||
switch (card.geometry.kind) {
|
||||
// A Modifier is not track: nothing connects to it and no train may enter (§9).
|
||||
// Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter.
|
||||
case 'modifier':
|
||||
case 'spaceUse':
|
||||
return [];
|
||||
case 'limits':
|
||||
return [['e', 'w']];
|
||||
@@ -329,7 +330,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) {
|
||||
|
||||
+409
-41
@@ -25,7 +25,7 @@ import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { coordKey } from '../engine/state.ts';
|
||||
import type { Facility, GameState, PlayerIndex, RollingStock } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock } from '../engine/state.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
name: string;
|
||||
@@ -56,12 +56,37 @@ function ruleOnClearance(options: Intent[]): Intent | null {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Why the developer bot made its last choice, for the replay's decision panel.
|
||||
*
|
||||
* WRITE-ONLY DIAGNOSTIC. Nothing reads this to make a decision, so it cannot affect play or
|
||||
* determinism — it exists so the viewer can show the branch that fired instead of re-deriving the
|
||||
* reasoning, which would drift from the bot exactly as a second copy of the rules would.
|
||||
*/
|
||||
let lastReason = '';
|
||||
|
||||
export function lastChoiceReason(): string {
|
||||
return lastReason;
|
||||
}
|
||||
|
||||
/** Records the branch that fired and returns the intent unchanged. */
|
||||
function because(reason: string, intent: Intent): Intent {
|
||||
lastReason = reason;
|
||||
return intent;
|
||||
}
|
||||
|
||||
export const developerBot: BotPolicy = {
|
||||
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 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.
|
||||
//
|
||||
@@ -69,15 +94,21 @@ export const developerBot: BotPolicy = {
|
||||
// a worker. Then advance loads already in the pipeline — finishing beats starting, because a
|
||||
// load parked on MEN|AT|WORK also locks the industry track (§9.3). Only then feed new work in.
|
||||
if (s.clock.phase === 'loadUnload') {
|
||||
const work = pickFirst(
|
||||
options,
|
||||
'porter.board',
|
||||
'porter.detrain',
|
||||
'laborer.advanceLoad',
|
||||
'laborer.startLoad',
|
||||
'laborer.beginUnload',
|
||||
);
|
||||
return work ?? options.find((i) => i.type === 'loadUnload.end') ?? options[0]!;
|
||||
const work =
|
||||
pickFirst(options, 'porter.board', 'porter.detrain', 'laborer.advanceLoad') ??
|
||||
// Only START a load that can FINISH. A load leaves MEN|AT|WORK onto a spotted empty car of
|
||||
// its own type (§9.3), so starting one with no car spotted parks it on WORK — which locks
|
||||
// the industry track, which blocks the very car that would clear it. A self-inflicted
|
||||
// deadlock, and the loop that was eating the game: 415 loads started, 413 unjams, and 13
|
||||
// completions across 60 games.
|
||||
options.find((i) => i.type === 'laborer.startLoad' && loadCanFinish(s, player, i.at)) ??
|
||||
pickFirst(options, 'laborer.beginUnload');
|
||||
// NO fallback to an unfinishable load. Idling a Laborer is strictly better than jamming: a
|
||||
// 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.
|
||||
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.
|
||||
@@ -92,9 +123,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.
|
||||
@@ -127,28 +158,54 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
|
||||
|
||||
// 1. Trains first, always. A scheduled train runs EVERY Day thereafter, so it is the only card
|
||||
// whose value compounds. Playing one needs the draw option.
|
||||
if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!;
|
||||
// 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 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 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 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 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. */
|
||||
@@ -163,6 +220,15 @@ function isWorthTaking(s: GameState, slot: number): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Does any facility want this particular car? Tolerates an undefined end car. */
|
||||
function facilityWants2(s: GameState, player: PlayerIndex, car: RollingStock | undefined): boolean {
|
||||
return car !== undefined && facilitiesWanting(s, player, car).length > 0;
|
||||
}
|
||||
|
||||
function isEnhancement(s: GameState, cardId: string): boolean {
|
||||
return s.cards.get(cardId)?.kind.kind === 'enhancement';
|
||||
}
|
||||
|
||||
function isTrainCard(s: GameState, cardId: string): boolean {
|
||||
const k = s.cards.get(cardId)?.kind.kind;
|
||||
return k === 'timetabledTrain' || k === 'extraTrain';
|
||||
@@ -213,6 +279,137 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grow the district outward from the Office, preferring straights.
|
||||
*
|
||||
* Straights are what Enhancements attach to and what Freight Facilities sit beside, so they are
|
||||
* worth more than a turnout the bot has no plan for. Ties break toward the Office: a compact
|
||||
* district keeps Crew Moves cheap, and Moves are the real currency of Local Operations.
|
||||
*/
|
||||
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = s.officeAreas.get(player);
|
||||
if (!area) return null;
|
||||
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);
|
||||
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.
|
||||
if (dRow > 0) score += 3;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function isEnhancementKey(s: GameState, cardId: string, key: string): boolean {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
return k?.kind === 'enhancement' && k.key === key;
|
||||
}
|
||||
|
||||
function isMainlineKey(s: GameState, cardId: string, key: string): boolean {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
return k?.kind === 'mainlineModifier' && k.key === key;
|
||||
}
|
||||
|
||||
/** Does the facility at `coord` want this particular car? */
|
||||
function facilityWantsAt(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
coord: { row: number; col: number },
|
||||
car: { type: string; loaded: boolean } | undefined,
|
||||
): boolean {
|
||||
if (!car) return false;
|
||||
const f = areaOf(s, player).grid.get(`${coord.row},${coord.col}`)?.facility;
|
||||
return f ? facilityWants(f, car as never) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Red Flags — "any time". Worth spending only when a train of ours is stopped out on the Mainline
|
||||
* with another train on the same card, which is the situation that becomes a rear-ender.
|
||||
*/
|
||||
function worthFlagging(s: GameState, options: Intent[]): Intent | null {
|
||||
for (const i of options) {
|
||||
if (i.type !== 'maneuver.redFlags') continue;
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray || tray.position.at !== 'mainline') continue;
|
||||
const node = s.division.nodes[tray.position.index];
|
||||
if (node?.kind !== 'mainline') continue;
|
||||
if (node.transits.length > 1) return i;
|
||||
}
|
||||
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.
|
||||
*/
|
||||
function loadCanFinish(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
|
||||
const f = areaOf(s, player).grid.get(`${at.row},${at.col}`)?.facility;
|
||||
const next = f?.outboundBox[0];
|
||||
if (!f || !next) return false;
|
||||
return f.industryTrack.cars.some((c) => !c.loaded && c.type === next.type);
|
||||
}
|
||||
|
||||
/** Is this player's crew sitting somewhere it can never depart from? */
|
||||
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
|
||||
const area = areaOf(s, player);
|
||||
const here = trayLocation(s, player);
|
||||
if (!here) return false;
|
||||
return here.row !== area.officeCoord.row || here.col !== area.officeCoord.col;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Move that gets the crew strictly closer to the Office, or null if it is already there or
|
||||
* nothing helps. "Strictly closer" is what stops this becoming the shuttling loop that an earlier
|
||||
* version of the switching heuristic fell into — the bot cannot oscillate if every step reduces the
|
||||
* distance.
|
||||
*/
|
||||
function moveTowardOffice(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = areaOf(s, player);
|
||||
const here = trayLocation(s, player);
|
||||
if (!here) return null;
|
||||
const dist = (c: { row: number; col: number }): number =>
|
||||
Math.abs(c.row - area.officeCoord.row) + Math.abs(c.col - area.officeCoord.col);
|
||||
if (dist(here) === 0) return null;
|
||||
|
||||
let best: Intent | null = null;
|
||||
let bestDist = dist(here);
|
||||
for (const i of options) {
|
||||
if (i.type !== 'switch.move') continue;
|
||||
const d = dist(i.to);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Once an option is chosen, work it to a sensible conclusion. */
|
||||
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
|
||||
switch (s.turn.option) {
|
||||
@@ -226,35 +423,100 @@ 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);
|
||||
}
|
||||
// Train cards first — they take no placement and their value compounds every Day.
|
||||
// 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
|
||||
// Post, none at all where an Interlocking was down. A Depot doubles the capacity and turns
|
||||
// the Office into a Passenger Facility, which is where the porters and passenger revenue come
|
||||
// from. The bot previously had no play preference for office cards at all, so an upgrade sat
|
||||
// in hand behind trains, enhancements and track.
|
||||
const upgrade = options.find(
|
||||
(i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind === 'office',
|
||||
);
|
||||
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
|
||||
// everyone, where a grade card only helps trains going one way.
|
||||
const realign = options.find(
|
||||
(i) => i.type === 'mainline.modify' && isMainlineKey(s, i.cardId, 'realignment'),
|
||||
);
|
||||
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 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
|
||||
// another piece of plain track.
|
||||
// Interlocking ahead of the other enhancements: it holds an arrival at the Limits instead of
|
||||
// colliding into a full Office, and no collision was ever recorded in a district that had one.
|
||||
const interlock = options.find(
|
||||
(i) =>
|
||||
i.type === 'card.play' &&
|
||||
i.placement !== undefined &&
|
||||
isEnhancementKey(s, i.cardId, 'interlocking'),
|
||||
);
|
||||
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 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
|
||||
// STRAIGHTS that Enhancements require, and no Freight Facility has anywhere to go until a
|
||||
// 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 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': {
|
||||
// Re-check every Move, not just when choosing the option. Conditions change mid-turn — a car
|
||||
// gets coupled, a siding fills — and once there is nothing left to do the fall-through would
|
||||
// pick an arbitrary legal move and burn the remaining Moves shuttling.
|
||||
// Flying Switch is real work, so it must be weighed before concluding there is none left —
|
||||
// otherwise the go-home branch below preempts it and the card never fires.
|
||||
const flyingFirst = options.find(
|
||||
(i) =>
|
||||
i.type === 'maneuver.flyingSwitch' &&
|
||||
i.count === 1 &&
|
||||
facilityWantsAt(s, player, i.to, trayOf(s, player)?.consist.slice(-1)[0]),
|
||||
);
|
||||
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
|
||||
// anywhere else `moveTrain` returns 'held', permanently. The bot used to end its turn
|
||||
// wherever the work ran out, which stranded the crew: 77 of 93 trains still on the board at
|
||||
// game end were sitting somewhere they could never depart from, 34 on the Running Track at
|
||||
// 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 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.
|
||||
@@ -272,26 +534,99 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
const here = trayLocation(s, player);
|
||||
const endCar = tray && tray.consist.length > 0 ? tray.consist[tray.consist.length - 1]! : null;
|
||||
|
||||
// If standing on a Small Yard, re-order so a car some facility actually wants ends up on
|
||||
// the droppable end. This is the whole point of the card.
|
||||
if (tray && here) {
|
||||
const onYard = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.enhancements.includes('smallYard');
|
||||
if (onYard && !facilityWants2(s, player, tray.consist[tray.consist.length - 1])) {
|
||||
const wantedIdx = tray.consist.findIndex((c) => facilitiesWanting(s, player, c).length > 0);
|
||||
if (wantedIdx >= 0 && wantedIdx !== tray.consist.length - 1) {
|
||||
const order = [...tray.consist.keys()].filter((k) => k !== wantedIdx);
|
||||
order.push(wantedIdx);
|
||||
const sort = options.find(
|
||||
(i) => i.type === 'switch.sortConsist' && i.order.join() === order.join(),
|
||||
);
|
||||
if (sort) return because('standing on a Small Yard — re-order so a car a facility wants ends up droppable', sort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (endCar && here) {
|
||||
const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
|
||||
const area = areaOf(s, player);
|
||||
const hereCard = area.grid.get(`${here.row},${here.col}`);
|
||||
const hereFacility = hereCard?.facility ?? null;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Otherwise head for a facility that does want this particular car.
|
||||
const wanted = facilitiesWanting(s, player, endCar);
|
||||
// SET OUT — and do it BEFORE travelling.
|
||||
//
|
||||
// Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations
|
||||
// drops any route whose pickups would overflow the tray, so it can never collect the loaded
|
||||
// car it came for. And a crew whose back car is dead weight cannot deliver the wanted car
|
||||
// hiding behind it, because §A.3 only lets the back car come off.
|
||||
//
|
||||
// Order matters. With travel first, the crew drove to a facility, found its end car
|
||||
// undroppable, turned round for a spur, then drove back — the shuttling loop again, from a
|
||||
// third direction. Fixing the consist first means every subsequent trip ends in a drop.
|
||||
//
|
||||
// A plain spur is the place to do it: dropping onto an industry track would silt it with the
|
||||
// wrong commodity, and the Running Track has to stay clear.
|
||||
const endCarUseless =
|
||||
tray !== null &&
|
||||
tray.consist.length > 1 &&
|
||||
facilitiesWanting(s, player, endCar).length === 0 &&
|
||||
tray.consist.some((c) => facilitiesWanting(s, player, c).length > 0);
|
||||
|
||||
if (tray && (tray.consist.length >= MAX_CONSIST || endCarUseless)) {
|
||||
const isSpur = here.row !== area.runningRow && !hereFacility;
|
||||
if (isSpur) {
|
||||
const setOut = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
|
||||
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) =>
|
||||
i.type === 'switch.move' &&
|
||||
i.to.row !== area.runningRow &&
|
||||
!area.grid.get(`${i.to.row},${i.to.col}`)?.facility,
|
||||
);
|
||||
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
|
||||
// the end car alone was the freight loop's real blocker: 744 switch turns produced 135 Moves
|
||||
// and 741 immediate ends, because a crew holding wanted cars behind an unwanted one decided
|
||||
// there was nothing to do. §A.3 makes the back car the only DROPPABLE one; it does not make
|
||||
// it the only one worth travelling for.
|
||||
const wanted = tray
|
||||
? tray.consist.flatMap((c) => facilitiesWanting(s, player, c))
|
||||
: facilitiesWanting(s, player, endCar);
|
||||
const toward = options.find(
|
||||
(i) =>
|
||||
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);
|
||||
}
|
||||
|
||||
const move = options.find((i) => i.type === 'switch.move');
|
||||
if (move) return move;
|
||||
return options.find((i) => i.type === 'switch.end') ?? options[0]!;
|
||||
// Never take an arbitrary Move. Picking "the first legal move" is what produced the original
|
||||
// shuttling bug, and it produced it again here: with the crew stranded but `usefulSwitching`
|
||||
// still true, the go-home branch above is skipped and this fallback burned the remaining
|
||||
// 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 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': {
|
||||
@@ -300,13 +635,13 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
// rescue it. §6.3's unjam exists for exactly this. Clear it before anything else.
|
||||
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:
|
||||
@@ -410,6 +745,26 @@ function usefulSwitching(s: GameState, player: PlayerIndex): boolean {
|
||||
}
|
||||
if (sidingsWorthCollecting(s, player).length > 0) return true;
|
||||
|
||||
// A FULL consist is itself work. At MAX_CONSIST the crew cannot couple anything, so every loaded
|
||||
// car the district produces is unreachable until cars are set out. Without this clause the bot
|
||||
// ended its turn the moment no facility wanted the end car, which is precisely when a full crew
|
||||
// most needs to break itself up.
|
||||
if (tray.consist.length >= MAX_CONSIST) {
|
||||
for (const card of areaOf(s, player).grid.values()) {
|
||||
if (card.facility && card.facility.kind === 'freight') return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Standing on a Small Yard with a wanted car buried in the consist is work worth doing.
|
||||
const here = trayLocation(s, player);
|
||||
if (here) {
|
||||
const card = areaOf(s, player).grid.get(`${here.row},${here.col}`);
|
||||
if (card?.enhancements.includes('smallYard')) {
|
||||
const buried = tray.consist.findIndex((c) => facilitiesWanting(s, player, c).length > 0);
|
||||
if (buried >= 0 && buried !== tray.consist.length - 1) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// A car left standing on ordinary track is also worth lifting if some facility wants it — the
|
||||
// hopper stranded on the Running Track that the crew kept driving past.
|
||||
if (tray.consist.length < MAX_CONSIST) {
|
||||
@@ -514,11 +869,23 @@ export type PlayOutcome = {
|
||||
* Drives a game to completion with the given policy. This is the whole reason the phase driver
|
||||
* lives inside the engine: it is a plain loop over `advance` and `applyIntent`, with no server.
|
||||
*/
|
||||
/**
|
||||
* Watches each event as it fires, with the state as it stands at that moment.
|
||||
*
|
||||
* This exists because the returned `events` log answers "what happened" but not "what was true when
|
||||
* it happened" — the Office tier at a collision, which cards were in hand at a draw. Measuring those
|
||||
* meant hand-rolling this loop in a throwaway script, and a hand-rolled copy that watched only the
|
||||
* `pump` events (and not the ones from `applyIntent`) reported "60 of 60 games never upgraded" while
|
||||
* the tier histogram plainly showed Depots and Stations. One driver, one place to observe.
|
||||
*/
|
||||
export type PlayObserver = (event: GameEvent, state: GameState) => void;
|
||||
|
||||
export function playGame(
|
||||
s: GameState,
|
||||
policy: BotPolicy,
|
||||
pumpFn: (s: GameState) => GameEvent[],
|
||||
maxTurns = 50_000,
|
||||
observe?: PlayObserver,
|
||||
): PlayOutcome {
|
||||
const events: GameEvent[] = [];
|
||||
const intents: Intent['type'][] = [];
|
||||
@@ -530,6 +897,7 @@ export function playGame(
|
||||
const tally = (batch: GameEvent[]): void => {
|
||||
events.push(...batch);
|
||||
for (const e of batch) {
|
||||
observe?.(e, s);
|
||||
if (e.type === 'trainScheduled') trainsScheduled++;
|
||||
else if (e.type === 'cardPlayed') cardsPlayed++;
|
||||
else if (
|
||||
|
||||
+63
-3
@@ -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
|
||||
@@ -124,6 +134,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
where: e.at,
|
||||
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
|
||||
};
|
||||
case 'consistSorted':
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
};
|
||||
case 'carsDropped':
|
||||
return {
|
||||
tone: 'plain',
|
||||
@@ -148,6 +164,30 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
? `Played ${card(e.cardId)} onto ${at(e.placement)}`
|
||||
: `Played ${card(e.cardId)}`,
|
||||
};
|
||||
case 'mainlineModified':
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.became
|
||||
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
|
||||
: `Played ${e.key} on Mainline card ${e.node}`,
|
||||
};
|
||||
case 'redFlagsSet':
|
||||
return {
|
||||
tone: 'good',
|
||||
text: `Red Flags set out to protect train ${e.trayId} on Mainline card ${e.node} — an approaching train must stop`,
|
||||
};
|
||||
case 'flyingSwitch':
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.to,
|
||||
text: `Flying Switch — ${e.stock.length} car(s) cut loose and rolled into the industry at ${at(e.to)} without the engine entering`,
|
||||
};
|
||||
case 'trackLaid':
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Laid ${e.hand === 'none' ? '' : e.hand + '-hand '}${e.geometry} track at ${at(e.at)} — ${e.remaining} left in the supply`,
|
||||
};
|
||||
case 'officeUpgraded':
|
||||
return { tone: 'good', text: `OFFICE UPGRADED — ${e.from} → ${e.to}` };
|
||||
case 'cardDiscarded':
|
||||
@@ -169,6 +209,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
tone: 'good',
|
||||
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
|
||||
};
|
||||
case 'enhancementPlaced':
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `ENHANCEMENT built: ${e.key.replace(/([A-Z])/g, ' $1')} at ${at(e.at)}`,
|
||||
};
|
||||
case 'secondSectionOrdered':
|
||||
return {
|
||||
tone: 'bad',
|
||||
@@ -194,6 +240,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
tone: 'plain',
|
||||
text: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it will highball again next Mainline Phase, so any work must happen now`,
|
||||
};
|
||||
case 'trainDiverted':
|
||||
return {
|
||||
tone: 'good',
|
||||
text: `Train ${e.trainNumber} DIVERTED to ${e.to} — ${e.reason}`,
|
||||
};
|
||||
case 'trainCompleted':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
@@ -238,17 +289,26 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
|
||||
case 'carPassed':
|
||||
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
|
||||
case 'dispatchBonusUsed':
|
||||
return {
|
||||
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 '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
|
||||
|
||||
+123
-261
@@ -23,262 +23,24 @@ 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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -299,10 +61,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);
|
||||
@@ -327,7 +94,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(
|
||||
@@ -344,8 +120,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);
|
||||
}
|
||||
|
||||
@@ -362,6 +141,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>
|
||||
@@ -431,6 +231,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>
|
||||
@@ -454,6 +269,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>
|
||||
@@ -467,7 +283,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
|
||||
@@ -484,6 +300,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>
|
||||
@@ -495,7 +313,14 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const FRAMES = ${JSON.stringify(rec.frames)};
|
||||
const FRAMES = ${JSON.stringify(compress(rec.frames))};
|
||||
/* The board changes rarely, so cells/facilities/division are emitted as null when identical to the
|
||||
previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of
|
||||
a 5.2 MB page. The scrubber jumps anywhere, hence the walk rather than a running pointer. */
|
||||
function carry(i, key) {
|
||||
for (let k = i; k >= 0; k--) if (FRAMES[k][key] !== null) return FRAMES[k][key];
|
||||
return [];
|
||||
}
|
||||
let i = 0, timer = null;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
||||
@@ -542,8 +367,13 @@ function render() {
|
||||
$('fno').textContent = i;
|
||||
$('scrub').value = i;
|
||||
|
||||
$('division').innerHTML = f.division.map((n) =>
|
||||
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) + '</div>' +
|
||||
const CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
||||
|
||||
$('division').innerHTML = DIV.map((n) =>
|
||||
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) +
|
||||
(n.gradeUp ? ' <span class="grade">' + (n.gradeUp === 'east' ? 'climbs E ▲' : '▲ W climbs') + '</span>' : '') +
|
||||
'</div>' +
|
||||
(n.modifiers.length ? '<div class="mlmods">' + n.modifiers.map(esc).join(' · ') + '</div>' : '') +
|
||||
'<div class="regions">' + n.trains.map((r) =>
|
||||
'<div class="region">' + r.map((t) =>
|
||||
'<span class="chip">' + esc(t.label) + '</span>' +
|
||||
@@ -551,14 +381,14 @@ function render() {
|
||||
).join('') + '</div>').join('') +
|
||||
'</div></div>').join('');
|
||||
|
||||
const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col);
|
||||
const rows = CELLS.map((c) => c.row), cols = CELLS.map((c) => c.col);
|
||||
const r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols);
|
||||
const g = $('grid');
|
||||
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
|
||||
let cellsHtml = '';
|
||||
for (let r = r1; r >= r0; r--) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const cell = f.cells.find((x) => x.row === r && x.col === c);
|
||||
const cell = CELLS.find((x) => x.row === r && x.col === c);
|
||||
if (!cell) { cellsHtml += '<div></div>'; continue; }
|
||||
const hl = f.where && f.where.row === r && f.where.col === c;
|
||||
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
|
||||
@@ -566,6 +396,9 @@ function render() {
|
||||
(cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
|
||||
'<div class="nm">' + esc(cell.label) + '</div>' +
|
||||
'<div class="dim" style="font-size:10px">(' + r + ',' + c + ')</div>' +
|
||||
(cell.enhancements.length
|
||||
? '<div class="enh">' + cell.enhancements.map(esc).join(' · ') + '</div>'
|
||||
: '') +
|
||||
(cell.tray ? '<div class="crew">🚂 ' + esc(cell.tray) + '</div>' : '') +
|
||||
(cell.facility ? miniFacility(cell.facility) : '') +
|
||||
(cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
|
||||
@@ -574,9 +407,9 @@ function render() {
|
||||
}
|
||||
g.innerHTML = cellsHtml;
|
||||
|
||||
$('facs').innerHTML = f.facilities.length === 0
|
||||
$('facs').innerHTML = FACS.length === 0
|
||||
? '<span class="dim">no freight facilities built yet</span>'
|
||||
: f.facilities.map((x) =>
|
||||
: FACS.map((x) =>
|
||||
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
|
||||
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
|
||||
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
|
||||
@@ -584,9 +417,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('');
|
||||
@@ -603,7 +461,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;
|
||||
@@ -614,6 +472,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';
|
||||
@@ -631,6 +491,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();
|
||||
|
||||
@@ -220,6 +220,9 @@ const EXPECTED_EVENTS = [
|
||||
'carPlacedOnTrain',
|
||||
'trayMoved',
|
||||
'carsCoupled',
|
||||
'mainlineModified',
|
||||
'redFlagsSet',
|
||||
'flyingSwitch',
|
||||
'carsDropped',
|
||||
'cardDrawn',
|
||||
'cardPlayed',
|
||||
|
||||
+733
@@ -0,0 +1,733 @@
|
||||
/**
|
||||
* 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 { 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;
|
||||
/**
|
||||
* 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[][];
|
||||
/** Modifiers laid on a Mainline card (Brakeman, Helpers, Realignment …). */
|
||||
modifiers: string[];
|
||||
/**
|
||||
* Which way a Heavy Grade climbs. The card prints "(Up)" and "Player sets orientation", so the
|
||||
* direction is a property of the placed card — and without showing it, a Brakeman or Helpers card
|
||||
* on that grade has no visible meaning.
|
||||
*/
|
||||
gradeUp: string | null;
|
||||
};
|
||||
|
||||
export type Frame = {
|
||||
day: number;
|
||||
stage: number;
|
||||
clock: string;
|
||||
phase: string;
|
||||
actor: number | null;
|
||||
superintendent: number;
|
||||
revenue: number;
|
||||
lines: { text: string; tone: string }[];
|
||||
where: { row: number; col: number } | null;
|
||||
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
|
||||
whereFrom: { row: number; col: number } | null;
|
||||
division: DivisionView[];
|
||||
cells: CellView[];
|
||||
facilities: FacilityView[];
|
||||
hand: string[];
|
||||
/** What each hand card does, in the same order — names alone are not a playable hand. */
|
||||
handWhat: string[];
|
||||
deck: number;
|
||||
departments: string[];
|
||||
/** What each face-up Department card does. */
|
||||
departmentsWhat: string[];
|
||||
/** 12 slots; the train number due out at each Stage, or null. */
|
||||
timetable: (number | null)[];
|
||||
blocked: Impediment[];
|
||||
trains: { label: string; where: string }[];
|
||||
/**
|
||||
* Where you stand against the target. Nothing on screen said what the game was FOR, so a player
|
||||
* had the score but no way to know whether it was good.
|
||||
*/
|
||||
objective: { target: number; days: number; daysLeft: number; onPace: boolean; note: string };
|
||||
/**
|
||||
* What is left of the player's personal track supply (§12.2). Track is NOT drawn from the deck —
|
||||
* each player starts with 26 pieces and lays at most one a turn, so "how many straights have I
|
||||
* got left" is a real planning question the board could not answer.
|
||||
*/
|
||||
trackSupply: { piece: string; left: number }[];
|
||||
/** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */
|
||||
decision: Decision | null;
|
||||
/** A Local Operations turn that changed nothing — the frames worth your attention. */
|
||||
wasted: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The choice behind a frame.
|
||||
*
|
||||
* The whole point is `rejected`: the replay could always show what happened, never what COULD have
|
||||
* happened, so a daft move was visible but the alternatives it passed over were not — which is
|
||||
* exactly what you need to say what it should have done instead.
|
||||
*/
|
||||
export type Decision = {
|
||||
actor: number;
|
||||
chose: string;
|
||||
why: string;
|
||||
/** Every legal option not taken, grouped by kind with a count. */
|
||||
rejected: { kind: string; count: number; detail: string }[];
|
||||
totalOptions: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshotting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FACILITY_NAMES: Record<string, string> = {
|
||||
mineTipple: 'Mine Tipple',
|
||||
produceShed: 'Produce Shed',
|
||||
grocersWarehouse: "Grocer's Warehouse",
|
||||
oilRefinery: 'Oil Refinery',
|
||||
powerPlant: 'Power Plant',
|
||||
};
|
||||
|
||||
function facilityView(
|
||||
card: { geometry: { kind: string; facility?: string }; facility: unknown },
|
||||
officeName: string,
|
||||
): FacilityView | null {
|
||||
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
|
||||
// Passenger facilities were excluded entirely, so the Office's green and red slots never
|
||||
// appeared — which is why stocking a coach into the green box looked like nothing happening.
|
||||
if (!f) return null;
|
||||
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
|
||||
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
|
||||
return {
|
||||
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? 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':
|
||||
return `add ${i.loaded ? 'loaded' : 'empty'} ${i.carType}`;
|
||||
case 'mainline.modify':
|
||||
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
|
||||
case 'maneuver.redFlags':
|
||||
return `Red Flags on ${i.trayId}`;
|
||||
case 'maneuver.flyingSwitch':
|
||||
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
|
||||
case 'mainline.clearance': {
|
||||
// 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';
|
||||
return i.allow
|
||||
? `ALLOW — ${who} follows ${ahead} into the same Subdivision (risks a collision, −5 Revenue)`
|
||||
: `HOLD — ${who} waits where it is: safe, but it loses the Stage`;
|
||||
}
|
||||
case 'draw.fromDepartment': {
|
||||
// Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and
|
||||
// the choice between a visible card and a blind draw is unmakeable without it.
|
||||
const id = s.decks.departments[i.slot];
|
||||
return id ? `take ${cardName(s, id)} (face-up slot ${i.slot + 1})` : `slot ${i.slot + 1} (empty)`;
|
||||
}
|
||||
case 'draw.fromHomeOffice':
|
||||
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
|
||||
case 'draw.end':
|
||||
return 'done drawing — end my turn';
|
||||
case 'switch.end':
|
||||
return 'done switching — end my turn';
|
||||
case 'loadUnload.end':
|
||||
return 'done working — end the Load/Unload phase';
|
||||
case 'newTrain.passCar':
|
||||
return 'add no more cars to this train';
|
||||
case 'newTrain.secondSection':
|
||||
return `run a Second Section behind Train ${i.trainNumber}`;
|
||||
case 'redFlag.play':
|
||||
return 'play your red flag';
|
||||
case 'maneuver.redFlags':
|
||||
return `Red Flags on ${i.trayId}`;
|
||||
default: {
|
||||
// Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes
|
||||
// that a COMPILE error the day someone adds an intent without describing it — the playable UI
|
||||
// labels its buttons from this function, so a missing case ships as a button reading
|
||||
// "maneuver.poling".
|
||||
const unhandled: never = i;
|
||||
return String((unhandled as { type: string }).type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the view-model for a state. Shared with the playable web app so the live game and the
|
||||
* replay cannot drift into two different pictures of the same board.
|
||||
*/
|
||||
export function snapshot(
|
||||
s: GameState,
|
||||
lines: { text: string; tone: string }[],
|
||||
where: { row: number; col: number } | null,
|
||||
whereFrom: { row: number; col: number } | null = null,
|
||||
decision: Decision | null = null,
|
||||
wasted = false,
|
||||
): Frame {
|
||||
const area = areaOf(s, 0);
|
||||
const trayAt = new Map<string, string>();
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at === 'grid') {
|
||||
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
|
||||
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
|
||||
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
|
||||
}
|
||||
}
|
||||
|
||||
const cells: CellView[] = [];
|
||||
const facilities: FacilityView[] = [];
|
||||
for (const [key, card] of area.grid) {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
const g = card.geometry;
|
||||
const kind = g.kind;
|
||||
let label: string;
|
||||
if (g.kind === 'office') label = officeProfile(area.tier).name;
|
||||
else if (g.kind === 'limits') label = 'Limits';
|
||||
// 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),
|
||||
enhancements: card.enhancements.map(prettyKey),
|
||||
tray: trayAt.get(key) ?? null,
|
||||
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
|
||||
facility: fv,
|
||||
});
|
||||
}
|
||||
|
||||
const division: DivisionView[] = s.division.nodes.map((n) => {
|
||||
if (n.kind === 'divisionPoint') {
|
||||
return {
|
||||
kind: 'dp',
|
||||
label: n.side === 'west' ? 'West DP' : 'East DP',
|
||||
trains: [n.holding.map((id) => trainChip(s, id))],
|
||||
modifiers: [],
|
||||
gradeUp: null,
|
||||
};
|
||||
}
|
||||
if (n.kind === 'mainline') {
|
||||
// Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it
|
||||
// with how long each still has to run.
|
||||
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
|
||||
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
|
||||
return {
|
||||
kind: 'ml',
|
||||
label: name,
|
||||
trains: [n.transits.map((t) => {
|
||||
const chip = trainChip(s, t.tray);
|
||||
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
|
||||
})],
|
||||
modifiers: [
|
||||
...(n.modifiers ?? []).map(prettyKey),
|
||||
...(n.absSignals ? ['ABS Signals'] : []),
|
||||
],
|
||||
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'office',
|
||||
label: officeProfile(areaOf(s, n.owner).tier).name,
|
||||
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
|
||||
modifiers: [],
|
||||
gradeUp: null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
day: s.clock.day,
|
||||
stage: s.clock.stage,
|
||||
clock: clockTime(s.clock.stage),
|
||||
phase: phaseLabel(s.clock.phase),
|
||||
actor: s.clock.currentActor,
|
||||
superintendent: s.clock.superintendent,
|
||||
revenue: s.players[0]?.revenue ?? 0,
|
||||
lines,
|
||||
where,
|
||||
whereFrom,
|
||||
division,
|
||||
cells,
|
||||
facilities,
|
||||
hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
|
||||
handWhat: (s.decks.hands.get(0) ?? []).map((id) => cardDescription(s, id)),
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
|
||||
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
|
||||
timetable: [...s.timetable],
|
||||
decision,
|
||||
wasted,
|
||||
objective: objectiveOf(s),
|
||||
trackSupply: [...area.trackSupply.entries()]
|
||||
.map(([key, left]) => {
|
||||
const [geometry, hand] = key.split(':');
|
||||
return { piece: `${hand === 'none' ? '' : hand + '-hand '}${geometryLabel(geometry ?? '')}`, left };
|
||||
})
|
||||
.sort((a, b) => a.piece.localeCompare(b.piece)),
|
||||
blocked: impediments(s, 0),
|
||||
trains: [...s.trays.values()].map((t) => ({
|
||||
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
where:
|
||||
t.position.at === 'divisionPoint'
|
||||
? `${t.position.side} Division Point`
|
||||
: t.position.at === 'mainline'
|
||||
? `Mainline card ${t.position.index}`
|
||||
: `Office Area (${t.position.coord.row},${t.position.coord.col})`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** A card id turned into something a person can read. */
|
||||
export function cardName(s: GameState, id: string): string {
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return 'a card';
|
||||
switch (k.kind) {
|
||||
case 'timetabledTrain':
|
||||
return `Train ${k.number}`;
|
||||
case 'extraTrain':
|
||||
return `Extra X${k.number}`;
|
||||
case 'office':
|
||||
return `${k.tier.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())} upgrade`;
|
||||
// Fall back to prettyKey, never to the raw key: an unnamed card showed as "rotaryDumps" on a
|
||||
// button a player is meant to read. The lookup tables are for names prettyKey cannot guess
|
||||
// (Grocer's Warehouse), not the only source of a readable label.
|
||||
case 'freightFacility':
|
||||
return FACILITY_NAMES[k.facility] ?? prettyKey(k.facility);
|
||||
case 'modifier':
|
||||
return MODIFIER_NAMES[k.modifier] ?? prettyKey(k.modifier);
|
||||
case 'track':
|
||||
return `${geometryLabel(k.geometry)} track`;
|
||||
case 'spaceUse':
|
||||
case 'enhancement':
|
||||
case 'mainlineModifier':
|
||||
case 'maneuver':
|
||||
case 'action':
|
||||
return prettyKey(k.key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a card actually DOES, in one line.
|
||||
*
|
||||
* The hand showed names only, so "Steam Turbines" or "Facing Point Locks" told a player nothing
|
||||
* about the effect — the difference between playing the game and clicking hopefully. Every fact
|
||||
* here already existed in the content tables; none of it was reaching the screen.
|
||||
*/
|
||||
export function cardDescription(s: GameState, id: string): string {
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return '';
|
||||
|
||||
switch (k.kind) {
|
||||
case 'timetabledTrain':
|
||||
case 'extraTrain': {
|
||||
const t = trainProfile(k.number, k.kind === 'extraTrain');
|
||||
if (!t) return '';
|
||||
const parts: string[] = [];
|
||||
if (t.consist.freight > 0) {
|
||||
const types = t.consist.freightTypes;
|
||||
parts.push(`${t.consist.freight} freight${types ? ` (${types.join('/')})` : ''}`);
|
||||
}
|
||||
if (t.consist.coach > 0) parts.push(`${t.consist.coach} coach`);
|
||||
if (t.consist.caboose > 0) parts.push('caboose');
|
||||
const extra = k.kind === 'extraTrain' ? 'runs ONCE, unscheduled' : 'runs every Day once scheduled';
|
||||
// Several Extras leave the direction to the player, so "playerChoicebound" is not a word.
|
||||
const dir = t.direction === 'playerChoice' ? 'either direction' : `${t.direction}bound`;
|
||||
const rule = t.rules.note ? ` · ${t.rules.note}` : '';
|
||||
return `${t.name} · ${t.speed}, ${dir} · ${parts.join(' + ') || 'no cars'} · ${extra}${rule}`;
|
||||
}
|
||||
case 'office': {
|
||||
const p = officeProfile(k.tier);
|
||||
// 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): 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';
|
||||
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load`;
|
||||
}
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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' },
|
||||
{ prefix: 'maneuver.', title: 'Manoeuvres' },
|
||||
{ prefix: 'freightAgent.', title: 'Freight Agent' },
|
||||
{ prefix: 'newTrain.', title: 'Making up the train' },
|
||||
{ prefix: 'porter.', title: 'Porters' },
|
||||
{ prefix: 'laborer.', title: 'Laborers' },
|
||||
{ prefix: 'loadUnload.', title: 'Finish' },
|
||||
{ prefix: 'redFlag.', title: 'Red flag' },
|
||||
];
|
||||
|
||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: ['You'] });
|
||||
const game: Game = { state, seed, history: [], log: [] };
|
||||
drain(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the engine forward until it needs a decision.
|
||||
*
|
||||
* Most of a Stage is automatic — the Mainline Phase moves trains, the clock turns over — so the
|
||||
* player is only ever asked when `advance` genuinely stops.
|
||||
*/
|
||||
export function drain(game: Game): void {
|
||||
record(game, pump(game.state));
|
||||
}
|
||||
|
||||
/** Whose turn it is, or null if the game is over or waiting on nothing. */
|
||||
export function currentActor(game: Game): PlayerIndex | null {
|
||||
if (game.state.status !== 'active') return null;
|
||||
return game.state.clock.pendingDecision !== null
|
||||
? game.state.clock.superintendent
|
||||
: game.state.clock.currentActor;
|
||||
}
|
||||
|
||||
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
|
||||
export function actionGroups(game: Game): { options: Intent[]; groups: ActionGroup[] } {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) return { options: [], groups: [] };
|
||||
|
||||
const options = legalActions(game.state, actor);
|
||||
const byKind = new Map<string, { index: number; label: string }[]>();
|
||||
options.forEach((intent, index) => {
|
||||
const label = describeIntent(game.state, intent);
|
||||
const list = byKind.get(intent.type) ?? [];
|
||||
// Orientation variants and duplicate copies describe identically; showing one is enough, and a
|
||||
// list of forty identical rows hides the real choice rather than presenting it.
|
||||
if (!list.some((a) => a.label === label)) list.push({ index, label });
|
||||
byKind.set(intent.type, list);
|
||||
});
|
||||
|
||||
const groups: ActionGroup[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const { prefix, title } of GROUP_ORDER) {
|
||||
const kinds = [...byKind.keys()].filter((k) => k.startsWith(prefix) && !used.has(k));
|
||||
const actions = kinds.flatMap((k) => {
|
||||
used.add(k);
|
||||
return byKind.get(k) ?? [];
|
||||
});
|
||||
if (actions.length > 0) groups.push({ kind: prefix, title, actions });
|
||||
}
|
||||
// Anything the table above does not name still has to be offered — silently dropping a legal
|
||||
// action would make the game unplayable in a way that is very hard to notice.
|
||||
const leftovers = [...byKind.entries()].filter(([k]) => !used.has(k));
|
||||
for (const [kind, actions] of leftovers) groups.push({ kind, title: kind, actions });
|
||||
|
||||
return { options, groups };
|
||||
}
|
||||
|
||||
/**
|
||||
* A thing you might do, and — if it goes on the board — where it could go.
|
||||
*
|
||||
* Placeable actions are presented as SUBJECT then LOCATION rather than as one flat list of every
|
||||
* (card x square x rotation) combination. A single turn offered 29 track buttons and 7 card buttons
|
||||
* with no way to tell which square each referred to; picking the card first and the square second is
|
||||
* how the choice is actually made at the table.
|
||||
*/
|
||||
export type Placeable = {
|
||||
/** Groups every option that plays the same card or lays the same piece. */
|
||||
subjectKey: string;
|
||||
subject: string;
|
||||
/**
|
||||
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
|
||||
* piece has more than one legal rotation there, which is why the label carries the rotation too.
|
||||
*/
|
||||
spots: { label: string; index: number; coord: { row: number; col: number } }[];
|
||||
};
|
||||
|
||||
export type Menu = {
|
||||
options: Intent[];
|
||||
/** Actions with no further choice to make. */
|
||||
direct: ActionGroup[];
|
||||
/** Actions needing a location, grouped under their card or track piece. */
|
||||
placeable: { title: string; items: Placeable[] }[];
|
||||
};
|
||||
|
||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||||
export function actionMenu(game: Game): Menu {
|
||||
const { options, groups } = actionGroups(game);
|
||||
const direct: ActionGroup[] = [];
|
||||
const placeableByTitle = new Map<string, Map<string, Placeable>>();
|
||||
|
||||
for (const g of groups) {
|
||||
const plain: ActionGroup['actions'] = [];
|
||||
for (const a of g.actions) {
|
||||
const intent = options[a.index]!;
|
||||
const key = subjectOf(game, intent);
|
||||
if (key === null) {
|
||||
plain.push(a);
|
||||
continue;
|
||||
}
|
||||
const bucket = placeableByTitle.get(g.title) ?? new Map<string, Placeable>();
|
||||
const entry = bucket.get(key.subjectKey) ?? {
|
||||
subjectKey: key.subjectKey,
|
||||
subject: key.subject,
|
||||
spots: [],
|
||||
};
|
||||
if (!entry.spots.some((sp) => sp.label === key.spot)) {
|
||||
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord });
|
||||
}
|
||||
bucket.set(key.subjectKey, entry);
|
||||
placeableByTitle.set(g.title, bucket);
|
||||
}
|
||||
if (plain.length > 0) direct.push({ ...g, actions: plain });
|
||||
}
|
||||
|
||||
const placeable = [...placeableByTitle.entries()].map(([title, m]) => ({
|
||||
title,
|
||||
items: [...m.values()],
|
||||
}));
|
||||
return { options, direct, placeable };
|
||||
}
|
||||
|
||||
/** Split an intent into "what" and "where", or null if it needs no location. */
|
||||
function subjectOf(
|
||||
game: Game,
|
||||
i: Intent,
|
||||
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
||||
if (i.type === 'card.play' && i.placement) {
|
||||
return {
|
||||
subjectKey: `card:${i.cardId}`,
|
||||
subject: cardName(game.state, i.cardId),
|
||||
spot: `${at(i.placement)}${rotationNote(null, i.variant)}`,
|
||||
coord: i.placement,
|
||||
};
|
||||
}
|
||||
if (i.type === 'track.lay') {
|
||||
const hand = i.hand === 'none' ? '' : `${i.hand}-hand `;
|
||||
return {
|
||||
subjectKey: `track:${i.geometry}:${i.hand}`,
|
||||
subject: `${hand}${geometryLabel(i.geometry)}`,
|
||||
spot: `${at(i.placement)}${rotationNote(i.geometry, i.variant)}`,
|
||||
coord: i.placement,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
|
||||
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
|
||||
* decision. Only shown when there is more than one way to lay the piece.
|
||||
*/
|
||||
function rotationNote(geometry: TrackGeometry | null, variant: number | undefined): string {
|
||||
if (geometry === null) return variant === undefined || variant === 0 ? '' : ` — option ${variant + 1}`;
|
||||
return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : '';
|
||||
}
|
||||
|
||||
/** Submit an action. Returns false and changes nothing if the engine rejects it. */
|
||||
export function submit(game: Game, intent: Intent): boolean {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) return false;
|
||||
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
if (!result.ok) {
|
||||
game.log.push({ text: `That is not allowed: ${result.code}`, tone: 'bad' });
|
||||
return false;
|
||||
}
|
||||
game.history.push(intent);
|
||||
record(game, result.events);
|
||||
drain(game);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,132 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Station Master — solitaire</title>
|
||||
<style>
|
||||
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
h1{font-size:17px;margin:0}
|
||||
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);
|
||||
margin:0 0 6px;font-weight:600}
|
||||
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
|
||||
.dim{color:var(--dim)}
|
||||
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
|
||||
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
|
||||
header b{font-size:16px}
|
||||
.build{margin-left:auto;font-size:11px;opacity:.7}
|
||||
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
|
||||
.pace.good{background:rgba(40,140,60,.32)}
|
||||
.pace.behind{background:rgba(190,120,40,.28)}
|
||||
.handcard{border-top:1px solid var(--line);padding:4px 0}
|
||||
.handcard:first-child{border-top:0}
|
||||
.handcard .what{font-size:11px;line-height:1.35}
|
||||
.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:4px;font-size:11px;padding:2px 5px;border-radius:3px;display:inline-block}
|
||||
.fstat.good{background:rgba(40,140,60,.28)}
|
||||
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
||||
.fstat.idle{opacity:.6}
|
||||
/* actions */
|
||||
#actions{max-height:none}
|
||||
.grp{margin-bottom:6px}
|
||||
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
|
||||
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
|
||||
button:hover{background:#39424e;border-color:#4d6fa8}
|
||||
button.act{display:inline-block}
|
||||
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
|
||||
.over.win{background:rgba(40,140,60,.35)}
|
||||
.over.loss{background:rgba(160,60,60,.3)}
|
||||
/* cards, log, blocked */
|
||||
.card.gone{opacity:.35;text-decoration:line-through}
|
||||
.subj{display:block;width:100%;margin:2px 0}
|
||||
.subj.open{border-color:#4d6fa8;background:#39424e}
|
||||
.spots{margin:0 0 6px 12px;padding-left:8px;border-left:2px solid #4d6fa8}
|
||||
.card{display:inline-block;background:#242c36;border:1px solid var(--line);border-radius:4px;
|
||||
padding:2px 6px;margin:2px 2px 0 0;font-size:11px}
|
||||
#log{max-height:230px;overflow:auto;font-size:12px}
|
||||
.line{padding:1px 0}
|
||||
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
|
||||
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
|
||||
ul.blocked li{padding:2px 0}
|
||||
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<b>Station Master</b>
|
||||
<span id="clock">—</span>
|
||||
<span>phase: <b id="phase">—</b></span>
|
||||
<span>Revenue <b id="revenue">0</b></span>
|
||||
<span id="objective" class="pace">—</span>
|
||||
<span class="dim">seed <span id="seed">—</span></span>
|
||||
<span class="dim">saved in this browser · add ?seed=1234 to the address for a set deal</span>
|
||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
||||
<section><h2>Your Office Area <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>
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Browser entry point — wires the DOM to `game.ts`.
|
||||
*
|
||||
* Presentation only. Every question of what is legal, what it means, or what the board looks like
|
||||
* is answered by the engine or by the shared view helpers.
|
||||
*/
|
||||
|
||||
import type { Game } from './game.ts';
|
||||
import {
|
||||
actionMenu,
|
||||
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 = f.division
|
||||
.map(
|
||||
(n) =>
|
||||
`<div class="node ${n.kind === 'office' ? 'office' : ''}">` +
|
||||
`<div class="nm">${esc(n.label)}` +
|
||||
(n.gradeUp
|
||||
? ` <span class="grade">${n.gradeUp === 'east' ? 'climbs E ▲' : '▲ W climbs'}</span>`
|
||||
: '') +
|
||||
`</div>` +
|
||||
(n.modifiers.length ? `<div class="mlmods">${n.modifiers.map(esc).join(' · ')}</div>` : '') +
|
||||
`<div class="regions">` +
|
||||
n.trains
|
||||
.map(
|
||||
(r) =>
|
||||
`<div class="region">` +
|
||||
r
|
||||
.map(
|
||||
(t) =>
|
||||
`<span class="chip">${esc(t.label)}</span>` +
|
||||
`<div class="consist">${t.consist.length ? esc(t.consist.join(', ')) : 'empty'}</div>`,
|
||||
)
|
||||
.join('') +
|
||||
`</div>`,
|
||||
)
|
||||
.join('') +
|
||||
`</div></div>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
// -- grid
|
||||
// Include the legal squares in the bounds. A placement is usually just OUTSIDE the current
|
||||
// extent — that is what extending the district means — so a grid sized to the existing cards
|
||||
// would highlight nothing at exactly the moment highlighting matters.
|
||||
const spotCoords = [...spotsAt.keys()].map((k) => k.split(',').map(Number));
|
||||
const rows = [...f.cells.map((c) => c.row), ...spotCoords.map((c) => c[0]!)];
|
||||
const cols = [...f.cells.map((c) => c.col), ...spotCoords.map((c) => c[1]!)];
|
||||
const r0 = Math.min(...rows);
|
||||
const r1 = Math.max(...rows);
|
||||
const c0 = Math.min(...cols);
|
||||
const c1 = Math.max(...cols);
|
||||
const grid = $('grid');
|
||||
grid.style.gridTemplateColumns = `repeat(${c1 - c0 + 1}, minmax(104px, 1fr))`;
|
||||
let cells = '';
|
||||
for (let r = r1; r >= r0; r--) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const cell = f.cells.find((x) => x.row === r && x.col === c);
|
||||
const spots = spotsAt.get(`${r},${c}`);
|
||||
if (!cell) {
|
||||
// An empty square is still a legal destination — render it as a target rather than a gap,
|
||||
// or the most common placement of all would have nothing to click.
|
||||
cells += spots
|
||||
? `<div class="cell ghost legal" data-at="${r},${c}">` +
|
||||
`<div class="nm">place here</div><div class="dim coord">(${r},${c})</div>` +
|
||||
(spots.length > 1 ? `<div class="dim">${spots.length} rotations</div>` : '') +
|
||||
`</div>`
|
||||
: '<div></div>';
|
||||
continue;
|
||||
}
|
||||
// The full explanation goes in `title` so hovering any card says what it does, with a short
|
||||
// version printed on the card itself — a name alone tells a player nothing once it is played.
|
||||
cells +=
|
||||
`<div class="cell ${cell.running ? 'run ' : ''}${cell.kind === 'office' ? 'office ' : ''}` +
|
||||
`${cell.kind === 'modifier' ? 'modifier ' : ''}${spots ? 'legal' : ''}"` +
|
||||
`${spots ? ` data-at="${r},${c}"` : ''} title="${esc(cell.label)} — ${esc(cell.what)}">` +
|
||||
`<div class="nm">${esc(cell.label)}</div>` +
|
||||
`<div class="dim coord">(${r},${c})</div>` +
|
||||
`<div class="dim what">${esc(short(cell.what))}</div>` +
|
||||
(cell.enhancements.length
|
||||
? `<div class="enh">${cell.enhancements.map(esc).join(' · ')}</div>`
|
||||
: '') +
|
||||
(cell.tray ? `<div class="crew">🚂 ${esc(cell.tray)}</div>` : '') +
|
||||
(cell.cars.length ? `<div class="cars">${esc(cell.cars.join(', '))}</div>` : '') +
|
||||
`</div>`;
|
||||
}
|
||||
}
|
||||
grid.innerHTML = cells;
|
||||
|
||||
// Clicking a highlighted square plays there. With more than one rotation available it narrows to
|
||||
// that square instead, so the rotation is a second click rather than a guess.
|
||||
for (const el of Array.from(grid.querySelectorAll('[data-at]'))) {
|
||||
const node = el as HTMLElement;
|
||||
node.onclick = () => {
|
||||
const spots = spotsAt.get(node.dataset['at'] ?? '') ?? [];
|
||||
if (spots.length === 1) {
|
||||
const intent = menu.options[spots[0]!.index];
|
||||
if (intent) submit(game, intent);
|
||||
selected = null;
|
||||
pendingAt = null;
|
||||
} else if (spots.length > 1) {
|
||||
pendingAt = node.dataset['at'] ?? null;
|
||||
}
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
// -- facilities
|
||||
$('facs').innerHTML =
|
||||
f.facilities.length === 0
|
||||
? '<div class="dim">no facilities yet</div>'
|
||||
: f.facilities
|
||||
.map(
|
||||
(x) =>
|
||||
`<div class="fac"><div class="nm">${esc(x.name)}</div>` +
|
||||
`<div class="dim">${esc(x.commodity)} · ${esc(x.flow)} · laborers ${esc(x.laborers)}</div>` +
|
||||
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
|
||||
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
|
||||
x.maw
|
||||
.map(
|
||||
(m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`,
|
||||
)
|
||||
.join('') +
|
||||
`</div>` +
|
||||
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
|
||||
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
|
||||
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}">` +
|
||||
(x.jammed
|
||||
? 'JAMMED — a load is on WORK with no car spotted; the siding is locked'
|
||||
: x.canFinish
|
||||
? 'ready — a matching empty car is spotted'
|
||||
: 'no car spotted — a load started here would jam') +
|
||||
`</div></div>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
|
||||
const cardRow = (name: string, why: string, playable: boolean | null): string =>
|
||||
`<div class="handcard${playable === false ? ' unplayable' : ''}"><b>${esc(name)}</b>` +
|
||||
(playable === false ? ' <span class="tag">not playable yet</span>' : '') +
|
||||
(why ? `<div class="dim what">${esc(why)}</div>` : '') +
|
||||
`</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();
|
||||
}
|
||||
|
||||
/** First clause only — the cards are small, and the full text is on the tooltip. */
|
||||
function short(what: string): string {
|
||||
const head = what.split(' · ')[0] ?? what;
|
||||
return head.length > 64 ? head.slice(0, 61) + '…' : head;
|
||||
}
|
||||
|
||||
function boxes(items: string[], cap: number): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < Math.max(cap, items.length); i++) {
|
||||
const v = items[i];
|
||||
out += `<span class="box ${v ? 'f' : 'empty'}">${v ? esc(v) : '·'}</span>`;
|
||||
}
|
||||
return out || '<span class="dim">—</span>';
|
||||
}
|
||||
|
||||
function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
||||
const el = $('actions');
|
||||
|
||||
if (game.state.status !== 'active') {
|
||||
const o = game.state.outcome;
|
||||
el.innerHTML =
|
||||
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
|
||||
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
|
||||
`final Revenue ${game.state.players[0]?.revenue ?? 0} against a target of ${view(game).objective.target}</div>` +
|
||||
`<button id="again">new game</button>`;
|
||||
$('again').onclick = () => {
|
||||
clearSave();
|
||||
location.search = '';
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (menu.direct.length === 0 && menu.placeable.length === 0) {
|
||||
el.innerHTML = '<div class="dim">nothing to decide — the engine is running the Division</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const apply = (index: number): void => {
|
||||
const intent = menu.options[index];
|
||||
if (intent) submit(game, intent);
|
||||
selected = null;
|
||||
pendingAt = null;
|
||||
render();
|
||||
};
|
||||
|
||||
let html = menu.direct
|
||||
.map(
|
||||
(g) =>
|
||||
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
||||
g.actions.map((a) => `<button class="act" data-i="${a.index}">${esc(a.label)}</button>`).join('') +
|
||||
`</div>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
// Subject first, location second. Picking a card then a square is how the choice is actually made;
|
||||
// one flat list of every card-square-rotation combination was unreadable.
|
||||
for (const group of menu.placeable) {
|
||||
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
|
||||
for (const item of group.items) {
|
||||
const open = selected === item.subjectKey;
|
||||
html +=
|
||||
`<button class="subj ${open ? 'open' : ''}" data-subj="${esc(item.subjectKey)}">` +
|
||||
`${esc(item.subject)} <span class="dim">${item.spots.length} spot${item.spots.length === 1 ? '' : 's'}</span>` +
|
||||
`</button>`;
|
||||
if (open) {
|
||||
// Once a square is picked, show only that square's rotations — the rest is noise.
|
||||
const shown = pendingAt
|
||||
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
||||
: item.spots;
|
||||
html +=
|
||||
`<div class="spots"><div class="dim">` +
|
||||
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
|
||||
`</div>` +
|
||||
shown.map((sp) => `<button class="act" data-i="${sp.index}">${esc(sp.label)}</button>`).join('') +
|
||||
`</div>`;
|
||||
}
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
|
||||
for (const b of Array.from(el.querySelectorAll('button.act'))) {
|
||||
const node = b as HTMLElement;
|
||||
node.onclick = () => apply(Number(node.dataset['i']));
|
||||
}
|
||||
for (const b of Array.from(el.querySelectorAll('button.subj'))) {
|
||||
const node = b as HTMLElement;
|
||||
node.onclick = () => {
|
||||
const key = node.dataset['subj'] ?? null;
|
||||
selected = selected === key ? null : key;
|
||||
pendingAt = null;
|
||||
render();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saving. localStorage only — nothing leaves the browser.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function save(): void {
|
||||
try {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game)));
|
||||
} catch {
|
||||
// A full or disabled localStorage must not take the game down with it.
|
||||
}
|
||||
}
|
||||
|
||||
function load(): ReturnType<typeof toSave> | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
return raw ? (JSON.parse(raw) as ReturnType<typeof toSave>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSave(): void {
|
||||
try {
|
||||
localStorage.removeItem(SAVE_KEY);
|
||||
} catch {
|
||||
/* nothing to do */
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
+31
-7
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY, lengthProfile } from '../src/engine/content.ts';
|
||||
import { STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
@@ -294,7 +294,13 @@ describe('collisions are automatic (Gap 2)', () => {
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
||||
}
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
@@ -311,7 +317,13 @@ describe('collisions are automatic (Gap 2)', () => {
|
||||
const classBefore = s.yards.classificationYard.length;
|
||||
|
||||
const mlx = s.division.nodes[1];
|
||||
if (mlx?.kind === 'mainline') mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
|
||||
if (mlx?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
mlx.card = 'plains';
|
||||
mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
|
||||
}
|
||||
s.trays.set('x', {
|
||||
id: 'x',
|
||||
trainNumber: 8,
|
||||
@@ -354,7 +366,13 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
||||
}
|
||||
|
||||
// A second train at the Western Division Point wanting to follow it.
|
||||
s.trays.set('behind', {
|
||||
@@ -391,7 +409,13 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
|
||||
}
|
||||
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting',
|
||||
@@ -523,7 +547,7 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
|
||||
});
|
||||
|
||||
it('conserves rolling stock across a whole game', () => {
|
||||
// Nothing may be created or destroyed: 62 pieces, wherever they sit.
|
||||
// Nothing may be created or destroyed: every piece in the roster, wherever it sits.
|
||||
const s = game(88);
|
||||
playToCompletion(s, 88);
|
||||
|
||||
@@ -539,6 +563,6 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(count, 62, 'rolling stock leaked or was duplicated');
|
||||
assert.equal(count, TOTAL_ROLLING_STOCK, 'rolling stock leaked or was duplicated');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -325,6 +326,7 @@ describe('Freight Agent operations (§6.3)', () => {
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
return coord;
|
||||
}
|
||||
@@ -421,6 +423,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
s.clock.phase = 'loadUnload';
|
||||
return coord;
|
||||
@@ -561,6 +564,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Enhancement cards (implications.md §7).
|
||||
*
|
||||
* These are the 18 cards that make up the largest block of the "third of the deck that does
|
||||
* nothing". Several change core loops — Small Yard makes the facing-point switching puzzle
|
||||
* solvable at all, and ABS Signals amends Gap 2's unconditional collisions.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDerail } from '../src/engine/apply.ts';
|
||||
import { ENHANCEMENT_RULES, enhancementRule } from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
|
||||
const straight = (): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), card);
|
||||
}
|
||||
|
||||
/** Puts an enhancement card in hand and returns its id. */
|
||||
function handEnhancement(s: GameState, key: string): string {
|
||||
for (const [id, card] of s.cards) {
|
||||
if (card.kind.kind === 'enhancement' && card.kind.key === key) {
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no enhancement card: ${key}`);
|
||||
}
|
||||
|
||||
function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('enhancement placement', () => {
|
||||
it('defines a rule for every enhancement card', () => {
|
||||
assert.equal(ENHANCEMENT_RULES.length, 10);
|
||||
for (const r of ENHANCEMENT_RULES) assert.ok(enhancementRule(r.key), r.key);
|
||||
});
|
||||
|
||||
it('puts Interlocking on a Running Track straight, not a Secondary one', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
addCard(s, at(-1, 0), straight());
|
||||
const id = handEnhancement(s, 'interlocking');
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), null);
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(-1, 0) }), 'NOT_CONNECTED');
|
||||
});
|
||||
|
||||
it('puts Small Yard on a Secondary Track straight, not the Running Track', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
addCard(s, at(-1, 0), straight());
|
||||
const id = handEnhancement(s, 'smallYard');
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(-1, 0) }), null);
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), 'NOT_CONNECTED');
|
||||
});
|
||||
|
||||
it('stacks Telephone on Telegraph and Radio on Telephone, never bare', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
|
||||
const phone = handEnhancement(s, 'telephone');
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: phone, placement: at(0, 2) }), 'NOT_CONNECTED');
|
||||
|
||||
const tel = handEnhancement(s, 'telegraph');
|
||||
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: tel, placement: at(0, 2) }).ok);
|
||||
|
||||
const phone2 = handEnhancement(s, 'telephone');
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: phone2, placement: at(0, 2) }), null);
|
||||
});
|
||||
|
||||
it('requires an Interlocking in the district before Facing Point Locks', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const fpl = handEnhancement(s, 'facingPointLocks');
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: fpl, placement: at(0, 2) }), 'NOT_CONNECTED');
|
||||
|
||||
const lock = handEnhancement(s, 'interlocking');
|
||||
applyIntent(s, 0, { type: 'card.play', cardId: lock, placement: at(0, 2) });
|
||||
const fpl2 = handEnhancement(s, 'facingPointLocks');
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: fpl2, placement: at(0, 2) }), null);
|
||||
assert.ok(isProtectedFromDerail(areaOf(s, 0)) === false, 'not protected until actually played');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Small Yard — the card that makes switching solvable', () => {
|
||||
function yardGame() {
|
||||
const s = game();
|
||||
const yard = at(-1, 0);
|
||||
const card = straight();
|
||||
card.enhancements.push('smallYard');
|
||||
addCard(s, yard, card);
|
||||
const tray = placeTray(s, yard, [
|
||||
{ type: 'boxcar', loaded: false },
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'coach', loaded: false },
|
||||
]);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
return { s, tray, yard };
|
||||
}
|
||||
|
||||
it('re-orders a consist, including bringing a buried car to the end', () => {
|
||||
// §A.3 forces cars off in seated order, so without this the middle car can never be spotted.
|
||||
const { s, tray } = yardGame();
|
||||
const r = applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [0, 2, 1] });
|
||||
assert.ok(r.ok);
|
||||
assert.deepEqual(
|
||||
s.trays.get(tray)!.consist.map((c) => c.type),
|
||||
['boxcar', 'coach', 'hopper'],
|
||||
'the hopper is now on the droppable end',
|
||||
);
|
||||
});
|
||||
|
||||
it('costs one Move — "spends one move in the yard"', () => {
|
||||
const { s, tray } = yardGame();
|
||||
const before = s.turn.movesRemaining;
|
||||
applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [2, 1, 0] });
|
||||
assert.equal(s.turn.movesRemaining, before - 1);
|
||||
});
|
||||
|
||||
it('is refused anywhere without a Small Yard', () => {
|
||||
const s = game();
|
||||
addCard(s, at(-1, 0), straight());
|
||||
const tray = placeTray(s, at(-1, 0), [
|
||||
{ type: 'boxcar', loaded: false },
|
||||
{ type: 'hopper', loaded: true },
|
||||
]);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [1, 0] }),
|
||||
'NOT_CONNECTED',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an order that is not a permutation of the consist', () => {
|
||||
const { s, tray } = yardGame();
|
||||
for (const bad of [[0, 1], [0, 1, 1], [0, 1, 5]]) {
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.sortConsist', trayId: tray, order: bad }),
|
||||
'CONSIST_ORDER',
|
||||
`accepted ${JSON.stringify(bad)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Interlocking and Yard Office relieve the Office', () => {
|
||||
function inbound(s: GameState, consist: TrackCard['standing']) {
|
||||
const id = 'inbound';
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
||||
}
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return id;
|
||||
}
|
||||
|
||||
it('holds a train at the Limits instead of colliding when the Office is full', () => {
|
||||
// Gap 2d made a full Office an automatic collision. Interlocking is the designed answer.
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('interlocking');
|
||||
addCard(s, at(0, 2), card);
|
||||
areaOf(s, 0).adOccupancy = ['blocker'];
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
assert.equal(s.players[0]!.revenue, 0, 'no collision penalty');
|
||||
assert.ok(areaOf(s, 0).heldAtLimits.includes(id), 'train is held at the Limits');
|
||||
});
|
||||
|
||||
it('still collides without an Interlocking', () => {
|
||||
const s = game();
|
||||
areaOf(s, 0).adOccupancy = ['blocker'];
|
||||
inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
advance(s);
|
||||
assert.equal(s.players[0]!.revenue, -5);
|
||||
});
|
||||
|
||||
it('diverts a coachless train to the Yard Office', () => {
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(-1, 0), card);
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
const pos = s.trays.get(id)!.position;
|
||||
assert.ok(pos.at === 'grid' && pos.coord.row === -1, 'arrived at the Yard Office');
|
||||
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'did not take an A/D track');
|
||||
});
|
||||
|
||||
it('does not divert a train carrying coaches', () => {
|
||||
// "An inbound train with NO COACHES" — passengers must reach the Train Order Office.
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(-1, 0), card);
|
||||
const id = inbound(s, [{ type: 'coach', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'a coach train must use the Office');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ABS Signals amend the collision rule', () => {
|
||||
it('turns a following-train judgment into a plain hold', () => {
|
||||
// "Trains on this card will not rear-end each other. They will stop short of a collision."
|
||||
const s = game();
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain — see the meet tests below. Double Track and Uncontrolled Siding let trains
|
||||
// pass, so the following train would never be held and there would be nothing for ABS to do.
|
||||
ml.card = 'plains';
|
||||
ml.absSignals = true;
|
||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
||||
}
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.trays.set('behind', {
|
||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
|
||||
const r = advance(s);
|
||||
assert.equal(r.needsInput, false, 'no clearance decision is needed with signals');
|
||||
assert.equal(s.clock.pendingDecision, null);
|
||||
assert.deepEqual(s.trays.get('behind')!.position, { at: 'divisionPoint', side: 'west' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
||||
function meet(s: GameState, device?: string) {
|
||||
if (device) {
|
||||
const card = straight();
|
||||
card.enhancements.push(device);
|
||||
addCard(s, at(0, 2), card);
|
||||
}
|
||||
// An oncoming senior train — §8.1 makes this an absolute bar without a device.
|
||||
s.trays.set('oncoming', {
|
||||
id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') {
|
||||
// Pin the terrain. Mainline types are drawn from the shuffled deck, so deck composition would
|
||||
// otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar this test is about.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
|
||||
}
|
||||
s.trays.set('mine', {
|
||||
id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
}
|
||||
|
||||
it('holds a facing train with no device — §8.1 stands', () => {
|
||||
const s = game();
|
||||
meet(s);
|
||||
advance(s);
|
||||
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
|
||||
});
|
||||
|
||||
it('lets a junior train win the meet with a Telegraph', () => {
|
||||
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
|
||||
const s = game();
|
||||
meet(s, 'radio');
|
||||
advance(s);
|
||||
assert.equal(s.trays.get('mine')!.position.at, 'mainline', 'the meet was dispatched');
|
||||
assert.ok(areaOf(s, 0).dispatchUsedToday.includes('radio'));
|
||||
});
|
||||
|
||||
it('spends each device only once a Day', () => {
|
||||
const s = game();
|
||||
meet(s, 'radio');
|
||||
advance(s);
|
||||
assert.deepEqual(areaOf(s, 0).dispatchUsedToday, ['radio']);
|
||||
// Reset happens at the Day boundary.
|
||||
s.clock.stage = 12;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.deepEqual(areaOf(s, 0).dispatchUsedToday, [], 'devices reset each Day');
|
||||
});
|
||||
|
||||
it('gives Radio the largest bonus and Telegraph the smallest', () => {
|
||||
assert.equal(enhancementRule('telegraph')!.dispatchBonus, 4);
|
||||
assert.equal(enhancementRule('telephone')!.dispatchBonus, 8);
|
||||
assert.equal(enhancementRule('radio')!.dispatchBonus, 12);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('defensive enhancements', () => {
|
||||
it('reports district protection only once actually built', () => {
|
||||
// These guard against opponent-directed cards, which a solitaire deck omits entirely (Q6).
|
||||
const s = game();
|
||||
assert.equal(isProtectedFromDerail(areaOf(s, 0)), false);
|
||||
const card = straight();
|
||||
card.enhancements.push('facingPointLocks');
|
||||
addCard(s, at(0, 2), card);
|
||||
assert.equal(isProtectedFromDerail(areaOf(s, 0)), true);
|
||||
assert.equal(hasDistrictEnhancement(areaOf(s, 0), 'waterColumn'), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Mainline modifiers and Maneuver cards (implications.md §7).
|
||||
*
|
||||
* The 14 cards left after the Enhancements. These are NOT multiplayer-only: Brakeman, Airbrakes,
|
||||
* Helpers and Realignment all change your own crossing times, and Red Flags prevents a rear-ender,
|
||||
* which happens in solitaire too.
|
||||
*
|
||||
* Poling is deliberately absent — the recovered sheet records its effect as "TBD in the source", so
|
||||
* there is nothing to implement and nothing to test.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import {
|
||||
MAINLINE_MODIFIER_CARDS,
|
||||
MANEUVER_CARDS,
|
||||
REALIGNMENTS,
|
||||
crossingStages,
|
||||
mainlineModifierRule,
|
||||
} from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||||
|
||||
/** Puts a card of `kind`/`key` in hand and returns its id. */
|
||||
function hand(s: GameState, kind: string, key: string): string {
|
||||
for (const [id, card] of s.cards) {
|
||||
const k = card.kind as { kind: string; key?: string };
|
||||
if (k.kind === kind && k.key === key) {
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no ${kind} card: ${key}`);
|
||||
}
|
||||
|
||||
/** A Mainline node pinned to a known terrain, so deck composition cannot decide an outcome. */
|
||||
function pinned(s: GameState, index: number, card: string) {
|
||||
const node = s.division.nodes[index];
|
||||
if (node?.kind !== 'mainline') throw new Error(`node ${index} is not a Mainline card`);
|
||||
node.card = card as never;
|
||||
node.transits = [];
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Lets the player actually play a card this turn. */
|
||||
function drawTurn(s: GameState): void {
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
s.turn.option = 'draw';
|
||||
s.turn.drawnThisTurn = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('grade modifiers change crossing time', () => {
|
||||
it('a Heavy Grade takes two Stages bare', () => {
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false), 2);
|
||||
});
|
||||
|
||||
it('Brakeman speeds the descent but not the climb', () => {
|
||||
// Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which
|
||||
// way is UPHILL. With up = east, a westbound train is descending.
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'east'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'east'), 2);
|
||||
});
|
||||
|
||||
it('follows the orientation the player chose, not a fixed compass direction', () => {
|
||||
// The same train on the same card, with the card turned around: Brakeman helps a westbound
|
||||
// train on an east-climbing grade, and an eastbound one when the grade climbs west.
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'west'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'west'), 2);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'west'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'west'), 2);
|
||||
});
|
||||
|
||||
it('Helpers speed the climb but not the descent', () => {
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1);
|
||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2);
|
||||
});
|
||||
|
||||
it('Airbrakes stack with Brakeman on a slow train', () => {
|
||||
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3);
|
||||
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2);
|
||||
assert.equal(
|
||||
crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('never lets a train cross in no time', () => {
|
||||
assert.equal(
|
||||
crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves non-grade cards alone', () => {
|
||||
// Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
|
||||
assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west', 'east'), 1);
|
||||
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placing a Mainline modifier', () => {
|
||||
it('accepts Brakeman on a grade', () => {
|
||||
const s = game();
|
||||
pinned(s, 1, 'heavyGrade');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
|
||||
});
|
||||
|
||||
it('rejects Brakeman on anything but a grade', () => {
|
||||
const s = game();
|
||||
pinned(s, 1, 'plains');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_A_GRADE');
|
||||
});
|
||||
|
||||
it('requires Brakeman before Airbrakes', () => {
|
||||
const s = game();
|
||||
const node = pinned(s, 1, 'heavyGrade');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'airbrakes');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_CONNECTED');
|
||||
|
||||
node.modifiers = ['brakeman'];
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
|
||||
});
|
||||
|
||||
it('will not modify a card with a train on it', () => {
|
||||
const s = game();
|
||||
const node = pinned(s, 1, 'heavyGrade');
|
||||
node.transits.push({ tray: 'someone', stagesRemaining: 1, direction: 'east' });
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD');
|
||||
});
|
||||
|
||||
it('rejects a Mainline node that is not a Mainline card', () => {
|
||||
const s = game();
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
// Node 0 is the western Division Point.
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 0 }), 'NO_PLACEMENT');
|
||||
});
|
||||
|
||||
it('lays the modifier and spends the card', () => {
|
||||
const s = game();
|
||||
const node = pinned(s, 1, 'heavyGrade');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });
|
||||
|
||||
assert.ok(r.ok, 'placement should be accepted');
|
||||
assert.deepEqual(node.modifiers, ['brakeman']);
|
||||
assert.ok(!(s.decks.hands.get(0) ?? []).includes(cardId), 'card should leave the hand');
|
||||
assert.ok(s.decks.salvageYard.includes(cardId), 'card should reach the Salvage Yard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Realignment converts one Mainline type to another', () => {
|
||||
it('converts according to the table', () => {
|
||||
const s = game();
|
||||
const node = pinned(s, 1, 'curves');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'realignment');
|
||||
const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });
|
||||
|
||||
assert.ok(r.ok);
|
||||
assert.equal(node.card, 'plains', 'Curves realigns to Plains');
|
||||
// The point of the card: Curves is a 30 (two Stages), Plains a 60 (one).
|
||||
assert.equal(crossingStages(node.card, 'fast', false), 1);
|
||||
});
|
||||
|
||||
it('refuses a card with no conversion listed', () => {
|
||||
const s = game();
|
||||
// Tunnel is not a `from` in REALIGNMENTS.
|
||||
assert.ok(!REALIGNMENTS.some((r) => r.from === 'tunnel'));
|
||||
pinned(s, 1, 'tunnel');
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'realignment');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NO_PLACEMENT');
|
||||
});
|
||||
|
||||
it('applies to any Mainline card, not only grades', () => {
|
||||
assert.equal(mainlineModifierRule('realignment')?.gradeOnly, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Red Flags protect a stopped train', () => {
|
||||
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
|
||||
function rearEnder(s: GameState) {
|
||||
const node = pinned(s, 1, 'plains');
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.trays.set('behind', {
|
||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
const dp = s.division.nodes[0];
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push('behind');
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return node;
|
||||
}
|
||||
|
||||
it('holds the approaching train instead of letting it close', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
node.redFlagged = ['ahead'];
|
||||
|
||||
advance(s);
|
||||
assert.deepEqual(
|
||||
s.trays.get('behind')!.position,
|
||||
{ at: 'divisionPoint', side: 'west' },
|
||||
'the flagged train must not be approached',
|
||||
);
|
||||
});
|
||||
|
||||
it('comes in when the protected train rolls', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
node.redFlagged = ['ahead'];
|
||||
// Bring the protected train to the end of its crossing so it leaves the card.
|
||||
node.transits[0]!.stagesRemaining = 1;
|
||||
|
||||
for (let i = 0; i < 12 && (node.redFlagged?.length ?? 0) > 0; i++) advance(s);
|
||||
assert.deepEqual(node.redFlagged, [], 'flags come in once the train moves off');
|
||||
});
|
||||
|
||||
it('only protects a train out on the Mainline', () => {
|
||||
const s = game();
|
||||
rearEnder(s);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
const cardId = hand(s, 'maneuver', 'redFlags');
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'behind' }),
|
||||
'NO_PLACEMENT',
|
||||
'a train sitting at a Division Point cannot be rear-ended',
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), null);
|
||||
});
|
||||
|
||||
it('will not double-flag the same train', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
const cardId = hand(s, 'maneuver', 'redFlags');
|
||||
node.redFlagged = ['ahead'];
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }),
|
||||
'OPTION_ALREADY_CHOSEN',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Flying Switch rolls a cut into an industry', () => {
|
||||
/** A crew on a straight with a facility next door, mid-switch. */
|
||||
function setup(s: GameState) {
|
||||
const area = areaOf(s, 0);
|
||||
// A flying switch rolls the cut ALONG THE TRACK, so the spur and the industry must be joined
|
||||
// north-south. Built east-west, they are two unconnected cards that merely look adjacent.
|
||||
const spur: TrackCard = {
|
||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
area.grid.set(coordKey(at(-1, 0)), spur);
|
||||
const industry: TrackCard = {
|
||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: 2, cars: [] },
|
||||
laborers: 1, porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
area.grid.set(coordKey(at(-2, 0)), industry);
|
||||
|
||||
s.trays.set('crew', {
|
||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
|
||||
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
||||
// Facing SOUTH, down the spur. `direction` only carries east/west, so a crew on north-south
|
||||
// track needs its actual port — without it the engine looks for an east exit the card has not
|
||||
// got, and the crew has no legal moves at all.
|
||||
direction: 'east', facing: 's',
|
||||
position: { at: 'grid', owner: 0, coord: at(-1, 0) }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
s.turn.option = 'switch';
|
||||
s.turn.movesRemaining = 6;
|
||||
return industry;
|
||||
}
|
||||
|
||||
it('drops the cut onto the industry track without moving the engine', () => {
|
||||
const s = game();
|
||||
const industry = setup(s);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
|
||||
const r = applyIntent(s, 0, {
|
||||
type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 0),
|
||||
});
|
||||
assert.ok(r.ok, 'the flying switch should be accepted');
|
||||
|
||||
assert.equal(industry.facility!.industryTrack.cars.length, 1, 'car reaches the industry track');
|
||||
assert.equal(industry.facility!.industryTrack.cars[0]!.type, 'boxcar', '§A.3 — the back car');
|
||||
assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
|
||||
assert.deepEqual(
|
||||
s.trays.get('crew')!.position,
|
||||
{ at: 'grid', owner: 0, coord: at(-1, 0) },
|
||||
'the engine never enters the industry',
|
||||
);
|
||||
assert.equal(s.turn.movesRemaining, 5, 'the manoeuvre costs a Move');
|
||||
});
|
||||
|
||||
it('only reaches an adjacent card', () => {
|
||||
const s = game();
|
||||
setup(s);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-4, 0) }),
|
||||
'NOT_CONNECTED',
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls into an industry, not onto plain track', () => {
|
||||
const s = game();
|
||||
setup(s);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
// (0,0) is the Office — track-connected to the crew, but not a freight industry.
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
|
||||
'CANNOT_DROP_HERE',
|
||||
);
|
||||
});
|
||||
|
||||
it('will not roll onto a card with no track connection', () => {
|
||||
const s = game();
|
||||
setup(s);
|
||||
const area = areaOf(s, 0);
|
||||
// An industry sitting beside the crew, but joined east-west while the spur runs north-south.
|
||||
const stranded = { ...area.grid.get(coordKey(at(-2, 0)))! };
|
||||
stranded.geometry = { kind: 'track', geometry: 'straight', axis: 'ew' };
|
||||
area.grid.set(coordKey(at(-1, 1)), stranded);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 1) }),
|
||||
'NOT_CONNECTED',
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot cut more cars than it is hauling', () => {
|
||||
const s = game();
|
||||
setup(s);
|
||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-2, 0) }),
|
||||
'CONSIST_EMPTY',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('card coverage', () => {
|
||||
it('every Mainline modifier is either implemented or a known duplicate', () => {
|
||||
for (const card of MAINLINE_MODIFIER_CARDS) {
|
||||
const known =
|
||||
mainlineModifierRule(card.key) !== null || card.key === 'facingPointLocksMainline';
|
||||
assert.ok(known, `${card.key} has no rule and is not the Facing Point Locks duplicate`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Poling remains unimplemented, deliberately', () => {
|
||||
// Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
|
||||
// source says otherwise, a silent no-op would be worse than a rejection.
|
||||
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
|
||||
assert.ok(poling, 'Poling should still be in the deck');
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+61
-2
@@ -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`);
|
||||
|
||||
+34
-16
@@ -49,17 +49,21 @@ const newSolitaireGame = (seed = 1234) =>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('card catalogue (component 1)', () => {
|
||||
it('composes the 115-card deck from the design', () => {
|
||||
// Transcribed from docs/Deck cards2.xlsx; the sheet's own total is 115.
|
||||
assert.equal(DECK_SIZE, 115);
|
||||
it('composes the 140-card deck from the design', () => {
|
||||
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus 18 extra industry cards
|
||||
// (Gap 12, industries 9 → 27) and 7 extra office cards (Q12, offices 7 → 14). Both are
|
||||
// deliberate departures from the sheet and both are flagged provisional in content.ts.
|
||||
assert.equal(DECK_SIZE, 140);
|
||||
assert.equal(buildDeck().length, DECK_SIZE);
|
||||
});
|
||||
|
||||
it('matches the design deck composition exactly', () => {
|
||||
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
|
||||
assert.deepEqual(byCategory, {
|
||||
office: 7,
|
||||
industry: 9,
|
||||
// 14, not the sheet's 7 — Q12 office density; see OFFICE_PROFILES.
|
||||
office: 14,
|
||||
// 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES.
|
||||
industry: 27,
|
||||
modifier: 23,
|
||||
train: 22,
|
||||
spaceUse: 12,
|
||||
@@ -72,13 +76,13 @@ describe('card catalogue (component 1)', () => {
|
||||
|
||||
it('removes opponent-directed cards from a solitaire deck', () => {
|
||||
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
|
||||
// game they would be 22 of 115 draws (19%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 93);
|
||||
// game they would be 22 of 140 draws (16%) that do nothing.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 118);
|
||||
const solo = buildDeck('solitaire');
|
||||
assert.equal(solo.length, 93);
|
||||
assert.equal(solo.length, 118);
|
||||
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
|
||||
// A competitive deck keeps them.
|
||||
assert.equal(buildDeck('competitive').length, 115);
|
||||
assert.equal(buildDeck('competitive').length, 140);
|
||||
});
|
||||
|
||||
it('keeps track OUT of the deck, as a per-player supply', () => {
|
||||
@@ -179,8 +183,20 @@ describe('card catalogue (component 1)', () => {
|
||||
});
|
||||
|
||||
it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
|
||||
// Assert the PROPERTY, not the literal counts. Upgrades are strictly sequential (Gap 3b), so
|
||||
// every tier must be at least as common as the one above it — otherwise players reach a rung
|
||||
// whose next card is scarcer than the one that got them there. Densities are provisional (Q12)
|
||||
// and expected to move again; the pyramid shape is what must survive.
|
||||
const inDeck = OFFICE_PROFILES.filter((o) => o.copiesInDeck > 0).map((o) => o.copiesInDeck);
|
||||
assert.deepEqual(inDeck, [4, 2, 1]);
|
||||
assert.ok(inDeck.length >= 2, 'there must be an upgrade ladder at all');
|
||||
for (let i = 1; i < inDeck.length; i++) {
|
||||
assert.ok(
|
||||
inDeck[i]! <= inDeck[i - 1]!,
|
||||
`tier ${i} has ${inDeck[i]} copies but the tier below it has ${inDeck[i - 1]}`,
|
||||
);
|
||||
}
|
||||
// The Whistle Post is the starting state, never a card.
|
||||
assert.equal(OFFICE_PROFILES.find((o) => o.tier === 'whistlePost')!.copiesInDeck, 0);
|
||||
});
|
||||
|
||||
it('walks the upgrade sequence without skipping', () => {
|
||||
@@ -190,9 +206,11 @@ describe('card catalogue (component 1)', () => {
|
||||
assert.equal(nextOfficeTier('terminal'), null);
|
||||
});
|
||||
|
||||
it('supplies 62 rolling stock pieces', () => {
|
||||
assert.equal(TOTAL_ROLLING_STOCK, 62);
|
||||
assert.equal(buildRollingStock().length, 62);
|
||||
it('supplies the full rolling stock roster', () => {
|
||||
// 80 pieces after the Gap 12 supply scale-up. Asserted against the constant rather than a
|
||||
// literal so the invariant is "the yard holds exactly the roster", not a number to re-edit.
|
||||
assert.equal(TOTAL_ROLLING_STOCK, 80);
|
||||
assert.equal(buildRollingStock().length, TOTAL_ROLLING_STOCK);
|
||||
});
|
||||
|
||||
it('never demands more cars than the supply can furnish', () => {
|
||||
@@ -340,7 +358,7 @@ describe('game setup (component 2)', () => {
|
||||
...g.decks.salvageYard,
|
||||
...[...g.decks.hands.values()].flat(),
|
||||
];
|
||||
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 93, not 115.
|
||||
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 118, not 140.
|
||||
assert.equal(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
|
||||
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
|
||||
});
|
||||
@@ -357,9 +375,9 @@ describe('game setup (component 2)', () => {
|
||||
assert.equal(newSolitaireGame().freeTrays.length, 4);
|
||||
});
|
||||
|
||||
it('puts all 62 rolling stock pieces in the Division Yard', () => {
|
||||
it('puts every rolling stock piece in the Division Yard', () => {
|
||||
const g = newSolitaireGame();
|
||||
assert.equal(g.yards.divisionYard.length, 62);
|
||||
assert.equal(g.yards.divisionYard.length, TOTAL_ROLLING_STOCK);
|
||||
assert.equal(g.yards.classificationYard.length, 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -287,3 +287,34 @@ describe('switching accomplishes something (regression)', () => {
|
||||
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('measurement discipline', () => {
|
||||
it('the observer sees exactly the events the log records', () => {
|
||||
// REGRESSION, against a measurement bug rather than a game bug. Events reach the log from TWO
|
||||
// sources — the phase driver (`pump`) and player intents (`applyIntent`). An ad-hoc probe that
|
||||
// watched only the first reported "60 of 60 games never upgraded past Whistle Post" while the
|
||||
// Office tier histogram from the same run plainly showed Depots, Stations and Terminals.
|
||||
//
|
||||
// A wrong measurement is worse than a missing one: it gets believed and acted on. This asserts
|
||||
// the observer hook cannot drift from the log, so probes have one trustworthy way in and no
|
||||
// reason to hand-roll the drive loop again.
|
||||
const seen: string[] = [];
|
||||
const s = createGame({ id: 'obs', seed: 4242, config: { ...config, length: 'standard' }, playerNames: ['b'] });
|
||||
const out = playGame(s, developerBot, pump, 50_000, (e) => seen.push(e.type));
|
||||
|
||||
assert.ok(out.events.length > 0, 'a finished game must produce events');
|
||||
assert.deepEqual(seen, out.events.map((e) => e.type));
|
||||
});
|
||||
|
||||
it('records office upgrades where a probe can actually find them', () => {
|
||||
// The upgrade arrives via applyIntent, not pump — the exact branch the broken probe missed.
|
||||
// Asserted across several seeds because a single game may never draw a Depot card.
|
||||
let upgrades = 0;
|
||||
for (let seed = 0; seed < 25; seed++) {
|
||||
const s = createGame({ id: `u${seed}`, seed, config: { ...config, length: 'standard' }, playerNames: ['b'] });
|
||||
const out = playGame(s, developerBot, pump);
|
||||
upgrades += out.events.filter((e) => e.type === 'officeUpgraded').length;
|
||||
}
|
||||
assert.ok(upgrades > 0, 'no office upgrade was visible in the event log across 25 games');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
|
||||
@@ -45,6 +46,7 @@ const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
||||
@@ -55,6 +57,7 @@ const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const officeCard = (): TrackCard => ({
|
||||
@@ -63,6 +66,7 @@ const officeCard = (): TrackCard => ({
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const lockedFacility = (): TrackCard => ({
|
||||
@@ -84,6 +88,7 @@ const lockedFacility = (): TrackCard => ({
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
||||
@@ -100,6 +105,9 @@ function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): Off
|
||||
limitsWest: { row: officeCoord.row, col: officeCoord.col - 2 },
|
||||
limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
|
||||
adOccupancy: [],
|
||||
heldAtLimits: [],
|
||||
dispatchUsedToday: [],
|
||||
trackSupply: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,6 +167,7 @@ describe('ports and geometry', () => {
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
assert.deepEqual(exitsFrom(r, 's'), ['w']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/** 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 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}`);
|
||||
}
|
||||
assert.match(allow, /collision/, 'granting clearance does not mention the risk');
|
||||
assert.match(hold, /safe|loses/, '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('the static build', () => {
|
||||
const root = join(import.meta.dirname, '..');
|
||||
const dist = join(root, 'dist');
|
||||
|
||||
it('builds, and needs nothing but a static host', () => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
|
||||
assert.ok(existsSync(join(dist, 'index.html')), 'no index.html');
|
||||
assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
|
||||
assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
|
||||
});
|
||||
|
||||
it('emits no import that a browser cannot resolve', () => {
|
||||
// Node's type-stripping lets the source import './x.ts'; a browser cannot. The build rewrites
|
||||
// those to .js, and nothing may reach for a bare module or a node: builtin.
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) walk(join(dir, e.name));
|
||||
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
|
||||
}
|
||||
};
|
||||
walk(dist);
|
||||
assert.ok(files.length > 5, 'suspiciously few emitted files');
|
||||
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8');
|
||||
// Anchor to real import/export statements. A loose `from '...'` also matches PROSE — the
|
||||
// turnout comment "a train entering a turnout from \"A\"" was read as importing a module
|
||||
// called A, which is the kind of false alarm that trains you to ignore a failing test.
|
||||
const imports = /^\s*(?:import|export)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm;
|
||||
for (const m of src.matchAll(imports)) {
|
||||
const spec = m[1]!.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 make = (): Record<string, unknown> => {
|
||||
let html = '';
|
||||
// Cache per selector and clear it when innerHTML changes. Returning fresh objects each call
|
||||
// would drop the handlers the page assigns — the test would then report "no button" for a
|
||||
// page that works perfectly.
|
||||
let cache = new Map<string, Record<string, unknown>[]>();
|
||||
const node: Record<string, unknown> = {
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
querySelectorAll(sel: string) {
|
||||
const hit = cache.get(sel);
|
||||
if (hit) return hit;
|
||||
const out: Record<string, unknown>[] = [];
|
||||
const re = sel.startsWith('[')
|
||||
? /data-at="([^"]*)"/g
|
||||
: new RegExp(`<button class="${sel.split('.')[1]}[^"]*"([^>]*)>`, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
const data: Record<string, string> = {};
|
||||
if (sel.startsWith('[')) data['at'] = m[1]!;
|
||||
else {
|
||||
const attr = /data-(\w+)="([^"]*)"/g;
|
||||
let a: RegExpExecArray | null;
|
||||
while ((a = attr.exec(m[1]!)) !== null) data[a[1]!] = a[2]!;
|
||||
}
|
||||
out.push({ dataset: data, onclick: null });
|
||||
}
|
||||
cache.set(sel, out);
|
||||
return out;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(node, 'innerHTML', {
|
||||
get: () => html,
|
||||
set: (v: string) => {
|
||||
html = v;
|
||||
cache = new Map();
|
||||
},
|
||||
});
|
||||
return node;
|
||||
};
|
||||
|
||||
// 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, 'index.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
|
||||
(m) => m[1]!,
|
||||
),
|
||||
);
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g['document'] = {
|
||||
getElementById: (id: string) => {
|
||||
if (!served.has(id)) return null;
|
||||
if (!els.has(id)) els.set(id, make());
|
||||
return els.get(id);
|
||||
},
|
||||
};
|
||||
g['location'] = { search: '?seed=555' };
|
||||
const store = new Map<string, string>();
|
||||
g['localStorage'] = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
};
|
||||
// No parameter properties — `erasableSyntaxOnly` forbids them, since Node strips types rather
|
||||
// than compiling them.
|
||||
g['URLSearchParams'] = class {
|
||||
search: string;
|
||||
constructor(search: string) {
|
||||
this.search = search;
|
||||
}
|
||||
get(k: string): string | null {
|
||||
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
|
||||
|
||||
const actions = els.get('actions')!;
|
||||
const grid = els.get('grid')!;
|
||||
|
||||
// Take the draw option, then pick the first placeable subject.
|
||||
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
|
||||
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
|
||||
const nodes = fn.call(el, sel);
|
||||
const target = nodes[0];
|
||||
if (!target) return false;
|
||||
(target['onclick'] as (() => void) | null)?.();
|
||||
return true;
|
||||
};
|
||||
|
||||
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
|
||||
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
|
||||
|
||||
const html = String(grid['innerHTML']);
|
||||
assert.match(html, /class="cell[^"]*legal/, 'picking a card highlighted nothing');
|
||||
assert.match(html, /data-at="/, 'highlighted squares carry no click target');
|
||||
assert.match(html, /ghost/, 'no empty square was offered as a destination');
|
||||
});
|
||||
|
||||
it('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, 'index.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 a page that loads the game as a module', () => {
|
||||
const html = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?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