Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af68aac78d | ||
|
|
b4f09f05cb | ||
|
|
3e961496b0 | ||
|
|
a02d1fcffe | ||
|
|
19a6a47ab6 | ||
|
|
228027637b | ||
|
|
5e34c73b16 | ||
|
|
9ae8e9e09d | ||
|
|
5865c3a6b7 | ||
|
|
45580d8b61 | ||
|
|
510e33bac7 | ||
|
|
c4a08433ba |
+289
@@ -19,6 +19,295 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.7.7 — 2026-08-30
|
||||
|
||||
### Two releases shipped to a browser that never received them
|
||||
|
||||
Jesse installed v0.7.5, clicked **Play solitaire**, and landed in a dealt game instead of the new
|
||||
setup screen. v0.7.6 diagnosed that as a routing bug, fixed it, installed, verified — and it happened
|
||||
again, identically. The second report is what made the real cause findable: the fix was correct both
|
||||
times and neither one ever reached the browser.
|
||||
|
||||
**`buildStamp()`'s no-git fallback was the literal `nogit`, and the `.s9pk` build has no git.** The
|
||||
Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there on every
|
||||
packaged build — and that string is not only the visible stamp, it is the cache-bust key every module
|
||||
URL carries. So v0.7.4, v0.7.5 and v0.7.6 all published `./web/main.js?v=nogit`, byte-identical, and
|
||||
a returning player's browser correctly concluded it had the file already. The fallback is now the
|
||||
package version plus the build's own timestamp, which is always distinct and needs nothing from the
|
||||
environment. Proven rather than assumed: two builds of an identical git-less tree now stamp
|
||||
`0.7.6-mtf6l8rm` and `0.7.6-mtf6lant`.
|
||||
|
||||
**And the server sent no `Cache-Control` at all**, which is the other half — the pages are the one
|
||||
thing that cannot be versioned in their own URL, since a player types the address or follows a
|
||||
bookmark, so a cached `play.html` pins that player to the whole build it names including every `?v=`
|
||||
inside it. Fixed the exact way round that matters: a request carrying `?v=` may be stored for a year
|
||||
and marked `immutable`, and anything else is `no-cache`. `?v=` rather than "not HTML" because
|
||||
`build-web.ts` tags the modules and nothing else — a year of `immutable` on an untagged image or on
|
||||
the replay manifest would outlive several releases of it.
|
||||
|
||||
Neither half is sufficient alone: without the varying tag there is nothing for a fresh page to point
|
||||
at, and without the header the fresh page is itself served from cache.
|
||||
|
||||
**What this says about the two releases before it.** v0.7.5's setup screen and v0.7.6's door fix were
|
||||
both real, both correct, and both verified on `phoenix.local` by reading what the server served —
|
||||
which was true, and was never the thing in doubt. What went unverified was the browser, and a
|
||||
hard-reload would have told us on the first report. Worth remembering the next time a fix "has had no
|
||||
effect": check that it arrived before re-diagnosing it.
|
||||
|
||||
864 tests pass, two of them new — one pinning the no-git fallback as something that varies per build,
|
||||
one pinning the header rule and that the `?v=` flag actually reaches `serveStatic`.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.6 — 2026-08-29
|
||||
|
||||
### The solitaire door could not reach solitaire
|
||||
|
||||
Found by Jesse verifying v0.7.5 on `phoenix.local`: from a browser that had ever held a multiplayer
|
||||
seat, clicking **Play solitaire** on the splash landed straight in a Co-op, four-seat lobby left over
|
||||
from unrelated earlier testing — not the new setup screen v0.7.5 just shipped.
|
||||
|
||||
`start()` checks a browser-remembered multiplayer session (`station-master.remote.v1`) before it ever
|
||||
looks at solitaire's own state, and there was nothing distinguishing "clicked Play solitaire" from
|
||||
"reloaded mid multiplayer game" — a bare `./play.html` load means both. `?lobby` already solved the
|
||||
identical problem for the door on the other side (D11); the solitaire door had no equivalent marker.
|
||||
|
||||
The door now links to `./play.html?solitaire`, and `start()` treats that — along with an explicit
|
||||
`?seed=` or a `?hand=` the setup screen's own Deal button just wrote — as unambiguous proof this
|
||||
navigation means solitaire, checked ahead of the remembered-session lookup rather than only below it.
|
||||
The `hand` check matters on its own: without it, pressing Deal would work once and then bounce the
|
||||
very next load into the remembered game, since `commitNewGame`'s URL carries `hand=` but not
|
||||
`solitaire=`.
|
||||
|
||||
862 tests pass, three of them new: the door reaching solitaire past a remembered game, a bare reload
|
||||
still correctly resuming one (unchanged behaviour, pinned so the fix does not overreach), and Deal's
|
||||
own URL surviving the same bounce.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.5 — 2026-08-29
|
||||
|
||||
### Solitaire asks first, the same way multiplayer already does
|
||||
|
||||
Jesse: "let the user choose their options like the start of a multiplayer game"; "asking first is
|
||||
the only path." A bare visit to `play.html` used to deal a game on the spot, at whatever defaults
|
||||
`gameOptionsFromUrl` fell back to, and the only way to see or change a setting was to open the
|
||||
in-game "New game" dialog after the fact — compare a hand you already have, not one you are about
|
||||
to be dealt. The lobby has asked this question for every multiplayer game since v0.6.0; solitaire
|
||||
never did.
|
||||
|
||||
A genuinely fresh visit now lands on a new `#solitairesetup` screen first: game type, starting hand,
|
||||
where an Extra may start, the three revenue rates, victory conditions, and the three optional rules
|
||||
— then a Deal button. A saved game, an explicit `?seed=`, or a URL a Deal already wrote (`hand` is
|
||||
the field every write always sets, so its presence is what tells the difference) all skip straight
|
||||
past it, the same way `?lobby` already skips the front doors on an invite link — those are not "no
|
||||
plan yet", they are a choice already made, elsewhere.
|
||||
|
||||
**One shared block instead of two copies drifting apart.** The in-game dialog, the lobby, and now
|
||||
this screen all drive the identical `settings-form.ts` block through one new function,
|
||||
`wireGameTypeBlock()` — factored out of what used to be dialog-only code. Only Solitaire can be
|
||||
dealt outside the lobby, so the setup screen shows the other four types exactly as the dialog always
|
||||
has: present, disabled, with a note pointing at the Multiplayer door. Committing an answer — from
|
||||
either the dialog or the setup screen — goes through one `commitNewGame()`, which builds the URL and
|
||||
navigates; `start()` is still the only place that turns a URL into a game.
|
||||
|
||||
Prefilling is deliberately left to the caller rather than folded into `wireGameTypeBlock` itself:
|
||||
the dialog opens on the game CURRENTLY IN PLAY, so redealing to compare keeps comparing against it,
|
||||
while the setup screen opens on the plain Solitaire defaults, since there is no live game yet to
|
||||
read.
|
||||
|
||||
`index.html`'s door copy changed to match: "Start a game" reads "Set up a game" now, and the blurb
|
||||
states the floor (15, not "20 Revenue") since that is what a player is agreeing to before they deal.
|
||||
|
||||
859 tests pass. **Not yet played in a browser** — verified by `tsc --noEmit`, the full suite, and
|
||||
reading the diff, not by loading `play.html` fresh and clicking through it.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.4 — 2026-08-29
|
||||
|
||||
Three rules issues off the tracker, in the order Jesse asked for them: #13, #5, #19. All three are
|
||||
rules Jesse has designed or redefined, and two of them turned out to be rules the code claimed to
|
||||
have and did not.
|
||||
|
||||
### Some Extras must run loaded (Gitea#13)
|
||||
|
||||
X17 Campaign, X18 Circus and X19 Military now prefer loaded cars at make-up — "if not loaded, then
|
||||
empty, and if none available, run without". It is a preference ORDER, so the rule is asked of the
|
||||
Division Yard: an empty is refused only while the yard can still supply a loaded car this train
|
||||
would take, per category, and once it cannot the empty is legal and the train may depart short.
|
||||
|
||||
The per-stop point is **once per Office Area** rather than once per game (Jesse: "in a multiplayer
|
||||
game, each player could score if the circus stops in their area"), and only when the train is fully
|
||||
loaded — every non-caboose car, with a coach counting as loaded when occupied. That last detail is
|
||||
what makes the rule work for the Campaign Train, which carries one coach and no freight, so "fully
|
||||
loaded" is exactly "the candidate is aboard". X17 also GAINS the point; it had `stopThenExpedite`
|
||||
and no scoring rule at all.
|
||||
|
||||
**Two bugs found doing it.** `ConsistSpec.emptiesOnly` was declared on X13 Appleseed, rendered to
|
||||
the player as "(empties only)", and enforced nowhere — the same rule as this issue pointing the
|
||||
other way, so it would have been perverse to add one and leave the other. And a set-up out on the
|
||||
Mainline paid its point to PLAYER 0 whoever was playing, because `playerAtSeat` needs a seat and off
|
||||
the grid the fallback was `0`; scoping the rule to Office Areas removes that rather than patching it.
|
||||
|
||||
### The Yard Office is offered, reachable, and can be run into (Gitea#5)
|
||||
|
||||
It was implemented, in a form missing all three of the rule's conditions: a qualifying train was
|
||||
TELEPORTED onto the card. Nobody was asked, no route was computed — so the card's own printed "that
|
||||
can reach the yard office in one move" was unenforced — and because nothing was walked, nothing was
|
||||
ever met on the way in.
|
||||
|
||||
Now it is offered to the district's owner; reachability is the engine's own move walk, which already
|
||||
means what the card means; and cars on the lead collide. The walk COUPLES standing cars rather than
|
||||
treating them as obstacles, because that is what a switching move does — so what an arriving train
|
||||
would have coupled is what it is about to hit, and `destination.couples` turned out to be the
|
||||
fouling signal with no new machinery. Per Jesse's ruling the two failures are kept apart: no route
|
||||
means no offer and the history says why; a route that exists but is fouled is offered, and taking it
|
||||
crashes.
|
||||
|
||||
**This needed a refactor that #19 then reused.** `pendingDecision` was one question asked of one
|
||||
player, and `currentActor` hardcoded that. It is a discriminated union now, with `decisionActor` as
|
||||
the single place mapping a question to whoever answers it; six copies of `pendingDecision !== null ?
|
||||
superintendent : currentActor` across engine, sim, web and tests collapse into `actingPlayer`.
|
||||
|
||||
### Red Flags, redesigned (Gitea#19)
|
||||
|
||||
The old card protected a stopped train out on the Mainline: offered 4,212 times and played 4 across
|
||||
600 games. It is replaced outright by a flag planted on one side of your own Limits, holding the
|
||||
next train from that direction — "Flag East holds westbound trains" — spent on the train it stops.
|
||||
One card, one train, so there is no lifting action to build and a flag cannot strangle the Division.
|
||||
|
||||
It can also be played **out of phase**: when an arrival would certainly collide and the district's
|
||||
owner holds the card, the phase breaks in with the question. Offered only to somebody holding one,
|
||||
because a prompt with a single button is not a choice and would leak that a collision is coming.
|
||||
|
||||
**The bot still never plays it, and the reason changed — measured, not assumed.** It now takes the
|
||||
out-of-phase prompt unconditionally, since the engine has already established the danger. Over 200
|
||||
solitaire games it plays ZERO, because the prompt needs an arrival that would collide (about one
|
||||
game in seven) to coincide with holding the card from a three-card hand out of 121. The anomaly
|
||||
exemption in `sim.test.ts` stays, but its comment no longer claims the bot is unwilling; what is
|
||||
left to fix is the half of the card a human uses — planting a flag on purpose to buy switching time.
|
||||
|
||||
**A bug the redesign walked into**, recorded because the next interruption will meet it too: the
|
||||
flag was originally taken down in a `reduce` case, which never fires for an event `advance.ts`
|
||||
emits — the phase driver mutates and then describes. The flag stayed up and held every train that
|
||||
came. `test/events.test.ts`'s unreduced-event registry is what makes that class of mistake visible,
|
||||
and `redFlagSpent` is now on it deliberately.
|
||||
|
||||
858 tests pass.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.3 — 2026-08-29
|
||||
|
||||
Two issues off the tracker, and they are halves of one thing: the end of a game. Gitea#11 stops the
|
||||
game being over when the timetable runs out, and Gitea#16 replaces the four lines that were shown
|
||||
there with a results screen worth reading. Neither ships on the 0.4.9 line — Jesse's call
|
||||
(2026-08-29): that line may be complete, and these are not fixes people mid-playtest need.
|
||||
|
||||
### The game asks before it ends (Gitea#11)
|
||||
|
||||
"When game ends allow players to continue playing if they wish… don't force end."
|
||||
|
||||
The rule, decided with Jesse: **the official result is settled at the original game length and never
|
||||
changes.** In a five-Day game extended to eight, the winner is whoever led at the end of Day 5.
|
||||
Extending grants exactly **one** Day and the question is put again at the end of it — solitaire the
|
||||
player decides alone, multiplayer it is unanimous and one refusal ends it there and then. Only
|
||||
days-based endings offer it: a §3.4 collision breach is final, during an extended Day exactly as
|
||||
during the scheduled game, because a railroad declared unsafe does not carry on regardless.
|
||||
|
||||
**It could not be a client-side change**, for three reasons that each rule out the others' fixes.
|
||||
`check` refused every intent once `status` left `active`; `server/index.ts` never loads a `finished`
|
||||
game back into memory; and a save is `{ seed, config, history }` replayed through the engine, so a
|
||||
"continue" the history does not record did not happen — the extended game would evaporate on the next
|
||||
reload, Undo or restart. So there is a fourth status, `awaitingExtension`, and the vote is an intent.
|
||||
|
||||
`config.days` never moves. `extraDays` counts the borrowed Days, and `official` — the outcome, the
|
||||
standings and the statistics, frozen at the first ending — is what the results screen reports. That
|
||||
freeze is done by a wrapper around `advance` rather than inside `checkVictory`, so it happens *after*
|
||||
the last Day's events have been counted rather than before them.
|
||||
|
||||
**Three bugs found while testing it, all of which would have shipped:**
|
||||
|
||||
- **A saved game with a vote in it could not be resumed** — `NO_ACTOR`. A history is a flat
|
||||
`Intent[]` with no seat written down; the replay derives who acted from the turn order. That works
|
||||
for every other intent, `mainline.clearance` included, because there is exactly one seat it could
|
||||
have been. Not here: every seat may vote in any order. `game.extend` therefore carries its voter,
|
||||
uniquely, and the server checks it against the seat it authenticated.
|
||||
- **An all-bot game hung on the question for ever.** `driveBots` loops on `currentActor`, which is
|
||||
null the moment the game stops, so it cannot cast a vote; the bot-vote driver returned early when
|
||||
there were no humans to follow, and nothing ever asked. With nobody to follow, the bots' own answer
|
||||
stands — no — and a bot-only game ends on the timetable it was dealt.
|
||||
- **The balance harness became unbounded**, which is how the third one announced itself:
|
||||
`test/sim.test.ts` went from under a second to never finishing. `randomBot` picks uniformly among
|
||||
its legal options, so once the two votes were among them it took another Day about half the time —
|
||||
and because a table may go on granting Days indefinitely, every seeded game ran to `playGame`'s
|
||||
50,000-turn cap instead of a couple of hundred. Giving `developerBot` a policy was not enough: the
|
||||
guarantee has to hold for every policy, so **`playGame` itself declines**. A simulated game plays
|
||||
the timetable it was dealt, whatever the bot would have voted.
|
||||
|
||||
All three have regression tests.
|
||||
|
||||
Bots never lead. They agree only once every human has agreed, which is the rule Jesse set: "bots will
|
||||
not disagree with the human. Humans get to vote first."
|
||||
|
||||
### The results screen (Gitea#16)
|
||||
|
||||
`GAME OVER — revenueFloor` was not a message. It was `outcome.reason`, an internal enum, interpolated
|
||||
straight into the page at the one moment the game has the player's whole attention. Every reason now
|
||||
has a sentence with the game's own numbers in it, and that fix alone answers the issue's "why did the
|
||||
game end?".
|
||||
|
||||
Around it: the result and the winner, the standings, the rules the game was actually dealt under, a
|
||||
per-player breakdown, and **the railroad** — trains through the Division and how many of them did any
|
||||
switching en route, loads made up and broken, passengers worked, cars switched, trains destroyed and
|
||||
what they took with them. It shares the Day-end dialog's standings, target and collision blocks rather
|
||||
than reimplementing them, because two screens reporting the same game must not be able to disagree.
|
||||
It opens itself once per ending and leaves a button to reopen it, which is what stops Gitea#11's
|
||||
extended play costing you the results.
|
||||
|
||||
**"I don't know if we keep statistics on…"** — nothing was being kept, and now `state.tally` is,
|
||||
folded from the event stream. Hooked at `applyIntent` and at `advance`, because `reduce` never sees
|
||||
the phase driver's events and those are exactly the interesting ones: `trainCompleted`,
|
||||
`trainsDestroyed`, `trainStoodStill`. The test that matters asserts **exactly once** — it plays real
|
||||
games, counts the event stream independently, and checks the tally against that count, so a fold
|
||||
hooked twice or nowhere fails whatever the seed.
|
||||
|
||||
Nothing in the rules reads the tally, so adding a counter is always safe. It rides the `Frame`, so
|
||||
multiplayer gets the same numbers as solitaire from one implementation — and `test/redaction.test.ts`
|
||||
gained a case proving a tally never carries a card id, since that is a property of what `tally.ts`
|
||||
chooses to count and not something the types enforce.
|
||||
|
||||
**"Longest an engine sat on a siding" is not in this pass, and the issue comment was wrong about
|
||||
why it could be.** That comment said `trainStoodStill` "is emitted per Stage, so a run of them is
|
||||
exactly the 'sat on a siding' streak you describe". It is not. Reading `advance.ts` rather than
|
||||
trusting it: the event fires only for a train whose profile sets `stopEarnsPoint` — the X18 Circus,
|
||||
the one card in the deck that pays for standing still — and `stopPointClaimed` makes sure it can
|
||||
never fire twice for the same train. The streak was built, shipped nothing but "1 Stage" against a
|
||||
raw grid coordinate, and has been taken out again. The engine has **no per-Stage "this train did not
|
||||
move" signal at all**, so this needs one before it can be answered; `TODO.md` #36 records that. What
|
||||
is reported instead is the Circus set-up itself, which is a real thing that happened.
|
||||
|
||||
**Badges are not here.** The issue asks for them in a second pass after "a whole conversation
|
||||
brainstorming session", and that is where they belong. The tally keeps the raw material — the
|
||||
switching join for "switching master", the standing runs for "longest engine sat on a siding" — and
|
||||
because the statistics are *derived* rather than recorded, a second pass can add any of them
|
||||
retroactively to games already played and saved.
|
||||
|
||||
### Also
|
||||
|
||||
- **A recorded replay plays to its end.** `save-replay.ts` stopped where `currentActor` went null,
|
||||
which since Gitea#11 is the extension question — so a newly recorded file would have replayed to a
|
||||
question nobody answered rather than to a finished game. It declines, like every other bot driver.
|
||||
The three files already published in `public/replays` predate the vote and stop on the question
|
||||
when replayed; `test/harness.test.ts` accepts that as the end of their history, since it is.
|
||||
- The status line stops lying past the last Day. It read `N of TARGET · D Days left` with both halves
|
||||
false; in an extended game it now reads the Day and how far beyond the timetable play has got.
|
||||
- `objectiveOf` paces against the timetable actually being played rather than `config.days`, which
|
||||
otherwise reported "the last Day is over" through every extended Day.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.2 — 2026-08-26
|
||||
|
||||
Five issues off the tracker. Two are engine bugs a player hit at the board, two are the design
|
||||
|
||||
@@ -132,6 +132,32 @@ is the thing this machinery exists to prevent.
|
||||
roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game
|
||||
replays the intents.
|
||||
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
||||
- **The Mainline Phase can stop and ask, and there are three questions it asks.** §8.1's clearance
|
||||
ruling goes to the Superintendent; the Yard Office offer and the Red Flag prompt go to the owner of
|
||||
the district a train is arriving at. `pendingDecision` is a discriminated union and `decisionActor`
|
||||
is the single place that maps a question to whoever must answer it — a new question adds a case
|
||||
there and nowhere else. **Ask before the move is committed:** returning `needsClearance` unwinds
|
||||
the whole phase and the driver re-enters from the top, so anything already mutated is applied
|
||||
twice or left half-done.
|
||||
- **A game ends by PAUSING, and the first ending is the real one.** Running out of Days, or closing
|
||||
short of the combined Revenue floor, puts the game in `awaitingExtension` rather than `finished`:
|
||||
the table is asked whether to play one more Day, unanimously, and asked again at the end of every
|
||||
Day it grants. `state.official` is written at the first ending and never rewritten, so the winner
|
||||
is always the one decided at `config.days` however long play carries on — `config.days` itself
|
||||
never moves, and `state.extraDays` counts the borrowed ones. A §3.4 collision breach is the
|
||||
exception and finishes outright, during an extended Day exactly as during the scheduled game.
|
||||
Because a save is a replay, the vote is an intent (`game.extend`), and it is the one intent that
|
||||
**carries its own player**: every seat may vote in any order, so a replay cannot derive who did.
|
||||
- **Statistics are folded, not recorded.** `state.tally` counts what the event stream says happened —
|
||||
trains through the Division and how many of them did any switching, loads made up and broken — and
|
||||
is hooked
|
||||
at the two boundaries every event crosses exactly once, `applyIntent` and `advance`. It is not
|
||||
hooked in `reduce`, which never sees the phase driver's events at all. Nothing in the rules reads
|
||||
it, so adding a counter is always safe; it rides the `Frame`, so a multiplayer client gets the same
|
||||
numbers as solitaire from one implementation. **What it cannot count is anything the events do not
|
||||
say.** `trainStoodStill` fires once per game for the X18 Circus alone, so "the longest an engine sat
|
||||
on a siding" has no signal behind it — see `TODO.md` #36 rather than assuming an event means what
|
||||
its name suggests.
|
||||
- **A game is one of four TYPES, and a type is a set of defaults rather than a ruleset.** Co-op,
|
||||
Competitive, Cutthroat and Solitaire (`src/web/presets.ts`) each name an opening hand, an Extra
|
||||
rule, three revenue rates and the victory conditions; picking one fills the form, and changing any
|
||||
|
||||
@@ -34,9 +34,11 @@ Queued 2026-08-21, from playing the StartOS build:
|
||||
|
||||
Queued 2026-08-22, from playing on StartOS:
|
||||
|
||||
7. **Make "Games in Progress" readable** — nested groups rather than one run-on line per game, plus
|
||||
sorting by name, start time or last move. Reasoning in Multiplayer below. Small, and it is the
|
||||
action most used for actual administration.
|
||||
7. **Make "Games in Progress" readable** — **ON HOLD, 2026-08-29 (Jesse).** StartOS 0.4.0.2 is
|
||||
expected to improve how action results are displayed, which is most of what makes this unreadable
|
||||
— so wait and see what the platform fixes before rewriting the action around a limitation that
|
||||
may be gone. Re-open it against 0.4.0.2 and re-read the output before designing anything.
|
||||
Reasoning in Multiplayer below.
|
||||
8. **Give a player a way back into a game after losing their browser** — a fresh browser is still
|
||||
locked out of a RUNNING game, even though the server knows who they are. Reasoning in Multiplayer
|
||||
below; needs Jesse's call on whether a token in a URL is acceptable. **The lobby half of this was
|
||||
@@ -106,8 +108,16 @@ Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issue
|
||||
Suppressed on the first frame, on Undo stepping back across a rollover, and on the Day the game
|
||||
ends — the outcome panel is the thing to read then.
|
||||
|
||||
13. **Show me the other players' moves, bots included** — raised by Jesse 2026-08-22 from playing a
|
||||
multiplayer game. Reasoning in Multiplayer below.
|
||||
13. **Watch the other players and the bots actually make their moves** — raised by Jesse
|
||||
2026-08-22, and **settled 2026-08-29 as the harder of the two readings**: "I want to be able to
|
||||
watch other players and bots make their moves. It's not fun to do my turn and have magic happen
|
||||
in the background and then have to figure out what others did."
|
||||
|
||||
So this is not the log-legibility fix. It is the ordered, per-action presentation of everyone
|
||||
else's turns — **the same mechanism Gitea#20 step 4 specifies for the common board**, routed to a
|
||||
player's own screen as well. Jesse: "this relates to issue #20 and will require a lot more
|
||||
thinking." Do not start it as a standalone piece; it wants designing with #20. Reasoning in
|
||||
Multiplayer below.
|
||||
|
||||
14. **INVESTIGATE: stamp the history with wall-clock time** — even if nothing displays it yet, so
|
||||
"how long did that turn take" can be answered afterwards. Reasoning in Replay / Save Games below.
|
||||
@@ -164,25 +174,168 @@ pass as 19-21 and 24 above, and all three are answered by Gitea#18 rather than f
|
||||
Queued 2026-08-25, from releasing 0.7.1 / 0.4.9g. **Both are blocked on the two commits being made
|
||||
and pushed** — they were still uncommitted when the session ended.
|
||||
|
||||
30. **Close the Gitea issues by hand, each with a comment naming the commit that fixed it.** Raised
|
||||
by Jesse 2026-08-25. `Closes #<n>` is in the main commit message and will auto-close #2, #8, #9
|
||||
and #10 when it reaches the default branch, but auto-closing leaves the issue with no record of
|
||||
WHICH commit or WHICH release answered it — and #9 supersedes #6, which nothing on #6 will ever
|
||||
say. So each issue wants a comment of its own: the commit SHA on both lines, the version each
|
||||
shipped in (0.7.1 and 0.4.9g), and for #2 and #9 the ruling that decided it, since neither was
|
||||
fixed the way the report implies. #6 wants a comment too, pointing at #9. The token and the API
|
||||
calls are in the workspace's `AGENTS.local.md`.
|
||||
30. ~~**Close the Gitea issues by hand, each with a comment naming the commit that fixed it.**~~ —
|
||||
done, and done again for v0.7.2 / v0.4.9h (2026-08-26). #2, #6, #8, #9 and #10 carry their
|
||||
comments from the v0.7.1 release, including the pointer on #6 saying #9 superseded it. #3, #14,
|
||||
#15, #17 and #18 now carry theirs, each naming `2ab25e3` and `e9683cc` and the version each
|
||||
shipped in — and, where the fix was not what the report implied, the ruling that decided it:
|
||||
#15 was REVERSED on review (the placement is legal; what was confirmed is that no train crosses
|
||||
the gap), and #14's comment lists the ten unbuilt cards that are held out, so they do not vanish
|
||||
along with the issue.
|
||||
|
||||
31. **Bump the StartOS wrapper to 0.7.1.** `station-master-startos` is clean, on `master`, pinned to
|
||||
`v0.7.0` and versioned `0.7.0:3`. Once v0.7.1 is tagged: move the submodule pin to the tag, set
|
||||
`startos/versions/current.ts` to **`0.7.1:0`** (a new upstream tag resets the downstream digit —
|
||||
`UPDATING.md`), and write release notes in all five locales. **No new version FILE and no
|
||||
migration** — `current.ts`'s `migrations.up` is empty, so nothing is carried from 0.7.0, and a
|
||||
0.7.0 save replays under 0.7.1 unchanged (its intents cannot contain a Timetabled discard,
|
||||
because that was illegal when they were recorded). `instructions.md` and `README.md` both name
|
||||
0.7.0 and both need the three user-visible changes: the Day-end dialog, the Timetabled discard
|
||||
and its New Game checkbox, and the reasons a blocked platform now gives. Remember the branch
|
||||
trap: the game pushes to `main`, the wrapper to `master`.
|
||||
**Keep doing this.** Auto-closing leaves an issue with no record of which commit or which release
|
||||
answered it. The token and the API calls are in the workspace's `AGENTS.local.md`.
|
||||
|
||||
31. ~~**Bump the StartOS wrapper to 0.7.2.**~~ — done 2026-08-26 (`1bfea8d`). Submodule pinned to
|
||||
`v0.7.2`, `current.ts` at `0.7.2:0`, release notes rewritten in all five locales, `README.md` and
|
||||
`instructions.md` updated. No new version file and no migration. Verified: `npm run check` clean
|
||||
and `make x86` packs as `v0.7.2:0`.
|
||||
|
||||
**This is the first release that does NOT carry games in progress**, and the release notes lead
|
||||
with it in every locale. A save is a seed plus the moves played; the deck going from 206 cards to
|
||||
121 means a card id recorded under 0.7.1 refers to a different card or to none, so a save stops
|
||||
replaying at its first `card.play`. There is nothing to migrate — those moves were made against a
|
||||
deck that no longer exists — and it fails safe: `src/server/index.ts` refuses to resume a save the
|
||||
rules reject, names the move it stopped at, and leaves the file untouched, so an operator can put
|
||||
0.7.1 back on to finish a game that matters.
|
||||
|
||||
32. **Tell the 0.4.9 playtesters their saves are dead, before they find out.** The same deck change
|
||||
shipped as v0.4.9h, so every save filed before it — including the ones attached to Gitea#15 and
|
||||
#17 — stops replaying at its first `card.play`, and any game a tester has in progress will be
|
||||
declined on restart. They fail safe and the files are kept, but nobody has been told. Worth a
|
||||
line wherever the tester build is announced, and worth knowing when the next bug report arrives
|
||||
with a save that will not load.
|
||||
|
||||
Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main only):
|
||||
|
||||
33. **The second pass on the results screen — badges, and the brainstorm Gitea#16 asks for.** The
|
||||
first pass is in and reports everything the Frame and the event tally know. What it deliberately
|
||||
does not have is the interesting half: "maybe create badges for anything interesting that
|
||||
happened… there should be a whole conversation brainstorming session on what are the things that
|
||||
might be interesting for people to be aware of at the end of the game." The raw material is
|
||||
already being kept — `tally.trainsCompletedWithWork` is the switching-master join, `longestStand`
|
||||
is the engine that sat on a siding — and because the statistics are DERIVED from the event stream
|
||||
rather than recorded, a second pass can add any of them retroactively to games already played and
|
||||
saved. Needs Jesse and a conversation, not code, to start.
|
||||
|
||||
34. **`replay.ts` prints a raw outcome enum, exactly as the results screen used to.** Its summary
|
||||
line is `` `${o.result} — ${o.reason}` ``, which renders "loss — revenueFloor" — the same defect
|
||||
Gitea#16 was filed about, in the dev-side replay viewer rather than the playable page. The
|
||||
sentences now exist (`panels.ts`'s `reasonSentence`), but they are written against a `Frame` and
|
||||
the replay recorder has a `GameState`, so it is a small refactor rather than a one-line swap. Not
|
||||
done in v0.7.3 because nothing about it is player-facing and the change earns its own look.
|
||||
|
||||
35. **Extended play has never been played at a real table** — but it now works on a real server.
|
||||
**Verified live on phoenix.local, 2026-08-29**, against the installed v0.7.3:0 rather than in
|
||||
tests: a two-seat competitive game (one human client, one bot) was dealt over the HTTP API with
|
||||
`days: 1`, played to the end of its timetable, and reached `awaitingExtension` on Day 2 with
|
||||
`official = { win, winner 0, daysElapsed }` frozen at Day 1 (`config.days`) and votes
|
||||
`[null, null]`. Voting yes as seat 0 was accepted, the bot followed as designed, and the game
|
||||
returned to `active` with `extraDays: 1` and the official outcome **unchanged**. The tally rode
|
||||
the Frame to the client. Both test games were deleted afterwards; the box is back to Jesse's own
|
||||
`WHISTLE-4086` and the `FREIGHT-3230` lobby.
|
||||
|
||||
**The save carry-over claim was also checked rather than asserted**: phoenix held five saves
|
||||
before the update, of which `WHISTLE-4086` resumed and three were already refused by the 0.7.2
|
||||
deck change. After updating to 0.7.3 the log is identical — same game resumed with the same 7
|
||||
intents, same three refusals at the same move with the same code.
|
||||
|
||||
**What is still untested is the part the item is named for: humans, at a table.** Nobody has sat
|
||||
down and played a game off the end of its timetable, and the multiplayer vote has never been
|
||||
driven through two browsers — what a second player sees while waiting on a first, and whether
|
||||
"waiting on Carol" is legible once Carol has closed her laptop, are still unanswered. Worth being
|
||||
the first thing the next play session does.
|
||||
|
||||
36. **There is no per-Stage "this train did not move" signal, so "longest an engine sat on a siding"
|
||||
cannot be answered.** Gitea#16 asks for it and the comment on that issue said `trainStoodStill`
|
||||
would supply it, "emitted per Stage, so a run of them is exactly the streak you describe". That
|
||||
is wrong, and was found only by reading `advance.ts` while building the tally: the event fires
|
||||
for a train whose profile sets `stopEarnsPoint` — the X18 Circus and nothing else — and
|
||||
`tray.stopPointClaimed` guarantees it fires at most once per train per game. A streak folded from
|
||||
it reads "1 Stage" for ever, which is what v0.7.3 built and then removed.
|
||||
**What it would take:** either a new event emitted per Stage per stationary tray (cheap to emit,
|
||||
but it is a lot of events for a statistic nothing scores), or sampling live state on the Stage
|
||||
boundary the way `stats.ts`'s funnel probe does — which the tally cannot do today, because it
|
||||
folds a batch of events AFTER `advance` has already mutated past the moment they describe. Worth
|
||||
settling with the badge pass (#33) rather than on its own, since that is the only consumer.
|
||||
|
||||
37. ~~**Bump the StartOS wrapper to 0.7.3, then 0.7.4.**~~ — 0.7.3 done 2026-08-29 (`74aea24` in
|
||||
`station-master-startos`). Submodule pinned to `v0.7.3` (`45580d8`), `current.ts` at `0.7.3:0`,
|
||||
release notes in all five locales, `README.md` and `instructions.md` updated. No new version file
|
||||
and no migration — the outgoing `0.7.2:0`'s `up` was empty, `versions.md`'s common case, so
|
||||
`current.ts` bumped in place. Verified: `npm run check` clean, prettier clean, `make x86` packs
|
||||
as `v0.7.3:0`. **NOT installed on a box and not played** — see #35.
|
||||
|
||||
**Unlike 0.7.2, games in progress survive this one**, and both docs lead with it. No card data
|
||||
changed and the engine changes are additive, so every intent in a 0.7.2 save is still legal;
|
||||
proven rather than assumed by `test/harness.test.ts`, which replays the three files in
|
||||
`public/replays` (all recorded under an older ruleset) and asserts every intent still applies.
|
||||
|
||||
**Bumped again to `0.7.4:0` the same day** (`085b88b`), pinned to `v0.7.4`, and installed on
|
||||
`phoenix.local` — verified there, not merely packed: the resume log shows `WHISTLE-4086` coming
|
||||
back with its 7 intents and all five new engine code paths present in the served build. Both tags
|
||||
are signed and pushed.
|
||||
|
||||
**Keep doing the whole sequence.** Tag the app, fetch the tag into the wrapper's submodule,
|
||||
bump `current.ts` in place (the outgoing `up` has been empty every time, `versions.md`'s common
|
||||
case), rewrite the notes in all five locales, update `README.md` and `instructions.md`, then
|
||||
`npm run check` / prettier / `make x86` / `make install`. `UPDATING.md` in the wrapper is the
|
||||
authority and has not needed changing.
|
||||
|
||||
38. ~~**Gitea#13, #5 and #19 — three rules corrections.**~~ — done 2026-08-29 in v0.7.4
|
||||
(`5e34c73`, `2280276`, `19a6a47`), wrapper `085b88b` as `0.7.4:0`, installed on `phoenix.local`.
|
||||
Each issue carries a comment naming its commit and what was ruled, per #30. Reasoning is in
|
||||
`CHANGELOG.md`; what matters here is what they left behind, below.
|
||||
|
||||
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 all tested end to end, packed, and
|
||||
running on `phoenix.local` — and no person 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. Together with #35 this is now the biggest gap in the
|
||||
project: four features shipped without a table.
|
||||
|
||||
40. **A save from before v0.7.4 may not replay, and nobody has been told.** The same shape as #32 but
|
||||
for the main line: the Red Flags intent changed shape, a make-up that was legal may now be
|
||||
refused, and a Yard Office arrival asks a question no older history has an answer for. It fails
|
||||
safe — the server declines the save, names the move and leaves the file untouched — and
|
||||
`WHISTLE-4086` did survive on `phoenix.local`, so it is "may not" rather than "will not". Worth a
|
||||
line wherever the build is announced, and worth knowing when a bug report arrives with a save
|
||||
that will not load.
|
||||
|
||||
41. **The bot cannot use the half of Red Flags a human would.** It takes the danger prompt
|
||||
unconditionally and still plays zero flags in 200 games, because the prompt needs a colliding
|
||||
arrival to coincide with holding the card. What it never does is plant a flag ON PURPOSE to buy a
|
||||
Stage for switching, which needs it to know it wants time — a notion it does not have. Reasoning
|
||||
and the measurement are under Bot Performance.
|
||||
|
||||
42. ~~**Solitaire must ask before it deals, the same way multiplayer's lobby already does.**~~ — done
|
||||
2026-08-29 in v0.7.5. Jesse: "let the user choose their options like the start of a multiplayer
|
||||
game"; "asking first is the only path." A new `#solitairesetup` screen in `play.html` asks the
|
||||
full shared block — game type, starting hand, Extra start, revenue, victory conditions, optional
|
||||
rules — before a genuinely fresh visit deals anything; a saved game, an explicit `?seed=`, or a
|
||||
URL a Deal already wrote all skip past it. The in-game dialog, the lobby and this screen now
|
||||
share one `wireGameTypeBlock()`/`commitNewGame()` pair instead of the dialog carrying its own
|
||||
copy. Reasoning in `CHANGELOG.md`.
|
||||
|
||||
**Played in a browser on `phoenix.local` 2026-08-29, and it found a real bug — fixed same day in
|
||||
v0.7.6.** The splash's "Play solitaire" door landed straight in a leftover Co-op four-seat lobby
|
||||
instead of the new setup screen: `start()` checked a browser-remembered multiplayer session
|
||||
before ever looking at solitaire's own state, and a bare `./play.html` load could not tell "I
|
||||
clicked Play solitaire" apart from "I reloaded mid multiplayer game" — the same class of problem
|
||||
`?lobby` already solved for the door on the other side (D11), just never applied to this one. The
|
||||
door now marks its intent (`?solitaire`), checked ahead of the remembered-session lookup.
|
||||
|
||||
**And it happened AGAIN on v0.7.6, which is what found the real cause — fixed in v0.7.7.** Every
|
||||
packaged build published the same cache-bust key (`?v=nogit`, because the `.s9pk` build has no
|
||||
`.git` for `git rev-parse`), and the server sent no `Cache-Control` at all, so neither release
|
||||
ever reached the browser that asked for it. Both earlier fixes were correct and both were
|
||||
verified by reading what the SERVER served — which was true and was never the thing in doubt.
|
||||
**The lesson worth keeping: when a fix appears to have had no effect, check that it arrived
|
||||
before re-diagnosing it.** A hard-reload would have answered it on the first report.
|
||||
|
||||
Still not verified past that: nobody has clicked all the way through the setup screen's own
|
||||
fields and confirmed the dealt game matches what was chosen. Worth being an early item in the
|
||||
next play session, alongside #39's four unplayed v0.7.4 features.
|
||||
|
||||
---
|
||||
|
||||
@@ -318,21 +471,23 @@ The replay viewer, the save format, and how a game gets shared.
|
||||
|
||||
## Bot Performance
|
||||
|
||||
- [ ] **THE BOT WILL NOT PLAY RED FLAGS, and the reachability canary now says so.** Measured over
|
||||
600 games (2026-08-26): `maneuver.redFlags` is OFFERED **4,212 times**, first in game 5 — the
|
||||
rule is live and constantly available — and the bot PLAYS it **4 times**, first in game 252.
|
||||
It is exempted by name in `sim.test.ts`'s anomaly test alongside `flyingSwitch`, and deleting
|
||||
that line is what proves this fixed.
|
||||
- [ ] **THE BOT NEVER PLAYS RED FLAGS — and since Gitea#19 that is deck luck, not unwillingness.**
|
||||
**Re-measured 2026-08-29, after the card was redesigned: `redFlagsSet` fires ZERO times in 200
|
||||
solitaire games.** The old measurement (600 games: OFFERED 4,212 times, PLAYED 4) described a
|
||||
bot that declined a card it was constantly handed. That bot is gone.
|
||||
|
||||
Two changes compounded to make it rarer without breaking anything: Gitea#14 took Red Flags from
|
||||
5 copies to the sheet's 3, and Gitea#3 shortened most crossings to a single Stage, so the
|
||||
window in which a train is STANDING on a Mainline card — the only place the card may be played
|
||||
— is now usually one Stage wide.
|
||||
Gitea#19 replaced the rule outright: a flag is planted on one side of your own Limits and holds
|
||||
the next train from that direction, and it can be played out of phase when the engine breaks in
|
||||
with "COLLISION RISK! FLAG AGAINST T2?". The bot takes that prompt **unconditionally** — the
|
||||
engine only raises it when an arrival is certainly about to collide, so there is nothing left
|
||||
to judge. It still never plays one, because the prompt needs two things to coincide: an arrival
|
||||
that would collide (0.14 collisions per game, about one game in seven) AND the district's owner
|
||||
holding a Red Flags card at that moment, from a three-card hand drawn out of 121.
|
||||
|
||||
|
||||
What the developer bot can and cannot yet do, measured. Every revenue figure below measured before
|
||||
v0.4.7 is low by roughly half a point — see the stub-industry entry — and the rebalance pass should
|
||||
not read that drop as a deck problem.
|
||||
**What is left to fix is the OTHER half of the card**, and it is the half a human would use:
|
||||
planting a flag on purpose to buy a Stage for switching. That needs the bot to know it wants
|
||||
time, which it has no notion of today. Until then the anomaly canary in `sim.test.ts` is
|
||||
measuring deck luck rather than reachability, and its comment now says so.
|
||||
|
||||
- [ ] **THE BOT DOES NOT KNOW TO BRING AN EXPEDITED TRAIN BACK TO THE STATION — new in v0.4.9.**
|
||||
The `expediteFault` mechanic (§7, Q3) charges 1 Revenue every Mainline Phase an expedited train
|
||||
@@ -662,11 +817,31 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
||||
is a line of text. The Division map is the one shared picture, and it shows trains on the
|
||||
Mainline, not switching inside a district.
|
||||
|
||||
**Not yet established: which of the two Jesse means.** "I need to see other players' moves" fits
|
||||
both "the history panel is not telling me" (a legibility problem — the panel scrolls, a bot can
|
||||
take a dozen actions between your turns, and nothing marks where your last turn ended) and "I
|
||||
want to watch their railroad" (a Frame problem). Ask before building: the first is an afternoon,
|
||||
the second is a new view.
|
||||
**ANSWERED 2026-08-29, and it is the harder reading.** Jesse: "I want to be able to watch other
|
||||
players and bots make their moves. It's not fun to do my turn and have magic happen in the
|
||||
background and then have to figure out what others did." So the complaint is not that the
|
||||
history panel is hard to read — it is that the moves are not WATCHABLE. Marking the log is a
|
||||
consolation prize, not the fix.
|
||||
|
||||
**This is Gitea#20 step 4, pointed at a player's screen instead of the common board.** That
|
||||
issue — the public common-board display published into Jitsi — already specifies the mechanism,
|
||||
and `docs/plans/jitsi-common-board.md` §"Step 4 — Preserve individual human and bot actions"
|
||||
has the design: a display-step collector inside `GameSession` that captures a projected frame
|
||||
after EVERY successful `submit()`, human and bot alike, deltas it, and emits one step per
|
||||
accepted intent (not one per `GameEvent` — an intent drains automatic work behind it, and the
|
||||
event list is not a complete reducer).
|
||||
|
||||
**The reason it is not simply free once #20 lands** is that the plan deliberately stops short
|
||||
of here: *"Keep player pushes unchanged: players still receive the final coalesced result after
|
||||
all immediately due bots finish."* Extending the step stream to seated players raises questions
|
||||
the common board never has to answer — a spectator can be a second behind, a player waiting to
|
||||
act cannot; and a player animating three bot turns while their own move is due is a game that
|
||||
feels slower, which is the opposite of the complaint. **Jesse, 2026-08-29: "this relates to
|
||||
issue #20 and will require a lot more thinking."** Design it with #20; do not start it alone.
|
||||
|
||||
**The constraint below still binds either way**, and hardest here: the common board is seatless
|
||||
and shows only public state, whereas a step stream sent to a SEATED player is a Frame, and
|
||||
Frames are redacted per seat.
|
||||
|
||||
**The constraint on the second**, and it is the one that must not be got wrong: a district's
|
||||
BOARD is public — cards on the table, cars standing on them, trains — and a player's HAND,
|
||||
@@ -733,8 +908,14 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
||||
not yet decided whether a token in a URL is acceptable; the alternative is a bare token
|
||||
pasted into a field, which is uglier and stays out of history.
|
||||
|
||||
- [ ] **The StartOS "Games in Progress" action is one long unreadable run-on per game.** Raised by
|
||||
Jesse 2026-08-22 after using it against four games. Lives in the WRAPPER repo
|
||||
- [ ] **The StartOS "Games in Progress" action is one long unreadable run-on per game.**
|
||||
**ON HOLD, 2026-08-29 (Jesse): StartOS 0.4.0.2 should make action displays better.** The
|
||||
diagnosis below is that the action-result view collapses newlines — which is exactly the sort
|
||||
of thing a platform release fixes. Re-read the real output on 0.4.0.2 before building anything;
|
||||
the nested-group rewrite may turn out to be unnecessary, and designing around a limitation that
|
||||
has just been lifted is worse than waiting.
|
||||
|
||||
Raised by Jesse 2026-08-22 after using it against four games. Lives in the WRAPPER repo
|
||||
(`station-master-startos`, `startos/actions/gamesInProgress.ts`), whose `AGENTS.md` says work
|
||||
belongs in issues on that repo rather than a `TODO.md` — recorded here because this is where
|
||||
the project's list actually is; move it if that policy is meant to bind.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1389,6 +1389,11 @@
|
||||
},
|
||||
{
|
||||
"type": "loadUnload.end"
|
||||
},
|
||||
{
|
||||
"type": "game.extend",
|
||||
"player": 0,
|
||||
"agree": false
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
-1
@@ -60,7 +60,21 @@ execFileSync(
|
||||
*/
|
||||
function buildStamp(): string {
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
|
||||
let git = 'nogit';
|
||||
/**
|
||||
* THE FALLBACK HAS TO BE UNIQUE PER BUILD, because this string is also the cache-bust key.
|
||||
*
|
||||
* It used to be the literal `nogit`, which is exactly what the `.s9pk` build produces — the
|
||||
* Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there every time.
|
||||
* Every packaged release therefore published `?v=nogit`, byte-identical to the release before it,
|
||||
* and a returning player's browser had no reason to refetch a single module. v0.7.5's setup screen
|
||||
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
|
||||
* that asked for them (Jesse, twice, 2026-08-29 — "setup did not work").
|
||||
*
|
||||
* The version plus the build's own timestamp is always distinct, needs nothing from the
|
||||
* environment, and stays honest: two builds of the same commit ARE two deploys, and a cache key
|
||||
* that says so costs one refetch, while one that lies costs a release nobody receives.
|
||||
*/
|
||||
let git = `${pkg.version}-${Date.now().toString(36)}`;
|
||||
try {
|
||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
||||
.toString()
|
||||
|
||||
+414
-45
@@ -36,10 +36,12 @@ import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||
// the legality test cannot disagree about which train is being assembled.
|
||||
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
|
||||
import { areaAtSeat, areaOf, occupancyFor, trainNeedingCars } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
import { reachableDestinations } from './track.ts';
|
||||
import { tallyEvent } from './tally.ts';
|
||||
|
||||
export type AdvanceResult = {
|
||||
events: GameEvent[];
|
||||
@@ -49,6 +51,31 @@ export type AdvanceResult = {
|
||||
|
||||
const NIGHT_STAGES = new Set([1, 2, 3, 11, 12]);
|
||||
|
||||
/**
|
||||
* IS EVERY CAR ON THIS TRAIN LOADED? (Gitea#13)
|
||||
*
|
||||
* "You only get credit for a circus or campaign train (one point per stop) if you have it fully
|
||||
* loaded. Not much of a circus if all the cars are empty."
|
||||
*
|
||||
* A COACH COUNTS AS LOADED WHEN IT IS OCCUPIED, which is what makes this the right test for the
|
||||
* Campaign Train: X17 carries one coach and no freight, so "fully loaded" is precisely "the
|
||||
* candidate is aboard" (Jesse's ruling, 2026-08-29). The engine already models an occupied coach
|
||||
* as `loaded`, so no second notion is introduced here.
|
||||
*
|
||||
* A CABOOSE IS EXEMPT, and it costs nothing to say so: every caboose in `ROLLING_STOCK_SUPPLY` is
|
||||
* minted `loaded: true` — there is no empty one — so including it would change no outcome today.
|
||||
* It is excluded anyway because a caboose is crew space rather than payload, and a supply table
|
||||
* that grew an empty caboose should not silently start voiding circus points.
|
||||
*
|
||||
* AN EMPTY TRAIN IS NOT FULLY LOADED. A Circus that departed short and carries nothing at all earns
|
||||
* nothing: `every` on an empty list is vacuously true, which would pay the emptiest train of the
|
||||
* lot, so the length is tested first.
|
||||
*/
|
||||
function fullyLoaded(tray: CrewTray): boolean {
|
||||
const payload = tray.consist.filter((c) => c.type !== 'caboose');
|
||||
return payload.length > 0 && payload.every((c) => c.loaded);
|
||||
}
|
||||
|
||||
function movesForStage(s: GameState): number {
|
||||
return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage)
|
||||
? MOVES_PER_LOCAL_OPS_NIGHT
|
||||
@@ -71,10 +98,58 @@ const step = (d: Direction): number => (d === 'east' ? 1 : -1);
|
||||
// advance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The phase driver, plus the two things that have to happen around EVERY batch of events it
|
||||
* produces. `advanceInner` below is the driver itself, unchanged.
|
||||
*
|
||||
* ORDER IS THE WHOLE POINT of this wrapper, and it is the one subtle thing in Gitea#16.
|
||||
* `checkVictory` runs deep inside the driver, so if the official result froze a copy of the Tally
|
||||
* from in there it would freeze it BEFORE this batch's events had been counted — and the batch that
|
||||
* ends a game is exactly the one carrying the last Day's work. So the Tally is folded first and the
|
||||
* result frozen second, both out here where the whole batch is in hand.
|
||||
*
|
||||
* Safe because both endings `return` the moment they fire: no scoring event is emitted after a game
|
||||
* has ended within a single batch, so "everything in this batch" and "everything up to the ending"
|
||||
* are the same set of events. `test/tally.test.ts` pins that.
|
||||
*/
|
||||
export function advance(s: GameState): AdvanceResult {
|
||||
const r = advanceInner(s);
|
||||
for (const e of r.events) tallyEvent(s, e);
|
||||
freezeOfficial(s);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE OFFICIAL RESULT, written once (Gitea#11).
|
||||
*
|
||||
* "The winner is based upon the original game length" — so the first ending is the real one and
|
||||
* every later evaluation is informational. Idempotent by construction: it does nothing once
|
||||
* `official` is set, which is what stops an extended Day, or a §3.4 breach during one, from
|
||||
* rewriting a recorded win.
|
||||
*/
|
||||
function freezeOfficial(s: GameState): void {
|
||||
if (s.official !== null || s.outcome === null) return;
|
||||
s.official = {
|
||||
day: s.config.days,
|
||||
outcome: { ...s.outcome },
|
||||
revenues: s.players.map((p) => p.revenue),
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
tally: cloneTally(s.tally),
|
||||
};
|
||||
}
|
||||
|
||||
function advanceInner(s: GameState): AdvanceResult {
|
||||
const events: GameEvent[] = [];
|
||||
if (s.status === 'finished') return { events, needsInput: false };
|
||||
|
||||
/**
|
||||
* §3.3 (Gitea#11) — the timetable has run out and the table is being asked whether to play one
|
||||
* more Day. Nothing runs itself while that question is open, so this is `needsInput` rather than
|
||||
* an ending: `pump` stops here, the server keeps the game in memory, and the only intent the
|
||||
* rules will take is `game.extend`.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') return { events, needsInput: true };
|
||||
|
||||
// The Superintendent's clearance ruling interrupts the Mainline Phase (§8.1).
|
||||
if (s.clock.pendingDecision !== null) return { events, needsInput: true };
|
||||
|
||||
@@ -371,33 +446,45 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
const where = tray.position;
|
||||
const moved = moveTrain(s, id, tray, events);
|
||||
/**
|
||||
* X18 CIRCUS TRAIN — "one turn stopped on any track (circus set-up) earns 1 point".
|
||||
* X18 CIRCUS / X17 CAMPAIGN — a Stage spent set up in somebody's Office Area earns a point.
|
||||
*
|
||||
* The flag was declared on the profile and read NOWHERE, so the one card in the deck that pays
|
||||
* The flag was declared on the profile and read NOWHERE, so the one card in the deck that paid
|
||||
* for standing still paid nothing: reported from a playtest where TX18 sat on a siding for a
|
||||
* full Stage and no point arrived. Claimed once — an Extra runs once and is gone.
|
||||
* full Stage and no point arrived.
|
||||
*
|
||||
* ONCE PER OFFICE AREA (Gitea#13, Jesse 2026-08-29): "once per stop in an office area. In a
|
||||
* multiplayer game, each player could score if the circus stops in their area." So a Circus
|
||||
* touring three districts is paid three times and one parked in the same district all game is
|
||||
* paid once, which `stopPointSeats` records per seat.
|
||||
*
|
||||
* ONLY IN AN OFFICE AREA. It used to pay for standing on the Mainline or at a Division Point
|
||||
* too, and misattributed both: `playerAtSeat` needs a seat, and off the grid there is none, so
|
||||
* the fallback handed the point to PLAYER 0 wherever the train happened to be. Scoping the rule
|
||||
* to Office Areas is what Jesse's ruling says and it removes that bug rather than patching it.
|
||||
*
|
||||
* FULLY LOADED, or nothing. "Not much of a circus if all the cars are empty" — see
|
||||
* `fullyLoaded` below for what that means for a train whose only car is a coach.
|
||||
*
|
||||
* "Stopped" is measured against the Mainline Phase: the train attempted to move and stayed where
|
||||
* it was. A train that is still in the district when the phase runs has not moved either, which
|
||||
* is the circus setting up on a siding rather than crossing the Division.
|
||||
*/
|
||||
if (!tray.stopPointClaimed && trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopEarnsPoint) {
|
||||
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopEarnsPoint) {
|
||||
const stillThere =
|
||||
tray.position.at === where.at &&
|
||||
(tray.position.at !== 'mainline' || where.at !== 'mainline' || tray.position.index === where.index) &&
|
||||
(tray.position.at !== 'grid' ||
|
||||
where.at !== 'grid' ||
|
||||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
|
||||
if (stillThere) {
|
||||
tray.stopPointClaimed = true;
|
||||
// Bound as one value so the grid case narrows: `tray.position` is a union, and testing a
|
||||
// `seat` extracted from it does not tell the compiler which member it came from.
|
||||
const at = tray.position.at === 'grid' ? tray.position : null;
|
||||
const alreadyPaidHere = at !== null && (tray.stopPointSeats ?? []).includes(at.seat);
|
||||
if (stillThere && at !== null && !alreadyPaidHere && fullyLoaded(tray)) {
|
||||
tray.stopPointSeats = [...(tray.stopPointSeats ?? []), at.seat];
|
||||
// The point goes to whoever is SITTING in the district it stopped in.
|
||||
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
|
||||
const label =
|
||||
tray.position.at === 'grid'
|
||||
? `(${tray.position.coord.col},${tray.position.coord.row})`
|
||||
: tray.position.at === 'mainline'
|
||||
? `Mainline card ${tray.position.index}`
|
||||
: `the ${tray.position.side} Division Point`;
|
||||
const owner = playerAtSeat(s, at.seat);
|
||||
const label = `(${at.coord.col},${at.coord.row})`;
|
||||
events.push({ type: 'trainStoodStill', trainNumber: tray.trainNumber ?? 0, where: label });
|
||||
const p = s.players[owner];
|
||||
if (p) {
|
||||
@@ -407,7 +494,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
player: owner,
|
||||
delta: 1,
|
||||
total: p.revenue,
|
||||
reason: 'circus set-up — a Stage spent standing still',
|
||||
reason: 'set up in the district — a Stage spent standing still, fully loaded',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -879,9 +966,30 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
// Off the end of the card: into the adjoining Limit, then straight to the Office (§8.2).
|
||||
const target = index + dir;
|
||||
const dest = s.division.nodes[target];
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — the Yard Office offer is put BEFORE the train leaves the Mainline card, for
|
||||
* the same reason §8.1's clearance is: `needsClearance` unwinds the whole phase and the driver
|
||||
* re-enters here from the top, so anything already mutated is mutated twice or, worse, left
|
||||
* half-applied. Asking after the `transits` filter below cost the train its place on the card
|
||||
* and it was never seen again — the question was asked and the answer had nowhere to land.
|
||||
*/
|
||||
if (dest?.kind === 'office') {
|
||||
/**
|
||||
* §Q, RED FLAGS (Gitea#19) — asked and answered before the train leaves the card, for exactly
|
||||
* the reason the Yard Office offer is (see below): `needsClearance` unwinds the phase.
|
||||
*
|
||||
* Order matters. A flag stops the train OUTSIDE the Limits, so it never reaches the point
|
||||
* where the Yard Office would be offered — flagging is about keeping a train out altogether.
|
||||
*/
|
||||
const flagged = redFlagStop(s, id, tray, dest, events);
|
||||
if (flagged === 'ask') return 'needsClearance';
|
||||
if (flagged === 'held') return 'held';
|
||||
|
||||
if (yardOfficeQuestion(s, id, tray, dest.seat, events) === 'ask') return 'needsClearance';
|
||||
}
|
||||
|
||||
node.transits = node.transits.filter((t) => t.tray !== id);
|
||||
// Red Flags protect a train while it is stopped here; once it rolls, the flags come in.
|
||||
if (node.redFlagged) node.redFlagged = node.redFlagged.filter((t) => t !== id);
|
||||
|
||||
if (!dest) return 'held';
|
||||
|
||||
@@ -917,10 +1025,10 @@ function evaluateClearance(
|
||||
): 'clear' | 'blocked' | 'ask' {
|
||||
// A ruling already given for this train is consumed here — this is what stops the driver from
|
||||
// re-asking the same question every time it re-evaluates the train.
|
||||
const ruling = s.clock.clearanceRuling;
|
||||
if (ruling && ruling.train === id) {
|
||||
s.clock.clearanceRuling = null;
|
||||
return ruling.allow ? 'clear' : 'blocked';
|
||||
const answer = s.clock.decisionAnswer;
|
||||
if (answer && answer.kind === 'clearance' && answer.train === id) {
|
||||
s.clock.decisionAnswer = null;
|
||||
return answer.allow ? 'clear' : 'blocked';
|
||||
}
|
||||
|
||||
const node = s.division.nodes[targetIndex];
|
||||
@@ -988,10 +1096,10 @@ function evaluateClearance(
|
||||
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
||||
// where ABS Signals protects everything on the card.
|
||||
//
|
||||
// Both of these read the card the OTHER train is standing on rather than the card being
|
||||
// entered. They were the same card while this only looked one card ahead; across a Subdivision
|
||||
// they are not, and the protection belongs where the train it protects actually is.
|
||||
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
|
||||
// Red Flags used to protect a stopped train here as well. Gitea#19 replaced that rule outright
|
||||
// (Jesse, 2026-08-29): a flag is now planted on a district's Limits and holds trains coming from
|
||||
// one direction, so it never applies out on the Mainline. ABS Signals is what protects a train
|
||||
// standing on a Mainline card now, and it always did the job better.
|
||||
|
||||
/**
|
||||
* ABS Signals — "this is played on a mainline card to prevent collisions. If a collision would
|
||||
@@ -1016,13 +1124,211 @@ function evaluateClearance(
|
||||
}
|
||||
|
||||
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
||||
s.clock.pendingDecision = { kind: 'clearance', train: id, occupiedBy: other };
|
||||
events.push({ type: 'clearanceRequested', trainId: id, occupiedBy: other });
|
||||
return 'ask';
|
||||
}
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
/**
|
||||
* CAN THIS TRAIN REACH THE YARD OFFICE, AND IS THE LEAD CLEAR? (Gitea#5)
|
||||
*
|
||||
* Three answers, because Jesse's ruling (2026-08-29) splits two failures his issue describes
|
||||
* separately: "if the Yard Office is not accessible in one move, you should not get the option"
|
||||
* and "cars on the tracks you use to get in result in a crash".
|
||||
*
|
||||
* - `clear` — a route exists and nothing is standing on it. Offer it; taking it is safe.
|
||||
* - `fouled` — a route exists and there are cars on it. Offer it; taking it collides.
|
||||
* - `none` — no route in one move. Do not offer it, and say why in the history.
|
||||
*
|
||||
* WALKED WITH THE ENGINE'S OWN MOVE RULES rather than a bespoke adjacency test. `exploreMoves`
|
||||
* already means exactly what the card's "in one move" means — any distance without changing
|
||||
* direction, finishing on Operational Rail (§2.4, §A.1) — so using it is what makes the code and
|
||||
* the card agree, which was the whole complaint.
|
||||
*
|
||||
* THE FOULING SIGNAL IS `couples`. The walk does not treat standing cars as obstructions: it
|
||||
* COUPLES them, because that is what a switching move does (§A.4). An arriving train is not
|
||||
* switching, so anything it would have coupled is instead something it is about to hit — the same
|
||||
* reading §8.3 already applies to the Running Track.
|
||||
*
|
||||
* The walk starts at the Office square, where a standard arrival puts the train, and leaves by the
|
||||
* way the train is already facing. Reversing is a separate Move (§A.5), so a Yard Office that can
|
||||
* only be reached by backing up is correctly "not in one move".
|
||||
*/
|
||||
/**
|
||||
* The flag comes down as it stops the train — one card, one train (Gitea#19).
|
||||
*
|
||||
* MUTATES RATHER THAN EMITTING A REDUCED EVENT, because this is the phase driver: `advance.ts`
|
||||
* changes state directly and then describes what it did, and roughly a third of the event types are
|
||||
* never reduced at all (`README.md`, and `tally.ts` on the same asymmetry). A `redFlagSpent`
|
||||
* reducer case looked right and never fired — the flag stayed up and held every train that came.
|
||||
*/
|
||||
function spendFlag(
|
||||
office: Extract<DivisionNode, { kind: 'office' }>,
|
||||
tray: CrewTray,
|
||||
side: Direction,
|
||||
events: GameEvent[],
|
||||
): 'held' {
|
||||
delete office.redFlag;
|
||||
events.push({ type: 'redFlagSpent', seat: office.seat, side, trainNumber: tray.trainNumber ?? 0 });
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason: 'Red Flags — held short of the Limits',
|
||||
});
|
||||
return 'held';
|
||||
}
|
||||
|
||||
/**
|
||||
* §Q, RED FLAGS (Gitea#19) — does a flag stop this train, and should its owner be offered one?
|
||||
*
|
||||
* Two jobs, because they are the same moment seen twice: a flag already planted stops the train
|
||||
* outright, and a train about to run into trouble is the cue to offer a flag to somebody holding
|
||||
* the card. "You can play the card normally or out of phase, but only if you need it."
|
||||
*
|
||||
* - `held` — a flag was up on the side this train is coming from. It loses this Mainline
|
||||
* Phase and the flag comes down with it: one card, one train (Jesse, 2026-08-29).
|
||||
* - `ask` — entering would collide and the district's owner holds a Red Flags card.
|
||||
* - `proceed` — neither.
|
||||
*
|
||||
* WHICH SIDE. A train running WEST arrives from the east, so `FLAG EAST` is what holds it — which
|
||||
* is the example the issue gives, and the reason the flag names a side rather than a heading.
|
||||
*/
|
||||
function redFlagStop(
|
||||
s: GameState,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
dest: Extract<DivisionNode, { kind: 'office' }>,
|
||||
events: GameEvent[],
|
||||
): 'held' | 'ask' | 'proceed' {
|
||||
const from: Direction = tray.direction === 'east' ? 'west' : 'east';
|
||||
|
||||
// The answer to a prompt already put. Consumed here so the driver cannot ask twice.
|
||||
const answer = s.clock.decisionAnswer;
|
||||
if (answer && answer.kind === 'redFlag' && answer.train === id) {
|
||||
s.clock.decisionAnswer = null;
|
||||
if (!answer.flag) return 'proceed';
|
||||
// The card was spent planting the flag; it stops this train and comes down again at once.
|
||||
return spendFlag(dest, tray, from, events);
|
||||
}
|
||||
|
||||
if (dest.redFlag === from) return spendFlag(dest, tray, from, events);
|
||||
|
||||
/**
|
||||
* "In actual cases of danger… if there is a train or cars on the track and there will be a
|
||||
* collision, then you break in with a dialog." The two ways an arrival collides are §8.3's own:
|
||||
* no free A/D track, and cars fouling the Running Track. Asked only of a player who can actually
|
||||
* answer — offering a flag to somebody holding no card is a prompt with one button.
|
||||
*/
|
||||
const owner = playerAtSeat(s, dest.seat);
|
||||
const holdsFlag = (s.decks.hands.get(owner) ?? []).some((cid) => {
|
||||
const c = s.cards.get(cid);
|
||||
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
||||
});
|
||||
if (!holdsFlag) return 'proceed';
|
||||
if (!arrivalWouldCollide(s, id, tray, dest.seat)) return 'proceed';
|
||||
|
||||
s.clock.pendingDecision = { kind: 'redFlag', train: id, seat: dest.seat, from };
|
||||
return 'ask';
|
||||
}
|
||||
|
||||
/**
|
||||
* Would this arrival collide? §8.3's two triggers, asked before the train commits.
|
||||
*
|
||||
* Deliberately a READ of the same conditions `arriveAtOffice` enforces rather than a second rule:
|
||||
* if these two ever diverge, the prompt offers a flag against a collision that will not happen, or
|
||||
* stays silent before one that will.
|
||||
*/
|
||||
function arrivalWouldCollide(s: GameState, id: TrayId, tray: CrewTray, seat: SeatIndex): boolean {
|
||||
const area = areaAtSeat(s, seat);
|
||||
const hasInterlocking = [...area.grid.values()].some((c) => c.enhancements.includes('interlocking'));
|
||||
const full = area.adOccupancy.length >= officeProfile(area.tier).adTracks;
|
||||
// Interlocking turns a full Office into a hold rather than a collision, so it is not danger.
|
||||
if (full && !hasInterlocking) return true;
|
||||
|
||||
// A coach may legally stand at the Office while its engine switches (§A.4's carve-out), so it is
|
||||
// not a hazard to the next arrival. Anything else on the Running Track is.
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
return officeCard !== undefined && officeCard.standing.some((c) => c.type !== 'coach');
|
||||
}
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — should the district's owner be asked about the Yard Office, and is there
|
||||
* anything to ask about?
|
||||
*
|
||||
* Returns `ask` only when the offer is real: a coachless train, a Yard Office card in the district,
|
||||
* and a route to it in one move. Everything else is `proceed`, which means the ordinary arrival.
|
||||
*
|
||||
* ALSO THE PLACE THE HISTORY LEARNS WHY NOT. Jesse, 2026-08-29: "make sure this is logged in
|
||||
* history — why can't move so user knows why they can't get to yard." A qualifying train that is
|
||||
* simply never offered the choice looks exactly like the feature being broken, which is how the
|
||||
* missing reachability check went unnoticed for so long.
|
||||
*/
|
||||
function yardOfficeQuestion(
|
||||
s: GameState,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
seat: SeatIndex,
|
||||
events: GameEvent[],
|
||||
): 'ask' | 'proceed' {
|
||||
// Already answered: `arriveAtOffice` consumes it. Asking again would loop the phase for ever.
|
||||
const answer = s.clock.decisionAnswer;
|
||||
if (answer && answer.kind === 'yardOffice' && answer.train === id) return 'proceed';
|
||||
|
||||
if (tray.consist.some((c) => c.type === 'coach')) return 'proceed';
|
||||
const area = areaAtSeat(s, seat);
|
||||
if (![...area.grid.values()].some((c) => c.enhancements.includes('yardOffice'))) return 'proceed';
|
||||
|
||||
const route = yardOfficeRoute(s, seat, id, tray);
|
||||
if (route.kind === 'none') {
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Office',
|
||||
reason: `the Yard Office could not be offered — ${route.why}`,
|
||||
});
|
||||
return 'proceed';
|
||||
}
|
||||
|
||||
s.clock.pendingDecision = { kind: 'yardOffice', train: id, seat };
|
||||
return 'ask';
|
||||
}
|
||||
|
||||
type YardOfficeRoute =
|
||||
| { kind: 'clear' | 'fouled'; coord: GridCoord }
|
||||
| { kind: 'none'; why: string };
|
||||
|
||||
function yardOfficeRoute(s: GameState, seat: SeatIndex, id: TrayId, tray: CrewTray): YardOfficeRoute {
|
||||
const area = areaAtSeat(s, seat);
|
||||
const target = [...area.grid.entries()].find(([, card]) => card.enhancements.includes('yardOffice'));
|
||||
if (!target) return { kind: 'none', why: 'there is no Yard Office in this district' };
|
||||
const [key] = target;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
const coord = { row: row!, col: col! };
|
||||
|
||||
const player = playerAtSeat(s, seat);
|
||||
const facing = railFacingOf(tray);
|
||||
const found = reachableDestinations(
|
||||
{
|
||||
area,
|
||||
occupancy: occupancyFor(s, player, id),
|
||||
consistSize: tray.consist.length,
|
||||
self: id,
|
||||
},
|
||||
area.officeCoord,
|
||||
facing,
|
||||
).find((d) => d.coord.row === coord.row && d.coord.col === coord.col);
|
||||
|
||||
if (!found) {
|
||||
return {
|
||||
kind: 'none',
|
||||
why: 'it cannot be reached from the Office in one move, running the way this train is facing',
|
||||
};
|
||||
}
|
||||
return { kind: found.couples.length > 0 ? 'fouled' : 'clear', coord };
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.3 — arriving at an Office. Collisions here are AUTOMATIC (Gap 2a): if the trigger holds,
|
||||
* the collision happens, with no die roll and no judgment.
|
||||
@@ -1039,22 +1345,51 @@ function arriveAtOffice(
|
||||
const hasEnhancement = (key: string): boolean =>
|
||||
[...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
|
||||
// Yard Office — "an inbound train with NO COACHES that can make a single move to the yard office
|
||||
// track may arrive there, not at the Train Order Office". It sidesteps the A/D track entirely.
|
||||
const noCoaches = !tray.consist.some((c) => c.type === 'coach');
|
||||
if (noCoaches && hasEnhancement('yardOffice')) {
|
||||
for (const [key, card] of area.grid) {
|
||||
if (!card.enhancements.includes('yardOffice')) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
tray.position = { at: 'grid', seat, coord: { row: row!, col: col! } };
|
||||
/**
|
||||
* §11, THE YARD OFFICE (Gitea#5) — offered, not imposed.
|
||||
*
|
||||
* "Trains that are only freight (cabooses ok, no coaches allowed) that arrive in a player's area
|
||||
* who has the yard office card get an extra ability. On the turn (mainline phase) that the train
|
||||
* arrives the game will offer that player the option to have that train go directly to the yard
|
||||
* office card instead of the standard office. They can of course still choose to have the train
|
||||
* go to the standard office."
|
||||
*
|
||||
* WHAT THIS USED TO DO, and why all three of the rule's conditions were missing: a qualifying
|
||||
* train was TELEPORTED onto the Yard Office card. The player was never asked, no route was ever
|
||||
* computed — so the card's own printed text, "that can reach the yard office in one move", was
|
||||
* unenforced — and because nothing was walked, nothing was ever met on the way in.
|
||||
*
|
||||
* The answer comes back through `pendingDecision`, so this returns `needsClearance` and is
|
||||
* re-entered once the player has answered. `yardOfficeOffer` below is where the route is walked.
|
||||
*/
|
||||
/**
|
||||
* The answer to the offer `yardOfficeQuestion` put before the train left the Mainline card.
|
||||
* Absent — because the train has no Yard Office, or no route to it, or carries coaches — this
|
||||
* falls straight through to the ordinary arrival below.
|
||||
*/
|
||||
const answer = s.clock.decisionAnswer;
|
||||
if (answer && answer.kind === 'yardOffice' && answer.train === id) {
|
||||
s.clock.decisionAnswer = null;
|
||||
const route = answer.take ? yardOfficeRoute(s, seat, id, tray) : { kind: 'none' as const };
|
||||
if (route.kind !== 'none') {
|
||||
tray.position = { at: 'grid', seat, coord: route.coord };
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Yard Office',
|
||||
reason: 'no coaches, so it need not occupy the Train Order Office',
|
||||
});
|
||||
/**
|
||||
* Cars on the lead are a COLLISION, not a coupling — the same §8.3 rule that governs the
|
||||
* Running Track, and the third of the three things this implementation was missing. An
|
||||
* arriving train is at speed and is not expecting them (§A.4).
|
||||
*/
|
||||
if (route.kind === 'fouled') {
|
||||
collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the lead into the Yard Office', 'the Yard Office');
|
||||
}
|
||||
return 'moved';
|
||||
}
|
||||
// Declined: fall through to the standard Office, with its own capacity and collision rules.
|
||||
}
|
||||
|
||||
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
|
||||
@@ -1167,7 +1502,6 @@ function collide(
|
||||
if (n.kind !== 'mainline') continue;
|
||||
n.transits = n.transits.filter((t) => t.tray !== id);
|
||||
if (n.holding) n.holding = n.holding.filter((t) => t !== id);
|
||||
if (n.redFlagged) n.redFlagged = n.redFlagged.filter((t) => t !== id);
|
||||
}
|
||||
/**
|
||||
* AND OFF THE A/D TRACK, for exactly the same reason as the transit above.
|
||||
@@ -1313,6 +1647,14 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
s.config.maxCollisionsTotal > 0 && s.collisionsTotal >= s.config.maxCollisionsTotal;
|
||||
if (perDayBreach || totalBreach) {
|
||||
s.status = 'finished';
|
||||
/**
|
||||
* NOT EXTENDABLE, AND IT DOES NOT REWRITE A RECORDED RESULT (Gitea#11).
|
||||
*
|
||||
* A breach during an EXTENDED Day ends play at once, exactly as it would during the regular
|
||||
* game — but by then the official result already exists, and a railroad declared unsafe on
|
||||
* Day 9 does not retract who won on Day 5. `freezeOfficial` is what keeps that true: it
|
||||
* writes only when `official` is still null, so assigning `outcome` here is safe.
|
||||
*/
|
||||
s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
|
||||
return { events, needsInput: false };
|
||||
}
|
||||
@@ -1356,28 +1698,55 @@ function rotateSeats(s: GameState, events: GameEvent[]): void {
|
||||
|
||||
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
|
||||
const daysElapsed = s.clock.day - 1;
|
||||
if (daysElapsed < s.config.days) return false;
|
||||
/**
|
||||
* `extraDays` is Gitea#11. `config.days` is never touched by an extension — it is what the
|
||||
* OFFICIAL result is decided at — so the Day the timetable currently runs to is the sum of the
|
||||
* two. On the first ending they are equal, which is why `freezeOfficial` can record `config.days`
|
||||
* as the official Day without asking anything further.
|
||||
*/
|
||||
if (daysElapsed < s.config.days + s.extraDays) return false;
|
||||
|
||||
s.status = 'finished';
|
||||
s.outcome = decideOutcome(s);
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY — an ending the table may play past PAUSES rather than finishing.
|
||||
*
|
||||
* `freezeOfficial` (the `advance` wrapper) records the first of these as the official result, so
|
||||
* by the time a second one is reached the winner is already settled and everything here is
|
||||
* informational. The votes are cleared each time because the question is asked again at the end
|
||||
* of every extended Day: agreeing once does not agree to the rest of the game.
|
||||
*/
|
||||
if (isExtendable(s.outcome.reason)) {
|
||||
s.status = 'awaitingExtension';
|
||||
s.extensionVotes = s.players.map(() => null);
|
||||
} else {
|
||||
s.status = 'finished';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHO WON, on the evidence as it stands right now.
|
||||
*
|
||||
* Split out of `checkVictory` for Gitea#11: it is asked once per ending, and an extended game has
|
||||
* more than one. Unchanged in substance — the revenue floor, then co-op's shared achievement, then
|
||||
* the highest Revenue — it simply no longer writes to the state it is reasoning about.
|
||||
*/
|
||||
function decideOutcome(s: GameState): Outcome {
|
||||
const combined = totalRevenue(s);
|
||||
if (s.config.minCombinedRevenue > 0 && combined < s.config.minCombinedRevenue) {
|
||||
s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||
return true;
|
||||
return { result: 'loss', winner: null, reason: 'revenueFloor' };
|
||||
}
|
||||
|
||||
if (s.config.mode === 'coop') {
|
||||
s.outcome = { result: 'win', winner: null, reason: 'daysElapsed' };
|
||||
return true;
|
||||
return { result: 'win', winner: null, reason: 'daysElapsed' };
|
||||
}
|
||||
|
||||
const best = Math.max(...s.players.map((p) => p.revenue));
|
||||
s.outcome = {
|
||||
return {
|
||||
result: 'win',
|
||||
winner: s.players.findIndex((p) => p.revenue === best),
|
||||
reason: 'daysElapsed',
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+219
-26
@@ -52,11 +52,14 @@ import type {
|
||||
TrayId,
|
||||
TurnoutOrientation,
|
||||
} from './state.ts';
|
||||
import { tallyEvent } from './tally.ts';
|
||||
import { createRng } from './rng.ts';
|
||||
import {
|
||||
carsOn,
|
||||
coordKey,
|
||||
cutTowards,
|
||||
decisionActor,
|
||||
officeNodeFor,
|
||||
isOperationalRail,
|
||||
playerAtSeat,
|
||||
pooled,
|
||||
@@ -123,7 +126,12 @@ function trayCoord(s: GameState, trayId: TrayId): GridCoord | null {
|
||||
}
|
||||
|
||||
/** A tray sitting on the Office card occupies an A/D track (§2.1). */
|
||||
function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy {
|
||||
/**
|
||||
* Exported for `advance.ts`'s Yard Office walk (Gitea#5), which has to ask the SAME occupancy
|
||||
* question a switching move asks — a second copy would be free to drift into a different answer
|
||||
* about which cards are free.
|
||||
*/
|
||||
export function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy {
|
||||
const area = areaOf(s, player);
|
||||
return {
|
||||
trayAt: (c) => {
|
||||
@@ -769,16 +777,64 @@ export function passengerRefusal(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCode | null {
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — asked ABOVE the status guard, because the whole point of the
|
||||
* vote is that it is the one thing the rules will take from a game that has stopped.
|
||||
*
|
||||
* Out of turn like `mainline.clearance` below, and unlike it open to every seat at once: it is a
|
||||
* table decision rather than a ruling, so there is no actor to be.
|
||||
*/
|
||||
if (i.type === 'game.extend') {
|
||||
if (s.status !== 'awaitingExtension') return 'NOT_AWAITING_EXTENSION';
|
||||
// The intent NAMES its voter so that a save can be replayed (`intents.ts`), which makes it a
|
||||
// claim until it is checked against the seat the caller authenticated. One seat may not vote
|
||||
// for another.
|
||||
if (i.player !== player) return 'NOT_YOUR_TURN';
|
||||
if (s.extensionVotes[player] !== null) return 'ALREADY_VOTED';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (s.status !== 'active') return 'WRONG_PHASE';
|
||||
|
||||
// The clearance ruling is the one intent that arrives out of turn order: it interrupts the
|
||||
// automatic Mainline Phase and goes to the Superintendent (§8.1, fourth condition).
|
||||
if (i.type === 'mainline.clearance') {
|
||||
if (s.clock.pendingDecision === null) return 'NO_PENDING_DECISION';
|
||||
if (s.clock.pendingDecision?.kind !== 'clearance') return 'NO_PENDING_DECISION';
|
||||
if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §Q (Gitea#19) — the Red Flag prompt, the third interruption of the Mainline Phase.
|
||||
*
|
||||
* Only ever raised for a player who holds the card, so `flag: true` can always be paid for; the
|
||||
* card is checked again here because `check` is the authority and a hand can change between the
|
||||
* prompt being raised and answered.
|
||||
*/
|
||||
if (i.type === 'mainline.redFlag') {
|
||||
if (s.clock.pendingDecision?.kind !== 'redFlag') return 'NO_RED_FLAG_PROMPT';
|
||||
if (decisionActor(s) !== player) return 'NOT_YOUR_TURN';
|
||||
if (!i.flag) return null;
|
||||
const held = (s.decks.hands.get(player) ?? []).find((id) => {
|
||||
const c = s.cards.get(id);
|
||||
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
||||
});
|
||||
if (!held) return 'NO_SUCH_CARD';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — the Yard Office offer, the second interruption of the Mainline Phase.
|
||||
*
|
||||
* Goes to the district's owner rather than the Superintendent, which is the whole reason
|
||||
* `pendingDecision` became a union. `decisionActor` is the single place that mapping lives.
|
||||
*/
|
||||
if (i.type === 'mainline.yardOffice') {
|
||||
if (s.clock.pendingDecision?.kind !== 'yardOffice') return 'NO_YARD_OFFICE_OFFER';
|
||||
if (decisionActor(s) !== player) return 'NOT_YOUR_TURN';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isActor(s, player)) return 'NOT_YOUR_TURN';
|
||||
|
||||
switch (i.type) {
|
||||
@@ -988,14 +1044,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
// "A STOPPED train is prevented from being hit" — it protects a train that is standing on a
|
||||
// Mainline card, which is the only place a rear-ender can happen.
|
||||
if (tray.position.at !== 'mainline') return 'NO_PLACEMENT';
|
||||
const node = s.division.nodes[tray.position.index];
|
||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
||||
if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN';
|
||||
// §Q (Gitea#19) — a flag goes on your OWN Limits. There is no target train to name and no
|
||||
// placement to find: the district is yours, and the only question is which side.
|
||||
if (officeNodeFor(s, seatOf(s, player))?.redFlag === i.side) return 'ALREADY_FLAGGED';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1141,7 +1192,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
// This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
|
||||
// Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
|
||||
// no caboose. Fewer is allowed; more, or of the wrong category, is not.
|
||||
return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR';
|
||||
return acceptsCar(tray, i.carType, i.loaded, s.yards.divisionYard) ? null : 'NO_SUITABLE_CAR';
|
||||
}
|
||||
|
||||
case 'newTrain.secondSection': {
|
||||
@@ -1532,6 +1583,33 @@ export function movesFor(
|
||||
|
||||
function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
switch (i.type) {
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the vote, and what it settles.
|
||||
*
|
||||
* Decided HERE rather than in `reduce` because the answer depends on the votes as they stand
|
||||
* BEFORE this one lands, and `execute` is the half that still sees that. Three outcomes:
|
||||
*
|
||||
* - a refusal ends it immediately. Unanimity means one "no" is decisive, so nobody is made to
|
||||
* wait on a player who has already said no (Jesse's call, 2026-08-28);
|
||||
* - the last outstanding "yes" grants the Day — ONE Day, and the question is put again at the
|
||||
* end of it;
|
||||
* - anything else is just a vote recorded, and the table waits.
|
||||
*/
|
||||
case 'game.extend': {
|
||||
const vote: GameEvent = { type: 'extensionVoted', player, agree: i.agree };
|
||||
if (!i.agree) return [vote, { type: 'playConcluded', declinedBy: player }];
|
||||
const after = s.extensionVotes.map((v, p) => (p === player ? true : v));
|
||||
return after.every((v) => v === true)
|
||||
? [vote, { type: 'dayExtended', day: s.config.days + s.extraDays + 1 }]
|
||||
: [vote];
|
||||
}
|
||||
|
||||
case 'mainline.yardOffice': {
|
||||
const pending = s.clock.pendingDecision;
|
||||
const trainId = pending?.kind === 'yardOffice' ? pending.train : '';
|
||||
return [{ type: 'yardOfficeRuled', player, trainId, take: i.take }];
|
||||
}
|
||||
|
||||
case 'localOps.choose':
|
||||
return [{ type: 'localOpsOptionChosen', player, option: i.option }];
|
||||
|
||||
@@ -1765,10 +1843,25 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
];
|
||||
}
|
||||
|
||||
case 'maneuver.redFlags': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
const index = tray.position.at === 'mainline' ? tray.position.index : -1;
|
||||
return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }];
|
||||
case 'maneuver.redFlags':
|
||||
return [{ type: 'redFlagsSet', player, cardId: i.cardId, seat: seatOf(s, player), side: i.side }];
|
||||
|
||||
case 'mainline.redFlag': {
|
||||
const pending = s.clock.pendingDecision;
|
||||
const trainId = pending?.kind === 'redFlag' ? pending.train : '';
|
||||
const seat = pending?.kind === 'redFlag' ? pending.seat : 0;
|
||||
const side = pending?.kind === 'redFlag' ? pending.from : 'east';
|
||||
if (!i.flag) return [{ type: 'redFlagRuled', player, trainId, flag: false }];
|
||||
const cardId =
|
||||
i.cardId ??
|
||||
(s.decks.hands.get(player) ?? []).find((id) => {
|
||||
const c = s.cards.get(id);
|
||||
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
||||
})!;
|
||||
return [
|
||||
{ type: 'redFlagsSet', player, cardId, seat, side },
|
||||
{ type: 'redFlagRuled', player, trainId, flag: true },
|
||||
];
|
||||
}
|
||||
|
||||
case 'maneuver.flyingSwitch': {
|
||||
@@ -1942,6 +2035,37 @@ function findTimetableSlot(s: GameState, from: number): number | null {
|
||||
|
||||
export function reduce(s: GameState, e: GameEvent): void {
|
||||
switch (e.type) {
|
||||
// -- §3.3, extended play (Gitea#11)
|
||||
case 'extensionVoted':
|
||||
s.extensionVotes[e.player] = e.agree;
|
||||
break;
|
||||
|
||||
/**
|
||||
* One more Day, and the votes are wiped: agreeing once does not agree to the rest of the game.
|
||||
*
|
||||
* `config.days` is deliberately untouched. It is what the OFFICIAL result was decided at
|
||||
* (`state.ts`'s `FinalReport`), so leaving it alone is what makes "the winner is decided at the
|
||||
* original game length" a fact about the code rather than a comment on it. `outcome` is left
|
||||
* alone too — it is the last evaluation, and it is what the results screen shows while the extra
|
||||
* Day is played.
|
||||
*/
|
||||
case 'dayExtended':
|
||||
s.extraDays += 1;
|
||||
s.extensionVotes = s.players.map(() => null);
|
||||
s.status = 'active';
|
||||
break;
|
||||
|
||||
case 'playConcluded':
|
||||
s.status = 'finished';
|
||||
break;
|
||||
|
||||
// §11 (Gitea#5) — the same shape as `clearanceGiven`: clear the question, record the answer for
|
||||
// the arriving train to consume, or the driver asks again for ever.
|
||||
case 'yardOfficeRuled':
|
||||
s.clock.pendingDecision = null;
|
||||
s.clock.decisionAnswer = { kind: 'yardOffice', train: e.trainId, take: e.take };
|
||||
break;
|
||||
|
||||
case 'localOpsOptionChosen':
|
||||
turnOf(s, e.player).option = e.option;
|
||||
break;
|
||||
@@ -2166,14 +2290,23 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
}
|
||||
|
||||
case 'redFlagsSet': {
|
||||
const node = s.division.nodes[e.node];
|
||||
if (node?.kind === 'mainline') {
|
||||
node.redFlagged = [...(node.redFlagged ?? []), e.trayId];
|
||||
}
|
||||
const node = officeNodeFor(s, e.seat);
|
||||
if (node) node.redFlag = e.side;
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* §Q (Gitea#19) — `redFlagSpent` is NOT reduced, deliberately. It is emitted only by the phase
|
||||
* driver, which mutates state itself and then describes it (`advance.ts`'s `spendFlag`), so a
|
||||
* case here would be dead code that reads as the live one.
|
||||
*/
|
||||
|
||||
case 'redFlagRuled':
|
||||
s.clock.pendingDecision = null;
|
||||
s.clock.decisionAnswer = { kind: 'redFlag', train: e.trainId, flag: e.flag };
|
||||
break;
|
||||
|
||||
case 'flyingSwitch': {
|
||||
const tray = s.trays.get(e.trayId);
|
||||
const area = areaOf(s, e.player);
|
||||
@@ -2530,7 +2663,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'clearanceGiven':
|
||||
s.clock.pendingDecision = null;
|
||||
// Recorded for the asking train to consume; otherwise the driver asks again forever.
|
||||
s.clock.clearanceRuling = { train: e.trainId, allow: e.allow };
|
||||
s.clock.decisionAnswer = { kind: 'clearance', train: e.trainId, allow: e.allow };
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -2933,7 +3066,20 @@ function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
||||
* this. A second copy stalled the game outright: the phase believed a car could be added while
|
||||
* `check` rejected every option, so the Stage never completed.
|
||||
*/
|
||||
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
export function acceptsCar(
|
||||
tray: CrewTray,
|
||||
carType: CarType,
|
||||
/**
|
||||
* Whether the car being offered is loaded, and what the Division Yard still holds.
|
||||
*
|
||||
* Both optional so that a caller asking the SHAPE question — "does this card take a car of this
|
||||
* category at all?" — need not answer the loading question. `trainNeedingCars` asks the shape
|
||||
* question of every car in the yard; `check` asks the full one about a specific car a player has
|
||||
* named. Omitting them skips the loading rules rather than guessing at them.
|
||||
*/
|
||||
loaded?: boolean,
|
||||
yard?: readonly RollingStock[],
|
||||
): boolean {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
if (!profile) return true;
|
||||
|
||||
@@ -2956,6 +3102,40 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
const types = profile.consist.freightTypes;
|
||||
if (adding === 'freight' && types && !types.includes(carType)) return false;
|
||||
|
||||
if (loaded === undefined) return true;
|
||||
|
||||
/**
|
||||
* X13 APPLESEED — "may drop MTs but not pick up anything", and its consist prints EMPTIES ONLY.
|
||||
*
|
||||
* `emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)" by both
|
||||
* `web/game.ts` and `sim/view.ts`, and enforced by nothing: the Appleseed could be made up with
|
||||
* loaded cars while its own card said it could not. Found while building Gitea#13, which is the
|
||||
* same rule pointing the other way, and fixed with it rather than left as the odd one out.
|
||||
*
|
||||
* A caboose is exempt. Every caboose in `ROLLING_STOCK_SUPPLY` is `loaded: true` — there is no
|
||||
* such thing as an empty one — so applying this to the caboose would bar the Appleseed from the
|
||||
* caboose its own consist calls for.
|
||||
*/
|
||||
if (profile.consist.emptiesOnly && loaded && adding !== 'caboose') return false;
|
||||
|
||||
/**
|
||||
* MUST RUN LOADED (Gitea#13) — a preference order, not a flat requirement.
|
||||
*
|
||||
* "If not loaded, then empty, and if none available, run without." So an EMPTY is refused only
|
||||
* while the yard can still supply a loaded car this train would accept; once it cannot, the empty
|
||||
* becomes legal and the train may also simply depart short. Asked of the yard rather than
|
||||
* remembered on the tray, because the yard is what the rule is about and it changes under the
|
||||
* train as other consists are built.
|
||||
*
|
||||
* The caboose is exempt for the same reason as above.
|
||||
*/
|
||||
if (profile.rules.mustRunLoaded && !loaded && adding !== 'caboose' && yard) {
|
||||
const loadedAvailable = yard.some(
|
||||
(c) => c.loaded && cat(c.type) === adding && acceptsCar(tray, c.type),
|
||||
);
|
||||
if (loadedAvailable) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2996,11 +3176,21 @@ export function isBeingMadeUp(tray: CrewTray): boolean {
|
||||
export function trainNeedingCars(s: GameState): TrayId | null {
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (!isBeingMadeUp(tray)) continue;
|
||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
|
||||
// uses: a separate copy of this test stalled the game, because the phase believed a car could
|
||||
// be added while `check` rejected every option, so the Stage never ended.
|
||||
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
|
||||
/**
|
||||
* Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||
* the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
|
||||
* uses: a separate copy of this test stalled the game, because the phase believed a car could
|
||||
* be added while `check` rejected every option, so the Stage never ended.
|
||||
*
|
||||
* ASKED PER CAR, WITH ITS LOADED STATE, since Gitea#13. The shape question alone is no longer
|
||||
* the same question `check` answers: an `emptiesOnly` train looking at a yard of nothing but
|
||||
* loaded cars, or a `mustRunLoaded` train offered only empties while loaded ones remain, would
|
||||
* both be told a car was available and then refused every one of them — the very stall this
|
||||
* comment was written about.
|
||||
*/
|
||||
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type, c.loaded, s.yards.divisionYard))) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3052,6 +3242,9 @@ export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): Apply
|
||||
|
||||
const events = execute(s, player, i);
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
+16
-3
@@ -434,6 +434,19 @@ export type TrainRules = {
|
||||
/** X17 Campaign, X18 Circus: a scheduled stop that does something. */
|
||||
stopEarnsPoint?: boolean;
|
||||
stopThenExpedite?: boolean;
|
||||
/**
|
||||
* MUST RUN LOADED (Gitea#13) — X17 Campaign, X18 Circus, X19 Military.
|
||||
*
|
||||
* "I've redefined some of the extra trains that they have to run full boxcars (not just any crazy
|
||||
* stuff) — military trains, circus trains, etc. If not loaded, then empty, and if none available,
|
||||
* run without." So it is a PREFERENCE ORDER enforced at make-up, not a flat requirement: a loaded
|
||||
* car of an acceptable type must be taken while one is in the Division Yard; only once none is
|
||||
* left may an empty be taken; and a train may still depart short (§8.2 already allows fewer cars
|
||||
* than the card lists).
|
||||
*
|
||||
* Distinct from `ConsistSpec.emptiesOnly`, which is the opposite rule for X13 Appleseed.
|
||||
*/
|
||||
mustRunLoaded?: boolean;
|
||||
/**
|
||||
* `copiesNextScheduled` was here and is DELETED. No train card ever carried it: a Second Section
|
||||
* is a Maneuver card played on a train that is due out, and it has its own intent
|
||||
@@ -492,9 +505,9 @@ export const EXTRA_TRAINS: readonly TrainProfile[] = [
|
||||
{ number: 14, isExtra: true, name: 'Fruit Growers Express', speed: 'fast', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1, freightTypes: ['reefer'] }, rules: { expedite: true, note: 'Reefers only. May pick up one extra loaded reefer.' } },
|
||||
{ number: 15, isExtra: true, name: 'Yard Xfer', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1 }, rules: {} },
|
||||
{ number: 16, isExtra: true, name: 'Light Engine Move', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 0 }, rules: { noSwitching: true, note: 'No cars at all.' } },
|
||||
{ number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, note: 'One turn at station (speeches) then expedite.' } },
|
||||
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, note: 'One turn stopped on any track (circus set-up) earns 1 point.' } },
|
||||
{ number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true } },
|
||||
{ number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, stopEarnsPoint: true, mustRunLoaded: true, note: 'One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard.' } },
|
||||
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, mustRunLoaded: true, note: 'One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded.' } },
|
||||
{ number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true, mustRunLoaded: true, note: 'Troops and materiel: runs loaded where the yard can supply it.' } },
|
||||
{ number: 20, isExtra: true, name: "Director's private car", speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 0 }, rules: { noPassengerWork: true } },
|
||||
{ number: 21, isExtra: true, name: 'Freight Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1 }, rules: {} },
|
||||
{ number: 22, isExtra: true, name: 'Pee-Dee', speed: 'slow', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 1 }, rules: { pickUpEmptiesOnly: true, note: 'Per-diem train. May only pick up MTs.' } },
|
||||
|
||||
+26
-2
@@ -87,7 +87,12 @@ export type GameEvent =
|
||||
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
|
||||
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number }
|
||||
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
|
||||
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
|
||||
| { type: 'redFlagSpent'; seat: SeatIndex; side: Direction; trainNumber: number }
|
||||
/** §Q (Gitea#19) — the district's owner answered the out-of-phase "flag against this train?". */
|
||||
| { type: 'redFlagRuled'; player: PlayerIndex; trainId: TrayId; flag: boolean }
|
||||
| {
|
||||
type: 'trainsDestroyed';
|
||||
player: PlayerIndex;
|
||||
@@ -195,6 +200,25 @@ export type GameEvent =
|
||||
| { type: 'unloadBegan'; player: PlayerIndex; at: GridCoord; carType: CarType; carIndex: number }
|
||||
// -- consequences
|
||||
| { type: 'revenueChanged'; player: PlayerIndex; delta: number; total: number; reason: string }
|
||||
| { type: 'phaseEnded'; player: PlayerIndex; phase: string };
|
||||
| { type: 'phaseEnded'; player: PlayerIndex; phase: string }
|
||||
// -- §3.3, extended play (Gitea#11)
|
||||
/**
|
||||
* One seat's answer to "play one more Day?". Every seat votes; the vote is unanimous, and one
|
||||
* refusal ends it. In the log so that a table can see who is still being waited on, and who
|
||||
* called time.
|
||||
*/
|
||||
| { type: 'extensionVoted'; player: PlayerIndex; agree: boolean }
|
||||
/** The table agreed. `day` is the Day the extra one becomes — `config.days + extraDays`. */
|
||||
| { type: 'dayExtended'; day: number }
|
||||
/**
|
||||
* Play is over for good — somebody declined the extension.
|
||||
*
|
||||
* Distinct from the ending itself, which `checkVictory` already announced by way of the result: an
|
||||
* ending that COULD have been played past and was not is a decision the table made, and the log
|
||||
* should say so rather than simply stopping.
|
||||
*/
|
||||
| { type: 'playConcluded'; declinedBy: PlayerIndex }
|
||||
/** §11 (Gitea#5) — the district's owner answered the Yard Office offer. */
|
||||
| { type: 'yardOfficeRuled'; player: PlayerIndex; trainId: TrayId; take: boolean };
|
||||
|
||||
export type EventType = GameEvent['type'];
|
||||
|
||||
+59
-5
@@ -120,10 +120,24 @@ export type Intent =
|
||||
*/
|
||||
| { type: 'mainline.modify'; cardId: CardId; node: number }
|
||||
/**
|
||||
* Red Flags — protect a stopped train. The flagged train cannot be hit; an approaching train is
|
||||
* held instead of colliding.
|
||||
* §Q, RED FLAGS (Gitea#19) — plant a flag on one side of your own district.
|
||||
*
|
||||
* "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits from
|
||||
* that direction (i.e. Flag East holds westbound trains). You can do this if you see a problem or
|
||||
* wish to complete switching."
|
||||
*
|
||||
* `side` names the side of the district the flag goes on, so a train arriving from that side is
|
||||
* held. It REPLACES the old rule, which was played on a stopped train out on the Mainline and
|
||||
* protected it from a rear-ender: measured at 4,212 offers and 4 plays across 600 games, a
|
||||
* mechanic nobody used. ABS Signals already protects a train standing on a Mainline card.
|
||||
*/
|
||||
| { type: 'maneuver.redFlags'; cardId: CardId; trayId: TrayId }
|
||||
| { type: 'maneuver.redFlags'; cardId: CardId; side: Direction }
|
||||
/**
|
||||
* The same card, played OUT OF PHASE at the moment of danger (Gitea#19) — "COLLISION RISK! FLAG
|
||||
* AGAINST T2?". Answers a pending `redFlag` decision; `flag: false` declines and lets the
|
||||
* collision happen. The side is not asked for: the train is already coming from one.
|
||||
*/
|
||||
| { type: 'mainline.redFlag'; flag: boolean; cardId?: CardId }
|
||||
/**
|
||||
* Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without
|
||||
* the engine entering it.
|
||||
@@ -147,7 +161,37 @@ export type Intent =
|
||||
| { type: 'laborer.startLoad'; at: GridCoord }
|
||||
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
|
||||
| { type: 'laborer.beginUnload'; at: GridCoord; carIndex: number }
|
||||
| { type: 'loadUnload.end' };
|
||||
| { type: 'loadUnload.end' }
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — one vote on whether to play one more Day.
|
||||
*
|
||||
* ARRIVES OUT OF TURN, like `mainline.clearance`, and unlike it goes to EVERY seat rather than to
|
||||
* the Superintendent: it is a table decision, not a ruling. Unanimous, and one `agree: false`
|
||||
* ends the game immediately — nobody waits on a player who has already refused.
|
||||
*
|
||||
* It is an intent, rather than a button the client handles by itself, because a save is
|
||||
* `{ seed, config, history }` replayed through the engine: a decision that is not in the history
|
||||
* did not happen, and an extended game would evaporate on the next reload, Undo, or server
|
||||
* restart. This is the record of the table agreeing.
|
||||
*
|
||||
* CARRIES ITS VOTER, uniquely among intents, and it has to. A saved history is a flat `Intent[]`
|
||||
* with no seat recorded against each move: the replay DERIVES who acted from the turn order
|
||||
* (`fromMultiplayerSave`). That works for every other intent, including `mainline.clearance`,
|
||||
* because there is exactly one seat it could have been. Here there is not — every seat may vote,
|
||||
* in any order — so a vote whose voter is not written down cannot be replayed at all, and a
|
||||
* resumed server would refuse the save with `NO_ACTOR`. The server checks this against the seat
|
||||
* it authenticated (`NOT_YOUR_TURN`), so it is a record, never a claim.
|
||||
*/
|
||||
| { type: 'game.extend'; player: PlayerIndex; agree: boolean }
|
||||
/**
|
||||
* §11 (Gitea#5) — take the Yard Office, or the standard Office.
|
||||
*
|
||||
* Interrupts the Mainline Phase like `mainline.clearance`, and like it goes to one named player:
|
||||
* whoever sits in the district the train is arriving at. Offered only when a route exists, so
|
||||
* `take: true` always has somewhere to go — though it may still meet cars on the lead and crash,
|
||||
* which is the point of the rule.
|
||||
*/
|
||||
| { type: 'mainline.yardOffice'; take: boolean };
|
||||
|
||||
export type IntentType = Intent['type'];
|
||||
|
||||
@@ -276,7 +320,17 @@ export type RejectionCode =
|
||||
* §6.2, Jesse's ruling (Gitea#6) — a train card is never discarded. Hold it as long as you like;
|
||||
* the only way it leaves your hand is onto the timetable.
|
||||
*/
|
||||
| 'TRAINS_ARE_NEVER_DISCARDED';
|
||||
| 'TRAINS_ARE_NEVER_DISCARDED'
|
||||
/** §3.3 (Gitea#11) — `game.extend` when the game is not waiting on an extension vote. */
|
||||
| 'NOT_AWAITING_EXTENSION'
|
||||
/** §3.3 (Gitea#11) — this seat has already voted on this extension. */
|
||||
| 'ALREADY_VOTED'
|
||||
/** §11 (Gitea#5) — answering a Yard Office offer that is not open. */
|
||||
| 'NO_YARD_OFFICE_OFFER'
|
||||
/** §Q (Gitea#19) — answering a Red Flag prompt that is not open. */
|
||||
| 'NO_RED_FLAG_PROMPT'
|
||||
/** §Q (Gitea#19) — this district already has a flag on that side. */
|
||||
| 'ALREADY_FLAGGED';
|
||||
|
||||
export type Rejection = { code: RejectionCode; message: string };
|
||||
|
||||
|
||||
+30
-3
@@ -68,11 +68,37 @@ export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
|
||||
// The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1).
|
||||
if (s.clock.pendingDecision !== null) {
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the only thing on offer when the timetable has run out and the
|
||||
* table is being asked whether to play on.
|
||||
*
|
||||
* Returned EARLY rather than added to the list, because nothing else is legal in this state and
|
||||
* the phase switch below would otherwise generate a boardful of candidates for `check` to reject
|
||||
* one at a time. It also puts the vote in front of the bot driver through the ordinary path, which
|
||||
* is what lets a bot seat answer without the engine having to know which seats are bots.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') {
|
||||
if (s.extensionVotes[player] === null) {
|
||||
out.push({ type: 'game.extend', player, agree: true });
|
||||
out.push({ type: 'game.extend', player, agree: false });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The two interruptions of the Mainline Phase. Each goes to one named player — `check` is the
|
||||
// authority on which — so both are generated here and filtered there.
|
||||
if (s.clock.pendingDecision?.kind === 'clearance') {
|
||||
out.push({ type: 'mainline.clearance', allow: true });
|
||||
out.push({ type: 'mainline.clearance', allow: false });
|
||||
}
|
||||
if (s.clock.pendingDecision?.kind === 'yardOffice') {
|
||||
out.push({ type: 'mainline.yardOffice', take: true });
|
||||
out.push({ type: 'mainline.yardOffice', take: false });
|
||||
}
|
||||
if (s.clock.pendingDecision?.kind === 'redFlag') {
|
||||
out.push({ type: 'mainline.redFlag', flag: true });
|
||||
out.push({ type: 'mainline.redFlag', flag: false });
|
||||
}
|
||||
|
||||
switch (s.clock.phase) {
|
||||
case 'localOps':
|
||||
@@ -93,7 +119,8 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue;
|
||||
for (const [trayId] of s.trays) out.push({ type: 'maneuver.redFlags', cardId, trayId });
|
||||
// §Q (Gitea#19) — a flag goes on one side of your own district, so the only choice is which.
|
||||
for (const side of ['east', 'west'] as const) out.push({ type: 'maneuver.redFlags', cardId, side });
|
||||
}
|
||||
|
||||
out.push({ type: 'redFlag.play' });
|
||||
|
||||
+6
-2
@@ -43,7 +43,7 @@ import type {
|
||||
TrackCard,
|
||||
TrayId,
|
||||
} from './state.ts';
|
||||
import { coordKey, freshTurns } from './state.ts';
|
||||
import { coordKey, emptyTally, freshTurns } from './state.ts';
|
||||
|
||||
export type SetupOptions = {
|
||||
id: string;
|
||||
@@ -424,7 +424,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
phase: 'localOps',
|
||||
currentActor: superintendent,
|
||||
pendingDecision: null,
|
||||
clearanceRuling: null,
|
||||
decisionAnswer: null,
|
||||
superintendent,
|
||||
actorOffset: 0,
|
||||
},
|
||||
@@ -434,6 +434,10 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
collisionsTotal: 0,
|
||||
status: 'active',
|
||||
outcome: null,
|
||||
extraDays: 0,
|
||||
extensionVotes: players.map(() => null),
|
||||
official: null,
|
||||
tally: emptyTally(playerCount),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+323
-21
@@ -445,20 +445,27 @@ export type CrewTray = {
|
||||
position: NodeRef;
|
||||
movesUsed: number;
|
||||
/**
|
||||
* X18 Circus Train — "one turn stopped on any track (circus set-up) earns 1 point", claimed once.
|
||||
* X18 Circus / X17 Campaign — Office Areas this train has already been paid for setting up in
|
||||
* (Gitea#13).
|
||||
*
|
||||
* Recorded on the tray rather than the player because it is the TRAIN that sets up, and an Extra
|
||||
* runs once and is gone; there is no second visit to claim it on.
|
||||
* "Once per stop in an office area. In a multiplayer game, each player could score if the circus
|
||||
* stops in their area" (Jesse, 2026-08-29). So the claim is per SEAT, not per train: a Circus
|
||||
* touring three districts is paid three times, and one that parks in the same district for six
|
||||
* Stages is paid once.
|
||||
*
|
||||
* Recorded on the tray, which also gives the other half of Jesse's ruling for free — "if the
|
||||
* circus train gets recycled and played a second time as a second extra, then it could again
|
||||
* score points later too". A train is made up onto a FRESH tray object every time, so a re-played
|
||||
* Extra starts with an empty list and no reset code is needed.
|
||||
*/
|
||||
stopPointClaimed?: boolean;
|
||||
stopPointSeats?: SeatIndex[];
|
||||
/**
|
||||
* X17 Campaign Train — "one turn at station (speeches) then expedite".
|
||||
*
|
||||
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
|
||||
* then on the train is expedited — it may be switched normally, but it faults (Q3) if it is left
|
||||
* off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as
|
||||
* `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs once, so there is no later
|
||||
* visit to hang it on.
|
||||
* `stopPointSeats` — it is the TRAIN that stops, and a re-played Extra gets a fresh tray.
|
||||
*/
|
||||
speechMade?: boolean;
|
||||
};
|
||||
@@ -518,10 +525,23 @@ export type DivisionNode =
|
||||
* "Player sets orientation", so the direction is chosen when the card is placed.
|
||||
*/
|
||||
gradeUp?: Direction;
|
||||
/** Red Flags protecting a stopped train here, by tray. */
|
||||
redFlagged?: TrayId[];
|
||||
}
|
||||
| { kind: 'office'; seat: SeatIndex };
|
||||
| {
|
||||
kind: 'office';
|
||||
seat: SeatIndex;
|
||||
/**
|
||||
* §Q, RED FLAGS (Gitea#19) — the side of this district a flag is planted on.
|
||||
*
|
||||
* "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits
|
||||
* from that direction (i.e. Flag East holds westbound trains)." So the value names the SIDE,
|
||||
* and a train arriving from that side is held: a westbound train comes from the east.
|
||||
*
|
||||
* SPENT ON THE TRAIN IT STOPS (Jesse's ruling, 2026-08-29). One card, one train — the flag
|
||||
* comes down as it is used, so there is no lifting action to build, nothing to forget, and a
|
||||
* flag cannot quietly strangle the Division.
|
||||
*/
|
||||
redFlag?: Direction;
|
||||
};
|
||||
|
||||
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
|
||||
export type Division = { nodes: DivisionNode[] };
|
||||
@@ -585,10 +605,42 @@ export type Yards = {
|
||||
export type Phase = 'localOps' | 'newTrain' | 'mainline' | 'loadUnload' | 'shiftChange';
|
||||
|
||||
/** §8.1 fourth condition — the Superintendent rules on a following train. */
|
||||
export type SuperintendentClearance = {
|
||||
train: TrayId;
|
||||
occupiedBy: TrayId;
|
||||
};
|
||||
/**
|
||||
* AN INTERRUPTION TO THE AUTOMATIC MAINLINE PHASE — a question the driver cannot answer itself.
|
||||
*
|
||||
* There was one of these and it was hardcoded to one question asked of one player: the §8.1
|
||||
* clearance ruling, always to the Superintendent. Gitea#5 and Gitea#19 each need to stop the same
|
||||
* phase and ask a DIFFERENT player something different, so the shape is a union and `decisionActor`
|
||||
* below decides who answers.
|
||||
*
|
||||
* Every member names the `train` the question is about, because the answer has to be matched back
|
||||
* to it — see `DecisionAnswer`.
|
||||
*/
|
||||
export type PendingDecision =
|
||||
/** §8.1 — a following train in the same Subdivision. The Superintendent rules. */
|
||||
| { kind: 'clearance'; train: TrayId; occupiedBy: TrayId }
|
||||
/**
|
||||
* §11 (Gitea#5) — an inbound freight may take the Yard Office instead of the Train Order Office.
|
||||
* Asked of whoever sits in `seat`, on the Mainline Phase the train arrives.
|
||||
*/
|
||||
| { kind: 'yardOffice'; train: TrayId; seat: SeatIndex }
|
||||
/**
|
||||
* §Q (Gitea#19) — a train is about to enter this district into a collision, and its owner holds a
|
||||
* Red Flags card. "You can play the card normally or out of phase, but only if you need it."
|
||||
*/
|
||||
| { kind: 'redFlag'; train: TrayId; seat: SeatIndex; from: Direction };
|
||||
|
||||
/**
|
||||
* The answer, waiting to be consumed by the train that asked.
|
||||
*
|
||||
* Without this the driver would re-evaluate the same train, ask the same question, and never
|
||||
* advance. Keyed by `kind` as well as `train` so an answer can never be mistaken for the reply to a
|
||||
* different question about the same train.
|
||||
*/
|
||||
export type DecisionAnswer =
|
||||
| { kind: 'clearance'; train: TrayId; allow: boolean }
|
||||
| { kind: 'yardOffice'; train: TrayId; take: boolean }
|
||||
| { kind: 'redFlag'; train: TrayId; flag: boolean };
|
||||
|
||||
export type Clock = {
|
||||
day: number;
|
||||
@@ -597,13 +649,10 @@ export type Clock = {
|
||||
phase: Phase;
|
||||
/** Exactly one player may act at a time. Null during automatic Mainline movement. */
|
||||
currentActor: PlayerIndex | null;
|
||||
/** Interrupts the Mainline Phase to ask the Superintendent (§8.1). */
|
||||
pendingDecision: SuperintendentClearance | null;
|
||||
/**
|
||||
* The Superintendent's answer, waiting to be consumed by the train that asked. Without this the
|
||||
* driver would re-evaluate the same train and ask the same question forever.
|
||||
*/
|
||||
clearanceRuling: { train: TrayId; allow: boolean } | null;
|
||||
/** Interrupts the Mainline Phase to ask a player something (§8.1, §11). */
|
||||
pendingDecision: PendingDecision | null;
|
||||
/** The answer to `pendingDecision`, waiting to be consumed by the train that asked. */
|
||||
decisionAnswer: DecisionAnswer | null;
|
||||
superintendent: PlayerIndex;
|
||||
/**
|
||||
* How far round the table the current phase has got. Acting order starts at the Superintendent
|
||||
@@ -684,6 +733,139 @@ export type Outcome = {
|
||||
reason: OutcomeReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — which endings may be played past.
|
||||
*
|
||||
* Both days-based endings offer another Day: running out of timetable, and closing short of the
|
||||
* combined Revenue floor, are the same event seen twice — the last Day ended and this is what the
|
||||
* books say. A `collisionFloor` ending is NOT extendable, and neither is a collision breach that
|
||||
* happens during an extended Day: §3.4 stopped the game because the railroad was declared unsafe,
|
||||
* and carrying on regardless would contradict the rule that stopped it (Jesse's call, 2026-08-28).
|
||||
*/
|
||||
export function isExtendable(reason: OutcomeReason): boolean {
|
||||
return reason === 'daysElapsed' || reason === 'revenueFloor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Running counts of everything interesting that has happened, tallied from the event stream
|
||||
* (Gitea#16).
|
||||
*
|
||||
* WHY IT LIVES ON `GameState` rather than being computed by whoever happens to want it. Three
|
||||
* reasons, in ascending order of how much they cost to work around:
|
||||
*
|
||||
* 1. `snapshot()` already takes a `GameState`, so every number here reaches a MULTIPLAYER client
|
||||
* through the `Frame` it is already being sent — no new server route, no new `Push` field, no
|
||||
* new `Session` method, and no second implementation that can disagree with the first.
|
||||
* 2. It is REPLAY-EXACT. A save is `{ seed, config, history }` replayed through the engine
|
||||
* (`web/game.ts`'s `fromSave`), so a tally folded from the events that replay emits is rebuilt
|
||||
* identically every time — which is what makes Undo and a server restart correct here for free.
|
||||
* 3. The official result freezes a COPY of this at the moment the timetable ran out (`official`
|
||||
* below), and a frozen copy has to be taken from something that already exists.
|
||||
*
|
||||
* Aggregate counts only. Nothing here is seat-secret — no card ids, no hands — which is why
|
||||
* `test/redaction.test.ts` stays green with the whole thing on the Frame.
|
||||
*
|
||||
* NOT SCORING. Nothing in here feeds a rule; it is read by the results screen and by the badge work
|
||||
* that Gitea#16 leaves to a second pass. Adding a counter is always safe.
|
||||
*/
|
||||
export type Tally = {
|
||||
/** §8.3 — a train that ran the length of the Division and left it. */
|
||||
trainsCompleted: number;
|
||||
/**
|
||||
* Of those, how many did some switching between being made up and leaving.
|
||||
*
|
||||
* The join Gitea#16 asks for by name ("a player who completes an entire game where every train
|
||||
* that passed through did some switching on"). Counted as the train completes, against whether
|
||||
* that tray has coupled or dropped anything since it was made up — which is why `switchedSince`
|
||||
* below exists rather than this being derivable afterwards.
|
||||
*/
|
||||
trainsCompletedWithWork: number;
|
||||
/** §10 — trains lost to a collision, and the cars that went with them. */
|
||||
trainsDestroyed: number;
|
||||
carsDestroyed: number;
|
||||
/** §6 — switching volume, both directions. */
|
||||
carsCoupled: number;
|
||||
carsDropped: number;
|
||||
/** §9.1 — the MEN | AT | WORK pipeline: begun, and carried all the way through. */
|
||||
loadsStarted: number;
|
||||
loadsCompleted: number;
|
||||
unloadsBegun: number;
|
||||
unloadsCompleted: number;
|
||||
/** §9.2 — passenger work. */
|
||||
passengersBoarded: number;
|
||||
passengersDetrained: number;
|
||||
/** Colour, and the vocabulary the badge pass will draw on. */
|
||||
flyingSwitches: number;
|
||||
officeUpgrades: number;
|
||||
dispatchBonusesUsed: number;
|
||||
facilitiesUnjammed: number;
|
||||
expediteFaults: number;
|
||||
trainsHeld: number;
|
||||
trainsDiverted: number;
|
||||
secondSections: number;
|
||||
extrasStarted: number;
|
||||
cardsDrawn: number;
|
||||
cardsPlayed: number;
|
||||
cardsDiscarded: number;
|
||||
clearancesRequested: number;
|
||||
/** §8.1 — rulings that let the other train through. A refusal is a ruling too, but not this one. */
|
||||
clearancesAllowed: number;
|
||||
/**
|
||||
* X18 CIRCUS SET-UPS — a train that spent a Stage standing still and was paid for it (§X18).
|
||||
*
|
||||
* NOT "the longest an engine sat on a siding", which is what Gitea#16 asks for and what the
|
||||
* comment on that issue assumed this was. `trainStoodStill` is emitted ONCE IN A GAME PER SUCH
|
||||
* TRAIN — only for a train whose profile has `stopEarnsPoint`, and `advance.ts` sets
|
||||
* `stopPointClaimed` so it can never fire twice. There is no per-Stage "this train did not move"
|
||||
* signal in the engine at all, so a longest-stand streak cannot be folded from the event stream:
|
||||
* it needs an engine-side signal that does not exist yet. Recorded in `TODO.md` for the badge
|
||||
* pass rather than shipped as a statistic that would read "1 Stage" for ever.
|
||||
*/
|
||||
circusStops: { trainNumber: number; where: string }[];
|
||||
/**
|
||||
* Train numbers that have coupled or dropped something since they were made up, for
|
||||
* `trainsCompletedWithWork`. Cleared when the train is made up and when it leaves the Division.
|
||||
*/
|
||||
switchedSince: number[];
|
||||
/** Indexed by PLAYER. Only events that name a player reach these. */
|
||||
byPlayer: PlayerTally[];
|
||||
};
|
||||
|
||||
export type PlayerTally = {
|
||||
loads: number;
|
||||
unloads: number;
|
||||
passengersBoarded: number;
|
||||
passengersDetrained: number;
|
||||
cardsPlayed: number;
|
||||
/** §10 — collisions this player was faulted for, not collisions they were caught in. */
|
||||
collisions: number;
|
||||
/** Revenue gained and Revenue lost, kept apart: the net is already on `players[i].revenue`. */
|
||||
revenueGained: number;
|
||||
revenueLost: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* THE OFFICIAL RESULT, frozen at the moment the timetable ran out (Gitea#11).
|
||||
*
|
||||
* "The winner is based upon the original game length. In a five-day game, even if it's extended to
|
||||
* eight or nine days, the winner and the official answer is the winner at the end of five days"
|
||||
* (Jesse, 2026-08-28). So this is written ONCE, at the first ending, and never overwritten —
|
||||
* including by a §3.4 collision breach during an extended Day, which ends play without touching it.
|
||||
*
|
||||
* `state.outcome` keeps moving: it is always the CURRENT evaluation, which is what the live game
|
||||
* wants. Once `official` exists, everything after it is informational.
|
||||
*/
|
||||
export type FinalReport = {
|
||||
/** The Day the game was scheduled to end on — always `config.days`. */
|
||||
day: number;
|
||||
outcome: Outcome;
|
||||
/** Every player's Revenue at that moment, in player order. */
|
||||
revenues: number[];
|
||||
collisionsTotal: number;
|
||||
/** The Tally as it stood when the timetable ran out. */
|
||||
tally: Tally;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-Stage transient bookkeeping for the acting player. Reset when the actor changes.
|
||||
*
|
||||
@@ -726,6 +908,66 @@ export function freshTurns(players: number, moves: number): Map<PlayerIndex, Tur
|
||||
return turns;
|
||||
}
|
||||
|
||||
/** A Tally with everything at zero — the state every game starts in (Gitea#16). */
|
||||
export function emptyTally(players: number): Tally {
|
||||
return {
|
||||
trainsCompleted: 0,
|
||||
trainsCompletedWithWork: 0,
|
||||
trainsDestroyed: 0,
|
||||
carsDestroyed: 0,
|
||||
carsCoupled: 0,
|
||||
carsDropped: 0,
|
||||
loadsStarted: 0,
|
||||
loadsCompleted: 0,
|
||||
unloadsBegun: 0,
|
||||
unloadsCompleted: 0,
|
||||
passengersBoarded: 0,
|
||||
passengersDetrained: 0,
|
||||
flyingSwitches: 0,
|
||||
officeUpgrades: 0,
|
||||
dispatchBonusesUsed: 0,
|
||||
facilitiesUnjammed: 0,
|
||||
expediteFaults: 0,
|
||||
trainsHeld: 0,
|
||||
trainsDiverted: 0,
|
||||
secondSections: 0,
|
||||
extrasStarted: 0,
|
||||
cardsDrawn: 0,
|
||||
cardsPlayed: 0,
|
||||
cardsDiscarded: 0,
|
||||
clearancesRequested: 0,
|
||||
clearancesAllowed: 0,
|
||||
circusStops: [],
|
||||
switchedSince: [],
|
||||
byPlayer: Array.from({ length: players }, () => ({
|
||||
loads: 0,
|
||||
unloads: 0,
|
||||
passengersBoarded: 0,
|
||||
passengersDetrained: 0,
|
||||
cardsPlayed: 0,
|
||||
collisions: 0,
|
||||
revenueGained: 0,
|
||||
revenueLost: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A deep copy, for freezing the official result (`FinalReport`).
|
||||
*
|
||||
* Written out rather than reached for via `structuredClone` because a Tally is a flat bag of numbers
|
||||
* with two containers in it, and spelling the copy out means a field added later that needs deep
|
||||
* copying is a compile error here rather than a shared reference discovered in a results screen.
|
||||
*/
|
||||
export function cloneTally(t: Tally): Tally {
|
||||
return {
|
||||
...t,
|
||||
circusStops: t.circusStops.map((c) => ({ ...c })),
|
||||
switchedSince: [...t.switchedSince],
|
||||
byPlayer: t.byPlayer.map((p) => ({ ...p })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH WAY TO DRAW THE ENGINE — east or west, for every train, everywhere.
|
||||
*
|
||||
@@ -866,8 +1108,34 @@ export type GameState = {
|
||||
collisionsToday: number;
|
||||
/** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */
|
||||
collisionsTotal: number;
|
||||
status: 'setup' | 'active' | 'finished';
|
||||
/**
|
||||
* `awaitingExtension` is Gitea#11: the timetable has run out, the result is recorded, and the
|
||||
* table is being asked whether to play one more Day. It is a PAUSE, not an ending — `advance`
|
||||
* reports `needsInput` there, the server resumes it like any live game, and the only intent the
|
||||
* rules will accept is `game.extend`.
|
||||
*/
|
||||
status: 'setup' | 'active' | 'awaitingExtension' | 'finished';
|
||||
/** The CURRENT evaluation, re-decided at the end of every Day including extended ones. */
|
||||
outcome: Outcome | null;
|
||||
/**
|
||||
* §3.3 (Gitea#11) — Days granted beyond `config.days`, one vote at a time.
|
||||
*
|
||||
* `config.days` is deliberately never touched: it is what the official result was decided at, so
|
||||
* leaving it alone is what makes "the winner is decided at the original game length" a fact about
|
||||
* the code rather than a comment on it.
|
||||
*/
|
||||
extraDays: number;
|
||||
/**
|
||||
* Per PLAYER, while `awaitingExtension`. `null` means they have not voted yet.
|
||||
*
|
||||
* Unanimous, and one refusal is decisive: nobody is made to wait on a player who has already said
|
||||
* no (Jesse's call, 2026-08-28). Solitaire is the same code with one voter.
|
||||
*/
|
||||
extensionVotes: (boolean | null)[];
|
||||
/** Frozen at the FIRST ending and never overwritten. See `FinalReport`. */
|
||||
official: FinalReport | null;
|
||||
/** Gitea#16. Folded from the event stream; see `Tally`. */
|
||||
tally: Tally;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -912,6 +1180,40 @@ export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** This seat's node on the Division — where its Limits, and any Red Flag on them, live. */
|
||||
export function officeNodeFor(
|
||||
state: GameState,
|
||||
seat: SeatIndex,
|
||||
): Extract<DivisionNode, { kind: 'office' }> | null {
|
||||
for (const n of state.division.nodes) if (n.kind === 'office' && n.seat === seat) return n;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHO MUST ANSWER the interruption, or null when nothing is pending.
|
||||
*
|
||||
* The one place that knows which player each kind of question goes to. §8.1's clearance is the
|
||||
* Superintendent's ruling wherever it happens; the Yard Office is offered to whoever sits in the
|
||||
* district the train is arriving at, because it is their card and their yard.
|
||||
*/
|
||||
export function decisionActor(state: GameState): PlayerIndex | null {
|
||||
const d = state.clock.pendingDecision;
|
||||
if (!d) return null;
|
||||
return d.kind === 'clearance' ? state.clock.superintendent : playerAtSeat(state, d.seat);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHOSE MOVE IT IS RIGHT NOW — a pending interruption's owner if there is one, else the phase's
|
||||
* own actor.
|
||||
*
|
||||
* Written out six times across the engine, the sim, the web client and the tests as
|
||||
* `pendingDecision !== null ? superintendent : currentActor`, which stopped being right the moment
|
||||
* a second kind of question existed. One copy now, so a new decision kind cannot be half-adopted.
|
||||
*/
|
||||
export function actingPlayer(state: GameState): PlayerIndex | null {
|
||||
return decisionActor(state) ?? state.clock.currentActor;
|
||||
}
|
||||
|
||||
/** Where this player is sitting, and therefore which Office Area is theirs. */
|
||||
/**
|
||||
* The player `n` seats to the LEFT of this one, wrapping round the table.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The event tally — Gitea#16's statistics, folded from the event stream into `GameState.tally`.
|
||||
*
|
||||
* WHERE IT IS HOOKED, and why it is not in `reduce`. `apply.ts`'s `reduce` sees only the events an
|
||||
* INTENT produced; `advance.ts` mutates state directly and pushes its events without reducing them
|
||||
* at all — and `advance` is where `trainCompleted`, `trainsDestroyed` and `trainStoodStill` come
|
||||
* from, which are exactly the numbers this issue asks for. So the fold is hooked at the two places
|
||||
* every event in the game passes through exactly once on its way to a caller:
|
||||
*
|
||||
* - `applyIntent` (`apply.ts`), beside its `reduce` loop;
|
||||
* - `advance` (`advance.ts`), which now wraps the phase driver and folds what it returns.
|
||||
*
|
||||
* Exactly once matters in both directions: an event folded twice inflates a count, and an event
|
||||
* folded nowhere is a statistic that silently reads zero. `test/tally.test.ts` pins both by playing
|
||||
* real games and checking the tally against an independent count over the same event array.
|
||||
*
|
||||
* NOTHING HERE IS A RULE. The tally is read by the results screen and by the badge work Gitea#16
|
||||
* leaves to a second pass; no engine decision consults it. That is what makes adding a counter
|
||||
* always safe.
|
||||
*
|
||||
* WHAT IS NOT COUNTED PER PLAYER, and why. `carsCoupled` and `carsDropped` carry a `trayId` and no
|
||||
* `player` — switching is done BY a crew, and the event says which crew rather than which person.
|
||||
* Rather than guess an owner from whose turn it happened to be, those two are table totals only.
|
||||
* The events that do name a player (`loadCompleted`, `passengersBoarded`, `cardPlayed`,
|
||||
* `revenueChanged`, `trainsDestroyed`) are the ones `byPlayer` reports.
|
||||
*/
|
||||
|
||||
import type { GameEvent } from './events.ts';
|
||||
import type { GameState } from './state.ts';
|
||||
|
||||
/**
|
||||
* Fold one event into `s.tally`.
|
||||
*
|
||||
* The switch is deliberately not exhaustive — most of the 49 event types say nothing a player would
|
||||
* want counted, and listing them all to `break` would bury the ones that do. A `default` that does
|
||||
* nothing is the honest shape.
|
||||
*/
|
||||
export function tallyEvent(s: GameState, e: GameEvent): void {
|
||||
const t = s.tally;
|
||||
const mine = 'player' in e && typeof e.player === 'number' ? t.byPlayer[e.player] : undefined;
|
||||
|
||||
switch (e.type) {
|
||||
/**
|
||||
* §X18 — the Circus train set up and was paid for the Stage it spent standing.
|
||||
*
|
||||
* NOT a "longest stand" streak, which is what Gitea#16 wants and what its comment assumed this
|
||||
* event was. It fires once in a game per such train: only trains whose profile sets
|
||||
* `stopEarnsPoint` emit it at all, and `advance.ts` claims it once with `stopPointClaimed`. So
|
||||
* there is nothing to count a run of, and the honest thing to report is the event itself.
|
||||
*/
|
||||
case 'trainStoodStill':
|
||||
t.circusStops.push({ trainNumber: e.trainNumber, where: e.where });
|
||||
break;
|
||||
|
||||
/**
|
||||
* DID THIS TRAIN DO ANY SWITCHING — Gitea#16's "switching master" join, kept as it happens
|
||||
* rather than reconstructed afterwards.
|
||||
*
|
||||
* The two halves of the join are in different currencies: switching events name a `trayId` and
|
||||
* completion names a `trainNumber`, and no event carries both. The tray is looked up in LIVE
|
||||
* state, which is sound precisely here — a crew that has just coupled or dropped is still on the
|
||||
* board — where re-deriving it at completion time would not be, the tray having been released by
|
||||
* then. A lookup that misses costs one train its mark on a statistic; it cannot affect a rule.
|
||||
*/
|
||||
case 'carsCoupled':
|
||||
t.carsCoupled += e.stock.length;
|
||||
markSwitched(s, e.trayId);
|
||||
break;
|
||||
|
||||
case 'carsDropped':
|
||||
t.carsDropped += e.stock.length;
|
||||
markSwitched(s, e.trayId);
|
||||
break;
|
||||
|
||||
case 'flyingSwitch':
|
||||
t.flyingSwitches += 1;
|
||||
markSwitched(s, e.trayId);
|
||||
break;
|
||||
|
||||
// A tray is reused run after run, so a fresh train starts with a clean sheet.
|
||||
case 'trainMadeUp':
|
||||
t.switchedSince = t.switchedSince.filter((n) => n !== e.trainNumber);
|
||||
break;
|
||||
|
||||
case 'trainCompleted':
|
||||
t.trainsCompleted += 1;
|
||||
if (t.switchedSince.includes(e.trainNumber)) t.trainsCompletedWithWork += 1;
|
||||
t.switchedSince = t.switchedSince.filter((n) => n !== e.trainNumber);
|
||||
break;
|
||||
|
||||
case 'trainsDestroyed':
|
||||
t.trainsDestroyed += e.trains.length;
|
||||
for (const train of e.trains) t.carsDestroyed += train.consist.length;
|
||||
if (mine) mine.collisions += 1;
|
||||
break;
|
||||
|
||||
case 'officeUpgraded':
|
||||
t.officeUpgrades += 1;
|
||||
break;
|
||||
|
||||
case 'dispatchBonusUsed':
|
||||
t.dispatchBonusesUsed += 1;
|
||||
break;
|
||||
|
||||
case 'facilityUnjammed':
|
||||
t.facilitiesUnjammed += 1;
|
||||
break;
|
||||
|
||||
case 'expediteFault':
|
||||
t.expediteFaults += 1;
|
||||
break;
|
||||
|
||||
case 'trainHeld':
|
||||
t.trainsHeld += 1;
|
||||
break;
|
||||
|
||||
case 'trainDiverted':
|
||||
t.trainsDiverted += 1;
|
||||
break;
|
||||
|
||||
case 'secondSectionOrdered':
|
||||
t.secondSections += 1;
|
||||
break;
|
||||
|
||||
case 'extraStarted':
|
||||
t.extrasStarted += 1;
|
||||
break;
|
||||
|
||||
case 'cardDrawn':
|
||||
t.cardsDrawn += 1;
|
||||
break;
|
||||
|
||||
case 'cardPlayed':
|
||||
t.cardsPlayed += 1;
|
||||
if (mine) mine.cardsPlayed += 1;
|
||||
break;
|
||||
|
||||
case 'cardDiscarded':
|
||||
t.cardsDiscarded += 1;
|
||||
break;
|
||||
|
||||
case 'clearanceRequested':
|
||||
t.clearancesRequested += 1;
|
||||
break;
|
||||
|
||||
// §8.1 — a ruling is given either way; only a YES let the other train through.
|
||||
case 'clearanceGiven':
|
||||
if (e.allow) t.clearancesAllowed += 1;
|
||||
break;
|
||||
|
||||
case 'loadStarted':
|
||||
t.loadsStarted += 1;
|
||||
break;
|
||||
|
||||
case 'loadCompleted':
|
||||
t.loadsCompleted += 1;
|
||||
if (mine) mine.loads += 1;
|
||||
break;
|
||||
|
||||
case 'unloadBegan':
|
||||
t.unloadsBegun += 1;
|
||||
break;
|
||||
|
||||
case 'unloadCompleted':
|
||||
t.unloadsCompleted += 1;
|
||||
if (mine) mine.unloads += 1;
|
||||
break;
|
||||
|
||||
case 'passengersBoarded':
|
||||
t.passengersBoarded += 1;
|
||||
if (mine) mine.passengersBoarded += 1;
|
||||
break;
|
||||
|
||||
case 'passengersDetrained':
|
||||
t.passengersDetrained += 1;
|
||||
if (mine) mine.passengersDetrained += 1;
|
||||
break;
|
||||
|
||||
/**
|
||||
* Gained and lost are kept APART because the net is already on `players[i].revenue`. What the
|
||||
* results screen cannot otherwise say is how much of a modest final score was earned and then
|
||||
* handed back at a grade crossing — which is the whole difference between a quiet game and an
|
||||
* eventful one.
|
||||
*/
|
||||
case 'revenueChanged':
|
||||
if (mine) {
|
||||
if (e.delta >= 0) mine.revenueGained += e.delta;
|
||||
else mine.revenueLost += -e.delta;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function markSwitched(s: GameState, trayId: string): void {
|
||||
const n = s.trays.get(trayId)?.trainNumber;
|
||||
if (n === undefined || n === null) return;
|
||||
if (!s.tally.switchedSince.includes(n)) s.tally.switchedSince.push(n);
|
||||
}
|
||||
+30
-4
@@ -101,7 +101,13 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
async function serveStatic(distDir: string, urlPath: string, res: ServerResponse): Promise<void> {
|
||||
async function serveStatic(
|
||||
distDir: string,
|
||||
urlPath: string,
|
||||
res: ServerResponse,
|
||||
/** The request's `?v=` build tag, when it has one — see the `Cache-Control` note below. */
|
||||
buildTagged = false,
|
||||
): Promise<void> {
|
||||
const rel = urlPath === '/' ? '/index.html' : urlPath;
|
||||
// `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request
|
||||
// for `/../../etc/passwd` must not escape the one directory this is allowed to read from.
|
||||
@@ -113,7 +119,27 @@ async function serveStatic(distDir: string, urlPath: string, res: ServerResponse
|
||||
try {
|
||||
const info = await stat(full);
|
||||
if (!info.isFile()) throw new Error('not a file');
|
||||
res.writeHead(200, { 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', 'Content-Length': info.size });
|
||||
/**
|
||||
* ONLY A URL CARRYING A BUILD TAG MAY BE CACHED, AND NOTHING ELSE MAY BE.
|
||||
*
|
||||
* Nothing here sent a `Cache-Control` at all before, so a browser applied its own heuristic to
|
||||
* the pages as much as the modules. The pages are the one thing that CANNOT be versioned in
|
||||
* their own URL — a player types the address or follows a bookmark — so a cached `play.html`
|
||||
* pins that player to the entire build it names, including every `?v=` tag inside it. That is
|
||||
* half of why v0.7.5 and v0.7.6 did not reach the browser that asked for them; `build-web.ts`
|
||||
* publishing `?v=nogit` on every packaged release was the other half, and neither is enough on
|
||||
* its own.
|
||||
*
|
||||
* `?v=` is the exact condition rather than "not HTML": `build-web.ts` tags the modules and the
|
||||
* script tags that load them, and tags NOTHING else. An untagged URL — an image, the replay
|
||||
* manifest — has no way to announce a change, so a year of `immutable` on one would outlive
|
||||
* several releases of whatever it holds.
|
||||
*/
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[extname(full)] ?? 'application/octet-stream',
|
||||
'Content-Length': info.size,
|
||||
'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache',
|
||||
});
|
||||
createReadStream(full).pipe(res);
|
||||
} catch {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
@@ -281,7 +307,7 @@ export function startServer(opts: ServerOptions): void {
|
||||
// Unset means the routes are not here — indistinguishable from any other unknown path, so
|
||||
// nothing advertises an administrative surface to someone probing for one.
|
||||
if (!opts.adminSecret) {
|
||||
await serveStatic(opts.distDir, url.pathname, res);
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
return;
|
||||
}
|
||||
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
|
||||
@@ -700,7 +726,7 @@ export function startServer(opts: ServerOptions): void {
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(opts.distDir, url.pathname, res);
|
||||
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||
})().catch((err: unknown) => {
|
||||
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
||||
});
|
||||
|
||||
+67
-6
@@ -22,7 +22,7 @@ import { check } from '../engine/apply.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
|
||||
import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, submit } from '../web/game.ts';
|
||||
import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultiplayerGame, submit } from '../web/game.ts';
|
||||
import type { Game, Menu } from '../web/game.ts';
|
||||
import { deltaFrame } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
@@ -262,6 +262,52 @@ function buildSession(
|
||||
* in zero wall-clock time by definition. Called once at construction (a resume could land exactly
|
||||
* on a bot's turn) and once after every accepted human intent.
|
||||
*/
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the bots' half of a unanimous vote.
|
||||
*
|
||||
* "Bots will not disagree with the human. Humans get to vote first. If all humans vote yes, then
|
||||
* bots vote yes too. If a human votes no, it's not unanimous, it ends right then. If only bots are
|
||||
* playing, they never vote to extend" (Jesse, 2026-08-28).
|
||||
*
|
||||
* Which makes a bot's vote a formality rather than a policy decision, performed once the humans
|
||||
* have already settled it, so that the unanimity the engine checks is a real unanimity rather than
|
||||
* a special case carved into the rules for absent players.
|
||||
*
|
||||
* THE ALL-BOT TABLE IS THE CASE TO GET RIGHT, and getting it wrong hung the game. `driveBots`
|
||||
* cannot reach the vote — it loops on `currentActor`, which is null the moment the game stops —
|
||||
* so if this returns early with no humans to follow, nobody votes at all and a bot-only game sits
|
||||
* on the question for ever. It happened: an all-bot session never reached `finished`. With nobody
|
||||
* to follow, the bots' own answer stands, and it is no.
|
||||
*/
|
||||
function driveBotVotes(): void {
|
||||
if (game.state.status !== 'awaitingExtension') return;
|
||||
const humans = [...Array(playerNames.length).keys()].filter((p) => !botSeats.has(p));
|
||||
// A human who has voted `false` has already ended the game, so reaching here with humans still
|
||||
// outstanding means the table is genuinely waiting on a person. Bots wait with it.
|
||||
if (humans.length > 0 && !humans.every((p) => game.state.extensionVotes[p] === true)) return;
|
||||
const agree = humans.length > 0;
|
||||
for (const seat of botSeats) {
|
||||
if (game.state.extensionVotes[seat] === null) {
|
||||
submit(game, { type: 'game.extend', player: seat, agree }, seat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bots play, bots vote, and an agreed extension puts them back to playing — so the two drivers
|
||||
* alternate rather than running once each. Bounded because every pass must consume something: a
|
||||
* turn, or a vote that cannot be cast twice.
|
||||
*/
|
||||
function driveBotTurns(): void {
|
||||
for (let pass = 0; pass < 1_000; pass++) {
|
||||
const before = game.history.length;
|
||||
driveBots();
|
||||
driveBotVotes();
|
||||
if (game.history.length === before) return;
|
||||
}
|
||||
throw new Error('driveBotTurns: probable infinite loop');
|
||||
}
|
||||
|
||||
function driveBots(): void {
|
||||
let guard = 0;
|
||||
for (;;) {
|
||||
@@ -281,7 +327,7 @@ function buildSession(
|
||||
settleTiming();
|
||||
}
|
||||
}
|
||||
driveBots();
|
||||
driveBotTurns();
|
||||
// Whatever the opening bot turns earned belongs to a game nobody was connected to yet — dropped
|
||||
// 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.)
|
||||
@@ -304,7 +350,17 @@ function buildSession(
|
||||
// never applied, so it is worth trying again (see the `lastSeq.set` below: only on success).
|
||||
if (lastSeq.get(seat) === seq) return { accepted: true, pushes: new Map(), timing: null };
|
||||
|
||||
if (seat !== currentActor(game)) return { accepted: false, code: 'NOT_YOUR_TURN' };
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — the vote is the one intent with no actor to be.
|
||||
*
|
||||
* `currentActor` is null once the timetable has run out, so this guard would refuse every
|
||||
* vote with NOT_YOUR_TURN. Every seat may vote, and `check` is still the authority on whether
|
||||
* this particular seat may vote right now (it has voted already; the game is not waiting on a
|
||||
* vote at all), so skipping the turn test here gives nothing away.
|
||||
*/
|
||||
if (!isOutOfTurn(i) && seat !== currentActor(game)) {
|
||||
return { accepted: false, code: 'NOT_YOUR_TURN' };
|
||||
}
|
||||
|
||||
// Checked directly, rather than via `submit`'s boolean, for two reasons: `submit` writes a
|
||||
// "that is not allowed" line into the SHARED `game.log` on rejection, which would otherwise
|
||||
@@ -315,7 +371,7 @@ function buildSession(
|
||||
if (code) return { accepted: false, code };
|
||||
|
||||
const drawnBefore = game.justDrawn;
|
||||
const applied = submit(game, i);
|
||||
const applied = submit(game, i, isOutOfTurn(i) ? seat : null);
|
||||
if (game.justDrawn !== drawnBefore && game.justDrawn !== null) {
|
||||
lastDraw = { seat, cardId: game.justDrawn };
|
||||
}
|
||||
@@ -327,8 +383,9 @@ function buildSession(
|
||||
const timing = settleTiming();
|
||||
// Any bot due to act now plays out entirely before this push goes back — the delta mechanism
|
||||
// diffs against whatever was last sent, so it captures the bots' moves along with the human's
|
||||
// in one push regardless of how many turns that took.
|
||||
driveBots();
|
||||
// in one push regardless of how many turns that took. Votes included, since Gitea#11: a human
|
||||
// agreeing to another Day is exactly the move the bots are waiting on to agree themselves.
|
||||
driveBotTurns();
|
||||
return { accepted: true, pushes: pushesForAll(), timing };
|
||||
},
|
||||
|
||||
@@ -338,6 +395,8 @@ function buildSession(
|
||||
config: game.state.config,
|
||||
playerNames: [...playerNames],
|
||||
history: [...game.history],
|
||||
// `awaitingExtension` is a game waiting on its table, not a game that is over — so it maps
|
||||
// to 'active' and `server/index.ts` resumes it on a restart like any other (Gitea#11).
|
||||
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||
createdAt,
|
||||
botSeats: [...botSeats],
|
||||
@@ -351,6 +410,8 @@ function buildSession(
|
||||
playerCount: playerNames.length,
|
||||
playerNames: [...playerNames],
|
||||
botSeats: [...botSeats],
|
||||
// `awaitingExtension` is a game waiting on its table, not a game that is over — so it maps
|
||||
// to 'active' and `server/index.ts` resumes it on a restart like any other (Gitea#11).
|
||||
status: game.state.status === 'finished' ? 'finished' : 'active',
|
||||
createdAt,
|
||||
lastMoveAt,
|
||||
|
||||
+72
-16
@@ -35,7 +35,7 @@ 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 type { Port } from '../engine/track.ts';
|
||||
import { coordKey, turnOf } from '../engine/state.ts';
|
||||
import { actingPlayer, coordKey, turnOf } from '../engine/state.ts';
|
||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||
|
||||
export type BotPolicy = {
|
||||
@@ -138,13 +138,39 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
||||
|
||||
choose(s, player, options) {
|
||||
lastReason = 'no specific reason — first legal option';
|
||||
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — a bot never asks for another Day.
|
||||
*
|
||||
* "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28), which is what keeps
|
||||
* the balance harness and every bot-only game ending at the timetable it was dealt with. It also
|
||||
* makes this the SAFE DEFAULT everywhere else: a bot's agreement in a game with humans in it is
|
||||
* decided by `server/session.ts`, which votes on the bots' behalf only once every human has
|
||||
* already said yes, and never reaches this policy at all.
|
||||
*/
|
||||
const extend = options.find((i) => i.type === 'game.extend' && i.agree === false);
|
||||
if (extend) return because('a bot plays the timetable it was dealt and no more', extend);
|
||||
|
||||
const clearance = ruleOnClearance(options);
|
||||
if (clearance) return because('the Superintendent must rule on a following train', clearance);
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — the bot keeps its trains at the Train Order Office.
|
||||
*
|
||||
* A deliberate policy, not an oversight, and the cautious half of a real choice: the Yard Office
|
||||
* frees an A/D track, which is worth something on a busy district, but the lead into it may be
|
||||
* fouled and the bot does not read its own yard well enough to tell (`TODO.md`, Bot
|
||||
* Performance — it cannot spot a car at a stub industry either). Declining is always safe, and
|
||||
* it keeps the balance harness comparable with every measurement taken before this rule existed.
|
||||
* Worth revisiting when the bot can judge the lead.
|
||||
*/
|
||||
const yardOffice = options.find((i) => i.type === 'mainline.yardOffice' && i.take === false);
|
||||
if (yardOffice) return because('the bot does not judge the lead into a yard, so it stays at the Office', yardOffice);
|
||||
|
||||
// Red Flags come before anything else — protection is only worth playing at the moment the
|
||||
// collision is actually pending, and that moment passes.
|
||||
const flags = worthFlagging(s, options);
|
||||
if (flags) return because('a train of ours is stopped on a Mainline card with another train on it — Red Flags now or not at all', flags);
|
||||
if (flags) return because('the engine says this arrival collides, and we hold a Red Flag — now or never', flags);
|
||||
|
||||
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
|
||||
//
|
||||
@@ -1017,19 +1043,20 @@ function facilityWantsAt(
|
||||
}
|
||||
|
||||
/**
|
||||
* Red Flags — "any time". Worth spending only when a train of ours is stopped out on the Mainline
|
||||
* with another train on the same card, which is the situation that becomes a rear-ender.
|
||||
* §Q, RED FLAGS (Gitea#19) — spent only at the moment of danger.
|
||||
*
|
||||
* The card was redefined: it plants a directional flag on your own Limits rather than protecting a
|
||||
* stopped train out on the Mainline, so the old heuristic ("is a train of ours sharing a Mainline
|
||||
* card") no longer describes anything the card does.
|
||||
*
|
||||
* The bot now flags ONLY through the out-of-phase prompt, which the engine raises exactly when an
|
||||
* arrival would collide (`redFlagStop`). That is a better policy than the old one and a much
|
||||
* simpler one: the engine has already established the danger, so there is nothing for the bot to
|
||||
* judge. It never plants a flag speculatively — it cannot tell whether it wants time to switch, and
|
||||
* a flag spent early is a flag not there when a train is actually bearing down.
|
||||
*/
|
||||
function worthFlagging(s: GameState, options: Intent[]): Intent | null {
|
||||
for (const i of options) {
|
||||
if (i.type !== 'maneuver.redFlags') continue;
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray || tray.position.at !== 'mainline') continue;
|
||||
const node = s.division.nodes[tray.position.index];
|
||||
if (node?.kind !== 'mainline') continue;
|
||||
if (node.transits.length > 1) return i;
|
||||
}
|
||||
return null;
|
||||
function worthFlagging(_s: GameState, options: Intent[]): Intent | null {
|
||||
return options.find((i) => i.type === 'mainline.redFlag' && i.flag === true) ?? null;
|
||||
}
|
||||
|
||||
/** A one-line account of which Load/Unload action was taken, and why it ranked first. */
|
||||
@@ -1861,8 +1888,37 @@ export function playGame(
|
||||
tally(pumpFn(s));
|
||||
if (s.status === 'finished') break;
|
||||
|
||||
const actor =
|
||||
s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — a simulated game plays the timetable it was dealt.
|
||||
*
|
||||
* DECIDED BY THE DRIVER, not by the policy, and that distinction is the whole point. A bot that
|
||||
* is merely handed the two votes among its legal options will sometimes take another Day —
|
||||
* `randomBot` does so half the time — and since the table can go on granting Days for ever, the
|
||||
* game then runs until `maxTurns`. That is not a hypothetical: it turned `test/sim.test.ts` from
|
||||
* under a second into an unbounded hang, because every seeded game in the harness suddenly played
|
||||
* fifty thousand turns instead of two hundred.
|
||||
*
|
||||
* The harness exists to measure games of a configured length against a configured floor, so
|
||||
* "would you like more Days?" has one answer here whatever the policy. `developerBot` declines on
|
||||
* its own account too, which is what the server relies on when a table is all bots; this is the
|
||||
* guarantee that holds for every OTHER policy, including ones not written yet.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') {
|
||||
const voter = s.extensionVotes.findIndex((v) => v === null);
|
||||
if (voter < 0) break;
|
||||
const decline: Intent = { type: 'game.extend', player: voter, agree: false };
|
||||
intents.push(decline.type);
|
||||
history.push(decline);
|
||||
const declined = applyIntent(s, voter, decline);
|
||||
// A broken invariant, not a game ending early: the status says a vote is pending and `voter` is
|
||||
// a seat that has not cast one. Thrown rather than broken out of, matching the illegal-action
|
||||
// check below — silently returning a short game is how a dead replay looks like a real one.
|
||||
if (!declined.ok) throw new Error(`the extension vote was refused with ${declined.code}`);
|
||||
tally(declined.events);
|
||||
continue;
|
||||
}
|
||||
|
||||
const actor = actingPlayer(s);
|
||||
if (actor === null) break;
|
||||
|
||||
const options = legalActions(s, actor);
|
||||
|
||||
+26
-1
@@ -218,10 +218,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
|
||||
: `Played ${e.key} on Mainline card ${e.node}`,
|
||||
};
|
||||
case 'redFlagSpent':
|
||||
return {
|
||||
tone: 'good',
|
||||
text: `RED FLAG — Train ${e.trainNumber} stopped short of the ${e.side === 'east' ? 'Eastern' : 'Western'} Limits. The flag comes down with it.`,
|
||||
};
|
||||
case 'redFlagRuled':
|
||||
return e.flag
|
||||
? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` }
|
||||
: { tone: 'plain', text: `Player ${e.player} waved the train through` };
|
||||
case 'redFlagsSet':
|
||||
return {
|
||||
tone: 'good',
|
||||
text: `Red Flags set out to protect train ${e.trayId} on Mainline card ${e.node} — an approaching train must stop`,
|
||||
text: `RED FLAGS set out on the ${e.side === 'east' ? 'Eastern' : 'Western'} Limits — the next train from that way is held short`,
|
||||
};
|
||||
case 'flyingSwitch':
|
||||
return {
|
||||
@@ -488,6 +497,22 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
|
||||
case 'phaseEnded':
|
||||
return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
|
||||
|
||||
// -- §3.3, extended play (Gitea#11)
|
||||
case 'extensionVoted':
|
||||
return e.agree
|
||||
? { tone: 'plain', text: `Player ${e.player} would play one more Day` }
|
||||
: { tone: 'plain', text: `Player ${e.player} called time — the game ends here` };
|
||||
case 'dayExtended':
|
||||
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
|
||||
case 'playConcluded':
|
||||
return { tone: 'clock', text: '── The railroad is put to bed. Final results stand. ──' };
|
||||
|
||||
// -- §11, the Yard Office (Gitea#5)
|
||||
case 'yardOfficeRuled':
|
||||
return e.take
|
||||
? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` }
|
||||
: { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -34,6 +34,7 @@ import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
||||
import { actingPlayer } from '../engine/state.ts';
|
||||
import { developerBot, lastChoiceReason } from './bot.ts';
|
||||
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
|
||||
// The view-model lives in its own module so the browser build can import it without dragging in
|
||||
@@ -135,7 +136,7 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
||||
if (s.status === 'finished') break;
|
||||
if (!r.needsInput) continue;
|
||||
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
const actor = actingPlayer(s);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) break;
|
||||
|
||||
@@ -52,6 +52,21 @@ export type PlayedGame = {
|
||||
export function playForReplay(seed: number, policy: BotPolicy, maxTurns = 50_000): PlayedGame {
|
||||
const game = newGame(seed);
|
||||
for (let t = 0; t < maxTurns; t++) {
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — a recorded replay is a game played to its end.
|
||||
*
|
||||
* The timetable running out leaves the game on "play one more Day?", where `currentActor` is
|
||||
* null and this loop would otherwise stop — recording a file that replays to a question nobody
|
||||
* answered rather than to a finished game. A recording bot plays the timetable it was dealt, the
|
||||
* same rule `playGame` follows, so it declines and the file ends where a real game would.
|
||||
*/
|
||||
if (game.state.status === 'awaitingExtension') {
|
||||
const voter = game.state.extensionVotes.findIndex((v) => v === null);
|
||||
if (voter < 0) break;
|
||||
if (!submit(game, { type: 'game.extend', player: voter, agree: false }, voter)) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
|
||||
+85
-5
@@ -398,6 +398,24 @@ export type Frame = {
|
||||
collisionsTotal: number;
|
||||
status: GameState['status'];
|
||||
outcome: GameState['outcome'];
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11). `days` above stays the ORIGINAL timetable — it is what the
|
||||
* official result was decided at — so the Day the game now runs to is `days + extraDays`.
|
||||
*/
|
||||
extraDays: number;
|
||||
/** Per PLAYER, while `status` is `awaitingExtension`. `null` is a seat that has not voted. */
|
||||
extensionVotes: (boolean | null)[];
|
||||
/** The official result, frozen when the original timetable ran out. Null until then. */
|
||||
official: GameState['official'];
|
||||
/**
|
||||
* Gitea#16 — everything interesting that has happened, folded from the event stream.
|
||||
*
|
||||
* Aggregate counts only, which is why it can ride the Frame at all: `test/redaction.test.ts`
|
||||
* proves a Frame carries no other seat's secrets, and a count of trains is nobody's secret. Being
|
||||
* here rather than on a side channel is what gets the results screen the same numbers in
|
||||
* multiplayer as in solitaire, from one implementation.
|
||||
*/
|
||||
tally: GameState['tally'];
|
||||
/**
|
||||
* Every PLAYER's public standing — names and Revenue. "The race is the game" (protocol.md §4).
|
||||
*
|
||||
@@ -1025,15 +1043,41 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`;
|
||||
}
|
||||
case 'maneuver.redFlags':
|
||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
||||
return (
|
||||
`FLAG ${i.side === 'east' ? 'EAST' : 'WEST'} — hold the next ${i.side === 'east' ? 'westbound' : 'eastbound'} ` +
|
||||
'train short of your Limits, so you can finish switching'
|
||||
);
|
||||
// §Q, the out-of-phase play (Gitea#19) — "COLLISION RISK! FLAG AGAINST T2?"
|
||||
case 'mainline.redFlag':
|
||||
return i.flag
|
||||
? 'FLAG IT — stop the train short of your Limits, spending a Red Flags card'
|
||||
: 'wave it through — let it come in';
|
||||
/**
|
||||
* §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to
|
||||
* carry the whole question — there is no surrounding context on screen to lean on, and the
|
||||
* player is being asked about a train they were not otherwise thinking about.
|
||||
*/
|
||||
case 'mainline.yardOffice':
|
||||
return i.take
|
||||
? 'take the YARD OFFICE — straight into the yard, leaving the Train Order Office free'
|
||||
: 'keep it at the Train Order Office — the ordinary arrival, onto an A/D track';
|
||||
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
|
||||
// options through this list like any other, and the label is what the history says it chose.
|
||||
case 'game.extend':
|
||||
return i.agree
|
||||
? 'play one more Day — the result already recorded still stands'
|
||||
: 'end the game here';
|
||||
case 'maneuver.flyingSwitch':
|
||||
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
|
||||
case 'mainline.clearance': {
|
||||
// The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint
|
||||
// that granting it risks a rear-ender, or that refusing merely costs time.
|
||||
// Narrowed to the clearance question: `pendingDecision` is a union since Gitea#5, and only
|
||||
// this member names a train ahead.
|
||||
const pending = s.clock.pendingDecision;
|
||||
const who = pending ? trainName(s, pending.train) : 'the train';
|
||||
const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead';
|
||||
const clearance = pending?.kind === 'clearance' ? pending : null;
|
||||
const who = clearance ? trainName(s, clearance.train) : 'the train';
|
||||
const ahead = clearance ? trainName(s, clearance.occupiedBy) : 'the train ahead';
|
||||
// NOT "risks a collision, −5". A rear-end on a Mainline card is described by §10 and is what
|
||||
// ABS Signals exists to prevent, but no such collision is implemented — granting clearance is
|
||||
// currently free. Saying otherwise invents a consequence the engine will never deliver.
|
||||
@@ -1073,7 +1117,30 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
case 'redFlag.play':
|
||||
return 'play your red flag';
|
||||
case 'maneuver.redFlags':
|
||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
||||
return (
|
||||
`FLAG ${i.side === 'east' ? 'EAST' : 'WEST'} — hold the next ${i.side === 'east' ? 'westbound' : 'eastbound'} ` +
|
||||
'train short of your Limits, so you can finish switching'
|
||||
);
|
||||
// §Q, the out-of-phase play (Gitea#19) — "COLLISION RISK! FLAG AGAINST T2?"
|
||||
case 'mainline.redFlag':
|
||||
return i.flag
|
||||
? 'FLAG IT — stop the train short of your Limits, spending a Red Flags card'
|
||||
: 'wave it through — let it come in';
|
||||
/**
|
||||
* §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to
|
||||
* carry the whole question — there is no surrounding context on screen to lean on, and the
|
||||
* player is being asked about a train they were not otherwise thinking about.
|
||||
*/
|
||||
case 'mainline.yardOffice':
|
||||
return i.take
|
||||
? 'take the YARD OFFICE — straight into the yard, leaving the Train Order Office free'
|
||||
: 'keep it at the Train Order Office — the ordinary arrival, onto an A/D track';
|
||||
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
|
||||
// options through this list like any other, and the label is what the history says it chose.
|
||||
case 'game.extend':
|
||||
return i.agree
|
||||
? 'play one more Day — the result already recorded still stands'
|
||||
: 'end the game here';
|
||||
default: {
|
||||
// Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes
|
||||
// that a COMPILE error the day someone adds an intent without describing it — the playable UI
|
||||
@@ -1335,6 +1402,10 @@ export function snapshot(
|
||||
collisionsTotal: s.collisionsTotal,
|
||||
status: s.status,
|
||||
outcome: s.outcome,
|
||||
extraDays: s.extraDays,
|
||||
extensionVotes: [...s.extensionVotes],
|
||||
official: s.official,
|
||||
tally: s.tally,
|
||||
players: s.players.map((p) => ({
|
||||
index: p.index,
|
||||
seat: seatOf(s, p.index),
|
||||
@@ -1779,7 +1850,16 @@ const SIMPLE_CARDS = [
|
||||
* view of its own. `0` means no floor is configured — nothing to pace against.
|
||||
*/
|
||||
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
|
||||
const { days, minCombinedRevenue: target } = s.config;
|
||||
const { minCombinedRevenue: target } = s.config;
|
||||
/**
|
||||
* PACED AGAINST THE TIMETABLE ACTUALLY BEING PLAYED, extensions included (Gitea#11).
|
||||
*
|
||||
* `config.days` alone would say "the last Day is over" through every extended Day, and pace an
|
||||
* eight-Day game against five — both of which the status line used to do the moment play carried
|
||||
* on past the end. The official result is still decided at `config.days`; that is `checkVictory`'s
|
||||
* business, and nothing here feeds it.
|
||||
*/
|
||||
const days = s.config.days + s.extraDays;
|
||||
const revenue = s.players[viewer]?.revenue ?? 0;
|
||||
const daysLeft = Math.max(0, days - s.clock.day + 1);
|
||||
const elapsed = days - daysLeft + 1;
|
||||
|
||||
+57
-9
@@ -30,6 +30,7 @@ import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||
import { actingPlayer } from '../engine/state.ts';
|
||||
import { playerAtSeat } from '../engine/state.ts';
|
||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||
@@ -266,6 +267,7 @@ export type Game = {
|
||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||
const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
|
||||
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
|
||||
{ prefix: 'mainline.yardOffice', title: 'Where does this train arrive?' },
|
||||
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
|
||||
{ prefix: 'switch.', title: 'Switching' },
|
||||
// Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the
|
||||
@@ -334,9 +336,9 @@ export function drain(game: Game): void {
|
||||
/** Whose turn it is, or null if the game is over or waiting on nothing. */
|
||||
export function currentActor(game: Game): PlayerIndex | null {
|
||||
if (game.state.status !== 'active') return null;
|
||||
return game.state.clock.pendingDecision !== null
|
||||
? game.state.clock.superintendent
|
||||
: game.state.clock.currentActor;
|
||||
// `actingPlayer` (state.ts) knows which player each kind of interruption goes to — the
|
||||
// Superintendent for a §8.1 clearance, the district's owner for a Yard Office offer.
|
||||
return actingPlayer(game.state);
|
||||
}
|
||||
|
||||
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
|
||||
@@ -465,13 +467,26 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
*/
|
||||
if (prefix === 'mainline.clearance') {
|
||||
const pending = game.state.clock.pendingDecision;
|
||||
if (pending) {
|
||||
if (pending?.kind === 'clearance') {
|
||||
headed =
|
||||
`Superintendent — may ${trainName(game.state, pending.train)} follow ` +
|
||||
`${trainName(game.state, pending.occupiedBy)} onto the same Mainline card?`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §11 (Gitea#5) — the Yard Office offer interrupts the Mainline Phase, so it arrives with no
|
||||
* context around it: the player was not thinking about this train a moment ago.
|
||||
*/
|
||||
if (prefix === 'mainline.yardOffice') {
|
||||
const pending = game.state.clock.pendingDecision;
|
||||
if (pending?.kind === 'yardOffice') {
|
||||
headed =
|
||||
`${trainName(game.state, pending.train)} is arriving with no coaches — ` +
|
||||
'take it into the Yard Office, or hold it at the Train Order Office?';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A PENDING EXTRA IS ITS OWN QUESTION, and its own heading.
|
||||
*
|
||||
@@ -1014,9 +1029,42 @@ export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean {
|
||||
return hand.length > limit;
|
||||
}
|
||||
|
||||
/** Submit an action. Returns false and changes nothing if the engine rejects it. */
|
||||
export function submit(game: Game, intent: Intent): boolean {
|
||||
const actor = currentActor(game);
|
||||
/**
|
||||
* Intents any seat may send regardless of whose turn it is — and, for `game.extend`, regardless of
|
||||
* whether the game is still running at all (§3.3, Gitea#11).
|
||||
*
|
||||
* `currentActor` is null once the timetable has run out, which is correct for everything else and
|
||||
* exactly wrong for the vote on playing another Day. Rather than teach `currentActor` about a state
|
||||
* where EVERY seat may act at once — which it has no way to express — callers name the seat.
|
||||
*/
|
||||
export function isOutOfTurn(intent: Intent): intent is Extract<Intent, { type: 'game.extend' }> {
|
||||
return intent.type === 'game.extend';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHO ACTED, when replaying a saved history.
|
||||
*
|
||||
* A save is a flat `Intent[]` with no seat written beside each move, so a replay normally derives
|
||||
* the actor from the turn order — the same order the live game went round in, reproduced exactly.
|
||||
* That breaks for exactly one intent: the extension vote, which every seat may cast in any order,
|
||||
* and which `currentActor` answers `null` for because the game has stopped. Replaying such a save
|
||||
* used to fail outright with `NO_ACTOR`, which is to say an extended game could not be resumed at
|
||||
* all — found by `test/server/session.test.ts`'s resume test, and the reason `game.extend` carries
|
||||
* its voter (`intents.ts`).
|
||||
*/
|
||||
function replayActor(game: Game, intent: Intent): PlayerIndex | null {
|
||||
return isOutOfTurn(intent) ? intent.player : currentActor(game);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an action. Returns false and changes nothing if the engine rejects it.
|
||||
*
|
||||
* `as` names the seat for an out-of-turn intent (see `isOutOfTurn`). It cannot be used to smuggle an
|
||||
* ordinary move past the turn order: `check` is still the authority and still asks `isActor`, so a
|
||||
* named seat that is not the actor is refused exactly as it would have been.
|
||||
*/
|
||||
export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null): boolean {
|
||||
const actor = as ?? currentActor(game);
|
||||
if (actor === null) return false;
|
||||
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
@@ -1167,7 +1215,7 @@ export function undo(game: Game, config: GameConfig = game.state.config): Game |
|
||||
export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const game = newGame(save.seed, configFor(save, config));
|
||||
for (const intent of save.history) {
|
||||
const actor = currentActor(game);
|
||||
const actor = replayActor(game, intent);
|
||||
if (actor === null) break;
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
if (!result.ok) break;
|
||||
@@ -1218,7 +1266,7 @@ export function fromMultiplayerSave(
|
||||
): { game: Game; stopped: ReplayStop | null } {
|
||||
const game = newMultiplayerGame(seed, config, playerNames);
|
||||
for (const [index, intent] of history.entries()) {
|
||||
const actor = currentActor(game);
|
||||
const actor = replayActor(game, intent);
|
||||
if (actor === null) {
|
||||
return { game, stopped: { index, intent, code: 'NO_ACTOR' } };
|
||||
}
|
||||
|
||||
+4
-4
@@ -79,13 +79,13 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
<span class="go" id="door-multiplayer-go">Set up a game →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./play.html">
|
||||
<a class="door" href="./play.html?solitaire">
|
||||
<h2>Play solitaire</h2>
|
||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
||||
Your game data is saved in your browser — if you close the tab and reopen this site
|
||||
<p>Play by yourself and run the entire division for five full days. Clear the Revenue floor of
|
||||
15 by the end or the game is a loss. Your game data is saved in your browser — if you close the tab and reopen this site
|
||||
without clearing your cache, your game is preserved and you can continue automatically.
|
||||
During the game you can also explicitly save your progress for later replay.</p>
|
||||
<span class="go">Start a game →</span>
|
||||
<span class="go">Set up a game →</span>
|
||||
</a>
|
||||
|
||||
<a class="door" href="./replays.html">
|
||||
|
||||
+345
-125
@@ -10,7 +10,7 @@ import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import { seatLabel } from '../sim/view.ts';
|
||||
import type { Menu, Save } from './game.ts';
|
||||
import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||
import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import { playCue } from './sound.ts';
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from './presets.ts';
|
||||
import type { GameType, PresetName } from './presets.ts';
|
||||
import { settingsForm } from './settings-form.ts';
|
||||
import type { SettingsForm } from './settings-form.ts';
|
||||
|
||||
const SAVE_KEY = 'station-master.save.v1';
|
||||
const SETTINGS_KEY = 'station-master.settings.v1';
|
||||
@@ -628,12 +629,15 @@ function noteFirstFrame(f: Frame): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** Toggles the two mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4)
|
||||
* and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither
|
||||
* ever flashes before `start()` decides which one this load actually needs. */
|
||||
function showScreen(which: 'lobby' | 'gameui'): void {
|
||||
/** Toggles the three mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4),
|
||||
* `#gameui` (the board, whether local or remote), and `#solitairesetup` (asked before the first
|
||||
* solitaire deal, the same way `#lobby` is asked before the first multiplayer one — Jesse,
|
||||
* 2026-08-29). All three start `hidden` in the markup so none ever flashes before `start()` decides
|
||||
* which one this load actually needs. */
|
||||
function showScreen(which: 'lobby' | 'gameui' | 'solitairesetup'): void {
|
||||
document.getElementById('lobby')!.hidden = which !== 'lobby';
|
||||
document.getElementById('gameui')!.hidden = which !== 'gameui';
|
||||
document.getElementById('solitairesetup')!.hidden = which !== 'solitairesetup';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -714,10 +718,29 @@ function start(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* ASKING FOR SOLITAIRE BEATS RESUMING A MULTIPLAYER SESSION TOO — same reasoning as `?lobby`
|
||||
* above, for the door on the other side. A browser that has ever held a multiplayer seat carries
|
||||
* `remembered` forever (`loadRemote` finds it below), and a bare `./play.html` load could not tell
|
||||
* "I clicked Play solitaire" apart from "I reloaded mid-game" — so the splash's solitaire door
|
||||
* always lost to whatever multiplayer game or lobby this browser last touched, and could never
|
||||
* actually reach solitaire. Found 2026-08-29 verifying v0.7.5 on `phoenix.local`: the door landed
|
||||
* back in a Co-op, four-seat LOBBY from unrelated earlier testing rather than solitaire's own new
|
||||
* setup screen. The door now marks its intent explicitly, the same way `?lobby` already does —
|
||||
* and so does everything else that already means "this is a solitaire navigation": an explicit
|
||||
* `?seed=` (a shared or bookmarked deal) and `?hand=` (the setup screen's own Deal button writes
|
||||
* it on every commit, so landing back here with it set is that navigation, not a bare reload).
|
||||
* Checked here, ahead of `remembered`, rather than only below with `saved` — otherwise Deal would
|
||||
* work once and then bounce the very next load into whatever multiplayer game this browser last
|
||||
* touched, since its URL carries `hand=` but not `solitaire=`.
|
||||
*/
|
||||
const wantsSolitaire =
|
||||
params.get('solitaire') !== null || params.get('seed') !== null || params.has('hand');
|
||||
|
||||
// Entered without checking it still exists — deliberately. Verifying up front would mean an
|
||||
// await before anything renders on the common path, where the game IS still there; instead the
|
||||
// session reports a dead game through `abandonRemote`, which lands in the lobby.
|
||||
const remembered = loadRemote();
|
||||
const remembered = wantsSolitaire ? null : loadRemote();
|
||||
if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) {
|
||||
beginRemote({ ...remembered, seat: remembered.seat });
|
||||
return;
|
||||
@@ -739,16 +762,35 @@ function start(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
showScreen('gameui');
|
||||
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
|
||||
// `configFor`. Read once, here, so the same answer decides both whether to ask before dealing and
|
||||
// (below) whether to restore.
|
||||
const saved = load();
|
||||
const requested = params.get('seed');
|
||||
|
||||
/**
|
||||
* ASK BEFORE THE FIRST DEAL, THE SAME WAY THE LOBBY ASKS BEFORE THE FIRST MULTIPLAYER GAME
|
||||
* (Jesse, 2026-08-29 — "let the user choose their options like the start of a multiplayer game";
|
||||
* "asking first is the only path").
|
||||
*
|
||||
* A saved game or an explicit `seed=` both mean this visit is not "no plan yet" — a saved game is
|
||||
* a game to resume, and a seed names a specific deal someone already chose to share or bookmark,
|
||||
* the same reasoning `?lobby` already uses to skip past the doors on an invite link. `hand` is the
|
||||
* one field every `commitNewGame` write always sets (`rulesToUrl`), so its presence means this
|
||||
* navigation IS the setup screen's own Deal button, landing back here to actually deal — checking
|
||||
* it is what stops the screen asking itself the question a second time.
|
||||
*/
|
||||
if (!saved && requested === null && !params.has('hand')) {
|
||||
showScreen('solitairesetup');
|
||||
runSolitaireSetup(params);
|
||||
return;
|
||||
}
|
||||
|
||||
showScreen('gameui');
|
||||
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
|
||||
session = local;
|
||||
|
||||
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
|
||||
// `configFor`. That is why the restore happens after the session is built rather than feeding it.
|
||||
const saved = load();
|
||||
if (saved && requested === null) local.restore(saved);
|
||||
|
||||
applyCapabilities();
|
||||
@@ -916,7 +958,16 @@ function render(): void {
|
||||
* figure came from a target that is itself an open question.
|
||||
*/
|
||||
const obj = $('objective');
|
||||
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
|
||||
/**
|
||||
* Past the original timetable this has to stop saying "N Days left" of a Day count that no longer
|
||||
* applies (Gitea#11). `objective.daysLeft` already counts against the EXTENDED timetable; what it
|
||||
* cannot say on its own is that the Days being counted are borrowed ones.
|
||||
*/
|
||||
const left = `${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
|
||||
obj.textContent =
|
||||
f.extraDays > 0
|
||||
? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable`
|
||||
: `${f.revenue} of ${f.objective.target} · ${left}`;
|
||||
obj.className = 'pace';
|
||||
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
|
||||
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
|
||||
@@ -1392,6 +1443,114 @@ function wirePointing(node: HTMLElement, key: string, routeKeys: readonly string
|
||||
node.onblur = off;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE END OF THE GAME — the action area once there is nothing left to decide, or only one thing.
|
||||
*
|
||||
* Two states share this, and they are genuinely different (Gitea#11):
|
||||
*
|
||||
* - `awaitingExtension` — the timetable ran out on an ending the table MAY play past. The result
|
||||
* is already recorded and already readable; the only question open is whether to run one more
|
||||
* Day. In multiplayer that is a unanimous vote, so this also has to show who is still to answer.
|
||||
* - `finished` — over for good. The results screen, and a new game.
|
||||
*
|
||||
* The results are reachable in BOTH, and stay reachable after play continues, which is the
|
||||
* constraint Gitea#16 and Gitea#11 put on each other: continuing must not cost you the results
|
||||
* screen, so it is a button that reopens rather than a screen you get one look at.
|
||||
*/
|
||||
function renderEnding(el: HTMLElement, f: Frame): void {
|
||||
const o = f.official?.outcome ?? f.outcome;
|
||||
const won = o?.result === 'win';
|
||||
|
||||
/**
|
||||
* Put the results up once per ending, unasked.
|
||||
*
|
||||
* "Once per ENDING" rather than once per game is the extended-play case: an extended game ends,
|
||||
* is played on, and ends again, and each of those is a moment worth reading. `renderActions`
|
||||
* clears the flag whenever the game is running again, so the next ending gets its own showing —
|
||||
* while a redraw during the same ending does not reopen a dialog the player has dismissed.
|
||||
*/
|
||||
if (!resultsShown) {
|
||||
resultsShown = true;
|
||||
showResults(f);
|
||||
}
|
||||
|
||||
if (f.status === 'awaitingExtension') {
|
||||
const mine = f.extensionVotes[f.viewer];
|
||||
// Only seats that exist are counted; `extensionVotes` is per PLAYER and so is `players`.
|
||||
const waiting = f.players.filter((p) => f.extensionVotes[p.index] === null);
|
||||
const tally = f.players.length > 1
|
||||
? '<div class="vote-tally">' +
|
||||
f.players
|
||||
.map((p) => {
|
||||
const v = f.extensionVotes[p.index];
|
||||
const mark = v === true ? '✓' : v === false ? '✗' : '·';
|
||||
return `<span class="vote ${v === true ? 'yes' : v === false ? 'no' : 'wait'}">` +
|
||||
`${mark} ${esc(p.name)}${p.index === f.viewer ? ' (you)' : ''}</span>`;
|
||||
})
|
||||
.join('') +
|
||||
'</div>'
|
||||
: '';
|
||||
|
||||
el.innerHTML =
|
||||
`<div class="over ${won ? 'win' : 'loss'}">` +
|
||||
`${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'} — Day ${f.official?.day ?? f.days} is scored.<br>` +
|
||||
'The result above is final. Play one more Day?</div>' +
|
||||
tally +
|
||||
(mine === null
|
||||
? '<button id="extend-yes">play one more Day</button>' +
|
||||
'<button id="extend-no">end the game here</button>'
|
||||
: `<div class="dim">You voted ${mine ? 'to play on' : 'to end it'}. ` +
|
||||
(waiting.length
|
||||
? `Waiting on ${waiting.map((p) => esc(p.name)).join(', ')}.`
|
||||
: 'Settling…') +
|
||||
'</div>') +
|
||||
'<button id="results">see the full results</button>';
|
||||
|
||||
if (mine === null) {
|
||||
const vote = (agree: boolean) => () =>
|
||||
void session.submit({ type: 'game.extend', player: f.viewer, agree });
|
||||
$('extend-yes').onclick = vote(true);
|
||||
$('extend-no').onclick = vote(false);
|
||||
}
|
||||
$('results').onclick = () => showResults(f);
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML =
|
||||
`<div class="over ${won ? 'win' : 'loss'}">` +
|
||||
`${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'}<br>` +
|
||||
`final Revenue ${f.revenue}${f.objective.target > 0 ? ` against a target of ${f.objective.target}` : ''}</div>` +
|
||||
'<button id="results">see the full results</button>' +
|
||||
(session.capabilities.newGame ? '<button id="again">new game</button>' : '');
|
||||
|
||||
$('results').onclick = () => showResults(f);
|
||||
if (session.capabilities.newGame) {
|
||||
$('again').onclick = () => {
|
||||
clearSave();
|
||||
location.search = '';
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the results have been put up by themselves for the ending currently on screen.
|
||||
*
|
||||
* Cleared by `renderActions` the moment the game is running again, so an extended game gets a fresh
|
||||
* showing at each of its endings while a redraw during one ending does not reopen a dialog the
|
||||
* player has just dismissed.
|
||||
*/
|
||||
let resultsShown = false;
|
||||
|
||||
function showResults(f: Frame): void {
|
||||
const dlg = document.getElementById('resultsdlg') as HTMLDialogElement | null;
|
||||
const body = document.getElementById('resultsbody');
|
||||
if (!dlg || !body) return;
|
||||
body.innerHTML = resultsHtml(f);
|
||||
// A redraw can arrive while it is open — `showModal` throws on an already-open dialog rather
|
||||
// than doing nothing (the same trap `noteDayEnd` documents).
|
||||
if (!dlg.open) dlg.showModal();
|
||||
}
|
||||
|
||||
function renderActions(
|
||||
menu: Menu,
|
||||
f: Frame,
|
||||
@@ -1400,18 +1559,12 @@ function renderActions(
|
||||
const el = $('actions');
|
||||
|
||||
if (f.status !== 'active') {
|
||||
const o = f.outcome;
|
||||
el.innerHTML =
|
||||
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
|
||||
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
|
||||
`final Revenue ${f.revenue} against a target of ${f.objective.target}</div>` +
|
||||
`<button id="again">new game</button>`;
|
||||
$('again').onclick = () => {
|
||||
clearSave();
|
||||
location.search = '';
|
||||
};
|
||||
renderEnding(el, f);
|
||||
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;
|
||||
|
||||
if (menu.direct.length === 0 && menu.placeable.length === 0) {
|
||||
el.innerHTML = '<div class="dim">nothing to decide — the engine is running the Division</div>';
|
||||
@@ -1784,58 +1937,77 @@ if (leaveBtn) {
|
||||
};
|
||||
}
|
||||
|
||||
const newBtn = document.getElementById('newgame');
|
||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
||||
if (newBtn && dlg) {
|
||||
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||
const ngForm = settingsForm('ng-');
|
||||
/**
|
||||
* ONE GAME-TYPE BLOCK, WIRED — the type radios, the shared rules form beneath them, and the small
|
||||
* glue between them (which type is currently selected, what its note says, how Days feeds the
|
||||
* floor). The in-game "New game" dialog (`ng-`) and the pre-game setup screen (`ss-`, Gitea
|
||||
* "let the user choose their options like the start of a multiplayer game", 2026-08-29) both need
|
||||
* an identical copy of this — factored out once so the two cannot drift apart the way the rules
|
||||
* block itself already had before `settings-form.ts` existed to stop it.
|
||||
*
|
||||
* PREFILLING IS DELIBERATELY LEFT TO THE CALLER. The dialog opens on the game CURRENTLY IN PLAY
|
||||
* (so redealing to compare keeps comparing); the setup screen opens on the plain Solitaire
|
||||
* defaults, because there is no game yet to read. `setBase` plus a direct `form.write(...)` is the
|
||||
* seam that lets each caller do its own version of "what do these fields show at first paint"
|
||||
* without this function having to guess which one it is wiring.
|
||||
*/
|
||||
type WiredGameType = {
|
||||
form: SettingsForm;
|
||||
days(): number;
|
||||
refresh(): void;
|
||||
/** The common case: prefill straight from a named type's own defaults, then repaint. */
|
||||
selectPreset(name: PresetName): void;
|
||||
/** The dialog's case: the caller writes the form itself (from a live game), then calls `refresh`
|
||||
* — this only sets which type that write should be compared against. */
|
||||
setBase(name: PresetName, type: GameType): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* THE SAME FIVE GAME TYPES THE LOBBY OFFERS, and the same shared rules block under them.
|
||||
*
|
||||
* The dialog used to carry its own copy of the questions and its own idea of the defaults, which
|
||||
* is how it ended up with "where an Extra may start" that the lobby did not have and none of the
|
||||
* three optional rules that it did. Both screens now read `presets.ts` and drive their block
|
||||
* through `settings-form.ts`; only Solitaire can actually be DEALT here, so the three multiplayer
|
||||
* types are shown disabled rather than hidden — what this screen offers and what the lobby offers
|
||||
* should read as one list, not two.
|
||||
*/
|
||||
let ngBase: PresetName = 'solitaire';
|
||||
let ngType: GameType = 'solitaire';
|
||||
function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType {
|
||||
const field = <T extends HTMLElement>(id: string): T => document.getElementById(`${prefix}${id}`) as T;
|
||||
const form = settingsForm(prefix);
|
||||
|
||||
let base: PresetName = 'solitaire';
|
||||
let type: GameType = 'solitaire';
|
||||
/** As in the lobby: the floor is derived from the length until the player sets one themselves. */
|
||||
let ngFloorTyped = false;
|
||||
let floorTyped = false;
|
||||
|
||||
const ngDays = (): number => {
|
||||
const raw = Number(field<HTMLInputElement>('ng-days').value);
|
||||
const days = (): number => {
|
||||
const raw = Number(field<HTMLInputElement>('days').value);
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
|
||||
};
|
||||
|
||||
const ngTypeRadios = (): HTMLInputElement[] =>
|
||||
Array.from(dlg.querySelectorAll<HTMLInputElement>('input[name="ng-type"]'));
|
||||
const typeRadios = (): HTMLInputElement[] =>
|
||||
Array.from(root.querySelectorAll<HTMLInputElement>(`input[name="${prefix}type"]`));
|
||||
|
||||
function ngRefresh(): void {
|
||||
const differing = ngForm.mark(ngBase, 1, ngDays());
|
||||
if (differing.length > 0) ngType = 'custom';
|
||||
else if (ngType === 'custom') ngType = ngBase;
|
||||
for (const r of ngTypeRadios()) r.checked = r.value === ngType;
|
||||
const note = field<HTMLElement>('ng-type-note');
|
||||
function refresh(): void {
|
||||
const differing = form.mark(base, 1, days());
|
||||
if (differing.length > 0) type = 'custom';
|
||||
else if (type === 'custom') type = base;
|
||||
for (const r of typeRadios()) r.checked = r.value === type;
|
||||
const note = field<HTMLElement>('type-note');
|
||||
note.textContent =
|
||||
ngType === 'custom'
|
||||
? `${gameTypeLabel('custom', preset(ngBase).scoring)} · ${differing.length} ` +
|
||||
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(ngBase).label}.`
|
||||
: preset(ngType as PresetName).blurb;
|
||||
type === 'custom'
|
||||
? `${gameTypeLabel('custom', preset(base).scoring)} · ${differing.length} ` +
|
||||
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(base).label}.`
|
||||
: preset(type as PresetName).blurb;
|
||||
}
|
||||
|
||||
function ngSelectPreset(name: PresetName): void {
|
||||
ngBase = name;
|
||||
ngType = name;
|
||||
ngFloorTyped = false;
|
||||
const values = presetSettings(name, 1, ngDays());
|
||||
ngForm.write(values, values);
|
||||
ngRefresh();
|
||||
function selectPreset(name: PresetName): void {
|
||||
base = name;
|
||||
type = name;
|
||||
floorTyped = false;
|
||||
const values = presetSettings(name, 1, days());
|
||||
form.write(values, values);
|
||||
refresh();
|
||||
}
|
||||
|
||||
for (const r of ngTypeRadios()) {
|
||||
function setBase(name: PresetName, t: GameType): void {
|
||||
base = name;
|
||||
type = t;
|
||||
floorTyped = false;
|
||||
}
|
||||
|
||||
for (const r of typeRadios()) {
|
||||
// Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and
|
||||
// a table needs a server. The lobby is the door, and the row says so rather than just refusing
|
||||
// the click (Jesse, 2026-08-23 — a disabled radio that looks enabled reads as a broken one).
|
||||
@@ -1853,33 +2025,99 @@ if (newBtn && dlg) {
|
||||
r.onchange = () => {
|
||||
if (!r.checked) return;
|
||||
if (r.value === 'custom') {
|
||||
ngType = 'custom';
|
||||
ngRefresh();
|
||||
type = 'custom';
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
ngSelectPreset(r.value as PresetName);
|
||||
selectPreset(r.value as PresetName);
|
||||
};
|
||||
}
|
||||
|
||||
ngForm.onEdit((key) => {
|
||||
if (key === 'minCombinedRevenue') ngFloorTyped = true;
|
||||
ngType = 'custom';
|
||||
ngRefresh();
|
||||
form.onEdit((key) => {
|
||||
if (key === 'minCombinedRevenue') floorTyped = true;
|
||||
type = 'custom';
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Days is a parameter, not a rule: it re-derives the floor and never makes a game Custom by itself.
|
||||
field<HTMLInputElement>('ng-days').oninput = () => {
|
||||
if (!ngFloorTyped) {
|
||||
const values = ngForm.read();
|
||||
const want = presetSettings(ngBase, 1, ngDays());
|
||||
ngForm.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
|
||||
field<HTMLInputElement>('days').oninput = () => {
|
||||
if (!floorTyped) {
|
||||
const values = form.read();
|
||||
const want = presetSettings(base, 1, days());
|
||||
form.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
|
||||
}
|
||||
ngRefresh();
|
||||
refresh();
|
||||
};
|
||||
|
||||
// "Everyone moves one chair left" has no meaning at a table of one — disabled with the rest of the
|
||||
// block still visible, so the two screens read the same.
|
||||
ngForm.setEmployeeRotationAvailable(false);
|
||||
// block still visible, so every screen that offers it reads the same.
|
||||
form.setEmployeeRotationAvailable(false);
|
||||
|
||||
return { form, days, refresh, selectPreset, setBase };
|
||||
}
|
||||
|
||||
/**
|
||||
* THE COMMIT — reads a wired block's answers and turns them into a URL, the same path `?seed=`
|
||||
* already took: `start()` reads it back out, so there is exactly one place that turns a URL into a
|
||||
* game, whichever screen produced it.
|
||||
*/
|
||||
function commitNewGame(wired: WiredGameType, seedFieldValue: string): void {
|
||||
const asked = seedFieldValue.trim();
|
||||
// A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable both
|
||||
// mean "surprise me", which is what leaving the box alone plainly asks for.
|
||||
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
|
||||
const settings = wired.form.read();
|
||||
const rules = houseRules({
|
||||
houseRules: {
|
||||
startingHand: settings.startingHand,
|
||||
extraStart: settings.extraStart,
|
||||
discardTimetabled: settings.discardTimetabled,
|
||||
revenue: {
|
||||
passengerPerCoach: settings.passengerPerCoach,
|
||||
freightPerLoad: settings.freightPerLoad,
|
||||
trainPerTransit: settings.trainPerTransit,
|
||||
},
|
||||
},
|
||||
});
|
||||
const victory: NewGameOptions = {
|
||||
days: Math.max(1, wired.days()),
|
||||
minCombinedRevenue: settings.minCombinedRevenue,
|
||||
maxCollisionsPerDay: settings.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: settings.maxCollisionsTotal,
|
||||
optionalRules: {
|
||||
reducedVisibility: settings.reducedVisibility,
|
||||
// Never on at a table of one, whatever the box says — the control is disabled for the same
|
||||
// reason, and this is the half that reaches the engine.
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: settings.emergencyToolbox,
|
||||
},
|
||||
};
|
||||
|
||||
clearSave();
|
||||
const next = rulesToUrl(rules, victory, seed);
|
||||
// Assigning the search string the page ALREADY has does nothing at all, which reads as a button
|
||||
// that did not work — and it is the common case: deal a random seed, decide it was a bad deal,
|
||||
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
|
||||
if (next === location.search) location.reload();
|
||||
else location.search = next;
|
||||
}
|
||||
|
||||
const newBtn = document.getElementById('newgame');
|
||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
||||
if (newBtn && dlg) {
|
||||
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||
|
||||
/**
|
||||
* THE SAME FIVE GAME TYPES THE LOBBY OFFERS, and the same shared rules block under them.
|
||||
*
|
||||
* The dialog used to carry its own copy of the questions and its own idea of the defaults, which
|
||||
* is how it ended up with "where an Extra may start" that the lobby did not have and none of the
|
||||
* three optional rules that it did. Every screen now reads `presets.ts` and drives its block
|
||||
* through `settings-form.ts`; only Solitaire can actually be DEALT here, so the three multiplayer
|
||||
* types are shown disabled rather than hidden — what this screen offers and what the lobby offers
|
||||
* should read as one list, not two.
|
||||
*/
|
||||
const ng = wireGameTypeBlock('ng-', dlg);
|
||||
|
||||
/**
|
||||
* ASK FOR ALL OF IT, rather than documenting URL parameters in the title bar.
|
||||
@@ -1908,67 +2146,49 @@ if (newBtn && dlg) {
|
||||
|
||||
field<HTMLInputElement>('ng-seed').value = '';
|
||||
field<HTMLInputElement>('ng-days').value = String(f.days);
|
||||
ngBase = 'solitaire';
|
||||
ngType = 'solitaire';
|
||||
ngFloorTyped = false;
|
||||
ng.setBase('solitaire', 'solitaire');
|
||||
// The rules actually in play, then the comparison decides what to call them.
|
||||
ngForm.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
|
||||
ngForm.setEmployeeRotationAvailable(false);
|
||||
ngRefresh();
|
||||
ng.form.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
|
||||
ng.refresh();
|
||||
dlg.showModal();
|
||||
};
|
||||
|
||||
/**
|
||||
* One handler for every way the dialog can close — the Deal button, the Cancel button, and Esc,
|
||||
* which `<dialog>` answers with an empty `returnValue` and no submit event at all.
|
||||
*
|
||||
* The answers go into the URL and the page navigates, which is the same path `?seed=` already
|
||||
* took: `start()` reads them back, so there is exactly one place that turns a URL into a game.
|
||||
*/
|
||||
dlg.addEventListener('close', () => {
|
||||
if (dlg.returnValue !== 'deal') return;
|
||||
|
||||
const asked = field<HTMLInputElement>('ng-seed').value.trim();
|
||||
// A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable
|
||||
// both mean "surprise me", which is what leaving the box alone plainly asks for.
|
||||
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
|
||||
const settings = ngForm.read();
|
||||
const rules = houseRules({
|
||||
houseRules: {
|
||||
startingHand: settings.startingHand,
|
||||
extraStart: settings.extraStart,
|
||||
discardTimetabled: settings.discardTimetabled,
|
||||
revenue: {
|
||||
passengerPerCoach: settings.passengerPerCoach,
|
||||
freightPerLoad: settings.freightPerLoad,
|
||||
trainPerTransit: settings.trainPerTransit,
|
||||
},
|
||||
},
|
||||
});
|
||||
const victory: NewGameOptions = {
|
||||
days: Math.max(1, ngDays()),
|
||||
minCombinedRevenue: settings.minCombinedRevenue,
|
||||
maxCollisionsPerDay: settings.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: settings.maxCollisionsTotal,
|
||||
optionalRules: {
|
||||
reducedVisibility: settings.reducedVisibility,
|
||||
// Never on at a table of one, whatever the box says — the control is disabled for the same
|
||||
// reason, and this is the half that reaches the engine.
|
||||
employeeRotation: false,
|
||||
emergencyToolbox: settings.emergencyToolbox,
|
||||
},
|
||||
};
|
||||
|
||||
clearSave();
|
||||
const next = rulesToUrl(rules, victory, seed);
|
||||
// Assigning the search string the page ALREADY has does nothing at all, which reads as a button
|
||||
// that did not work — and it is the common case: deal a random seed, decide it was a bad deal,
|
||||
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
|
||||
if (next === location.search) location.reload();
|
||||
else location.search = next;
|
||||
commitNewGame(ng, field<HTMLInputElement>('ng-seed').value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* THE PRE-GAME SETUP SCREEN — asked before the FIRST solitaire deal, the same way `#lobby` is
|
||||
* already asked before the first multiplayer one (Jesse, 2026-08-29: "let the user choose their
|
||||
* options like the start of a multiplayer game"; "asking first is the only path").
|
||||
*
|
||||
* Only reached for a genuinely fresh visit — `start()` is what decides that; by the time this runs,
|
||||
* there is no saved game and no URL already carrying a deal's answers. It opens on the plain
|
||||
* Solitaire defaults, since there is no live game to compare against yet, and reuses the identical
|
||||
* `wireGameTypeBlock`/`commitNewGame` pair the in-game dialog uses — the two are one design, not two.
|
||||
*/
|
||||
function runSolitaireSetup(params: URLSearchParams): void {
|
||||
const screen = document.getElementById('solitairesetup');
|
||||
const dealBtn = document.getElementById('ss-deal');
|
||||
if (!screen || !dealBtn) return;
|
||||
|
||||
const ss = wireGameTypeBlock('ss-', screen);
|
||||
// A `?seed=` with no other rules params still means SOMETHING — a shared or bookmarked link
|
||||
// naming a specific deal — so it is honoured as a prefill rather than discarded because this
|
||||
// visit happened to be routed through the screen that now asks first.
|
||||
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
|
||||
if (seedField) seedField.value = params.get('seed') ?? '';
|
||||
|
||||
ss.selectPreset('solitaire');
|
||||
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
||||
}
|
||||
|
||||
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
|
||||
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
|
||||
const zoomLabel = document.getElementById('zoomlabel');
|
||||
|
||||
+321
-38
@@ -168,52 +168,319 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
|
||||
*/
|
||||
export function dayEndHtml(f: Frame): string {
|
||||
const ended = f.day - 1;
|
||||
const left = f.days - ended;
|
||||
const standings = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map(
|
||||
(p) =>
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}` +
|
||||
`${p.index === f.viewer ? ' <span class="dim">(you)</span>' : ''}</td>` +
|
||||
`<td class="num">${p.revenue}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
// The target is a COMBINED floor in every mode that sets one, so it is reported against the whole
|
||||
// table's Revenue rather than the viewer's — showing one player's score against a four-player
|
||||
// target reads as a hopeless position when the table may be comfortably ahead.
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
const target =
|
||||
f.minCombinedRevenue > 0
|
||||
? `<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>.</p>`
|
||||
: '';
|
||||
|
||||
/**
|
||||
* Only when the game is actually scored on collisions.
|
||||
*
|
||||
* Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the
|
||||
* checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on
|
||||
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||
* screen that this game does not have.
|
||||
*/
|
||||
const scoredOnCollisions =
|
||||
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||
const collisions = scoredOnCollisions
|
||||
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||
: '';
|
||||
const left = f.days + f.extraDays - ended;
|
||||
|
||||
const ahead =
|
||||
left <= 0
|
||||
? '<p>That was the last Day on the timetable.</p>'
|
||||
: `<p><b>Day ${f.day} of ${f.days}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||
: `<p><b>Day ${f.day} of ${f.days + f.extraDays}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h">Day ${ended} has ended</h3>` +
|
||||
ahead +
|
||||
`<table class="dayend-t"><tbody>${standings}</tbody></table>` +
|
||||
target +
|
||||
collisions
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared between the Day-end dialog and the end-of-game results screen.
|
||||
//
|
||||
// Gitea#16 asked for the results screen and Gitea#10's dialog had already assembled most of it. The
|
||||
// three blocks below are the overlap, factored out rather than written twice: the two screens report
|
||||
// the same numbers about the same game, and the one thing they must never do is disagree.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every player in Revenue order, the viewer marked.
|
||||
*
|
||||
* `winner` rings the player the OFFICIAL result named, which is not always the player at the top:
|
||||
* in an extended game the standings keep moving after the result is settled, and showing the leader
|
||||
* without saying who actually won would be the screen contradicting itself.
|
||||
*/
|
||||
function standingsHtml(f: Frame, winner: number | null = null): string {
|
||||
const rows = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map((p) => {
|
||||
const marks =
|
||||
(p.index === f.viewer ? ' <span class="dim">(you)</span>' : '') +
|
||||
(p.index === winner ? ' <span class="wins">— winner</span>' : '');
|
||||
return (
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}${marks}</td>` +
|
||||
`<td class="num">${p.revenue}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<table class="dayend-t"><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The target is a COMBINED floor in every mode that sets one, so it is reported against the whole
|
||||
* table's Revenue rather than the viewer's — showing one player's score against a four-player
|
||||
* target reads as a hopeless position when the table may be comfortably ahead.
|
||||
*/
|
||||
function targetHtml(f: Frame): string {
|
||||
if (f.minCombinedRevenue <= 0) return '';
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
const met = combined >= f.minCombinedRevenue;
|
||||
return (
|
||||
`<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>` +
|
||||
`${met ? ' — cleared.' : ' — short.'}</p>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only when the game is actually scored on collisions.
|
||||
*
|
||||
* Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the
|
||||
* checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on
|
||||
* 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 {
|
||||
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>`
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY THE GAME ENDED, as a sentence (Gitea#16).
|
||||
*
|
||||
* The page used to interpolate `outcome.reason` straight into the DOM, so a player who finished a
|
||||
* game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment
|
||||
* the game has the player's whole attention. Each reason gets a sentence that says what actually
|
||||
* happened, with this game's own numbers in it.
|
||||
*/
|
||||
function reasonSentence(f: Frame, o: NonNullable<Frame['outcome']>, day: number): string {
|
||||
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||
switch (o.reason) {
|
||||
case 'daysElapsed':
|
||||
return `Day ${day} was the last on the timetable, and it ran out.`;
|
||||
case 'revenueFloor':
|
||||
return (
|
||||
`The Division closed short: <b>${combined}</b> Revenue between everyone, against a floor of ` +
|
||||
`<b>${f.minCombinedRevenue}</b>. §3.3 — miss the floor and the whole table loses, whoever ` +
|
||||
`earned the most.`
|
||||
);
|
||||
case 'collisionFloor':
|
||||
return (
|
||||
`Too many collisions — <b>${f.collisionsToday}</b> in one Day and <b>${f.collisionsTotal}</b> ` +
|
||||
`in all, against limits of ${f.maxCollisionsPerDay || '—'} and ${f.maxCollisionsTotal || '—'}. ` +
|
||||
`§3.4 — the railroad was declared unsafe and the game was stopped.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rules this game was actually dealt under — Gitea#16's "what the rules of the game were". */
|
||||
function rulesHtml(f: Frame): string {
|
||||
const mode =
|
||||
f.mode === 'coop' ? 'Co-op — the table scores together' :
|
||||
f.mode === 'competitive' ? 'Competitive — highest Revenue wins' :
|
||||
'Solitaire';
|
||||
const optional = [
|
||||
f.optionalRules.employeeRotation ? 'Employee Rotation' : null,
|
||||
f.optionalRules.reducedVisibility ? 'Reduced Visibility' : null,
|
||||
f.optionalRules.emergencyToolbox ? 'Emergency Toolbox' : null,
|
||||
].filter((x): x is string => x !== null);
|
||||
const r = f.houseRules.revenue;
|
||||
|
||||
const rows: [string, string][] = [
|
||||
['Scoring', mode],
|
||||
['Timetable', f.extraDays > 0
|
||||
? `${f.days} Days, extended by ${f.extraDays} more`
|
||||
: `${f.days} Day${f.days === 1 ? '' : 's'}`],
|
||||
['Revenue floor', f.minCombinedRevenue > 0 ? `${f.minCombinedRevenue} combined` : 'none'],
|
||||
['Collision limits', f.maxCollisionsPerDay > 0 || f.maxCollisionsTotal > 0
|
||||
? `${f.maxCollisionsPerDay || '—'} per Day, ${f.maxCollisionsTotal || '—'} in all`
|
||||
: 'not scored'],
|
||||
['Pay rates', `${r.freightPerLoad} per load, ${r.passengerPerCoach} per coach, ${r.trainPerTransit} per transit`],
|
||||
['Extras start', f.houseRules.extraStart === 'divisionPointsOnly' ? 'Division Points and the Interchange'
|
||||
: f.houseRules.extraStart === 'ownOffice' ? 'those, plus your own Control Point'
|
||||
: 'those, plus any Control Point'],
|
||||
['Timetabled trains', f.houseRules.discardTimetabled ? 'may be discarded' : 'are never discarded'],
|
||||
['Optional rules', optional.length ? optional.join(', ') : 'none'],
|
||||
];
|
||||
|
||||
return `<h4 class="res-h">The rules in play</h4>${factTable(rows)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-column table of plain-text facts.
|
||||
*
|
||||
* BOTH HALVES ESCAPED, exactly once, which is the only reason this is a shared helper rather than a
|
||||
* template repeated twice. The rows it is given today are numbers and fixed phrases, but they are
|
||||
* assembled from the Frame — and the day somebody adds a row carrying a player's name, or a facility
|
||||
* label, the escaping has to already be here rather than be remembered.
|
||||
*/
|
||||
function factTable(rows: [string, string][]): string {
|
||||
return (
|
||||
'<table class="res-t"><tbody>' +
|
||||
rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join('') +
|
||||
'</tbody></table>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE RAILROAD — what actually happened out there, from `GameState.tally` (Gitea#16).
|
||||
*
|
||||
* Rows that would read zero for a reason (no passenger work in a game that had none, no collisions
|
||||
* in a clean one) are dropped rather than printed as `0`: a screen of zeroes reads as a bug, and the
|
||||
* absence of a line is the same information more quietly. A zero that is genuinely interesting —
|
||||
* trains through the Division — stays.
|
||||
*/
|
||||
function tallyHtml(t: Frame['tally']): string {
|
||||
const rows: [string, string][] = [['Trains through the Division', String(t.trainsCompleted)]];
|
||||
|
||||
if (t.trainsCompleted > 0) {
|
||||
rows.push([
|
||||
'Of those, worked en route',
|
||||
`${t.trainsCompletedWithWork} of ${t.trainsCompleted}` +
|
||||
(t.trainsCompletedWithWork === t.trainsCompleted ? ' — every one' : ''),
|
||||
]);
|
||||
}
|
||||
const push = (label: string, n: number, detail = ''): void => {
|
||||
if (n > 0) rows.push([label, `${n}${detail}`]);
|
||||
};
|
||||
push('Loads made up', t.loadsCompleted);
|
||||
push('Loads broken', t.unloadsCompleted);
|
||||
push('Loads still in the pipeline', t.loadsStarted - t.loadsCompleted);
|
||||
push('Passengers boarded', t.passengersBoarded);
|
||||
push('Passengers detrained', t.passengersDetrained);
|
||||
push('Cars coupled', t.carsCoupled);
|
||||
push('Cars set out', t.carsDropped);
|
||||
push('Extras run', t.extrasStarted);
|
||||
push('Second sections ordered', t.secondSections);
|
||||
push('Flying switches', t.flyingSwitches);
|
||||
push('Offices upgraded', t.officeUpgrades);
|
||||
push('Facilities unjammed', t.facilitiesUnjammed);
|
||||
push('Trains held', t.trainsHeld);
|
||||
push('Trains diverted', t.trainsDiverted);
|
||||
push('Expedite faults', t.expediteFaults);
|
||||
push('Dispatch bonuses used', t.dispatchBonusesUsed);
|
||||
if (t.clearancesRequested > 0) {
|
||||
rows.push(['Clearances', `${t.clearancesAllowed} allowed of ${t.clearancesRequested} asked`]);
|
||||
}
|
||||
if (t.trainsDestroyed > 0) {
|
||||
rows.push([
|
||||
'Trains destroyed',
|
||||
`${t.trainsDestroyed}, taking ${t.carsDestroyed} car${t.carsDestroyed === 1 ? '' : 's'} with them`,
|
||||
]);
|
||||
}
|
||||
// §X18 only — the one card in the deck that pays a train for standing still. Reported as what it
|
||||
// is rather than as "the longest an engine sat on a siding", which the engine cannot answer (see
|
||||
// `Tally.circusStops`).
|
||||
if (t.circusStops.length > 0) {
|
||||
rows.push([
|
||||
'Circus set-ups',
|
||||
t.circusStops.map((c) => `Train ${c.trainNumber} at ${c.where}`).join(', '),
|
||||
]);
|
||||
}
|
||||
push('Cards drawn', t.cardsDrawn);
|
||||
push('Cards played', t.cardsPlayed);
|
||||
|
||||
return `<h4 class="res-h">The railroad</h4>${factTable(rows)}`;
|
||||
}
|
||||
|
||||
/** Per-player work, for a table that wants to know who did what rather than only who won. */
|
||||
function perPlayerHtml(f: Frame): string {
|
||||
const t = f.tally;
|
||||
if (f.players.length < 2) return '';
|
||||
const head =
|
||||
'<tr><th></th><th class="num">Rev</th><th class="num">Loads</th><th class="num">Unloads</th>' +
|
||||
'<th class="num">Pass.</th><th class="num">Cards</th><th class="num">Crashes</th></tr>';
|
||||
const rows = [...f.players]
|
||||
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||
.map((p) => {
|
||||
const q = t.byPlayer[p.index];
|
||||
if (!q) return '';
|
||||
return (
|
||||
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}</td>` +
|
||||
`<td class="num">${p.revenue}</td><td class="num">${q.loads}</td>` +
|
||||
`<td class="num">${q.unloads}</td>` +
|
||||
`<td class="num">${q.passengersBoarded + q.passengersDetrained}</td>` +
|
||||
`<td class="num">${q.cardsPlayed}</td><td class="num">${q.collisions}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
return `<h4 class="res-h">Who did what</h4><table class="res-t res-wide"><tbody>${head}${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE END-OF-GAME RESULTS SCREEN — Gitea#16, first pass.
|
||||
*
|
||||
* Everything the Frame already knew plus everything the event tally counted, in the order a player
|
||||
* asks for it: what happened, who won, by how much, under what rules, and then what the railroad
|
||||
* actually did all game. Badges and the "what would make this exciting" brainstorm are the second
|
||||
* pass the issue asks for and are deliberately not here.
|
||||
*
|
||||
* THE OFFICIAL RESULT IS THE ONE AT THE TOP, always. In an extended game (Gitea#11) the standings go
|
||||
* on moving after the winner is settled, so this screen reports the frozen result first and puts
|
||||
* everything that happened afterwards in its own section, marked as informational. "In a five-day
|
||||
* game, even if it's extended to eight or nine days, the winner and the official answer is the
|
||||
* winner at the end of five days" (Jesse, 2026-08-28).
|
||||
*/
|
||||
export function resultsHtml(f: Frame): string {
|
||||
// `official` is written by the engine the moment any game ends, so it is present on every finished
|
||||
// game. The fallback keeps this rendering something sane for a Frame that predates it — a replay
|
||||
// of a save recorded before this release, which the replay viewer will happily hand us.
|
||||
const report = f.official;
|
||||
const o = report?.outcome ?? f.outcome;
|
||||
if (!o) return '<p class="dim">This game has not ended.</p>';
|
||||
|
||||
const officialDay = report?.day ?? f.days;
|
||||
const winnerName =
|
||||
o.winner === null ? null : (f.players.find((p) => p.index === o.winner)?.name ?? null);
|
||||
|
||||
const headline =
|
||||
o.result === 'loss'
|
||||
? 'The Division failed'
|
||||
: winnerName === null
|
||||
? 'The Division ran'
|
||||
: `${esc(winnerName)} takes the Division`;
|
||||
|
||||
/**
|
||||
* The result is reported against the standings AS THEY WERE at the official ending, not as they
|
||||
* are now — in an extended game those are different numbers, and the winner has to be shown
|
||||
* winning. `revenues` is frozen alongside the outcome for exactly this.
|
||||
*/
|
||||
const frozen = report
|
||||
? { ...f, players: f.players.map((p) => ({ ...p, revenue: report.revenues[p.index] ?? p.revenue })) }
|
||||
: f;
|
||||
|
||||
const result =
|
||||
o.result === 'loss'
|
||||
? '<p>Nobody wins this one.</p>'
|
||||
: o.winner === null
|
||||
? '<p>The table clears it together — a Co-op game has no individual winner.</p>'
|
||||
: `<p><b>${esc(winnerName ?? '')}</b> finishes ahead on Revenue.</p>`;
|
||||
|
||||
const extended =
|
||||
f.extraDays > 0
|
||||
? '<h4 class="res-h">After the timetable</h4>' +
|
||||
`<p>The table played on for ${f.extraDays} more Day${f.extraDays === 1 ? '' : 's'}, ` +
|
||||
`through Day ${f.days + f.extraDays}. None of it changed the result above — it is recorded ` +
|
||||
'here because it happened.</p>' +
|
||||
standingsHtml(f) +
|
||||
targetHtml(f) +
|
||||
collisionsHtml(f) +
|
||||
tallyHtml(f.tally)
|
||||
: '';
|
||||
|
||||
return (
|
||||
`<h3 class="dayend-h res-${o.result}">${headline}</h3>` +
|
||||
`<p>${reasonSentence(frozen, o, officialDay)}</p>` +
|
||||
result +
|
||||
standingsHtml(frozen, o.winner) +
|
||||
targetHtml(frozen) +
|
||||
collisionsHtml(frozen) +
|
||||
perPlayerHtml(frozen) +
|
||||
rulesHtml(f) +
|
||||
tallyHtml(report?.tally ?? f.tally) +
|
||||
extended
|
||||
);
|
||||
}
|
||||
|
||||
@@ -479,4 +746,20 @@ ul.blocked{margin:0;padding-left:18px}
|
||||
.dayend-t tr:first-child td{border-top:0}
|
||||
.dayend-t .num{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;padding-right:0}
|
||||
.dayend-t .you td{color:#8fd6a0}
|
||||
.dayend-t .wins{color:#e8c56a;font-weight:700}
|
||||
|
||||
/* END-OF-GAME RESULTS (Gitea#16). Same family as the Day-end dialog above, which is the point —
|
||||
the two screens share their standings/target/collision blocks and should look like each other. */
|
||||
.res-h{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8b95a3;
|
||||
margin:16px 0 6px;border-top:1px solid #2c333d;padding-top:10px}
|
||||
.res-win{color:#8fd6a0}
|
||||
.res-loss{color:#d98f8f}
|
||||
.res-t{border-collapse:collapse;margin:4px 0;width:100%}
|
||||
.res-t td,.res-t th{padding:3px 12px 3px 0;border-top:1px solid #232a33;vertical-align:top}
|
||||
.res-t tr:first-child td{border-top:0}
|
||||
.res-t td:first-child{color:#8b95a3;white-space:nowrap}
|
||||
.res-t th{color:#6d7783;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
|
||||
.res-t .num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.res-wide td:first-child{color:#e6e9ee}
|
||||
.res-t .you td{color:#8fd6a0}
|
||||
`;
|
||||
|
||||
+202
-3
@@ -80,7 +80,7 @@ main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14p
|
||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
padding:10px 12px;margin-bottom:12px}
|
||||
#lobby{max-width:1040px;margin:0 auto;padding:14px}
|
||||
#lobby,#solitairesetup{max-width:1040px;margin:0 auto;padding:14px}
|
||||
/* The create form is two short lists, not one long one: what game this is on the left, what its
|
||||
rules are on the right. Collapses to one column where there is no room for two. */
|
||||
.lb-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:22px;align-items:start}
|
||||
@@ -96,8 +96,8 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
.ng-radio.disabled{opacity:.45;cursor:not-allowed}
|
||||
.ng-radio.disabled:hover{background:none}
|
||||
.lb-why{color:#e0b060;font-size:11px}
|
||||
#lobby h2{margin-top:0}
|
||||
#lobby h3{margin-bottom:2px}
|
||||
#lobby h2,#solitairesetup h2{margin-top:0}
|
||||
#lobby h3,#solitairesetup h3{margin-bottom:2px}
|
||||
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
|
||||
.lb-seat:last-child{border-bottom:none}
|
||||
.lb-seat .who{flex:1}
|
||||
@@ -222,6 +222,14 @@ h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe
|
||||
.over{padding:9px;border-radius:5px;font-weight:700;margin-bottom:8px}
|
||||
.over.win{background:rgba(40,140,60,.35)}
|
||||
.over.loss{background:rgba(160,60,60,.3)}
|
||||
/* THE EXTENSION VOTE (Gitea#11) — unanimous, so who has not answered yet is the useful half. */
|
||||
.vote-tally{display:flex;flex-wrap:wrap;gap:4px 12px;margin:0 0 9px;font-size:12px}
|
||||
.vote.yes{color:#8fd6a0}
|
||||
.vote.no{color:#d98f8f}
|
||||
.vote.wait{color:var(--dim)}
|
||||
/* Wider than the New Game dialog: the results carry a seven-column per-player table (Gitea#16). */
|
||||
#resultsdlg{max-width:640px}
|
||||
#resultsdlg table{max-width:100%}
|
||||
/* cards, log, blocked */
|
||||
.card.gone{opacity:.35;text-decoration:line-through}
|
||||
.subj{display:block;width:100%;margin:2px 0}
|
||||
@@ -584,6 +592,185 @@ ul.blocked li{padding:2px 0}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ===================================================================
|
||||
SOLITAIRE SETUP — the same question multiplayer already asks first,
|
||||
now asked here too (Jesse, 2026-08-29): a genuinely fresh visit deals
|
||||
nothing until this screen's own Deal button is pressed. A saved game,
|
||||
an explicit `?seed=`, or a URL already carrying a Deal's answers (any
|
||||
of the shared block's fields — `hand` names the one always written)
|
||||
all skip straight past this screen, exactly as `?lobby` already skips
|
||||
past it into the lobby: those are not "no plan yet", they are a
|
||||
choice already made, elsewhere.
|
||||
|
||||
THE SAME BLOCK THE DIALOG AND THE LOBBY USE, same shared module
|
||||
(`settings-form.ts`), same order — three screens are one design now
|
||||
instead of two. Only Solitaire can be dealt from here, so the other
|
||||
four types are shown exactly as the in-game dialog shows them: present,
|
||||
disabled, with a note pointing at the Multiplayer door instead.
|
||||
==================================================================== -->
|
||||
<div id="solitairesetup" hidden>
|
||||
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Solitaire</span></header>
|
||||
|
||||
<section>
|
||||
<h2>New solitaire game</h2>
|
||||
<p class="ng-note">One railroad, one player, five full days by default — everything below is
|
||||
yours to change before you deal. Clearing the Revenue floor wins; falling short loses.</p>
|
||||
|
||||
<div class="lb-params">
|
||||
<label class="ng-num"><span>Seed</span>
|
||||
<input id="ss-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
||||
<label class="ng-num"><span>Days</span>
|
||||
<input id="ss-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||
</div>
|
||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||
|
||||
<h3>Game type</h3>
|
||||
<div class="set-row" id="ss-type-row">
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="solitaire" checked>
|
||||
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="coop">
|
||||
<span><b>Co-op</b><br><span class="dim">Everyone’s Revenue is one table score. You win together or lose together.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="competitive">
|
||||
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="cutthroat">
|
||||
<span><b>Cutthroat</b><br><span class="dim">Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-type" value="custom">
|
||||
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
||||
</div>
|
||||
|
||||
<p class="ng-note" id="ss-type-note"></p>
|
||||
|
||||
<details id="ss-settings" open>
|
||||
<summary>Game settings</summary>
|
||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
||||
Changing any of them selects <b>Custom</b>; clicking a type again resets all of them back
|
||||
to it.</p>
|
||||
<div class="set-groups">
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Starting hand</h3>
|
||||
<p class="ng-note">What you are dealt before the first turn. The hand limit is three either
|
||||
way — deal six and the first turn is spent choosing which of them to keep.</p>
|
||||
<div class="set-row" id="ss-hand-row">
|
||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeRandom">
|
||||
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="sixRandom" checked>
|
||||
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeTrackThreeOther">
|
||||
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
|
||||
<span class="set-hint" id="ss-hand-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Where an Extra may start</h3>
|
||||
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes,
|
||||
and the place decides which way it runs — a Division Point sends it away from itself; in the
|
||||
middle of the railroad the player picks east or west. The Division Points and the Interchange
|
||||
belong to nobody and are always available. Starting one inside a district is the part that
|
||||
favours a seat, so it is set here. An Office must be a Control Point whatever this says: a
|
||||
Whistle Post never qualifies.</p>
|
||||
<div class="set-row" id="ss-extra-row">
|
||||
<label class="ng-radio"><input type="radio" name="ss-extra" value="divisionPointsOnly">
|
||||
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading. Every Extra begins on shared ground.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-extra" value="ownOffice">
|
||||
<span><b>Also the playing player’s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else’s district.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ss-extra" value="anyOffice">
|
||||
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player’s district.</span></span></label>
|
||||
<span class="set-hint" id="ss-extra-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Revenue</h3>
|
||||
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and
|
||||
again when it is detrained; a load pays when it is made up and again when it is broken. Zero
|
||||
switches an economy off so the others can be read.</p>
|
||||
<div class="set-row" id="ss-passenger-row">
|
||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||
<input id="ss-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="ss-passenger-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-freight-row">
|
||||
<label class="ng-num"><span>Freight revenue per load</span>
|
||||
<input id="ss-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="ss-freight-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-transit-row">
|
||||
<label class="ng-num"><span>Train revenue per transit</span>
|
||||
<input id="ss-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||
<span class="set-hint" id="ss-transit-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the
|
||||
Division — the one thing nobody has to work for.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Victory conditions</h3>
|
||||
<p class="ng-note">The ways this game can end badly. How long it runs is set above, in Days.</p>
|
||||
<div class="set-row" id="ss-minrev-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
|
||||
<span>You lose if Revenue at the end is under</span>
|
||||
<input id="ss-minrev" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-minrev-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-colday-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked>
|
||||
<span>The game ends in a loss if collisions in one Day reach</span>
|
||||
<input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-colday-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-coltotal-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked>
|
||||
<span>The game ends in a loss after this many collisions in the whole game</span>
|
||||
<input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-coltotal-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">The opponent-directed cards — Derail, Watertower, Hobo Jungle and the
|
||||
nineteen others, along with the seven that answer them — are not implemented yet, so no game
|
||||
type deals them whatever else is set here.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
|
||||
<div class="set-row" id="ss-visibility-row">
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
<input id="ss-visibility" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-visibility-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-rotation-row">
|
||||
<label class="ng-num"><span>Employee Rotation — meaningless at a table of one, shown here so
|
||||
this screen and the lobby read as one list</span>
|
||||
<input id="ss-rotation" type="checkbox" disabled></label>
|
||||
<span class="set-hint" id="ss-rotation-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-toolbox-row">
|
||||
<label class="ng-num"><span>Emergency Toolbox — start holding a Red Flag, so a hand of four;
|
||||
play or discard down to three on the first turn</span>
|
||||
<input id="ss-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-toolbox-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-tossloco-row">
|
||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||
Department slot. Turn this off and a train card can only ever be played onto the timetable.
|
||||
An Extra is never discardable either way</span>
|
||||
<input id="ss-tossloco" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-tossloco-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<menu class="ng-buttons">
|
||||
<button id="ss-deal" type="button">Deal</button>
|
||||
</menu>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="gameui" hidden>
|
||||
<div class="topbar">
|
||||
<header>
|
||||
@@ -890,6 +1077,18 @@ ul.blocked li{padding:2px 0}
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- THE END-OF-GAME RESULTS (Gitea#16). Filled by `resultsHtml` and opened from `renderEnding`,
|
||||
which puts it up once per ending unasked and leaves a button to reopen it. Reopenable matters:
|
||||
Gitea#11 lets a table play past the end, and continuing must not cost you the results screen. -->
|
||||
<dialog id="resultsdlg" aria-labelledby="rs-title">
|
||||
<form method="dialog">
|
||||
<div id="resultsbody"></div>
|
||||
<menu class="ng-buttons">
|
||||
<button value="ok" id="rs-ok" type="submit">Close</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
|
||||
`beginRemote` used to write "… connecting to the game" into `#presence` — the DISCONNECT banner,
|
||||
whose job is `⚠ waiting on Alice`. It worked only because the first render overwrote it, and it
|
||||
|
||||
+2
-1
@@ -26,6 +26,7 @@ import { createGame } from '../engine/setup.ts';
|
||||
import { snapshot } from '../sim/view.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { SOLO_CONFIG } from './game.ts';
|
||||
import { actingPlayer } from '../engine/state.ts';
|
||||
|
||||
type Save = { seed: number; history: Intent[] };
|
||||
type Entry = { file: string; title: string; note?: string; seed?: number };
|
||||
@@ -69,7 +70,7 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
||||
push(pump(s));
|
||||
let stoppedEarly = false;
|
||||
for (const intent of save.history) {
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
const actor = actingPlayer(s);
|
||||
if (actor === null || s.status !== 'active') break;
|
||||
const r = applyIntent(s, actor, intent);
|
||||
if (!r.ok) {
|
||||
|
||||
+4
-1
@@ -27,6 +27,7 @@ import {
|
||||
currentActor,
|
||||
fromSave,
|
||||
handPlayable,
|
||||
isOutOfTurn,
|
||||
newGame,
|
||||
overHandLimit,
|
||||
submit,
|
||||
@@ -163,7 +164,9 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
||||
overHandLimit: () => overHandLimit(game),
|
||||
handPlayable: () => handPlayable(game),
|
||||
submit: async (intent: Intent) => {
|
||||
const ok = submit(game, intent);
|
||||
// Seat 0 is the solitaire player, and the extension vote (Gitea#11) is the one intent that
|
||||
// arrives when `currentActor` is null — so it has to name its seat. See `isOutOfTurn`.
|
||||
const ok = submit(game, intent, isOutOfTurn(intent) ? 0 : null);
|
||||
if (ok) changed();
|
||||
return ok;
|
||||
},
|
||||
|
||||
+161
-29
@@ -66,6 +66,21 @@ function playToCompletion(s: GameState, seed = 1, maxTurns = 20_000): PlayStats
|
||||
tally(pump(s));
|
||||
if (s.status === 'finished') break;
|
||||
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY (Gitea#11) — this harness plays the timetable it was dealt.
|
||||
*
|
||||
* It picks at random among legal options, and one of the two options here is "play another
|
||||
* Day" — so left alone it would extend the game forever and terminate only on `maxTurns`. That
|
||||
* is not a bug in the feature: a game genuinely does not end now until somebody says stop, and
|
||||
* `developerBot` says stop for exactly this reason. Said explicitly here rather than folded
|
||||
* into the random pick, because "how does this loop terminate" deserves an answer in the loop.
|
||||
*/
|
||||
if (s.status === 'awaitingExtension') {
|
||||
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: false });
|
||||
assert.ok(r.ok, 'a solitaire player could not decline an extension');
|
||||
break;
|
||||
}
|
||||
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
if (actor === null) break;
|
||||
|
||||
@@ -480,7 +495,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
assert.equal(r.needsInput, true, 'the phase must stop and ask');
|
||||
assert.notEqual(s.clock.pendingDecision, null);
|
||||
assert.equal(s.clock.pendingDecision!.train, 'behind');
|
||||
assert.equal(s.clock.pendingDecision!.occupiedBy, 'ahead');
|
||||
assert.equal((s.clock.pendingDecision as { occupiedBy: string }).occupiedBy, 'ahead');
|
||||
});
|
||||
|
||||
it('does not ask when the train ahead is coming the other way — that is an absolute bar', () => {
|
||||
@@ -526,7 +541,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
it('clears the decision once the Superintendent rules', () => {
|
||||
const s = game();
|
||||
s.clock.phase = 'mainline';
|
||||
s.clock.pendingDecision = { train: 'a', occupiedBy: 'b' };
|
||||
s.clock.pendingDecision = { kind: 'clearance', train: 'a', occupiedBy: 'b' };
|
||||
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.clock.pendingDecision, null);
|
||||
@@ -544,9 +559,13 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 0;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
// PAUSES rather than finishes since Gitea#11: a days-based ending offers another Day, and the
|
||||
// result is recorded either way. `official` is the frozen answer; `status` is only where play
|
||||
// has got to. The two collision tests at the foot of this block are the contrast.
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||
assert.equal(s.official!.outcome.reason, 'revenueFloor');
|
||||
});
|
||||
|
||||
it('wins a timed Solitaire game that clears minCombinedRevenue', () => {
|
||||
@@ -556,7 +575,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 10;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
});
|
||||
|
||||
@@ -567,7 +586,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.players[0]!.revenue = 0;
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
});
|
||||
|
||||
@@ -582,7 +601,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.players[0]!.revenue = 5; // best individual score...
|
||||
s.players[1]!.revenue = 3; // ...but combined (8) still misses the floor (20).
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'loss');
|
||||
assert.equal(s.outcome!.reason, 'revenueFloor');
|
||||
});
|
||||
@@ -598,7 +617,7 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
s.players[0]!.revenue = 4;
|
||||
s.players[1]!.revenue = 6; // combined 10 clears the floor, neither alone would.
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.status, 'awaitingExtension');
|
||||
assert.equal(s.outcome!.result, 'win');
|
||||
assert.equal(s.outcome!.winner, null, 'Co-op names an individual winner instead of a shared one');
|
||||
});
|
||||
@@ -730,43 +749,156 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('X18 Circus Train — a point for standing still', () => {
|
||||
it('pays once for a Stage spent stopped, and never again', () => {
|
||||
/**
|
||||
* REPORTED: "Circus train TX18 was stopped on a siding for a full Stage and I did not get my
|
||||
* Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read
|
||||
* NOWHERE, along with eight other special-train rules. The one card in the deck that pays for
|
||||
* standing still paid nothing.
|
||||
*/
|
||||
const s = game();
|
||||
describe('X18 Circus / X17 Campaign — a point for setting up (Gitea#13)', () => {
|
||||
/**
|
||||
* REPORTED originally: "Circus train TX18 was stopped on a siding for a full Stage and I did not
|
||||
* get my Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read
|
||||
* NOWHERE, along with eight other special-train rules.
|
||||
*
|
||||
* REDEFINED by Gitea#13 (Jesse, 2026-08-29), and these tests carry the three parts of that
|
||||
* ruling: the point is paid ONCE PER OFFICE AREA rather than once per game, only when the train
|
||||
* is FULLY LOADED, and only in an Office Area at all.
|
||||
*/
|
||||
const circusAt = (s: GameState, seat: number, coord: { row: number; col: number }, consist: unknown[]) => {
|
||||
s.clock.phase = 'mainline';
|
||||
s.trays.set('circus', {
|
||||
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
|
||||
consist: [], direction: 'east',
|
||||
position: { at: 'grid', seat: 0, coord: { row: -1, col: 0 } },
|
||||
consist, direction: 'east',
|
||||
position: { at: 'grid', seat, coord },
|
||||
movesUsed: 0,
|
||||
} as never);
|
||||
// A card under it, so the crew is somewhere real rather than off the grid.
|
||||
areaOf(s, 0).grid.set('-1,0', {
|
||||
areaOf(s, seat as never).grid.set(`${coord.row},${coord.col}`, {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
} as never);
|
||||
};
|
||||
|
||||
const loaded = [
|
||||
{ type: 'boxcar', loaded: true },
|
||||
{ type: 'boxcar', loaded: true },
|
||||
{ type: 'coach', loaded: true },
|
||||
{ type: 'caboose', loaded: true },
|
||||
];
|
||||
|
||||
const runPhase = (s: GameState) => {
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return pump(s);
|
||||
};
|
||||
|
||||
it('pays a fully loaded Circus for a Stage spent set up', () => {
|
||||
const s = game();
|
||||
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||||
const before = s.players[0]!.revenue;
|
||||
const first = pump(s);
|
||||
assert.ok(
|
||||
first.some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18),
|
||||
pump(s).some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18),
|
||||
'the Circus Train stood still for a Stage and earned nothing',
|
||||
);
|
||||
assert.equal(s.players[0]!.revenue, before + 1, 'the point was not paid');
|
||||
});
|
||||
|
||||
// "One turn stopped" — once. A train that goes on standing there does not keep earning.
|
||||
const paidAgain = () => {
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return pump(s).some((e) => e.type === 'trainStoodStill');
|
||||
};
|
||||
assert.ok(!paidAgain(), 'the Circus Train collected a second time for the same set-up');
|
||||
it('pays once per Office Area, however long it parks there', () => {
|
||||
// "Once per stop in an office area" — a train that goes on standing in the same district does
|
||||
// not keep earning. This is the half that was already true, for a different reason.
|
||||
const s = game();
|
||||
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||||
pump(s);
|
||||
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||||
'the Circus collected twice for the same set-up');
|
||||
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||||
'the Circus collected a third time for the same set-up');
|
||||
});
|
||||
|
||||
it('pays AGAIN in a different district — each player can be visited', () => {
|
||||
/**
|
||||
* The half that is new. "In a multiplayer game, each player could score if the circus stops in
|
||||
* their area" — so the claim is per seat, and a touring Circus is paid by each district it sets
|
||||
* up in. Before Gitea#13 this paid once per GAME and the second district got nothing.
|
||||
*/
|
||||
const s = createGame({
|
||||
id: 'g', seed: 5, config: baseConfig({ mode: 'competitive' }), playerNames: ['A', 'B'],
|
||||
});
|
||||
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||||
pump(s);
|
||||
const paidFirst = s.players.map((p) => p.revenue);
|
||||
|
||||
// The same train, moved into the other player's district.
|
||||
const tray = s.trays.get('circus')!;
|
||||
areaOf(s, 1 as never).grid.set('-1,0', {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
} as never);
|
||||
tray.position = { at: 'grid', seat: 1, coord: { row: -1, col: 0 } } as never;
|
||||
|
||||
assert.ok(runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||||
'the Circus set up in a second district and earned nothing');
|
||||
const owner = s.seating[1]!;
|
||||
assert.equal(
|
||||
s.players[owner]!.revenue,
|
||||
paidFirst[owner]! + 1,
|
||||
'the point did not go to whoever sits in the district it stopped in',
|
||||
);
|
||||
});
|
||||
|
||||
it('pays nothing when the cars are empty — "not much of a circus"', () => {
|
||||
const s = game();
|
||||
circusAt(s, 0, { row: -1, col: 0 }, [
|
||||
{ type: 'boxcar', loaded: false },
|
||||
{ type: 'coach', loaded: true },
|
||||
{ type: 'caboose', loaded: true },
|
||||
]);
|
||||
const before = s.players[0]!.revenue;
|
||||
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
|
||||
'an empty car aboard still collected the set-up point');
|
||||
assert.equal(s.players[0]!.revenue, before, 'Revenue moved for a train that was not full');
|
||||
});
|
||||
|
||||
it('pays nothing to a train carrying nothing at all', () => {
|
||||
// `every` on an empty list is vacuously true, so the emptiest train of the lot is exactly the
|
||||
// one a careless test would pay.
|
||||
const s = game();
|
||||
circusAt(s, 0, { row: -1, col: 0 }, []);
|
||||
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
|
||||
'a Circus carrying nothing was paid for setting up');
|
||||
});
|
||||
|
||||
it('pays nothing for standing out on the Mainline', () => {
|
||||
/**
|
||||
* It used to, and it misattributed the point: `playerAtSeat` needs a seat, there is none off
|
||||
* the grid, and the fallback handed it to PLAYER 0 wherever the train was standing. Jesse's
|
||||
* ruling scopes the rule to Office Areas, which removes the bug rather than patching it.
|
||||
*/
|
||||
const s = game();
|
||||
s.clock.phase = 'mainline';
|
||||
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
s.trays.set('circus', {
|
||||
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
|
||||
consist: loaded, direction: 'east',
|
||||
position: { at: 'mainline', index },
|
||||
movesUsed: 0,
|
||||
} as never);
|
||||
const before = s.players[0]!.revenue;
|
||||
pump(s);
|
||||
assert.equal(s.players[0]!.revenue, before, 'a Mainline set-up paid a point');
|
||||
});
|
||||
|
||||
it('pays the Campaign Train only when its candidate is aboard', () => {
|
||||
// X17 carries one coach and no freight, so "fully loaded" is exactly "the coach is occupied".
|
||||
// It earned nothing at all before Gitea#13 — it had `stopThenExpedite` and no scoring rule.
|
||||
const occupied = game();
|
||||
circusAt(occupied, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: true }]);
|
||||
occupied.trays.get('circus')!.trainNumber = 17;
|
||||
const beforeOccupied = occupied.players[0]!.revenue;
|
||||
pump(occupied);
|
||||
assert.equal(occupied.players[0]!.revenue, beforeOccupied + 1, 'a full Campaign Train earned nothing');
|
||||
|
||||
const empty = game();
|
||||
circusAt(empty, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: false }]);
|
||||
empty.trays.get('circus')!.trainNumber = 17;
|
||||
const beforeEmpty = empty.players[0]!.revenue;
|
||||
pump(empty);
|
||||
assert.equal(empty.players[0]!.revenue, beforeEmpty, 'an empty Campaign Train was paid for its speech');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1272,7 +1404,7 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
|
||||
const r = advance(s);
|
||||
assert.equal(r.needsInput, true, 'the phase must stop and ask');
|
||||
assert.equal(s.clock.pendingDecision?.train, tray.id);
|
||||
assert.equal(s.clock.pendingDecision?.occupiedBy, 'ahead');
|
||||
assert.equal((s.clock.pendingDecision as { occupiedBy: string } | null)?.occupiedBy, 'ahead');
|
||||
|
||||
// HOLD keeps it in the yard.
|
||||
assert.ok(applyIntent(s, s.clock.superintendent, { type: 'mainline.clearance', allow: false }).ok);
|
||||
|
||||
+2
-2
@@ -956,7 +956,7 @@ describe('the Superintendent clearance ruling (§8.1)', () => {
|
||||
const s = game();
|
||||
s.clock.phase = 'mainline';
|
||||
s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule
|
||||
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
|
||||
s.clock.pendingDecision = { kind: 'clearance', train: 'tray0', occupiedBy: 'tray1' };
|
||||
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
||||
assert.ok(r.ok);
|
||||
assert.equal(s.clock.pendingDecision, null);
|
||||
@@ -964,7 +964,7 @@ describe('the Superintendent clearance ruling (§8.1)', () => {
|
||||
|
||||
it('is refused to a player who is not the Superintendent', () => {
|
||||
const s = game();
|
||||
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
|
||||
s.clock.pendingDecision = { kind: 'clearance', train: 'tray0', occupiedBy: 'tray1' };
|
||||
s.clock.superintendent = 1;
|
||||
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT');
|
||||
});
|
||||
|
||||
+112
-5
@@ -14,7 +14,7 @@ import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDera
|
||||
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey, subdivisions, turnOf } from '../src/engine/state.ts';
|
||||
import { coordKey, decisionActor, subdivisions, turnOf } from '../src/engine/state.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
@@ -287,17 +287,124 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
||||
assert.equal(s.players[0]!.revenue, -5);
|
||||
});
|
||||
|
||||
it('diverts a coachless train to the Yard Office', () => {
|
||||
/**
|
||||
* §11, THE YARD OFFICE (Gitea#5) — offered, not imposed, and only down a route that exists.
|
||||
*
|
||||
* Jesse: "you have to ask if non-coach trains wish to go in there, rather than to the office",
|
||||
* "if the Yard Office is not accessible in one move, you should not get the option", and cars on
|
||||
* the way in "result in a crash". All three were missing: the train was teleported onto the card.
|
||||
*/
|
||||
const answer = (s: GameState, take: boolean) => {
|
||||
const who = decisionActor(s);
|
||||
assert.notEqual(who, null, 'nothing was pending, so there was nothing to answer');
|
||||
const r = applyIntent(s, who!, { type: 'mainline.yardOffice', take });
|
||||
assert.ok(r.ok, 'the district owner could not answer the Yard Office offer');
|
||||
advance(s);
|
||||
};
|
||||
|
||||
it('OFFERS the Yard Office to the district owner rather than diverting automatically', () => {
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(-1, 0), card);
|
||||
addCard(s, at(0, 2), card);
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'yardOffice', 'the phase did not stop to ask');
|
||||
assert.equal(decisionActor(s), 0, 'the question went to the wrong player');
|
||||
const pos = s.trays.get(id)!.position;
|
||||
assert.ok(pos.at === 'grid' && pos.coord.row === -1, 'arrived at the Yard Office');
|
||||
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'did not take an A/D track');
|
||||
assert.ok(pos.at !== 'grid' || pos.coord.col !== 2, 'the train moved before anyone answered');
|
||||
});
|
||||
|
||||
it('takes the Yard Office when the owner says yes', () => {
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
answer(s, true);
|
||||
const pos = s.trays.get(id)!.position;
|
||||
assert.ok(pos.at === 'grid' && pos.coord.col === 2, 'did not arrive at the Yard Office');
|
||||
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'took an A/D track anyway');
|
||||
assert.equal(s.players[0]!.revenue, 0, 'a clear lead should not have collided');
|
||||
});
|
||||
|
||||
it('goes to the Train Order Office when the owner says no', () => {
|
||||
// "They can of course still choose to have the train go to the standard office."
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
answer(s, false);
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'declining did not put it on an A/D track');
|
||||
});
|
||||
|
||||
it('does not offer what cannot be reached, and says why in the history', () => {
|
||||
/**
|
||||
* Jesse, 2026-08-29: "make sure this is logged in history — why can't move so user knows why
|
||||
* they can't get to yard." A silent absence is indistinguishable from a broken feature, which
|
||||
* is how the missing reachability check survived this long.
|
||||
*/
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
// Far off the Running Track, with nothing laid between: no route in one move.
|
||||
addCard(s, at(3, 4), card);
|
||||
inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
const events = advance(s).events;
|
||||
assert.equal(s.clock.pendingDecision, null, 'offered a Yard Office it cannot reach');
|
||||
const said = events.find(
|
||||
(e) => e.type === 'trainDiverted' && e.reason.includes('could not be offered'),
|
||||
);
|
||||
assert.ok(said, `nothing in the history explains why:\n${JSON.stringify(events, null, 1)}`);
|
||||
assert.match(
|
||||
(said as { reason: string }).reason,
|
||||
/one move/,
|
||||
'the reason does not say it is out of reach in one move',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a fouled lead, and taking it collides', () => {
|
||||
/**
|
||||
* The third missing condition. "Just like other trains finding cars on the tracks you use to
|
||||
* get into either result in a crash" — and Jesse's ruling keeps the OFFER: a route that exists
|
||||
* is offered, and the consequence of taking it is the player's. §8.3 already reads cars in the
|
||||
* path of an arriving train as a collision rather than a coupling.
|
||||
*/
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
// A car standing on the lead between the Office and the yard.
|
||||
areaOf(s, 0).grid.get(coordKey(at(0, 1)))!.standing = [{ type: 'boxcar', loaded: false }];
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'yardOffice', 'a fouled lead was not offered at all');
|
||||
|
||||
answer(s, true);
|
||||
assert.equal(s.players[0]!.revenue, -5, 'running through standing cars did not collide');
|
||||
assert.ok(!s.trays.has(id), 'the train survived the collision');
|
||||
});
|
||||
|
||||
it('declining a fouled lead is safe — the standard Office is unaffected', () => {
|
||||
const s = game();
|
||||
const card = straight();
|
||||
card.enhancements.push('yardOffice');
|
||||
addCard(s, at(0, 2), card);
|
||||
areaOf(s, 0).grid.get(coordKey(at(0, 1)))!.standing = [{ type: 'boxcar', loaded: false }];
|
||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||
|
||||
advance(s);
|
||||
answer(s, false);
|
||||
assert.equal(s.players[0]!.revenue, 0, 'declining the Yard Office still cost a collision');
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'the train did not reach the Office');
|
||||
});
|
||||
|
||||
it('does not divert a train carrying coaches', () => {
|
||||
|
||||
@@ -48,6 +48,15 @@ const KNOWN_UNREDUCED = [
|
||||
'dispatchBonusUsed',
|
||||
'expediteFault',
|
||||
'phaseBegan',
|
||||
/**
|
||||
* §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so
|
||||
* this is described rather than reduced like everything else here.
|
||||
*
|
||||
* ADDED DELIBERATELY, and it cost a bug first: the flag was originally taken down in a `reduce`
|
||||
* case, which never fires for an event `advance.ts` emits — so it stayed up and held every train
|
||||
* that came. That is precisely the failure this list exists to make visible.
|
||||
*/
|
||||
'redFlagSpent',
|
||||
// Employee Rotation moves `seating` in the phase driver and then describes what it did, which is
|
||||
// the pattern every entry on this list follows.
|
||||
'seatsRotated',
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* §3.3, EXTENDED PLAY — Gitea#11, "when game ends allow players to continue playing if they wish".
|
||||
*
|
||||
* The rule as Jesse specified it (2026-08-28), which is what these tests are written against:
|
||||
*
|
||||
* - the OFFICIAL result is decided at the original game length and never changes. "In a five-day
|
||||
* game, even if it's extended to eight or nine days, the winner and the official answer is the
|
||||
* winner at the end of five days";
|
||||
* - extending grants exactly ONE Day, and the question is put again at the end of it;
|
||||
* - solitaire: the player decides alone. Multiplayer: unanimous, and one refusal ends it there;
|
||||
* - only days-based endings offer it. A §3.4 collision breach is final, during an extended Day
|
||||
* just as during the regular game.
|
||||
*
|
||||
* The persistence half matters as much as the rules half: a save is `{ seed, config, history }`
|
||||
* replayed through the engine, so an extension that is not an INTENT does not survive a reload, an
|
||||
* Undo, or a server restart. `replays the extension` below is the test that pins that.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, check } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
|
||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
mode: 'solitaire',
|
||||
days: 3,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
});
|
||||
|
||||
const game = (over: Partial<GameConfig> = {}, names = ['Jesse']): GameState =>
|
||||
createGame({ id: 'g', seed: 1, config: baseConfig(over), playerNames: names });
|
||||
|
||||
/** Parks a game on the last Stage of its final Day, so one `advance` runs the clock off the end. */
|
||||
function atTheEnd(s: GameState): GameState {
|
||||
s.clock.day = s.config.days + s.extraDays + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('§3.3 extended play — the ending pauses rather than stopping (Gitea#11)', () => {
|
||||
it('offers another Day on a days-based ending, and records the result anyway', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
assert.equal(s.status, 'awaitingExtension', 'a days-based ending did not offer another Day');
|
||||
assert.ok(s.outcome, 'the result was not decided');
|
||||
assert.ok(s.official, 'the official result was not frozen');
|
||||
assert.equal(s.official!.day, s.config.days, 'the official Day is not the original game length');
|
||||
});
|
||||
|
||||
it('does NOT offer another Day after a collision breach — §3.4 is final', () => {
|
||||
const s = game({ mode: 'competitive', maxCollisionsPerDay: 2 });
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished', 'a railroad declared unsafe offered to carry on');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
|
||||
it('refuses every ordinary intent while the extension question is open', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'WRONG_PHASE');
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), null, 'the vote itself was refused');
|
||||
});
|
||||
|
||||
it('offers only the two votes, and only to a seat that has not voted', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
advance(s);
|
||||
assert.deepEqual(
|
||||
legalActions(s, 0).map((i) => i.type),
|
||||
['game.extend', 'game.extend'],
|
||||
'something other than the vote is legal while the game is stopped',
|
||||
);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(legalActions(s, 0).length, 0, 'a seat was offered a second vote');
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'ALREADY_VOTED');
|
||||
assert.equal(legalActions(s, 1).length, 2, 'the seat still to vote was not offered the vote');
|
||||
});
|
||||
|
||||
it('refuses the vote when no extension is pending', () => {
|
||||
const s = game();
|
||||
assert.equal(check(s, 0, { type: 'game.extend', player: 0, agree: true }), 'NOT_AWAITING_EXTENSION');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — one Day at a time (Gitea#11)', () => {
|
||||
it('grants exactly one Day in solitaire, then asks again at the end of it', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const official = { ...s.official!.outcome };
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(s.status, 'active', 'agreeing did not resume play');
|
||||
assert.equal(s.extraDays, 1, 'more or less than one Day was granted');
|
||||
assert.deepEqual(s.extensionVotes, [null], 'the votes were not cleared for the next question');
|
||||
|
||||
// Run the extra Day off the end: the question comes round again.
|
||||
atTheEnd(s);
|
||||
advance(s);
|
||||
assert.equal(s.status, 'awaitingExtension', 'the second ending did not ask again');
|
||||
assert.equal(s.extraDays, 1, 'a second Day was granted without being asked for');
|
||||
assert.deepEqual(s.official!.outcome, official, 'the official result was rewritten');
|
||||
assert.equal(s.official!.day, s.config.days, 'the official Day moved with the extension');
|
||||
});
|
||||
|
||||
it('ends the moment one seat declines, without waiting for the rest', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(s.status, 'awaitingExtension', 'one yes ended the vote');
|
||||
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
|
||||
assert.equal(s.status, 'finished', 'a refusal did not end the game immediately');
|
||||
assert.equal(s.extraDays, 0, 'a Day was granted despite a refusal');
|
||||
assert.equal(check(s, 2, { type: 'game.extend', player: 2, agree: true }), 'NOT_AWAITING_EXTENSION',
|
||||
'the seat that never voted is still being waited on');
|
||||
});
|
||||
|
||||
it('needs every seat before it grants the Day', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B', 'C']));
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(s.status, 'awaitingExtension', 'two of three votes granted the Day');
|
||||
applyIntent(s, 2, { type: 'game.extend', player: 2, agree: true });
|
||||
assert.equal(s.status, 'active', 'a unanimous table was not given its Day');
|
||||
assert.equal(s.extraDays, 1);
|
||||
});
|
||||
|
||||
it('keeps the official result when a collision ends an EXTENDED Day', () => {
|
||||
// The case the freeze exists for: a railroad declared unsafe on the extra Day does not retract
|
||||
// who won on the last scheduled one.
|
||||
const s = atTheEnd(game({ mode: 'competitive', maxCollisionsPerDay: 2 }, ['A', 'B']));
|
||||
s.players[0]!.revenue = 9;
|
||||
s.players[1]!.revenue = 2;
|
||||
advance(s);
|
||||
const official = { ...s.official!.outcome };
|
||||
assert.equal(official.result, 'win');
|
||||
assert.equal(official.winner, 0);
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(s.status, 'active');
|
||||
|
||||
s.collisionsToday = 2;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor', 'the current evaluation was not updated');
|
||||
assert.deepEqual(s.official!.outcome, official, 'a late collision rewrote a recorded win');
|
||||
});
|
||||
|
||||
it('decides the winner at the ORIGINAL game length, whoever leads afterwards', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
s.players[0]!.revenue = 9;
|
||||
s.players[1]!.revenue = 2;
|
||||
advance(s);
|
||||
assert.equal(s.official!.outcome.winner, 0);
|
||||
assert.deepEqual(s.official!.revenues, [9, 2], 'the official standings were not frozen');
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
applyIntent(s, 1, { type: 'game.extend', player: 1, agree: true });
|
||||
|
||||
// B overtakes A during the extra Day, and it changes nothing official.
|
||||
s.players[1]!.revenue = 40;
|
||||
atTheEnd(s);
|
||||
advance(s);
|
||||
assert.equal(s.official!.outcome.winner, 0, 'overtaking after the timetable took the win');
|
||||
assert.deepEqual(s.official!.revenues, [9, 2], 'the frozen standings moved');
|
||||
assert.equal(s.outcome!.winner, 1, 'the informational evaluation did not follow the new leader');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — it survives being replayed (Gitea#11)', () => {
|
||||
it('reproduces an extended game from seed and intents alone', () => {
|
||||
// The reason the vote is an intent at all. A save is a replay, so a decision that is not in the
|
||||
// history did not happen — an extended game would evaporate on the next reload.
|
||||
const play = (): GameState => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
return s;
|
||||
};
|
||||
const a = play();
|
||||
const b = play();
|
||||
assert.equal(a.extraDays, b.extraDays);
|
||||
assert.equal(a.status, b.status);
|
||||
assert.deepEqual(a.official!.outcome, b.official!.outcome);
|
||||
});
|
||||
|
||||
it('narrates the vote, so a table can see who called time', () => {
|
||||
const s = atTheEnd(game({ mode: 'competitive' }, ['A', 'B']));
|
||||
advance(s);
|
||||
const yes = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.ok(yes.ok);
|
||||
assert.deepEqual(yes.events.map((e) => e.type), ['extensionVoted']);
|
||||
const no = applyIntent(s, 1, { type: 'game.extend', player: 1, agree: false });
|
||||
assert.ok(no.ok);
|
||||
assert.deepEqual(no.events.map((e) => e.type), ['extensionVoted', 'playConcluded']);
|
||||
});
|
||||
|
||||
it('announces the granted Day', () => {
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.ok(r.ok);
|
||||
assert.deepEqual(r.events.map((e) => e.type), ['extensionVoted', 'dayExtended']);
|
||||
const extended = r.events.find((e) => e.type === 'dayExtended');
|
||||
assert.equal(extended && 'day' in extended ? extended.day : null, s.config.days + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('§3.3 extended play — bots play the timetable they were dealt (Gitea#11)', () => {
|
||||
it('REGRESSION: a simulated game terminates whatever the policy would vote', () => {
|
||||
/**
|
||||
* `playGame` declines on the driver's own account rather than leaving it to the policy, and this
|
||||
* is why. `randomBot` picks uniformly among its legal options, so it takes another Day about
|
||||
* half the time — and since a table may go on granting Days for ever, the game then runs to
|
||||
* `maxTurns`. It did: `test/sim.test.ts` went from under a second to an unbounded hang, every
|
||||
* seeded game in the harness playing fifty thousand turns instead of a couple of hundred.
|
||||
*
|
||||
* `randomBot` is the policy that exposes it, but the guarantee has to hold for any policy,
|
||||
* including ones not written yet — hence the fix living in the driver and the test living here.
|
||||
*/
|
||||
const s = createGame({ id: 'g', seed: 9, config: baseConfig({ days: 1 }), playerNames: ['Jesse'] });
|
||||
const out = playGame(s, randomBot(7), pump, 4_000);
|
||||
assert.equal(s.status, 'finished', 'a simulated game did not finish');
|
||||
assert.equal(s.extraDays, 0, 'the harness played Days the game was not dealt');
|
||||
assert.ok(out.turns < 4_000, `ran to the turn cap (${out.turns}) instead of ending`);
|
||||
});
|
||||
|
||||
it('developerBot declines, so a bot-only game ends on schedule', () => {
|
||||
// "If only bots are playing, they never vote to extend" (Jesse, 2026-08-28). The server votes
|
||||
// yes on a bot's behalf once every human has already agreed; this policy is what is left when
|
||||
// there are no humans to follow.
|
||||
const s = atTheEnd(game());
|
||||
advance(s);
|
||||
const choice = developerBot.choose(s, 0, legalActions(s, 0));
|
||||
assert.equal(choice.type, 'game.extend');
|
||||
assert.equal('agree' in choice ? choice.agree : null, false, 'a bot asked for another Day');
|
||||
});
|
||||
});
|
||||
+12
-1
@@ -304,7 +304,18 @@ describe('every published replay actually replays', () => {
|
||||
`${f} is dead — it replays ${back.history.length} of ${save.history.length} intents. ` +
|
||||
'Re-record it with save-replay.ts rather than editing the file.',
|
||||
);
|
||||
assert.equal(back.state.status, 'finished', `${f} does not reach the end of its game`);
|
||||
/**
|
||||
* `awaitingExtension` counts as the end since Gitea#11 — and for a published file it is the
|
||||
* EXPECTED end. These saves were recorded before extended play existed, so their histories
|
||||
* carry no `game.extend` vote: replaying one runs the timetable out and stops on the question
|
||||
* nobody was there to answer. That is a game that reached the end of its own history, which is
|
||||
* what this test is about. `active` would still be a dead replay.
|
||||
*/
|
||||
assert.ok(
|
||||
back.state.status === 'finished' || back.state.status === 'awaitingExtension',
|
||||
`${f} does not reach the end of its game — status ${back.state.status}`,
|
||||
);
|
||||
assert.ok(back.state.official !== null, `${f} ends without recording a result`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+240
-46
@@ -28,7 +28,7 @@ import {
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameEvent } from '../src/engine/events.ts';
|
||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey, turnOf } from '../src/engine/state.ts';
|
||||
import { coordKey, decisionActor, turnOf } from '../src/engine/state.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
@@ -317,77 +317,177 @@ describe('Realignment converts one Mainline type to another', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Red Flags protect a stopped train', () => {
|
||||
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
|
||||
function rearEnder(s: GameState) {
|
||||
const node = pinned(s, 1, 'plains');
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
describe('Red Flags hold a train out of your Limits (Gitea#19)', () => {
|
||||
/**
|
||||
* REPLACES the old rule outright (Jesse, 2026-08-29). Red Flags used to be played on a stopped
|
||||
* train out on the Mainline and protected it from a rear-ender — measured at 4,212 offers and 4
|
||||
* plays across 600 games, a mechanic nobody used. ABS Signals already does that job better.
|
||||
*
|
||||
* Now: "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits
|
||||
* from that direction (i.e. Flag East holds westbound trains)." Spent on the train it stops —
|
||||
* one card, one train.
|
||||
*/
|
||||
|
||||
/** A westbound train one Stage from entering seat 0's district from the east. */
|
||||
function approaching(s: GameState) {
|
||||
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const node = pinned(s, officeIndex + 1, 'plains');
|
||||
node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' });
|
||||
s.trays.set('inbound', {
|
||||
id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'hopper', loaded: true }], direction: 'west',
|
||||
position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0,
|
||||
});
|
||||
s.trays.set('behind', {
|
||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
const dp = s.division.nodes[0];
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push('behind');
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return node;
|
||||
return s.division.nodes[officeIndex] as Extract<typeof s.division.nodes[0], { kind: 'office' }>;
|
||||
}
|
||||
|
||||
it('holds the approaching train instead of letting it close', () => {
|
||||
it('FLAG EAST holds a westbound train short of the Limits', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
node.redFlagged = ['ahead'];
|
||||
const office = approaching(s);
|
||||
office.redFlag = 'east';
|
||||
|
||||
advance(s);
|
||||
assert.deepEqual(
|
||||
s.trays.get('behind')!.position,
|
||||
{ at: 'divisionPoint', side: 'west' },
|
||||
'the flagged train must not be approached',
|
||||
);
|
||||
const pos = s.trays.get('inbound')!.position;
|
||||
assert.equal(pos.at, 'mainline', 'the flagged train came in anyway');
|
||||
assert.ok(!areaOf(s, 0).adOccupancy.includes('inbound'), 'it reached an A/D track');
|
||||
});
|
||||
|
||||
it('comes in when the protected train rolls', () => {
|
||||
it('is spent on the train it stops — one card, one train', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
node.redFlagged = ['ahead'];
|
||||
// Bring the protected train to the end of its crossing so it leaves the card.
|
||||
node.transits[0]!.stagesRemaining = 1;
|
||||
const office = approaching(s);
|
||||
office.redFlag = 'east';
|
||||
|
||||
for (let i = 0; i < 12 && (node.redFlagged?.length ?? 0) > 0; i++) advance(s);
|
||||
assert.deepEqual(node.redFlagged, [], 'flags come in once the train moves off');
|
||||
advance(s);
|
||||
assert.equal(office.redFlag, undefined, 'the flag stayed up after stopping a train');
|
||||
});
|
||||
|
||||
it('only protects a train out on the Mainline', () => {
|
||||
it('lets the train in on the next Mainline Phase', () => {
|
||||
// "Loses one Mainline Phase" — it buys a Stage to clear the lead, not permanent protection.
|
||||
const s = game();
|
||||
const office = approaching(s);
|
||||
office.redFlag = 'east';
|
||||
advance(s);
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
advance(s);
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'the train never came in');
|
||||
});
|
||||
|
||||
it('does not hold a train coming from the OTHER side', () => {
|
||||
// "Flag East holds westbound trains" — an eastbound train arrives from the west.
|
||||
const s = game();
|
||||
const office = approaching(s);
|
||||
office.redFlag = 'west';
|
||||
|
||||
advance(s);
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'a west flag held a train from the east');
|
||||
assert.equal(office.redFlag, 'west', 'the wrong-side flag was spent');
|
||||
});
|
||||
|
||||
it('is played on a side, not on a train', () => {
|
||||
const s = game();
|
||||
rearEnder(s);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
const cardId = hand(s, 'maneuver', 'redFlags');
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'behind' }),
|
||||
'NO_PLACEMENT',
|
||||
'a train sitting at a Division Point cannot be rear-ended',
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), null);
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), null);
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null);
|
||||
});
|
||||
|
||||
it('will not double-flag the same train', () => {
|
||||
it('will not double-flag the same side', () => {
|
||||
const s = game();
|
||||
const node = rearEnder(s);
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
const cardId = hand(s, 'maneuver', 'redFlags');
|
||||
node.redFlagged = ['ahead'];
|
||||
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const office = s.division.nodes[officeIndex] as { redFlag?: string };
|
||||
office.redFlag = 'east';
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }),
|
||||
'OPTION_ALREADY_CHOSEN',
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), 'ALREADY_FLAGGED');
|
||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null,
|
||||
'the other side should still be free');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Red Flags offered at the moment of danger (Gitea#19)', () => {
|
||||
/**
|
||||
* "In actual cases of danger… if there is a train or cars on the track and there will be a
|
||||
* collision, then you break in with a dialog that says COLLISION RISK! FLAG AGAINST T2? This way,
|
||||
* you can play the card normally or out of phase, but only if you need it."
|
||||
*
|
||||
* The engine establishes the danger, so the player is never asked to judge it — which is also why
|
||||
* the bot can now use this card at all. It is offered ONLY to somebody holding one.
|
||||
*/
|
||||
function dangerous(s: GameState, giveCard: boolean) {
|
||||
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const node = pinned(s, officeIndex + 1, 'plains');
|
||||
node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' });
|
||||
s.trays.set('inbound', {
|
||||
id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'hopper', loaded: true }], direction: 'west',
|
||||
position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0,
|
||||
});
|
||||
// A hopper fouling the Running Track: §8.3 makes this arrival a collision.
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.get(coordKey(area.officeCoord))!.standing = [{ type: 'hopper', loaded: false }];
|
||||
if (giveCard) hand(s, 'maneuver', 'redFlags');
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
}
|
||||
|
||||
it('breaks in to offer the flag when an arrival would collide', () => {
|
||||
const s = game();
|
||||
dangerous(s, true);
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision?.kind, 'redFlag', 'no prompt before a certain collision');
|
||||
assert.equal(decisionActor(s), 0, 'the prompt went to the wrong player');
|
||||
});
|
||||
|
||||
it('flagging holds the train and costs the card', () => {
|
||||
const s = game();
|
||||
dangerous(s, true);
|
||||
advance(s);
|
||||
const before = (s.decks.hands.get(0) ?? []).length;
|
||||
|
||||
const r = applyIntent(s, 0, { type: 'mainline.redFlag', flag: true });
|
||||
assert.ok(r.ok, 'the flag was refused');
|
||||
advance(s);
|
||||
|
||||
assert.equal(s.players[0]!.revenue, 0, 'the collision happened anyway');
|
||||
assert.equal(s.trays.get('inbound')!.position.at, 'mainline', 'the train came in regardless');
|
||||
assert.equal((s.decks.hands.get(0) ?? []).length, before - 1, 'the card was not spent');
|
||||
});
|
||||
|
||||
it('declining lets the collision happen', () => {
|
||||
const s = game();
|
||||
dangerous(s, true);
|
||||
advance(s);
|
||||
|
||||
assert.ok(applyIntent(s, 0, { type: 'mainline.redFlag', flag: false }).ok);
|
||||
advance(s);
|
||||
assert.equal(s.players[0]!.revenue, -5, 'waving it through did not collide');
|
||||
});
|
||||
|
||||
it('does not offer a flag to a player holding none', () => {
|
||||
// A prompt with one button is not a choice, and it leaks that a collision is coming.
|
||||
const s = game();
|
||||
dangerous(s, false);
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision, null, 'offered a flag to a player with no card');
|
||||
assert.equal(s.players[0]!.revenue, -5, 'the collision should have happened');
|
||||
});
|
||||
|
||||
it('stays quiet when the arrival is safe', () => {
|
||||
const s = game();
|
||||
dangerous(s, true);
|
||||
// Clear the hazard: nothing fouling the Running Track, and room at the Office.
|
||||
const area = areaOf(s, 0);
|
||||
area.grid.get(coordKey(area.officeCoord))!.standing = [];
|
||||
advance(s);
|
||||
assert.equal(s.clock.pendingDecision, null, 'interrupted the phase for a safe arrival');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -770,6 +870,100 @@ describe('a turnout may be laid on top of a card already down', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extras that must run loaded, and one that must not (Gitea#13)', () => {
|
||||
/**
|
||||
* "I've redefined some of the extra trains that they have to run full boxcars — military trains,
|
||||
* circus trains, etc. If not loaded, then empty, and if none available, run without."
|
||||
*
|
||||
* A PREFERENCE ORDER, so every test here is about what the DIVISION YARD still holds. The rule
|
||||
* has nothing to say about a train once it is running; it decides which car may be taken next.
|
||||
*/
|
||||
const madeUp = (trainNumber: number) => {
|
||||
const s = game();
|
||||
s.clock.phase = 'newTrain';
|
||||
s.trays.set('t', {
|
||||
id: 't', trainNumber, trainIsExtra: true, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const place = (carType: string, loaded: boolean) =>
|
||||
({ type: 'newTrain.placeCar', trayId: 't', carType, loaded }) as never;
|
||||
/** Leaves the Division Yard holding exactly the cars described. */
|
||||
const stockYard = (s: ReturnType<typeof madeUp>, cars: { type: string; loaded: boolean }[]) => {
|
||||
s.yards.divisionYard.length = 0;
|
||||
s.yards.divisionYard.push(...(cars as never[]));
|
||||
};
|
||||
|
||||
it('refuses an empty while the yard can still supply a loaded one (X18 Circus)', () => {
|
||||
const s = madeUp(18);
|
||||
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('boxcar', false)), 'NO_SUITABLE_CAR',
|
||||
'an empty was accepted while a loaded boxcar was still in the yard');
|
||||
assert.equal(check(s, 0, place('boxcar', true)), null, 'the loaded boxcar was refused');
|
||||
});
|
||||
|
||||
it('accepts an empty once the yard has no loaded car of that kind left', () => {
|
||||
// "If not loaded, then empty." The rule releases as soon as the yard cannot supply.
|
||||
const s = madeUp(18);
|
||||
stockYard(s, [{ type: 'boxcar', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('boxcar', false)), null,
|
||||
'an empty was refused when the yard held no loaded car at all');
|
||||
});
|
||||
|
||||
it('does not let a loaded car of the WRONG category unlock the rule', () => {
|
||||
// A loaded coach is no reason to refuse an empty boxcar: the preference is per category, since
|
||||
// that is the slot the car is competing for.
|
||||
const s = madeUp(18);
|
||||
stockYard(s, [{ type: 'coach', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('boxcar', false)), null,
|
||||
'a loaded coach blocked an empty boxcar');
|
||||
});
|
||||
|
||||
it('exempts the caboose, which is never empty in the supply', () => {
|
||||
const s = madeUp(18);
|
||||
stockYard(s, [{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }]);
|
||||
assert.equal(check(s, 0, place('caboose', true)), null, 'the caboose its card calls for was refused');
|
||||
});
|
||||
|
||||
it('applies to the Military train too', () => {
|
||||
const s = madeUp(19);
|
||||
stockYard(s, [{ type: 'coach', loaded: true }, { type: 'coach', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('coach', false)), 'NO_SUITABLE_CAR',
|
||||
'the Military train took an empty coach over a loaded one');
|
||||
});
|
||||
|
||||
it('leaves trains without the rule alone', () => {
|
||||
// X21 Freight Extra has no loading rule: an empty is as good as a loaded one.
|
||||
const s = madeUp(21);
|
||||
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('boxcar', false)), null,
|
||||
'a train with no loading rule was made to prefer loaded cars');
|
||||
});
|
||||
|
||||
it('REGRESSION: X13 Appleseed is empties-only, and now the rules say so too', () => {
|
||||
/**
|
||||
* `ConsistSpec.emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)"
|
||||
* by `web/game.ts` and `sim/view.ts`, and enforced by NOTHING — `acceptsCar` never read it. So
|
||||
* the Appleseed could be made up with loaded cars while its own card said it could not. Found
|
||||
* while building Gitea#13, which is the same rule pointing the other way.
|
||||
*/
|
||||
const s = madeUp(13);
|
||||
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||
assert.equal(check(s, 0, place('boxcar', true)), 'NO_SUITABLE_CAR',
|
||||
'the Appleseed took a loaded car despite printing "empties only"');
|
||||
assert.equal(check(s, 0, place('boxcar', false)), null, 'the Appleseed refused an empty');
|
||||
});
|
||||
|
||||
it('still lets the Appleseed take the caboose its consist calls for', () => {
|
||||
// Every caboose in ROLLING_STOCK_SUPPLY is minted loaded, so an unexempted empties-only rule
|
||||
// would bar the one car the card explicitly lists.
|
||||
const s = madeUp(13);
|
||||
stockYard(s, [{ type: 'caboose', loaded: true }]);
|
||||
assert.equal(check(s, 0, place('caboose', true)), null, 'the empties-only rule ate the caboose');
|
||||
});
|
||||
});
|
||||
|
||||
describe("a train is made up to its card's consist (§8.2)", () => {
|
||||
it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => {
|
||||
// Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and
|
||||
|
||||
@@ -99,6 +99,30 @@ describe('redaction — a seat\'s Frame never carries another seat\'s secrets',
|
||||
assert.ok(!serialized.includes(String(s.seed)), 'the seed value leaked into the Frame some other way');
|
||||
});
|
||||
|
||||
it('the tally that rides the Frame is aggregate counts, never a card id (Gitea#16)', () => {
|
||||
// Gitea#16's statistics live on `GameState` and reach a remote client on the Frame, which is
|
||||
// only safe because nothing in a Tally identifies a card. That is a property of what
|
||||
// `tally.ts` chooses to count, and nothing in the type system enforces it — so it is asserted
|
||||
// here, where a future counter that stashed a `cardId` "just for badges" would be caught.
|
||||
for (const players of [3, 4]) {
|
||||
const s = midGame(players, 5000 + players);
|
||||
const secrets = new Set<string>([...s.decks.homeOffice]);
|
||||
for (const hand of s.decks.hands.values()) for (const id of hand) secrets.add(id);
|
||||
for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) {
|
||||
const serialized = JSON.stringify(snapshot(s, [], null, null, null, false, viewer).tally);
|
||||
for (const cardId of secrets) {
|
||||
assert.ok(!serialized.includes(`"${cardId}"`), `the tally carries card id "${cardId}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every seat sees the SAME tally — it is the table\'s account, not a private one', () => {
|
||||
const s = midGame(3, 5555);
|
||||
const tallies = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p as PlayerIndex).tally);
|
||||
for (const t of tallies) assert.deepEqual(t, tallies[0], 'the tally differs by seat');
|
||||
});
|
||||
|
||||
it('only the viewer\'s own hand and handCount are non-public — everything else matches across seats', () => {
|
||||
// The redaction surface is four fields (§7), not sixty event types. Cross-check that seats agree
|
||||
// on everything else a Frame carries about shared state.
|
||||
|
||||
@@ -412,3 +412,122 @@ describe('the four transient signals (2026-08-23)', () => {
|
||||
assert.equal(later.scheduled, undefined, 'a reconnecting client was re-sent an old timetable flash');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§3.3 extended play across the server (Gitea#11)', () => {
|
||||
/**
|
||||
* A one-Day game, so these tests reach the end of the timetable by actually PLAYING to it.
|
||||
*
|
||||
* There is no back door into a session's engine state and there should not be — `connect`,
|
||||
* `intent`, `exportSave` and `summary` are the whole surface. So the clock is run down through the
|
||||
* same calls a client makes, which has the side benefit of exercising the real path: what is under
|
||||
* test here is the session's handling of the vote (the turn guard, the bots, the saved status),
|
||||
* and reaching it any other way would prove less.
|
||||
*/
|
||||
const oneDay: GameConfig = { ...config, days: 1 };
|
||||
|
||||
/** What seat `seat` can currently see. `connect` always yields a full Frame, never a delta. */
|
||||
const frameOf = (session: GameSession, seat: PlayerIndex) => session.connect(seat).frame!;
|
||||
|
||||
/** Plays until the game stops asking for ordinary moves. Returns the Frame it stopped on. */
|
||||
function playToTheEnd(session: GameSession, seats: PlayerIndex[]) {
|
||||
let seq = 0;
|
||||
for (let i = 0; i < 5_000; i++) {
|
||||
const acting = seats.find((s) => session.connect(s).menu !== null);
|
||||
if (acting === undefined) break;
|
||||
const menu = session.connect(acting).menu!;
|
||||
if (menu.options.length === 0) break;
|
||||
if (!session.intent(acting, seq++, menu.options[0]!).accepted) break;
|
||||
}
|
||||
return { frame: frameOf(session, seats[0]!), seq };
|
||||
}
|
||||
|
||||
it('stops to ask rather than ending, and every seat can see the question', () => {
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
const { frame } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frame.status, 'awaitingExtension', 'the game did not stop to ask');
|
||||
assert.ok(frame.official, 'the official result did not reach the client');
|
||||
assert.deepEqual(frame.extensionVotes, [null, null], 'the votes did not reach the client');
|
||||
});
|
||||
|
||||
it('accepts the vote from a seat that is not the current actor', () => {
|
||||
// `currentActor` is null once the game has stopped, so the ordinary turn guard would refuse
|
||||
// every vote with NOT_YOUR_TURN. Both seats vote here and neither of them is the actor.
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
const a = session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true });
|
||||
assert.equal(a.accepted, true, 'seat 0 could not vote');
|
||||
const b = session.intent(1 as PlayerIndex, seq + 2, { type: 'game.extend', player: 1, agree: true });
|
||||
assert.equal(b.accepted, true, 'seat 1 could not vote');
|
||||
|
||||
const after = frameOf(session, 0 as PlayerIndex);
|
||||
assert.equal(after.extraDays, 1, 'a unanimous table was not given its Day');
|
||||
assert.equal(after.status, 'active', 'play did not resume');
|
||||
});
|
||||
|
||||
it('bots agree only once every human has, and never lead', () => {
|
||||
// "Bots will not disagree with the human. Humans get to vote first" (Jesse, 2026-08-28).
|
||||
const session = createSession(42, oneDay, ['Alice', 'Botty'], [1 as PlayerIndex]);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).status, 'awaitingExtension');
|
||||
assert.equal(
|
||||
frameOf(session, 0 as PlayerIndex).extensionVotes[1],
|
||||
null,
|
||||
'the bot voted before the human did',
|
||||
);
|
||||
|
||||
session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true });
|
||||
const after = frameOf(session, 0 as PlayerIndex);
|
||||
assert.equal(after.extraDays, 1, 'the bot did not follow the human into another Day');
|
||||
assert.equal(after.status, 'active');
|
||||
});
|
||||
|
||||
it('a human refusal ends it, and no bot overrides that', () => {
|
||||
const session = createSession(42, oneDay, ['Alice', 'Botty'], [1 as PlayerIndex]);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: false });
|
||||
const after = frameOf(session, 0 as PlayerIndex);
|
||||
assert.equal(after.status, 'finished');
|
||||
assert.equal(after.extraDays, 0);
|
||||
});
|
||||
|
||||
it('REGRESSION: an all-bot game ends rather than hanging on the question', () => {
|
||||
/**
|
||||
* `driveBots` loops on `currentActor`, which is null the moment the game stops to ask — so it
|
||||
* cannot cast the vote itself, and the bots' vote is driven separately. The first cut of that
|
||||
* driver returned early when there were no humans to follow, on the reasoning that a bot-only
|
||||
* table would decline through the ordinary path. It has no ordinary path: nothing ever asked
|
||||
* the bots, and an all-bot session sat on the question for ever without reaching `finished`.
|
||||
* Caught by `summary()`'s own "a finished game waits on nobody" test.
|
||||
*/
|
||||
const session = createSession(4242, oneDay, ['A', 'B'], [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).status, 'finished', 'the bots never answered');
|
||||
assert.equal(session.summary().waitingOn, null);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).extraDays, 0, 'bots voted themselves another Day');
|
||||
});
|
||||
|
||||
it('saves a game awaiting its vote as ACTIVE, so a restart resumes it', () => {
|
||||
// `server/index.ts` never loads a `finished` game back into memory. A game paused on the
|
||||
// extension question is waiting on its table, not over — persisting it as finished would strand
|
||||
// it on disk mid-decision.
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
assert.equal(frameOf(session, 0 as PlayerIndex).status, 'awaitingExtension');
|
||||
assert.equal(session.exportSave().status, 'active', 'a paused game was saved as finished');
|
||||
assert.equal(session.summary().status, 'active');
|
||||
});
|
||||
|
||||
it('resumes a paused game from its history, vote and all', () => {
|
||||
const session = createSession(42, oneDay, ['Alice', 'Bob']);
|
||||
const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]);
|
||||
session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true });
|
||||
|
||||
const resumed = resumeSession(session.exportSave());
|
||||
const a = frameOf(session, 0 as PlayerIndex);
|
||||
const b = frameOf(resumed, 0 as PlayerIndex);
|
||||
assert.deepEqual(b.extensionVotes, a.extensionVotes, 'the votes did not survive the replay');
|
||||
assert.equal(b.extraDays, a.extraDays, 'the extra Day did not survive the replay');
|
||||
assert.deepEqual(b.official, a.official, 'the official result did not survive the replay');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,13 @@ describe('a local session plays the same game as the calls it replaced', () => {
|
||||
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
||||
|
||||
const f = session.view();
|
||||
assert.equal(f.status, 'finished');
|
||||
/**
|
||||
* `awaitingExtension` since Gitea#11, not `finished`: both loops stop when there is no actor,
|
||||
* and a days-based ending now parks the game on the "play one more Day?" question rather than
|
||||
* ending it outright. What this test is actually about is unchanged — the two sides reach the
|
||||
* SAME position — and the assertion below is still the one carrying that.
|
||||
*/
|
||||
assert.equal(f.status, 'awaitingExtension');
|
||||
assert.equal(f.status, view(game).status);
|
||||
assert.equal(f.day, view(game).day);
|
||||
assert.deepEqual(f.cells, view(game).cells, 'the board differs across the boundary');
|
||||
|
||||
+20
-12
@@ -363,21 +363,29 @@ describe('end-of-game statistics', () => {
|
||||
* and so removing this line is what proves the bot has been fixed.
|
||||
*/
|
||||
/**
|
||||
* RED FLAGS JOINS IT (Gitea#3), and for the same reason — the rule is reachable and the bot will
|
||||
* not take it.
|
||||
* RED FLAGS JOINS IT, and the reason CHANGED with Gitea#19 — the exemption stays, but it no
|
||||
* longer means what it used to.
|
||||
*
|
||||
* MEASURED over 600 games: `maneuver.redFlags` is OFFERED 4,212 times, first in game 5 — so the
|
||||
* rule is live and constantly available. The bot PLAYS it 4 times, first in game 252. At 200
|
||||
* games this canary sees nothing and calls it unreachable, which is the opposite of the truth.
|
||||
* IT USED TO MEAN "the bot will not take it": measured over 600 games under the old rule,
|
||||
* `maneuver.redFlags` was OFFERED 4,212 times and PLAYED 4. The card protected a stopped train
|
||||
* out on the Mainline, it was always available, and the bot simply declined it.
|
||||
*
|
||||
* It got rarer for two compounding reasons, neither of them a broken rule: Gitea#14 took Red
|
||||
* Flags from 5 copies to the sheet's 3, and Gitea#3 shortened most crossings to a single Stage,
|
||||
* so the window in which a train is STANDING on a Mainline card — the only place the card may be
|
||||
* played — is now usually one Stage wide.
|
||||
* SINCE Gitea#19 the bot would take it every time — `worthFlagging` accepts the out-of-phase
|
||||
* prompt unconditionally, because the engine only raises that prompt when an arrival is
|
||||
* certainly about to collide, so there is nothing left for the bot to judge. It still never
|
||||
* plays one. MEASURED after the redesign, 200 solitaire games: `redFlagsSet` fires ZERO times.
|
||||
*
|
||||
* The bot's unwillingness is the thing worth fixing, and it is in TODO.md under Bot Performance.
|
||||
* Exempted BY NAME so the other forty-odd checks stay live, and so deleting this line is what
|
||||
* proves the bot has learned to use it.
|
||||
* The reason is now arithmetic rather than judgement, and it is worth writing down because it
|
||||
* says what would actually change it. The prompt needs two things to coincide — an arrival that
|
||||
* would collide (0.14 collisions per game, so roughly one game in seven) AND the district's
|
||||
* owner holding a Red Flags card at that moment, out of a three-card hand drawn from 121. The
|
||||
* bot also never plants a flag speculatively, which is the other half of the card and the half
|
||||
* a human would use to buy time for switching.
|
||||
*
|
||||
* So this canary is measuring deck luck, not reachability. `test/mainline-cards.test.ts`
|
||||
* exercises both halves of the rule end to end on a hand-built board, which is where the
|
||||
* behaviour is actually pinned. Removing this line still proves something worth proving — that
|
||||
* the bot has learned to plant a flag on purpose rather than only when handed one.
|
||||
*/
|
||||
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch', 'event redFlagsSet'];
|
||||
const found = anomalies(report.perGame);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* THE EVENT TALLY — Gitea#16's statistics, and the one property they depend on.
|
||||
*
|
||||
* "I don't know if we keep statistics on…" is the question the issue opens with. Nothing was being
|
||||
* kept; `GameState.tally` now is, folded from the event stream at the two places every event passes
|
||||
* through (`engine/tally.ts` explains which and why).
|
||||
*
|
||||
* The property that matters is EXACTLY ONCE. A statistic folded twice reads high and a statistic
|
||||
* folded nowhere reads zero, and both are indistinguishable from a quiet game when you are looking
|
||||
* at a results screen. So the central test here does not assert particular numbers: it plays real
|
||||
* games, collects every event the engine emitted along the way, counts them independently, and
|
||||
* checks the tally against that count. A fold hooked in the wrong place fails it whatever the seed.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance } from '../src/engine/advance.ts';
|
||||
import { tallyEvent } from '../src/engine/tally.ts';
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { developerBot } from '../src/sim/bot.ts';
|
||||
import type { GameEvent } from '../src/engine/events.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
|
||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
mode: 'solitaire',
|
||||
days: 3,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
});
|
||||
|
||||
/** Plays a whole game with the developer bot, keeping every event the engine produced. */
|
||||
function playKeepingEvents(
|
||||
seed: number,
|
||||
over: Partial<GameConfig> = {},
|
||||
names = ['Jesse'],
|
||||
): { state: GameState; events: GameEvent[] } {
|
||||
const state = createGame({ id: 'g', seed, config: baseConfig(over), playerNames: names });
|
||||
const events: GameEvent[] = [];
|
||||
for (let i = 0; i < 20_000; i++) {
|
||||
const r = advance(state);
|
||||
events.push(...r.events);
|
||||
if (state.status === 'finished') break;
|
||||
if (!r.needsInput) continue;
|
||||
|
||||
// The bot declines an extension, so this terminates on the timetable it was dealt (Gitea#11).
|
||||
const actor =
|
||||
state.status === 'awaitingExtension'
|
||||
? state.extensionVotes.findIndex((v) => v === null)
|
||||
: state.clock.pendingDecision !== null
|
||||
? state.clock.superintendent
|
||||
: state.clock.currentActor;
|
||||
if (actor === null || actor < 0) break;
|
||||
const options = legalActions(state, actor);
|
||||
if (options.length === 0) break;
|
||||
const applied = applyIntent(state, actor, developerBot.choose(state, actor, options));
|
||||
if (!applied.ok) break;
|
||||
events.push(...applied.events);
|
||||
}
|
||||
return { state, events };
|
||||
}
|
||||
|
||||
/** Counts events the way `tally.ts` should have, without sharing any of its code. */
|
||||
function countIndependently(events: GameEvent[]) {
|
||||
let sum = 0;
|
||||
const n = (type: GameEvent['type']): number => events.filter((e) => e.type === type).length;
|
||||
for (const e of events) {
|
||||
if (e.type === 'carsCoupled' || e.type === 'carsDropped') sum += e.stock.length;
|
||||
}
|
||||
return {
|
||||
trainsCompleted: n('trainCompleted'),
|
||||
loadsCompleted: n('loadCompleted'),
|
||||
unloadsCompleted: n('unloadCompleted'),
|
||||
loadsStarted: n('loadStarted'),
|
||||
unloadsBegun: n('unloadBegan'),
|
||||
passengersBoarded: n('passengersBoarded'),
|
||||
passengersDetrained: n('passengersDetrained'),
|
||||
cardsDrawn: n('cardDrawn'),
|
||||
cardsPlayed: n('cardPlayed'),
|
||||
cardsDiscarded: n('cardDiscarded'),
|
||||
officeUpgrades: n('officeUpgraded'),
|
||||
flyingSwitches: n('flyingSwitch'),
|
||||
extrasStarted: n('extraStarted'),
|
||||
secondSections: n('secondSectionOrdered'),
|
||||
trainsHeld: n('trainHeld'),
|
||||
trainsDiverted: n('trainDiverted'),
|
||||
expediteFaults: n('expediteFault'),
|
||||
facilitiesUnjammed: n('facilityUnjammed'),
|
||||
dispatchBonusesUsed: n('dispatchBonusUsed'),
|
||||
clearancesRequested: n('clearanceRequested'),
|
||||
switchedCars: sum,
|
||||
};
|
||||
}
|
||||
|
||||
describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
for (const seed of [1, 7, 42, 116956197]) {
|
||||
it(`agrees with an independent count of the event stream — seed ${seed}`, () => {
|
||||
const { state, events } = playKeepingEvents(seed);
|
||||
const want = countIndependently(events);
|
||||
const t = state.tally;
|
||||
|
||||
assert.equal(t.trainsCompleted, want.trainsCompleted, 'trains through the Division');
|
||||
assert.equal(t.loadsCompleted, want.loadsCompleted, 'loads made up');
|
||||
assert.equal(t.unloadsCompleted, want.unloadsCompleted, 'loads broken');
|
||||
assert.equal(t.loadsStarted, want.loadsStarted, 'loads started');
|
||||
assert.equal(t.unloadsBegun, want.unloadsBegun, 'unloads begun');
|
||||
assert.equal(t.passengersBoarded, want.passengersBoarded, 'passengers boarded');
|
||||
assert.equal(t.passengersDetrained, want.passengersDetrained, 'passengers detrained');
|
||||
assert.equal(t.cardsDrawn, want.cardsDrawn, 'cards drawn');
|
||||
assert.equal(t.cardsPlayed, want.cardsPlayed, 'cards played');
|
||||
assert.equal(t.cardsDiscarded, want.cardsDiscarded, 'cards discarded');
|
||||
assert.equal(t.officeUpgrades, want.officeUpgrades, 'offices upgraded');
|
||||
assert.equal(t.flyingSwitches, want.flyingSwitches, 'flying switches');
|
||||
assert.equal(t.extrasStarted, want.extrasStarted, 'extras started');
|
||||
assert.equal(t.secondSections, want.secondSections, 'second sections');
|
||||
assert.equal(t.trainsHeld, want.trainsHeld, 'trains held');
|
||||
assert.equal(t.trainsDiverted, want.trainsDiverted, 'trains diverted');
|
||||
assert.equal(t.expediteFaults, want.expediteFaults, 'expedite faults');
|
||||
assert.equal(t.facilitiesUnjammed, want.facilitiesUnjammed, 'facilities unjammed');
|
||||
assert.equal(t.dispatchBonusesUsed, want.dispatchBonusesUsed, 'dispatch bonuses');
|
||||
assert.equal(t.clearancesRequested, want.clearancesRequested, 'clearances requested');
|
||||
assert.equal(t.carsCoupled + t.carsDropped, want.switchedCars, 'cars switched');
|
||||
});
|
||||
}
|
||||
|
||||
it('actually counted something — a tally of zeroes would pass the check above vacuously', () => {
|
||||
// The trap `stats.ts` warns about in its own doc comment: "this never happened" is a finding,
|
||||
// not something to scroll past. A fold hooked nowhere at all agrees perfectly with an
|
||||
// independent count of an event stream nobody looked at, so the exactly-once tests above cannot
|
||||
// catch it on their own.
|
||||
//
|
||||
// ASSERTED ON WHAT THE DEVELOPER BOT ACTUALLY DOES, which is not much: `node src/sim/harness.ts
|
||||
// 12 standard` means 1.1 Revenue per game at an 8% freight share and loses every game on the
|
||||
// revenue floor, and it goes whole games without coupling a single car. That is a known
|
||||
// property of the bot (TODO.md, Bot Performance) and not this fold's business — so this test
|
||||
// asserts on traffic and cards, which happen in every game, rather than on switching, which
|
||||
// would make it a bot-strength test wearing a statistics test's clothes.
|
||||
const { state, events } = playKeepingEvents(1);
|
||||
assert.ok(events.length > 500, `only ${events.length} events — the game barely ran`);
|
||||
assert.ok(state.tally.cardsDrawn > 0, 'no cards were drawn all game');
|
||||
assert.ok(state.tally.trainsCompleted > 0, 'no train ever left the Division');
|
||||
assert.ok(state.tally.cardsPlayed > 0, 'no card was ever played');
|
||||
});
|
||||
|
||||
it('splits Revenue into what was earned and what was given back', () => {
|
||||
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
|
||||
// lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually
|
||||
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
|
||||
// being told apart rather than both sitting at zero.
|
||||
for (const seed of [1, 7, 42]) {
|
||||
const { state } = playKeepingEvents(seed);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.equal(
|
||||
me.revenueGained - me.revenueLost,
|
||||
state.players[0]!.revenue,
|
||||
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
|
||||
);
|
||||
}
|
||||
const { state } = playKeepingEvents(42);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted');
|
||||
});
|
||||
|
||||
it('records a Circus set-up as the one-off it is, not as a streak', () => {
|
||||
/**
|
||||
* `trainStoodStill` is NOT "this train did not move this Stage". It fires only for a train whose
|
||||
* profile sets `stopEarnsPoint` — the X18 Circus — and `advance.ts` claims it once per train
|
||||
* with `stopPointClaimed`, so it can never fire twice for the same one.
|
||||
*
|
||||
* Gitea#16 asks for "longest engine sat on a siding" and its comment assumed this event would
|
||||
* answer it. It cannot, and a streak folded from it would have read "1 Stage" for ever. Pinned
|
||||
* here so that the day a real per-Stage signal is added, whoever adds it finds this test rather
|
||||
* than the old wrong assumption.
|
||||
*/
|
||||
const s = createGame({ id: 'g', seed: 1, config: baseConfig(), playerNames: ['Jesse'] });
|
||||
const feed = (e: GameEvent): void => tallyEvent(s, e);
|
||||
feed({ type: 'trainStoodStill', trainNumber: 18, where: '(0,0)' });
|
||||
assert.deepEqual(s.tally.circusStops, [{ trainNumber: 18, where: '(0,0)' }]);
|
||||
assert.ok(!('longestStand' in s.tally), 'a streak that cannot be computed is being reported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the official result freezes the tally with it (Gitea#11 + #16)', () => {
|
||||
it('records the statistics as they stood when the timetable ran out', () => {
|
||||
const { state } = playKeepingEvents(1);
|
||||
assert.ok(state.official, 'no official result was recorded');
|
||||
// Nothing was played after the ending in this game, so the two agree — which is the check that
|
||||
// the freeze happens AFTER the last batch of events is folded rather than before it.
|
||||
assert.equal(state.official!.tally.trainsCompleted, state.tally.trainsCompleted);
|
||||
assert.ok(state.official!.tally.cardsDrawn > 0, 'the frozen tally is empty');
|
||||
});
|
||||
|
||||
it('keeps the frozen copy still while the live tally moves on', () => {
|
||||
const s = createGame({ id: 'g', seed: 1, config: baseConfig(), playerNames: ['Jesse'] });
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
const frozen = s.official!.tally.cardsDrawn;
|
||||
|
||||
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
|
||||
tallyEvent(s, { type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'x' });
|
||||
assert.equal(s.tally.cardsDrawn, frozen + 1, 'the live tally did not move');
|
||||
assert.equal(s.official!.tally.cardsDrawn, frozen, 'the frozen tally moved with it');
|
||||
});
|
||||
});
|
||||
+209
-16
@@ -21,14 +21,16 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url';
|
||||
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||
import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, 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';
|
||||
import type { Frame } from '../src/sim/view.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||
import { advance as advanceEngine } from '../src/engine/advance.ts';
|
||||
import type { GameConfig } from '../src/engine/state.ts';
|
||||
import {
|
||||
overHandLimit,
|
||||
actionGroups,
|
||||
@@ -104,6 +106,18 @@ describe('the browser game plays', () => {
|
||||
it('reaches the end of a game through the same calls the page makes', () => {
|
||||
const { game, turns } = playThrough(77);
|
||||
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
||||
|
||||
/**
|
||||
* The timetable runs out and the game STOPS TO ASK rather than ending (Gitea#11) — `currentActor`
|
||||
* is null there, which is where `playThrough` breaks. Declining through `submit` finishes it,
|
||||
* which is worth doing here rather than merely asserting the pause: this test exists to prove a
|
||||
* whole game is playable through the calls the page makes, and since Gitea#11 the last of those
|
||||
* calls is the vote.
|
||||
*/
|
||||
assert.equal(game.state.status, 'awaitingExtension');
|
||||
assert.ok(game.state.official !== null, 'an ended game must have recorded its official result');
|
||||
assert.ok(submit(game, { type: 'game.extend', player: 0, agree: false }, 0), 'the page cannot decline');
|
||||
|
||||
assert.equal(game.state.status, 'finished');
|
||||
assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
|
||||
});
|
||||
@@ -1210,7 +1224,7 @@ describe('the page explains itself', () => {
|
||||
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };
|
||||
s.clock.pendingDecision = { kind: 'clearance', train: 'tray2', occupiedBy: 'tray3' };
|
||||
|
||||
const allow = describeIntent(s, { type: 'mainline.clearance', allow: true });
|
||||
const hold = describeIntent(s, { type: 'mainline.clearance', allow: false });
|
||||
@@ -1932,8 +1946,10 @@ describe('the static build', () => {
|
||||
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
|
||||
// `again` is created by the game-over screen before it is looked up.
|
||||
present.add('again');
|
||||
// Created by the ending screen (`renderEnding`) before they are looked up, so they are never in
|
||||
// the served HTML: `again` starts a new game, `results` reopens the results dialog, and the two
|
||||
// `extend-*` buttons are the extension vote (Gitea#11).
|
||||
for (const id of ['again', 'results', 'extend-yes', 'extend-no']) present.add(id);
|
||||
for (const id of asked) {
|
||||
assert.ok(present.has(id), `main.ts asks for #${id}, which the page does not contain`);
|
||||
}
|
||||
@@ -2606,10 +2622,42 @@ describe('the static build', () => {
|
||||
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), `${name} fetches something external`);
|
||||
}
|
||||
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
assert.match(splash, /href="\.\/play\.html"/, 'the splash does not link to the game');
|
||||
// `?solitaire` marks the door's intent explicitly (2026-08-29) so a browser that remembers a
|
||||
// multiplayer session cannot swallow it — see `start()`'s own comment on `wantsSolitaire`.
|
||||
assert.match(splash, /href="\.\/play\.html\?solitaire"/, 'the splash does not link to the game');
|
||||
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
|
||||
});
|
||||
|
||||
it('cache-busts with a tag that varies per build even where there is no git', () => {
|
||||
/**
|
||||
* THE BUG THIS PINS COST TWO RELEASES. `buildStamp`'s no-git fallback was the literal `nogit`,
|
||||
* and the `.s9pk` Dockerfile copies the working tree in WITHOUT `.git` — so every packaged
|
||||
* release published `?v=nogit`, byte-identical to the one before it, and a returning player's
|
||||
* browser refetched nothing. v0.7.5's setup screen and v0.7.6's fix to it both installed
|
||||
* correctly on `phoenix.local` and neither reached the browser that asked for them.
|
||||
*
|
||||
* Asserted against the SCRIPT rather than a built page, because the property is about what the
|
||||
* fallback does when `git rev-parse` fails, which a normal build here never exercises.
|
||||
*/
|
||||
const src = readFileSync(join(root, 'scripts/build-web.ts'), 'utf8');
|
||||
const fallback = /let git = ([^;]+);/.exec(src)?.[1] ?? '';
|
||||
assert.ok(fallback !== '', 'the no-git fallback moved and this test cannot see it any more');
|
||||
assert.doesNotMatch(fallback, /^'nogit'$|^"nogit"$/, 'the no-git fallback is a constant again');
|
||||
assert.match(fallback, /Date\.now\(\)/, 'the no-git fallback carries nothing that varies per build');
|
||||
});
|
||||
|
||||
it('lets a build-tagged URL be cached and nothing else', () => {
|
||||
// The other half of the same bug: the pages carry the `?v=` tags but cannot be versioned in
|
||||
// their own URL, so a cached `play.html` pins a player to the whole build it names. Only a
|
||||
// request that actually carries `?v=` may be stored — an untagged image or the replay manifest
|
||||
// has no way to announce a change.
|
||||
const src = readFileSync(join(root, 'src/server/http.ts'), 'utf8');
|
||||
assert.match(src, /'Cache-Control':\s*buildTagged\s*\?/, 'static responses no longer vary their caching');
|
||||
assert.match(src, /immutable/, 'a tagged asset is not allowed to be cached at all');
|
||||
assert.match(src, /serveStatic\(opts\.distDir, url\.pathname, res, url\.searchParams\.has\('v'\)\)/,
|
||||
'the ?v= tag is not reaching serveStatic, so every response falls back to no-cache');
|
||||
});
|
||||
|
||||
it('opens the multiplayer door from the splash, straight into the lobby', () => {
|
||||
// This door sat `disabled` and labelled "Coming soon" from before the server existed until
|
||||
// v0.5.2 — Phases 2-4 built a working lobby and nothing ever linked to it, so a player with a
|
||||
@@ -3283,12 +3331,97 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
|
||||
it('gives the page the dialog to fill', () => {
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
for (const id of ['dayenddlg', 'dayendbody']) {
|
||||
for (const id of ['dayenddlg', 'dayendbody', 'resultsdlg', 'resultsbody']) {
|
||||
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the end-of-game results screen (Gitea#16)', () => {
|
||||
/**
|
||||
* A finished game, built by running the clock off the end of a real one rather than by hand — the
|
||||
* screen reads `official`, and only the engine writes that.
|
||||
*/
|
||||
const finished = (over: Partial<GameConfig> = {}, names = ['Solitaire']): Frame => {
|
||||
const s = createEngineGame({
|
||||
id: 'results', seed: 4021,
|
||||
config: {
|
||||
mode: 'solitaire', days: 5, minCombinedRevenue: 12,
|
||||
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
},
|
||||
playerNames: names,
|
||||
});
|
||||
s.clock.day = s.config.days + 1;
|
||||
s.clock.stage = STAGES_PER_DAY;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advanceEngine(s);
|
||||
return snapshot(s, [], null);
|
||||
};
|
||||
|
||||
it('never prints a raw enum at the player', () => {
|
||||
// The bug the issue opens on: `GAME OVER — revenueFloor` is an internal identifier, shown at
|
||||
// the one moment the game has the player's whole attention.
|
||||
for (const f of [finished(), finished({ minCombinedRevenue: 0 })]) {
|
||||
const html = resultsHtml(f);
|
||||
for (const raw of ['revenueFloor', 'daysElapsed', 'collisionFloor']) {
|
||||
assert.ok(!html.includes(raw), `the results screen prints the raw reason "${raw}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('says why the game ended, in a sentence, with the game\'s own numbers in it', () => {
|
||||
const html = resultsHtml(finished());
|
||||
assert.ok(html.includes('closed short'), `the revenue-floor ending is not explained:\n${html}`);
|
||||
assert.ok(html.includes('<b>12</b>'), 'the floor that was missed is not named');
|
||||
});
|
||||
|
||||
it('reports the rules the game was actually dealt under', () => {
|
||||
const html = resultsHtml(finished({ mode: 'coop' }, ['Ada', 'Bo']));
|
||||
assert.ok(html.includes('The rules in play'), 'the rules section is missing');
|
||||
assert.ok(html.includes('Co-op'), 'the mode is not reported');
|
||||
assert.ok(html.includes('per load'), 'the pay rates are not reported');
|
||||
});
|
||||
|
||||
it('reports the railroad from the tally, not from nothing', () => {
|
||||
const html = resultsHtml(finished());
|
||||
assert.ok(html.includes('Trains through the Division'), 'the tally section is missing');
|
||||
});
|
||||
|
||||
it('names the winner the OFFICIAL result named, not whoever leads now (Gitea#11)', () => {
|
||||
const f = finished({ mode: 'competitive', minCombinedRevenue: 0 }, ['Ada', 'Bo']);
|
||||
// Ada won at the timetable; Bo overtakes during extended play. The frozen result stands.
|
||||
f.official = {
|
||||
day: 5,
|
||||
outcome: { result: 'win', winner: 0, reason: 'daysElapsed' },
|
||||
revenues: [9, 2],
|
||||
collisionsTotal: 0,
|
||||
tally: f.tally,
|
||||
};
|
||||
f.extraDays = 3;
|
||||
f.players = [
|
||||
{ index: 0, seat: 0, name: 'Ada', revenue: 9, hand: 3 },
|
||||
{ index: 1, seat: 1, name: 'Bo', revenue: 40, hand: 2 },
|
||||
];
|
||||
const html = resultsHtml(f);
|
||||
assert.ok(html.includes('Ada takes the Division'), `the official winner is not the headline:\n${html}`);
|
||||
assert.ok(html.includes('winner'), 'nobody is marked as the winner in the standings');
|
||||
assert.ok(html.includes('After the timetable'), 'extended play is not reported at all');
|
||||
assert.ok(html.includes('3 more Days'), 'the extra Days are not counted');
|
||||
assert.ok(
|
||||
html.indexOf('Ada takes the Division') < html.indexOf('After the timetable'),
|
||||
'the informational section is above the official result',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves out the extended-play section entirely when nothing was extended', () => {
|
||||
assert.ok(!resultsHtml(finished()).includes('After the timetable'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('every file the build needs is actually in the repo (regression)', () => {
|
||||
it('does not gitignore a source page', () => {
|
||||
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
||||
@@ -3714,18 +3847,20 @@ describe('the lobby and the dialog ask the same questions', () => {
|
||||
return readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
};
|
||||
|
||||
it('carries every field of the shared block on both screens', () => {
|
||||
it('carries every field of the shared block on all three screens', () => {
|
||||
// `ss-` joined `lb-`/`ng-` 2026-08-29: the pre-game solitaire setup screen drives the identical
|
||||
// block ("asking first is the only path"). Same drift guard, one more prefix.
|
||||
const html = page();
|
||||
for (const prefix of ['lb-', 'ng-']) {
|
||||
for (const prefix of ['lb-', 'ng-', 'ss-']) {
|
||||
for (const selector of fieldSelectors(prefix)) {
|
||||
assert.ok(html.includes(selector), `the ${prefix} block is missing ${selector}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('offers all five game types on both screens', () => {
|
||||
it('offers all five game types on all three screens', () => {
|
||||
const html = page();
|
||||
for (const prefix of ['lb-', 'ng-']) {
|
||||
for (const prefix of ['lb-', 'ng-', 'ss-']) {
|
||||
for (const type of ['solitaire', 'coop', 'competitive', 'cutthroat', 'custom']) {
|
||||
assert.ok(
|
||||
html.includes(`name="${prefix}type" value="${type}"`),
|
||||
@@ -3772,7 +3907,7 @@ describe('the New Game dialog', () => {
|
||||
* questions through `settings-form.ts`, which addresses its radio groups by NAME through the
|
||||
* DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog.
|
||||
*/
|
||||
const load = async (search: string) => {
|
||||
const load = async (search: string, stored: Record<string, string> = {}) => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
|
||||
const served = new Set(
|
||||
@@ -3804,6 +3939,13 @@ describe('the New Game dialog', () => {
|
||||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
||||
// The pre-game setup screen (Gitea, "asking first is the only path", 2026-08-29) drives the
|
||||
// same shared block under the `ss-` prefix — one set here too, matching the markup's own
|
||||
// `checked` defaults rather than the dialog's (Solitaire, not Co-op: there is no live game to
|
||||
// reopen on, so the static default IS the Solitaire default).
|
||||
'ss-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
|
||||
};
|
||||
const matching = (sel: string): Radio[] => {
|
||||
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
|
||||
@@ -3856,7 +3998,7 @@ describe('the New Game dialog', () => {
|
||||
origin: 'http://box.local',
|
||||
pathname: '/play.html',
|
||||
};
|
||||
const store = new Map<string, string>();
|
||||
const store = new Map<string, string>(Object.entries(stored));
|
||||
g['localStorage'] = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
@@ -4041,11 +4183,62 @@ describe('the New Game dialog', () => {
|
||||
assert.match(String(els.get('gametype')!['title']), /Days: 5/, 'the tooltip does not carry the victory conditions');
|
||||
});
|
||||
|
||||
it('asks before the first deal — a bare visit shows the setup screen, not a dealt game', async () => {
|
||||
// Jesse, 2026-08-29: "let the user choose their options like the start of a multiplayer game";
|
||||
// "asking first is the only path". A saved game, an explicit seed, or a URL a Deal already wrote
|
||||
// (checked via `hand`, below) all skip this screen — nothing else does.
|
||||
const { els } = await load('');
|
||||
assert.equal(els.get('solitairesetup')!['hidden'], false, 'the setup screen stayed hidden');
|
||||
assert.equal(els.get('gameui')!['hidden'], true, 'a game was dealt before anyone chose anything');
|
||||
});
|
||||
|
||||
it('the solitaire door reaches solitaire even when this browser remembers a multiplayer game', async () => {
|
||||
// Found 2026-08-29 verifying v0.7.5 on phoenix.local: a browser with ANY remembered multiplayer
|
||||
// seat (`station-master.remote.v1`) could never reach solitaire's setup screen at all — a bare
|
||||
// `./play.html` load and the splash's "Play solitaire" door were indistinguishable from a reload
|
||||
// mid-multiplayer-game, and `start()` checked the remembered session first. The door now marks
|
||||
// its intent with `?solitaire`, the same way `?lobby` already does for the door on the other side.
|
||||
const remembered = JSON.stringify({
|
||||
games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } },
|
||||
last: 'g1',
|
||||
});
|
||||
const { els } = await load('?solitaire', { 'station-master.remote.v1': remembered });
|
||||
assert.equal(els.get('solitairesetup')!['hidden'], false, 'the door lost to the remembered game');
|
||||
assert.equal(els.get('gameui')!['hidden'], true, 'the remembered multiplayer game was resumed instead');
|
||||
});
|
||||
|
||||
it('a bare reload still resumes a remembered multiplayer game, unlike the solitaire door', async () => {
|
||||
// The other half of the fix above: `?solitaire` must be what changed, not remembered-session
|
||||
// resume itself, which is the correct behaviour for an actual reload mid-game (D11/D14).
|
||||
const remembered = JSON.stringify({
|
||||
games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } },
|
||||
last: 'g1',
|
||||
});
|
||||
const { els } = await load('', { 'station-master.remote.v1': remembered });
|
||||
assert.equal(els.get('gameui')!['hidden'], false, 'a bare reload did not resume the remembered game');
|
||||
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen wrongly took priority');
|
||||
});
|
||||
|
||||
it("the setup screen's own Deal does not bounce into a remembered multiplayer game", async () => {
|
||||
// The same bug one level deeper: `commitNewGame` writes `?hand=...`, not `?solitaire=...`, so the
|
||||
// very next load after pressing Deal has to be recognised as a solitaire navigation too — checked
|
||||
// via `hand`, the same signal `start()` already uses to skip the setup screen a second time.
|
||||
const remembered = JSON.stringify({
|
||||
games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } },
|
||||
last: 'g1',
|
||||
});
|
||||
const { els } = await load('?hand=sixRandom', { 'station-master.remote.v1': remembered });
|
||||
assert.equal(els.get('gameui')!['hidden'], false, "the Deal button's own URL was not honoured");
|
||||
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen re-asked its own answer');
|
||||
});
|
||||
|
||||
it('deals six cards by default now, matching what the lobby calls Solitaire', async () => {
|
||||
// Jesse, 2026-08-23: every game type opens with six. `SOLO_CONFIG` — the ENGINE's fallback, which
|
||||
// every sim measurement is taken against — deliberately did not move; this is the page's deal.
|
||||
const { els } = await load('');
|
||||
assert.match(String(els.get('houserules')!['textContent']), /6 cards/);
|
||||
// every sim measurement is taken against — deliberately did not move; this is what the setup
|
||||
// screen deals when nothing on it is touched, the same way the dialog always has.
|
||||
const { els, nav } = await load('');
|
||||
(els.get('ss-deal')!['onclick'] as () => void)();
|
||||
assert.match(nav.search, /hand=sixRandom/, "the setup screen's own default was not six cards");
|
||||
});
|
||||
|
||||
it('ignores a seed the browser cannot parse rather than refusing to deal', async () => {
|
||||
|
||||
Reference in New Issue
Block a user