Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e255076b5c | ||
|
|
7c35e002af |
+218
@@ -19,6 +19,224 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.4.9f — 2026-08-22
|
||||
|
||||
Three more from the v0.4.9e gameplay-testing round, filed as Gitea issues: **#4** extras did not start
|
||||
where the player said, **#6** train cards could be discarded, **#7** four train cards had the wrong
|
||||
coach counts. Two further bugs were found underneath #4 and are fixed with it. **Gitea#2 is diagnosed
|
||||
but NOT fixed** — it needs a ruling, and the reasoning is in `TODO.md`.
|
||||
|
||||
The same change ships as **0.6.2** on the main line. The full reasoning for every item is in that
|
||||
line's CHANGELOG; this entry records what reached the playtest build and what differs.
|
||||
|
||||
### Gitea#4 — an Extra starts where the player puts it
|
||||
|
||||
Both Division Points are now offered, not the one the train's number dictates, and **the start decides
|
||||
the direction** rather than the number — the two cannot both hold, since an odd westbound Extra placed
|
||||
at the WEST end would leave the Division on its first move having crossed nothing and be paid for the
|
||||
run. At an Interchange or a Control Point, where both ways are real runs, the player picks.
|
||||
|
||||
The Interchange start is a **yard**, off the running line, which is what makes the Superintendent
|
||||
clause work: placing an Extra there can never force a collision however busy the card is; a guaranteed
|
||||
collision holds it in the yard for another Stage and it tries again; a potential one is the
|
||||
Superintendent's to rule on. Those are §8.1's two existing answers, so nothing new decides collisions.
|
||||
|
||||
Where an Extra may start is a **new setting in the New Game dialog** — Division Points and the
|
||||
Interchange only, plus your own Control Point, or plus any player's — defaulting to what the engine
|
||||
already did, so this build does not change under its testers mid-line.
|
||||
|
||||
**Found underneath it, and both had been shipping:** an Extra started anywhere but a Division Point
|
||||
could never be given a consist and ran empty (so the Control Point start has been broken since it was
|
||||
added), and `collide` left destroyed trains' transits on the Mainline card, which permanently poisoned
|
||||
that card for every later train.
|
||||
|
||||
**The Mainline cards are now dealt from the printed deck without replacement.** They were drawn from
|
||||
the nine types WITH replacement, so a Division could hold two Interchanges — and "an Extra may start at
|
||||
the Interchange if one is on the board" only reads as a rule if the board holds at most one.
|
||||
|
||||
### Gitea#6 — a train card is never discarded
|
||||
|
||||
Timetabled and Extra alike. Hold one for as many Stages and Days as you like; it never goes onto a
|
||||
Department pile. **No forcing mechanism was needed:** nothing discardable plus a hand over the limit
|
||||
leaves exactly one legal way to end the turn, and playing a train is unconditionally legal, so a hand
|
||||
of four trains cannot trap anyone. The hand panel says so on the train itself, and the blocked
|
||||
end-turn button changes its wording when every card held is a train.
|
||||
|
||||
### Gitea#7 — coach counts
|
||||
|
||||
**1/2 Crack Limited 3 coaches → 2. 5/6 The Sparrow 2 → 3.**
|
||||
|
||||
### What differs from the main line
|
||||
|
||||
- The **car-placement round rotation test** that accompanies #7 on main was not ported: that test
|
||||
arrived with the 0.6 multiplayer work and does not exist on this line, so there was nothing for the
|
||||
coach-count change to follow.
|
||||
- `src/web/main.ts` and `test/web.test.ts` needed hand-resolution — this line's New Game dialog has
|
||||
neither the mode radios nor the victory dials, so only the Extra-start parts were taken.
|
||||
- The `content.ts` comment pass came across whole; the `TODO.md` documentation item did not, since
|
||||
this line keeps its own TODO.
|
||||
|
||||
**Both published replays were retired by the rules change and re-recorded.** 623 tests pass.
|
||||
|
||||
### The deploy script was broken by a host upgrade
|
||||
|
||||
Not part of the playtest report — found trying to publish this build. Every deploy died with
|
||||
`login failed: 404 404 page not found`, because the File Browser host has been upgraded to
|
||||
**FileBrowser Quantum**, a fork whose API differs from the v2.63 one `deploy-web.ts` was written
|
||||
against. Three things moved at once, each fatal on its own:
|
||||
|
||||
1. **Auth is a session COOKIE**, not a JWT in the response body sent back as `X-Auth:`.
|
||||
2. **The password is a header** — `X-Password`, URL-encoded — not a JSON body field.
|
||||
3. **The path is a query parameter** (`?path=`), and every resource call must also name a
|
||||
**`source`**: Quantum can serve several named stores and refuses any call that does not say which
|
||||
("no source provided"). The v2.63 API had no such concept.
|
||||
|
||||
Rewritten against the running instance's own bundle rather than guessed — the same discipline the
|
||||
v2.63 version was written with — and each path confirmed against the live host by response code: an
|
||||
endpoint that exists answers a bad password with **401**, one that does not answers **404**. The
|
||||
source is discovered from `GET /api/settings/sources`; one configured source is used silently, and
|
||||
several makes the script stop and list them rather than deploy the site into the wrong store.
|
||||
`FB_SOURCE` overrides it, and `FB_OTP` carries a two-factor code.
|
||||
|
||||
---
|
||||
|
||||
## 0.4.9e — 2026-08-22
|
||||
|
||||
Six bugs came back from a gameplay-testing session on 0.4.9d. Five are fixed here; the sixth could not
|
||||
be reproduced and is written up in `TODO.md` with the two questions that would pin it down. Patched
|
||||
onto the 0.4.9d line, and applied to the 0.5/0.6 line as the same change.
|
||||
|
||||
### Two trains at one platform answered to one button
|
||||
|
||||
**REPORTED:** "Operating two trains in a station: the select button does not work. Regardless of which
|
||||
you pick, it is always one train, not the other."
|
||||
|
||||
It did not work because there was nothing for it to do. `porter.board` and `porter.detrain` carried
|
||||
**no tray at all** — `{ type, at }` and nothing else — so there was one "board passengers at (0,0)"
|
||||
button however many trains were standing at the platform, and the reducer walked `adOccupancy` and
|
||||
filled the first empty coach it met. The roster chip the player clicked chose which crew the board
|
||||
DREW and nothing else. Two independent things were wrong at once:
|
||||
|
||||
- `check` asked whether SOME train at the Office had an empty coach, skipping any whose card refuses
|
||||
passenger work (`refusesPassengers`, `refusesThisOffice`). The reducer did not skip those. So with a
|
||||
Military train and an ordinary one at the same platform, `check` said yes on behalf of the ordinary
|
||||
one and the reducer boarded the Military.
|
||||
- The action list collapses identical labels, and "board passengers at (0,0)" describes both trains —
|
||||
the same trap that once ate a turnout's second rotation and a Department discard.
|
||||
|
||||
Both intents now carry an optional `trayId`, one function (`passengerWork`) resolves which train and
|
||||
which coach for `check`, `execute` and the reducer alike, `legal.ts` enumerates one candidate per
|
||||
train standing at the Office, and the label names it: *"board passengers at (0,0) onto Train 9"*.
|
||||
`trayId` is OPTIONAL for the reason `switch.move`'s `via` is — intents are the canonical record every
|
||||
save and undo replay against, and absent still means "the first eligible train".
|
||||
|
||||
The events carry `trayId` and `coachIndex` rather than leaving the reducer to find them again, which
|
||||
is the lesson `unloadBegan`'s `carIndex` already taught: a reducer that re-derives the target is a
|
||||
second implementation of the rule, and it disagreed with the first.
|
||||
|
||||
### A load could be made and broken without going anywhere
|
||||
|
||||
**REPORTED, twice over:** "Freight House: boxcars loaded cannot be immediately unloaded. In the game
|
||||
we'll put the chip upside down in the tray to indicate." And: "Passenger stations: passengers just
|
||||
boarded cannot be immediately unloaded."
|
||||
|
||||
They could. A Freight House permits both directions, so the boxcar its own Laborers had just loaded
|
||||
was standing on its own industry track, loaded, with an empty of that type in the Division Yard and a
|
||||
free red box — every gate said yes. Passengers were worse: `porter.board` filled a coach and
|
||||
`porter.detrain` looked for "a loaded coach on a train at the Office", which is the coach that had
|
||||
just been filled. Full Revenue at both ends of a movement that never happened, for one Porter action.
|
||||
|
||||
**Jesse's rule, and it is wider than the report:** freight or passengers loaded anywhere in an Office
|
||||
Area may not be unloaded anywhere in that same Office Area — not at another facility, not in a later
|
||||
Stage. A train has to carry them to a different district. So a load carries a stamp naming the SEAT
|
||||
that made it (`RollingStock.origin`), and the stamp never expires; `laborer.beginUnload` and
|
||||
`porter.detrain` refuse a car stamped with the district they are standing in, with a rejection code of
|
||||
its own (`LOADED_IN_THIS_DISTRICT`) because "the car is loaded, the Laborer is free, the boxes are
|
||||
clear, and the only thing wrong is where it came from" deserves better than "wrong car".
|
||||
|
||||
**A seat, not a player**, because Employee Rotation moves players between chairs and the district
|
||||
stays with the chair. **Undefined, not −1**, for "no origin": the Division Yard opens with loaded cars
|
||||
and loaded coaches out of the common supply, and those are exactly the inbound traffic a solitaire
|
||||
district lives on — a sentinel inside `SeatIndex`'s own range is not a sentinel. And `pooled` strips
|
||||
the stamp at every yard push, because the stamp belongs to the LOAD: a train can retire at a Division
|
||||
Point with freight still aboard, and that car must not carry a district it left three Days ago into
|
||||
whatever train is made up from it next.
|
||||
|
||||
**Measured: −0.60 ± 0.10 Revenue a game** (t = −6.1) over 400 paired deals — 78 deals worse, 3 better,
|
||||
319 unchanged. That shape is the point. This is not a nerf spread across the game; it is a narrow
|
||||
piece of free Revenue coming off the board, and on four deals in five the bot never took it.
|
||||
|
||||
The screen's version of the upside-down chip: a car or coach loaded by this district reads *"loaded
|
||||
boxcar (loaded here)"* on the card, in the tray and in the facility panel.
|
||||
|
||||
### The Grocer's Warehouse shipped, and the Refinery received
|
||||
|
||||
**REPORTED:** "Grocer's warehouse should be receive only, does not ship anything out." And:
|
||||
"Refinery: only ships out tanks, does not receive anything."
|
||||
|
||||
Both were `flow: 'both'` in `content.ts`, put there deliberately and for a reason that has since
|
||||
collapsed. `card-reference.md` read: *"'Freight House' is not a card. It is the collective term for a
|
||||
freight facility that loads and unloads — the Grocer's Warehouse and the Oil Refinery."* If that were
|
||||
true, §9.3's "Passenger Facilities and Freight Houses permit cars to move each direction" named
|
||||
exactly those two, and they had to be two-way. But the engine has dealt a `freightHouse` card since
|
||||
before v0.4.9 — 6 copies, one slot each direction — so §9.3 names it, and the argument evaporates.
|
||||
|
||||
The card set says the same thing without needing the rules text. All three Refinery modifiers —
|
||||
Pipelines, Oil Depot, Viscosity Breakers — grant **+1 outbound**; a two-way Refinery would be the only
|
||||
industry in the game with no card able to raise one of its two directions.
|
||||
`StationMaster-Home-Deck-v0.4.5.md` prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
|
||||
Warehouse · Inbound · 0 out / 1 in".
|
||||
|
||||
So the Refinery ships and the Grocer's receives, and the **Freight House is the one two-way industry**
|
||||
— which also means the only same-district load-and-unload the district rule above has to refuse is a
|
||||
Freight House unloading its own work. The two fixes meet exactly where the report said they would.
|
||||
|
||||
A consequence worth naming rather than discovering: an **Ice House beside a Grocer's Warehouse is now
|
||||
a dead card**, its +1 outbound dropped on a direction the host does not have. That is the design, not
|
||||
an oversight — the Home Deck sheet says so outright, and names the Truck Dock's inbound grant beside
|
||||
the outbound-only Packing Sheds as the other example. `suppressedGrants` already reports it on the
|
||||
page. It does mean the v0.4.7 note in `TODO.md` that opened these two facilities up was half wrong,
|
||||
and it is annotated there rather than deleted: the *machinery* it built (an industry's printed flow is
|
||||
absolute; drop the grant, never open the direction) is exactly what makes this correction land.
|
||||
|
||||
### Not reproduced: cars left behind when backing up over them
|
||||
|
||||
**REPORTED:** "When I back up to collect standing cars and, further down the tracks, the caboose, I
|
||||
get the caboose but the cars remain. I can later drive right through them."
|
||||
|
||||
Not found, and not for want of looking. Cars on plain track on the way; cars spotted at an INDUSTRY on
|
||||
the way (Jesse's own guess at the shape); the train's own cut on the square it is pulling out of; a
|
||||
stale `standingWest`; an industry locked by MEN AT WORK. Every one couples the lot, and the last
|
||||
correctly blocks the whole route rather than letting the crew past. Three of them are now pinned in
|
||||
`apply.test.ts` so the case, when it is found, is somewhere none of them cover.
|
||||
|
||||
The reason it is hard to make happen is structural: coupling is mandatory (§A.4) and `exploreMoves`
|
||||
accumulates what it meets card by card, so a route that reaches the caboose has already met everything
|
||||
between. `carsOn` is the single answer to "what is standing here", and the movement walk, the sweep in
|
||||
`carsCoupled` and every renderer all ask it — so cars a train can drive through would have to be cars
|
||||
that are on screen and not in `carsOn`, and there is no such place.
|
||||
|
||||
There was one way to MAKE such a place, and it is closed: `flyingSwitch`'s reducer wrote the cut
|
||||
straight into `industryTrack`, which is not where `carsOn` looks on a Passenger Facility. `check`
|
||||
refuses a non-freight target so it never fired, but a trap that needs another rule to stay unsprung is
|
||||
still a trap; it goes through `carsOn` now.
|
||||
|
||||
`TODO.md` carries the two questions that would settle it: was there a second route to the caboose, and
|
||||
what did the move button say it would couple. The label names every car, so "couples caboose" and
|
||||
"couples 2 boxcars, caboose" are different bugs — the first is route selection, the second the sweep.
|
||||
|
||||
### Also
|
||||
|
||||
- **An unload never checks the facility's commodity** — found reading `laborer.beginUnload` for the
|
||||
district rule, not from play. It gates on `allows.inbound`, a loaded car, a matching empty in the
|
||||
yard and room in the red box, but never on `facilityCarTypes`, which `freightAgent.stockOutbound`
|
||||
does check. So a Freight House will unload a hopper. Left alone and logged: the district rule now
|
||||
refuses the one same-Office pairing that made it easy to reach, and the fix is a rule question about
|
||||
what an industry will accept, not a one-line guard.
|
||||
- **Both published replays that had gone dead were re-recorded** (`save-replay.ts 400 --top 3`). A save
|
||||
is a save from a particular ruleset, so a rules change retires the files that no longer replay —
|
||||
`harness.test.ts` catches it, which is what that test is for.
|
||||
|
||||
## 0.4.9d — 2026-08-21
|
||||
|
||||
Three bugs from the same playtest session, patched directly onto 0.4.9a rather than the in-progress
|
||||
|
||||
@@ -121,6 +121,14 @@ is the thing this machinery exists to prevent.
|
||||
it was worth more than the entire freight and passenger economy put together, for traffic nobody
|
||||
has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside
|
||||
it, and every save records the rules it was dealt under.
|
||||
- **A load may not be broken in the district that made it.** Freight or passengers loaded anywhere in
|
||||
an Office Area cannot be unloaded anywhere in that same Office Area — not at another facility, not
|
||||
in a later Stage. A train has to carry them to a different district first. The printed game turns
|
||||
the chip upside down in the tray; here the load carries the seat that made it (`RollingStock.origin`
|
||||
in `src/engine/state.ts`) and it never expires. Without it a Freight House could unload the boxcar
|
||||
its own Laborers had just loaded and a platform could detrain the passengers it had just boarded,
|
||||
each paying Revenue at both ends for a load that went nowhere: worth **0.60 ± 0.10 Revenue a game**
|
||||
to the developer bot over 400 paired deals, on 78 of them.
|
||||
- **A turnout can be laid on top of a card already down.** It upgrades a straight at any rotation, or
|
||||
a curve whose arc matches its own diverging leg — both strict port supersets of what they replace,
|
||||
so an upgrade can never sever an existing join. Without it a district could only hang off track that
|
||||
|
||||
@@ -14,8 +14,22 @@ to Rules Questions.
|
||||
|
||||
## Next
|
||||
|
||||
Nothing scheduled at the moment — v0.4.9's plan (coordinate labels, the no-switching fix, the
|
||||
expedite rewrite, the `evaluateClearance` bug, the splash artwork) is built; see Done below.
|
||||
Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issues.
|
||||
|
||||
1. ~~**Gitea#4 — confirm that extra trains start properly**~~ — done in v0.4.9f. Either Division
|
||||
Point, the Interchange with a direction chosen there, a Control Point gated by a new `extraStart`
|
||||
setting, and the Superintendent's hold on the way out of the Interchange yard. Reasoning in
|
||||
`docs/rules/implications.md` §7.
|
||||
2. ~~**Gitea#6 — players may not discard train cards**~~ — done in v0.4.9f. Timetabled and Extra
|
||||
alike; the forced play falls out of the hand limit rather than needing a mechanism of its own.
|
||||
Reasoning in `docs/rules/implications.md` §6.2.
|
||||
3. ~~**Gitea#7 — coach counts on four train cards**~~ — done in v0.4.9f. 1/2 Crack Limited 3 → 2,
|
||||
5/6 The Sparrow 2 → 3.
|
||||
4. **Gitea#2 — four porters, two passengers on the platform, and only one may be worked.**
|
||||
DIAGNOSED, AWAITING JESSE'S RULING — see Play Balance below.
|
||||
|
||||
The rest of v0.4.9's plan (coordinate labels, the no-switching fix, the expedite rewrite, the
|
||||
`evaluateClearance` bug, the splash artwork) is built; see Done below.
|
||||
|
||||
---
|
||||
|
||||
@@ -208,6 +222,32 @@ not read that drop as a deck problem.
|
||||
|
||||
## Play Balance
|
||||
|
||||
- [ ] **Gitea#2 — passenger operations starve themselves of coaches, and the game says nothing.**
|
||||
Reported from v0.4.9e play: the Sparrow pulls into the Terminal with two loaded coaches, two
|
||||
passengers wait on the platform, four porters are unused, and only ONE of the four intended
|
||||
actions can be taken. Reproduced from the save: the Division Yard holds **1 empty coach and 0
|
||||
loaded**, while the Classification Yard holds **4 loaded and 4 empty** that cannot come back.
|
||||
|
||||
**The engine is not deviating from the rules.** §9.2 discards the white coach into the
|
||||
Classification Yard on boarding, draws the white coach from the Division Yard on de-training,
|
||||
and §2.2 returns the Classification Yard only when the Division Yard is empty — all three
|
||||
implemented exactly. The problem is the interaction: a single global refill condition over a
|
||||
pile holding six commodities with very different demand, where coaches are consumed by BOTH
|
||||
halves of every passenger cycle. Traced over the reported game, the coach pool runs from 8+/8−
|
||||
to 0+/1− by Day 5.
|
||||
|
||||
**Three ways out, and it is Jesse's call which:** (a) refill when the Division Yard is dry of
|
||||
the type-and-state being asked for rather than dry of everything; (b) the same trigger but
|
||||
return only the cars of that type; (c) leave the rules alone and raise the coach count in
|
||||
`ROLLING_STOCK_SUPPLY`. Measure (a) or (b) over 400 paired seeds before shipping.
|
||||
|
||||
- [ ] **A blocked PASSENGER facility produces no impediment at all.** `impediments()`
|
||||
(`src/sim/narrate.ts`) opens with `if (!f || f.kind !== 'freight') continue`, so the "why
|
||||
nothing is moving" panel has never had anything to say about a platform. That is the second
|
||||
half of Gitea#2 and the half that is unambiguously a bug: the player was not merely blocked,
|
||||
he was given no reason — the button simply was not there. Worth fixing whichever way the
|
||||
supply question is settled.
|
||||
|
||||
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
|
||||
target is settled and freight carries its intended share; read no balance conclusion from a revenue
|
||||
number until the rules stop moving.
|
||||
@@ -396,6 +436,41 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
||||
|
||||
Blocked on a decision, not on work.
|
||||
|
||||
- [ ] **NOT REPRODUCED: "when I back up to collect standing cars and, further down the tracks, the
|
||||
caboose, I get the caboose but the cars remain. I can later drive right through them."**
|
||||
Reported against v0.4.9d by a playtester (not Jesse, who forwarded it and could not add detail;
|
||||
his guess was that the cars were spotted at an industry).
|
||||
|
||||
**What was tried, all of which works.** Cars on plain track on the way to the caboose; cars
|
||||
SPOTTED AT AN INDUSTRY on the way; the train's own cut standing on the square it is pulling out
|
||||
of; a stale `standingWest` on the intermediate card; the industry locked by MEN AT WORK (which
|
||||
correctly blocks the whole route rather than letting the crew past). Every one couples the lot.
|
||||
The first three are pinned in `apply.test.ts` — "backing up over a cut to something beyond it
|
||||
takes both" — so if the case is found later it is somewhere none of them cover.
|
||||
|
||||
**Why it is hard to make happen.** Coupling is mandatory (§A.4) and `exploreMoves` accumulates
|
||||
what it meets card by card, so a route that reaches the caboose has already met everything
|
||||
between. `carsOn` (`state.ts`) is the SINGLE answer to "what is standing here", and the movement
|
||||
walk, the sweep in `carsCoupled` and every renderer all ask it — so cars a train can drive
|
||||
through would have to be cars that are on screen and not in `carsOn`, and there is no such
|
||||
place. (One route to one was closed anyway: `flyingSwitch`'s reducer wrote the cut straight into
|
||||
`industryTrack`, which for a Passenger Facility is not where `carsOn` looks. `check` refuses a
|
||||
non-freight target, so it never fired.)
|
||||
|
||||
**The two questions that would settle it**, for whoever has the board: was there a SECOND route
|
||||
to the caboose — a parallel row, or a turnout — so the move could have gone round the cars? And
|
||||
what did the move button say it would couple? The label names every car (`describeIntent`), so a
|
||||
button that read "couples caboose" and one that read "couples 2 boxcars, caboose" are different
|
||||
bugs: the first is route selection, the second is the sweep.
|
||||
|
||||
- [ ] **An unload does not check the facility's commodity.** `laborer.beginUnload` gates on
|
||||
`allows.inbound`, a loaded car, an empty of that type in the Division Yard and room in the red
|
||||
box — but never on `facilityCarTypes(f)`, which `freightAgent.stockOutbound` does check. So a
|
||||
Freight House (boxcars) will unload a hopper. Found reading the code for the v0.4.9e district
|
||||
rule, not from play. Low impact today because the district rule now refuses the only same-Office
|
||||
pairing that made it easy to hit, and because the bot spots matching cars — but it is a rule the
|
||||
engine states in one direction and not the other.
|
||||
|
||||
- [ ] **WHERE THE LOCAL'S COACH STANDS WHILE ITS ENGINE WORKS (§A.4) — now hit in play, still open.**
|
||||
Trains 7/8 print "coach must remain on station track if switching", read as "the coach is never
|
||||
set out". A cut comes off an OUTER end, so a coach on one outer end with the engine on the
|
||||
|
||||
@@ -98,11 +98,18 @@ Playing a timetabled train card rolls the seeded D12 and places its number in th
|
||||
|
||||
The listed consist is a maximum, not a minimum: a train may depart with fewer cars, but must not exceed the listed categories, put a car behind a caboose, or leave with the engine buried among cars. A Crew Tray holds no more than four rolling-stock cars.
|
||||
|
||||
> **Changed 2026-08-22 (Gitea#7), Jesse's call:** the coach counts on **1/2 Crack Limited** and
|
||||
> **5/6 The Sparrow** were swapped — the Limited drops from three coaches to two, the Sparrow rises
|
||||
> from two to three. This is a change to the CARDS, not a correction to this table: `Trains3.pdf` and
|
||||
> the transcription in [`rules/implications.md`](rules/implications.md) §5 still show the original
|
||||
> numbers, and are right about what the printed cards said. `src/engine/content.ts` and this table
|
||||
> carry what the game plays.
|
||||
|
||||
| Train | Speed | Direction | Listed maximum consist | Implemented special rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1/2 Crack Limited | Fast | 1 west / 2 east | 3 coaches | No switching; passenger work only at Terminals; expedited. |
|
||||
| 1/2 Crack Limited | Fast | 1 west / 2 east | **2 coaches** | No switching; passenger work only at Terminals; expedited. |
|
||||
| 3/4 Express | Fast | 3 west / 4 east | 2 freight | May exchange at most one freight car at each grid location during its switching turn; expedited. |
|
||||
| 5/6 The Sparrow | Fast | 5 west / 6 east | 2 coaches | No switching; expedited. |
|
||||
| 5/6 The Sparrow | Fast | 5 west / 6 east | **3 coaches** | No switching; expedited. |
|
||||
| 7/8 Local | Slow | 7 west / 8 east | 1 freight, 1 coach | Its coach may not be set out during switching. |
|
||||
| 9/10 Heavy Freight | Slow | 9 west / 10 east | 3 freight, 1 caboose | — |
|
||||
| 11/12 Drag Freight | Slow | 11 west / 12 east | 2 freight, 1 caboose | — |
|
||||
|
||||
@@ -45,9 +45,13 @@ The PDF art labels this card “Yard”; this reference uses the implementation
|
||||
| Trestle | 60 mph. |
|
||||
| Interchange | 60 mph. A train may be reordered there only through the card’s printed “sort cars” concept; the current engine does **not** provide a Mainline sorting action for it. |
|
||||
|
||||
### PDF/code mismatch requiring correction
|
||||
### PDF/code mismatch — CORRECTED
|
||||
|
||||
`src/engine/content.ts` defines nine `MAINLINE_PROFILES` types: one Plains entry plus the eight other terrain types above. `setup.ts` selects uniformly from that nine-type list. The second Plains card shown in `Mainline Cards.pdf` is therefore not represented as a duplicate card or as extra Plains weight in setup. If the PDF inventory is authoritative, the setup selection needs a second Plains entry (or an equivalent weighted selection).
|
||||
**Was:** `src/engine/content.ts` defines nine `MAINLINE_PROFILES` types: one Plains entry plus the eight other terrain types above. `setup.ts` selected uniformly from that nine-type list, **with replacement**. The second Plains card shown in `Mainline Cards.pdf` was therefore not represented as a duplicate card or as extra Plains weight in setup — and, worse than a weighting error, a Division could be dealt two Interchanges, two Tunnels or two Trestles, none of which the deck contains.
|
||||
|
||||
**Now:** `MAINLINE_DECK` in `content.ts` is the inventory table above — ten drawable cards, Plains twice and the other eight once each — and `buildDivision` deals from it without replacement. The two Division Point cards are not in that deck: they are the fixed ends of the Division, laid by `buildDivision` itself rather than drawn.
|
||||
|
||||
The Interchange is what forced the correction. §7 lets an Extra be started at the Interchange "if one is on the board" (see `docs/rules/implications.md`, §7), which only reads as a rule if the board can hold at most one.
|
||||
|
||||
The executable state represents East and West Division Points as fixed end nodes, not as card records. They are functionally present at the ends of the Division, but are not represented as the two PDF cards in the deck/state model.
|
||||
|
||||
|
||||
@@ -42,17 +42,33 @@ Operational Rail wheel icon, an industry track of the stated length, Laborer ico
|
||||
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| Mine Tipple | Hopper | Outbound only | 3 | 3 | — | 4 | 2 |
|
||||
| Produce Shed | Reefer | Outbound only | 2 | 2 | — | 3 | 2 |
|
||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 | 2 |
|
||||
| Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4 | 2 |
|
||||
| Grocer's Warehouse | Boxcar | Inbound only | 2 | — | 2 | 3 | 2 |
|
||||
| Oil Refinery | Tank car | Outbound only | 3 | 2 | — | 4 | 2 |
|
||||
| Power Plant | Hopper | Inbound only | 3 | — | 3 | 4 | 2 |
|
||||
|
||||
Directions follow the commodity: coal originates at a Mine Tipple and is consumed at a Power Plant;
|
||||
produce ships out; a warehouse and a refinery do both. This gives §9 all three of its cases —
|
||||
outbound-only, inbound-only, and both.
|
||||
produce ships out; a warehouse receives. This gives §9 all three of its cases — outbound-only,
|
||||
inbound-only, and both — with the **Freight House the one card that does both**.
|
||||
|
||||
**"Freight House"** (§9.3, Appendix A) is not a card. It is the collective term for a freight facility
|
||||
that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery. §9.3's "Passenger Facilities
|
||||
and Freight Houses permit cars to move each direction" therefore names exactly those two.
|
||||
**The Grocer's Warehouse and the Oil Refinery are ONE-WAY** (corrected v0.4.9e, from gameplay
|
||||
testing): "Grocer's Warehouse should be receive only, does not ship anything out"; "Refinery: only
|
||||
ships out tanks, does not receive anything" (Jesse). The Direction column read "Both" for both, on the
|
||||
reading below that "Freight House" named exactly those two.
|
||||
|
||||
**And that reading is wrong.** §9.3/Appendix A's "Passenger Facilities and Freight Houses permit cars
|
||||
to move each direction" names a CARD: the engine deals a `freightHouse` industry, 6 copies, one
|
||||
Laborer and one slot each direction (`content.ts`), and has since before v0.4.9. This paragraph used
|
||||
to read "'Freight House' is not a card — it is the collective term for a freight facility that loads
|
||||
*and* unloads, the Grocer's Warehouse and the Oil Refinery", and that premise is what made those two
|
||||
two-way. `StationMaster-Home-Deck-v0.4.5.md` prints them one-way, and the modifier set agrees: all
|
||||
three Refinery modifiers (Pipelines, Oil Depot, Viscosity Breakers) grant **+1 outbound**, which would
|
||||
be an odd card set for a facility that receives half the time.
|
||||
|
||||
<!-- The Laborer and capacity numbers in the rows above were NOT re-verified against the engine in
|
||||
this pass — only the Direction column was, which is what playtesting reported on. `content.ts`
|
||||
gives every industry base 1 out / 1 in / 1 Laborer, so the table's 2s and 3s and the "Throughput"
|
||||
reasoning built on them are likely stale. Flagged rather than silently rewritten: Laborer counts
|
||||
are a balance question, not only a docs one. -->
|
||||
|
||||
### Throughput — why these Laborer counts
|
||||
|
||||
@@ -80,9 +96,10 @@ deliberately slower industries, unable to quite keep up with a dedicated player.
|
||||
That is why Laborer counts track Outbound capacity: Mine Tipple 3/3, Power Plant 3/3, Produce Shed
|
||||
2/2, Grocer's Warehouse 2/2. The numbers are derived from the action budget, not chosen freely.
|
||||
|
||||
The Oil Refinery is the exception at 3 Laborers against 2+2 capacity, and deliberately so: it serves
|
||||
two flows through one three-box pipeline, so its pipeline stays fuller than a one-way facility's and
|
||||
the third Laborer is earning its keep.
|
||||
<!-- The paragraph that stood here explained the Oil Refinery's third Laborer as the price of serving
|
||||
two flows through one pipeline. It serves one flow (v0.4.9e), so the explanation is gone with the
|
||||
premise; whether the Laborer count is still right is part of the same unverified block flagged
|
||||
above and in TODO.md. -->
|
||||
|
||||
Even so, Laborers are rarely what limits a player — spotting the empty car and hauling the loaded one
|
||||
away both cost switching actions from the same budget. See §7.
|
||||
@@ -258,12 +275,12 @@ with all boxes full:
|
||||
| Car | Facility demand | Supply | Headroom |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Hopper | Mine Tipple 3×2 + Power Plant 3×2 = 12 | 12 | exactly met |
|
||||
| Tank | Oil Refinery (2+2)×2 = 8 | 8 | exactly met |
|
||||
| Boxcar | Grocer's (2+2)×2 = 8 | 12 | 4 spare |
|
||||
| Tank | Oil Refinery 2×2 = 4 | 8 | 4 spare (was "exactly met" while the Refinery was two-way) |
|
||||
| Boxcar | Grocer's 2×2 = 4 | 12 | 8 spare (same correction) |
|
||||
| Reefer | Produce Shed 2×2 = 4 | 8 | 4 spare |
|
||||
| Coach | Terminal 4+4, per Office | 16 | scales with Office count |
|
||||
|
||||
Hoppers and tank cars are exactly met in the theoretical worst case, which cannot occur in practice —
|
||||
Hoppers are exactly met in the theoretical worst case, which cannot occur in practice —
|
||||
only 10 freight facility cards exist across a 52-card deck shared by all players, and the §2.2
|
||||
Classification Yard recycle returns stock to the Division Yard whenever it empties. Both are worth
|
||||
watching in playtesting.
|
||||
|
||||
@@ -24,8 +24,8 @@ Every defined term, alphabetized for lookup. The core comes from the Definitions
|
||||
| **Extra Platform** | Modifier card: +1 green and +1 red slot at a Passenger Facility. | §12.5 |
|
||||
| **Extra Train** | A one-and-done train; its card returns to the Salvage Yard on completion. Head-on card image, so the drawing player picks its direction. Numbered with an "X" prefix; the following number gives its seniority, and it yields to the Timetabled train of that number. | §2.3, §8 |
|
||||
| **Facility** | A business which loads/unloads cargo and freight. | §2.5 |
|
||||
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
|
||||
| **Freight House** | Not a card — the collective term for a freight facility permitting both directions, namely the Grocer's Warehouse and the Oil Refinery. | §9.3, §12.5 |
|
||||
| **Freight Facility** | Mine Tipples, Produce Sheds, Grocer's Warehouses, Oil Refineries, Power Plants, Freight Houses. Some allow only outbound, some only inbound, some both. Per-card values in §12.5. | §9, §12.5 |
|
||||
| **Freight House** | A sixth Freight Facility card, dealt like any other industry (`content.ts`) — this entry used to read "not a card, the collective term for the Grocer's Warehouse and the Oil Refinery", which the engine never agreed with. **The only industry that permits both directions**: the Grocer's Warehouse receives and the Oil Refinery ships, one way each (v0.4.9e). | §9.3, §12.5 |
|
||||
| **Highball** | When a train holding at an Office automatically departs. | §2.4 |
|
||||
| **Home Office** | The primary face-down deck cards are drawn from. 52 cards. | §2.6, §12.1 |
|
||||
| **Hopper** | Coal rolling stock (brown = loaded, white = empty). | §2.2 |
|
||||
|
||||
+127
-2
@@ -486,13 +486,19 @@ Hotel) are what grow them.
|
||||
|
||||
| # | Name | Speed | Consist | Rule |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1/2 | Crack Limited | Fast | 3 coaches | Stop at Terminals only. No switching. Expedite. |
|
||||
| 1/2 | Crack Limited | Fast | 3 coaches † | Stop at Terminals only. No switching. Expedite. |
|
||||
| 3/4 | Express | Fast | 2 freight | May drop or pick up one freight car at every location. Expedite. |
|
||||
| 5/6 | The Sparrow | Fast | 2 coaches | No switching. Expedite. |
|
||||
| 5/6 | The Sparrow | Fast | 2 coaches † | No switching. Expedite. |
|
||||
| 7/8 | Local | Slow | 1 freight + 1 coach | Coach must remain on station track if switching. |
|
||||
| 9/10 | Heavy Freight | Slow | 3 freight + caboose | |
|
||||
| 11/12 | Drag Freight | Slow | 2 freight + caboose | |
|
||||
|
||||
† **The two coach counts have since been swapped by Jesse** (Gitea#7, 2026-08-22): the Crack Limited
|
||||
now carries **2** coaches and The Sparrow **3**. The table above is left as `Trains3.pdf` prints it,
|
||||
because that is what this section is for — what the design SAYS. What the game plays is
|
||||
`src/engine/content.ts`, with the per-card table in
|
||||
[`../StationMaster-Home-Deck-v0.4.5.md`](../StationMaster-Home-Deck-v0.4.5.md).
|
||||
|
||||
### Extras (X13–X22) — ten distinct trains, not four generic ones
|
||||
|
||||
Appleseed Extra (MT freight only, may drop but not pick up), Fruit Growers Express (reefers only),
|
||||
@@ -783,3 +789,122 @@ Crossing is counted in Stages (Q1), so both trains simply run their counters dow
|
||||
Until this is settled the clearance buttons describe only what the engine actually does — they say
|
||||
the train "closes up behind" rather than promising a −5 risk that cannot occur. Wording that invents
|
||||
a consequence is worse than wording that under-sells one.
|
||||
|
||||
---
|
||||
|
||||
## §7 — where an Extra starts, and which way it runs
|
||||
|
||||
**Reported from playing v0.4.9e** (Gitea#4): "When extras are played the player doing so may choose
|
||||
where the extra starts. They may choose either division point. And if the interchange mainline card
|
||||
has been played, they may start the extra on that card and choose the direction from there. If there
|
||||
is potential for conflict with other trains in that area the superintendent may hold the extra."
|
||||
|
||||
**This supersedes an earlier ruling**, and the supersession is the interesting part. §2.3 gives every
|
||||
train its direction from its number — odd runs west, even runs east — and an earlier pass extended
|
||||
that to Extras explicitly: *"the number decides, like everything else on the timetable."* That reading
|
||||
cannot survive "either Division Point". An odd Extra placed at the WEST end would run west, leave the
|
||||
Division on its first move having crossed nothing, and be paid the completion Revenue for the run.
|
||||
|
||||
So for **Extras only**, the start decides the direction:
|
||||
|
||||
| Start | Direction |
|
||||
| --- | --- |
|
||||
| Western Division Point | east |
|
||||
| Eastern Division Point | west |
|
||||
| Interchange | player's choice |
|
||||
| Control Point (any Office above a Whistle Post) | player's choice |
|
||||
|
||||
A timetabled train is unchanged: its number still decides. The Extra cards were always printed
|
||||
`direction: 'playerChoice'` (§5) and the engine had been overriding it; they now mean it.
|
||||
|
||||
### The Interchange start is a YARD, not a spot on the running line
|
||||
|
||||
§7's last clause — "the superintendent may hold the extra" — is what settles how this is modelled.
|
||||
An Extra started at an Interchange stands in that card's **yard**, off the running line, and highballs
|
||||
onto the card itself at a later Mainline Phase. Three things follow, all of them Jesse's rule rather
|
||||
than an implementation convenience:
|
||||
|
||||
1. **Placing it can never force a collision**, however busy the card is. It is not on the road yet.
|
||||
2. **A guaranteed collision holds it in the yard** for another Stage, and it tries again next Stage.
|
||||
3. **A potential collision is the Superintendent's to rule on.**
|
||||
|
||||
Those last two are exactly §8.1's two answers — an absolute bar against a facing train, a judgment
|
||||
call against a following one — so the Extra leaves the yard through the same clearance check a train
|
||||
leaves a Division Point through. Nothing new decides collisions.
|
||||
|
||||
The Interchange keeps its printed "sort cars in new order" concept, still unimplemented (§6). Being
|
||||
the card with a Yard Limit is what makes it the one Mainline card a train can be made up on.
|
||||
|
||||
### Which starts are offered is a setting
|
||||
|
||||
The Division Points and the Interchange sit on shared ground and belong to nobody; starting an Extra
|
||||
inside a player's own district does not. That is a table preference rather than a rule, so it is set
|
||||
when the game is dealt (`extraStart`): Division Points and Interchange only, plus the playing
|
||||
player's own Control Point, or plus any player's Control Point. A **Whistle Post never qualifies at
|
||||
any setting** — being a place an Extra can start is part of what upgrading buys (§11).
|
||||
|
||||
### Two bugs found underneath it
|
||||
|
||||
- **The Mainline cards were rolled, not dealt.** `buildDivision` drew uniformly from the nine card
|
||||
TYPES **with replacement**, so a Division could be dealt two Interchanges or two Tunnels, and
|
||||
Plains — printed twice in the deck — carried the same weight as cards printed once.
|
||||
`docs/StationMaster-Mainline-Deck-v0.4.5.md` had already flagged the mismatch as needing
|
||||
correction; "an Extra may start at the Interchange if one is on the board" is what forced it, since
|
||||
that only reads as a rule if the board holds at most one. Now dealt from the printed ten-card deck
|
||||
without replacement.
|
||||
- **An Extra started anywhere but a Division Point ran empty.** `isBeingMadeUp` asked only "is this
|
||||
tray standing at a Division Point", which was the whole truth while that was the only place to
|
||||
build a train — so the Control Point start had shipped since it was added with a train that could
|
||||
never be given a consist, and the Interchange start would have shipped the same way. Found by
|
||||
playing it, not by the tests, which had only ever asserted where the tray landed.
|
||||
|
||||
---
|
||||
|
||||
## §6.2 — a train card is never discarded
|
||||
|
||||
**Jesse's ruling, v0.4.9e playtest** (Gitea#6): "Players are not allowed to discard Train cards. They
|
||||
may keep the card in their hand for multiple stages and even multiple days, but they may not discard
|
||||
it. If a player has three train cards in their hand, and they draw a fourth, then they must play one
|
||||
of those cards."
|
||||
|
||||
**Extras count.** An Extra is a train, even though it runs once and ends in the Salvage Yard where a
|
||||
Timetabled card joins the timetable for the rest of the game.
|
||||
|
||||
§6.2 as transcribed says only "the player must reduce his hand to no more than three cards" with no
|
||||
exception for any card type, so this is a ruling rather than a gap — the prototype rules do not
|
||||
address it either way.
|
||||
|
||||
### It needs no forcing mechanism, and that is the point
|
||||
|
||||
The interesting property of this rule is that the forced play falls out of two rules that already
|
||||
exist rather than needing a third:
|
||||
|
||||
1. a train card cannot be discarded, so it is not among the ways to shed a card; and
|
||||
2. `draw.end` already refuses while the hand is over the limit (§6.2).
|
||||
|
||||
A player holding four trains therefore has exactly one legal way to conclude the turn — play one —
|
||||
without anything in the engine ever computing "you must play a train". The corner cannot lock a
|
||||
player in, because **playing a train card is unconditionally legal**: `card.play`'s train case
|
||||
refuses only a board placement, and a train card played when the timetable is full still leaves the
|
||||
hand (it simply schedules nothing). Confirmed by playing it: a hand of four trains offers zero
|
||||
discards, no `draw.end`, and four plays.
|
||||
|
||||
The bot needed no rule of its own either. `legal.ts` enumerates candidates and filters them through
|
||||
`check`, so the option stops being offered; and the developer bot already reaches for `card.play`
|
||||
before it reaches for a discard. Measured over 400 games: 400/400 finished, revenue unmoved, and
|
||||
**trains scheduled 1.2 → 1.3** — the rule's intended effect, small because a bot rarely held four.
|
||||
|
||||
### Consequences
|
||||
|
||||
- **Two of the three published replays discarded train cards** (2 and 15 of them) and were retired
|
||||
and re-recorded. Jesse's call: "I'm okay with retiring the replays that no longer work under those
|
||||
old rules."
|
||||
- **The opening six-card hand is not exempt.** Under *six random cards* a player opens holding six
|
||||
against a limit of three; if four or more are trains, they all go onto the timetable on turn one.
|
||||
Jesse's call: "If the opening hand has lots of trains, then lots of trains will be placed on the
|
||||
board." Rare — roughly 1.5% of deals — but deliberate.
|
||||
- **The player is told, on the card and on the button.** `handDiscardable` on the Frame marks which
|
||||
cards may be shed, the hand panel says so in the card's own tooltip, and when EVERY card held is a
|
||||
train the blocked end-turn button changes its text to say a train must be played. That is the
|
||||
Gitea#2 lesson applied: a rule the player cannot see is a board with nothing to click and no reason
|
||||
given.
|
||||
|
||||
@@ -670,10 +670,15 @@ balance work was possible.
|
||||
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
||||
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
|
||||
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
|
||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
|
||||
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
|
||||
| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
|
||||
| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
|
||||
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
|
||||
|
||||
*Amended v0.4.9e.* The warehouse and the refinery were briefly "Both", on the reading that "Freight
|
||||
House" was a collective term for exactly those two and therefore what §9.3's "permit cars to move
|
||||
each direction" described. The Freight House turned out to be a card of its own (v0.5.0), and
|
||||
playtesting confirmed the one-way reading the sheet always printed.
|
||||
|
||||
*Rationale.* Directions follow the commodity and give §9 all three of its stated cases. Bulk
|
||||
industries get more Laborers and a 4-car track so the types feel distinct when choosing what to
|
||||
build — but the counts stay moderate because Laborers are **not** the binding constraint (see 10e),
|
||||
|
||||
@@ -735,8 +735,8 @@ Modifier effects, and track geometries — is catalogued in
|
||||
| --- | --- | --- | ---: | ---: | ---: | ---: |
|
||||
| Mine Tipple | Hopper | Outbound | 3 | 3 | — | 4 |
|
||||
| Produce Shed | Reefer | Outbound | 2 | 2 | — | 3 |
|
||||
| Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3 |
|
||||
| Oil Refinery | Tank | Both | 3 | 2 | 2 | 4 |
|
||||
| Grocer's Warehouse | Boxcar | Inbound | 2 | — | 2 | 3 |
|
||||
| Oil Refinery | Tank | Outbound | 3 | 2 | — | 4 |
|
||||
| Power Plant | Hopper | Inbound | 3 | — | 3 | 4 |
|
||||
|
||||
| Office | Porters | Green slots | Red slots |
|
||||
@@ -753,8 +753,11 @@ Modifier effects, and track geometries — is catalogued in
|
||||
| Extra Platform | +1 green and +1 red slot (passenger) |
|
||||
| Section Gang | +1 Laborer or +1 Porter |
|
||||
|
||||
**"Freight House"** (§9.3, Appendix A) is not a card — it is the collective term for a freight
|
||||
facility that both loads and unloads, namely the Grocer's Warehouse and the Oil Refinery.
|
||||
**"Freight House"** (§9.3, Appendix A) is a Freight Facility card, dealt like any other industry —
|
||||
this used to read "not a card, only the collective term for a facility that both loads and unloads",
|
||||
which the engine never agreed with. It is the **only** industry that permits both directions: the
|
||||
Grocer's Warehouse receives and the Oil Refinery ships, one way each (corrected v0.4.9e from gameplay
|
||||
testing — the "Both" reading was the other half of the same mistake about what "Freight House" meant).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.4.9d",
|
||||
"version": "0.4.9f",
|
||||
"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
File diff suppressed because it is too large
Load Diff
+92
-31
@@ -1,11 +1,29 @@
|
||||
/**
|
||||
* Build the solitaire site and push it to a FileBrowser instance.
|
||||
*
|
||||
* Written against File Browser v2.63's REST API, read from its own bundle rather than guessed:
|
||||
* REWRITTEN FOR **FileBrowser Quantum** (2026-08-23). The host was upgraded from File Browser v2.63
|
||||
* to the Quantum fork, whose API is different in three ways at once, and every deploy failed with
|
||||
* `login failed: 404 404 page not found` — the old `/api/login` simply is not there any more.
|
||||
*
|
||||
* POST /api/login {username, password, recaptcha} -> JWT as plain text
|
||||
* POST /api/resources/<dir>/ X-Auth: <jwt> -> create a directory
|
||||
* POST /api/resources/<file>?override=true X-Auth: <jwt>, body = bytes -> upload
|
||||
* Read from the running instance's own bundle rather than guessed, the same way the v2.63 version
|
||||
* was (`/public/static/assets/index-*.js`, gzipped — pipe it through `gunzip` before grepping), and
|
||||
* each path confirmed against the live host by the response code: an endpoint that exists answers a
|
||||
* bad password with **401**, one that does not answers **404**.
|
||||
*
|
||||
* POST /api/auth/login?username=<u>&recaptcha=
|
||||
* headers X-Password: <urlencoded>, X-Secret: <otp or empty> -> sets a session COOKIE
|
||||
* GET /api/settings/sources -> the named sources
|
||||
* POST /api/resources?path=<p>&source=<s>&isDir=true -> create a directory
|
||||
* POST /api/resources?path=<p>&source=<s>&override=true body=bytes -> upload
|
||||
*
|
||||
* THREE THINGS MOVED, and each would break on its own:
|
||||
* 1. AUTH IS A COOKIE, not an `X-Auth: <jwt>` header. Login returns no usable token in its body;
|
||||
* the session arrives in `Set-Cookie` and every later request has to carry it back.
|
||||
* 2. THE PASSWORD IS A HEADER, `X-Password`, URL-encoded — not a JSON body field.
|
||||
* 3. THE PATH IS A QUERY PARAMETER, `?path=`, not part of the URL, and every resource call also
|
||||
* needs a **`source`** naming which configured store to write to. Quantum throws "no source
|
||||
* provided" without it. `FB_SOURCE` names it; left unset, the sole configured source is used,
|
||||
* and if there is more than one this stops and lists them rather than guessing.
|
||||
*
|
||||
* File Browser is the STORE, not the server — Start9 Pages serves the uploaded folder as the site.
|
||||
* So the job here is simply to land the built files in the right folder, intact.
|
||||
@@ -18,6 +36,8 @@
|
||||
* Optional:
|
||||
* FB_URL default https://phoenix.local:58157
|
||||
* FB_DEST default websites/stationmaster — the folder Start9 Pages serves from
|
||||
* FB_SOURCE which configured source to write to; discovered automatically when there is one
|
||||
* FB_OTP the one-time code, if the account has two-factor enabled
|
||||
* FB_INSECURE set to 1 for a self-signed certificate (usual for a .local StartOS host)
|
||||
* SITE_URL default https://65.78.82.12:54697/ — the public address Start9 Pages serves at
|
||||
* --dry-run list what would be sent, contact nothing
|
||||
@@ -44,6 +64,8 @@ const URL_BASE = (process.env['FB_URL'] ?? 'https://phoenix.local:58157').replac
|
||||
const DEST = `/${(process.env['FB_DEST'] ?? 'websites/stationmaster').replace(/^\/+|\/+$/g, '')}`;
|
||||
const USER = process.env['FB_USER'] ?? '';
|
||||
const PASS = process.env['FB_PASS'] ?? '';
|
||||
const OTP = process.env['FB_OTP'] ?? '';
|
||||
const SOURCE_ENV = process.env['FB_SOURCE'] ?? '';
|
||||
const DRY = process.argv.includes('--dry-run');
|
||||
|
||||
/**
|
||||
@@ -75,37 +97,80 @@ const CONTENT_TYPES: Record<string, string> = {
|
||||
'.txt': 'text/plain',
|
||||
};
|
||||
|
||||
/**
|
||||
* Log in and return the session cookie every later request must carry.
|
||||
*
|
||||
* The password goes in a HEADER and URL-encoded, which is Quantum's own client does
|
||||
* (`X-Password: encodeURIComponent(password)`). The body carries nothing useful on success — the
|
||||
* session is in `Set-Cookie`, so a deploy that ignored the cookie would authenticate and then be
|
||||
* rejected by every upload.
|
||||
*/
|
||||
async function login(): Promise<string> {
|
||||
const res = await fetch(`${URL_BASE}/api/login`, {
|
||||
const url = `${URL_BASE}/api/auth/login?username=${encodeURIComponent(USER)}&recaptcha=`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: USER, password: PASS, recaptcha: '' }),
|
||||
headers: { 'X-Password': encodeURIComponent(PASS), 'X-Secret': OTP },
|
||||
});
|
||||
const body = await res.text();
|
||||
if (!res.ok) throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
|
||||
if (!body.trim()) throw new Error('login returned an empty token');
|
||||
return body.trim();
|
||||
if (!res.ok) {
|
||||
// 401 here is a wrong username/password; 404 would mean this build has moved the API again.
|
||||
throw new Error(`login failed: ${res.status} ${body || res.statusText}`);
|
||||
}
|
||||
const cookies = res.headers.getSetCookie();
|
||||
if (cookies.length === 0) throw new Error('login succeeded but set no session cookie');
|
||||
return cookies.map((c) => c.split(';')[0]).join('; ');
|
||||
}
|
||||
|
||||
async function makeDir(jwt: string, path: string): Promise<void> {
|
||||
// Trailing slash is what marks a directory in this API. A 409 means it already exists, which is
|
||||
// the normal case on every deploy after the first.
|
||||
const res = await fetch(`${URL_BASE}/api/resources${encodePath(path)}/`, {
|
||||
/**
|
||||
* WHICH STORE TO WRITE TO. Quantum can serve several named sources and refuses any resource call
|
||||
* that does not name one ("no source provided"), which is the parameter the v2.63 API had no
|
||||
* concept of. One configured source is the normal case and is used without asking; more than one is
|
||||
* ambiguous, and guessing would silently deploy the site into the wrong store.
|
||||
*/
|
||||
async function resolveSource(cookie: string): Promise<string> {
|
||||
if (SOURCE_ENV) return SOURCE_ENV;
|
||||
const res = await fetch(`${URL_BASE}/api/settings/sources`, { headers: { cookie } });
|
||||
if (!res.ok) throw new Error(`could not list sources: ${res.status} ${await res.text()}`);
|
||||
const names = Object.keys((await res.json()) ?? {});
|
||||
if (names.length === 1) return names[0]!;
|
||||
if (names.length === 0) throw new Error('the server reports no sources at all');
|
||||
throw new Error(`several sources configured (${names.join(', ')}) — pick one with FB_SOURCE=<name>`);
|
||||
}
|
||||
|
||||
function resourceUrl(source: string, path: string, extra: Record<string, string>): string {
|
||||
const params = new URLSearchParams({ path, source, ...extra });
|
||||
return `${URL_BASE}/api/resources?${params}`;
|
||||
}
|
||||
|
||||
async function makeDir(cookie: string, source: string, path: string): Promise<void> {
|
||||
const res = await fetch(resourceUrl(source, path, { isDir: 'true' }), {
|
||||
method: 'POST',
|
||||
headers: { 'X-Auth': jwt },
|
||||
headers: { cookie },
|
||||
});
|
||||
if (!res.ok && res.status !== 409) {
|
||||
throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
if (res.ok) return;
|
||||
/**
|
||||
* "Already there" is the normal case on every deploy after the first, and Quantum is not
|
||||
* consistent about which code it reports it with. So the two failures worth stopping for are
|
||||
* named — a rejected session, and a server that broke — and every other 4xx is treated as the
|
||||
* directory already existing. A directory that genuinely is not there fails loudly at the upload
|
||||
* a moment later, which is a better place to find out than a guess here.
|
||||
*/
|
||||
const fatal = res.status === 401 || res.status === 403 || res.status >= 500;
|
||||
if (fatal) throw new Error(`could not create ${path}: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function upload(jwt: string, localPath: string, remotePath: string): Promise<void> {
|
||||
async function upload(
|
||||
cookie: string,
|
||||
source: string,
|
||||
localPath: string,
|
||||
remotePath: string,
|
||||
): Promise<void> {
|
||||
const bytes = readFileSync(localPath);
|
||||
const ext = remotePath.slice(remotePath.lastIndexOf('.'));
|
||||
const res = await fetch(`${URL_BASE}/api/resources${encodePath(remotePath)}?override=true`, {
|
||||
const res = await fetch(resourceUrl(source, remotePath, { override: 'true' }), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Auth': jwt,
|
||||
cookie,
|
||||
'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
|
||||
'Content-Length': String(bytes.byteLength),
|
||||
},
|
||||
@@ -114,11 +179,6 @@ async function upload(jwt: string, localPath: string, remotePath: string): Promi
|
||||
if (!res.ok) throw new Error(`upload ${remotePath} failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
/** Encode each segment but keep the separators, so a path stays a path. */
|
||||
function encodePath(p: string): string {
|
||||
return p.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log('building…');
|
||||
@@ -147,15 +207,16 @@ if (DRY) {
|
||||
);
|
||||
}
|
||||
|
||||
const jwt = await login();
|
||||
console.log('logged in');
|
||||
const cookie = await login();
|
||||
const source = await resolveSource(cookie);
|
||||
console.log(`logged in — writing to source "${source}"`);
|
||||
|
||||
await makeDir(jwt, DEST);
|
||||
for (const d of dirs) await makeDir(jwt, `${DEST}/${d}`);
|
||||
await makeDir(cookie, source, DEST);
|
||||
for (const d of dirs) await makeDir(cookie, source, `${DEST}/${d}`);
|
||||
|
||||
let done = 0;
|
||||
for (const f of files) {
|
||||
await upload(jwt, join(dist, f), `${DEST}/${f}`);
|
||||
await upload(cookie, source, join(dist, f), `${DEST}/${f}`);
|
||||
done++;
|
||||
console.log(` [${String(done).padStart(2)}/${files.length}] ${f}`);
|
||||
}
|
||||
|
||||
+67
-5
@@ -41,7 +41,7 @@ import type { GameEvent } from './events.ts';
|
||||
import { areaAtSeat, areaOf, 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, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
|
||||
export type AdvanceResult = {
|
||||
events: GameEvent[];
|
||||
@@ -445,6 +445,9 @@ function enterMainline(
|
||||
);
|
||||
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
|
||||
tray.position = { at: 'mainline', index };
|
||||
// It is running now, so it is no longer being assembled (state.ts). A train at a Division Point
|
||||
// needs no equivalent: leaving one changes its position, which is what that case reads.
|
||||
delete tray.beingMadeUp;
|
||||
|
||||
/**
|
||||
* OUT OF THE DISTRICT, AND THE SPUR PORT GOES WITH IT.
|
||||
@@ -642,6 +645,43 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
const node = s.division.nodes[index];
|
||||
if (!node || node.kind !== 'mainline') return 'held';
|
||||
|
||||
/**
|
||||
* §7/§8.1 — AN EXTRA HIGHBALLING OUT OF THE INTERCHANGE'S YARD.
|
||||
*
|
||||
* Standing in `holding` rather than crossing in `transits` (state.ts), which is the position an
|
||||
* Extra started at an Interchange begins in. It leaves exactly the way a train at a Division
|
||||
* Point does — clearance first, then onto the running line — except that the card it enters is
|
||||
* the one it is already standing beside rather than the next one along.
|
||||
*
|
||||
* That reuse is the whole point of modelling the yard separately: Jesse's rule is "a guaranteed
|
||||
* collision holds it at the Interchange for another Stage and it tries again; a potential one is
|
||||
* the Superintendent's to hold", and those are precisely `evaluateClearance`'s `blocked` and
|
||||
* `ask`. Nothing new decides collisions here.
|
||||
*/
|
||||
if (node.holding?.includes(id)) {
|
||||
const clearance = evaluateClearance(s, id, tray, index, events);
|
||||
if (clearance === 'blocked') {
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason: 'held in the Interchange — the Subdivision ahead is occupied',
|
||||
});
|
||||
return 'held';
|
||||
}
|
||||
if (clearance === 'ask') return 'needsClearance';
|
||||
|
||||
node.holding = node.holding.filter((t) => t !== id);
|
||||
enterMainline(s, node, id, tray, index);
|
||||
events.push({
|
||||
type: 'trainHighballed',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
from: 'the Interchange',
|
||||
to: 'the Mainline',
|
||||
why: 'it was made up in the yard and the Subdivision was clear, so its run begins',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
const transit = node.transits.find((t) => t.tray === id);
|
||||
if (!transit) return 'held';
|
||||
|
||||
@@ -943,12 +983,33 @@ function collide(
|
||||
consist: [...tray.consist],
|
||||
});
|
||||
// Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
|
||||
// `pooled` because a car reaching a yard is back in the common supply: the load's origin stamp
|
||||
// (state.ts) belongs to the load, not to the car that happened to be carrying it.
|
||||
for (const car of tray.consist) {
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
||||
else s.yards.classificationYard.push(car);
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
|
||||
else s.yards.classificationYard.push(pooled(car));
|
||||
}
|
||||
s.trays.delete(id);
|
||||
s.freeTrays.push(id);
|
||||
/**
|
||||
* TAKE THE WRECK OFF THE CARD.
|
||||
*
|
||||
* Nothing did. `s.trays.delete` removed the train and left its `Transit` sitting on the Mainline
|
||||
* card it died on, and `evaluateClearance` counts every transit as an occupant — so a rear-end
|
||||
* collision (the caller at "ran into the train ahead") permanently poisoned that card: every
|
||||
* later train was either held against a ghost or put to the Superintendent about one. The only
|
||||
* other place a transit is removed is a train rolling off the far end, which a destroyed train
|
||||
* never does.
|
||||
*
|
||||
* Found while adding the Interchange start, which clears onto the running line through that
|
||||
* same occupant list.
|
||||
*/
|
||||
for (const n of s.division.nodes) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (lost.length > 0) {
|
||||
@@ -1014,9 +1075,10 @@ function retireTrain(
|
||||
side: Direction,
|
||||
events: GameEvent[],
|
||||
): void {
|
||||
// `pooled` — see `trainsDestroyed` above; a load's origin stamp does not survive the yard.
|
||||
for (const car of tray.consist) {
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(car);
|
||||
else s.yards.classificationYard.push(car);
|
||||
if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
|
||||
else s.yards.classificationYard.push(pooled(car));
|
||||
}
|
||||
s.trays.delete(id);
|
||||
s.freeTrays.push(id);
|
||||
|
||||
+309
-90
@@ -33,9 +33,9 @@ import {
|
||||
officeProfile,
|
||||
trainProfile,
|
||||
} from './content.ts';
|
||||
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts';
|
||||
import type { CarType, Direction, FreightKind, Hand, MainlineKind, ModifierKind, ModifierProfile, TrackGeometry, TrainRules } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import type { Intent, RejectionCode } from './intents.ts';
|
||||
import type { ExtraStart, Intent, RejectionCode } from './intents.ts';
|
||||
import type {
|
||||
CardId,
|
||||
CrewTray,
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
cutTowards,
|
||||
isOperationalRail,
|
||||
playerAtSeat,
|
||||
pooled,
|
||||
railFacingOf,
|
||||
seatOf,
|
||||
spaceOn,
|
||||
@@ -388,17 +389,11 @@ export function canStartLoad(f: Facility): boolean {
|
||||
}
|
||||
|
||||
/** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */
|
||||
export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
|
||||
export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord, trayId?: TrayId): boolean {
|
||||
const f = facilityAt(s, player, at);
|
||||
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
||||
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false;
|
||||
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
|
||||
// train these passengers can board however many empty coaches it is carrying.
|
||||
return trainAtOfficeWith(
|
||||
s, player,
|
||||
(c) => c.type === 'coach' && !c.loaded,
|
||||
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
||||
);
|
||||
return passengerWork(s, player, 'board', trayId) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -413,29 +408,131 @@ export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): bool
|
||||
* de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the
|
||||
* train. Measured at 1.29 cars a game created out of nothing across the two inbound paths.
|
||||
*/
|
||||
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
|
||||
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord, trayId?: TrayId): boolean {
|
||||
const f = facilityAt(s, player, at);
|
||||
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
||||
if (f.inboundBox.length >= f.capacity.inbound) return false;
|
||||
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
|
||||
return trainAtOfficeWith(
|
||||
s, player,
|
||||
(c) => c.type === 'coach' && c.loaded,
|
||||
(t) => !refusesPassengers(t) && !refusesThisOffice(s, player, t),
|
||||
);
|
||||
return passengerWork(s, player, 'detrain', trayId) !== null;
|
||||
}
|
||||
|
||||
function trainAtOfficeWith(
|
||||
/**
|
||||
* A Timetabled or Extra train card (§6.2, Gitea#6) — the one place that decides what "a train card"
|
||||
* means, so the rule, the UI's reason text and any test all ask the same question.
|
||||
*/
|
||||
export function isTrainCard(s: GameState, cardId: CardId): boolean {
|
||||
const kind = s.cards.get(cardId)?.kind.kind;
|
||||
return kind === 'timetabledTrain' || kind === 'extraTrain';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE AN EXTRA STARTS AND WHICH WAY IT RUNS — the one answer `check`, `execute` and the reducer
|
||||
* all use, so a placement can never be checked against one square and made on another.
|
||||
*
|
||||
* §7 lets the player who played the card choose the start, and Jesse's ruling makes the start
|
||||
* decide the direction rather than the number (`runDirection`'s comment carries the supersession):
|
||||
*
|
||||
* - a DIVISION POINT runs the train away from itself — the west end runs east, the east end west.
|
||||
* `direction` on the intent is ignored rather than refused, because there is only one answer;
|
||||
* - an INTERCHANGE or a CONTROL POINT sits in the middle of the railroad, where both ways are real
|
||||
* runs, so the intent must say which.
|
||||
*
|
||||
* Which of those are on offer is the `extraStart` house rule. The two that belong to nobody — the
|
||||
* Division Points and the Interchange — are always available; an Office is a seat's own ground and
|
||||
* is gated, to `ownOffice` (the player who played the card) or `anyOffice`.
|
||||
*
|
||||
* Returns a refusal code rather than throwing, so `check` can hand it straight back.
|
||||
*/
|
||||
export function resolveExtraStart(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
pred: (c: RollingStock) => boolean,
|
||||
trayOk: (t: CrewTray) => boolean = () => true,
|
||||
): boolean {
|
||||
i: { trainNumber: number; atSeat?: SeatIndex | null; start?: ExtraStart; direction?: Direction },
|
||||
): { at: ExtraStart; direction: Direction } | RejectionCode {
|
||||
/**
|
||||
* A save written before the choice existed. `atSeat` null meant the Division Point the NUMBER
|
||||
* sent the train to, a seat meant that Office, and both ran in the number's direction — so that
|
||||
* is what these replay as, whatever the rules say today.
|
||||
*/
|
||||
if (!i.start) {
|
||||
const direction = runDirection(i.trainNumber);
|
||||
if (i.atSeat === null || i.atSeat === undefined) {
|
||||
return { at: { kind: 'divisionPoint', side: startingDivisionPoint(i.trainNumber) }, direction };
|
||||
}
|
||||
return { at: { kind: 'office', seat: i.atSeat }, direction };
|
||||
}
|
||||
|
||||
const start = i.start;
|
||||
if (start.kind === 'divisionPoint') {
|
||||
if (!s.division.nodes.some((n) => n.kind === 'divisionPoint' && n.side === start.side)) {
|
||||
return 'NO_SUCH_DIVISION_POINT';
|
||||
}
|
||||
// Away from the end it is standing at. Nothing else is a run.
|
||||
return { at: start, direction: start.side === 'west' ? 'east' : 'west' };
|
||||
}
|
||||
|
||||
if (i.direction === undefined) return 'NO_DIRECTION_CHOSEN';
|
||||
|
||||
if (start.kind === 'mainline') {
|
||||
const node = s.division.nodes[start.node];
|
||||
if (!node || node.kind !== 'mainline') return 'NO_SUCH_CARD';
|
||||
// The Interchange is the one Mainline card an Extra may be made up on — it is the one with a
|
||||
// yard. `sortsCars` is what the card prints and the only thing that distinguishes it.
|
||||
if (!mainlineProfile(node.card).sortsCars) return 'NOT_AN_INTERCHANGE';
|
||||
return { at: start, direction: i.direction };
|
||||
}
|
||||
|
||||
const rule = houseRules(s.config).extraStart;
|
||||
if (rule === 'divisionPointsOnly') return 'OFFICE_STARTS_NOT_ALLOWED';
|
||||
if (rule === 'ownOffice' && start.seat !== seatOf(s, player)) return 'NOT_YOUR_OFFICE';
|
||||
const area = s.officeAreas.get(start.seat);
|
||||
if (!area) return 'NO_SUCH_FACILITY';
|
||||
// A Control Point is any Office above a Whistle Post (§8). A Whistle Post is not one, which is
|
||||
// the whole reason upgrading buys a place for an Extra to start — and no setting of the house
|
||||
// rule lets one in.
|
||||
if (!officeProfile(area.tier).isControlPoint) return 'NOT_A_CONTROL_POINT';
|
||||
return { at: start, direction: i.direction };
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH TRAIN, AND WHICH COACH ON IT — the one answer `check`, `execute` and the reducer all use.
|
||||
*
|
||||
* TWO PLAYTEST BUGS SHARED ONE CAUSE HERE. Reported against v0.4.9d: "operating two trains in a
|
||||
* station, the select button does not work — regardless of which you pick, it is always one train,
|
||||
* not the other". `porter.board` carried no tray at all, so `check` asked whether SOME train at the
|
||||
* Office had an empty coach and the reducer then walked `adOccupancy` and filled the first one it
|
||||
* found. The two were not even asking the same question: `check` skipped a train whose card refuses
|
||||
* passenger work and the reducer did not, so a Military train could be boarded as long as some other
|
||||
* train at the platform was eligible. The intent now names its tray (`intents.ts`) and this is the
|
||||
* one place that resolves it.
|
||||
*
|
||||
* And "passengers just boarded cannot be immediately unloaded": a coach carries the district that
|
||||
* filled it (`RollingStock.origin`), and a homegrown coach is not a coach these passengers may
|
||||
* alight from — they have to be carried to a different Office Area first.
|
||||
*
|
||||
* `trayId` absent means "any eligible train", which is what every intent recorded before this
|
||||
* existed meant, so an old save replays unchanged.
|
||||
*/
|
||||
function passengerWork(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
dir: 'board' | 'detrain',
|
||||
trayId?: TrayId,
|
||||
): { trayId: TrayId; coachIndex: number } | null {
|
||||
const area = areaOf(s, player);
|
||||
return area.adOccupancy.some((id) => {
|
||||
const t = s.trays.get(id);
|
||||
return !!t && trayOk(t) && t.consist.some(pred);
|
||||
});
|
||||
const seat = seatOf(s, player);
|
||||
const wanted = (c: RollingStock): boolean =>
|
||||
c.type === 'coach' && (dir === 'board' ? !c.loaded : c.loaded && c.origin !== seat);
|
||||
for (const id of area.adOccupancy) {
|
||||
if (trayId !== undefined && id !== trayId) continue;
|
||||
const tray = s.trays.get(id);
|
||||
if (!tray) continue;
|
||||
// §7 — a train whose card refuses passenger work, or which is not booked to stop here, is not a
|
||||
// train these passengers can board however many empty coaches it is carrying.
|
||||
if (refusesPassengers(tray) || refusesThisOffice(s, player, tray)) continue;
|
||||
const coachIndex = tray.consist.findIndex(wanted);
|
||||
if (coachIndex >= 0) return { trayId: id, coachIndex };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -570,12 +667,30 @@ function passengerRefusal(
|
||||
player: PlayerIndex,
|
||||
at: GridCoord,
|
||||
dir: 'board' | 'detrain',
|
||||
trayId?: TrayId,
|
||||
): RejectionCode {
|
||||
const area = areaOf(s, player);
|
||||
const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t);
|
||||
const trains = area.adOccupancy
|
||||
.filter((id) => trayId === undefined || id === trayId)
|
||||
.map((id) => s.trays.get(id))
|
||||
.filter((t): t is CrewTray => !!t);
|
||||
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
|
||||
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
|
||||
if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE';
|
||||
/**
|
||||
* EVERY LOADED COACH ABOARD BOARDED HERE — so the refusal is the district rule, not "no loaded
|
||||
* coach". Told apart because the two read as opposite situations to a player: one is an empty
|
||||
* train, the other is a train full of passengers who have not been anywhere yet.
|
||||
*/
|
||||
if (
|
||||
dir === 'detrain' &&
|
||||
trains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded)) &&
|
||||
trains.every((t) =>
|
||||
t.consist.every((c) => !(c.type === 'coach' && c.loaded) || c.origin === seatOf(s, player)),
|
||||
)
|
||||
) {
|
||||
return 'LOADED_IN_THIS_DISTRICT';
|
||||
}
|
||||
|
||||
/**
|
||||
* A TRAIN IS STANDING THERE, so say what is actually missing.
|
||||
@@ -766,11 +881,31 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node);
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.2, AS RULED BY JESSE (Gitea#6): A TRAIN CARD MAY NOT BE DISCARDED. EVER.
|
||||
*
|
||||
* It may be held for as many Stages and Days as the player likes — the hand limit is the only
|
||||
* pressure on it — but it never goes onto a Department pile. The consequence is the point of the
|
||||
* rule and needs no machinery of its own: a player holding four train cards has nothing
|
||||
* discardable, and `draw.end` already refuses while the hand is over the limit, so the only way
|
||||
* to conclude the turn is to PLAY one. Playing a train card is unconditionally legal (see
|
||||
* `card.play`'s `timetabledTrain` case, which refuses only a board placement), so that corner
|
||||
* can never lock a player in.
|
||||
*
|
||||
* Extras count. They are trains — Jesse's ruling in the same breath — even though an Extra runs
|
||||
* once and ends in the Salvage Yard while a Timetabled card joins the timetable for the rest of
|
||||
* the game.
|
||||
*
|
||||
* `legal.ts` enumerates candidates and filters them through here, so the discard option simply
|
||||
* stops being offered for these cards; the bot needs no separate rule and already reaches for
|
||||
* `card.play` before it reaches for a discard.
|
||||
*/
|
||||
case 'card.discard': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
const hand = s.decks.hands.get(player) ?? [];
|
||||
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
||||
if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY';
|
||||
if (isTrainCard(s, i.cardId)) return 'TRAINS_ARE_NEVER_DISCARDED';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -789,8 +924,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
|
||||
if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
|
||||
// "Not while a train is on it" — realigning under a moving train is exactly the situation the
|
||||
// restriction exists to prevent.
|
||||
if (node.transits.length > 0) return 'TRAIN_ON_CARD';
|
||||
// restriction exists to prevent. A train standing in the Interchange's yard counts: it is on
|
||||
// the card, and it is about to pull out onto the very rail being relaid.
|
||||
if (node.transits.length > 0 || (node.holding?.length ?? 0) > 0) return 'TRAIN_ON_CARD';
|
||||
if (rule.key === 'realignment' && !REALIGNMENTS.some((r) => r.from === node.card)) {
|
||||
return 'NO_PLACEMENT';
|
||||
}
|
||||
@@ -917,12 +1053,16 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
||||
if (!s.pendingExtras.includes(i.trainNumber)) return 'NO_EXTRA_PENDING';
|
||||
if (s.freeTrays.length === 0) return 'NO_FREE_TRAY';
|
||||
if (i.atSeat === null) return null;
|
||||
// A Control Point is any Office above a Whistle Post (§8). A Whistle Post is not one, which is
|
||||
// the whole reason upgrading buys a place for an Extra to start.
|
||||
const area = s.officeAreas.get(i.atSeat);
|
||||
if (!area) return 'NO_SUCH_FACILITY';
|
||||
if (!officeProfile(area.tier).isControlPoint) return 'NOT_A_CONTROL_POINT';
|
||||
const where = resolveExtraStart(s, player, i);
|
||||
if (typeof where === 'string') return where;
|
||||
if (where.at.kind === 'divisionPoint') return null;
|
||||
if (where.at.kind === 'mainline') {
|
||||
// Nothing to refuse. The train is made up in the Interchange's yard, off the running line,
|
||||
// so however busy the card is this cannot be the collision §7 says it must not force. What
|
||||
// it may not do is get OUT — that is §8.1's question, asked at the Mainline Phase.
|
||||
return null;
|
||||
}
|
||||
const area = s.officeAreas.get(where.at.seat)!;
|
||||
// It still has to fit: an Extra starting here takes an A/D track like any other arrival.
|
||||
return area.adOccupancy.length >= officeProfile(area.tier).adTracks ? 'NO_FREE_AD_TRACK' : null;
|
||||
}
|
||||
@@ -982,7 +1122,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!f) return 'NO_SUCH_FACILITY';
|
||||
if (f.porters < 1) return 'NO_PORTERS_HERE';
|
||||
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
|
||||
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'board');
|
||||
if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY';
|
||||
return canBoard(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'board', i.trayId);
|
||||
}
|
||||
|
||||
case 'porter.detrain': {
|
||||
@@ -991,7 +1132,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!f) return 'NO_SUCH_FACILITY';
|
||||
if (f.porters < 1) return 'NO_PORTERS_HERE';
|
||||
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
|
||||
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'detrain');
|
||||
if (i.trayId !== undefined && !s.trays.has(i.trayId)) return 'NO_SUCH_TRAY';
|
||||
return canDetrain(s, player, i.at, i.trayId) ? null : passengerRefusal(s, player, i.at, 'detrain', i.trayId);
|
||||
}
|
||||
|
||||
case 'laborer.startLoad': {
|
||||
@@ -1027,6 +1169,19 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
|
||||
const car = f.industryTrack.cars[i.carIndex];
|
||||
if (!car || !car.loaded) return 'WRONG_CAR_TYPE';
|
||||
/**
|
||||
* A LOAD MAY NOT BE BROKEN IN THE DISTRICT THAT MADE IT (Jesse's ruling, v0.4.9e).
|
||||
*
|
||||
* Reported from playtesting v0.4.9d: "Freight House: boxcars loaded cannot be immediately
|
||||
* unloaded." They could — a Freight House permits both directions, so the car its own Laborers
|
||||
* had just loaded was standing on its own track, loaded, with an empty of that type in the
|
||||
* yard, and every gate below said yes. Full Revenue at both ends for a load that never moved.
|
||||
*
|
||||
* The rule is district-wide and permanent, not "not at this facility" and not "not this
|
||||
* Stage": the stamp says which Office Area made the load, and it never expires. Traffic runs
|
||||
* BETWEEN districts, which is what the lockout pairs in `content.ts` exist to force.
|
||||
*/
|
||||
if (car.origin === seatOf(s, player)) return 'LOADED_IN_THIS_DISTRICT';
|
||||
/**
|
||||
* §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE
|
||||
* DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*"
|
||||
@@ -1585,8 +1740,13 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }];
|
||||
}
|
||||
|
||||
case 'newTrain.startExtra':
|
||||
return [{ type: 'extraStarted', player, trainNumber: i.trainNumber, atSeat: i.atSeat }];
|
||||
case 'newTrain.startExtra': {
|
||||
// `check` has already accepted this, so the resolve cannot fail here.
|
||||
const where = resolveExtraStart(s, player, i) as { at: ExtraStart; direction: Direction };
|
||||
return [
|
||||
{ type: 'extraStarted', player, trainNumber: i.trainNumber, at: where.at, direction: where.direction },
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.placeCar':
|
||||
return [
|
||||
@@ -1618,17 +1778,23 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
* `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate
|
||||
* is named per COACH because a Porter handles exactly one coach per action.
|
||||
*/
|
||||
case 'porter.board':
|
||||
case 'porter.board': {
|
||||
// `check` has already established there is one; resolving it HERE, once, is what stops the
|
||||
// reducer from finding a different train than the one the rules were tested against.
|
||||
const work = passengerWork(s, player, 'board', i.trayId)!;
|
||||
return [
|
||||
{ type: 'passengersBoarded', player, at: i.at },
|
||||
{ type: 'passengersBoarded', player, at: i.at, ...work },
|
||||
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
|
||||
];
|
||||
}
|
||||
|
||||
case 'porter.detrain':
|
||||
case 'porter.detrain': {
|
||||
const work = passengerWork(s, player, 'detrain', i.trayId)!;
|
||||
return [
|
||||
{ type: 'passengersDetrained', player, at: i.at },
|
||||
{ type: 'passengersDetrained', player, at: i.at, ...work },
|
||||
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'),
|
||||
];
|
||||
}
|
||||
|
||||
case 'laborer.startLoad': {
|
||||
const f = facilityAt(s, player, i.at)!;
|
||||
@@ -1957,9 +2123,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const card = area.grid.get(coordKey(e.to));
|
||||
if (tray && card) {
|
||||
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
|
||||
const track = card.facility?.industryTrack;
|
||||
if (track) track.cars.push(...e.stock);
|
||||
else card.standing.push(...e.stock);
|
||||
// `carsOn` is the one function that knows WHERE cars stand on a given card — an industry
|
||||
// track for a freight facility, the card itself for everything else. Written out longhand
|
||||
// here it was a second copy of that rule, and the copy was wrong for a Passenger Facility:
|
||||
// it has an `industryTrack` too (an empty one, `setup.ts`), so a cut pushed into an Office
|
||||
// would have landed somewhere `carsOn` cannot see — cars on the board that no train can
|
||||
// couple and no walk is blocked by. `check` refuses a non-freight target, so this never
|
||||
// fired; a trap that needs another rule to stay unsprung is still a trap.
|
||||
carsOn(card).push(...e.stock);
|
||||
}
|
||||
turnOf(s, e.player).movesRemaining -= 1;
|
||||
spendCard(s, e.player, e.cardId);
|
||||
@@ -2050,7 +2221,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
|
||||
if (idx >= 0) f.inboundBox.splice(idx, 1);
|
||||
s.yards.classificationYard.push(e.stock);
|
||||
// `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts).
|
||||
s.yards.classificationYard.push(pooled(e.stock));
|
||||
turnOf(s, e.player).freightAgentUsed = true;
|
||||
break;
|
||||
}
|
||||
@@ -2065,7 +2237,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const idx = box.findIndex((c) => c.type === e.stock.type);
|
||||
if (idx >= 0) box.splice(idx, 1);
|
||||
}
|
||||
s.yards.classificationYard.push(e.stock);
|
||||
s.yards.classificationYard.push(pooled(e.stock));
|
||||
turnOf(s, e.player).freightAgentUsed = true;
|
||||
break;
|
||||
}
|
||||
@@ -2107,45 +2279,82 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE THREE PLACES AN EXTRA MAY BE MADE UP (§7, Jesse's ruling) — and all three leave it
|
||||
* STANDING somewhere it must later highball out of, never already running.
|
||||
*/
|
||||
case 'extraStarted': {
|
||||
const trayId = s.freeTrays.pop()!;
|
||||
s.pendingExtras = s.pendingExtras.filter((n) => n !== e.trainNumber);
|
||||
const direction = runDirection(e.trainNumber);
|
||||
if (e.atSeat === null) {
|
||||
const side = startingDivisionPoint(e.trainNumber);
|
||||
s.trays.set(trayId, {
|
||||
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction, position: { at: 'divisionPoint', side }, movesUsed: 0,
|
||||
});
|
||||
const { direction } = e;
|
||||
const base = {
|
||||
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0,
|
||||
consist: [] as RollingStock[], direction, movesUsed: 0,
|
||||
};
|
||||
// Which way the tray physically points, for the cards it will pick up. A Division Point start
|
||||
// does not set it: those trays have never carried a facing and `enterMainline` does not read
|
||||
// one, so writing it here would be inventing state the DP path has always done without.
|
||||
const facing = { facing: direction === 'west' ? ('w' as const) : ('e' as const),
|
||||
railFacing: direction === 'west' ? ('w' as const) : ('e' as const) };
|
||||
|
||||
if (e.at.kind === 'divisionPoint') {
|
||||
const side = e.at.side;
|
||||
s.trays.set(trayId, { ...base, position: { at: 'divisionPoint', side } });
|
||||
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side);
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push(trayId);
|
||||
} else {
|
||||
// Starting at a Control Point: it stands on the Office card and takes an A/D track, exactly
|
||||
// as though it had arrived there.
|
||||
const area = areaAtSeat(s, e.atSeat);
|
||||
s.trays.set(trayId, {
|
||||
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction, facing: direction === 'west' ? 'w' : 'e', railFacing: direction === 'west' ? 'w' : 'e',
|
||||
position: { at: 'grid', seat: e.atSeat, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push(trayId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'passengersBoarded': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const area = areaOf(s, e.player);
|
||||
const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded);
|
||||
const loaded = f.outboundBox.splice(idx, 1)[0]!;
|
||||
for (const id of area.adOccupancy) {
|
||||
const tray = s.trays.get(id);
|
||||
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1;
|
||||
if (tray && ci >= 0) {
|
||||
s.yards.classificationYard.push(tray.consist[ci]!);
|
||||
tray.consist[ci] = loaded;
|
||||
if (e.at.kind === 'mainline') {
|
||||
/**
|
||||
* IN THE INTERCHANGE'S YARD, NOT ON THE MAINLINE — the distinction §7 turns on.
|
||||
*
|
||||
* `holding` rather than `transits`, so the card can be nose to tail with traffic and this
|
||||
* placement still forces no collision. It joins the running line at a later Mainline Phase
|
||||
* through `evaluateClearance`, which is what gives the Superintendent the hold Jesse asked
|
||||
* for: an automatic one against a facing train, a ruling against a following one.
|
||||
*/
|
||||
const node = s.division.nodes[e.at.node];
|
||||
s.trays.set(trayId, {
|
||||
...base, ...facing, beingMadeUp: true, position: { at: 'mainline', index: e.at.node },
|
||||
});
|
||||
if (node?.kind === 'mainline') (node.holding ??= []).push(trayId);
|
||||
break;
|
||||
}
|
||||
|
||||
// Starting at a Control Point: it stands on the Office card and takes an A/D track, exactly
|
||||
// as though it had arrived there.
|
||||
const area = areaAtSeat(s, e.at.seat);
|
||||
s.trays.set(trayId, {
|
||||
...base, ...facing, beingMadeUp: true,
|
||||
position: { at: 'grid', seat: e.at.seat, coord: area.officeCoord },
|
||||
});
|
||||
area.adOccupancy.push(trayId);
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE TRAIN AND THE COACH THE PLAYER PICKED, not "the first one on the A/D tracks".
|
||||
*
|
||||
* This used to walk `adOccupancy` and fill the first empty coach it met, which is why two trains
|
||||
* standing at one station both answered to whichever chip was clicked (v0.4.9d playtest), and
|
||||
* why it could fill a coach on a train whose card refuses passenger work — `check` skipped such
|
||||
* a train and the reducer did not. `e.trayId`/`e.coachIndex` are exactly what `passengerWork`
|
||||
* resolved for `check`, carried on the event rather than looked up again here.
|
||||
*/
|
||||
case 'passengersBoarded': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded);
|
||||
const loaded = f.outboundBox.splice(idx, 1)[0]!;
|
||||
const tray = s.trays.get(e.trayId);
|
||||
if (tray && tray.consist[e.coachIndex]) {
|
||||
s.yards.classificationYard.push(pooled(tray.consist[e.coachIndex]!));
|
||||
/**
|
||||
* Stamped with the district that filled it — the chip turned upside down in the tray. These
|
||||
* passengers may not alight anywhere in this Office Area; the train has to carry them to a
|
||||
* different one. See `RollingStock.origin` in state.ts.
|
||||
*/
|
||||
tray.consist[e.coachIndex] = { ...loaded, origin: seatOf(s, e.player) };
|
||||
}
|
||||
f.usedThisStage.porters += 1;
|
||||
break;
|
||||
@@ -2153,21 +2362,18 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
|
||||
case 'passengersDetrained': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const area = areaOf(s, e.player);
|
||||
for (const id of area.adOccupancy) {
|
||||
const tray = s.trays.get(id);
|
||||
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1;
|
||||
if (tray && ci >= 0) {
|
||||
const tray = s.trays.get(e.trayId);
|
||||
if (tray && tray.consist[e.coachIndex]) {
|
||||
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
|
||||
// which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`.
|
||||
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
|
||||
if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard');
|
||||
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
|
||||
refillDivisionYardIfEmpty(s);
|
||||
f.inboundBox.push(tray.consist[ci]!);
|
||||
tray.consist[ci] = empty;
|
||||
break;
|
||||
}
|
||||
// The arriving coach goes into the red box carrying nothing: the journey it was stamped for
|
||||
// is over, and the box feeds straight back to a yard through the Freight Agent.
|
||||
f.inboundBox.push(pooled(tray.consist[e.coachIndex]!));
|
||||
tray.consist[e.coachIndex] = empty;
|
||||
}
|
||||
f.usedThisStage.porters += 1;
|
||||
break;
|
||||
@@ -2203,8 +2409,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
workTrack(f)[workTrack(f).length - 1] = null;
|
||||
const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
|
||||
if (ci >= 0) {
|
||||
s.yards.classificationYard.push(f.industryTrack.cars[ci]!);
|
||||
f.industryTrack.cars[ci] = { type: e.carType, loaded: true };
|
||||
s.yards.classificationYard.push(pooled(f.industryTrack.cars[ci]!));
|
||||
/**
|
||||
* THE LOAD IS STAMPED WITH THE DISTRICT THAT MADE IT — the chip turned upside down in the
|
||||
* tray. `laborer.beginUnload` refuses a car stamped with the district it is standing in, so
|
||||
* this load now has to leave the Office Area on a train before anyone can break it. See
|
||||
* `RollingStock.origin` in state.ts for the rule and why it is a seat.
|
||||
*/
|
||||
f.industryTrack.cars[ci] = { type: e.carType, loaded: true, origin: seatOf(s, e.player) };
|
||||
}
|
||||
f.usedThisStage.laborers += 1;
|
||||
break;
|
||||
@@ -2689,9 +2901,16 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
/**
|
||||
* §7 — IS THIS TRAY THE ONE BEING MADE UP?
|
||||
*
|
||||
* A train is made up where it is built, standing at a Division Point, and only until its consist
|
||||
* matches its card. Everything else with a Crew Tray — a train working your district, a train
|
||||
* halfway across the Division — is running, not being assembled.
|
||||
* A train is made up where it is built and only until its consist matches its card. Everything else
|
||||
* with a Crew Tray — a train working your district, a train halfway across the Division — is
|
||||
* running, not being assembled.
|
||||
*
|
||||
* WHERE that is used to be the whole test: "standing at a Division Point". True while a Division
|
||||
* Point was the only place to build one, and wrong once an Extra could be started at a Control Point
|
||||
* or in an Interchange's yard (§7) — those trains were never offered a car and ran empty. The extra
|
||||
* clause is `beingMadeUp` (state.ts), a flag set on exactly those trays and cleared when they start
|
||||
* running, rather than a second positional rule: a train STANDING at an Office is usually one that
|
||||
* arrived, and must not be fillable from the yard.
|
||||
*
|
||||
* SHARED with the New Train Phase, which uses it to decide whether to stop and ask. It has to be:
|
||||
* `check` accepted any tray with room in its consist, so during a New Train Phase the Division Yard
|
||||
@@ -2701,7 +2920,7 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
||||
*/
|
||||
export function isBeingMadeUp(tray: CrewTray): boolean {
|
||||
if (tray.trainNumber === null) return false;
|
||||
if (tray.position.at !== 'divisionPoint') return false;
|
||||
if (tray.position.at !== 'divisionPoint' && !tray.beingMadeUp) return false;
|
||||
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||
if (!profile) return false;
|
||||
return tray.consist.length < consistSize(profile.consist);
|
||||
|
||||
+205
-97
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* TRANSCRIBED FROM THE RECOVERED DESIGN FILES (2026-07-30):
|
||||
* docs/Deck cards2.xlsx — the complete card list and counts
|
||||
* docs/Trains3.pdf — all 22 train cards
|
||||
* docs/Mainline Cards.pdf — the ten Mainline card types
|
||||
* docs/Trains3.pdf — the train cards
|
||||
* docs/Mainline Cards.pdf — the Mainline card types, and the two Division Points
|
||||
* docs/tracks.png — card art
|
||||
*
|
||||
* This replaced an invented 52-card placeholder. See docs/rules/implications.md for what changed
|
||||
@@ -60,13 +60,17 @@ export type TrackProfile = {
|
||||
/**
|
||||
* TRACK IS IN THE HOME OFFICE DECK, and is drawn and played like any other card.
|
||||
*
|
||||
* 96 dealt of the sheet's 104 (column B of `docs/Deck cards2.xlsx`) — the 8 sharp curves are dealt
|
||||
* zero, see below. An earlier reading made track a
|
||||
* separate per-player supply of 26 pieces, sitting outside the deck and laid one a turn. That came
|
||||
* from misreading the sheet's LAST column, headed "Track Per Player" — 8/4/4/1/1/4/4 = 26, which is
|
||||
* a note about each player's likely share of 104 across four players, not a second stack of cards.
|
||||
* The sheet's own totals settle it: "Sum other 115", "Total track 104", and a grand total of 231
|
||||
* once the 12 start cards are counted. 115 + 104 + 12 = 231.
|
||||
* Every row's dealt count is in `copiesInDeck` below; the sharp curves are dealt zero, see below.
|
||||
* How many that comes to is `TRACK_IN_DECK`, computed rather than written down — it moves with
|
||||
* balance work, so a number in this comment would be wrong before long.
|
||||
*
|
||||
* An earlier reading made track a separate per-player supply, sitting outside the deck and laid one
|
||||
* a turn. That came from misreading the sheet's LAST column, headed "Track Per Player" —
|
||||
* 8/4/4/1/1/4/4 = 26, which is a note about each player's likely share across four players, not a
|
||||
* second stack of cards. The sheet's own totals settle it: "Sum other 115", "Total track 104", and a
|
||||
* grand total of 231 once the 12 start cards are counted. 115 + 104 + 12 = 231. Those are figures
|
||||
* from `docs/Deck cards2.xlsx`, a fixed document, and stay here as the audit trail for the
|
||||
* transcription — they are not claims about what the game deals today.
|
||||
*
|
||||
* It matters well beyond bookkeeping. Track competes for the draw with industry, trains and
|
||||
* enhancements, so building a district is paid for in cards you did not draw instead — and the
|
||||
@@ -89,10 +93,10 @@ export const TRACK_CARDS: readonly TrackProfile[] = [
|
||||
* SHARP CURVES ARE DEALT ZERO COPIES — Jesse's call, and the same treatment as Poling.
|
||||
*
|
||||
* The only thing that made them different from an ordinary curve was `moveCost: 2`, and nothing
|
||||
* ever charged it: every switching move costs exactly 1, hard-coded. So the 8 cards in the deck
|
||||
* were geometric duplicates of the curves, taking 8 draws from a deck the rebalance already thinks
|
||||
* is too diluted. They come out rather than having the Move cost built, because a per-card movement
|
||||
* cost is a change to the Move model and the rebalance can wait.
|
||||
* ever charged it: every switching move costs exactly 1, hard-coded. So they were geometric
|
||||
* duplicates of the curves, taking draws from a deck the rebalance already thinks is too diluted.
|
||||
* They come out rather than having the Move cost built, because a per-card movement cost is a
|
||||
* change to the Move model and the rebalance can wait.
|
||||
*
|
||||
* The rows stay in the catalogue at zero, exactly as Poling does, so the design is still visible
|
||||
* and the geometry still works if they are ever dealt again.
|
||||
@@ -103,10 +107,10 @@ export const TRACK_CARDS: readonly TrackProfile[] = [
|
||||
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
|
||||
];
|
||||
|
||||
/** 96 — the sheet's "Total track" of 104, less the 8 sharp curves now dealt at zero. */
|
||||
/** Summed from `copiesInDeck` above, never written down — it moves whenever the deck is retuned. */
|
||||
export const TRACK_IN_DECK = TRACK_CARDS.reduce((n, t) => n + t.copiesInDeck, 0);
|
||||
|
||||
/** Start cards, placed at setup and never shuffled: 4 Whistle Posts, 8 Limits. */
|
||||
/** Start cards, placed at setup and never shuffled. */
|
||||
export const WHISTLE_POST_SUPPLY = 4;
|
||||
export const LIMITS_SUPPLY = 8;
|
||||
|
||||
@@ -116,7 +120,7 @@ export function trackProfile(geometry: TrackGeometry, hand: Hand): TrackProfile
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offices — Depot 4, Station 2, Terminal 1
|
||||
// Offices — the upgrade ladder: Whistle Post, Depot, Station, Terminal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OfficeProfile = {
|
||||
@@ -139,13 +143,12 @@ export type OfficeProfile = {
|
||||
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
|
||||
*/
|
||||
/**
|
||||
* Office cards. `copiesInDeck` was **doubled** (Depot 4→8, Station 2→4, Terminal 1→2) — Q12.
|
||||
* Office cards. Every tier's `copiesInDeck` was **doubled** against the recovered design — Q12.
|
||||
*
|
||||
* Players always start at a Whistle Post, which has ONE A/D track, so a second arrival is an
|
||||
* automatic collision (§8.3, Gap 2a). Measured at the original density, 25 of 100 games never drew
|
||||
* a Depot and never escaped: they averaged **−6.0** revenue against **−0.4** for games that
|
||||
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post. Escaping needed one of
|
||||
* 4 Depot cards in 111, roughly a 59% chance across a game's draws.
|
||||
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post.
|
||||
*
|
||||
* Upgrades are strictly sequential (Gap 3b, no skipping), so Station and Terminal are rarer than
|
||||
* their raw counts imply — Terminal needs all three cards in order. Station and Terminal were
|
||||
@@ -153,9 +156,10 @@ export type OfficeProfile = {
|
||||
*
|
||||
* PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal,
|
||||
* not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and
|
||||
* dilutes every other category slightly (deck 133 → 140). Revisit once the victory target is
|
||||
* settled and freight is carrying its intended share; the right answer may instead be fewer
|
||||
* Terminals, a cheaper first upgrade, or more A/D capacity at Whistle Post.
|
||||
* dilutes every other category slightly. Revisit once the victory target is settled and freight is
|
||||
* carrying its intended share; the right answer may instead be fewer Terminals, a cheaper first
|
||||
* upgrade, or more A/D capacity at Whistle Post. The counts themselves are in the rows below, which
|
||||
* is the only place they should be read from.
|
||||
*/
|
||||
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
|
||||
{ tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 },
|
||||
@@ -199,13 +203,14 @@ export type IndustryProfile = {
|
||||
|
||||
/**
|
||||
* Industry density (Gap 12). The recovered sheet lists 9 industries in a 115-card deck; the
|
||||
* prototype ran 10 in 52. At 9-in-115 a game saw 1.6 Freight Facilities, freight was 10% of gross
|
||||
* revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the point of
|
||||
* the game, effectively never ran.
|
||||
* prototype ran 10 in 52. At the sheet's density a game saw 1.6 Freight Facilities, freight was 10%
|
||||
* of gross revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the
|
||||
* point of the game, effectively never ran.
|
||||
*
|
||||
* Each industry's `copies` is TRIPLED, giving 27 in 133. That restores roughly the prototype's
|
||||
* Each industry's `copies` is TRIPLED against the sheet, which restores roughly the prototype's
|
||||
* ratio while preserving the sheet's proportions exactly: the outbound/inbound balance and the
|
||||
* lockout structure are unchanged, because every kind scales by the same factor.
|
||||
* lockout structure are unchanged, because every kind scales by the same factor. The multiplier is
|
||||
* the decision; the resulting totals are in the rows below and move with every retune.
|
||||
*/
|
||||
/**
|
||||
* LOCKOUTS, from the sheet's "Lockouts" column verbatim:
|
||||
@@ -231,42 +236,51 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
|
||||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
|
||||
/**
|
||||
* BOTH DIRECTIONS, per the card reference — this was `outbound` and it contradicted the rules.
|
||||
* OUTBOUND ONLY. A Refinery ships oil out and receives nothing; reported from playtesting and
|
||||
* confirmed by Jesse (v0.4.9e): "only ships out tanks, does not receive anything".
|
||||
*
|
||||
* `card-reference.md`: "Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4", and in prose — "'Freight
|
||||
* House' is not a card. It is the collective term for a freight facility that loads *and* unloads
|
||||
* — the Grocer's Warehouse and the Oil Refinery." §9.3's "Passenger Facilities and Freight Houses
|
||||
* permit cars to move each direction" therefore names exactly these two, and the engine had both
|
||||
* of them one-way.
|
||||
* It was briefly `flow: 'both'`, on the reading that "'Freight House' is not a card — it is the
|
||||
* collective term for a freight facility that loads *and* unloads, the Grocer's Warehouse and the
|
||||
* Oil Refinery", which made §9.3's "Passenger Facilities and Freight Houses permit cars to move
|
||||
* each direction" name exactly those two. That premise is dead: `glossary.md` and
|
||||
* `rules-v0.2.md` corrected the Freight House to a card of its own, dealt like any other industry,
|
||||
* so §9.3 names the Freight House and nothing else, and card-reference.md's "Both" column loses
|
||||
* the only argument it had.
|
||||
*
|
||||
* The consequence was silent: `usableGrant` drops a Modifier's grant on a direction its host
|
||||
* cannot use, so every +1 inbound beside a Refinery went nowhere.
|
||||
*
|
||||
* The base numbers stay at the engine's own scale (1 per direction it allows) rather than the card
|
||||
* reference's 2/2 — every industry here is scaled down the same way, Mine Tipple included, and
|
||||
* raising one of them alone would be a balance change rather than a correction. Flagged in TODO.
|
||||
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
|
||||
* Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
|
||||
* the game with no way to raise the direction it is supposed to use half its capacity on.
|
||||
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
|
||||
*/
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
|
||||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
|
||||
/**
|
||||
* BOTH DIRECTIONS — see the Refinery above; "Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3".
|
||||
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
|
||||
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
|
||||
* not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in".
|
||||
*
|
||||
* Reported from play: "grocer's warehouse didn't get extra outbound slot for truck dock." It could
|
||||
* not: the Truck Dock printed +1 outbound at the time and this was `flow: 'inbound'`, so the grant
|
||||
* was dropped on a direction the facility did not have. The same trap still swallows an Ice House
|
||||
* set beside a Grocer's that has been left one-way.
|
||||
*
|
||||
* `TODO.md` had previously recorded this as "checked, and there is no bug" on the reasoning that a
|
||||
* Grocer's is inbound-only. That premise was the bug.
|
||||
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
|
||||
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
|
||||
* Home Deck sheet says so outright — "a bonus beside a facility that cannot use its direction is
|
||||
* not usable", naming the Truck Dock's inbound bonus beside the outbound-only Packing Sheds as the
|
||||
* other example. The Truck Dock (+1 inbound) and Local Small Groceries (+1 Laborer) are the two
|
||||
* that do work here.
|
||||
*/
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
|
||||
];
|
||||
|
||||
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
|
||||
export const FREIGHT_PROFILES = INDUSTRY_PROFILES;
|
||||
|
||||
/** §9.3 — the collective term for an industry that both loads and unloads. */
|
||||
/**
|
||||
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction".
|
||||
*
|
||||
* ONE CARD ANSWERS TO THIS NOW: the Freight House itself. It was briefly three, while the Refinery
|
||||
* and the Grocer's Warehouse were also `both` on a reading of the term the glossary has since
|
||||
* corrected — a Freight House is a card, not a collective noun. Kept as a predicate on `flow`
|
||||
* rather than a comparison against the kind, because it is the DIRECTION §9.3 is talking about.
|
||||
*/
|
||||
export function isFreightHouse(p: IndustryProfile): boolean {
|
||||
return p.flow === 'both';
|
||||
}
|
||||
@@ -278,7 +292,7 @@ export function industryProfile(kind: FreightKind): IndustryProfile {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modifiers — 17 industry + 6 passenger, each tied to specific hosts
|
||||
// Modifiers — passenger and industry, each tied to specific hosts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ModifierKind =
|
||||
@@ -347,7 +361,7 @@ export function modifierProfile(kind: ModifierKind): ModifierProfile {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trains — 22 cards, named, with speed class and individual rules
|
||||
// Trains — named, with a speed class and individual printed rules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TrainSpeed = 'fast' | 'slow';
|
||||
@@ -411,11 +425,18 @@ function pair(odd: number, name: string, speed: TrainSpeed, consist: ConsistSpec
|
||||
}
|
||||
|
||||
export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
|
||||
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 3, caboose: 0 },
|
||||
/**
|
||||
* COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited
|
||||
* drops from three coaches to two, and The Sparrow rises from two to three. A change to the card
|
||||
* faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it
|
||||
* still print the old numbers, so `docs/rules/card-reference.md` is the place that now carries
|
||||
* what the cards say.
|
||||
*/
|
||||
...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 2, caboose: 0 },
|
||||
{ terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }),
|
||||
...pair(3, 'Express', 'fast', { freight: 2, coach: 0, caboose: 0 },
|
||||
{ oneFreightPerLocation: true, expedite: true, note: 'May drop or pick up one freight car at every location.' }),
|
||||
...pair(5, 'The Sparrow', 'fast', { freight: 0, coach: 2, caboose: 0 },
|
||||
...pair(5, 'The Sparrow', 'fast', { freight: 0, coach: 3, caboose: 0 },
|
||||
{ noSwitching: true, expedite: true }),
|
||||
...pair(7, 'Local', 'slow', { freight: 1, coach: 1, caboose: 0 },
|
||||
{ coachStaysOnStationTrack: true, note: 'Maximum one freight, one coach.' }),
|
||||
@@ -443,11 +464,18 @@ export const EXTRA_TRAINS: readonly TrainProfile[] = [
|
||||
export const ALL_TRAINS: readonly TrainProfile[] = [...TIMETABLED_TRAINS, ...EXTRA_TRAINS];
|
||||
|
||||
/**
|
||||
* §2.3 — ODD RUNS WEST, EVEN RUNS EAST. The number is the direction, for an Extra as much as for a
|
||||
* timetabled train, and the Division Point it starts at is therefore the one it runs away from.
|
||||
* §2.3 — ODD RUNS WEST, EVEN RUNS EAST. The number is the direction for a TIMETABLED train.
|
||||
*
|
||||
* Extras print `direction: 'playerChoice'`, which the engine read as "always eastbound from the West
|
||||
* Division Point". Jesse's ruling: the number decides, like everything else on the timetable.
|
||||
* NOT for an Extra any more. Extras print `direction: 'playerChoice'` and now mean it (Jesse's
|
||||
* ruling, superseding "the number decides, like everything else on the timetable"): the player who
|
||||
* played the card picks where it starts, and the start decides the direction — a Crew Tray placed
|
||||
* at the Western Division Point runs east and one at the Eastern runs west, because the alternative
|
||||
* is a train that leaves the Division on its first move having crossed nothing. Where the start is
|
||||
* NOT an end of the Division — an Interchange, or a Control Point — both ways are real runs and the
|
||||
* player says which. `resolveExtraStart` in apply.ts is where that happens.
|
||||
*
|
||||
* Still read for an Extra in one place: replaying a save written before the choice existed, where
|
||||
* `newTrain.startExtra` carries only the legacy `atSeat` field.
|
||||
*/
|
||||
export function runDirection(trainNumber: number): Direction {
|
||||
return trainNumber % 2 === 0 ? 'east' : 'west';
|
||||
@@ -518,6 +546,29 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
||||
{ kind: 'interchange', name: 'Interchange', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* THE MAINLINE DECK AS PRINTED, dealt WITHOUT replacement.
|
||||
*
|
||||
* `MAINLINE_PROFILES` above is a list of card TYPES, one entry each. It is not the deck, and setup
|
||||
* used it as one: `buildDivision` drew uniformly from those types with replacement, which made two
|
||||
* Interchanges (or two Trestles, or two Tunnels) an ordinary outcome and gave Plains the same weight
|
||||
* as everything else although the deck prints two of it. `Mainline Cards.pdf` is the
|
||||
* inventory, transcribed in `docs/StationMaster-Mainline-Deck-v0.4.5.md`, which had already flagged
|
||||
* the mismatch as needing correction.
|
||||
*
|
||||
* It matters more than card flavour now that an Extra may start at an Interchange (§7): "if an
|
||||
* Interchange is on the board" has to mean a card there is at most one of, not a type the deal can
|
||||
* hand out twice.
|
||||
*
|
||||
* The two Division Point cards in that inventory are not here — they are the fixed ends of the
|
||||
* Division, laid by `buildDivision` itself rather than drawn. Plains appears twice because the deck
|
||||
* prints it twice; every other type once. The list below is the deck, so it is not counted here.
|
||||
*/
|
||||
export const MAINLINE_DECK: readonly MainlineKind[] = [
|
||||
'plains', 'plains', 'curves', 'hilly', 'heavyGrade',
|
||||
'doubleTrack', 'uncontrolledSiding', 'tunnel', 'trestle', 'interchange',
|
||||
];
|
||||
|
||||
/**
|
||||
* How many Stages a train needs to cross a Mainline card.
|
||||
*
|
||||
@@ -684,9 +735,9 @@ export type SimpleCard = {
|
||||
* The opponent-directed card this exists SOLELY to answer.
|
||||
*
|
||||
* A defence with nothing to defend against is a dead draw, exactly as the attack itself would be.
|
||||
* The 22 Space-use and Action cards are held out of every deck until they are implemented (Q6),
|
||||
* and these go with them — named here rather than in a list somewhere else so the pairing is
|
||||
* visible on the card, and so they come back together when their attacker does.
|
||||
* The Space-use and Action cards are held out of every deck until they are implemented (Q6), and
|
||||
* these go with them — named here rather than in a list somewhere else so the pairing is visible
|
||||
* on the card, and so they come back together when their attacker does.
|
||||
*/
|
||||
answers?: string;
|
||||
};
|
||||
@@ -732,39 +783,48 @@ export type EnhancementRule = {
|
||||
* opponent-directed card that a solitaire deck does not contain (Q6).
|
||||
* - `unbuilt` — nothing reads it at all. The effect is recorded here and not yet written.
|
||||
*
|
||||
* Seven of the ten are live. Each row below cites the file that reads it, because the first
|
||||
* attempt at this table got FIVE of the ten wrong: it was filled in by grepping for four helper
|
||||
* function names and reading "no match" as "no implementation", when Interlocking, Yard Office,
|
||||
* Small Yard and ABS Signals are all read directly by key — and all four are covered by tests in
|
||||
* `enhancements.test.ts` that were passing the whole time. The result was a tooltip telling players
|
||||
* that four working cards did nothing, which is worse than the bare label it replaced.
|
||||
* Most are live. Each row below cites the file that reads it — the row is the answer, and no
|
||||
* tally is kept here, because a tally is one more thing to forget when a card is implemented.
|
||||
*
|
||||
* The first attempt at this table got FIVE of the ten wrong: it was filled in by grepping for four
|
||||
* helper function names and reading "no match" as "no implementation", when Interlocking, Yard
|
||||
* Office, Small Yard and ABS Signals are all read directly by key — and all four are covered by
|
||||
* tests in `enhancements.test.ts` that were passing the whole time. The result was a tooltip
|
||||
* telling players that four working cards did nothing, which is worse than the bare label it
|
||||
* replaced.
|
||||
*
|
||||
* KEEP THIS HONEST, AND CHECK THE CITATION. Implementing one of these means changing its value in
|
||||
* the same commit; otherwise the card goes on apologising for something it now does. It is data
|
||||
* rather than something derived because "is this key read anywhere" is not a question the type
|
||||
* system can answer — but a claim here without a file reference beside it is a claim nobody checked.
|
||||
* system can answer — but a claim here without a reference beside it is a claim nobody checked.
|
||||
*
|
||||
* CITE THE FUNCTION, NEVER THE LINE NUMBER. Every one of these rows once carried a `file.ts:NNN`
|
||||
* and every one of them had rotted — `apply.ts:405` for Small Yard was pointing four hundred lines
|
||||
* short by the time anyone looked. A line number is a citation that decays silently on the next
|
||||
* edit anywhere above it, which is the opposite of what this note is for.
|
||||
*/
|
||||
effect: 'live' | 'dormantSolo' | 'unbuilt';
|
||||
};
|
||||
|
||||
export const ENHANCEMENT_RULES: readonly EnhancementRule[] = [
|
||||
// Holds an arrival at the Limits instead of colliding when the Office is full — advance.ts:770.
|
||||
// Holds an arrival at the Limits instead of colliding when the Office is full — `arriveAtOffice`.
|
||||
{ key: 'interlocking', placement: 'runningTrackStraight', effect: 'live' },
|
||||
// Wired at apply.ts:1738, but it answers Derail, an Action card the solitaire deck omits (Q6).
|
||||
// Wired in `isProtectedFromDerail`, but it answers Derail, an Action card the solo deck omits (Q6).
|
||||
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking', effect: 'dormantSolo' },
|
||||
// Diverts a coachless arrival away from the Train Order Office — advance.ts:750.
|
||||
// Diverts a coachless arrival away from the Train Order Office — `arriveAtOffice`.
|
||||
{ key: 'yardOffice', placement: 'secondaryTrackStraight', effect: 'live' },
|
||||
// Lets a consist be re-ordered for one Move — apply.ts:405.
|
||||
// Lets a consist be re-ordered for one Move — `check`'s `switch.sortConsist` case.
|
||||
{ key: 'smallYard', placement: 'secondaryTrackStraight', effect: 'live' },
|
||||
// Wired at apply.ts:1743, but it removes a Watertower, a Space-use card the solo deck omits.
|
||||
// Wired in `watertowersRemovable`, but it removes a Watertower, a Space-use card the solo deck omits.
|
||||
{ key: 'waterColumn', placement: 'runningTrackStraight', effect: 'dormantSolo' },
|
||||
// The only one with NO code path at all: nothing anywhere reads `overpass`.
|
||||
{ key: 'overpass', placement: 'onCard', effect: 'unbuilt' },
|
||||
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4, effect: 'live' },
|
||||
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8, effect: 'live' },
|
||||
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12, effect: 'live' },
|
||||
// Stored on the Mainline node rather than in `enhancements[]` — apply.ts:1461, read at
|
||||
// advance.ts:599 (no rear-ending) and advance.ts:721 (the follower holds instead of being ruled on).
|
||||
// Stored on the Mainline node rather than in `enhancements[]` — written by `reduce`'s
|
||||
// `enhancementPlaced`, read in `moveTrain` (no rear-ending) and `evaluateClearance` (the follower
|
||||
// holds instead of being ruled on).
|
||||
{ key: 'absSignals', placement: 'mainlineCard', effect: 'live' },
|
||||
];
|
||||
|
||||
@@ -855,11 +915,15 @@ export type StockSupply = { type: CarType; loaded: number; empty: number };
|
||||
/**
|
||||
* NOT in the recovered files — still the provisional figure.
|
||||
*
|
||||
* Scaled up alongside the Gap 12 industry increase. Worst-case demand (every copy of every
|
||||
* industry in play at full capacity) is boxcar 15, hopper 12, tank 9, reefer 6; the supply must
|
||||
* cover that, since a Division Yard that runs dry starves the freight loop the increase exists to
|
||||
* feed. Lockouts and district size mean the worst case cannot actually occur, so this carries
|
||||
* deliberate headroom.
|
||||
* Scaled up alongside the Gap 12 industry increase. The rule it was set by, rather than the numbers
|
||||
* it produced: worst-case demand for a car type is every copy of every industry that uses it, in
|
||||
* play at full capacity, and the supply must cover that — a Division Yard that runs dry starves the
|
||||
* freight loop the increase exists to feed. Lockouts and district size mean the worst case cannot
|
||||
* actually occur, so this carries deliberate headroom. RE-DERIVE IT FROM `INDUSTRY_PROFILES`
|
||||
* whenever industry copies move; a figure written here would not survive the next retune.
|
||||
*
|
||||
* Coaches are the type this rule does NOT cover, because no industry asks for one — their demand
|
||||
* comes from passenger work, and Gitea#2 is the open report that the supply is short (see TODO.md).
|
||||
*/
|
||||
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||
{ type: 'coach', loaded: 8, empty: 8 },
|
||||
@@ -887,11 +951,11 @@ export const STAGES_PER_SHIFT = 3;
|
||||
export const HAND_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* The split opening deal: 3 track cards and 3 others, from two separately shuffled piles
|
||||
* (`setup.ts`). One of the three `StartingHand` options below, not the only one any more.
|
||||
* The split opening deal — track cards and others, from two separately shuffled piles (`setup.ts`).
|
||||
* One of the three `StartingHand` options below, not the only one any more.
|
||||
*
|
||||
* Six against a limit of three on purpose — the first turn is spent choosing which district you can
|
||||
* afford to build.
|
||||
* Deliberately over the hand limit: the first turn is spent choosing which district you can afford
|
||||
* to build. The two constants below are the deal.
|
||||
*/
|
||||
export const OPENING_TRACK = 3;
|
||||
export const OPENING_OTHER = 3;
|
||||
@@ -925,10 +989,16 @@ export const OPENING_DEALS: Readonly<Record<StartingHand, { any: number; track:
|
||||
/**
|
||||
* WHAT THE THREE WORKING ECONOMIES PAY.
|
||||
*
|
||||
* Balance is the open problem in this game — the developer bot averages 7.0 Revenue against a target
|
||||
* of 20, of which most came from traffic nobody had to work — and the way to settle it is to play it
|
||||
* at several settings rather than to keep re-deriving it. So the three rates are dials, set when the
|
||||
* game is dealt and fixed for its duration.
|
||||
* Balance is the open problem in this game, and the way to settle it is to play it at several
|
||||
* settings rather than to keep re-deriving it. So the three rates are dials, set when the game is
|
||||
* dealt and fixed for its duration.
|
||||
*
|
||||
* DO NOT WRITE A CURRENT BALANCE FIGURE HERE. This said "the developer bot averages 7.0 Revenue
|
||||
* against a target of 20" long after that stopped being true: 7.0 was measured while
|
||||
* `trainPerTransit` still defaulted to 1, and the paragraph below explains that this very setting
|
||||
* was then defaulted to 0 for being worth ~5.4 of it. Re-measured 2026-08-22 over 400 games at the
|
||||
* current defaults, the developer bot means about ZERO. Run `node src/sim/harness.ts 400 standard`
|
||||
* for today's number rather than trusting one written here.
|
||||
*
|
||||
* `passengerPerCoach` and `freightPerLoad` each pay on BOTH halves of their cycle: a coach pays when
|
||||
* it is boarded and again when it is detrained, a load pays when it is made up outbound and again
|
||||
@@ -945,10 +1015,30 @@ export type RevenueRules = {
|
||||
trainPerTransit: number;
|
||||
};
|
||||
|
||||
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules };
|
||||
/**
|
||||
* WHERE AN EXTRA MAY BE STARTED — a setting, because the table disagrees about it.
|
||||
*
|
||||
* §7 gives an Extra "either Division Point", and Jesse's ruling adds the Interchange to that base
|
||||
* set: both are places on the shared Division that belong to nobody, so neither favours a seat.
|
||||
* Starting one inside a player's own district is the part that does, which is what this dials.
|
||||
*
|
||||
* - `divisionPointsOnly` — the Division Points and the Interchange. No Office start at all.
|
||||
* - `ownOffice` — those, plus a Control Point in the district of the player who played the card.
|
||||
* - `anyOffice` — those, plus a Control Point in ANY player's district.
|
||||
*
|
||||
* A Whistle Post never qualifies however this is set: an Office has to be a Control Point to start
|
||||
* an Extra, which is part of what upgrading buys (§11).
|
||||
*/
|
||||
export type ExtraStartRule = 'divisionPointsOnly' | 'ownOffice' | 'anyOffice';
|
||||
|
||||
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules; extraStart: ExtraStartRule };
|
||||
|
||||
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
|
||||
export type HouseRuleOverrides = { startingHand?: StartingHand; revenue?: Partial<RevenueRules> };
|
||||
export type HouseRuleOverrides = {
|
||||
startingHand?: StartingHand;
|
||||
revenue?: Partial<RevenueRules>;
|
||||
extraStart?: ExtraStartRule;
|
||||
};
|
||||
|
||||
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
||||
export const REVENUE_MIN = 0;
|
||||
@@ -957,6 +1047,10 @@ export const REVENUE_MAX = 5;
|
||||
export const DEFAULT_HOUSE_RULES: HouseRules = {
|
||||
startingHand: 'threeRandom',
|
||||
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0 },
|
||||
// `anyOffice` is what the engine did before the setting existed, so a game dealt without naming
|
||||
// it plays the way it always has. Jesse's call, so the 0.4.9 playtest line does not change under
|
||||
// its testers in the middle of a bugfix release.
|
||||
extraStart: 'anyOffice',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -971,6 +1065,8 @@ export const DEFAULT_HOUSE_RULES: HouseRules = {
|
||||
export const LEGACY_HOUSE_RULES: HouseRules = {
|
||||
startingHand: 'threeTrackThreeOther',
|
||||
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
|
||||
// An Extra could always be started at a Control Point in these games, in any district.
|
||||
extraStart: 'anyOffice',
|
||||
};
|
||||
|
||||
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
||||
@@ -989,6 +1085,7 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
|
||||
freightPerLoad: clamp(rev.freightPerLoad, d.revenue.freightPerLoad),
|
||||
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
|
||||
},
|
||||
extraStart: given.extraStart ?? d.extraStart,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -998,6 +1095,12 @@ export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string
|
||||
{ value: 'sixRandom', label: 'Six random cards' },
|
||||
{ value: 'threeTrackThreeOther', label: 'Three random track and three random non-track cards' },
|
||||
];
|
||||
/** What the dialog calls each `ExtraStartRule`, in the order it offers them. */
|
||||
export const EXTRA_START_LABELS: readonly { value: ExtraStartRule; label: string }[] = [
|
||||
{ value: 'divisionPointsOnly', label: 'Division Points and the Interchange only' },
|
||||
{ value: 'ownOffice', label: 'Also the playing player\u2019s own Control Point' },
|
||||
{ value: 'anyOffice', label: 'Also any player\u2019s Control Point' },
|
||||
];
|
||||
export const MAX_CONSIST = 4;
|
||||
export const MOVES_PER_LOCAL_OPS = 6;
|
||||
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||
@@ -1037,7 +1140,8 @@ export function lengthProfile(length: GameLength): LengthProfile {
|
||||
|
||||
/**
|
||||
* Cards that can only be played AT another player (Q6). In a solitaire game they have no legal
|
||||
* target, so they are removed from the deck rather than sitting in hand as 19% dead draws.
|
||||
* target, so they are removed from the deck rather than sitting in hand as dead draws — which, when
|
||||
* this was written, was very nearly a fifth of the deck.
|
||||
*/
|
||||
export const OPPONENT_ONLY_CATEGORIES: readonly string[] = ['spaceUse', 'action'];
|
||||
|
||||
@@ -1045,8 +1149,10 @@ export function isOpponentOnly(category: string): boolean {
|
||||
return OPPONENT_ONLY_CATEGORIES.includes(category);
|
||||
}
|
||||
|
||||
/** How many cards `DEFENCE_ONLY_CARDS` accounts for — 7: two Facing Point Locks of each kind, two
|
||||
* Water Columns and one Overpass. */
|
||||
/**
|
||||
* How many cards `DEFENCE_ONLY_CARDS` accounts for. Summed from the rows rather than written down:
|
||||
* which cards qualify is `SimpleCard.answers`, and the total moves whenever one is added.
|
||||
*/
|
||||
export const DEFENCE_ONLY_COPIES =
|
||||
ENHANCEMENT_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0) +
|
||||
MAINLINE_MODIFIER_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0);
|
||||
@@ -1069,17 +1175,19 @@ export function deckComposition(): { category: string; count: number }[] {
|
||||
|
||||
/**
|
||||
* The whole CATALOGUE, including cards not currently dealt. Not the size of any deck in play — see
|
||||
* `DEALT_DECK_SIZE`, which is what `buildDeck` actually returns.
|
||||
* `SOLITAIRE_DECK_SIZE` below, which is what `buildDeck` actually deals. (This pointed at a
|
||||
* `DEALT_DECK_SIZE` that has never existed.)
|
||||
*/
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
|
||||
|
||||
/**
|
||||
* The deck actually dealt, in every mode: the catalogue less the 22 opponent-directed cards.
|
||||
* The deck actually dealt, in every mode: the catalogue less the opponent-directed cards, less the
|
||||
* defensive cards that exist only to answer them.
|
||||
*
|
||||
* Named for solitaire because Q6 dropped them there first, and kept under that name because the
|
||||
* number is the same either way. They are out of the competitive deck too until they are
|
||||
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make ~9%
|
||||
* of draws reject. See `buildDeck`.
|
||||
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make a
|
||||
* meaningful share of draws reject outright. See `buildDeck`.
|
||||
*/
|
||||
export const SOLITAIRE_DECK_SIZE =
|
||||
deckComposition()
|
||||
|
||||
+24
-5
@@ -19,8 +19,8 @@
|
||||
* reconstruct the whole board to draw one frame.
|
||||
*/
|
||||
|
||||
import type { CarType, OfficeTier } from './content.ts';
|
||||
import type { LocalOpsOption } from './intents.ts';
|
||||
import type { CarType, Direction, OfficeTier } from './content.ts';
|
||||
import type { ExtraStart, LocalOpsOption } from './intents.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
|
||||
export type GameEvent =
|
||||
@@ -154,15 +154,34 @@ export type GameEvent =
|
||||
*/
|
||||
| { type: 'trainCompleted'; trainNumber: number; isExtra: boolean; side: 'east' | 'west'; consist: RollingStock[] }
|
||||
/** An Extra took a Crew Tray and started its run — at a Division Point, or at a Control Point. */
|
||||
| { type: 'extraStarted'; player: PlayerIndex; trainNumber: number; atSeat: SeatIndex | null }
|
||||
/**
|
||||
* Carries the RESOLVED start and direction (`resolveExtraStart`), not the raw intent fields, so
|
||||
* the reducer never re-answers a question `check` already answered — the same shape as
|
||||
* `passengersBoarded` carrying its tray and coach index.
|
||||
*/
|
||||
| {
|
||||
type: 'extraStarted';
|
||||
player: PlayerIndex;
|
||||
trainNumber: number;
|
||||
at: ExtraStart;
|
||||
direction: Direction;
|
||||
}
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
||||
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||
// -- load / unload
|
||||
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord }
|
||||
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord }
|
||||
/**
|
||||
* `trayId` and `coachIndex` name the TRAIN and the COACH the Porter worked, rather than leaving the
|
||||
* reducer to find them again — the same lesson as `unloadBegan`'s `carIndex` below. Re-deriving
|
||||
* "the first empty coach on the first train at the Office" is how two trains standing at one
|
||||
* station both answered to one roster chip (v0.4.9d playtest), and how a coach the player had not
|
||||
* chosen got filled. Required, not optional: an event is a fact, and a fact that has to be looked
|
||||
* up against live state cannot render standalone in a replay.
|
||||
*/
|
||||
| { type: 'passengersBoarded'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number }
|
||||
| { type: 'passengersDetrained'; player: PlayerIndex; at: GridCoord; trayId: TrayId; coachIndex: number }
|
||||
| { type: 'loadStarted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
| { type: 'loadAdvanced'; player: PlayerIndex; at: GridCoord; fromBox: number; toBox: number }
|
||||
| { type: 'unloadCompleted'; player: PlayerIndex; at: GridCoord; carType: CarType }
|
||||
|
||||
+74
-8
@@ -13,6 +13,17 @@ import type { CardId, GridCoord, PlayerIndex, SeatIndex, TrayId } from './state.
|
||||
|
||||
export type LocalOpsOption = 'switch' | 'draw' | 'freightAgent';
|
||||
|
||||
/**
|
||||
* Where an Extra is placed when it is started (§7).
|
||||
*
|
||||
* `mainline` names a node index in `division.nodes` and is only ever an Interchange; `office` names
|
||||
* a SEAT, which is what an Office Area belongs to, and only ever a Control Point.
|
||||
*/
|
||||
export type ExtraStart =
|
||||
| { kind: 'divisionPoint'; side: Direction }
|
||||
| { kind: 'mainline'; node: number }
|
||||
| { kind: 'office'; seat: SeatIndex };
|
||||
|
||||
export type Intent =
|
||||
| { type: 'localOps.choose'; option: LocalOpsOption }
|
||||
// -- switch (§6.1, Appendix A)
|
||||
@@ -74,12 +85,31 @@ export type Intent =
|
||||
| { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean }
|
||||
| { type: 'newTrain.passCar'; trayId: TrayId }
|
||||
/**
|
||||
* §7 — "the player who played the card may place the Crew Tray in either division point for
|
||||
* immediate departure", extended by Jesse: an Extra starts at the Division Point its NUMBER sends
|
||||
* it to (odd runs west, even east, exactly as a timetabled train), or at any Control Point — any
|
||||
* Office above a Whistle Post — at the player's choice. `atSeat` null means the Division Point.
|
||||
* §7 — "the player who played the card may place the Crew Tray at EITHER Division Point for
|
||||
* immediate departure", plus Jesse's ruling on where else and which way.
|
||||
*
|
||||
* The number does not decide an Extra's direction — the START does. Either Division Point may be
|
||||
* chosen and the train runs away from it (west end runs east, east end runs west, since the other
|
||||
* reading is a train that leaves the Division having crossed nothing). At an Interchange or a
|
||||
* Control Point, which are in the middle of the railroad, both ways are real runs and `direction`
|
||||
* says which; it is required there and ignored at a Division Point.
|
||||
*
|
||||
* WHICH STARTS ARE OFFERED is the `extraStart` house rule (content.ts) — Division Points and the
|
||||
* Interchange always, Offices by setting.
|
||||
*
|
||||
* `atSeat` IS LEGACY AND WRITE-ONCE. Saves written before this choice existed carry only that
|
||||
* field: `null` meant "the Division Point this train's number sends it to" and a seat meant that
|
||||
* Office, both running in the number's direction. `start` absent is exactly what those saves said,
|
||||
* so they replay unchanged; everything written from now on carries `start` and `atSeat` is
|
||||
* omitted. `resolveExtraStart` (apply.ts) is the single place that reads either.
|
||||
*/
|
||||
| { type: 'newTrain.startExtra'; trainNumber: number; atSeat: SeatIndex | null }
|
||||
| {
|
||||
type: 'newTrain.startExtra';
|
||||
trainNumber: number;
|
||||
atSeat?: SeatIndex | null;
|
||||
start?: ExtraStart;
|
||||
direction?: Direction;
|
||||
}
|
||||
/** Q9 — run a second, identical section behind a train that is due out this Stage. */
|
||||
| { type: 'newTrain.secondSection'; trainNumber: number }
|
||||
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
|
||||
@@ -101,8 +131,18 @@ export type Intent =
|
||||
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
|
||||
| { type: 'redFlag.play' }
|
||||
// -- Load/Unload Phase (§9)
|
||||
| { type: 'porter.board'; at: GridCoord }
|
||||
| { type: 'porter.detrain'; at: GridCoord }
|
||||
/**
|
||||
* `trayId` names the train the Porter works — reported from playtesting v0.4.9d as "operating two
|
||||
* trains in a station, the select button does not work: regardless of which you pick, it is always
|
||||
* one train, not the other". It was: neither intent carried a train, so the reducer took the first
|
||||
* one on the A/D tracks and the roster chip the player had clicked changed nothing but the drawing.
|
||||
*
|
||||
* OPTIONAL, like `switch.move`'s `via` and for the same reason: intents are the canonical record
|
||||
* `undo` and every save replay against, and absent means what it has always meant — the first
|
||||
* eligible train at the Office.
|
||||
*/
|
||||
| { type: 'porter.board'; at: GridCoord; trayId?: TrayId }
|
||||
| { type: 'porter.detrain'; at: GridCoord; trayId?: TrayId }
|
||||
/** §9.3 — the first Laborer step: Green Loading Slot -> MEN. */
|
||||
| { type: 'laborer.startLoad'; at: GridCoord }
|
||||
| { type: 'laborer.advanceLoad'; at: GridCoord; box: number }
|
||||
@@ -199,6 +239,17 @@ export type RejectionCode =
|
||||
* the Laborers can move it out of the box.
|
||||
*/
|
||||
| 'NO_EMPTY_CAR_SPOTTED'
|
||||
/**
|
||||
* §9 (Jesse's ruling, v0.4.9e) — freight or passengers loaded anywhere in an Office Area may not
|
||||
* be unloaded anywhere in that same Office Area. The load has to be carried out of the district by
|
||||
* a train first; a Freight House may not break the load it just made, and passengers may not
|
||||
* detrain at the platform they boarded from.
|
||||
*
|
||||
* Distinct from the other refusals because the car IS loaded, the Laborer IS free and the boxes
|
||||
* ARE clear: the only thing wrong with it is where it came from, and a player looking at a loaded
|
||||
* boxcar standing on their own industry track deserves to be told that rather than "wrong car".
|
||||
*/
|
||||
| 'LOADED_IN_THIS_DISTRICT'
|
||||
| 'NO_PORTERS_HERE'
|
||||
| 'NO_PASSENGERS_WAITING'
|
||||
| 'NO_EMPTY_COACH'
|
||||
@@ -208,7 +259,22 @@ export type RejectionCode =
|
||||
| 'NOT_A_CONTROL_POINT'
|
||||
| 'NO_EXTRA_PENDING'
|
||||
| 'NO_FREE_TRAY'
|
||||
| 'NO_FREE_AD_TRACK';
|
||||
| 'NO_FREE_AD_TRACK'
|
||||
// -- §7, where an Extra may be started (`resolveExtraStart`)
|
||||
| 'NO_SUCH_DIVISION_POINT'
|
||||
/** Only the Interchange has a yard an Extra can be made up in. */
|
||||
| 'NOT_AN_INTERCHANGE'
|
||||
/** In the middle of the railroad both ways are real runs, so the intent has to say which. */
|
||||
| 'NO_DIRECTION_CHOSEN'
|
||||
/** The `extraStart` house rule is `divisionPointsOnly`. */
|
||||
| 'OFFICE_STARTS_NOT_ALLOWED'
|
||||
/** The `extraStart` house rule is `ownOffice` and this is somebody else's district. */
|
||||
| 'NOT_YOUR_OFFICE'
|
||||
/**
|
||||
* §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';
|
||||
|
||||
export type Rejection = { code: RejectionCode; message: string };
|
||||
|
||||
|
||||
+34
-8
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
||||
import { enhancementRule } from './content.ts';
|
||||
import { enhancementRule, mainlineProfile } from './content.ts';
|
||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||
import type { Intent } from './intents.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||
@@ -271,14 +271,28 @@ function newTrainCandidates(s: GameState): Intent[] {
|
||||
out.push({ type: 'newTrain.secondSection', trainNumber: due });
|
||||
}
|
||||
/**
|
||||
* Where a pending Extra starts: its own Division Point, decided by its number, or any Control
|
||||
* Point. `check` refuses a Whistle Post and a full Office, so every seat is offered and the rules
|
||||
* do the filtering — one implementation of "is this a Control Point", not two.
|
||||
* WHERE A PENDING EXTRA MAY START (§7, Jesse's ruling) — every candidate offered, with `check`
|
||||
* doing the filtering, so "is this a Control Point" and "does the house rule allow it" have one
|
||||
* implementation each rather than two.
|
||||
*
|
||||
* BOTH Division Points, not the one the number dictates: an Extra's direction comes from where it
|
||||
* is placed. In the middle of the railroad — an Interchange, a Control Point — both ways are real
|
||||
* runs, so those are offered twice, once per direction.
|
||||
*/
|
||||
for (const trainNumber of s.pendingExtras) {
|
||||
out.push({ type: 'newTrain.startExtra', trainNumber, atSeat: null });
|
||||
for (const side of ['west', 'east'] as const) {
|
||||
out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'divisionPoint', side } });
|
||||
}
|
||||
for (const [node, n] of s.division.nodes.entries()) {
|
||||
if (n.kind !== 'mainline' || !mainlineProfile(n.card).sortsCars) continue;
|
||||
for (const direction of ['west', 'east'] as const) {
|
||||
out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'mainline', node }, direction });
|
||||
}
|
||||
}
|
||||
for (const seat of s.officeAreas.keys()) {
|
||||
out.push({ type: 'newTrain.startExtra', trainNumber, atSeat: seat });
|
||||
for (const direction of ['west', 'east'] as const) {
|
||||
out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'office', seat }, direction });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -288,9 +302,21 @@ function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
const out: Intent[] = [];
|
||||
const area = areaOf(s, player);
|
||||
|
||||
/**
|
||||
* ONE OPTION PER TRAIN STANDING AT THE OFFICE, not one per square.
|
||||
*
|
||||
* Reported from playtesting v0.4.9d: "operating two trains in a station, the select button does
|
||||
* not work — regardless of which you pick, it is always one train, not the other". There was only
|
||||
* ever ONE `board passengers` button, because the intent carried no train; the roster chip chose
|
||||
* what the board drew and nothing else. Now each eligible train is its own candidate, and `check`
|
||||
* filters the ones whose card, consist or passengers rule them out.
|
||||
*/
|
||||
const traysHere = area.adOccupancy.filter((id) => s.trays.has(id));
|
||||
for (const coord of facilityCoords(s, player)) {
|
||||
out.push({ type: 'porter.board', at: coord });
|
||||
out.push({ type: 'porter.detrain', at: coord });
|
||||
for (const trayId of traysHere) {
|
||||
out.push({ type: 'porter.board', at: coord, trayId });
|
||||
out.push({ type: 'porter.detrain', at: coord, trayId });
|
||||
}
|
||||
const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
|
||||
if (f) {
|
||||
out.push({ type: 'laborer.startLoad', at: coord });
|
||||
|
||||
+19
-3
@@ -17,7 +17,7 @@ import {
|
||||
MODIFIER_PROFILES,
|
||||
OFFICE_PROFILES,
|
||||
OPENING_DEALS,
|
||||
MAINLINE_PROFILES,
|
||||
MAINLINE_DECK,
|
||||
houseRules,
|
||||
mainlineProfile,
|
||||
TRACK_CARDS,
|
||||
@@ -220,11 +220,27 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
||||
* placed between each player"), which is what gives the Division its terrain and therefore its
|
||||
* crossing times.
|
||||
*/
|
||||
/**
|
||||
* THE MAINLINE CARDS ARE DEALT FROM A DECK, NOT ROLLED.
|
||||
*
|
||||
* `MAINLINE_PROFILES` is a list of card TYPES and this drew from it uniformly WITH replacement, so
|
||||
* a Division could be handed two Interchanges or two Tunnels, and Plains — printed twice in the
|
||||
* deck — carried the same weight as cards printed once. `MAINLINE_DECK` is the printed inventory
|
||||
* (`docs/StationMaster-Mainline-Deck-v0.4.5.md`, which flagged this as needing correction), and the
|
||||
* deal is now a deal: take cards out of it and do not put them back.
|
||||
*
|
||||
* The Extra-start rules are what forced the issue. "An Extra may start at the Interchange if one is
|
||||
* on the board" only reads as a rule if the board can hold at most one.
|
||||
*
|
||||
* A Division needs `players + 1` cards, so four players draw five from ten and the deck is never
|
||||
* close to exhausted; the throw is there because a silent short Division would be very hard to see.
|
||||
*/
|
||||
function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
||||
const nodes: DivisionNode[] = [];
|
||||
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
|
||||
const deck = [...MAINLINE_DECK];
|
||||
const mainline = (): DivisionNode => {
|
||||
const card = kinds[rng.nextInt(kinds.length)]!;
|
||||
if (deck.length === 0) throw new Error('the Mainline deck ran out — too many players for it');
|
||||
const card = deck.splice(rng.nextInt(deck.length), 1)[0]!;
|
||||
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
|
||||
// The Heavy Grade card says "Player sets orientation", but setup has no decision point yet —
|
||||
// createGame is synchronous and returns a ready state. Rolled for now so the orientation is at
|
||||
|
||||
+72
-1
@@ -56,7 +56,47 @@ export function coordKey(c: GridCoord): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** §2.2 — a coloured car is loaded, a white car is empty. */
|
||||
export type RollingStock = { type: CarType; loaded: boolean };
|
||||
export type RollingStock = {
|
||||
type: CarType;
|
||||
loaded: boolean;
|
||||
/**
|
||||
* WHICH OFFICE AREA MADE THIS LOAD — the physical game's chip turned upside down in the tray.
|
||||
*
|
||||
* Reported from playtesting v0.4.9d as two bugs with one cause: a boxcar loaded at a Freight
|
||||
* House could be unloaded at that same Freight House on the next Laborer action, and passengers
|
||||
* who had just boarded could be detrained again before the train turned a wheel. Both paid full
|
||||
* Revenue at each end for a load that never went anywhere.
|
||||
*
|
||||
* Jesse's rule (v0.4.9e): freight or passengers loaded anywhere in an Office Area may not be
|
||||
* unloaded ANYWHERE in that same Office Area — not at another facility, not in a later Stage.
|
||||
* They have to be carried by a train to a different Office Area. So the stamp is the SEAT, which
|
||||
* is what an Office Area belongs to (Employee Rotation moves players between chairs; the district
|
||||
* stays with the chair), and it never expires.
|
||||
*
|
||||
* A SEAT, NOT A PLAYER, and undefined rather than -1 for "no origin": the Division Yard opens with
|
||||
* loaded cars and loaded coaches that were made up off-Division (`ROLLING_STOCK_SUPPLY`), and
|
||||
* those are exactly the inbound traffic a solitaire district lives on. A sentinel inside
|
||||
* `SeatIndex`'s own value range is not a sentinel — see `card.play`'s `node` in intents.ts.
|
||||
*
|
||||
* Stripped by `pooled` whenever a car goes back to a yard: the stamp belongs to the LOAD, and a
|
||||
* car returning to the common supply is carrying nothing.
|
||||
*/
|
||||
origin?: SeatIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* A car returning to the common pool — the Division or Classification Yard — with its load's origin
|
||||
* stamp taken off.
|
||||
*
|
||||
* Every yard push goes through this. A loaded car CAN reach a yard still loaded (a train retires at
|
||||
* a Division Point with freight aboard, `advance.ts`), and without this it would carry a stamp from
|
||||
* a district it left several Days ago into whatever train is made up from it next.
|
||||
*/
|
||||
export function pooled(car: RollingStock): RollingStock {
|
||||
if (car.origin === undefined) return car;
|
||||
const { origin: _origin, ...rest } = car;
|
||||
return rest;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track and Office Area
|
||||
@@ -315,6 +355,23 @@ export type CrewTray = {
|
||||
/** null while a local crew is switching without a train card. */
|
||||
trainNumber: number | null;
|
||||
trainIsExtra: boolean;
|
||||
/**
|
||||
* STILL BEING ASSEMBLED, somewhere that is not a Division Point.
|
||||
*
|
||||
* `isBeingMadeUp` used to read the position alone — "standing at a Division Point" — which was
|
||||
* true of every train being built when the only place to build one WAS a Division Point. An Extra
|
||||
* may now be started at a Control Point or in an Interchange's yard (§7), and those trains could
|
||||
* not be given a consist at all: they ran empty, and so did every Control Point Extra since that
|
||||
* option was added. Jesse's report says it plainly — an Extra started at the Interchange "would be
|
||||
* loaded with cars".
|
||||
*
|
||||
* Set when such an Extra is placed and cleared the moment it starts running (`enterMainline`), so
|
||||
* it names a train that is being made up rather than one that merely happens to be standing
|
||||
* somewhere. That distinction is load-bearing: a train that ARRIVED at an Office must never be
|
||||
* fillable from the Division Yard, which is the "cars appearing on a train nobody was making up"
|
||||
* bug `isBeingMadeUp` was tightened to kill, and an arriving train never carries this.
|
||||
*/
|
||||
beingMadeUp?: boolean;
|
||||
/**
|
||||
* WHERE THE ENGINE SITS IN THE TRAY, as an index into `consist`.
|
||||
*
|
||||
@@ -425,6 +482,20 @@ export type DivisionNode =
|
||||
kind: 'mainline';
|
||||
card: MainlineKind;
|
||||
transits: Transit[];
|
||||
/**
|
||||
* TRAINS STANDING IN THE INTERCHANGE'S YARD — not out on the running line.
|
||||
*
|
||||
* Only an Interchange ever has these. An Extra started there (§7, Jesse's ruling) is made up
|
||||
* in the yard beside the Mainline, which is why placing it can never be a collision however
|
||||
* busy the card is: it is not on the road yet. It highballs onto this same card at a later
|
||||
* Mainline Phase, through the ordinary §8.1 clearance check — held automatically against a
|
||||
* facing train, put to the Superintendent against a following one — and becomes a `Transit`
|
||||
* at that moment, exactly like a train leaving a Division Point.
|
||||
*
|
||||
* A tray listed here has `position.at === 'mainline'` with this node's index and NO entry in
|
||||
* `transits`. That pair is what distinguishes standing from crossing.
|
||||
*/
|
||||
holding?: TrayId[];
|
||||
absSignals?: boolean;
|
||||
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
|
||||
modifiers?: string[];
|
||||
|
||||
+17
-2
@@ -151,15 +151,30 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
continue;
|
||||
}
|
||||
const dp = n.kind === 'dp';
|
||||
/**
|
||||
* A train in the Interchange's yard is drawn on the card but counted against nothing.
|
||||
*
|
||||
* It is not on the running line — that is the whole distinction §7 rests on — so it cannot take
|
||||
* the card's capacity. It still has to be SEEN: an Extra made up here would otherwise be a train
|
||||
* the player just placed that appears nowhere on the map.
|
||||
*/
|
||||
const inYard = n.yard ?? [];
|
||||
const onRoad = n.trains.flat();
|
||||
const free = n.capacity === null ? '' : `${Math.max(0, n.capacity - onRoad.length)} of ${n.capacity} free`;
|
||||
push({
|
||||
kind: dp ? 'dp' : 'ml',
|
||||
label: n.label,
|
||||
sub: n.capacity === null ? 'no limit — trains queue' : `${Math.max(0, n.capacity - n.trains.flat().length)} of ${n.capacity} free`,
|
||||
trains: n.trains.flat(),
|
||||
sub: n.capacity === null
|
||||
? 'no limit — trains queue'
|
||||
: [free, inYard.length > 0 ? `${inYard.length} in the yard` : ''].filter(Boolean).join(' · '),
|
||||
trains: [...onRoad, ...inYard],
|
||||
cap: n.capacity,
|
||||
tip: dp
|
||||
? 'A Division Point — the end of the line. Trains both enter and leave the Division here (odd numbers run west, even run east), and queue without limit'
|
||||
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}` +
|
||||
(inYard.length > 0
|
||||
? `\n\n${inYard.length} train${inYard.length === 1 ? '' : 's'} standing in the yard, not on the running line — waiting to highball onto this card`
|
||||
: '') +
|
||||
// What the card actually DOES. The name alone left Hilly and Uncontrolled Siding as
|
||||
// words with no gameplay attached — reported exactly that way.
|
||||
(n.what ? `\n\n${n.what}` : ''),
|
||||
|
||||
+34
-8
@@ -17,7 +17,7 @@
|
||||
|
||||
import { MAX_CONSIST } from '../engine/content.ts';
|
||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
|
||||
@@ -34,11 +34,20 @@ export function clockTime(stage: number): string {
|
||||
return CLOCK[stage - 1] ?? `Stage ${stage}`;
|
||||
}
|
||||
|
||||
export function carLabel(c: RollingStock): string {
|
||||
/**
|
||||
* `homeSeat` is the district the page is being drawn for. Give it, and a load THIS district made
|
||||
* says so — the printed game's answer is to turn the chip upside down in the tray, and this is the
|
||||
* screen's. A load may not be broken in the Office Area that made it (state.ts `RollingStock.origin`),
|
||||
* so "loaded here" is the difference between a boxcar worth switching and one that has to leave the
|
||||
* district first. Omit it and the label is what it always was, which is what the replay viewers and
|
||||
* the history lines want: they describe a board, not a seat's view of one.
|
||||
*/
|
||||
export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string {
|
||||
// A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even
|
||||
// though the supply marks every caboose loaded. Name it plainly.
|
||||
if (c.type === 'caboose') return 'caboose';
|
||||
return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
|
||||
const label = `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
|
||||
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
|
||||
}
|
||||
|
||||
export function carsLabel(cars: RollingStock[]): string {
|
||||
@@ -246,14 +255,31 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
tone: 'bad',
|
||||
text: `SECOND SECTION ordered on Train ${e.trainNumber} — an identical train will run right behind it, which forces the Superintendent to rule on a following train (§8.1)`,
|
||||
};
|
||||
case 'extraStarted':
|
||||
/**
|
||||
* WHERE THE PLAYER PUT IT, not where its number would have sent it.
|
||||
*
|
||||
* An Extra's direction comes from its start now (§7, Jesse's ruling), so the line that used to
|
||||
* explain the number's parity would be explaining a rule that no longer applies to this train.
|
||||
*/
|
||||
case 'extraStarted': {
|
||||
const where =
|
||||
e.at.kind === 'divisionPoint'
|
||||
? `the ${e.at.side === 'west' ? 'Western' : 'Eastern'} Division Point`
|
||||
: e.at.kind === 'mainline'
|
||||
? "the Interchange's yard"
|
||||
: `the Control Point in seat ${e.at.seat}`;
|
||||
const why =
|
||||
e.at.kind === 'divisionPoint'
|
||||
? 'the end it runs away from — an Extra may start at either, and the end chooses the run'
|
||||
: e.at.kind === 'mainline'
|
||||
? 'made up off the running line, so it highballs onto the Mainline once the Subdivision ' +
|
||||
'is clear and may be held in the yard until it is'
|
||||
: 'an Extra may begin at any Office above a Whistle Post, and the player chooses the run';
|
||||
return {
|
||||
tone: 'good',
|
||||
text:
|
||||
e.atSeat === null
|
||||
? `EXTRA X${e.trainNumber} started at the ${e.trainNumber % 2 === 0 ? 'Western' : 'Eastern'} Division Point, running ${e.trainNumber % 2 === 0 ? 'east' : 'west'} — odd numbers run west and even run east (§2.3), so its number chose the end`
|
||||
: `EXTRA X${e.trainNumber} started at the Control Point in seat ${e.atSeat}, running ${e.trainNumber % 2 === 0 ? 'east' : 'west'} — an Extra may begin at any Office above a Whistle Post instead of at a Division Point`,
|
||||
text: `EXTRA X${e.trainNumber} started at ${where}, running ${e.direction} — ${why}`,
|
||||
};
|
||||
}
|
||||
case 'trainMadeUp':
|
||||
return {
|
||||
tone: 'good',
|
||||
|
||||
+66
-12
@@ -18,7 +18,9 @@ import {
|
||||
laborersLeft,
|
||||
movesFor,
|
||||
ownCutFor,
|
||||
isTrainCard,
|
||||
portersLeft,
|
||||
resolveExtraStart,
|
||||
selectDestination,
|
||||
} from '../engine/apply.ts';
|
||||
import {
|
||||
@@ -307,6 +309,14 @@ export type DivisionView = {
|
||||
/** Office nodes only: whose district this is. */
|
||||
/** Which SEAT's district this is — a position on the Division, not a player. */
|
||||
seat?: number;
|
||||
/**
|
||||
* Interchange only: trains standing in its yard, not out on the running line (state.ts).
|
||||
*
|
||||
* Separate from `trains` for the same reason `switching` is separate from an Office's A/D list —
|
||||
* they are not occupying the thing whose capacity is being counted. An Extra made up here has to
|
||||
* be VISIBLE, though, or the player who placed it has a train that exists nowhere on the map.
|
||||
*/
|
||||
yard?: TrainChip[];
|
||||
/**
|
||||
* Office nodes only: crews working BELOW the Running Track.
|
||||
*
|
||||
@@ -417,6 +427,15 @@ export type Frame = {
|
||||
hand: string[];
|
||||
/** What each hand card does, in the same order — names alone are not a playable hand. */
|
||||
handWhat: string[];
|
||||
/**
|
||||
* Whether each hand card may be DISCARDED, in the same order.
|
||||
*
|
||||
* §6.2 as ruled by Jesse (Gitea#6): a train card never can be. The player has to be told which
|
||||
* cards those are, not merely find that a button is missing — that silence is the whole of the
|
||||
* Gitea#2 complaint, where a blocked platform left the board with nothing to click and no reason.
|
||||
* Named for the rule rather than for trains, since it answers the question the panel is asking.
|
||||
*/
|
||||
handDiscardable: boolean[];
|
||||
deck: number;
|
||||
/** The face-up card on top of each Department pile — the only one that may be drawn. */
|
||||
departments: string[];
|
||||
@@ -508,6 +527,7 @@ const FACILITY_NAMES: Record<string, string> = {
|
||||
function facilityView(
|
||||
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
|
||||
officeName: string,
|
||||
viewerSeat: SeatIndex,
|
||||
): FacilityView | null {
|
||||
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
|
||||
// Passenger facilities were excluded entirely, so the Office's green and red slots never
|
||||
@@ -529,7 +549,9 @@ function facilityView(
|
||||
maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
|
||||
red: f.inboundBox.map(carLabel),
|
||||
redCap: f.capacity.inbound,
|
||||
track: f.industryTrack.cars.map(carLabel),
|
||||
// Marked when this district made the load: the spotted car is exactly where a player is looking
|
||||
// when they ask why the Laborer will not unload it.
|
||||
track: f.industryTrack.cars.map((c) => carLabel(c, viewerSeat)),
|
||||
laborers: `${laborersLeft(f)}/${f.laborers}`,
|
||||
porters: `${portersLeft(f)}/${f.porters}`,
|
||||
canFinish: canFinishHere(f),
|
||||
@@ -620,7 +642,9 @@ function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellVie
|
||||
out.push({
|
||||
trayId: id,
|
||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||
cars: t.consist.map(carLabel),
|
||||
// A coach filled at THIS Office reads "loaded coach (loaded here)" — those passengers may not
|
||||
// alight in the district that boarded them, and the tray is where a player looks for that.
|
||||
cars: t.consist.map((c) => carLabel(c, viewerSeat)),
|
||||
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
|
||||
facing: railFacingOf(t),
|
||||
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
|
||||
@@ -676,6 +700,11 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
|
||||
return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
|
||||
}
|
||||
|
||||
/** " onto Train 8", or nothing at all when the intent names no train (an old save, or one train). */
|
||||
function onto(s: GameState, trayId: string | undefined, joiner: string): string {
|
||||
return trayId === undefined ? '' : `${joiner}${trainName(s, trayId)}`;
|
||||
}
|
||||
|
||||
/** One readable line for a single intent. */
|
||||
export function describeIntent(s: GameState, i: Intent): string {
|
||||
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||
@@ -855,18 +884,39 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
return `advance load in box ${i.box} at ${at(i.at)}`;
|
||||
case 'laborer.beginUnload':
|
||||
return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
|
||||
/**
|
||||
* NAME THE TRAIN. The action list drops duplicate labels within a crew, and with two trains
|
||||
* standing at one station "board passengers at (0,0)" describes both — which is half of why the
|
||||
* v0.4.9d playtest found that picking a train changed nothing. The intent now carries the tray;
|
||||
* the label has to say so or the second button is thrown away before the menu sees it.
|
||||
*/
|
||||
case 'porter.board':
|
||||
return `board passengers at ${at(i.at)}`;
|
||||
return `board passengers at ${at(i.at)}${onto(s, i.trayId, ' onto ')}`;
|
||||
case 'porter.detrain':
|
||||
return `detrain passengers at ${at(i.at)}`;
|
||||
return `detrain passengers at ${at(i.at)}${onto(s, i.trayId, ' from ')}`;
|
||||
/**
|
||||
* NAME THE PLACE AND THE DIRECTION, because the player is choosing both.
|
||||
*
|
||||
* This used to explain why the Extra had no choice — "it runs west, so that is the end it
|
||||
* starts from". It has one now (§7, Jesse's ruling), and every candidate is on screen at once,
|
||||
* so each label has to be distinguishable from its three or four siblings at a glance.
|
||||
*/
|
||||
case 'newTrain.startExtra': {
|
||||
const runs = i.trainNumber % 2 === 0 ? 'east' : 'west';
|
||||
if (i.atSeat === null) {
|
||||
const end = i.trainNumber % 2 === 0 ? 'Western' : 'Eastern';
|
||||
return `start Extra X${i.trainNumber} at the ${end} Division Point — it runs ${runs}, so that is the end it starts from`;
|
||||
const where = resolveExtraStart(s, s.clock.currentActor ?? 0, i);
|
||||
if (typeof where === 'string') return `start Extra X${i.trainNumber}`;
|
||||
const { direction } = where;
|
||||
if (where.at.kind === 'divisionPoint') {
|
||||
const end = where.at.side === 'west' ? 'Western' : 'Eastern';
|
||||
return `start Extra X${i.trainNumber} at the ${end} Division Point — it runs ${direction} from there`;
|
||||
}
|
||||
const tier = officeProfile(areaAtSeat(s, i.atSeat).tier).name;
|
||||
return `start Extra X${i.trainNumber} at the ${tier} in seat ${i.atSeat} — a Control Point, so it may begin its ${runs}bound run there instead`;
|
||||
if (where.at.kind === 'mainline') {
|
||||
return (
|
||||
`start Extra X${i.trainNumber} ${direction}bound in the Interchange — it is made up in the ` +
|
||||
'yard and highballs onto the Mainline once the Subdivision is clear'
|
||||
);
|
||||
}
|
||||
const tier = officeProfile(areaAtSeat(s, where.at.seat).tier).name;
|
||||
return `start Extra X${i.trainNumber} ${direction}bound at the ${tier} in seat ${where.at.seat} — a Control Point, so it may begin its run there`;
|
||||
}
|
||||
|
||||
case 'newTrain.placeCar':
|
||||
@@ -1007,7 +1057,7 @@ export function snapshot(
|
||||
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
|
||||
else label = geometryLabel(g.geometry);
|
||||
|
||||
const fv = facilityView(card as never, officeProfile(area.tier).name);
|
||||
const fv = facilityView(card as never, officeProfile(area.tier).name, viewerSeat);
|
||||
if (fv) facilities.push(fv);
|
||||
|
||||
cells.push({
|
||||
@@ -1022,7 +1072,7 @@ export function snapshot(
|
||||
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
||||
trains: trainsOnCard(s, viewerSeat, key),
|
||||
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
||||
cars: carsOn(card).map(carLabel),
|
||||
cars: carsOn(card).map((c) => carLabel(c, viewerSeat)),
|
||||
standingWest: card.standingWest,
|
||||
facility: fv,
|
||||
});
|
||||
@@ -1091,6 +1141,9 @@ export function snapshot(
|
||||
};
|
||||
})],
|
||||
capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1,
|
||||
// Drawn at the start of the card: the yard is beside the rail, and this is the end the
|
||||
// train will pull out of. It counts against nothing — see `yard` on DivisionView.
|
||||
yard: (n.holding ?? []).map((id) => ({ ...trainChip(s, id), region: 0 })),
|
||||
modifiers: [
|
||||
...(n.modifiers ?? []).map(prettyKey),
|
||||
...(n.absSignals ? ['ABS Signals'] : []),
|
||||
@@ -1179,6 +1232,7 @@ export function snapshot(
|
||||
*/
|
||||
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
|
||||
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
||||
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => !isTrainCard(s, id)),
|
||||
deck: s.decks.homeOffice.length,
|
||||
departments: s.decks.departments.map((pile) => {
|
||||
const top = pile[pile.length - 1];
|
||||
|
||||
+34
-4
@@ -12,8 +12,8 @@ import type { Menu, Save } from './game.ts';
|
||||
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import { playCue } from './sound.ts';
|
||||
import { MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, houseRules } from '../engine/content.ts';
|
||||
import type { HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts';
|
||||
import { EXTRA_START_LABELS, MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, houseRules } from '../engine/content.ts';
|
||||
import type { ExtraStartRule, HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts';
|
||||
import type { LocalSession } from './session.ts';
|
||||
import { createLocalSession } from './session.ts';
|
||||
|
||||
@@ -169,6 +169,8 @@ function rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
|
||||
const rules: HouseRuleOverrides = {};
|
||||
const hand = params.get('hand');
|
||||
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
|
||||
const extra = params.get('extra');
|
||||
if (EXTRA_START_LABELS.some((o) => o.value === extra)) rules.extraStart = extra as ExtraStartRule;
|
||||
|
||||
const revenue: Partial<RevenueRules> = {};
|
||||
for (const [param, key] of Object.entries(RULE_PARAMS)) {
|
||||
@@ -185,6 +187,9 @@ function rulesToUrl(rules: HouseRules, seed: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (seed !== '') params.set('seed', seed);
|
||||
params.set('hand', rules.startingHand);
|
||||
// The dialog answers reach `start()` through the URL and nowhere else, so a setting missing from
|
||||
// here is a setting the dialog silently discards.
|
||||
params.set('extra', rules.extraStart);
|
||||
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
|
||||
return `?${params}`;
|
||||
}
|
||||
@@ -944,9 +949,29 @@ function renderActions(
|
||||
) {
|
||||
// The hand being counted is the ACTOR's — they are the one who cannot end the turn.
|
||||
const hand = f.handCount;
|
||||
/**
|
||||
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
|
||||
*
|
||||
* §6.2 as ruled by Jesse (Gitea#6): a train card is never discarded, so a player holding four
|
||||
* trains has exactly one way forward — play one onto the timetable. The rule creates that corner
|
||||
* deliberately and needs no machinery, but it must not be a corner the player has to infer from
|
||||
* a discard button that has quietly stopped appearing.
|
||||
*/
|
||||
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
|
||||
const tip = stuck
|
||||
? '§6.2 — you may not end a turn holding more than three cards, and a TRAIN CARD IS NEVER ' +
|
||||
'DISCARDED. Every card you hold is a train, so the only way on is to play one onto the ' +
|
||||
'timetable. A train may be held for as many Stages and Days as you like; it just cannot be ' +
|
||||
'thrown away.'
|
||||
: '§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play ' +
|
||||
'one onto the board, or discard one face-up to a Department slot. A train card is never ' +
|
||||
'discardable and can only be played.';
|
||||
html +=
|
||||
`<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play one onto the board, or discard one face-up to a Department slot.">` +
|
||||
`End Local Operations — play or discard down to three first (holding ${hand})</button></div>`;
|
||||
`<div class="grp"><button class="act blocked" disabled data-tip="${tip.replace(/"/g, '"')}">` +
|
||||
(stuck
|
||||
? `End Local Operations — play a train card first, they cannot be discarded (holding ${hand})`
|
||||
: `End Local Operations — play or discard down to three first (holding ${hand})`) +
|
||||
`</button></div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
@@ -1066,6 +1091,9 @@ if (newBtn && dlg) {
|
||||
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-hand"]')) {
|
||||
input.checked = input.value === current.startingHand;
|
||||
}
|
||||
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-extra"]')) {
|
||||
input.checked = input.value === current.extraStart;
|
||||
}
|
||||
field<HTMLInputElement>('ng-passenger').value = String(current.revenue.passengerPerCoach);
|
||||
field<HTMLInputElement>('ng-freight').value = String(current.revenue.freightPerLoad);
|
||||
field<HTMLInputElement>('ng-transit').value = String(current.revenue.trainPerTransit);
|
||||
@@ -1087,9 +1115,11 @@ if (newBtn && dlg) {
|
||||
// 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 picked = dlg.querySelector<HTMLInputElement>('input[name="ng-hand"]:checked')?.value;
|
||||
const extra = dlg.querySelector<HTMLInputElement>('input[name="ng-extra"]:checked')?.value;
|
||||
const rules = houseRules({
|
||||
houseRules: {
|
||||
...(STARTING_HAND_LABELS.some((o) => o.value === picked) ? { startingHand: picked as StartingHand } : {}),
|
||||
...(EXTRA_START_LABELS.some((o) => o.value === extra) ? { extraStart: extra as ExtraStartRule } : {}),
|
||||
revenue: {
|
||||
passengerPerCoach: Number(field<HTMLInputElement>('ng-passenger').value),
|
||||
freightPerLoad: Number(field<HTMLInputElement>('ng-freight').value),
|
||||
|
||||
+11
-1
@@ -31,8 +31,18 @@ export function cardRow(name: string, why: string, playable: boolean | null): st
|
||||
}
|
||||
|
||||
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||
// §6.2 (Gitea#6) — say so on the card itself. A player who cannot discard a train needs to read
|
||||
// that on the train, not deduce it from a button that is not there.
|
||||
const held = 'You may hold this for as many Stages and Days as you like — but a train card is ' +
|
||||
'never discarded (§6.2). The only way it leaves your hand is onto the timetable.';
|
||||
return f.hand.length
|
||||
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
|
||||
? f.hand
|
||||
.map((h, i) => {
|
||||
const what = f.handWhat[i] ?? '';
|
||||
const keep = f.handDiscardable[i] === false;
|
||||
return cardRow(h, keep ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
|
||||
})
|
||||
.join('')
|
||||
: '<span class="dim">empty</span>';
|
||||
}
|
||||
|
||||
|
||||
@@ -322,6 +322,15 @@ ul.blocked li{padding:2px 0}
|
||||
<label class="ng-radio"><input type="radio" name="ng-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>
|
||||
|
||||
<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>
|
||||
<label class="ng-radio"><input type="radio" name="ng-extra" value="divisionPointsOnly">
|
||||
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading of §7. Every Extra begins on shared ground.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-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="ng-extra" value="anyOffice" checked>
|
||||
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive, and what the game did before this setting existed.</span></span></label>
|
||||
|
||||
<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>
|
||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||
|
||||
+281
-34
@@ -8,13 +8,13 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.ts';
|
||||
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { developerBot } from '../src/sim/bot.ts';
|
||||
import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { railFacingOf } from '../src/engine/state.ts';
|
||||
import type { CrewTray, DivisionNode, GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { coordKey, railFacingOf } from '../src/engine/state.ts';
|
||||
|
||||
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
mode: 'solitaire',
|
||||
@@ -872,15 +872,20 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('an Extra starts where its number sends it, or at a Control Point', () => {
|
||||
describe('an Extra starts where the player puts it (Gitea#4)', () => {
|
||||
/**
|
||||
* REPORTED: "Extras should start at Eastern or Western Division point based on their numbers. Even
|
||||
* trains run to the east (start at western DP), odd run to the west (start at eastern DP). They
|
||||
* can also start at a control point (any office except whistlepost) at player's choice."
|
||||
* REPORTED, v0.4.9e: "When extras are played the player doing so may choose where the extra
|
||||
* starts. They may choose either division point. And if the interchange mainline card has been
|
||||
* played, they may start the extra on that card and choose the direction from there. If there is
|
||||
* potential for conflict with other trains in that area the superintendent may hold the extra."
|
||||
*
|
||||
* Every Extra used to launch eastbound from the West Division Point, hardcoded, with the
|
||||
* simplification flagged in a comment — so half the Extras ran the wrong way and the Control Point
|
||||
* option did not exist at all.
|
||||
* This SUPERSEDES the earlier ruling these tests used to assert — "the number decides, like
|
||||
* everything else on the timetable" — for Extras only. The number still decides for a timetabled
|
||||
* train. The reason the two cannot both hold: an odd (westbound) Extra placed at the WEST end
|
||||
* would leave the Division on its first move having crossed nothing, and be paid for the run.
|
||||
*
|
||||
* So: the start decides the direction. A Division Point runs the train away from itself; in the
|
||||
* middle of the railroad — an Interchange, a Control Point — the player says which way.
|
||||
*/
|
||||
const pending = (trainNumber: number, tier?: 'depot' | 'station'): GameState => {
|
||||
const s = game(11);
|
||||
@@ -898,6 +903,14 @@ describe('an Extra starts where its number sends it, or at a Control Point', ()
|
||||
return tray;
|
||||
};
|
||||
|
||||
/** Turn one Mainline card into an Interchange, and say which node it is. */
|
||||
const withInterchange = (s: GameState): number => {
|
||||
const i = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
const node = s.division.nodes[i]!;
|
||||
if (node.kind === 'mainline') node.card = 'interchange';
|
||||
return i;
|
||||
};
|
||||
|
||||
it('stops for the decision instead of launching the train itself', () => {
|
||||
const s = pending(17);
|
||||
assert.equal(s.clock.phase, 'newTrain');
|
||||
@@ -908,36 +921,46 @@ describe('an Extra starts where its number sends it, or at a Control Point', ()
|
||||
);
|
||||
});
|
||||
|
||||
it('sends an odd Extra west from the EASTERN Division Point', () => {
|
||||
// §2.3 — odd runs west. It therefore starts at the end it runs away from.
|
||||
it('offers BOTH Division Points, not the one the number would dictate', () => {
|
||||
const s = pending(17);
|
||||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
|
||||
const tray = started(s);
|
||||
assert.equal(tray.direction, 'west');
|
||||
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'east');
|
||||
const sides = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'newTrain.startExtra' && i.start?.kind === 'divisionPoint')
|
||||
.map((i) => (i.type === 'newTrain.startExtra' && i.start?.kind === 'divisionPoint' ? i.start.side : ''));
|
||||
assert.deepEqual([...sides].sort(), ['east', 'west']);
|
||||
});
|
||||
|
||||
it('sends an even Extra east from the WESTERN Division Point', () => {
|
||||
const s = pending(18);
|
||||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: null }).ok);
|
||||
it('runs an Extra AWAY from the Division Point it was placed at, whatever its number', () => {
|
||||
// X17 is odd. Under the superseded rule it could only ever start at the East end and run west.
|
||||
for (const [side, direction] of [['west', 'east'], ['east', 'west']] as const) {
|
||||
const s = pending(17);
|
||||
const r = applyIntent(s, 0, {
|
||||
type: 'newTrain.startExtra', trainNumber: 17, start: { kind: 'divisionPoint', side },
|
||||
});
|
||||
assert.ok(r.ok, `the ${side} Division Point was refused: ${r.ok ? '' : r.code}`);
|
||||
const tray = started(s);
|
||||
assert.equal(tray.direction, 'east');
|
||||
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'west');
|
||||
assert.equal(tray.direction, direction, `an Extra at the ${side} end must run ${direction}`);
|
||||
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, side);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a Whistle Post, which is not a Control Point', () => {
|
||||
it('refuses a Whistle Post, which is not a Control Point, at every setting of the house rule', () => {
|
||||
for (const extraStart of ['divisionPointsOnly', 'ownOffice', 'anyOffice'] as const) {
|
||||
const s = pending(18);
|
||||
s.config = { ...s.config, houseRules: { ...s.config.houseRules, extraStart } };
|
||||
assert.equal(s.officeAreas.get(0)!.tier, 'whistlePost');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: 0 }),
|
||||
'NOT_A_CONTROL_POINT',
|
||||
);
|
||||
const code = check(s, 0, {
|
||||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
|
||||
});
|
||||
assert.ok(code !== null, `a Whistle Post was allowed under ${extraStart}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('starts at a Control Point when the player picks one, taking an A/D track', () => {
|
||||
// Upgrading the Office is what buys this: a Depot is a Control Point, a Whistle Post is not.
|
||||
const s = pending(18, 'depot');
|
||||
const r = applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: 0 });
|
||||
const r = applyIntent(s, 0, {
|
||||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'west',
|
||||
});
|
||||
assert.ok(r.ok, `starting at the Depot was refused: ${r.ok ? '' : r.code}`);
|
||||
const tray = started(s);
|
||||
const area = s.officeAreas.get(0)!;
|
||||
@@ -948,16 +971,240 @@ describe('an Extra starts where its number sends it, or at a Control Point', ()
|
||||
'the Extra did not start on the Office card',
|
||||
);
|
||||
assert.ok(area.adOccupancy.includes(tray.id), 'it did not take an A/D track');
|
||||
assert.equal(tray.direction, 'east', 'an even Extra still runs east from wherever it starts');
|
||||
// The point of the change: an EVEN Extra running WEST, because the player said so.
|
||||
assert.equal(tray.direction, 'west', 'the direction the player chose was not honoured');
|
||||
});
|
||||
|
||||
it('needs a direction anywhere that is not an end of the Division', () => {
|
||||
const s = pending(18, 'depot');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 } }),
|
||||
'NO_DIRECTION_CHOSEN',
|
||||
);
|
||||
});
|
||||
|
||||
it('honours the extraStart house rule for Office starts, and never for the shared ground', () => {
|
||||
for (const [extraStart, code] of [
|
||||
['divisionPointsOnly', 'OFFICE_STARTS_NOT_ALLOWED'],
|
||||
['anyOffice', null],
|
||||
] as const) {
|
||||
const s = pending(18, 'depot');
|
||||
s.config = { ...s.config, houseRules: { ...s.config.houseRules, extraStart } };
|
||||
assert.equal(
|
||||
check(s, 0, {
|
||||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
|
||||
}),
|
||||
code,
|
||||
`office start under ${extraStart}`,
|
||||
);
|
||||
// The Division Points belong to nobody, so no setting ever closes them.
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'divisionPoint', side: 'west' } }),
|
||||
null,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('starts at an Interchange in the players yard, not out on the running line', () => {
|
||||
const s = pending(18);
|
||||
const node = withInterchange(s);
|
||||
const r = applyIntent(s, 0, {
|
||||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
|
||||
});
|
||||
assert.ok(r.ok, `the Interchange was refused: ${r.ok ? '' : r.code}`);
|
||||
const tray = started(s);
|
||||
const card = s.division.nodes[node]!;
|
||||
assert.equal(tray.direction, 'east');
|
||||
assert.deepEqual(tray.position, { at: 'mainline', index: node });
|
||||
assert.ok(card.kind === 'mainline' && card.holding?.includes(tray.id), 'it is not in the yard');
|
||||
assert.equal(card.kind === 'mainline' && card.transits.length, 0, 'it was put on the running line');
|
||||
});
|
||||
|
||||
it('refuses any Mainline card that is not an Interchange', () => {
|
||||
const s = pending(18);
|
||||
const plains = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
const node = s.division.nodes[plains]!;
|
||||
if (node.kind === 'mainline') node.card = 'plains';
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node: plains }, direction: 'east' }),
|
||||
'NOT_AN_INTERCHANGE',
|
||||
);
|
||||
});
|
||||
|
||||
it('may be started at an Interchange however busy the card is — the yard forces no collision', () => {
|
||||
const s = pending(18);
|
||||
const node = withInterchange(s);
|
||||
const card = s.division.nodes[node]!;
|
||||
// Nose to tail with opposing traffic. §7: placing here still must not force a collision.
|
||||
if (card.kind === 'mainline') {
|
||||
card.transits.push({ tray: 'tray9', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east' }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* §7's last clause, and the reason the Interchange start is modelled as a yard at all: "If there
|
||||
* is potential for conflict with other trains in that area the superintendent may hold the extra."
|
||||
*
|
||||
* Jesse's split: a GUARANTEED collision holds the train at the Interchange for another Stage and
|
||||
* it tries again; a POTENTIAL one is the Superintendent's to rule on. Those are exactly §8.1's
|
||||
* two answers, so the Extra highballs out of the yard through `evaluateClearance` — the same
|
||||
* check a train leaving a Division Point goes through — rather than through anything new.
|
||||
*/
|
||||
describe('highballing out of the Interchange yard', () => {
|
||||
/** A pending X18 sitting in the yard of an Interchange, with the terrain pinned. */
|
||||
const inYard = (): { s: GameState; node: number; tray: CrewTray } => {
|
||||
const s = pending(18);
|
||||
const node = withInterchange(s);
|
||||
const r = applyIntent(s, 0, {
|
||||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
|
||||
});
|
||||
assert.ok(r.ok, `the Interchange was refused: ${r.ok ? '' : r.code}`);
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
return { s, node, tray: started(s) };
|
||||
};
|
||||
|
||||
const card = (s: GameState, node: number): Extract<DivisionNode, { kind: 'mainline' }> => {
|
||||
const n = s.division.nodes[node]!;
|
||||
assert.equal(n.kind, 'mainline');
|
||||
return n as Extract<DivisionNode, { kind: 'mainline' }>;
|
||||
};
|
||||
|
||||
it('pulls out onto the card at the next Mainline Phase when the Subdivision is clear', () => {
|
||||
const { s, node, tray } = inYard();
|
||||
advance(s);
|
||||
const n = card(s, node);
|
||||
assert.deepEqual(n.holding, [], 'it never left the yard');
|
||||
assert.ok(n.transits.some((t) => t.tray === tray.id), 'it is not on the running line');
|
||||
});
|
||||
|
||||
it('is held in the yard by a facing train, and tries again the next Stage', () => {
|
||||
const { s, node, tray } = inYard();
|
||||
// Westbound, against an eastbound Extra: §8.1 calls that an absolute bar, not a judgment call.
|
||||
card(s, node).transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
s.trays.set('facing', {
|
||||
id: 'facing', trainNumber: 3, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'west', position: { at: 'mainline', index: node }, movesUsed: 0,
|
||||
});
|
||||
|
||||
const r = advance(s);
|
||||
assert.equal(r.needsInput ?? false, false, 'a guaranteed collision is not a question to ask');
|
||||
assert.equal(s.clock.pendingDecision, null);
|
||||
assert.ok(card(s, node).holding?.includes(tray.id), 'it was not held in the yard');
|
||||
assert.ok(
|
||||
!card(s, node).transits.some((t) => t.tray === tray.id),
|
||||
'it pulled out in front of a train coming the other way',
|
||||
);
|
||||
assert.ok(s.trays.has(tray.id), 'the Extra was destroyed rather than held');
|
||||
|
||||
// AND IT TRIES AGAIN. The Extra waits in the yard, not on the pending list, so once the road
|
||||
// clears the next Mainline Phase takes it out with no further intervention.
|
||||
s.trays.delete('facing');
|
||||
card(s, node).transits = [];
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
advance(s);
|
||||
assert.deepEqual(card(s, node).holding, [], 'it did not try again once the road was clear');
|
||||
assert.ok(card(s, node).transits.some((t) => t.tray === tray.id), 'it never pulled out');
|
||||
});
|
||||
|
||||
it('puts a following train to the Superintendent rather than holding it automatically', () => {
|
||||
const { s, node, tray } = inYard();
|
||||
// Same direction: §8.1's judgment call, which is what "may hold the extra" means.
|
||||
card(s, 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: node }, movesUsed: 0,
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
// HOLD keeps it in the yard.
|
||||
assert.ok(applyIntent(s, s.clock.superintendent, { type: 'mainline.clearance', allow: false }).ok);
|
||||
advance(s);
|
||||
assert.ok(card(s, node).holding?.includes(tray.id), 'the Superintendent held it and it left anyway');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* FOUND BY PLAYING IT, not by the tests above: an Extra started anywhere but a Division Point was
|
||||
* never offered a car and ran empty.
|
||||
*
|
||||
* `isBeingMadeUp` asked only "is this tray standing at a Division Point", which was the whole
|
||||
* truth while that was the only place to build a train. The Control Point start has therefore
|
||||
* shipped since it was added with a train that could not be loaded, and the Interchange start
|
||||
* would have shipped the same way — against Jesse's report, which says an Extra started at the
|
||||
* Interchange "would be Loaded with cars".
|
||||
*/
|
||||
describe('an Extra started away from a Division Point can still be made up', () => {
|
||||
const fill = (s: GameState): string[] => {
|
||||
for (let i = 0; i < MAX_CONSIST + 1; i++) {
|
||||
const options = legalActions(s, s.clock.currentActor ?? 0).filter((a) => a.type === 'newTrain.placeCar');
|
||||
if (options.length === 0) break;
|
||||
assert.ok(applyIntent(s, s.clock.currentActor ?? 0, options[0]!).ok);
|
||||
}
|
||||
return started(s).consist.map((c) => c.type);
|
||||
};
|
||||
|
||||
it('takes a consist in the Interchange yard', () => {
|
||||
const s = pending(18);
|
||||
const node = withInterchange(s);
|
||||
assert.ok(applyIntent(s, 0, {
|
||||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
|
||||
}).ok);
|
||||
assert.ok(fill(s).length > 0, 'the Extra was never offered a car and would have run empty');
|
||||
});
|
||||
|
||||
it('takes a consist at a Control Point', () => {
|
||||
const s = pending(18, 'depot');
|
||||
assert.ok(applyIntent(s, 0, {
|
||||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
|
||||
}).ok);
|
||||
assert.ok(fill(s).length > 0, 'the Extra was never offered a car and would have run empty');
|
||||
});
|
||||
|
||||
it('stops being made up the moment it starts running', () => {
|
||||
// Otherwise a train out on the Mainline could be handed cars from the Division Yard — the
|
||||
// "cars appearing on a train nobody was making up" bug `isBeingMadeUp` exists to prevent.
|
||||
const s = pending(18);
|
||||
const node = withInterchange(s);
|
||||
assert.ok(applyIntent(s, 0, {
|
||||
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
|
||||
}).ok);
|
||||
const tray = started(s);
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
advance(s);
|
||||
assert.equal(s.trays.get(tray.id)?.beingMadeUp, undefined, 'a running train is still being made up');
|
||||
assert.equal(isBeingMadeUp(s.trays.get(tray.id)!), false);
|
||||
});
|
||||
});
|
||||
|
||||
it('replays a save written before the choice existed exactly as it meant it', () => {
|
||||
/**
|
||||
* A save is a seed and a list of intents, so an intent whose meaning moves is a save that
|
||||
* quietly replays as a different game. The legacy shape carried only `atSeat`: null meant the
|
||||
* Division Point the NUMBER sent it to, running in the number's direction.
|
||||
*/
|
||||
const s = pending(17);
|
||||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
|
||||
const tray = started(s);
|
||||
assert.equal(tray.direction, 'west', 'the legacy intent stopped meaning what it meant');
|
||||
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'east');
|
||||
});
|
||||
|
||||
it('takes the Extra off the pending list once, whichever end it started from', () => {
|
||||
const s = pending(17);
|
||||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
|
||||
const at = { type: 'newTrain.startExtra', trainNumber: 17, start: { kind: 'divisionPoint', side: 'east' } } as const;
|
||||
assert.ok(applyIntent(s, 0, at).ok);
|
||||
assert.deepEqual(s.pendingExtras, []);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }),
|
||||
'NO_EXTRA_PENDING',
|
||||
);
|
||||
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
|
||||
});
|
||||
});
|
||||
|
||||
+387
-3
@@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, check, areaOf, facilityCarTypes, movesFor, reduce } from '../src/engine/apply.ts';
|
||||
import { pump } from '../src/engine/advance.ts';
|
||||
import { HAND_LIMIT, INDUSTRY_PROFILES, MAX_CONSIST, MOVES_PER_LOCAL_OPS, officeProfile } from '../src/engine/content.ts';
|
||||
import type { Intent } from '../src/engine/intents.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
@@ -259,6 +260,122 @@ describe('Local Operations: drawing (§6.2)', () => {
|
||||
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'the turn cannot be ended even at the limit');
|
||||
});
|
||||
|
||||
describe('a train card is never discarded (Gitea#6)', () => {
|
||||
/**
|
||||
* Jesse's ruling, v0.4.9e playtest: "Players are not allowed to discard Train cards. They may
|
||||
* keep the card in their hand for multiple stages and even multiple days, but they may not
|
||||
* discard it. If a player has three train cards in their hand, and they draw a fourth, then they
|
||||
* must play one of those cards."
|
||||
*
|
||||
* Extras count too — an Extra is a train, even though it runs once and ends in the Salvage Yard
|
||||
* where a Timetabled card joins the timetable for the rest of the game.
|
||||
*
|
||||
* Note there is no new FORCING mechanism, and deliberately so: the corner is what the two
|
||||
* existing rules produce together. Nothing discardable plus "you may not end the turn over the
|
||||
* limit" leaves exactly one legal way on, and playing a train is unconditionally legal.
|
||||
*/
|
||||
const handOf = (s: GameState, kinds: string[]): string[] => {
|
||||
// Hand-pick cards of the wanted kinds straight out of the catalogue, so the test does not
|
||||
// depend on what the shuffle happened to deal.
|
||||
const picked: string[] = [];
|
||||
for (const want of kinds) {
|
||||
for (const [id, card] of s.cards) {
|
||||
if (card.kind.kind !== want || picked.includes(id)) continue;
|
||||
picked.push(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.equal(picked.length, kinds.length, 'the catalogue is missing a card this test needs');
|
||||
s.decks.hands.set(0, picked);
|
||||
return picked;
|
||||
};
|
||||
|
||||
it('refuses the discard, for a Timetabled train and for an Extra alike', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
|
||||
'TRAINS_ARE_NEVER_DISCARDED',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
|
||||
'TRAINS_ARE_NEVER_DISCARDED',
|
||||
);
|
||||
// And everything else is still discardable — the rule is about trains, not about discarding.
|
||||
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
|
||||
});
|
||||
|
||||
it('never offers the discard, so the bot needs no rule of its own', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
||||
const offered = legalActions(s, 0).filter(
|
||||
(i) => i.type === 'card.discard' && i.cardId === timetabled,
|
||||
);
|
||||
assert.deepEqual(offered, [], 'a train discard was offered as a legal action');
|
||||
});
|
||||
|
||||
it('leaves PLAYING a train as the only way out of a hand of four trains', () => {
|
||||
const s = game();
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
const four = handOf(s, ['timetabledTrain', 'timetabledTrain', 'timetabledTrain', 'extraTrain']);
|
||||
assert.ok(four.length > HAND_LIMIT, 'this test needs a hand over the limit');
|
||||
|
||||
// Over the limit, so the turn cannot be ended...
|
||||
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
|
||||
// ...and not one of them may be discarded...
|
||||
for (const id of four) {
|
||||
assert.equal(check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED');
|
||||
}
|
||||
// ...but playing one is always legal, so the player is never actually stuck.
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId: four[0]! }), null);
|
||||
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: four[0]! }).ok);
|
||||
assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT);
|
||||
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'playing a train did not free the turn');
|
||||
});
|
||||
|
||||
it('lets a train be held across Stages and into the next Day', () => {
|
||||
// "They may keep the card in their hand for multiple stages and even multiple days." Nothing
|
||||
// sweeps a hand at a Stage or Day boundary, and this is what says so out loud.
|
||||
const s = game();
|
||||
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
||||
const startDay = s.clock.day;
|
||||
|
||||
// Play out Stages by taking whatever ends the current turn, until the Day turns over.
|
||||
for (let guard = 0; guard < 400 && s.clock.day === startDay; guard++) {
|
||||
pump(s);
|
||||
const actor = s.clock.currentActor;
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
const end = options.find((i) => i.type.endsWith('.end')) ?? options[0];
|
||||
if (!end) break;
|
||||
applyIntent(s, actor, end);
|
||||
}
|
||||
|
||||
assert.ok(s.clock.day > startDay, `the Day never turned (stopped at ${s.clock.day}/${s.clock.stage})`);
|
||||
assert.ok(
|
||||
(s.decks.hands.get(0) ?? []).includes(timetabled!),
|
||||
'the train did not survive being held into the next Day',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
|
||||
'TRAINS_ARE_NEVER_DISCARDED',
|
||||
'a Day boundary made a train discardable',
|
||||
);
|
||||
});
|
||||
|
||||
it('tells the player on the card itself, and on the button when every card is a train', () => {
|
||||
// The Gitea#2 lesson: a rule the player cannot see is a board with nothing to click and no
|
||||
// reason given.
|
||||
const s = game();
|
||||
handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||
const f = snapshot(s, [], null);
|
||||
// `hand` is reversed for display, so compare as a set rather than by position.
|
||||
assert.deepEqual([...f.handDiscardable].sort(), [false, false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
it('discards face up ON TOP of a chosen Department, burying what was there', () => {
|
||||
// The choice of WHICH Department is the strategy: a card put on an empty-ish pile is an offer, a
|
||||
// card put on top of one a rival wants takes that card out of reach. Overwriting the slot — what
|
||||
@@ -1284,9 +1401,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
||||
* and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied
|
||||
* only the difference between two tiers and knew nothing about what had been discarded.
|
||||
*
|
||||
* This used to be written against an Ice House on a Grocer's Warehouse. That case no longer
|
||||
* suppresses anything, because the Grocer's is a both-direction facility — which was the other
|
||||
* half of the same report.
|
||||
* This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again
|
||||
* now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the
|
||||
* suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across
|
||||
* that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently
|
||||
* and tests nothing about the upgrade path.
|
||||
*/
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
@@ -1895,3 +2014,268 @@ describe('the switching job a player actually does: put a car in a siding, take
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The v0.4.9d playtest, three reports with two causes.
|
||||
*
|
||||
* "Freight House: boxcars loaded cannot be immediately unloaded", "passenger stations: passengers
|
||||
* just boarded cannot be immediately unloaded" — one rule, `RollingStock.origin`. And "operating two
|
||||
* trains in a station: the select button does not work, regardless of which you pick it is always
|
||||
* one train, not the other" — the porter intents carrying no tray.
|
||||
*/
|
||||
describe('a load may not be broken in the district that made it (v0.4.9e)', () => {
|
||||
const office = (s: GameState) => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!;
|
||||
|
||||
/** An Office that can work passengers, with someone waiting and Porters to hand. */
|
||||
function platform(s: GameState): void {
|
||||
const f = office(s);
|
||||
f.allows = { outbound: true, inbound: true };
|
||||
f.porters = 4;
|
||||
f.capacity = { outbound: 2, inbound: 2 };
|
||||
f.outboundBox = [{ type: 'coach', loaded: true }];
|
||||
s.yards.divisionYard.push({ type: 'coach', loaded: false });
|
||||
s.clock.phase = 'loadUnload';
|
||||
s.clock.currentActor = 0;
|
||||
}
|
||||
|
||||
/** A tray standing on an A/D track at the Office. */
|
||||
function atOffice(s: GameState, consist: CrewTray['consist']): string {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist,
|
||||
direction: 'east', position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord }, movesUsed: 0,
|
||||
});
|
||||
areaOf(s, 0).adOccupancy.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** A Freight House with a load staged, an empty spotted, and Laborers enough to finish. */
|
||||
function freightHouse(s: GameState, coord: GridCoord): void {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), {
|
||||
geometry: { kind: 'facility', facility: 'freightHouse' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'freightHouse',
|
||||
allows: { outbound: true, inbound: true },
|
||||
outboundBox: [{ type: 'boxcar', loaded: true }],
|
||||
inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 1 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { cars: [{ type: 'boxcar', loaded: false }] },
|
||||
laborers: 9, porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [], enhancements: [],
|
||||
} as TrackCard);
|
||||
s.yards.divisionYard.push({ type: 'boxcar', loaded: false }, { type: 'boxcar', loaded: false });
|
||||
s.clock.phase = 'loadUnload';
|
||||
s.clock.currentActor = 0;
|
||||
}
|
||||
|
||||
/** Walk a staged load all the way onto the spotted car. */
|
||||
function finishLoad(s: GameState, coord: GridCoord): void {
|
||||
applyIntent(s, 0, { type: 'laborer.startLoad', at: coord });
|
||||
for (const box of [0, 1, 2]) applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
|
||||
}
|
||||
|
||||
it('refuses to unload the boxcar the Freight House just loaded', () => {
|
||||
const s = game();
|
||||
const coord = at(-1, 0);
|
||||
freightHouse(s, coord);
|
||||
finishLoad(s, coord);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
assert.deepEqual(f.industryTrack.cars.map((c) => c.loaded), [true], 'the load never reached the car');
|
||||
assert.equal(f.industryTrack.cars[0]!.origin, 0, 'the load is not stamped with the district that made it');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }),
|
||||
'LOADED_IN_THIS_DISTRICT',
|
||||
);
|
||||
// And it is not merely absent from the menu by accident — the menu agrees with `check`.
|
||||
assert.ok(
|
||||
!legalActions(s, 0).some((i) => i.type === 'laborer.beginUnload'),
|
||||
'the unload was still offered',
|
||||
);
|
||||
});
|
||||
|
||||
it('unloads a load that came from somewhere else', () => {
|
||||
// The mirror, and the reason the rule is a stamp rather than a per-facility flag: a car made up
|
||||
// at a Division Point out of the common supply carries no origin, and is exactly the inbound
|
||||
// traffic a district lives on.
|
||||
const s = game();
|
||||
const coord = at(-1, 0);
|
||||
freightHouse(s, coord);
|
||||
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
|
||||
f.industryTrack.cars = [{ type: 'boxcar', loaded: true }];
|
||||
assert.equal(check(s, 0, { type: 'laborer.beginUnload', at: coord, carIndex: 0 }), null);
|
||||
});
|
||||
|
||||
it('refuses to detrain the passengers this Office just put aboard', () => {
|
||||
const s = game();
|
||||
platform(s);
|
||||
const tray = atOffice(s, [{ type: 'coach', loaded: false }]);
|
||||
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
|
||||
const coach = s.trays.get(tray)!.consist[0]!;
|
||||
assert.equal(coach.loaded, true, 'nobody boarded');
|
||||
assert.equal(coach.origin, 0, 'the coach is not stamped with the Office that filled it');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }),
|
||||
'LOADED_IN_THIS_DISTRICT',
|
||||
);
|
||||
});
|
||||
|
||||
it('detrains passengers who boarded somewhere else', () => {
|
||||
const s = game();
|
||||
platform(s);
|
||||
const tray = atOffice(s, [{ type: 'coach', loaded: true }]);
|
||||
assert.equal(check(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }), null);
|
||||
});
|
||||
|
||||
it('takes the origin stamp off a coach that reaches the red box', () => {
|
||||
// The stamp belongs to the LOAD. A coach going into the inbound box has finished its journey and
|
||||
// heads back to a yard from there; carrying the stamp on would poison the common supply.
|
||||
const s = game();
|
||||
platform(s);
|
||||
const tray = atOffice(s, [{ type: 'coach', loaded: true, origin: 1 }]);
|
||||
assert.ok(applyIntent(s, 0, { type: 'porter.detrain', at: areaOf(s, 0).officeCoord, trayId: tray }).ok);
|
||||
assert.equal(office(s).inboundBox[0]!.origin, undefined, 'the stamp survived the red box');
|
||||
});
|
||||
});
|
||||
|
||||
describe('two trains in one station are told apart (v0.4.9e)', () => {
|
||||
function twoAtOffice(s: GameState): [string, string] {
|
||||
const area = areaOf(s, 0);
|
||||
const f = area.grid.get(coordKey(area.officeCoord))!.facility!;
|
||||
f.allows = { outbound: true, inbound: true };
|
||||
f.porters = 4;
|
||||
f.capacity = { outbound: 2, inbound: 2 };
|
||||
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
|
||||
s.clock.phase = 'loadUnload';
|
||||
s.clock.currentActor = 0;
|
||||
const ids: string[] = [];
|
||||
for (let n = 0; n < 2; n++) {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'coach', loaded: false }],
|
||||
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return [ids[0]!, ids[1]!];
|
||||
}
|
||||
|
||||
it('offers boarding on each train, not once for the platform', () => {
|
||||
// REPORTED: "operating two trains in a station, the select button does not work — regardless of
|
||||
// which you pick, it is always one train, not the other." There was one button, because the
|
||||
// intent carried no train at all.
|
||||
const s = game();
|
||||
const [a, b] = twoAtOffice(s);
|
||||
const boards = legalActions(s, 0).filter((i) => i.type === 'porter.board');
|
||||
assert.deepEqual(
|
||||
boards.map((i) => (i as { trayId?: string }).trayId).sort(),
|
||||
[a, b].sort(),
|
||||
'both trains at the platform must be offered',
|
||||
);
|
||||
});
|
||||
|
||||
it('boards the train the player named, not the first on the A/D tracks', () => {
|
||||
const s = game();
|
||||
const [a, b] = twoAtOffice(s);
|
||||
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord, trayId: b }).ok);
|
||||
assert.equal(s.trays.get(b)!.consist[0]!.loaded, true, 'the named train did not get the passengers');
|
||||
assert.equal(s.trays.get(a)!.consist[0]!.loaded, false, 'the other train was filled instead');
|
||||
});
|
||||
|
||||
it('still works for an intent that names no train, so old saves replay', () => {
|
||||
// `trayId` is optional for the same reason `switch.move`'s `via` is: intents are the canonical
|
||||
// record every save and every undo replays against.
|
||||
const s = game();
|
||||
const [a] = twoAtOffice(s);
|
||||
assert.ok(applyIntent(s, 0, { type: 'porter.board', at: areaOf(s, 0).officeCoord }).ok);
|
||||
assert.equal(s.trays.get(a)!.consist[0]!.loaded, true, 'the first eligible train should have taken them');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Reported from the v0.4.9d playtest and NOT reproduced: "when I back up to collect standing cars
|
||||
* and, further down the tracks, the caboose, I get the caboose but the cars remain. I can later
|
||||
* drive right through them."
|
||||
*
|
||||
* Coupling is mandatory (§A.4) and the walk accumulates what it meets card by card, so cars the
|
||||
* engine can see always couple — which means cars a train can drive through are cars the engine does
|
||||
* not think are there. Nothing found: `carsOn` is the single answer to "what is standing here" and
|
||||
* the movement walk, the sweep and every renderer all ask it. These pin the shapes that were tried,
|
||||
* so if the case is found later it is somewhere none of them cover.
|
||||
*/
|
||||
describe('backing up over a cut to something beyond it takes both (v0.4.9d report)', () => {
|
||||
const boxcar = () => ({ type: 'boxcar' as const, loaded: false });
|
||||
const caboose = () => ({ type: 'caboose' as const, loaded: false });
|
||||
|
||||
/** A Freight House card, with cars spotted on its industry track. */
|
||||
function industry(cars: TrackCard['standing']): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'facility', facility: 'freightHouse' },
|
||||
baseOperationalRail: true, standing: [], standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'freightHouse',
|
||||
allows: { outbound: true, inbound: true },
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 1 },
|
||||
menAtWork: [null, null, null], industryTrack: { cars: [...cars] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
modifiers: [], enhancements: [],
|
||||
} as TrackCard;
|
||||
}
|
||||
|
||||
const empty = (s: GameState, ...coords: GridCoord[]): void => {
|
||||
for (const c of coords) {
|
||||
assert.deepEqual(carsOn(areaOf(s, 0).grid.get(coordKey(c))!), [], `cars left standing at (${c.col},${c.row})`);
|
||||
}
|
||||
};
|
||||
|
||||
it('takes a cut standing on plain track on the way to the caboose', () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
addCard(s, at(0, 1), straight([boxcar(), boxcar()]));
|
||||
addCard(s, at(0, 0), straight([caboose()]));
|
||||
const id = placeTray(s, at(0, 2));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
|
||||
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
|
||||
empty(s, at(0, 1), at(0, 0));
|
||||
});
|
||||
|
||||
it('takes cars SPOTTED AT AN INDUSTRY on the way, not just the destination', () => {
|
||||
// Jesse's best guess at the reported shape. An industry card is plain east-west track carrying a
|
||||
// facility, and `carsOn` reads its industry track rather than the card — so this is the case
|
||||
// where the two could have come apart.
|
||||
const s = game();
|
||||
addCard(s, at(0, 2), straight());
|
||||
addCard(s, at(0, 1), industry([boxcar(), boxcar()]));
|
||||
addCard(s, at(0, 0), straight([caboose()]));
|
||||
const id = placeTray(s, at(0, 2));
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
|
||||
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
|
||||
empty(s, at(0, 1), at(0, 0));
|
||||
});
|
||||
|
||||
it("takes the train's own cut off the square it is standing on as well", () => {
|
||||
const s = game();
|
||||
addCard(s, at(0, 1), straight());
|
||||
addCard(s, at(0, 0), straight([caboose()]));
|
||||
const id = placeTray(s, at(0, 1), [boxcar(), boxcar()]);
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
|
||||
// Set the pair out behind the engine, pull forward, then back up past them to the caboose.
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok);
|
||||
assert.equal(carsOn(areaOf(s, 0).grid.get(coordKey(at(0, 1)))!).length, 2);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
|
||||
assert.deepEqual(s.trays.get(id)!.consist.map((c) => c.type), ['boxcar', 'boxcar', 'caboose']);
|
||||
empty(s, at(0, 1), at(0, 0));
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -52,8 +52,8 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'carPassed', player: 0, trayId: 't0' },
|
||||
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
|
||||
{ type: 'clearanceGiven', trainId: 't1', allow: false },
|
||||
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } },
|
||||
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } },
|
||||
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
|
||||
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
|
||||
{ type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
|
||||
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
|
||||
|
||||
+56
-12
@@ -22,6 +22,7 @@ import {
|
||||
deckComposition,
|
||||
isFreightHouse,
|
||||
lengthProfile,
|
||||
MAINLINE_DECK,
|
||||
mainlineCardCount,
|
||||
nextOfficeTier,
|
||||
officeProfile,
|
||||
@@ -181,23 +182,30 @@ describe('card catalogue (component 1)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('identifies the both-direction industries the card reference names', () => {
|
||||
it('names the Freight House and nothing else as the two-way industry', () => {
|
||||
/**
|
||||
* `card-reference.md`: "'Freight House' is not a card. It is the collective term for a freight
|
||||
* facility that loads *and* unloads — the Grocer's Warehouse and the Oil Refinery." The table
|
||||
* agrees: both are "Both", and only the Power Plant is inbound-only.
|
||||
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction". ONE card
|
||||
* answers to that.
|
||||
*
|
||||
* The engine had the Refinery as outbound-only and the Grocer's as inbound-only, so §9.3's
|
||||
* "Passenger Facilities and Freight Houses permit cars to move each direction" named neither of
|
||||
* them — and every Modifier grant on the missing direction was silently dropped, which is how
|
||||
* "grocer's warehouse didn't get extra outbound slot for truck dock" was reported.
|
||||
* This briefly asserted three. `card-reference.md` reads "'Freight House' is not a card. It is
|
||||
* the collective term for a freight facility that loads *and* unloads — the Grocer's Warehouse
|
||||
* and the Oil Refinery", and on that premise the Refinery and the Grocer's were both made
|
||||
* `flow: 'both'`. The premise is dead: `glossary.md` and `rules-v0.2.md` corrected the Freight
|
||||
* House to a card of its own, dealt like any other industry, so §9.3 names it and the table's
|
||||
* "Both" column loses its only argument.
|
||||
*
|
||||
* `freightHouse` is still in this list because the engine deals it as a CARD, which the rules say
|
||||
* it is not. That is a deck-composition question, recorded in TODO.md, not something to quietly
|
||||
* delete six cards over.
|
||||
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
|
||||
* the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way,
|
||||
* and so does the modifier set — all three Refinery modifiers grant outbound.
|
||||
*/
|
||||
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
||||
assert.deepEqual(houses.sort(), ['freightHouse', 'grocersWarehouse', 'refinery']);
|
||||
assert.deepEqual(houses.sort(), ['freightHouse']);
|
||||
const refinery = FREIGHT_PROFILES.find((f) => f.kind === 'refinery')!;
|
||||
assert.equal(refinery.flow, 'outbound');
|
||||
assert.deepEqual([refinery.baseOut, refinery.baseIn], [1, 0]);
|
||||
const grocers = FREIGHT_PROFILES.find((f) => f.kind === 'grocersWarehouse')!;
|
||||
assert.equal(grocers.flow, 'inbound');
|
||||
assert.deepEqual([grocers.baseOut, grocers.baseIn], [0, 1]);
|
||||
});
|
||||
|
||||
it('starts every industry at one car out and one loader', () => {
|
||||
@@ -400,6 +408,42 @@ describe('game setup (component 2)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('deals the Mainline cards from the printed deck, without replacement', () => {
|
||||
/**
|
||||
* `buildDivision` drew uniformly from the nine card TYPES with replacement, so a Division could
|
||||
* be dealt two Interchanges (or two Tunnels), and Plains — printed twice in the deck — carried
|
||||
* the same weight as cards printed once. That became a rules question rather than a flavour one
|
||||
* when an Extra gained the right to start "at the Interchange if one is on the board" (§7): the
|
||||
* board has to hold at most one for that to mean anything.
|
||||
*
|
||||
* Swept over many seeds because a single deal cannot tell a deck from a die.
|
||||
*/
|
||||
const seen = new Map<string, number>();
|
||||
for (let seed = 0; seed < 400; seed++) {
|
||||
for (const players of [1, 2, 3, 4]) {
|
||||
const g = createGame({
|
||||
id: 'deck', seed,
|
||||
config: players === 1 ? solitaireConfig : { ...solitaireConfig, mode: 'competitive' },
|
||||
playerNames: Array.from({ length: players }, (_, i) => `P${i}`),
|
||||
});
|
||||
const cards = g.division.nodes.flatMap((n) => (n.kind === 'mainline' ? [n.card] : []));
|
||||
assert.equal(cards.length, mainlineCardCount(players));
|
||||
const counts = new Map<string, number>();
|
||||
for (const c of cards) {
|
||||
const n = (counts.get(c) ?? 0) + 1;
|
||||
counts.set(c, n);
|
||||
seen.set(c, (seen.get(c) ?? 0) + 1);
|
||||
// Plains is the one card printed twice; nothing else may be dealt twice at all.
|
||||
assert.ok(n <= (c === 'plains' ? 2 : 1), `${c} dealt ${n} times at seed ${seed}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every card in the deck reachable, so the deal is not quietly missing one.
|
||||
for (const kind of new Set(MAINLINE_DECK)) {
|
||||
assert.ok((seen.get(kind) ?? 0) > 0, `${kind} was never dealt in 400 seeds`);
|
||||
}
|
||||
});
|
||||
|
||||
it('opens with the whole railroad as one Subdivision', () => {
|
||||
// §8 — every Office is a Whistle Post, which is not a Control Point.
|
||||
const g = newSolitaireGame();
|
||||
|
||||
+26
-14
@@ -386,13 +386,22 @@ describe('switching accomplishes something (regression)', () => {
|
||||
// no switching at all (§9.2 works coaches straight off the A/D track), so an entire Local
|
||||
// Operations action was wasted.
|
||||
/**
|
||||
* ACROSS SEEDS, because one game cannot tell a fixed bug from a lucky deal. Measured over these
|
||||
* 16: thirteen show no oscillation at all and three reach a run of five, so the shuttling is a
|
||||
* minority behaviour rather than the every-game waste this test was written to catch. The bar is
|
||||
* therefore a RATE — most games clean — plus a ceiling on how bad the worst may get. The residual
|
||||
* is recorded in TODO.md with the rest of the bot work.
|
||||
* ACROSS SEEDS, because one game cannot tell a fixed bug from a lucky deal — and the sample has
|
||||
* to be big enough that it cannot tell a lucky DEAL from a fixed bug either.
|
||||
*
|
||||
* It was 16 hand-picked seeds against a bar of 70% clean, on a measurement of 13/16. Dealing the
|
||||
* Mainline cards from the printed deck instead of rolling them (`buildDivision`) re-dealt every
|
||||
* one of those boards and the same 16 came back 11/16, which read as a regression and was not
|
||||
* one: re-measured over 80 seeds the rate is **70.0% clean, worst run 5** — the identical
|
||||
* behaviour, and 13/16 was the lucky draw. A bar sitting exactly on the true rate fails half the
|
||||
* time it is moved.
|
||||
*
|
||||
* So: a wider sweep, and a bar well below the measured rate. What the test is really guarding is
|
||||
* the every-game waste it was written for, which shows up as a rate near ZERO, not as a few
|
||||
* points of drift. The ceiling on the worst run is the sharp half of the assertion and is
|
||||
* unchanged. The residual is recorded in TODO.md with the rest of the bot work.
|
||||
*/
|
||||
const seeds = [1234, 5, 77, 430, 202, 999, 21, 555, 4321, 31337, 60606, 7777, 123456, 888, 31, 42];
|
||||
const seeds = Array.from({ length: 48 }, (_, i) => i + 1);
|
||||
let clean = 0;
|
||||
let worstAnywhere = 0;
|
||||
for (const seed of seeds) {
|
||||
@@ -424,7 +433,7 @@ describe('switching accomplishes something (regression)', () => {
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
clean >= seeds.length * 0.7,
|
||||
clean >= seeds.length * 0.55,
|
||||
`only ${clean}/${seeds.length} games were free of aimless shuttling`,
|
||||
);
|
||||
assert.ok(worstAnywhere <= 5, `a crew oscillated ${worstAnywhere + 1} times without doing any work`);
|
||||
@@ -474,8 +483,10 @@ describe('switching accomplishes something (regression)', () => {
|
||||
* grew faster, which is traffic rather than aimlessness, and there are two new sources of it:
|
||||
* Extras now start at the Division Point their NUMBER sends them to, so westbound Extras exist
|
||||
* at all (measured 32 west / 29 east across 60 deals, against every single one launching
|
||||
* eastbound from the West Division Point before); and the Grocer's Warehouse ships as well as
|
||||
* receives, so there is more switching worth doing.
|
||||
* eastbound from the West Division Point before); and the Grocer's Warehouse briefly shipped as
|
||||
* well as received, which was more switching worth doing. That second source is gone again in
|
||||
* v0.4.9e — the Grocer's is inbound-only, as it always was on the sheet — and the ratio still
|
||||
* clears the floor, so the figure is left where it is rather than re-tuned to one release.
|
||||
*
|
||||
* A crew that shuttles for its own sake would show this ratio climbing while `work` stood still.
|
||||
* Logged in TODO.md with the rest of the bot drift rather than quietly absorbed.
|
||||
@@ -1070,12 +1081,13 @@ describe('the freight figures count both halves (regression)', () => {
|
||||
// that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it,
|
||||
// and whether the bot manages all three on a given deal is luck, not the thing under test.
|
||||
/**
|
||||
* FORTY DEALS, up from twelve, and the reason is a rules correction rather than flakiness.
|
||||
* FORTY DEALS, up from twelve, and the reason was a rules correction rather than flakiness.
|
||||
*
|
||||
* The Grocer's Warehouse is a BOTH-direction facility now — `card-reference.md` always said so —
|
||||
* where the engine had it inbound-only. So the bot can ship from it as well as receive, and it
|
||||
* often does: deals producing at least one completed unload went from 12 in 40 to 6 in 40, while
|
||||
* unloads themselves are unharmed (30 completed across the 40 measured after the change).
|
||||
* The Grocer's Warehouse was briefly a both-direction facility, so the bot shipped from it as
|
||||
* well as receiving and deals producing at least one completed unload fell from 12 in 40 to 6 in
|
||||
* 40. v0.4.9e put it back to inbound-only, which is what the sheet always printed. The wider
|
||||
* sample is kept: the precondition it protects — that some deal in the batch actually completes
|
||||
* an unload — is worth having whichever way the rule goes.
|
||||
*
|
||||
* The subject here is the INSTRUMENT — does `freightUnload` count Revenue earned rather than
|
||||
* unloads started — and `unloads > 0` is only the precondition that makes the comparison mean
|
||||
|
||||
+118
-5
@@ -2818,12 +2818,17 @@ describe('the New Game dialog', () => {
|
||||
value,
|
||||
checked: false,
|
||||
}));
|
||||
const extraRadios = ['divisionPointsOnly', 'ownOffice', 'anyOffice'].map((value) => ({
|
||||
value,
|
||||
checked: value === 'anyOffice',
|
||||
}));
|
||||
let html = '';
|
||||
const node: Record<string, unknown> = {
|
||||
id, value: '', textContent: '', title: '', returnValue: '', open: false,
|
||||
style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
radios,
|
||||
extraRadios,
|
||||
addEventListener: (type: string, fn: () => void) =>
|
||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||
showModal: () => void ((node as { open: boolean }).open = true),
|
||||
@@ -2833,9 +2838,18 @@ describe('the New Game dialog', () => {
|
||||
},
|
||||
// Only the radio-group selectors the dialog actually uses; anything else is not this
|
||||
// element's business and answering it with a guess would hide a typo in the real selector.
|
||||
querySelectorAll: (sel: string) => (sel === 'input[name="ng-hand"]' ? radios : []),
|
||||
querySelectorAll: (sel: string) =>
|
||||
sel === 'input[name="ng-hand"]'
|
||||
? radios
|
||||
: sel === 'input[name="ng-extra"]'
|
||||
? extraRadios
|
||||
: [],
|
||||
querySelector: (sel: string) =>
|
||||
sel === 'input[name="ng-hand"]:checked' ? (radios.find((r) => r.checked) ?? null) : null,
|
||||
sel === 'input[name="ng-hand"]:checked'
|
||||
? (radios.find((r) => r.checked) ?? null)
|
||||
: sel === 'input[name="ng-extra"]:checked'
|
||||
? (extraRadios.find((r) => r.checked) ?? null)
|
||||
: null,
|
||||
};
|
||||
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||
return node;
|
||||
@@ -2901,7 +2915,7 @@ describe('the New Game dialog', () => {
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
|
||||
assert.equal(nav.search, '?seed=99&hand=threeTrackThreeOther&passenger=5&freight=0&transit=2');
|
||||
assert.equal(nav.search, '?seed=99&hand=threeTrackThreeOther&extra=anyOffice&passenger=5&freight=0&transit=2');
|
||||
});
|
||||
|
||||
it('deals nothing on cancel, and nothing on Esc', async () => {
|
||||
@@ -2921,7 +2935,7 @@ describe('the New Game dialog', () => {
|
||||
it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => {
|
||||
// Dealing a random seed, disliking it and dealing again at the same settings produces the same
|
||||
// search string — and assigning `location.search` the value it already holds does nothing.
|
||||
const url = '?hand=threeRandom&passenger=1&freight=1&transit=0';
|
||||
const url = '?hand=threeRandom&extra=anyOffice&passenger=1&freight=1&transit=0';
|
||||
const { els, nav } = await load(url);
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
@@ -2950,7 +2964,7 @@ describe('the New Game dialog', () => {
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
|
||||
assert.equal(nav.search, '?hand=threeRandom&passenger=1&freight=1&transit=0', 'a bad seed was carried into the URL');
|
||||
assert.equal(nav.search, '?hand=threeRandom&extra=anyOffice&passenger=1&freight=1&transit=0', 'a bad seed was carried into the URL');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3193,6 +3207,105 @@ describe('two crews switching are told apart', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('two trains at one platform are told apart (v0.4.9e)', () => {
|
||||
/**
|
||||
* REPORTED from the v0.4.9d playtest: "operating two trains in a station — the select button does
|
||||
* not work. Regardless of which you pick, it is always one train, not the other."
|
||||
*
|
||||
* `porter.board` and `porter.detrain` carried no tray, so there was ONE button per platform however
|
||||
* many trains were standing at it, and the reducer filled whichever tray came first out of
|
||||
* `adOccupancy`. Clicking a roster chip changed what the board drew and nothing else — which is
|
||||
* exactly what "the select button does not work" describes.
|
||||
*
|
||||
* Driven through `actionMenu` rather than `legalActions` because the second half of the failure was
|
||||
* at this layer: the menu collapses identical labels within a crew, and "board passengers at (0,0)"
|
||||
* describes both trains.
|
||||
*/
|
||||
const twoAtPlatform = (): { game: Game; trays: string[] } => {
|
||||
const game = newGame(4242);
|
||||
const s = game.state;
|
||||
const area = areaOf(s, 0);
|
||||
const f = area.grid.get(`${area.officeCoord.row},${area.officeCoord.col}`)!.facility!;
|
||||
// A Station's worth of platform: Porters, slots, and two fares waiting.
|
||||
f.allows = { outbound: true, inbound: true };
|
||||
f.porters = 4;
|
||||
f.capacity = { outbound: 2, inbound: 2 };
|
||||
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
|
||||
const trays: string[] = [];
|
||||
for (const trainNumber of [7, 9]) {
|
||||
const id = s.freeTrays.pop()!;
|
||||
s.trays.set(id, {
|
||||
id, trainNumber, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'coach', loaded: false }],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord },
|
||||
movesUsed: 0,
|
||||
} as never);
|
||||
area.adOccupancy.push(id);
|
||||
trays.push(id);
|
||||
}
|
||||
s.clock.phase = 'loadUnload';
|
||||
s.clock.currentActor = 0;
|
||||
return { game, trays };
|
||||
};
|
||||
|
||||
it('offers boarding once per train, with the train named on the button', () => {
|
||||
const { game } = twoAtPlatform();
|
||||
const labels = actionMenu(game)
|
||||
.direct.flatMap((g) => g.actions)
|
||||
.map((a) => a.label)
|
||||
.filter((l) => /^board passengers/.test(l));
|
||||
assert.equal(labels.length, 2, `expected one button per train, got ${JSON.stringify(labels)}`);
|
||||
assert.ok(labels.some((l) => /Train 7/.test(l)), `no button names Train 7: ${JSON.stringify(labels)}`);
|
||||
assert.ok(labels.some((l) => /Train 9/.test(l)), `no button names Train 9: ${JSON.stringify(labels)}`);
|
||||
});
|
||||
|
||||
it('boards the train whose button was pressed', () => {
|
||||
const { game, trays } = twoAtPlatform();
|
||||
const { options } = actionGroups(game);
|
||||
const nine = options.findIndex(
|
||||
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[1],
|
||||
);
|
||||
assert.ok(nine >= 0, 'no boarding option names the second train');
|
||||
submit(game, options[nine]!);
|
||||
assert.equal(game.state.trays.get(trays[1]!)!.consist[0]!.loaded, true, 'Train 9 did not get them');
|
||||
assert.equal(game.state.trays.get(trays[0]!)!.consist[0]!.loaded, false, 'Train 7 was filled instead');
|
||||
});
|
||||
|
||||
it('will not detrain the passengers it has just put aboard', () => {
|
||||
// The other half of the same playtest: "passenger stations — passengers just boarded cannot be
|
||||
// immediately unloaded." They could, for a Porter action and full Revenue, without the train
|
||||
// moving an inch.
|
||||
const { game, trays } = twoAtPlatform();
|
||||
const board = actionGroups(game).options.find(
|
||||
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0],
|
||||
)!;
|
||||
submit(game, board);
|
||||
const detrains = actionGroups(game).options.filter((o) => o.type === 'porter.detrain');
|
||||
assert.equal(detrains.length, 0, 'detraining was still offered for passengers who boarded here');
|
||||
assert.equal(
|
||||
check(game.state, 0, { type: 'porter.detrain', at: areaOf(game.state, 0).officeCoord, trayId: trays[0]! }),
|
||||
'LOADED_IN_THIS_DISTRICT',
|
||||
);
|
||||
});
|
||||
|
||||
it('says on the coach that it was loaded here', () => {
|
||||
// The printed game turns the chip upside down in the tray; this is the screen's version of that.
|
||||
const { game, trays } = twoAtPlatform();
|
||||
submit(game, actionGroups(game).options.find(
|
||||
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0],
|
||||
)!);
|
||||
const f = view(game);
|
||||
const office = f.cells.find((c) => c.kind === 'office')!;
|
||||
const train = office.trains.find((t) => t.trayId === trays[0]);
|
||||
assert.ok(train, 'the boarded train is not on the Office card');
|
||||
assert.ok(
|
||||
train!.cars.some((c) => /loaded here/.test(c)),
|
||||
`the coach does not say where it was loaded: ${JSON.stringify(train!.cars)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Superintendent ruling names the train it is ruling on', () => {
|
||||
/**
|
||||
* REPORTED from play: "when the Superintendent has to rule on a train to allow or hold, it should
|
||||
|
||||
Reference in New Issue
Block a user