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:
+199
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user