v0.4.3 fixed load/unload bugs. empty cars must be staged prior to loading. rolling stock is now conserved.

This commit is contained in:
Jesse
2026-08-14 08:33:32 -04:00
parent 393a669418
commit d1314066bd
13 changed files with 2258 additions and 3822 deletions
+70
View File
@@ -21,6 +21,76 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## Unreleased
## 0.4.3 — 2026-08-14
### A load has to have somewhere to go
Jesse walked through how loading an industry is meant to work at the table, and the engine got four of
the five steps exactly right. It got the first one wrong, in both directions.
**You could stage cargo against no car.** §9.3 requires an empty car of the right type standing on the
industry's track before anything else happens — *"otherwise you are just dropping cargo onto the
tracks, pointless waste"*. The engine checked for that car only at the **last** step, when the load
came off WORK. So you could fetch cargo with the Freight Agent, walk it M→A→W across three Stages and
three Laborers, and only then find there was nowhere to put it. Worse than waste: from the moment the
load lands on M the industry track is **locked**, so no train can come in to spot the car you now need.
The only way out was a Freight Agent unjam — another whole turn, to undo a move the rules should never
have offered.
Now gated in three places, all counted rather than merely present:
- **`freightAgent.stockOutbound`** — no cargo is fetched unless a matching empty is spotted and not
already promised to a load already in the box or on the sign.
- **`laborer.startLoad`** — the same check again, because it is not redundant: coupling is mandatory,
so a crew running over that industry track *must* pick up the spotted empty, and the cargo you
staged an hour ago can be left with nothing.
- **`laborer.beginUnload`** — the mirror. The red Inbound box is where an inbound load lands, and it
was checked only at the final step too. Counted, because only the W box must be free to begin: once
a load moves W→A a second can start behind it, and every red box in play holds exactly one car.
**Strict type matching throughout** — a hopper load cannot be swapped onto a tank. And `startLoad` no
longer always takes `outboundBox[0]`: it starts the first load that has a car to land on, so a Power
Plant holding a hopper load and a tank load against one spotted tank works the tank instead of
reporting the whole facility blocked.
**Passengers are deliberately exempt and it is written down.** People can wait on a platform for a
train that has not arrived, so a Passenger Facility still stocks freely. Freight cannot: a crate on the
ground is not a shipment.
**Measured before building it, which is why it was worth building.** Requiring the car removes **78% of
the `stockOutbound` moves the menu offered** (704 → 157 across 200 games) — but **99.3% of the moves the
bot actually took survive** (138 of 139). The rule deletes offers a competent player would never have
used. Bot unchanged at 7.3 mean. On the unload side it removes 2.2% of offers, and the jam it prevents
was caught happening three times in 200 games.
### The Blocked panel says what to do, and in what order
The refusal codes never reach a player, because the menu simply stops offering the action — so with
78% of stocking moves gone the panel was the only thing that could explain it. It said *"green box
empty — nothing to load (needs a Freight Agent action)"*, which is step two told to someone who has not
done step one. It now says to bring a car in first, names the commodity, and distinguishes "MEN is
occupied" from "there is nothing here to load onto", which send you to fix quite different things.
### Rolling stock is exactly conserved
`TODO.md` recorded the inbound path minting ~1.3 cars a game and blocked tuning `ROLLING_STOCK_SUPPLY`
until it was settled. Re-audited: **exactly conserved, 100 games out of 100, range 0..0.** The
asymmetry had already been closed from the other end when `unloadBegan` and `passengersDetrained`
started taking their replacement empty out of the Division Yard instead of conjuring it — a load is a
car that moved, not a car that appeared, which is exactly the tabletop procedure Jesse described.
Both conjuring fallbacks now **throw** instead of minting, so the leak cannot come back quietly;
neither fired across the suite or the audit. `ROLLING_STOCK_SUPPLY` is unblocked for the rebalance.
(The original audit's arithmetic was off in the same way mine was on the first attempt: cars set out on
a card live in `card.standing` and are easy to leave out of the count, which makes a conserved game
look like a leaking one.)
### Replays
Two of five died on the rule change — they replayed 138/336 and 81/347, which on screen looks exactly
like a game that ended early. Re-recorded; three published, all verified to their last intent.
## 0.4.2 — 2026-08-13
### A completed run pays the whole table
+1 -1
View File
@@ -10,7 +10,7 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed.
## Status
**v0.4.2 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
**v0.4.3 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs.
- **Rules** — specified, with **three open questions** left. Thirteen gaps in the original prototype
+15 -18
View File
@@ -122,24 +122,21 @@ Ordered within each section by how much it is currently costing us.
present it that way — the supply has to change, not the player.** Options: give track its own
hand or yard the way the prototype did, raise the hand limit for track specifically, or print a
siding as a single card. Nothing else reaches it.
- [ ] **CLEARING AN INBOUND BOX MINTS A CAR — measured at 1.29 a game against a supply of 80.**
Answered, and it is duplication after all. The two directions are not symmetrical:
- **Outbound is paid for.** `stockToOutbound` SPLICES a loaded car out of the Division Yard to
become the load, and `loadCompleted` swaps the emptied car into the Classification Yard as
the loaded one takes its place on the industry track. Objects in, objects out.
- **Inbound is not.** `unloadBegan` (apply.ts) turns one loaded car into an empty car on the
track **plus** a load on MEN|AT|WORK, and `passengersDetrained` does the same to a coach —
one loaded coach becomes an empty coach in the train plus an object in the red box. Then
`inboundCleared` pushes that object into the **Classification Yard as a car**. The cargo
becomes rolling stock, while the car it came out of is already back in service.
Measured over 200 games: cars at the end minus 80, plus collision losses, equals the
`inboundCleared` count in 71/200 games exactly and 258 against 279 in total — the rest is
loads still in flight at the final whistle. So the supply inflates by about 1.3 cars a game.
That is the number `ROLLING_STOCK_SUPPLY` is supposed to control, so **no supply figure below
can be tuned until this is settled**. The fix is a decision, not a patch: either a load stops
being a `RollingStock` and becomes its own type, or `inboundCleared` discards rather than
banking. Found by a conservation audit, not by a failing test.
- [x] **~~CLEARING AN INBOUND BOX MINTS A CAR — measured at 1.29 a game against a supply of 80.~~
Re-audited in v0.4.3: rolling stock is EXACTLY CONSERVED, 100 games out of 100, range 0..0.**
The old audit's premise was right — the two directions were not symmetrical — but the asymmetry
has since been closed from the other end. `unloadBegan` and `passengersDetrained` now take their
replacement empty OUT of the Division Yard rather than conjuring it, so a load is a car that
moved rather than a car that appeared: one leaves the yard, one arrives in Classification.
Jesse's description of the tabletop procedure confirms this is the intended model — the token
you push along the MEN|AT|WORK sign IS the car, fetched from the yard by the Freight Agent and
swapped onto the industry track at the end.
Both conjuring fallbacks now **throw** rather than minting, so the leak cannot silently return;
neither fired across the suite or 100 audited games.
**`ROLLING_STOCK_SUPPLY` is therefore unblocked** — it was waiting on this and can now be tuned
in the rebalance pass. (The first audit's own arithmetic was off in the same way mine was on the
first attempt: cars set out on a card, in `card.standing`, are easy to leave out of the count and
make a conserved game look like a leaking one.)
- [x] **`state = fold(events)` was not true, and the docs said it was. Settled: the INTENTS are
canonical.** Measured before deciding — `advance.ts` never calls `reduce`, so **14 of the 46
event types are never reduced**: the clock, and the entire Mainline phase, which is every train
+1 -1
View File
@@ -49,7 +49,7 @@ must do.
## Current status
**v0.4.2.** Rules formalized, card faces specified, architecture documented, and the game playable
**v0.4.3.** Rules formalized, card faces specified, architecture documented, and the game playable
solitaire in a browser. See [`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and
[`../TODO.md`](../TODO.md) for what is open; this section is the shape of the project, not a
running tally, because a hand-maintained tally is what drifted last time.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.4.2",
"version": "0.4.3",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+308 -332
View File
@@ -1,7 +1,7 @@
{
"seed": 1022551,
"title": "27 Revenue · seed 1022551",
"note": "27 Revenue over 5 Days · 2 train(s) on the timetable · 21 cards down · no collisions · played by developer",
"note": "27 Revenue over 5 Days · 2 train(s) on the timetable · 19 cards down · no collisions · played by developer",
"history": [
{
"type": "localOps.choose",
@@ -864,12 +864,7 @@
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
"carType": "boxcar"
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
@@ -1026,12 +1021,7 @@
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
"carType": "boxcar"
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
@@ -1147,6 +1137,205 @@
},
"index": 0
},
{
"type": "laborer.beginUnload",
"at": {
"row": -1,
"col": -2
},
"carIndex": 1
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
"carType": "boxcar"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 1
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": -1,
"col": -2
},
"index": 0
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.board",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "laborer.startLoad",
"at": {
"row": -1,
"col": -2
}
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 0,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 1
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": 0,
"col": 0
},
"carType": "coach"
},
{
"type": "laborer.beginUnload",
"at": {
"row": -1,
"col": -2
},
"carIndex": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
"carType": "boxcar"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 1
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": -1,
"col": -2
},
"index": 0
},
{
"type": "laborer.startLoad",
"at": {
@@ -1184,216 +1373,6 @@
{
"type": "draw.end"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 1
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c48",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.board",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "laborer.beginUnload",
"at": {
"row": -1,
"col": -2
},
"carIndex": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 0,
"col": 0
},
"index": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 1
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": -1,
"col": -2
},
"index": 0
},
{
"type": "laborer.startLoad",
"at": {
"row": -1,
"col": -2
}
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"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": -2
},
"box": 1
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c61",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "laborer.beginUnload",
"at": {
"row": -1,
"col": -2
},
"carIndex": 0
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c163",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray2",
@@ -1432,22 +1411,25 @@
"row": -1,
"col": -2
},
"box": 0
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
"option": "draw"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": -1,
"col": -2
"type": "draw.fromHomeOffice"
},
"index": 0
{
"type": "card.discard",
"cardId": "c48",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
@@ -1468,6 +1450,54 @@
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c61",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c163",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
},
@@ -1503,16 +1533,6 @@
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "switch"
},
{
"type": "switch.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
@@ -1528,6 +1548,42 @@
{
"type": "draw.end"
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.board",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 0,
"col": 0
},
"index": 0
},
{
"type": "loadUnload.end"
},
@@ -1572,42 +1628,6 @@
{
"type": "draw.end"
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.board",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "porter.detrain",
"at": {
"row": 0,
"col": 0
}
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "freightAgent"
},
{
"type": "freightAgent.clearInbound",
"at": {
"row": 0,
"col": 0
},
"index": 0
},
{
"type": "loadUnload.end"
},
@@ -1648,44 +1668,6 @@
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.discard",
"cardId": "c61",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.discard",
"cardId": "c150",
"toSlot": 1
},
{
"type": "draw.end"
},
{
"type": "newTrain.placeCar",
"trayId": "tray3",
@@ -1718,16 +1700,13 @@
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c205",
"placement": {
"row": 0,
"col": -5
},
"variant": 0
"type": "card.discard",
"cardId": "c61",
"toSlot": 1
},
{
"type": "draw.end"
@@ -1752,16 +1731,13 @@
"option": "draw"
},
{
"type": "draw.fromHomeOffice"
"type": "draw.fromDepartment",
"slot": 1
},
{
"type": "card.play",
"cardId": "c126",
"placement": {
"row": 0,
"col": -6
},
"variant": 0
"type": "card.discard",
"cardId": "c150",
"toSlot": 1
},
{
"type": "draw.end"
@@ -1778,10 +1754,10 @@
},
{
"type": "card.play",
"cardId": "c136",
"cardId": "c205",
"placement": {
"row": 0,
"col": 6
"col": -5
},
"variant": 0
},
File diff suppressed because it is too large Load Diff
+122 -212
View File
@@ -524,12 +524,7 @@
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
"carType": "boxcar"
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
@@ -544,22 +539,10 @@
},
{
"type": "localOps.choose",
"option": "draw"
"option": "freightAgent"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c76",
"placement": {
"row": 0,
"col": -3
},
"variant": 0
},
{
"type": "draw.end"
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
@@ -574,22 +557,10 @@
},
{
"type": "localOps.choose",
"option": "draw"
"option": "freightAgent"
},
{
"type": "draw.fromHomeOffice"
},
{
"type": "card.play",
"cardId": "c181",
"placement": {
"row": 0,
"col": -2
},
"variant": 0
},
{
"type": "draw.end"
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
@@ -610,9 +581,13 @@
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c41",
"toSlot": 2
"type": "card.play",
"cardId": "c76",
"placement": {
"row": 0,
"col": -3
},
"variant": 0
},
{
"type": "draw.end"
@@ -687,19 +662,15 @@
},
{
"type": "localOps.choose",
"option": "draw"
"option": "freightAgent"
},
{
"type": "draw.fromDepartment",
"slot": 2
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
{
"type": "card.discard",
"cardId": "c36",
"toSlot": 2
},
{
"type": "draw.end"
"carType": "boxcar"
},
{
"type": "laborer.advanceLoad",
@@ -717,13 +688,16 @@
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c40",
"toSlot": 2
"type": "card.play",
"cardId": "c181",
"placement": {
"row": 0,
"col": -2
},
"variant": 0
},
{
"type": "draw.end"
@@ -778,12 +752,7 @@
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
"carType": "boxcar"
"type": "freightAgent.end"
},
{
"type": "porter.detrain",
@@ -879,12 +848,11 @@
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
"type": "draw.fromHomeOffice"
},
{
"type": "card.discard",
"cardId": "c53",
"cardId": "c41",
"toSlot": 2
},
{
@@ -903,19 +871,15 @@
},
{
"type": "localOps.choose",
"option": "draw"
"option": "freightAgent"
},
{
"type": "draw.fromDepartment",
"slot": 2
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
{
"type": "card.discard",
"cardId": "c41",
"toSlot": 2
},
{
"type": "draw.end"
"carType": "boxcar"
},
{
"type": "laborer.advanceLoad",
@@ -1041,12 +1005,7 @@
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
"carType": "boxcar"
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
@@ -1061,19 +1020,10 @@
},
{
"type": "localOps.choose",
"option": "draw"
"option": "freightAgent"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c40",
"toSlot": 2
},
{
"type": "draw.end"
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
@@ -1088,19 +1038,10 @@
},
{
"type": "localOps.choose",
"option": "draw"
"option": "freightAgent"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c53",
"toSlot": 2
},
{
"type": "draw.end"
"type": "freightAgent.end"
},
{
"type": "newTrain.placeCar",
@@ -1135,7 +1076,7 @@
},
{
"type": "card.discard",
"cardId": "c41",
"cardId": "c40",
"toSlot": 2
},
{
@@ -1154,19 +1095,15 @@
},
{
"type": "localOps.choose",
"option": "draw"
"option": "freightAgent"
},
{
"type": "draw.fromDepartment",
"slot": 2
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
{
"type": "card.discard",
"cardId": "c36",
"toSlot": 2
},
{
"type": "draw.end"
"carType": "boxcar"
},
{
"type": "porter.board",
@@ -1203,7 +1140,7 @@
},
{
"type": "card.discard",
"cardId": "c40",
"cardId": "c53",
"toSlot": 2
},
{
@@ -1248,7 +1185,7 @@
},
{
"type": "card.discard",
"cardId": "c53",
"cardId": "c41",
"toSlot": 2
},
{
@@ -1292,12 +1229,7 @@
"option": "freightAgent"
},
{
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
"carType": "boxcar"
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
@@ -1312,19 +1244,10 @@
},
{
"type": "localOps.choose",
"option": "draw"
"option": "freightAgent"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c41",
"toSlot": 2
},
{
"type": "draw.end"
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
@@ -1339,19 +1262,10 @@
},
{
"type": "localOps.choose",
"option": "draw"
"option": "freightAgent"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c36",
"toSlot": 2
},
{
"type": "draw.end"
"type": "freightAgent.end"
},
{
"type": "laborer.advanceLoad",
@@ -1374,7 +1288,7 @@
},
{
"type": "card.discard",
"cardId": "c40",
"cardId": "c36",
"toSlot": 2
},
{
@@ -1405,19 +1319,15 @@
},
{
"type": "localOps.choose",
"option": "draw"
"option": "freightAgent"
},
{
"type": "draw.fromDepartment",
"slot": 2
"type": "freightAgent.stockOutbound",
"at": {
"row": -1,
"col": -2
},
{
"type": "card.discard",
"cardId": "c53",
"toSlot": 2
},
{
"type": "draw.end"
"carType": "boxcar"
},
{
"type": "laborer.advanceLoad",
@@ -1440,7 +1350,7 @@
},
{
"type": "card.discard",
"cardId": "c41",
"cardId": "c40",
"toSlot": 2
},
{
@@ -1467,7 +1377,7 @@
},
{
"type": "card.discard",
"cardId": "c36",
"cardId": "c53",
"toSlot": 2
},
{
@@ -1528,7 +1438,7 @@
},
{
"type": "card.discard",
"cardId": "c40",
"cardId": "c41",
"toSlot": 2
},
{
@@ -1555,7 +1465,7 @@
},
{
"type": "card.discard",
"cardId": "c53",
"cardId": "c36",
"toSlot": 2
},
{
@@ -1598,6 +1508,60 @@
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c40",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c53",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "laborer.beginUnload",
"at": {
"row": -1,
"col": -2
},
"carIndex": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c41",
@@ -1633,60 +1597,6 @@
{
"type": "draw.end"
},
{
"type": "laborer.beginUnload",
"at": {
"row": -1,
"col": -2
},
"carIndex": 0
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c40",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "laborer.advanceLoad",
"at": {
"row": -1,
"col": -2
},
"box": 2
},
{
"type": "loadUnload.end"
},
{
"type": "localOps.choose",
"option": "draw"
},
{
"type": "draw.fromDepartment",
"slot": 2
},
{
"type": "card.discard",
"cardId": "c53",
"toSlot": 2
},
{
"type": "draw.end"
},
{
"type": "laborer.advanceLoad",
"at": {
@@ -1708,7 +1618,7 @@
},
{
"type": "card.discard",
"cardId": "c41",
"cardId": "c40",
"toSlot": 2
},
{
@@ -1762,7 +1672,7 @@
},
{
"type": "card.discard",
"cardId": "c36",
"cardId": "c53",
"toSlot": 2
},
{
@@ -1781,7 +1691,7 @@
},
{
"type": "card.discard",
"cardId": "c40",
"cardId": "c41",
"toSlot": 2
},
{
File diff suppressed because it is too large Load Diff
+91 -9
View File
@@ -297,11 +297,56 @@ function workTrack(f: Facility): [Load | null, Load | null, Load | null] {
return f.menAtWork;
}
/**
* HOW MANY EMPTY CARS OF THIS TYPE ARE SPOTTED AND NOT ALREADY PROMISED TO A LOAD.
*
* §9.3 — a load has to have somewhere to go. You cannot start the MEN | AT | WORK moves until an
* empty car of the right type is standing on the industry's track, "otherwise you are just dropping
* cargo onto the tracks — pointless waste". The final swap needs a car of the LOAD's type, so a
* hopper load cannot be swapped onto a tank: strict matching, not "any empty car".
*
* Counted rather than merely present, because green boxes and industry tracks both grow past one
* slot with Modifiers (measured: capacity > 1 on 24.5% of freight facilities and a track longer than
* one on 41%). Two loads staged against one spotted car would put the second one on the sign with
* nowhere to land, which is the jam this rule exists to prevent — just one step later.
*
* Loads already in the green box and already walking the sign both count as claims on a car.
*/
export function unclaimedEmpties(f: Facility, type: CarType): number {
const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === type).length;
const staged = f.outboundBox.filter((c) => c.type === type).length;
const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === type).length;
return spotted - staged - working;
}
/**
* The green-box load that may start down the sign, or null if none may.
*
* Not simply `outboundBox[0]`. A Power Plant takes hoppers AND tanks, so a green box holding a
* hopper load and a tank load against one spotted empty tank must start the TANK — taking the first
* entry regardless would report the whole facility as blocked while a perfectly legal load sat
* beside it.
*/
export function startableLoad(f: Facility): number | null {
const claims = new Map<CarType, number>();
for (let i = 0; i < f.outboundBox.length; i++) {
const type = f.outboundBox[i]!.type;
// Each earlier entry of the same type has first claim on the spotted cars.
const ahead = claims.get(type) ?? 0;
const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === type).length;
const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === type).length;
if (spotted - working - ahead > 0) return i;
claims.set(type, ahead + 1);
}
return null;
}
export function canStartLoad(f: Facility): boolean {
if (!f.menAtWork) return false;
if (laborersLeft(f) < 1) return false;
if (f.outboundBox.length === 0) return false;
return f.menAtWork[0] === null;
if (f.menAtWork[0] !== null) return false;
return startableLoad(f) !== null;
}
/** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */
@@ -741,6 +786,15 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded)) {
return 'NO_SUITABLE_CAR';
}
/**
* §9.3 — FREIGHT NEEDS A CAR AT THE DOCK BEFORE THE CARGO IS FETCHED. Staging a load against
* no car is fetching cargo you cannot ship.
*
* PASSENGERS ARE DELIBERATELY DIFFERENT and this is not an oversight: people can wait on the
* platform for a train that has not arrived, so a Passenger Facility stocks freely. Freight
* cannot — a crate on the ground is not a shipment.
*/
if (f.kind === 'freight' && unclaimedEmpties(f, i.carType) < 1) return 'NO_EMPTY_CAR_SPOTTED';
return null;
}
@@ -844,7 +898,13 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
return canStartLoad(f) ? null : 'BOX_EMPTY';
if (!f.menAtWork) return 'NO_SUCH_FACILITY';
if (f.outboundBox.length === 0) return 'BOX_EMPTY';
if (f.menAtWork[0] !== null) return 'BOX_FULL';
// The car can be coupled away after the cargo was staged — running over an industry track
// couples whatever stands on it, mandatorily (§A.4) — so the Freight Agent's check is not
// enough on its own. Both gates are required.
return startableLoad(f) !== null ? null : 'NO_EMPTY_CAR_SPOTTED';
}
case 'laborer.advanceLoad': {
@@ -874,7 +934,20 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
// The load is placed on WORK, the last box, so that box must be free — and a Passenger
// Facility has no such box, so there is nothing to unload into.
if (!f.menAtWork) return 'NO_SUCH_FACILITY';
return f.menAtWork[f.menAtWork.length - 1] === null ? null : 'BOX_FULL';
if (f.menAtWork[f.menAtWork.length - 1] !== null) return 'BOX_FULL';
/**
* THE MIRROR OF THE LOAD RULE: a load coming IN needs somewhere to land too, and its
* destination is the red Inbound box. This was checked only at the last step, so an unload
* could be begun into a full box and walked W→A→M over three Stages before discovering it had
* nowhere to go — jamming the industry, which is then locked and needs a Freight Agent turn to
* clear. Measured: rare (2.2% of offers) but real, and seen jamming three times in 200 games.
*
* COUNTED, not just "is there a slot". Only the W box has to be free to begin, so once a load
* moves W→A a second can start behind it — two loads walking toward one slot. Every red box in
* play today holds exactly one car, which is precisely when that bites.
*/
const inFlight = f.menAtWork.filter((l) => l?.dir === 'in').length;
return f.capacity.inbound - f.inboundBox.length > inFlight ? null : 'INBOUND_BOX_FULL';
}
case 'loadUnload.end':
@@ -1360,7 +1433,8 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'laborer.startLoad': {
const f = facilityAt(s, player, i.at)!;
return [{ type: 'loadStarted', player, at: i.at, carType: f.outboundBox[0]!.type }];
// The first load with a car to land on, which is not always the first in the box.
return [{ type: 'loadStarted', player, at: i.at, carType: f.outboundBox[startableLoad(f)!]!.type }];
}
case 'laborer.advanceLoad': {
@@ -1744,9 +1818,10 @@ export function reduce(s: GameState, e: GameEvent): void {
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1;
if (tray && ci >= 0) {
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
// which minted a coach on every de-training.
// which minted a coach on every de-training. Throws now, for the reason in `unloadBegan`.
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
const empty = yi >= 0 ? s.yards.divisionYard.splice(yi, 1)[0]! : { type: 'coach' as const, loaded: false };
if (yi < 0) throw new Error('passengersDetrained: no empty coach in the Division Yard');
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
refillDivisionYardIfEmpty(s);
f.inboundBox.push(tray.consist[ci]!);
tray.consist[ci] = empty;
@@ -1798,10 +1873,17 @@ export function reduce(s: GameState, e: GameEvent): void {
const f = facilityAt(s, e.player, e.at)!;
const ci = f.industryTrack.cars.findIndex((c) => c.loaded);
if (ci >= 0) {
// §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
// minted a car on every unload, and it also skipped a requirement the rule states.
/**
* §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
* minted a car on every unload, and it also skipped a requirement the rule states.
*
* THROWS rather than falling back. `check` guarantees the car is there, so reaching this is
* a broken invariant, and the old fallback quietly minted rolling stock instead of saying
* so — which is exactly the shape of leak `TODO.md` spent a conservation audit chasing.
*/
const yi = s.yards.divisionYard.findIndex((c) => c.type === e.carType && !c.loaded);
const empty = yi >= 0 ? s.yards.divisionYard.splice(yi, 1)[0]! : { type: e.carType, loaded: false };
if (yi < 0) throw new Error(`unloadBegan: no empty ${e.carType} in the Division Yard`);
const empty = s.yards.divisionYard.splice(yi, 1)[0]!;
refillDivisionYardIfEmpty(s);
f.industryTrack.cars[ci] = empty;
}
+6
View File
@@ -170,6 +170,12 @@ export type RejectionCode =
* fallback fired 51 times with a coach train in front of the player: 27 with nobody waiting to
* travel, 24 with passengers waiting and every coach on the train already full.
*/
/**
* §9.3 — the green box may only be stocked, and a load may only start down the sign, when an empty
* car of THAT type is standing on the industry's track and is not already promised to another
* load. Fetching cargo you cannot ship is "just dropping it onto the tracks".
*/
| 'NO_EMPTY_CAR_SPOTTED'
| 'NO_PORTERS_HERE'
| 'NO_PASSENGERS_WAITING'
| 'NO_EMPTY_COACH'
+23 -4
View File
@@ -17,7 +17,7 @@
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
// ---------------------------------------------------------------------------
@@ -478,17 +478,36 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
}
if (f.outboundBox.length > 0 && !canStartLoad(f) && laborersLeft(f) > 0) {
// Two quite different reasons, and telling a player "MEN is occupied" when the real answer is
// "there is no car to load onto" sends them to fix the wrong thing.
const blockedByBox = f.menAtWork?.[0] != null;
out.push({
where: `${name} ${key}`,
why: 'green box has a load but MEN is occupied',
severity: 'waiting',
why: blockedByBox
? 'green box has a load but MEN is occupied'
: `no empty ${want} spotted on this industry's track — a load has nowhere to go until a ` +
'crew sets one out here (§9.3)',
severity: blockedByBox ? 'waiting' : 'stuck',
});
}
if (f.allows.outbound && f.outboundBox.length === 0) {
/**
* WHAT TO DO NEXT, in the order you have to do it.
*
* A freight industry cannot be stocked at all until an empty car of its commodity is standing
* on its track (§9.3) — so "needs a Freight Agent action" was the second step told to a player
* who had not done the first, and the Freight Agent option simply would not be there. Measured
* before the rule was enforced, 78% of the stocking moves the menu offered had no car to load
* onto.
*/
const spotted = f.industryTrack.cars.some((c) => !c.loaded && facilityCarTypes(f).includes(c.type));
out.push({
where: `${name} ${key}`,
why: 'green box empty — nothing to load (needs a Freight Agent action)',
why: spotted
? 'green box empty — nothing to load (needs a Freight Agent action)'
: `green box empty, and no empty ${want} is spotted here — bring one in with a crew FIRST, ` +
'then the Freight Agent can stage a load onto it (§9.3)',
severity: 'waiting',
});
}
+4 -1
View File
@@ -449,7 +449,10 @@ describe('Freight Agent operations (§6.3)', () => {
inboundBox: [],
capacity: { outbound: 3, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 4, cars: [] },
// An empty hopper is SPOTTED. §9.3 refuses to stage cargo against no car — "otherwise you
// are just dropping cargo onto the tracks" — so a fixture with a bare track is a facility
// the Freight Agent cannot legally touch.
industryTrack: { length: 4, cars: [{ type: 'hopper', loaded: false }] },
laborers: 3,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },