Compare commits
9
Commits
401b879140
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e23d81eade | ||
|
|
f52ff0e9ac | ||
|
|
f00255ce31 | ||
|
|
d1f689d4fc | ||
|
|
dd300ac154 | ||
|
|
2eca9de09f | ||
|
|
160190da3f | ||
|
|
d261ad7af8 | ||
|
|
b085d5a0bb |
+183
@@ -0,0 +1,183 @@
|
||||
# Changelog
|
||||
|
||||
Detail behind each commit. Commit messages stay high level; the reasoning, the measurements, and
|
||||
the things that turned out to be wrong live here.
|
||||
|
||||
Measured figures are 100 solitaire Standard games with the developer bot unless stated otherwise.
|
||||
The target is 20 Revenue over 5 Days.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Teaching the bot to use a siding
|
||||
|
||||
**Nose coupling, which the rules had and the engine did not.** §A.3: "engines also have couplers on
|
||||
the front end, so a train can pick cars up onto its nose". Coupling always appended to the back, so
|
||||
which end cars landed on did not exist — and that is precisely what a run-around is for. Cars met
|
||||
running FORWARD now couple in front, cars met BACKING couple behind, so the approach decides which
|
||||
car is next off the tail:
|
||||
|
||||
running forward -> reefer, boxcar, hopper next off: hopper
|
||||
backing up -> boxcar, hopper, reefer next off: reefer
|
||||
|
||||
The bot prefers a run-around to setting a car down, since it keeps the car — but only when the drop
|
||||
can actually follow. Without that guard it ran the loop for its own sake (8 a game, 63 Moves), which
|
||||
the shuttling regression correctly failed.
|
||||
|
||||
**A serious bug found on the way.** The `carsCoupled` reducer cleared **every card in the Office
|
||||
Area**, not the ones the crew ran over — its own comment said "every card along the path" while the
|
||||
code iterated the whole grid. One coupling anywhere deleted every standing car and every industry
|
||||
track in the district, so loads worked over several Stages vanished when a crew picked up an
|
||||
unrelated boxcar somewhere else. The event now names the cards it lifted from.
|
||||
|
||||
**A test replaced rather than relaxed.** "Under 60 Moves a game" started failing. That threshold was
|
||||
calibrated when the crew coupled ~0.4 cars a game; it now couples ~8 and drops ~11, and a run-around
|
||||
is *supposed* to cost several Moves. Counting Moves was always a proxy for aimlessness, so the test
|
||||
now measures the thing itself — Moves per drop-or-coupling — which still fails when the bot is made
|
||||
to wander.
|
||||
|
||||
| | before sidings | now |
|
||||
| --- | --- | --- |
|
||||
| revenue | 3.9 | 3.2 |
|
||||
| freight | 0.9 | 1.0 |
|
||||
| drops per game | 3.3 | **11.1** |
|
||||
| couplings per game | ~0.4 | **7.9** |
|
||||
|
||||
Switching is transformed; revenue is not. The crew now does real work — roughly 4 Moves per
|
||||
productive act, which is what running a loop costs — but that work is not yet converting into
|
||||
Revenue. Where it goes next is an open question rather than a known fix.
|
||||
|
||||
---
|
||||
|
||||
## Board rendering, three-page site, curve geometry
|
||||
|
||||
Drawing the board as track, splitting the site, tooltips, and the curve fix.
|
||||
|
||||
### Board rendering
|
||||
|
||||
Two renderers in `src/sim/board-svg.ts`, chosen because they fail in opposite places:
|
||||
|
||||
- **Office Area** — the district stays a map. Cards on a grid with the rails drawn edge to edge, so
|
||||
a join is rail meeting rail rather than two descriptions that happen to agree. The through rail
|
||||
sits at a constant height on every card, which is the alignment the printed cards use.
|
||||
- **The Division** — not a map but a queue of sections with hard capacities, so it is a dispatcher's
|
||||
diagram: one line per track, `1 of 2 free` under each section, `no limit — trains queue` at the
|
||||
Division Points.
|
||||
|
||||
Both are **self-contained** — no imports, no module-level helpers — because the playable app imports
|
||||
them normally while the replay is a single HTML file with an inline script that cannot import
|
||||
anything, and embeds them via `Function.toString()`. One implementation either way; a second copy
|
||||
would eventually draw a different board from the same state.
|
||||
|
||||
Rails come from the engine's own `connectionsFor`, now exported. A drawn rail cannot claim a
|
||||
connection the rules do not have — visible in that **Modifier cards draw no rails at all**.
|
||||
|
||||
Capacity, confirmed from the rules and now shown: Division Points unlimited, most Mainline cards 1,
|
||||
Double Track and Uncontrolled Siding 2, Offices 1/2/3/4 by tier.
|
||||
|
||||
### Three pages
|
||||
|
||||
`index.html` is now a splash with two doors. The game moved to `play.html`; `replays.html` is a
|
||||
directory.
|
||||
|
||||
**A replay is a save**, and a save is `{seed, history}`. The engine is deterministic and runs in the
|
||||
browser, so re-submitting the same moves rebuilds the position exactly — **18 KB instead of 3.4 MB**,
|
||||
about 190× smaller, small enough to email. A save that would describe an impossible position cannot
|
||||
be replayed at all, because every step goes back through `applyIntent`; the viewer stops and says so
|
||||
rather than showing a board the rules could not produce.
|
||||
|
||||
Static hosting cannot list a directory, so `replays/manifest.json` is generated at build time from
|
||||
whatever is in `public/replays/`, with each file validated first.
|
||||
|
||||
### Tooltips
|
||||
|
||||
Reference detail is read once and printing it costs the space the board and action list need. A
|
||||
single delegated tooltip (`src/web/tooltip.ts`) now carries card effects, action reasoning, and
|
||||
facility state. Not the native `title`: that waits a second, cannot be styled, and never appears for
|
||||
keyboard users.
|
||||
|
||||
### Curve geometry — the significant fix
|
||||
|
||||
A curve was modelled as a through track **plus** a diverging leg, which is a turnout. Consequences:
|
||||
|
||||
- Curves were **topologically identical duplicates of turnouts** — same connections, no distinction
|
||||
beyond the sharp curve's 2-Move cost.
|
||||
- Every diverging leg went south, so **no piece anywhere reached north** except an n-s straight,
|
||||
which connects n↔s and nothing else.
|
||||
- Therefore a district could only ever be a **vertical column**: no siding, no parallel track, no
|
||||
run-around, no second turnout back to the main.
|
||||
|
||||
The printed cards (`docs/tracks.png`, rows 3–4) show a curve as a single arc from edge to edge with
|
||||
no through track. A curve is now a **two-port arc**, rotatable to `ne`/`nw`/`se`/`sw`. A sharp curve
|
||||
is geometrically identical and costs 2 Moves. Turnouts keep §A.1 unchanged — a two-port arc has no
|
||||
third port, so the absent-edge rule cannot apply to it.
|
||||
|
||||
Verified through the engine, not the types: a crew leaves the main at a turnout, runs a siding
|
||||
parallel, and rejoins at the far end.
|
||||
|
||||
### Measurements
|
||||
|
||||
| | revenue | freight | sidings built |
|
||||
| --- | --- | --- | --- |
|
||||
| before | 4.1 | 0.5 | impossible |
|
||||
| geometry only | **3.9** | 0.9 | possible, not sought |
|
||||
| bot builds sidings | 3.3 | **1.1** | **59/60 games** |
|
||||
|
||||
Freight more than doubled and the harness recorded **its first win**, but overall revenue is down
|
||||
from geometry-alone and the bad tail worsened (−29 → −59).
|
||||
|
||||
Capping turnouts at one or two measured **worse** (revenue 2.5, freight 0.6) — more ways off the main
|
||||
means more industries the crew can reach, and that beats a tidy Running Track. The uncapped version
|
||||
stands, with that measurement recorded at the code.
|
||||
|
||||
**The bot builds sidings but does not exploit them.** It pays about 20 of its 26 track pieces for
|
||||
them while its switching logic still sets out dead weight on a spur rather than planning a
|
||||
run-around. That is the next piece of work and where the revenue should appear.
|
||||
|
||||
---
|
||||
|
||||
## Earlier commits
|
||||
|
||||
Recorded from memory of the work rather than written at the time; detail thins going back.
|
||||
|
||||
### `f00255c` — more fixes and tweaks
|
||||
|
||||
Card descriptions on every card in hand and every face-up slot, the three Local Operations options
|
||||
explained, the objective and pace in the header, and rotations named in words rather than
|
||||
"rotation 2". Build stamp added (version, git SHA, `-dirty` for an uncommitted tree) because nothing
|
||||
tracked what was deployed. Extra X22 was **not** a bug — a per-diem train whose card calls for a
|
||||
caboose and nothing else — but "loaded caboose" was.
|
||||
|
||||
### `d1f689d` — name the caboose, the train and the Running Track hazard
|
||||
|
||||
`maneuver.redFlags` was labelled "Red Flags on tray3": the earlier tray-id fix checked the history
|
||||
log, and the action list was a surface it missed. An industry on the Running Track does not block
|
||||
traffic (§11.2 gives it rails) but a car left standing there is hit by the next arrival (§10).
|
||||
|
||||
### `dd300ac` — fix caching, Limits placement, and explain the board better
|
||||
|
||||
The first deploy served a fresh `index.html` against a **cached** `main.js`, which threw
|
||||
`missing element: target` and never started. Every module import now carries the build tag. The
|
||||
Limits fix was half-done: the sign could move, but nothing forbade building past it, so one straight
|
||||
at (0,2) produced `limits · Whistle Post · limits · straight · limits`.
|
||||
|
||||
### `2eca9de` — playable browser build
|
||||
|
||||
The solitaire game as a static site, proven to need no server: full games run with every Node global
|
||||
replaced by a throwing stub. Train cards stopped accepting a board placement — seed 555 had offered
|
||||
Extra X15 at six squares with six rotations, all identical.
|
||||
|
||||
### `160190d` — the bot's reasoning in the replay
|
||||
|
||||
Decision panel showing what the bot chose, why, and every option it passed over, with the reasons
|
||||
reported by the bot itself rather than re-derived by the viewer.
|
||||
|
||||
### `d261ad7` — parking trains, freight deadlock, Whistle Post lock-in
|
||||
|
||||
Three faults found by measurement rather than failing tests. Trains parked because `destinationsFor`
|
||||
computed reverse as `facing === 'e' ? 'w' : 'e'`, so a crew facing south reversed to east — a port a
|
||||
north-south card does not have. Freight ran at a **3% load completion rate** because a load started
|
||||
with no spotted car parks on WORK and locks the industry track, blocking the very car that would
|
||||
clear it. Office density doubled after 25 of 100 games never drew a Depot and never escaped a
|
||||
one-track Whistle Post.
|
||||
@@ -24,15 +24,24 @@ single browser.
|
||||
|
||||
```
|
||||
station-master/
|
||||
├── CHANGELOG.md ← what changed and why, in detail, commit to commit
|
||||
├── TODO.md ← open questions, provisional numbers, things to come back to
|
||||
├── docs/
|
||||
│ ├── rules/ ← the ruleset, card reference, glossary, decision record
|
||||
│ └── architecture/ ← how it is built, and what the pieces are
|
||||
│ ├── architecture/ ← how it is built, and what the pieces are
|
||||
│ └── design/ ← board layout studies and rendering samples
|
||||
├── public/replays/ ← saved games published to the site's replay directory
|
||||
├── scripts/ ← build and deploy the static site
|
||||
├── src/
|
||||
│ └── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
|
||||
│ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
|
||||
│ ├── sim/ ← bot, harness, replay, board rendering
|
||||
│ └── web/ ← the playable site: splash, game, replay viewer
|
||||
└── test/
|
||||
```
|
||||
|
||||
Start with [`docs/design.md`](docs/design.md) — it indexes everything.
|
||||
Start with [`docs/design.md`](docs/design.md) — it indexes everything. Commit messages stay high
|
||||
level; [`CHANGELOG.md`](CHANGELOG.md) carries the reasoning and the measurements, and
|
||||
[`TODO.md`](TODO.md) is what we have decided not to forget.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# To do
|
||||
|
||||
Things worth coming back to. Anything noted here should either get done or get an explicit decision
|
||||
not to — the point is that nothing quietly evaporates.
|
||||
|
||||
Ordered within each section by how much it is currently costing us.
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] **Find out why switching work does not become Revenue.** The crew now drops 11 cars a game and
|
||||
couples 8 (was 3.3 and ~0.4), at about 4 Moves per productive act — real railroading. Revenue
|
||||
did not move (3.9 → 3.2). Either the freight it is now shuffling is not the freight the
|
||||
industries want, or the loads finish too late in the Day to be worked, or the Revenue is going
|
||||
somewhere and being lost again. Measure the freight chain end to end before changing anything.
|
||||
|
||||
---
|
||||
|
||||
## Open questions for Jesse
|
||||
|
||||
Blocked on a decision, not on work.
|
||||
|
||||
- [ ] **Q13 — rear-end collisions on a Mainline card.** §10 says a Mainline collision is the
|
||||
Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders —
|
||||
but §8.3's trigger list does not include one, and **none is implemented**. Granting clearance is
|
||||
currently free: verified, both trains survive, no penalty. Three candidates: collide on entry
|
||||
(clearance becomes a gamble), collide on catching up (rewards judging the gap), or accept that
|
||||
clearance is safe and ABS Signals is worth less than it reads. The buttons currently describe
|
||||
only what the engine does, so nothing promises a consequence that cannot happen.
|
||||
- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect
|
||||
as "TBD in the source". A test asserts it stays TBD so nobody invents one.
|
||||
- [ ] **Heavy Grade orientation at setup.** The card says "Player sets orientation", but `createGame`
|
||||
is synchronous and has no decision point, so it is currently rolled from the seed. Should become
|
||||
a real choice when setup gains an interactive phase.
|
||||
|
||||
---
|
||||
|
||||
## Balance, provisional
|
||||
|
||||
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
|
||||
target is settled and freight carries its intended share.
|
||||
|
||||
- [ ] **Office card density** (Depot 4→8, Station 2→4, Terminal 1→2). Chosen to remove a 25% chance
|
||||
of an unwinnable opening deal. Blunt: it lifts the whole ladder and dilutes every other
|
||||
category. The better answer may be fewer Terminals, a cheaper first upgrade, or more A/D
|
||||
capacity at the Whistle Post itself.
|
||||
- [ ] **Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio; freight still only
|
||||
13–18% of gross revenue.
|
||||
- [ ] **Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often
|
||||
than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density
|
||||
changes.
|
||||
- [ ] **The victory target itself** (20 over 5 Days). Still only 1 win in 100. Worth revisiting once
|
||||
the bot exploits sidings, since that is a known unclaimed gain.
|
||||
|
||||
---
|
||||
|
||||
## Not yet built
|
||||
|
||||
- [ ] **Action cards (10) and Space-use cards (12).** Genuinely multiplayer-only — they are played AT
|
||||
an opponent. Rejected with `NOT_IMPLEMENTED`.
|
||||
- [ ] **Multiplayer proper.** The engine runs 2–5 player games and the bot plays them, but there is no
|
||||
server, no turn submission, and no per-player view.
|
||||
|
||||
---
|
||||
|
||||
## Smaller things
|
||||
|
||||
- [ ] **Carry `links` forward in replay frames.** Static per card and never changes once laid, but
|
||||
re-sent whenever anything on the board changes. The replay is 3.4 MB, mostly board state.
|
||||
- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
|
||||
its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every
|
||||
frame — but the number itself deserves a reason.
|
||||
- [ ] **Curves are drawn as two straight segments meeting**, not true arcs. Fine at this size, angular
|
||||
close up.
|
||||
- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal
|
||||
scrolling. Legible, not compact.
|
||||
- [ ] **No way to start a fresh game from inside the page.** `?seed=` gives a reproducible deal and
|
||||
"new game" only appears once a game has ended, so abandoning a bad opening means editing the URL.
|
||||
- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
|
||||
failing loudly, which is the safe direction but says little about what changed.
|
||||
|
||||
---
|
||||
|
||||
## Done, kept for the reasoning
|
||||
|
||||
- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction
|
||||
decides which car is droppable; the bot runs around rather than setting out, when the drop can
|
||||
follow. Switching activity transformed, revenue unchanged.
|
||||
- [x] **Curve geometry.** Curves were topologically identical duplicates of turnouts, and nothing
|
||||
reached north, so a district could only be a vertical column. Now two-port rotatable arcs.
|
||||
- [x] **Q10 — when track may be laid.** During the "draw a card" option, one piece a turn. Track was a
|
||||
card when §6.2 was written; a 26-piece supply has no hand to bound it.
|
||||
- [x] **Q11 — which way a Heavy Grade climbs.** Answered from the card: it prints "(Up)" and "Player
|
||||
sets orientation", so it is a property of the placed card, not a compass constant.
|
||||
- [x] **Q12 — Whistle Post lock-in.** Players always start at a Whistle Post; office density doubled
|
||||
instead.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+276
-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.
|
||||
@@ -488,3 +726,38 @@ The temptation is to rebuild everything at once. I would not.
|
||||
|
||||
**Do not retune anything against current measurements.** Gap 12's numbers describe a placeholder
|
||||
ruleset. Re-measure after step 5 and treat everything before that as void.
|
||||
|
||||
---
|
||||
|
||||
## §10 Q13 — rear-end collisions on a Mainline card are not implemented
|
||||
|
||||
**Found by playing.** The Superintendent's §8.1 ruling asks whether one train may follow another onto
|
||||
an occupied Mainline card. Granting it is currently **free**: both trains cross, nothing collides, no
|
||||
penalty. Verified directly — two trains on one card, clearance granted, both alive and revenue
|
||||
unchanged.
|
||||
|
||||
Two places in the rules say it should not be free:
|
||||
|
||||
- **§10** — "A collision on a Mainline card is the fault of the Superintendent who cleared the trains
|
||||
out; he loses 5 Revenue points" and "**Train-on-train.** Both trains are immediately removed."
|
||||
- **ABS Signals** — "Trains on this card will not rear-end each other. They will stop short of a
|
||||
collision." A card whose entire purpose is preventing rear-enders presupposes that rear-enders
|
||||
happen.
|
||||
|
||||
But **§8.3's trigger list does not include it.** The three automatic triggers are all about arriving
|
||||
at an Office or highballing past fouled track; none covers a follower catching a leader mid-card. So
|
||||
the rule is asserted in §10 and priced by ABS Signals, yet never given a trigger.
|
||||
|
||||
The missing piece is **timing**, and it is a design decision rather than a transcription gap.
|
||||
Crossing is counted in Stages (Q1), so both trains simply run their counters down. Candidates:
|
||||
|
||||
1. **On entry** — following onto an occupied card wrecks both at once. Makes clearance a pure gamble
|
||||
and ABS Signals very strong.
|
||||
2. **On catching up** — collide only when the follower's remaining Stages would take it past the
|
||||
leader. Rewards judging the gap, which is what a Superintendent actually does.
|
||||
3. **Never** — clearance is genuinely safe, §10's Mainline clause is vestigial, and ABS Signals is
|
||||
worth less than it reads.
|
||||
|
||||
Until this is settled the clearance buttons describe only what the engine actually does — they say
|
||||
the train "closes up behind" rather than promising a −5 risk that cannot occur. Wording that invents
|
||||
a consequence is worse than wording that under-sells one.
|
||||
|
||||
+4
-1
@@ -9,7 +9,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/**/*.test.ts"
|
||||
"test": "node --test test/**/*.test.ts",
|
||||
"build:web": "node scripts/build-web.ts",
|
||||
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
|
||||
"deploy:web": "node scripts/deploy-web.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.2",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Build the static solitaire site into `dist/`.
|
||||
*
|
||||
* Everything here is BUILD-time. The output is plain files on disk — upload them anywhere that
|
||||
* serves static content and the game runs entirely in the visitor's browser. No server, no API, no
|
||||
* network call at runtime.
|
||||
*
|
||||
* The one thing a browser cannot do is run TypeScript: Node 22's type-stripping is a Node feature.
|
||||
* So `tsc` emits ES2022 modules, rewriting the `.ts` import extensions the engine uses into `.js`.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dist = join(root, 'dist');
|
||||
|
||||
rmSync(dist, { recursive: true, force: true });
|
||||
mkdirSync(dist, { recursive: true });
|
||||
|
||||
execFileSync(
|
||||
'npx',
|
||||
[
|
||||
'tsc',
|
||||
'--ignoreConfig',
|
||||
'src/web/main.ts',
|
||||
'src/web/splash.ts',
|
||||
'src/web/replays.ts',
|
||||
'--outDir', dist,
|
||||
'--rootDir', 'src',
|
||||
'--target', 'es2022',
|
||||
'--module', 'es2022',
|
||||
'--moduleResolution', 'bundler',
|
||||
'--lib', 'es2022,dom',
|
||||
'--types', '',
|
||||
'--strict',
|
||||
'--allowImportingTsExtensions',
|
||||
'--rewriteRelativeImportExtensions',
|
||||
],
|
||||
{ cwd: root, stdio: 'inherit' },
|
||||
);
|
||||
|
||||
/**
|
||||
* Stamp the build into the page.
|
||||
*
|
||||
* Once this is published, "what is actually deployed?" stops being answerable by looking at the
|
||||
* source. The stamp is written into index.html at copy time rather than generated into `src/`, so
|
||||
* no generated file has to be ignored, imported, or kept in step.
|
||||
*
|
||||
* `-dirty` marks a build made from an uncommitted tree — the honest state of most test deploys, and
|
||||
* exactly the thing you want to know when a fix appears to have had no effect.
|
||||
*/
|
||||
function buildStamp(): string {
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
|
||||
let git = 'nogit';
|
||||
try {
|
||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
||||
.toString()
|
||||
.trim();
|
||||
const dirty =
|
||||
execFileSync('git', ['status', '--porcelain'], { cwd: root }).toString().trim().length > 0;
|
||||
git = sha + (dirty ? '-dirty' : '');
|
||||
} catch {
|
||||
// A build from a tarball with no git history is still a valid build.
|
||||
}
|
||||
const when = new Date().toISOString().replace('T', ' ').slice(0, 16);
|
||||
return `v${pkg.version} · ${git} · ${when}Z`;
|
||||
}
|
||||
|
||||
const stamp = buildStamp();
|
||||
|
||||
/**
|
||||
* Cache-bust every module.
|
||||
*
|
||||
* A returning visitor gets a fresh index.html and STALE JavaScript, because a static host caches
|
||||
* .js and the filenames never change. That is not a cosmetic staleness: it mixes new HTML with old
|
||||
* code, and the first mismatch is fatal. It happened on the first real deploy — index.html had
|
||||
* dropped an element that the cached main.js still asked for, so the page threw
|
||||
* `missing element: target` and the game never started.
|
||||
*
|
||||
* Appending the build tag to every relative import makes each deploy a new set of URLs, so a
|
||||
* browser cannot serve half of one build and half of another.
|
||||
*/
|
||||
const tag = encodeURIComponent(stamp.split(' · ')[1] ?? stamp);
|
||||
|
||||
function bustImports(dir: string): number {
|
||||
let count = 0;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += bustImports(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith('.js')) continue;
|
||||
const src = readFileSync(full, 'utf8');
|
||||
const next = src.replace(
|
||||
/^(\s*(?:import|export)[^'"\n]*?from\s+['"])(\.[^'"]+\.js)(['"])/gm,
|
||||
(_m, head: string, spec: string, tail: string) => `${head}${spec}?v=${tag}${tail}`,
|
||||
);
|
||||
if (next !== src) {
|
||||
writeFileSync(full, next);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const busted = bustImports(dist);
|
||||
|
||||
// Three pages now: the splash, the game, and the replay directory. Each is stamped and each has
|
||||
// its entry script cache-busted, or a returning visitor gets new HTML against old modules.
|
||||
for (const name of ['index.html', 'play.html', 'replays.html']) {
|
||||
const page = readFileSync(join(root, 'src/web', name), 'utf8')
|
||||
.replaceAll('__BUILD__', stamp)
|
||||
.replace(/(<script type="module" src="[^"]+?\.js)(")/, `$1?v=${tag}$2`);
|
||||
writeFileSync(join(dist, name), page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish whatever replays are in `public/replays/`, plus an index of them.
|
||||
*
|
||||
* Static hosting cannot list a directory, so the page needs a manifest. A replay is a save — seed
|
||||
* plus moves — so these are a few hundred bytes each, not megabytes.
|
||||
*/
|
||||
const replaySrc = join(root, 'public/replays');
|
||||
const replayOut = join(dist, 'replays');
|
||||
mkdirSync(replayOut, { recursive: true });
|
||||
const manifest: { file: string; title: string; note?: string; seed?: number }[] = [];
|
||||
if (existsSync(replaySrc)) {
|
||||
for (const f of readdirSync(replaySrc).filter((n) => n.endsWith('.json') && n !== 'manifest.json')) {
|
||||
const raw = readFileSync(join(replaySrc, f), 'utf8');
|
||||
try {
|
||||
const save = JSON.parse(raw) as { seed?: number; title?: string; note?: string; history?: unknown[] };
|
||||
if (typeof save.seed !== 'number' || !Array.isArray(save.history)) {
|
||||
console.warn(` skipped ${f} — not a Station Master save`);
|
||||
continue;
|
||||
}
|
||||
writeFileSync(join(replayOut, f), raw);
|
||||
manifest.push({
|
||||
file: f,
|
||||
title: save.title ?? f.replace(/\.json$/, ''),
|
||||
...(save.note ? { note: save.note } : {}),
|
||||
seed: save.seed,
|
||||
});
|
||||
} catch {
|
||||
console.warn(` skipped ${f} — could not be parsed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(join(replayOut, 'manifest.json'), JSON.stringify(manifest, null, 1));
|
||||
|
||||
// A tiny note for whoever unzips this later and wonders what it needs.
|
||||
writeFileSync(
|
||||
join(dist, 'README.txt'),
|
||||
[
|
||||
'Station Master — solitaire, static build.',
|
||||
'',
|
||||
'Upload the contents of this folder to any static host and open index.html.',
|
||||
'There is no server component. The game runs entirely in the browser and saves',
|
||||
'to localStorage. Add ?seed=1234 to the URL for a reproducible deal.',
|
||||
'',
|
||||
'Note: ES modules require the files to be SERVED over http(s). Opening',
|
||||
'index.html directly from the filesystem will be blocked by the browser.',
|
||||
'To try it locally: npx http-server dist (or any static file server)',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`built -> ${dist}\n ${stamp}\n cache-busted ${busted} modules with ?v=${tag}` +
|
||||
`\n ${manifest.length} replay(s) published`,
|
||||
);
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Build the solitaire site and push it to a File Browser instance.
|
||||
*
|
||||
* Written against File Browser v2.63's REST API, read from its own bundle rather than guessed:
|
||||
*
|
||||
* POST /api/login {username, password, recaptcha} -> JWT as plain text
|
||||
* POST /api/resources/<dir>/ X-Auth: <jwt> -> create a directory
|
||||
* POST /api/resources/<file>?override=true X-Auth: <jwt>, body = bytes -> upload
|
||||
*
|
||||
* File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site.
|
||||
* So the job here is simply to land the built files in the right folder, intact.
|
||||
*
|
||||
* CREDENTIALS COME FROM THE ENVIRONMENT and are never written anywhere. Putting a password in a
|
||||
* repo is how it ends up in a commit, and this repo is going to be pushed.
|
||||
*
|
||||
* FB_USER=jesse FB_PASS='…' npm run deploy:web
|
||||
*
|
||||
* Optional:
|
||||
* FB_URL default https://phoenix.local:58157
|
||||
* FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
|
||||
* FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host)
|
||||
* --dry-run list what would be sent, contact nothing
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join, posix, relative, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dist = join(root, 'dist');
|
||||
|
||||
const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replace(/\/+$/, '');
|
||||
const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`;
|
||||
const USER = process.env['FB_USER'] ?? '';
|
||||
const PASS = process.env['FB_PASS'] ?? '';
|
||||
const DRY = process.argv.includes('--dry-run');
|
||||
|
||||
/**
|
||||
* A .local StartOS host presents a certificate the system store does not know. Disabling
|
||||
* verification is opt-in and announced rather than silent: it is the right call on a LAN box you
|
||||
* own and the wrong one everywhere else, and that judgment is not the script's to make quietly.
|
||||
*/
|
||||
if (process.env['FB_INSECURE'] === '1') {
|
||||
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
|
||||
console.warn('! TLS verification disabled (FB_INSECURE=1)');
|
||||
}
|
||||
|
||||
/** Every file in `dir`, as paths relative to it, with POSIX separators. */
|
||||
function walk(dir: string, base = dir): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) out.push(...walk(full, base));
|
||||
else out.push(relative(base, full).split(sep).join('/'));
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.txt': 'text/plain',
|
||||
};
|
||||
|
||||
async function login(): Promise<string> {
|
||||
const res = await fetch(`${URL_BASE}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: USER, password: PASS, recaptcha: '' }),
|
||||
});
|
||||
const body = await res.text();
|
||||
if (!res.ok) throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
|
||||
if (!body.trim()) throw new Error('login returned an empty token');
|
||||
return body.trim();
|
||||
}
|
||||
|
||||
async function makeDir(jwt: string, path: string): Promise<void> {
|
||||
// Trailing slash is what marks a directory in this API. A 409 means it already exists, which is
|
||||
// the normal case on every deploy after the first.
|
||||
const res = await fetch(`${URL_BASE}/api/resources${encodePath(path)}/`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Auth': jwt },
|
||||
});
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(jwt: string, localPath: string, remotePath: string): Promise<void> {
|
||||
const bytes = readFileSync(localPath);
|
||||
const ext = remotePath.slice(remotePath.lastIndexOf('.'));
|
||||
const res = await fetch(`${URL_BASE}/api/resources${encodePath(remotePath)}?override=true`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Auth': jwt,
|
||||
'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
|
||||
'Content-Length': String(bytes.byteLength),
|
||||
},
|
||||
body: new Uint8Array(bytes),
|
||||
});
|
||||
if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
/** Encode each segment but keep the separators, so a path stays a path. */
|
||||
function encodePath(p: string): string {
|
||||
return p.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('building…');
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'inherit' });
|
||||
|
||||
const files = walk(dist);
|
||||
if (files.length === 0) throw new Error('dist/ is empty — nothing to deploy');
|
||||
|
||||
const dirs = [...new Set(files.map((f) => posix.dirname(f)).filter((d) => d !== '.'))].sort();
|
||||
const total = files.reduce((n, f) => n + statSync(join(dist, f)).size, 0);
|
||||
|
||||
console.log(`\n${files.length} files, ${(total / 1024).toFixed(0)} KB`);
|
||||
console.log(` from ${dist}`);
|
||||
console.log(` to ${URL_BASE}${DEST}\n`);
|
||||
|
||||
if (DRY) {
|
||||
for (const d of dirs) console.log(` dir ${DEST}/${d}`);
|
||||
for (const f of files) console.log(` file ${DEST}/${f}`);
|
||||
console.log('\ndry run — nothing was sent');
|
||||
} else {
|
||||
if (!USER || !PASS) {
|
||||
throw new Error(
|
||||
'set FB_USER and FB_PASS.\n' +
|
||||
" e.g. FB_USER=me FB_PASS='…' FB_INSECURE=1 npm run deploy:web\n" +
|
||||
' or run with --dry-run to see what would be sent',
|
||||
);
|
||||
}
|
||||
|
||||
const jwt = await login();
|
||||
console.log('logged in');
|
||||
|
||||
await makeDir(jwt, DEST);
|
||||
for (const d of dirs) await makeDir(jwt, `${DEST}/${d}`);
|
||||
|
||||
let done = 0;
|
||||
for (const f of files) {
|
||||
await upload(jwt, join(dist, f), `${DEST}/${f}`);
|
||||
done++;
|
||||
console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
|
||||
}
|
||||
|
||||
console.log(`\ndeployed to ${URL_BASE}${DEST}`);
|
||||
console.log('Start9 Pages serves these files as the site; File Browser is only the store.');
|
||||
}
|
||||
+138
-10
@@ -19,6 +19,7 @@
|
||||
import {
|
||||
COLLISION_PENALTY,
|
||||
MAINLINE_PROFILES,
|
||||
enhancementRule,
|
||||
consistSize,
|
||||
crossingStages,
|
||||
trainProfile,
|
||||
@@ -32,9 +33,9 @@ import {
|
||||
} from './content.ts';
|
||||
import type { Direction } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import { areaOf } from './apply.ts';
|
||||
import { acceptsCar, areaOf } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, TrayId } from './state.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurn, totalRevenue } from './state.ts';
|
||||
|
||||
export type AdvanceResult = {
|
||||
@@ -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,18 +594,60 @@ 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) {
|
||||
collide(s, owner, [id], events, 'no free A/D track');
|
||||
// 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', 'the Office');
|
||||
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));
|
||||
if (officeCard && officeCard.standing.length > 0) {
|
||||
collide(s, owner, [id], events, 'cars fouling the Running Track');
|
||||
collide(s, owner, [id], events, 'cars fouling the Running Track', 'the Running Track');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
@@ -562,10 +672,22 @@ function collide(
|
||||
trains: TrayId[],
|
||||
events: GameEvent[],
|
||||
reason: string,
|
||||
where = 'the Office',
|
||||
): void {
|
||||
// Record WHAT was lost before removing it. The log said only "COLLISION: NO FREE A/D TRACK", so a
|
||||
// player could see the 5 points go without learning which train had just been written off, or
|
||||
// what it was carrying.
|
||||
const lost: { label: string; consist: RollingStock[] }[] = [];
|
||||
for (const id of trains) {
|
||||
const tray = s.trays.get(id);
|
||||
if (!tray) continue;
|
||||
lost.push({
|
||||
label:
|
||||
tray.trainNumber === null
|
||||
? 'the local crew'
|
||||
: `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`,
|
||||
consist: [...tray.consist],
|
||||
});
|
||||
// Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
|
||||
for (const car of tray.consist) {
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
||||
@@ -575,6 +697,10 @@ function collide(
|
||||
s.freeTrays.push(id);
|
||||
}
|
||||
|
||||
if (lost.length > 0) {
|
||||
events.push({ type: 'trainsDestroyed', player: faultPlayer, trains: lost, reason, where });
|
||||
}
|
||||
|
||||
s.collisionsToday += 1;
|
||||
const player = s.players[faultPlayer];
|
||||
if (player) {
|
||||
@@ -627,6 +753,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;
|
||||
|
||||
+515
-14
@@ -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,10 +685,30 @@ 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) {
|
||||
events.push({ type: 'carsCoupled', trayId: i.trayId, at: i.to, stock: dest.couples });
|
||||
// Name the cards the cars came from. The reducer used to clear every card in the district
|
||||
// instead, so one coupling anywhere deleted every standing car and every industry track in
|
||||
// the Office Area — loads worked over several Stages vanished when a crew picked up a single
|
||||
// boxcar somewhere else entirely.
|
||||
const lifted = [
|
||||
...dest.path.map((step) => step.coord),
|
||||
i.to,
|
||||
].filter((c) => carsOn(areaOf(s, player).grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
||||
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
|
||||
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
|
||||
// up, they couple behind. Which end they land on is the whole point of a run-around: it
|
||||
// decides which car is next to come off.
|
||||
events.push({
|
||||
type: 'carsCoupled',
|
||||
trayId: i.trayId,
|
||||
at: i.to,
|
||||
stock: dest.couples,
|
||||
from: lifted,
|
||||
toNose: !i.reverse,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -552,6 +721,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 +790,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 +826,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 +995,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).
|
||||
@@ -778,15 +1011,27 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'carsCoupled': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
|
||||
tray.consist.push(...e.stock);
|
||||
// Every card along the path is cleared; the event carries what was taken.
|
||||
for (const card of area.grid.values()) {
|
||||
if (card.standing.length > 0) card.standing = [];
|
||||
if (e.toNose) tray.consist.unshift(...e.stock);
|
||||
else tray.consist.push(...e.stock);
|
||||
// ONLY the cards the crew ran over. Clearing the whole grid emptied industry tracks the crew
|
||||
// never went near.
|
||||
for (const coord of e.from) {
|
||||
const card = area.grid.get(coordKey(coord));
|
||||
if (!card) continue;
|
||||
card.standing = [];
|
||||
if (card.facility) card.facility.industryTrack.cars = [];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
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 +1078,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 +1087,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 +1189,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 +1337,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;
|
||||
@@ -1044,6 +1349,7 @@ function protoCard(
|
||||
kind: 'track',
|
||||
geometry,
|
||||
...(v.axis ? { axis: v.axis } : {}),
|
||||
...(v.arc ? { arc: v.arc } : {}),
|
||||
...(v.turnout ? { turnout: v.turnout } : {}),
|
||||
...(v.bypass ? { bypass: v.bypass } : {}),
|
||||
},
|
||||
@@ -1117,6 +1423,135 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
/** A stand-in with nothing on it, so a missing card reads as "no cars here" rather than throwing. */
|
||||
function emptyCard(): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: false,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
|
||||
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
|
||||
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.arc ? { arc: v.arc } : {}),
|
||||
...(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 +1582,76 @@ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKin
|
||||
}
|
||||
|
||||
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
|
||||
/**
|
||||
* §2.1 Gap 4a — "when you extend your Running Track, the Limits sign MOVES outwards with it".
|
||||
*
|
||||
* The sign is a physical card, not just a recorded column. Moving only the coordinate left the
|
||||
* Limits card stranded mid-track: a district grew to
|
||||
* (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2)=Freight House …
|
||||
* with everything beyond (0,-2) built OUTSIDE a sign that never moved. That is not cosmetic —
|
||||
* §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
|
||||
* an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
|
||||
*/
|
||||
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||
if (placed.row !== area.runningRow) return;
|
||||
if (placed.col <= area.limitsWest.col) area.limitsWest = { row: placed.row, col: placed.col - 1 };
|
||||
if (placed.col >= area.limitsEast.col) area.limitsEast = { row: placed.row, col: placed.col + 1 };
|
||||
|
||||
if (placed.col <= area.limitsWest.col) {
|
||||
area.limitsWest = { row: placed.row, col: placed.col - 1 };
|
||||
area.grid.set(coordKey(area.limitsWest), limitsCard());
|
||||
}
|
||||
if (placed.col >= area.limitsEast.col) {
|
||||
area.limitsEast = { row: placed.row, col: placed.col + 1 };
|
||||
area.grid.set(coordKey(area.limitsEast), limitsCard());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.2 — may this car be coupled onto this train right now?
|
||||
*
|
||||
* "The train must be in the order listed on the train's card (engine on the front, Rolling Stock,
|
||||
* and possibly a Caboose). It may depart with FEWER Rolling Stock than listed, but not out of
|
||||
* order." Fewer is allowed; more, or of the wrong category, is not.
|
||||
*
|
||||
* SHARED. Both `check` and the New Train Phase's "is there a suitable car in the yard" test call
|
||||
* this. A second copy stalled the game outright: the phase believed a car could be added while
|
||||
* `check` rejected every option, so the Stage never completed.
|
||||
*/
|
||||
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!profile) return true;
|
||||
|
||||
const cat = (t: CarType): 'coach' | 'caboose' | 'freight' =>
|
||||
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
||||
const adding = cat(carType);
|
||||
const already = tray.consist.filter((c) => cat(c.type) === adding).length;
|
||||
const allowed =
|
||||
adding === 'coach'
|
||||
? profile.consist.coach
|
||||
: adding === 'caboose'
|
||||
? profile.consist.caboose
|
||||
: profile.consist.freight;
|
||||
if (already >= allowed) return false;
|
||||
|
||||
// The caboose rides last (§A.3), so nothing may be coupled behind one.
|
||||
if (adding !== 'caboose' && tray.consist.some((c) => c.type === 'caboose')) return false;
|
||||
|
||||
// The card also narrows WHICH freight types it will take.
|
||||
const types = profile.consist.freightTypes;
|
||||
if (adding === 'freight' && types && !types.includes(carType)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
|
||||
function limitsCard(): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+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);
|
||||
|
||||
+29
-2
@@ -21,12 +21,36 @@ 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: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
stock: RollingStock[];
|
||||
/** The cards the cars were lifted from — the ones the crew actually ran over. */
|
||||
from: GridCoord[];
|
||||
/**
|
||||
* Coupled onto the ENGINE'S NOSE rather than behind the train (§A.3). True when the crew was
|
||||
* running forward: the engine meets the cars head-on and takes them on the front.
|
||||
*/
|
||||
toNose: boolean;
|
||||
}
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/** `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: 'trainsDestroyed';
|
||||
player: PlayerIndex;
|
||||
trains: { label: string; consist: RollingStock[] }[];
|
||||
reason: string;
|
||||
where: string;
|
||||
}
|
||||
| { type: 'flyingSwitch'; player: PlayerIndex; cardId: CardId; trayId: TrayId; to: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier }
|
||||
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number }
|
||||
| { type: 'deckReshuffled' }
|
||||
@@ -46,15 +70,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++) {
|
||||
|
||||
+56
-2
@@ -58,6 +58,13 @@ export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | '
|
||||
* how many run each way, yet a layout with no north-south straight can never use the Office's
|
||||
* junction stubs at all.
|
||||
*/
|
||||
/**
|
||||
* A curve joins two ADJACENT edges. There are exactly four such arcs, and a card can be turned to
|
||||
* any of them — which is what makes a siding possible: an arc reaching NORTH is the only way back
|
||||
* up to the Running Track from the district below.
|
||||
*/
|
||||
export type TrackArc = 'ne' | 'nw' | 'se' | 'sw';
|
||||
|
||||
export type TrackAxis = 'ew' | 'ns';
|
||||
|
||||
export type CardGeometry =
|
||||
@@ -66,6 +73,8 @@ export type CardGeometry =
|
||||
geometry: TrackGeometry;
|
||||
turnout?: TurnoutOrientation;
|
||||
axis?: TrackAxis;
|
||||
/** Which two edges a CURVE joins — chosen on placement, since the card can be turned. */
|
||||
arc?: TrackArc;
|
||||
/** Curves are printed left- or right-handed (design supply); not chosen on placement. */
|
||||
hand?: 'left' | 'right';
|
||||
}
|
||||
@@ -73,7 +82,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 +97,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 +112,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 +207,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 +232,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 +376,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 +391,7 @@ export function freshTurn(moves: number): TurnState {
|
||||
option: null,
|
||||
movesRemaining: moves,
|
||||
drawnThisTurn: false,
|
||||
laidThisTurn: false,
|
||||
freightAgentUsed: false,
|
||||
done: false,
|
||||
};
|
||||
|
||||
+48
-16
@@ -23,6 +23,7 @@ import type {
|
||||
RollingStock,
|
||||
TrackCard,
|
||||
TrayId,
|
||||
TrackArc,
|
||||
TurnoutOrientation,
|
||||
} from './state.ts';
|
||||
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
|
||||
@@ -77,10 +78,16 @@ type PortPair = readonly [Port, Port];
|
||||
* - **facility** — a through track; the industry spur is the card's own spotting capacity rather
|
||||
* than a separate port.
|
||||
*/
|
||||
function connectionsFor(card: TrackCard): readonly PortPair[] {
|
||||
/**
|
||||
* Exported so the board can DRAW what the engine believes. Deriving the rails from anything else
|
||||
* would let a picture disagree with the rules about whether two cards join — which is the one thing
|
||||
* a track diagram exists to settle.
|
||||
*/
|
||||
export function connectionsFor(card: TrackCard): readonly PortPair[] {
|
||||
switch (card.geometry.kind) {
|
||||
// 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']];
|
||||
@@ -107,18 +114,22 @@ function connectionsFor(card: TrackCard): readonly PortPair[] {
|
||||
[o.stem, o.diverge],
|
||||
];
|
||||
}
|
||||
// A CURVE joins the through track to one diverging side — handedness is printed on the
|
||||
// card, not chosen (the design supplies both hands). A SHARP curve is geometrically the
|
||||
// same but costs two Moves to cross.
|
||||
// Curves and turnouts diverge DOWNWARD from the through track (Q7); handedness decides
|
||||
// which end of the through track the leg leaves from, not whether it goes up or down.
|
||||
/**
|
||||
* A CURVE IS AN ARC between two adjacent edges — one connection, no through track. The
|
||||
* printed cards show exactly this (docs/tracks.png, rows 3-4): a single sweep from edge to
|
||||
* edge with nothing running past it.
|
||||
*
|
||||
* It was previously modelled as a through track PLUS a diverging leg, which is a turnout —
|
||||
* and made curves topologically identical duplicates of them. Worse, every leg diverged
|
||||
* south, so no piece anywhere reached NORTH except an n-s straight. A district could
|
||||
* therefore only ever be a vertical column: no siding, no parallel track, no run-around.
|
||||
*
|
||||
* A SHARP curve is the same shape and costs two Moves to cross.
|
||||
*/
|
||||
case 'curved':
|
||||
case 'sharpCurved': {
|
||||
const stem = card.geometry.hand === 'left' ? 'w' : 'e';
|
||||
return [
|
||||
['e', 'w'],
|
||||
[stem, 's'],
|
||||
];
|
||||
const arc = card.geometry.arc ?? (card.geometry.hand === 'left' ? 'sw' : 'se');
|
||||
return [[arc[0] as Port, arc[1] as Port]];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +154,10 @@ export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', di
|
||||
* §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's
|
||||
* rotation is free.
|
||||
*/
|
||||
const ARCS: readonly TrackArc[] = ['ne', 'nw', 'se', 'sw'];
|
||||
|
||||
export type TrackVariant = {
|
||||
arc?: TrackArc;
|
||||
axis?: 'ew' | 'ns';
|
||||
turnout?: TurnoutOrientation;
|
||||
bypass?: Port;
|
||||
@@ -166,9 +180,9 @@ export function variantsFor(geometry: TrackGeometry): TrackVariant[] {
|
||||
return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
|
||||
case 'curved':
|
||||
case 'sharpCurved':
|
||||
// Handedness is printed on the card, and the leg always goes down (Q7), so nothing is left
|
||||
// to choose — one variant.
|
||||
return [{ axis: 'ew' }];
|
||||
// All four rotations. A two-port arc has no handedness that survives turning — its mirror IS
|
||||
// one of its rotations — so the printed hand governs supply, not what can be built.
|
||||
return ARCS.map((arc) => ({ arc }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +343,25 @@ export function allReachable(
|
||||
* stubs above and below so this is satisfiable from the opening Stage.
|
||||
*/
|
||||
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
|
||||
if (cardAt(area, coord)) return false;
|
||||
const existing = cardAt(area, coord);
|
||||
// A Limits sign on the Running Track is the GROWTH POINT, not an obstacle. Extending means laying
|
||||
// the card where the sign stands and moving the sign outward (§2.1, Gap 4a) — the physical act at
|
||||
// the table. Treating the sign as occupied forced players to build PAST it, stranding the sign
|
||||
// mid-track with everything beyond it nominally outside their own Limits.
|
||||
const isMovableSign =
|
||||
existing?.geometry.kind === 'limits' &&
|
||||
coord.row === area.runningRow &&
|
||||
existing.standing.length === 0;
|
||||
if (existing && !isMovableSign) return false;
|
||||
|
||||
// §2.1 — the Running Track runs BETWEEN the Limits. Nothing on that row may sit outside them, so
|
||||
// the sign itself is the only growth point. Allowing a placement beyond it built track on the far
|
||||
// side of the sign and then planted a SECOND sign further out, leaving the board reading
|
||||
// limits · Whistle Post · limits · straight · limits
|
||||
// with a Limits card stranded mid-track. The district below the Running Track is unbounded.
|
||||
if (coord.row === area.runningRow) {
|
||||
if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false;
|
||||
}
|
||||
|
||||
const ports: Port[] = ['n', 's', 'e', 'w'];
|
||||
for (const p of ports) {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Drawing the board.
|
||||
*
|
||||
* Two renderers, chosen because they fail in opposite places (docs/design/board-layout-studies.html):
|
||||
*
|
||||
* - `officeSvg` — the district stays a MAP. Cards on a grid with the rails actually drawn, so a
|
||||
* join is rail meeting rail rather than two descriptions that happen to agree.
|
||||
* - `divisionSvg`— the Division is not a map, it is a queue of sections with hard capacities, so
|
||||
* it is drawn as a dispatcher's diagram: one line per track, count them to know
|
||||
* what fits.
|
||||
*
|
||||
* SELF-CONTAINED ON PURPOSE. Both functions reference nothing outside their own bodies — no imports,
|
||||
* no module-level helpers. The playable app imports them normally; the replay is a single HTML file
|
||||
* with an inline script and cannot import anything, so it embeds these via `Function.toString()`.
|
||||
* That keeps ONE implementation: a second copy would eventually draw a different board from the same
|
||||
* state, which is the exact failure a track diagram exists to prevent.
|
||||
*
|
||||
* Rails come from `CellView.links`, which is the engine's own `connectionsFor`. A drawn rail can
|
||||
* therefore never claim a connection the rules do not have.
|
||||
*/
|
||||
|
||||
import type { CellView, DivisionView } from './view.ts';
|
||||
|
||||
/** Anything the renderers need to know about a train standing somewhere. */
|
||||
export type BoardTrain = { label: string; consist: string[] };
|
||||
|
||||
/**
|
||||
* The Division as a dispatcher would see it: one continuous line per running track, sections
|
||||
* separated by thin seams, capacity legible because the lines can be counted.
|
||||
*/
|
||||
export function divisionSvg(nodes: DivisionView[]): string {
|
||||
const COL = 190;
|
||||
const TOP = 34;
|
||||
const LANE = 30;
|
||||
const width = Math.max(1, nodes.length) * COL;
|
||||
const maxCap = nodes.reduce((n, d) => Math.max(n, d.capacity ?? 3), 1);
|
||||
const height = TOP + maxCap * LANE + 54;
|
||||
|
||||
const esc = (t: string): string =>
|
||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||
|
||||
const rail = (x1: number, y: number, x2: number): string => {
|
||||
let out =
|
||||
`<line class="bs-rail" x1="${x1}" y1="${y - 2.5}" x2="${x2}" y2="${y - 2.5}"/>` +
|
||||
`<line class="bs-rail" x1="${x1}" y1="${y + 2.5}" x2="${x2}" y2="${y + 2.5}"/>`;
|
||||
const n = Math.max(2, Math.floor(Math.abs(x2 - x1) / 9));
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const tx = x1 + ((x2 - x1) * i) / n;
|
||||
out += `<line class="bs-tie" x1="${tx}" y1="${y - 4.5}" x2="${tx}" y2="${y + 4.5}"/>`;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const chip = (x: number, y: number, t: BoardTrain): string => {
|
||||
const w = 30 + Math.min(t.consist.length, 4) * 9;
|
||||
return (
|
||||
`<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}">` +
|
||||
`<rect x="${x - w / 2}" y="${y - 10}" width="${w}" height="20" rx="3"/>` +
|
||||
`<text class="bs-tlab" x="${x}" y="${y + 4}" text-anchor="middle">${esc(t.label)}</text></g>`
|
||||
);
|
||||
};
|
||||
|
||||
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
|
||||
nodes.forEach((node, i) => {
|
||||
const x0 = i * COL;
|
||||
const x1 = x0 + COL;
|
||||
const cap = node.capacity;
|
||||
const lanes = cap ?? 3;
|
||||
const kind = node.kind === 'dp' ? 'dp' : node.kind === 'office' ? 'office' : 'ml';
|
||||
|
||||
out += `<rect class="bs-sec bs-${kind}" x="${x0 + 2}" y="${TOP - 16}" width="${COL - 4}" height="${lanes * LANE + 30}" rx="5"/>`;
|
||||
out += `<text class="bs-name" x="${x0 + 10}" y="${TOP - 3}">${esc(node.label)}</text>`;
|
||||
if (node.gradeUp) {
|
||||
out += `<text class="bs-grade" x="${x1 - 10}" y="${TOP - 3}" text-anchor="end">climbs ${node.gradeUp === 'east' ? 'E ▲' : '▲ W'}</text>`;
|
||||
}
|
||||
|
||||
// One rail per track this section can hold. Unlimited sections are drawn as three faded lines
|
||||
// and labelled, rather than pretending to a number they do not have.
|
||||
for (let k = 0; k < lanes; k++) {
|
||||
out += `<g class="${cap === null ? 'bs-open' : ''}">${rail(x0 + 12, TOP + 14 + k * LANE, x1 - 12)}</g>`;
|
||||
}
|
||||
|
||||
const here = node.trains.flat();
|
||||
here.forEach((t, k) => {
|
||||
const lane = Math.min(k, lanes - 1);
|
||||
const across = here.length > lanes ? (k - lane) * 34 : 0;
|
||||
out += chip(x0 + COL / 2 + across, TOP + 14 + lane * LANE, t);
|
||||
});
|
||||
|
||||
const free = cap === null ? '∞' : `${Math.max(0, cap - here.length)} of ${cap} free`;
|
||||
const warn = cap !== null && here.length >= cap ? ' bs-full' : '';
|
||||
out += `<text class="bs-cap${warn}" x="${x0 + 10}" y="${TOP + lanes * LANE + 8}">${cap === null ? 'no limit — trains queue' : free}</text>`;
|
||||
if (node.modifiers.length > 0) {
|
||||
out += `<text class="bs-mod" x="${x0 + 10}" y="${TOP + lanes * LANE + 22}">${esc(node.modifiers.join(' · '))}</text>`;
|
||||
}
|
||||
});
|
||||
out += '</svg>';
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Office Area as a map: one card per grid square, rails drawn edge to edge.
|
||||
*
|
||||
* The through rail sits at a constant height on every card — the alignment the printed cards use —
|
||||
* so abutting cards produce one unbroken line and a gap is visibly a gap.
|
||||
*/
|
||||
export function officeSvg(cells: CellView[], runningRow: number): string {
|
||||
const W = 132;
|
||||
const H = 96;
|
||||
const PAD = 3;
|
||||
const RAIL = 30;
|
||||
|
||||
const esc = (t: string): string =>
|
||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||
|
||||
if (cells.length === 0) return '<svg class="bs" viewBox="0 0 10 10"></svg>';
|
||||
const rows = cells.map((c) => c.row);
|
||||
const cols = cells.map((c) => c.col);
|
||||
const r0 = Math.min(...rows);
|
||||
const r1 = Math.max(...rows);
|
||||
const c0 = Math.min(...cols);
|
||||
const c1 = Math.max(...cols);
|
||||
const width = (c1 - c0 + 1) * (W + PAD);
|
||||
const height = (r1 - r0 + 1) * (H + PAD) + 4;
|
||||
|
||||
// Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest
|
||||
// and the district hangs beneath it, as the rules describe it.
|
||||
const px = (col: number): number => (col - c0) * (W + PAD);
|
||||
const py = (row: number): number => (r1 - row) * (H + PAD);
|
||||
|
||||
const rail = (x1: number, y1: number, x2: number, y2: number): string => {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy);
|
||||
const nx = (-dy / len) * 2.5;
|
||||
const ny = (dx / len) * 2.5;
|
||||
let out =
|
||||
`<line class="bs-rail" x1="${x1 + nx}" y1="${y1 + ny}" x2="${x2 + nx}" y2="${y2 + ny}"/>` +
|
||||
`<line class="bs-rail" x1="${x1 - nx}" y1="${y1 - ny}" x2="${x2 - nx}" y2="${y2 - ny}"/>`;
|
||||
const n = Math.max(2, Math.floor(len / 9));
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const tx = x1 + (dx * i) / n;
|
||||
const ty = y1 + (dy * i) / n;
|
||||
out += `<line class="bs-tie" x1="${tx + nx * 1.8}" y1="${ty + ny * 1.8}" x2="${tx - nx * 1.8}" y2="${ty - ny * 1.8}"/>`;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Where each port meets the card edge. East and west sit at the rail height so a straight run
|
||||
// stays straight across the whole row; north and south are centred.
|
||||
const port = (p: string): { x: number; y: number } => {
|
||||
if (p === 'e') return { x: W, y: RAIL };
|
||||
if (p === 'w') return { x: 0, y: RAIL };
|
||||
if (p === 'n') return { x: W / 2, y: 0 };
|
||||
return { x: W / 2, y: H };
|
||||
};
|
||||
|
||||
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
|
||||
for (const cell of cells) {
|
||||
const x = px(cell.col);
|
||||
const y = py(cell.row);
|
||||
const kind =
|
||||
cell.kind === 'office'
|
||||
? 'office'
|
||||
: cell.kind === 'limits'
|
||||
? 'lim'
|
||||
: cell.kind === 'facility'
|
||||
? 'fac'
|
||||
: cell.kind === 'modifier'
|
||||
? 'mod'
|
||||
: 'trk';
|
||||
|
||||
// Addressable so a caller can outline a square as a legal destination without re-rendering.
|
||||
// The explanation rides on the group as a tooltip rather than being printed: it is reference
|
||||
// detail, read once, and printing it costs the space the board itself needs.
|
||||
out += `<g data-cell="${cell.row},${cell.col}" data-tip="${esc(cell.label)} — ${esc(cell.what)}" transform="translate(${x},${y})">`;
|
||||
out += `<rect class="bs-card bs-${kind}${cell.running ? ' bs-run' : ''}" x="0" y="0" width="${W}" height="${H}" rx="4"/>`;
|
||||
|
||||
// The rails. A Modifier is not track and deliberately gets none — that is why nothing can be
|
||||
// routed through it, and the picture should say so.
|
||||
for (const link of cell.links) {
|
||||
const a = port(link[0] ?? 'e');
|
||||
const b = port(link[1] ?? 'w');
|
||||
const straight = (link === 'ew' || link === 'we') || (link === 'ns' || link === 'sn');
|
||||
if (straight) {
|
||||
out += rail(a.x, a.y, b.x, b.y);
|
||||
} else {
|
||||
// A diverging leg: run out from each edge and meet, which reads as a curve at this size.
|
||||
const mid = { x: W / 2, y: RAIL + (H - RAIL) / 2 };
|
||||
out += rail(a.x, a.y, mid.x, mid.y) + rail(mid.x, mid.y, b.x, b.y);
|
||||
}
|
||||
}
|
||||
|
||||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
||||
|
||||
// Standing room, drawn as the printed squares: filled means occupied.
|
||||
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
|
||||
for (let i = 0; i < Math.min(spots, 4); i++) {
|
||||
const filled = i < cell.cars.length;
|
||||
out += `<rect class="bs-slot${filled ? ' bs-occ' : ''}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
|
||||
if (filled) {
|
||||
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc((cell.cars[i] ?? '').slice(0, 3))}</text>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (cell.enhancements.length > 0) {
|
||||
out += `<text class="bs-enh" x="6" y="${H - 30}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
}
|
||||
if (cell.tray) {
|
||||
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
|
||||
`<text class="bs-tlab" x="${W / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(cell.tray.split(' ')[0] ?? 'crew')}</text></g>`;
|
||||
}
|
||||
out += '</g>';
|
||||
}
|
||||
|
||||
// Mark the Running Track so the spine of the district is unmistakable.
|
||||
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
|
||||
out += '</svg>';
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Targets for legal EMPTY squares. They have no card to outline, so the board draws a dashed
|
||||
* placeholder — the commonest placement of all is onto a blank square, and without this the
|
||||
* highlight would have nothing to attach to.
|
||||
*/
|
||||
export function ghostSvg(
|
||||
spots: { row: number; col: number }[],
|
||||
cells: CellView[],
|
||||
runningRow: number,
|
||||
): string {
|
||||
const W = 132;
|
||||
const H = 96;
|
||||
const PAD = 3;
|
||||
const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...spots];
|
||||
if (all.length === 0) return '';
|
||||
const r1 = Math.max(...all.map((c) => c.row));
|
||||
const c0 = Math.min(...all.map((c) => c.col));
|
||||
let out = '';
|
||||
for (const s of spots) {
|
||||
const x = (s.col - c0) * (W + PAD);
|
||||
const y = (r1 - s.row) * (H + PAD);
|
||||
out +=
|
||||
`<g data-ghost="${s.row},${s.col}" transform="translate(${x},${y})" class="bs-ghost">` +
|
||||
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
|
||||
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
|
||||
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${s.row},${s.col}</text>` +
|
||||
`</g>`;
|
||||
}
|
||||
void runningRow;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Styling for both renderers. Shared so the replay and the app cannot drift apart visually. */
|
||||
export const BOARD_CSS = `
|
||||
.bs{width:100%;height:auto;background:#0e1116;border-radius:6px;padding:6px;overflow:visible}
|
||||
.bs-rail{stroke:#cfd6e0;stroke-width:1.6}
|
||||
.bs-tie{stroke:#8d97a5;stroke-width:1.1}
|
||||
.bs-open .bs-rail{stroke:#6b7686;stroke-dasharray:6 4}
|
||||
.bs-open .bs-tie{stroke:#59626f}
|
||||
.bs-card{fill:#161b21;stroke:#39424e;stroke-width:1}
|
||||
.bs-card.bs-run{fill:#1d232c}
|
||||
.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
|
||||
.bs-lim{fill:#241d1d;stroke:#8a5a5a}
|
||||
.bs-fac{fill:#201c26;stroke:#7a5f9a}
|
||||
.bs-mod{fill:#231d2b;stroke:#5c4a70;stroke-dasharray:4 3}
|
||||
.bs-sec{fill:#161b21;stroke:#39424e}
|
||||
.bs-sec.bs-dp{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3}
|
||||
.bs-sec.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2}
|
||||
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
||||
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
|
||||
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
||||
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
||||
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
||||
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
|
||||
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
||||
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
|
||||
.bs-cap.bs-full{fill:#e0a060;font-weight:600}
|
||||
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
||||
.bs-mod{font:10px ui-monospace,monospace}
|
||||
text.bs-mod{fill:#c8a04a}
|
||||
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
||||
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
||||
.bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4}
|
||||
.bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace}
|
||||
.bs-ghost,g[data-cell].bs-legal{cursor:pointer}
|
||||
g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5}
|
||||
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
|
||||
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
|
||||
g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
|
||||
`;
|
||||
+542
-45
@@ -19,13 +19,14 @@
|
||||
* complete at all.
|
||||
*/
|
||||
|
||||
import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts';
|
||||
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarType, laborersLeft } from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { variantsFor } from '../engine/track.ts';
|
||||
import { coordKey } from '../engine/state.ts';
|
||||
import type { Facility, GameState, PlayerIndex, RollingStock } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
name: string;
|
||||
@@ -56,12 +57,37 @@ function ruleOnClearance(options: Intent[]): Intent | null {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Why the developer bot made its last choice, for the replay's decision panel.
|
||||
*
|
||||
* WRITE-ONLY DIAGNOSTIC. Nothing reads this to make a decision, so it cannot affect play or
|
||||
* determinism — it exists so the viewer can show the branch that fired instead of re-deriving the
|
||||
* reasoning, which would drift from the bot exactly as a second copy of the rules would.
|
||||
*/
|
||||
let lastReason = '';
|
||||
|
||||
export function lastChoiceReason(): string {
|
||||
return lastReason;
|
||||
}
|
||||
|
||||
/** Records the branch that fired and returns the intent unchanged. */
|
||||
function because(reason: string, intent: Intent): Intent {
|
||||
lastReason = reason;
|
||||
return intent;
|
||||
}
|
||||
|
||||
export const developerBot: BotPolicy = {
|
||||
name: 'developer',
|
||||
|
||||
choose(s, player, options) {
|
||||
lastReason = 'no specific reason — first legal option';
|
||||
const clearance = ruleOnClearance(options);
|
||||
if (clearance) return clearance;
|
||||
if (clearance) return because('the Superintendent must rule on a following train (§8.1)', clearance);
|
||||
|
||||
// Red Flags come before anything else — protection is only worth playing at the moment the
|
||||
// collision is actually pending, and that moment passes.
|
||||
const flags = worthFlagging(s, options);
|
||||
if (flags) return 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 +95,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 +124,9 @@ export const developerBot: BotPolicy = {
|
||||
const match = options.find(
|
||||
(i) => i.type === 'newTrain.placeCar' && i.carType === w.type && i.loaded === w.loaded,
|
||||
);
|
||||
if (match) return match;
|
||||
if (match) return because(`the ${w.loaded ? 'loaded' : 'empty'} ${w.type} is what a facility is short of`, match);
|
||||
}
|
||||
return pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!;
|
||||
return because('no car on offer is one our facilities need', pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!);
|
||||
}
|
||||
|
||||
// --- Local Operations.
|
||||
@@ -127,28 +159,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 +221,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 +280,237 @@ 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;
|
||||
|
||||
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
|
||||
const below = area.runningRow - 1;
|
||||
|
||||
/** Does this card send a leg DOWN off the Running Track? */
|
||||
const divergesSouth = (c: TrackCard | undefined): boolean =>
|
||||
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'turnout';
|
||||
|
||||
/** A card on the siding row that runs east-west — the siding itself. */
|
||||
const runsAcross = (c: TrackCard | undefined): boolean =>
|
||||
!!c && c.geometry.kind === 'track' && c.geometry.geometry === 'straight' && c.geometry.axis === 'ew';
|
||||
|
||||
/** An arc that reaches up to the Running Track. */
|
||||
const reachesUp = (c: TrackCard | undefined): boolean =>
|
||||
!!c &&
|
||||
c.geometry.kind === 'track' &&
|
||||
(c.geometry.geometry === 'curved' || c.geometry.geometry === 'sharpCurved') &&
|
||||
(c.geometry.arc === 'ne' || c.geometry.arc === 'nw');
|
||||
|
||||
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
|
||||
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
|
||||
|
||||
// Where the district already turns down off the main.
|
||||
const turnouts = [...area.grid.entries()]
|
||||
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c))
|
||||
.map(([k]) => Number(k.split(',')[1]));
|
||||
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const i of options) {
|
||||
if (i.type !== 'track.lay') continue;
|
||||
const { row, col } = i.placement;
|
||||
const dRow = Math.abs(row - area.officeCoord.row);
|
||||
const dCol = Math.abs(col - area.officeCoord.col);
|
||||
let score = -(dRow * 2 + dCol);
|
||||
|
||||
/**
|
||||
* BUILD A SIDING, not a stub.
|
||||
*
|
||||
* A turnout dropping into a dead-end column is worth nothing: the crew can shove cars down it
|
||||
* but never get past them. What makes switching solvable is a siding — down off the main, along
|
||||
* beside it, and ideally back up — because that is what lets a crew run around its own train and
|
||||
* change the order of the cars. Scored as a sequence, so each piece is laid because the previous
|
||||
* one asked for it.
|
||||
*/
|
||||
if (row === area.runningRow && i.geometry === 'turnout') {
|
||||
// A first way down is the most valuable single piece on the board; a second closes the
|
||||
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
|
||||
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
|
||||
// siding they were supposed to serve.
|
||||
// Measured: capping this to one or two ways down cost more than the spare turnouts did
|
||||
// (revenue 3.3 -> 2.5, freight 1.1 -> 0.6). More ways off the main means more industries the
|
||||
// crew can actually reach, which matters more than a tidy Running Track.
|
||||
score += turnouts.length === 0 ? 14 : 3;
|
||||
} else if (row === below) {
|
||||
const arc = arcOf(i);
|
||||
const turnoutAbove = divergesSouth(at(area.runningRow, col));
|
||||
if (arc && (arc === 'ne' || arc === 'nw') && turnoutAbove) {
|
||||
// Turn along beneath the turnout — this is what a bare n-s stub could never do.
|
||||
score += 13;
|
||||
} else if (i.geometry === 'straight' && variantsFor('straight')[i.variant ?? 0]?.axis === 'ew') {
|
||||
// Extend the siding beside the main, but only from something that already turned.
|
||||
if (reachesUp(at(row, col - 1)) || reachesUp(at(row, col + 1)) ||
|
||||
runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1))) {
|
||||
score += 11;
|
||||
}
|
||||
} else if (arc && (arc === 'ne' || arc === 'nw') &&
|
||||
(runsAcross(at(row, col - 1)) || runsAcross(at(row, col + 1)))) {
|
||||
// Close the loop back up to the main: the run-around is complete.
|
||||
score += 12;
|
||||
}
|
||||
}
|
||||
|
||||
if (i.geometry === 'straight') score += 2;
|
||||
if (dRow > 0) score += 3;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/** Where the crew could go NEXT from a square, so a plan can be checked one move ahead. */
|
||||
function reachableFrom(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
trayId: string,
|
||||
from: GridCoord,
|
||||
): GridCoord[] {
|
||||
return [
|
||||
...destinationsFor(s, player, trayId, from, false),
|
||||
...destinationsFor(s, player, trayId, from, true),
|
||||
].map((d) => d.coord);
|
||||
}
|
||||
|
||||
/**
|
||||
* The consist this move would leave, coupling included.
|
||||
*
|
||||
* Asks the engine's own reachability for what the crew would pick up, so the prediction cannot
|
||||
* disagree with what actually happens when the move is submitted.
|
||||
*/
|
||||
function consistAfterMove(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
move: Extract<Intent, { type: 'switch.move' }>,
|
||||
): RollingStock[] | null {
|
||||
const tray = s.trays.get(move.trayId);
|
||||
if (!tray || tray.position.at !== 'grid') return null;
|
||||
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, move.reverse).find(
|
||||
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
|
||||
);
|
||||
if (!dest) return null;
|
||||
// Forward means the engine leads and meets the cars head-on (§A.3).
|
||||
return move.reverse ? [...tray.consist, ...dest.couples] : [...dest.couples, ...tray.consist];
|
||||
}
|
||||
|
||||
/** Is this player's crew sitting somewhere it can never depart from? */
|
||||
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
|
||||
const area = areaOf(s, player);
|
||||
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 +524,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 +635,127 @@ 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 (endCar && here) {
|
||||
const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
|
||||
if (hereFacility && facilityWants(hereFacility, endCar)) {
|
||||
const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
|
||||
if (drop) return drop;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise head for a facility that does want this particular car.
|
||||
const wanted = facilitiesWanting(s, player, endCar);
|
||||
if (endCar && here) {
|
||||
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 because('standing on a facility that wants the back car — spot it', drop);
|
||||
}
|
||||
|
||||
/**
|
||||
* RUN AROUND. §A.3 gives the engine couplers on its nose, so cars met while running
|
||||
* FORWARD land in front of the train and cars met while BACKING land behind — which decides
|
||||
* which car is next off the tail.
|
||||
*
|
||||
* That is what a siding is for. Rather than setting a useless car down and coming back for
|
||||
* it, the crew can approach from the side that leaves the car it actually wants droppable.
|
||||
* Both directions are offered on every move; this picks the one that ends with useful work
|
||||
* at the tail.
|
||||
*/
|
||||
const runAround = options.find((i) => {
|
||||
if (i.type !== 'switch.move') return false;
|
||||
const after = consistAfterMove(s, player, i);
|
||||
if (!after || after.length === 0) return false;
|
||||
const tail = after[after.length - 1]!;
|
||||
// The approach must change the answer: a useless car at the tail now, a wanted one after.
|
||||
if (facilitiesWanting(s, player, endCar).length > 0) return false;
|
||||
const wants = facilitiesWanting(s, player, tail);
|
||||
if (wants.length === 0) return false;
|
||||
// AND the drop has to be able to follow. Without this the crew ran the loop for its own
|
||||
// sake — 8 run-arounds a game and 63 Moves, which the shuttling guard correctly failed.
|
||||
return wants.some((w) => w.row === i.to.row && w.col === i.to.col) ||
|
||||
reachableFrom(s, player, i.trayId, i.to).some((c) =>
|
||||
wants.some((w) => w.row === c.row && w.col === c.col),
|
||||
);
|
||||
});
|
||||
if (runAround) return because('running around: approach from the side that leaves a wanted car droppable', runAround);
|
||||
|
||||
// SET OUT — and do it BEFORE travelling.
|
||||
//
|
||||
// Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations
|
||||
// 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 +764,13 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
||||
// rescue it. §6.3's unjam exists for exactly this. Clear it before anything else.
|
||||
if (hasStuckLoad(s, player)) {
|
||||
const unjam = options.find((i) => i.type === 'freightAgent.unjam' && i.from === 'menAtWork');
|
||||
if (unjam) return unjam;
|
||||
if (unjam) return because('a jammed load blocks the pipeline AND strips the industry track of Operational Rail', unjam);
|
||||
}
|
||||
const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
|
||||
if (clear) return clear;
|
||||
if (clear) return because('the red Inbound box is full and blocking further unloading', clear);
|
||||
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
|
||||
if (stock) return stock;
|
||||
return pickFirst(options, 'freightAgent.unjam') ?? options[0]!;
|
||||
if (stock) return because('stock a green box so a Laborer has work next Stage', stock);
|
||||
return because('nothing productive at any facility — clear whatever is stuck', pickFirst(options, 'freightAgent.unjam') ?? options[0]!);
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -410,6 +874,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 +998,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 +1026,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 (
|
||||
|
||||
+87
-4
@@ -34,6 +34,9 @@ export function clockTime(stage: number): string {
|
||||
}
|
||||
|
||||
export function carLabel(c: RollingStock): string {
|
||||
// A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even
|
||||
// though the supply marks every caboose loaded. Name it plainly.
|
||||
if (c.type === 'caboose') return 'caboose';
|
||||
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
|
||||
}
|
||||
|
||||
@@ -84,10 +87,17 @@ export type Narration = {
|
||||
export type NarrateContext = {
|
||||
/** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */
|
||||
cardName?: (id: string) => string;
|
||||
/**
|
||||
* Resolves a Crew Tray id to the train riding it. Without this the §8.1 clearance question read
|
||||
* "may tray2 follow tray3 into the next Subdivision?" — the most consequential decision in the
|
||||
* game, phrased in internal identifiers.
|
||||
*/
|
||||
trainName?: (trayId: TrayId) => string;
|
||||
};
|
||||
|
||||
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
|
||||
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
|
||||
|
||||
switch (e.type) {
|
||||
// -- clock
|
||||
@@ -122,7 +132,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
|
||||
text:
|
||||
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||
`: ${carsLabel(e.stock)}`,
|
||||
};
|
||||
case 'consistSorted':
|
||||
return {
|
||||
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 {
|
||||
@@ -148,6 +166,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 +211,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 +242,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 +291,47 @@ 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 'trainsDestroyed': {
|
||||
// Say what hit what, where, and what was written off. "COLLISION: NO FREE A/D TRACK" told a
|
||||
// player the score had changed and nothing else.
|
||||
const why =
|
||||
e.reason === 'no free A/D track'
|
||||
? `it arrived at ${e.where} with every A/D track already occupied — there was nowhere to put it (§8.3)`
|
||||
: e.reason === 'cars fouling the Running Track'
|
||||
? `it ran into cars left standing on ${e.where} between the Limits and the Office (§8.3)`
|
||||
: e.reason;
|
||||
const wrecked = e.trains
|
||||
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
|
||||
.join(' and ');
|
||||
return {
|
||||
tone: 'bad',
|
||||
text:
|
||||
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
|
||||
`Yard, all other cars to the Classification Yard (§10). A Timetabled train card returns ` +
|
||||
`to its slot and runs again next Day; an Extra is gone for good.`,
|
||||
};
|
||||
}
|
||||
|
||||
case 'clearanceRequested':
|
||||
return {
|
||||
tone: 'bad',
|
||||
text: `SUPERINTENDENT ASKED: may ${e.trainId} follow ${e.occupiedBy} into the next Subdivision?`,
|
||||
text:
|
||||
`SUPERINTENDENT MUST RULE (§8.1): ${train(e.trainId)} wants to enter the Mainline card ` +
|
||||
`that ${train(e.occupiedBy)} is still crossing. Allow it and ${train(e.trainId)} may run ` +
|
||||
`into the back of ${train(e.occupiedBy)} — a collision costs 5 Revenue. Hold it and it ` +
|
||||
`waits where it is, losing time but safe.`,
|
||||
};
|
||||
case 'clearanceGiven':
|
||||
return {
|
||||
tone: e.allow ? 'bad' : 'plain',
|
||||
text: e.allow
|
||||
? `Clearance GRANTED to ${e.trainId} — it follows into an occupied Subdivision`
|
||||
: `Clearance DENIED to ${e.trainId} — it holds where it is`,
|
||||
? `Clearance GRANTED — ${train(e.trainId)} follows into the occupied Subdivision`
|
||||
: `Clearance DENIED — ${train(e.trainId)} holds where it is`,
|
||||
};
|
||||
|
||||
// -- passengers
|
||||
|
||||
+130
-285
@@ -23,262 +23,25 @@ import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from
|
||||
import type { GameLength } from '../engine/content.ts';
|
||||
import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { GameConfig, GameState } from '../engine/state.ts';
|
||||
import { developerBot } from './bot.ts';
|
||||
import type { Impediment } from './narrate.ts';
|
||||
import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } from './narrate.ts';
|
||||
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
||||
import { developerBot, lastChoiceReason } from './bot.ts';
|
||||
import { carLabel, idleNote, isVisible, narrate } from './narrate.ts';
|
||||
// The view-model lives in its own module so the browser build can import it without dragging in
|
||||
// this file's Node dependencies. Re-exported because tests and the web app import it from here.
|
||||
export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
|
||||
export { cardName, describeIntent, snapshot } from './view.ts';
|
||||
import type { Frame } from './view.ts';
|
||||
import type { Decision } from './view.ts';
|
||||
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
|
||||
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from './board-svg.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame shape — only what the viewer draws
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CellView = {
|
||||
row: number;
|
||||
col: number;
|
||||
kind: string;
|
||||
label: string;
|
||||
running: boolean;
|
||||
tray: string | null;
|
||||
cars: string[];
|
||||
facility: FacilityView | null;
|
||||
};
|
||||
|
||||
export type FacilityView = {
|
||||
name: string;
|
||||
commodity: string;
|
||||
flow: string;
|
||||
green: string[];
|
||||
greenCap: number;
|
||||
maw: (string | null)[];
|
||||
red: string[];
|
||||
redCap: number;
|
||||
track: string[];
|
||||
trackCap: number;
|
||||
laborers: string;
|
||||
porters: string;
|
||||
};
|
||||
|
||||
export type TrainChip = { label: string; consist: string[] };
|
||||
export type DivisionView = { kind: string; label: string; trains: TrainChip[][] };
|
||||
|
||||
export type Frame = {
|
||||
day: number;
|
||||
stage: number;
|
||||
clock: string;
|
||||
phase: string;
|
||||
actor: number | null;
|
||||
superintendent: number;
|
||||
revenue: number;
|
||||
lines: { text: string; tone: string }[];
|
||||
where: { row: number; col: number } | null;
|
||||
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
|
||||
whereFrom: { row: number; col: number } | null;
|
||||
division: DivisionView[];
|
||||
cells: CellView[];
|
||||
facilities: FacilityView[];
|
||||
hand: string[];
|
||||
deck: number;
|
||||
departments: string[];
|
||||
/** 12 slots; the train number due out at each Stage, or null. */
|
||||
timetable: (number | null)[];
|
||||
blocked: Impediment[];
|
||||
trains: { label: string; where: string }[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshotting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FACILITY_NAMES: Record<string, string> = {
|
||||
mineTipple: 'Mine Tipple',
|
||||
produceShed: 'Produce Shed',
|
||||
grocersWarehouse: "Grocer's Warehouse",
|
||||
oilRefinery: 'Oil Refinery',
|
||||
powerPlant: 'Power Plant',
|
||||
};
|
||||
|
||||
function facilityView(
|
||||
card: { geometry: { kind: string; facility?: string }; facility: unknown },
|
||||
officeName: string,
|
||||
): FacilityView | null {
|
||||
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
|
||||
// Passenger facilities were excluded entirely, so the Office's green and red slots never
|
||||
// appeared — which is why stocking a coach into the green box looked like nothing happening.
|
||||
if (!f) return null;
|
||||
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
|
||||
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
|
||||
return {
|
||||
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? key),
|
||||
commodity: facilityCarType(f) ?? '?',
|
||||
flow: f.kind === 'passenger'
|
||||
? 'passengers on and off'
|
||||
: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
|
||||
green: f.outboundBox.map(carLabel),
|
||||
greenCap: f.capacity.outbound,
|
||||
maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
|
||||
red: f.inboundBox.map(carLabel),
|
||||
redCap: f.capacity.inbound,
|
||||
track: f.industryTrack.cars.map(carLabel),
|
||||
trackCap: f.industryTrack.length,
|
||||
laborers: `${laborersLeft(f)}/${f.laborers}`,
|
||||
porters: `${portersLeft(f)}/${f.porters}`,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
s: GameState,
|
||||
lines: { text: string; tone: string }[],
|
||||
where: { row: number; col: number } | null,
|
||||
whereFrom: { row: number; col: number } | null = null,
|
||||
): Frame {
|
||||
const area = areaOf(s, 0);
|
||||
const trayAt = new Map<string, string>();
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at === 'grid') {
|
||||
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
|
||||
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
|
||||
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
|
||||
}
|
||||
}
|
||||
|
||||
const cells: CellView[] = [];
|
||||
const facilities: FacilityView[] = [];
|
||||
for (const [key, card] of area.grid) {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
const g = card.geometry;
|
||||
const kind = g.kind;
|
||||
let label: string;
|
||||
if (g.kind === 'office') label = officeProfile(area.tier).name;
|
||||
else if (g.kind === 'limits') label = 'Limits';
|
||||
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
|
||||
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier;
|
||||
else 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 +62,15 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
};
|
||||
const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] });
|
||||
const frames: Frame[] = [];
|
||||
const narrateCtx = { cardName: (id: string) => cardName(s, id) };
|
||||
const narrateCtx = {
|
||||
cardName: (id: string) => cardName(s, id),
|
||||
trainName: (id: string) => trainName(s, id),
|
||||
};
|
||||
|
||||
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
|
||||
let didSomething = false;
|
||||
// The choice that produced the events about to be pushed.
|
||||
let pendingDecision: Decision | null = null;
|
||||
|
||||
const push = (events: GameEvent[]): void => {
|
||||
const visible = events.filter(isVisible);
|
||||
@@ -327,7 +95,16 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
const where = visible.map((e) => narrate(e, narrateCtx)).find((n) => n.where)?.where ?? null;
|
||||
const moved = visible.find((e) => e.type === 'trayMoved');
|
||||
const from = moved && moved.type === 'trayMoved' ? moved.from : null;
|
||||
frames.push(snapshot(s, lines, where ?? null, from));
|
||||
const d = pendingDecision;
|
||||
pendingDecision = null;
|
||||
// A Local Operations turn that ended with nothing but the turn ending is a wasted action —
|
||||
// six Moves and a card play spent on nothing. Among 600+ frames these are invisible unless
|
||||
// marked, and they are the ones worth reviewing.
|
||||
const wasted =
|
||||
d !== null &&
|
||||
(d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') &&
|
||||
visible.every((e) => e.type === 'phaseEnded' || e.type === 'actorChanged');
|
||||
frames.push(snapshot(s, lines, where ?? null, from, d, wasted));
|
||||
};
|
||||
|
||||
frames.push(
|
||||
@@ -344,8 +121,11 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) break;
|
||||
const applied = applyIntent(s, actor, developerBot.choose(s, actor, options));
|
||||
const chosen = developerBot.choose(s, actor, options);
|
||||
const decision = describeDecision(s, actor, chosen, options, lastChoiceReason());
|
||||
const applied = applyIntent(s, actor, chosen);
|
||||
if (!applied.ok) break;
|
||||
pendingDecision = decision;
|
||||
push(applied.events);
|
||||
}
|
||||
|
||||
@@ -362,6 +142,27 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
// HTML
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Drop repeated board state from the serialised frames.
|
||||
*
|
||||
* Only the wire format changes: `record()` still returns complete frames, so tests and any other
|
||||
* consumer are unaffected. The page resolves a null by walking back to the last frame that carried
|
||||
* the field.
|
||||
*/
|
||||
function compress(frames: Frame[]): unknown[] {
|
||||
const keys = ['cells', 'facilities', 'division'] as const;
|
||||
let prev: Record<string, string> = {};
|
||||
return frames.map((f, i) => {
|
||||
const out: Record<string, unknown> = { ...f };
|
||||
for (const k of keys) {
|
||||
const json = JSON.stringify(f[k]);
|
||||
if (i > 0 && prev[k] === json) out[k] = null;
|
||||
prev[k] = json;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderHtml(rec: Recording): string {
|
||||
const target = lengthProfile(rec.length);
|
||||
return `<!doctype html>
|
||||
@@ -369,6 +170,8 @@ export function renderHtml(rec: Recording): string {
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Station Master — replay seed ${rec.seed}</title>
|
||||
<style>
|
||||
${BOARD_CSS}
|
||||
|
||||
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
|
||||
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
|
||||
*{box-sizing:border-box}
|
||||
@@ -431,6 +234,21 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
.mini .box{padding:0 4px;font-size:9.5px}
|
||||
.cell.office{border-color:var(--clock)}
|
||||
.cell.modifier{border-style:dashed;opacity:.85}
|
||||
|
||||
.chose{font-size:15px;margin-bottom:4px}
|
||||
.why{font-style:italic;opacity:.85;margin-bottom:2px}
|
||||
.rejected{list-style:none;margin:0;padding:0;max-height:210px;overflow:auto}
|
||||
.rejected li{padding:3px 0;border-top:1px solid rgba(128,128,128,.25);font-size:12px}
|
||||
.wasted{background:#b03030;color:#fff;font-weight:bold;padding:3px 6px;margin-bottom:5px;border-radius:3px}
|
||||
.fstat{margin-top:4px;font-size:11px;padding:2px 5px;border-radius:3px;display:inline-block}
|
||||
.fstat.good{background:rgba(40,140,60,.28)}
|
||||
.fstat.bad{background:rgba(190,50,50,.38);font-weight:bold}
|
||||
.fstat.idle{opacity:.6}
|
||||
.wastedmark{color:#e06060;font-weight:bold}
|
||||
|
||||
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
|
||||
.mlmods{font-size:10px;background:rgba(200,150,60,.28);border-radius:3px;padding:1px 4px;margin-top:2px}
|
||||
.grade{font-size:10px;background:rgba(200,90,60,.35);border-radius:3px;padding:1px 4px;font-weight:normal}
|
||||
</style></head><body>
|
||||
|
||||
<header>
|
||||
@@ -454,6 +272,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
</div>
|
||||
<div>
|
||||
<section><h2>What just happened</h2><div id="now"></div></section>
|
||||
<section><h2>Decision — what it chose, and what it passed over</h2><div id="decision"></div></section>
|
||||
<section><h2>Cards</h2>
|
||||
<div><span class="dim">your hand:</span> <span id="hand">—</span></div>
|
||||
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
|
||||
@@ -467,7 +286,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
<div class="legend">
|
||||
<b>Keyboard:</b> <kbd>←</kbd>/<kbd>→</kbd> step · <kbd>space</kbd> play/pause ·
|
||||
<kbd>Home</kbd>/<kbd>End</kbd> first/last · <kbd>S</kbd> next Stage · <kbd>R</kbd> next revenue change ·
|
||||
<kbd>C</kbd> next collision.<br>
|
||||
<kbd>C</kbd> next collision · <kbd>W</kbd> next wasted turn · <kbd>J</kbd> next jam.<br>
|
||||
Running Track is the lighter row. <span class="chip">T4</span> is a train on the Division;
|
||||
<span style="background:var(--warn);color:#0d1117;font-weight:700;border-radius:3px;padding:0 4px">🚂 T4</span>
|
||||
is the crew in your yard — it carries the whole train with it as it moves, and a dashed outline
|
||||
@@ -484,6 +303,8 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
<button id="stage">next Stage ⏭</button>
|
||||
<button id="money">next revenue 💰</button>
|
||||
<button id="crash">next collision ⚠</button>
|
||||
<button id="waste">next wasted turn 🗑</button>
|
||||
<button id="jam">next jam 🔒</button>
|
||||
<input type="range" id="scrub" min="0" value="0">
|
||||
<select id="speed">
|
||||
<option value="3000">extra slow</option>
|
||||
@@ -495,7 +316,20 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const FRAMES = ${JSON.stringify(rec.frames)};
|
||||
/* The board renderers, shared with the playable app. Embedded as SOURCE because this page has an
|
||||
inline script and cannot import a module — a second copy would eventually draw a different board
|
||||
from the same state, which is the one failure a track diagram exists to prevent. */
|
||||
const divisionSvg = ${divisionSvg.toString()};
|
||||
const officeSvg = ${officeSvg.toString()};
|
||||
|
||||
const FRAMES = ${JSON.stringify(compress(rec.frames))};
|
||||
/* The board changes rarely, so cells/facilities/division are emitted as null when identical to the
|
||||
previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of
|
||||
a 5.2 MB page. The scrubber jumps anywhere, hence the walk rather than a running pointer. */
|
||||
function carry(i, key) {
|
||||
for (let k = i; k >= 0; k--) if (FRAMES[k][key] !== null) return FRAMES[k][key];
|
||||
return [];
|
||||
}
|
||||
let i = 0, timer = null;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
||||
@@ -542,41 +376,23 @@ function render() {
|
||||
$('fno').textContent = i;
|
||||
$('scrub').value = i;
|
||||
|
||||
$('division').innerHTML = f.division.map((n) =>
|
||||
'<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) + '</div>' +
|
||||
'<div class="regions">' + n.trains.map((r) =>
|
||||
'<div class="region">' + r.map((t) =>
|
||||
'<span class="chip">' + esc(t.label) + '</span>' +
|
||||
'<div class="consist">' + (t.consist.length ? esc(t.consist.join(', ')) : 'empty') + '</div>'
|
||||
).join('') + '</div>').join('') +
|
||||
'</div></div>').join('');
|
||||
const CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
||||
|
||||
const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col);
|
||||
const r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols);
|
||||
const g = $('grid');
|
||||
g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
|
||||
let cellsHtml = '';
|
||||
for (let r = r1; r >= r0; r--) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const cell = f.cells.find((x) => x.row === r && x.col === c);
|
||||
if (!cell) { cellsHtml += '<div></div>'; continue; }
|
||||
const hl = f.where && f.where.row === r && f.where.col === c;
|
||||
const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
|
||||
cellsHtml += '<div class="cell ' + (cell.running ? 'run ' : '') + (cell.kind === 'office' ? 'office ' : '') +
|
||||
(cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
|
||||
'<div class="nm">' + esc(cell.label) + '</div>' +
|
||||
'<div class="dim" style="font-size:10px">(' + r + ',' + c + ')</div>' +
|
||||
(cell.tray ? '<div class="crew">🚂 ' + esc(cell.tray) + '</div>' : '') +
|
||||
(cell.facility ? miniFacility(cell.facility) : '') +
|
||||
(cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
|
||||
'</div>';
|
||||
$('division').innerHTML = divisionSvg(DIV);
|
||||
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow);
|
||||
if (f.where) {
|
||||
const hit = $('grid').querySelector('g[data-cell="' + f.where.row + ',' + f.where.col + '"]');
|
||||
if (hit) hit.classList.add('bs-focus');
|
||||
}
|
||||
if (f.whereFrom) {
|
||||
const was = $('grid').querySelector('g[data-cell="' + f.whereFrom.row + ',' + f.whereFrom.col + '"]');
|
||||
if (was) was.classList.add('bs-from');
|
||||
}
|
||||
g.innerHTML = cellsHtml;
|
||||
|
||||
$('facs').innerHTML = f.facilities.length === 0
|
||||
$('facs').innerHTML = FACS.length === 0
|
||||
? '<span class="dim">no freight facilities built yet</span>'
|
||||
: f.facilities.map((x) =>
|
||||
: FACS.map((x) =>
|
||||
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
|
||||
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
|
||||
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
|
||||
@@ -584,9 +400,34 @@ function render() {
|
||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>' +
|
||||
'<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
|
||||
'<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 'g') + '</div>' +
|
||||
'<div class="fstat ' + (x.jammed ? 'bad' : (x.canFinish ? 'good' : 'idle')) + '">' +
|
||||
(x.jammed
|
||||
? 'JAMMED — a load is on WORK with no car spotted to receive it; the industry track is locked'
|
||||
: (x.canFinish
|
||||
? 'ready — a matching empty car is spotted, so a load can finish'
|
||||
: 'no car spotted — starting a load here would jam it')) +
|
||||
'</div>' +
|
||||
'</div>').join('');
|
||||
|
||||
$('now').innerHTML = f.lines.map((l) => '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>').join('');
|
||||
|
||||
var d = f.decision;
|
||||
if (!d) {
|
||||
$('decision').innerHTML = '<span class="dim">no choice here — the engine advanced on its own</span>';
|
||||
} else {
|
||||
var h = '';
|
||||
if (f.wasted) h += '<div class="wasted">WASTED TURN — the whole action produced no change</div>';
|
||||
h += '<div class="chose"><span class="dim">chose:</span> <b>' + esc(d.chose) + '</b></div>';
|
||||
h += '<div class="why">why: ' + esc(d.why) + '</div>';
|
||||
h += '<div class="dim" style="margin:6px 0 3px">passed over ' + (d.totalOptions - 1) +
|
||||
' other legal option' + ((d.totalOptions - 1) === 1 ? '' : 's') + ':</div>';
|
||||
if (d.rejected.length === 0) h += '<div class="dim">none — this was the only legal action</div>';
|
||||
else h += '<ul class="rejected">' + d.rejected.map(function (r) {
|
||||
return '<li><b>' + esc(r.kind) + '</b> <span class="dim">x' + r.count + '</span><br>' +
|
||||
'<span class="dim">' + esc(r.detail) + '</span></li>';
|
||||
}).join('') + '</ul>';
|
||||
$('decision').innerHTML = h;
|
||||
}
|
||||
$('blocked').innerHTML = f.blocked.length === 0
|
||||
? '<li class="dim">nothing blocked</li>'
|
||||
: f.blocked.map((b) => '<li class="sev-' + b.severity + '"><b>' + esc(b.where) + '</b> — ' + esc(b.why) + '</li>').join('');
|
||||
@@ -603,7 +444,7 @@ function render() {
|
||||
}
|
||||
|
||||
function go(n) { i = Math.max(0, Math.min(FRAMES.length - 1, n)); render(); }
|
||||
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1])) return go(k); go(FRAMES.length - 1); }
|
||||
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1], k)) return go(k); go(FRAMES.length - 1); }
|
||||
|
||||
$('ftot').textContent = FRAMES.length - 1;
|
||||
$('scrub').max = FRAMES.length - 1;
|
||||
@@ -614,6 +455,8 @@ $('fwd').onclick = () => go(i + 1);
|
||||
$('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p.day);
|
||||
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
|
||||
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
|
||||
$('waste').onclick = () => findNext((f) => f.wasted);
|
||||
$('jam').onclick = () => findNext((f, p, idx) => carry(idx, 'facilities').some((x) => x.jammed));
|
||||
$('play').onclick = () => {
|
||||
if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
|
||||
$('play').textContent = '⏸ pause';
|
||||
@@ -631,6 +474,8 @@ document.onkeydown = (e) => {
|
||||
else if (e.key === 's' || e.key === 'S') $('stage').click();
|
||||
else if (e.key === 'r' || e.key === 'R') $('money').click();
|
||||
else if (e.key === 'c' || e.key === 'C') $('crash').click();
|
||||
else if (e.key === 'w' || e.key === 'W') $('waste').click();
|
||||
else if (e.key === 'j' || e.key === 'J') $('jam').click();
|
||||
else if (e.key === ' ') { e.preventDefault(); $('play').click(); }
|
||||
};
|
||||
render();
|
||||
|
||||
@@ -220,6 +220,9 @@ const EXPECTED_EVENTS = [
|
||||
'carPlacedOnTrain',
|
||||
'trayMoved',
|
||||
'carsCoupled',
|
||||
'mainlineModified',
|
||||
'redFlagsSet',
|
||||
'flyingSwitch',
|
||||
'carsDropped',
|
||||
'cardDrawn',
|
||||
'cardPlayed',
|
||||
|
||||
+763
@@ -0,0 +1,763 @@
|
||||
/**
|
||||
* The view-model: what a Station Master position LOOKS like.
|
||||
*
|
||||
* Split out of `replay.ts` so the playable browser build can use it. `replay.ts` writes files and
|
||||
* reads `process.argv`, so importing it pulled `node:fs` into the bundle — and the engine's whole
|
||||
* claim to run client-side rests on nothing in the import graph needing Node.
|
||||
*
|
||||
* Nothing here decides anything. It turns a `GameState` into boxes and labels, and turns an
|
||||
* `Intent` into a sentence. The live game and the replay both render from this, so they cannot
|
||||
* drift into two different pictures of the same board.
|
||||
*/
|
||||
|
||||
import { areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
|
||||
import {
|
||||
ACTION_CARDS,
|
||||
ENHANCEMENT_CARDS,
|
||||
MAINLINE_MODIFIER_CARDS,
|
||||
MAINLINE_PROFILES,
|
||||
MANEUVER_CARDS,
|
||||
OFFICE_ORDER,
|
||||
SPACE_USE_CARDS,
|
||||
industryProfile,
|
||||
modifierProfile,
|
||||
lengthProfile,
|
||||
officeProfile,
|
||||
trainProfile,
|
||||
} from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Facility, GameState, TrackCard } from '../engine/state.ts';
|
||||
import type { TrackGeometry } from '../engine/content.ts';
|
||||
import { connectionsFor, variantsFor } from '../engine/track.ts';
|
||||
import type { Impediment } from './narrate.ts';
|
||||
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
|
||||
|
||||
export type CellView = {
|
||||
row: number;
|
||||
col: number;
|
||||
kind: string;
|
||||
label: string;
|
||||
running: boolean;
|
||||
/**
|
||||
* Enhancements laid ON this card. They were invisible: playing an Overpass onto the Office
|
||||
* announced itself in the log and then changed nothing on the board, so a permanent change to how
|
||||
* the district works left no trace you could see.
|
||||
*/
|
||||
enhancements: string[];
|
||||
tray: string | null;
|
||||
cars: string[];
|
||||
facility: FacilityView | null;
|
||||
/**
|
||||
* The port pairs this card joins, as two-letter codes — 'ew', 'ns', 'es' and so on. Taken from
|
||||
* the engine's own `connectionsFor`, so a drawn rail can never claim a connection the rules do
|
||||
* not have.
|
||||
*/
|
||||
links: string[];
|
||||
/**
|
||||
* What this card DOES, now that it is on the board.
|
||||
*
|
||||
* A played card becomes a cell with a name on it and nothing else — "turnout", "Freight House",
|
||||
* "waiting area" — so the explanation that was visible while it sat in hand disappears at exactly
|
||||
* the moment it starts mattering.
|
||||
*/
|
||||
what: string;
|
||||
};
|
||||
|
||||
export type FacilityView = {
|
||||
name: string;
|
||||
commodity: string;
|
||||
flow: string;
|
||||
green: string[];
|
||||
greenCap: number;
|
||||
maw: (string | null)[];
|
||||
red: string[];
|
||||
redCap: number;
|
||||
track: string[];
|
||||
trackCap: number;
|
||||
laborers: string;
|
||||
porters: string;
|
||||
/**
|
||||
* Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
|
||||
* WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
|
||||
* that held freight to a 3% completion rate.
|
||||
*/
|
||||
canFinish: boolean;
|
||||
/** A load is sitting on WORK with no spotted car to receive it. */
|
||||
jammed: boolean;
|
||||
};
|
||||
|
||||
export type TrainChip = { label: string; consist: string[] };
|
||||
export type DivisionView = {
|
||||
kind: string;
|
||||
label: string;
|
||||
trains: TrainChip[][];
|
||||
/**
|
||||
* How many trains may stand here, or null for no limit. 1 on most Mainline cards, 2 where the
|
||||
* card prints "trains may pass", the A/D count at an Office, unlimited at a Division Point.
|
||||
*/
|
||||
capacity: number | null;
|
||||
/** Modifiers laid on a Mainline card (Brakeman, Helpers, Realignment …). */
|
||||
modifiers: string[];
|
||||
/**
|
||||
* Which way a Heavy Grade climbs. The card prints "(Up)" and "Player sets orientation", so the
|
||||
* direction is a property of the placed card — and without showing it, a Brakeman or Helpers card
|
||||
* on that grade has no visible meaning.
|
||||
*/
|
||||
gradeUp: string | null;
|
||||
};
|
||||
|
||||
export type Frame = {
|
||||
day: number;
|
||||
stage: number;
|
||||
clock: string;
|
||||
phase: string;
|
||||
actor: number | null;
|
||||
superintendent: number;
|
||||
revenue: number;
|
||||
lines: { text: string; tone: string }[];
|
||||
where: { row: number; col: number } | null;
|
||||
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
|
||||
whereFrom: { row: number; col: number } | null;
|
||||
division: DivisionView[];
|
||||
cells: CellView[];
|
||||
/** Which grid row is the Running Track — the spine the district hangs beneath. */
|
||||
runningRow: number;
|
||||
facilities: FacilityView[];
|
||||
hand: string[];
|
||||
/** What each hand card does, in the same order — names alone are not a playable hand. */
|
||||
handWhat: string[];
|
||||
deck: number;
|
||||
departments: string[];
|
||||
/** What each face-up Department card does. */
|
||||
departmentsWhat: string[];
|
||||
/** 12 slots; the train number due out at each Stage, or null. */
|
||||
timetable: (number | null)[];
|
||||
blocked: Impediment[];
|
||||
trains: { label: string; where: string }[];
|
||||
/**
|
||||
* Where you stand against the target. Nothing on screen said what the game was FOR, so a player
|
||||
* had the score but no way to know whether it was good.
|
||||
*/
|
||||
objective: { target: number; days: number; daysLeft: number; onPace: boolean; note: string };
|
||||
/**
|
||||
* What is left of the player's personal track supply (§12.2). Track is NOT drawn from the deck —
|
||||
* each player starts with 26 pieces and lays at most one a turn, so "how many straights have I
|
||||
* got left" is a real planning question the board could not answer.
|
||||
*/
|
||||
trackSupply: { piece: string; left: number }[];
|
||||
/** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */
|
||||
decision: Decision | null;
|
||||
/** A Local Operations turn that changed nothing — the frames worth your attention. */
|
||||
wasted: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The choice behind a frame.
|
||||
*
|
||||
* The whole point is `rejected`: the replay could always show what happened, never what COULD have
|
||||
* happened, so a daft move was visible but the alternatives it passed over were not — which is
|
||||
* exactly what you need to say what it should have done instead.
|
||||
*/
|
||||
export type Decision = {
|
||||
actor: number;
|
||||
chose: string;
|
||||
why: string;
|
||||
/** Every legal option not taken, grouped by kind with a count. */
|
||||
rejected: { kind: string; count: number; detail: string }[];
|
||||
totalOptions: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshotting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FACILITY_NAMES: Record<string, string> = {
|
||||
mineTipple: 'Mine Tipple',
|
||||
produceShed: 'Produce Shed',
|
||||
grocersWarehouse: "Grocer's Warehouse",
|
||||
oilRefinery: 'Oil Refinery',
|
||||
powerPlant: 'Power Plant',
|
||||
};
|
||||
|
||||
function facilityView(
|
||||
card: { geometry: { kind: string; facility?: string }; facility: unknown },
|
||||
officeName: string,
|
||||
): FacilityView | null {
|
||||
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
|
||||
// Passenger facilities were excluded entirely, so the Office's green and red slots never
|
||||
// appeared — which is why stocking a coach into the green box looked like nothing happening.
|
||||
if (!f) return null;
|
||||
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
|
||||
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
|
||||
return {
|
||||
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? prettyKey(key)),
|
||||
commodity: facilityCarType(f) ?? '?',
|
||||
flow: f.kind === 'passenger'
|
||||
? 'passengers on and off'
|
||||
: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
|
||||
green: f.outboundBox.map(carLabel),
|
||||
greenCap: f.capacity.outbound,
|
||||
maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
|
||||
red: f.inboundBox.map(carLabel),
|
||||
redCap: f.capacity.inbound,
|
||||
track: f.industryTrack.cars.map(carLabel),
|
||||
trackCap: f.industryTrack.length,
|
||||
laborers: `${laborersLeft(f)}/${f.laborers}`,
|
||||
porters: `${portersLeft(f)}/${f.porters}`,
|
||||
canFinish: canFinishHere(f),
|
||||
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
|
||||
};
|
||||
}
|
||||
|
||||
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
|
||||
function canFinishHere(f: Facility): boolean {
|
||||
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
|
||||
if (!pending) return false;
|
||||
return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise the choice: what was taken, why, and what was passed over.
|
||||
*
|
||||
* Options are grouped by kind because a switching turn can offer 40 destinations, and a list that
|
||||
* long hides the shape of the decision rather than showing it.
|
||||
*/
|
||||
export function describeDecision(
|
||||
s: GameState,
|
||||
actor: number,
|
||||
chosen: Intent,
|
||||
options: Intent[],
|
||||
why: string,
|
||||
): Decision {
|
||||
const groups = new Map<string, Intent[]>();
|
||||
for (const o of options) {
|
||||
if (o === chosen) continue;
|
||||
const list = groups.get(o.type) ?? [];
|
||||
list.push(o);
|
||||
groups.set(o.type, list);
|
||||
}
|
||||
const rejected = [...groups.entries()]
|
||||
.map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length };
|
||||
}
|
||||
|
||||
/** A short, concrete example of what a group of rejected options would have done. */
|
||||
function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
|
||||
// Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe
|
||||
// identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the
|
||||
// actual range of choices — the opposite of what this panel is for.
|
||||
const seen = new Set<string>();
|
||||
for (const i of list) seen.add(describeIntent(s, i));
|
||||
const unique = [...seen];
|
||||
const shown = unique.slice(0, 4);
|
||||
const more = unique.length - shown.length;
|
||||
return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
|
||||
}
|
||||
|
||||
/** One readable line for a single intent. */
|
||||
export function describeIntent(s: GameState, i: Intent): string {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
|
||||
switch (i.type) {
|
||||
case 'localOps.choose':
|
||||
// The most consequential decision of the Stage, and it was labelled "choose switch". Say what
|
||||
// each option actually spends and buys.
|
||||
return i.option === 'switch'
|
||||
? 'SWITCH — six Moves to shunt cars: spot empties at industries, collect loads'
|
||||
: i.option === 'draw'
|
||||
? 'DRAW — take a card and play one, and you may lay a piece of track'
|
||||
: 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
|
||||
case 'card.play':
|
||||
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
|
||||
case 'card.discard':
|
||||
return `discard ${cardName(s, i.cardId)}`;
|
||||
case 'track.lay':
|
||||
return (
|
||||
`lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${geometryLabel(i.geometry)} ` +
|
||||
`at ${at(i.placement)}${variantLabel(i.geometry, i.variant)}`
|
||||
);
|
||||
case 'switch.move':
|
||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
|
||||
case 'switch.dropCars':
|
||||
return `drop ${i.count} car(s)`;
|
||||
case 'switch.sortConsist':
|
||||
return `re-order consist [${i.order.join(',')}]`;
|
||||
case 'freightAgent.stockOutbound':
|
||||
return `stock a ${i.carType} at ${at(i.at)}`;
|
||||
case 'freightAgent.unjam':
|
||||
return `unjam ${i.from} at ${at(i.at)}`;
|
||||
case 'freightAgent.clearInbound':
|
||||
return `clear red box at ${at(i.at)}`;
|
||||
case 'laborer.startLoad':
|
||||
return `start a load at ${at(i.at)}`;
|
||||
case 'laborer.advanceLoad':
|
||||
return `advance load in box ${i.box} at ${at(i.at)}`;
|
||||
case 'laborer.beginUnload':
|
||||
return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
|
||||
case 'porter.board':
|
||||
return `board passengers at ${at(i.at)}`;
|
||||
case 'porter.detrain':
|
||||
return `detrain passengers at ${at(i.at)}`;
|
||||
case 'newTrain.placeCar':
|
||||
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
|
||||
// "add loaded caboose" on a button.
|
||||
return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`;
|
||||
case 'mainline.modify':
|
||||
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
|
||||
case 'maneuver.redFlags':
|
||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
||||
case 'maneuver.flyingSwitch':
|
||||
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
|
||||
case 'mainline.clearance': {
|
||||
// The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint
|
||||
// that granting it risks a rear-ender, or that refusing merely costs time.
|
||||
const pending = s.clock.pendingDecision;
|
||||
const who = pending ? trainName(s, pending.train) : 'the train';
|
||||
const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead';
|
||||
// NOT "risks a collision, −5". A rear-end on a Mainline card is described by §10 and is what
|
||||
// ABS Signals exists to prevent, but no such collision is implemented — granting clearance is
|
||||
// currently free. Saying otherwise invents a consequence the engine will never deliver.
|
||||
// See implications.md §10 Q13.
|
||||
return i.allow
|
||||
? `ALLOW — ${who} follows ${ahead} onto the same Mainline card, closing up behind it`
|
||||
: `HOLD — ${who} waits where it is, losing the Stage but keeping the line clear`;
|
||||
}
|
||||
case 'draw.fromDepartment': {
|
||||
// Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and
|
||||
// the choice between a visible card and a blind draw is unmakeable without it.
|
||||
const id = s.decks.departments[i.slot];
|
||||
return id ? `take ${cardName(s, id)} (face-up slot ${i.slot + 1})` : `slot ${i.slot + 1} (empty)`;
|
||||
}
|
||||
case 'draw.fromHomeOffice':
|
||||
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
|
||||
case 'draw.end':
|
||||
return 'done drawing — end my turn';
|
||||
case 'switch.end':
|
||||
return 'done switching — end my turn';
|
||||
case 'loadUnload.end':
|
||||
return 'done working — end the Load/Unload phase';
|
||||
case 'newTrain.passCar':
|
||||
return 'add no more cars to this train';
|
||||
case 'newTrain.secondSection':
|
||||
return `run a Second Section behind Train ${i.trainNumber}`;
|
||||
case 'redFlag.play':
|
||||
return 'play your red flag';
|
||||
case 'maneuver.redFlags':
|
||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
||||
default: {
|
||||
// Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes
|
||||
// that a COMPILE error the day someone adds an intent without describing it — the playable UI
|
||||
// labels its buttons from this function, so a missing case ships as a button reading
|
||||
// "maneuver.poling".
|
||||
const unhandled: never = i;
|
||||
return String((unhandled as { type: string }).type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the view-model for a state. Shared with the playable web app so the live game and the
|
||||
* replay cannot drift into two different pictures of the same board.
|
||||
*/
|
||||
export function snapshot(
|
||||
s: GameState,
|
||||
lines: { text: string; tone: string }[],
|
||||
where: { row: number; col: number } | null,
|
||||
whereFrom: { row: number; col: number } | null = null,
|
||||
decision: Decision | null = null,
|
||||
wasted = false,
|
||||
): Frame {
|
||||
const area = areaOf(s, 0);
|
||||
const trayAt = new Map<string, string>();
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at === 'grid') {
|
||||
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
|
||||
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
|
||||
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
|
||||
}
|
||||
}
|
||||
|
||||
const cells: CellView[] = [];
|
||||
const facilities: FacilityView[] = [];
|
||||
for (const [key, card] of area.grid) {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
const g = card.geometry;
|
||||
const kind = g.kind;
|
||||
let label: string;
|
||||
if (g.kind === 'office') label = officeProfile(area.tier).name;
|
||||
else if (g.kind === 'limits') label = 'Limits';
|
||||
// Fall back to prettyKey, never the raw key. The lookup tables exist for names prettyKey cannot
|
||||
// guess ("Grocer's Warehouse"), not as the only route to a readable label — leaving the raw key
|
||||
// as the fallback put `refinery` and `viscosityBreakers` on the board.
|
||||
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility);
|
||||
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier);
|
||||
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
||||
else label = geometryLabel(g.geometry);
|
||||
|
||||
const fv = facilityView(card as never, officeProfile(area.tier).name);
|
||||
if (fv) facilities.push(fv);
|
||||
|
||||
cells.push({
|
||||
row: row!,
|
||||
col: col!,
|
||||
kind,
|
||||
label,
|
||||
running: row === area.runningRow,
|
||||
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
|
||||
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||
enhancements: card.enhancements.map(prettyKey),
|
||||
tray: trayAt.get(key) ?? null,
|
||||
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
|
||||
facility: fv,
|
||||
});
|
||||
}
|
||||
|
||||
const division: DivisionView[] = s.division.nodes.map((n) => {
|
||||
if (n.kind === 'divisionPoint') {
|
||||
return {
|
||||
kind: 'dp',
|
||||
label: n.side === 'west' ? 'West DP' : 'East DP',
|
||||
trains: [n.holding.map((id) => trainChip(s, id))],
|
||||
capacity: null,
|
||||
modifiers: [],
|
||||
gradeUp: null,
|
||||
};
|
||||
}
|
||||
if (n.kind === 'mainline') {
|
||||
// Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it
|
||||
// with how long each still has to run.
|
||||
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
|
||||
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
|
||||
return {
|
||||
kind: 'ml',
|
||||
label: name,
|
||||
trains: [n.transits.map((t) => {
|
||||
const chip = trainChip(s, t.tray);
|
||||
return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
|
||||
})],
|
||||
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
|
||||
modifiers: [
|
||||
...(n.modifiers ?? []).map(prettyKey),
|
||||
...(n.absSignals ? ['ABS Signals'] : []),
|
||||
],
|
||||
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'office',
|
||||
label: officeProfile(areaOf(s, n.owner).tier).name,
|
||||
trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
|
||||
capacity: officeProfile(areaOf(s, n.owner).tier).adTracks,
|
||||
modifiers: [],
|
||||
gradeUp: null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
day: s.clock.day,
|
||||
stage: s.clock.stage,
|
||||
clock: clockTime(s.clock.stage),
|
||||
phase: phaseLabel(s.clock.phase),
|
||||
actor: s.clock.currentActor,
|
||||
superintendent: s.clock.superintendent,
|
||||
revenue: s.players[0]?.revenue ?? 0,
|
||||
lines,
|
||||
where,
|
||||
whereFrom,
|
||||
division,
|
||||
cells,
|
||||
facilities,
|
||||
hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
|
||||
handWhat: (s.decks.hands.get(0) ?? []).map((id) => cardDescription(s, id)),
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
|
||||
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
|
||||
timetable: [...s.timetable],
|
||||
decision,
|
||||
wasted,
|
||||
objective: objectiveOf(s),
|
||||
trackSupply: [...area.trackSupply.entries()]
|
||||
.map(([key, left]) => {
|
||||
const [geometry, hand] = key.split(':');
|
||||
return { piece: `${hand === 'none' ? '' : hand + '-hand '}${geometryLabel(geometry ?? '')}`, left };
|
||||
})
|
||||
.sort((a, b) => a.piece.localeCompare(b.piece)),
|
||||
runningRow: area.runningRow,
|
||||
blocked: impediments(s, 0),
|
||||
trains: [...s.trays.values()].map((t) => ({
|
||||
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
where:
|
||||
t.position.at === 'divisionPoint'
|
||||
? `${t.position.side} Division Point`
|
||||
: t.position.at === 'mainline'
|
||||
? `Mainline card ${t.position.index}`
|
||||
: `Office Area (${t.position.coord.row},${t.position.coord.col})`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** A card id turned into something a person can read. */
|
||||
export function cardName(s: GameState, id: string): string {
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return 'a card';
|
||||
switch (k.kind) {
|
||||
case 'timetabledTrain':
|
||||
return `Train ${k.number}`;
|
||||
case 'extraTrain':
|
||||
return `Extra X${k.number}`;
|
||||
case 'office':
|
||||
return `${k.tier.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())} upgrade`;
|
||||
// Fall back to prettyKey, never to the raw key: an unnamed card showed as "rotaryDumps" on a
|
||||
// button a player is meant to read. The lookup tables are for names prettyKey cannot guess
|
||||
// (Grocer's Warehouse), not the only source of a readable label.
|
||||
case 'freightFacility':
|
||||
return FACILITY_NAMES[k.facility] ?? prettyKey(k.facility);
|
||||
case 'modifier':
|
||||
return MODIFIER_NAMES[k.modifier] ?? prettyKey(k.modifier);
|
||||
case 'track':
|
||||
return `${geometryLabel(k.geometry)} track`;
|
||||
case 'spaceUse':
|
||||
case 'enhancement':
|
||||
case 'mainlineModifier':
|
||||
case 'maneuver':
|
||||
case 'action':
|
||||
return prettyKey(k.key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a card actually DOES, in one line.
|
||||
*
|
||||
* The hand showed names only, so "Steam Turbines" or "Facing Point Locks" told a player nothing
|
||||
* about the effect — the difference between playing the game and clicking hopefully. Every fact
|
||||
* here already existed in the content tables; none of it was reaching the screen.
|
||||
*/
|
||||
export function cardDescription(s: GameState, id: string): string {
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return '';
|
||||
|
||||
switch (k.kind) {
|
||||
case 'timetabledTrain':
|
||||
case 'extraTrain': {
|
||||
const t = trainProfile(k.number, k.kind === 'extraTrain');
|
||||
if (!t) return '';
|
||||
const parts: string[] = [];
|
||||
if (t.consist.freight > 0) {
|
||||
const types = t.consist.freightTypes;
|
||||
parts.push(`${t.consist.freight} freight${types ? ` (${types.join('/')})` : ''}`);
|
||||
}
|
||||
if (t.consist.coach > 0) parts.push(`${t.consist.coach} coach`);
|
||||
if (t.consist.caboose > 0) parts.push('caboose');
|
||||
const extra = k.kind === 'extraTrain' ? 'runs ONCE, unscheduled' : 'runs every Day once scheduled';
|
||||
// Several Extras leave the direction to the player, so "playerChoicebound" is not a word.
|
||||
const dir = t.direction === 'playerChoice' ? 'either direction' : `${t.direction}bound`;
|
||||
const rule = t.rules.note ? ` · ${t.rules.note}` : '';
|
||||
return `${t.name} · ${t.speed}, ${dir} · ${parts.join(' + ') || 'no cars'} · ${extra}${rule}`;
|
||||
}
|
||||
case 'office': {
|
||||
const p = officeProfile(k.tier);
|
||||
// Upgrades are strictly sequential (Gap 3b), so a Station card is dead weight until the
|
||||
// Office is a Depot. Without saying so, the card looks playable and simply never is.
|
||||
const needs = OFFICE_ORDER[OFFICE_ORDER.indexOf(k.tier) - 1];
|
||||
const prereq = needs ? ` · requires the Office to be a ${prettyKey(needs)} first` : '';
|
||||
return (
|
||||
`upgrade the Office: ${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'}, ` +
|
||||
`${p.porters} porter${p.porters === 1 ? '' : 's'}, ` +
|
||||
`${p.passengerOut} passenger out / ${p.passengerIn} in` +
|
||||
(p.isControlPoint ? ' · a Control Point' : '') +
|
||||
prereq
|
||||
);
|
||||
}
|
||||
case 'freightFacility': {
|
||||
const f = industryProfile(k.facility);
|
||||
const flow = f.flow === 'both' ? 'ships out AND receives' : f.flow === 'outbound' ? 'ships out' : 'receives';
|
||||
const lock = f.lockouts.length
|
||||
? ` · cannot share a district with ${f.lockouts.map(facilityLabel).join(', ')}`
|
||||
: '';
|
||||
return `${flow} ${f.carTypes.join('/')} · ${f.baseLoaders} laborer${f.baseLoaders === 1 ? '' : 's'}${lock}`;
|
||||
}
|
||||
case 'modifier': {
|
||||
const m = modifierProfile(k.modifier);
|
||||
const adds: string[] = [];
|
||||
if (m.addOut) adds.push(`+${m.addOut} out`);
|
||||
if (m.addIn) adds.push(`+${m.addIn} in`);
|
||||
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
|
||||
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
|
||||
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}`;
|
||||
}
|
||||
default: {
|
||||
// The recovered categories carry their own prose — effect plus where it may be played.
|
||||
const card = SIMPLE_CARDS.find((c) => c.key === (k as { key: string }).key);
|
||||
return card ? `${card.effect} · played on ${card.placement}` : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The train riding a Crew Tray, for anything that has to talk about it. */
|
||||
export function trainName(s: GameState, trayId: string): string {
|
||||
const tray = s.trays.get(trayId);
|
||||
if (!tray) return trayId;
|
||||
if (tray.trainNumber === null) return 'the local crew';
|
||||
return `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
|
||||
}
|
||||
|
||||
/** What a card on the board does, in one short line. */
|
||||
function cellDescription(card: TrackCard, officeName: string, onRunning: boolean): string {
|
||||
const g = card.geometry;
|
||||
switch (g.kind) {
|
||||
case 'limits':
|
||||
return 'the edge of your control area — lay track HERE to extend the Running Track';
|
||||
case 'office': {
|
||||
const p = officeProfile(
|
||||
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
|
||||
);
|
||||
return (
|
||||
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
|
||||
(p.isPassengerFacility
|
||||
? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
|
||||
: 'not a Passenger Facility — no porters, no passenger boxes')
|
||||
);
|
||||
}
|
||||
case 'modifier': {
|
||||
const m = modifierProfile(g.modifier);
|
||||
const adds: string[] = [];
|
||||
if (m.addOut) adds.push(`+${m.addOut} outbound slot`);
|
||||
if (m.addIn) adds.push(`+${m.addIn} inbound slot`);
|
||||
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
|
||||
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
|
||||
return `${adds.join(', ') || 'no effect'} for the adjacent ${m.hosts.map(facilityLabel).join('/')}`;
|
||||
}
|
||||
case 'spaceUse':
|
||||
return SIMPLE_CARDS.find((c) => c.key === g.key)?.effect ?? 'takes up space';
|
||||
case 'facility': {
|
||||
const f = card.facility;
|
||||
if (!f) return 'a facility';
|
||||
if (f.kind === 'passenger') return 'passengers board and detrain here';
|
||||
const p = industryProfile(g.facility as never);
|
||||
const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives';
|
||||
// §11.2 — "Facility cards carry their own rails", so an industry on the Running Track does not
|
||||
// block anything. It does inherit the Running Track's hazard: §10 makes cars left standing
|
||||
// between the Limits and the Office a collision when a train arrives.
|
||||
const where = onRunning
|
||||
? ' · ON THE RUNNING TRACK — trains pass straight through, but a car left standing here is hit by the next arrival'
|
||||
: '';
|
||||
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${where}`;
|
||||
}
|
||||
case 'track':
|
||||
switch (g.geometry) {
|
||||
case 'straight':
|
||||
return g.axis === 'ns' ? 'through track, north–south' : 'through track, east–west';
|
||||
case 'curved':
|
||||
case 'sharpCurved': {
|
||||
const stem = g.hand === 'left' ? 'west' : 'east';
|
||||
const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
|
||||
return `curve — joins the ${stem} end to a leg running south${cost}`;
|
||||
}
|
||||
case 'turnout': {
|
||||
const t = g.turnout;
|
||||
if (!t) return 'turnout';
|
||||
const dir = (p: string): string =>
|
||||
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
|
||||
// §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street.
|
||||
return (
|
||||
`turnout — stem ${dir(t.stem)}, through ${dir(t.through)}, diverges ${dir(t.diverge)} · ` +
|
||||
`a train may run stem↔through or stem↔diverge, but NEVER between ${dir(t.through)} and ${dir(t.diverge)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */
|
||||
function facilityLabel(key: string): string {
|
||||
return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key));
|
||||
}
|
||||
|
||||
/** Every card category that carries a written effect rather than a profile. */
|
||||
const SIMPLE_CARDS = [
|
||||
...ENHANCEMENT_CARDS,
|
||||
...MAINLINE_MODIFIER_CARDS,
|
||||
...MANEUVER_CARDS,
|
||||
...SPACE_USE_CARDS,
|
||||
...ACTION_CARDS,
|
||||
];
|
||||
|
||||
/** The goal, and whether the current score is keeping up with the clock. */
|
||||
function objectiveOf(s: GameState): Frame['objective'] {
|
||||
const profile = lengthProfile(s.config.length);
|
||||
const revenue = s.players[0]?.revenue ?? 0;
|
||||
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
|
||||
const elapsed = profile.days - daysLeft + 1;
|
||||
// Straight-line pace: by the end of Day N you want N/days of the target.
|
||||
const expected = (profile.target * elapsed) / profile.days;
|
||||
const onPace = revenue >= expected;
|
||||
const note =
|
||||
daysLeft === 0
|
||||
? 'the last Day is over'
|
||||
: `${revenue} of ${profile.target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
|
||||
(onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`);
|
||||
return { target: profile.target, days: profile.days, daysLeft, onPace, note };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which way a piece will point, in words.
|
||||
*
|
||||
* "rotation 2" is not a choice anyone can make — for a curve or a turnout the orientation IS the
|
||||
* decision. Read from `variantsFor`, the same list the placement uses, so the label cannot describe
|
||||
* one rotation while the engine lays another.
|
||||
*/
|
||||
export function variantLabel(geometry: TrackGeometry, variant: number | undefined): string {
|
||||
const v = variantsFor(geometry)[variant ?? 0];
|
||||
if (!v) return '';
|
||||
if (v.axis) return v.axis === 'ew' ? ' — east–west' : ' — north–south';
|
||||
if (v.turnout) {
|
||||
const dir = (p: string): string =>
|
||||
({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
|
||||
return ` — stem ${dir(v.turnout.stem)}, through ${dir(v.turnout.through)}, diverges ${dir(v.turnout.diverge)}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A track shape in words. One place, because it is written on the board, in the action buttons and
|
||||
* in the supply list — and `sharpCurved` was reaching the screen raw in two of the three.
|
||||
*/
|
||||
export function geometryLabel(geometry: string): string {
|
||||
switch (geometry) {
|
||||
case 'sharpCurved':
|
||||
return 'sharp curve';
|
||||
case 'curved':
|
||||
return 'curve';
|
||||
case 'turnout':
|
||||
return 'turnout';
|
||||
case 'straight':
|
||||
return 'straight';
|
||||
default:
|
||||
return prettyKey(geometry);
|
||||
}
|
||||
}
|
||||
|
||||
/** camelCase key → readable name, for the card categories that carry only a key. */
|
||||
function prettyKey(key: string): string {
|
||||
return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
const MODIFIER_NAMES: Record<string, string> = {
|
||||
teamTrack: 'Team Track',
|
||||
loadingDock: 'Loading Dock',
|
||||
storageShed: 'Storage Shed',
|
||||
extraPlatform: 'Extra Platform',
|
||||
sectionGang: 'Section Gang',
|
||||
};
|
||||
|
||||
/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
|
||||
function trainChip(s: GameState, id: string): TrainChip {
|
||||
const t = s.trays.get(id);
|
||||
if (!t) return { label: id, consist: [] };
|
||||
return {
|
||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
consist: t.consist.map(carLabel),
|
||||
};
|
||||
}
|
||||
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Station Master — solitaire, playable in a browser.
|
||||
*
|
||||
* The whole game runs client-side. There is no server and no network call at any point: the engine
|
||||
* is pure, imports nothing outside itself, and never touches `Math.random`, `Date` or `crypto`, so
|
||||
* a static host is all this needs. (Proven, not assumed — a test runs full games with every Node
|
||||
* global replaced by a throwing stub.)
|
||||
*
|
||||
* WHAT THIS MODULE IS. Everything here is presentation and input. It builds no rules of its own:
|
||||
*
|
||||
* - what you may do -> `legalActions(state, actor)`
|
||||
* - what it means -> `describeIntent()`, shared with the replay
|
||||
* - what the board looks like -> `snapshot()`, shared with the replay
|
||||
* - what just happened -> `narrate()`, shared with the replay
|
||||
*
|
||||
* That is the same discipline the engine holds itself to. A second opinion about which moves are
|
||||
* legal would eventually disagree with `check`, and the failure mode is a UI that offers an illegal
|
||||
* move or refuses a legal one.
|
||||
*
|
||||
* SAVING. The event log is the game (`state = fold(events)`), and the RNG is seeded, so a save is
|
||||
* the seed plus the list of intents submitted. Replaying them reconstructs the position exactly,
|
||||
* which is far smaller and far more robust than serialising the state graph.
|
||||
*/
|
||||
|
||||
import { pump } from '../engine/advance.ts';
|
||||
import { applyIntent } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||
import { narrate } from '../sim/narrate.ts';
|
||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||
// which would pull node:fs into a browser bundle.
|
||||
import {
|
||||
cardName,
|
||||
describeIntent,
|
||||
geometryLabel,
|
||||
snapshot,
|
||||
trainName,
|
||||
variantLabel,
|
||||
} from '../sim/view.ts';
|
||||
import type { TrackGeometry } from '../engine/content.ts';
|
||||
import { variantsFor } from '../engine/track.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
|
||||
export const SOLO_CONFIG: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** A group of legal actions of one kind, ready to put on screen. */
|
||||
export type ActionGroup = {
|
||||
kind: string;
|
||||
title: string;
|
||||
actions: { index: number; label: string }[];
|
||||
};
|
||||
|
||||
export type Game = {
|
||||
state: GameState;
|
||||
seed: number;
|
||||
/** Every intent submitted, in order — the save file. */
|
||||
history: Intent[];
|
||||
/** Narrated lines, newest last. */
|
||||
log: { text: string; tone: string }[];
|
||||
};
|
||||
|
||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||
const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
|
||||
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
|
||||
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
|
||||
{ prefix: 'switch.', title: 'Switching' },
|
||||
{ prefix: 'draw.', title: 'Draw' },
|
||||
{ prefix: 'card.', title: 'Cards' },
|
||||
{ prefix: 'track.lay', title: 'Lay track from your supply' },
|
||||
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
|
||||
// American spelling throughout, to match MANEUVER_CARDS and the source deck.
|
||||
{ prefix: 'maneuver.', title: 'Maneuvers' },
|
||||
{ prefix: 'freightAgent.', title: 'Freight Agent' },
|
||||
{ prefix: 'newTrain.', title: 'Making up the train' },
|
||||
{ prefix: 'porter.', title: 'Porters' },
|
||||
{ prefix: 'laborer.', title: 'Laborers' },
|
||||
{ prefix: 'loadUnload.', title: 'Finish' },
|
||||
{ prefix: 'redFlag.', title: 'Red flag' },
|
||||
];
|
||||
|
||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: ['You'] });
|
||||
const game: Game = { state, seed, history: [], log: [] };
|
||||
drain(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the engine forward until it needs a decision.
|
||||
*
|
||||
* Most of a Stage is automatic — the Mainline Phase moves trains, the clock turns over — so the
|
||||
* player is only ever asked when `advance` genuinely stops.
|
||||
*/
|
||||
export function drain(game: Game): void {
|
||||
record(game, pump(game.state));
|
||||
}
|
||||
|
||||
/** Whose turn it is, or null if the game is over or waiting on nothing. */
|
||||
export function currentActor(game: Game): PlayerIndex | null {
|
||||
if (game.state.status !== 'active') return null;
|
||||
return game.state.clock.pendingDecision !== null
|
||||
? game.state.clock.superintendent
|
||||
: game.state.clock.currentActor;
|
||||
}
|
||||
|
||||
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
|
||||
export function actionGroups(game: Game): { options: Intent[]; groups: ActionGroup[] } {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) return { options: [], groups: [] };
|
||||
|
||||
const options = legalActions(game.state, actor);
|
||||
const byKind = new Map<string, { index: number; label: string }[]>();
|
||||
options.forEach((intent, index) => {
|
||||
const label = describeIntent(game.state, intent);
|
||||
const list = byKind.get(intent.type) ?? [];
|
||||
// Orientation variants and duplicate copies describe identically; showing one is enough, and a
|
||||
// list of forty identical rows hides the real choice rather than presenting it.
|
||||
if (!list.some((a) => a.label === label)) list.push({ index, label });
|
||||
byKind.set(intent.type, list);
|
||||
});
|
||||
|
||||
const groups: ActionGroup[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const { prefix, title } of GROUP_ORDER) {
|
||||
const kinds = [...byKind.keys()].filter((k) => k.startsWith(prefix) && !used.has(k));
|
||||
const actions = kinds.flatMap((k) => {
|
||||
used.add(k);
|
||||
return byKind.get(k) ?? [];
|
||||
});
|
||||
if (actions.length > 0) groups.push({ kind: prefix, title, actions });
|
||||
}
|
||||
// Anything the table above does not name still has to be offered — silently dropping a legal
|
||||
// action would make the game unplayable in a way that is very hard to notice.
|
||||
const leftovers = [...byKind.entries()].filter(([k]) => !used.has(k));
|
||||
for (const [kind, actions] of leftovers) groups.push({ kind, title: kind, actions });
|
||||
|
||||
return { options, groups };
|
||||
}
|
||||
|
||||
/**
|
||||
* A thing you might do, and — if it goes on the board — where it could go.
|
||||
*
|
||||
* Placeable actions are presented as SUBJECT then LOCATION rather than as one flat list of every
|
||||
* (card x square x rotation) combination. A single turn offered 29 track buttons and 7 card buttons
|
||||
* with no way to tell which square each referred to; picking the card first and the square second is
|
||||
* how the choice is actually made at the table.
|
||||
*/
|
||||
export type Placeable = {
|
||||
/** Groups every option that plays the same card or lays the same piece. */
|
||||
subjectKey: string;
|
||||
subject: string;
|
||||
/**
|
||||
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
|
||||
* piece has more than one legal rotation there, which is why the label carries the rotation too.
|
||||
*/
|
||||
spots: { label: string; index: number; coord: { row: number; col: number } }[];
|
||||
};
|
||||
|
||||
export type Menu = {
|
||||
options: Intent[];
|
||||
/** Actions with no further choice to make. */
|
||||
direct: ActionGroup[];
|
||||
/** Actions needing a location, grouped under their card or track piece. */
|
||||
placeable: { title: string; items: Placeable[] }[];
|
||||
};
|
||||
|
||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||||
export function actionMenu(game: Game): Menu {
|
||||
const { options, groups } = actionGroups(game);
|
||||
const direct: ActionGroup[] = [];
|
||||
const placeableByTitle = new Map<string, Map<string, Placeable>>();
|
||||
|
||||
for (const g of groups) {
|
||||
const plain: ActionGroup['actions'] = [];
|
||||
for (const a of g.actions) {
|
||||
const intent = options[a.index]!;
|
||||
const key = subjectOf(game, intent);
|
||||
if (key === null) {
|
||||
plain.push(a);
|
||||
continue;
|
||||
}
|
||||
const bucket = placeableByTitle.get(g.title) ?? new Map<string, Placeable>();
|
||||
const entry = bucket.get(key.subjectKey) ?? {
|
||||
subjectKey: key.subjectKey,
|
||||
subject: key.subject,
|
||||
spots: [],
|
||||
};
|
||||
if (!entry.spots.some((sp) => sp.label === key.spot)) {
|
||||
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord });
|
||||
}
|
||||
bucket.set(key.subjectKey, entry);
|
||||
placeableByTitle.set(g.title, bucket);
|
||||
}
|
||||
if (plain.length > 0) direct.push({ ...g, actions: plain });
|
||||
}
|
||||
|
||||
const placeable = [...placeableByTitle.entries()].map(([title, m]) => ({
|
||||
title,
|
||||
items: [...m.values()],
|
||||
}));
|
||||
return { options, direct, placeable };
|
||||
}
|
||||
|
||||
/** Split an intent into "what" and "where", or null if it needs no location. */
|
||||
function subjectOf(
|
||||
game: Game,
|
||||
i: Intent,
|
||||
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
||||
if (i.type === 'card.play' && i.placement) {
|
||||
return {
|
||||
subjectKey: `card:${i.cardId}`,
|
||||
subject: cardName(game.state, i.cardId),
|
||||
spot: `${at(i.placement)}${rotationNote(null, i.variant)}`,
|
||||
coord: i.placement,
|
||||
};
|
||||
}
|
||||
if (i.type === 'track.lay') {
|
||||
const hand = i.hand === 'none' ? '' : `${i.hand}-hand `;
|
||||
return {
|
||||
subjectKey: `track:${i.geometry}:${i.hand}`,
|
||||
subject: `${hand}${geometryLabel(i.geometry)}`,
|
||||
spot: `${at(i.placement)}${rotationNote(i.geometry, i.variant)}`,
|
||||
coord: i.placement,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
|
||||
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
|
||||
* decision. Only shown when there is more than one way to lay the piece.
|
||||
*/
|
||||
function rotationNote(geometry: TrackGeometry | null, variant: number | undefined): string {
|
||||
if (geometry === null) return variant === undefined || variant === 0 ? '' : ` — option ${variant + 1}`;
|
||||
return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : '';
|
||||
}
|
||||
|
||||
/** Submit an action. Returns false and changes nothing if the engine rejects it. */
|
||||
export function submit(game: Game, intent: Intent): boolean {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) return false;
|
||||
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
if (!result.ok) {
|
||||
game.log.push({ text: `That is not allowed: ${result.code}`, tone: 'bad' });
|
||||
return false;
|
||||
}
|
||||
game.history.push(intent);
|
||||
record(game, result.events);
|
||||
drain(game);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which cards in hand can be played RIGHT NOW, in hand order.
|
||||
*
|
||||
* A hand where some cards are simply unplayable — a Station upgrade before the Office is a Depot, a
|
||||
* Modifier with no Facility to sit beside — looks identical to one where everything is available.
|
||||
* Derived from `legalActions`, so it cannot disagree with what the buttons offer.
|
||||
*/
|
||||
export function handPlayable(game: Game): boolean[] {
|
||||
const hand = game.state.decks.hands.get(0) ?? [];
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) return hand.map(() => false);
|
||||
const playable = new Set(
|
||||
legalActions(game.state, actor)
|
||||
.filter((i) => i.type === 'card.play')
|
||||
.map((i) => (i as Extract<Intent, { type: 'card.play' }>).cardId),
|
||||
);
|
||||
return hand.map((id) => playable.has(id));
|
||||
}
|
||||
|
||||
/** The board as the replay draws it, so the live game and the replay agree. */
|
||||
export function view(game: Game): Frame {
|
||||
return snapshot(game.state, [], null);
|
||||
}
|
||||
|
||||
function record(game: Game, events: GameEvent[]): void {
|
||||
for (const e of events) {
|
||||
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
|
||||
if (e.type === 'actorChanged') continue;
|
||||
const n = narrate(e, {
|
||||
cardName: (id) => cardName(game.state, id),
|
||||
trainName: (id) => trainName(game.state, id),
|
||||
});
|
||||
game.log.push({ text: n.text, tone: n.tone });
|
||||
}
|
||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saving — seed plus intents, replayed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Save = { seed: number; history: Intent[] };
|
||||
|
||||
export function toSave(game: Game): Save {
|
||||
return { seed: game.seed, history: game.history };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a game from a save.
|
||||
*
|
||||
* Replays the intents through the real engine rather than restoring a serialised state, so a save
|
||||
* can never describe a position the rules could not have produced. An intent that no longer applies
|
||||
* stops the replay rather than being forced — better a short game than a corrupt one.
|
||||
*/
|
||||
export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const game = newGame(save.seed, config);
|
||||
for (const intent of save.history) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
if (!result.ok) break;
|
||||
game.history.push(intent);
|
||||
record(game, result.events);
|
||||
drain(game);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Station Master</title>
|
||||
<style>
|
||||
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:15px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:28px}
|
||||
main{width:100%;max-width:760px}
|
||||
h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
|
||||
.tag{color:var(--dim);margin:0;font-size:14px}
|
||||
.blurb{color:#c6ccd6;margin:16px 0 26px;max-width:66ch}
|
||||
.rule{height:1px;background:var(--line);margin:22px 0}
|
||||
.doors{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
@media(max-width:640px){.doors{grid-template-columns:1fr}}
|
||||
a.door{display:block;text-decoration:none;color:inherit;background:var(--panel);
|
||||
border:1px solid var(--line);border-radius:9px;padding:18px 18px 16px;transition:.12s}
|
||||
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
|
||||
a.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
|
||||
a.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
||||
a.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
|
||||
/* the rule between title and blurb is drawn as rail, not decorated */
|
||||
svg.trackline{width:100%;height:24px;display:block;margin:10px 0 0}
|
||||
.rail{stroke:#4a5361;stroke-width:1.6}
|
||||
.tie{stroke:#39424e;stroke-width:1.1}
|
||||
footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;flex-wrap:wrap}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Station Master</h1>
|
||||
<p class="tag">Timetable-and-train-order railroading, 1840–1950.</p>
|
||||
|
||||
<svg class="trackline" viewBox="0 0 700 24" preserveAspectRatio="none" aria-hidden="true">
|
||||
<line class="rail" x1="0" y1="10" x2="700" y2="10"/>
|
||||
<line class="rail" x1="0" y1="15" x2="700" y2="15"/>
|
||||
<g id="ties"></g>
|
||||
</svg>
|
||||
|
||||
<p class="blurb">
|
||||
You run a division: a Running Track between your Limits, a district of industries hanging beneath
|
||||
it, and trains that arrive whether or not you are ready for them. Spot the right cars, work the
|
||||
loads, and get every train away again — a train with nowhere to stand is a collision, and a
|
||||
collision costs more than the freight was worth.
|
||||
</p>
|
||||
|
||||
<div class="doors">
|
||||
<a class="door" href="./play.html">
|
||||
<h2>Play solitaire</h2>
|
||||
<p>Five Days, twelve Stages each, and a target of 20 Revenue. Your game saves in this browser,
|
||||
so you can close the tab and come back to it.</p>
|
||||
<span class="go">Start a game →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./replays.html">
|
||||
<h2>Replays</h2>
|
||||
<p>Watch a finished game back, Stage by Stage. Open one saved on this site, or a save file
|
||||
somebody has sent you.</p>
|
||||
<span class="go">Browse replays →</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="rule"></div>
|
||||
|
||||
<footer>
|
||||
<span>build <span id="build">__BUILD__</span></span>
|
||||
<span>runs entirely in your browser — nothing is sent anywhere</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./web/splash.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Browser entry point — wires the DOM to `game.ts`.
|
||||
*
|
||||
* Presentation only. Every question of what is legal, what it means, or what the board looks like
|
||||
* is answered by the engine or by the shared view helpers.
|
||||
*/
|
||||
|
||||
import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from '../sim/board-svg.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import type { Game } from './game.ts';
|
||||
import {
|
||||
actionMenu,
|
||||
handPlayable,
|
||||
currentActor,
|
||||
fromSave,
|
||||
newGame,
|
||||
submit,
|
||||
toSave,
|
||||
view,
|
||||
} from './game.ts';
|
||||
|
||||
const SAVE_KEY = 'station-master.save.v1';
|
||||
|
||||
let game: Game;
|
||||
/** Which card or track piece is picked, waiting for a location. */
|
||||
let selected: string | null = null;
|
||||
/** A square picked on the board, waiting for a rotation. */
|
||||
let pendingAt: string | null = null;
|
||||
|
||||
const $ = (id: string): HTMLElement => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) throw new Error(`missing element: ${id}`);
|
||||
return el as HTMLElement;
|
||||
};
|
||||
|
||||
const esc = (s: string): string =>
|
||||
s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function start(): void {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const requested = params.get('seed');
|
||||
|
||||
const saved = load();
|
||||
if (saved && requested === null) {
|
||||
game = fromSave(saved);
|
||||
} else {
|
||||
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||
game = newGame(seed);
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const f = view(game);
|
||||
const menu = actionMenu(game);
|
||||
|
||||
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
|
||||
// coordinate list into a board: you pick the thing, then click where it goes.
|
||||
const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected);
|
||||
const spotsAt = new Map<string, { label: string; index: number }[]>();
|
||||
for (const sp of chosen?.spots ?? []) {
|
||||
const key = `${sp.coord.row},${sp.coord.col}`;
|
||||
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
|
||||
}
|
||||
|
||||
$('clock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
|
||||
$('phase').textContent = f.phase;
|
||||
$('revenue').textContent = String(f.revenue);
|
||||
const obj = $('objective');
|
||||
obj.textContent = f.objective.note;
|
||||
obj.className = f.objective.onPace ? 'pace good' : 'pace behind';
|
||||
$('seed').textContent = String(game.seed);
|
||||
|
||||
// -- division
|
||||
$('division').innerHTML = divisionSvg(f.division);
|
||||
|
||||
// -- board. Both renderers are shared with the replay so the two can never draw different
|
||||
// pictures of the same position.
|
||||
const grid = $('grid');
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow);
|
||||
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
const [gr, gc] = key.split(',').map(Number);
|
||||
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
|
||||
if (g) {
|
||||
g.classList.add('bs-legal');
|
||||
(g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
}
|
||||
// A legal EMPTY square has no card to outline, so draw a target for it.
|
||||
const ghosts = [...spotsAt.entries()].filter(([k]) => !f.cells.some((c) => `${c.row},${c.col}` === k));
|
||||
if (ghosts.length > 0) {
|
||||
const coords = ghosts.map(([k]) => {
|
||||
const [gr, gc] = k.split(',').map(Number);
|
||||
return { row: gr!, col: gc! };
|
||||
});
|
||||
grid.innerHTML = grid.innerHTML.replace('</svg>', ghostSvg(coords, f.cells, f.runningRow) + '</svg>');
|
||||
for (const [key, list] of ghosts) {
|
||||
const g = grid.querySelector(`g[data-ghost="${key}"]`);
|
||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
}
|
||||
|
||||
function pick(key: string, list: { label: string; index: number }[]): void {
|
||||
if (list.length === 1) {
|
||||
const intent = menu.options[list[0]!.index];
|
||||
if (intent) submit(game, intent);
|
||||
selected = null;
|
||||
pendingAt = null;
|
||||
} else {
|
||||
pendingAt = key;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
// -- facilities
|
||||
$('facs').innerHTML =
|
||||
f.facilities.length === 0
|
||||
? '<div class="dim">no facilities yet</div>'
|
||||
: f.facilities
|
||||
.map(
|
||||
(x) =>
|
||||
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)} · laborers ${esc(x.laborers)} · porters ${esc(x.porters)}" tabindex="0">` +
|
||||
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
|
||||
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
|
||||
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
|
||||
x.maw
|
||||
.map(
|
||||
(m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`,
|
||||
)
|
||||
.join('') +
|
||||
`</div>` +
|
||||
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
|
||||
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
|
||||
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
|
||||
x.jammed
|
||||
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
|
||||
: x.canFinish
|
||||
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
|
||||
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
|
||||
}">` +
|
||||
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
|
||||
`</div></div>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
|
||||
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
|
||||
const cardRow = (name: string, why: string, playable: boolean | null): string =>
|
||||
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
|
||||
`<b>${esc(name)}</b>` +
|
||||
(playable === false ? ' <span class="tag">not yet</span>' : '') +
|
||||
`</div>`;
|
||||
|
||||
const canPlay = handPlayable(game);
|
||||
$('hand').innerHTML = f.hand.length
|
||||
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
|
||||
: '<span class="dim">empty</span>';
|
||||
$('depts').innerHTML = f.departments.length
|
||||
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
|
||||
: '<span class="dim">none</span>';
|
||||
|
||||
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
|
||||
$('supply').innerHTML =
|
||||
`<div class="dim">${total} pieces left · one may be laid per turn, during the DRAW option</div>` +
|
||||
f.trackSupply
|
||||
.map(
|
||||
(t) =>
|
||||
`<span class="card ${t.left === 0 ? 'gone' : ''}">${esc(t.piece)} <b>${t.left}</b></span>`,
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
$('blocked').innerHTML =
|
||||
f.blocked.length === 0
|
||||
? '<li class="dim">nothing blocked</li>'
|
||||
: f.blocked
|
||||
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
|
||||
.join('');
|
||||
|
||||
// -- log
|
||||
const log = $('log');
|
||||
log.innerHTML = game.log
|
||||
.slice(-60)
|
||||
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
|
||||
.join('');
|
||||
log.scrollTop = log.scrollHeight;
|
||||
|
||||
renderActions(menu);
|
||||
save();
|
||||
}
|
||||
|
||||
function boxes(items: string[], cap: number): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < Math.max(cap, items.length); i++) {
|
||||
const v = items[i];
|
||||
out += `<span class="box ${v ? 'f' : 'empty'}">${v ? esc(v) : '·'}</span>`;
|
||||
}
|
||||
return out || '<span class="dim">—</span>';
|
||||
}
|
||||
|
||||
function renderActions(menu: ReturnType<typeof actionMenu>): void {
|
||||
const el = $('actions');
|
||||
|
||||
if (game.state.status !== 'active') {
|
||||
const o = game.state.outcome;
|
||||
el.innerHTML =
|
||||
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
|
||||
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
|
||||
`final Revenue ${game.state.players[0]?.revenue ?? 0} against a target of ${view(game).objective.target}</div>` +
|
||||
`<button id="again">new game</button>`;
|
||||
$('again').onclick = () => {
|
||||
clearSave();
|
||||
location.search = '';
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (menu.direct.length === 0 && menu.placeable.length === 0) {
|
||||
el.innerHTML = '<div class="dim">nothing to decide — the engine is running the Division</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const apply = (index: number): void => {
|
||||
const intent = menu.options[index];
|
||||
if (intent) submit(game, intent);
|
||||
selected = null;
|
||||
pendingAt = null;
|
||||
render();
|
||||
};
|
||||
|
||||
// A long label is two things: the action, and why it is offered. Put the first on the button and
|
||||
// the second on the tooltip, or the list crowds out the board.
|
||||
const actionButton = (label: string, index: number): string => {
|
||||
const cut = label.indexOf(' — ');
|
||||
const head = cut > 0 ? label.slice(0, cut) : label;
|
||||
const rest = cut > 0 ? label.slice(cut + 3) : '';
|
||||
return (
|
||||
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
|
||||
);
|
||||
};
|
||||
|
||||
let html = menu.direct
|
||||
.map(
|
||||
(g) =>
|
||||
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
||||
g.actions.map((a) => actionButton(a.label, a.index)).join('') +
|
||||
`</div>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
// Subject first, location second. Picking a card then a square is how the choice is actually made;
|
||||
// one flat list of every card-square-rotation combination was unreadable.
|
||||
for (const group of menu.placeable) {
|
||||
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
|
||||
for (const item of group.items) {
|
||||
const open = selected === item.subjectKey;
|
||||
html +=
|
||||
`<button class="subj ${open ? 'open' : ''}" data-subj="${esc(item.subjectKey)}">` +
|
||||
`${esc(item.subject)} <span class="dim">${item.spots.length} spot${item.spots.length === 1 ? '' : 's'}</span>` +
|
||||
`</button>`;
|
||||
if (open) {
|
||||
// Once a square is picked, show only that square's rotations — the rest is noise.
|
||||
const shown = pendingAt
|
||||
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
||||
: item.spots;
|
||||
html +=
|
||||
`<div class="spots"><div class="dim">` +
|
||||
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
|
||||
`</div>` +
|
||||
shown.map((sp) => `<button class="act" data-i="${sp.index}">${esc(sp.label)}</button>`).join('') +
|
||||
`</div>`;
|
||||
}
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
|
||||
for (const b of Array.from(el.querySelectorAll('button.act'))) {
|
||||
const node = b as HTMLElement;
|
||||
node.onclick = () => apply(Number(node.dataset['i']));
|
||||
}
|
||||
for (const b of Array.from(el.querySelectorAll('button.subj'))) {
|
||||
const node = b as HTMLElement;
|
||||
node.onclick = () => {
|
||||
const key = node.dataset['subj'] ?? null;
|
||||
selected = selected === key ? null : key;
|
||||
pendingAt = null;
|
||||
render();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saving. localStorage only — nothing leaves the browser.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write the game out as a file.
|
||||
*
|
||||
* The save IS the replay: a seed and the moves made, which the engine can replay exactly. A few
|
||||
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
|
||||
* where a rendered page would have been megabytes.
|
||||
*/
|
||||
function downloadSave(): void {
|
||||
const data = JSON.stringify(toSave(game), null, 1);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `station-master-seed${game.seed}-day${game.state.clock.day}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
try {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game)));
|
||||
} catch {
|
||||
// A full or disabled localStorage must not take the game down with it.
|
||||
}
|
||||
}
|
||||
|
||||
function load(): ReturnType<typeof toSave> | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
return raw ? (JSON.parse(raw) as ReturnType<typeof toSave>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSave(): void {
|
||||
try {
|
||||
localStorage.removeItem(SAVE_KEY);
|
||||
} catch {
|
||||
/* nothing to do */
|
||||
}
|
||||
}
|
||||
|
||||
// BOTH stylesheets. The board is SVG built by the shared renderers, and every shape it draws is
|
||||
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
|
||||
// background, so the cards are drawn correctly and are simply invisible.
|
||||
const pageStyle = document.createElement('style');
|
||||
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS;
|
||||
document.head.appendChild(pageStyle);
|
||||
installTooltips();
|
||||
|
||||
const saveBtn = document.getElementById('savefile');
|
||||
if (saveBtn) saveBtn.onclick = downloadSave;
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,139 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Station Master — play</title>
|
||||
<style>
|
||||
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26;--run:#3a4250}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
h1{font-size:17px;margin:0}
|
||||
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);
|
||||
margin:0 0 6px;font-weight:600}
|
||||
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
|
||||
.dim{color:var(--dim)}
|
||||
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
|
||||
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
|
||||
header b{font-size:16px}
|
||||
header button{background:#2a3038;color:inherit;border:1px solid #2c333d;border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px}
|
||||
header button:hover{border-color:#4d6fa8}
|
||||
.build{margin-left:auto;font-size:11px;opacity:.7}
|
||||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||||
.home:hover{color:#5aa9e6}
|
||||
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
|
||||
.pace.good{background:rgba(40,140,60,.32)}
|
||||
.pace.behind{background:rgba(190,120,40,.28)}
|
||||
.handcard{border-top:1px solid var(--line);padding:4px 0}
|
||||
.handcard:first-child{border-top:0}
|
||||
.handcard{padding:3px 0}
|
||||
.handcard:focus{outline:1px solid #4d6fa8;border-radius:3px}
|
||||
.handcard.unplayable{opacity:.55}
|
||||
.tag{font-size:10px;background:rgba(190,120,40,.3);border-radius:3px;padding:0 5px}
|
||||
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
padding:10px 12px;margin-bottom:12px}
|
||||
/* division strip */
|
||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
||||
.node{border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:112px;flex:0 0 auto}
|
||||
.node.office{border-color:#4d6fa8;background:rgba(60,100,170,.13)}
|
||||
.node .nm{font-weight:600;font-size:12px}
|
||||
.chip{display:inline-block;background:#2f6b3d;border-radius:4px;padding:1px 6px;margin:3px 3px 0 0;font-size:11px}
|
||||
.consist{font-size:10px;color:var(--dim)}
|
||||
.grade{font-size:10px;background:rgba(200,90,60,.35);border-radius:3px;padding:1px 4px;font-weight:400}
|
||||
.mlmods{font-size:10px;background:rgba(200,150,60,.28);border-radius:3px;padding:1px 4px;margin-top:2px}
|
||||
/* board */
|
||||
#grid{display:grid;gap:5px;overflow-x:auto}
|
||||
.cell{border:1px solid var(--line);border-radius:5px;padding:5px 6px;min-height:64px;background:#161b21}
|
||||
.cell.run{background:var(--run)}
|
||||
.cell.office{border-color:#4d6fa8}
|
||||
.cell.modifier{background:#2b2333;border-style:dashed}
|
||||
.cell.legal{outline:2px solid #5aa9e6;outline-offset:-2px;cursor:pointer;
|
||||
box-shadow:0 0 0 3px rgba(90,169,230,.18) inset}
|
||||
.cell.legal:hover{background:#233246}
|
||||
.cell.ghost{border-style:dashed;background:rgba(90,169,230,.07);
|
||||
display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}
|
||||
.cell.ghost .nm{font-weight:400;color:#5aa9e6;font-size:11px}
|
||||
.cell .nm{font-size:12px;font-weight:600}
|
||||
.cell .what{font-size:10px;line-height:1.25;margin-top:2px}
|
||||
.cell{cursor:help}
|
||||
.coord{font-size:10px}
|
||||
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
|
||||
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
|
||||
.cars{font-size:10px;color:var(--dim)}
|
||||
/* facilities */
|
||||
.fac{border-top:1px solid var(--line);padding:7px 0}
|
||||
.fac:first-child{border-top:0}
|
||||
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
|
||||
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
|
||||
.box.empty{background:#242a32;color:#5a6472}
|
||||
.box.f{background:#2f6b3d}
|
||||
.box.m{background:#8a6d1f}
|
||||
.fstat{margin-top:3px;font-size:10px;padding:1px 6px;border-radius:3px;display:inline-block}
|
||||
.fstat.good{background:rgba(40,140,60,.28)}
|
||||
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
||||
.fstat.idle{opacity:.6}
|
||||
/* actions */
|
||||
#actions{max-height:none}
|
||||
.grp{margin-bottom:6px}
|
||||
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
|
||||
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
|
||||
button:hover{background:#39424e;border-color:#4d6fa8}
|
||||
button.act{display:inline-block}
|
||||
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
|
||||
.over.win{background:rgba(40,140,60,.35)}
|
||||
.over.loss{background:rgba(160,60,60,.3)}
|
||||
/* cards, log, blocked */
|
||||
.card.gone{opacity:.35;text-decoration:line-through}
|
||||
.subj{display:block;width:100%;margin:2px 0}
|
||||
.subj.open{border-color:#4d6fa8;background:#39424e}
|
||||
.spots{margin:0 0 6px 12px;padding-left:8px;border-left:2px solid #4d6fa8}
|
||||
.card{display:inline-block;background:#242c36;border:1px solid var(--line);border-radius:4px;
|
||||
padding:2px 6px;margin:2px 2px 0 0;font-size:11px}
|
||||
#log{max-height:230px;overflow:auto;font-size:12px}
|
||||
.line{padding:1px 0}
|
||||
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
|
||||
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
|
||||
ul.blocked li{padding:2px 0}
|
||||
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<b><a href="./index.html" class="home">Station Master</a></b>
|
||||
<span id="clock">—</span>
|
||||
<span>phase: <b id="phase">—</b></span>
|
||||
<span>Revenue <b id="revenue">0</b></span>
|
||||
<span id="objective" class="pace">—</span>
|
||||
<span class="dim">seed <span id="seed">—</span></span>
|
||||
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<section><h2>The Division — west to east</h2><div id="division"></div></section>
|
||||
<section><h2>Your Office Area <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span></h2><div id="grid"></div></section>
|
||||
<section><h2>History</h2><div id="log"></div></section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<section><h2>Your move</h2><div id="actions"></div></section>
|
||||
<section><h2>Cards</h2>
|
||||
<div><span class="dim">hand:</span> <span id="hand">—</span></div>
|
||||
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
|
||||
</section>
|
||||
<section><h2>Your track supply</h2><div id="supply"></div></section>
|
||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./web/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* The replay directory, and the viewer that plays one back.
|
||||
*
|
||||
* A REPLAY IS A SAVE. `{ seed, history }` — a few hundred bytes — because the engine is
|
||||
* deterministic and runs in the browser: re-submitting the same intents against the same seed
|
||||
* reconstructs the position exactly. So sharing a game means sharing a small JSON file, not a
|
||||
* multi-megabyte page, and a save that would describe an impossible position simply cannot be
|
||||
* replayed, because every step goes through `applyIntent`.
|
||||
*
|
||||
* Static hosting cannot list a directory, so site-hosted replays are enumerated by
|
||||
* `replays/manifest.json`, written at build time from whatever is in `public/replays/`.
|
||||
*/
|
||||
|
||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import { narrate } from '../sim/narrate.ts';
|
||||
import { cardName, trainName } from '../sim/view.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import { applyIntent } from '../engine/apply.ts';
|
||||
import { pump } from '../engine/advance.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import { snapshot } from '../sim/view.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { SOLO_CONFIG } from './game.ts';
|
||||
|
||||
type Save = { seed: number; history: Intent[] };
|
||||
type Entry = { file: string; title: string; note?: string; seed?: number };
|
||||
|
||||
const $ = (id: string): HTMLElement => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) throw new Error(`missing element: ${id}`);
|
||||
return el;
|
||||
};
|
||||
const esc = (s: string): string =>
|
||||
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||
|
||||
/** Every position the save passes through, with the lines narrated along the way. */
|
||||
type Step = { frame: Frame; lines: { text: string; tone: string }[] };
|
||||
|
||||
/**
|
||||
* Rebuild a game from a save, keeping a snapshot at each step.
|
||||
*
|
||||
* An intent that no longer applies stops the rebuild rather than being forced — better a short
|
||||
* replay than one showing a position the rules could not produce.
|
||||
*/
|
||||
function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
||||
const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] });
|
||||
const steps: Step[] = [];
|
||||
const ctx = { cardName: (id: string) => cardName(s, id), trainName: (id: string) => trainName(s, id) };
|
||||
const push = (events: ReturnType<typeof pump>): void => {
|
||||
const lines = events
|
||||
.filter((e) => e.type !== 'actorChanged')
|
||||
.map((e) => {
|
||||
const n = narrate(e, ctx);
|
||||
return { text: n.text, tone: n.tone };
|
||||
});
|
||||
steps.push({ frame: snapshot(s, [], null), lines });
|
||||
};
|
||||
|
||||
push(pump(s));
|
||||
let stoppedEarly = false;
|
||||
for (const intent of save.history) {
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
if (actor === null || s.status !== 'active') break;
|
||||
const r = applyIntent(s, actor, intent);
|
||||
if (!r.ok) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
const events = [...r.events, ...pump(s)];
|
||||
push(events);
|
||||
}
|
||||
return { steps, stoppedEarly };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Viewer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let steps: Step[] = [];
|
||||
let at = 0;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function show(i: number): void {
|
||||
at = Math.max(0, Math.min(steps.length - 1, i));
|
||||
const step = steps[at];
|
||||
if (!step) return;
|
||||
const f = step.frame;
|
||||
|
||||
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
|
||||
$('vphase').textContent = f.phase;
|
||||
$('vrev').textContent = String(f.revenue);
|
||||
$('vpos').textContent = `${at} / ${steps.length - 1}`;
|
||||
($('vscrub') as HTMLInputElement).value = String(at);
|
||||
$('vdivision').innerHTML = divisionSvg(f.division);
|
||||
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
|
||||
|
||||
// A window of history rather than only this step, so a jump lands in context.
|
||||
let log = '';
|
||||
for (let k = Math.max(0, at - 18); k <= at; k++) {
|
||||
for (const l of steps[k]?.lines ?? []) {
|
||||
log += `<div class="line t-${l.tone}${k === at ? ' now' : ''}">${esc(l.text)}</div>`;
|
||||
}
|
||||
}
|
||||
$('vlog').innerHTML = log;
|
||||
const logEl = $('vlog');
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function openSave(save: Save, title: string): void {
|
||||
const built = rebuild(save);
|
||||
steps = built.steps;
|
||||
$('picker').style.display = 'none';
|
||||
$('viewer').style.display = 'block';
|
||||
$('vtitle').textContent = title;
|
||||
$('vnote').textContent = built.stoppedEarly
|
||||
? 'this save stopped early — an action in it is no longer legal under the current rules'
|
||||
: `${steps.length} steps · seed ${save.seed}`;
|
||||
const scrub = $('vscrub') as HTMLInputElement;
|
||||
scrub.max = String(Math.max(0, steps.length - 1));
|
||||
show(0);
|
||||
}
|
||||
|
||||
function parseSave(text: string, from: string): Save | null {
|
||||
try {
|
||||
const raw = JSON.parse(text) as Partial<Save>;
|
||||
if (typeof raw.seed !== 'number' || !Array.isArray(raw.history)) {
|
||||
throw new Error('not a Station Master save');
|
||||
}
|
||||
return { seed: raw.seed, history: raw.history as Intent[] };
|
||||
} catch (e) {
|
||||
$('perr').textContent = `Could not read ${from}: ${(e as Error).message}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function listHosted(): Promise<void> {
|
||||
const el = $('hosted');
|
||||
try {
|
||||
const res = await fetch('./replays/manifest.json', { cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const entries = (await res.json()) as Entry[];
|
||||
if (entries.length === 0) {
|
||||
el.innerHTML = '<p class="dim">No replays have been published to this site yet.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = entries
|
||||
.map(
|
||||
(e, i) =>
|
||||
`<button class="entry" data-i="${i}"><b>${esc(e.title)}</b>` +
|
||||
(e.note ? `<span class="dim"> — ${esc(e.note)}</span>` : '') +
|
||||
`<div class="dim sm">${esc(e.file)}</div></button>`,
|
||||
)
|
||||
.join('');
|
||||
for (const b of Array.from(el.querySelectorAll('button.entry'))) {
|
||||
(b as HTMLElement).onclick = async () => {
|
||||
const entry = entries[Number((b as HTMLElement).dataset['i'])];
|
||||
if (!entry) return;
|
||||
const r = await fetch(`./replays/${entry.file}`, { cache: 'no-cache' });
|
||||
const save = parseSave(await r.text(), entry.file);
|
||||
if (save) openSave(save, entry.title);
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
el.innerHTML =
|
||||
'<p class="dim">No replay index on this site yet. You can still open a save file from your computer below.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function wire(): void {
|
||||
const file = $('file') as HTMLInputElement;
|
||||
file.onchange = () => {
|
||||
const f = file.files?.[0];
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const save = parseSave(String(reader.result), f.name);
|
||||
if (save) openSave(save, f.name.replace(/\.json$/i, ''));
|
||||
};
|
||||
reader.readAsText(f);
|
||||
};
|
||||
|
||||
$('vback').onclick = () => show(at - 1);
|
||||
$('vfwd').onclick = () => show(at + 1);
|
||||
$('vfirst').onclick = () => show(0);
|
||||
$('vlast').onclick = () => show(steps.length - 1);
|
||||
($('vscrub') as HTMLInputElement).oninput = (e) => show(Number((e.target as HTMLInputElement).value));
|
||||
$('vstage').onclick = () => {
|
||||
const now = steps[at]?.frame;
|
||||
for (let k = at + 1; k < steps.length; k++) {
|
||||
const f = steps[k]!.frame;
|
||||
if (f.stage !== now?.stage || f.day !== now?.day) return show(k);
|
||||
}
|
||||
show(steps.length - 1);
|
||||
};
|
||||
$('vplay').onclick = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
$('vplay').textContent = '▶ play';
|
||||
return;
|
||||
}
|
||||
$('vplay').textContent = '⏸ pause';
|
||||
timer = setInterval(() => {
|
||||
if (at >= steps.length - 1) {
|
||||
clearInterval(timer!);
|
||||
timer = null;
|
||||
$('vplay').textContent = '▶ play';
|
||||
return;
|
||||
}
|
||||
show(at + 1);
|
||||
}, Number(($('vspeed') as HTMLSelectElement).value));
|
||||
};
|
||||
$('vclose').onclick = () => {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
$('viewer').style.display = 'none';
|
||||
$('picker').style.display = 'block';
|
||||
};
|
||||
|
||||
document.onkeydown = (e) => {
|
||||
if ($('viewer').style.display === 'none') return;
|
||||
if (e.key === 'ArrowRight') show(at + 1);
|
||||
else if (e.key === 'ArrowLeft') show(at - 1);
|
||||
else if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
$('vplay').click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = BOARD_CSS + TOOLTIP_CSS;
|
||||
document.head.appendChild(style);
|
||||
installTooltips();
|
||||
wire();
|
||||
void listHosted();
|
||||
@@ -0,0 +1,9 @@
|
||||
/** The splash page. Draws ties on the rule so the line reads as track rather than a border. */
|
||||
const ties = document.getElementById('ties');
|
||||
if (ties) {
|
||||
let out = '';
|
||||
for (let x = 4; x < 700; x += 9) {
|
||||
out += `<line class="tie" x1="${x}" y1="6" x2="${x}" y2="19"/>`;
|
||||
}
|
||||
ties.innerHTML = out;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* One floating tooltip, shared by every page.
|
||||
*
|
||||
* Reference detail — what a card does, why an action is offered, what a limit means — is needed
|
||||
* occasionally and read once. Printing all of it costs the space that the board, the hand and the
|
||||
* action list actually need. So anything carrying `data-tip` shows its text on hover or focus and
|
||||
* nothing the rest of the time.
|
||||
*
|
||||
* Not the browser's native `title`: that waits about a second, cannot be styled, wraps badly at
|
||||
* this length, and never appears for keyboard users.
|
||||
*/
|
||||
|
||||
let tip: HTMLElement | null = null;
|
||||
|
||||
function ensure(): HTMLElement {
|
||||
if (tip) return tip;
|
||||
const el = document.createElement('div');
|
||||
el.id = 'tip';
|
||||
el.setAttribute('role', 'tooltip');
|
||||
document.body.appendChild(el);
|
||||
tip = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
function place(el: HTMLElement, text: string): void {
|
||||
const t = ensure();
|
||||
t.textContent = text;
|
||||
t.style.display = 'block';
|
||||
|
||||
// Measure, then keep it on screen: flip above when it would fall off the bottom, and pull back
|
||||
// from the right edge rather than letting the text run off it.
|
||||
const r = el.getBoundingClientRect();
|
||||
const box = t.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
let left = r.left + r.width / 2 - box.width / 2;
|
||||
left = Math.max(margin, Math.min(left, window.innerWidth - box.width - margin));
|
||||
let top = r.bottom + 6;
|
||||
if (top + box.height > window.innerHeight - margin) top = r.top - box.height - 6;
|
||||
t.style.left = `${Math.round(left + window.scrollX)}px`;
|
||||
t.style.top = `${Math.round(top + window.scrollY)}px`;
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
if (tip) tip.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening. Delegated from the document, so markup replaced later — the board redraws on
|
||||
* every action — needs no re-binding.
|
||||
*/
|
||||
export function installTooltips(): void {
|
||||
const find = (node: EventTarget | null): HTMLElement | null => {
|
||||
let el = node as HTMLElement | null;
|
||||
while (el && el !== document.body) {
|
||||
// SVG elements have no closest() in older engines, so walk manually.
|
||||
if (el.getAttribute && el.getAttribute('data-tip')) return el;
|
||||
el = (el.parentNode as HTMLElement) ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const el = find(e.target);
|
||||
if (el) place(el, el.getAttribute('data-tip') ?? '');
|
||||
else hide();
|
||||
});
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
if (!find((e as MouseEvent).relatedTarget)) hide();
|
||||
});
|
||||
document.addEventListener('focusin', (e) => {
|
||||
const el = find(e.target);
|
||||
if (el) place(el, el.getAttribute('data-tip') ?? '');
|
||||
});
|
||||
document.addEventListener('focusout', hide);
|
||||
document.addEventListener('scroll', hide, true);
|
||||
// Escape closes it, which matters when one is pinned open by focus.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e as KeyboardEvent).key === 'Escape') hide();
|
||||
});
|
||||
}
|
||||
|
||||
/** Styling, kept here so every page that installs tooltips gets the same one. */
|
||||
export const TOOLTIP_CSS = `
|
||||
#tip{position:absolute;z-index:9999;display:none;max-width:340px;
|
||||
background:#0b0e12;color:#e6e9ee;border:1px solid #4d6fa8;border-radius:6px;
|
||||
padding:7px 10px;font:12px/1.45 ui-monospace,Menlo,Consolas,monospace;
|
||||
box-shadow:0 6px 22px rgba(0,0,0,.55);pointer-events:none;white-space:pre-wrap}
|
||||
[data-tip]{cursor:help}
|
||||
button[data-tip],a[data-tip]{cursor:pointer}
|
||||
`;
|
||||
+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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+62
-3
@@ -36,7 +36,7 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'actorChanged', player: 0 },
|
||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
|
||||
@@ -191,7 +191,7 @@ describe('replay HTML', () => {
|
||||
});
|
||||
|
||||
it('offers the controls that make it usable', () => {
|
||||
for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash']) {
|
||||
for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash', 'waste', 'jam', 'decision']) {
|
||||
assert.ok(html.includes(`id="${id}"`), `missing control: ${id}`);
|
||||
}
|
||||
});
|
||||
@@ -207,7 +207,7 @@ describe('replay HTML', () => {
|
||||
|
||||
it('wires a handler to every control', () => {
|
||||
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
|
||||
for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash']) {
|
||||
for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash', 'waste', 'jam']) {
|
||||
assert.match(js, new RegExp(`\\$\\('${id}'\\)\\.onclick`), `no handler bound to ${id}`);
|
||||
}
|
||||
assert.match(js, /\$\('scrub'\)\.oninput/, 'no handler bound to scrub');
|
||||
@@ -233,6 +233,65 @@ describe('replay HTML', () => {
|
||||
assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
|
||||
});
|
||||
|
||||
it('records what the bot chose, why, and what it passed over', () => {
|
||||
// The replay could always show what happened, never what COULD have happened — so a daft move
|
||||
// was visible but the alternatives it declined were not, which is what you need to say what it
|
||||
// should have done instead.
|
||||
const rec = record(202, 'standard');
|
||||
const decided = rec.frames.filter((f) => f.decision !== null);
|
||||
assert.ok(decided.length > 0, 'no frame carried a decision');
|
||||
|
||||
for (const f of decided) {
|
||||
assert.ok(f.decision!.chose.length > 0, 'a decision with no chosen action');
|
||||
assert.ok(f.decision!.why.length > 0, 'a decision with no stated reason');
|
||||
assert.ok(f.decision!.totalOptions >= 1, 'a decision offering nothing');
|
||||
}
|
||||
|
||||
// The reasons must come from the bot's own branches, not a generic fallback for everything.
|
||||
const reasons = new Set(decided.map((f) => f.decision!.why));
|
||||
assert.ok(reasons.size > 5, `only ${reasons.size} distinct reasons — the branches are not reporting`);
|
||||
|
||||
// Turns with real alternatives must show them, or the panel is decoration.
|
||||
assert.ok(
|
||||
decided.some((f) => f.decision!.rejected.length > 0),
|
||||
'no frame ever recorded a rejected option',
|
||||
);
|
||||
});
|
||||
|
||||
it('flags jammed facilities and can tell them from ready ones', () => {
|
||||
const rec = record(202, 'standard');
|
||||
for (const f of rec.frames) {
|
||||
for (const x of f.facilities) {
|
||||
// A jam is precisely "work on WORK that cannot come off", so it can never be 'ready'.
|
||||
assert.ok(!(x.jammed && x.canFinish), `${x.name} reported both jammed and ready`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a LATER frame, where the board state is carried forward', () => {
|
||||
// The page omits cells/facilities/division when unchanged and resolves them by walking back —
|
||||
// 63% of a 5.2 MB payload was the same grid re-serialised every frame. Rendering only frame 0
|
||||
// would never exercise that path, because frame 0 always carries everything.
|
||||
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const stub = {
|
||||
getElementById: (id: string) => {
|
||||
if (!els.has(id)) els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0 });
|
||||
return els.get(id);
|
||||
},
|
||||
};
|
||||
// Append a jump to a late frame so render() runs against carried-forward state.
|
||||
const run = new Function('document', 'setInterval', 'clearInterval', js + '\n;go(FRAMES.length - 1);');
|
||||
assert.doesNotThrow(
|
||||
() => run(stub, () => 0, () => undefined),
|
||||
'the page threw rendering a frame whose board state was carried forward',
|
||||
);
|
||||
assert.ok(
|
||||
String(els.get('grid')?.innerHTML ?? '').length > 0,
|
||||
'the grid rendered empty on a carried-forward frame',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays a sane size', () => {
|
||||
const mb = Buffer.byteLength(html) / 1024 / 1024;
|
||||
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
|
||||
|
||||
+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);
|
||||
});
|
||||
|
||||
|
||||
+50
-6
@@ -276,14 +276,58 @@ describe('switching accomplishes something (regression)', () => {
|
||||
assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`);
|
||||
});
|
||||
|
||||
it('does not spend its whole Local Operations budget on motion', () => {
|
||||
// The absolute ratio is no longer a fair test: with only 9 industries in 115 cards there is
|
||||
// often nothing to switch, so `work` is legitimately near zero. What still must hold is that
|
||||
// the crew is not burning all six Moves every Stage — the shuttling bug.
|
||||
it('does not shuttle aimlessly — Moves have to buy something', () => {
|
||||
// Counting Moves alone stopped being a fair test once the crew could do real work. Run-arounds
|
||||
// and sidings are SUPPOSED to cost several Moves each: the crew leaves the main, runs the loop,
|
||||
// and comes back with a different car droppable. Measured before sidings existed the crew
|
||||
// coupled ~0.4 cars a game; it now couples ~8 and drops ~11.
|
||||
//
|
||||
// So test the thing the old threshold was a proxy for: motion must convert into work. A crew
|
||||
// that shuttles for its own sake shows a high ratio here, however few or many Moves it makes.
|
||||
const report = simulate({
|
||||
games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||
});
|
||||
const moves = report.perGame.reduce((n, g) => n + g.actions.moves, 0) / report.perGame.length;
|
||||
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
|
||||
const per = (f: (g: (typeof report.perGame)[number]) => number): number =>
|
||||
report.perGame.reduce((n, g) => n + f(g), 0) / report.perGame.length;
|
||||
|
||||
const moves = per((g) => g.actions.moves);
|
||||
const work = per((g) => g.actions.drops + g.actions.couples);
|
||||
assert.ok(work > 2, `only ${work.toFixed(1)} productive acts a game — the crew is doing nothing`);
|
||||
assert.ok(
|
||||
moves / work < 8,
|
||||
`${(moves / work).toFixed(1)} Moves per drop or coupling suggests aimless shuttling ` +
|
||||
`(${moves.toFixed(0)} Moves, ${work.toFixed(1)} productive acts)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type {
|
||||
GameState,
|
||||
GridCoord,
|
||||
OfficeArea,
|
||||
RollingStock,
|
||||
@@ -14,6 +15,8 @@ import type {
|
||||
TurnoutOrientation,
|
||||
} from '../src/engine/state.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
||||
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
|
||||
import {
|
||||
allReachable,
|
||||
@@ -24,6 +27,8 @@ import {
|
||||
neighbour,
|
||||
opposite,
|
||||
reachableDestinations,
|
||||
connectionsFor,
|
||||
variantsFor,
|
||||
} from '../src/engine/track.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -36,6 +41,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 +51,7 @@ const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
standing,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
||||
@@ -55,6 +62,7 @@ const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const officeCard = (): TrackCard => ({
|
||||
@@ -63,6 +71,7 @@ const officeCard = (): TrackCard => ({
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const lockedFacility = (): TrackCard => ({
|
||||
@@ -84,10 +93,32 @@ const lockedFacility = (): TrackCard => ({
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
||||
|
||||
function straightCard(): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'track', geometry: 'straight', axis: 'ew' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A minimal game wrapped around a prepared Office Area, for engine-level switching tests. */
|
||||
function gameWith(area: OfficeArea): GameState {
|
||||
const s = createGame({
|
||||
id: 'g', seed: 5,
|
||||
config: {
|
||||
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['p'],
|
||||
});
|
||||
s.officeAreas.set(0, area);
|
||||
return s;
|
||||
}
|
||||
|
||||
function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): OfficeArea {
|
||||
const grid = new Map<string, TrackCard>();
|
||||
for (const [k, v] of Object.entries(cards)) grid.set(k, v);
|
||||
@@ -100,6 +131,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 +193,7 @@ describe('ports and geometry', () => {
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
assert.deepEqual(exitsFrom(r, 's'), ['w']);
|
||||
});
|
||||
@@ -399,3 +434,136 @@ describe('placement and drop-off', () => {
|
||||
assert.ok(!canDropCarsAt(area, at(0, 1)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('curves are arcs, and arcs make sidings possible', () => {
|
||||
const arcCard = (arc: 'ne' | 'nw' | 'se' | 'sw', geometry: 'curved' | 'sharpCurved' = 'curved'): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry, arc },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
|
||||
it('joins exactly two adjacent edges, with no through track', () => {
|
||||
// The printed cards (docs/tracks.png, rows 3-4) are a single sweep from edge to edge. Modelling
|
||||
// a curve as through-track PLUS a diverging leg made it a turnout, and an exact duplicate of one.
|
||||
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
||||
const links = connectionsFor(arcCard(arc));
|
||||
assert.equal(links.length, 1, `${arc} should be ONE connection, not a through track plus a leg`);
|
||||
assert.deepEqual([...links[0]!].sort(), [...arc].sort(), `${arc} joins the wrong edges`);
|
||||
}
|
||||
});
|
||||
|
||||
it('is no longer a duplicate of a turnout', () => {
|
||||
const norm = (c: readonly (readonly string[])[]): string =>
|
||||
c.map((p) => [...p].sort().join('')).sort().join(' ');
|
||||
const turnout: TrackCard = {
|
||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
};
|
||||
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
||||
assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)),
|
||||
`a ${arc} curve still has the same connections as a turnout`);
|
||||
}
|
||||
});
|
||||
|
||||
it('can reach NORTH, which nothing but an n-s straight could before', () => {
|
||||
// This is the whole point. With every leg diverging south, a district could only ever be a
|
||||
// vertical column — no siding, no parallel track, no way back up to the Running Track.
|
||||
const reachesNorth = (['ne', 'nw'] as const).every((arc) =>
|
||||
connectionsFor(arcCard(arc)).some((p) => p.includes('n')),
|
||||
);
|
||||
assert.ok(reachesNorth, 'a curve still cannot reach north');
|
||||
});
|
||||
|
||||
it('offers all four rotations when placed', () => {
|
||||
// A two-port arc has no handedness that survives turning — its mirror IS one of its rotations —
|
||||
// so the printed hand governs supply, not what can be built.
|
||||
for (const g of ['curved', 'sharpCurved'] as const) {
|
||||
const arcs = variantsFor(g).map((v) => v.arc).sort();
|
||||
assert.deepEqual(arcs, ['ne', 'nw', 'se', 'sw'], `${g} does not offer all four rotations`);
|
||||
}
|
||||
});
|
||||
|
||||
it('a sharp curve differs from a curve only in the Moves it costs', () => {
|
||||
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
||||
assert.deepEqual(
|
||||
connectionsFor(arcCard(arc, 'sharpCurved')),
|
||||
connectionsFor(arcCard(arc, 'curved')),
|
||||
'a sharp curve should be geometrically identical',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a crew run a siding and rejoin the main', () => {
|
||||
// The shape the whole change exists for: leave the Running Track, run parallel, come back up.
|
||||
// A crew must ENTER a turnout through its stem to take the diverging leg (§A.1).
|
||||
const grid: Record<string, TrackCard> = {
|
||||
'0,-2': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
||||
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
||||
'-1,-1': arcCard('ne'),
|
||||
'-1,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
||||
'-1,1': arcCard('nw'),
|
||||
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
||||
'0,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
||||
};
|
||||
const area = areaFrom(grid, { row: 0, col: 0 });
|
||||
const ctx = { area, occupancy: { trayAt: () => null, freeAdTracks: () => 2 }, consistSize: 0, self: 'crew' as const };
|
||||
|
||||
const seen = new Set<string>(['0,-2']);
|
||||
const queue = [{ row: 0, col: -2 }];
|
||||
for (let i = 0; i < 40 && queue.length > 0; i++) {
|
||||
const at = queue.shift()!;
|
||||
for (const facing of ['e', 'w', 'n', 's'] as const) {
|
||||
for (const d of reachableDestinations(ctx, at, facing)) {
|
||||
const key = `${d.coord.row},${d.coord.col}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
queue.push(d.coord);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const cell of ['-1,-1', '-1,0', '-1,1']) {
|
||||
assert.ok(seen.has(cell), `the siding cell ${cell} is unreachable — no run-around is possible`);
|
||||
}
|
||||
assert.ok(seen.has('0,1'), 'the siding does not rejoin the Running Track');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coupling lifts only the cars the crew ran over', () => {
|
||||
it('leaves cars standing elsewhere in the district alone', () => {
|
||||
// The reducer cleared EVERY card in the Office Area on any coupling, so picking up one boxcar
|
||||
// deleted every car standing anywhere — including loads worked over several Stages onto an
|
||||
// industry track the crew never went near.
|
||||
const grid: Record<string, TrackCard> = {
|
||||
'0,-1': straightCard(),
|
||||
'0,0': straightCard(),
|
||||
'0,1': straightCard(),
|
||||
'0,2': straightCard(),
|
||||
};
|
||||
grid['0,-1']!.standing.push({ type: 'boxcar', loaded: false }); // on the path
|
||||
grid['0,2']!.standing.push({ type: 'hopper', loaded: true }); // nowhere near it
|
||||
|
||||
const area = areaFrom(grid, { row: 0, col: 0 });
|
||||
const s = gameWith(area);
|
||||
s.trays.set('crew', {
|
||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true, consist: [],
|
||||
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
s.turn.option = 'switch';
|
||||
s.turn.movesRemaining = 6;
|
||||
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: { row: 0, col: -1 }, reverse: false });
|
||||
assert.ok(r.ok, 'the move should be legal');
|
||||
assert.deepEqual(
|
||||
s.trays.get('crew')!.consist.map((c: RollingStock) => c.type),
|
||||
['boxcar'],
|
||||
'the crew should have picked up the car it ran over',
|
||||
);
|
||||
assert.equal(
|
||||
areaOf(s, 0).grid.get('0,2')!.standing.length,
|
||||
1,
|
||||
'a car standing off the path was deleted by an unrelated coupling',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* The playable browser build.
|
||||
*
|
||||
* These tests drive `game.ts` exactly as the page does — take the offered actions, submit one, look
|
||||
* at the new position — so "it compiles" is never mistaken for "it plays".
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
import { cardDescription, describeIntent } from '../src/sim/view.ts';
|
||||
import {
|
||||
actionGroups,
|
||||
actionMenu,
|
||||
handPlayable,
|
||||
currentActor,
|
||||
fromSave,
|
||||
newGame,
|
||||
submit,
|
||||
toSave,
|
||||
view,
|
||||
} from '../src/web/game.ts';
|
||||
|
||||
const root = join(import.meta.dirname, '..');
|
||||
const dist = join(root, 'dist');
|
||||
|
||||
/** Play a whole game by always taking the first offered action. */
|
||||
function playThrough(seed: number, maxTurns = 20_000) {
|
||||
const game = newGame(seed);
|
||||
let turns = 0;
|
||||
for (; turns < maxTurns; turns++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options, groups } = actionGroups(game);
|
||||
if (groups.length === 0 || options.length === 0) break;
|
||||
const first = groups[0]!.actions[0]!;
|
||||
if (!submit(game, options[first.index]!)) break;
|
||||
}
|
||||
return { game, turns };
|
||||
}
|
||||
|
||||
describe('the browser game plays', () => {
|
||||
it('reaches the end of a game through the same calls the page makes', () => {
|
||||
const { game, turns } = playThrough(77);
|
||||
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
||||
assert.equal(game.state.status, 'finished');
|
||||
assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
|
||||
});
|
||||
|
||||
it('never offers an action the engine then refuses', () => {
|
||||
// The action list comes from legalActions, which delegates to check — so every button on the
|
||||
// page must be accepted. A rejection here means the UI and the rules disagree.
|
||||
const game = newGame(21);
|
||||
for (let i = 0; i < 300; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options, groups } = actionGroups(game);
|
||||
if (groups.length === 0) break;
|
||||
const pick = groups[groups.length - 1]!.actions[0]!;
|
||||
assert.ok(submit(game, options[pick.index]!), 'the engine refused an offered action');
|
||||
}
|
||||
});
|
||||
|
||||
it('offers every legal action somewhere, dropping none', () => {
|
||||
// Grouping is presentation. If a kind is not named in GROUP_ORDER it must still appear, or a
|
||||
// legal move becomes unreachable in a way that is very hard to notice.
|
||||
const game = newGame(5);
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options, groups } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
const shown = new Set(groups.flatMap((g) => g.actions.map((a) => a.index)));
|
||||
const kinds = new Set(options.map((o) => o.type));
|
||||
const shownKinds = new Set([...shown].map((i2) => options[i2]!.type));
|
||||
assert.deepEqual(shownKinds, kinds, 'a kind of legal action was not offered at all');
|
||||
submit(game, options[[...shown][0]!]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores a saved game to the same position', () => {
|
||||
// A save is the seed plus the intents; replaying them must reproduce the game exactly. That is
|
||||
// the payoff of event sourcing, and it is only true while the RNG stays seeded.
|
||||
const game = newGame(909);
|
||||
for (let i = 0; i < 80; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options, groups } = actionGroups(game);
|
||||
if (groups.length === 0) break;
|
||||
submit(game, options[groups[0]!.actions[0]!.index]!);
|
||||
}
|
||||
|
||||
const restored = fromSave(toSave(game));
|
||||
assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
|
||||
assert.equal(restored.state.clock.day, game.state.clock.day);
|
||||
assert.equal(restored.state.clock.stage, game.state.clock.stage);
|
||||
assert.equal(restored.state.status, game.state.status);
|
||||
assert.deepEqual(view(restored).cells, view(game).cells, 'the board differs after restore');
|
||||
});
|
||||
|
||||
it('is deterministic — the same seed deals the same game', () => {
|
||||
const a = playThrough(4242).game;
|
||||
const b = playThrough(4242).game;
|
||||
assert.equal(a.state.players[0]!.revenue, b.state.players[0]!.revenue);
|
||||
assert.deepEqual(view(a).cells, view(b).cells);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the action menu presents choices the way they are made', () => {
|
||||
it('offers a card ONCE, with its locations underneath', () => {
|
||||
// A single turn offered 29 track buttons and 7 card buttons in one flat list, with no way to
|
||||
// tell which square each referred to. Subject first, location second.
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
|
||||
const menu = actionMenu(game);
|
||||
const items = menu.placeable.flatMap((g) => g.items);
|
||||
assert.ok(items.length > 0, 'nothing placeable was offered');
|
||||
|
||||
const keys = items.map((i) => i.subjectKey);
|
||||
assert.equal(new Set(keys).size, keys.length, 'the same card appeared as two subjects');
|
||||
for (const item of items) {
|
||||
const labels = item.spots.map((sp) => sp.label);
|
||||
assert.equal(new Set(labels).size, labels.length, `${item.subject} lists a spot twice`);
|
||||
assert.ok(item.spots.length > 0, `${item.subject} has nowhere to go but was offered`);
|
||||
}
|
||||
});
|
||||
|
||||
it('loses no legal action in the reshuffle', () => {
|
||||
// Splitting into direct + placeable must not drop anything: every option must still be reachable.
|
||||
const game = newGame(88);
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const menu = actionMenu(game);
|
||||
if (menu.options.length === 0) break;
|
||||
const reachable = new Set([
|
||||
...menu.direct.flatMap((g) => g.actions.map((a) => a.index)),
|
||||
...menu.placeable.flatMap((g) => g.items.flatMap((it) => it.spots.map((sp) => sp.index))),
|
||||
]);
|
||||
const kinds = new Set(menu.options.map((o) => o.type));
|
||||
const shown = new Set([...reachable].map((k) => menu.options[k]!.type));
|
||||
assert.deepEqual(shown, kinds, 'a kind of legal action became unreachable');
|
||||
submit(game, menu.options[[...reachable][0]!]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the card in every face-up slot', () => {
|
||||
// "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
const draw = actionMenu(game).direct.find((g) => g.title === 'Draw');
|
||||
assert.ok(draw, 'no draw group');
|
||||
for (const a of draw!.actions) {
|
||||
assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
|
||||
}
|
||||
assert.ok(
|
||||
draw!.actions.some((a) => /face-up slot/.test(a.label)),
|
||||
'face-up slots are not named',
|
||||
);
|
||||
});
|
||||
|
||||
it('never offers a train card a place on the board', () => {
|
||||
// A train card goes to the TIMETABLE. Seed 555 offered Extra X15 at six squares with six
|
||||
// rotations each — all identical, because the placement was accepted and then ignored.
|
||||
const game = newGame(555);
|
||||
for (let i = 0; i < 200; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
for (const o of options) {
|
||||
if (o.type !== 'card.play') continue;
|
||||
const kind = game.state.cards.get(o.cardId)?.kind.kind;
|
||||
if (kind === 'timetabledTrain' || kind === 'extraTrain') {
|
||||
assert.equal(o.placement, undefined, 'a train card was offered a board square');
|
||||
}
|
||||
}
|
||||
submit(game, options[0]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports what is left of the personal track supply', () => {
|
||||
// Track is not drawn from the deck — 26 pieces per player, at most one laid a turn — so the
|
||||
// board alone could never answer "how many straights have I got left".
|
||||
const game = newGame(31);
|
||||
const supply = view(game).trackSupply;
|
||||
assert.ok(supply.length > 0, 'no track supply reported');
|
||||
assert.equal(supply.reduce((n, t) => n + t.left, 0), 26, 'the opening supply should be 26');
|
||||
assert.ok(supply.every((t) => !/none/.test(t.piece)), 'un-handed pieces should not say "none"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('board highlighting', () => {
|
||||
it('gives every spot a coordinate to highlight', () => {
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
const items = actionMenu(game).placeable.flatMap((g) => g.items);
|
||||
assert.ok(items.length > 0);
|
||||
for (const it of items) {
|
||||
for (const sp of it.spots) {
|
||||
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
|
||||
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
|
||||
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('highlights squares OUTSIDE the current district', () => {
|
||||
// The commonest placement is just beyond the existing cards — that is what extending means. A
|
||||
// grid sized only to the cards already down would highlight nothing exactly when it matters.
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
|
||||
const cells = view(game).cells;
|
||||
const minCol = Math.min(...cells.map((c) => c.col));
|
||||
const maxCol = Math.max(...cells.map((c) => c.col));
|
||||
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots);
|
||||
|
||||
assert.ok(
|
||||
spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0),
|
||||
'no legal spot lies outside the current cards — the bounds test would be vacuous',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page explains itself', () => {
|
||||
it('says what every card in hand and every face-up slot does', () => {
|
||||
// A hand of bare names is unplayable: "Steam Turbines" carries no hint of its effect, and all of
|
||||
// this text already existed in the content tables without reaching the screen.
|
||||
for (const seed of [555, 430, 7, 99]) {
|
||||
const f = view(newGame(seed));
|
||||
assert.equal(f.handWhat.length, f.hand.length, 'a hand card has no description slot');
|
||||
f.hand.forEach((name, i) => {
|
||||
const what = f.handWhat[i]!;
|
||||
assert.ok(what.length > 0, `${name} has no description`);
|
||||
// camelCase leaking to the screen is the recurring failure here.
|
||||
assert.doesNotMatch(what, /[a-z][A-Z]/, `raw camelCase in "${name}": ${what}`);
|
||||
assert.doesNotMatch(what, /playerChoicebound|undefined|NaN/, `broken text: ${what}`);
|
||||
});
|
||||
f.departments.forEach((name, i) => {
|
||||
if (name === '—') return;
|
||||
assert.ok(f.departmentsWhat[i]!.length > 0, `face-up ${name} has no description`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('says what every card on the BOARD does, in readable words', () => {
|
||||
// A played card becomes a cell with a name on it — "turnout", "Freight House", "waiting area" —
|
||||
// and the explanation that was visible while it sat in hand disappears exactly when it starts
|
||||
// mattering. Play a long way in so every card kind reaches the grid.
|
||||
const game = newGame(111);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
const pick =
|
||||
options.find((o) => o.type === 'card.play' && o.placement) ??
|
||||
options.find((o) => o.type === 'track.lay') ??
|
||||
options[0]!;
|
||||
if (!submit(game, pick)) break;
|
||||
}
|
||||
|
||||
const cells = view(game).cells;
|
||||
assert.ok(cells.length > 4, 'not enough of the board was built to be a real check');
|
||||
for (const c of cells) {
|
||||
assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`);
|
||||
// camelCase on the board is the failure that keeps recurring — labels AND descriptions.
|
||||
assert.doesNotMatch(c.label, /[a-z][A-Z]/, `raw camelCase label: ${c.label}`);
|
||||
assert.doesNotMatch(c.what, /[a-z][A-Z]/, `raw camelCase in "${c.label}": ${c.what}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('spells out which way a turnout will and will not let a train run', () => {
|
||||
// §A.1 is an ABSENT edge, not a one-way street, and it is invisible on a card that just says
|
||||
// "turnout". Getting it wrong is how a crew ends up somewhere it cannot leave.
|
||||
const game = newGame(3);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
area.grid.set('-1,0', {
|
||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
||||
assert.match(cell.what, /stem east/);
|
||||
assert.match(cell.what, /diverges south/);
|
||||
assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out');
|
||||
});
|
||||
|
||||
it('describes every card kind in the deck, not just the easy ones', () => {
|
||||
// Walk the whole deck rather than one hand: the categories differ, and a missing branch would
|
||||
// show as a blank line under a card name only for the seeds that happen to deal it.
|
||||
const game = newGame(1);
|
||||
const seen = new Set<string>();
|
||||
for (const [id, card] of game.state.cards) {
|
||||
const what = cardDescription(game.state, id);
|
||||
assert.ok(what.length > 0, `${card.kind.kind} has no description`);
|
||||
seen.add(card.kind.kind);
|
||||
}
|
||||
assert.ok(seen.size >= 7, `only ${seen.size} card kinds seen — the deck should hold more`);
|
||||
});
|
||||
|
||||
it('marks cards that cannot be played yet, and says why', () => {
|
||||
// A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential
|
||||
// (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an
|
||||
// action that did nothing.
|
||||
const game = newGame(111);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'draw.fromDepartment' && o.slot === 0)!);
|
||||
|
||||
const f = view(game);
|
||||
const playable = handPlayable(game);
|
||||
assert.equal(playable.length, f.hand.length, 'a hand card has no playable flag');
|
||||
|
||||
const upgrades = f.hand
|
||||
.map((name, i) => ({ name, what: f.handWhat[i]!, can: playable[i]! }))
|
||||
.filter((c) => /upgrade/.test(c.name));
|
||||
assert.ok(upgrades.length > 0, 'this seed should deal an Office upgrade');
|
||||
for (const u of upgrades) {
|
||||
// Only Depot is reachable from a Whistle Post.
|
||||
if (/Depot/.test(u.name)) continue;
|
||||
assert.equal(u.can, false, `${u.name} should not be playable from a Whistle Post`);
|
||||
assert.match(u.what, /requires the Office to be a/, `${u.name} does not say what it needs`);
|
||||
}
|
||||
});
|
||||
|
||||
it('agrees with the buttons about what is playable', () => {
|
||||
// The flag is derived from legalActions, so it must never disagree with the offered actions.
|
||||
const game = newGame(7);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const hand = game.state.decks.hands.get(0) ?? [];
|
||||
const flags = handPlayable(game);
|
||||
const offered = new Set(
|
||||
actionGroups(game)
|
||||
.options.filter((o) => o.type === 'card.play')
|
||||
.map((o) => (o as { cardId: string }).cardId),
|
||||
);
|
||||
hand.forEach((id, k) => {
|
||||
assert.equal(flags[k], offered.has(id), 'playable flag disagrees with the action list');
|
||||
});
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
submit(game, options[0]!);
|
||||
}
|
||||
});
|
||||
|
||||
it('never puts an internal tray id on a BUTTON either', () => {
|
||||
// The history check below missed this: `maneuver.redFlags` was labelled "Red Flags on tray3",
|
||||
// so the id leaked through the action list rather than the log. Every button, every turn.
|
||||
const game = newGame(2345);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
for (const o of options) {
|
||||
const label = describeIntent(game.state, o);
|
||||
assert.doesNotMatch(label, /\btray\d+\b/, `internal tray id on a button: ${label}`);
|
||||
}
|
||||
if (!submit(game, options[0]!)) break;
|
||||
}
|
||||
});
|
||||
|
||||
it('never offers to load a caboose', () => {
|
||||
// A caboose carries the crew, not freight. "add loaded caboose" appeared because the button was
|
||||
// formatted by hand instead of using the labeller that already knew.
|
||||
const game = newGame(2345);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
for (const o of options) {
|
||||
assert.doesNotMatch(
|
||||
describeIntent(game.state, o),
|
||||
/(loaded|empty) caboose/,
|
||||
'a caboose was described as loaded or empty',
|
||||
);
|
||||
}
|
||||
if (!submit(game, options[0]!)) break;
|
||||
}
|
||||
});
|
||||
|
||||
it('warns that an industry on the Running Track is in the way of arrivals', () => {
|
||||
// §11.2 — a Facility carries its own rails, so it does NOT block traffic. But §10 makes a car
|
||||
// left standing between the Limits and the Office a collision, and nothing said so.
|
||||
const game = newGame(9);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
const industry = {
|
||||
geometry: { kind: 'facility' as const, facility: 'grocersWarehouse' as const, axis: 'ew' as const },
|
||||
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
||||
facility: {
|
||||
kind: 'freight' as const, subtype: 'grocersWarehouse',
|
||||
allows: { outbound: false, inbound: true },
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: 0, inbound: 1 },
|
||||
menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
};
|
||||
area.grid.set(`${area.runningRow},2`, industry as never);
|
||||
area.grid.set(`${area.runningRow - 1},0`, JSON.parse(JSON.stringify(industry)) as never);
|
||||
|
||||
const cells = view(game).cells;
|
||||
const onRunning = cells.find((c) => c.row === area.runningRow && c.col === 2)!;
|
||||
const below = cells.find((c) => c.row === area.runningRow - 1 && c.col === 0)!;
|
||||
|
||||
assert.match(onRunning.what, /RUNNING TRACK/, 'no warning on a Running Track industry');
|
||||
assert.match(onRunning.what, /pass straight through/, 'does not say traffic still passes');
|
||||
assert.doesNotMatch(below.what, /RUNNING TRACK/, 'a Secondary Track industry must not warn');
|
||||
});
|
||||
|
||||
it('never puts an internal tray id in front of a player', () => {
|
||||
// The §8.1 clearance question read "may tray2 follow tray3 into the next Subdivision?" — the
|
||||
// sharpest decision in the game, phrased in internal identifiers.
|
||||
const game = newGame(430);
|
||||
for (let i = 0; i < 600; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
submit(game, options[0]!);
|
||||
}
|
||||
for (const line of game.log) {
|
||||
assert.doesNotMatch(line.text, /\btray\d+\b/, `internal tray id shown to the player: ${line.text}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('spells out what each clearance ruling costs', () => {
|
||||
const game = newGame(5);
|
||||
const s = game.state;
|
||||
s.trays.set('tray2', {
|
||||
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.trays.set('tray3', {
|
||||
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };
|
||||
|
||||
const allow = describeIntent(s, { type: 'mainline.clearance', allow: true });
|
||||
const hold = describeIntent(s, { type: 'mainline.clearance', allow: false });
|
||||
for (const label of [allow, hold]) {
|
||||
assert.match(label, /Train 4/, `the ruling does not name the train: ${label}`);
|
||||
assert.doesNotMatch(label, /tray\d/, `internal id on a button: ${label}`);
|
||||
}
|
||||
// Deliberately NOT asserting that granting clearance mentions a collision: no rear-end is
|
||||
// implemented, and promising one would describe a consequence the engine never delivers.
|
||||
assert.match(allow, /same Mainline card/, 'granting clearance does not say what happens');
|
||||
assert.match(hold, /losing the Stage/, 'holding does not mention the cost');
|
||||
});
|
||||
|
||||
it('states the objective and whether you are keeping up', () => {
|
||||
const f = view(newGame(430));
|
||||
assert.equal(f.objective.target, 20);
|
||||
assert.equal(f.objective.days, 5);
|
||||
assert.match(f.objective.note, /of 20/);
|
||||
assert.match(f.objective.note, /Days? left/);
|
||||
});
|
||||
|
||||
it('explains what each Local Operations option spends', () => {
|
||||
// The most consequential decision of the Stage, previously labelled "choose switch".
|
||||
const game = newGame(555);
|
||||
const choices = actionGroups(game)
|
||||
.options.filter((o) => o.type === 'localOps.choose')
|
||||
.map((o) => describeIntent(game.state, o));
|
||||
assert.ok(choices.length > 0);
|
||||
for (const c of choices) {
|
||||
assert.ok(c.length > 25, `too terse to be an explanation: "${c}"`);
|
||||
assert.match(c, /—/, `no explanation after the option name: "${c}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('says which way a piece will point, never "rotation 2"', () => {
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
const track = actionMenu(game)
|
||||
.placeable.flatMap((g) => g.items)
|
||||
.filter((i) => /straight|curve|turnout/.test(i.subject));
|
||||
assert.ok(track.length > 0, 'no track pieces offered');
|
||||
|
||||
for (const item of track) {
|
||||
for (const sp of item.spots) {
|
||||
assert.doesNotMatch(sp.label, /rotation \d/, `opaque rotation label: ${sp.label}`);
|
||||
}
|
||||
// A piece with more than one orientation must say which is which, or the spots are ambiguous.
|
||||
const perSquare = new Map<string, number>();
|
||||
for (const sp of item.spots) {
|
||||
const k = `${sp.coord.row},${sp.coord.col}`;
|
||||
perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
|
||||
}
|
||||
if ([...perSquare.values()].some((n) => n > 1)) {
|
||||
assert.ok(
|
||||
item.spots.some((sp) => /east|west|north|south/.test(sp.label)),
|
||||
`${item.subject} offers two orientations on one square without naming them`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('replays are saves', () => {
|
||||
it('rebuilds the exact position a save describes', () => {
|
||||
// A replay is `{seed, history}` — a few hundred bytes — because the engine is deterministic and
|
||||
// runs in the browser. Re-submitting the same intents must land on the same board, or a shared
|
||||
// replay shows something the player never saw.
|
||||
const game = newGame(430);
|
||||
for (let i = 0; i < 250; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options[0]!)) break;
|
||||
}
|
||||
const restored = fromSave(toSave(game));
|
||||
assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
|
||||
assert.equal(restored.state.clock.day, game.state.clock.day);
|
||||
assert.equal(restored.state.clock.stage, game.state.clock.stage);
|
||||
assert.deepEqual(view(restored).cells, view(game).cells, 'the rebuilt board differs');
|
||||
assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs');
|
||||
});
|
||||
|
||||
it('stays small enough to email', () => {
|
||||
const game = newGame(202);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options[0]!)) break;
|
||||
}
|
||||
const kb = Buffer.byteLength(JSON.stringify(toSave(game))) / 1024;
|
||||
assert.ok(kb < 200, `a save is ${kb.toFixed(0)} KB — too big to be worth sharing as a file`);
|
||||
});
|
||||
|
||||
it('publishes every save in public/replays with an index', () => {
|
||||
// Static hosting cannot list a directory, so the page needs a manifest or it shows nothing.
|
||||
const manifestPath = join(dist, 'replays/manifest.json');
|
||||
assert.ok(existsSync(manifestPath), 'no replay manifest was built');
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { file: string; title: string }[];
|
||||
for (const entry of manifest) {
|
||||
assert.ok(existsSync(join(dist, 'replays', entry.file)), `${entry.file} is indexed but not published`);
|
||||
assert.ok(entry.title.length > 0, `${entry.file} has no title`);
|
||||
const save = JSON.parse(readFileSync(join(dist, 'replays', entry.file), 'utf8')) as Record<string, unknown>;
|
||||
assert.equal(typeof save['seed'], 'number', `${entry.file} has no seed`);
|
||||
assert.ok(Array.isArray(save['history']), `${entry.file} has no history`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the static build', () => {
|
||||
|
||||
it('builds, and needs nothing but a static host', () => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
|
||||
assert.ok(existsSync(join(dist, 'index.html')), 'no index.html');
|
||||
assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
|
||||
assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
|
||||
});
|
||||
|
||||
it('emits no import that a browser cannot resolve', () => {
|
||||
// Node's type-stripping lets the source import './x.ts'; a browser cannot. The build rewrites
|
||||
// those to .js, and nothing may reach for a bare module or a node: builtin.
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) walk(join(dir, e.name));
|
||||
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
|
||||
}
|
||||
};
|
||||
walk(dist);
|
||||
assert.ok(files.length > 5, 'suspiciously few emitted files');
|
||||
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8');
|
||||
// Anchor to real import/export statements. A loose `from '...'` also matches PROSE — the
|
||||
// turnout comment "a train entering a turnout from \"A\"" was read as importing a module
|
||||
// called A, which is the kind of false alarm that trains you to ignore a failing test.
|
||||
const imports = /^\s*(?:import|export)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm;
|
||||
for (const m of src.matchAll(imports)) {
|
||||
const spec = m[1]!.split('?')[0]!;
|
||||
assert.ok(!spec.endsWith('.ts'), `${f} imports a .ts path: ${spec}`);
|
||||
assert.ok(!spec.startsWith('node:'), `${f} imports a Node builtin: ${spec}`);
|
||||
assert.ok(spec.startsWith('.') || spec.startsWith('/'), `${f} imports bare module: ${spec}`);
|
||||
}
|
||||
assert.ok(!/^[^/*]*\bprocess\.\w/m.test(src), `${f} references process`);
|
||||
assert.ok(!/\brequire\(/.test(src), `${f} uses require()`);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders clickable highlights on the board when a card is picked', async () => {
|
||||
// The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board
|
||||
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
|
||||
// nothing about whether the page draws them.
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const injected: Record<string, unknown>[] = [];
|
||||
const make = (): Record<string, unknown> => {
|
||||
let html = '';
|
||||
// Cache per selector and clear it when innerHTML changes. Returning fresh objects each call
|
||||
// would drop the handlers the page assigns — the test would then report "no button" for a
|
||||
// page that works perfectly.
|
||||
let cache = new Map<string, Record<string, unknown>[]>();
|
||||
const found = new Map<string, Record<string, unknown>>();
|
||||
const node: Record<string, unknown> = {
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
// The board is SVG now, and highlighting works by finding a card group and adding a class.
|
||||
// The stub has to model that much or it cannot see whether highlighting happened at all.
|
||||
querySelector(sel: string) {
|
||||
const m = /\[data-(cell|ghost)="([^"]+)"\]/.exec(sel);
|
||||
if (!m) return null;
|
||||
const key = `${m[1]}:${m[2]}`;
|
||||
if (!html.includes(`data-${m[1]}="${m[2]}"`)) return null;
|
||||
if (!found.has(key)) {
|
||||
const classes = new Set<string>();
|
||||
found.set(key, {
|
||||
onclick: null,
|
||||
classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) },
|
||||
classes,
|
||||
});
|
||||
}
|
||||
return found.get(key);
|
||||
},
|
||||
highlighted: () => [...found.values()].filter((f) => (f['classes'] as Set<string>).size > 0),
|
||||
querySelectorAll(sel: string) {
|
||||
const hit = cache.get(sel);
|
||||
if (hit) return hit;
|
||||
const out: Record<string, unknown>[] = [];
|
||||
const re = sel.startsWith('[')
|
||||
? /data-at="([^"]*)"/g
|
||||
: new RegExp(`<button class="${sel.split('.')[1]}[^"]*"([^>]*)>`, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
const data: Record<string, string> = {};
|
||||
if (sel.startsWith('[')) data['at'] = m[1]!;
|
||||
else {
|
||||
const attr = /data-(\w+)="([^"]*)"/g;
|
||||
let a: RegExpExecArray | null;
|
||||
while ((a = attr.exec(m[1]!)) !== null) data[a[1]!] = a[2]!;
|
||||
}
|
||||
out.push({ dataset: data, onclick: null });
|
||||
}
|
||||
cache.set(sel, out);
|
||||
return out;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(node, 'innerHTML', {
|
||||
get: () => html,
|
||||
set: (v: string) => {
|
||||
html = v;
|
||||
cache = new Map();
|
||||
},
|
||||
});
|
||||
return node;
|
||||
};
|
||||
|
||||
// STRICT: only ids that really exist in the served page. The stub used to conjure an element
|
||||
// for any id asked for, so a `$('target')` left behind after removing #target from the HTML
|
||||
// would pass here and throw on load in a browser — the page would simply never start.
|
||||
const served = new Set(
|
||||
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
|
||||
(m) => m[1]!,
|
||||
),
|
||||
);
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
// The page installs a tooltip layer on load, so the stub needs enough DOM to let it.
|
||||
g['document'] = {
|
||||
getElementById: (id: string) => {
|
||||
if (!served.has(id)) return null;
|
||||
if (!els.has(id)) els.set(id, make());
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => make(),
|
||||
addEventListener: () => {},
|
||||
body: { appendChild: () => {} },
|
||||
// Capture injected stylesheets. The board is SVG styled entirely by class, so a page that
|
||||
// renders every card correctly and never loads BOARD_CSS draws them black on black — visibly
|
||||
// broken, and invisible to a stub that ignores CSS.
|
||||
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
|
||||
};
|
||||
g['location'] = { search: '?seed=555' };
|
||||
const store = new Map<string, string>();
|
||||
g['localStorage'] = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
};
|
||||
// No parameter properties — `erasableSyntaxOnly` forbids them, since Node strips types rather
|
||||
// than compiling them.
|
||||
g['URLSearchParams'] = class {
|
||||
search: string;
|
||||
constructor(search: string) {
|
||||
this.search = search;
|
||||
}
|
||||
get(k: string): string | null {
|
||||
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
|
||||
|
||||
const actions = els.get('actions')!;
|
||||
const grid = els.get('grid')!;
|
||||
|
||||
// Take the draw option, then pick the first placeable subject.
|
||||
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
|
||||
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
|
||||
const nodes = fn.call(el, sel);
|
||||
const target = nodes[0];
|
||||
if (!target) return false;
|
||||
(target['onclick'] as (() => void) | null)?.();
|
||||
return true;
|
||||
};
|
||||
|
||||
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
|
||||
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
|
||||
|
||||
// Without the board stylesheet every shape is drawn black on a near-black background: the page
|
||||
// looks empty even though the markup is perfect.
|
||||
const styles = injected.map((n) => String(n['textContent'] ?? '')).join('\n');
|
||||
assert.match(styles, /\.bs-card\s*\{/, 'the page never loaded the board styles — cards would be invisible');
|
||||
assert.match(styles, /\.bs-rail\s*\{/, 'the page never loaded the rail styles');
|
||||
|
||||
const html = String(grid['innerHTML']);
|
||||
// The board draws cards as addressable groups; legality is an added class or a drawn ghost.
|
||||
assert.match(html, /data-cell="/, 'the board drew no addressable cards');
|
||||
const lit = (grid['highlighted'] as () => unknown[])();
|
||||
const ghosts = /data-ghost="/.test(html);
|
||||
assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board');
|
||||
});
|
||||
|
||||
it('busts the cache on every module, so a deploy cannot half-load', () => {
|
||||
// The first real deploy served a fresh index.html against a CACHED main.js: the HTML had
|
||||
// dropped an element the old script still asked for, so the page threw `missing element:
|
||||
// target` and never started. Filenames never change, so without a version query a static host
|
||||
// will happily mix two builds.
|
||||
const html = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
const entry = /<script type="module" src="([^"]+)"/.exec(html)?.[1] ?? '';
|
||||
assert.match(entry, /\?v=/, 'the entry script is not cache-busted');
|
||||
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) walk(join(dir, e.name));
|
||||
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
|
||||
}
|
||||
};
|
||||
walk(dist);
|
||||
|
||||
let checked = 0;
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8');
|
||||
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
|
||||
assert.match(m[1]!, /\.js\?v=/, `${f} imports ${m[1]} without a version`);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
assert.ok(checked > 5, `only ${checked} relative imports seen — the check is too weak`);
|
||||
});
|
||||
|
||||
it('resolves every busted import to a real file', () => {
|
||||
// Appending a query must not break resolution: the path before `?` still has to exist.
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) walk(join(dir, e.name));
|
||||
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
|
||||
}
|
||||
};
|
||||
walk(dist);
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8');
|
||||
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
|
||||
const target = resolve(dirname(f), m[1]!.split('?')[0]!);
|
||||
assert.ok(existsSync(target), `${f} imports ${m[1]}, which does not exist`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('asks only for elements the page actually has', () => {
|
||||
// Cheap and total: compare every $('id') in the source against the ids in the served HTML.
|
||||
// Getting this wrong does not degrade the page, it stops the game starting at all.
|
||||
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
|
||||
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z]+)'\)/g)].map((m) => m[1]!));
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
const present = new Set([...html.matchAll(/id="([a-zA-Z]+)"/g)].map((m) => m[1]!));
|
||||
// `again` is created by the game-over screen before it is looked up.
|
||||
present.add('again');
|
||||
for (const id of asked) {
|
||||
assert.ok(present.has(id), `main.ts asks for #${id}, which the page does not contain`);
|
||||
}
|
||||
});
|
||||
|
||||
it('serves all three pages, each stamped and cache-busted', () => {
|
||||
// The splash is the front door now; the game and the replay directory are separate pages.
|
||||
for (const [name, entry] of [
|
||||
['index.html', 'splash'],
|
||||
['play.html', 'main'],
|
||||
['replays.html', 'replays'],
|
||||
] as const) {
|
||||
const html = readFileSync(join(dist, name), 'utf8');
|
||||
assert.match(html, new RegExp(`src="\\./web/${entry}\\.js\\?v=`), `${name} is not cache-busted`);
|
||||
assert.doesNotMatch(html, /__BUILD__/, `${name} was published without a build stamp`);
|
||||
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), `${name} fetches something external`);
|
||||
}
|
||||
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
assert.match(splash, /href="\.\/play\.html"/, 'the splash does not link to the game');
|
||||
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
|
||||
});
|
||||
|
||||
it('serves a page that loads the game as a module', () => {
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
|
||||
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), 'page fetches something external');
|
||||
for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {
|
||||
assert.ok(html.includes(`id="${id}"`), `page is missing #${id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user