Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4adf149ba5 | ||
|
|
76c6e103b3 | ||
|
|
072029b1f7 |
+466
@@ -19,6 +19,472 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.10 — 2026-09-15
|
||||
|
||||
Three reports from the first multiplayer playtest of v0.8.0.9 — one player and three bots — each traced
|
||||
to its cause before it was fixed.
|
||||
|
||||
### The Superintendent is no longer asked about a train BEHIND the one departing (Gitea#26)
|
||||
|
||||
*"Two westbound trains: X15 is further west on Mainline cards than X18. And I still get a Superintendent
|
||||
must rule... held X15 and then collision happened. X18 destroyed, but X15 fine."*
|
||||
|
||||
**Reproduced by replaying the exported save.** At move 78, X15 was highballing west out of seat 3's
|
||||
Office while X18, also westbound, was still crossing the card to its EAST — behind it. Every Office was a
|
||||
Whistle Post, so the Subdivision ran the whole railroad, and `evaluateClearance` counted every train in it
|
||||
without asking which side of the departing train it stood on. The ruling was meaningless; holding X15
|
||||
kept the Whistle Post's only A/D track full, and X18 arrived into it and was destroyed. Unasked, X15 —
|
||||
the lower number, so the first to move — would have left and freed the track.
|
||||
|
||||
§8.1 asks about a train the departing one would FOLLOW and one moving TOWARDS it, and both are ahead of
|
||||
it. So a train on a card strictly behind the departing train's own card is no longer counted, in either
|
||||
pass: a following train behind is no concern, and an oncoming one behind is moving away. A train on the
|
||||
SAME card is still counted, exactly as before — which of two trains sharing a card is in front is
|
||||
`entryConflict`'s region question. The other two rulings in the save (moves 37 and 530) were correct and
|
||||
are unchanged.
|
||||
|
||||
A test had the fault written into it: its "train ahead" of an eastbound train was the first Mainline card
|
||||
in the Division, which is west of the Office. It now stands on a card the train would follow, and three
|
||||
new cases pin the rule: a same-direction train behind is not put to the Superintendent, an opposite-
|
||||
direction train behind does not bar the departure, and a same-direction train ahead still is. The tally
|
||||
test's seed moved from 42 to 44, because the collision seed 42 was chosen for was this bug.
|
||||
|
||||
### Games in progress — MANY WILL NOT RESUME
|
||||
|
||||
**This release changes when the engine asks for a ruling**, so a save holding a ruling it would no longer
|
||||
ask for stops replaying at that move: `mainline.clearance` is refused with `NO_PENDING_DECISION`. The
|
||||
server then refuses to resume that game and logs the move it stopped at, leaving the save untouched —
|
||||
putting v0.8.0.9 back would resume it. **Measured:** 40 four-seat co-op games recorded by bots under
|
||||
v0.8.0.9 and replayed under this release — 28 stop, usually 5–40% of the way in, most at a ruling on a
|
||||
train behind (about ten rulings a game were being asked). The exported playtest game stops at move 78.
|
||||
**Jesse's call (2026-09-15): no games in progress need keeping, so the fix ships as it is,** with no
|
||||
per-game switch preserving the old rule.
|
||||
|
||||
### Six more from the same playtest (Gitea#27–#32)
|
||||
|
||||
**A Realignment says which card it converted (#27).** *"It stated Mainline card 3 converted to plains. It
|
||||
should state that the mainline card 3 curves was converted to plains."* The event carried only what the
|
||||
card became, so the line could not name what it had been; `mainlineModified` now carries `from` as well —
|
||||
events are derived by replaying a save and never stored, so widening one strands nothing — and it reads
|
||||
"Realignment: Mainline card 3, Curves, converted to Plains".
|
||||
|
||||
**And the map flashes it (#28).** *"Is it possible to flash the mainline card when it gets changed by
|
||||
realignment?"* The one play that changes the Division itself was invisible on the map of it.
|
||||
`changedDivisionCards` compares the two public boards the animation queue already holds — the same way a
|
||||
pile is lit — so the pulse lands with the step that shows the change rather than when the intent arrived,
|
||||
and it is shown to the player who made it too, unlike a lit pile. `prefers-reduced-motion` turns it off.
|
||||
|
||||
**The history stops running ahead of the board (#29).** *"Does history display immediately for all bot
|
||||
turns... is it possible to stall history so it stays in sync with the number behind?"* A push carries its
|
||||
narration and its steps together, so every line of a bot's turn was in the panel before the board had
|
||||
drawn any of it. The queue now reports how many lines belong to steps not yet shown, and the panel holds
|
||||
back exactly those, revealing each as its step goes up. Skip still shows everything at once.
|
||||
|
||||
**A ruling reads as the office's (#30).** *"If a player makes a move as a superintendent instead of as
|
||||
themselves maybe it could say 'Superintendent Player Tom'."* The three moves made by holding the office
|
||||
rather than in turn — §8.1's clearance, §11's Yard Office offer, §Q's Red Flag prompt — are prefixed that
|
||||
way. `clearanceGiven` carries no player at all (the office made it, whoever holds it), so the acting seat
|
||||
is what names it.
|
||||
|
||||
**And it names them once (#31).** *"It gives the player's name and then their player number together."*
|
||||
`record()` prefixes the acting player's NAME for every event carrying `player`, and five narration lines
|
||||
embedded `Player <index>` themselves — phase ended, actor changed, the Red Flag ruling, the extension vote
|
||||
and the Yard Office ruling. The name is the log's job; the sentence is the narration's. A test now scans a
|
||||
played game's whole log for a bare player index.
|
||||
|
||||
**A multiplayer game can be saved as a file (#32).** *"In the StartOS actions for the save game, I get a
|
||||
string I can copy. Most of the time, I want to just save it as a JSON file."* Not possible in the action
|
||||
itself: an action result member is text only — copyable, QR or masked, with nested groups — and the SDK
|
||||
has no file member. So it is done where it can be: the play page's "Save replay" button was shown only in
|
||||
solitaire, because a server-backed session has no local save to hand it. `GET /api/save?token=…` — the
|
||||
same seat token `/api/stream` and `/api/intent` use, not the administrative secret — hands a seated player
|
||||
their own game, and the button now appears in multiplayer.
|
||||
|
||||
### "Waiting on" follows the move on screen (Gitea#25)
|
||||
|
||||
*"Playing against 3 bots — waiting on always says me, even when it is someone else's turn."*
|
||||
|
||||
The server plays every bot move the moment a human's turn ends, so the LIVE game is almost always waiting
|
||||
on the human — while the screen is still replaying those bots step by step. The turn chart and the
|
||||
Division map's move marker read the live actor, and contradicted the playback row naming the bot actually
|
||||
moving. `actorOnScreen` (`web/step-queue.ts`) answers with the player of the step on screen while the
|
||||
board is catching up, and the live actor once it has; during playback the chart also leaves off a live
|
||||
"asks … ruling" note that belongs to a position the screen has not reached. Five tests pin it.
|
||||
|
||||
### The name on the Division map is readable again (Gitea#24)
|
||||
|
||||
*"When it shows your player, the font is unreadable... the bold font makes it look fuzzy, and the letters
|
||||
blur together."*
|
||||
|
||||
A name takes the class `bs-turn` while it is that player's move — and `.bs-turn` is also the Division
|
||||
map's turn ARROW, which strokes its shape 2.4px grey with no fill. The name's own rule changed only the
|
||||
fill, so every letter was outlined in grey. It showed for the viewing player almost constantly because of
|
||||
Gitea#25: the map believed it was nearly always their move. `.bs-name` now sets `stroke:none`.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.9 — 2026-09-15
|
||||
|
||||
**The developer bot, re-measured decision by decision — and an engine 2.8× faster.** Across the changes
|
||||
adopted below, each paired against the bot before it, the bot went from about −0.3 revenue a game to
|
||||
about 4.8: the switching planner +2.89, taking a face-up card only if it could be played +1.52, the
|
||||
deliberate New Train fallback +0.32, and laying track by what the district can do afterwards +0.12.
|
||||
|
||||
### Games in progress
|
||||
|
||||
**Resume.** No rule changed, and no legality check changes its answer. The engine's speed-ups were proven
|
||||
to leave play identical — every event and intent of 32 seeded games hashed before and after each step —
|
||||
and `test/route-cache.test.ts` pins the new check/commit split. Only the developer bot plays differently,
|
||||
and a bot's past moves are already in the save.
|
||||
|
||||
### The bot plans its switching turn instead of choosing one Move at a time
|
||||
|
||||
The switching branch was a ladder of rules picking ONE Move, and its own comment named the gap: "a
|
||||
strong player would use the six Moves to re-order the consist — that is the game's central switching
|
||||
puzzle, and this bot does not attempt it." `sim/switch-planner.ts` attempts it, for one turn.
|
||||
|
||||
**Why search is fair.** A switching turn draws no card and rolls no die, so trying sequences on a copy
|
||||
of the game is what a player does by looking at the board. The score reads only what a player can see
|
||||
— the district, the cars on the trains, the facilities — and never the deck. Jesse's line
|
||||
(2026-09-14): no non-player advantage for the bot.
|
||||
|
||||
**Why not every sequence.** Measured over 30 switching turns from bot games: a median turn reaches
|
||||
229 distinct positions, but 11 of 30 passed 20,000, because **setting cars out costs no Move** and a
|
||||
crew can leave them in a great many places. So the search keeps the best 48 positions at each step
|
||||
and stops at 3,000 tried; small turns are searched completely inside that.
|
||||
|
||||
**The score is of where the turn ends**, starting from Jesse's ruling that players deliver and pick up
|
||||
cars even when it delays trains. A car an industry can work is +1 (+0.25 past its box count); a car it
|
||||
cannot is −0.5 on its track; a finished car left on it −0.15; a wanted car aboard +0.35 or staged on
|
||||
plain track +0.2; a coach kept with its train +0.3, stranded −0.3; anything fouling the Office −3; a
|
||||
train away from the Office −0.25, −1 more if expedited; a train §8.2 would refuse (`badlyMadeUp`, now
|
||||
exported rather than copied) −0.6. A load made in this district scores nothing at a receiver here.
|
||||
|
||||
**The copy is partial.** `forkForSwitching` copies only the arrays a switching intent writes — cars
|
||||
standing on cards, industry tracks, the district's A/D and held lists, the trays' consists, this turn
|
||||
and the tally — and shares everything else. It began as a `structuredClone` of the district, which a
|
||||
profile put at **44% of all planning time**; the targeted copy made a position about three times
|
||||
cheaper to try. `test/switch-planner.test.ts`
|
||||
proves across seeded games that planning leaves the real game byte-identical, and that every plan
|
||||
replays through `applyIntent` on a FULL copy to the exact position it promised.
|
||||
|
||||
**Measured, 1600 paired seeds: +2.89 ± 0.18 revenue a game (t = 15.79)**, 733 seeds better, 21 worse.
|
||||
|
||||
| | rules | planner |
|
||||
| --- | --- | --- |
|
||||
| revenue | −0.05 | 2.83 |
|
||||
| freight loads + unloads | 0.22 | 1.53 |
|
||||
| collisions | 0.12 | 0.12 |
|
||||
| Cargo phases with a car spotted | 5% | 18% |
|
||||
| … with green box, car and Laborer at one industry | 3% | 11% |
|
||||
|
||||
**Split by source over 400 seeds**, because the biggest wins were rescues: freight **+1.21** (t = 13.9),
|
||||
passengers +0.26, expedite faults **+1.07** (19 of 400 games faulted under the rules, none under the
|
||||
planner — TODO #53, closed by this), collisions unchanged. **Without the fault rescue it is still
|
||||
+1.45 ± 0.12 (t = 12.2)** — the switching itself got better, not just the catastrophes rarer. It also
|
||||
sets out half as many cars (3.6 against 7.0): the ladder was setting cars down and picking them up.
|
||||
|
||||
**The worst seeds, traced.** Two of the three lost to "no free A/D track" collisions with both tracks
|
||||
already held by trains standing at the Office — not trains left away, which is what the score's
|
||||
`trainAway` weight would have explained. The third never had a coach train at the Office in a
|
||||
Load/Unload phase at all: a different switching turn draws different cards, and the game diverges.
|
||||
Neither is a scoring defect found; the full-Office collisions are worth watching.
|
||||
|
||||
**How far to search.** The search size was measured rather than guessed, paired over 400 seeds against
|
||||
the 3000-position, beam-48 search the gain above was measured with:
|
||||
|
||||
| budget / beam | revenue against 3000/48 | median / worst per turn |
|
||||
| --- | --- | --- |
|
||||
| 3000 / 48 | — | 150 ms / 445 ms |
|
||||
| **2000 / 32 (adopted)** | −0.02 ± 0.01 (t = −1.68), inside the noise | **69 ms / 222 ms** |
|
||||
| 1000 / 24 | −0.06 ± 0.02 (t = −2.65) | 53 ms / 104 ms |
|
||||
|
||||
Times are from 51 switching turns with other simulations sharing the CPU, so read them as relative.
|
||||
|
||||
**A switching-only legal list.** `legal.ts` now exports `legalSwitchingActions`, the switching half of
|
||||
the Local Operations candidates run through the same `check`, in the same order — so the planner stops
|
||||
paying for every draw and Freight Agent candidate at each position it tries. A test asserts it equals
|
||||
`legalActions`' switching subset across real games. It contains no rule; `check` still decides.
|
||||
|
||||
**Nothing the simulation tests measure moved backwards.** `sim.test.ts` passes all 35 tests with the
|
||||
planner as the default, floors unchanged.
|
||||
|
||||
**Cost.** A turn is planned once and then played a step per decision, replanned if the position is
|
||||
ever not the one expected. At a live table that is a synchronous pause inside `driveBots`, well inside
|
||||
Jesse's bar of half a second before a switch.
|
||||
|
||||
**The price is test time.** A standard solitaire game takes ~400 ms with the planner against ~108 ms
|
||||
without, and `npm test` — which plays well over a thousand bot games in `sim.test.ts` — went from about
|
||||
150 s to **8 min 6 s** (992 of 992 passing, measured with nothing else running). What is left of a
|
||||
planned turn's time is the engine itself: applying a move is about half of it and `check` a third.
|
||||
|
||||
**And the take rule tripled it again.** With both defaults in, `npm test` passes 992 of 992 in
|
||||
**21 min 21 s** with nothing else running — the bigger districts the take rule builds (14 → 24 cards,
|
||||
29 cards played a game) make every game longer to play and every switching turn wider to search.
|
||||
|
||||
`noPlanSwitching=1` is the ablation.
|
||||
|
||||
### Track goes where it lets the district do something
|
||||
|
||||
Jesse's third area, after switching and industries: track for the run-around. With the draw no longer
|
||||
wasted, ~13 of ~16 pieces a game were still being laid by the draw turn's fallback at the first legal
|
||||
square, and both fixes to that fallback failed (holding −0.83, `bestTrackLay`'s own score +0.07). So the
|
||||
limit was the scoring: `bestTrackLay` scores the PIECE, and cannot tell one that opens an industry site
|
||||
or closes a run-around from one that fills a square.
|
||||
|
||||
`bestValuedLay` scores the LAYOUT the piece would leave instead. Each legal lay is placed on a copy of
|
||||
the district exactly as the reducer places it — `protoCard` and `extendLimitsIfNeeded`, now exported
|
||||
rather than copied — and worth the change it makes to `layoutValue`: industry sites a crew can reach
|
||||
(`canPlaceAt`), a closed run-around (the bot's own `descendFrom` walk), ways off the main and reachable
|
||||
siding, a Running Track straight for Interlocking, and a penalty for a turnout on the main whose leg
|
||||
joins nothing. `bestTrackLay`'s slot takes the best lay that gains something; the fallback still lays as
|
||||
often as before — holding starved the district — but the best lay rather than the first.
|
||||
|
||||
**+0.118 ± 0.029 revenue a game (t = 4.14) over 6400 paired seeds**, 1,667 better against 1,430 worse.
|
||||
It needed that many: 400 seeds read +0.17 (t = 1.48) and 1600 read +0.14 (t = 2.58). What it builds is
|
||||
the larger change — **closed run-arounds in 22 of 60 districts against 9**, industries 1.78 → 1.87, freight
|
||||
2.54 → 2.65, collisions unchanged, the district 24.2 → 21.9 cards because squares stop being filled with
|
||||
pieces that build nothing. No slower: a standard game measured faster, the smaller districts leaving
|
||||
less to search. `noValueLays=1` is the ablation.
|
||||
|
||||
**Why so many run-arounds buy so little.** Traced over 40 games: the planner uses the loop — a move ends
|
||||
on it in 63 of 131 plans where one exists, 35 run it both ways — but its planned gain per turn is the
|
||||
same with a run-around as without (0.28 against 0.27). A run-around is for putting a train's cars in a
|
||||
different order, which pays off in the turns AFTER; a one-turn planner cannot value it. That is TODO #105.
|
||||
|
||||
**How 6400 seeds were measured.** `compare.ts` keeps every game's full statistics for both sides, and the
|
||||
run was stopped for low memory — as was a first lean attempt, by the machine's background-task guard
|
||||
rather than by the process: a probe showed no leak (heap flat at 9 MB after GC across 300 paired games).
|
||||
The figure above comes from a paired script with the same seeds and configuration that keeps only each
|
||||
seed's revenue difference, run in resumable foreground chunks; its first 1600 seeds read +0.143, matching
|
||||
`compare.ts`'s +0.14.
|
||||
|
||||
### The engine walks each position's routes once — 2.8× faster, every game identical
|
||||
|
||||
Jesse's call (2026-09-15): speed up the engine's move checking and applying first, because the live
|
||||
server runs the same `legalActions` and `applyIntent` for every bot and every player. A CPU profile
|
||||
with inlining off put the route walk (`reachableDestinations`) at a third of all time and garbage
|
||||
collection at another third, and most of the walking was repeated work.
|
||||
|
||||
- **A route cache scoped to one unchanged position** (`withRouteCache`, `apply.ts`). `legal.ts` walked a
|
||||
tray's routes to list its moves, `check` walked them again for every one of those moves, and
|
||||
`execute` walked the chosen move a third time. Now a legal-action listing, and the check-and-execute
|
||||
of one intent, walk each route once. The cache is keyed to the state object and the walk's inputs
|
||||
and dropped before `reduce` changes anything, so a hit is exactly what a fresh walk returns.
|
||||
- **`applyIntent` = `prepareIntent` + `commitEvents`.** The planner decides every candidate against one
|
||||
position inside one cache and commits each to its own copy; deciding on the copy had re-walked every
|
||||
route the listing had just walked.
|
||||
- **Less garbage in the walk itself** (`exploreMoves`): the blocked-square explanations are not built
|
||||
when only destinations are wanted, the queue is read by index instead of `shift()`, and "has this route
|
||||
been here" reads the route's own path instead of copying a Set at every step.
|
||||
|
||||
**A standard solitaire bot game, 561 ms → 203 ms; a short one, 141 ms → 65 ms.** Proved identical by
|
||||
hashing every event and intent of 32 seeded games (20 solo standard, 6 three-player standard, 6
|
||||
two-player short) before and after each change, and pinned by `test/route-cache.test.ts`, which checks
|
||||
at every decision of a seeded game that `prepareIntent` writes nothing and that committing its events
|
||||
to a copy equals `applyIntent` in place.
|
||||
|
||||
### The test suite is split
|
||||
|
||||
Jesse (2026-09-15): `npm test` runs after every change, so it has to stay fast; reducing the simulations'
|
||||
game counts is not acceptable. `npm test` now runs every file except `test/sim.test.ts`, and
|
||||
`npm run test:sim` runs the bot simulations on their own, typechecked first.
|
||||
|
||||
**Measured with nothing else running, after the engine speed-up: `npm test` 958 of 958 in 1 min 45 s;
|
||||
`npm run test:sim` 35 of 35 in 6 min 58 s** — against 21 min 21 s for the two together before it.
|
||||
|
||||
### Where industries go was not the problem — there was nowhere, and the draw was going in circles
|
||||
|
||||
The next thing Jesse named after switching was where industries and enhancements go, then track for the
|
||||
run-around. Measured before touching either, 30 standard games, and it turned both round:
|
||||
|
||||
- **The bot places an industry every time it legally can.** It held an industry card at 1,441 draw
|
||||
decisions and a legal square existed at 24 of them (2%); it played the industry at all 24 and never
|
||||
discarded one that had somewhere to go. 0.70 industries stand on a board at game end.
|
||||
- **Why there was nowhere.** An industry is a plain east-west piece that must join existing track and
|
||||
may not sit on the Running Track. Asked of `check` square by square, the closest a held card got was
|
||||
NOT_CONNECTED 1,681 times, OUTSIDE_LIMITS 398, legal 28 — never locked out. Printed districts show why:
|
||||
the row beside the main fills with 45° curves and turnouts whose east-west ends face occupied squares.
|
||||
- **And track was not the lever either — its supply was.** At 1,334 decisions holding an industry with
|
||||
no site, a track lay was legal at only 54; a lay that would open a site existed at 22 and the bot
|
||||
already chose one at 17. The hand had no track in it.
|
||||
- **Because the draw was cycling.** The bot took 20.1 timetabled trains and 11.9 industries a game off
|
||||
the Departments and discarded 20.2 and 11.8: `takingRank` ranked a face-up train 2 whether or not the
|
||||
A/D cap would let it be played, and a face-up industry 1 whether or not it had a site (one did at 0.17
|
||||
of those takes). The same card was taken again 28.8 times a game, against 19.6 blind draws.
|
||||
|
||||
### The bot takes a face-up card only if it could play it
|
||||
|
||||
`takingRank` now ranks a face-up train card 0 when the A/D cap would hold it, and a face-up industry 0
|
||||
when it is locked out or has no legal site — both asked of the rules the play itself is checked by
|
||||
(`trainWouldOverfillTheOffice`, and `isLockedOut` with `canPlaceAt` in `industrySiteExists`). Nothing
|
||||
here is hidden information: the Departments are face up.
|
||||
|
||||
**+1.52 ± 0.10 revenue a game (t = 15.59)** over 1600 paired seeds, measured as the ablation
|
||||
`noPlayableTakes=1` against the new default: 880 seeds worse without it, 211 better. (The first
|
||||
400-seed read was +1.63 ± 0.19.) Cards played 15.8 → 29.0 a game and the district grows 14.2 → 24.2
|
||||
cards, because the draws it stopped wasting now bring in track: trains scheduled 1.74 → 2.30, freight
|
||||
1.51 → 2.54, passengers 1.89 → 2.94. **Collisions rose, 0.12 → 0.24**, with the extra trains — see
|
||||
TODO #106.
|
||||
|
||||
### Rejected: holding track the run-around scoring declined
|
||||
|
||||
Traced first: of ~16.4 track pieces a standard game lays, only ~3.3 came from `bestTrackLay`. The
|
||||
draw turn's "play what is in hand" fallback laid the other ~13 at the FIRST legal square — pieces
|
||||
`bestTrackLay` had just declined, including 4.0 turnouts and 4.3 straights a game on the main and 1.5
|
||||
turnouts a row off it. The candidate let the fallback lay track only when nothing else could bring the
|
||||
hand under the limit. **−0.83 ± 0.16 (t = −5.17)**, 172 worse against 90 better: the district fell
|
||||
24.1 → 11.7 cards and freight 2.41 → 1.77. `bestTrackLay` declines most pieces, so holding them
|
||||
starves the district — #59's finding, again, with a fuller hand to hold them in.
|
||||
|
||||
### Rejected: laying fallback track where it scores best instead of first
|
||||
|
||||
The other half of the same trace: keep laying the ~13 fallback pieces a game, but at `bestTrackLay`'s
|
||||
best-scoring square (bonus or not) rather than the first legal one. **+0.07 ± 0.13 (t = 0.60), inside
|
||||
the noise**; freight 2.41 → 2.56 but the district 24.1 → 21.0 cards. With both halves measured, the
|
||||
limit is `bestTrackLay`'s SCORING — it cannot tell a piece that opens an industry site or advances a
|
||||
run-around from one that fills a square — not which branch places the piece. Code removed.
|
||||
|
||||
### The bot stops running Second Sections by accident
|
||||
|
||||
TODO #106 traced under today's defaults: of 20 "no free A/D track" collisions in 60 standard games, no
|
||||
train had been held before any of them and only 8 of the destroyed were Extras. Six destroyed a train of
|
||||
the same number as a Second Section run within two Stages, and **all 26 Second Sections the bot ran were
|
||||
an accident**: when no car was on offer, the New Train phase fell back to `options[0]`, and `legalActions`
|
||||
lists `newTrain.secondSection` ahead of the Extra starts — so a waiting Extra became a doubled train due
|
||||
out, into an Office that never had an A/D track to spare. The fallback now takes a car, a pass or the
|
||||
Extra's start, and never a Second Section or a Red Flag merely because it was listed first.
|
||||
|
||||
**+0.32 ± 0.09 revenue a game (t = 3.64)** over 400 paired seeds, 30 better against 12 worse; collisions
|
||||
0.24 → 0.19. `noDeliberateNewTrain=1` is the ablation.
|
||||
|
||||
**Found on the way, for Jesse — a rules question, not changed.** Q9 defines the Second Section as a card
|
||||
played on a train due out and `content.ts` gives `SECOND_SECTION` one copy, but `buildDeck` never deals it
|
||||
and `check` asks for no card, so any player can run one for free on every train due out.
|
||||
|
||||
### Rejected: planning two switching turns ahead (TODO #105)
|
||||
|
||||
Built as `planTwoTurns`: keep the six best ends of a switching turn, remove the trains that highball in
|
||||
the Mainline Phase in between (on the Office square and made up — their cars leave with them), reset the
|
||||
Moves, plan the next turn from each, and choose by the position after departures plus 0.8 of what the
|
||||
next turn adds. **−0.28 ± 0.11 (t = −2.58)**; corrected to charge the expedite fault the gap cannot undo
|
||||
and a Stage for every train left away, **−0.14 ± 0.06 (t = −2.36)** — 400 paired seeds each. A second turn
|
||||
has little to find: only 49% of switching turns keep their train for the next, a further turn could have
|
||||
spotted just 0.7 of the 6.1 wanted cars a game that leave aboard departing trains, and trains left away
|
||||
cost Stages walking crews home. Dropped at Jesse's call; the measurements are in TODO #105.
|
||||
|
||||
### Rejected: starting an Extra the Office cannot take where its run never arrives
|
||||
|
||||
TODO #106, re-measured under today's defaults: 20 collisions in 60 standard games (−1.67 revenue a
|
||||
game), every one "no free A/D track", and 26 Extras forced out by a full hand of them. The bot also took
|
||||
the first legal start for EVERY Extra — the western Division Point, 158 of 158 — so the candidate chose,
|
||||
when the committed trains exceeded the A/D tracks, a start whose run never reaches the Office: an Extra
|
||||
runs away from its start (`resolveExtraStart`), and at the Interchange one way may miss the Office.
|
||||
**+0.10 ± 0.05 (t = 1.82), 395 of 400 seeds identical**: such a start was on offer at 1 of 25 over-cap
|
||||
starts — most Divisions have no Interchange, and a Division Point always runs through the Office. Code
|
||||
removed.
|
||||
|
||||
### Rejected: refusing to draw into a forced Extra
|
||||
|
||||
With the take rule in, the bot's worst seeds all lost to "no free A/D track". Traced: every one of 23
|
||||
train plays past the A/D cap in 40 games was an EXTRA, played because the hand held four of them and
|
||||
the only legal action was `card.play` — §6.2 makes a player over the limit reduce the hand, and an Extra
|
||||
may never be discarded (`keepReason`), so the cap in `choose` yields rather than leave nothing legal.
|
||||
The candidate declined to choose the draw option with a full hand of such cards when switching or the
|
||||
Freight Agent was on offer. **−0.03 ± 0.11 (t = −0.30), inside the noise**: collisions fell
|
||||
0.26 → 0.20, and development fell with them (cards played 29.0 → 25.6) — the Stage spent avoiding the
|
||||
draw was a Stage not spent building. Code removed; the trap itself is real and filed.
|
||||
|
||||
### Rejected: discarding the card least likely to become playable
|
||||
|
||||
When a discard was forced, the candidate picked WHICH card by a keep-value (next Office tier, then
|
||||
Enhancements, track, trains, and industries with no site or Modifiers with no industry last), weighted
|
||||
so no pile preference could overturn it. **−1.16 ± 0.12 (t = −9.27)**, 16 better against 119 worse,
|
||||
cards played 15.7 → 10.4. Not traced; the likeliest reason is that the weight overrode `bestDiscard`'s
|
||||
pile choice, which exists to avoid burying a face-up card the bot wants. Code removed.
|
||||
|
||||
### Flying Switch is searched, and cannot be measured
|
||||
|
||||
The planner now considers Flying Switch alongside Moves, set-outs and sorts — its partial copy of the
|
||||
game carries the hand and the Salvage Yard, which `spendCard` writes, and spending the card costs a
|
||||
tenth of a point so it is played only when it buys something. **Over 400 paired seeds it changed
|
||||
nothing, because the card is dealt 0 copies** (not in sheet 5; Jesse, 2026-08-26): across 30 standard
|
||||
games no Flying Switch was ever drawn. TODO #58's "never fires" is therefore a deck fact, not a bot
|
||||
one. On by default, so the planner uses the card the day it is dealt again.
|
||||
|
||||
### Rejected: letting the planned gain decide whether to switch at all
|
||||
|
||||
Whether a Stage goes to switching is decided by `usefulSwitching`, a yes/no reading of the district.
|
||||
The candidate replaced it with the planner's own answer — plan the turn on a fork before the option is
|
||||
chosen, and switch only if it gains at least a threshold. Paired over 400 seeds against the planner:
|
||||
|
||||
| threshold | revenue | seeds better / worse / identical |
|
||||
| --- | --- | --- |
|
||||
| 0.5 | **−0.33 ± 0.06 (t = −5.06)** | 15 / 70 / 315 |
|
||||
| 0.25 | −0.01 ± 0.05 (t = −0.21) | 21 / 22 / 357 |
|
||||
| 0.1 | +0.06 ± 0.03 (t = 1.79) | 20 / 13 / 367 |
|
||||
|
||||
At 0.5 it refuses turns that only COLLECT — a wanted car picked up scores +0.35 — and those pickups feed
|
||||
the deliveries after them. Below that it agrees with `usefulSwitching` almost everywhere, because that
|
||||
rule already says yes exactly when there is a car to deliver or lift, which is when a plan gains. The
|
||||
decision that would matter is switching against DRAWING or the Freight Agent, and the bot has no value
|
||||
for either to compare with; that is the next piece of work, not a threshold. It also cost a full
|
||||
search at nearly every Local Operations decision. Code removed.
|
||||
|
||||
### Rejected: discounting a car its industry cannot work yet
|
||||
|
||||
A spotted car scored half when its shipper had no load staged or its receiver's red box was full.
|
||||
**−0.29 ± 0.05 (t = −5.52)** over 400 seeds, 47 worse against 4 better; green boxes stocked fell
|
||||
15% → 11% of Cargo phases. The reason is the order the bot works in: the Freight Agent stocks a box
|
||||
only once a car is spotted to receive it (`canStockProductively`), so an industry is "not ready"
|
||||
precisely because nothing has been delivered — the discount withheld the delivery that makes it ready.
|
||||
Code removed.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.8 — 2026-09-10
|
||||
|
||||
**A played train does not come back. Gitea#23, ruled and closed.**
|
||||
|
||||
Jesse, on the question v0.8.0.7 filed rather than answered: *"Once you've played a regularly
|
||||
scheduled train and it's in the salvage deck, that train is already on the timetable. It does not
|
||||
make sense to put that back into a reshuffled home deck to get played again. By contrast, a regularly
|
||||
scheduled train that's in a discard pile could potentially get reused later, and so should have that
|
||||
capability. Extras run one time and then they're done — if they are in the Salvage deck, they should
|
||||
get shuffled back in so that they could get run again."*
|
||||
|
||||
**The test is WHERE the card is, not only what it is** — which is the part worth writing down, because
|
||||
it is exactly the rule a later tidy-up would "simplify" into filtering by card kind everywhere. The
|
||||
same train card is spent in the Salvage Yard and still runnable in a Department:
|
||||
|
||||
| card | where | on a reshuffle |
|
||||
| --- | --- | --- |
|
||||
| timetabled train | Salvage Yard — it was **played**, its number is on the timetable | stays out |
|
||||
| timetabled train | a Department — **discarded**, never played, slot still open | comes back |
|
||||
| Extra | anywhere | comes back; an Extra is one run, not a standing slot |
|
||||
| everything else | anywhere | comes back, as before |
|
||||
|
||||
### And the duplicate that started it
|
||||
|
||||
`trainScheduled` was pushing a synthetic `train-<number>` into the Salvage Yard **beside the real
|
||||
card `cardPlayed` had already put there** — measured: four scheduled trains left eight entries in a
|
||||
pile holding four cards. Nothing read that id. It inflated the pile's depth, displayed as "a card"
|
||||
because no such card exists, and would have been swept into the draw deck to be drawn as an id with
|
||||
nothing behind it. Removed, which retires the whole phantom-id class rather than papering over it —
|
||||
so v0.8.0.7's `cardName()` resolver for `train-<n>` is gone too, along with its test. Dead code kept
|
||||
for an id that can no longer exist is worse than no code.
|
||||
|
||||
### Games in progress
|
||||
|
||||
**Resume.** No predicate changed its answer — nothing that was legal became illegal, and a draw is a
|
||||
draw whatever is on top of the deck. What differs is the Salvage Yard's depth, which was
|
||||
double-counting, and what a reshuffle would recover. Reshuffles are effectively unreachable in
|
||||
ordinary play: eight games driven to 4000 moves across eight seeds produced zero.
|
||||
|
||||
Closes #23.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.7 — 2026-09-10
|
||||
|
||||
### The Salvage Yard was face up and had nothing to say
|
||||
|
||||
@@ -56,7 +56,8 @@ deliberately no longer names one: it went stale for six releases.
|
||||
Balance is *not* where it should be, and this file no longer quotes a figure for it. It used to say
|
||||
"the developer bot averages 7.0 Revenue against a target of 20", which stopped being true the moment
|
||||
the transit rule it names was defaulted to off — that rule was worth ~5.4 of the 7.0, for traffic
|
||||
nobody had to work. Measured at the current defaults the bot means about **zero**.
|
||||
nobody had to work. Measured at the current defaults the bot meant about **zero** until it began
|
||||
planning its switching turns (2026-09-14), which put it near **2.8**.
|
||||
|
||||
The three rates — passenger per coach, freight per load, train per transit — are **settings fixed when
|
||||
the game is dealt**, along with the opening hand and where an Extra may start, so the economy can be
|
||||
@@ -102,7 +103,8 @@ separate thing: it assembles the static SITE into `dist/`.)
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm test # node --test
|
||||
npm test # node --test, everything except the bot simulations — run after every change
|
||||
npm run test:sim # test/sim.test.ts, the bot simulations (~7 min) — run after a bot or balance change
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
|
||||
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
|
||||
6. **Rules** — #12 #80 #82 #83 #85
|
||||
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
|
||||
8. **The bot** — #41 #57 #59 #53 #54 #58 #55 #56 #60
|
||||
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
|
||||
9. **Code health and housekeeping** — #46 #45 #84 #87
|
||||
10. **Documentation and assets** — #15a #86 #88
|
||||
|
||||
@@ -100,20 +100,119 @@ Everything else in this file waits behind a release; this waits behind an aftern
|
||||
**Test runs WERE made across 0.7.4 through 0.7.9** (Jesse, 2026-09-07) and produced no change
|
||||
requests — the two bugs that did come out of them are Gitea#21 and #22, fixed in v0.7.9.1. So this
|
||||
section is not "nobody has touched it since 0.7.4"; it is the narrower and still-true claim that the
|
||||
specific paths below have not been exercised at a table. **More testing is planned at the end of the
|
||||
0.7.9 series, before 0.8.0 starts** — that is the moment to close these, not a separate errand.
|
||||
specific paths below have not been exercised at a table.
|
||||
|
||||
**The gate moved.** It was "before 0.8.0 starts"; 0.8.0 shipped anyway, through v0.8.0.8, so the
|
||||
session now runs against that build and covers what it added as well. See **Preparing the session**
|
||||
below — written 2026-09-10 because the measurement it rests on is the whole point: **three of the
|
||||
four things this section is named for do not happen by themselves.**
|
||||
|
||||
### Preparing the session
|
||||
|
||||
**MEASURED, 2026-09-10, across ten full competitive games driven to completion.** What a table will
|
||||
meet without trying, and what it will not:
|
||||
|
||||
| interruption | fires in | so |
|
||||
| --- | --- | --- |
|
||||
| Superintendent clearance (§8.1) | **9/10 games** | you will meet it; just play |
|
||||
| a train held at the Limits | 7/10 | ditto |
|
||||
| Extras started and queued | 10/10 | ditto |
|
||||
| collisions | 7/10 | ditto |
|
||||
| Red Flags set / spent | 7/10, 6/10 | ditto |
|
||||
| **the Yard Office offer** | **0/10** | must be set up |
|
||||
| **the Red Flag hold and its prompt** | **0/10** | must be set up |
|
||||
| **extended play (`dayExtended`)** | **0/10** | must be set up |
|
||||
|
||||
Those last three are exactly what #39 and #35 are NAMED for. They are not broken — they are
|
||||
conditional, and the conditions are these, read out of `advance.ts` rather than guessed:
|
||||
|
||||
- **Yard Office** (`advance.ts` ~1290) needs the destination district to contain a card carrying the
|
||||
`yardOffice` **enhancement**, AND an arriving train with **no coach** in its consist, AND a usable
|
||||
route. The bot never builds one, so **somebody has to build a Yard Office and then let a freight
|
||||
train arrive.**
|
||||
- **Red Flag hold** (`advance.ts` ~1232) needs the destination player to be **holding the Red Flags
|
||||
maneuver card**, AND an arrival that would genuinely collide — §8.3's own two ways: no free A/D
|
||||
track, or cars fouling the Running Track. So: **hold that card and let your A/D tracks fill.**
|
||||
- **Extended play** needs the timetable to RUN OUT, which a five-Day game does not do. Deal it with
|
||||
**`days: 1`** — that is exactly what the 2026-08-29 API verification did, and why it got there.
|
||||
|
||||
**What the session needs**
|
||||
|
||||
- **Two people, two browsers, two devices.** #35's remaining gap is specifically what a SECOND
|
||||
player sees while waiting on a first, and whether "waiting on Carol" still reads once Carol has
|
||||
closed her laptop. That cannot be tested alone, and it is the half that has never been done.
|
||||
- **Two games, not one.** A short `days: 1` game to reach the extension vote, and an ordinary game
|
||||
for everything else — with somebody deliberately building a Yard Office and holding Red Flags.
|
||||
- **#42a is separate and takes five minutes**, solitaire, one person: click every field on the setup
|
||||
screen and confirm the dealt game matches what was chosen.
|
||||
|
||||
**The caution this section exists because of.** #35's own Reference entry records that the
|
||||
2026-08-29 verification passed over the HTTP API — **which renders no dialog** — and that is exactly
|
||||
why the v0.7.9 bug survived: the vote sat underneath a modal results dialog whose only control was
|
||||
Close. What was proven was that the SERVER supports extended play, not that a player can reach it.
|
||||
Read that into every "verified on `phoenix.local`" line in this file, and into everything v0.8.0
|
||||
added, all of which is verified by test and simulation and none of it by eye.
|
||||
|
||||
**What v0.8.0 added to this list**, none of it played by a person for a whole game and none with a
|
||||
second human: the watchable board and its ordered steps, the speed control, the pile highlighting and
|
||||
the Home Office deck tile, "Your Move" being put away while catching up, the Day-end collision line,
|
||||
and the Salvage Yard naming its top card.
|
||||
|
||||
### The checklist
|
||||
|
||||
Grouped by what has to be set up, with the item each observation closes. Nothing here needs a
|
||||
developer present; what it needs is somebody writing down what they saw.
|
||||
|
||||
**Game A — `days: 1`, two humans, two browsers.** Reaches the extension vote in one Day.
|
||||
|
||||
- [ ] The vote appears **in front of both players**, not underneath the results dialog (#35 — this is
|
||||
the exact shape of the bug v0.7.9 fixed).
|
||||
- [ ] While one player has not voted, the other's turn chart says **who** it is waiting on (#35).
|
||||
- [ ] **Close the second laptop mid-vote.** Does the first player learn why nothing is happening, and
|
||||
does "waiting on Carol" still read once Carol is gone? (#35 — never tested.)
|
||||
- [ ] Reopen it. The history panel comes back **populated**, not empty, and the board is current
|
||||
(the v0.7.9.5 reconnect fix, never seen by a person).
|
||||
- [ ] Vote yes. The extra Day begins and the official result is **unchanged** from when the
|
||||
timetable ran out (#35).
|
||||
|
||||
**Game B — ordinary length, two humans, bots to fill.** Everything else.
|
||||
|
||||
- [ ] Somebody **builds a Yard Office** and lets a freight train (no coach) arrive at it. The offer
|
||||
interrupts the Mainline Phase and asks a question mid-thought — is it legible, and does it say
|
||||
which train? (#39)
|
||||
- [ ] Somebody **holds the Red Flags card** while their A/D tracks are full, so an arrival would
|
||||
collide. The hold is offered out of phase (#39).
|
||||
- [ ] A **loaded Extra** is made up and run (#39 — the third of its three).
|
||||
- [ ] Watch a bot take a whole turn: does the district follow it, does the lit pile catch the eye,
|
||||
does the caption say who and what? (v0.8.0)
|
||||
- [ ] Find the speed that suits you and say what it is — it becomes the committed default.
|
||||
- [ ] Let the board fall behind, then press **Skip**. Nothing is lost; the history has it all.
|
||||
- [ ] End a Day with a collision on it: the summary reads "N on Day D, N in all" and cannot
|
||||
contradict itself (v0.8.0.2).
|
||||
|
||||
**Solitaire, five minutes, alone.**
|
||||
|
||||
- [ ] Click through **every field** on the setup screen and confirm the dealt game matches what was
|
||||
chosen (#42a).
|
||||
|
||||
**Whatever else happens.** The two bugs that came out of the 0.7.4-0.7.9 runs were both things
|
||||
nobody set out to test. Write down anything that reads wrong, even where the rule underneath is
|
||||
right — most of this release's defects were legible-but-wrong rather than broken.
|
||||
|
||||
- [ ] **#39** — **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag
|
||||
hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end,
|
||||
packed, and running on `phoenix.local` — and nobody has met any of them at a board. **Two are
|
||||
interruptions that stop the Mainline Phase and put a question in front of somebody
|
||||
mid-thought**, which is exactly the kind of thing only play reveals. See **Reference · #39**.
|
||||
mid-thought**, which is exactly the kind of thing only play reveals. **Neither of those two
|
||||
happens by itself — 0/10 games. See Preparing the session above for what to set up.** See
|
||||
**Reference · #39**.
|
||||
|
||||
- [ ] **#35** — **Extended play has never been played at a real table.** It was verified over the HTTP
|
||||
API, which renders no dialog — and when a human first reached it in a browser it was unusable
|
||||
(fixed in v0.7.9). The multiplayer vote has still never been driven through two browsers: what a
|
||||
second player sees while waiting, and whether "waiting on Carol" reads once Carol has closed her
|
||||
laptop, are unanswered. See **Reference · #35**.
|
||||
laptop, are unanswered. **Needs a `days: 1` game — the timetable does not run out in five
|
||||
Days, so extended play fired in 0/10 measured games.** See **Reference · #35**.
|
||||
|
||||
- [ ] **#42a** — **Nobody has clicked through the solitaire setup screen's own fields** and confirmed
|
||||
the dealt game matches what was chosen. It took three attempts to become reachable at all —
|
||||
@@ -368,21 +467,37 @@ v0.7.9's collision-floor change (#61).
|
||||
The developer bot exists to measure the game, not to be a good opponent — so a bot weakness matters
|
||||
when it stops a measurement being trustworthy. **Read #57 before tuning any weights.**
|
||||
|
||||
**Since 2026-09-14 the bot plans its whole switching turn** (`sim/switch-planner.ts`, +2.89 revenue a
|
||||
game), **takes a face-up card only if it could play it** (+1.52), and **since 2026-09-15 lays track by
|
||||
what the district can do afterwards** (`bestValuedLay`, +0.12 over 6400 seeds, run-arounds 9/60 → 22/60). Jesse's goal for it is better decisions in simulated runs AND at a real table, with no
|
||||
non-player advantage — it reads the board, never the deck.
|
||||
|
||||
- [ ] **#104** — Weigh a switching turn against drawing and the Freight Agent. Letting the planned gain
|
||||
gate switching on its own measured nothing (0.1, 0.25) or worse (0.5): `usefulSwitching` already
|
||||
says yes exactly when a plan gains. What would matter is a VALUE for the other two options to
|
||||
compare against, which the bot does not have. See **Reference · #104**.
|
||||
|
||||
- [ ] **#106** — The Extra trap: a full hand of Extras the A/D cap is holding back cannot be discarded,
|
||||
so the next draw forces one into a full Office. All 23 train plays past the cap in 40 games were
|
||||
this. Avoiding the draw measured nothing (−0.03) because it stalled development. See
|
||||
**Reference · #106**.
|
||||
|
||||
- [ ] **#105** — Plan across more than one turn. Jesse is in favour, one turn first to see the impact —
|
||||
which is now measured. Deferred for a conversation, not declined. See **Reference · #105**.
|
||||
|
||||
- [ ] **#41** — The bot never plays Red Flags — zero in 200 games since Gitea#19, and that is deck
|
||||
luck rather than unwillingness. It takes the danger prompt unconditionally; what it never does
|
||||
is plant a flag ON PURPOSE to buy a Stage for switching, which needs it to know it wants time.
|
||||
See **Reference · #41**.
|
||||
|
||||
- [ ] **#57** — The bot's priorities are not the problem — measured across ten heuristic variations.
|
||||
**Read this before tuning weights**; it is the argument that the ceiling is elsewhere. See
|
||||
**Reference · #57**.
|
||||
**Read this before tuning weights**; it is the argument that the ceiling is elsewhere. **Part of
|
||||
"elsewhere" was choosing one Move at a time**: planning the whole switching turn was worth
|
||||
+2.89 (t = 15.8) in the 2026-09-14 bot-tuning round. See **Reference · #57**.
|
||||
|
||||
- [ ] **#59** — The run-around is out of reach of any bot, and the deck is why — measured five ways.
|
||||
See **Reference · #59**.
|
||||
|
||||
- [ ] **#53** — The bot does not know to bring an expedited train back to the station. See **Reference
|
||||
· #53**.
|
||||
|
||||
- [ ] **#54** — The bot cannot spot a car at a stub industry, and the cut-ordering rules made that
|
||||
visible. See **Reference · #54**.
|
||||
|
||||
@@ -1424,6 +1539,12 @@ measuring deck luck rather than reachability, and its comment now says so.
|
||||
|
||||
#### #57 — THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.
|
||||
|
||||
**2026-09-14 — confirmed, and one ceiling found.** Reordering priorities still moves nothing; what
|
||||
moved the bot was SEARCH. `sim/switch-planner.ts` plans the whole switching turn against a score of
|
||||
where it ends, and measured +2.89 ± 0.18 (t = 15.79) over 1600 paired seeds — freight loads and
|
||||
unloads 0.22 → 1.53, Cargo phases with a car spotted 5% → 18%. The "8% of Cargo phases" below was a
|
||||
fact about how the bot switched, not only about the deck. See `CHANGELOG.md`, 0.8.0.9.
|
||||
|
||||
**THE BOT'S PRIORITIES ARE NOT THE PROBLEM — measured.** Ten heuristic variations, each paired
|
||||
|
||||
over 400+ seeds. Every reordering of what the bot prefers came out inside the noise; the only
|
||||
@@ -1449,6 +1570,21 @@ prioritised better. What is left is the economy itself, which is a deck question
|
||||
|
||||
#### #59 — THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measu…
|
||||
|
||||
**2026-09-14 — part of "the deck is why" was the bot's own draw.** It was taking ~32 face-up trains and
|
||||
industries a game it could not play and discarding them again, so the hand rarely held track. Taking
|
||||
only playable cards (now the default) grew districts 14 → 24 cards and run-arounds 3/60 → 9/60. And
|
||||
~13 of the ~16 track pieces a game were being laid by the draw turn's "play what is in hand" fallback
|
||||
at the first legal square, not by `bestTrackLay`; holding them (−0.83) and placing them by
|
||||
`bestTrackLay`'s score (+0.07, noise) both failed, so the next limit is that SCORING — it cannot tell a
|
||||
piece that opens an industry site or advances a run-around from one that fills a square. The deck
|
||||
measurements below were taken before any of this and should be re-read with it in mind.
|
||||
|
||||
**2026-09-15 — the scoring, fixed.** `bestValuedLay` scores the layout a lay leaves (reachable industry
|
||||
sites, a closed run-around, ways off the main) instead of the piece: closed run-arounds in 22 of 60
|
||||
districts against 9, +0.118 ± 0.029 revenue (t = 4.14, 6400 seeds). The run-around is now reachable
|
||||
without changing the deck; what is left is a bot that can USE one, which needs more than one turn of
|
||||
planning (#105).
|
||||
|
||||
**THE RUN-AROUND IS OUT OF REACH OF ANY BOT, AND THE DECK IS WHY — measured, five ways.**
|
||||
|
||||
"Teach the bot to plan across turns" was tried properly and does not work. Every attempt is
|
||||
@@ -1490,6 +1626,8 @@ the end of the game, drawing the fault **26 times**. Not an engine bug — the m
|
||||
exactly as designed — but a clear next bot heuristic: prefer ending a switching turn with any
|
||||
expedited crew back on the Office square, at least once it has finished the work it went out for.
|
||||
|
||||
**CLOSED 2026-09-14** — see **Done · 53**.
|
||||
|
||||
#### #54 — THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rul…
|
||||
|
||||
**THE BOT CANNOT SPOT A CAR AT A STUB INDUSTRY, and the cut-ordering rules made that visible.**
|
||||
@@ -1511,6 +1649,12 @@ pass should not read the drop as a deck problem.
|
||||
|
||||
#### #58 — The bot cannot get a crew next to an industry, so Flying Switch never…
|
||||
|
||||
**2026-09-14 — the premise is now a deck fact.** Flying Switch is dealt **0 copies** (not in sheet 5;
|
||||
Jesse, 2026-08-26), so no bot can fire it: across 30 standard games none was ever drawn. The switching
|
||||
planner searches the card by default, so it will be used the day it is dealt again. The reachability
|
||||
sweep's exemption in `sim.test.ts` stays until then.
|
||||
|
||||
|
||||
**The bot cannot get a crew next to an industry, so Flying Switch never fires.** Industries are
|
||||
|
||||
now stub-only and the bot places 2.23 a game (was 3.84), in districts averaging under two rows
|
||||
@@ -1558,6 +1702,114 @@ of zero" over 400 games — but at 400 games the standard error is ±0.33, so a
|
||||
have looked like nothing. They are nearly free to re-run now and at least one may have been
|
||||
discarded wrongly.
|
||||
|
||||
#### #104 — WEIGH SWITCHING AGAINST THE OTHER TWO OPTIONS, not against a threshold.
|
||||
|
||||
Measured 2026-09-14, paired over 400 seeds against the planner: switching only when the planned gain
|
||||
clears a threshold scored −0.33 at 0.5 (t = −5.06), −0.01 at 0.25, +0.06 at 0.1 (t = 1.79). At 0.5 it
|
||||
refuses turns that only collect cars, which feed later deliveries; below that it agrees with
|
||||
`usefulSwitching`. The choice that is still made by a fixed ladder is WHICH of §6's three options a
|
||||
Stage goes to, and the planner can now put a number on one of them. The other two need numbers of
|
||||
their own — what a draw is worth given the hand and the Departments, what stocking a box is worth given
|
||||
the cars spotted — before the three can be compared. Also: planning at every Local Operations decision
|
||||
costs a full search each time, so any version of this has to stay cheap.
|
||||
|
||||
**2026-09-15 — measured the ceiling first: there is almost none.** At 907 real Local Operations choices
|
||||
(75 standard games, seeds outside the usual measurement range), every legal option was tried and the rest
|
||||
of the game played out by today's bot, 4 times each with the HIDDEN parts reshuffled — the Home Office
|
||||
deck order and future rolls — and the same reshuffles for every option, so the comparison is paired.
|
||||
Grouped by the rule that made the choice, the value of each alternative against it:
|
||||
|
||||
| the ladder chose | times | switch instead | draw instead | Freight Agent instead |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| draw — nothing urgent, develop | 494 | +0.08 ± 0.12 | — | +0.02 ± 0.04 |
|
||||
| Freight Agent — feed the pipeline | 147 | +0.07 ± 0.06 | −0.01 ± 0.04 | — |
|
||||
| switch — a train with work at the Office | 108 | — | −0.17 ± 0.10 | −0.25 ± 0.08 |
|
||||
| draw — an Office upgrade in hand | 100 | +0.21 ± 0.16 (6) | — | −0.08 ± 0.06 |
|
||||
| switch — walk the crew home | 47 | — | +0.22 ± 0.14 | −0.15 ± 0.08 |
|
||||
| draw — a train card in hand | 11 | — | — | −0.45 ± 0.24 |
|
||||
|
||||
No rule has an alternative that is significantly better; where the table leans, the ladder is usually
|
||||
the one that is right. So given how the bot plays each option once chosen, the choice itself is close to
|
||||
optimal, and value functions for draw and Freight Agent have little to find. The one lean worth a look if
|
||||
this is reopened is walking a stranded crew home (+0.22, t ≈ 1.6). The rollout tool is analysis only —
|
||||
the bot never sees a rollout.
|
||||
|
||||
#### #106 — THE EXTRA TRAP — why the cap on committed trains still lets an Office overfill.
|
||||
|
||||
**2026-09-15 — re-measured under today's defaults, and most of it is not the Extra trap.** 20 "no free
|
||||
A/D track" collisions in 60 standard games, −1.67 revenue a game. No train was held (§8.2 or clearance) in
|
||||
the Stage before any of them, and only 8 of the 20 trains destroyed were Extras. **Six destroyed a train
|
||||
of the same number as a Second Section run within the previous two Stages — and all 26 Second Sections
|
||||
the bot ran in those games were an accident:** the New Train phase's "no car on offer" fallback takes
|
||||
`options[0]`, and `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, so whenever an
|
||||
Extra was waiting to start the bot doubled the train due out instead. The Office never had an A/D track
|
||||
to spare for one.
|
||||
|
||||
**A RULES QUESTION FOR JESSE, found on the way — not a bot matter.** Q9 (`implications.md`) defines the
|
||||
Second Section as a CARD "played on a train that is due out", and `content.ts` defines `SECOND_SECTION`
|
||||
with 1 copy — but `buildDeck` never deals it, and `check`'s `newTrain.secondSection` asks for no card in
|
||||
hand. So any player may run a Second Section for free on every train due out. Either the card should be
|
||||
dealt and required, or the free action is the intended rule and Q9's wording is stale.
|
||||
|
||||
Also measured and removed: starting an over-cap Extra where its run never reaches the Office (+0.10,
|
||||
t = 1.82) — such a start was on offer at 1 of 25 over-cap starts.
|
||||
|
||||
**The accident is fixed** (2026-09-15, default): the New Train fallback takes a car, a pass or the
|
||||
Extra's start, never `options[0]` — +0.32 ± 0.09 (t = 3.64), collisions 0.24 → 0.19. **Still open under
|
||||
this item:** the forced Extra itself (a full, undiscardable hand of Extras), and the Second Section card
|
||||
question above.
|
||||
|
||||
`choose` removes train-card plays from the options when `trainWouldOverfillTheOffice`, but yields if
|
||||
that would leave nothing legal. It does leave nothing legal in one ordinary position: the hand is over
|
||||
the limit (§6.2 requires reducing it) and every card in it is an Extra, which `keepReason` forbids
|
||||
discarding. Measured 2026-09-14 after the face-up take rule: 23 of 196 train plays in 40 games were past
|
||||
the cap, every one "play what is in hand" with four Extras held, and the worst seeds each lost 3-4
|
||||
collisions to it. Declining the draw option in that position measured −0.03 ± 0.11 — collisions fell
|
||||
0.26 → 0.20 but cards played fell 29.0 → 25.6. Better answers to try: play the Extra at the least
|
||||
dangerous moment rather than the first, count WHEN each committed train is due at the Office instead of
|
||||
how many there are, or keep the hand from filling with Extras in the first place.
|
||||
|
||||
#### #105 — PLAN ACROSS TURNS — the evidence so far, for the conversation.
|
||||
|
||||
For: one-turn planning already reaches most switching work (Cargo phases with a car spotted 5% → 18%),
|
||||
and what it cannot do is exactly what spans a Stage — leave a car on a spur for the next crew, or start
|
||||
a run-around and finish it later. The planner already scores staged wanted cars (+0.2), which is a
|
||||
first, crude step in that direction.
|
||||
|
||||
Against, for now: everything between two switching turns is not the player's — a Mainline Phase, trains
|
||||
arriving, a Load/Unload phase — so a second turn cannot be searched the way the first is without either
|
||||
simulating those phases (arrivals are on the public timetable, but cars on arriving trains are not
|
||||
known) or scoring the position between turns more cleverly. And search cost is already what sets the
|
||||
test suite's running time. Cheapest next step if taken up: a better score for "what the next turn can
|
||||
still reach", not a deeper search.
|
||||
|
||||
**2026-09-15 — the run-around measurement that bears on this.** A candidate that lays track by what the
|
||||
district can do afterwards (`valueLays`) more than doubled closed run-arounds, 9/60 → 22/60, yet moved
|
||||
revenue only +0.14 ± 0.06 (t = 2.58, 1600 seeds). Traced over 40 games: the one-turn planner DOES use
|
||||
the loop — 63 of 131 plans in a district with one end a move on it, 35 run it both ways — but its planned
|
||||
gain per turn is the same with a run-around as without (0.28 against 0.27). A run-around is for putting a
|
||||
train's cars in a different order, which pays off in the turns after; a planner that looks one turn
|
||||
ahead has no way to value it.
|
||||
|
||||
**2026-09-15 — two-turn planning, built and measured: it does not pay.** `planTwoTurns` kept the six best
|
||||
ends of a switching turn, removed the trains that would highball in the Mainline Phase in between (on the
|
||||
Office square and made up — their cars leave with them), reset the Moves, planned the next turn from each,
|
||||
and chose by the position after departures plus 0.8 of what the next turn adds. Nothing hidden is read.
|
||||
|
||||
| version | revenue, 400 paired seeds | what went wrong |
|
||||
| --- | --- | --- |
|
||||
| first | −0.28 ± 0.11 (t = −2.58) | an expedited train left away drew 31 faults in one game: the fault is charged in the gap, which the discounted next turn "recovered"; trains left away 5.3 a game against 3.1 |
|
||||
| with the gap fault charged in full and 0.5 a Stage per train left away | −0.14 ± 0.06 (t = −2.36) | trains still left away 4.8 a game; the "crew must get back to the Office" choice 4.2 a game against 2.7 |
|
||||
|
||||
Why a second turn has so little to find, measured over 30 standard games:
|
||||
- only **49%** of switching turns have the same train in the district at the next Local Operations choice;
|
||||
- **6.1 wanted cars a game** do leave aboard departing trains — but a further switching turn from those
|
||||
exact positions could have spotted only **0.7** of them: most were never deliverable;
|
||||
- the one-turn planner already gains no more with a run-around than without (0.28 against 0.27).
|
||||
And what a second turn COSTS is a Stage: trains left away have to be walked home, and those choices come
|
||||
out of drawing and the Freight Agent (cards played 28.8 → 28.3). A multi-turn bot would have to weigh
|
||||
switching against the other two options — which is #104, not a deeper search.
|
||||
|
||||
### Code health and housekeeping
|
||||
|
||||
#### #46 — tsc --noUnusedLocals finds 29 unused declarations across 14 files, and…
|
||||
@@ -1801,6 +2053,15 @@ where it belongs, and it is still open.
|
||||
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
|
||||
each group.
|
||||
|
||||
### Closed in the 2026-09-14 bot-tuning round (unreleased)
|
||||
|
||||
53. ~~**The bot did not know to bring an expedited train back to the station.**~~ — done 2026-09-14,
|
||||
not by a heuristic of its own but as a consequence of planning the switching turn: the planner's
|
||||
score charges an expedited train left away from the Office a full Revenue point, which is what Q3
|
||||
charges. Over 400 paired seeds, 19 games drew expedite faults under the rule ladder and **none**
|
||||
under the planner, worth +1.07 a game (t = 3.83) — the two worst cases had drawn 45 and 42 faults
|
||||
in a single game. See `CHANGELOG.md`, 0.8.0.9.
|
||||
|
||||
### Shipped through v0.7.9.8, from the queue
|
||||
|
||||
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.0.7",
|
||||
"version": "0.8.0.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
@@ -10,7 +10,9 @@
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"pretest": "tsc --noEmit && node scripts/build-web.ts",
|
||||
"test": "node --test test/*.test.ts test/**/*.test.ts",
|
||||
"test": "node --test $(ls test/*.test.ts test/**/*.test.ts | grep -v '^test/sim.test.ts$')",
|
||||
"pretest:sim": "tsc --noEmit",
|
||||
"test:sim": "node --test test/sim.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",
|
||||
|
||||
+33
-1
@@ -94,6 +94,18 @@ function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number {
|
||||
|
||||
const step = (d: Direction): number => (d === 'east' ? 1 : -1);
|
||||
|
||||
/** The Division node a train is standing on — its Office, its Mainline card, or its Division Point. */
|
||||
function nodeIndexOfTray(s: GameState, tray: CrewTray): number | null {
|
||||
const at = tray.position;
|
||||
if (at.at === 'grid') return nodeIndexOfOffice(s, at.seat);
|
||||
if (at.at === 'mainline') return at.index;
|
||||
if (at.at === 'divisionPoint') {
|
||||
const side = at.side;
|
||||
return s.division.nodes.findIndex((n) => n.kind === 'divisionPoint' && n.side === side);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// advance
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -522,8 +534,11 @@ type MoveOutcome = 'moved' | 'held' | 'needsClearance';
|
||||
*
|
||||
* This is reachable purely through switching. A train arrives made up, and only comes apart because
|
||||
* the player took cars onto the nose or picked up a cut in a run-around.
|
||||
*
|
||||
* Exported for the switching planner (`sim/switch-planner.ts`), which has to know whether a plan
|
||||
* leaves a train unable to run — and must ask this rule rather than keep a copy of it.
|
||||
*/
|
||||
function badlyMadeUp(tray: CrewTray): string | null {
|
||||
export function badlyMadeUp(tray: CrewTray): string | null {
|
||||
const n = tray.consist.length;
|
||||
if (n === 0) return null;
|
||||
const pulling = tray.engineAt === 0;
|
||||
@@ -1064,10 +1079,27 @@ function evaluateClearance(
|
||||
* constrained. Each Office upgrade to a Control Point splits one in two and buys capacity.
|
||||
*/
|
||||
const subdivision = subdivisions(s).find((group) => group.includes(targetIndex)) ?? [targetIndex];
|
||||
/**
|
||||
* ONLY WHAT IS AHEAD (Gitea#26). §8.1 asks about a train the considered train would FOLLOW, and one
|
||||
* moving TOWARDS it — both of which are ahead of it. A Subdivision runs the length of every Whistle
|
||||
* Post between two Control Points, so it can hold a train BEHIND the one departing: in the reported
|
||||
* game X15 highballed west from an Office while X18, also westbound, was still crossing the card to its
|
||||
* east. Counting X18 put a meaningless ruling to the Superintendent; holding X15 kept the Whistle Post's
|
||||
* one A/D track full, and X18 arrived into it and was destroyed. A train behind and moving away is no
|
||||
* threat at all.
|
||||
*
|
||||
* "Behind" is strictly behind the card the departing train stands on. A train on that same card is still
|
||||
* counted, exactly as before: which of two trains sharing a card is in front is `entryConflict`'s region
|
||||
* question, and this is not the place to answer it.
|
||||
*/
|
||||
const from = nodeIndexOfTray(s, tray);
|
||||
const behind = (onCard: number): boolean =>
|
||||
from !== null && from >= 0 && (tray.direction === 'east' ? onCard < from : onCard > from);
|
||||
const occupants: { tray: TrayId; onCard: number }[] = [];
|
||||
for (const i of subdivision) {
|
||||
const n = s.division.nodes[i];
|
||||
if (!n || n.kind !== 'mainline') continue;
|
||||
if (behind(i)) continue;
|
||||
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
|
||||
}
|
||||
|
||||
|
||||
+109
-13
@@ -1508,17 +1508,48 @@ export function selectDestination(
|
||||
return chosen ?? atTo[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* ROUTES, WALKED ONCE PER POSITION.
|
||||
*
|
||||
* A route walk (`reachableDestinations`) was a third of all simulation time, and most of it was the
|
||||
* same walk repeated: `legal.ts` walks a tray's routes to list its moves, then `check` walks them again
|
||||
* for every one of those moves, and `applyIntent` walks the chosen one a third time in `execute`.
|
||||
* Profiled 2026-09-14 with inlining off: `reachableDestinations` 34% inclusive, garbage collection 34%.
|
||||
*
|
||||
* So while one position is being examined — a legal-action listing, or the check and execute of one
|
||||
* intent — a walk is kept and reused. Both scopes read the state and never write it, the key names
|
||||
* everything the walk depends on besides that state, and the cache is keyed to the state OBJECT and
|
||||
* cleared when the scope ends, so a hit returns exactly what a fresh walk would have. Nothing may
|
||||
* mutate a returned route; nothing does.
|
||||
*/
|
||||
let routeCache: { state: GameState; routes: Map<string, MoveDestination[]> } | null = null;
|
||||
|
||||
export function withRouteCache<T>(s: GameState, fn: () => T): T {
|
||||
if (routeCache) return fn();
|
||||
routeCache = { state: s, routes: new Map() };
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
routeCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
function destinationsFor(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
trayId: TrayId,
|
||||
from: GridCoord,
|
||||
reverse: boolean,
|
||||
) {
|
||||
): MoveDestination[] {
|
||||
const cache = routeCache?.state === s ? routeCache.routes : null;
|
||||
const key = cache ? `${player}|${trayId}|${from.row},${from.col}|${reverse ? 1 : 0}` : '';
|
||||
const hit = cache?.get(key);
|
||||
if (hit) return hit;
|
||||
|
||||
const tray = s.trays.get(trayId)!;
|
||||
const facing = facingPort(s, trayId);
|
||||
const exit: Port = reverse ? reversePort(s, player, from, facing) : facing;
|
||||
return reachableDestinations(
|
||||
const routes = reachableDestinations(
|
||||
{
|
||||
area: areaOf(s, player),
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
@@ -1528,6 +1559,8 @@ function destinationsFor(
|
||||
from,
|
||||
exit,
|
||||
);
|
||||
cache?.set(key, routes);
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1856,7 +1889,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
? REALIGNMENTS.find((r) => r.from === node.card)?.to
|
||||
: undefined;
|
||||
return [
|
||||
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
|
||||
{
|
||||
type: 'mainlineModified',
|
||||
player,
|
||||
cardId: i.cardId,
|
||||
node: i.node,
|
||||
key,
|
||||
...(node?.kind === 'mainline' ? { from: node.card } : {}),
|
||||
...(became ? { became } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2242,7 +2283,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
// Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards
|
||||
// turned face up as the Departments, the rest face down as the Home Office deck. The
|
||||
// Departments start one deep again, exactly as at setup.
|
||||
s.decks.salvageYard = [];
|
||||
// The spent trains stay where they are; everything else in the Yard has just been swept up.
|
||||
s.decks.salvageYard = s.decks.salvageYard.filter((id) => isSpentTimetabledTrain(s, id));
|
||||
s.decks.departments = [[], [], []];
|
||||
const order = [...e.order];
|
||||
for (const pile of s.decks.departments) {
|
||||
@@ -2473,7 +2515,15 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'trainScheduled':
|
||||
s.timetable[e.slot] = e.trainNumber;
|
||||
s.rngState = e.rngState;
|
||||
s.decks.salvageYard.push(`train-${e.trainNumber}`);
|
||||
/**
|
||||
* THE CARD IS ALREADY IN THE SALVAGE YARD — `cardPlayed` put it there, by its real id.
|
||||
*
|
||||
* This used to push a second, SYNTHETIC `train-<number>` beside it, so scheduling four trains
|
||||
* left eight entries in a pile holding four cards. Nothing ever read that id: it inflated the
|
||||
* pile's depth, it displayed as "a card" because no such card exists, and
|
||||
* `reshuffleIfDepleted` would have swept it into the draw deck to be drawn as an id with
|
||||
* nothing behind it. Removed 2026-09-10 (Gitea#23).
|
||||
*/
|
||||
break;
|
||||
|
||||
case 'carPlacedOnTrain': {
|
||||
@@ -2693,7 +2743,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
* index is out of range, which `check` reports rather than silently defaulting — a wrong
|
||||
* orientation is a different card, not a detail.
|
||||
*/
|
||||
function protoCard(
|
||||
/** Exported for the same reason as `extendLimitsIfNeeded`: the bot builds the card a lay would place exactly as the reducer does. */
|
||||
export function protoCard(
|
||||
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
@@ -2930,9 +2981,34 @@ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
* that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile.
|
||||
* Cards played onto the board are NOT recovered: they are on the table, which is where they belong.
|
||||
*/
|
||||
/**
|
||||
* §6.2, AND THE RULING THAT SETTLES IT — Jesse, 2026-09-10 (Gitea#23).
|
||||
*
|
||||
* "Once you've played a regularly scheduled train and it's in the salvage deck, that train is
|
||||
* already on the timetable. It does not make sense to put that back into a reshuffled home deck to
|
||||
* get played again. By contrast, a regularly scheduled train that's in a discard pile could
|
||||
* potentially get reused later, and so should have that capability. Extras run one time and then
|
||||
* they're done — if they are in the Salvage deck, they should get shuffled back in so that they
|
||||
* could get run again."
|
||||
*
|
||||
* So the test is WHERE the card is, not only what it is. A timetabled train in the SALVAGE YARD was
|
||||
* played: its number is on the timetable and cannot be scheduled twice, so the card is spent and
|
||||
* stays out. The same card sitting in a DEPARTMENT was discarded, never played, and its slot is
|
||||
* still open — so it comes back with everything else. An Extra is a single run rather than a
|
||||
* standing slot, so a played one is free to be run again.
|
||||
*/
|
||||
function isSpentTimetabledTrain(s: GameState, id: CardId): boolean {
|
||||
return s.cards.get(id)?.kind.kind === 'timetabledTrain';
|
||||
}
|
||||
|
||||
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
|
||||
if (s.decks.homeOffice.length > taking) return null;
|
||||
const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()];
|
||||
const collected = [
|
||||
// The Salvage Yard, less the trains whose slots are already filled — see above.
|
||||
...s.decks.salvageYard.filter((id) => !isSpentTimetabledTrain(s, id)),
|
||||
// Every Department in full: a discarded train was never played, so it is still runnable.
|
||||
...s.decks.departments.flat(),
|
||||
];
|
||||
if (collected.length === 0) return null;
|
||||
const rng = createRng(s.rngState);
|
||||
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
|
||||
@@ -3059,7 +3135,8 @@ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKin
|
||||
* §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 {
|
||||
/** Exported so the bot can score a lay on a copy of the district by the engine's own rule, not a copy of it. */
|
||||
export function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||
if (placed.row !== area.runningRow) return;
|
||||
|
||||
if (placed.col <= area.limitsWest.col) {
|
||||
@@ -3253,16 +3330,35 @@ function limitsCard(): TrackCard {
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
||||
const code = check(s, player, i);
|
||||
if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` };
|
||||
/**
|
||||
* THE FIRST HALF OF `applyIntent`: decide, without changing anything.
|
||||
*
|
||||
* `check` and `execute` read the same unchanged position, so its routes are walked once between them
|
||||
* (`withRouteCache`). Never writes `s`. Split out for a caller that decides many intents against ONE
|
||||
* position and applies each to a COPY of it — the switching planner — which can then share that
|
||||
* position's routes across every candidate instead of re-walking them on each copy.
|
||||
*/
|
||||
export function prepareIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
||||
const prepared = withRouteCache(s, (): { code: RejectionCode } | { events: GameEvent[] } => {
|
||||
const code = check(s, player, i);
|
||||
return code ? { code } : { events: execute(s, player, i) };
|
||||
});
|
||||
if ('code' in prepared) return { ok: false, code: prepared.code, message: `${i.type} rejected: ${prepared.code}` };
|
||||
return { ok: true, events: prepared.events };
|
||||
}
|
||||
|
||||
const events = execute(s, player, i);
|
||||
/** THE SECOND HALF: fold events `prepareIntent` produced into a state equal to the one it read. */
|
||||
export function commitEvents(s: GameState, events: readonly GameEvent[]): void {
|
||||
for (const e of events) reduce(s, e);
|
||||
// Gitea#16 — the intent half of the fold; `advance` does the phase driver's half. See `tally.ts`
|
||||
// for why it cannot simply live inside `reduce`.
|
||||
for (const e of events) tallyEvent(s, e);
|
||||
return { ok: true, events };
|
||||
}
|
||||
|
||||
export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
|
||||
const r = prepareIntent(s, player, i);
|
||||
if (r.ok) commitEvents(s, r.events);
|
||||
return r;
|
||||
}
|
||||
|
||||
export { isOperationalRail, destinationsFor };
|
||||
|
||||
@@ -87,7 +87,13 @@ export type GameEvent =
|
||||
| { type: 'deckReshuffled'; order: CardId[]; rngState: number }
|
||||
/** `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: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
||||
/**
|
||||
* `from` is the card's kind BEFORE the change, carried so the log can say what was realigned
|
||||
* rather than only what it turned into (playtest, 2026-09-15: "it should state that the mainline
|
||||
* card 3 curves was converted to plains"). Events are derived by replaying a save, never stored,
|
||||
* so widening one strands nothing on disk.
|
||||
*/
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; from?: string; became?: string }
|
||||
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
|
||||
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
|
||||
|
||||
+76
-58
@@ -14,7 +14,7 @@
|
||||
|
||||
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
||||
import { enhancementRule, mainlineProfile } from './content.ts';
|
||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||
import { check, areaOf, destinationsFor, withRouteCache } from './apply.ts';
|
||||
import type { Intent } from './intents.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||
import { coordKey, seatOf } from './state.ts';
|
||||
@@ -53,7 +53,80 @@ const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 't
|
||||
|
||||
/** Every intent `player` may legally submit right now. */
|
||||
export function legalActions(s: GameState, player: PlayerIndex): Intent[] {
|
||||
return candidates(s, player).filter((i) => check(s, player, i) === null);
|
||||
// One position, examined many times over: its routes are walked once (`withRouteCache`).
|
||||
return withRouteCache(s, () => candidates(s, player).filter((i) => check(s, player, i) === null));
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.1 — the switching half of the Local Operations candidates, in the order `legalActions` offers
|
||||
* them. Split out so the switching planner (`sim/switch-planner.ts`) can ask for just these without
|
||||
* `check` running over every draw and Freight Agent candidate at each of the thousands of positions it
|
||||
* tries — that was about a quarter of all planning time. Still no rules here: `check` decides.
|
||||
*/
|
||||
function switchCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
const dests = destinationsFor(s, player, trayId, from, reverse);
|
||||
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
|
||||
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
|
||||
// tell apart, `to` already does that.
|
||||
const byCoord = new Map<string, MoveDestination[]>();
|
||||
for (const d of dests) {
|
||||
const k = coordKey(d.coord);
|
||||
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
|
||||
}
|
||||
for (const group of byCoord.values()) {
|
||||
for (const d of group) {
|
||||
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
// Off the nose as well as the tail — the only way to get cars back off the front of a train
|
||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||
}
|
||||
// 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.seat !== seatOf(s, 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' });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The switching intents `player` may legally submit right now — exactly `legalActions`' switching subset. */
|
||||
export function legalSwitchingActions(s: GameState, player: PlayerIndex): Intent[] {
|
||||
return withRouteCache(s, () => switchCandidates(s, player).filter((i) => check(s, player, i) === null));
|
||||
}
|
||||
|
||||
export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
@@ -138,62 +211,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const area = areaOf(s, player);
|
||||
|
||||
// -- switch (§6.1)
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
const dests = destinationsFor(s, player, trayId, from, reverse);
|
||||
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
|
||||
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
|
||||
// tell apart, `to` already does that.
|
||||
const byCoord = new Map<string, MoveDestination[]>();
|
||||
for (const d of dests) {
|
||||
const k = coordKey(d.coord);
|
||||
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
|
||||
}
|
||||
for (const group of byCoord.values()) {
|
||||
for (const d of group) {
|
||||
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
// Off the nose as well as the tail — the only way to get cars back off the front of a train
|
||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||
}
|
||||
// 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.seat !== seatOf(s, 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' });
|
||||
out.push(...switchCandidates(s, player));
|
||||
|
||||
// -- draw (§6.2)
|
||||
out.push({ type: 'draw.fromHomeOffice' });
|
||||
|
||||
+34
-13
@@ -377,7 +377,7 @@ export function reachableDestinations(
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
): MoveDestination[] {
|
||||
return exploreMoves(ctx, start, initialExit).destinations;
|
||||
return exploreMoves(ctx, start, initialExit, false).destinations;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -434,12 +434,19 @@ export function exploreMoves(
|
||||
ctx: MoveContext,
|
||||
start: GridCoord,
|
||||
initialExit: Port,
|
||||
/**
|
||||
* False when only the destinations are wanted (`reachableDestinations`, every legality check): the
|
||||
* rejections are then not recorded at all. They never change a destination, and building them was
|
||||
* pure allocation on the hottest path in the engine.
|
||||
*/
|
||||
collectBlocks = true,
|
||||
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
|
||||
const { area, occupancy } = ctx;
|
||||
const results: MoveDestination[] = [];
|
||||
const blocked: MoveBlock[] = [];
|
||||
const noted = new Set<string>();
|
||||
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
|
||||
if (!collectBlocks) return;
|
||||
const k = coordKey(coord);
|
||||
if (noted.has(k)) return;
|
||||
noted.add(k);
|
||||
@@ -459,10 +466,25 @@ export function exploreMoves(
|
||||
couples: RollingStock[];
|
||||
/** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */
|
||||
origins: string[];
|
||||
/** Cards visited on THIS route, start included. A per-path set, not a global one — see the
|
||||
* module doc comment on `MAX_ENUMERATED_FRONTIER` for why a global one would forbid the very
|
||||
* routes this walk exists to find. */
|
||||
visited: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Has THIS route already used `to`? Per-path, not global — see the doc comment on
|
||||
* `MAX_ENUMERATED_FRONTIER` for why a global set would forbid the very routes this walk exists to
|
||||
* find.
|
||||
*
|
||||
* Read off the route's own `path` instead of a Set copied at every step, which was a large share of
|
||||
* the walk's garbage. It answers exactly as that Set did: the start square, then every square
|
||||
* enqueued along the route AFTER the first hop, including this node's own — the first hop's square
|
||||
* was never added, and `path[0]` is that square, so the scan begins at 1.
|
||||
*/
|
||||
const onRoute = (node: Frontier, to: GridCoord): boolean => {
|
||||
if (sameCoord(to, start)) return true;
|
||||
if (node.path.length > 0 && sameCoord(to, node.coord)) return true;
|
||||
for (let k = 1; k < node.path.length; k++) {
|
||||
if (sameCoord(node.path[k]!.coord, to)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// The very first hop is checked here because `start`'s card is not itself enqueued; every later
|
||||
@@ -493,13 +515,14 @@ export function exploreMoves(
|
||||
path: [],
|
||||
couples: ownCut,
|
||||
origins: ownCut.map(() => startKey),
|
||||
visited: new Set([startKey]),
|
||||
},
|
||||
];
|
||||
let enumerated = 1;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
// FIFO by index rather than `shift()`, which re-packs the array on every pop. Same order.
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const node = queue[head++]!;
|
||||
const card = cardAt(area, node.coord);
|
||||
if (!card) continue;
|
||||
|
||||
@@ -592,17 +615,15 @@ export function exploreMoves(
|
||||
// direction; it never says without repeating ground, but a train cannot occupy the same
|
||||
// track twice at once either). Per-path, not global — a DIFFERENT route may legitimately
|
||||
// pass through a card this one already used.
|
||||
const toKey = coordKey(to);
|
||||
if (node.visited.has(toKey)) continue;
|
||||
if (onRoute(node, to)) continue;
|
||||
if (enumerated >= MAX_ENUMERATED_FRONTIER) break;
|
||||
enumerated++;
|
||||
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
||||
const visited = new Set(node.visited);
|
||||
visited.add(toKey);
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins, visited });
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins });
|
||||
}
|
||||
}
|
||||
|
||||
if (!collectBlocks) return { destinations: results, blocked };
|
||||
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
|
||||
// from a bad angle first and a good one later.
|
||||
const reached = new Set(results.map((r) => coordKey(r.coord)));
|
||||
|
||||
@@ -656,6 +656,27 @@ export function startServer(opts: ServerOptions): void {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and
|
||||
* just save it as a JSON file in my Downloads folder").
|
||||
*
|
||||
* The administrative export at `/api/games/<id>/save` is gated on the admin secret, which a player
|
||||
* does not have and should not need: a save is the seed and the moves, and every one of those moves
|
||||
* is already on this player's screen. So the seat's own session token is the gate, exactly as it is
|
||||
* for `/api/stream` and `/api/intent` — it proves which game and which chair, and nothing else is
|
||||
* disclosed. The page turns the JSON into a file (`main.ts`'s `downloadSave`).
|
||||
*/
|
||||
if (url.pathname === '/api/save' && req.method === 'GET') {
|
||||
const ps = sessions.get(url.searchParams.get('token') ?? '');
|
||||
const session = ps ? games.get(ps.gameId) : undefined;
|
||||
if (!ps || !session) {
|
||||
sendJson(res, 404, { error: 'no such game' });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 200, { gameId: ps.gameId, save: session.exportSave() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/stream' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const ps = sessions.get(token);
|
||||
|
||||
+24
-2
@@ -42,6 +42,12 @@ export type DivisionRoster = {
|
||||
actor: number | null;
|
||||
/** The player this map is being drawn for. */
|
||||
viewer: number;
|
||||
/**
|
||||
* Division nodes to flash — a Mainline card that has just become a different card (Realignment).
|
||||
* Playtest, 2026-09-15: the log said a card had been converted and the map said nothing, so the one
|
||||
* play that changes the Division itself was invisible on the map of it.
|
||||
*/
|
||||
flash?: readonly number[];
|
||||
};
|
||||
|
||||
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
|
||||
@@ -134,6 +140,8 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
}[];
|
||||
cap: number | null;
|
||||
tip: string;
|
||||
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
|
||||
flash?: boolean;
|
||||
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
||||
seat: number | null;
|
||||
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
||||
@@ -168,7 +176,11 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
cells.push({ ...c, x: 0, y: 0 });
|
||||
};
|
||||
|
||||
// The node's own index, so a cell can be matched against `roster.flash`. `continue` below skips the
|
||||
// rest of the body, never this.
|
||||
let nodeIndex = -1;
|
||||
for (const n of nodes) {
|
||||
nodeIndex++;
|
||||
if (n.kind === 'office') {
|
||||
const cap = n.capacity;
|
||||
const ad = n.trains.flat();
|
||||
@@ -261,6 +273,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
push({
|
||||
kind: dp ? 'dp' : 'ml',
|
||||
label: n.label,
|
||||
...(roster?.flash?.includes(nodeIndex) ? { flash: true } : {}),
|
||||
sub: n.capacity === null
|
||||
? 'no limit — trains queue'
|
||||
: [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '),
|
||||
@@ -356,7 +369,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
|
||||
cells.forEach((c) => {
|
||||
const full = c.cap !== null && c.trains.length >= c.cap;
|
||||
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
|
||||
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}${c.flash ? ' bs-changed' : ''}" data-tip="${esc(c.tip)}">`;
|
||||
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
|
||||
/**
|
||||
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
|
||||
@@ -1248,6 +1261,12 @@ export const BOARD_CSS = `
|
||||
and leave at the other, and a seated layout must not be read as a ring. */
|
||||
.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round}
|
||||
.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em}
|
||||
/* A card that has just BECOME a different card (Realignment). The same amber the rest of the page
|
||||
spends on "it is happening here", pulsing only while the step that did it is on screen — so the
|
||||
change is seen on the map rather than only read in the log. */
|
||||
.bs-dcell.bs-changed rect{stroke:#e0a060;stroke-width:2.4;animation:bs-changed-pulse 1.1s ease-in-out infinite}
|
||||
@keyframes bs-changed-pulse{0%,100%{stroke-opacity:1}50%{stroke-opacity:.35}}
|
||||
@media (prefers-reduced-motion: reduce){.bs-dcell.bs-changed rect{animation:none}}
|
||||
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
|
||||
train is measured against, not something to look at instead of the train. */
|
||||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||
@@ -1329,7 +1348,10 @@ export const BOARD_CSS = `
|
||||
.bs-arrow{fill:#5f6b7a;font:10px 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}
|
||||
/* stroke:none (Gitea#24). A name takes the class \`bs-turn\` while it is that player's move, and \`.bs-turn\` is
|
||||
also the turn ARROW's rule, which strokes its shape 2.4px grey. Declared after it, this keeps that
|
||||
outline off the letters, which it smeared into an unreadable blur. */
|
||||
.bs-name{fill:#e6e9ee;stroke:none;font:600 11px ui-monospace,monospace}
|
||||
.bs-name.bs-you{fill:#5aa9e6}
|
||||
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
|
||||
seconds and which railroad is yours never does.
|
||||
|
||||
+238
-16
@@ -22,6 +22,9 @@
|
||||
import {
|
||||
applyIntent,
|
||||
areaOf,
|
||||
extendLimitsIfNeeded,
|
||||
isLockedOut,
|
||||
protoCard,
|
||||
canAdvanceLoad,
|
||||
destinationsFor,
|
||||
facilityCarTypes,
|
||||
@@ -29,14 +32,15 @@ import {
|
||||
ownCutFor,
|
||||
} from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.ts';
|
||||
import type { CarType, Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { CarType, FreightKind, Hand, TrackGeometry } 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 { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import { canPlaceAt, connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { actingPlayer, coordKey, turnOf } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
import { planSwitchingTurn, switchFingerprint } from './switch-planner.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
name: string;
|
||||
@@ -106,7 +110,8 @@ function because(reason: string, intent: Intent): Intent {
|
||||
* Every flag here turns something OFF. That is the opposite of how this started — the tweaks were
|
||||
* candidates to switch on — and it is the right shape once a candidate has been adopted: what a
|
||||
* measured heuristic needs afterwards is a way to ask "is this still worth it?" when the deck or
|
||||
* the rules move under it. Both of these were worth about +1.5 revenue together when adopted; if a
|
||||
* the rules move under it. The first two were worth about +1.5 revenue together when adopted, and
|
||||
* planning the switching turn (`noPlanSwitching`) +2.89 on its own; if a
|
||||
* rebalance changes the economy, that is a claim to re-test rather than to assume.
|
||||
*
|
||||
* The candidates that did NOT survive are gone rather than left switched off: preferring coaches at
|
||||
@@ -121,9 +126,78 @@ export type BotTweaks = {
|
||||
noTrainCap?: boolean;
|
||||
/** Draw whenever nothing is urgent, as the bot did before it preferred operating. */
|
||||
noOperateFirst?: boolean;
|
||||
|
||||
/**
|
||||
* Choose switching Moves one at a time from the rule ladder, as the bot did before it planned the
|
||||
* whole turn (`switch-planner.ts`). Measured at adoption, 2026-09-14: planning was worth
|
||||
* +2.89 ± 0.18 revenue a game (t = 15.79) over 1600 paired seeds, 733 better against 21 worse.
|
||||
*/
|
||||
noPlanSwitching?: boolean;
|
||||
/**
|
||||
* Take a face-up train or industry card whether or not it could be played, as the bot did before
|
||||
* 2026-09-14. It then took 20.1 trains and 11.9 industries a game off the Departments and discarded
|
||||
* 20.2 and 11.8, retaking the same card 28.8 times a game. Asking first measured +1.52 ± 0.10
|
||||
* (t = 15.59) over 1600 paired seeds — this ablation was worse on 880 of them and better on 211.
|
||||
*/
|
||||
noPlayableTakes?: boolean;
|
||||
/**
|
||||
* Choose where track goes by `bestTrackLay`'s piece rules and the fallback's first legal square, as the
|
||||
* bot did before 2026-09-15, instead of by what the district can DO afterwards (`bestValuedLay`).
|
||||
* Scoring the layout measured +0.118 ± 0.029 (t = 4.14) over 6400 paired seeds, and closed run-arounds
|
||||
* in 22 of 60 districts against 9.
|
||||
*/
|
||||
noValueLays?: boolean;
|
||||
/**
|
||||
* Let the New Train phase's fallback take `options[0]`, as the bot did before 2026-09-15. Because
|
||||
* `legalActions` lists `newTrain.secondSection` ahead of the Extra starts, all 26 Second Sections the
|
||||
* bot ran in 60 games were that accident, and 6 of the 20 collisions followed one. Taking a car, a pass
|
||||
* or the Extra's start instead measured +0.32 ± 0.09 (t = 3.64) over 400 paired seeds.
|
||||
*/
|
||||
noDeliberateNewTrain?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A switching turn planned once and then played a step per decision.
|
||||
*
|
||||
* Keyed by the tweaks object, because that is what one policy owns — the server shares a single
|
||||
* `developerBot` across every bot seat, so the plan inside it is kept per player. Each step is
|
||||
* submitted only while the position still matches the fingerprint the plan expected there; anything
|
||||
* else replans. A switching turn has no randomness, so in practice a plan is made once a turn.
|
||||
*/
|
||||
type ActivePlan = { steps: Intent[]; keys: string[]; next: number; summary: string };
|
||||
const activePlans = new WeakMap<BotTweaks, Map<PlayerIndex, ActivePlan>>();
|
||||
|
||||
function plannedSwitch(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null {
|
||||
let mine = activePlans.get(tweaks);
|
||||
if (!mine) activePlans.set(tweaks, (mine = new Map()));
|
||||
const here = switchFingerprint(s, player);
|
||||
let active = mine.get(player);
|
||||
if (!active || active.keys[active.next] !== here) {
|
||||
const p = planSwitchingTurn(s, player);
|
||||
active = {
|
||||
steps: p.steps,
|
||||
keys: p.keys,
|
||||
next: 0,
|
||||
summary:
|
||||
`position ${p.rootScore.toFixed(2)} → ${p.score.toFixed(2)} over ${p.expanded} positions` +
|
||||
(p.complete ? '' : ', search budget reached'),
|
||||
};
|
||||
mine.set(player, active);
|
||||
}
|
||||
if (active.next >= active.steps.length) {
|
||||
mine.delete(player);
|
||||
const end = options.find((i) => i.type === 'switch.end');
|
||||
return end ? because(`planned switching turn complete — ${active.summary}`, end) : null;
|
||||
}
|
||||
const want = JSON.stringify(active.steps[active.next]);
|
||||
const match = options.find((i) => JSON.stringify(i) === want);
|
||||
if (!match) {
|
||||
mine.delete(player);
|
||||
return null;
|
||||
}
|
||||
active.next++;
|
||||
return because(`step ${active.next} of ${active.steps.length} of a planned switching turn — ${active.summary}`, match);
|
||||
}
|
||||
|
||||
/** The bot as it plays today. Every knob off. */
|
||||
export const developerBot: BotPolicy = makeDeveloperBot({});
|
||||
|
||||
@@ -209,6 +283,13 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
||||
);
|
||||
if (match) return because(`the ${w.loaded ? 'loaded' : 'empty'} ${w.type} is what a facility is short of`, match);
|
||||
}
|
||||
if (!tweaks.noDeliberateNewTrain) {
|
||||
const move =
|
||||
pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar', 'newTrain.startExtra') ??
|
||||
options.find((i) => i.type !== 'newTrain.secondSection' && i.type !== 'maneuver.redFlags') ??
|
||||
options[0]!;
|
||||
return because('no car on offer is one our facilities need', move);
|
||||
}
|
||||
return because('no car on offer is one our facilities need', pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!);
|
||||
}
|
||||
|
||||
@@ -438,7 +519,7 @@ function topOfDepartment(s: GameState, slot: number): string | undefined {
|
||||
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
|
||||
* attack — which is why the choice belongs to the discarding player and not to the rules.
|
||||
*/
|
||||
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[], tweaks: BotTweaks): Intent | null {
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const i of options) {
|
||||
@@ -448,7 +529,7 @@ function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Inte
|
||||
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
|
||||
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
|
||||
const top = topOfDepartment(s, i.toSlot);
|
||||
const wanted = isWorthTaking(s, player, i.toSlot);
|
||||
const wanted = isWorthTaking(s, player, i.toSlot, tweaks);
|
||||
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
|
||||
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
|
||||
if (score > bestScore) {
|
||||
@@ -460,10 +541,39 @@ function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Inte
|
||||
}
|
||||
|
||||
/** A face-up card worth spending the draw on rather than gambling on the deck. */
|
||||
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean {
|
||||
return takingRank(s, player, slot) > 0;
|
||||
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): boolean {
|
||||
return takingRank(s, player, slot, tweaks) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could an industry of this kind be laid anywhere right now? Asked of the engine's own placement
|
||||
* rule (`canPlaceAt`) and lockout (`isLockedOut`) rather than a copy: an industry is plain east-west
|
||||
* track, so the only squares worth asking about are empty ones east or west of a card already down.
|
||||
*/
|
||||
function industrySiteExists(s: GameState, player: PlayerIndex, kind: FreightKind): boolean {
|
||||
const area = areaOf(s, player);
|
||||
if (isLockedOut(area, kind)) return false;
|
||||
const probe = {
|
||||
geometry: { kind: 'facility', facility: kind },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
} as unknown as TrackCard;
|
||||
for (const key of area.grid.keys()) {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
for (const dc of [1, -1]) {
|
||||
const at = { row: row!, col: col! + dc };
|
||||
if (at.row === area.runningRow || area.grid.has(coordKey(at))) continue;
|
||||
if (canPlaceAt(area, at, probe)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* HOW BADLY the face-up card is wanted. 0 means not worth the draw.
|
||||
*
|
||||
@@ -471,14 +581,18 @@ function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean
|
||||
* happened to be scanned first — a coin flip on the card that decides whether the district ever
|
||||
* becomes a Passenger Facility at all.
|
||||
*/
|
||||
function takingRank(s: GameState, player: PlayerIndex, slot: number): number {
|
||||
function takingRank(s: GameState, player: PlayerIndex, slot: number, tweaks: BotTweaks): number {
|
||||
const id = topOfDepartment(s, slot);
|
||||
if (!id) return 0;
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return 0;
|
||||
if (k.kind === 'office') return nextOfficeTier(areaOf(s, player).tier) === k.tier ? 3 : 0;
|
||||
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
|
||||
if (k.kind === 'freightFacility') return 1;
|
||||
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') {
|
||||
return !tweaks.noPlayableTakes && trainWouldOverfillTheOffice(s, player, tweaks) ? 0 : 2;
|
||||
}
|
||||
if (k.kind === 'freightFacility') {
|
||||
return !tweaks.noPlayableTakes && !industrySiteExists(s, player, k.facility) ? 0 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -741,6 +855,104 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT A DISTRICT'S TRACK IS WORTH FOR WHAT IT LETS HAPPEN NEXT — the default since 2026-09-15;
|
||||
* `noValueLays` turns it off.
|
||||
*
|
||||
* `bestTrackLay` scores the PIECE — its shape and where it sits — and cannot tell one that opens an
|
||||
* industry site or closes a run-around from one that merely fills a square. This scores the LAYOUT the
|
||||
* piece would leave, so a lay is worth the difference it makes. Every term is something the rules turn
|
||||
* into play: a site is somewhere a held industry can go; a run-around lets a crew pass its own cars
|
||||
* (§A.5); a way off the main is the only road to either; a Running Track straight is what Interlocking
|
||||
* needs. The weights are a starting point to measure, not a result.
|
||||
*/
|
||||
function layoutValue(area: OfficeArea): number {
|
||||
let v = 0;
|
||||
const reachable = reachableOffMain(area);
|
||||
v += Math.min(reachable.size, 12) * 0.2;
|
||||
|
||||
let ways = 0;
|
||||
let loops = 0;
|
||||
for (const side of SIDES) {
|
||||
for (const col of waysOff(area, side)) {
|
||||
ways++;
|
||||
if (descendFrom(area, col, side).rejoins.size > 0) loops++;
|
||||
}
|
||||
}
|
||||
v += [0, 1.5, 2, 2.5][Math.min(ways, 3)]!;
|
||||
if (loops > 0) v += 6 + Math.min(loops - 1, 1) * 2;
|
||||
|
||||
// Squares an industry could legally be laid on, joined to track a crew can reach.
|
||||
const probe = protoCard({ kind: 'freightFacility', facility: 'mineTipple' }, 0)!;
|
||||
const tried = new Set<string>();
|
||||
let sites = 0;
|
||||
for (const key of reachable) {
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
for (const dc of [1, -1]) {
|
||||
const at = { row: row!, col: col! + dc };
|
||||
const k = coordKey(at);
|
||||
if (tried.has(k) || area.grid.has(k) || at.row === area.runningRow) continue;
|
||||
tried.add(k);
|
||||
if (canPlaceAt(area, at, probe)) sites++;
|
||||
}
|
||||
}
|
||||
v += [0, 2, 3, 3.5][Math.min(sites, 3)]!;
|
||||
|
||||
let mainStraight = false;
|
||||
for (const [key, card] of area.grid) {
|
||||
if (Number(key.split(',')[0]) !== area.runningRow || card.geometry.kind !== 'track') continue;
|
||||
if (card.geometry.geometry === 'straight') mainStraight = true;
|
||||
// A turnout on the main whose leg joins nothing is a hole in the Running Track with no road behind it.
|
||||
if (card.geometry.geometry === 'turnout') {
|
||||
const col = Number(key.split(',')[1]);
|
||||
for (const side of SIDES) {
|
||||
if (!hasPort(card, legPort(side))) continue;
|
||||
const beyond = area.grid.get(`${area.runningRow + side},${col}`);
|
||||
if (!beyond || !joins(card, legPort(side), beyond)) v -= 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mainStraight) v += 1.5;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* The track lay worth most by `layoutValue`, placed on a copy of the district exactly as the reducer
|
||||
* places it (`protoCard`, `extendLimitsIfNeeded`). With `mustBuild`, only a lay that gains something is
|
||||
* offered, which is the slot `bestTrackLay` fills; without it, the best of whatever is legal, which is
|
||||
* the slot the "play what is in hand" fallback fills. Ties go to the square nearer the Office.
|
||||
*/
|
||||
function bestValuedLay(s: GameState, player: PlayerIndex, options: Intent[], mustBuild: boolean): Intent | null {
|
||||
const area = areaOf(s, player);
|
||||
const base = layoutValue(area);
|
||||
let best: Intent | null = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const i of options) {
|
||||
if (i.type !== 'card.play' || i.placement === undefined) continue;
|
||||
const kind = s.cards.get(i.cardId)?.kind;
|
||||
if (kind?.kind !== 'track') continue;
|
||||
const built = protoCard(kind, i.variant);
|
||||
if (!built) continue;
|
||||
const after: OfficeArea = {
|
||||
...area,
|
||||
grid: new Map(area.grid),
|
||||
limitsWest: { ...area.limitsWest },
|
||||
limitsEast: { ...area.limitsEast },
|
||||
};
|
||||
after.grid.set(coordKey(i.placement), built);
|
||||
extendLimitsIfNeeded(after, i.placement);
|
||||
const gain = layoutValue(after) - base;
|
||||
if (mustBuild && gain <= 0.1) continue;
|
||||
const distance = Math.abs(i.placement.row - area.officeCoord.row) * 2 + Math.abs(i.placement.col - area.officeCoord.col);
|
||||
const score = gain - distance * 0.01;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
|
||||
const area = areaOf(s, player);
|
||||
|
||||
@@ -1213,7 +1425,7 @@ function followThrough(
|
||||
if (!turnOf(s, player).drawnThisTurn) {
|
||||
const piles = options.filter(
|
||||
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
|
||||
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot),
|
||||
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot, tweaks),
|
||||
);
|
||||
// Best-ranked pile rather than the first that qualifies: an Office card and a train card
|
||||
// both "qualify", and only one of them stops the collisions.
|
||||
@@ -1221,7 +1433,7 @@ function followThrough(
|
||||
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
|
||||
// ranking function is for, and a coin flip on the card that decides whether the district
|
||||
// ever becomes a Passenger Facility is not worth keeping for its own sake.
|
||||
const useful = piles.sort((a, b) => takingRank(s, player, b.slot) - takingRank(s, player, a.slot))[0];
|
||||
const useful = piles.sort((a, b) => takingRank(s, player, b.slot, tweaks) - takingRank(s, player, a.slot, tweaks))[0];
|
||||
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 because('no face-up card is worth taking — gamble on the deck', blind);
|
||||
@@ -1282,7 +1494,7 @@ function followThrough(
|
||||
// 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);
|
||||
const track = !tweaks.noValueLays ? bestValuedLay(s, player, options, true) : 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. Freight facilities are scored —
|
||||
@@ -1307,17 +1519,27 @@ function followThrough(
|
||||
s.cards.get(i.cardId)?.kind.kind !== 'track',
|
||||
);
|
||||
if (placed) return because('develop the district with a card that goes on the board', placed);
|
||||
const play = options.find((i) => i.type === 'card.play');
|
||||
const play = !tweaks.noValueLays
|
||||
? options.find((i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind !== 'track') ??
|
||||
bestValuedLay(s, player, options, false)
|
||||
: options.find((i) => i.type === 'card.play');
|
||||
if (play) return because('play what is in hand', play);
|
||||
const end = options.find((i) => i.type === 'draw.end');
|
||||
if (end) return because('nothing in hand can be played anywhere legal', end);
|
||||
return because(
|
||||
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable',
|
||||
bestDiscard(s, player, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
|
||||
bestDiscard(s, player, options, tweaks) ?? pickFirst(options, 'card.discard') ?? options[0]!,
|
||||
);
|
||||
}
|
||||
|
||||
case 'switch': {
|
||||
// The planner decides the whole turn; the rules below are its fallback if the position is ever
|
||||
// not the one it planned for, and the whole of switching under `noPlanSwitching`.
|
||||
if (!tweaks.noPlanSwitching) {
|
||||
const planned = plannedSwitch(s, player, options, tweaks);
|
||||
if (planned) return planned;
|
||||
}
|
||||
|
||||
/**
|
||||
* A MOVE THAT DRAGS THE CREW'S OWN CUT BACK ON IS A WASTED MOVE, so take those off the table
|
||||
* before any heuristic gets to choose one.
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@
|
||||
* Run with:
|
||||
* node src/sim/compare.ts 1600 noTrainCap=1 — what the A/D cap is worth today
|
||||
* node src/sim/compare.ts 1600 noOperateFirst=1 — what operating before drawing is worth
|
||||
* node src/sim/compare.ts 1600 noPlanSwitching=1 — what planning the switching turn is worth
|
||||
*
|
||||
* The flags are ABLATIONS: they turn off heuristics the bot already plays, so a negative delta is
|
||||
* the heuristic earning its place. That is what a measured bot needs going forward — the question
|
||||
@@ -221,7 +222,7 @@ export function formatPaired(r: PairedResult): string {
|
||||
* against itself and report a confident zero, which is the most expensive way this tool could fail.
|
||||
*/
|
||||
export const NUMERIC_TWEAKS = new Set<string>([]);
|
||||
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst']);
|
||||
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst', 'noPlanSwitching', 'noPlayableTakes', 'noValueLays', 'noDeliberateNewTrain']);
|
||||
|
||||
export function parseTweaks(args: string[]): BotTweaks {
|
||||
const tweaks: Record<string, number | boolean> = {};
|
||||
|
||||
+18
-12
@@ -15,7 +15,7 @@
|
||||
* panel cannot drift from the rules.
|
||||
*/
|
||||
|
||||
import { MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
|
||||
import { MAINLINE_PROFILES, MAX_CONSIST, crewTrayCount } from '../engine/content.ts';
|
||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
||||
@@ -134,7 +134,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
case 'actorChanged':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`,
|
||||
text: e.player === null ? 'No player acts — automatic phase' : 'to act',
|
||||
};
|
||||
|
||||
// -- local operations
|
||||
@@ -211,13 +211,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
? `Played ${card(e.cardId)} onto ${at(e.placement)}`
|
||||
: `Played ${card(e.cardId)}`,
|
||||
};
|
||||
case 'mainlineModified':
|
||||
case 'mainlineModified': {
|
||||
// WHICH CARD, NOT JUST WHICH WAY IT WENT. "Mainline card 3 converted to plains" left the reader
|
||||
// to remember what card 3 had been (playtest, 2026-09-15), and the card it WAS is the half that
|
||||
// says what the play was worth.
|
||||
const kindName = (k: string | undefined): string =>
|
||||
MAINLINE_PROFILES.find((m) => m.kind === k)?.name ?? k ?? 'that card';
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.became
|
||||
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
|
||||
: `Played ${e.key} on Mainline card ${e.node}`,
|
||||
? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}`
|
||||
: `Played ${e.key} on Mainline card ${e.node}${e.from ? ` (${kindName(e.from)})` : ''}`,
|
||||
};
|
||||
}
|
||||
case 'redFlagSpent':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -225,8 +231,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
case 'redFlagRuled':
|
||||
return e.flag
|
||||
? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` }
|
||||
: { tone: 'plain', text: `Player ${e.player} waved the train through` };
|
||||
? { tone: 'plain', text: 'Flagged the approaching train' }
|
||||
: { tone: 'plain', text: 'Waved the train through' };
|
||||
case 'redFlagsSet':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -496,13 +502,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
|
||||
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
|
||||
case 'phaseEnded':
|
||||
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
|
||||
return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` };
|
||||
|
||||
// -- §3.3, extended play (Gitea#11)
|
||||
case 'extensionVoted':
|
||||
return e.agree
|
||||
? { tone: 'plain', text: `Player ${e.player} would play one more Day` }
|
||||
: { tone: 'plain', text: `Player ${e.player} called time — the game ends here` };
|
||||
? { tone: 'plain', text: 'Would play one more Day' }
|
||||
: { tone: 'plain', text: 'Called time — the game ends here' };
|
||||
case 'dayExtended':
|
||||
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
|
||||
case 'playConcluded':
|
||||
@@ -511,8 +517,8 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
// -- §11, the Yard Office (Gitea#5)
|
||||
case 'yardOfficeRuled':
|
||||
return e.take
|
||||
? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` }
|
||||
: { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` };
|
||||
? { tone: 'plain', text: `Sent ${train(e.trainId)} into the Yard Office` }
|
||||
: { tone: 'plain', text: `Kept ${train(e.trainId)} at the Train Order Office` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,25 @@ export type PileKey = 'home' | 'salvage' | `dept${number}`;
|
||||
* | `card.play` | `salvage`, or nothing here when it lands on the board |
|
||||
* | switching, new trains | nothing here — those show on the board itself |
|
||||
*/
|
||||
/**
|
||||
* Mainline cards that became a different card between two public boards — a Realignment, which is the
|
||||
* one play that changes the Division itself.
|
||||
*
|
||||
* Playtest, 2026-09-15: *"is it possible to flash the mainline card when it gets changed by realignment?
|
||||
* This would be more obvious to see what's happening on the map."* Detected the same way `changedPiles`
|
||||
* detects a pile moving — by comparing the two boards the queue already holds — rather than by reading
|
||||
* the event, so the flash lands with the step that shows it and not when the intent arrived.
|
||||
*/
|
||||
export function changedDivisionCards(before: PublicFrame | null, after: PublicFrame): number[] {
|
||||
if (before === null) return [];
|
||||
const out: number[] = [];
|
||||
after.division.forEach((node, i) => {
|
||||
const was = before.division[i];
|
||||
if (was && was.kind === 'ml' && node.kind === 'ml' && was.label !== node.label) out.push(i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
|
||||
if (before === null) return [];
|
||||
const out: PileKey[] = [];
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Component 17b — planning a whole switching turn before making the first Move.
|
||||
*
|
||||
* Dev-side, like the rest of the bot. The developer bot's switching branch chooses ONE move at a time
|
||||
* from a ladder of rules, and its own comment names what that cannot do: "a strong player would use
|
||||
* the six Moves to re-order the consist — that is the game's central switching puzzle, and this bot
|
||||
* does not attempt it." This attempts it, for one turn at a time.
|
||||
*
|
||||
* WHY SEARCH IS FAIR HERE. A switching turn draws no card and rolls no die, so trying sequences on a
|
||||
* copy of the game is exactly what a player does by looking at the board. The score below reads only
|
||||
* what a player can see — the district, the cars on the trains, the facilities — and never the deck.
|
||||
*
|
||||
* WHY NOT EVERY SEQUENCE. Measured 2026-09-14 over 30 switching turns from bot games: a median turn
|
||||
* reaches 229 distinct positions, but 11 of 30 passed 20,000, because setting cars out is free and a
|
||||
* crew can leave them in a great many places. So the search keeps the best `beam` positions at each
|
||||
* step and stops at `budget` positions tried. Small turns are searched completely inside that.
|
||||
*
|
||||
* THE SCORE IS OF WHERE THE TURN ENDS, not of what it did, and it starts from Jesse's ruling
|
||||
* (2026-09-14): "players will attempt to deliver / pick up cars even if it delays trains." So a car
|
||||
* put where it can be worked is worth a point, and a train left away from the Office costs a quarter
|
||||
* of one. The weights are a starting point to measure, not a result.
|
||||
*/
|
||||
|
||||
import { areaAtSeat, areaOf, commitEvents, facilityCarTypes, prepareIntent, withRouteCache } from '../engine/apply.ts';
|
||||
import { badlyMadeUp, isExpedited } from '../engine/advance.ts';
|
||||
import { MAX_CONSIST } from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalSwitchingActions } from '../engine/legal.ts';
|
||||
import { cloneTally, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
|
||||
export const SWITCH_WEIGHTS = {
|
||||
/** A car standing where its industry can load or unload it — the point of switching. */
|
||||
spot: 1.0,
|
||||
/** The same, past what the industry's boxes can work at once. */
|
||||
spotBeyondCapacity: 0.25,
|
||||
/** A car the industry cannot work, taking room on its track. */
|
||||
junkOnIndustry: -0.5,
|
||||
/** A finished car — loaded at a shipper, emptied at a receiver — still waiting to be lifted. */
|
||||
finishedLeft: -0.15,
|
||||
/** A car on one of this district's trains that some industry here would work. */
|
||||
carriedWanted: 0.35,
|
||||
/** The same car left on ordinary track, where a later turn can fetch it. */
|
||||
stagedWanted: 0.2,
|
||||
/** A coach kept with its train, or parked at the Office where §A.4 allows it. */
|
||||
coachWithTrain: 0.3,
|
||||
/** A coach left anywhere else, where no Porter can work it. */
|
||||
coachStranded: -0.3,
|
||||
/** Anything but a coach standing on the Office square — the next arrival collides (§8.3). */
|
||||
fouling: -3,
|
||||
/** A train that ends the turn away from the Office and so cannot highball next Mainline Phase. */
|
||||
trainAway: -0.25,
|
||||
/** On top of that, an expedited train — Q3 charges a Revenue point every Phase it is away. */
|
||||
expeditedAway: -1.0,
|
||||
/** A train that could not leave even from the Office — engine buried, caboose mid-train (§8.2). */
|
||||
notMadeUp: -0.6,
|
||||
/** Tie-breaks, so equal outcomes prefer the plan that does less. */
|
||||
perMove: -0.02,
|
||||
perSetOut: -0.005,
|
||||
/** A maneuver card spent — Flying Switch — so the planner plays one only when it buys something. */
|
||||
cardSpent: -0.1,
|
||||
} as const;
|
||||
|
||||
export type PlanOptions = {
|
||||
budget: number;
|
||||
beam: number;
|
||||
/**
|
||||
* Search Flying Switch alongside Moves, set-outs and sorts. On by default but UNMEASURED: the card
|
||||
* is dealt 0 copies (Jesse, 2026-08-26), so over 400 paired seeds turning it on changed nothing —
|
||||
* it is here so the planner can use the card the day it is dealt again.
|
||||
*/
|
||||
flyingSwitch?: boolean;
|
||||
};
|
||||
/**
|
||||
* Measured 2026-09-14, paired over 400 seeds against 3000/48: 2000/32 cost −0.02 ± 0.01 (t = −1.68,
|
||||
* inside the noise) at half the time per turn; 1000/24 cost −0.06 ± 0.02 (t = −2.65) for little more.
|
||||
*/
|
||||
export const DEFAULT_PLAN: PlanOptions = { budget: 2000, beam: 32, flyingSwitch: true };
|
||||
|
||||
export type SwitchPlan = {
|
||||
/** The intents to submit, in order. Empty when nothing beats stopping where the crew stands. */
|
||||
steps: Intent[];
|
||||
/** `switchFingerprint` before each step, and after the last — so a caller can tell it is on plan. */
|
||||
keys: string[];
|
||||
rootScore: number;
|
||||
score: number;
|
||||
/** Positions tried. */
|
||||
expanded: number;
|
||||
/** False when the budget ran out before the search did. */
|
||||
complete: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A copy of the game that a switching intent can be applied to without touching the original.
|
||||
*
|
||||
* NOT `structuredClone`, of the state or even of the district. A switching intent writes only the
|
||||
* cars standing on cards, the industry tracks, the district's A/D and held lists, the consist and
|
||||
* position of the trays standing in it, this player's turn and the tally — so exactly those arrays are
|
||||
* copied and everything else is shared by reference. Measured 2026-09-14, deep-cloning the district
|
||||
* was 44% of all planning time.
|
||||
*
|
||||
* `test/switch-planner.test.ts` proves across real games that planning leaves the original
|
||||
* byte-identical — which is what fails first if a reducer ever starts writing somewhere new, or
|
||||
* starts mutating a car or a card in place instead of replacing it.
|
||||
*/
|
||||
export function forkForSwitching(s: GameState, player: PlayerIndex): GameState {
|
||||
const seat = seatOf(s, player);
|
||||
const area = areaAtSeat(s, seat);
|
||||
const grid = new Map<string, TrackCard>();
|
||||
for (const [key, card] of area.grid) {
|
||||
const f = card.facility;
|
||||
grid.set(key, {
|
||||
...card,
|
||||
standing: [...card.standing],
|
||||
facility: f ? { ...f, industryTrack: { cars: [...f.industryTrack.cars] } } : f,
|
||||
});
|
||||
}
|
||||
const officeAreas = new Map(s.officeAreas);
|
||||
officeAreas.set(seat, {
|
||||
...area,
|
||||
grid,
|
||||
adOccupancy: [...area.adOccupancy],
|
||||
heldAtLimits: [...area.heldAtLimits],
|
||||
dispatchUsedToday: [...area.dispatchUsedToday],
|
||||
});
|
||||
const trays = new Map(s.trays);
|
||||
for (const [id, t] of s.trays) {
|
||||
if (t.position.at === 'grid' && t.position.seat === seat) trays.set(id, { ...t, consist: [...t.consist] });
|
||||
}
|
||||
const turns = new Map(s.turns);
|
||||
const turn = s.turns.get(player)!;
|
||||
turns.set(player, { ...turn, freightWorked: { ...turn.freightWorked } });
|
||||
// Flying Switch spends its card (`spendCard`): the hand map is rewritten and the Salvage Yard grows.
|
||||
const decks = { ...s.decks, hands: new Map(s.decks.hands), salvageYard: [...s.decks.salvageYard] };
|
||||
return { ...s, officeAreas, trays, turns, decks, tally: cloneTally(s.tally) };
|
||||
}
|
||||
|
||||
const carList = (xs: readonly RollingStock[]): string =>
|
||||
xs.map((c) => `${c.type}${c.loaded ? '+' : '-'}${c.origin ?? ''}`).join(',');
|
||||
|
||||
/**
|
||||
* Everything a switching intent can change, as a string — two positions with the same fingerprint
|
||||
* are the same position as far as the rest of the turn is concerned. Identical cars are not told
|
||||
* apart, which is right: no intent names a car.
|
||||
*/
|
||||
export function switchFingerprint(s: GameState, player: PlayerIndex): string {
|
||||
const seat = seatOf(s, player);
|
||||
const parts: string[] = [];
|
||||
for (const [id, t] of s.trays) {
|
||||
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
|
||||
const { row, col } = t.position.coord;
|
||||
parts.push(`${id}@${row},${col}/${t.facing}/${t.railFacing ?? ''}/${t.engineAt}:${carList(t.consist)}`);
|
||||
}
|
||||
for (const [key, card] of areaOf(s, player).grid) {
|
||||
const track = card.facility?.kind === 'freight' ? card.facility.industryTrack.cars : null;
|
||||
if (card.standing.length === 0 && card.standingWest === 0 && (track?.length ?? 0) === 0) continue;
|
||||
parts.push(`${key}=${carList(card.standing)}|${card.standingWest}|${track ? carList(track) : ''}`);
|
||||
}
|
||||
const turn = turnOf(s, player);
|
||||
parts.push(`m${turn.movesRemaining}`, JSON.stringify(turn.freightWorked), `h${(s.decks.hands.get(player) ?? []).join(',')}`);
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* §9.3 — an outbound industry loads EMPTY cars of its commodity, an inbound one unloads LOADED ones —
|
||||
* but never a load that was made in this same district (v0.4.9e, `LOADED_IN_THIS_DISTRICT`).
|
||||
*/
|
||||
function works(f: Facility, c: RollingStock, seat: number): boolean {
|
||||
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
|
||||
return (!c.loaded && f.allows.outbound) || (c.loaded && f.allows.inbound && c.origin !== seat);
|
||||
}
|
||||
|
||||
/**
|
||||
* The car an industry has finished with. Only decidable at a one-way industry: at one that both
|
||||
* ships and receives, a loaded car may be a delivery still waiting to be unloaded.
|
||||
*/
|
||||
function finished(f: Facility, c: RollingStock): boolean {
|
||||
if (f.kind !== 'freight' || !facilityCarTypes(f).includes(c.type)) return false;
|
||||
if (f.allows.outbound && !f.allows.inbound) return c.loaded;
|
||||
if (f.allows.inbound && !f.allows.outbound) return !c.loaded;
|
||||
return false;
|
||||
}
|
||||
|
||||
const same = (a: GridCoord, b: GridCoord): boolean => a.row === b.row && a.col === b.col;
|
||||
|
||||
/** How good this district's position is for the rest of the game, in rough Revenue points. */
|
||||
export function evaluateSwitching(s: GameState, player: PlayerIndex): number {
|
||||
const W = SWITCH_WEIGHTS;
|
||||
const area = areaOf(s, player);
|
||||
const seat = seatOf(s, player);
|
||||
const officeKey = coordKey(area.officeCoord);
|
||||
const passengerOffice = area.grid.get(officeKey)?.facility?.kind === 'passenger';
|
||||
let v = 0;
|
||||
|
||||
const withRoom: Facility[] = [];
|
||||
for (const card of area.grid.values()) {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight') continue;
|
||||
if (f.industryTrack.cars.length < MAX_CONSIST) withRoom.push(f);
|
||||
const cap = Math.max(1, f.capacity.outbound + f.capacity.inbound);
|
||||
let working = 0;
|
||||
for (const c of f.industryTrack.cars) {
|
||||
if (works(f, c, seat)) v += ++working <= cap ? W.spot : W.spotBeyondCapacity;
|
||||
else if (finished(f, c)) v += W.finishedLeft;
|
||||
else v += W.junkOnIndustry;
|
||||
}
|
||||
}
|
||||
const wanted = (c: RollingStock): boolean => withRoom.some((f) => works(f, c, seat));
|
||||
|
||||
for (const [key, card] of area.grid) {
|
||||
if (card.facility?.kind === 'freight') continue;
|
||||
const atOffice = key === officeKey;
|
||||
for (const c of card.standing) {
|
||||
if (c.type === 'coach') v += atOffice && passengerOffice ? W.coachWithTrain : W.coachStranded;
|
||||
else if (atOffice) v += W.fouling;
|
||||
else if (wanted(c)) v += W.stagedWanted;
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of s.trays.values()) {
|
||||
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
|
||||
for (const c of t.consist) {
|
||||
if (c.type === 'coach') v += passengerOffice ? W.coachWithTrain : 0;
|
||||
else if (wanted(c)) v += W.carriedWanted;
|
||||
}
|
||||
if (t.trainNumber === null) continue;
|
||||
if (!same(t.position.coord, area.officeCoord)) {
|
||||
v += W.trainAway;
|
||||
if (isExpedited(t)) v += W.expeditedAway;
|
||||
}
|
||||
if (badlyMadeUp(t)) v += W.notMadeUp;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* For ORDERING the beam only, never for choosing the plan: a Move toward an industry changes nothing
|
||||
* the score can see until the car is set out, so without this the beam would drop the approach in
|
||||
* favour of positions that merely look tidy.
|
||||
*/
|
||||
function approach(s: GameState, player: PlayerIndex): number {
|
||||
const area = areaOf(s, player);
|
||||
const seat = seatOf(s, player);
|
||||
const targets: { at: GridCoord; f: Facility }[] = [];
|
||||
for (const [key, card] of area.grid) {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight' || f.industryTrack.cars.length >= MAX_CONSIST) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
targets.push({ at: { row: row!, col: col! }, f });
|
||||
}
|
||||
let bonus = 0;
|
||||
for (const t of s.trays.values()) {
|
||||
if (t.position.at !== 'grid' || t.position.seat !== seat) continue;
|
||||
const here = t.position.coord;
|
||||
for (const c of t.consist) {
|
||||
let nearest = Infinity;
|
||||
for (const { at, f } of targets) {
|
||||
if (works(f, c, seat)) nearest = Math.min(nearest, Math.abs(at.row - here.row) + Math.abs(at.col - here.col));
|
||||
}
|
||||
if (nearest !== Infinity) bonus += 0.1 / (1 + nearest);
|
||||
}
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
|
||||
const SEARCHED = new Set<Intent['type']>(['switch.move', 'switch.dropCars', 'switch.sortConsist']);
|
||||
|
||||
type Node = {
|
||||
s: GameState;
|
||||
steps: Intent[];
|
||||
keys: string[];
|
||||
moves: number;
|
||||
setOuts: number;
|
||||
cards: number;
|
||||
score: number;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
/** The best way found to spend what is left of this switching turn. Never mutates `s`. */
|
||||
export function planSwitchingTurn(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
opts: PlanOptions = DEFAULT_PLAN,
|
||||
): SwitchPlan {
|
||||
const W = SWITCH_WEIGHTS;
|
||||
const scoreOf = (st: GameState, moves: number, setOuts: number, cards: number): number =>
|
||||
evaluateSwitching(st, player) + moves * W.perMove + setOuts * W.perSetOut + cards * W.cardSpent;
|
||||
const searched = (type: Intent['type']): boolean =>
|
||||
SEARCHED.has(type) || (opts.flyingSwitch === true && type === 'maneuver.flyingSwitch');
|
||||
|
||||
const rootKey = switchFingerprint(s, player);
|
||||
const rootScore = scoreOf(s, 0, 0, 0);
|
||||
const root: Node = { s, steps: [], keys: [rootKey], moves: 0, setOuts: 0, cards: 0, score: rootScore, rank: rootScore };
|
||||
let best = root;
|
||||
const seen = new Set([rootKey]);
|
||||
let frontier: Node[] = [root];
|
||||
let expanded = 0;
|
||||
let complete = true;
|
||||
|
||||
search: while (frontier.length > 0) {
|
||||
const next: Node[] = [];
|
||||
for (const node of frontier) {
|
||||
const movesLeft = turnOf(node.s, player).movesRemaining;
|
||||
// Every candidate is decided against THIS position, inside one route cache, and only then applied
|
||||
// to its own copy: deciding on the copy would re-walk routes the listing had just walked.
|
||||
const decided = withRouteCache(node.s, () =>
|
||||
legalSwitchingActions(node.s, player)
|
||||
.filter((i) => searched(i.type) && (i.type === 'switch.dropCars' || movesLeft >= 1))
|
||||
.map((i) => ({ i, r: prepareIntent(node.s, player, i) })),
|
||||
);
|
||||
for (const { i, r } of decided) {
|
||||
if (expanded >= opts.budget) {
|
||||
complete = false;
|
||||
break search;
|
||||
}
|
||||
expanded++;
|
||||
if (!r.ok) continue;
|
||||
const f = forkForSwitching(node.s, player);
|
||||
commitEvents(f, r.events);
|
||||
const key = switchFingerprint(f, player);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const setOut = i.type === 'switch.dropCars';
|
||||
const moves = node.moves + (setOut ? 0 : 1);
|
||||
const setOuts = node.setOuts + (setOut ? 1 : 0);
|
||||
const cards = node.cards + (i.type === 'maneuver.flyingSwitch' ? 1 : 0);
|
||||
const score = scoreOf(f, moves, setOuts, cards);
|
||||
const child: Node = {
|
||||
s: f,
|
||||
steps: [...node.steps, i],
|
||||
keys: [...node.keys, key],
|
||||
moves,
|
||||
setOuts,
|
||||
cards,
|
||||
score,
|
||||
rank: score + approach(f, player),
|
||||
};
|
||||
if (score > best.score + 1e-9) best = child;
|
||||
next.push(child);
|
||||
}
|
||||
}
|
||||
// A stable sort, so equal ranks keep `legalActions` order and the bot stays deterministic.
|
||||
frontier = next.length > opts.beam ? next.sort((a, b) => b.rank - a.rank).slice(0, opts.beam) : next;
|
||||
}
|
||||
|
||||
return { steps: best.steps, keys: best.keys, rootScore, score: best.score, expanded, complete };
|
||||
}
|
||||
@@ -1791,23 +1791,6 @@ export function snapshot(
|
||||
|
||||
/** A card id turned into something a person can read. */
|
||||
export function cardName(s: GameState, id: string): string {
|
||||
/**
|
||||
* A SCHEDULED TRAIN IS IN THE SALVAGE YARD UNDER A SYNTHETIC ID, and without this the pile that is
|
||||
* supposed to be face up reads "a card".
|
||||
*
|
||||
* `trainScheduled` pushes `train-<number>` rather than the id of the card that was played
|
||||
* (`apply.ts`), so there is nothing in `s.cards` to look up — and since a train is scheduled
|
||||
* several times a Day, that synthetic id is on top of the Salvage Yard most of the time. Reported
|
||||
* by Jesse 2026-09-10 as "why is salvage deck not face up. I should see the card played onto
|
||||
* salvage": it was face up all along and simply had nothing to say.
|
||||
*
|
||||
* Resolved here rather than in the engine because this is a NAME, which is this file's job. Whether
|
||||
* the engine should be pushing a real card id instead is a separate question with a separate
|
||||
* consequence — the Salvage Yard is swept back into the draw deck when it runs out — and is filed
|
||||
* rather than answered in passing.
|
||||
*/
|
||||
const scheduled = /^train-(\d+)$/.exec(id);
|
||||
if (scheduled) return `Train ${scheduled[1]}`;
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return 'a card';
|
||||
switch (k.kind) {
|
||||
|
||||
+15
-2
@@ -1255,9 +1255,22 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
|
||||
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
||||
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
||||
/**
|
||||
* A RULING IS MADE AS SUPERINTENDENT, NOT AS YOURSELF (playtest, 2026-09-15: "maybe it could say
|
||||
* 'Superintendent Player Tom', so it's clear they got the move because they're Superintendent").
|
||||
* These three are the only moves a player makes out of turn, by holding the office: §8.1's
|
||||
* clearance, §11's Yard Office offer and §Q's Red Flag prompt. `clearanceGiven` carries no
|
||||
* player at all — the office made it, whoever holds it — so the actor is what names it.
|
||||
*/
|
||||
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
|
||||
const ruling = RULINGS.includes(e.type) && who !== null;
|
||||
const mine = who !== null && 'player' in e;
|
||||
const text = mine ? `Player ${who} ${uncapitalise(said)}` : said;
|
||||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||||
const text = ruling
|
||||
? `Superintendent Player ${who} ${uncapitalise(said)}`
|
||||
: mine
|
||||
? `Player ${who} ${uncapitalise(said)}`
|
||||
: said;
|
||||
game.log.push({ text, tone: mine || ruling ? 'act' : n.tone });
|
||||
|
||||
}
|
||||
game.cues.push(...cuesFor(events));
|
||||
|
||||
+55
-14
@@ -25,7 +25,7 @@ import type { LocalSession, Session } from './session.ts';
|
||||
import { createLocalSession, createRemoteSession } from './session.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import type { PublicDistrict } from '../sim/view.ts';
|
||||
import { createStepQueue } from './step-queue.ts';
|
||||
import { actorOnScreen, createStepQueue } from './step-queue.ts';
|
||||
import { PACE_LEVELS } from '../sim/pacing.ts';
|
||||
import { notice, prefillCode, runLobby } from './lobby.ts';
|
||||
import type { LobbyReady } from './lobby.ts';
|
||||
@@ -493,12 +493,14 @@ function piecePreview(links: string[], label: string): string {
|
||||
* are in the Day the same way and with the same violet highlight. It used to live here alone.
|
||||
*/
|
||||
function renderTurnChart(f: Frame): void {
|
||||
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null);
|
||||
// The move on screen, not the live one, while the board is still catching up (Gitea#25).
|
||||
const { actor, replaying } = actorOnScreen(stepQueue, f.actor);
|
||||
const actorName = actor === null ? null : (f.players[actor]?.name ?? null);
|
||||
// Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a
|
||||
// chip that can never change is a chip to read past.
|
||||
const superName =
|
||||
f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null;
|
||||
$('turnchart').innerHTML = turnChartHtml(f, actorName, superName);
|
||||
$('turnchart').innerHTML = turnChartHtml(replaying ? { ...f, awaiting: null } : f, actorName, superName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1000,6 +1002,7 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
|
||||
// banner (`#presence`), and it holds a beat so the game visibly begins.
|
||||
openHandoff();
|
||||
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
|
||||
remoteToken = ready.token;
|
||||
rejoiningRemote = rejoining;
|
||||
applyCapabilities();
|
||||
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
|
||||
@@ -1167,6 +1170,14 @@ function start(): void {
|
||||
* the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the
|
||||
* question "why not?" every turn, and the honest answer is that the control does not belong there.
|
||||
*/
|
||||
/**
|
||||
* The session token of a server-backed game, or null in solitaire (playtest, 2026-09-15: "most of the
|
||||
* time, I want to go ahead and just save it as a JSON file"). It is the seat's proof of identity to
|
||||
* `/api/save`, exactly as it is to `/api/stream` — a save is the seed and the moves, every one of which
|
||||
* is already on this player's screen.
|
||||
*/
|
||||
let remoteToken: string | null = null;
|
||||
|
||||
function applyCapabilities(): void {
|
||||
const c = session.capabilities;
|
||||
const hide = (id: string, on: boolean): void => {
|
||||
@@ -1174,7 +1185,7 @@ function applyCapabilities(): void {
|
||||
if (el) el.hidden = !on;
|
||||
};
|
||||
hide('undo', c.undo);
|
||||
hide('savefile', c.saveLocal);
|
||||
hide('savefile', c.saveLocal || remoteToken !== null);
|
||||
hide('newgame', c.newGame);
|
||||
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
|
||||
// page offers — same reasoning as `newgame`, and the same capability answers both.
|
||||
@@ -1347,8 +1358,10 @@ function render(): void {
|
||||
// -- division
|
||||
$('division').innerHTML = divisionSvg(f.division, {
|
||||
players: f.players,
|
||||
actor: f.actor,
|
||||
actor: actorOnScreen(stepQueue, f.actor).actor,
|
||||
viewer: f.viewer,
|
||||
// A Realignment changes the Division under everyone; flashed only while the step that did it is up.
|
||||
flash: stepQueue.busy() ? stepQueue.flashing() : [],
|
||||
});
|
||||
renderSeatingChain(f);
|
||||
applyZoom($('division'));
|
||||
@@ -1633,7 +1646,16 @@ function render(): void {
|
||||
|
||||
// -- log
|
||||
const log = $('log');
|
||||
const allLines = session.lines();
|
||||
/**
|
||||
* THE LOG IS HELD BACK WITH THE BOARD (playtest, 2026-09-15).
|
||||
*
|
||||
* A push carries its narration and its display steps together, so every line of a bot's turn was in
|
||||
* this panel before the board had drawn a single move of it — the history ran ahead of the "N behind"
|
||||
* counter it is meant to match. Those lines are the TAIL of the log, so exactly the ones belonging to
|
||||
* steps still queued are withheld, and each appears as its step goes up.
|
||||
*/
|
||||
const heldBack = stepQueue.pendingLines();
|
||||
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
|
||||
const shownLines = allLines.slice(-60);
|
||||
/**
|
||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||
@@ -2317,21 +2339,40 @@ function renderActions(
|
||||
* 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 {
|
||||
// The button this fires from is hidden by `applyCapabilities()` for any session that cannot save
|
||||
// (`#savefile`), but nothing stops this function being called directly, so the guard is repeated
|
||||
// here rather than only trusted to the DOM.
|
||||
if (!isLocal(session)) return;
|
||||
const data = JSON.stringify(session.save(), null, 1);
|
||||
function writeFile(name: string, data: string): void {
|
||||
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${session.seed()}-day${session.view().day}.json`;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function downloadSave(): Promise<void> {
|
||||
const f = session.view();
|
||||
const stamp = `day${f.day}-stage${f.stage}`;
|
||||
/**
|
||||
* A SERVER-BACKED GAME HAS NO LOCAL SAVE TO HAND OVER, so it asks the server for its own — the seat's
|
||||
* token is the gate (`/api/save`), the same one the stream and every intent already use. The StartOS
|
||||
* Manage Game action cannot do this: an action result is text only, with no file member in the SDK.
|
||||
*/
|
||||
if (!isLocal(session)) {
|
||||
if (remoteToken === null) return;
|
||||
try {
|
||||
const res = await fetch(`/api/save?token=${encodeURIComponent(remoteToken)}`);
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { save: unknown };
|
||||
writeFile(`station-master-${stamp}.json`, JSON.stringify(body.save, null, 1));
|
||||
} catch {
|
||||
// Offline, or the game has been ended under us: the button simply does nothing, which is the
|
||||
// same thing every other server call on this page does when the server is not there.
|
||||
}
|
||||
return;
|
||||
}
|
||||
writeFile(`station-master-seed${session.seed()}-${stamp}.json`, JSON.stringify(session.save(), null, 1));
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!isLocal(session)) return;
|
||||
try {
|
||||
@@ -2367,7 +2408,7 @@ document.head.appendChild(pageStyle);
|
||||
installTooltips();
|
||||
|
||||
const saveBtn = document.getElementById('savefile');
|
||||
if (saveBtn) saveBtn.onclick = downloadSave;
|
||||
if (saveBtn) saveBtn.onclick = () => void downloadSave();
|
||||
|
||||
/**
|
||||
* Forget the saved game and deal a fresh one.
|
||||
|
||||
+43
-1
@@ -19,7 +19,7 @@
|
||||
|
||||
import type { PublicFrame } from '../sim/view.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import { applyPublicDelta, changedPiles } from '../sim/public-delta.ts';
|
||||
import { applyPublicDelta, changedDivisionCards, changedPiles } from '../sim/public-delta.ts';
|
||||
import type { PileKey } from '../sim/public-delta.ts';
|
||||
import { dwellForStep } from '../sim/pacing.ts';
|
||||
|
||||
@@ -53,8 +53,43 @@ export type StepQueue = {
|
||||
lit(): readonly PileKey[];
|
||||
/** True while there is anything left to show. */
|
||||
busy(): boolean;
|
||||
/**
|
||||
* How many narrated lines belong to steps NOT yet shown.
|
||||
*
|
||||
* The log and the board are two different moments while the queue is behind: a push carries its
|
||||
* narration and its steps together, so every line of a bot's turn is in the history panel before the
|
||||
* board has drawn a single move of it (playtest, 2026-09-15: *"is it possible to stall history so it
|
||||
* stays in sync with the number behind?"*). Those lines are the TAIL of the log — they arrived last —
|
||||
* so the caller holds back exactly this many and reveals each as its step goes up.
|
||||
*/
|
||||
pendingLines(): number;
|
||||
/** Division nodes whose card changed in the step now on screen, for the map to flash. */
|
||||
flashing(): readonly number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* WHOSE MOVE THE SCREEN IS SHOWING (Gitea#25).
|
||||
*
|
||||
* The game and the board on screen are two different moments. The server plays every bot move the
|
||||
* instant a human's turn ends (`driveBots`), so the LIVE game is nearly always waiting on the human —
|
||||
* while this queue is still replaying the bots, step by step. The turn chart and the Division map's
|
||||
* move marker read the live actor, so a table of one person and three bots said "waiting on" that
|
||||
* person throughout, against a playback row naming the bot actually moving.
|
||||
*
|
||||
* While the queue is behind or still showing a step, the answer is that step's player — `null` for an
|
||||
* automatic phase, which is "the Division is running itself". Otherwise it is the live actor, and
|
||||
* `replaying` is false so a caller can keep live-only detail, such as a ruling the game is waiting on,
|
||||
* off a screen that has not caught up with it yet.
|
||||
*/
|
||||
export function actorOnScreen(
|
||||
queue: Pick<StepQueue, 'behind' | 'busy' | 'showing'>,
|
||||
live: number | null,
|
||||
): { actor: number | null; replaying: boolean } {
|
||||
if (queue.behind() === 0 && !queue.busy()) return { actor: live, replaying: false };
|
||||
const shown = queue.showing();
|
||||
return shown === null ? { actor: live, replaying: false } : { actor: shown.player, replaying: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* `pace` is read on every step rather than captured, so changing the setting takes effect at once.
|
||||
*
|
||||
@@ -74,6 +109,7 @@ export function createStepQueue(
|
||||
let shown: PublicFrame | null = null;
|
||||
let last: DisplayStep | null = null;
|
||||
let litPiles: readonly PileKey[] = [];
|
||||
let flashedCards: readonly number[] = [];
|
||||
let pending: DisplayStep[] = [];
|
||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||
let dueAt: number | null = null;
|
||||
@@ -92,6 +128,9 @@ export function createStepQueue(
|
||||
* that gives your own steps no dwell — the display is for watching everybody else.
|
||||
*/
|
||||
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
|
||||
// A Realignment changes the Division under everyone, so it is flashed for the player who did it
|
||||
// too — unlike a pile, which only tells the drawer what they already know.
|
||||
flashedCards = changedDivisionCards(before, shown);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -101,6 +140,7 @@ export function createStepQueue(
|
||||
dueAt = null;
|
||||
// Nothing was watched arriving at this board, so nothing on it is lit.
|
||||
litPiles = [];
|
||||
flashedCards = [];
|
||||
// `last` deliberately survives: a reconnect should not blank the caption line, and the
|
||||
// sentence describing the most recent action is still true.
|
||||
},
|
||||
@@ -165,6 +205,8 @@ export function createStepQueue(
|
||||
* `dueAt` is non-null exactly while the step on screen has time left, so the two together mean
|
||||
* "there is more to come, or what is up has not had its moment yet".
|
||||
*/
|
||||
pendingLines: () => pending.reduce((n, s) => n + s.lines.length, 0),
|
||||
flashing: () => flashedCards,
|
||||
busy: () => pending.length > 0 || dueAt !== null,
|
||||
};
|
||||
}
|
||||
|
||||
+72
-1
@@ -1141,7 +1141,12 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
|
||||
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
|
||||
// which is the Superintendent's call rather than an absolute bar.
|
||||
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
//
|
||||
// AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card
|
||||
// in the Division, which is west of the Office — behind the train — and still expected a ruling,
|
||||
// which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow.
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
|
||||
const node = s.division.nodes[ahead];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
@@ -1511,3 +1516,69 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
|
||||
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => {
|
||||
/**
|
||||
* REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing
|
||||
* the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it —
|
||||
* and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was
|
||||
* destroyed. Reproduced by replaying the exported save; the positions below are that situation in a
|
||||
* one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all.
|
||||
*/
|
||||
const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => {
|
||||
const s = game(7, { days: 5 });
|
||||
const area = areaOf(s, 0);
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
s.trays.set('departing', {
|
||||
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push('departing');
|
||||
const card = s.division.nodes.findIndex((n, i) =>
|
||||
n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office));
|
||||
const node = s.division.nodes[card];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('other', {
|
||||
id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w',
|
||||
position: { at: 'mainline', index: card }, movesUsed: 0,
|
||||
});
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction });
|
||||
}
|
||||
s.clock.phase = 'mainline';
|
||||
return s;
|
||||
};
|
||||
|
||||
it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => {
|
||||
const s = setup({ direction: 'west', side: 'east', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent');
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
|
||||
'the departing train did not highball with nothing ahead of it',
|
||||
);
|
||||
assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed');
|
||||
});
|
||||
|
||||
it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => {
|
||||
const s = setup({ direction: 'east', side: 'east', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
|
||||
'a train moving away behind it held the departure',
|
||||
);
|
||||
});
|
||||
|
||||
it('still puts a same-direction train AHEAD to the Superintendent', () => {
|
||||
const s = setup({ direction: 'west', side: 'west', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'),
|
||||
'a train the departing one would follow was not put to the Superintendent',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+59
-1
@@ -159,6 +159,60 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
assert.notEqual(s.decks.departments[1]![0], target, 'refilled with the same card');
|
||||
});
|
||||
|
||||
it('a PLAYED timetabled train never comes back, but a discarded one does — Gitea#23', () => {
|
||||
/**
|
||||
* Jesse's ruling, 2026-09-10: *"Once you've played a regularly scheduled train and it's in the
|
||||
* salvage deck, that train is already on the timetable. It does not make sense to put that back
|
||||
* into a reshuffled home deck to get played again. By contrast, a regularly scheduled train
|
||||
* that's in a discard pile could potentially get reused later, and so should have that
|
||||
* capability. Extras run one time and then they're done — if they are in the Salvage deck, they
|
||||
* should get shuffled back in so that they could get run again."*
|
||||
*
|
||||
* So the test is WHERE the card is, not only what it is: the same card is spent in the Salvage
|
||||
* Yard and still runnable in a Department. That is what this pins, because it is the kind of rule
|
||||
* a later tidy-up would happily "simplify" into filtering by card kind everywhere.
|
||||
*/
|
||||
const s = game();
|
||||
const kindOfCard = (id: string): string => s.cards.get(id)?.kind.kind ?? '?';
|
||||
const pool = [...s.decks.homeOffice];
|
||||
const trains = pool.filter((id) => kindOfCard(id) === 'timetabledTrain');
|
||||
const extras = pool.filter((id) => kindOfCard(id) === 'extraTrain');
|
||||
const others = pool.filter((id) => !['timetabledTrain', 'extraTrain'].includes(kindOfCard(id)));
|
||||
assert.ok(trains.length >= 2 && extras.length >= 1 && others.length >= 5, 'the deal lacks the cards this needs');
|
||||
|
||||
const spentTrain = trains[0]!; // played: in the Salvage Yard, its slot taken
|
||||
const discardedTrain = trains[1]!; // never played: sitting in a Department
|
||||
const playedExtra = extras[0]!; // a single run, free to run again
|
||||
|
||||
s.decks.salvageYard = [spentTrain, playedExtra, ...others.slice(0, 3)];
|
||||
s.decks.departments = [[discardedTrain], [others[3]!], [others[4]!]];
|
||||
s.decks.homeOffice = [others[5]!];
|
||||
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
|
||||
|
||||
const recovered = new Set([...s.decks.homeOffice, ...s.decks.departments.flat()]);
|
||||
const hands = new Set([...s.decks.hands.values()].flat());
|
||||
|
||||
// THE RULING, both halves.
|
||||
assert.ok(!recovered.has(spentTrain), 'a played timetabled train was shuffled back in');
|
||||
assert.ok(!hands.has(spentTrain), 'a played timetabled train was dealt back into a hand');
|
||||
assert.ok(
|
||||
s.decks.salvageYard.includes(spentTrain),
|
||||
'a played timetabled train should stay in the Salvage Yard, not vanish',
|
||||
);
|
||||
assert.ok(
|
||||
recovered.has(discardedTrain) || hands.has(discardedTrain),
|
||||
'a DISCARDED timetabled train must come back — it was never played, so its slot is open',
|
||||
);
|
||||
assert.ok(
|
||||
recovered.has(playedExtra) || hands.has(playedExtra),
|
||||
'a played Extra must come back — an Extra is one run, not a standing slot',
|
||||
);
|
||||
});
|
||||
|
||||
it('reshuffles the Salvage Yard and Departments back in when the deck runs out', () => {
|
||||
// §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards
|
||||
// from the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office
|
||||
@@ -182,7 +236,11 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
assert.ok(r.ok);
|
||||
assert.ok(r.events.some((e) => e.type === 'deckReshuffled'), 'no reshuffle was emitted');
|
||||
|
||||
assert.equal(s.decks.salvageYard.length, 0, 'the Salvage Yard must be swept');
|
||||
// Swept EXCEPT the trains whose slots are already on the timetable — see the ruling test below.
|
||||
assert.ok(
|
||||
s.decks.salvageYard.every((id) => s.cards.get(id)?.kind.kind === 'timetabledTrain'),
|
||||
'the Salvage Yard must be swept apart from spent timetabled trains',
|
||||
);
|
||||
assert.ok(s.decks.homeOffice.length > 0, 'the deck must be re-established');
|
||||
assert.ok(
|
||||
s.decks.departments.every((p) => p.length === 1),
|
||||
|
||||
@@ -254,3 +254,41 @@ describe('which piles a step moved', () => {
|
||||
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Mainline card that changed under the players (Gitea#28)', () => {
|
||||
it('names the node a Realignment converted, and nothing else', async () => {
|
||||
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
|
||||
const { REALIGNMENTS } = await import('../src/engine/content.ts');
|
||||
|
||||
const s = newState(4242);
|
||||
const before = publicSnapshot(s);
|
||||
|
||||
// Realignment converts a card to another kind (`content.ts`'s table). Applied to the state directly:
|
||||
// what is being tested is the DETECTOR, not the play that reaches it — which needs the card in hand,
|
||||
// the draw option taken and no train on the card.
|
||||
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline' && REALIGNMENTS.some((r) => r.from === n.card));
|
||||
assert.ok(at >= 0, 'no Mainline card in this Division can be realigned at all');
|
||||
const node = s.division.nodes[at];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
if (node?.kind === 'mainline') {
|
||||
node.card = REALIGNMENTS.find((r) => r.from === node.card)!.to;
|
||||
}
|
||||
const after = publicSnapshot(s);
|
||||
|
||||
assert.deepEqual(changedDivisionCards(before, after), [at], 'the realigned card was not the one reported');
|
||||
assert.deepEqual(changedDivisionCards(after, after), [], 'an unchanged Division reported a change');
|
||||
assert.deepEqual(changedDivisionCards(null, after), [], 'a first board has nothing to compare against');
|
||||
});
|
||||
|
||||
it('says nothing when only the trains on a card moved', async () => {
|
||||
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
|
||||
const s = newState(1917398);
|
||||
const before = publicSnapshot(s);
|
||||
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
const node = s.division.nodes[at];
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'tray1', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
}
|
||||
assert.deepEqual(changedDivisionCards(before, publicSnapshot(s)), [], 'a train arriving flashed the card');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The engine's speed-ups must not change a single game (2026-09-15).
|
||||
*
|
||||
* `applyIntent` became `prepareIntent` (check and execute, sharing one walk of the position's routes)
|
||||
* followed by `commitEvents` (reduce and tally), so the switching planner can decide every candidate
|
||||
* against one position and apply each to a copy. Two properties hold that together:
|
||||
*
|
||||
* 1. `prepareIntent` never writes the state it reads — including through the route cache it opens.
|
||||
* 2. Preparing on one state and committing to an EQUAL copy lands on exactly what `applyIntent` does.
|
||||
*
|
||||
* Checked at every decision of seeded bot games rather than on hand-built positions, so the intents
|
||||
* exercised are the ones real play submits.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, commitEvents, prepareIntent } from '../src/engine/apply.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
} 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';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
|
||||
const config = (): GameConfig => {
|
||||
const days = lengthProfile('short').days;
|
||||
return {
|
||||
mode: 'solitaire',
|
||||
days,
|
||||
minCombinedRevenue: collectiveRevenueFloor(1, days),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
};
|
||||
|
||||
const serialise = (s: GameState): string =>
|
||||
JSON.stringify(s, (_k, v) => (v instanceof Map ? [...v] : v instanceof Set ? [...v] : v));
|
||||
|
||||
describe('applyIntent split into prepareIntent and commitEvents', () => {
|
||||
it('prepares without writing, and committing to a copy matches applying in place', () => {
|
||||
let decisions = 0;
|
||||
let rejectedSeen = 0;
|
||||
const s = createGame({ id: 'split-8919', seed: 8919, config: config(), playerNames: ['bot'] });
|
||||
const policy = {
|
||||
name: 'split-probe',
|
||||
choose(st: GameState, player: number, options: ReturnType<typeof legalActions>) {
|
||||
const chosen = developerBot.choose(st, player, options);
|
||||
if (decisions < 400) {
|
||||
decisions++;
|
||||
const before = serialise(st);
|
||||
const prepared = prepareIntent(st, player, chosen);
|
||||
assert.equal(serialise(st), before, `decision ${decisions}: prepareIntent wrote into the state it read`);
|
||||
assert.ok(prepared.ok, `decision ${decisions}: a legal choice was refused by prepareIntent`);
|
||||
|
||||
const viaCommit = structuredClone(st);
|
||||
const viaApply = structuredClone(st);
|
||||
commitEvents(viaCommit, prepared.events);
|
||||
const applied = applyIntent(viaApply, player, chosen);
|
||||
assert.ok(applied.ok);
|
||||
assert.deepEqual(applied.events, prepared.events, `decision ${decisions}: the two paths produced different events`);
|
||||
assert.equal(serialise(viaCommit), serialise(viaApply), `decision ${decisions}: committing to a copy diverged from applying`);
|
||||
|
||||
// A refused intent must come back refused from both paths, with nothing written.
|
||||
const refused = { type: 'switch.end' } as const;
|
||||
const r = prepareIntent(st, player, refused);
|
||||
if (!r.ok) {
|
||||
rejectedSeen++;
|
||||
assert.equal(serialise(st), before);
|
||||
assert.equal(applyIntent(structuredClone(st), player, refused).ok, false);
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
},
|
||||
};
|
||||
const r = playGame(s, policy, pump);
|
||||
assert.ok(r.finished, 'the probed game did not finish');
|
||||
assert.ok(decisions > 0, 'no decision was probed');
|
||||
assert.ok(rejectedSeen > 0, 'no refused intent was exercised');
|
||||
});
|
||||
});
|
||||
+75
-1
@@ -15,7 +15,7 @@ import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||
import { publicSnapshot } from '../src/sim/view.ts';
|
||||
import { takeSteps } from '../src/sim/display-step.ts';
|
||||
import type { DisplayStep } from '../src/sim/display-step.ts';
|
||||
import { createStepQueue } from '../src/web/step-queue.ts';
|
||||
import { actorOnScreen, createStepQueue } from '../src/web/step-queue.ts';
|
||||
import { DWELL } from '../src/sim/pacing.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
@@ -278,3 +278,77 @@ describe('the step queue', () => {
|
||||
assert.equal(q.showing(), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose move the screen is showing (Gitea#25)', () => {
|
||||
const step = (player: number | null) => ({ player }) as DisplayStep;
|
||||
const queue = (behind: number, busy: boolean, showing: DisplayStep | null) => ({
|
||||
behind: () => behind,
|
||||
busy: () => busy,
|
||||
showing: () => showing,
|
||||
});
|
||||
|
||||
it('names the live actor once the board has caught up', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(0, false, step(2)), 0), { actor: 0, replaying: false });
|
||||
});
|
||||
|
||||
it('names the player of the step on screen while the board is behind — not the live actor', () => {
|
||||
// One human (seat 0) against bots: the live game already waits on seat 0 while bot 2's moves replay.
|
||||
assert.deepEqual(actorOnScreen(queue(3, true, step(2)), 0), { actor: 2, replaying: true });
|
||||
});
|
||||
|
||||
it('keeps naming the last step while it is still on screen, after the counter reaches zero', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(0, true, step(1)), 0), { actor: 1, replaying: true });
|
||||
});
|
||||
|
||||
it('names nobody for an automatic phase being shown', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(2, true, step(null)), 0), { actor: null, replaying: true });
|
||||
});
|
||||
|
||||
it('falls back to the live actor before any step has been shown', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(1, true, null), 3), { actor: 3, replaying: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the log is held back with the board, and a changed card flashes (playtest, 2026-09-15)', () => {
|
||||
it('owes exactly the lines of the steps not yet shown', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
assert.equal(q.pendingLines(), 0, 'an empty queue holds nothing back');
|
||||
|
||||
q.push(steps);
|
||||
const owed = steps.reduce((n, s) => n + s.lines.length, 0);
|
||||
assert.equal(q.pendingLines(), owed, 'every queued step still owes its lines');
|
||||
|
||||
// Drive the clock as a render loop would; the debt falls monotonically and ends at nothing.
|
||||
let now = 0;
|
||||
let last = owed;
|
||||
for (let i = 0; i < 20_000 && q.busy(); i++) {
|
||||
q.advance(now);
|
||||
const left = q.pendingLines();
|
||||
assert.ok(left <= last, 'the held-back count grew while the board caught up');
|
||||
last = left;
|
||||
now += 50;
|
||||
}
|
||||
assert.equal(q.pendingLines(), 0, 'the board caught up but lines were still withheld');
|
||||
});
|
||||
|
||||
it('skipping reveals the whole log at once', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
q.skip();
|
||||
assert.equal(q.pendingLines(), 0, 'Skip left lines withheld — the history would stay short');
|
||||
});
|
||||
|
||||
it('flashes nothing on an ordinary step', () => {
|
||||
// Realignment is rare in bot play, so this pins the quiet case: the map must not pulse at random.
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps.slice(0, 5));
|
||||
q.advance(0);
|
||||
assert.deepEqual(q.flashing(), [], 'a step that changed no Mainline card flashed one');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The switching planner (`sim/switch-planner.ts`) — the two properties it cannot be allowed to lose.
|
||||
*
|
||||
* 1. PLANNING TOUCHES NOTHING. The planner applies intents to a partial copy of the game
|
||||
* (`forkForSwitching`) that shares everything a switching intent is not supposed to write. If a
|
||||
* reducer ever starts writing somewhere new, the copy leaks into the real game, and this is where
|
||||
* that shows: the game is serialised before and after planning and must not have changed.
|
||||
* 2. A PLAN IS WHAT THE ENGINE WILL DO. Every step replays through `applyIntent` on a FULL copy, and
|
||||
* lands on the fingerprint the planner promised for it. That is what makes the partial copy
|
||||
* trustworthy, and it is what the bot relies on to know it is still on plan.
|
||||
*
|
||||
* Taken from real seeded bot games rather than hand-built positions, because a district that
|
||||
* satisfies the track geometry by hand tests the builder as much as the planner.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { legalActions, legalSwitchingActions } from '../src/engine/legal.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
collectiveRevenueFloor,
|
||||
lengthProfile,
|
||||
} from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { actingPlayer, turnOf } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
import { planSwitchingTurn, switchFingerprint } from '../src/sim/switch-planner.ts';
|
||||
|
||||
const config = (): GameConfig => {
|
||||
const days = lengthProfile('short').days;
|
||||
return {
|
||||
mode: 'solitaire',
|
||||
days,
|
||||
minCombinedRevenue: collectiveRevenueFloor(1, days),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
};
|
||||
|
||||
const serialise = (s: GameState): string =>
|
||||
JSON.stringify(s, (_k, v) => (v instanceof Map ? [...v] : v instanceof Set ? [...v] : v));
|
||||
|
||||
describe('switching planner', () => {
|
||||
it('asks for switching intents that are exactly the switching subset of legalActions, in order', () => {
|
||||
const SWITCHING = new Set(['switch.move', 'switch.dropCars', 'switch.sortConsist', 'maneuver.flyingSwitch', 'switch.end']);
|
||||
let compared = 0;
|
||||
for (const seed of [1000, 8919]) {
|
||||
const s = createGame({ id: `legal-${seed}`, seed, config: config(), playerNames: ['bot'] });
|
||||
playGame(s, developerBot, pump, 50_000, undefined, (st) => {
|
||||
const p = actingPlayer(st);
|
||||
if (p === null || st.clock.phase !== 'localOps') return;
|
||||
const all = legalActions(st, p).filter((i) => SWITCHING.has(i.type));
|
||||
assert.deepEqual(legalSwitchingActions(st, p), all);
|
||||
compared++;
|
||||
});
|
||||
}
|
||||
assert.ok(compared > 0, 'no Local Operations decision was reached');
|
||||
});
|
||||
|
||||
it('never changes the game it plans for, and every plan replays to the position it promised', () => {
|
||||
let checked = 0;
|
||||
let withSteps = 0;
|
||||
for (const seed of [1000, 8919, 16838]) {
|
||||
const s = createGame({ id: `plan-${seed}`, seed, config: config(), playerNames: ['bot'] });
|
||||
const r = playGame(s, developerBot, pump, 50_000, undefined, (st) => {
|
||||
const p = actingPlayer(st);
|
||||
if (p === null || st.clock.phase !== 'localOps') return;
|
||||
const turn = turnOf(st, p);
|
||||
if (turn.option !== 'switch' || turn.movesRemaining !== MOVES_PER_LOCAL_OPS) return;
|
||||
|
||||
const before = serialise(st);
|
||||
const plan = planSwitchingTurn(st, p, { budget: 400, beam: 16 });
|
||||
assert.equal(serialise(st), before, `seed ${seed}: planning wrote into the real game`);
|
||||
assert.ok(plan.score >= plan.rootScore, 'a plan is never worse than stopping where the crew stands');
|
||||
|
||||
const copy = structuredClone(st);
|
||||
plan.steps.forEach((step, n) => {
|
||||
assert.equal(switchFingerprint(copy, p), plan.keys[n], `seed ${seed}: step ${n} started off plan`);
|
||||
const applied = applyIntent(copy, p, step);
|
||||
assert.ok(applied.ok, `seed ${seed}: step ${n} (${step.type}) was refused by the engine`);
|
||||
});
|
||||
assert.equal(switchFingerprint(copy, p), plan.keys.at(-1), `seed ${seed}: the plan did not end where it said`);
|
||||
|
||||
checked++;
|
||||
if (plan.steps.length > 0) withSteps++;
|
||||
});
|
||||
assert.ok(r.finished, `seed ${seed}: a game with the planner switched on did not finish`);
|
||||
}
|
||||
assert.ok(checked > 0, 'no switching turn was reached, so nothing was tested');
|
||||
assert.ok(withSteps > 0, 'every plan was empty, so replay was never exercised');
|
||||
});
|
||||
});
|
||||
+9
-5
@@ -151,10 +151,14 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
|
||||
it('splits Revenue into what was earned and what was given back', () => {
|
||||
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
|
||||
// lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually
|
||||
// lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually
|
||||
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
|
||||
// being told apart rather than both sitting at zero.
|
||||
for (const seed of [1, 7, 42]) {
|
||||
//
|
||||
// It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over
|
||||
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and
|
||||
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion.
|
||||
for (const seed of [1, 7, 44]) {
|
||||
const { state } = playKeepingEvents(seed);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.equal(
|
||||
@@ -163,10 +167,10 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
|
||||
);
|
||||
}
|
||||
const { state } = playKeepingEvents(42);
|
||||
const { state } = playKeepingEvents(44);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted');
|
||||
assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted');
|
||||
});
|
||||
|
||||
it('records a Circus set-up as the one-off it is, not as a streak', () => {
|
||||
|
||||
@@ -309,3 +309,71 @@ describe('steps reach a seated player — TODO #13', () => {
|
||||
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
||||
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
||||
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||
const { legalActions } = await import('../src/engine/legal.ts');
|
||||
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
}
|
||||
|
||||
assert.ok(game.log.length > 50, 'the game barely ran, so this proved little');
|
||||
for (const line of game.log) {
|
||||
// `record()` prefixes the acting player's NAME; a narration that also named them read
|
||||
// "Player Alice player 0 finished Local Operations" (playtest, 2026-09-15).
|
||||
assert.doesNotMatch(
|
||||
line.text,
|
||||
/\bplayer \d+\b/i,
|
||||
`a line still carries a bare player index: ${line.text}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('attributes a clearance ruling to the office, not to the seat\'s own turn', async () => {
|
||||
const { newMultiplayerGame, drain, submit } = await import('../src/web/game.ts');
|
||||
const { areaOf } = await import('../src/engine/apply.ts');
|
||||
|
||||
const game = newMultiplayerGame(7, config, ['Alice', 'Bob', 'Carol']);
|
||||
const s = game.state;
|
||||
const area = areaOf(s, 0);
|
||||
|
||||
// A westbound train at seat 0's Office, and another westbound AHEAD of it — west of the Office —
|
||||
// which is §8.1's fourth condition and the Superintendent's to rule on (see Gitea#26).
|
||||
s.trays.set('departing', {
|
||||
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
} as never);
|
||||
area.adOccupancy.push('departing');
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const card = s.division.nodes.findIndex((n, i) => i < office && n.kind === 'mainline');
|
||||
const node = s.division.nodes[card];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
|
||||
} as never);
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
drain(game);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'clearance', 'no ruling was called for, so nothing was tested');
|
||||
|
||||
const before = game.log.length;
|
||||
assert.ok(submit(game, { type: 'mainline.clearance', allow: false }, s.clock.superintendent));
|
||||
const said = game.log.slice(before).map((l) => l.text);
|
||||
assert.ok(
|
||||
said.some((text) => text.startsWith('Superintendent Player ')),
|
||||
`a ruling did not read as the office's: ${said.join(' | ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3647,34 +3647,6 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('names the train on top of the Salvage Yard, instead of "a card"', () => {
|
||||
/**
|
||||
* The Salvage Yard is a FACE-UP pile and its tile reads the top card — but `trainScheduled`
|
||||
* pushes a synthetic `train-<number>` id rather than the id of the card that was played
|
||||
* (`apply.ts`), so there was nothing in `s.cards` to look up and the tile said "a card". A train
|
||||
* is scheduled several times a Day, so that id is on top most of the time: the pile was face up
|
||||
* and had nothing to say. Jesse, 2026-09-10: *"why is salvage deck not face up. I should see the
|
||||
* card played onto salvage."*
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'salv',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
s.decks.salvageYard.push('train-13');
|
||||
const f = snapshot(s, [], null);
|
||||
assert.equal(f.salvage.top, 'Train 13', 'a scheduled train on the pile must be named');
|
||||
|
||||
const html = pilesHtml(f);
|
||||
assert.ok(html.includes('Train 13'), `the Salvage tile does not name the train:\n${html}`);
|
||||
assert.equal(html.includes('>a card<'), false, 'the face-up pile still says "a card"');
|
||||
});
|
||||
|
||||
it('lights only the pile a watched move touched', () => {
|
||||
const s = createEngineGame({
|
||||
id: 'piles2',
|
||||
|
||||
Reference in New Issue
Block a user