v0.4.4 — New Game dialog for the opening hand and the three revenue rates, Yard renamed Interchange, and two movement bugs behind a mirrored consist

This commit is contained in:
Jesse
2026-08-14 22:39:50 -04:00
parent d1314066bd
commit 6655e20ea8
29 changed files with 5450 additions and 3902 deletions
+199
View File
@@ -21,6 +21,205 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## Unreleased
## 0.4.4 — 2026-08-14
Out of a play session: engines that pointed north, a card whose name collided with five other things,
a New Game dialog that only asked for a seed — and then, chasing a mirrored consist, two movement
bugs that had been there all along.
Both playtest reports were confirmed against Jesse's own saved game,
`docs/station-master-seed493290760-day2.json` (seed 493290760, two Days, 127 intents), which is kept
as the evidence for what follows.
### A curve is not a straight, and a train that rounds one knows it
**Reported:** "a train reversed into a siding and the display of the cars was reversed."
`facing` is the port the engine would leave by, and a Move recorded it as `opposite(entry)`. That is
the far end of a **straight** and of nothing else: a curve is an arc between two ADJACENT edges, so a
train entering a north-west curve through its west port comes out facing **north**, not east. Every
train that rounded a curve was left facing a port its own card does not have.
Two things went wrong with that, and only the second was visible:
- **Movement.** `movesFor` explores from `facing`, and a port the card lacks yields no destinations
at all — so a crew that rounded a curve could only ever back out the way it came. It could not
continue round the corner it had just taken.
- **Display.** The east-west sense the board draws is carried from `facing`, so a curve that had
really turned the engine west could leave the board still drawing it east — the consist mirrored,
which is what was seen.
The forward case now asks the **card** for its far end (`farPort`). The reverse case is unchanged and
was already right: backing up, the engine trails and points out through the port the train came in
by, whatever the track does underneath — there is a test pinning that so the fix cannot drift into it.
**The reported moment, out of the save.** Three times in two Days the crew backs off the Running
Track into the curved siding at (1,-2) — a `sw` arc, so entering it from the south leaves the engine
facing south. Intent #109, drawn both ways:
```
came from (0,0) office [ew] west cab tnk [>] east
BEFORE the fix west [v] box tnk cab east
AFTER the fix west cab tnk box [>] east
```
The engine was on the east end before the move and is on the east end after it — backing up does not
turn a train around. The old renderer flipped the whole strip to nose-left the moment `facing` stopped
being `'e'`, which is precisely the mirroring that was reported.
### A train leaving a district drops its spur port
The same family, found while fixing the above. Nothing reset `facing` when a train left an Office, so
a crew that had been shunted onto a north-south spur carried a compass port out onto a Division that
runs east and west — and then into the next Office, whose card has no north or south edge at all.
With neither `facing` nor its opposite on the card, `movesFor` returned nothing in either direction:
**the train arrived at the next Office unable to make a single Move.** It also drew a ▲ on the
Division map, where there is no north to point at.
A train out there is running one way along an east-west railroad with its engine at one end, so
`enterMainline` now says so. This is the same stale port that made the ▲ on the Division map — the
`railFacing` change above stopped it being *drawn*, and this stops it existing.
### Departures after Cargo: investigated, not a bug — but one card contradicts itself
Also reported: "a train left at the end of the Cargo phase — shouldn't it wait for the next Mainline
phase?" It should not, and it did not: **only Expedite trains do this**, which is Q3 working as
agreed. In the save it is **6 The Sparrow**, twice — arriving in Stage 9's Mainline and highballing
in Stage 9's Supervisor Shift, then the same in Stage 10 of Day 2. The one non-Expedite train in the
game, 12 Drag Freight, arrived in Stage 8 and left three Stages later in a **Mainline** phase, exactly
as it should. Measured more widely, over 40 bot games, the split is perfect: every departure with no
Local Operations turn was an Expedite train, and every non-Expedite train got at least one.
Two things are worth writing down because they read as bugs and are not:
- **Expedite departs in Supervisor Shift, not in Cargo.** Q3 as recorded says the train "gets a
second `moveTrain` in the same Mainline Phase"; the code deliberately does not, because that had
expedited trains gone before a single Porter could reach them. Supervisor Shift is the last phase
of the Stage, so it is still the Stage the train arrived in — it just looks like "I finished Cargo
and it left", because Supervisor Shift needs no input and runs itself.
- **Expedite costs the train its Local Operations turn**, since Local Ops is phase 1 and the train
arrives in phase 3 and leaves in phase 5. Harmless for four of the five — Crack Limited, The
Sparrow, the Military train and the Light Engine all print "no switching" and would decline it.
**Except for 3/4 Express**, which prints *"may drop or pick up one freight car at every location"*
and is also Expedite. That budget is spent by coupling and setting out, which happen only in Local
Operations — so the Express has a printed ability it can never use: 31 Office visits in 40 games, 31
with no turn to use it in. X14 Fruit Growers Express is in the same position, though its extra-reefer
line is a note today with no mechanics behind it. Left alone pending Jesse's call and recorded in
`TODO.md`; it is a rules contradiction on the card, not a fault in the timing.
### Replays are replaced rather than piled up
Both fixes change which Moves are legal, so the published replays stopped replaying — correctly, and
the liveness test caught it. Re-recording used to write the new set **beside** the old one, and the
old set is precisely the one whose rules have just moved, so dead files accumulated and the test
failed on them forever. `save-replay.ts` now retires what it replaces (only once a replacement has
verified), and writes the `rules` block into each file so a replay can never again be silently
re-dealt. `harness.test.ts` was passing `{ seed, history }` and dropping `rules` on the floor, which
would have replayed every published file under the pre-dialog defaults whatever it said.
### The engine points east or west, always
**Reported:** a crew that turned onto a north-south spur was drawn with a ▲ over it, and the change
of convention was harder to read than no arrow at all.
`facing` on a Crew Tray is a **port** — 'n', 's', 'e' or 'w' — because movement needs one: a crew
standing on a north-south spur has to be able to leave by 'n' or 's', and the last attempt to derive
east/west from the direction of the run stranded 29 of 62 leftover crews on north-south track with no
legal move. So the port stays exactly as it is, and what changed is the **drawing**.
A new `railFacing` on the tray carries the east-west sense across north-south track: it updates
whenever `facing` becomes 'e' or 'w' and holds its value in between. That is the railroad's own
convention, where compass north on a branch is still timetable east. It follows the engine around
180° of curves, because a train that runs forward through two curves really has turned around — and
it does *not* move when a train backs up, because a train that backs up has not.
**It also fixes a bug nobody had reported yet.** Nothing resets `facing` when a train leaves a
district, so a crew that shunted onto a north-south spur and then departed carried its 'n' out onto
the Division map — where there is no north or south — and drew ▲ there too.
The Frame's `facing` is now typed `'e' | 'w'`, so this is enforced rather than merely observed, and
the Office card lays a north-south crew's consist east-west like every other train instead of pinning
it nose-left.
### The Yard mainline card is now the Interchange
Same 60, same "sort cars into any new order", same entry points, same art. What it did not have was a
name of its own: **Division Yard, Classification Yard, Salvage Yard, Yard Office and Small Yard** are
five other things in this game, and none of them is this card. The internal key is renamed with it
(`MainlineKind` `'yard'` → `'interchange'`) so the two cannot drift. The other five are deliberately
untouched — they merely shared a word.
### New game asks for the rules, not just the seed
It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game you are
about to play had no way in at all: the opening hand had been changed twice with no way back to the
earlier rule, and the three revenue rates were constants in the source. Balance is the open question
this game has, and settling it means dealing several games at different settings — which needs a
dialog, not a rebuild.
**Starting hand**, three options, all of which have been the rule at some point:
- **Three random cards** *(new default)* — the prototype rule. At the hand limit already.
- **Six random cards** — twice the choice, still no guaranteed track.
- **Three random track and three random non-track** — the v0.4.x deal, from two shuffled piles.
**Revenue**, three rates, each 0–5:
- **Passenger revenue per coach**, default **1**. Paid when a coach is boarded and again when it is
detrained — both halves of the movement, as it has always worked.
- **Freight revenue per load**, default **1**. Paid when a load is made up outbound and again when it
is broken inbound.
- **Train revenue per transit**, default **0** — *changed from 1*. Paid to every player when a train
runs off the end of the Division. At 1 it was worth ~5.4 Revenue against a bot mean of 7.0: the
railroad was earning most of its money from the one thing nobody has to work for, and the freight
and passenger economies the game is about could not be read through it.
Zero is a real setting rather than "one, suppressed" — no revenue event is emitted at all, so the
history does not fill with "+0 Revenue" for work that did not pay.
**A seed no longer names a game**, so the settings ride in the URL beside it
(`?seed=430&hand=sixRandom&passenger=1&freight=1&transit=0`) and the header carries a readout of what
is in force. A playtest note reading "scored 4" is worthless without it.
**Saves carry the rules they were dealt under.** A save is a seed and a list of intents: replay it
under different rules and it is a different game, and the symptom is not an error but a replay that
quietly stops early — which `TODO.md` records happening twice unnoticed, one of them 42 intents into
360. `Save.rules` is written from now on, and a save without it replays under the pre-dialog rules
(3+3, and 1 per transit) rather than today's defaults, so the three published replays still run.
**Under the hood.** The settings live on `GameConfig.houseRules` as a partial, resolved in one place
by `houseRules()`, which clamps and rounds — so a hand-edited URL cannot deal a game at 900 Revenue a
coach. They reach the page on the `Frame` rather than off the config, because a remote client holds
no `GameState` and still has to be able to answer "what does a load pay here?"; `createLocalSession`
takes house rules rather than a whole `GameConfig` for the same reason.
**Exercised, not just compiled.** Ten developer-bot games per setting, solitaire Standard — far too
small a sample to conclude anything about balance, and enough to show the dials are wired to the
game rather than to the dialog:
| Setting | Dealt | Mean Revenue |
| --- | --- | --- |
| `threeRandom` (default) | 3 | 4.00 |
| `sixRandom` | 6 | 2.00 |
| `threeTrackThreeOther` | 6 | 0.50 |
| pax 1 · frt 1 · **trn 0** (default) | — | 4.00 |
| pax 1 · frt 1 · **trn 1** (the old rule) | — | 9.30 |
| pax 0 · frt 0 · trn 0 | — | −0.20 |
| pax 5 · frt 5 · trn 0 | — | 20.80 |
The transit rule is worth **+5.30** here against the +5.4 measured over 200 games before it became a
setting, which is the number this change was made to get out from underneath. All zeroes lands just
below zero — collision penalties, with nothing left paying — which is the right shape for an economy
switched off. **Read no balance conclusion from the two random-deal rows**: ten seeds is noise, and
the hand rows are three different games rather than the same game dealt differently.
**Tests.** 513 passing, six of them driving the dialog through the *emitted bundle* — the failure
mode here is wiring, not logic. Four existing tests had to be re-pinned rather than merely updated:
the opening deal changes the RNG stream, so `advance.test.ts` was relying on whichever mainline card
seed 1 happened to lay down and now pins its own terrain, and the widest action list was re-measured
over five seeds in all three deals (13 under the random deals, 10 under 3+3).
## 0.4.3 — 2026-08-14
### A load has to have somewhere to go
+19 -11
View File
@@ -26,10 +26,13 @@ imports nothing outside itself, and never touches `Math.random`, so a static hos
a remote one drops in without the page changing — but there is no server, no turn submission and no
per-player push), the 22 opponent-directed cards, and real audio.
Balance is *not* where it should be: the developer bot averages 7.0 Revenue against a target of 20 —
of which ~5.4 is the "one Revenue per train that clears your section" rule, so the working freight
and passenger economy is still only ~2. `TODO.md` says why, and says which of it is the bot and which
is the deck.
Balance is *not* where it should be: the developer bot averaged 7.0 Revenue against a target of 20 —
of which ~5.4 was the "one Revenue per train that completes its run" rule, so the working freight and
passenger economy is still only ~2. That rule is now a **setting that defaults to off**, along with
the passenger and freight rates and the opening hand, so the economy can be read on its own and the
alternatives can be played rather than argued about. Nothing in this file or in `TODO.md` has been
re-measured at the new defaults; `TODO.md` says why, and says which of it is the bot and which is the
deck.
Versions follow the convention at the top of [`CHANGELOG.md`](CHANGELOG.md): third digit for fixes,
second for a set of features, 1.0 for the first release that deserves the name.
@@ -106,13 +109,18 @@ is the thing this machinery exists to prevent.
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
- **Track is a deck card, but the opening district is dealt.** 96 of the 235 cards are track — the
largest category — so a district is built from what you draw, and building it costs you the
industry or train you drew instead. The one exception is setup: track is shuffled separately and
each player is dealt **3 track + 3 other**, with the leftover track shuffled back in afterwards.
You therefore open holding six against a limit of three, and the first turn is spent choosing.
Provisional — see `TODO.md`.
- **Clearing the line scores.** Every train highballed out of your Office earns one Revenue, paid
once. It is worth +5.4 a game, more than the entire freight and passenger economy put together,
and it pays for traffic you do not have to work. Also provisional.
industry or train you drew instead. The opening hand is the exception, and it is now **chosen when
the game is dealt**: three random cards (the default, and the prototype rule), six random cards, or
three track and three other from two separately shuffled piles. The last of those is the only one
that guarantees you a district to build; deal six and you open over the limit of three, so the
first turn is spent choosing. See `TODO.md`.
- **What the work pays is a setting too.** Passenger revenue per coach, freight revenue per load and
train revenue per transit each run 0–5 and are fixed when the game is dealt. The first two default
to 1 and pay at both ends of a movement — boarding *and* detraining, loading *and* unloading. The
third pays every player when a train runs off the end of the Division and **defaults to 0**: at 1
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 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
+29 -3
View File
@@ -9,6 +9,20 @@ Ordered within each section by how much it is currently costing us.
## Next
- [ ] **3/4 EXPRESS PRINTS A RULE IT CAN NEVER USE — Jesse's call.** The card says *"may drop or pick
up one freight car at every location"* and also prints **Expedite**. Expedite means the train
departs the Stage it arrives (Q3): it arrives in the Mainline phase, stands through Cargo, and
highballs in Supervisor Shift — so it is never on the board during a Local Operations phase,
which is the only phase in which freight is coupled or set out. Measured over 40 bot games:
**31 Office visits, 31 of them with no Local Operations turn.** X14 Fruit Growers Express is in
the same position, though its "may pick up one extra loaded reefer" is only a note today.
The four options put to Jesse, unchanged: drop Expedite from 3/4 only (the other Expedite
trains all print "no switching" and lose nothing); leave Q3 alone and strike the freight line
from the card; drop Expedite everywhere (it partly exists to relieve Crew Tray scarcity, so
this needs re-measuring); or move the Express's freight budget into the Cargo phase, where an
expedited train does still get a turn. **Nothing is broken** — this is a contradiction between
two lines on one card, and the timing rule itself is behaving exactly as recorded.
- [ ] **REBALANCE, once the rules are right — deliberately deferred.** Card counts, industry counts
and the track mix all need a pass together, and none of them should move until the rules stop
moving. Standing distortions to account for when it happens: offices are doubled (Q12) and
@@ -17,8 +31,18 @@ Ordered within each section by how much it is currently costing us.
those multipliers exist to relieve. The 8 sharp curves have already been taken out on that
argument; offices and industries are the two left. Until then, read no balance conclusion from the revenue
numbers; they are a functionality signal only.
- [ ] **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN PLAYED — both went in provisional.** Jesse's
call, both implemented and measured, both flagged in `rules-v0.2.md`.
- [ ] **RE-MEASURE THE BOT AT THE NEW DEFAULTS.** Both provisional rules below are now **settings on
the New Game dialog** rather than fixed choices, and the defaults are not what the numbers in
this file were measured under: the opening hand defaults to **three random cards** (the
prototype rule) rather than 3+3, and **train revenue per transit defaults to 0** rather than 1.
That second one is the big move — it was worth ~5.4 of a 7.0 mean, so the bot's revenue should
fall to roughly the working freight-and-passenger economy alone, which is the number this game
has actually been trying to read all along. Every mean, floor and threshold quoted below and in
the tests predates it. The three revenue rates run 0–5, so the useful next step is a sweep
rather than a single re-run.
- [ ] **REVIEW THE TWO NEW RULES ONCE THEY HAVE BEEN PLAYED — both went in provisional, and both are
now selectable rather than fixed.** Jesse's call, both implemented and measured, both flagged
in `rules-v0.2.md`. What follows is what was measured when each was the only option.
**The opening deal (3 track + 3 other, from two separately shuffled piles).** It did what it
was aimed at, modestly: run-arounds **4/60 → 7/60** and districts **17.9 → 20.3 cards**, with
@@ -37,7 +61,9 @@ Ordered within each section by how much it is currently costing us.
completions run at almost the same rate; **in a multi-player game the shape is completely
different** and needs measuring once multiplayer exists — N players × 1 per completed run
against the old N payments per train. **The victory-target question stays live**: 20 over 5 Days
is still reachable largely on traffic, which is either the intent or an argument for raising it.
is still reachable largely on traffic, which is either the intent or an argument for raising it
— and at the new default of 0 per transit it is not reachable on traffic at all, which is the
first thing a playtest should check.
- [ ] **BOT DRIFT ACROSS THIS RELEASE — four measurements, all for the rebalance pass.** Recorded
together so the pattern is visible rather than four relaxed thresholds nobody adds up:
- **Switching work down ~16%, 1.76 → 1.48 productive acts a game** (400 games), because an
+2 -2
View File
@@ -6,7 +6,7 @@ it into this workspace:
| File | What it is |
| --- | --- |
| `Deck cards2.xlsx` | The **complete card list** — every card, its count, where it may be placed, what it does |
| `Mainline Cards.pdf` | The **Mainline card types** — Plains, Curves, Hilly, Heavy Grade, Double Track, Uncontrolled Siding, Tunnel, Trestle, Yard, and both Division Points |
| `Mainline Cards.pdf` | The **Mainline card types** — Plains, Curves, Hilly, Heavy Grade, Double Track, Uncontrolled Siding, Tunnel, Trestle, Interchange (printed "Yard"), and both Division Points |
| `Trains3.pdf` | The **train cards** — all 22, with names, speed class, consist and individual operating rules |
| `tracks.png` | **Card art** for track and industry cards |
@@ -518,7 +518,7 @@ We modelled a Mainline card as **2 regions, uniform**. The design has **ten dist
| Uncontrolled Siding | 60 | trains may pass; separate "no pass" and "passing" starts |
| Tunnel | 30 | |
| Trestle | 60 | |
| Yard | 60 | **sort cars into any new order**; has a Yard Limit |
| Interchange | 60 | **sort cars into any new order**; has a Yard Limit. Printed "Yard" on the prototype card and renamed after play — it shared a word with the Division Yard, the Classification Yard, the Salvage Yard, the Yard Office and the Small Yard, and is none of them |
| East / West Division Point | — | the ends |
Each card shows **Start positions** — where a train enters depending on direction, train type, and
+656
View File
@@ -0,0 +1,656 @@
{
"seed": 493290760,
"history": [
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c138",
"placement": {
"row": 0,
"col": -1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c144",
"placement": {
"row": 0,
"col": 1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c11"
},
{
"type": "card.play",
"cardId": "c24"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c31"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c182",
"placement": {
"row": 0,
"col": 1
},
"variant": 1
},
{
"type": "card.play",
"cardId": "c159",
"placement": {
"row": 1,
"col": 1
},
"variant": 0
},
{
"type": "card.play",
"cardId": "c49",
"placement": {
"row": 1,
"col": 0
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c5"
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "caboose",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": false
},
{
"type": "mainline.clearance",
"allow": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.end"
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.board",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -1
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -1
},
"reverse": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 0
},
"reverse": false
},
{
"type": "switch.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 1,
"col": 0
},
"carType": "tank"
},
{
"type": "laborer.startLoad",
"at": {
"row": 1,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 0
},
{
"type": "card.play",
"cardId": "c194",
"placement": {
"row": 0,
"col": -2
},
"variant": 1
},
{
"type": "draw.end"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 1
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": 1,
"col": 0
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c154",
"placement": {
"row": 1,
"col": -2
},
"variant": 0
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c102",
"node": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "boxcar",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "tank",
"loaded": false
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
"carType": "caboose",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
"carType": "coach",
"loaded": true
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": -2
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 2
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": 0
},
"reverse": true
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": 2
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": -2
},
"reverse": true
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 3
},
{
"type": "switch.dropCars",
"trayId": "tray3",
"count": 1
},
{
"type": "switch.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.end"
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.board",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 0,
"col": -1
},
"reverse": false
},
{
"type": "switch.move",
"trayId": "tray3",
"to": {
"row": 1,
"col": -2
},
"reverse": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.4.3",
"version": "0.4.4",
"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
+29 -4
View File
@@ -27,6 +27,7 @@ import {
STAGES_PER_DAY,
STAGES_PER_SHIFT,
collectiveRevenueFloor,
houseRules,
lengthProfile,
officeProfile,
REGIONS_PER_MAINLINE_CARD,
@@ -422,6 +423,26 @@ function enterMainline(
);
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
tray.position = { at: 'mainline', index };
/**
* OUT OF THE DISTRICT, AND THE SPUR PORT GOES WITH IT.
*
* `facing` is a port on the card the train is standing on, and switching round a district leaves
* it holding a real compass port — 'n' or 's' off a curve. Nothing cleared it when the train
* departed, so it carried that port out onto a Division that runs east and west, and then into
* the next Office, whose card has no north or south edge at all.
*
* That is not cosmetic. `movesFor` explores from `facing` and from its opposite, and a card with
* neither port yields no destinations either way — so a train that had been shunted onto a spur
* arrived at the next Office **unable to make a single Move**. It also drew a ▲ on the Division
* map, where there is no north to point at.
*
* A train out here is running one way along an east-west railroad with its engine at one end, so
* this is what `facing` means on the Division; there is nothing else it could be. `railFacing`
* follows for the same reason — this IS the east-west sense, freshly known.
*/
tray.facing = tray.direction === 'west' ? 'w' : 'e';
tray.railFacing = tray.facing;
}
/**
@@ -931,17 +952,21 @@ function collide(
*
* A crew with no train number is a local switching move, not a run, so it earns nothing.
*
* PROVISIONAL — the previous version was worth about +5 Revenue a game against a mean of 2.7, and
* this one pays far less often. Flagged in `TODO.md`.
* NOW A DIAL, AND OFF BY DEFAULT. At 1 it was worth ~5.4 Revenue against a bot mean of 7.0 — the
* railroad was making most of its money from the one thing no one has to work, and the freight and
* passenger economies it exists to reward could not be read through it. `trainPerTransit` sets the
* rate per player, and 0 (the default) means no event at all rather than a run of "+0" entries.
*/
function awardCompletedRun(s: GameState, tray: CrewTray, events: GameEvent[]): void {
if (tray.trainNumber === null) return;
const rate = houseRules(s.config).revenue.trainPerTransit;
if (rate <= 0) return;
for (const p of s.players) {
p.revenue += 1;
p.revenue += rate;
events.push({
type: 'revenueChanged',
player: p.index,
delta: 1,
delta: rate,
total: p.revenue,
reason: 'a train completed its run',
});
+65 -15
View File
@@ -25,6 +25,7 @@ import {
industryProfile,
mainlineModifierRule,
mainlineProfile,
houseRules,
modifierProfile,
nextOfficeTier,
officeProfile,
@@ -56,6 +57,7 @@ import {
canDropCarsAt,
canPlaceAt,
carriesThroughTrack,
exitsFrom,
exploreMoves,
facilityVariants,
opposite,
@@ -122,6 +124,20 @@ function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupanc
};
}
/**
* The port a train that came in through `entry` would carry on out by — read off the CARD.
*
* On a straight that is `opposite(entry)`, which is what this used to assume everywhere. On a curve
* it is the other end of the arc, and the two are never the same: a curve joins ADJACENT edges.
*
* A card reached by a Move has exactly one exit from the port it was entered by — the only card with
* three is a turnout, and a train may not finish a Move on one (§A.1). The fallback is for a caller
* holding a card the walk never validated, and matches the old behaviour rather than throwing.
*/
function farPort(card: TrackCard | undefined, entry: Port): Port {
return (card ? exitsFrom(card, entry)[0] : undefined) ?? opposite(entry);
}
/** A tray's facing, expressed as the port it would leave by going forward. */
function facingPort(s: GameState, trayId: TrayId): Port {
const tray = s.trays.get(trayId);
@@ -1183,19 +1199,29 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
to: i.to,
movesRemaining: turnOf(s, player).movesRemaining - 1,
/**
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND.
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
*
* `facing` is which way the ENGINE points, and this set it to the direction of travel on
* every move — so one reverse move silently spun the train about. Everything then read
* "forward" again, and a run-around became pointless: you could change ends for free by
* backing up twice.
* `facing` is which way the ENGINE points, and this once set it to the direction of travel
* on every move — so one reverse move silently spun the train about, and a run-around
* became pointless: you could change ends for free by backing up twice.
*
* Running forward the engine leads, so it points the way the train went: `opposite(entry)`.
* Backing up it trails, still pointing the way it came, which is the port it arrived
* through. Both hold around a curve, where the compass heading changes but the engine's
* relationship to its train does not.
* Backing up, the engine TRAILS, still pointing the way it came — out through the port the
* train arrived by. That holds whatever the track does underneath, so it is `dest.entry`
* and nothing else.
*
* Running forward, the engine LEADS, so it points out through the card's far end. That was
* written `opposite(entry)`, which is the far end of a straight and of nothing else: a
* curve is an arc between two ADJACENT edges, so entering a north-west curve through its
* west port leaves the engine facing NORTH, not east. The wrong port was not merely
* cosmetic — `movesFor` explores from `facing`, and a port the card does not have yields
* no destinations at all, so a crew that rounded a curve could only back out the way it
* came. Reported as a consist drawn mirrored, which is the other half of the same bug: the
* east-west sense the board draws is carried from `facing` (`railFacingOf`).
*
* `farPort` asks the CARD. A destination is never a turnout — a train may not finish a
* Move on one (§A.1) — so there is exactly one way out of it.
*/
facing: i.reverse ? dest.entry : opposite(dest.entry),
facing: i.reverse ? dest.entry : farPort(areaOf(s, player).grid.get(coordKey(i.to)), dest.entry),
},
];
if (dest.couples.length > 0) {
@@ -1419,16 +1445,21 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
},
];
/**
* A COACH PAYS AT BOTH ENDS OF ITS JOURNEY — once boarded, once detrained — and each end pays
* `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':
return [
{ type: 'passengersBoarded', player, at: i.at },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'boarding' },
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
];
case 'porter.detrain':
return [
{ type: 'passengersDetrained', player, at: i.at },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'detraining' },
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'),
];
case 'laborer.startLoad': {
@@ -1442,18 +1473,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
const load = workTrack(f)[i.box]!;
const next = load.dir === 'out' ? i.box + 1 : i.box - 1;
// Like a coach, a load pays at both ends — made up outbound and broken inbound — and each end
// pays `freightPerLoad` (`content.ts`).
if (next >= workTrack(f).length) {
// Outbound complete: the load goes onto the spotted car (§9.3).
return [
{ type: 'loadCompleted', player, at: i.at, carType: load.type },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightLoad' },
...earns(s, player, houseRules(s.config).revenue.freightPerLoad, 'freightLoad'),
];
}
if (next < 0) {
// Inbound complete: the load reaches the red Unloading box (§9.3).
return [
{ type: 'unloadCompleted', player, at: i.at, carType: load.type },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightUnload' },
...earns(s, player, houseRules(s.config).revenue.freightPerLoad, 'freightUnload'),
];
}
return [{ type: 'loadAdvanced', player, at: i.at, fromBox: i.box, toBox: next }];
@@ -1481,6 +1514,18 @@ function revenueAfter(s: GameState, player: PlayerIndex, delta: number): number
return (s.players[player]?.revenue ?? 0) + delta;
}
/**
* A revenue award at this game's rate, or NO EVENT AT ALL when the rate is zero.
*
* Zero is a real setting — it is how you switch one economy off to read the others — and a stream of
* "+0 Revenue" entries in the history panel would be the loudest possible way to say nothing
* happened. The work still happens; it just does not pay.
*/
function earns(s: GameState, player: PlayerIndex, rate: number, reason: string): GameEvent[] {
if (rate <= 0) return [];
return [{ type: 'revenueChanged', player, delta: rate, total: revenueAfter(s, player, rate), reason }];
}
/** §7 — from the rolled slot, walk down the Timetable column, wrapping at the bottom. */
function findTimetableSlot(s: GameState, from: number): number | null {
for (let i = 0; i < s.timetable.length; i++) {
@@ -1509,7 +1554,12 @@ export function reduce(s: GameState, e: GameEvent): void {
// A tray moving stays in the district it was already in — the seat does not change.
const seat = tray.position.at === 'grid' ? tray.position.seat : 0;
tray.position = { at: 'grid', seat, coord: e.to };
if (e.facing) tray.facing = e.facing;
if (e.facing) {
tray.facing = e.facing;
// The east-west sense only exists on east-west track, so it is CARRIED across north-south
// track rather than recomputed there — see `railFacing` in state.ts.
if (e.facing === 'e' || e.facing === 'w') tray.railFacing = e.facing;
}
// Only the player sitting in this district can be switching this tray, so the Moves come off
// their turn. The event carries no player of its own.
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
+116 -5
View File
@@ -417,7 +417,7 @@ export function consistSize(c: ConsistSpec): number {
export type MainlineKind =
| 'plains' | 'curves' | 'hilly' | 'heavyGrade' | 'doubleTrack'
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'yard';
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange';
/**
* Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade
@@ -437,7 +437,7 @@ export type MainlineProfile = {
speed: MainlineSpeed;
/** Double Track and Uncontrolled Siding: "Trains may pass". */
trainsMayPass: boolean;
/** Yard: "Sort cars in new order". */
/** Interchange: "Sort cars in new order". */
sortsCars: boolean;
/** Named entry points printed on the card; some are unlocked by modifier cards. */
entryPoints: readonly string[];
@@ -452,7 +452,14 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['noPass', 'passingTrains'] },
{ kind: 'tunnel', name: 'Tunnel', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'trestle', name: 'Trestle', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'yard', name: 'Yard', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
/**
* RENAMED FROM "Yard" after play. The card is unchanged — same 60, same "sort cars in new
* order", same entry points, same art — but "Yard" collided with the Division Yard, the
* Classification Yard, the Salvage Yard, the Yard Office and the Small Yard, none of which are
* this. Its own key is renamed with it, so the two never drift apart. Those OTHER yards are
* deliberately left alone: they are different things that merely shared a word.
*/
{ kind: 'interchange', name: 'Interchange', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
];
/**
@@ -756,13 +763,117 @@ export const STAGES_PER_SHIFT = 3;
export const HAND_LIMIT = 3;
/**
* The opening deal: 3 track cards and 3 others, from two separately shuffled piles (`setup.ts`).
* 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.
*
* Six against a limit of three on purpose — the first turn is spent choosing which district you can
* afford to build. PROVISIONAL, and flagged in `TODO.md` for review after play.
* afford to build.
*/
export const OPENING_TRACK = 3;
export const OPENING_OTHER = 3;
// ---------------------------------------------------------------------------
// House rules — the settings the New Game dialog offers
// ---------------------------------------------------------------------------
/**
* WHAT EACH PLAYER OPENS HOLDING.
*
* Three answers, all of which have been the rule at some point, and none of which is obviously
* right — so the game stops guessing and asks whoever deals.
*
* - `threeRandom` — the prototype rule. Three cards off one deck, at the hand limit, no guarantees.
* - `sixRandom` — six off one deck, so the first turn is a discard and the opening is a choice,
* without the track being handed to you.
* - `threeTrackThreeOther` — three of each from separately shuffled piles. Introduced because a
* run-around needs five specific pieces and the bot held a turnout and a matching-hand curve
* together on 0.2% of turns; measured five ways, that was a SUPPLY problem, not a bot weakness.
*/
export type StartingHand = 'threeRandom' | 'sixRandom' | 'threeTrackThreeOther';
/** How many cards come off which pile, per `StartingHand`. `any` is dealt from the single deck. */
export const OPENING_DEALS: Readonly<Record<StartingHand, { any: number; track: number; other: number }>> = {
threeRandom: { any: 3, track: 0, other: 0 },
sixRandom: { any: 6, track: 0, other: 0 },
threeTrackThreeOther: { any: 0, track: OPENING_TRACK, other: OPENING_OTHER },
};
/**
* 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.
*
* `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
* when it is broken inbound. That is what the rates have always done; these scale it.
*
* `trainPerTransit` pays every player, once, when a train runs off the end of the Division — it is
* the shared achievement, and every Office it crossed had to clear it. It defaults to 0 because at 1
* it was worth ~5.4 of a 7.0 mean: the railroad was earning most of its money from traffic no one
* had to work, which drowned out the freight and passenger economies this game is actually about.
*/
export type RevenueRules = {
passengerPerCoach: number;
freightPerLoad: number;
trainPerTransit: number;
};
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules };
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
export type HouseRuleOverrides = { startingHand?: StartingHand; revenue?: Partial<RevenueRules> };
/** 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;
export const REVENUE_MAX = 5;
export const DEFAULT_HOUSE_RULES: HouseRules = {
startingHand: 'threeRandom',
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0 },
};
/**
* THE RULES A SAVE THAT PREDATES THIS SETTING WAS PLAYED UNDER.
*
* A save is a seed and a list of intents, so it only replays under the ruleset that produced it —
* `TODO.md` records two published replays going dead unnoticed when the rules moved, one of them 42
* intents into 360. Every save written from now on carries its rules; the ones already written do
* not, and this is what they meant. Do not "tidy" it into the defaults above: that silently kills
* the three replays in `public/replays/`.
*/
export const LEGACY_HOUSE_RULES: HouseRules = {
startingHand: 'threeTrackThreeOther',
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
};
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRules {
const given = config.houseRules ?? {};
const rev = given.revenue ?? {};
const clamp = (n: number | undefined, fallback: number): number =>
typeof n === 'number' && Number.isFinite(n)
? Math.max(REVENUE_MIN, Math.min(REVENUE_MAX, Math.round(n)))
: fallback;
const d = DEFAULT_HOUSE_RULES;
return {
startingHand: given.startingHand ?? d.startingHand,
revenue: {
passengerPerCoach: clamp(rev.passengerPerCoach, d.revenue.passengerPerCoach),
freightPerLoad: clamp(rev.freightPerLoad, d.revenue.freightPerLoad),
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
},
};
}
/** What the dialog calls each option, in the order it offers them. */
export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string }[] = [
{ value: 'threeRandom', label: 'Three random cards' },
{ value: 'sixRandom', label: 'Six random cards' },
{ value: 'threeTrackThreeOther', label: 'Three random track and three random non-track cards' },
];
export const MAX_CONSIST = 4;
export const MOVES_PER_LOCAL_OPS = 6;
export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
+43 -37
View File
@@ -16,9 +16,9 @@ import {
MOVES_PER_LOCAL_OPS,
MODIFIER_PROFILES,
OFFICE_PROFILES,
OPENING_OTHER,
OPENING_TRACK,
OPENING_DEALS,
MAINLINE_PROFILES,
houseRules,
mainlineProfile,
TRACK_CARDS,
ROLLING_STOCK_SUPPLY,
@@ -287,53 +287,59 @@ export function createGame(opts: SetupOptions): GameState {
.sort((a, b) => divisionRolls[a]! - divisionRolls[b]! || b - a);
/**
* §4.6-4.7 — THE OPENING DEAL, dealt from two piles rather than one.
* §4.6-4.7 — THE OPENING DEAL, in whichever of the three shapes this game was dealt with.
*
* Track is shuffled SEPARATELY from everything else and each player is dealt **3 track cards and 3
* other cards**; the track left over is then shuffled back in and the game runs off one deck as
* before. The player opens holding six against a limit of three, so the first turn is spent
* `threeRandom` and `sixRandom` deal off ONE shuffled deck; `threeTrackThreeOther` shuffles track
* separately and deals three of each, then shuffles the remainder back together so the game runs
* off one deck from the first draw onward either way. `content.ts` says what each option is for.
*
* A player dealt six holds six against a limit of three, on purpose: the first turn is spent
* choosing what to keep — draw as usual, then play or discard down to three (§6.2, enforced on
* `draw.end`, which needs no special case for this).
* `draw.end`, which needs no special case for this). A player dealt three is already at the limit.
*
* WHY. A run-around needs five specific pieces in a usable order, and the bot held a turnout and a
* matching-hand curve together on **0.2% of turns** — about once every eight games. Measured five
* ways, that is a SUPPLY problem and not a bot weakness: 91 run-arounds per 100 games when track
* was a private 26-piece supply the player chose from, 29 per 100 once it was drawn, 4 per 60 with
* track in the deck. Dealing the opening district as track restores something close to the
* prototype's private supply without reintroducing an unbounded one, and makes the opening a
* decision rather than a wait.
*
* PROVISIONAL — flagged for review once it has been played. See `TODO.md`.
* §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table and
* is therefore SEAT order, not player order. That is the same in all three shapes.
*/
const rules = houseRules(config);
const deal = OPENING_DEALS[rules.startingHand];
const deck = buildDeck(config.mode);
const cards = new Map<CardId, Card>();
for (const c of deck) cards.set(c.id, c);
const isTrack = (id: CardId): boolean => cards.get(id)?.kind.kind === 'track';
const ids = deck.map((c) => c.id);
// Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed.
const trackPile = rng.shuffle(ids.filter(isTrack));
const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id)));
const hands = new Map<PlayerIndex, CardId[]>();
let trackCursor = 0;
let otherCursor = 0;
// §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table
// and is therefore seat order, not player order.
const superSeat = seating.indexOf(superintendent);
for (let i = 0; i < playerCount; i++) {
const p = seating[(superSeat + i) % playerCount]!;
hands.set(p, [
...trackPile.slice(trackCursor, trackCursor + OPENING_TRACK),
...otherPile.slice(otherCursor, otherCursor + OPENING_OTHER),
]);
trackCursor += OPENING_TRACK;
otherCursor += OPENING_OTHER;
}
const hands = new Map<PlayerIndex, CardId[]>();
const dealtTo = (i: number): PlayerIndex => seating[(superSeat + i) % playerCount]!;
// The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal
// device only, so track competes for the draw exactly as before from the first draw onward.
const shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]);
let shuffled: CardId[];
if (deal.any > 0) {
// One deck, one shuffle, the top cards off it — the prototype's own deal.
const single = rng.shuffle(ids);
let cut = 0;
for (let i = 0; i < playerCount; i++) {
hands.set(dealtTo(i), single.slice(cut, cut + deal.any));
cut += deal.any;
}
shuffled = single.slice(cut);
} else {
// Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed.
const trackPile = rng.shuffle(ids.filter(isTrack));
const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id)));
let trackCursor = 0;
let otherCursor = 0;
for (let i = 0; i < playerCount; i++) {
hands.set(dealtTo(i), [
...trackPile.slice(trackCursor, trackCursor + deal.track),
...otherPile.slice(otherCursor, otherCursor + deal.other),
]);
trackCursor += deal.track;
otherCursor += deal.other;
}
// The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal
// device only, so track competes for the draw exactly as before from the first draw onward.
shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]);
}
// §4.6-4.7 — three cards turned face up beside the deck. Each is the bottom of a Department pile
// that grows as players discard onto it. Taken after the recombination, so a Department slot can
+46
View File
@@ -13,6 +13,7 @@ import type {
MainlineKind,
FreightKind,
GameLength,
HouseRuleOverrides,
ModifierKind,
OfficeTier,
TrackGeometry,
@@ -282,6 +283,28 @@ export type CrewTray = {
* without one falls back to `direction`.
*/
facing?: 'n' | 's' | 'e' | 'w';
/**
* WHICH WAY THE ENGINE POINTS IN RAILROAD TERMS — east or west, and never anything else.
*
* `facing` is a PORT, because movement needs one: a crew on a north-south spur must be able to
* leave by 'n' or 's'. But a Division runs east and west, and a player reads a train the way a
* railroader does — "the engine is on the west end" — so a ▲ on a crew that had turned onto a
* spur read as a train that had somehow stood itself on end. Worse, nothing resets `facing` when
* a train leaves the district, so a crew that shunted onto a north-south spur and then departed
* carried its 'n' out onto the Division map and drew ▲ there too.
*
* So this is the DISPLAY facing, and it is a separate field because it cannot be derived: on
* north-south track the east-west sense is not in the current port, it is in the last one. It
* holds its value across north-south track and updates whenever `facing` becomes 'e' or 'w' —
* which is exactly the railroad's own convention, where compass north on a branch is still
* timetable east. A train that runs forward through 180° of curves genuinely does come out
* pointing the other way, and this follows it; backing up does not change it, because a train
* that backs up has not turned around.
*
* NOT `direction`: that is the timetable direction of the RUN and does not move when a run-around
* puts the engine on the other end, which is the one thing the arrow exists to show.
*/
railFacing?: 'e' | 'w';
position: NodeRef;
movesUsed: number;
/**
@@ -468,6 +491,14 @@ export type GameConfig = {
employeeRotation: boolean;
emergencyToolbox: boolean;
};
/**
* The opening deal and the three revenue rates, chosen when the game is dealt (`content.ts`).
*
* Optional and PARTIAL on purpose. Every caller that does not care about them — and most of the
* engine tests do not — gets `DEFAULT_HOUSE_RULES` through `houseRules()`, which is the one place
* a default is written down. A caller that cares names only the dials it is setting.
*/
houseRules?: HouseRuleOverrides;
};
export type OutcomeReason =
@@ -524,6 +555,21 @@ export function freshTurns(players: number, moves: number): Map<PlayerIndex, Tur
return turns;
}
/**
* WHICH WAY TO DRAW THE ENGINE — east or west, for every train, everywhere.
*
* The single place the display facing is decided, so the Division map, the Office cards and the
* tooltips can never disagree about which end of a train the engine is on. Three sources, in the
* order they can be trusted: the carried east-west sense; the current port, when it happens to be
* an east-west one (a tray placed straight onto the board has no history yet); and failing both,
* the direction of the run.
*/
export function railFacingOf(tray: Pick<CrewTray, 'railFacing' | 'facing' | 'direction'>): 'e' | 'w' {
if (tray.railFacing) return tray.railFacing;
if (tray.facing === 'e' || tray.facing === 'w') return tray.facing;
return tray.direction === 'west' ? 'w' : 'e';
}
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
const t = s.turns.get(player);
if (!t) throw new Error(`no turn state for player ${player}`);
+12 -7
View File
@@ -77,7 +77,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
/** Nose first, no engine — drawn as blocks, loaded solid and empty hollow. */
cars?: string[];
engineAt?: number;
facing?: string;
/** East or west, always — a Division runs east and west and so does its rolling stock. */
facing?: 'e' | 'w';
region?: number;
direction?: string;
stagesLeft?: number;
@@ -291,7 +292,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
*/
c.trains.forEach((t, k) => {
const cars = t.cars ?? [];
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
@@ -704,7 +705,7 @@ export function officeSvg(
kind: /^loaded/.test(c) || /caboose/.test(c) ? 'ld' : 'mt',
what: c,
}));
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng', what: 'the engine' });
/**
* WEST ON THE LEFT, AND THE NOSE POINTING THE WAY THE ENGINE FACES.
@@ -715,15 +716,19 @@ export function officeSvg(
* drawn engine-first at the WEST end, which reads as an engine shoving four cars ahead of it.
*
* The board is a map, so the drawing has to obey the map: reverse the seating order for an
* east-facing train and its nose lands at the east end, where it is. A crew on a north-south
* spur has no left or right to be right about, so it keeps nose-left and its \u25b2/\u25bc says the rest.
* east-facing train and its nose lands at the east end, where it is.
*
* A crew on a north-south spur is drawn east-west like every other train, because `facing` is
* now always east or west (`railFacingOf`). It used to keep its compass port and draw \u25b2 or \u25bc
* with the consist pinned nose-left, which meant the one thing the picture is for \u2014 which end
* the engine is on \u2014 flipped its convention the moment a crew turned a corner. Playtested and
* reported as more confusing than a strip drawn the same way every time.
*/
const laid = t.facing === 'e' ? [...items].reverse() : items;
const cw = 17;
const tw = Math.min(W - 8, laid.length * cw + 30);
const tx = W / 2 - tw / 2;
const facingWord =
t.facing === 'e' ? 'east' : t.facing === 'w' ? 'west' : t.facing === 'n' ? 'north' : 'south';
const facingWord = t.facing === 'e' ? 'east' : 'west';
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
out += `<g class="bs-crew" data-tip="${esc(
`${t.label} — engine pointing ${facingWord}, carrying ${consistWords}` + (t.what ? `\n\n${t.what}` : ''),
+30 -7
View File
@@ -21,7 +21,7 @@
* node src/sim/save-replay.ts 400 --top 3 trainCapSlack=0
*/
import { writeFileSync } from 'node:fs';
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -131,13 +131,33 @@ if (isMain) {
` best ${revenues.slice(0, 5).join(', ')} · median ${revenues[Math.floor(revenues.length / 2)]}`,
);
let written = 0;
for (const p of best) {
const verified = best.filter((p) => {
const check = verifyReplays(p);
if (!check.ok) {
console.warn(` REFUSED seed ${p.seed} — ${check.why}`);
continue;
if (!check.ok) console.warn(` REFUSED seed ${p.seed} — ${check.why}`);
return check.ok;
});
/**
* THE PUBLISHED SET IS REPLACED, NOT ADDED TO.
*
* Every file here is named for its seed, so re-recording used to leave the previous set sitting
* beside the new one — and the previous set is precisely the one whose rules have just moved. The
* point of re-recording is that those files are dead; keeping them means the site serves dead
* replays and `harness.test.ts` fails on them forever, which is how the last two went unnoticed.
*
* Only ever run after at least one replacement verifies, so a run that produces nothing publishable
* leaves what is already published alone.
*/
if (verified.length > 0) {
for (const old of readdirSync(dest).filter((f) => f.endsWith('.json') && f !== 'manifest.json')) {
if (verified.some((p) => `seed-${p.seed}.json` === old)) continue;
unlinkSync(join(dest, old));
console.log(` retired ${old} — recorded under rules that have since moved`);
}
}
let written = 0;
for (const p of verified) {
const file = join(dest, `seed-${p.seed}.json`);
writeFileSync(
file,
@@ -148,13 +168,16 @@ if (isMain) {
// there is room — a title carrying seven tweak names is a title nobody reads.
title: `${p.revenue} Revenue · seed ${p.seed}`,
note: `${p.note} · played by ${policy.name}`,
// The house rules it was DEALT under, without which the seed does not name this game and
// the file replays as something else — see `Save.rules` in `web/game.ts`.
...(p.save.rules ? { rules: p.save.rules } : {}),
history: p.save.history,
},
null,
1,
),
);
console.log(` wrote ${file} (${p.note}, ${check.why})`);
console.log(` wrote ${file} (${p.note})`);
written += 1;
}
console.log(`${written} replay(s) saved — run \`npm run build:web\` to publish them`);
+22 -10
View File
@@ -38,11 +38,12 @@ import {
lengthProfile,
officeProfile,
trainProfile,
houseRules,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import { playerAtSeat, seatOf, turnOf } from '../engine/state.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import { playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
@@ -76,13 +77,14 @@ export type CellView = {
* not plan a move at all: "drop 1 car" tells you nothing when you cannot see what is on the back.
*
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
* `facing` is the port it points at on this card.
* `facing` is which way the engine points — EAST OR WEST, never north or south, whatever the
* track under it runs. See `railFacingOf` in state.ts for why, and why the type says so.
*/
train: {
label: string;
cars: string[];
engineAt: number;
facing: string;
facing: 'e' | 'w';
/**
* WHAT THIS PARTICULAR TRAIN'S CARD SAYS.
*
@@ -188,13 +190,14 @@ export type TrainChip = {
* THE SAME TRAIN THE OFFICE CARD DRAWS, so the Division map can draw it the same way.
*
* `cars` is nose first and carries no engine; `engineAt` is where the engine sits among them and
* `facing` is the port it points at. The Division chip used to be a name and a number — and the
* number was Stages left to cross, which reads as redundant beside the position already drawn on
* the card. A train is worth drawing: what it is carrying, loaded or empty, and which end leads.
* `facing` is which way the engine points, east or west. The Division chip used to be a name and a
* number — and the number was Stages left to cross, which reads as redundant beside the position
* already drawn on the card. A train is worth drawing: what it is carrying, loaded or empty, and
* which end leads.
*/
cars: string[];
engineAt: number;
facing: string;
facing: 'e' | 'w';
/**
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
@@ -287,6 +290,14 @@ export type Frame = {
*/
/** Which of §6's three exclusive options the VIEWER has taken this Stage, if any. */
option: 'switch' | 'draw' | 'freightAgent' | null;
/**
* The settings this game was dealt under — the opening hand, and what the three economies pay.
*
* On the Frame rather than read off the config, for the same reason as everything else here: a
* remote client holds no `GameState`, and "what does a load pay in this game?" is a question it
* must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults.
*/
houseRules: HouseRules;
status: GameState['status'];
outcome: GameState['outcome'];
/**
@@ -513,7 +524,7 @@ function trainOnCard(s: GameState, key: string): CellView['train'] {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
cars: t.consist.map(carLabel),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
facing: railFacingOf(t),
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
};
void id;
@@ -1044,6 +1055,7 @@ export function snapshot(
decision,
wasted,
option: turnOf(s, viewer).option,
houseRules: houseRules(s.config),
status: s.status,
outcome: s.outcome,
players: s.players.map((p) => ({
@@ -1579,7 +1591,7 @@ function trainChip(s: GameState, id: string): TrainChip {
consist: seated,
cars,
engineAt: at,
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
facing: railFacingOf(t),
};
}
+44 -6
View File
@@ -42,8 +42,15 @@ import {
trainName,
variantLabel,
} from '../sim/view.ts';
import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import {
DEFAULT_HOUSE_RULES,
HAND_LIMIT,
LEGACY_HOUSE_RULES,
houseRules,
mainlineProfile,
trainProfile,
} from '../engine/content.ts';
import type { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
import { areaOf, trainNeedingCars } from '../engine/apply.ts';
@@ -59,8 +66,16 @@ export const SOLO_CONFIG: GameConfig = {
employeeRotation: false,
emergencyToolbox: false,
},
// Spelt out rather than left to fall through, so a save written by the page always names the rules
// it was played under — see `Save.rules`.
houseRules: DEFAULT_HOUSE_RULES,
};
/** The same config with the New Game dialog's answers in it. */
export function configWith(rules: HouseRuleOverrides): GameConfig {
return { ...SOLO_CONFIG, houseRules: houseRules({ houseRules: rules }) };
}
/** A group of legal actions of one kind, ready to put on screen. */
export type ActionGroup = {
kind: string;
@@ -708,10 +723,31 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
// Saving — seed plus intents, replayed
// ---------------------------------------------------------------------------
export type Save = { seed: number; history: Intent[] };
/**
* A save is a seed, the house rules it was dealt under, and the intents submitted.
*
* `rules` is optional because saves written before the New Game dialog existed do not have it, and
* those replay under `LEGACY_HOUSE_RULES` — see `configFor`. Everything written from now on carries
* its rules, so a save can never again be silently re-dealt by a change of default.
*/
export type Save = { seed: number; history: Intent[]; rules?: HouseRuleOverrides };
export function toSave(game: Game): Save {
return { seed: game.seed, history: game.history };
const rules = game.state.config.houseRules;
return rules ? { seed: game.seed, history: game.history, rules } : { seed: game.seed, history: game.history };
}
/**
* THE CONFIG A SAVE MUST BE REPLAYED UNDER, which is not necessarily today's default.
*
* A save is a seed and a list of intents: replay it under different rules and it is a different
* game, and the symptom is not an error but a replay that quietly stops early. `TODO.md` records
* that happening twice unnoticed, once 42 intents into 360. So a save that names its rules gets
* exactly those, and a save that names none is from before the dialog and gets the rules that were
* in force then — never the current defaults.
*/
function configFor(save: Save, config: GameConfig): GameConfig {
return { ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES };
}
/**
@@ -736,7 +772,9 @@ export function toSave(game: Game): Save {
*/
export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null {
if (game.history.length === 0) return null;
return fromSave({ seed: game.seed, history: game.history.slice(0, -1) }, config);
// `toSave` first, so the rules this game was dealt under come with it. Rebuilding the save by hand
// here dropped them, and undo re-dealt the game under the defaults instead of its own settings.
return fromSave({ ...toSave(game), history: game.history.slice(0, -1) }, config);
}
/**
@@ -747,7 +785,7 @@ export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null
* stops the replay rather than being forced — better a short game than a corrupt one.
*/
export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
const game = newGame(save.seed, config);
const game = newGame(save.seed, configFor(save, config));
for (const intent of save.history) {
const actor = currentActor(game);
if (actor === null) break;
+124 -22
View File
@@ -12,7 +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 } from '../engine/content.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 type { LocalSession } from './session.ts';
import { createLocalSession } from './session.ts';
@@ -112,14 +113,73 @@ function renderTurnChart(f: Frame): void {
$('turnchart').innerHTML = turnChartHtml(f, actorName);
}
/**
* The settings this game was dealt under, beside the seed, because the seed alone does not name it.
*
* Abbreviated to fit a header that must not wrap — `3 cards · 1/1/0` — with the whole of it in the
* tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one
* thing at 1 Revenue per transit and another at 5.
*/
function renderHouseRules(rules: HouseRules): void {
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = rules.revenue;
const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' };
const el = $('houserules');
el.textContent = `· ${short[rules.startingHand]} · ${pax}/${frt}/${trn}`;
const handWords = STARTING_HAND_LABELS.find((o) => o.value === rules.startingHand)?.label ?? '';
el.title =
`Opening hand: ${handWords.toLowerCase()}.\n` +
`Passenger revenue per coach: ${pax} (paid on boarding and again on detraining).\n` +
`Freight revenue per load: ${frt} (paid on loading and again on unloading).\n` +
`Train revenue per transit: ${trn} (paid to every player when a train leaves the Division).`;
}
/**
* THE HOUSE RULES TRAVEL IN THE URL, BESIDE THE SEED.
*
* A seed on its own no longer names a game: `?seed=430` dealt three random cards is a different
* railroad from `?seed=430` dealt three track and three other, and at 0 Revenue per transit it is a
* different economy again. The link has to carry all of it or "same link, same deal" stops being
* true — and the New Game dialog navigates by URL, so this is also how its answers reach `start()`.
*
* Absent parameters mean the DEFAULTS, not the legacy rules: a bare `?seed=430` is a new game at
* today's settings. It is a save with no rules in it that is old (`game.ts`, `configFor`).
*/
const RULE_PARAMS = { passenger: 'passengerPerCoach', freight: 'freightPerLoad', transit: 'trainPerTransit' } as const;
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 revenue: Partial<RevenueRules> = {};
for (const [param, key] of Object.entries(RULE_PARAMS)) {
const raw = params.get(param);
// `houseRules()` clamps and rounds, so anything hand-edited into the URL lands in range rather
// than dealing a game at 900 Revenue a coach.
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw);
}
if (Object.keys(revenue).length > 0) rules.revenue = revenue;
return rules;
}
function rulesToUrl(rules: HouseRules, seed: string): string {
const params = new URLSearchParams();
if (seed !== '') params.set('seed', seed);
params.set('hand', rules.startingHand);
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
return `?${params}`;
}
function start(): void {
const params = new URLSearchParams(location.search);
const requested = params.get('seed');
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
session = createLocalSession(seed);
session = createLocalSession(seed, rulesFromUrl(params));
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
// `configFor`. That is why the restore happens after the session is built rather than feeding it.
const saved = load();
if (saved && requested === null) session.restore(saved);
@@ -185,6 +245,7 @@ function render(): void {
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
obj.className = 'pace';
$('seed').textContent = String(session.seed());
renderHouseRules(f.houseRules);
// -- division
$('division').innerHTML = divisionSvg(f.division);
@@ -828,35 +889,76 @@ if (saveBtn) saveBtn.onclick = downloadSave;
* steps back one action at a time, but nothing brings back a game that has been dealt over, and the
* replay download is right beside it.
*
* `location.search = ''` rather than a direct re-render, so a `?seed=` in the URL goes too — leaving
* it would deal the same game again and look like the button had done nothing.
* Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would
* deal the same game again and look like the button had done nothing.
*/
const newBtn = document.getElementById('newgame');
if (newBtn) {
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
if (newBtn && dlg) {
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
/**
* ASK FOR ALL THREE, rather than documenting URL parameters in the title bar.
*
* It asked for the seed alone, through `prompt()`. The opening hand and the three revenue rates
* were constants in the source, so trying a variation meant an edit and a rebuild — and balance is
* the open question this game has (`TODO.md`). A dialog is what lets a playtest be a playtest.
*
* The dialog OPENS ON THE RULES IN PLAY rather than on the defaults: dealing a second game to
* compare against the first is the common case, and re-entering four settings each time is how a
* comparison silently stops comparing.
*/
newBtn.onclick = () => {
const f = session.view();
const day = f.day;
const started = f.status === 'active' && (day > 1 || f.stage > 1);
if (started && !confirm(`Forget this game (seed ${session.seed()}, Day ${day}) and deal a new one?`)) return;
/**
* ASK FOR THE SEED, rather than documenting a URL parameter in the title bar.
*
* The same deal can be replayed, shared or compared by seed, which is worth offering — it was
* offered as the note "add ?seed=1234 for a set deal", which spent width on the one line that
* must not wrap to explain a thing the button could simply ask. Blank means random.
*/
const asked = prompt('Seed for the new game — leave blank for a random one:', '');
if (asked === null) return; // cancelled
clearSave();
const wanted = asked.trim();
if (wanted === '') {
// No `?seed=`, so `start()` rolls one. Reload rather than re-render, to clear any seed in the URL.
if (location.search === '') location.reload();
else location.search = '';
return;
const current = session.view().houseRules;
field<HTMLInputElement>('ng-seed').value = '';
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-hand"]')) {
input.checked = input.value === current.startingHand;
}
location.search = `?seed=${encodeURIComponent(wanted)}`;
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);
dlg.showModal();
};
/**
* One handler for every way the dialog can close — the Deal button, the Cancel button, and Esc,
* which `<dialog>` answers with an empty `returnValue` and no submit event at all.
*
* The answers go into the URL and the page navigates, which is the same path `?seed=` already
* took: `start()` reads them back, so there is exactly one place that turns a URL into a game.
*/
dlg.addEventListener('close', () => {
if (dlg.returnValue !== 'deal') return;
const asked = field<HTMLInputElement>('ng-seed').value.trim();
// A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable
// both mean "surprise me", which is what leaving the box alone plainly asks for.
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
const picked = dlg.querySelector<HTMLInputElement>('input[name="ng-hand"]:checked')?.value;
const rules = houseRules({
houseRules: {
...(STARTING_HAND_LABELS.some((o) => o.value === picked) ? { startingHand: picked as StartingHand } : {}),
revenue: {
passengerPerCoach: Number(field<HTMLInputElement>('ng-passenger').value),
freightPerLoad: Number(field<HTMLInputElement>('ng-freight').value),
trainPerTransit: Number(field<HTMLInputElement>('ng-transit').value),
},
},
});
clearSave();
const next = rulesToUrl(rules, seed);
// Assigning the search string the page ALREADY has does nothing at all, which reads as a button
// that did not work — and it is the common case: deal a random seed, decide it was a bad deal,
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
if (next === location.search) location.reload();
else location.search = next;
});
}
const soundBtn = document.getElementById('sound');
+79 -1
View File
@@ -40,6 +40,32 @@ header button:disabled:hover{border-color:#2c333d}
.pace.good{background:rgba(40,140,60,.32)}
.pace.behind{background:rgba(190,120,40,.28)}
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
/* NEW GAME DIALOG. Native <dialog>, so Esc closes it and focus is trapped without any of that
being written here. Everything below is only colour and spacing: the browser's own white-on-white
default is unreadable against this page. */
dialog{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:9px;
padding:16px 18px;max-width:520px;width:calc(100% - 32px);font:inherit;max-height:86vh;overflow:auto}
dialog::backdrop{background:rgba(0,0,0,.62)}
dialog h3{margin-top:15px}
/* The rationale under each heading, not beside each control — the reasons are sentences, and a
sentence squeezed into a label column wraps into noise. */
.ng-note{color:var(--dim);font-size:11.5px;margin:0 0 8px;line-height:1.45}
dialog input[type=text],dialog input[type=number]{background:#0f1318;color:var(--fg);
border:1px solid var(--line);border-radius:5px;padding:4px 7px;font:inherit;font-size:13px}
dialog input[type=text]{width:100%}
dialog input[type=number]{width:64px;text-align:right}
dialog input:focus{outline:none;border-color:#4d6fa8}
/* Whole rows, so the click target is the sentence and not the 13px circle beside it. */
.ng-radio{display:flex;gap:9px;align-items:flex-start;padding:6px 7px;border-radius:5px;cursor:pointer}
.ng-radio:hover{background:#20262e}
.ng-radio input{margin-top:3px;flex:0 0 auto}
.ng-radio .dim{font-size:11.5px}
.ng-num{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:4px 7px}
.ng-buttons{display:flex;justify-content:flex-end;gap:8px;margin:16px 0 0;padding:0}
.ng-buttons button{background:#2a3038;color:inherit;border:1px solid var(--line);border-radius:5px;
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
.ng-buttons button:hover{border-color:#4d6fa8}
#ng-deal{background:#31527f;border-color:#4d6fa8}
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
@@ -176,10 +202,15 @@ ul.blocked li{padding:2px 0}
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
<span id="objective" class="pace">—</span>
<span class="dim">seed <span id="seed">—</span></span>
<!-- WHICH RULES THIS GAME IS BEING PLAYED UNDER. The settings are chosen when the game is dealt
and then never mentioned again, which makes a playtest note ("scored 4") unreadable a week
later: at 0 revenue per transit that is a different game from the same seed at 5. Short enough
to keep the header on one line; the tooltip spells it out. -->
<span class="dim" id="houserules" title="">—</span>
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Deal a fresh game. You will be asked for a seed — leave it blank for a random one. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
@@ -248,6 +279,53 @@ ul.blocked li{padding:2px 0}
</div>
</main>
<!-- ===================================================================
NEW GAME — the seed, the opening hand, and what the three economies pay.
It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game
you are about to play had no way in at all: the opening hand had been changed twice with no
way back to the earlier rule, and the revenue rates were constants in the source. Balance is
the open question in this game (`TODO.md`), and the way to settle it is to deal several games
at different settings — which needs a dialog, not a rebuild.
Every control has a default that is the recommended answer, so DEAL with nothing touched is a
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
==================================================================== -->
<dialog id="newgamedlg" aria-labelledby="ng-title">
<form method="dialog" id="newgameform">
<h2 class="big" id="ng-title">New game</h2>
<h3>Seed</h3>
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game can be shared, compared or replayed. Leave it blank for a random one.</p>
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
<h3>Starting hand</h3>
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three either way — deal six and the first turn is spent choosing which of them to keep.</p>
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeRandom" checked>
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
<label class="ng-radio"><input type="radio" name="ng-hand" value="sixRandom">
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
<label class="ng-radio"><input type="radio" name="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>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>
<input id="ng-passenger" type="number" min="0" max="5" step="1" value="1"></label>
<label class="ng-num"><span>Freight revenue per load</span>
<input id="ng-freight" type="number" min="0" max="5" step="1" value="1"></label>
<label class="ng-num"><span>Train revenue per transit</span>
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the Division — the one thing nobody has to work for. It defaults to 0 for that reason.</p>
<menu class="ng-buttons">
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
<button value="deal" id="ng-deal" type="submit">Deal</button>
</menu>
</form>
</dialog>
<script type="module" src="./web/main.js"></script>
</body>
</html>
+11 -3
View File
@@ -17,10 +17,12 @@
import type { Intent } from '../engine/intents.ts';
import type { Frame } from '../sim/view.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import type { PlayerIndex } from '../engine/state.ts';
import type { HouseRuleOverrides } from '../engine/content.ts';
import type { Game, Menu, Save } from './game.ts';
import {
actionMenu,
configWith,
currentActor,
fromSave,
handPlayable,
@@ -112,9 +114,15 @@ export type LocalSession = Session & {
*
* `Game` is mutated in place by `submit`, so the wrapper keeps a mutable reference rather than
* copying — `undo` and `restore` replace the whole game, which is why `game` is a getter.
*
* It takes `rules` rather than a whole `GameConfig` because DEALING is the only thing on the far
* side of this that the page is allowed to decide. A config carries the mode, the victory condition
* and the optional rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build
* meant importing the engine's own defaults into the page, which is the boundary `session.test.ts`
* guards. The seed and the house rules are the two things a player picks when they press New game.
*/
export function createLocalSession(seed: number, config?: GameConfig): LocalSession {
let game: Game = config ? newGame(seed, config) : newGame(seed);
export function createLocalSession(seed: number, rules?: HouseRuleOverrides): LocalSession {
let game: Game = rules ? newGame(seed, configWith(rules)) : newGame(seed);
const listeners = new Set<() => void>();
const changed = (): void => {
for (const fn of [...listeners]) fn();
+69 -1
View File
@@ -12,7 +12,8 @@ import { applyIntent, areaOf } from '../src/engine/apply.ts';
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts';
import { railFacingOf } from '../src/engine/state.ts';
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
mode: 'solitaire',
@@ -230,6 +231,14 @@ describe('Mainline Phase (§8)', () => {
it('moves trains one region per Stage (§8.2)', () => {
const s = game();
/**
* PIN THE TERRAIN. This used to rely on whatever card the seed happened to lay down, and the
* moment the RNG stream moved — a different opening deal draws a different number of cards
* before the Division is built — seed 1 dealt a 60 card instead of a 30 and the train crossed in
* one Stage. What is under test is that crossing takes the card's time, so the card has to be
* the test's own: Curves is a 30, which is two Stages for a fast train.
*/
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'curves';
scheduleTrain(s, 1, 2);
s.clock.phase = 'newTrain';
pump(s);
@@ -612,3 +621,62 @@ describe('X18 Circus Train — a point for standing still', () => {
assert.ok(!paidAgain(), 'the Circus Train collected a second time for the same set-up');
});
});
// ---------------------------------------------------------------------------
describe('a train on the Division points the way it is running', () => {
/**
* Nothing reset `facing` when a train left a district, so a crew that had been shunted onto a
* north-south spur carried a compass port — 'n' or 's' — out onto the Division with it.
*
* The Division is east-west, and so is every Office card, so that port exists nowhere the train is
* about to be. `movesFor` explores from `facing` and from its opposite, and a card with neither
* yields nothing at all: the train arrived at the next Office **unable to switch at all**. It also
* drew a ▲ on the Division map, where there is no north.
*
* A train running the Division has its engine at one end of an east-west railroad, so this is not
* a repair applied after the fact — it is the only thing `facing` can mean out there.
*/
const runningOnTheDivision = (spurFacing: 'n' | 's', direction: 'east' | 'west'): CrewTray => {
const s = game();
// Straight onto the Mainline card west of the first Office, as a departure would.
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[index]!;
assert.equal(node.kind, 'mainline');
const id = 'shunted';
s.trays.set(id, {
id, trainNumber: 2, trainIsExtra: false, engineAt: 0, consist: [],
direction,
// What a Move round a district leaves behind: a real port on the card it was standing on.
facing: spurFacing,
railFacing: 'w',
position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord },
movesUsed: 0,
});
const tray = s.trays.get(id)!;
// Depart it: the Mainline Phase's own path onto the card.
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
for (let i = 0; i < 3 && tray.position.at === 'grid'; i++) {
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
}
assert.notEqual(tray.position.at, 'grid', 'the train never left the district');
return tray;
};
it('drops the spur port the moment it reaches the Mainline', () => {
for (const spur of ['n', 's'] as const) {
for (const direction of ['east', 'west'] as const) {
const tray = runningOnTheDivision(spur, direction);
assert.equal(
tray.facing,
direction === 'east' ? 'e' : 'w',
`a ${direction}bound train left the district still facing ${spur}`,
);
assert.equal(railFacingOf(tray), direction === 'east' ? 'e' : 'w');
}
}
});
});
+175 -8
View File
@@ -6,13 +6,13 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, check, areaOf, facilityCarTypes, reduce } from '../src/engine/apply.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS, OPENING_OTHER, OPENING_TRACK } from '../src/engine/content.ts';
import { applyIntent, check, areaOf, facilityCarTypes, movesFor, reduce } from '../src/engine/apply.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey, turnOf } from '../src/engine/state.ts';
import { coordKey, railFacingOf, turnOf } from '../src/engine/state.ts';
import { cardDescription, snapshot } from '../src/sim/view.ts';
const config: GameConfig = {
@@ -115,13 +115,15 @@ describe('Local Operations: drawing (§6.2)', () => {
it('draws from the Home Office deck into the hand', () => {
const s = game();
const before = s.decks.homeOffice.length;
const dealt = s.decks.hands.get(0)!.length;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.ok(r.ok);
assert.equal(s.decks.homeOffice.length, before - 1);
// The opening deal is 3 track + 3 other, so a draw takes the hand to seven — and §6.2's limit
// then has to be played down to three before the turn can end.
assert.equal(s.decks.hands.get(0)!.length, OPENING_TRACK + OPENING_OTHER + 1);
// Measured against the deal rather than against a constant: the opening hand is a house rule
// now (three random, six random, or three-and-three), and none of the three changes what a draw
// does — it adds one card, and §6.2's limit then has to be played down to three.
assert.equal(s.decks.hands.get(0)!.length, dealt + 1);
});
it('takes the TOP card from a Department pile, never one buried under it', () => {
@@ -239,13 +241,15 @@ describe('Local Operations: drawing (§6.2)', () => {
it('will not end the phase over the hand limit', () => {
// §6.2 — "must reduce his hand to no more than three cards".
const s = game();
const dealt = s.decks.hands.get(0)!.length;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
assert.equal(s.decks.hands.get(0)!.length, OPENING_TRACK + OPENING_OTHER + 1);
assert.equal(s.decks.hands.get(0)!.length, dealt + 1);
assert.ok(dealt + 1 > HAND_LIMIT, 'a draw must put the player over the limit for this to test anything');
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
// And the way down is play or discard — there is no per-turn cap on either, which is what makes
// the six-card opening hand playable in one turn rather than a limit that cannot be met.
// a six-card opening hand playable in one turn rather than a limit that cannot be met.
const hand = () => s.decks.hands.get(0)!;
while (hand().length > HAND_LIMIT) {
const r = applyIntent(s, 0, { type: 'card.discard', cardId: hand()[0]!, toSlot: 0 });
@@ -1252,3 +1256,166 @@ describe('an Office upgrade keeps what Modifiers added (§9)', () => {
assert.ok(office().capacity.outbound > base.out, 'and the upgrade still raised the tier');
});
});
// ---------------------------------------------------------------------------
describe('the engine is drawn pointing east or west, whatever track it is standing on', () => {
/**
* Reported from play: a crew that turned onto a north-south spur drew ▲, and the same crew kept
* that ▲ after it departed onto the Division — where there is no north or south at all — because
* nothing resets `facing` when a train leaves a district. A Division runs east and west, and a
* player reads a train the way a railroader does: which END the engine is on.
*
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
*/
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
({ type: 'trayMoved', trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
it('carries the east-west sense across north-south track', () => {
const s = game();
const id = placeTray(s, at(0, 0));
const tray = s.trays.get(id)!;
reduce(s, moved(id, 'w'));
assert.equal(railFacingOf(tray), 'w');
// Onto a curve, and away up a spur. The port is north; the engine is still on the west end.
reduce(s, moved(id, 'n'));
assert.equal(tray.facing, 'n', 'the movement port must still be the real one');
assert.equal(railFacingOf(tray), 'w', 'but the drawing must not turn the train on end');
});
it('follows the engine around 180° of curves, because that really is a turn', () => {
// Forward through two curves — west port to north, north to east — and the engine genuinely
// does come out pointing the other way. Held values are held, not frozen.
const s = game();
const id = placeTray(s, at(0, 0));
const tray = s.trays.get(id)!;
for (const f of ['w', 'n', 'e'] as const) reduce(s, moved(id, f));
assert.equal(railFacingOf(tray), 'e');
});
it('falls back to the direction of the run for a tray that has not moved yet', () => {
// A tray placed straight onto the board has no history to carry, and `direction` is the only
// east-west fact about it.
const s = game();
const tray = s.trays.get(placeTray(s, at(0, 0)))!;
assert.equal(tray.railFacing, undefined);
assert.equal(railFacingOf(tray), 'e', 'placed running east');
tray.direction = 'west';
assert.equal(railFacingOf(tray), 'w');
});
it('never reports north or south to the board', () => {
// The view type says 'e' | 'w'; this is the runtime half of that promise, and it is the one a
// north-south spur used to break.
const s = game();
const id = placeTray(s, at(0, 0), [{ type: 'boxcar', loaded: false }] as never);
addCard(s, at(0, 0), straight());
reduce(s, moved(id, 's'));
const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0);
assert.ok(cell?.train, 'the train should be on the card');
assert.ok(cell.train.facing === 'e' || cell.train.facing === 'w', `got ${cell.train.facing}`);
});
});
// ---------------------------------------------------------------------------
describe('a train that rounds a curve points where the curve took it', () => {
/**
* REPORTED from play: "a train reversed into a siding and the display of the cars was reversed."
*
* `facing` is the port the engine would leave by, and a move recorded it as `opposite(entry)` —
* which is only the far end of a STRAIGHT. A curve is an arc between two ADJACENT edges: enter a
* north-west curve through its west port and the far end is north, not east. So a train that
* rounded a curve was left facing a port the card does not have.
*
* Two things went wrong with that. Movement: the next Move explores from `facing`, and a port the
* card lacks yields nothing, so the crew could only ever back out the way it came. Display: the
* east-west sense is carried from `facing` (`railFacingOf`), so a curve that turned the engine
* west could leave the board still drawing it east — the consist mirrored, which is what was seen.
*/
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
/**
* Runs a crew one Move and reports where the engine ended up pointing.
*
* The card it starts ON matters as much as the one it moves to: a crew facing north or south has
* to be standing on something with that port, and only a curve has one.
*/
const roundIt = (
from: GridCoord,
to: GridCoord,
origin: TrackCard,
dest: TrackCard,
start: { facing: 'n' | 's' | 'e' | 'w'; railFacing: 'e' | 'w' },
reverse = false,
): { facing?: string; drawn: string } => {
const s = game();
addCard(s, from, origin);
addCard(s, to, dest);
const id = placeTray(s, from, [{ type: 'boxcar', loaded: false }] as never);
const tray = s.trays.get(id)!;
tray.facing = start.facing;
tray.railFacing = start.railFacing;
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse });
assert.ok(r.ok, `the move was refused: ${r.ok ? '' : r.code}`);
return { facing: tray.facing, drawn: railFacingOf(tray) };
};
it('leaves by the curve’s own far end, not by the opposite of the way it came in', () => {
// East along the running track into a curve that turns north. The far end is NORTH.
const out = roundIt({ row: 0, col: 0 }, { row: 0, col: 1 }, straight(), curve('nw'), { facing: 'e', railFacing: 'e' });
assert.equal(out.facing, 'n', 'the engine was left facing a port the curve does not have');
});
it('does not strand the crew on the curve it just rounded', () => {
// The symptom the wrong port produces: `movesFor` explores from `facing`, and a card without
// that port yields nothing at all — so the only move left was backing out the way it came.
const s = game();
addCard(s, at(0, 0), straight());
addCard(s, at(0, 1), curve('nw')); // joins north and west
addCard(s, at(1, 1), curve('se')); // joins south and east — the track continues
addCard(s, at(1, 2), straight());
const id = placeTray(s, at(0, 0), []);
s.trays.get(id)!.facing = 'e';
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 1), reverse: false }).ok);
const { to } = movesFor(s, 0, id);
assert.ok(
to.some((c) => c.row === 1 && c.col === 1),
'the crew cannot carry on round the curve — it can only back out the way it came',
);
});
it('turns the DRAWN direction when the curve really does turn the engine round', () => {
// South down a spur into a curve that turns west. The engine genuinely now points west, and the
// board has to say so — this is the mirrored consist that was reported.
const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 's', railFacing: 'e' });
assert.equal(out.facing, 'w', 'the curve turned the engine west');
assert.equal(out.drawn, 'w', 'the board would still have drawn the consist facing east');
});
it('still points back the way it came when it BACKS round a curve', () => {
// Backing up does not turn a train around, whatever the track does underneath it: the engine
// trails, pointing out through the port the train came in by. That is geometry-independent and
// was already right — this is here so the fix above cannot quietly change it.
const out = roundIt({ row: 1, col: 1 }, { row: 0, col: 1 }, curve('se'), curve('nw'), { facing: 'n', railFacing: 'e' }, true);
assert.equal(out.facing, 'n', 'backing up turned the train around');
assert.equal(out.drawn, 'e', 'backing up changed which end the engine is on');
});
});
+42 -3
View File
@@ -332,7 +332,20 @@ describe('ABS Signals amend the collision rule', () => {
// ---------------------------------------------------------------------------
describe('one Revenue to EVERY player when a train completes its run', () => {
describe('train revenue per transit — paid to EVERY player when a train completes its run', () => {
/**
* `trainPerTransit` is a dial now, and it defaults to ZERO: at 1 it was worth ~5.4 Revenue against
* a bot mean of 7.0, drowning out the freight and passenger economies the game is about. These
* tests are about the rule, so they set the rate; the last one is about the default.
*/
const paying = (rate = 1, seed = 5): GameState =>
createGame({
id: 'g',
seed,
config: { ...config, houseRules: { revenue: { trainPerTransit: rate } } },
playerNames: ['p'],
});
/** A train sitting on the A/D track, made up and ready to highball. */
const readyToLeave = (s: GameState, direction: 'east' | 'west' = 'east'): string => {
const id = 'leaving';
@@ -364,7 +377,7 @@ describe('one Revenue to EVERY player when a train completes its run', () => {
});
it('pays every player once the train runs off the end of the Division', () => {
const s = game();
const s = paying();
const before = s.players.map((p) => p.revenue);
readyToLeave(s, 'east');
@@ -395,7 +408,7 @@ describe('one Revenue to EVERY player when a train completes its run', () => {
});
it('pays nothing for a local crew, which is switching rather than running', () => {
const s = game();
const s = paying();
const before = s.players[0]!.revenue;
const id = readyToLeave(s, 'east');
s.trays.get(id)!.trainNumber = null;
@@ -404,6 +417,32 @@ describe('one Revenue to EVERY player when a train completes its run', () => {
assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had completed a run');
});
it('pays at the rate the game was dealt with, and nothing at the default of zero', () => {
// Zero is not "one, suppressed": no revenue event is emitted at all, so the history does not
// fill with "+0 Revenue" for work that did not pay.
const run = (rate: number): { revenue: number; events: number } => {
const s = rate === 0 ? game() : paying(rate);
readyToLeave(s, 'east');
let events = 0;
let done = false;
for (let i = 0; i < 20 && !done; i++) {
s.movedThisPhase = new Set();
s.clock.phase = 'mainline';
const r = advance(s);
events += r.events.filter(
(e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run',
).length;
done = r.events.some((e) => e.type === 'trainCompleted');
}
assert.ok(done, `the train never left the Division at rate ${rate}`);
return { revenue: s.players[0]!.revenue, events };
};
assert.deepEqual(run(0), { revenue: 0, events: 0 }, 'the default paid for a transit');
assert.deepEqual(run(1), { revenue: 1, events: 1 });
assert.deepEqual(run(5), { revenue: 5, events: 1 }, 'the top of the range pays 5 in one event');
});
});
// ---------------------------------------------------------------------------
+6 -2
View File
@@ -119,10 +119,14 @@ describe('the intents are what reconstructs a game', () => {
);
});
it('carries no state in the save beyond the seed and the intents', () => {
it('carries no POSITION in the save — the seed, the rules dealt under, and the intents', () => {
// If anything else ever creeps into `Save`, the claim above weakens: the game would no longer be
// reconstructible from decisions alone, and persistence would have a schema to migrate.
//
// `rules` is the one addition, and it is not position: it is the other half of the seed. A seed
// only names a game together with the rules it was dealt under, which is why two published
// replays went dead when the rules moved (`TODO.md`) — the intents were fine, the deal was not.
const game = newGame(7);
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'seed']);
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']);
});
});
+6 -1
View File
@@ -275,8 +275,13 @@ describe('every published replay actually replays', () => {
const save = JSON.parse(readFileSync(join(dir, f), 'utf8')) as {
seed: number;
history: unknown[];
rules?: unknown;
};
const back = fromSave({ seed: save.seed, history: save.history as never });
// The WHOLE save, `rules` included. Rebuilding it from seed and history alone threw away the
// one field that says which ruleset the file was recorded under, so this replayed every
// published file under the pre-dialog defaults no matter what it said — a test that would
// pass a genuinely dead replay the moment the defaults and the file disagreed.
const back = fromSave(save as never);
assert.equal(
back.history.length,
save.history.length,
+9 -1
View File
@@ -552,8 +552,16 @@ describe('scoring lands on the right seat', () => {
*
* With one player this is unfalsifiable, because "everybody" is one person. This is the test
* that says so with three.
*
* The rate is a dial now and defaults to zero, so this names it: what is under test is WHO gets
* paid, not whether the default pays at all (`enhancements.test.ts` covers that).
*/
const s = game(3);
const s = createGame({
id: 'm',
seed: 4242,
config: { ...competitive, houseRules: { revenue: { trainPerTransit: 1 } } },
playerNames: ['a', 'b', 'c'],
});
pinTerrain(s);
const before = s.players.map((p) => p.revenue);
+48 -8
View File
@@ -28,6 +28,7 @@ import {
} from '../src/engine/content.ts';
import { createRng } from '../src/engine/rng.ts';
import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts';
import type { StartingHand } from '../src/engine/content.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { subdivisions } from '../src/engine/state.ts';
@@ -46,6 +47,15 @@ const solitaireConfig: GameConfig = {
const newSolitaireGame = (seed = 1234) =>
createGame({ id: 'g1', seed, config: solitaireConfig, playerNames: ['Jesse'] });
/** A game dealt under one named `StartingHand`, for the tests that are about the deal itself. */
const gameDealtWith = (startingHand: StartingHand, seed = 1234) =>
createGame({
id: 'g1',
seed,
config: { ...solitaireConfig, houseRules: { startingHand } },
playerNames: ['Jesse'],
});
// ---------------------------------------------------------------------------
describe('card catalogue (component 1)', () => {
@@ -380,26 +390,40 @@ describe('game setup (component 2)', () => {
assert.equal(area.adOccupancy.length, 0);
});
it('deals three track and three other cards, and starts three Department piles', () => {
// The opening deal comes from two separately shuffled piles, so the district you can build is
// dealt rather than waited for. Six against a hand limit of three is deliberate: the first turn
// is spent choosing which of them to keep.
it('deals three random cards by default, and starts three Department piles', () => {
// The prototype rule, and the New Game dialog's default: three off one deck, already at the hand
// limit, with no guarantee of anything. The other two shapes are below.
const g = newSolitaireGame();
assert.equal(g.decks.hands.get(0)!.length, 3);
assert.equal(g.decks.departments.length, 3);
assert.ok(g.decks.departments.every((pile) => pile.length === 1), 'each Department starts with one face-up card');
});
it('deals six random cards when that is the rule, over the hand limit on purpose', () => {
// Six against a hand limit of three is deliberate: the first turn is spent choosing which of
// them to keep. What it does NOT do is guarantee track, which is the difference from the split
// deal below.
const g = gameDealtWith('sixRandom');
assert.equal(g.decks.hands.get(0)!.length, 6);
});
it('deals three track and three other cards when that is the rule', () => {
// From two separately shuffled piles, so the district you can build is dealt rather than waited
// for — a run-around needs five specific pieces, and drawing for them took eight games.
const g = gameDealtWith('threeTrackThreeOther');
const hand = g.decks.hands.get(0)!;
assert.equal(hand.length, OPENING_TRACK + OPENING_OTHER);
const track = hand.filter((id) => g.cards.get(id)!.kind.kind === 'track');
assert.equal(track.length, OPENING_TRACK, 'the opening hand is not three track cards');
assert.equal(hand.length - track.length, OPENING_OTHER, 'the opening hand is not three other cards');
assert.equal(g.decks.departments.length, 3);
assert.ok(g.decks.departments.every((pile) => pile.length === 1), 'each Department starts with one face-up card');
});
it('shuffles the leftover track back into one deck for the rest of the game', () => {
// The split is an opening-deal device only. If the leftover track stayed out, every draw after
// the first turn would be drawn from a deck with no track in it at all.
const g = newSolitaireGame();
const g = gameDealtWith('threeTrackThreeOther');
const rest = [...g.decks.homeOffice, ...g.decks.departments.flat()];
const track = rest.filter((id) => g.cards.get(id)!.kind.kind === 'track');
assert.equal(
@@ -408,6 +432,22 @@ describe('game setup (component 2)', () => {
);
});
it('leaves every card accounted for whichever shape it was dealt in', () => {
// The single-deck path and the two-pile path deal from different piles into the same game; a
// card lost or duplicated by either would be a deck that quietly runs short mid-game.
for (const shape of ['threeRandom', 'sixRandom', 'threeTrackThreeOther'] as const) {
const g = gameDealtWith(shape);
const all = [
...g.decks.homeOffice,
...g.decks.departments.flat(),
...g.decks.salvageYard,
...[...g.decks.hands.values()].flat(),
];
assert.equal(all.length, SOLITAIRE_DECK_SIZE, `cards lost or duplicated dealing ${shape}`);
assert.equal(new Set(all).size, all.length, `duplicate card dealing ${shape}`);
}
});
it('accounts for every card exactly once', () => {
const g = newSolitaireGame();
const all = [
+186 -4
View File
@@ -11,6 +11,10 @@ import { turnOf } from '../src/engine/state.ts';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
// The REAL one. An earlier test in this file replaces `globalThis.URLSearchParams` with a two-line
// stub, and the dialog needs `set` as well as `get` — reading the global here would hand the page
// whichever stub happened to run last.
import { URLSearchParams as NodeURLSearchParams } from 'node:url';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.ts';
@@ -1324,8 +1328,23 @@ describe('the static build', () => {
// it, and a stub without one throws on load — which is a page that never starts, not a
// cosmetic gap.
const ownClasses = new Set<string>();
// The New Game dialog is a native <dialog>: the page listens for `close` on it at load, and
// opens it with `showModal`. Without these the page throws before it draws anything — which
// is a page that never starts, exactly what this stub exists to catch.
const listeners = new Map<string, ((e?: unknown) => void)[]>();
const node: Record<string, unknown> = {
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
title: '', returnValue: '', open: false,
addEventListener: (type: string, fn: (e?: unknown) => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal() {
(node as { open: boolean }).open = true;
},
close(value?: string) {
(node as { open: boolean; returnValue: string }).open = false;
if (value !== undefined) (node as { returnValue: string }).returnValue = value;
for (const fn of listeners.get('close') ?? []) fn();
},
classList: {
add: (c: string) => void ownClasses.add(c),
remove: (c: string) => void ownClasses.delete(c),
@@ -1740,10 +1759,11 @@ describe('the static build', () => {
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
// Raised 8 -> 10: the widest group is Switching, and the opening deal grew districts from 17.9
// to 20.3 cards, so a crew simply has more squares it can legally reach. That is the list getting
// longer for a good reason rather than the cross-products this test was written to kill.
assert.ok(worst <= 10, `the action list still reaches ${worst} buttons`);
// Raised 8 -> 10 -> 13. The widest group is Switching every time, which is a crew's reachable
// squares — the list getting longer for a good reason rather than the cross-products this test
// was written to kill. Re-measured over five seeds (430, 99, 1234, 880009, 202) in all three
// opening deals: 13 under `threeRandom` and `sixRandom`, 10 under `threeTrackThreeOther`.
assert.ok(worst <= 13, `the action list still reaches ${worst} buttons`);
});
it('makes up ONE train at a time, and names that train', () => {
@@ -2494,3 +2514,165 @@ describe('the tray is an engine plus its Rolling Stock', () => {
}
});
});
// ---------------------------------------------------------------------------
describe('the New Game dialog', () => {
/**
* DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go
* wrong here is wiring rather than logic: an id that does not match the HTML, a handler on the
* wrong element, or a navigation that assigns the search string the page already has and so does
* nothing at all. None of that is visible to a test of `rulesFromUrl` in isolation.
*
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
* settings, so what this really pins is that all four ride in the URL and come back out.
*/
const load = async (search: string) => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const els = new Map<string, Record<string, unknown>>();
/** Enough of an element for the page to start: the dialog's own API, and a settable `value`. */
const make = (id: string): Record<string, unknown> => {
const listeners = new Map<string, (() => void)[]>();
const radios = ['threeRandom', 'sixRandom', 'threeTrackThreeOther'].map((value) => ({
value,
checked: false,
}));
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,
addEventListener: (type: string, fn: () => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal: () => void ((node as { open: boolean }).open = true),
close: () => {
(node as { open: boolean }).open = false;
for (const fn of listeners.get('close') ?? []) fn();
},
// 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 : []),
querySelector: (sel: string) =>
sel === 'input[name="ng-hand"]:checked' ? (radios.find((r) => r.checked) ?? null) : null,
};
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
return node;
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make(id));
return els.get(id);
},
createElement: () => make('style'),
addEventListener: () => {},
body: { appendChild: () => {} },
head: { appendChild: () => {} },
};
const nav: { search: string; reloads: number } = { search, reloads: 0 };
g['location'] = {
get search() { return nav.search; },
set search(v: string) { nav.search = v; },
reload: () => void (nav.reloads += 1),
};
const store = new Map<string, string>();
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = NodeURLSearchParams;
g['confirm'] = () => true;
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`);
return { els, nav };
};
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
// Re-entering four settings for every comparison game is how a comparison silently stops
// comparing. The seed is the one field that clears: the same seed twice is not a second sample.
const { els } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
assert.equal(dlg['open'], true, 'the New game button did not open the dialog');
assert.equal(els.get('ng-seed')!['value'], '', 'the seed box kept the last game’s seed');
assert.equal(els.get('ng-passenger')!['value'], '2');
assert.equal(els.get('ng-freight')!['value'], '3');
assert.equal(els.get('ng-transit')!['value'], '4');
const checked = (dlg['radios'] as { value: string; checked: boolean }[]).filter((r) => r.checked);
assert.deepEqual(checked.map((r) => r.value), ['sixRandom'], 'the opening hand in play was not preselected');
});
it('puts the seed and all three settings into the URL when it deals', async () => {
const { els, nav } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
els.get('ng-seed')!['value'] = '99';
for (const r of dlg['radios'] as { value: string; checked: boolean }[]) r.checked = r.value === 'threeTrackThreeOther';
els.get('ng-passenger')!['value'] = '5';
els.get('ng-freight')!['value'] = '0';
els.get('ng-transit')!['value'] = '2';
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, '?seed=99&hand=threeTrackThreeOther&passenger=5&freight=0&transit=2');
});
it('deals nothing on cancel, and nothing on Esc', async () => {
// Esc closes a <dialog> with an empty returnValue and fires no submit at all, so "not deal" has
// to be the test rather than "cancel" — the two arrive identically.
for (const returnValue of ['cancel', '']) {
const { els, nav } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
dlg['returnValue'] = returnValue;
(dlg['close'] as () => void)();
assert.equal(nav.search, '?seed=430', `closing with "${returnValue}" navigated`);
assert.equal(nav.reloads, 0, `closing with "${returnValue}" reloaded`);
}
});
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 { els, nav } = await load(url);
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
dlg['returnValue'] = 'deal';
(dlg['close'] as () => void)();
assert.equal(nav.search, url, 'the URL should be unchanged — that is the whole case');
assert.equal(nav.reloads, 1, 'a re-deal at the same settings did nothing at all');
});
it('deals the game the URL describes, and says so in the header', async () => {
// The other half of the round trip: the dialog wrote those parameters, and this is the page
// reading them back. Without this the two halves can drift and each still pass its own test.
const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1');
assert.match(String(els.get('houserules')!['textContent']), /6 cards.*4\/2\/1/);
assert.match(String(els.get('houserules')!['title']), /six random cards/i);
});
it('ignores a seed the browser cannot parse rather than refusing to deal', async () => {
// Blank and unparseable both plainly mean "surprise me"; an error dialog over a typo in an
// optional box is not worth writing.
const { els, nav } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const dlg = els.get('newgamedlg')!;
els.get('ng-seed')!['value'] = 'not a number';
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');
});
});