Compare commits
10
Commits
312e0301e0
...
v0.8.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c6e103b3 | ||
|
|
072029b1f7 | ||
|
|
d0e5091824 | ||
|
|
64e8ce584f | ||
|
|
3fca325699 | ||
|
|
fc40fc39ed | ||
|
|
ff629c0708 | ||
|
|
c10f52791e | ||
|
|
0cfeb4c496 | ||
|
|
02289e94b8 |
+836
@@ -19,6 +19,842 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
*"Why is salvage deck not face up? I should see the card played onto salvage."* It always was — the
|
||||
tile reads the top card. It just said **"a card"**.
|
||||
|
||||
`apply.ts` pushes a **synthetic id** on `trainScheduled`:
|
||||
|
||||
```ts
|
||||
s.decks.salvageYard.push(`train-${e.trainNumber}`);
|
||||
```
|
||||
|
||||
Nothing in `s.cards` matches that, so `cardName()` fell through to its "a card" default — and since a
|
||||
train is scheduled several times a Day, that id is on top of the pile most of the time. Measured
|
||||
before touching anything: a session's Salvage tile read `"a card"` from the opening frame through 60
|
||||
pushes, never once changing, while its depth climbed from 2 to 8.
|
||||
|
||||
`cardName()` resolves `train-<n>` now, so the tile reads **Train 3**, **Train 2** as cards land.
|
||||
Resolved in `sim/view.ts` because this is a NAME, which is that file's job.
|
||||
|
||||
**The engine half is filed, not fixed — Gitea#23.** `reshuffleIfDepleted()` sweeps the Salvage Yard
|
||||
back into the draw deck, so a synthetic id can be shuffled in and drawn into a hand as an id with no
|
||||
card behind it. Not reachable in ordinary play: eight games driven to 4000 moves across eight seeds
|
||||
produced **zero** reshuffles. There are two defensible fixes and the choice turns on what that
|
||||
synthetic id is *for*, which is not a call to make in passing while fixing a label.
|
||||
|
||||
### Phases scale with the speed control again — at a third of the rate
|
||||
|
||||
*"Phases displayed on the upper line go by too quickly still. Should be 4 times as long — at a guess.
|
||||
Maybe use the speed multiplier for that too?"*
|
||||
|
||||
Two complaints from opposite directions, and the answer is between them. v0.8.0.3 **pinned** phases at
|
||||
their tabled beat because scaling them walled off a player's own turn — *"that makes no sense"*. At
|
||||
10× that pinned beat is too short to read the sentence on it.
|
||||
|
||||
So they scale, damped to a third of the rate: **1× unchanged, 10× lands exactly on the four-times
|
||||
guess**, 20× gives 4400ms. The cost stays bounded because phase beats cluster rather than accumulate
|
||||
— measured over 60 pushes, a push carries **1.0 phase beat on average and 4 at worst**, so the wait
|
||||
after a move is ~2.4s typical and ~10s at its very worst, against the minutes a full multiplier would
|
||||
have cost. A player's own move still outlasts a phase beat at every speed, which is the ordering that
|
||||
matters.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.6 — 2026-09-10
|
||||
|
||||
Playing v0.8.0.5: *"saw bot's office area now — much better."* Three things still wrong, and one of
|
||||
them was mine hiding inside the fix for another.
|
||||
|
||||
### Your move is put away while the board is catching up
|
||||
|
||||
*"Your actions should be hidden while catching up."* Two reasons, and the second is the one that
|
||||
changed my mind about a decision taken early in v0.8.0 ("never block input"). The board on screen is
|
||||
behind the game, so a move offered there is a move against a position that has already moved on — the
|
||||
menu is computed from the CURRENT state and would be acted on while looking at an older one. And the
|
||||
screen had grown to four things competing at once: the district, the history, the catching-up row,
|
||||
and now a lit pile. Taking the action list out of that competition, while there is nothing to decide
|
||||
anyway, is the cheapest way to quieten it.
|
||||
|
||||
Not a block: Skip is one click away at the left of the row, so the wait stays voluntary. The buttons
|
||||
are replaced by the reason they are gone.
|
||||
|
||||
### …which could have locked a player out of their own game
|
||||
|
||||
Hiding actions behind `busy()` makes that flag the thing standing between a player and their turn —
|
||||
and **without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would never
|
||||
clear.** The action list would have been hidden permanently, with Skip the only way to play.
|
||||
|
||||
Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same stub that has been proving this
|
||||
page still starts since long before any of this existed. Two fallbacks now: no `rAF` means draw
|
||||
everything at once (exactly what `pace = 0` does deliberately), and a queue that throws empties
|
||||
itself rather than stranding the player. `test/step-queue.test.ts` pins that a never-advanced queue
|
||||
is still skippable.
|
||||
|
||||
### The lit pile was never brief — it was too quiet
|
||||
|
||||
*"Never saw decks lighting up… caught one flash deck light up for just a very brief moment, but
|
||||
couldn't see that with what bot was doing in office area and history and catch up area all at same
|
||||
time."*
|
||||
|
||||
Measured before changing anything: at 10× a pile stays lit for **6997ms**, just under seven seconds.
|
||||
So the highlight was not brief at all. It was a single 0.45s flash-in over a dark green fill, easy to
|
||||
miss entirely while looking at the district — a state that settles stops asking to be looked at. It
|
||||
pulses now for as long as the move is up, with a ring and a glow. The reduced-motion fallback is loud
|
||||
in a different way rather than simply still, since motion is the whole point here.
|
||||
|
||||
### The ceiling was not theoretical
|
||||
|
||||
*"At 10× — still a bit fast, but followable."* 10× was the top of the ladder, so the control's
|
||||
slowest setting was not slow enough for the person using it. `PACE_LEVELS` now runs to 20 and
|
||||
`MAX_PACE` with it. A control whose limit is reached in ordinary use has the wrong limit, not the
|
||||
right one held firmly.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.5 — 2026-09-10
|
||||
|
||||
**Somewhere to look.** Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast for
|
||||
me to see."* At 10× an action holds the screen for seven seconds, so this was never about duration —
|
||||
it was that a bot drawing a card changes one number in a panel nobody is watching, and the board sits
|
||||
unchanged for those seven seconds. **Raising the dwell was the wrong lever, and it had been pulled
|
||||
three times.** His diagnosis was the right one: mark WHERE, not longer.
|
||||
|
||||
### The Home Office deck was never drawn
|
||||
|
||||
`f.deck` has carried the face-down count since the Frame existed and **nothing in `src/web/` read
|
||||
it** — the exact display gap `test/display-gaps.test.ts` was written to sweep for, surviving in the
|
||||
one panel that draws every other pile. It is a tile now, first in the row, because that is the order
|
||||
a card travels: out of the deck, into a hand, then onto a Department or the Salvage Yard. Face down,
|
||||
so its card slot says so rather than naming one — not knowing what is on top is the point of the
|
||||
pile.
|
||||
|
||||
### What lights, and why that is exactly what is public
|
||||
|
||||
The piles a move touched are now lit for as long as that move is on screen. **Derived, never sent**:
|
||||
the client already holds the frame before a step and the frame after it, so `changedPiles()` is a
|
||||
diff. Nothing is added to the protocol, nothing can drift out of step with the projection, and the
|
||||
0.8.1 seatless board gets it for free.
|
||||
|
||||
Measured across four seeds rather than reasoned about, and pinned by a test that requires each case
|
||||
to have actually occurred rather than passing on whichever the bot happened to play:
|
||||
|
||||
| action | lights | why that is public |
|
||||
| --- | --- | --- |
|
||||
| `draw.fromHomeOffice` | the deck | the count, never the card — a blind draw stays the drawer's |
|
||||
| `draw.fromDepartment` | that Department, and the deck when it refills | the pile is face up, so the card taken is public |
|
||||
| `card.discard` | that Department | face up, and which pile it went on is the point |
|
||||
| `card.play` | the Salvage Yard | where a played card that did not stay on the board lands |
|
||||
| switching, new trains | nothing here | they move the board, which the district panel already follows |
|
||||
| `*.end`, `localOps.choose` | nothing | no card moved |
|
||||
|
||||
**It is a state, not a flash**, and that distinction is the whole reason it works. The timetable's
|
||||
existing `.tt-slot.fresh` animates for a fixed 1.5s — right for a die roll nobody is waiting on, and
|
||||
wrong here, because a step can hold for seven seconds and the animation would be long over before
|
||||
the pause it belongs to. A brief flash-in marks the moment; the lit border and background stay for
|
||||
exactly as long as the step is up.
|
||||
|
||||
**Not for your own moves.** You drew that card — the same rule that already gives your own steps no
|
||||
dwell.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.4 — 2026-09-09
|
||||
|
||||
**Housekeeping: the test server's name is out of the ten places this session put it.**
|
||||
|
||||
Both of this project's repositories allow anonymous clone — checked, not assumed: `info/refs` for
|
||||
`git-upload-pack` answers 200 for `station-master` and for `station-master-startos` alike, while
|
||||
`git-receive-pack` answers 401. So everything committed here is public, and the standing rule is that
|
||||
tracked files carry placeholders rather than real hosts.
|
||||
|
||||
Ten mentions added while building v0.8.0 are now "the test server" or "the target hardware":
|
||||
`CHANGELOG.md`, `docs/plans/jitsi-common-board.md` (three identical deferral banners), `sim/pacing.ts`,
|
||||
`test/pacing.test.ts` and `test/step-queue.test.ts`. Prose and comments only — no behaviour, and the
|
||||
quotes they carry are unchanged, because what a player said about bot pacing is the part worth
|
||||
keeping.
|
||||
|
||||
**What is deliberately left, and why it is not an oversight:**
|
||||
|
||||
- **Nineteen older mentions**, in entries about v0.7.5, v0.7.6 and v0.7.8 and in `TODO.md`. Rewriting
|
||||
a changelog after the fact makes the record less true, and these describe verification that
|
||||
genuinely happened on that machine.
|
||||
- **`scripts/deploy-web.ts` is FUNCTIONAL, not prose.** It carries the host as the default for
|
||||
`FB_URL`, so a placeholder there would break the deploy for the person the default exists to serve.
|
||||
Same for the public address it publishes to. If those should move to required environment variables
|
||||
with no default, that is a change to how deploying works and wants deciding on its own rather than
|
||||
being smuggled in beside a comment sweep.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.3 — 2026-09-09
|
||||
|
||||
Three things from playing v0.8.0.2, all of them about the row rather than the mechanism.
|
||||
|
||||
### Skip was at the wrong end of the row
|
||||
|
||||
Jesse: *"the skip button should be on the far left, in front of where it says [the count], so it's
|
||||
always close to where people are looking."* It was on the far right, and a player's eye is on the
|
||||
countdown. Moved.
|
||||
|
||||
### The caption said what, but never who
|
||||
|
||||
*"I saw 2 behind, 1 behind, and then it was caught up, but it didn't tell me what the actual action
|
||||
was, like who I was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I
|
||||
was supposed to be looking for."*
|
||||
|
||||
The caption was there. It was the wrong half of the sentence. **Measured over 40 turns of a real
|
||||
3-seat game, half the waiting is automatic phases** — 21.0s of phases against 21.7s of other players —
|
||||
and a phase narrates as "Mainline", which is accurate and no answer at all to "who am I waiting on".
|
||||
A phase now introduces itself: **"The Division: ▸ Mainline phase"**. A player's move already carries
|
||||
its name from `record()`, so it is left alone rather than stuttering it twice.
|
||||
|
||||
**And the row was hiding a step early.** It was shown only while `behind > 0` — which goes false the
|
||||
moment the LAST step of a burst goes up, so the one step a player was most likely to be reading about
|
||||
lost its caption. It now stays up while the queue is still showing something, and reads "catching up"
|
||||
once nothing is queued behind.
|
||||
|
||||
### The speed control was stretching the clock, not just the other players
|
||||
|
||||
*"After my turn, when I actually execute my turn, I'm still subject to that same delay before it
|
||||
moves on. That makes no sense. Since I've just done my turn, I don't need to wait after it."*
|
||||
|
||||
He was right, and it was not his move being replayed — own moves have cost nothing since v0.8.0.1. It
|
||||
was the automatic phases behind it, which were scaling with `pace` along with everything else. At 5×
|
||||
that put **105 seconds of clock-ticking** into the game, all of it after a player's own move and none
|
||||
of it anything to watch.
|
||||
|
||||
**`pace` now scales a player's move and leaves a phase at its tabled beat.** The control is labelled
|
||||
as how long another player's move is held, and that is now what it does. A phase still gets its beat
|
||||
(TODO #18) and still vanishes entirely at `pace = 0`, because off has to mean off.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.2 — 2026-09-09
|
||||
|
||||
Two things found by playing v0.8.0.1 on the test server, neither of them in the mechanism itself.
|
||||
|
||||
### `?pace=` never worked, and a whole game was played at the wrong speed
|
||||
|
||||
Jesse: *"I'm playing at pace = 7, and the bots are still moving too fast for me to follow."* At 7×
|
||||
a switching move holds for seven seconds, so that could not be calibration — and it was not. **He was
|
||||
at 1× the entire time.**
|
||||
|
||||
`index.html`'s two doors are `./play.html?lobby` and `./play.html?solitaire`. Arriving through the
|
||||
splash therefore **replaces** the query string, and `location.search` on the play page is `?lobby` —
|
||||
so `PACE_OVERRIDE` was null and it fell back to the stored setting of 1. v0.8.0 shipped `?pace=` as
|
||||
the only way to change speed and the game's own front door destroyed it. Verified rather than
|
||||
assumed: the queue at pace 7 holds a bot's turn for 32.9s with the bot's district up for 24.5s, so
|
||||
the mechanism was right and the value never arrived.
|
||||
|
||||
Fixed twice over, because one of them is the durable answer:
|
||||
|
||||
- **A speed control on the play screen**, beside zoom — `− 1× +`, persisted per viewer, reading
|
||||
through to the queue on the very next move. `PACE_LEVELS` is `0, 0.5, 1, 2, 3, 5, 7, 10`: off is
|
||||
the first rung (TODO #18's "a player who has seen it a hundred times will want it off") and the
|
||||
ladder reaches the speeds people actually reach for. At the top, a six-move switching turn takes a
|
||||
full minute to watch.
|
||||
- **The doors now carry `pace` through**, so the URL lever is honest for handing two playtesters
|
||||
different speeds — the only thing it was ever for. When one is present the control says
|
||||
`7× (URL)` and disables itself rather than showing buttons that do nothing.
|
||||
|
||||
`PACE_LEVELS` lives in `sim/pacing.ts` with `DWELL` and `MAX_PACE`, not in `main.ts` — the whole
|
||||
tuning surface in one file, and testable, which a constant inside the page entry point is not.
|
||||
|
||||
**The committed default is unchanged at 1×.** What it should be is a question for a game played at a
|
||||
speed that actually took effect.
|
||||
|
||||
### "0 today, 2 in all" — the Day-end dialog contradicted itself Jesse, at the end of a Day 1 with
|
||||
two collisions in it: *"It shows a total of two collisions, but zero today. Since we just finished day
|
||||
one, that does seem to be a contradiction."* Unrelated to v0.8.0 — this has been wrong since the
|
||||
dialog was built for Gitea#10, and nobody had played a Day with a collision in it and then read the
|
||||
summary.
|
||||
|
||||
#### One line of ordering
|
||||
|
||||
`advance.ts`, at the rollover:
|
||||
|
||||
```ts
|
||||
s.clock.day += 1;
|
||||
s.collisionsToday = 0;
|
||||
```
|
||||
|
||||
And `noteDayEnd()` fires when `f.day` goes UP — so the dialog reporting the Day that just finished is
|
||||
drawn from the very frame in which that Day's count was zeroed. It printed the *new* Day's zero beside
|
||||
a running total that could not possibly agree with it. Reproduced on four of five seeds before
|
||||
touching anything: Day 1 ended with `today=3 total=3`, and the dialog read `today=0 total=3`.
|
||||
|
||||
**Not derivable on the client, which is why the fix is in the engine.** A Day turns over inside the
|
||||
phases that run themselves, so in multiplayer the push announcing the new Day is the same push that
|
||||
carries the reset — a client may never see the ended Day's final count to remember it. So
|
||||
`collisionsPrevDay` is captured in state at the rollover, immediately before the reset, and rides on
|
||||
the frame like the other two counts.
|
||||
|
||||
#### And "today" was the wrong word anyway
|
||||
|
||||
Even with the right number, a dialog headed "Day 1 has ended" should not say "today" — by then
|
||||
"today" is Day 2. It now names the Day: **"Collisions: 2 on Day 1, 2 in all."** The end-of-game
|
||||
results screen passes no Day and keeps "today", where the Day has not turned over and the word is
|
||||
accurate.
|
||||
|
||||
`test/redaction.test.ts`'s allow-list did its job on the way through: adding a public property failed
|
||||
the suite until it was declared out loud.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.1 — 2026-09-09
|
||||
|
||||
**Bot play was way too fast.** v0.8.0 was installed on the test server and played within the hour;
|
||||
Jesse: *"I briefly saw that it was the bot's office area then their turn was done and it pointed back
|
||||
to my office area"*, and the countdown row appeared "very briefly". Everything else looked right —
|
||||
the bots were visibly doing things — so this is calibration and one real bug, not a redesign.
|
||||
|
||||
### The bug: the last step of a burst never got its moment
|
||||
|
||||
`busy()` was `pending.length > 0`. So the instant the FINAL step of a burst was shown, the queue
|
||||
reported itself idle — the animation loop stopped and, because the district panel follows `busy()`,
|
||||
it snapped back to the viewer's own board without that step ever being looked at. The countdown row
|
||||
went with it. `busy()` is now `pending.length > 0 || dueAt !== null`: there is more to come, **or**
|
||||
what is on screen has not had its moment yet.
|
||||
|
||||
### The calibration: 250ms was invented, and it was wrong
|
||||
|
||||
Jesse's instruction had been "start at 1s and tune down". That was applied to switching and then a
|
||||
250ms `action` tier was made up beside it, which held for the case the design was measured against —
|
||||
a switching burst — and failed the common one. **Switching is not legal until there is track down**,
|
||||
so an early-game bot turn contains none of it. Measured from a real 3-seat game, one bot turn was:
|
||||
|
||||
```
|
||||
localOps.choose 0ms · draw.fromHomeOffice 250ms · card.play 250ms
|
||||
draw.end 0ms · localOps.choose 0ms · freightAgent.stockOutbound 250ms
|
||||
```
|
||||
|
||||
**750ms for a whole turn.** `action` is now 700ms, which puts that same turn at 4.7s.
|
||||
|
||||
**And `localOps.choose` was the worst of it.** It was classed as bookkeeping, at zero — but it is the
|
||||
line reading *"Player Bot 1 chose to SWITCH — six Moves to shunt cars around the yard"*: the heading
|
||||
for everything that follows. A bot's turn began with no indication of what it was about to do. It is
|
||||
an announcement, and it is now in `action`.
|
||||
|
||||
### The viewer's own moves cost nothing
|
||||
|
||||
Raising `action` exposed a waste: your own click was being held for 700ms before the bots' turn
|
||||
started animating. A seated player's own board is drawn from their authoritative `Frame`, never from
|
||||
the queue, so replaying their own move shows them nothing and delays the thing they wanted to watch.
|
||||
Own steps are still applied — the delta chain runs through them — but at zero dwell. Automatic phases
|
||||
have no player and are unaffected, which is what keeps #18 working in solitaire, where every intent
|
||||
is the viewer's own.
|
||||
|
||||
### Faster and slower, without a rebuild
|
||||
|
||||
`pace` multipliers above 1 are supported and expected — Jesse asked for 2 and 3 — bounded by a new
|
||||
`MAX_PACE` of 10 so that `?pace=300` from somebody meaning 3.00 cannot look like a frozen board.
|
||||
Every tier scales by the same factor, so **a switching move outlasts an ordinary action at 0.5× and
|
||||
at 3× alike**: the relative weighting is the design, and the multiplier is only how fast it runs.
|
||||
|
||||
Whole-game animation is now ~5.7 minutes across a 6-day game.
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0 — 2026-09-09
|
||||
|
||||
**Watching the table.** TODO #13, #15 and #18, which is Gitea#20 steps 2-4 pointed at a seated
|
||||
player's own screen. Jesse, 2026-08-29: *"It's not fun to do my turn and have magic happen in the
|
||||
background and then have to figure out what others did."* And 2026-09-09, on what he most wants to
|
||||
see: *"I definitely want to watch other players struggle with the switching exercises … I don't
|
||||
think reading the switching in the log will be anywhere nearly as interesting as watching the trains
|
||||
actually move on the board."*
|
||||
|
||||
The release was scoped in conversation: **0.8.0 is this, 0.8.1 is the seatless board page, 0.9.0 is
|
||||
the Jitsi publisher** — *"13 is the key. Watching on a TV is the bonus."* The design is
|
||||
`docs/plans/jitsi-common-board.md` § v0.8.0.
|
||||
|
||||
### The correction that set the scope
|
||||
|
||||
`Frame.cells` is ONE district — the viewer's own, built from `areaOf(s, viewer)`. So a step stream
|
||||
alone does not answer #13: the data would arrive with nowhere to be drawn. **Rendering a district
|
||||
you do not own is the feature**, not part of the seatless page it had been filed under. Nothing
|
||||
needed to change in `officeSvg` to do it — it takes board data and has never wanted a private
|
||||
viewer, which is why `PublicDistrict` renders as-is.
|
||||
|
||||
### One hook, not two, and replay inert for free
|
||||
|
||||
The design anticipated wiring a collector into `GameSession.intent()` and `driveBots()` separately,
|
||||
with solitaire doing its own thing. It needs neither: `src/server/session.ts` imports `submit` from
|
||||
`src/web/game.ts`, so solitaire, live multiplayer and every bot turn already funnel through one
|
||||
function. That is also what makes this a special case of multiplayer rather than a second
|
||||
implementation.
|
||||
|
||||
And `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
|
||||
than `submit`, so a resumed server does not re-emit a whole game as steps. The plan expected that to
|
||||
need a guard. It needs none — but the property is load-bearing rather than lucky, so it is pinned by
|
||||
test.
|
||||
|
||||
### Pacing, decided by measurement
|
||||
|
||||
The obvious scheme is a time budget divided by the queue length. Measured against real games it does
|
||||
exactly the wrong thing: 44 of a 307-intent game are `draw.end` and 60 are `loadUnload.end`, while
|
||||
the thing worth watching is rare and clustered — two of the three published replays contain no
|
||||
`switch.move` at all, and the third has bursts of **14, 6, 6 and 6**. Six is the engine's own cap per
|
||||
crew, which `trayMoved` says out loud ("N of 6 Moves left"). A uniform budget spends the player's
|
||||
attention on bookkeeping and rushes the switching.
|
||||
|
||||
So dwell is assigned **by kind**: switching 1000ms (Jesse: *"start at 1s and tune down"*), an
|
||||
ordinary action 250ms, a phase 600ms, bookkeeping zero. Three tuning levels, because the committed
|
||||
table needs a web rebuild and in the `.s9pk` that is a release: the table, a per-viewer `pace`
|
||||
multiplier in `Settings` where **0 turns it off**, and a `?pace=` URL parameter for handing two
|
||||
playtesters different speeds. Deliberately **not** in game-creation settings — dwell is presentation,
|
||||
not a rule, and a `GameConfig` rides along in saves and replays.
|
||||
|
||||
**Cost, measured:** about **4.4 minutes of animation across a whole 6-day game**, of which phases are
|
||||
now the largest slice and therefore the first dial to turn.
|
||||
|
||||
### TODO #18 needed a stepped pump, not a delay
|
||||
|
||||
`pump()` runs every automatic phase between one click and the next and `drain()` records the whole
|
||||
batch, so New Train, the Mainline and the shift change were never drawn at all. Folding them into the
|
||||
triggering intent's step reproduced exactly that. `submit()` now steps `advance()` one call at a time
|
||||
and collects per phase; `drain()` is untouched, because replay, undo and `fromSave` all use it and
|
||||
the inertness above depends on their staying off that path.
|
||||
|
||||
**Two obvious rules for what earns a beat were both wrong, and both are now pinned by test.** "No
|
||||
narration, no dwell" looked right and silently killed #18 — a phase can move trains without saying
|
||||
anything. "Anything that changed the board" beat on every turn hand-off, and `submit()` steps
|
||||
`advance()` about 4.6 times per intent, which came to a quarter of an hour a game. The rule is that
|
||||
the **clock turning over** earns the beat.
|
||||
|
||||
### The counter, which is Jesse's design
|
||||
|
||||
*"If I saw the counter as I'm watching the board go 17, 16, 15 … and I got impatient, I could just
|
||||
click a button and have it skip all the rest."* One row rather than three additions — the countdown,
|
||||
#15's caption naming the action being shown, and Skip. It counts only the steps that will actually
|
||||
**dwell**: with bookkeeping at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to
|
||||
5 instantly and then crawl, which is not a countdown anyone can act on. Skip costs the animation and
|
||||
never the information — every line is already in the History panel.
|
||||
|
||||
This also settled the question the design had left open: an "it's your turn" that arrives while the
|
||||
board is still catching up is confusing, and showing the lag beats both alternatives (holding the
|
||||
turn indicator back, or saying nothing).
|
||||
|
||||
### Switching logged unattributed, and now names its train
|
||||
|
||||
`record()` attributes a line only when the event carries `player`. **`trayMoved`, `carsCoupled`,
|
||||
`carsDropped` and `consistSorted` were the only events in their class that did not** — so a switching
|
||||
turn read as an attributed bracket around anonymous contents: "Player Alice chose to switch / CREW
|
||||
moved (1,2) → (1,3) / Player Alice finished Local Operations". Fixed with the feature that reads
|
||||
those lines rather than filed. Two texts were reworded to compose with the prefix, because
|
||||
`uncapitalise` deliberately protects acronyms and "Player Alice CREW moved" is what it would
|
||||
otherwise have produced. On Jesse's ask the move now names its train — "moved Train 3 (1,2) → (1,3)"
|
||||
— using the existing `trainName`, since a second way of naming a train is the drift this codebase
|
||||
avoids.
|
||||
|
||||
Saves are unaffected: a save is a seed and a list of intents, and events are derived.
|
||||
|
||||
### The delta had to become a true partial
|
||||
|
||||
Steps carry a `PublicFrame` delta. The first version spread `...next` and nulled only the board
|
||||
fields, so every step shipped all 35 top-level properties even when the only change was whose turn it
|
||||
was. Once #18 gave phases their own steps most steps became exactly that, and a full game cost
|
||||
**19.4 MB, of which 16.7 MB was silent steps at ~11 KB each**. As a true partial — only changed
|
||||
fields, only changed districts — the same game is **8.7 MB**. Districts are keyed by seat rather than
|
||||
compared as one array, which alone saves 22%: one intent changes one district, and a whole-array
|
||||
compare resends every other player's board every step.
|
||||
|
||||
### The redaction net grew to cover the steps, and grew two exemptions
|
||||
|
||||
The steps are folded into `everythingSeatSees`, so every existing case covers them — the blind draw,
|
||||
the pending decision, Employee Rotation before and after the seating moves, the reconnect, the
|
||||
played-out game. Doing that surfaced two false positives in the name-based heuristic, **neither
|
||||
caused by this feature**, and the distinction they forced is worth keeping: **a card NAME is
|
||||
circumstantial, a card ID is proof.** Ids are searched everywhere. Names are not searched in two
|
||||
places entitled to carry them — lines naming a **face-up pile** (§2.6: a Department is public, so
|
||||
"Ann discarded Train 6 face-up on Department 3" is the record working, and it stays in the log after
|
||||
she takes it back), and the **accumulated step frames**, which record what was public over time
|
||||
rather than the position now.
|
||||
|
||||
The harness was also passing `g.log` into `snapshot()` for the Frame's own lines, which **production
|
||||
has not done since #97**; now `[]`, matching `frameFor()`.
|
||||
|
||||
**Verified by injecting the v0.7.9.2 blind-draw leak and confirming the net still fails** — both the
|
||||
dedicated test and, independently, the new step coverage. A relaxed safety test that has not been
|
||||
shown to still bite is not a safety test.
|
||||
|
||||
### What is not verified
|
||||
|
||||
The mechanism is proven end to end **server-side**: a real server, a real 3-seat game with two bots,
|
||||
and 28 steps read off a live SSE stream with dense sequence numbers and a 13-step bot burst intact.
|
||||
The page is proven not to throw — `drainIntoQueue`, `renderWatching` and `watchedDistrict` all run
|
||||
under the existing DOM-stub tests. **Nobody has watched it in a browser.** The district switching to a
|
||||
bot's board, the row appearing, and Skip are unexercised, because the stub has no
|
||||
`requestAnimationFrame` and the page degrades to un-animated without one.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.9.8 — 2026-09-07
|
||||
|
||||
Housekeeping before v0.8.0 — the answer to "anything else that should be looked at first", which
|
||||
|
||||
@@ -35,8 +35,19 @@ deliberately no longer names one: it went stale for six releases.
|
||||
reload; anybody may leave and the host may clear a chair; and the four transient signals that make
|
||||
a game feel alive — sound, the timetable flash, an announcement, the badge on the card you just
|
||||
drew — reach a remote client, which they did not before v0.7.0. What is still open is in `TODO.md`
|
||||
under Multiplayer — chiefly that **a player cannot see what the others did**, and that a lost
|
||||
under Multiplayer — chiefly that a lost
|
||||
session token still locks someone out of a running game from a genuinely fresh browser.
|
||||
- **Watching the table — v0.8.0.** Every accepted move, and every automatic phase that does
|
||||
anything, becomes an ordered **presentation step**: the board replays other people's turns instead
|
||||
of arriving already rearranged. This is what closes "a player cannot see what the others did",
|
||||
which stood open through v0.7.x. A bot's whole switching turn used to land in one push, because
|
||||
`driveBots()` plays it out before the push goes back; now it arrives as a run of steps, the
|
||||
district panel follows whoever is acting, and a `[N behind] … [Skip]` row says how far the board is
|
||||
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
|
||||
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
|
||||
where its automatic phases finally get a visible beat.
|
||||
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
|
||||
the page is proven not to throw, but nobody has watched a bot switch on screen.
|
||||
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
||||
deck until they have an implementation, along with the defensive cards whose only purpose is to
|
||||
answer them), and real audio. No screen offers a control for the opponent cards any more: the
|
||||
@@ -45,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
|
||||
@@ -91,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 —
|
||||
@@ -128,32 +227,72 @@ specific paths below have not been exercised at a table. **More testing is plann
|
||||
|
||||
## The common board, and watching play happen — Gitea#20
|
||||
|
||||
**This is v0.8.0.** One shared, seatless display of the public game, usable on a TV or in OBS on its
|
||||
own and publishable into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`,
|
||||
seven steps, of which 1-4 are the useful release and 5-7 are the Jitsi publisher.
|
||||
One shared, seatless display of the public game, usable on a TV or in OBS on its own and publishable
|
||||
into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`, seven steps.
|
||||
|
||||
**THE RELEASE SPLIT, settled with Jesse 2026-09-09. Jesse: "13 is the key. Watching on a TV is the
|
||||
bonus."**
|
||||
|
||||
- **v0.8.0 — #13, #15 and #18: the watchable table.** The step collector, steps on the `Session`
|
||||
interface, **foreign-district rendering**, the client animation queue, pacing by kind, and the
|
||||
behind-counter. **The design is the plan's § v0.8.0**, which supersedes the parts of steps 2-4 it
|
||||
covers. Needs no HTTP work at all.
|
||||
- **v0.8.1 — the seatless board page.** `display.json`, `viewToken`, `/api/display/stream`,
|
||||
`/display.html`, an all-districts layout: most of step 2 and step 3 without its canvas. Cheap once
|
||||
0.8.0 lands, and nothing in it moves #13 forward.
|
||||
- **v0.9.0 — the Jitsi publisher.** Steps 5-7 plus step 3's canvas pipeline. Held off deliberately:
|
||||
it needs Chromium in the image (several hundred MB onto a 63 MB `.s9pk`) and measurement on
|
||||
`phoenix.local`, and the only self-hosted Jitsi available needs an authenticated moderator to open
|
||||
a room, so "waiting for moderator" is the ordinary path here rather than an edge case.
|
||||
|
||||
**The correction that set that split:** `Frame.cells` is ONE district — the viewer's own
|
||||
(`view.ts:510`, from `areaOf(s, viewer)`), exactly as **Reference · #13** already said. So a step
|
||||
stream alone does not answer #13; the data would arrive with nowhere to be drawn. Rendering a
|
||||
district you do not own is the core of 0.8.0, not part of the seatless page. Two other decisions
|
||||
taken with it: **no WebSocket and no new runtime dependency** (SSE down + POST up, the pattern
|
||||
`server/http.ts` already uses), and `protocolVersion` on the wire in 0.8.0.
|
||||
|
||||
**Step 1 is BUILT** — v0.7.9.2 through v0.7.9.5 (#91, #92, #95, #97), with one item struck off
|
||||
rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and now says
|
||||
which of its "current code findings" are history: it had drifted badly enough to send the next reader
|
||||
fixing things twice. Steps 2-7 were never implemented and their findings have NOT been re-verified —
|
||||
check each before building on it. See **Reference · #103**. Items that look
|
||||
like screen polish live here because they need step 4's ordered presentation mechanism and nothing
|
||||
cheaper.
|
||||
rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and again
|
||||
on 2026-09-09, and now says which of its "current code findings" are history: it had drifted badly
|
||||
enough to send the next reader fixing things twice. Steps 2-7 were never implemented; step 4's
|
||||
findings were re-verified 2026-09-09 and steps 5-7's were NOT — check each before building on it.
|
||||
See **Done · 103**. Items that look like screen polish live here because they need step 4's ordered
|
||||
presentation mechanism and nothing cheaper.
|
||||
|
||||
**The design is settled — the plan's § v0.8.0 is the authority.** In outline: public steps animate
|
||||
the board while the existing private Push supplies hand, menu and objective (so there is no new
|
||||
redaction surface); the collector is a shared `sim/` module both `LocalSession.submit()` and
|
||||
`GameSession.submit()` call, so solitaire and multiplayer run one code path; dwell is assigned **by
|
||||
kind** with switching protected at 1s and bookkeeping at zero; and a `[N behind] … [Skip]` row shows
|
||||
the lag, carries #15's caption, and never blocks input. Two measurements that decided it: a
|
||||
switching turn runs to the engine's cap of **6 moves** (bursts of 14, 6, 6, 6 in `seed-1917398`),
|
||||
and dwell-by-kind costs ~40s of animation across a 60-stage game against 3.6 minutes for a flat
|
||||
700ms.
|
||||
|
||||
- [ ] **#13** — I cannot see what the other players did — bots included. **Settled 2026-08-29 as the
|
||||
harder reading**: not log legibility but the ordered, per-action presentation of everyone else's
|
||||
turns. Jesse: "It's not fun to do my turn and have magic happen in the background." This is
|
||||
Gitea#20 step 4 pointed at a player's own screen. See **Reference · #13**.
|
||||
Gitea#20 step 4 pointed at a player's own screen. **DESIGNED 2026-09-09 — the plan's § v0.8.0.**
|
||||
The answer is foreign-district rendering with focus following the actor; without it a step
|
||||
stream has nowhere to draw, because `Frame.cells` is the viewer's district alone. See
|
||||
**Reference · #13**.
|
||||
|
||||
- [ ] **#15** — A "most recent action" line under the status block. The text already exists and is
|
||||
already correct — this is placement, not content. **Decide with #13**: in solitaire "most
|
||||
recent" is the right unit; in multiplayer what you missed is everything that happened while you
|
||||
were WAITING. See **Reference · #15**.
|
||||
were WAITING. **DESIGNED 2026-09-09 — it is the caption in the `[N behind] … [Skip]` row, not a
|
||||
separate line.** The queue IS "everything that happened while you were waiting", which is the
|
||||
unit this entry could not choose. See **Reference · #15**.
|
||||
|
||||
- [ ] **#18** — Give every phase a visible beat. Not a timing problem — `pump` runs every automatic
|
||||
phase before the page renders once, so they are never drawn at all. **A `sleep` fixes nothing;
|
||||
it needs the async stepped pump that Gitea#20 step 4 specifies**, which is why it lives here
|
||||
rather than under The screen. See **Reference · #18**.
|
||||
rather than under The screen. **DESIGNED 2026-09-09 — it is a dwell setting on the shared queue,
|
||||
not a feature.** Phases where nothing happened dwell at ZERO (Jesse: "if nothing happens during
|
||||
a phase then we shouldn't lose time to it"); a flat second per phase is rejected on the same
|
||||
arithmetic this entry already worked out. Solitaire gets it through `LocalSession`, the same
|
||||
path multiplayer gets #13 through. See **Reference · #18**.
|
||||
|
||||
- [ ] **#75** — Let the game join a call and talk to the table — the chat, audio and nudge half of the
|
||||
idea Gitea#20 took the visual half of. Long-term. See **Reference · #75**.
|
||||
@@ -328,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**.
|
||||
|
||||
@@ -1384,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
|
||||
@@ -1409,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
|
||||
@@ -1450,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.**
|
||||
@@ -1471,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
|
||||
@@ -1518,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…
|
||||
@@ -1761,7 +2053,16 @@ 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.
|
||||
|
||||
### Shipped through v0.7.9.4, from the queue
|
||||
### 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;
|
||||
the numbers stay so cross-references above and below still resolve.
|
||||
@@ -1996,11 +2297,13 @@ the numbers stay so cross-references above and below still resolve.
|
||||
`# fail 0`. `pretest` is `tsc --noEmit && node scripts/build-web.ts` now, and the same planted
|
||||
error exits 1 with the tests never running.
|
||||
|
||||
**Why it mattered THIS week rather than generally.** v0.8.0 is steps 2-7 of the common board —
|
||||
a display stream, credentials, persistence, and a Chromium supervisor — which is almost
|
||||
entirely `src/server/` and is exactly the half the test command could not see. Found while
|
||||
answering "anything else before 0.8.0", which is the only reason it was found at all: nothing
|
||||
about a green suite would ever have said so.
|
||||
**Why it mattered THIS week rather than generally.** The next release is steps 2-4 of the
|
||||
common board — a display stream, credentials, persistence and the display-step collector, which
|
||||
is almost entirely `src/server/` and is exactly the half the test command could not see. (The
|
||||
Chromium supervisor was in this list when the entry was written; steps 5-7 became v0.9.0 on
|
||||
2026-09-09, and the point stands without it.) Found while answering "anything else before
|
||||
0.8.0", which is the only reason it was found at all: nothing about a green suite would ever
|
||||
have said so.
|
||||
|
||||
103. ~~**The common-board plan had drifted from the code it is the source for.**~~ — done 2026-09-07
|
||||
in v0.7.9.8. `docs/plans/jitsi-common-board.md` was written 2026-08-27, still said "No
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
# Station Master Jitsi Common Board Implementation Plan
|
||||
|
||||
**Status (2026-09-07):** **STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.**
|
||||
**Status (2026-09-09):** **STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.**
|
||||
|
||||
Step 1 landed across four releases rather than one — v0.7.9.2 (the two narration leaks), v0.7.9.4
|
||||
(the projection helpers and the redaction net), v0.7.9.5 (the narration path), and v0.7.9.8 (this
|
||||
reconciliation). One of its items is struck off rather than built; see § Public game projection.
|
||||
|
||||
## THE RELEASE SPLIT — read this before picking up any step
|
||||
|
||||
Settled with Jesse 2026-09-09. The document below was written as one seven-step delivery and its
|
||||
ordering still reads that way, so this section and § v0.8.0 are the authority on what belongs where.
|
||||
|
||||
| Release | What | Why here |
|
||||
| --- | --- | --- |
|
||||
| **v0.8.0** | **TODO #13/#15/#18 — the watchable table.** The step collector, steps on the `Session` interface, foreign-district rendering, the client animation queue, pacing, and the behind-counter. § v0.8.0 is the design. | #13 is the point. *"Watching on a TV is the bonus"* — Jesse, 2026-09-09. |
|
||||
| **v0.8.1** | **The seatless board page.** `display.json`, `viewToken`, `/api/display/stream`, `/display.html`, an all-districts layout. Most of step 2, and step 3 without its canvas. | Cheap once 0.8.0's foreign-district rendering exists; nothing in it moves #13 forward. |
|
||||
| **v0.9.0** | **The Jitsi publisher.** Steps 5-7, plus step 3's canvas capture pipeline. | Needs Chromium in the image and hardware measurement. Held off deliberately. |
|
||||
|
||||
**What 0.8.0 is FOR, in Jesse's words (TODO #13, 2026-08-29):** *"It's not fun to do my turn and have
|
||||
magic happen in the background and then have to figure out what others did."* And 2026-09-09, on what
|
||||
he most wants to watch: *"I definitely want to watch other players struggle with the switching
|
||||
exercises … I don't think reading the switching in the log will be anywhere nearly as interesting as
|
||||
watching the trains actually move on the board."*
|
||||
|
||||
### Decisions taken 2026-09-09 that the text below has NOT been rewritten around
|
||||
|
||||
- **`Frame.cells` is ONE district — the viewer's own** (`view.ts:510`, from `areaOf(s, viewer)`). So a
|
||||
step stream alone does not answer #13: the data would arrive and have nowhere to be drawn.
|
||||
**Rendering a district you do not own is the core of 0.8.0, not part of the seatless page.** This
|
||||
is the correction that set the split above; it was mis-assigned until 2026-09-09.
|
||||
- **No WebSocket, and no new runtime dependency.** The control channel exists only to join the
|
||||
supervisor to the headless agent — a handful of messages per publisher lifetime, on loopback.
|
||||
`package.json` has no `dependencies` key and the wrapper's runtime image copies only
|
||||
`package.json` and `src/` with no `node_modules`, so adding `ws` changes the deployment model
|
||||
rather than adding a dependency. Use SSE down + POST up, the pattern `src/server/http.ts` already
|
||||
implements for players (`/api/stream` + `/api/intent`). The proven Jitsi code ports either way:
|
||||
`engine/communications/CommunicationsClient.ts` in the sibling repo references the control layer
|
||||
in two comments and nothing else. **0.9.0 work.**
|
||||
- **`protocolVersion` is added in 0.8.0.** See § Public game projection.
|
||||
- **0.8.0 needs no HTTP work at all.** The `startServer()` refactor and the project's first HTTP test
|
||||
harness existed to test `/api/display/stream`, which is now 0.8.1. `Push.steps` is built in
|
||||
`session.ts`, and `broadcastGame()` forwards whatever push it is handed, so `http.ts` does not
|
||||
change and the tests land in the existing `test/server/session.test.ts`.
|
||||
- **Solitaire is a special case of multiplayer, not a second implementation** — Jesse's stated design
|
||||
direction, to minimize rule and implementation drift. `src/web/session.ts` already draws that seam:
|
||||
*"the page … does not care whether the rules are being applied a function call away or across a
|
||||
network."* Every mechanism below hangs off `Session`, with `LocalSession` and `RemoteSession` both
|
||||
feeding it. Solitaire therefore gets #18 through the same code path multiplayer gets #13 through.
|
||||
|
||||
**This document has drifted from the code and is no longer the authority on what exists.** It was
|
||||
written on 2026-08-27 against the code of that date, and the "Current code findings" under each step
|
||||
describe faults that were then real — several are now fixed, and reading them as present tense will
|
||||
@@ -13,6 +55,262 @@ send you to fix things twice. Where a step is marked built, `src/sim/view.ts`, `
|
||||
tests named in TODO.md are the authority. Steps 2-7 were never implemented and their findings have
|
||||
NOT been re-verified against the current code; check each before building on it.
|
||||
|
||||
## v0.8.0 — The watchable table
|
||||
|
||||
**This section supersedes the parts of steps 2-4 it covers.** Where it and a step below disagree,
|
||||
this wins; the steps keep the material that is still 0.8.1/0.9.0 work. Designed with Jesse
|
||||
2026-09-09 in conversation; every measurement quoted was taken from the code and the published
|
||||
replays that day.
|
||||
|
||||
### The shape
|
||||
|
||||
One mechanism, four consumers, no branch between solitaire and multiplayer:
|
||||
|
||||
```
|
||||
submit() ──► collector (sim/) ──► DisplayStep
|
||||
│
|
||||
┌───────────────────┴───────────────────┐
|
||||
LocalSession.steps() Push.steps ──► RemoteSession.steps()
|
||||
└───────────────────┬───────────────────┘
|
||||
▼
|
||||
client animation queue (one impl)
|
||||
▼
|
||||
board render · caption · behind-counter · skip
|
||||
```
|
||||
|
||||
### 1. The collector
|
||||
|
||||
> **BUILT 2026-09-09** — `src/sim/display-step.ts`, `test/watchable.test.ts`. Two things below were
|
||||
> wrong in a way worth recording, because both made the job smaller.
|
||||
|
||||
**ONE HOOK, NOT TWO.** This section said to wire two call sites in `GameSession` and let
|
||||
`LocalSession` do its own thing. It does not need to: **`src/server/session.ts` imports `submit`
|
||||
from `src/web/game.ts`**, so solitaire, live multiplayer and every bot turn already funnel through
|
||||
one function. Collecting inside `submit()` covers all three, and *that* is what makes solitaire a
|
||||
special case of multiplayer here rather than a parallel implementation.
|
||||
|
||||
**REPLAY IS INERT FOR FREE.** `fromSave` and `fromMultiplayerSave` rebuild a game with `applyIntent`
|
||||
+ `record` + `drain` directly rather than through `submit`, so a resumed server does not re-emit the
|
||||
whole game as steps. No guard is needed. But the property is load-bearing rather than lucky — move a
|
||||
replay path onto `submit()` and it silently becomes the #97-class bug this section feared — so
|
||||
`test/watchable.test.ts` pins it.
|
||||
|
||||
- One step per accepted intent, **including** automatic work drained behind it. Never one step per
|
||||
`GameEvent` — an intent drains `pump()` work and the event list is not a complete reducer.
|
||||
- **Narration's high-water mark is taken inside `submit()`**, which brackets `record` and `drain` and
|
||||
is therefore the only place that knows what one intent said. Not `sentLines`: that is per-seat and
|
||||
is *mutated* by `linesSince()` as a side effect of building a push.
|
||||
- Capture the frame immediately; never retain a mutable `GameState` reference for later projection,
|
||||
or every retained reference resolves to the final state.
|
||||
- Accumulated on `Game` beside `log`, `cues` and `announced` and drained by `takeSteps()` the way
|
||||
`takeMoment()` drains the rest — the established convention for "the model accumulated something,
|
||||
the view takes it". `pushesForAll()` drains ONCE per broadcast, not per seat.
|
||||
|
||||
### 2. Delivery — on the `Session` interface
|
||||
|
||||
`Session` gains steps. `LocalSession` emits them from its own `submit()`; `RemoteSession` reads them
|
||||
off `Push.steps`. The page consumes one queue and cannot tell which it has.
|
||||
|
||||
`Push` gains `steps?: DisplayStep[]`, following the **`presence` precedent** — `Push.frame` is
|
||||
already optional, and presence went in as a field rather than a second SSE event type for the reason
|
||||
recorded at `http.ts:210`: *"one message shape for the client to parse."*
|
||||
|
||||
`http.ts` does not change. `broadcastGame()` forwards whatever push `session.ts` builds.
|
||||
|
||||
### 3. What a step carries, and what animates
|
||||
|
||||
**Public steps animate the board; the private `Frame` supplies hand, menu and objective.**
|
||||
|
||||
Your hand never changes because somebody else moved. What can change splits cleanly: revenue is
|
||||
already in `PublicFrame` (`players` carries it) so it animates; `menu` and `blocked` are recomputed
|
||||
and arrive with the final coalesced Push, as today. So a step carries a `PublicFrame` delta and
|
||||
**adds no new redaction surface** — it reuses the projection `test/redaction.test.ts` already guards.
|
||||
|
||||
Rejected: emitting N per-seat redacted `Frame`s per intent. It multiplies both the projection work
|
||||
and the redaction test surface, and buys nothing — a seated step would be a `Frame`, which is
|
||||
redacted per seat, so it could not reuse the public delta anyway.
|
||||
|
||||
**Delta the districts per seat, not as one array.** Measured 2026-09-09 over a 300-step 4-player
|
||||
game (`sim/public-delta.ts` now built, `test/public-delta.test.ts` pins reconstruction): a full
|
||||
frame every step is **20.4 KB/step, 6.0 MB** over the game; a `frame-delta`-style whole-array
|
||||
compare is **3.0 MB** (51%); keying by seat is **2.4 MB** (40%), saving a further **657 KB, 22%**
|
||||
over the whole-array form. `sim/frame-delta.ts` hardcodes
|
||||
`BOARD_KEYS = ['cells','facilities','division']` against `Frame`; `PublicFrame` has no top-level
|
||||
`cells`/`facilities` — they live inside `districts[]`, one per seat, which is where nearly all the
|
||||
bytes are. One accepted intent changes one district, so a whole-array comparison resends every other
|
||||
player's board on every step. Keep `deltaFrame`/`applyDelta`'s "null means unchanged" convention;
|
||||
replace the key set.
|
||||
|
||||
**`protocolVersion` goes on the ENVELOPE, not on `PublicFrame`.** The original sketch put it inside
|
||||
the frame; it does not belong there. `PublicFrame`'s property list is an allow-list that
|
||||
`test/redaction.test.ts` enumerates, so a transport concern living in it would have to be declared
|
||||
public *game state*, which it is not. The step is the message; the message carries the version.
|
||||
Built as `DISPLAY_PROTOCOL_VERSION` in `sim/display-step.ts`.
|
||||
|
||||
### 4. Foreign-district rendering — THIS IS #13
|
||||
|
||||
`Frame.cells` is one district, the viewer's own. A step stream without this is data with nowhere to
|
||||
go, so this is the feature rather than a supporting part of it.
|
||||
|
||||
- Render any seat's district from `PublicDistrict`.
|
||||
- **Focus follows the actor** — the district panel shows whoever is acting, theirs while they switch
|
||||
and yours when it is your turn. Same rule the seatless board will use in 0.8.1: acting player's
|
||||
seat, else most recent actor's seat, else seat zero.
|
||||
- **Resolve owner from seat on every frame**, so Employee Rotation relabels a district in place
|
||||
rather than moving it.
|
||||
- `Frame` already carries `whereFrom` — *"Origin of a Move, so the crew's journey is visible rather
|
||||
than a chip teleporting"* — which is the same idea for the viewer's own moves. Extend it, don't
|
||||
invent a second one.
|
||||
|
||||
### 5. Pacing — dwell by kind
|
||||
|
||||
**Measured 2026-09-09, and the measurements decide the model.** From `public/replays/`: ~60 stages
|
||||
per game and ~5 intents per player per stage, so a 4-player table generates **~15 other-player steps
|
||||
per stage**. Burst sizes inside one switching turn, in the switching-heavy seed
|
||||
(`seed-1917398`): **14, 6, 6, 6** — and `trayMoved`'s own narration says *"N of **6** Moves left"*,
|
||||
so six is the engine's cap per crew. Two of the three published replays contain **zero**
|
||||
`switch.move`: bot switching is clustered, not spread.
|
||||
|
||||
So a uniform budget spread over the queue does exactly the wrong thing — it steals time from the
|
||||
6-move switching burst to spend on the 44 `draw.end` and 60 `loadUnload.end`. **Assign dwell by kind
|
||||
and let the total fall out.**
|
||||
|
||||
`sim/pacing.ts` — shared, so the 0.8.1 seatless page paces identically and the TV and the play screen
|
||||
never disagree about how fast the game looks:
|
||||
|
||||
```ts
|
||||
export type StepKind = 'switching' | 'action' | 'bookkeeping';
|
||||
|
||||
/** THE TUNING TABLE. Dwell in ms per kind. Start generous; tune down by playing. */
|
||||
export const DWELL: Record<StepKind, number> = {
|
||||
switching: 1000, // switch.move / dropCars / sortConsist / maneuver.*
|
||||
action: 250, // draw.from* / card.* / newTrain.placeCar / porter.* / laborer.*
|
||||
bookkeeping: 0, // *.end, and any phase where nothing happened
|
||||
};
|
||||
```
|
||||
|
||||
Jesse 2026-09-09: **start switching at 1s and tune down**, and *"make sure that the tuning parameters
|
||||
for the delays are easy to set."* Three levels, deliberately:
|
||||
|
||||
| Level | Where | Reach |
|
||||
| --- | --- | --- |
|
||||
| Committed default | the table above | needs a web rebuild — in the `.s9pk`, a release |
|
||||
| Live per-viewer | `pace` in `Settings` (`localStorage`), a multiplier; **`0` = off** | no rebuild |
|
||||
| Per-session | `?pace=` URL parameter | matches the existing `?seed=` convention; hand two testers different links |
|
||||
|
||||
**Not in game-creation settings.** Jesse, 2026-09-09: *"for now, they should not be in the game
|
||||
creation settings, but we may want to put them there later."* Correct on its own terms — dwell is
|
||||
presentation, not a rule, and `config` rides along in saves and replays. `Settings` already carries
|
||||
the argument for this: *"A save is the seed plus the intents and has to stay portable; none of this
|
||||
belongs in it, and in a multiplayer game two players may reasonably want these set differently."*
|
||||
The migration path is cheap: because the table is shared and the override is one scalar, moving it to
|
||||
game config later means adding a config field that supplies the multiplier's default. The table, the
|
||||
classification and the queue do not change.
|
||||
|
||||
**Pacing is client-side only.** The server emits steps as fast as it likes, which is what keeps
|
||||
Gitea#20's *"do not slow the authoritative game"* true.
|
||||
|
||||
**Arithmetic, because it is the reassuring part:** ~16s of switching plus ~25s of one-shots ≈ **40s
|
||||
of animation across a whole 60-stage game**, against 3.6 minutes for a uniform 700ms. Tiering gives
|
||||
*better* switching visibility for a fifth of the total time. One complete switching exercise is
|
||||
6 × 1s = 6s to watch.
|
||||
|
||||
### 6. The behind-counter, the caption, and skip
|
||||
|
||||
**Jesse's design, 2026-09-09**, and it resolves the "it's your turn but the board is stale" question
|
||||
that had two unattractive answers before it (hold the turn indicator, or show both silently):
|
||||
|
||||
> *"If I saw the counter as I'm watching the board go 17, 16, 15 … and I got impatient, and I could
|
||||
> just click a button and have it skip all the rest."*
|
||||
|
||||
One row, not three additions — the slot between `#turnchart` and `<main>` already holds
|
||||
`#phasenote`, `#announce` and `#presence`, and the palette is spoken for (violet = where you are,
|
||||
amber = clickable, green/red = good/bad):
|
||||
|
||||
```
|
||||
[13 behind] Player Alice moved Train 12 (1,2) → (1,3) via (1,1) [Skip]
|
||||
```
|
||||
|
||||
- The counter **is** `queue.length` — client-side, derived, **zero protocol impact**.
|
||||
- **Count only steps that will dwell.** With bookkeeping at 0ms, a backlog of 17 where 12 are `*.end`
|
||||
would read "17", plummet to 5 instantly, then crawl. Thirteen dwelling steps means thirteen things
|
||||
you are going to watch.
|
||||
- The caption is **#15**. TODO's Reference · #15 asks what the unit should be and concludes that in
|
||||
multiplayer it is *"everything that happened while you were WAITING"* — which is what the queue
|
||||
holds. So #15 is this row, not a separate feature.
|
||||
- **Skip costs the animation and never the information.** Everything skipped is already in the history
|
||||
log. That is what makes the button safe to press without hesitation.
|
||||
- Amber for the button. Self-hides at zero, so solitaire only sees it during an automatic-phase run.
|
||||
- **Input is never blocked.** Any input skips to current. `pace = 0` turns the whole thing off, which
|
||||
is also TODO #18's *"a player who has seen it a hundred times will want it off"* — no second
|
||||
mechanism for it.
|
||||
|
||||
### 7. #18 becomes a dwell setting
|
||||
|
||||
Not a feature. Phases where nothing happened dwell at zero — Jesse, 2026-09-09: *"if nothing happens
|
||||
during a phase then we shouldn't lose time to it"*, and on solitaire, *"you kind of look to see and
|
||||
guess, 'Oh, I guess nothing happened in those phases'"*, which is acceptable. Phases where something
|
||||
happened get a beat through the same queue.
|
||||
|
||||
**A flat second per phase is explicitly rejected**: 5 phases × ~60 stages is about five minutes per
|
||||
game of enforced dwell, most of it spent on phases where nothing happened. TODO's Reference · #18
|
||||
reached the same figure from the other direction (48s/Day).
|
||||
|
||||
Solitaire inherits all of this through `LocalSession` rather than being special-cased.
|
||||
|
||||
### 8. Carried along — switching logs unattributed
|
||||
|
||||
`record()` attributes with `const mine = who !== null && 'player' in e` (`web/game.ts:1188`).
|
||||
**`trayMoved`, `carsCoupled`, `carsDropped` and `consistSorted` carry no `player` field** — measured
|
||||
2026-09-09, and they are the only events in their class that do not. `cardDrawn`, `cardPlayed`,
|
||||
`cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`, `flyingSwitch` and
|
||||
`localOpsOptionChosen` all do.
|
||||
|
||||
So switching — the one class of action Jesse most wants to follow — logs unattributed and with
|
||||
`tone: 'plain'` instead of `'act'`, meaning it does not even read as somebody's move:
|
||||
|
||||
```
|
||||
Player Alice chose to switch ← attributed
|
||||
CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
|
||||
CREW coupled 2 cars ← whose?
|
||||
Player Alice finished Local Operations ← attributed
|
||||
```
|
||||
|
||||
An attributed bracket around unattributed contents. Add `player` to those four events. Fixed here
|
||||
rather than filed, because it is the same feature.
|
||||
|
||||
### Tests
|
||||
|
||||
- Collector: one human intent with no bot response; one human intent followed by several bot intents;
|
||||
consecutive bot turns; bot pending decisions; automatic engine work inside one intent; ordering of
|
||||
narration against frames; sequence continuity; player pushes still coalesced; **no steps emitted
|
||||
during `resumeSession()`**; reconstructed state matching `publicSnapshot()` after the final step.
|
||||
- Public delta: per-seat district deltas reconstruct a frame identical to a fresh full projection.
|
||||
- Redaction: **done 2026-09-09**, by folding the steps into `everythingSeatSees` so that every
|
||||
existing case covers them — the blind draw, the pending decision, Employee Rotation before and
|
||||
after the seating moves, the reconnect and the played-out game — rather than adding one test beside
|
||||
them. Doing it surfaced two false positives in the existing name-based heuristic, neither caused by
|
||||
this feature, and the distinction they forced is worth keeping:
|
||||
- **A card NAME is circumstantial evidence; a card ID is proof.** Ids are searched everywhere. Names
|
||||
are not searched in two places that are legitimately entitled to carry them: lines naming a
|
||||
**face-up pile** (§2.6 — a Department or the Salvage Yard is public, so "Ann discarded Train 6
|
||||
face-up on Department 3" is the record working, and it stays in the log after she takes it back),
|
||||
and the **accumulated step frames**, which are a record of what was public *over time* rather
|
||||
than a view of the position now. What guarantees a step frame is clean is the allow-list test on
|
||||
`PublicFrame`, not a substring search over its history.
|
||||
- The harness also passed `g.log` into `snapshot()` for the Frame's own `lines`, which **production
|
||||
has not done since #97**. Now `[]`, matching `frameFor()`. The log is still audited in full, once.
|
||||
- **Verified by injecting the v0.7.9.2 blind-draw leak and confirming the net still fails** — both
|
||||
the dedicated test and, independently, the new step coverage. A relaxed safety test that has not
|
||||
been shown to still bite is not a safety test.
|
||||
- Pacing: kind classification for every intent type in the `Intent` union, so a new intent cannot
|
||||
land silently in the wrong tier; dwell arithmetic against the table; `pace = 0` produces no dwell.
|
||||
- Queue: step order preserved; skip drains and applies final state; counter counts dwelling steps
|
||||
only; a stopped queue stops its timers.
|
||||
- Attribution: each of the four switching events narrates with the acting player's name and `act`
|
||||
tone.
|
||||
- Solitaire: `LocalSession` produces the same steps for the same intents as `GameSession` does.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a privacy-safe common game board that can be viewed in a browser and published into the game’s Jitsi meeting by a server-managed headless Chromium participant.
|
||||
@@ -29,6 +327,8 @@ The implementation is divided into independently useful stages:
|
||||
6. Supervise one headless Chromium process per published game.
|
||||
7. Integrate lifecycle, configuration, packaging, health, and live verification.
|
||||
|
||||
Steps 1-4 are **v0.8.0** (1 shipped); steps 5-7 are **v0.9.0** — § THE RELEASE SPLIT.
|
||||
|
||||
The browser display remains useful without Jitsi. The public projection and leak fixes improve multiplayer security even if no visual display is deployed.
|
||||
|
||||
## Research incorporated
|
||||
@@ -61,15 +361,20 @@ Introduce dedicated allow-listed types. Do not derive them with `Omit<Frame, ...
|
||||
> **Read those two, not this**, when building steps 2-7. The differences that matter:
|
||||
>
|
||||
> - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts`
|
||||
> object. Their contents sit at the top level — `day`, `stage`, `clock` (a time string), `phase`,
|
||||
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentDepth`, `salvage`,
|
||||
> `yards`, `mode`, `days`, `optionalRules`, `houseRules`, `minCombinedRevenue`,
|
||||
> `maxCollisionsPerDay`, `maxCollisionsTotal`, `collisionsToday`, `collisionsTotal`, `status`,
|
||||
> `outcome`, `extraDays`, `extensionVotes`, `official`, `tally`, `openingRolls`, `timetable`,
|
||||
> `timetableWhat`, `trains`, `players`, `division`, `districts`.
|
||||
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. Step 2 is the reconnecting
|
||||
> display stream, which is the first thing that would want one — decide there whether to add it,
|
||||
> rather than assuming it is already on the wire.
|
||||
> object. Their contents sit at the top level. **All 38 properties, which is the same list as
|
||||
> `test/redaction.test.ts`'s allow-list** — `day`, `stage`, `clock` (a time string), `phase`,
|
||||
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentsWhat`,
|
||||
> `departmentDepth`, `salvage`, `yards`, `timetable`, `timetableWhat`, `houseRules`, `mode`,
|
||||
> `optionalRules`, `days`, `minCombinedRevenue`, `maxCollisionsPerDay`, `maxCollisionsTotal`,
|
||||
> `collisionsToday`, `collisionsPrevDay`, `collisionsTotal`, `status`, `outcome`, `extraDays`,
|
||||
> `extensionVotes`,
|
||||
> `official`, `tally`, `players`, `openingRolls`, `trains`, `crewTrays`, `queued`, `division`,
|
||||
> `districts`. The first 36 come from `projectSharedTable`; `division` and `districts` are added
|
||||
> by `PublicFrame` itself.
|
||||
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. **Decided 2026-09-09: add it
|
||||
> in step 2.** `display.json` carries its own `schemaVersion`, and the SSE wire format is a second,
|
||||
> independent contract — parsed in 0.9.0 by a headless agent page that lives for hours, where
|
||||
> `build-web.ts`'s cache-busting does not help.
|
||||
> - **`redFlagHeld` is STRUCK OFF**, not deferred. See below.
|
||||
> - **Fields gained since this was written** that the renderer should know about: `crewTrays` and
|
||||
> `queued` (#98, the Crew Tray pool and the trains waiting for one), and on each district's cells
|
||||
@@ -275,6 +580,13 @@ Cover:
|
||||
Acceptance requires an allow-list review of every `PublicFrame` property. Passing redaction tests alone is insufficient.
|
||||
|
||||
## Step 2 — Display credentials, stream, and persistence
|
||||
> **SPLIT (2026-09-09).** The **display-step stream** part of this step is superseded by § v0.8.0,
|
||||
> which puts steps on the `Session` interface and on `Push.steps` — no endpoint, no credential, and
|
||||
> no change to `http.ts`. What remains here is **v0.8.1**: `display.json`, the `viewToken`, the
|
||||
> `/api/display/stream` SSE endpoint, `/display.html`, the security requirements, and the
|
||||
> `startServer()` refactor plus the first HTTP test harness that testing those needs. `protocolVersion`
|
||||
> and the per-seat public delta moved into 0.8.0. Findings below are from 2026-08-27 and unverified.
|
||||
|
||||
|
||||
### Display metadata
|
||||
|
||||
@@ -305,22 +617,32 @@ For older saves without `display.json`, generate the metadata once on resume and
|
||||
|
||||
### HTTP endpoints
|
||||
|
||||
Add:
|
||||
Add in **0.8.0**:
|
||||
|
||||
- `GET /display.html#token=<viewToken>` — manual common-board page
|
||||
- `GET /api/display/stream?token=<viewToken>` — public-board SSE
|
||||
|
||||
Deferred to **0.9.0** with the publisher (§ THE RELEASE SPLIT):
|
||||
|
||||
- `GET /display-agent.html` — internal headless publisher page
|
||||
- WebSocket upgrade at `/api/display/control` — supervisor/agent control
|
||||
- the supervisor/agent control channel, as **SSE down + POST up**, not a WebSocket upgrade
|
||||
|
||||
Extend the authenticated game/session response with:
|
||||
|
||||
- display URL
|
||||
- Jitsi meeting URL
|
||||
- sanitized publisher state
|
||||
- sanitized publisher state (`'disabled'` until 0.9.0)
|
||||
|
||||
The Jitsi meeting URL joins that response in 0.9.0.
|
||||
|
||||
The browser display reads the fragment token, removes it from the visible address if practical, and supplies it to the SSE request. Fragments keep the credential out of the initial HTTP request and normal server access logs.
|
||||
|
||||
Do not add a publisher-configuration HTTP endpoint. The internal agent receives its meeting configuration and view token over the authenticated control WebSocket.
|
||||
Do not add a publisher-configuration HTTP endpoint. The internal agent receives its meeting configuration and view token over its authenticated control channel.
|
||||
|
||||
**0.8.0 must not presume an upgrade handler exists.** `startServer()` in `src/server/http.ts:169`
|
||||
returns `void` today and there are no HTTP-level tests in the project at all — `test/server/` is
|
||||
lobby, persistence and session only. Step 2 therefore builds the project's first HTTP test harness
|
||||
on top of the `startServer()` refactor, which is a larger opening move than the one line it gets
|
||||
below. TODO #102 is the record of why this half of the codebase was untested until v0.7.9.8.
|
||||
|
||||
### SSE behavior
|
||||
|
||||
@@ -336,6 +658,14 @@ On connection:
|
||||
|
||||
Use a dedicated public-frame delta function rather than the player `Frame` delta. The observed public snapshot grows enough during longer games that full frames for every action would be wasteful.
|
||||
|
||||
**This is new code, not a reuse, and the shape differs from the player delta** (checked 2026-09-09).
|
||||
`src/sim/frame-delta.ts` hardcodes `BOARD_KEYS = ['cells', 'facilities', 'division']` against
|
||||
`Frame`, but `PublicFrame` has no top-level `cells` or `facilities` — they live inside `districts[]`,
|
||||
one entry per seat, which is where nearly all of the bytes are. **Delta `districts` per seat, not as
|
||||
one array**: a single accepted intent changes one district, so a whole-array comparison sends every
|
||||
other player's board again on every step. `deltaFrame`/`applyDelta`'s "null means unchanged, merged
|
||||
against the last full frame the receiver holds" convention is worth keeping; the key set is not.
|
||||
|
||||
If an SSE client is slow or disconnected, close it and let EventSource reconnect to a new reset. Do not keep an unbounded replay buffer.
|
||||
|
||||
### Security requirements
|
||||
@@ -365,6 +695,15 @@ Cover:
|
||||
- display failure not interrupting `/api/intent` or player SSE
|
||||
|
||||
## Step 3 — Reusable common-board renderer
|
||||
> **SPLIT (2026-09-09).** The **canvas** half of this step exists only to feed WebRTC —
|
||||
> `canvas.captureStream(10)` is the only way to hand a rendered board to Jitsi, and DOM cannot be
|
||||
> captured into a `MediaStream`. A TV or an OBS browser source renders HTML directly, and more
|
||||
> crisply. So: the 1280×720 canvas, the 10fps draw loop, `contentHint: 'detail'`, SVG→image
|
||||
> rasterization and the content-keyed image cache are all **v0.9.0**. The **layout** work — an
|
||||
> all-districts seatless page, and adapting the Division/office SVG generators to render without a
|
||||
> private viewer — is **v0.8.1**, and is cheap once 0.8.0's foreign-district rendering exists. The
|
||||
> animation queue and the focused-district rule moved into 0.8.0 (§ v0.8.0 §§ 4, 6).
|
||||
|
||||
|
||||
### Renderer structure
|
||||
|
||||
@@ -447,9 +786,30 @@ Test renderer lifecycle with a fake canvas/image layer:
|
||||
Perform visual review at 1280×720 and as a reduced Jitsi tile. Text and train positions must remain legible without opening a tooltip.
|
||||
|
||||
## Step 4 — Preserve individual human and bot actions
|
||||
> **SUPERSEDED BY § v0.8.0 (2026-09-09).** Read that section, not this one. The collector design here
|
||||
> is broadly right and its "Current code findings" were re-verified 2026-09-09, so both are kept —
|
||||
> but three things changed: the collector lives in `sim/` and is called by `LocalSession` too rather
|
||||
> than being a `GameSession` private; *"Keep player pushes unchanged"* below is **reversed** (that was
|
||||
> the open question, and #13 is the answer); and the mechanism now includes foreign-district
|
||||
> rendering, pacing by kind, and the behind-counter, none of which are described here.
|
||||
|
||||
|
||||
### Current code findings
|
||||
|
||||
> **RE-VERIFIED 2026-09-09.** The three findings below still hold, and the insertion point is as
|
||||
> cheap as they imply: `intent()` (`src/server/session.ts:361`) is synchronous — `check` → `submit`
|
||||
> → `driveBotTurns()` → `pushesForAll()` — and `driveBots()` (`:320`) loops `submit()`. A collector
|
||||
> after each `submit()` is two call sites and needs no async surgery. **Two traps that are not in
|
||||
> the text below and were found by reading the code rather than the plan:**
|
||||
>
|
||||
> - **The collector must be inert during replay.** `resumeSession()` rebuilds a game by replaying
|
||||
> its whole history through `submit()`. Wired naively, every server restart re-emits the entire
|
||||
> game as display steps and burns `nextSequence` in `display.json`. This is the same class of bug
|
||||
> as #97 — a mechanism firing on a path nobody pictured it running on.
|
||||
> - **Narration-per-step needs its own high-water mark.** `sentLines` is per-seat and is *mutated*
|
||||
> by `linesSince()` as a side effect of building a push, so step 2 of "Required changes" cannot
|
||||
> read it. Track an independent mark against `game.log.length`.
|
||||
|
||||
`GameSession.intent()` applies the human intent, runs `driveBots()`, and only then creates player pushes.
|
||||
|
||||
`driveBots()` can call `submit()` many times. Player deltas intentionally collapse those moves into one final state, which is appropriate for gameplay but would make bots appear to teleport through several actions on the common board.
|
||||
@@ -480,6 +840,24 @@ One display step corresponds to one accepted intent, including automatic consequ
|
||||
|
||||
Keep player pushes unchanged: players still receive the final coalesced result after all immediately due bots finish.
|
||||
|
||||
> **REVERSED FOR 0.8.0 — RESOLVED 2026-09-09. See § v0.8.0.**
|
||||
>
|
||||
> The sentence above scoped step 4 to the seatless board. It was the open question of this design and
|
||||
> it now has an answer: **seated players receive steps too**, because #13 is what 0.8.0 is for.
|
||||
>
|
||||
> How the three objections that made it an open question were settled:
|
||||
>
|
||||
> - *A player waiting to act cannot lag a second behind.* Answered by the **behind-counter and skip**
|
||||
> (§ v0.8.0 § 6) rather than by policy — the lag is shown, counted down, and skippable, so the
|
||||
> player decides instead of the design guessing.
|
||||
> - *A step sent to a seat is a `Frame`, and `Frame`s are redacted per seat.* Answered by **not
|
||||
> sending a `Frame`**: public steps animate the board and the existing private Push supplies hand,
|
||||
> menu and objective. Zero new redaction surface.
|
||||
> - *Animating other people's turns makes the game feel slower.* Answered by **dwell by kind** — the
|
||||
> measured cost is ~40s of animation across a 60-stage game, and bookkeeping dwells at zero.
|
||||
>
|
||||
> Player pushes are still coalesced; what changes is that they now also carry `steps`.
|
||||
|
||||
Opening bot moves that occur before any client connects do not need replay. Persist the resulting game state and sequence; a later display receives the final reset.
|
||||
|
||||
### Failure isolation
|
||||
@@ -509,6 +887,14 @@ Cover:
|
||||
|
||||
## Step 5 — Minimal visual-only Jitsi engine
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
> that declares no `hardwareRequirements` today.
|
||||
|
||||
|
||||
### Source strategy
|
||||
|
||||
Port the smallest relevant production patterns from `jitsi-transcription` into Station Master with attribution where required. Do not import the sibling repository at runtime, add it as a submodule, or copy its transcription/audio/chat features.
|
||||
@@ -530,7 +916,12 @@ Pin the known working `lib-jitsi-meet` release:
|
||||
v2192.0.0+d6f3312f
|
||||
```
|
||||
|
||||
Add `ws` for the Node control broker. Do not add Playwright.
|
||||
**Do not add `ws`, and do not add Playwright.** The control channel is SSE down + POST up
|
||||
(§ THE RELEASE SPLIT), so it needs nothing beyond `node:http`, which the server already uses.
|
||||
`lib-jitsi-meet` is browser-side only: vendor the pinned release into `public/` and load it with a
|
||||
`<script>` tag — there is no bundler in this project, `scripts/build-web.ts` runs bare `tsc`. Pin
|
||||
and checksum it; it is a multi-megabyte minified blob entering the repo, which is a decision to
|
||||
take deliberately rather than a build artefact.
|
||||
|
||||
### Agent page
|
||||
|
||||
@@ -547,7 +938,7 @@ Add `ws` for the Node control broker. Do not add Playwright.
|
||||
9. Report normalized lifecycle state over the control channel.
|
||||
10. Leave and stop all tracks on supervisor command or `pagehide`.
|
||||
|
||||
Meeting server, room, XMPP configuration, and view token are delivered over the control WebSocket, not placed in query parameters.
|
||||
Meeting server, room, XMPP configuration, and view token are delivered over the control channel, not placed in query parameters.
|
||||
|
||||
### Jitsi publishing
|
||||
|
||||
@@ -636,6 +1027,14 @@ Port/adapt the sibling repository’s proven tests for:
|
||||
|
||||
## Step 6 — Chromium publisher supervisor
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
> that declares no `hardwareRequirements` today.
|
||||
|
||||
|
||||
### Process model
|
||||
|
||||
Create one Chromium child process per published game.
|
||||
@@ -699,7 +1098,8 @@ The broker must:
|
||||
- allow a newer engine to supersede only the same session
|
||||
- reject client/engine role changes on an established socket
|
||||
- clear pending commands when an engine disconnects
|
||||
- reconnect the agent-side WebSocket every two seconds until stopped
|
||||
- reconnect the agent-side control stream every two seconds until stopped — note that an
|
||||
`EventSource` does this by itself, which is part of why the transport changed
|
||||
|
||||
Public frames remain on SSE and never traverse this control protocol.
|
||||
|
||||
@@ -760,6 +1160,14 @@ Use fake child processes and fake control sockets to test:
|
||||
|
||||
## Step 7 — Configuration, lifecycle, packaging, and observability
|
||||
|
||||
> **v0.9.0 — DEFERRED (2026-09-09).** Steps 5-7 are the Jitsi publisher and are explicitly held
|
||||
> off until 0.8.0 ships and Chromium has been measured on the target hardware. Nothing below has been
|
||||
> re-verified against the current code; it was written 2026-08-27. Two things will have moved by
|
||||
> the time it is picked up: the control channel is **SSE + POST, not a WebSocket** (§ THE RELEASE
|
||||
> SPLIT), and adding Chromium takes the `.s9pk` from 63 MB to several hundred against a package
|
||||
> that declares no `hardwareRequirements` today.
|
||||
|
||||
|
||||
### Configuration
|
||||
|
||||
Support:
|
||||
@@ -844,8 +1252,10 @@ Exclude all tokens, full control frames, player private state, query strings, an
|
||||
|
||||
Add runtime dependencies:
|
||||
|
||||
- the pinned `lib-jitsi-meet` release
|
||||
- `ws`
|
||||
- the pinned `lib-jitsi-meet` release, vendored as a browser asset rather than an npm runtime import
|
||||
|
||||
**No Node runtime dependency is added.** `package.json` has no `dependencies` key and the wrapper's
|
||||
runtime image copies only `package.json` and `src/` — see § THE RELEASE SPLIT. Keep it that way.
|
||||
|
||||
Add Chromium and `tini` to the Station Master runtime image or companion StartOS packaging repository.
|
||||
|
||||
@@ -912,7 +1322,7 @@ Capture:
|
||||
|
||||
- raw display SSE
|
||||
- rendered canvas screenshots
|
||||
- control WebSocket messages
|
||||
- control-channel messages
|
||||
- Jitsi network destinations
|
||||
- server logs
|
||||
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.9.8",
|
||||
"version": "0.8.0.9",
|
||||
"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",
|
||||
|
||||
@@ -522,8 +522,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;
|
||||
@@ -1638,6 +1641,9 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
// Captured BEFORE the reset: the Day-end dialog reports the Day that just finished, and it is
|
||||
// drawn from the frame this rollover produces. See `collisionsPrevDay` in `state.ts`.
|
||||
s.collisionsPrevDay = s.collisionsToday;
|
||||
s.collisionsToday = 0;
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
|
||||
rotateSeats(s, events);
|
||||
|
||||
+104
-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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1642,6 +1675,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const events: GameEvent[] = [
|
||||
{
|
||||
type: 'trayMoved',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
from,
|
||||
to: i.to,
|
||||
@@ -1706,6 +1740,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
// decides which car is next to come off.
|
||||
events.push({
|
||||
type: 'carsCoupled',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
at: i.to,
|
||||
stock: dest.couples,
|
||||
@@ -1726,7 +1761,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const stock = i.fromNose
|
||||
? tray.consist.slice(0, i.count)
|
||||
: tray.consist.slice(tray.consist.length - i.count);
|
||||
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
||||
return [{ type: 'carsDropped', player, trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
@@ -1735,6 +1770,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
return [
|
||||
{
|
||||
type: 'consistSorted',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
at: here,
|
||||
before: tray.consist.map((c) => ({ ...c })),
|
||||
@@ -2239,7 +2275,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) {
|
||||
@@ -2470,7 +2507,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': {
|
||||
@@ -2690,7 +2735,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 {
|
||||
@@ -2927,9 +2973,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() };
|
||||
@@ -3056,7 +3127,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) {
|
||||
@@ -3250,16 +3322,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 };
|
||||
|
||||
@@ -37,9 +37,10 @@ export type GameEvent =
|
||||
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
||||
* choice the player made invisible in their own log.
|
||||
*/
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
player: PlayerIndex;
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
stock: RollingStock[];
|
||||
@@ -71,8 +72,8 @@ export type GameEvent =
|
||||
*/
|
||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||
}
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/**
|
||||
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||
|
||||
+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' });
|
||||
|
||||
@@ -431,6 +431,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
||||
movedThisPhase: new Set(),
|
||||
collisionsToday: 0,
|
||||
collisionsPrevDay: 0,
|
||||
collisionsTotal: 0,
|
||||
status: 'active',
|
||||
outcome: null,
|
||||
|
||||
@@ -1119,6 +1119,20 @@ export type GameState = {
|
||||
movedThisPhase: Set<TrayId>;
|
||||
/** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */
|
||||
collisionsToday: number;
|
||||
/**
|
||||
* What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately
|
||||
* before the reset.
|
||||
*
|
||||
* The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER
|
||||
* the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no
|
||||
* matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it —
|
||||
* "it shows a total of two collisions, but zero today ... that does seem to be a contradiction".
|
||||
*
|
||||
* NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in
|
||||
* multiplayer the push that reports the new Day is the same push that reports the reset — a client
|
||||
* may never see the ended Day's final count to remember it.
|
||||
*/
|
||||
collisionsPrevDay: number;
|
||||
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||
collisionsTotal: number;
|
||||
/**
|
||||
|
||||
+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)));
|
||||
|
||||
+55
-5
@@ -26,8 +26,10 @@ import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultipla
|
||||
import type { Game, Menu } from '../web/game.ts';
|
||||
import { deltaFrame } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
import { snapshot, seatLabel } from '../sim/view.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import { publicSnapshot, snapshot, seatLabel } from '../sim/view.ts';
|
||||
import type { Frame, PublicFrame } from '../sim/view.ts';
|
||||
import { takeSteps } from '../sim/display-step.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import { developerBot } from '../sim/bot.ts';
|
||||
|
||||
export type Push = {
|
||||
@@ -69,6 +71,29 @@ export type Push = {
|
||||
scheduled?: number | null;
|
||||
announcement?: string | null;
|
||||
justDrawn?: string | null;
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
|
||||
*
|
||||
* A field on `Push` rather than a second SSE event type, following `presence`'s precedent and for
|
||||
* its stated reason (`http.ts`): one message shape for the client to parse. `http.ts` therefore
|
||||
* needs no change at all — `broadcastGame` forwards whatever this file builds.
|
||||
*
|
||||
* IDENTICAL IN EVERY SEAT'S PUSH, because a step carries the PUBLIC board and nothing else. A
|
||||
* player's own hand, menu and objective are not animated: they arrive on the same push, already
|
||||
* coalesced, exactly as they always have. That is what keeps the redaction surface at zero new
|
||||
* area — `test/redaction.test.ts` guards the projection these are built from.
|
||||
*/
|
||||
steps?: DisplayStep[];
|
||||
/**
|
||||
* The public board to start a step queue from — sent on a CONNECT, never on an update.
|
||||
*
|
||||
* Steps carry deltas against one chain shared by the whole table, so a client that has just
|
||||
* arrived (or come back) has nothing to merge the next delta onto and `applyPublicDelta` would
|
||||
* rightly throw. This is that baseline: the exact frame the chain has reached, so the next step
|
||||
* lands on it. A reconnecting client resets rather than replaying what it missed — the history
|
||||
* panel is what carries the words, and it is already sent whole on connect (#97).
|
||||
*/
|
||||
publicReset?: PublicFrame;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -211,11 +236,12 @@ function buildSession(
|
||||
return { cues, scheduled, announcement };
|
||||
}
|
||||
|
||||
function pushFor(seat: PlayerIndex, moment: Moment | null): Push {
|
||||
function pushFor(seat: PlayerIndex, moment: Moment | null, steps: DisplayStep[] = []): Push {
|
||||
const frame = frameFor(seat);
|
||||
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
|
||||
lastFrame.set(seat, frame);
|
||||
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
|
||||
if (steps.length > 0) push.steps = steps;
|
||||
if (moment) {
|
||||
if (moment.cues.length > 0) push.cues = moment.cues;
|
||||
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
|
||||
@@ -228,9 +254,13 @@ function buildSession(
|
||||
|
||||
function pushesForAll(): Map<PlayerIndex, Push> {
|
||||
const moment = takeMoment();
|
||||
// Drained ONCE for the whole broadcast, not per seat: the steps are public and identical, and
|
||||
// `takeSteps` empties the collector, so draining inside the loop would give them to seat 0 and
|
||||
// an empty list to everybody else.
|
||||
const steps = takeSteps(game.display);
|
||||
const out = new Map<PlayerIndex, Push>();
|
||||
for (let seat = 0; seat < playerNames.length; seat++) {
|
||||
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment));
|
||||
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment, steps));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -341,6 +371,19 @@ function buildSession(
|
||||
// here rather than fired at the first client to arrive. (It also stops `game.cues` growing without
|
||||
// bound on a server, which nothing was draining before this.)
|
||||
takeMoment();
|
||||
/**
|
||||
* THE PRESENTATION STEPS THOSE TURNS PRODUCED GO WITH THEM (v0.8.0).
|
||||
*
|
||||
* Left in the collector they would be delivered on the FIRST broadcast after somebody connects —
|
||||
* but that client's `publicReset` is the board as it stands AFTER these very moves, so replaying
|
||||
* them onto it would draw positions the game had already left. The plan says as much: opening bot
|
||||
* moves need no replay, and a later display simply receives the final reset.
|
||||
*
|
||||
* This is the only moment the collector holds anything outside an intent. `pushesForAll()` drains
|
||||
* it synchronously at the end of every `intent()`, so between moves it is always empty — which is
|
||||
* what makes dropping here safe rather than a race with a seat that has not been sent them yet.
|
||||
*/
|
||||
takeSteps(game.display);
|
||||
|
||||
return {
|
||||
playerCount: playerNames.length,
|
||||
@@ -355,7 +398,14 @@ function buildSession(
|
||||
// blank history panel mid-game, with the server holding the whole log. `Push.lines` on a
|
||||
// connect IS the history, which is what lets the Frame stop carrying a second copy.
|
||||
sentLines.delete(seat);
|
||||
return pushFor(seat, null);
|
||||
const push = pushFor(seat, null);
|
||||
/**
|
||||
* The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame
|
||||
* the shared delta chain has reached, so the next step merges onto it; before any step has
|
||||
* been collected there is no chain yet and a fresh projection is the same thing.
|
||||
*/
|
||||
push.publicReset = game.display.last ?? publicSnapshot(game.state);
|
||||
return push;
|
||||
},
|
||||
|
||||
intent(seat, seq, i) {
|
||||
|
||||
+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> = {};
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* THE DISPLAY-STEP COLLECTOR — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 1-3.
|
||||
*
|
||||
* One ordered, watchable presentation step per accepted intent, so a player can see what everyone
|
||||
* else did instead of finding the board already rearranged. TODO #13: *"It's not fun to do my turn
|
||||
* and have magic happen in the background and then have to figure out what others did."*
|
||||
*
|
||||
* ONE HOOK, NOT TWO. The design anticipated wiring this into `GameSession.intent()` and
|
||||
* `GameSession.driveBots()` separately, with `LocalSession` doing its own thing for solitaire. It
|
||||
* does not need to: `src/server/session.ts` imports `submit` from `src/web/game.ts`, so solitaire,
|
||||
* live multiplayer and every bot turn already funnel through ONE function. Collecting there is what
|
||||
* makes solitaire a special case of multiplayer rather than a second implementation, which is the
|
||||
* standing design direction for this codebase.
|
||||
*
|
||||
* AND REPLAY IS INERT FOR FREE. `fromSave` and `fromMultiplayerSave` rebuild a game by calling
|
||||
* `applyIntent` + `record` + `drain` directly rather than `submit`, so a resumed server or a rebuilt
|
||||
* undo does NOT re-emit the whole game as steps. That was expected to need an explicit guard — the
|
||||
* plan calls it out as the same class of bug as #97, a mechanism firing on a path nobody pictured
|
||||
* it running on. It needs none, but the property is load-bearing: **if a replay path is ever moved
|
||||
* onto `submit()`, this becomes a real bug**, and `test/watchable.test.ts` pins it.
|
||||
*
|
||||
* WHAT A STEP IS. One accepted intent, or ONE AUTOMATIC PHASE — never one per `GameEvent`, because
|
||||
* the event list is not a complete reducer and a receiver could not rebuild state from it. It gets a
|
||||
* projected frame instead.
|
||||
*
|
||||
* PHASES EARN THEIR OWN STEPS, and that is TODO #18. `pump()` runs every automatic phase between one
|
||||
* click and the next and `drain()` records the whole batch at once, so New Train, the Mainline and
|
||||
* the shift change "look like they are being skipped entirely" — trains cross the Division in one
|
||||
* jump. Folding them into the triggering intent's step reproduces exactly that. So `submit()` steps
|
||||
* `advance()` one call at a time instead, and collects a step for each phase that actually DID
|
||||
* something. A phase that did nothing adds no narration and therefore produces no step at all, which
|
||||
* is Jesse's own rule (2026-09-09): "if nothing happens during a phase then we shouldn't lose time
|
||||
* to it."
|
||||
*
|
||||
* `drain()` is deliberately NOT changed. Replay, undo and `fromSave` all use it, and the
|
||||
* replay-inertness property below depends on their staying off this path. The stepped version lives
|
||||
* in `submit()` and makes the same `advance()` calls in the same order, so the resulting state is
|
||||
* identical — only the collection differs.
|
||||
*/
|
||||
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameState, PlayerIndex, SeatIndex } from '../engine/state.ts';
|
||||
import { seatOf } from '../engine/state.ts';
|
||||
import { publicSnapshot } from './view.ts';
|
||||
import type { PublicFrame } from './view.ts';
|
||||
import { deltaPublicFrame } from './public-delta.ts';
|
||||
import type { PublicFrameDelta } from './public-delta.ts';
|
||||
|
||||
/**
|
||||
* The wire format's version, on the ENVELOPE rather than on the projection.
|
||||
*
|
||||
* The plan's original sketch put `protocolVersion` inside `PublicFrame`. It does not belong there:
|
||||
* `PublicFrame` is a projection of the game and its property list is an allow-list that
|
||||
* `test/redaction.test.ts` enumerates, so a transport concern living in it would have to be
|
||||
* allow-listed as public game state, which it is not. The step is the message; the message carries
|
||||
* the version.
|
||||
*/
|
||||
export const DISPLAY_PROTOCOL_VERSION = 1;
|
||||
|
||||
/** What produced a step: somebody's intent, or the Division advancing a phase by itself. */
|
||||
export type StepCause = Intent['type'] | 'phase';
|
||||
|
||||
/** One watchable thing that happened, in order. */
|
||||
export type DisplayStep = {
|
||||
protocolVersion: typeof DISPLAY_PROTOCOL_VERSION;
|
||||
/** Monotonic per game. 0.8.1's reconnecting display stream needs it to detect a gap; a queue only needs the order. */
|
||||
seq: number;
|
||||
/**
|
||||
* Who acted — NULL for an automatic phase, which nobody did.
|
||||
*
|
||||
* Both are carried because Employee Rotation makes "which seat" and "which player" different
|
||||
* questions.
|
||||
*/
|
||||
player: PlayerIndex | null;
|
||||
seat: SeatIndex | null;
|
||||
/** What caused it — the input to pacing's kind classification. */
|
||||
cause: StepCause;
|
||||
/** The public board after this intent and everything it drained, against the previous step. */
|
||||
frame: PublicFrameDelta;
|
||||
/** The narration this intent added, in order, including any phase lines drained behind it. */
|
||||
lines: { text: string; tone: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-game collector state.
|
||||
*
|
||||
* Held on `Game` beside `log`, `cues` and `announced` and drained the same way, which is the
|
||||
* established convention in this codebase for "the model accumulated something, the view takes it".
|
||||
*/
|
||||
export type DisplayCollector = {
|
||||
/** Undrained steps, oldest first. */
|
||||
steps: DisplayStep[];
|
||||
/** The last public frame a step was built against, so the next delta has something to diff. */
|
||||
last: PublicFrame | null;
|
||||
/** Next sequence number to assign. */
|
||||
seq: number;
|
||||
};
|
||||
|
||||
export function newCollector(): DisplayCollector {
|
||||
return { steps: [], last: null, seq: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one accepted intent as a step.
|
||||
*
|
||||
* Called from `submit()` AFTER `record()` and `drain()`, so `state` is the position the intent
|
||||
* finally produced and `lines` is everything it caused to be said. The frame is projected
|
||||
* immediately and never from a retained `GameState` reference — a retained reference would resolve
|
||||
* to the FINAL state of a whole bot run, which is exactly the teleporting this exists to prevent.
|
||||
*/
|
||||
export function collectStep(
|
||||
collector: DisplayCollector,
|
||||
state: GameState,
|
||||
player: PlayerIndex | null,
|
||||
cause: StepCause,
|
||||
lines: { text: string; tone: string }[],
|
||||
): void {
|
||||
const next = publicSnapshot(state);
|
||||
collector.steps.push({
|
||||
protocolVersion: DISPLAY_PROTOCOL_VERSION,
|
||||
seq: collector.seq++,
|
||||
player,
|
||||
seat: player === null ? null : seatOf(state, player),
|
||||
cause,
|
||||
frame: deltaPublicFrame(collector.last, next),
|
||||
lines,
|
||||
});
|
||||
collector.last = next;
|
||||
}
|
||||
|
||||
/** Take everything collected so far, leaving the collector empty — `takeMoment()`'s pattern. */
|
||||
export function takeSteps(collector: DisplayCollector): DisplayStep[] {
|
||||
return collector.steps.splice(0, collector.steps.length);
|
||||
}
|
||||
+2
-2
@@ -155,7 +155,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.to,
|
||||
text: `CREW moved ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
};
|
||||
case 'carsCoupled': {
|
||||
/**
|
||||
@@ -181,7 +181,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
};
|
||||
case 'carsDropped':
|
||||
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* HOW LONG EACH STEP IS SHOWN — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
|
||||
*
|
||||
* Shared rather than living in `src/web/`, so the 0.8.1 seatless board paces identically to a
|
||||
* player's own screen. Two views of one game that disagreed about how fast it looks would be worse
|
||||
* than either alone.
|
||||
*
|
||||
* WHY BY KIND RATHER THAN BY BUDGET. The obvious scheme is to give the whole backlog a time budget
|
||||
* and divide it by the queue length. Measured against real games, that does exactly the wrong
|
||||
* thing. From `public/replays/`: ~60 stages per game and ~5 intents per player per stage, so a
|
||||
* four-player table produces ~15 other-player steps per stage — but 44 of a 307-intent game are
|
||||
* `draw.end` and 60 are `loadUnload.end`, bookkeeping nobody wants to watch, while the thing that
|
||||
* is worth watching is rare and clustered. Two of the three published replays contain no
|
||||
* `switch.move` at all; the third has bursts of 14, 6, 6 and 6, and `trayMoved`'s own narration says
|
||||
* "N of 6 Moves left" because six is the engine's cap per crew. So a uniform budget spends the
|
||||
* player's attention on `draw.end` and rushes the switching.
|
||||
*
|
||||
* Assigning dwell by kind and letting the total fall out costs ~40s of animation across a whole
|
||||
* 60-stage game, against ~3.6 minutes for a flat 700ms — better switching visibility for a fifth of
|
||||
* the time. Jesse, 2026-09-09, on what matters: *"I definitely want to watch other players struggle
|
||||
* with the switching exercises … I don't think reading the switching in the log will be anywhere
|
||||
* nearly as interesting as watching the trains actually move on the board."*
|
||||
*
|
||||
* PACING IS CLIENT-SIDE ONLY. The server emits steps as fast as it likes and the client decides how
|
||||
* to show them, which is what keeps Gitea#20's "do not slow the authoritative game" true.
|
||||
*/
|
||||
|
||||
import type { StepCause } from './display-step.ts';
|
||||
|
||||
export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
|
||||
|
||||
/**
|
||||
* THE TUNING TABLE — dwell in milliseconds per kind.
|
||||
*
|
||||
* Start generous and tune down by playing; Jesse, 2026-09-09: *"start at 1s and tune down."* This is
|
||||
* the committed default and changing it needs a web rebuild, which in the `.s9pk` is a release — so
|
||||
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
|
||||
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
|
||||
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
|
||||
* hundred times will want it off". **Multipliers above 1 are supported and expected** — Jesse asked
|
||||
* for 2 and 3 explicitly after the first play — up to `MAX_PACE`, and every tier scales together so
|
||||
* their relative weighting survives.
|
||||
*
|
||||
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
|
||||
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
|
||||
* table's multiplier — the table, the classification and the queue do not change.
|
||||
*/
|
||||
export const DWELL: Record<StepKind, number> = {
|
||||
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
|
||||
switching: 1000,
|
||||
/**
|
||||
* A card, a car or a load changing hands somewhere visible — and the announcement of what a
|
||||
* player is about to do.
|
||||
*
|
||||
* WAS 250ms, WHICH WAS WRONG, and wrong in the way that mattered most: an early-game bot turn has
|
||||
* no switching in it at all, so it was six steps of 250ms and 0ms — **750ms for a whole turn**.
|
||||
* Jesse, from the first real play on the test server: *"bot play was way too fast. I briefly saw
|
||||
* that it was the bot's office area then their turn was done."* His instruction had been "start at
|
||||
* 1s and tune down", and that was applied only to switching while this number was invented.
|
||||
*/
|
||||
action: 700,
|
||||
/**
|
||||
* An automatic phase that DID something — TODO #18.
|
||||
*
|
||||
* Only reached when the phase actually narrated: `submit()` collects no step for a phase that
|
||||
* changed nothing, so this is never spent on the empty ones Jesse is content to guess at. Between
|
||||
* an ordinary action and a switching move, because the Mainline phase moves trains the length of
|
||||
* the Division and is the clearest case of "stuff just happened without being able to see how".
|
||||
*/
|
||||
phase: 600,
|
||||
/** Turn and phase bookkeeping. Nothing moved; do not spend the player's attention on it. */
|
||||
bookkeeping: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Which kind an intent is.
|
||||
*
|
||||
* Exhaustive over `Intent['type']` on purpose — a `default` would silently drop a newly added intent
|
||||
* into whatever tier the fallback names, and the failure mode is invisible (a move that never gets
|
||||
* a beat, or bookkeeping that stalls the queue for a second). `test/pacing.test.ts` walks every
|
||||
* member of the union so a new intent cannot land here unclassified.
|
||||
*/
|
||||
export function kindOf(cause: StepCause): StepKind {
|
||||
switch (cause) {
|
||||
// The Division advancing itself — New Train, the Mainline, the shift change (TODO #18).
|
||||
case 'phase':
|
||||
return 'phase';
|
||||
|
||||
// The crew and its train moving, coupling, setting out and re-ordering — §6.1 and Appendix A.
|
||||
case 'switch.move':
|
||||
case 'switch.dropCars':
|
||||
case 'switch.sortConsist':
|
||||
case 'maneuver.flyingSwitch':
|
||||
case 'maneuver.redFlags':
|
||||
return 'switching';
|
||||
|
||||
// Something visible changed hands or position, but no train drove anywhere.
|
||||
case 'card.play':
|
||||
case 'card.discard':
|
||||
case 'draw.fromHomeOffice':
|
||||
case 'draw.fromDepartment':
|
||||
case 'newTrain.placeCar':
|
||||
case 'newTrain.passCar':
|
||||
case 'newTrain.secondSection':
|
||||
case 'newTrain.startExtra':
|
||||
case 'porter.board':
|
||||
case 'porter.detrain':
|
||||
case 'laborer.startLoad':
|
||||
case 'laborer.advanceLoad':
|
||||
case 'laborer.beginUnload':
|
||||
case 'freightAgent.stockOutbound':
|
||||
case 'freightAgent.clearInbound':
|
||||
case 'freightAgent.unjam':
|
||||
case 'mainline.clearance':
|
||||
case 'mainline.modify':
|
||||
case 'mainline.redFlag':
|
||||
case 'mainline.yardOffice':
|
||||
case 'redFlag.play':
|
||||
return 'action';
|
||||
|
||||
/**
|
||||
* `localOps.choose` IS AN ANNOUNCEMENT, NOT BOOKKEEPING — moved out 2026-09-09 after the first
|
||||
* real play. It is the line that reads "Player Bot 1 chose to SWITCH — six Moves to shunt cars
|
||||
* around the yard": the heading for everything that follows, and at zero dwell nobody ever saw
|
||||
* it, so a bot's turn began with no indication of what it was about to do.
|
||||
*/
|
||||
case 'localOps.choose':
|
||||
return 'action';
|
||||
|
||||
// Ending a phase or a turn, and voting. Nothing to see: the consequences were the thing, and
|
||||
// there are more of these than of anything else.
|
||||
case 'loadUnload.end':
|
||||
case 'draw.end':
|
||||
case 'switch.end':
|
||||
case 'freightAgent.end':
|
||||
case 'game.extend':
|
||||
return 'bookkeeping';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest multiplier that is a speed rather than a mistake.
|
||||
*
|
||||
* `pace` has no lower surprise — 0 means off — but an unbounded upper one does: `?pace=300` from
|
||||
* somebody typing 3.00, or a corrupt `localStorage` value, would give a switching move a five-minute
|
||||
* dwell and look exactly like a frozen board. Twenty is far past any speed anyone would choose and
|
||||
* well short of unusable.
|
||||
*
|
||||
* RAISED FROM TEN 2026-09-10, because the ceiling turned out not to be theoretical: Jesse played at
|
||||
* 10× — the top of the ladder — and reported it *"still a bit fast, but followable"*. A control whose
|
||||
* slowest setting is not slow enough for the person using it has the wrong ceiling, not the right one
|
||||
* held firmly.
|
||||
*/
|
||||
export const MAX_PACE = 20;
|
||||
|
||||
/**
|
||||
* The speeds the on-screen control offers, slowest last.
|
||||
*
|
||||
* `0` is off: every move is drawn at once, as it was before v0.8.0 — TODO #18's "a player who has
|
||||
* seen it a hundred times will want it off". The ladder runs well past 1 because that is what the
|
||||
* first real play asked for: Jesse reached for 7×, and although the `?pace=` he used never took
|
||||
* effect (the splash replaces the query string, so the play page only ever saw `?lobby`), the wish
|
||||
* was real. Watching a bot shunt cars is the point of this feature, and it is worth as long as it
|
||||
* takes.
|
||||
*/
|
||||
export const PACE_LEVELS = [0, 0.5, 1, 2, 3, 5, 7, 10, 15, 20] as const;
|
||||
|
||||
/**
|
||||
* How long to show one step, in ms, at a given speed.
|
||||
*
|
||||
* `pace` scales every tier by the same factor, so **the tiers stay in proportion at any speed** — a
|
||||
* switching move outlasts an ordinary action at 0.5× and at 3× alike. That is deliberate: the
|
||||
* relative weighting is the design (a train moving is worth more attention than a card changing
|
||||
* hands), and the multiplier is only how fast the whole thing runs. `0` means do not animate at all.
|
||||
*/
|
||||
export function dwellFor(cause: StepCause, pace = 1): number {
|
||||
return Math.round(DWELL[kindOf(cause)] * Math.min(MAX_PACE, Math.max(0, pace)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the speed control a PHASE gets — damped, not the full multiplier.
|
||||
*
|
||||
* Phases were pinned at their tabled beat in v0.8.0.3, because scaling them with everything else put
|
||||
* a wall of clock-ticking after a player's own move. That was right about the cost and wrong about
|
||||
* the need: at 10× the caption row goes past faster than the sentence on it can be read. Jesse,
|
||||
* 2026-09-10: *"phases displayed on the upper line go by too quickly still. Should be 4 times as
|
||||
* long — at a guess. Maybe use the speed multiplier for that too?"*
|
||||
*
|
||||
* So they scale, at a third of the rate. That lands exactly on his guess — 10× gives a phase four
|
||||
* times its tabled beat — while leaving 1× untouched, and it stays affordable because phase beats
|
||||
* cluster rather than accumulate: measured over 60 pushes, a push carries **1.0 phase beat on
|
||||
* average and 4 at worst**, so the wait after a move goes to ~2.4s typical and ~10s at its very
|
||||
* worst rather than the minutes a full multiplier would have cost.
|
||||
*
|
||||
* Below 1× it simply follows the multiplier: somebody asking for everything faster means the phases
|
||||
* too.
|
||||
*/
|
||||
function phaseSpeed(pace: number): number {
|
||||
return pace <= 1 ? pace : 1 + (pace - 1) / 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to show one STEP — the form the queue actually uses.
|
||||
*
|
||||
* A step that said nothing gets no dwell, whatever caused it. That is one rule covering two cases
|
||||
* arrived at separately: a phase where nothing happened (Jesse, 2026-09-09 — *"if nothing happens
|
||||
* during a phase then we shouldn't lose time to it"*), and a phase that only handed the turn on,
|
||||
* which changes the board but has nothing on it to look at. Structurally typed so this file does not
|
||||
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
|
||||
*/
|
||||
export function dwellForStep(
|
||||
step: { cause: StepCause; player: number | null; lines: readonly unknown[]; frame: { table: object } },
|
||||
pace = 1,
|
||||
): number {
|
||||
// Off means off, for the clock as much as for anybody's move.
|
||||
if (pace <= 0) return 0;
|
||||
/**
|
||||
* THE SPEED CONTROL IS ABOUT OTHER PEOPLE, NOT ABOUT THE CLOCK.
|
||||
*
|
||||
* A phase keeps its tabled beat at every speed. Measured over 40 turns of a real 3-seat game, the
|
||||
* waiting split almost evenly — 21.0s of other players against 21.0s of phases turning over — so
|
||||
* scaling both put 105 seconds of clock-ticking into a 5× game, all of it after the player's own
|
||||
* move and none of it anything to watch. Jesse, from that game: *"after my turn, when I actually
|
||||
* execute my turn, I'm still subject to that same delay before it moves on. That makes no sense."*
|
||||
*
|
||||
* The phase still gets its beat (TODO #18) — it just does not get longer because somebody wanted
|
||||
* to watch a bot shunt cars.
|
||||
*/
|
||||
const speed = step.player === null ? phaseSpeed(pace) : pace;
|
||||
if (step.lines.length > 0) return dwellFor(step.cause, speed);
|
||||
/**
|
||||
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
|
||||
* every phase a visible beat", for New Train, the Mainline and the shift change.
|
||||
*
|
||||
* Measured, because the obvious rule was wrong twice. "No narration, no dwell" looked right and
|
||||
* silently killed #18: a phase can move trains without saying anything, and those steps were being
|
||||
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
|
||||
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
|
||||
* all of them would cost a quarter of an hour a game.
|
||||
*/
|
||||
const table = step.frame.table as Record<string, unknown>;
|
||||
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
|
||||
return turned ? dwellFor(step.cause, speed) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many steps in a queue are actually going to be WATCHED.
|
||||
*
|
||||
* This is the number the "N behind" counter shows, and it is deliberately not `queue.length`. With
|
||||
* bookkeeping dwelling at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to 5
|
||||
* the instant it started, and then crawl — which is not the steady countdown the counter is for.
|
||||
* Thirteen dwelling steps means thirteen things you are going to see.
|
||||
*/
|
||||
export function watchableCount(causes: readonly StepCause[], pace = 1): number {
|
||||
return causes.filter((c) => dwellFor(c, pace) > 0).length;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Delta for the SEATLESS public frame — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
|
||||
*
|
||||
* `frame-delta.ts` solves the same-shaped problem for a seated player's `Frame` and does NOT carry
|
||||
* over, which is worth saying plainly because reusing it looks obvious and is wrong. It nulls three
|
||||
* TOP-LEVEL keys — `cells`, `facilities`, `division` — and a `PublicFrame` has only the last of
|
||||
* those. Its `cells` and `facilities` live one level down, inside `districts[]`, one entry per seat,
|
||||
* and that is where nearly all of the bytes are.
|
||||
*
|
||||
* **So the districts are deltaed PER SEAT rather than as one array.** One accepted intent changes
|
||||
* one district; comparing the whole array as a unit would resend every other player's board on
|
||||
* every step, which is exactly the cost this exists to avoid. On a four-player table that is three
|
||||
* boards of waste per step, and a step is emitted for every bot move as well as every human one.
|
||||
*
|
||||
* It is also a TRUE PARTIAL rather than a full frame with holes in it, which is the other place
|
||||
* `frame-delta.ts` does not carry over. See `PublicFrameDelta` below for the measurement that forced
|
||||
* that; in short, most steps change one field and shipping the other thirty-four cost 16.7 MB a game.
|
||||
*
|
||||
* The convention that does carry over, kept identical so a reader of one file can read the other:
|
||||
* an absent or `null` field means "unchanged since the last thing sent to this receiver", and the
|
||||
* receiving side merges against the last full frame it actually holds. A first connect or a
|
||||
* reconnect after a gap sends a full frame instead — the display stream resets rather than replaying
|
||||
* (§ v0.8.0).
|
||||
*
|
||||
* Node-free by design, like `frame-delta.ts`: the server and the browser both import this directly.
|
||||
*/
|
||||
|
||||
import type { CellView, DivisionView, FacilityView, PublicDistrict, PublicFrame } from './view.ts';
|
||||
|
||||
/**
|
||||
* One district with its two heavy fields nulled when unchanged.
|
||||
*
|
||||
* `seat` is the identity and is always present — it is what the receiver matches on. `player` and
|
||||
* `name` are always sent too, and deliberately: Employee Rotation moves players between districts,
|
||||
* so the pairing of seat to player is itself news, and it costs two small fields to never have to
|
||||
* reason about whether a relabelling was missed.
|
||||
*/
|
||||
export type PublicDistrictDelta = Omit<PublicDistrict, 'cells' | 'facilities'> & {
|
||||
cells: CellView[] | null;
|
||||
facilities: FacilityView[] | null;
|
||||
};
|
||||
|
||||
/** The shared-table half of a `PublicFrame` — everything that is not the Division or a district. */
|
||||
type PublicTable = Omit<PublicFrame, 'division' | 'districts'>;
|
||||
|
||||
/**
|
||||
* A `PublicFrame` reduced to WHAT CHANGED.
|
||||
*
|
||||
* **Partial, not a full frame with holes**, and that distinction was measured rather than assumed.
|
||||
* The first version of this spread `...next` and nulled only the board fields, so every step shipped
|
||||
* all 35 top-level properties even when the sole change was whose turn it was. Once TODO #18 gave
|
||||
* automatic phases their own steps, most steps became exactly that — a turn handed on, nothing to
|
||||
* look at — and a full 6-day game cost **19.4 MB**, of which **16.7 MB was those silent steps at
|
||||
* ~11 KB each**. As a partial they are a few dozen bytes.
|
||||
*/
|
||||
export type PublicFrameDelta = {
|
||||
/** Only the shared-table fields whose value differs from the previous frame. */
|
||||
table: Partial<PublicTable>;
|
||||
/** The Division, only when it changed. */
|
||||
division: DivisionView[] | null;
|
||||
/** Only the districts that changed, each carrying only the board fields that changed. */
|
||||
districts: PublicDistrictDelta[];
|
||||
};
|
||||
|
||||
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
const TABLE_KEYS = (frame: PublicFrame): (keyof PublicTable)[] =>
|
||||
(Object.keys(frame) as (keyof PublicFrame)[]).filter(
|
||||
(k): k is keyof PublicTable => k !== 'division' && k !== 'districts',
|
||||
);
|
||||
|
||||
/**
|
||||
* `previous` is the last public frame actually sent to THIS receiver, or `null` for a first connect
|
||||
* or a reset — in which case everything is sent in full.
|
||||
*/
|
||||
export function deltaPublicFrame(previous: PublicFrame | null, next: PublicFrame): PublicFrameDelta {
|
||||
const before = new Map(previous?.districts.map((d) => [d.seat, d]) ?? []);
|
||||
const table: Partial<PublicTable> = {};
|
||||
for (const key of TABLE_KEYS(next)) {
|
||||
if (previous === null || !same(previous[key], next[key])) {
|
||||
(table as Record<string, unknown>)[key] = next[key];
|
||||
}
|
||||
}
|
||||
const districts: PublicDistrictDelta[] = [];
|
||||
for (const d of next.districts) {
|
||||
const was = before.get(d.seat);
|
||||
const cells = was && same(was.cells, d.cells) ? null : d.cells;
|
||||
const facilities = was && same(was.facilities, d.facilities) ? null : d.facilities;
|
||||
// A district with nothing new is left out entirely rather than sent as a row of nulls: on a
|
||||
// four-player table three of them are unchanged on every single step.
|
||||
if (was && cells === null && facilities === null && same(was, d)) continue;
|
||||
districts.push({ ...d, cells, facilities });
|
||||
}
|
||||
return {
|
||||
table,
|
||||
division: previous !== null && same(previous.division, next.division) ? null : next.division,
|
||||
districts,
|
||||
};
|
||||
}
|
||||
|
||||
/** The receiving side: merges a delta back onto the last full public frame this receiver holds. */
|
||||
export function applyPublicDelta(previous: PublicFrame | null, delta: PublicFrameDelta): PublicFrame {
|
||||
const base = previous ?? (delta.table as PublicTable);
|
||||
const merged = { ...base, ...delta.table } as PublicTable;
|
||||
const bySeat = new Map((previous?.districts ?? []).map((d) => [d.seat, d]));
|
||||
for (const d of delta.districts) {
|
||||
const was = bySeat.get(d.seat);
|
||||
bySeat.set(d.seat, {
|
||||
...d,
|
||||
cells: d.cells ?? need(was?.cells, `districts[seat ${d.seat}].cells`),
|
||||
facilities: d.facilities ?? need(was?.facilities, `districts[seat ${d.seat}].facilities`),
|
||||
});
|
||||
}
|
||||
return {
|
||||
...merged,
|
||||
division: delta.division ?? need(previous?.division, 'division'),
|
||||
districts: [...bySeat.values()].sort((a, b) => a.seat - b.seat),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A delta that says "unchanged" against a receiver that has nothing to merge onto is a bug in the
|
||||
* SENDER's bookkeeping, not a recoverable state — it means the two sides disagree about what has
|
||||
* been delivered, and quietly producing a frame with a missing board would put a blank district in
|
||||
* front of a player. `frame-delta.ts` throws in the same situation and for the same reason.
|
||||
*/
|
||||
function need<T>(value: T | undefined, what: string): T {
|
||||
if (value === undefined) {
|
||||
throw new Error(`deltaPublicFrame said "${what}" is unchanged, but there is no previous frame to merge onto`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A face-up or face-down pile a card can move to or from, as the display addresses it. */
|
||||
export type PileKey = 'home' | 'salvage' | `dept${number}`;
|
||||
|
||||
/**
|
||||
* WHICH PILES A STEP MOVED — derived, never sent.
|
||||
*
|
||||
* The receiver already holds the frame before a step and the frame after it, so which pile changed
|
||||
* is a diff rather than something the wire has to carry. That matters twice over: nothing is added
|
||||
* to the protocol, and it cannot drift out of step with the projection the way a hand-maintained
|
||||
* hint would.
|
||||
*
|
||||
* WHY IT IS NEEDED AT ALL. A player watching somebody else draw a card sees seven seconds of an
|
||||
* unchanged board — the step holds the screen, and the only thing that moved is a number in a panel
|
||||
* they were not looking at. Jesse, playing v0.8.0.4 at 10×: *"many operations still occurred too fast
|
||||
* for me to see"*, which was never about duration. Lighting the pile is what tells the eye where.
|
||||
*
|
||||
* WHAT EACH ACTION MOVES, measured across four seeds rather than reasoned about:
|
||||
*
|
||||
* | intent | piles |
|
||||
* | ----------------------- | -------------------------------------------------------- |
|
||||
* | `draw.fromHomeOffice` | `home` — the COUNT only; the card itself stays private |
|
||||
* | `draw.fromDepartment` | that `dept`, and `home` too when the pile refills from it |
|
||||
* | `card.discard` | that `dept` |
|
||||
* | `card.play` | `salvage`, or nothing here when it lands on the board |
|
||||
* | switching, new trains | nothing here — those show on the board itself |
|
||||
*/
|
||||
export function changedPiles(before: PublicFrame | null, after: PublicFrame): PileKey[] {
|
||||
if (before === null) return [];
|
||||
const out: PileKey[] = [];
|
||||
if (before.deck !== after.deck) out.push('home');
|
||||
after.departmentDepth.forEach((depth, i) => {
|
||||
// The TOP as well as the depth: taking the face-up card and replacing it leaves the count alone
|
||||
// and changes the card everybody can see, which is the half that matters to a watcher.
|
||||
if (before.departmentDepth[i] !== depth || before.departments[i] !== after.departments[i]) {
|
||||
out.push(`dept${i}`);
|
||||
}
|
||||
});
|
||||
if (before.salvage.depth !== after.salvage.depth || before.salvage.top !== after.salvage.top) {
|
||||
out.push('salvage');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -437,6 +437,8 @@ export type Frame = {
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
collisionsToday: number;
|
||||
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
|
||||
collisionsPrevDay: number;
|
||||
collisionsTotal: number;
|
||||
status: GameState['status'];
|
||||
outcome: GameState['outcome'];
|
||||
@@ -1594,6 +1596,7 @@ export function projectSharedTable(s: GameState) {
|
||||
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: s.config.maxCollisionsTotal,
|
||||
collisionsToday: s.collisionsToday,
|
||||
collisionsPrevDay: s.collisionsPrevDay,
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
status: s.status,
|
||||
outcome: s.outcome,
|
||||
|
||||
+74
-4
@@ -23,7 +23,7 @@
|
||||
* folding events does not rebuild a game — `protocol.md` §3.)
|
||||
*/
|
||||
|
||||
import { pump } from '../engine/advance.ts';
|
||||
import { advance, pump } from '../engine/advance.ts';
|
||||
import { applyIntent } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
@@ -33,6 +33,8 @@ import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state
|
||||
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
|
||||
import { playerAtSeat } from '../engine/state.ts';
|
||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||
import { collectStep, newCollector } from '../sim/display-step.ts';
|
||||
import type { DisplayCollector } from '../sim/display-step.ts';
|
||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||
// which would pull node:fs into a browser bundle.
|
||||
import {
|
||||
@@ -276,6 +278,16 @@ export type Game = {
|
||||
* having taken a turn to cause it.
|
||||
*/
|
||||
announced: string | null;
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. One per accepted intent, so a player can WATCH
|
||||
* what everyone else did rather than find the board already rearranged.
|
||||
*
|
||||
* Accumulated here beside `log`, `cues` and `announced` and drained the same way, because that is
|
||||
* how this file already hands things to whatever is displaying the game. Filled by `submit()`
|
||||
* alone, which is what makes it identical for solitaire and multiplayer and inert during replay —
|
||||
* see `sim/display-step.ts`.
|
||||
*/
|
||||
display: DisplayCollector;
|
||||
};
|
||||
|
||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||
@@ -311,7 +323,7 @@ export const SOLO_PLAYER = 'Solitaire';
|
||||
|
||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null };
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
|
||||
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||
// first, then let the clock take over.
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
@@ -330,7 +342,7 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
*/
|
||||
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
|
||||
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null };
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
/**
|
||||
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
|
||||
@@ -1105,11 +1117,69 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null
|
||||
return false;
|
||||
}
|
||||
game.history.push(intent);
|
||||
/**
|
||||
* THE HIGH-WATER MARK FOR THIS STEP'S NARRATION (v0.8.0).
|
||||
*
|
||||
* Taken here rather than read from `session.ts`'s `sentLines`, which is per-seat and is MUTATED
|
||||
* by `linesSince()` as a side effect of building a push — so it cannot answer "what did this one
|
||||
* intent say?". `submit` brackets the whole thing, `record` and `drain` below are the only things
|
||||
* that append, and the slice after them is exactly this intent's narration including whatever
|
||||
* automatic phases it drained.
|
||||
*/
|
||||
const saidFrom = game.log.length;
|
||||
record(game, result.events, actor);
|
||||
drain(game);
|
||||
collectStep(game.display, game.state, actor, intent.type, game.log.slice(saidFrom));
|
||||
drainStepping(game);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `drain()`'s STEPPED TWIN — TODO #18, and the reason this is not just `drain(game)`.
|
||||
*
|
||||
* `pump()` runs every automatic phase between one click and the next and `drain()` records the whole
|
||||
* batch at once, so New Train, the Mainline and the shift change are never drawn at all: trains
|
||||
* cross the Division in a single jump. Stepping `advance()` one call at a time and collecting after
|
||||
* each is what gives those phases a visible beat, which is exactly what TODO Reference · #18 says is
|
||||
* needed — *"a minimum dwell time on its own therefore fixes nothing"*.
|
||||
*
|
||||
* IDENTICAL BEHAVIOUR TO `drain()`, deliberately. The same `advance()` calls in the same order
|
||||
* produce the same state; `record()` is called per phase rather than per batch, which is equivalent
|
||||
* because `cuesFor` is a pure per-event map with no cross-event state and `record`'s other outputs
|
||||
* (`scheduled`, `justDrawn`, `announced`) are last-wins in event order either way.
|
||||
*
|
||||
* A PHASE THAT DID NOTHING PRODUCES NO STEP. Jesse, 2026-09-09: *"if nothing happens during a phase
|
||||
* then we shouldn't lose time to it."* Narrating nothing is the test for that — an empty phase adds
|
||||
* no lines, so it is skipped rather than given a dwell to sit through.
|
||||
*
|
||||
* `drain()` itself is untouched, and must stay that way: `fromSave`, `fromMultiplayerSave` and
|
||||
* `undo` all use it, and the collector staying off those paths is what keeps a replay from
|
||||
* re-emitting a whole game as steps.
|
||||
*/
|
||||
function drainStepping(game: Game): void {
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
const from = game.log.length;
|
||||
const r = advance(game.state);
|
||||
record(game, r.events);
|
||||
/**
|
||||
* THE TEST IS THE EVENT LIST, NOT THE LOG — and getting that wrong drifted the board.
|
||||
*
|
||||
* `record()` deliberately drops `actorChanged` before narrating, so a phase whose only effect is
|
||||
* handing the turn to the next player grows no lines at all. Collecting only when the log grew
|
||||
* therefore skipped those, and the last step's frame was then a position behind the real one:
|
||||
* the animated board ended a turn out of step with the game (`actor: 2` where the game said 1).
|
||||
*
|
||||
* A step whose narration is empty still carries the board. It simply costs no time to show —
|
||||
* `dwellForStep` gives a silent step a dwell of zero — which is the same rule that collapses an
|
||||
* empty phase, arrived at from the other direction.
|
||||
*/
|
||||
if (r.events.length > 0) {
|
||||
collectStep(game.display, game.state, null, 'phase', game.log.slice(from));
|
||||
}
|
||||
if (r.needsInput || game.state.status === 'finished') return;
|
||||
}
|
||||
throw new Error('phase driver failed to settle — probable infinite loop');
|
||||
}
|
||||
|
||||
/**
|
||||
* Which cards in hand can be played RIGHT NOW, in hand order.
|
||||
*
|
||||
|
||||
+393
-61
@@ -24,6 +24,9 @@ import type { NewGameOptions } from './game.ts';
|
||||
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 { PACE_LEVELS } from '../sim/pacing.ts';
|
||||
import { notice, prefillCode, runLobby } from './lobby.ts';
|
||||
import type { LobbyReady } from './lobby.ts';
|
||||
import {
|
||||
@@ -51,6 +54,7 @@ const REMOTE_KEY = 'station-master.remote.v1';
|
||||
/** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */
|
||||
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
|
||||
|
||||
|
||||
/**
|
||||
* Small persisted preferences, kept in a `localStorage` key of their own — separate from
|
||||
* `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs
|
||||
@@ -67,9 +71,43 @@ type Settings = {
|
||||
* to?", which Jesse's own framing says is "not something they're likely to need all the time".
|
||||
*/
|
||||
gameCardOpen: boolean;
|
||||
/**
|
||||
* HOW FAST OTHER PEOPLE'S TURNS PLAY BACK — v0.8.0, TODO #13/#18. A multiplier over the dwell
|
||||
* table in `sim/pacing.ts`: 1 is as tabled, 0.5 is twice as fast, and **0 turns animation off**,
|
||||
* which is TODO #18's "a player who has seen it a hundred times will want it off" without a second
|
||||
* mechanism for it.
|
||||
*
|
||||
* Here rather than in the game's config, on Jesse's call 2026-09-09: dwell is presentation, not a
|
||||
* rule, and a `GameConfig` rides along in saves and replays. It is also per-viewer for the reason
|
||||
* this whole object exists — two players at one table may reasonably want different speeds.
|
||||
*/
|
||||
pace: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false };
|
||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false, pace: 1 };
|
||||
|
||||
/**
|
||||
* `?pace=` — a per-session override that persists nothing.
|
||||
*
|
||||
* The third of the three tuning levels the design calls for (`docs/plans/jitsi-common-board.md`
|
||||
* § v0.8.0 § 5): the committed table needs a rebuild, the setting needs a click, and this needs a
|
||||
* link — which is what makes it the one that is actually useful at a playtest, where two testers can
|
||||
* be handed different speeds and compared. Follows `?seed=`, which is already the convention here.
|
||||
*
|
||||
* Read ONCE, at load. The queue asks for the pace on every step it measures, and `behind()` asks for
|
||||
* every step still queued — so parsing the query string in there meant building a `URLSearchParams`
|
||||
* a hundred times to render one row. It cannot change without a reload anyway.
|
||||
*/
|
||||
const PACE_OVERRIDE: number | null = (() => {
|
||||
try {
|
||||
const raw = new URLSearchParams(location.search).get('pace');
|
||||
if (raw === null) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
function loadSettings(): Settings {
|
||||
try {
|
||||
@@ -87,6 +125,11 @@ function loadSettings(): Settings {
|
||||
: DEFAULT_SETTINGS.zoom,
|
||||
gameCardOpen:
|
||||
typeof parsed.gameCardOpen === 'boolean' ? parsed.gameCardOpen : DEFAULT_SETTINGS.gameCardOpen,
|
||||
// A negative or non-finite saved value is corrupt, not a request to run time backwards.
|
||||
pace:
|
||||
typeof parsed.pace === 'number' && Number.isFinite(parsed.pace) && parsed.pace >= 0
|
||||
? parsed.pace
|
||||
: DEFAULT_SETTINGS.pace,
|
||||
};
|
||||
} catch {
|
||||
// A full or disabled localStorage must not take the game down with it — same guard as the save.
|
||||
@@ -113,6 +156,206 @@ function saveSettings(patch: Partial<Settings>): void {
|
||||
*/
|
||||
let session: Session;
|
||||
|
||||
/**
|
||||
* THE ANIMATION QUEUE — v0.8.0, TODO #13/#15/#18.
|
||||
*
|
||||
* Holds the board the screen is showing, which is not always the board the game is on. One queue
|
||||
* for both session kinds: solitaire drains its own collector and a remote session reads the same
|
||||
* steps off the wire, and this cannot tell which it has (`web/step-queue.ts`).
|
||||
*
|
||||
* Reads `pace` through a function rather than a captured value, so changing the setting takes effect
|
||||
* on the next step instead of the next game. `?pace=` wins over the saved setting for this session
|
||||
* only.
|
||||
*/
|
||||
const stepQueue = createStepQueue(
|
||||
() => PACE_OVERRIDE ?? settings.pace,
|
||||
// Whose moves not to bother replaying — this client's own. Read lazily: `session` is assigned when
|
||||
// a game starts, long after this queue is built.
|
||||
() => (session ? session.seat() : null),
|
||||
);
|
||||
|
||||
/**
|
||||
* Pulls whatever the session has for us into the queue. Called on every push, before rendering.
|
||||
*
|
||||
* A RESET IS TAKEN FIRST AND SEPARATELY: it means "start over from this board", so applying it after
|
||||
* the steps that arrived with it would draw them onto a baseline they do not chain from.
|
||||
*/
|
||||
function drainIntoQueue(): void {
|
||||
const reset = session.takeDisplayReset();
|
||||
if (reset) stepQueue.reset(reset);
|
||||
stepQueue.push(session.takeDisplaySteps());
|
||||
/**
|
||||
* NOWHERE TO ANIMATE MEANS DO NOT QUEUE AT ALL.
|
||||
*
|
||||
* Without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would stay true for
|
||||
* good — and since "Your Move" is now put away while the board is catching up, that would hide a
|
||||
* player's own actions permanently, leaving Skip as the only way to play the game. Drawing
|
||||
* everything at once is exactly what `pace = 0` does deliberately, so that is the honest fallback
|
||||
* rather than a broken page. Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same
|
||||
* stub that has been proving this page still starts since long before any of this existed.
|
||||
*/
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
stepQueue.skip();
|
||||
return;
|
||||
}
|
||||
if (stepQueue.busy()) startAnimationLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* WHOSE DISTRICT THE BOARD IS SHOWING — v0.8.0, TODO #13. Null means "your own", drawn exactly as
|
||||
* it always was.
|
||||
*
|
||||
* FOLLOW THE ACTOR (Jesse, 2026-09-09). While the queue is animating, follow the step being shown,
|
||||
* so a bot's switching turn is watched on the bot's board. At rest, follow whoever the game is
|
||||
* waiting on — which is how you watch a human opponent work in something close to real time, since
|
||||
* their steps trickle in as they click rather than arriving in a burst.
|
||||
*
|
||||
* `Frame.cells` is the VIEWER'S district and nobody else's, which is the whole reason a step stream
|
||||
* alone could not answer #13: the data would arrive with nowhere to be drawn. This is where it gets
|
||||
* drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and
|
||||
* has never needed a private viewer.
|
||||
*/
|
||||
function renderWatching(f?: Frame): void {
|
||||
const behind = stepQueue.behind();
|
||||
const row = $('watching');
|
||||
/**
|
||||
* VISIBLE WHILE THE BOARD IS BEHIND **OR** STILL SHOWING SOMETHING.
|
||||
*
|
||||
* It used to hide the moment `behind` hit zero — which is the moment the LAST step of a burst goes
|
||||
* up, so the one step a player was most likely to be reading about lost its caption. Collapsed
|
||||
* otherwise: in solitaire that is nearly always, and between turns in multiplayer too, and a row
|
||||
* that is always there is a row nobody reads.
|
||||
*/
|
||||
if (behind === 0 && !stepQueue.busy()) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
row.hidden = false;
|
||||
$('watching-behind').textContent = behind === 0 ? 'catching up' : `${behind} behind`;
|
||||
/**
|
||||
* THE CAPTION IS #15, and this is where that item lands rather than as a line of its own.
|
||||
*
|
||||
* TODO Reference · #15 could not decide the unit — "most recent action" is right in solitaire and
|
||||
* wrong in multiplayer, where what you missed is everything that happened while you were waiting.
|
||||
* The queue IS that, so the caption simply names the step being shown, and the counter beside it
|
||||
* says how much of the wait is left.
|
||||
*/
|
||||
/**
|
||||
* WHO, THEN WHAT — Jesse, 2026-09-09: *"it didn't tell me what the actual action was, like who I
|
||||
* was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I was
|
||||
* supposed to be looking for."*
|
||||
*
|
||||
* The caption was there; it was the wrong half of the sentence. Half the waiting is automatic
|
||||
* phases, whose narration reads "Mainline" — accurate, and no answer at all to "who am I waiting
|
||||
* on". So the name goes first, and a phase says so in as many words rather than leaving the reader
|
||||
* to infer that nobody is acting.
|
||||
*
|
||||
* The narrated line is used as it stands otherwise, because `record()` already prefixes it with the
|
||||
* player — "Player Bot 1 moved Train 3 (−1,−2) → (−1,1)" — so a second name would stutter.
|
||||
*/
|
||||
const showing = stepQueue.showing();
|
||||
const said = showing?.lines[0]?.text ?? '';
|
||||
const who =
|
||||
showing === null || showing === undefined
|
||||
? ''
|
||||
: showing.player === null
|
||||
? 'The Division'
|
||||
: (f?.players[showing.player]?.name ?? `Seat ${seatLabel(showing.player)}`);
|
||||
// A player action already names its actor; a phase does not, so it is introduced.
|
||||
$('watching-what').textContent = showing?.player === null && said !== '' ? `${who}: ${said}` : said;
|
||||
/**
|
||||
* SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION.
|
||||
*
|
||||
* Every line skipped is already in the History panel — the queue animates a board, it does not
|
||||
* carry the record — which is what makes this safe to press without weighing it up. Assigned each
|
||||
* render rather than once, matching how every other button on this page is wired.
|
||||
*/
|
||||
$('watching-skip').onclick = () => {
|
||||
if (stepQueue.skip()) render();
|
||||
};
|
||||
}
|
||||
|
||||
function watchedDistrict(f: Frame): PublicDistrict | null {
|
||||
const pub = stepQueue.current();
|
||||
if (!pub) return null;
|
||||
/**
|
||||
* FOLLOW WHOEVER IS ACTING. While animating that is the step on screen; at rest it is whoever the
|
||||
* game is waiting on.
|
||||
*
|
||||
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
|
||||
* which keeps the board where it was instead of snapping home mid-sequence.
|
||||
*/
|
||||
let player: PlayerIndex | null = f.actor;
|
||||
if (stepQueue.busy()) {
|
||||
const acting = stepQueue.showing()?.player;
|
||||
if (acting !== undefined && acting !== null) player = acting;
|
||||
}
|
||||
if (player === null || player === f.viewer) return null;
|
||||
return pub.districts.find((d) => d.player === player) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the queue from the browser's own frame clock, ON DEMAND.
|
||||
*
|
||||
* The queue owns no timer of its own — that is what makes it testable without faking one — so
|
||||
* something has to advance it. This runs only while there is a backlog and stops itself when the
|
||||
* board catches up, for two reasons beyond tidiness:
|
||||
*
|
||||
* - **Loops must not accumulate.** `startAnimationLoop` is reachable from both session kinds, and
|
||||
* a player can go lobby → game → lobby → game in one page load. A loop started per game and
|
||||
* never stopped would leave one running per visit, each calling `render()` forever.
|
||||
* - An idle table should do nothing at all. Solitaire between clicks, and multiplayer between
|
||||
* turns, is the common case.
|
||||
*
|
||||
* `requestAnimationFrame` may be absent — the static build is loaded head-first by `test/web.test.ts`
|
||||
* against a DOM stub. Nothing here is required for correctness; without it the board simply arrives
|
||||
* without being animated, which is exactly what `pace = 0` does on purpose.
|
||||
*/
|
||||
let animating = false;
|
||||
function startAnimationLoop(): void {
|
||||
if (animating || typeof requestAnimationFrame !== 'function') return;
|
||||
animating = true;
|
||||
const tick = (now: number): void => {
|
||||
try {
|
||||
if (stepQueue.advance(now)) render();
|
||||
} catch (err) {
|
||||
/**
|
||||
* A BROKEN QUEUE MUST NOT TAKE THE GAME WITH IT, or wedge itself on.
|
||||
*
|
||||
* `applyPublicDelta` throws when a delta says "unchanged" and there is nothing to merge onto
|
||||
* — a sender/receiver disagreement about what has been delivered. The board is still correct
|
||||
* (the authoritative Frame comes down the same push and is drawn from `session.view()`); only
|
||||
* the animation is lost. Without the flag being cleared here, one throw would leave `animating`
|
||||
* true forever and no later burst would ever play.
|
||||
*/
|
||||
console.error('display queue stopped:', err);
|
||||
animating = false;
|
||||
// Do not strand the player behind a queue that can no longer advance: jump the board to the
|
||||
// live position, which brings "Your Move" back with it.
|
||||
try {
|
||||
stepQueue.skip();
|
||||
} catch {
|
||||
/* nothing further to try — the authoritative Frame is still what the rest of the page draws */
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (!stepQueue.busy()) {
|
||||
animating = false;
|
||||
/**
|
||||
* A FULL RENDER, not just the row. The board being level again is what brings "Your Move"
|
||||
* back and clears the last lit pile, so redrawing only the catching-up row would leave the
|
||||
* action list hidden until something else happened to trigger a render — which, when the game
|
||||
* is waiting on this player, is nothing at all.
|
||||
*/
|
||||
render();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
/**
|
||||
* The three `Capabilities` (`undo`/`saveLocal`/`newGame`) travel together — all `true` for a
|
||||
* `LocalSession`, all `false` for a `RemoteSession` (`session.ts`) — so any one of them is a safe
|
||||
@@ -763,7 +1006,8 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
|
||||
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
|
||||
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
|
||||
// `createRemoteSession` explains why `view()` would otherwise throw).
|
||||
session.subscribe(render);
|
||||
// Steps are pulled in BEFORE the redraw, so a push and the animation it starts land together.
|
||||
session.subscribe(() => { drainIntoQueue(); render(); });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -907,7 +1151,8 @@ function start(): void {
|
||||
applyCapabilities();
|
||||
// Every render goes through the session, so the page redraws whenever the game says it changed —
|
||||
// which is what a remote session will use to push. Locally it fires on each accepted intent.
|
||||
session.subscribe(render);
|
||||
// Steps are pulled in BEFORE the redraw, so a push and the animation it starts land together.
|
||||
session.subscribe(() => { drainIntoQueue(); render(); });
|
||||
render();
|
||||
// Coming back to a game is not the same event as being dealt one, and the board looks identical
|
||||
// either way — mid-Day, mid-phase, with a log already deep (Jesse, 2026-08-30).
|
||||
@@ -1073,6 +1318,7 @@ function render(): void {
|
||||
|
||||
renderTurnChart(f);
|
||||
renderPresence(f);
|
||||
renderWatching(f);
|
||||
$('revenue').textContent = String(f.revenue);
|
||||
/**
|
||||
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
||||
@@ -1139,68 +1385,83 @@ function render(): void {
|
||||
: 'ATTACH TO THIS CARD';
|
||||
return [{ row: cell.row, col: cell.col, label }];
|
||||
});
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
|
||||
/**
|
||||
* SOMEBODY ELSE'S BOARD IS READ-ONLY, and that is not a cosmetic distinction.
|
||||
*
|
||||
* No ghosts, no legal caps and no selected crew: all three are answers to "what could YOU do
|
||||
* here", computed from this seat's own menu, and drawing them over another player's district
|
||||
* would offer moves on a board you cannot play. Every click handler below is skipped for the same
|
||||
* reason — `spotsAt` holds coordinates in YOUR district, and the same coordinates exist in theirs,
|
||||
* so wiring them up would silently attach your moves to their squares.
|
||||
*/
|
||||
const watched = watchedDistrict(f);
|
||||
$('districtwho').textContent = watched ? `${watched.name}'s Office Area` : 'Your Office Area';
|
||||
grid.innerHTML = watched
|
||||
? officeSvg(watched.cells, watched.runningRow, [], [], watched.limits, null)
|
||||
: officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
|
||||
applyZoom(grid);
|
||||
|
||||
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
|
||||
// you switching?" picker writes, so the board and the action panel drive one value either way.
|
||||
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
|
||||
const trayId = (g as HTMLElement).dataset['crew'];
|
||||
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
|
||||
}
|
||||
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
const [gr, gc] = key.split(',').map(Number);
|
||||
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
|
||||
if (g) {
|
||||
g.classList.add('bs-legal');
|
||||
(g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
if (!watched) {
|
||||
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
|
||||
// you switching?" picker writes, so the board and the action panel drive one value either way.
|
||||
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
|
||||
const trayId = (g as HTMLElement).dataset['crew'];
|
||||
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
|
||||
}
|
||||
}
|
||||
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
|
||||
for (const [key, list] of spotsAt) {
|
||||
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
|
||||
const g = grid.querySelector(`g[data-ghost="${key}"]`);
|
||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SWITCHING MOVE, ON THE BOARD.
|
||||
*
|
||||
* Every switching decision is about geography — which card the crew can reach, what it will couple
|
||||
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
|
||||
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
|
||||
* months; the play page simply never used them.
|
||||
*
|
||||
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
|
||||
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
|
||||
*/
|
||||
/**
|
||||
* ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than
|
||||
* drawing one crew's: the highlights merge into a single blob and stop meaning "here is where
|
||||
* THIS train can go", which is the whole reason they are on the board.
|
||||
*/
|
||||
const crew = pickedCrew(f);
|
||||
if (crew && !forPlay) {
|
||||
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
|
||||
// share, and outlining the card would claim it belongs to both.
|
||||
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
|
||||
if (strip) strip.classList.add('bs-from');
|
||||
for (const c of crew.to) {
|
||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||
if (g) g.classList.add('bs-focus');
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
const [gr, gc] = key.split(',').map(Number);
|
||||
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
|
||||
if (g) {
|
||||
g.classList.add('bs-legal');
|
||||
(g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
}
|
||||
for (const b of crew.blocked) {
|
||||
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
|
||||
if (!g) continue;
|
||||
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
|
||||
// your way — you run through it — so it must not be drawn like an industry that is locked.
|
||||
const passable = b.kind === 'noStopping';
|
||||
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
|
||||
const own = g.getAttribute('data-tip') ?? '';
|
||||
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
|
||||
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
||||
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
|
||||
for (const [key, list] of spotsAt) {
|
||||
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
|
||||
const g = grid.querySelector(`g[data-ghost="${key}"]`);
|
||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SWITCHING MOVE, ON THE BOARD.
|
||||
*
|
||||
* Every switching decision is about geography — which card the crew can reach, what it will couple
|
||||
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
|
||||
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
|
||||
* months; the play page simply never used them.
|
||||
*
|
||||
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
|
||||
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
|
||||
*/
|
||||
/**
|
||||
* ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than
|
||||
* drawing one crew's: the highlights merge into a single blob and stop meaning "here is where
|
||||
* THIS train can go", which is the whole reason they are on the board.
|
||||
*/
|
||||
const crew = pickedCrew(f);
|
||||
if (crew && !forPlay) {
|
||||
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
|
||||
// share, and outlining the card would claim it belongs to both.
|
||||
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
|
||||
if (strip) strip.classList.add('bs-from');
|
||||
for (const c of crew.to) {
|
||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||
if (g) g.classList.add('bs-focus');
|
||||
}
|
||||
for (const b of crew.blocked) {
|
||||
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
|
||||
if (!g) continue;
|
||||
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
|
||||
// your way — you run through it — so it must not be drawn like an industry that is locked.
|
||||
const passable = b.kind === 'noStopping';
|
||||
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
|
||||
const own = g.getAttribute('data-tip') ?? '';
|
||||
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
|
||||
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,7 +1538,9 @@ function render(): void {
|
||||
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
|
||||
* action rather than a click on the card itself.
|
||||
*/
|
||||
$('depts').innerHTML = pilesHtml(f);
|
||||
// The pile the move being WATCHED just touched, lit for as long as that step is on screen. Empty
|
||||
// whenever the board is level with the game, or when the move was this player's own.
|
||||
$('depts').innerHTML = pilesHtml(f, stepQueue.busy() ? stepQueue.lit() : []);
|
||||
|
||||
renderYards(f);
|
||||
|
||||
@@ -1751,6 +2014,29 @@ function renderActions(
|
||||
renderEnding(el, f);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* YOUR MOVE IS PUT AWAY WHILE THE BOARD IS CATCHING UP — Jesse, 2026-09-10: *"your actions should
|
||||
* be hidden while catching up."*
|
||||
*
|
||||
* Two reasons, and the second is the one that changed my mind about it. The board on screen is
|
||||
* behind the game, so a move offered here is a move against a position that has already moved on —
|
||||
* the menu is computed from the CURRENT state and would be acted on while looking at an older one.
|
||||
* And the display had grown to four things demanding attention at once — the district, the history,
|
||||
* the catching-up row and now a lit pile — which is what made the pile highlight so easy to miss.
|
||||
* Taking the action list out of that competition while there is nothing to decide anyway is the
|
||||
* cheapest way to quieten it.
|
||||
*
|
||||
* NOT A BLOCK. Skip is one click away and sits at the left of the row, so the wait is always
|
||||
* voluntary; this replaces the buttons with the reason they are gone, rather than leaving a live
|
||||
* menu over a stale board.
|
||||
*/
|
||||
if (stepQueue.busy()) {
|
||||
el.innerHTML =
|
||||
'<div class="dim">Catching up on what everyone else did — your move is here when the board is ' +
|
||||
'level with the game. <b>Skip</b> jumps straight to it.</div>';
|
||||
return;
|
||||
}
|
||||
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
|
||||
// put its results up unasked again (Gitea#11).
|
||||
resultsShown = false;
|
||||
@@ -2398,6 +2684,52 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame
|
||||
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* PLAYBACK SPEED — v0.8.0.3, TODO #13.
|
||||
*
|
||||
* Persisted per viewer in `Settings`, so it survives the navigation that was eating `?pace=`. The
|
||||
* queue reads `settings.pace` through a closure on every step, so a change here takes effect on the
|
||||
* very next move rather than the next game.
|
||||
*/
|
||||
const paceSlowerBtn = document.getElementById('paceslower') as HTMLButtonElement | null;
|
||||
const paceFasterBtn = document.getElementById('pacefaster') as HTMLButtonElement | null;
|
||||
const paceLabel = document.getElementById('pacelabel');
|
||||
if (paceSlowerBtn && paceFasterBtn && paceLabel) {
|
||||
const nearestPace = (): number => {
|
||||
// A saved or URL value need not be on the ladder — `?pace=7` and a hand-edited setting are both
|
||||
// legitimate — so the buttons step from whichever preset is closest rather than refusing to move.
|
||||
const want = PACE_OVERRIDE ?? settings.pace;
|
||||
return PACE_LEVELS.reduce((best, p) => (Math.abs(p - want) < Math.abs(best - want) ? p : best), PACE_LEVELS[0]);
|
||||
};
|
||||
const paintPace = (): void => {
|
||||
const p = PACE_OVERRIDE ?? settings.pace;
|
||||
paceLabel.textContent = p === 0 ? 'off' : `${p}×`;
|
||||
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
|
||||
paceSlowerBtn.disabled = i >= PACE_LEVELS.length - 1;
|
||||
paceFasterBtn.disabled = i <= 0;
|
||||
// A `?pace=` in the URL wins over the setting, so say so rather than showing dead buttons.
|
||||
if (PACE_OVERRIDE !== null) {
|
||||
paceSlowerBtn.disabled = true;
|
||||
paceFasterBtn.disabled = true;
|
||||
paceLabel.textContent = `${PACE_OVERRIDE}× (URL)`;
|
||||
}
|
||||
};
|
||||
const stepPace = (by: number): void => {
|
||||
const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]);
|
||||
const next = PACE_LEVELS[Math.min(PACE_LEVELS.length - 1, Math.max(0, i + by))];
|
||||
if (next === undefined) return;
|
||||
saveSettings({ pace: next });
|
||||
paintPace();
|
||||
// The row's countdown is measured in steps that will dwell, so a change to 0 empties it at once.
|
||||
renderWatching();
|
||||
};
|
||||
// Slower is a BIGGER multiplier, so "−" walks up the ladder. Labelled by what it does to the game,
|
||||
// not to the number: a player pressing "slower" wants to watch for longer.
|
||||
paceSlowerBtn.onclick = () => stepPace(1);
|
||||
paceFasterBtn.onclick = () => stepPace(-1);
|
||||
paintPace();
|
||||
}
|
||||
|
||||
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
|
||||
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
|
||||
const zoomLabel = document.getElementById('zoomlabel');
|
||||
|
||||
+84
-8
@@ -52,25 +52,61 @@ export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
|
||||
* is out of reach, and choosing where to discard is choosing what to put there.
|
||||
*/
|
||||
export function pilesHtml(f: Frame): string {
|
||||
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
|
||||
export function pilesHtml(f: Frame, lit: readonly string[] = []): string {
|
||||
const pile = (
|
||||
key: string,
|
||||
label: string,
|
||||
top: string,
|
||||
depth: number,
|
||||
why: string,
|
||||
extra = '',
|
||||
slot = -1,
|
||||
faceDown = false,
|
||||
): string => {
|
||||
const tip = [why, extra].filter(Boolean).join(' · ');
|
||||
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
|
||||
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
|
||||
// viewer draws exactly the same markup and nothing there is clickable.
|
||||
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
|
||||
// `lit` marks the pile the move being watched just touched — see `changedPiles`.
|
||||
const cls = `handcard${faceDown ? ' facedown' : ''}${lit.includes(key) ? ' pilelit' : ''}`;
|
||||
return (
|
||||
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="${cls}"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
|
||||
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
|
||||
`<b>${esc(top)}</b></div>`
|
||||
);
|
||||
};
|
||||
return (
|
||||
/**
|
||||
* THE HOME OFFICE DECK, which the screen had never drawn.
|
||||
*
|
||||
* `f.deck` has carried the face-down count since the Frame existed and nothing read it — the
|
||||
* exact shape of display gap `test/display-gaps.test.ts` was written to sweep for, surviving in
|
||||
* the panel that draws every OTHER pile. Asked for by Jesse 2026-09-10 for a second reason: a
|
||||
* player drawing from it is the commonest move nobody can see, so it needs somewhere to flash.
|
||||
*
|
||||
* FIRST, because that is the order a card travels: out of here, into a hand, then onto a
|
||||
* Department or the Salvage Yard. Face down, so the card slot says so rather than naming a card
|
||||
* — the whole point of this pile is that nobody knows what is on top.
|
||||
*/
|
||||
pile(
|
||||
'home',
|
||||
'Home Office',
|
||||
'face down',
|
||||
f.deck,
|
||||
'The draw deck. Face down — nobody sees what is on top, and a card drawn from here is private ' +
|
||||
'to whoever drew it. When it runs out, the Salvage Yard and the Departments are swept back ' +
|
||||
'into it.',
|
||||
'',
|
||||
-1,
|
||||
true,
|
||||
) +
|
||||
f.departments
|
||||
.map((d, i) => {
|
||||
const depth = f.departmentDepth[i] ?? 0;
|
||||
const under = depth - 1;
|
||||
return pile(
|
||||
`dept${i}`,
|
||||
`Dept ${i + 1}`,
|
||||
d,
|
||||
depth,
|
||||
@@ -81,6 +117,7 @@ export function pilesHtml(f: Frame): string {
|
||||
})
|
||||
.join('') +
|
||||
pile(
|
||||
'salvage',
|
||||
'Salvage',
|
||||
f.salvage.top,
|
||||
f.salvage.depth,
|
||||
@@ -180,7 +217,7 @@ export function dayEndHtml(f: Frame): string {
|
||||
ahead +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f)
|
||||
collisionsHtml(f, ended)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,13 +275,28 @@ function targetHtml(f: Frame): string {
|
||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
function collisionsHtml(f: Frame): string {
|
||||
function collisionsHtml(f: Frame, endedDay?: number): string {
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
return scoredOnCollisions
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
if (!scoredOnCollisions) return '';
|
||||
/**
|
||||
* "TODAY" IS THE WRONG WORD IN A DAY-END DIALOG, and it read as a contradiction.
|
||||
*
|
||||
* That dialog is drawn from the frame whose `day` went UP — which is the same frame in which
|
||||
* `collisionsToday` was reset — so it reported 0 however many there had been. Jesse, 2026-09-09,
|
||||
* at the end of a Day 1 with two collisions in it: "it shows a total of two collisions, but zero
|
||||
* today ... that does seem to be a contradiction."
|
||||
*
|
||||
* So when the caller knows which Day just ended it says so by name, and reads the count captured at
|
||||
* the rollover. The end-of-game results screen passes nothing and keeps "today", where the Day has
|
||||
* not turned over and the word is accurate.
|
||||
*/
|
||||
const [count, when] =
|
||||
endedDay === undefined
|
||||
? [f.collisionsToday, 'today']
|
||||
: [f.collisionsPrevDay, `on Day ${endedDay}`];
|
||||
return `<p>Collisions: <b>${count}</b> ${when}, <b>${f.collisionsTotal}</b> in all.</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -661,6 +713,30 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
|
||||
.handcard.unplayable{color:#7d8794;border-color:#39424e}
|
||||
/* THE HOME OFFICE DECK. Face down, so its card slot names no card — it says so instead, in the
|
||||
dimmed voice the rest of the panel uses for "nothing to read here". */
|
||||
.handcard.facedown > b{color:#6f7885;font-style:italic;font-weight:400}
|
||||
/* THE PILE A WATCHED MOVE JUST TOUCHED (v0.8.1).
|
||||
A STATE, NOT A FLASH, and that is the whole point. The .tt-slot.fresh rule above animates for a fixed
|
||||
1.5s, which is right for a die roll nobody is waiting on — but a step can hold the screen for
|
||||
seven seconds at 10x, so a fixed animation would be over long before the pause it belongs to and
|
||||
the player would be back to staring at an unchanged board. The flash-in marks the moment; the lit
|
||||
border and background stay for exactly as long as the step is up, because the class is on the
|
||||
element only while that step is the one being shown. */
|
||||
.handcard.pilelit{border-color:#8fd6a0;background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.55);
|
||||
animation:pilepulse 1.15s ease-in-out infinite}
|
||||
/* A PULSE FOR THE WHOLE DWELL, not one flash at the start. Measured: at 10x a pile stays lit for
|
||||
just under seven seconds, so the highlight was never brief — but a single 0.45s flash-in and a
|
||||
dark green fill were easy to miss entirely while watching the district. Jesse: "caught one flash
|
||||
deck light up for just a very brief moment, but couldn't see that with what bot was doing in
|
||||
office area and history and catch up area all at same time." Something still moving keeps drawing
|
||||
the eye for as long as the move is up; a state that settles stops asking to be looked at. */
|
||||
@keyframes pilepulse{0%,100%{background:#1d3327;box-shadow:0 0 0 2px #2f6b47,0 0 14px rgba(143,214,160,.45)}
|
||||
50%{background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0,0 0 22px rgba(143,214,160,.85)}}
|
||||
/* Motion is the point here, so the reduced-motion fallback has to be loud in a different way rather
|
||||
than simply not moving: a solid ring and a brighter fill, held. */
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.handcard.pilelit{animation:none;background:#2f6b47;box-shadow:0 0 0 3px #8fd6a0}}
|
||||
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
|
||||
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
|
||||
/* THE CARD JUST DRAWN. It sits first in the row, and this says which one that is — three cards that
|
||||
|
||||
+34
-1
@@ -114,6 +114,18 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
.lb-seat:last-child{border-bottom:none}
|
||||
.lb-seat .who{flex:1}
|
||||
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
|
||||
/* WHAT YOU ARE WATCHING — v0.8.0, TODO #13/#15. An IN-FLOW row rather than a floating banner like
|
||||
#phasenote and #announce: those announce a moment and fade, this one stands for as long as the
|
||||
board is behind and has a button you have to be able to hit. Amber on the button because amber
|
||||
already means clickable everywhere else on this page; the row itself stays quiet so it does not
|
||||
compete with the three banners it sits under. */
|
||||
#watching{display:flex;align-items:center;gap:10px;padding:4px 14px;font-size:12px;color:#9aa0b4}
|
||||
#watching[hidden]{display:none}
|
||||
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
|
||||
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
|
||||
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
|
||||
#watching-who{color:#c9cee0;font-weight:700}
|
||||
#presence:empty{display:none}
|
||||
/* division strip */
|
||||
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
|
||||
@@ -861,6 +873,14 @@ ul.blocked li{padding:2px 0}
|
||||
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
|
||||
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
|
||||
</span>
|
||||
<!-- HOW FAST OTHER PLAYERS' TURNS PLAY BACK — v0.8.0.3, TODO #13.
|
||||
A CONTROL RATHER THAN ONLY A URL PARAMETER. `?pace=` shipped first and is unreachable through
|
||||
the front door: `index.html`'s two doors are `play.html?lobby` and `play.html?solitaire`, so
|
||||
arriving from the splash REPLACES the query string and any pace with it. Jesse played a whole
|
||||
game believing he was at 7x when he was at 1x. -->
|
||||
<span class="zoom" title="How long another player's or a bot's move is held on screen before the next one. Yours are never delayed. Off draws every move at once, as it did before v0.8.0.">
|
||||
<button id="paceslower" aria-label="Slower playback">−</button><span id="pacelabel">1×</span><button id="pacefaster" aria-label="Faster playback">+</button>
|
||||
</span>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
|
||||
@@ -890,12 +910,25 @@ ul.blocked li{padding:2px 0}
|
||||
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
|
||||
<div id="presence"></div>
|
||||
|
||||
<!-- WHAT YOU ARE WATCHING, and how far behind the board is — v0.8.0, TODO #13/#15.
|
||||
One row rather than three additions: the countdown, the caption naming the action being shown,
|
||||
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
|
||||
almost always. -->
|
||||
<div id="watching" hidden>
|
||||
<!-- SKIP FIRST, on the left. It sat on the far right and a player's eye is on the countdown, not at
|
||||
the other end of the row — Jesse, 2026-09-09: "the skip button should be on the far left, in
|
||||
front of where it says [the count], so it's always close to where people are looking." -->
|
||||
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
|
||||
<span id="watching-behind" class="wbehind"></span>
|
||||
<span id="watching-what"></span>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
<section><h2>The Division — west to east</h2><div id="division"></div>
|
||||
<p class="ng-note" id="seating-chain"></p></section>
|
||||
<section id="district">
|
||||
<h2>Your Office Area
|
||||
<h2><span id="districtwho">Your Office Area</span>
|
||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||
</h2>
|
||||
|
||||
+70
-1
@@ -16,7 +16,10 @@
|
||||
*/
|
||||
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import type { Frame, PublicFrame } from '../sim/view.ts';
|
||||
import { publicSnapshot } from '../sim/view.ts';
|
||||
import { takeSteps } from '../sim/display-step.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import { applyDelta } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
@@ -105,6 +108,28 @@ export type Session = {
|
||||
* starts — which is exactly when "is everyone here?" is the question.
|
||||
*/
|
||||
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||
/**
|
||||
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. What everyone else did, in order, so it can be
|
||||
* WATCHED rather than discovered.
|
||||
*
|
||||
* On the interface rather than on `LocalSession`, which is the whole point: solitaire drains its
|
||||
* own collector and a remote session reads the same steps off `Push.steps`, so the page animates
|
||||
* one queue and cannot tell which it has. That is what makes TODO #18 (solitaire's phases flying
|
||||
* past) and TODO #13 (multiplayer's invisible turns) the same code path.
|
||||
*
|
||||
* NOT `steps()` — `LocalSession.steps()` already exists and counts submitted intents for the Undo
|
||||
* button. Different thing entirely, hence the longer name.
|
||||
*/
|
||||
takeDisplaySteps(): DisplayStep[];
|
||||
/**
|
||||
* A public frame to start the queue from, once — draining, and non-null only when the queue must
|
||||
* be RESET rather than advanced.
|
||||
*
|
||||
* Steps carry deltas against a chain, so a client with no baseline cannot merge the next one. That
|
||||
* happens on a first connect, on a reconnect, and locally after an undo or a restore — all of
|
||||
* which rebuild from scratch. A reset means "throw away what is queued and draw this".
|
||||
*/
|
||||
takeDisplayReset(): PublicFrame | null;
|
||||
/**
|
||||
* Stop listening, for good.
|
||||
*
|
||||
@@ -145,6 +170,12 @@ export type LocalSession = Session & {
|
||||
*/
|
||||
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
||||
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
|
||||
/**
|
||||
* The baseline the step queue starts from. Set here, and again whenever the game is REPLACED —
|
||||
* `undo` and `restore` rebuild by replaying history, which (by design) collects no steps, so the
|
||||
* queue has to be told to start over rather than left holding a chain that no longer continues.
|
||||
*/
|
||||
let pendingReset: PublicFrame | null = publicSnapshot(game.state);
|
||||
const listeners = new Set<() => void>();
|
||||
const changed = (): void => {
|
||||
for (const fn of [...listeners]) fn();
|
||||
@@ -186,6 +217,14 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
},
|
||||
justDrawn: () => game.justDrawn,
|
||||
presence: () => [],
|
||||
// Solitaire's own steps, from the same collector `submit()` fills for every seat of a
|
||||
// multiplayer game. No separate code path — see `sim/display-step.ts`.
|
||||
takeDisplaySteps: () => takeSteps(game.display),
|
||||
takeDisplayReset: () => {
|
||||
const reset = pendingReset;
|
||||
pendingReset = null;
|
||||
return reset;
|
||||
},
|
||||
|
||||
seed: () => game.seed,
|
||||
save: () => toSave(game),
|
||||
@@ -199,6 +238,8 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
back.scheduled = null;
|
||||
back.justDrawn = null;
|
||||
back.announced = null;
|
||||
// The rebuilt game has an empty collector and a chain that starts over, so the queue must too.
|
||||
pendingReset = publicSnapshot(back.state);
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
@@ -207,6 +248,7 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
// Restoring replays the whole history and re-records every draw; none of it is news.
|
||||
game.justDrawn = null;
|
||||
game.announced = null;
|
||||
pendingReset = publicSnapshot(game.state);
|
||||
changed();
|
||||
},
|
||||
};
|
||||
@@ -220,6 +262,10 @@ type Push = {
|
||||
lines: { text: string; tone: string }[];
|
||||
/** One entry for a change; every other seat at once on the connect push. */
|
||||
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
|
||||
/** Ordered presentation steps — v0.8.0, identical in every seat's push because they are public. */
|
||||
steps?: DisplayStep[];
|
||||
/** The baseline for the step queue, sent on a connect only. */
|
||||
publicReset?: PublicFrame;
|
||||
/**
|
||||
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
|
||||
*
|
||||
@@ -270,6 +316,8 @@ export function createRemoteSession(
|
||||
let menu: Menu | null = null;
|
||||
let lines: { text: string; tone: string }[] = [];
|
||||
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
|
||||
let displaySteps: DisplayStep[] = [];
|
||||
let displayReset: PublicFrame | null = null;
|
||||
let cues: string[] = [];
|
||||
let scheduled: number | null = null;
|
||||
let announcement: string | null = null;
|
||||
@@ -324,6 +372,21 @@ export function createRemoteSession(
|
||||
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
|
||||
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
|
||||
if (push.justDrawn !== undefined) justDrawnCard = push.justDrawn;
|
||||
/**
|
||||
* A RESET DISCARDS WHAT WAS QUEUED, rather than arriving alongside it.
|
||||
*
|
||||
* `publicReset` comes on a connect, which is also a RECONNECT — and a reconnecting client's
|
||||
* queue holds steps whose deltas chain off a baseline the server has since moved past. Merging
|
||||
* them onto the new baseline would draw a board that never existed. The history panel is what
|
||||
* carries what was missed; the animation does not replay it (§ v0.8.0).
|
||||
*/
|
||||
if (push.publicReset) {
|
||||
displayReset = push.publicReset;
|
||||
displaySteps = [];
|
||||
}
|
||||
// Accumulated, like cues: two pushes can land between two renders and every step is one thing
|
||||
// that happened.
|
||||
if (push.steps) displaySteps = [...displaySteps, ...push.steps];
|
||||
changed();
|
||||
};
|
||||
|
||||
@@ -376,6 +439,12 @@ export function createRemoteSession(
|
||||
},
|
||||
justDrawn: () => justDrawnCard,
|
||||
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
|
||||
takeDisplaySteps: () => displaySteps.splice(0, displaySteps.length),
|
||||
takeDisplayReset: () => {
|
||||
const reset = displayReset;
|
||||
displayReset = null;
|
||||
return reset;
|
||||
},
|
||||
close() {
|
||||
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
|
||||
// a game that vanished — `onGone` must not be called and land the page in "that game is no
|
||||
|
||||
@@ -40,6 +40,29 @@ if (heroImage && lightbox) {
|
||||
* other way costs a click and a lobby that says it cannot reach a server — which is legible, and
|
||||
* recoverable. So a slow or flaky probe leaves the door alone; only a definite answer closes it.
|
||||
*/
|
||||
/**
|
||||
* CARRY `?pace=` THROUGH THE DOORS — v0.8.0.3.
|
||||
*
|
||||
* Both doors are static hrefs that REPLACE the query string (`play.html?lobby`,
|
||||
* `play.html?solitaire`), so a `pace` typed on this page was silently dropped on the way in: Jesse
|
||||
* played a whole game believing he was at 7× when the play page had only ever seen `?lobby`. The
|
||||
* durable answer is the speed control on the play screen, which persists per viewer — this keeps the
|
||||
* URL lever honest for handing two playtesters different speeds, which is the only thing it was ever
|
||||
* for.
|
||||
*/
|
||||
try {
|
||||
const pace = new URLSearchParams(location.search).get('pace');
|
||||
if (pace !== null) {
|
||||
for (const door of Array.from(document.querySelectorAll('a.door'))) {
|
||||
const href = door.getAttribute('href');
|
||||
// Only the doors into the game, and only ones that have not been disabled above.
|
||||
if (href?.startsWith('./play.html?')) door.setAttribute('href', `${href}&pace=${encodeURIComponent(pace)}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A door that keeps its own href is the status quo, not a broken page.
|
||||
}
|
||||
|
||||
const mpDoor = document.getElementById('door-multiplayer');
|
||||
if (mpDoor) {
|
||||
const close = (): void => {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 4-6.
|
||||
*
|
||||
* Holds the public board the screen is currently showing, which is not always the board the game is
|
||||
* actually on. Steps arrive faster than a person can follow — a bot's whole switching turn lands in
|
||||
* ONE push, because `driveBots()` plays it out before the push goes back — so this is what turns a
|
||||
* burst into something watchable. TODO #13.
|
||||
*
|
||||
* TRANSPORT-AGNOSTIC ON PURPOSE. It takes `DisplayStep`s and does not care whether they came from
|
||||
* the engine in this tab or off an SSE stream, which is what lets solitaire (#18: phases that are
|
||||
* never drawn) and multiplayer (#13: turns you never see) run one implementation. Nothing here
|
||||
* imports the DOM either, so it is testable without one.
|
||||
*
|
||||
* NO TIMERS OF ITS OWN. The caller drives it with `advance(now)` from whatever loop it already has
|
||||
* — a `requestAnimationFrame`, a test's fake clock. A queue that owned a `setInterval` would need
|
||||
* starting, stopping and cleaning up on every game replacement, and would be untestable without
|
||||
* faking timers.
|
||||
*/
|
||||
|
||||
import type { PublicFrame } from '../sim/view.ts';
|
||||
import type { DisplayStep } from '../sim/display-step.ts';
|
||||
import { applyPublicDelta, changedPiles } from '../sim/public-delta.ts';
|
||||
import type { PileKey } from '../sim/public-delta.ts';
|
||||
import { dwellForStep } from '../sim/pacing.ts';
|
||||
|
||||
export type StepQueue = {
|
||||
/** Throw away what is queued and show this board — a first connect, a reconnect, an undo. */
|
||||
reset(frame: PublicFrame): void;
|
||||
/** Queue steps to be shown in order. */
|
||||
push(steps: readonly DisplayStep[]): void;
|
||||
/**
|
||||
* Show as much as `now` allows. Returns true if the displayed board changed, so a caller can skip
|
||||
* a redraw when nothing did.
|
||||
*/
|
||||
advance(now: number): boolean;
|
||||
/** Show everything immediately. Returns true if anything was skipped. */
|
||||
skip(): boolean;
|
||||
/** The board to draw, or null before any reset has arrived. */
|
||||
current(): PublicFrame | null;
|
||||
/**
|
||||
* How many queued steps the player is still going to WATCH — the number the "N behind" counter
|
||||
* shows. Not the queue length: see `watchableCount` in `sim/pacing.ts`.
|
||||
*/
|
||||
behind(): number;
|
||||
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
|
||||
showing(): DisplayStep | null;
|
||||
/**
|
||||
* The piles the step now on screen moved, for the display to light.
|
||||
*
|
||||
* Here because this is the only place that holds both the frame before a step and the frame after
|
||||
* it — deriving it anywhere else would mean keeping a second copy of the board in step.
|
||||
*/
|
||||
lit(): readonly PileKey[];
|
||||
/** True while there is anything left to show. */
|
||||
busy(): boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* `pace` is read on every step rather than captured, so changing the setting takes effect at once.
|
||||
*
|
||||
* `viewer` says which seat is watching, so THIS PLAYER'S OWN MOVES COST NO TIME. They are already on
|
||||
* screen: a seated player's own board is drawn from their authoritative `Frame`, not from the queue,
|
||||
* so holding their click for a dwell shows them nothing and delays the thing they actually want to
|
||||
* watch — the 700ms before a bot's turn starts animating is 700ms of their own move being replayed
|
||||
* at them. The step is still APPLIED, because the delta chain runs through it.
|
||||
*
|
||||
* Automatic phases have no player and are unaffected, which is what keeps TODO #18 working in
|
||||
* solitaire where every intent is the viewer's own.
|
||||
*/
|
||||
export function createStepQueue(
|
||||
pace: () => number = () => 1,
|
||||
viewer: () => number | null = () => null,
|
||||
): StepQueue {
|
||||
let shown: PublicFrame | null = null;
|
||||
let last: DisplayStep | null = null;
|
||||
let litPiles: readonly PileKey[] = [];
|
||||
let pending: DisplayStep[] = [];
|
||||
/** When the step now on screen is due to give way. Null when nothing is waiting. */
|
||||
let dueAt: number | null = null;
|
||||
|
||||
/** How long this step holds the screen — zero for the viewer's own moves; see above. */
|
||||
const dwell = (step: DisplayStep): number =>
|
||||
step.player !== null && step.player === viewer() ? 0 : dwellForStep(step, pace());
|
||||
|
||||
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
|
||||
const show = (step: DisplayStep): void => {
|
||||
const before = shown;
|
||||
shown = applyPublicDelta(shown, step.frame);
|
||||
last = step;
|
||||
/**
|
||||
* NOT FOR YOUR OWN MOVES. You drew that card; you do not need the deck flashed at you. Same rule
|
||||
* that gives your own steps no dwell — the display is for watching everybody else.
|
||||
*/
|
||||
litPiles = step.player !== null && step.player === viewer() ? [] : changedPiles(before, shown);
|
||||
};
|
||||
|
||||
return {
|
||||
reset(frame) {
|
||||
shown = frame;
|
||||
pending = [];
|
||||
dueAt = null;
|
||||
// Nothing was watched arriving at this board, so nothing on it is lit.
|
||||
litPiles = [];
|
||||
// `last` deliberately survives: a reconnect should not blank the caption line, and the
|
||||
// sentence describing the most recent action is still true.
|
||||
},
|
||||
|
||||
push(steps) {
|
||||
pending.push(...steps);
|
||||
},
|
||||
|
||||
advance(now) {
|
||||
if (pending.length === 0) {
|
||||
// The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue
|
||||
// idle the instant that step was shown, which snapped the district panel home before anyone
|
||||
// could look at it — see `busy()`.
|
||||
if (dueAt !== null && now >= dueAt) dueAt = null;
|
||||
return false;
|
||||
}
|
||||
// First step of a burst: show it immediately rather than waiting out a dwell for a board the
|
||||
// player has not been shown yet.
|
||||
if (dueAt === null) {
|
||||
const first = pending.shift()!;
|
||||
show(first);
|
||||
dueAt = now + dwell(first);
|
||||
return true;
|
||||
}
|
||||
let drew = false;
|
||||
/**
|
||||
* A LOOP, not a single step. A dwell of zero means "do not spend the player's attention on
|
||||
* this" — bookkeeping, and phases where nothing happened (TODO #18) — so a run of them must
|
||||
* collapse within one call instead of costing a frame each. The board still passes through
|
||||
* every state in order; nobody is shown a state that never existed.
|
||||
*/
|
||||
while (pending.length > 0 && now >= dueAt) {
|
||||
const next = pending.shift()!;
|
||||
show(next);
|
||||
dueAt = dueAt + dwell(next);
|
||||
drew = true;
|
||||
}
|
||||
if (pending.length === 0 && now >= dueAt) dueAt = null;
|
||||
return drew;
|
||||
},
|
||||
|
||||
skip() {
|
||||
if (pending.length === 0) return false;
|
||||
for (const step of pending) show(step);
|
||||
pending = [];
|
||||
dueAt = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
current: () => shown,
|
||||
behind: () => pending.filter((s) => dwell(s) > 0).length,
|
||||
showing: () => last,
|
||||
lit: () => litPiles,
|
||||
/**
|
||||
* STILL SHOWING SOMETHING, not just still holding something back.
|
||||
*
|
||||
* This was `pending.length > 0`, which went false the moment the last step of a burst was
|
||||
* shown — so the animation loop stopped and the district panel snapped back to the viewer's own
|
||||
* board without that step ever being visible. Reported from real play: "I briefly saw that it was
|
||||
* the bot's office area, then their turn was done and it pointed back to my office area."
|
||||
*
|
||||
* `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".
|
||||
*/
|
||||
busy: () => pending.length > 0 || dueAt !== null,
|
||||
};
|
||||
}
|
||||
+63
-5
@@ -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),
|
||||
@@ -1679,7 +1737,7 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
|
||||
const build = (toNose: boolean): string[] => {
|
||||
const s = game();
|
||||
const id = placeTray(s, at(0, 0), [car('boxcar')] as never);
|
||||
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
|
||||
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose });
|
||||
return s.trays.get(id)!.consist.map((c) => c.type);
|
||||
};
|
||||
assert.deepEqual(build(true), ['hopper', 'boxcar'], 'running forward takes cars on the nose');
|
||||
@@ -1694,11 +1752,11 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
|
||||
const tray = s.trays.get(id)!;
|
||||
tray.engineAt = 0;
|
||||
|
||||
reduce(s, { type: 'carsCoupled', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
|
||||
reduce(s, { type: 'carsCoupled', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, from: [], toNose: true });
|
||||
assert.equal(tray.engineAt, 1, 'the engine should now have a car ahead of it');
|
||||
assert.deepEqual(tray.consist.map((c) => c.type), ['hopper', 'boxcar']);
|
||||
|
||||
reduce(s, { type: 'carsDropped', trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
|
||||
reduce(s, { type: 'carsDropped', player: 0, trayId: id, at: at(0, 0), stock: [car('hopper')] as never, fromNose: true });
|
||||
assert.equal(tray.engineAt, 0, 'setting out the nose cars puts the engine back in front');
|
||||
assert.deepEqual(tray.consist.map((c) => c.type), ['boxcar']);
|
||||
});
|
||||
@@ -1823,7 +1881,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
|
||||
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
||||
*/
|
||||
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
||||
({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
||||
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
||||
|
||||
it('carries the east-west sense across north-south track', () => {
|
||||
const s = game();
|
||||
|
||||
@@ -24,12 +24,13 @@ import { impediments, narrate } from '../src/sim/narrate.ts';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { actionMenu } from '../src/web/game.ts';
|
||||
import { newCollector } from '../src/sim/display-step.ts';
|
||||
import type { Game } from '../src/web/game.ts';
|
||||
|
||||
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
|
||||
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
|
||||
const wrap = (s: GameState): Game =>
|
||||
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null });
|
||||
({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() });
|
||||
|
||||
const competitive: GameConfig = {
|
||||
mode: 'competitive',
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* DWELL BY KIND — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
|
||||
*
|
||||
* The classification is exhaustive over `Intent['type']` at COMPILE time: `kindOf` declares a
|
||||
* `StepKind` return and has no `default`, so a new intent breaks the build rather than landing
|
||||
* silently in a fallback tier. These tests add the part the compiler cannot do — they read the
|
||||
* intent union out of the source, so the guard survives someone later adding a `default:` that
|
||||
* would swallow the very thing the exhaustiveness was protecting.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { DWELL, MAX_PACE, PACE_LEVELS, dwellFor, dwellForStep, kindOf, watchableCount } from '../src/sim/pacing.ts';
|
||||
import type { StepKind } from '../src/sim/pacing.ts';
|
||||
import type { Intent } from '../src/engine/intents.ts';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** Every `type: '…'` literal in the Intent union, read from the source rather than hand-listed. */
|
||||
function declaredIntents(): string[] {
|
||||
const src = readFileSync(join(root, 'src/engine/intents.ts'), 'utf8');
|
||||
return [...new Set([...src.matchAll(/type: '([a-zA-Z.]+)'/g)].map((m) => m[1]!))].sort();
|
||||
}
|
||||
|
||||
const KINDS: StepKind[] = ['switching', 'action', 'phase', 'bookkeeping'];
|
||||
|
||||
describe('pacing — dwell by kind', () => {
|
||||
it('classifies every intent the engine declares', () => {
|
||||
const declared = declaredIntents();
|
||||
assert.ok(declared.length > 25, `only found ${declared.length} intents — the parse is wrong`);
|
||||
for (const intent of declared) {
|
||||
const kind = kindOf(intent as Intent['type']);
|
||||
assert.ok(
|
||||
KINDS.includes(kind),
|
||||
`${intent} classified as "${kind}", which is not a StepKind — a default case has crept in`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('protects switching and collapses bookkeeping', () => {
|
||||
// The two ends of the measured argument: a switching move is the thing worth watching, and
|
||||
// `*.end` bookkeeping is over half of a real game's intents.
|
||||
assert.equal(kindOf('switch.move'), 'switching');
|
||||
assert.equal(kindOf('switch.dropCars'), 'switching');
|
||||
assert.equal(kindOf('switch.sortConsist'), 'switching');
|
||||
assert.equal(kindOf('draw.end'), 'bookkeeping');
|
||||
assert.equal(kindOf('loadUnload.end'), 'bookkeeping');
|
||||
assert.equal(kindOf('switch.end'), 'bookkeeping');
|
||||
/**
|
||||
* `localOps.choose` IS AN ANNOUNCEMENT, not bookkeeping — moved 2026-09-09 after the first real
|
||||
* play on the test server. It is the line reading "Player Bot 1 chose to SWITCH", the heading for
|
||||
* everything that follows, and at zero dwell a bot's turn began with no sign of what it was about
|
||||
* to do.
|
||||
*/
|
||||
assert.equal(kindOf('localOps.choose'), 'action');
|
||||
|
||||
assert.ok(DWELL.switching > DWELL.action, 'switching must outrank an ordinary action');
|
||||
assert.equal(DWELL.bookkeeping, 0, 'bookkeeping must cost the player no time at all');
|
||||
});
|
||||
|
||||
it('starts switching at a full second, per the 2026-09-09 decision', () => {
|
||||
// Jesse: "start at 1s and tune down". Pinned so a later tune is a deliberate edit rather than
|
||||
// a drift, and so the number in the plan and the number in the code cannot disagree.
|
||||
assert.equal(DWELL.switching, 1000);
|
||||
assert.equal(dwellFor('switch.move'), 1000);
|
||||
});
|
||||
|
||||
it('supports multipliers above 1, and keeps the tiers in proportion at every speed', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-09, after the first play: keep switching and ordinary actions at DIFFERENT
|
||||
* delays, and support 2.0 and 3.0 as well as 1.5. So this pins both halves — that the larger
|
||||
* multipliers work at all, and that scaling never flattens the tiers into each other, since the
|
||||
* relative weighting is the design and the multiplier is only how fast it runs.
|
||||
*/
|
||||
for (const pace of [0.5, 1, 1.5, 2, 3]) {
|
||||
assert.equal(dwellFor('switch.move', pace), Math.round(DWELL.switching * pace));
|
||||
assert.equal(dwellFor('card.play', pace), Math.round(DWELL.action * pace));
|
||||
assert.ok(
|
||||
dwellFor('switch.move', pace) > dwellFor('card.play', pace),
|
||||
`at ${pace}x a switching move no longer outlasts an ordinary action`,
|
||||
);
|
||||
assert.equal(dwellFor('draw.end', pace), 0, 'bookkeeping stays free at every speed');
|
||||
}
|
||||
// A whole switching exercise at 3x is slow on purpose, and still not absurd.
|
||||
assert.equal(dwellFor('switch.move', 3) * 6, 18_000);
|
||||
|
||||
// And a typo cannot freeze the board: ?pace=300 from somebody meaning 3.00.
|
||||
assert.equal(dwellFor('switch.move', 300), DWELL.switching * MAX_PACE);
|
||||
assert.equal(dwellFor('switch.move', MAX_PACE + 5), dwellFor('switch.move', MAX_PACE));
|
||||
});
|
||||
|
||||
it('scales with the viewer\'s pace, and 0 turns it off', () => {
|
||||
assert.equal(dwellFor('switch.move', 1), 1000);
|
||||
assert.equal(dwellFor('switch.move', 0.5), 500);
|
||||
assert.equal(dwellFor('switch.move', 2), 2000);
|
||||
// TODO #18's "a player who has seen it a hundred times will want it off" — no second mechanism.
|
||||
for (const intent of declaredIntents()) {
|
||||
assert.equal(dwellFor(intent as Intent['type'], 0), 0, `${intent} still dwells at pace 0`);
|
||||
}
|
||||
// A negative pace is a corrupt preference, not a request to run time backwards.
|
||||
assert.equal(dwellFor('switch.move', -3), 0);
|
||||
});
|
||||
|
||||
it('counts only the steps a player will actually watch', () => {
|
||||
/**
|
||||
* The counter's whole point. A backlog of 17 where 12 are bookkeeping must read "5", not "17"
|
||||
* followed by an instant plummet to 5 — the countdown is meant to be steady enough to decide
|
||||
* whether to press Skip.
|
||||
*/
|
||||
const queue: Intent['type'][] = [
|
||||
...Array<Intent['type']>(12).fill('draw.end'),
|
||||
...Array<Intent['type']>(5).fill('switch.move'),
|
||||
];
|
||||
assert.equal(queue.length, 17);
|
||||
assert.equal(watchableCount(queue), 5);
|
||||
assert.equal(watchableCount(queue, 0), 0, 'with animation off, nothing is behind');
|
||||
});
|
||||
|
||||
it('offers speeds a player actually reached for, and none the code would clamp', () => {
|
||||
/**
|
||||
* Jesse played a whole game believing he was at 7× and was in fact at 1×: `?pace=` shipped as the
|
||||
* only lever, and `index.html`'s doors are `play.html?lobby` / `play.html?solitaire`, so arriving
|
||||
* from the splash REPLACES the query string. Hence a real control on the play screen, and hence
|
||||
* this ladder — which must reach the speeds people ask for and must not offer one that
|
||||
* `dwellFor` would silently clamp.
|
||||
*/
|
||||
assert.equal(PACE_LEVELS[0], 0, 'off must be the first rung — #18 wants it turned off');
|
||||
assert.ok(PACE_LEVELS.includes(1), 'the default must be on the ladder');
|
||||
assert.ok(PACE_LEVELS.includes(7), '7x was asked for by name');
|
||||
/**
|
||||
* The ceiling is not theoretical. Jesse played at 10× — the top of the ladder as it then was —
|
||||
* and called it "still a bit fast, but followable", so the ladder has to go past the speed
|
||||
* somebody actually reached for and found insufficient.
|
||||
*/
|
||||
assert.ok(PACE_LEVELS.some((p) => p > 10), 'the ladder must go beyond the speed that was too fast');
|
||||
for (const p of PACE_LEVELS) {
|
||||
assert.ok(p <= MAX_PACE, `${p}x is past MAX_PACE, so the control would lie about it`);
|
||||
assert.equal(dwellFor('switch.move', p), Math.round(DWELL.switching * p));
|
||||
}
|
||||
// Strictly increasing, so stepping the control always changes the speed.
|
||||
for (let i = 1; i < PACE_LEVELS.length; i++) {
|
||||
assert.ok(PACE_LEVELS[i]! > PACE_LEVELS[i - 1]!, 'the ladder must be strictly increasing');
|
||||
}
|
||||
// The slowest rung has to be slow enough to be worth having: six switching moves at the top of
|
||||
// the ladder is a full minute, which is the "watch them struggle" case.
|
||||
const slowest = dwellFor('switch.move', PACE_LEVELS[PACE_LEVELS.length - 1]!) * 6;
|
||||
assert.ok(slowest >= 60_000, `the slowest a switching turn can be watched is ${slowest}ms`);
|
||||
});
|
||||
|
||||
it('a silent step beats only when the clock turns over — TODO #18', () => {
|
||||
/**
|
||||
* Both obvious rules were wrong, so both are pinned. "No narration, no dwell" flashed past
|
||||
* phases that moved trains without saying so, killing the very thing #18 asks for. "Anything
|
||||
* that changed the board" beat on every turn hand-off — `submit()` steps `advance()` about 4.6
|
||||
* times per intent — which came to a quarter of an hour a game.
|
||||
*/
|
||||
const silent = { cause: 'phase' as const, player: null, lines: [] as string[] };
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: { actor: 2 } } }), 0, 'a turn hand-off shows nothing');
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: {} } }), 0, 'a step that changed nothing shows nothing');
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: { phase: 'mainline' } } }), DWELL.phase);
|
||||
assert.equal(dwellForStep({ ...silent, frame: { table: { stage: 4 } } }), DWELL.phase);
|
||||
// Narration always earns the dwell of whatever caused it, clock or no clock.
|
||||
assert.equal(
|
||||
dwellForStep({ cause: 'switch.move', player: 1, lines: ['moved'], frame: { table: {} } }),
|
||||
DWELL.switching,
|
||||
);
|
||||
});
|
||||
|
||||
it('the speed control stretches the clock at a THIRD of the rate it stretches people', () => {
|
||||
/**
|
||||
* Two complaints, one from each direction, and the answer is between them.
|
||||
*
|
||||
* v0.8.0.3, from a 5× game: *"after my turn … I'm still subject to that same delay before it
|
||||
* moves on. That makes no sense."* — phases were scaling with everything else and walling off a
|
||||
* player's own turn. So they were pinned at their tabled beat.
|
||||
*
|
||||
* v0.8.0.7, from a 10× game: *"phases displayed on the upper line go by too quickly still.
|
||||
* Should be 4 times as long — at a guess."* — pinned was too short to read the caption.
|
||||
*
|
||||
* Damped scaling satisfies both: 1× unchanged, 10× lands exactly on the four-times guess, and
|
||||
* the cost stays bounded because phase beats cluster rather than accumulate.
|
||||
*/
|
||||
const phase = { cause: 'phase' as const, player: null, lines: ['New Train'], frame: { table: { phase: 'newTrain' } } };
|
||||
const theirs = { cause: 'switch.move' as const, player: 1, lines: ['moved'], frame: { table: {} } };
|
||||
|
||||
assert.equal(dwellForStep(phase, 1), DWELL.phase, '1x must be exactly the tabled beat');
|
||||
assert.equal(dwellForStep(phase, 10), DWELL.phase * 4, '10x must be four times it, as asked for');
|
||||
|
||||
for (const pace of [2, 3, 5, 7, 10, 15, 20]) {
|
||||
const p = dwellForStep(phase, pace);
|
||||
const t = dwellForStep(theirs, pace);
|
||||
assert.ok(p > DWELL.phase, `a phase must grow at ${pace}x`);
|
||||
assert.ok(
|
||||
p < DWELL.phase * pace,
|
||||
`a phase must grow SLOWER than the multiplier at ${pace}x, or the clock walls off the turn`,
|
||||
);
|
||||
assert.ok(t > p, `somebody's move must still outlast a phase beat at ${pace}x`);
|
||||
}
|
||||
// Off still means off, for the clock as much as for anybody's move; and below 1x the clock
|
||||
// follows the multiplier straight, because "faster" should mean everything.
|
||||
assert.equal(dwellForStep(phase, 0), 0);
|
||||
assert.equal(dwellForStep(theirs, 0), 0);
|
||||
assert.equal(dwellForStep(phase, 0.5), DWELL.phase * 0.5);
|
||||
});
|
||||
|
||||
it('a real switching turn is watchable in a few seconds, not tens of them', () => {
|
||||
// Six moves is the engine's cap per crew ("N of 6 Moves left"), so this is the worst ordinary
|
||||
// case for one crew: the announcement, six moves, and an end that shows nothing.
|
||||
const turn: Intent['type'][] = [
|
||||
'localOps.choose',
|
||||
...Array<Intent['type']>(6).fill('switch.move'),
|
||||
'switch.end',
|
||||
];
|
||||
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
||||
assert.equal(total, DWELL.action + 6 * DWELL.switching);
|
||||
assert.ok(total > 5_000 && total < 10_000, `a switching turn takes ${total}ms to watch`);
|
||||
assert.equal(watchableCount(turn), 7, 'the six moves and the announcement; not the end');
|
||||
});
|
||||
|
||||
it("a bot's ordinary turn is followable, which is what the first real play was not", () => {
|
||||
/**
|
||||
* MEASURED FROM A REAL GAME, then pinned. Jesse, after installing v0.8.0 on the test server:
|
||||
* *"bot play was way too fast. I briefly saw that it was the bot's office area then their turn
|
||||
* was done."* This is the shape that turn actually had — no switching in it at all, because
|
||||
* switching is not legal until there is track down — and under the original values it came to
|
||||
* 750ms for the whole thing.
|
||||
*/
|
||||
const turn: Intent['type'][] = [
|
||||
'localOps.choose',
|
||||
'draw.fromHomeOffice',
|
||||
'card.play',
|
||||
'draw.end',
|
||||
'localOps.choose',
|
||||
'freightAgent.stockOutbound',
|
||||
];
|
||||
const total = turn.reduce((ms, i) => ms + dwellFor(i), 0);
|
||||
assert.ok(total >= 3_000, `an ordinary bot turn is only ${total}ms — too fast to follow`);
|
||||
assert.equal(watchableCount(turn), 5, 'only the turn-ending bookkeeping is free');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* THE SEATLESS PUBLIC DELTA — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
|
||||
*
|
||||
* The property that matters is RECONSTRUCTION: a receiver that started from one full frame and
|
||||
* merged every delta since must hold exactly what a fresh `publicSnapshot()` would give it. A delta
|
||||
* scheme that is merely smaller is worthless if the two sides drift, and the drift would show up as
|
||||
* a board that is subtly wrong rather than as an error.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { currentActorOfState, publicSnapshot } from '../src/sim/view.ts';
|
||||
import type { PublicFrame } from '../src/sim/view.ts';
|
||||
import { applyPublicDelta, changedPiles, deltaPublicFrame } from '../src/sim/public-delta.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
function newState(seed: number, players = 3, rotation = false): GameState {
|
||||
const s = createGame({
|
||||
id: `delta-${seed}`,
|
||||
seed,
|
||||
config: rotation
|
||||
? { ...config, optionalRules: { ...config.optionalRules, employeeRotation: true } }
|
||||
: config,
|
||||
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
|
||||
});
|
||||
pump(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Plays one legal action, preferring a switch move so districts actually change between frames. */
|
||||
function step(s: GameState, actor: PlayerIndex): boolean {
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) return false;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
|
||||
const r = applyIntent(s, actor, chosen);
|
||||
if (!r.ok) return false;
|
||||
pump(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
describe('public frame delta', () => {
|
||||
it('reconstructs exactly what a fresh projection produces, over a long chain', () => {
|
||||
for (const seed of [1917398, 4242]) {
|
||||
const s = newState(seed);
|
||||
let sent: PublicFrame | null = null;
|
||||
let held: PublicFrame | null = null;
|
||||
let steps = 0;
|
||||
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const actor = currentActorOfState(s);
|
||||
if (actor === null) break;
|
||||
if (!step(s, actor)) break;
|
||||
|
||||
const next = publicSnapshot(s);
|
||||
const delta = deltaPublicFrame(sent, next);
|
||||
held = applyPublicDelta(held, delta);
|
||||
sent = next;
|
||||
steps++;
|
||||
|
||||
assert.deepEqual(
|
||||
held,
|
||||
next,
|
||||
`merged frame drifted from a fresh projection at step ${steps} (seed ${seed})`,
|
||||
);
|
||||
}
|
||||
assert.ok(steps > 20, `only ${steps} steps for seed ${seed} — the chain proved little`);
|
||||
}
|
||||
});
|
||||
|
||||
it('sends a district board only when that district changed', () => {
|
||||
const s = newState(1917398);
|
||||
const first = publicSnapshot(s);
|
||||
// Nothing has moved, so a delta against an identical frame must null every board.
|
||||
const idle = deltaPublicFrame(first, publicSnapshot(s));
|
||||
assert.equal(idle.division, null, 'the Division was unchanged and must not be resent');
|
||||
assert.equal(idle.districts.length, 0, 'an unchanged district must be omitted, not sent as nulls');
|
||||
assert.deepEqual(idle.table, {}, 'an unchanged table must send no fields at all');
|
||||
|
||||
// Now move one player. Only that seat's board may be sent — this is the whole point of keying
|
||||
// the delta by seat rather than comparing `districts` as one array.
|
||||
let moved: PublicIndexed | null = null;
|
||||
for (let i = 0; i < 200 && moved === null; i++) {
|
||||
const actor = currentActorOfState(s);
|
||||
if (actor === null) break;
|
||||
const before = publicSnapshot(s);
|
||||
if (!step(s, actor)) break;
|
||||
const after = publicSnapshot(s);
|
||||
const changed = after.districts.filter(
|
||||
(d) => JSON.stringify(d.cells) !== JSON.stringify(before.districts.find((b) => b.seat === d.seat)?.cells),
|
||||
);
|
||||
if (changed.length === 1) moved = { seat: changed[0]!.seat, before, after };
|
||||
}
|
||||
assert.ok(moved !== null, 'no single-district change occurred, so this test proved nothing');
|
||||
|
||||
const delta = deltaPublicFrame(moved.before, moved.after);
|
||||
assert.equal(delta.districts.length, 1, 'only the district that changed may be sent');
|
||||
assert.equal(delta.districts[0]!.seat, moved.seat);
|
||||
assert.notEqual(delta.districts[0]!.cells, null, 'the district that changed must carry its board');
|
||||
});
|
||||
|
||||
it('a step that changes one field sends one field — the reason this is a partial', () => {
|
||||
/**
|
||||
* MEASURED, not assumed. The first version spread the whole frame and nulled only the boards, so
|
||||
* a step whose sole change was whose turn it is still shipped all 35 top-level properties. Once
|
||||
* TODO #18 gave automatic phases their own steps, most steps became exactly that, and a full game
|
||||
* cost 19.4 MB of which 16.7 MB was those. This is the guard against that returning.
|
||||
*/
|
||||
const s = newState(1917398);
|
||||
const before = publicSnapshot(s);
|
||||
const full = JSON.stringify(deltaPublicFrame(null, before)).length;
|
||||
|
||||
// Hand the turn on without touching a board, which is what an automatic phase mostly does.
|
||||
const after = { ...before, actor: ((before.actor ?? 0) + 1) as PlayerIndex };
|
||||
const delta = deltaPublicFrame(before, after);
|
||||
assert.deepEqual(Object.keys(delta.table), ['actor'], 'only the field that changed may be sent');
|
||||
assert.equal(delta.districts.length, 0);
|
||||
assert.equal(delta.division, null);
|
||||
|
||||
const size = JSON.stringify(delta).length;
|
||||
assert.ok(size < 120, `a one-field delta serialised to ${size} bytes`);
|
||||
assert.ok(size * 100 < full, `a one-field delta (${size}B) is not much smaller than a full frame (${full}B)`);
|
||||
});
|
||||
|
||||
it('always carries seat, player and name, so Employee Rotation cannot be missed', () => {
|
||||
// Rotation moves players between districts, so the seat→player pairing is itself news. Those
|
||||
// fields are small and are never nulled; the boards they label are what the delta saves.
|
||||
const s = newState(777, 3, true);
|
||||
const a = publicSnapshot(s);
|
||||
// A full frame carries every district, each labelled — that is what a receiver matches on later.
|
||||
const full = deltaPublicFrame(null, a);
|
||||
assert.equal(full.districts.length, a.districts.length);
|
||||
for (const d of full.districts) {
|
||||
assert.equal(typeof d.seat, 'number');
|
||||
assert.equal(typeof d.player, 'number');
|
||||
assert.ok(typeof d.name === 'string' && d.name.length > 0, 'every district must stay labelled');
|
||||
}
|
||||
// And a district sent at all always carries its labels, even when only its board moved: rotation
|
||||
// makes the seat→player pairing news in its own right.
|
||||
const rotated = { ...a, districts: a.districts.map((d, i) => (i === 0 ? { ...d, player: ((d.player + 1) % 3) as PlayerIndex } : d)) };
|
||||
const delta = deltaPublicFrame(a, rotated);
|
||||
assert.equal(delta.districts.length, 1, 'a relabelled district must be sent even with no board change');
|
||||
assert.equal(typeof delta.districts[0]!.player, 'number');
|
||||
});
|
||||
|
||||
it('a first frame is sent whole', () => {
|
||||
const s = newState(4242);
|
||||
const full = deltaPublicFrame(null, publicSnapshot(s));
|
||||
assert.notEqual(full.division, null);
|
||||
for (const d of full.districts) {
|
||||
assert.notEqual(d.cells, null, `seat ${d.seat} must be sent in full on a first frame`);
|
||||
assert.notEqual(d.facilities, null);
|
||||
}
|
||||
// And it merges with no previous frame at all.
|
||||
assert.deepEqual(applyPublicDelta(null, full), publicSnapshot(s));
|
||||
});
|
||||
|
||||
it('refuses to merge an "unchanged" board it has nothing to merge onto', () => {
|
||||
// A sender whose bookkeeping has drifted would otherwise hand a player a blank district.
|
||||
const s = newState(4242);
|
||||
const a = publicSnapshot(s);
|
||||
const unchanged = deltaPublicFrame(a, publicSnapshot(s));
|
||||
assert.throws(() => applyPublicDelta(null, unchanged), /no previous frame to merge onto/);
|
||||
});
|
||||
});
|
||||
|
||||
type PublicIndexed = { seat: number; before: PublicFrame; after: PublicFrame };
|
||||
|
||||
describe('which piles a step moved', () => {
|
||||
/**
|
||||
* MEASURED FROM REAL PLAY, then pinned. The table in `changedPiles` claims what each action moves,
|
||||
* and a claim in a comment is worth nothing unless something checks it — so this drives real games
|
||||
* and asserts the mapping holds, action by action.
|
||||
*/
|
||||
it('maps each action to the piles it actually touches', () => {
|
||||
const seen = new Map<string, Set<string>>();
|
||||
/**
|
||||
* TWO PASSES, because a single driver cannot reach every case. Left to itself the bot almost
|
||||
* never takes a Department card, and a driver that prefers one then never draws from the deck —
|
||||
* so each preference is played out separately and the assertions below require BOTH to have
|
||||
* been observed rather than passing on whichever happened to occur.
|
||||
*/
|
||||
for (const prefer of ['draw.fromDepartment', 'draw.fromHomeOffice'] as const) {
|
||||
for (const seed of [1917398, 191056, 4242]) {
|
||||
const s = newState(seed);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const actor = currentActorOfState(s);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) break;
|
||||
const chosen =
|
||||
options.find((o) => o.type === prefer) ??
|
||||
options.find((o) => o.type === 'card.discard') ??
|
||||
options[i % options.length]!;
|
||||
const before = publicSnapshot(s);
|
||||
const r = applyIntent(s, actor, chosen);
|
||||
if (!r.ok) break;
|
||||
pump(s);
|
||||
const piles = changedPiles(before, publicSnapshot(s)).map((p) => p.replace(/dept\d/, 'dept'));
|
||||
if (!seen.has(chosen.type)) seen.set(chosen.type, new Set());
|
||||
for (const p of piles) seen.get(chosen.type)!.add(p);
|
||||
if (piles.length === 0) seen.get(chosen.type)!.add('(none)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const of = (t: string): Set<string> => seen.get(t) ?? new Set();
|
||||
// NOT VACUOUS: the four cases the mapping is actually about must all have happened.
|
||||
for (const needed of ['draw.fromHomeOffice', 'draw.fromDepartment', 'card.discard', 'card.play']) {
|
||||
assert.ok(of(needed).size > 0, `${needed} never occurred, so its rule proved nothing`);
|
||||
}
|
||||
|
||||
// A HOME OFFICE DRAW MOVES THE COUNT AND NOTHING ELSE ON A PILE. The card is private; the deck
|
||||
// getting shorter is not, and it is the only thing a watcher can be shown.
|
||||
assert.deepEqual([...of('draw.fromHomeOffice')].sort(), ['home']);
|
||||
// A DEPARTMENT DRAW touches that Department, and sometimes the deck too — the pile refills from
|
||||
// it. Both are public, so both may light.
|
||||
for (const p of of('draw.fromDepartment')) {
|
||||
assert.ok(p === 'dept' || p === 'home', `a Department draw moved "${p}"`);
|
||||
}
|
||||
assert.ok(of('draw.fromDepartment').has('dept'), 'a Department draw must light its Department');
|
||||
// A DISCARD lands face up on a Department, and which one is public.
|
||||
assert.deepEqual([...of('card.discard')].sort(), ['dept']);
|
||||
// A PLAYED CARD that does not stay on the board lands face up in the Salvage Yard.
|
||||
assert.ok(of('card.play').has('salvage'), 'a played card must be able to light the Salvage Yard');
|
||||
// ENDING A PHASE moves no card anywhere, so nothing should light for it.
|
||||
for (const quiet of ['draw.end', 'loadUnload.end', 'switch.end', 'localOps.choose']) {
|
||||
if (of(quiet).size > 0) assert.deepEqual([...of(quiet)], ['(none)'], `${quiet} lit a pile`);
|
||||
}
|
||||
});
|
||||
|
||||
it('lights nothing without a previous frame to compare against', () => {
|
||||
// A reset has nothing to have watched arriving, so nothing on it is lit.
|
||||
const s = newState(4242);
|
||||
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
|
||||
});
|
||||
});
|
||||
+104
-13
@@ -244,11 +244,67 @@ describe('redaction — the shared narration log never carries a seat\'s secrets
|
||||
describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
const names = ['Ann', 'Bob', 'Cy'];
|
||||
|
||||
/** Everything one seat can see, as one string: their Frame, the public board, and their lines. */
|
||||
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): string =>
|
||||
JSON.stringify(snapshot(g.state, g.log, null, null, null, false, seat)) +
|
||||
'\n' + JSON.stringify(publicSnapshot(g.state)) +
|
||||
'\n' + g.log.map((l) => l.text).join('\n');
|
||||
/**
|
||||
* Everything one seat can see, split into the two halves the checks below treat differently.
|
||||
*
|
||||
* `structural` is the machine-readable state: their Frame, the public board, and the frame of every
|
||||
* presentation step they are sent (v0.8.0, TODO #13). `narration` is what the table was TOLD.
|
||||
*
|
||||
* Steps are folded in here rather than given a test of their own so every case below covers them:
|
||||
* the blind draw, the pending decision, Employee Rotation before and after the seating moves, and
|
||||
* the played-out game. Their `lines` are a slice of `g.log` by construction, so the log covers the
|
||||
* narration half of a step and does not need to be searched twice.
|
||||
*/
|
||||
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): {
|
||||
structural: string;
|
||||
history: string;
|
||||
narration: string[];
|
||||
} => ({
|
||||
/**
|
||||
* `[]` for the Frame's own lines, MATCHING PRODUCTION. `frameFor()` (`server/session.ts`) has
|
||||
* passed no log since #97 — narration goes out incrementally through `Push.lines` instead — so
|
||||
* embedding it here audits a path that no longer exists, and worse, it puts the whole log inside
|
||||
* `structural` where the face-up-pile rule below cannot reach it. The log is audited in full as
|
||||
* `narration`; this is a de-duplication, not a relaxation.
|
||||
*/
|
||||
structural:
|
||||
JSON.stringify(snapshot(g.state, [], null, null, null, false, seat)) +
|
||||
'\n' + JSON.stringify(publicSnapshot(g.state)),
|
||||
/**
|
||||
* THE STEP FRAMES ARE A RECORD OF WHAT WAS PUBLIC OVER TIME, not a view of the position now —
|
||||
* so they get the PRECISE check and not the fuzzy one, for the same reason the face-up-pile
|
||||
* lines do.
|
||||
*
|
||||
* Every one is built by `deltaPublicFrame` over `publicSnapshot`, which the allow-list test at
|
||||
* the bottom of this file pins property by property; that is what guarantees a step frame is
|
||||
* clean. Searching their accumulation for a card NAME asks "was this ever public?" and answers
|
||||
* a question nobody was posing: Train 6 sat face-up in a Department at step 40 and is in Ann's
|
||||
* hand at step 120, and both facts are correct. A card ID is different — narration never renders
|
||||
* one and no public field carries an opponent's, so finding one anywhere is still proof.
|
||||
*/
|
||||
history: JSON.stringify(g.display.steps.map((step) => step.frame)),
|
||||
narration: g.log.map((l) => l.text),
|
||||
});
|
||||
|
||||
/**
|
||||
* A FACE-UP PILE IS ALLOWED TO NAME THE CARD ON IT, and the log is history rather than a view.
|
||||
*
|
||||
* §2.6: the three Department piles and the Salvage Yard are face up, "so players can audit
|
||||
* discards" — a discard goes onto one precisely so a rival can take it. So "Player Ann discarded
|
||||
* Train 6 face-up on top of Department 3" is the record working, and it stays in the log after Ann
|
||||
* takes the card back into her hand. The name-based check below would otherwise read that historical
|
||||
* line as proof of what Ann is holding NOW, which is how it reported a leak against correct code on
|
||||
* seed 1917398.
|
||||
*
|
||||
* These lines are excluded from the NAME check only. The card-id check and the seed check still run
|
||||
* over them, because those are precise: an id is unique, so finding one is proof, and narration
|
||||
* never renders a raw id.
|
||||
*
|
||||
* **This does not weaken the blind-draw detection**, which is the leak this whole net was built
|
||||
* for (v0.7.9.2, "Red Flags"): a blind draw names the HOME OFFICE DECK, which is face down and
|
||||
* matches nothing here.
|
||||
*/
|
||||
const namesAFaceUpPile = (line: string): boolean => /Department|Salvage/i.test(line);
|
||||
|
||||
/**
|
||||
* Every secret belonging to somebody OTHER than `seat`: their card ids, and the names those ids
|
||||
@@ -265,8 +321,14 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
* This is what caught the blind-draw leak in v0.7.9.2: "Red Flags" was in exactly one hand, and it
|
||||
* was in the log.
|
||||
*/
|
||||
const secretsOfOthers = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): { what: string; value: string }[] => {
|
||||
const out: { what: string; value: string }[] = [];
|
||||
const secretsOfOthers = (
|
||||
g: ReturnType<typeof newMultiplayerGame>,
|
||||
seat: PlayerIndex,
|
||||
): { what: string; value: string; precise: boolean }[] => {
|
||||
// `precise` marks evidence that is proof on its own — a card id is unique, so finding one
|
||||
// anywhere is a leak. A NAME is circumstantial and is searched over a narrower string; see
|
||||
// `namesAFaceUpPile`.
|
||||
const out: { what: string; value: string; precise: boolean }[] = [];
|
||||
// How many cards in the whole game carry each name, and how many of those are in a given hand.
|
||||
const totalByName = new Map<string, number>();
|
||||
for (const id of g.state.cards.keys()) {
|
||||
@@ -282,10 +344,10 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
heldByName.set(n, (heldByName.get(n) ?? 0) + 1);
|
||||
}
|
||||
for (const id of hand) {
|
||||
out.push({ what: `${p.name}'s card id`, value: id });
|
||||
out.push({ what: `${p.name}'s card id`, value: id, precise: true });
|
||||
const name = cardName(g.state, id);
|
||||
if (totalByName.get(name) === heldByName.get(name)) {
|
||||
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name });
|
||||
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name, precise: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,15 +357,19 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
/** Runs the whole net over one state, and says which state failed if it does. */
|
||||
const audit = (g: ReturnType<typeof newMultiplayerGame>, where: string): void => {
|
||||
for (const seat of g.state.players.map((p) => p.index)) {
|
||||
const seen = everythingSeatSees(g, seat);
|
||||
for (const { what, value } of secretsOfOthers(g, seat)) {
|
||||
const { structural, history, narration } = everythingSeatSees(g, seat);
|
||||
const everything = structural + '\n' + history + '\n' + narration.join('\n');
|
||||
// Names are fuzzy evidence, so they are searched everywhere EXCEPT the lines a face-up pile
|
||||
// is entitled to name a card on. Ids are precise and are searched everywhere.
|
||||
const forNames = structural + '\n' + narration.filter((l) => !namesAFaceUpPile(l)).join('\n');
|
||||
for (const { what, value, precise } of secretsOfOthers(g, seat)) {
|
||||
assert.ok(
|
||||
!seen.includes(value),
|
||||
!(precise ? everything : forNames).includes(value),
|
||||
`${where}: seat ${seat} can see ${what} ("${value}")`,
|
||||
);
|
||||
}
|
||||
// The seed is the whole future of the deal and must not reach a seat by any route.
|
||||
assert.ok(!seen.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
|
||||
assert.ok(!everything.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
|
||||
}
|
||||
// And the spectator board, which has no seat and is therefore entitled to nothing private.
|
||||
const pub = JSON.stringify(publicSnapshot(g.state));
|
||||
@@ -346,6 +412,28 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
audit(g, 'after a blind draw');
|
||||
});
|
||||
|
||||
it('the net actually sees the presentation steps it claims to cover (v0.8.0)', () => {
|
||||
/**
|
||||
* Guards the COVERAGE, not the code. `everythingSeatSees` folds `display.steps` into the string
|
||||
* every case above is audited against — which is worth nothing if that array is empty in
|
||||
* practice. So: play a real game, and assert both that steps accumulated and that the audited
|
||||
* string contains them.
|
||||
*/
|
||||
const g = newMultiplayerGame(1917398, config, names);
|
||||
play(g, 120);
|
||||
assert.ok(g.display.steps.length > 20, `only ${g.display.steps.length} steps — the net covers little`);
|
||||
const { history, narration } = everythingSeatSees(g, 0 as PlayerIndex);
|
||||
assert.ok(
|
||||
history.includes(JSON.stringify(g.display.steps.map((step) => step.frame))),
|
||||
'the audited string does not actually contain the step frames',
|
||||
);
|
||||
// And a step's own narration is a slice of the log, so the log half covers it.
|
||||
const fromSteps = g.display.steps.flatMap((step) => step.lines.map((l) => l.text));
|
||||
assert.ok(fromSteps.length > 0, 'the steps carried no narration to cover');
|
||||
assert.ok(fromSteps.every((t) => narration.includes(t)), 'a step said something the log did not');
|
||||
audit(g, 'a played game with presentation steps');
|
||||
});
|
||||
|
||||
it('mid-game, with real hands and a built board', () => {
|
||||
// A DISTINCTIVE seed, deliberately. Seed 7 makes the seed check meaningless — "7" is in "Train
|
||||
// 7", in every coordinate and in half the numbers on the board — so it reported a leak that was
|
||||
@@ -436,6 +524,9 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
||||
// The rules the game was dealt under, and the score.
|
||||
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
|
||||
'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal',
|
||||
// What the Day that just ended finished on. Public for the same reason the running counts are:
|
||||
// a collision happens on the Mainline in front of everybody.
|
||||
'collisionsPrevDay',
|
||||
'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally',
|
||||
// Names, seats, revenue and HAND SIZE — never hand contents.
|
||||
'players',
|
||||
|
||||
+56
-9
@@ -6,6 +6,9 @@
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
@@ -42,9 +45,9 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'phaseBegan', phase: 'mainline' },
|
||||
{ type: 'actorChanged', player: 0 },
|
||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
|
||||
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
|
||||
@@ -72,12 +75,56 @@ const SAMPLES: GameEvent[] = [
|
||||
|
||||
describe('narration', () => {
|
||||
it('covers every event type the engine can emit', () => {
|
||||
// Guards against a new event type slipping in unnarrated.
|
||||
const covered = new Set(SAMPLES.map((e) => e.type));
|
||||
const declared = new Set<string>();
|
||||
for (const e of SAMPLES) declared.add(e.type);
|
||||
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
|
||||
assert.equal(declared.size, 30);
|
||||
/**
|
||||
* THIS TEST USED TO BUILD BOTH SETS FROM `SAMPLES` and compare them to each other, so it could
|
||||
* only ever assert that the sample list had 30 distinct entries — the one thing it could not
|
||||
* detect was the thing its comment promised, a new `GameEvent` slipping in unnarrated. Fixed
|
||||
* 2026-09-09 while adding the v0.8.0 step collector, which made the event union load-bearing for
|
||||
* a second reader.
|
||||
*
|
||||
* The union is read out of `src/engine/events.ts` rather than hand-listed, the same way
|
||||
* `test/pacing.test.ts` reads the intent union: a list maintained by hand is a list that goes
|
||||
* stale, which is how this got here.
|
||||
*/
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const declared = new Set(
|
||||
[...readFileSync(join(here, '../src/engine/events.ts'), 'utf8').matchAll(/type: '([a-zA-Z]+)'/g)]
|
||||
.map((m) => m[1]!),
|
||||
);
|
||||
const narrated = new Set(
|
||||
[...readFileSync(join(here, '../src/sim/narrate.ts'), 'utf8').matchAll(/case '([a-zA-Z]+)':/g)]
|
||||
.map((m) => m[1]!),
|
||||
);
|
||||
assert.ok(declared.size > 40, `only ${declared.size} event types parsed — the parse is wrong`);
|
||||
|
||||
// THE INVARIANT THAT MATTERS: an event the engine can emit and `narrate` has no case for falls
|
||||
// through to a placeholder, in front of a player. This is the check the old version promised.
|
||||
const unnarrated = [...declared].filter((t) => !narrated.has(t));
|
||||
assert.deepEqual(unnarrated, [], 'these event types can be emitted and have no narration case');
|
||||
|
||||
const covered = new Set<string>(SAMPLES.map((e) => e.type));
|
||||
const unknown = [...covered].filter((t) => !declared.has(t));
|
||||
assert.deepEqual(unknown, [], 'these samples name an event the engine no longer declares');
|
||||
|
||||
/**
|
||||
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
|
||||
*
|
||||
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
|
||||
* case (checked above) but no sample, so nothing proves their sentence is any good. Found
|
||||
* 2026-09-09 — the old test built both of its sets from `SAMPLES` and compared them to each
|
||||
* other, so it could only ever assert that the sample list had 30 distinct entries, and the one
|
||||
* thing it could not detect was the thing its comment promised.
|
||||
*
|
||||
* Pinned rather than fixed: writing 25 fixtures is a job of its own, and a bad sentence is worth
|
||||
* finding deliberately rather than in a rush. What this does guarantee is that a NEW event type
|
||||
* cannot join the unsampled set silently.
|
||||
*/
|
||||
const unsampled = [...declared].filter((t) => !covered.has(t)).sort();
|
||||
assert.equal(
|
||||
unsampled.length,
|
||||
25,
|
||||
`the unsampled set changed (${unsampled.length}): add a sample for a new event, or update this count`,
|
||||
);
|
||||
});
|
||||
|
||||
it('gives every event a specific, non-empty sentence', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 5-6.
|
||||
*
|
||||
* Driven against REAL steps from a real game rather than hand-built fixtures, because the properties
|
||||
* that matter are about what actual play produces: a bot's whole switching turn arriving in one
|
||||
* burst, and a backlog that is mostly bookkeeping.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig } from '../src/engine/state.ts';
|
||||
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 { DWELL } from '../src/sim/pacing.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Plays a real game and returns its steps, preferring switch moves so a burst actually occurs.
|
||||
*
|
||||
* 400 moves, not 120: switching is not legal until there is track laid and a train in the district,
|
||||
* and on this seed the first `switch.move` is at move 144. A shorter run produces a queue with no
|
||||
* switching in it at all, which would make the pacing assertions here vacuous.
|
||||
*/
|
||||
function realSteps(seed: number, moves: number): { steps: DisplayStep[]; final: ReturnType<typeof publicSnapshot> } {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
takeSteps(game.display);
|
||||
const steps: DisplayStep[] = [];
|
||||
for (let i = 0; i < moves; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
steps.push(...takeSteps(game.display));
|
||||
}
|
||||
return { steps, final: publicSnapshot(game.state) };
|
||||
}
|
||||
|
||||
/** The baseline a queue starts from, matching what a connect push carries. */
|
||||
function baseline(seed: number): ReturnType<typeof publicSnapshot> {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
return publicSnapshot(game.state);
|
||||
}
|
||||
|
||||
describe('the step queue', () => {
|
||||
it('shows the whole burst in order and lands on the real board', () => {
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
assert.ok(steps.length > 30, `only ${steps.length} steps — this proved little`);
|
||||
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
|
||||
// Run a clock forward until it settles, in 50ms ticks like a render loop would.
|
||||
let now = 0;
|
||||
for (let i = 0; i < 20_000 && q.busy(); i++) {
|
||||
q.advance(now);
|
||||
now += 50;
|
||||
}
|
||||
assert.equal(q.busy(), false, 'the queue never drained');
|
||||
assert.deepEqual(q.current(), final, 'the animated board did not land on the real one');
|
||||
assert.equal(q.showing()?.seq, steps[steps.length - 1]!.seq, 'the caption is not on the last step');
|
||||
});
|
||||
|
||||
it('a burst of switching takes real time, and bookkeeping takes none', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
|
||||
// Only the bookkeeping: it must all collapse into a single advance.
|
||||
// `.end` only: `localOps.choose` became an announcement worth watching after the first real play.
|
||||
const bookkeeping = steps.filter((s) => s.cause.endsWith('.end'));
|
||||
assert.ok(bookkeeping.length > 10, 'not enough bookkeeping steps to prove the collapse');
|
||||
q.push(bookkeeping);
|
||||
q.advance(0);
|
||||
q.advance(0);
|
||||
assert.equal(q.busy(), false, `${bookkeeping.length} bookkeeping steps should cost no time at all`);
|
||||
|
||||
// And switching: each one must hold the screen.
|
||||
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end');
|
||||
assert.ok(switching.length >= 6, `only ${switching.length} switching steps found`);
|
||||
const q2 = createStepQueue();
|
||||
q2.reset(baseline(1917398));
|
||||
q2.push(switching.slice(0, 6));
|
||||
q2.advance(0);
|
||||
assert.equal(q2.behind(), 5, 'the first is shown at once; five are still to watch');
|
||||
q2.advance(DWELL.switching - 1);
|
||||
assert.equal(q2.behind(), 5, 'a switching move must not be replaced early');
|
||||
q2.advance(DWELL.switching);
|
||||
assert.equal(q2.behind(), 4, 'and must be replaced once its dwell is up');
|
||||
});
|
||||
|
||||
it('counts only what will be watched, so the countdown is steady', () => {
|
||||
// The counter's whole purpose: a backlog of mostly-bookkeeping must not read as a huge number
|
||||
// that collapses the instant it starts.
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
const behind = q.behind();
|
||||
assert.ok(behind > 0 && behind < steps.length, `behind ${behind} of ${steps.length} queued`);
|
||||
|
||||
q.advance(0);
|
||||
let ticks = 0;
|
||||
let previous = q.behind();
|
||||
let now = 0;
|
||||
while (q.busy() && ticks++ < 20_000) {
|
||||
now += 50;
|
||||
q.advance(now);
|
||||
const nowBehind = q.behind();
|
||||
assert.ok(nowBehind <= previous, 'the counter must never go up while draining');
|
||||
previous = nowBehind;
|
||||
}
|
||||
assert.equal(q.behind(), 0);
|
||||
});
|
||||
|
||||
it('skip jumps to the real board without losing a single state on the way', () => {
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
q.advance(0);
|
||||
|
||||
assert.equal(q.skip(), true, 'there was a backlog to skip');
|
||||
assert.equal(q.busy(), false);
|
||||
assert.equal(q.behind(), 0);
|
||||
// Skip applies every delta rather than jumping the chain, so the board is exact.
|
||||
assert.deepEqual(q.current(), final, 'skipping produced a board the game was never in');
|
||||
assert.equal(q.skip(), false, 'skipping an empty queue changes nothing');
|
||||
});
|
||||
|
||||
it('pace 0 turns animation off entirely — TODO #18', () => {
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue(() => 0);
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
// One advance at a single instant must consume everything: nothing dwells at all.
|
||||
q.advance(0);
|
||||
q.advance(0);
|
||||
assert.equal(q.busy(), false, 'with animation off, nothing may be left waiting');
|
||||
assert.equal(q.behind(), 0, 'nothing is "behind" when nothing is being animated');
|
||||
assert.deepEqual(q.current(), final);
|
||||
});
|
||||
|
||||
it('pace scales the wait without changing the order', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const switching = steps.filter((s) => s.cause.startsWith('switch.') && s.cause !== 'switch.end').slice(0, 3);
|
||||
assert.equal(switching.length, 3);
|
||||
|
||||
const half = createStepQueue(() => 0.5);
|
||||
half.reset(baseline(1917398));
|
||||
half.push(switching);
|
||||
half.advance(0);
|
||||
half.advance(DWELL.switching / 2);
|
||||
assert.equal(half.behind(), 1, 'at half pace, half the dwell should have advanced one step');
|
||||
});
|
||||
|
||||
it('holds the LAST step of a burst for its dwell — the v0.8.0 snap-back bug', () => {
|
||||
/**
|
||||
* REGRESSION. `busy()` was `pending.length > 0`, so the instant the final step of a burst was
|
||||
* shown the queue reported idle: the animation loop stopped and the district panel snapped back
|
||||
* to the viewer's own board without that step ever being looked at. Jesse, from the first real
|
||||
* play on the test server: *"I briefly saw that it was the bot's office area then their turn was
|
||||
* done and it pointed back to my office area"*, and the countdown row appeared "very briefly".
|
||||
*
|
||||
* The panel follows `busy()`, so this is the property that keeps somebody else's board on screen
|
||||
* for as long as their move is being shown.
|
||||
*/
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const one = steps.filter((s) => s.cause === 'switch.move').slice(0, 1);
|
||||
assert.equal(one.length, 1);
|
||||
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(one);
|
||||
|
||||
q.advance(0);
|
||||
assert.equal(q.behind(), 0, 'nothing is queued behind it');
|
||||
assert.equal(q.busy(), true, 'but it is still being shown, so the queue is not idle');
|
||||
|
||||
q.advance(DWELL.switching - 1);
|
||||
assert.equal(q.busy(), true, 'still inside its dwell');
|
||||
|
||||
q.advance(DWELL.switching);
|
||||
assert.equal(q.busy(), false, 'and idle only once its moment has passed');
|
||||
});
|
||||
|
||||
it("does not spend time replaying the viewer's own moves", () => {
|
||||
// A seated player's own board is drawn from their authoritative Frame, so they have already seen
|
||||
// their own click. Holding it delays the thing they wanted to watch — a bot's turn.
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const mine = steps.filter((s) => s.player === 0 && s.cause === 'switch.move').slice(0, 3);
|
||||
assert.equal(mine.length, 3, 'need three of seat 0\'s own moves');
|
||||
|
||||
const asSeat0 = createStepQueue(() => 1, () => 0);
|
||||
asSeat0.reset(baseline(1917398));
|
||||
asSeat0.push(mine);
|
||||
// Twice at the same instant: the first call shows the head of the burst, the second collapses the
|
||||
// zero-dwell run behind it. In the page that is two animation frames, ~16ms apart.
|
||||
asSeat0.advance(0);
|
||||
asSeat0.advance(0);
|
||||
assert.equal(asSeat0.busy(), false, "the viewer's own moves must cost no time at all");
|
||||
assert.equal(asSeat0.behind(), 0, 'and must never be counted as something to wait for');
|
||||
|
||||
// The same steps seen by somebody else are worth watching.
|
||||
const asSpectator = createStepQueue(() => 1, () => 1);
|
||||
asSpectator.reset(baseline(1917398));
|
||||
asSpectator.push(mine);
|
||||
asSpectator.advance(0);
|
||||
assert.equal(asSpectator.busy(), true, "another seat's moves are worth showing");
|
||||
assert.equal(asSpectator.behind(), 2);
|
||||
});
|
||||
|
||||
it('a reset discards the backlog rather than merging it onto a new baseline', () => {
|
||||
/**
|
||||
* A reconnecting client holds steps whose deltas chain off a baseline the server has moved past.
|
||||
* Merging them onto the new one would draw a board that never existed — and `applyPublicDelta`
|
||||
* would throw the moment a "null means unchanged" field had nothing to merge onto.
|
||||
*/
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps.slice(0, 10));
|
||||
q.advance(0);
|
||||
assert.ok(q.busy());
|
||||
|
||||
q.reset(final);
|
||||
assert.equal(q.busy(), false, 'a reset must empty the queue');
|
||||
assert.equal(q.behind(), 0);
|
||||
assert.deepEqual(q.current(), final);
|
||||
// And the caption survives: a reconnect should not blank the "what just happened" line.
|
||||
assert.ok(q.showing() !== null, 'the caption should survive a reset');
|
||||
});
|
||||
|
||||
it('can always be emptied, so a player is never stranded behind it', () => {
|
||||
/**
|
||||
* "Your Move" is put away while the board is catching up (v0.8.0.6), which makes `busy()` the
|
||||
* thing standing between a player and their own turn. So the ways it can be cleared matter more
|
||||
* than they did: `skip()` must always work, from any state, including one where the clock has
|
||||
* never advanced — which is exactly the situation a page with no `requestAnimationFrame` is in,
|
||||
* and how this was found.
|
||||
*/
|
||||
const { steps, final } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
// Never advanced at all: no frame has been shown, and the queue is full.
|
||||
assert.equal(q.busy(), true);
|
||||
assert.equal(q.skip(), true, 'a never-advanced queue must still be skippable');
|
||||
assert.equal(q.busy(), false, 'and must be idle afterwards, or the player stays locked out');
|
||||
assert.deepEqual(q.current(), final);
|
||||
});
|
||||
|
||||
it('draws nothing before a reset has arrived', () => {
|
||||
const q = createStepQueue();
|
||||
assert.equal(q.current(), null);
|
||||
assert.equal(q.advance(0), false);
|
||||
assert.equal(q.behind(), 0);
|
||||
assert.equal(q.showing(), null);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* THE WATCHABLE TABLE — v0.8.0, Gitea#20 / TODO #13, #15, #18.
|
||||
*
|
||||
* One shared, ordered presentation of everyone else's turns, on a seated player's own screen. The
|
||||
* design is `docs/plans/jitsi-common-board.md` § v0.8.0; this file is its tests.
|
||||
*
|
||||
* Starting with ATTRIBUTION, because the caption row and the history panel both read these lines
|
||||
* and a line that does not say who acted is useless on a screen built to answer "what did they
|
||||
* just do?".
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||
import { currentActor } from '../src/web/game.ts';
|
||||
import { applyPublicDelta } from '../src/sim/public-delta.ts';
|
||||
import { publicSnapshot } from '../src/sim/view.ts';
|
||||
import type { PublicFrame } from '../src/sim/view.ts';
|
||||
import { takeSteps } from '../src/sim/display-step.ts';
|
||||
import { createSession } from '../src/server/session.ts';
|
||||
import { kindOf } from '../src/sim/pacing.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The four events a switching turn is made of. Every one of them used to arrive in the shared log
|
||||
* unattributed: `record()` (`web/game.ts`) prefixes a line with the player's name only when the
|
||||
* event itself carries `player`, and these four were the only events in their class that did not
|
||||
* — `cardDrawn`, `cardPlayed`, `cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`,
|
||||
* `flyingSwitch` and `localOpsOptionChosen` all did. So a switching turn read as an attributed
|
||||
* bracket around anonymous contents:
|
||||
*
|
||||
* Player Alice chose to switch ← attributed
|
||||
* CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
|
||||
* Player Alice finished Local Operations ← attributed
|
||||
*
|
||||
* Measured 2026-09-09 and fixed with the feature that reads them, not filed.
|
||||
*/
|
||||
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
|
||||
|
||||
/** How each of those four reads in the log, so the assertions can find them by text. */
|
||||
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled \d+ car|set out |used the SMALL YARD)/;
|
||||
|
||||
describe('switching is attributed — TODO #13', () => {
|
||||
it('every switching event carries the player who acted', () => {
|
||||
/**
|
||||
* Driven by PREFERRING switch moves rather than taking the first legal action, because bot
|
||||
* switching is clustered rather than spread: two of the three published replays contain no
|
||||
* `switch.move` at all, so a game driven by `options[0]` can finish without ever exercising
|
||||
* this. The counter below then guards against the test passing vacuously.
|
||||
*/
|
||||
let seen = 0;
|
||||
for (const seed of [1917398, 191056, 4242]) {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 800; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
|
||||
|
||||
// Read the events this intent produces before applying it for real, so the assertion sees
|
||||
// exactly what `record()` will be handed.
|
||||
const preview = applyIntent(structuredClone(game.state), actor, chosen);
|
||||
if (preview.ok) {
|
||||
for (const e of preview.events) {
|
||||
if ((SWITCHING_EVENTS as readonly string[]).includes(e.type)) {
|
||||
assert.ok(
|
||||
'player' in e,
|
||||
`${e.type} carries no player, so the log cannot say whose crew it was`,
|
||||
);
|
||||
assert.equal(
|
||||
(e as { player: PlayerIndex }).player,
|
||||
actor,
|
||||
`${e.type} names the wrong player`,
|
||||
);
|
||||
seen++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!submit(game, chosen)) break;
|
||||
}
|
||||
}
|
||||
assert.ok(seen > 0, 'no switching event was produced, so this test proved nothing');
|
||||
});
|
||||
|
||||
it('reads as a player action in the log, not as anonymous plain text', () => {
|
||||
let lines = 0;
|
||||
for (const seed of [1917398, 4242]) {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 800; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
}
|
||||
|
||||
for (const line of game.log) {
|
||||
// The old wording. `uncapitalise` deliberately leaves an acronym alone (`^[A-Z][a-z]` only),
|
||||
// so "CREW moved" and "SMALL YARD —" would have survived the prefix and read as
|
||||
// "Player Alice CREW moved …". Both were reworded to compose.
|
||||
assert.doesNotMatch(
|
||||
line.text,
|
||||
/^CREW moved|^SMALL YARD —/,
|
||||
`an unattributed switching line survived: ${line.text}`,
|
||||
);
|
||||
if (SWITCHING_LINE.test(line.text)) {
|
||||
assert.equal(line.tone, 'act', `a switching line must read as somebody's move: ${line.text}`);
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(lines > 0, 'no switching line reached the log, so this test proved nothing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the display-step collector — TODO #13', () => {
|
||||
it('emits one step per accepted intent plus one per automatic phase, in order', () => {
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
let accepted = 0;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
accepted++;
|
||||
}
|
||||
assert.ok(accepted > 30, `only ${accepted} intents accepted — this proved little`);
|
||||
|
||||
const steps = takeSteps(game.display);
|
||||
/**
|
||||
* TWO KINDS OF STEP SINCE TODO #18: one per accepted intent, and one per automatic phase that
|
||||
* did anything. So the count is no longer `accepted` — but every intent must still have exactly
|
||||
* one step, which is the invariant that matters.
|
||||
*/
|
||||
const byIntent = steps.filter((s) => s.cause !== 'phase');
|
||||
const byPhase = steps.filter((s) => s.cause === 'phase');
|
||||
assert.equal(byIntent.length, accepted, 'one step per accepted intent, no more and no fewer');
|
||||
assert.ok(byPhase.length > 0, 'no phase produced a step — TODO #18 is not being served');
|
||||
steps.forEach((s, i) => {
|
||||
assert.equal(s.seq, i, 'sequence numbers must be dense and in order');
|
||||
assert.equal(s.protocolVersion, 1);
|
||||
assert.ok(kindOf(s.cause), `step ${i} carries a cause pacing cannot classify`);
|
||||
// A phase is nobody's move; an intent is always somebody's.
|
||||
assert.equal(s.player === null, s.cause === 'phase', `step ${i} disagrees about who acted`);
|
||||
assert.equal(s.seat === null, s.cause === 'phase');
|
||||
});
|
||||
assert.equal(takeSteps(game.display).length, 0, 'draining must empty the collector');
|
||||
});
|
||||
|
||||
it('a rejected intent produces no step', () => {
|
||||
const game = newMultiplayerGame(4242, config, ['Alice', 'Bob', 'Carol']);
|
||||
takeSteps(game.display);
|
||||
// Somebody else's turn: refused before the engine is touched, so nothing to present.
|
||||
const notMyTurn = ((currentActor(game) ?? 0) + 1) % 3;
|
||||
assert.equal(submit(game, { type: 'draw.end' }, notMyTurn as PlayerIndex), false);
|
||||
assert.equal(takeSteps(game.display).length, 0, 'a refused intent must not be presented');
|
||||
});
|
||||
|
||||
it('the step deltas reconstruct the public board exactly', () => {
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
let held: PublicFrame | null = null;
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
for (const s of takeSteps(game.display)) held = applyPublicDelta(held, s.frame);
|
||||
}
|
||||
assert.deepEqual(held, publicSnapshot(game.state), 'the animated board drifted from the real one');
|
||||
});
|
||||
|
||||
/**
|
||||
* THE PROPERTY THAT IS CURRENTLY FREE AND MUST STAY THAT WAY.
|
||||
*
|
||||
* `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
|
||||
* than `submit`, so a resumed server does not re-emit the whole game as steps and burn the
|
||||
* sequence. The plan expected this to need an explicit guard. It does not — but move a replay
|
||||
* path onto `submit()` and it silently becomes a real bug, which is why this is pinned.
|
||||
*/
|
||||
it('replaying a save emits no steps at all', () => {
|
||||
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options[0]!)) break;
|
||||
}
|
||||
assert.ok(game.history.length > 20, 'need a real history to replay');
|
||||
|
||||
const rebuilt = fromMultiplayerSave(game.seed, config, ['Alice', 'Bob', 'Carol'], game.history);
|
||||
assert.equal(
|
||||
rebuilt.game.display.steps.length,
|
||||
0,
|
||||
'a replay re-emitted the whole game as display steps',
|
||||
);
|
||||
assert.equal(rebuilt.game.display.seq, 0, 'a replay burned display sequence numbers');
|
||||
});
|
||||
|
||||
it('solitaire collects the same way multiplayer does', () => {
|
||||
// The standing design direction: solitaire is a special case of multiplayer, not a second
|
||||
// implementation. Both go through one `submit()`, so this needs no separate code path — and
|
||||
// that is exactly what makes TODO #18 fall out of TODO #13's mechanism.
|
||||
const game = newGame(4242);
|
||||
let accepted = 0;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
if (!submit(game, options[0]!)) break;
|
||||
accepted++;
|
||||
}
|
||||
assert.ok(accepted > 10, 'the solitaire game did not get going');
|
||||
const collected = takeSteps(game.display);
|
||||
assert.equal(
|
||||
collected.filter((s) => s.cause !== 'phase').length,
|
||||
accepted,
|
||||
'solitaire must collect a step per intent too',
|
||||
);
|
||||
// And solitaire is where TODO #18 lives — its phases must earn beats on the same path.
|
||||
assert.ok(collected.some((s) => s.cause === 'phase'), 'solitaire got no phase steps');
|
||||
});
|
||||
});
|
||||
|
||||
describe('steps reach a seated player — TODO #13', () => {
|
||||
it('never replays the opening bot turns at the first client to connect', () => {
|
||||
/**
|
||||
* `buildSession` runs `driveBotTurns()` at construction, so with bots ahead of you in the order
|
||||
* the game has already moved before anybody can connect. Those steps must be DROPPED, not
|
||||
* queued: a connecting client's `publicReset` is the board as it stands after those very moves,
|
||||
* so replaying them onto it would draw positions the game had already left.
|
||||
*
|
||||
* Found by review 2026-09-09 rather than by a failing test, which is why this one exists.
|
||||
*/
|
||||
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
|
||||
const push = session.connect(0 as PlayerIndex);
|
||||
assert.ok(push.publicReset, 'a connecting client needs a baseline');
|
||||
assert.equal(push.steps, undefined, 'the connect push must carry no steps at all');
|
||||
|
||||
// And the first real broadcast must carry only what THIS move produced — nothing older.
|
||||
const option = push.menu?.options[0];
|
||||
assert.ok(option, 'seat 0 should have something to do');
|
||||
const r = session.intent(0 as PlayerIndex, 1, option);
|
||||
assert.ok(r.accepted);
|
||||
const steps = [...r.pushes.values()][0]?.steps ?? [];
|
||||
assert.ok(steps.length > 0, 'the move produced no steps');
|
||||
/**
|
||||
* The first step delivered must be THIS seat's move — not a bot's, which is what a replayed
|
||||
* opening turn would look like. The sequence does NOT restart at 0: `takeSteps` empties the
|
||||
* collector without rewinding the counter, so the first thing a client sees may be seq 14. That
|
||||
* is fine and deliberate — what 0.8.1's gap detection needs is monotonic and dense, not
|
||||
* zero-based.
|
||||
*/
|
||||
assert.equal(steps[0]!.player, 0, 'the first delivered step was not the move just made');
|
||||
assert.equal(steps[0]!.cause, option.type);
|
||||
steps.forEach((st, i) => {
|
||||
if (i > 0) assert.equal(st.seq, steps[i - 1]!.seq + 1, 'sequence must stay dense');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('every seat gets the same public steps, and a connect gets a baseline to merge onto', () => {
|
||||
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
|
||||
|
||||
const connected = session.connect(0 as PlayerIndex);
|
||||
assert.ok(connected.publicReset, 'a connecting client needs a baseline for its step queue');
|
||||
|
||||
let seen = 0;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const menu = session.connect(0 as PlayerIndex).menu;
|
||||
const option = menu?.options[0];
|
||||
if (!option) break;
|
||||
const r = session.intent(0 as PlayerIndex, i, option);
|
||||
if (!r.accepted) break;
|
||||
const pushes = [...r.pushes.values()];
|
||||
if (pushes.length === 0) continue;
|
||||
const first = pushes[0]!.steps ?? [];
|
||||
if (first.length === 0) continue;
|
||||
seen += first.length;
|
||||
for (const p of pushes) {
|
||||
assert.deepEqual(p.steps, first, 'every seat must receive the identical public steps');
|
||||
}
|
||||
}
|
||||
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
|
||||
});
|
||||
});
|
||||
+103
-1
@@ -23,7 +23,7 @@ import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import type { DivisionView } from '../src/sim/view.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
@@ -3614,6 +3614,108 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
|
||||
});
|
||||
|
||||
it('draws the Home Office deck, face down, with its count', () => {
|
||||
/**
|
||||
* `f.deck` has carried the face-down count since the Frame existed and NOTHING drew it — the
|
||||
* exact display gap `test/display-gaps.test.ts` sweeps for, surviving in the panel that draws
|
||||
* every other pile. Asked for by Jesse 2026-09-10, who also wanted somewhere for a draw to
|
||||
* flash: taking a card off this deck is the commonest move nobody can see.
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'piles',
|
||||
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'],
|
||||
});
|
||||
const f = snapshot(s, [], null);
|
||||
assert.ok(f.deck > 0, 'the deal should leave cards in the Home Office deck');
|
||||
|
||||
const html = pilesHtml(f);
|
||||
assert.ok(html.includes('Home Office'), `no Home Office pile:\n${html}`);
|
||||
assert.ok(html.includes(`>${f.deck}<`), 'the face-down count is not shown');
|
||||
// Face down means the card slot must NOT name a card — that is the whole point of the pile.
|
||||
assert.ok(html.includes('facedown'), 'the Home Office pile is not marked face down');
|
||||
assert.ok(html.includes('face down'), 'the card slot should say so rather than naming a card');
|
||||
// It comes first: a card travels out of here, then onto a Department or the Salvage Yard.
|
||||
assert.ok(
|
||||
html.indexOf('Home Office') < html.indexOf('Dept 1'),
|
||||
'the draw deck should be read before the piles cards land on',
|
||||
);
|
||||
});
|
||||
|
||||
it('lights only the pile a watched move touched', () => {
|
||||
const s = createEngineGame({
|
||||
id: 'piles2',
|
||||
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'],
|
||||
});
|
||||
const f = snapshot(s, [], null);
|
||||
|
||||
assert.equal(pilesHtml(f).includes('pilelit'), false, 'nothing is lit when nothing was watched');
|
||||
|
||||
const home = pilesHtml(f, ['home']);
|
||||
assert.equal((home.match(/pilelit/g) ?? []).length, 1, 'exactly one pile should light');
|
||||
assert.ok(
|
||||
home.indexOf('pilelit') < home.indexOf('Dept 1'),
|
||||
'a Home Office draw must light the Home Office pile, not a Department',
|
||||
);
|
||||
|
||||
const dept2 = pilesHtml(f, ['dept1']);
|
||||
assert.equal((dept2.match(/pilelit/g) ?? []).length, 1);
|
||||
assert.ok(dept2.indexOf('Dept 2') > dept2.indexOf('Dept 1'), 'order sanity');
|
||||
// Two piles can move at once — a Department draw that refills from the deck.
|
||||
assert.equal((pilesHtml(f, ['home', 'dept0']).match(/pilelit/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two
|
||||
* collisions, but zero today. Since we just finished day one, that does seem to be a
|
||||
* contradiction."*
|
||||
*
|
||||
* The cause is a one-line ordering fact: `advance.ts` increments the Day and then zeroes
|
||||
* `collisionsToday`, and this dialog is drawn from the frame whose Day went UP — so it read the
|
||||
* fresh Day's zero and printed it beside a running total that could not agree with it. The count
|
||||
* is captured at the rollover now, and the dialog names the Day rather than saying "today".
|
||||
*/
|
||||
const s = createEngineGame({
|
||||
id: 'collide',
|
||||
seed: 5,
|
||||
config: {
|
||||
mode: 'competitive',
|
||||
days: 5,
|
||||
minCombinedRevenue: 60,
|
||||
maxCollisionsPerDay: 3,
|
||||
maxCollisionsTotal: 10,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
});
|
||||
// The state as the rollover out of Day 1 leaves it: two collisions happened, `today` is reset.
|
||||
s.clock.day = 2;
|
||||
s.collisionsPrevDay = 2;
|
||||
s.collisionsToday = 0;
|
||||
s.collisionsTotal = 2;
|
||||
|
||||
const html = dayEndHtml(snapshot(s, [], null));
|
||||
assert.ok(html.includes('Day 1 has ended'), `wrong Day named:\n${html}`);
|
||||
assert.ok(html.includes('<b>2</b> on Day 1'), `the ended Day's collisions are wrong:\n${html}`);
|
||||
assert.ok(html.includes('<b>2</b> in all'), `the running total is wrong:\n${html}`);
|
||||
assert.doesNotMatch(html, /<b>0<\/b> today/, `still reporting the fresh Day's zero:\n${html}`);
|
||||
// The contradiction itself: a Day-end dialog must never claim fewer in all than on that Day.
|
||||
assert.doesNotMatch(html, /<b>0<\/b> on Day 1/, 'reported no collisions on a Day that had two');
|
||||
});
|
||||
|
||||
it('counts the last Day as the last Day rather than promising more', () => {
|
||||
const html = dayEndHtml(frameAt(6));
|
||||
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');
|
||||
|
||||
Reference in New Issue
Block a user