adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.
This commit is contained in:
+6
-2
@@ -22,5 +22,9 @@ __pycache__/
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Generated replays
|
# Generated replays — ANCHORED to the repo root on purpose.
|
||||||
replay*.html
|
#
|
||||||
|
# Unanchored, `replay*.html` also matched `src/web/replays.html`, which is a SOURCE file: the
|
||||||
|
# website's replay viewer. It was therefore never committed, and a fresh clone was missing a page
|
||||||
|
# the build copies unconditionally. Only the throwaway files this directory collects are ignored.
|
||||||
|
/replay*.html
|
||||||
|
|||||||
+231
@@ -10,6 +10,237 @@ The target is 20 Revenue over 5 Days.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Q13 — a train that catches the one ahead runs into it
|
||||||
|
|
||||||
|
Answered, and implemented as option B: **collide on catching up**, which is the version that rewards
|
||||||
|
judging the gap.
|
||||||
|
|
||||||
|
§10 makes a Mainline collision the Superintendent's fault and removes both trains, and ABS Signals
|
||||||
|
exists to stop trains rear-ending each other — but §8.3's trigger list never named one and nothing
|
||||||
|
was implemented, so granting clearance was FREE: both trains survived, no penalty, and ABS Signals
|
||||||
|
protected against nothing. Now a following train that closes on the one ahead runs into it, and one
|
||||||
|
that never closes is fine, so clearance is a bet on relative speed rather than a formality. §2.1
|
||||||
|
divides the card into two regions, and sharing one is what "caught up" means. ABS Signals does what
|
||||||
|
it prints instead: the follower stops short and holds.
|
||||||
|
|
||||||
|
**Not on a card that prints "trains may pass".** The first version fired 0.41 times a game while the
|
||||||
|
bot never once granted clearance, which is the tell — those were all Double Track and Uncontrolled
|
||||||
|
Siding, cards that hold two trains because they HAVE two roads. Catching up there means going past,
|
||||||
|
which is what the card is for.
|
||||||
|
|
||||||
|
With that corrected the mechanic is invisible to the current bot, because it always denies
|
||||||
|
clearance. That is the right shape, and the teeth are real:
|
||||||
|
|
||||||
|
| Superintendent | revenue | rear-enders | ABS holds |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| always denies (the bot) | 7.34 | 0.00/game | 0 |
|
||||||
|
| **always allows** | **−5.13** | **2.20/game** | 19 |
|
||||||
|
|
||||||
|
So the bot's always-deny policy — a deliberate choice made when clearance was free and the
|
||||||
|
arithmetic only guessed at — turns out to be correct, and is now correct for a measured reason.
|
||||||
|
|
||||||
|
Tested deterministically rather than through the bot: two trains built onto one single-track card
|
||||||
|
with the follower closing, which collides whichever order the phase processes them in, and the same
|
||||||
|
pair again with ABS Signals to prove it holds instead.
|
||||||
|
|
||||||
|
### The engine has a place in the train, and the yards are visible
|
||||||
|
|
||||||
|
**The engine had a position the game recorded and never used.** `engineFront` was a boolean, written
|
||||||
|
in three places and read in none — so every consist was drawn as an anonymous row of cars. It is now
|
||||||
|
`engineAt`, an index into the consist, because a Crew Tray is an engine plus its Rolling Stock and
|
||||||
|
the engine may be PULLING (ahead of everything), PUSHING (behind everything), or in the middle doing
|
||||||
|
both at once. A boolean cannot say the third thing. Consists are drawn with `ENG` where it sits.
|
||||||
|
|
||||||
|
The engine is deliberately NOT one of the `consist` entries: §8.2 counts the consist as Rolling
|
||||||
|
Stock, and the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
|
||||||
|
|
||||||
|
**Both yards are now on the page**, by car type and split loaded / empty, with the Division Yard
|
||||||
|
outlined the moment it goes bare. This matters more than it did: the Classification Yard returns to
|
||||||
|
service only when the Division Yard is empty, so the supply genuinely runs down, and a game that
|
||||||
|
never showed either yard gave no warning at all.
|
||||||
|
|
||||||
|
It shows the pressure immediately. In a finished game the Division Yard held 30 cars — hoppers,
|
||||||
|
tanks and cabooses — and **no boxcars, coaches or reefers at all**, while 19 of them sat in
|
||||||
|
Classification unable to come back, because the Division Yard was not bare.
|
||||||
|
|
||||||
|
Crew Tray scarcity was already implemented and is left alone: a train with no free tray is held
|
||||||
|
(`trainHeld`), which is §7.
|
||||||
|
|
||||||
|
### The Classification Yard rule, from the source — and my guess was worth 2.4 Revenue it should not have been
|
||||||
|
|
||||||
|
Answered: used Rolling Stock is set out in the Classification Yard, used engines and cabooses go
|
||||||
|
straight back to the Division Yard, and **the Classification Yard empties only when the Division
|
||||||
|
Yard is bare** — then all of it returns at once.
|
||||||
|
|
||||||
|
That is a much harder rule than the one I invented. Returning cars at every DAY boundary keeps the
|
||||||
|
yard topped up continuously; this lets it run down to nothing and refill in one go, which is the
|
||||||
|
whole of the supply pressure the game is meant to have. Measured paired over 400 seeds:
|
||||||
|
|
||||||
|
my Day-boundary guess 9.67 the real rule 7.25
|
||||||
|
paired change -2.42 ± 0.49 (t -9.67) 235 of 400 seeds affected
|
||||||
|
|
||||||
|
So the +2.32 celebrated when the Classification Yard was first made readable was very largely an
|
||||||
|
artefact of getting the trigger wrong. The refill is now checked wherever a car leaves the Division
|
||||||
|
Yard, so it fires the moment the yard empties rather than at the next convenient tick.
|
||||||
|
|
||||||
|
**Poling is dealt zero copies** rather than deleted. Its effect is "TBD in the source", so there is
|
||||||
|
nothing to implement and a card that cannot be played is worse in a hand than absent from the deck.
|
||||||
|
The catalogue entry stays so the gap remains visible. Deck 140 → 139, solitaire 118 → 117.
|
||||||
|
|
||||||
|
**Heavy Grade orientation stays rolled from the seed**, and is now documented as temporary in the
|
||||||
|
code rather than only in TODO: the card says the player sets it, but it is dealt during setup and
|
||||||
|
setup has no decision point — `createGame` is a pure function of the seed, which is also what makes
|
||||||
|
a save portable.
|
||||||
|
|
||||||
|
**A counting question this raised, and could not answer.** A census of every holder of rolling stock
|
||||||
|
comes to 92 against the 80 dealt. That is not proof of duplication: `outboundBox`, `inboundBox` and
|
||||||
|
`menAtWork` all hold `RollingStock`, and stocking a green box takes a LOADED CAR out of the Division
|
||||||
|
Yard — so some of those objects are cargo in transit rather than cars, and nothing distinguishes
|
||||||
|
them. An accounting test was written and then withdrawn, because it could not tell the two apart.
|
||||||
|
Logged: until a load is its own type, "is any stock being created or destroyed?" is unanswerable.
|
||||||
|
|
||||||
|
### The freight figures were counting one half of freight
|
||||||
|
|
||||||
|
A measurement fix, not a game fix — but it is the instrument every balance decision is read from.
|
||||||
|
|
||||||
|
`rev.freightUnload` was assigned `eventCounts['unloadBegan']`: unloads STARTED, not Revenue EARNED,
|
||||||
|
and the two differ by every unload that never finished. `grossFreight` then used `freightLoad`
|
||||||
|
alone, so `freightShare` omitted the unload half outright. A completed load and a completed unload
|
||||||
|
each earn a point, on two distinct `revenueChanged` reasons.
|
||||||
|
|
||||||
|
The comment that stood there claimed "an unload scores through the same event as a load completion
|
||||||
|
in the reducer". It does not — `apply.ts` emits `freightUnload` separately. A comment asserting a
|
||||||
|
fact about code a few lines away, and wrong.
|
||||||
|
|
||||||
|
freight share of gross: 39% -> 49%
|
||||||
|
|
||||||
|
The harness now prints both halves. `strategyBuckets` counted "a scoring game" the same wrong way,
|
||||||
|
and so did the test guarding it — so a game that scored only by unloading was bucketed but not
|
||||||
|
counted, which is how the fix first showed up as a failure.
|
||||||
|
|
||||||
|
This matters backwards as well as forwards: the "freight is only 13-18% of gross" finding was read
|
||||||
|
from this number, and it is what drove the Gap 12 industry-density change. That decision was taken
|
||||||
|
against an instrument reading roughly 60% low.
|
||||||
|
|
||||||
|
### Turnouts that go nowhere — reported, confirmed, and mostly not the problem
|
||||||
|
|
||||||
|
Reported from two replays: of 8 turnouts off the Running Track, 2 formed a run-around, 2 served
|
||||||
|
industries and 4 went nowhere. Measured across 120 games, that holds exactly:
|
||||||
|
|
||||||
|
| what a turnout leads to | |
|
||||||
|
| --- | --- |
|
||||||
|
| part of a run-around | 30% |
|
||||||
|
| a stub, but serves an industry | 12% |
|
||||||
|
| a stub ending in bare track | 44% |
|
||||||
|
| **nothing below it at all** | **15%** |
|
||||||
|
|
||||||
|
**4.61 wasted turnouts a game.** Then three attempts to stop it, each measured paired over 400
|
||||||
|
seeds, and each WORSE than leaving it alone:
|
||||||
|
|
||||||
|
| attempt | paired change | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| no new way down while one leads nowhere | run-arounds → **0** | deadlocked: a run-around needs TWO ways down, and the second cannot be justified by what hangs off the first |
|
||||||
|
| first two free, gate the rest | **−0.84 ± 0.53** (t −3.08) | track spend collapsed 15.4 → 4.8 |
|
||||||
|
| forbid rail that butts an incompatible card | **−0.62 ± 0.57** (t −2.11) | |
|
||||||
|
|
||||||
|
The reason is that a turnout is not only a way DOWN. It is also a way UP, and both the east-west
|
||||||
|
extension and the closing arc are gated on one existing beyond them — so cutting the turnouts cuts
|
||||||
|
the places a siding can rejoin, and the sidings stop forming too. The apparent waste is optionality.
|
||||||
|
This also re-confirms, with proper statistics, a note left in the code by an earlier attempt.
|
||||||
|
|
||||||
|
### Rail that can never go anywhere
|
||||||
|
|
||||||
|
The one that did work, and only as a tie-breaker.
|
||||||
|
|
||||||
|
A port facing an EMPTY square is a promise: something may be built there later. A port butting an
|
||||||
|
OCCUPIED square whose card has no matching port is not — that square is taken, so the rail stops
|
||||||
|
dead and always will. Reported from seed 618682, where an arc came off a turnout with its far end
|
||||||
|
jammed into a curve that could not accept it. Measured: **28% of all pieces laid, 4.26 a game.**
|
||||||
|
|
||||||
|
Forbidding it cost 0.62 revenue a game. Applying it as a **tie-breaker on the distance score** —
|
||||||
|
never able to veto a piece, only to choose between two the heuristics rate equally — measured
|
||||||
|
**+0.43 ± 0.49 (t 1.74)**, with 108 seeds better against 67, and cut these from 28% of pieces to 7%.
|
||||||
|
Not significant on its own, but it is the only one of four attempts pointing the right way, and the
|
||||||
|
mechanism is sound.
|
||||||
|
|
||||||
|
**And it fixed the reported problem after all — sideways.** Re-running the turnout taxonomy:
|
||||||
|
|
||||||
|
| what a turnout leads to | before | after |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| part of a run-around | 30% | **66%** |
|
||||||
|
| a stub, but serves an industry | 12% | 13% |
|
||||||
|
| a stub ending in bare track | **44%** | **2%** |
|
||||||
|
| nothing below it at all | 15% | 18% |
|
||||||
|
| **wasted per game** | **4.61** | **1.65** |
|
||||||
|
|
||||||
|
Bare stubs all but gone and run-arounds more than doubled, without ever refusing a turnout. Refusing
|
||||||
|
them directly had destroyed the run-arounds; declining to lay rail INTO a dead end leaves the bot
|
||||||
|
free to cut every turnout it likes and quietly stops it building the stubs. The remaining waste is
|
||||||
|
the last turnouts of a game, cut with no turns left to build beneath them.
|
||||||
|
|
||||||
|
Also added, and honest about it: a turnout is not cut when no arc remains to hang beneath it.
|
||||||
|
Measured at **0%** today — the bot lays the arc immediately after the turnout and never runs the
|
||||||
|
supply dry — so it is a guard against the supply changing rather than a fix for anything happening
|
||||||
|
now. Its test constructs the situation by draining the arcs.
|
||||||
|
|
||||||
|
### A source file git was hiding
|
||||||
|
|
||||||
|
Found while checking the above: **`src/web/replays.html` had never been committed.** `.gitignore`
|
||||||
|
carried `replay*.html` to catch the throwaway files generated at the repo root, and unanchored it
|
||||||
|
also matched a source page — the website's replay viewer. `git archive HEAD` confirms it: a fresh
|
||||||
|
clone does not contain that file, and `build-web.ts` copies it unconditionally, so the build would
|
||||||
|
have failed for anyone but this working copy.
|
||||||
|
|
||||||
|
The pattern is anchored to the root now (`/replay*.html`), which still ignores the generated files
|
||||||
|
and no longer ignores the source. A test asks GIT — not `.gitignore` — whether each source page
|
||||||
|
would survive a clone, because that is the actual question.
|
||||||
|
|
||||||
|
### Three places draw a game, and they had drifted
|
||||||
|
|
||||||
|
Reported from playtesting: the replay had lost its "extra slow" speed, and neither the sound nor the
|
||||||
|
auto-hide could be found. Both true, and the same cause — a game is drawn in THREE places and only
|
||||||
|
some of them had kept up:
|
||||||
|
|
||||||
|
| | speeds | sound | auto-hide |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| the playable page | — | yes | yes |
|
||||||
|
| the standalone replay file | 5 | yes | yes |
|
||||||
|
| **the website's replay viewer** | **3** | **no** | **no** |
|
||||||
|
|
||||||
|
The website viewer is its own implementation — it replays a save through the engine in the browser
|
||||||
|
rather than reading a rendered file — and it never got what the other two grew. It now has all five
|
||||||
|
speeds (extra slow through very fast), the sound, and the auto-hide, using the same shared `cuesFor`
|
||||||
|
and `playCue` as everywhere else.
|
||||||
|
|
||||||
|
Three tests hold them together from now on: the two viewers must offer the SAME set of speeds, all
|
||||||
|
three pages must carry the sound and auto-hide controls, and `replays.ts` may not ask for an element
|
||||||
|
its page does not have — the same total check the playable page already had, and the one that would
|
||||||
|
have caught this. Verified by removing a speed and a control and watching them fail.
|
||||||
|
|
||||||
|
### The published replays had stopped replaying
|
||||||
|
|
||||||
|
Both saves in `public/replays/` were dead. A save is a seed plus the intents, replayed through the
|
||||||
|
real engine — so it cannot describe a position the rules could not produce, and an intent that no
|
||||||
|
longer applies stops the replay rather than being forced. That is the safe direction, but it is
|
||||||
|
silent: `seed-202` got 42 intents into 360 before halting, and `seed-430` managed 4 of 338.
|
||||||
|
|
||||||
|
They were recorded before this run's rules work — Modifier hosts, the industry-to-car mapping, the
|
||||||
|
Classification Yard — so most of what they described is no longer legal. Replaced with three games
|
||||||
|
generated against the rules as they stand, each verified to replay every intent to the final Day:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| A winning run | 36 Revenue, seed 1038389 |
|
||||||
|
| A strong run | 32 Revenue, seed 618682 |
|
||||||
|
| Collisions | 10 Revenue and 27 smashes, seed 919604 |
|
||||||
|
|
||||||
|
The third is deliberately a bad game: a full Office is a collision, and it costs more than the
|
||||||
|
freight was worth.
|
||||||
|
|
||||||
|
This is the "save/restore is not version-aware" item in TODO doing exactly what it warns about. The
|
||||||
|
saves are cheap to regenerate, so the fix is not to freeze them — it is for a stale save to say so
|
||||||
|
instead of quietly ending early.
|
||||||
|
|
||||||
### The replay behaves like the game it is replaying
|
### The replay behaves like the game it is replaying
|
||||||
|
|
||||||
Sound and the district auto-hide were built for the playable page and the replay had neither, which
|
Sound and the district auto-hide were built for the playable page and the replay had neither, which
|
||||||
|
|||||||
@@ -9,16 +9,45 @@ Ordered within each section by how much it is currently costing us.
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
|
- [ ] **A "load" is stored as a car, so rolling stock cannot be counted.** `outboundBox`,
|
||||||
|
`inboundBox` and `menAtWork` all hold `RollingStock`, and `freightAgent.stockOutbound` takes a
|
||||||
|
LOADED CAR out of the Division Yard to fill a green box. So a census of every holder comes to
|
||||||
|
92 against the 80 dealt at setup — not necessarily duplication, because some of those objects
|
||||||
|
are cargo in transit rather than cars, but there is no way to tell them apart. Until a load is
|
||||||
|
its own type, "is any stock being created or destroyed?" is an unanswerable question, and the
|
||||||
|
supply numbers below cannot be tuned with confidence.
|
||||||
|
- [ ] **Engines are not a SUPPLY yet, only a position.** `engineAt` now records where the engine
|
||||||
|
sits in the tray and the consist shows it, but an engine is still conjured with the tray
|
||||||
|
rather than drawn from the Division Yard and returned to it. The rules put engines in the
|
||||||
|
Division Yard alongside the cars, with a predefined number of them, so running out of engines
|
||||||
|
should be a second way trains get held — today only the Crew Tray count does that. Needs a
|
||||||
|
number to start from, then playtesting.
|
||||||
|
- [ ] **The yards are shown on the play page but not in either replay viewer.** The Frame carries
|
||||||
|
them, so it is a rendering job, not a modelling one.
|
||||||
|
- [ ] **The rolling stock supply is a guess.** `ROLLING_STOCK_SUPPLY` (coach 8+8, boxcar 10+10,
|
||||||
|
hopper 8+8, reefer 5+5, tank 6+6, caboose 6) is marked provisional in `content.ts` and was
|
||||||
|
scaled alongside the Gap 12 industry increase. Now that the Classification Yard returns stock
|
||||||
|
only when the Division Yard empties, these numbers set the real supply pressure. Adjust from
|
||||||
|
playtesting rather than theory, and watch whether industry density feels light or heavy at the
|
||||||
|
same time.
|
||||||
|
- [ ] **Heavy Grade orientation is rolled, not chosen.** The card prints "Player sets orientation",
|
||||||
|
but it is dealt during setup and setup has no decision point at all — `createGame` is a pure
|
||||||
|
function of the seed, which is also what makes a save portable. Rolled from the seed for now.
|
||||||
|
Revisit when setup gains an interactive phase; the orientation matters, because it decides
|
||||||
|
which direction climbs and therefore what Brakeman and Helpers are worth.
|
||||||
|
|
||||||
- [ ] **Measure with error bars from now on.** Revenue has a standard deviation of ~9, so a
|
- [ ] **Measure with error bars from now on.** Revenue has a standard deviation of ~9, so a
|
||||||
100-game run carries about ±1.0 of noise — every single-change revenue claim in the changelog
|
100-game run carries about ±1.0 of noise — every single-change revenue claim in the changelog
|
||||||
before the Interlocking work is inside that. Use paired per-seed comparison (the harness deals
|
before the Interlocking work is inside that. Use paired per-seed comparison (the harness deals
|
||||||
the same seeds either way) and 400+ games before calling a heuristic change good or bad. The
|
the same seeds either way) and 400+ games before calling a heuristic change good or bad. The
|
||||||
first attempt at the Running Track straight was read as a 0.6 REGRESSION on 100 games and is
|
first attempt at the Running Track straight was read as a 0.6 REGRESSION on 100 games and is
|
||||||
a 0.7 improvement on 400.
|
a 0.7 improvement on 400.
|
||||||
- [ ] **Confirm the Classification Yard rule against the source.** Cars now return to the Division
|
- [x] ~~**Confirm the Classification Yard rule against the source.**~~ Confirmed, and the guess was
|
||||||
Yard at the Day boundary — an ASSUMPTION, not a recovered rule. Gap 2c says everything but
|
wrong. The rule is: used Rolling Stock to the Classification Yard, used engines and cabooses
|
||||||
cabooses goes to Classification and never says how it empties. It is worth +2.32 revenue a
|
straight back to the Division Yard, and the Classification Yard empties ONLY when the Division
|
||||||
game, so if the real rule differs the balance numbers move with it.
|
Yard is bare — then all at once. The Day-boundary version I had invented was far more generous
|
||||||
|
and worth **+2.42 revenue a game the game does not actually grant**. Corrected; revenue 9.67
|
||||||
|
→ 7.25.
|
||||||
- [x] ~~**Enhancements are placed but mostly do nothing.**~~ Measured: forbidding every Enhancement
|
- [x] ~~**Enhancements are placed but mostly do nothing.**~~ Measured: forbidding every Enhancement
|
||||||
except Interlocking is worth **-0.01 ± 0.41 (t = -0.04)** over 400 paired seeds. They neither
|
except Interlocking is worth **-0.01 ± 0.41 (t = -0.04)** over 400 paired seeds. They neither
|
||||||
pay nor cost. Left alone. Unlocking the Running Track straight put
|
pay nor cost. Left alone. Unlocking the Running Track straight put
|
||||||
@@ -42,11 +71,8 @@ Ordered within each section by how much it is currently costing us.
|
|||||||
needs a stocked green box AND a spotted car AND a free Laborer to line up in the same Stage.
|
needs a stocked green box AND a spotted car AND a free Laborer to line up in the same Stage.
|
||||||
Measure how many Stages have all three before changing any heuristic — the answer may be that
|
Measure how many Stages have all three before changing any heuristic — the answer may be that
|
||||||
the economy, not the bot, is what caps freight.
|
the economy, not the bot, is what caps freight.
|
||||||
- [ ] **`stats.ts` undercounts freight.** `rev.freightUnload` is assigned `eventCounts['unloadBegan']`
|
- [x] ~~**`stats.ts` undercounts freight.**~~ Fixed: both halves counted, freight share 39% → 49%.
|
||||||
— unloads *begun*, not revenue *earned* — and `grossFreight` uses `freightLoad` alone, so
|
Worth revisiting the **industry density** decision below, which was taken on the old number.
|
||||||
`freightShare` omits unload revenue entirely. The comment justifying it ("an unload scores
|
|
||||||
through the same event as a load completion") is wrong: `apply.ts:947` emits a distinct
|
|
||||||
`freightUnload` reason. This is why freight was recorded at 13–18% of gross.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -54,13 +80,9 @@ Ordered within each section by how much it is currently costing us.
|
|||||||
|
|
||||||
Blocked on a decision, not on work.
|
Blocked on a decision, not on work.
|
||||||
|
|
||||||
- [ ] **Q13 — rear-end collisions on a Mainline card.** §10 says a Mainline collision is the
|
- [x] ~~**Q13 — rear-end collisions on a Mainline card.**~~ Answered: collide on catching up.
|
||||||
Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders —
|
Implemented, and not on cards that print "trains may pass". Invisible to a bot that always
|
||||||
but §8.3's trigger list does not include one, and **none is implemented**. Granting clearance is
|
denies clearance; a bot that always allows drops from 7.34 revenue to **-5.13**.
|
||||||
currently free: verified, both trains survive, no penalty. Three candidates: collide on entry
|
|
||||||
(clearance becomes a gamble), collide on catching up (rewards judging the gap), or accept that
|
|
||||||
clearance is safe and ABS Signals is worth less than it reads. The buttons currently describe
|
|
||||||
only what the engine does, so nothing promises a consequence that cannot happen.
|
|
||||||
- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect
|
- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect
|
||||||
as "TBD in the source". A test asserts it stays TBD so nobody invents one.
|
as "TBD in the source". A test asserts it stays TBD so nobody invents one.
|
||||||
- [ ] **Heavy Grade orientation at setup.** The card says "Player sets orientation", but `createGame`
|
- [ ] **Heavy Grade orientation at setup.** The card says "Player sets orientation", but `createGame`
|
||||||
@@ -165,7 +187,11 @@ target is settled and freight carries its intended share.
|
|||||||
- [ ] **No way to start a fresh game from inside the page.** `?seed=` gives a reproducible deal and
|
- [ ] **No way to start a fresh game from inside the page.** `?seed=` gives a reproducible deal and
|
||||||
"new game" only appears once a game has ended, so abandoning a bad opening means editing the URL.
|
"new game" only appears once a game has ended, so abandoning a bad opening means editing the URL.
|
||||||
- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
|
- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
|
||||||
failing loudly, which is the safe direction but says little about what changed.
|
failing loudly, which is the safe direction but says little about what changed. **This has now
|
||||||
|
bitten once**: both published replays were dead — one got 42 intents into 360, the other 4 of
|
||||||
|
338 — and nothing said so; they simply ended early and looked like short games. A save should
|
||||||
|
carry a ruleset stamp and the page should say "this replay was recorded under an older
|
||||||
|
ruleset and stops at Stage N" rather than presenting a truncated game as a whole one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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
+52
-22
@@ -30,6 +30,8 @@ import {
|
|||||||
collectiveRevenueFloor,
|
collectiveRevenueFloor,
|
||||||
lengthProfile,
|
lengthProfile,
|
||||||
officeProfile,
|
officeProfile,
|
||||||
|
REGIONS_PER_MAINLINE_CARD,
|
||||||
|
mainlineProfile,
|
||||||
} from './content.ts';
|
} from './content.ts';
|
||||||
import type { Direction } from './content.ts';
|
import type { Direction } from './content.ts';
|
||||||
import type { GameEvent } from './events.ts';
|
import type { GameEvent } from './events.ts';
|
||||||
@@ -186,7 +188,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
id: trayId,
|
id: trayId,
|
||||||
trainNumber: due,
|
trainNumber: due,
|
||||||
trainIsExtra: false,
|
trainIsExtra: false,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist: [],
|
consist: [],
|
||||||
direction,
|
direction,
|
||||||
position: { at: 'divisionPoint', side },
|
position: { at: 'divisionPoint', side },
|
||||||
@@ -219,7 +221,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
id: trayId,
|
id: trayId,
|
||||||
trainNumber: number,
|
trainNumber: number,
|
||||||
trainIsExtra: false,
|
trainIsExtra: false,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist: [],
|
consist: [],
|
||||||
direction,
|
direction,
|
||||||
position: { at: 'divisionPoint', side },
|
position: { at: 'divisionPoint', side },
|
||||||
@@ -250,7 +252,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
id: trayId,
|
id: trayId,
|
||||||
trainNumber: number,
|
trainNumber: number,
|
||||||
trainIsExtra: true,
|
trainIsExtra: true,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist: [],
|
consist: [],
|
||||||
direction: 'east',
|
direction: 'east',
|
||||||
position: { at: 'divisionPoint', side: 'west' },
|
position: { at: 'divisionPoint', side: 'west' },
|
||||||
@@ -488,6 +490,53 @@ function moveTrain(
|
|||||||
// Q1/Q2 — crossing takes a whole number of Stages set by the card's speed and the train's
|
// Q1/Q2 — crossing takes a whole number of Stages set by the card's speed and the train's
|
||||||
// Fast/Slow class. Count it down rather than stepping through printed cells.
|
// Fast/Slow class. Count it down rather than stepping through printed cells.
|
||||||
if (transit.stagesRemaining > 1) {
|
if (transit.stagesRemaining > 1) {
|
||||||
|
/**
|
||||||
|
* Q13 — REAR-END COLLISIONS, answered: a train collides when it CATCHES UP.
|
||||||
|
*
|
||||||
|
* §10 makes a Mainline collision the Superintendent's fault and removes both trains, and ABS
|
||||||
|
* Signals exists to stop trains rear-ending each other — but §8.3's trigger list never named
|
||||||
|
* one and none was implemented, so granting clearance was free: both trains survived, no
|
||||||
|
* penalty, and ABS Signals protected against nothing.
|
||||||
|
*
|
||||||
|
* Colliding on CATCHING UP is the version that rewards judging the gap. A following train
|
||||||
|
* that closes on the one ahead runs into it; a following train that never closes is fine, so
|
||||||
|
* clearance becomes a bet on relative speed rather than a formality. §2.1 divides the card
|
||||||
|
* into two regions, and sharing one is what "caught up" means.
|
||||||
|
*
|
||||||
|
* ABS Signals does what it says instead: the follower stops SHORT of the collision and holds.
|
||||||
|
*/
|
||||||
|
const regionOf = (t: { stagesTotal: number; stagesRemaining: number }): number => {
|
||||||
|
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
|
||||||
|
const elapsed = t.stagesTotal - t.stagesRemaining;
|
||||||
|
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
|
||||||
|
};
|
||||||
|
// NOT on a card that prints "trains may pass". Double Track and Uncontrolled Siding hold two
|
||||||
|
// trains because they HAVE two roads, so a train catching another there goes past it — that
|
||||||
|
// is what the card is for. Without this the mechanic fired 0.41 times a game while the bot
|
||||||
|
// never once granted clearance, which is the tell: those were all passing cards.
|
||||||
|
const mayPass = mainlineProfile(node.card).trainsMayPass;
|
||||||
|
const next = regionOf({ stagesTotal: transit.stagesTotal, stagesRemaining: transit.stagesRemaining - 1 });
|
||||||
|
const ahead = mayPass
|
||||||
|
? undefined
|
||||||
|
: node.transits.find(
|
||||||
|
(t) => t.tray !== id && t.direction === transit.direction && regionOf(t) === next,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ahead) {
|
||||||
|
if (node.absSignals) {
|
||||||
|
// "Trains on this card will not rear-end each other; they stop short of a collision."
|
||||||
|
events.push({
|
||||||
|
type: 'trainHeld',
|
||||||
|
trainNumber: tray.trainNumber ?? 0,
|
||||||
|
reason: 'ABS Signals — held short of the train ahead',
|
||||||
|
});
|
||||||
|
return 'held';
|
||||||
|
}
|
||||||
|
// §10 — the Superintendent let it in behind the other, so it is the Superintendent's fault.
|
||||||
|
collide(s, s.clock.superintendent, [id, ahead.tray], events, 'ran into the train ahead', 'the Mainline');
|
||||||
|
return 'moved';
|
||||||
|
}
|
||||||
|
|
||||||
transit.stagesRemaining -= 1;
|
transit.stagesRemaining -= 1;
|
||||||
return 'moved';
|
return 'moved';
|
||||||
}
|
}
|
||||||
@@ -756,25 +805,6 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
// Telegraph/Telephone/Radio are each usable once a Day.
|
// Telegraph/Telephone/Radio are each usable once a Day.
|
||||||
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
||||||
|
|
||||||
/**
|
|
||||||
* THE CLASSIFICATION YARD GOES BACK INTO SERVICE OVERNIGHT.
|
|
||||||
*
|
|
||||||
* ASSUMPTION, flagged rather than derived: Gap 2c says engines and cabooses return to the
|
|
||||||
* Division Yard and everything else to Classification, and the recovered rules never say how
|
|
||||||
* Classification empties. It was write-only — seven places pushed into it and nothing ever read
|
|
||||||
* it — so rolling stock drained one way and left the game. Measured at 30 cars a game dead by
|
|
||||||
* the end, 37% of the 80 dealt at setup.
|
|
||||||
*
|
|
||||||
* Sorting cars for redistribution is what a classification yard is FOR, and a Day is its
|
|
||||||
* natural cycle, so they return at the Day boundary. This does not bind a 5-Day game — the
|
|
||||||
* Division Yard still held 16.6 loaded freight cars at the end and ran dry in 2 games of 100 —
|
|
||||||
* but a campaign-length game would have starved.
|
|
||||||
*/
|
|
||||||
if (s.yards.classificationYard.length > 0) {
|
|
||||||
s.yards.divisionYard.push(...s.yards.classificationYard);
|
|
||||||
s.yards.classificationYard = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
s.clock.day += 1;
|
s.clock.day += 1;
|
||||||
s.clock.stage = 1;
|
s.clock.stage = 1;
|
||||||
s.collisionsToday = 0;
|
s.collisionsToday = 0;
|
||||||
|
|||||||
@@ -1174,6 +1174,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
|
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
|
||||||
);
|
);
|
||||||
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
|
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
|
||||||
|
refillDivisionYardIfEmpty(s);
|
||||||
f.outboundBox.push(e.stock);
|
f.outboundBox.push(e.stock);
|
||||||
s.turn.freightAgentUsed = true;
|
s.turn.freightAgentUsed = true;
|
||||||
break;
|
break;
|
||||||
@@ -1236,6 +1237,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
|
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
|
||||||
);
|
);
|
||||||
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
|
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
|
||||||
|
refillDivisionYardIfEmpty(s);
|
||||||
tray.consist.push(e.stock);
|
tray.consist.push(e.stock);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1674,6 +1676,30 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
|
||||||
|
*
|
||||||
|
* Used Rolling Stock is set out in the Classification Yard; used engines and cabooses go straight
|
||||||
|
* back to the Division Yard. The Classification Yard empties only when the Division Yard is bare —
|
||||||
|
* every car of every kind gone — and then all of it returns at once.
|
||||||
|
*
|
||||||
|
* Confirmed from the source after an earlier guess. The first implementation returned cars at the
|
||||||
|
* DAY boundary, which is a different rule and a much more generous one: it kept the yard topped up
|
||||||
|
* continuously, where this lets it run down to nothing and refill in one go. That difference is the
|
||||||
|
* whole of the supply pressure the game is meant to have.
|
||||||
|
*
|
||||||
|
* Called wherever a car leaves the Division Yard, so the refill happens the moment it empties
|
||||||
|
* rather than at the next convenient tick.
|
||||||
|
*/
|
||||||
|
export function refillDivisionYardIfEmpty(s: GameState): { type: 'yardRefilled'; count: number } | null {
|
||||||
|
if (s.yards.divisionYard.length > 0) return null;
|
||||||
|
if (s.yards.classificationYard.length === 0) return null;
|
||||||
|
const count = s.yards.classificationYard.length;
|
||||||
|
s.yards.divisionYard.push(...s.yards.classificationYard);
|
||||||
|
s.yards.classificationYard = [];
|
||||||
|
return { type: 'yardRefilled', count };
|
||||||
|
}
|
||||||
|
|
||||||
/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
|
/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
|
||||||
function limitsCard(): TrackCard {
|
function limitsCard(): TrackCard {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -584,7 +584,12 @@ export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', co
|
|||||||
export const MANEUVER_CARDS: readonly SimpleCard[] = [
|
export const MANEUVER_CARDS: readonly SimpleCard[] = [
|
||||||
{ key: 'redFlags', name: 'Red Flags', copies: 5, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' },
|
{ key: 'redFlags', name: 'Red Flags', copies: 5, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' },
|
||||||
{ key: 'flyingSwitch', name: 'Flying Switch', copies: 1, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' },
|
{ key: 'flyingSwitch', name: 'Flying Switch', copies: 1, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' },
|
||||||
{ key: 'poling', name: 'Poling', copies: 1, placement: 'any time', effect: 'TBD in the source.' },
|
// POLING IS OUT OF THE DECK, at 0 copies rather than deleted.
|
||||||
|
//
|
||||||
|
// It is the one card whose effect the source records as "TBD", so there is nothing to implement
|
||||||
|
// and inventing something would be worse than leaving it out. Kept in the table with its text so
|
||||||
|
// the gap stays visible and the card can be dealt again the moment its rule is known.
|
||||||
|
{ key: 'poling', name: 'Poling', copies: 0, placement: 'any time', effect: 'TBD in the source.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Played AT other players. We have no player-interaction mechanic yet. */
|
/** Played AT other players. We have no player-interaction mechanic yet. */
|
||||||
|
|||||||
@@ -196,6 +196,13 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
|||||||
// least deterministic and varies between games; it should become a real player choice when
|
// least deterministic and varies between games; it should become a real player choice when
|
||||||
// setup gains an interactive phase. See implications.md §10 Q11.
|
// setup gains an interactive phase. See implications.md §10 Q11.
|
||||||
if (mainlineProfile(card).speed.kind === 'grade') {
|
if (mainlineProfile(card).speed.kind === 'grade') {
|
||||||
|
/**
|
||||||
|
* TEMPORARY, and flagged in TODO. The card prints "(Up)" and "Player sets orientation", so
|
||||||
|
* which way a Heavy Grade climbs is the player's decision — but it is dealt during setup, and
|
||||||
|
* setup has no decision point at all. Rolled from the seed until it gains one, because the
|
||||||
|
* orientation MATTERS: it decides which direction climbs, and therefore what a Brakeman or
|
||||||
|
* Helpers card is worth.
|
||||||
|
*/
|
||||||
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
|
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
|
||||||
}
|
}
|
||||||
return node;
|
return node;
|
||||||
|
|||||||
+13
-2
@@ -202,8 +202,19 @@ export type CrewTray = {
|
|||||||
/** null while a local crew is switching without a train card. */
|
/** null while a local crew is switching without a train card. */
|
||||||
trainNumber: number | null;
|
trainNumber: number | null;
|
||||||
trainIsExtra: boolean;
|
trainIsExtra: boolean;
|
||||||
/** Which end the engine occupies (§A.3). */
|
/**
|
||||||
engineFront: boolean;
|
* WHERE THE ENGINE SITS IN THE TRAY, as an index into `consist`.
|
||||||
|
*
|
||||||
|
* A Crew Tray is an engine plus its Rolling Stock, and the engine may be PULLING (index 0, ahead
|
||||||
|
* of everything), PUSHING (index `consist.length`, behind everything) or somewhere in the middle
|
||||||
|
* doing both at once. That last case is why this is an index and not the boolean it replaced —
|
||||||
|
* `engineFront` was written in three places and read in none, so the engine had a position the
|
||||||
|
* game recorded and never used.
|
||||||
|
*
|
||||||
|
* The engine is NOT one of the `consist` entries: §8.2 counts the consist as Rolling Stock, and
|
||||||
|
* the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
|
||||||
|
*/
|
||||||
|
engineAt: number;
|
||||||
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
|
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
|
||||||
consist: RollingStock[];
|
consist: RollingStock[];
|
||||||
direction: Direction;
|
direction: Direction;
|
||||||
|
|||||||
+61
-2
@@ -24,7 +24,7 @@ import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
|
|||||||
import type { GameEvent } from '../engine/events.ts';
|
import type { GameEvent } from '../engine/events.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import { legalActions } from '../engine/legal.ts';
|
import { legalActions } from '../engine/legal.ts';
|
||||||
import { exitsFrom, facilityVariants, hasPort, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
import { connectionsFor, exitsFrom, facilityVariants, hasPort, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
import { coordKey } from '../engine/state.ts';
|
import { coordKey } from '../engine/state.ts';
|
||||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||||
@@ -524,6 +524,60 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
|||||||
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
|
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
|
||||||
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
|
variantsFor(i.geometry)[i.variant ?? 0]?.arc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAIL THAT CAN NEVER GO ANYWHERE.
|
||||||
|
*
|
||||||
|
* A port facing an EMPTY square is a promise: something may be built there later. A port butting
|
||||||
|
* an OCCUPIED square whose card has no matching port is nothing of the kind — that square is
|
||||||
|
* taken, so the rail stops dead and always will.
|
||||||
|
*
|
||||||
|
* Measured, 28% of the pieces the bot laid had one, at 4.26 a game: an arc dropped off a turnout
|
||||||
|
* with its far end jammed into a curve that could not accept it, which is exactly the placement
|
||||||
|
* that prompted this. Penalised rather than forbidden — a piece can still be worth laying for the
|
||||||
|
* end that DOES connect, and the last two attempts to forbid something outright starved the
|
||||||
|
* district instead of tidying it.
|
||||||
|
*/
|
||||||
|
const deadEnds = (i: Extract<Intent, { type: 'track.lay' }>): number => {
|
||||||
|
const v = variantsFor(i.geometry)[i.variant ?? 0];
|
||||||
|
const probe = {
|
||||||
|
geometry: {
|
||||||
|
kind: 'track',
|
||||||
|
geometry: i.geometry,
|
||||||
|
...(v?.axis ? { axis: v.axis } : {}),
|
||||||
|
...(v?.arc ? { arc: v.arc } : {}),
|
||||||
|
...(v?.turnout ? { turnout: v.turnout } : {}),
|
||||||
|
},
|
||||||
|
baseOperationalRail: true,
|
||||||
|
standing: [],
|
||||||
|
facility: null,
|
||||||
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
|
} as unknown as TrackCard;
|
||||||
|
let dead = 0;
|
||||||
|
for (const p of new Set(connectionsFor(probe).flat())) {
|
||||||
|
const n = neighbour(i.placement, p);
|
||||||
|
const nb = at(n.row, n.col);
|
||||||
|
if (!nb) continue; // empty — may still be built on
|
||||||
|
if (hasPort(nb, opposite(p))) continue; // joins
|
||||||
|
dead++;
|
||||||
|
}
|
||||||
|
return dead;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arcs left to build BENEATH a turnout.
|
||||||
|
*
|
||||||
|
* A turnout is a hole in the Running Track and is worth cutting only for what hangs under it, so
|
||||||
|
* cutting one with nothing left to hang there is pure loss. Measured at 0% today — the bot builds
|
||||||
|
* the arc immediately after the turnout and never runs the supply dry first — so this is a guard
|
||||||
|
* against the supply changing rather than a fix for something happening now.
|
||||||
|
*/
|
||||||
|
const arcsLeft =
|
||||||
|
(area.trackSupply.get('curved:left') ?? 0) +
|
||||||
|
(area.trackSupply.get('curved:right') ?? 0) +
|
||||||
|
(area.trackSupply.get('sharpCurved:left') ?? 0) +
|
||||||
|
(area.trackSupply.get('sharpCurved:right') ?? 0);
|
||||||
|
|
||||||
// Where the district already turns down off the main.
|
// Where the district already turns down off the main.
|
||||||
const turnouts = [...area.grid.entries()]
|
const turnouts = [...area.grid.entries()]
|
||||||
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c))
|
.filter(([k, c]) => Number(k.split(',')[0]) === area.runningRow && divergesSouth(c))
|
||||||
@@ -562,7 +616,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
|||||||
// without competing further with the ways down. Scored below a first turnout, which is still
|
// without competing further with the ways down. Scored below a first turnout, which is still
|
||||||
// the most valuable single piece on the board, and below closing a run-around.
|
// the most valuable single piece on the board, and below closing a run-around.
|
||||||
bonus += 13;
|
bonus += 13;
|
||||||
} else if (row === area.runningRow && i.geometry === 'turnout') {
|
} else if (row === area.runningRow && i.geometry === 'turnout' && arcsLeft > 0) {
|
||||||
// A first way down is the most valuable single piece on the board; a second closes the
|
// A first way down is the most valuable single piece on the board; a second closes the
|
||||||
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
|
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
|
||||||
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
|
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
|
||||||
@@ -618,6 +672,11 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
|||||||
if (i.geometry === 'straight') score += 2;
|
if (i.geometry === 'straight') score += 2;
|
||||||
if (dRow > 0) score += 3;
|
if (dRow > 0) score += 3;
|
||||||
|
|
||||||
|
// A TIE-BREAKER, not a veto. Applied to the distance score rather than the bonus, so it can
|
||||||
|
// never push a piece below the "builds nothing" floor and refuse it outright: given two
|
||||||
|
// placements the heuristics rate the same, take the one whose rail can still grow.
|
||||||
|
score -= deadEnds(i) * 0.5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DECLINE A PIECE THAT BUILDS NOTHING.
|
* DECLINE A PIECE THAT BUILDS NOTHING.
|
||||||
*
|
*
|
||||||
|
|||||||
+19
-5
@@ -113,6 +113,10 @@ export function summarize(
|
|||||||
const reason = e.reason;
|
const reason = e.reason;
|
||||||
if (reason.startsWith('collision')) rev.collisionLoss += -e.delta;
|
if (reason.startsWith('collision')) rev.collisionLoss += -e.delta;
|
||||||
else if (reason === 'freightLoad') rev.freightLoad += e.delta;
|
else if (reason === 'freightLoad') rev.freightLoad += e.delta;
|
||||||
|
// `freightUnload` is its own reason on its own event (apply.ts). It used to be counted from
|
||||||
|
// `unloadBegan` instead — unloads STARTED, not Revenue EARNED — and the two differ by every
|
||||||
|
// unload that never finished.
|
||||||
|
else if (reason === 'freightUnload') rev.freightUnload += e.delta;
|
||||||
else if (reason === 'boarding') rev.passengerBoard += e.delta;
|
else if (reason === 'boarding') rev.passengerBoard += e.delta;
|
||||||
else if (reason === 'detraining') rev.passengerDetrain += e.delta;
|
else if (reason === 'detraining') rev.passengerDetrain += e.delta;
|
||||||
}
|
}
|
||||||
@@ -129,12 +133,21 @@ export function summarize(
|
|||||||
|
|
||||||
for (const i of intents) inc(intentCounts, i);
|
for (const i of intents) inc(intentCounts, i);
|
||||||
|
|
||||||
// An unload scores through the same event as a load completion in the reducer, so count the
|
|
||||||
// distinct operations directly.
|
|
||||||
rev.freightUnload = eventCounts['unloadBegan'] ?? 0;
|
|
||||||
rev.net = final.players.reduce((n, p) => n + p.revenue, 0);
|
rev.net = final.players.reduce((n, p) => n + p.revenue, 0);
|
||||||
|
|
||||||
const grossFreight = rev.freightLoad;
|
/**
|
||||||
|
* FREIGHT IS BOTH HALVES.
|
||||||
|
*
|
||||||
|
* A completed load and a completed unload each earn a point, on two distinct `revenueChanged`
|
||||||
|
* reasons — and this counted only the first, so `freightShare` reported roughly 60% of the
|
||||||
|
* freight actually earned. That is the number the "freight is only 13-18% of gross" finding was
|
||||||
|
* read from, and it drove the Gap 12 industry-density change.
|
||||||
|
*
|
||||||
|
* The comment that stood here claimed "an unload scores through the same event as a load
|
||||||
|
* completion in the reducer", which is not true: `apply.ts` emits `freightUnload` separately. It
|
||||||
|
* was a comment asserting a fact about code a few lines away, and it was wrong.
|
||||||
|
*/
|
||||||
|
const grossFreight = rev.freightLoad + rev.freightUnload;
|
||||||
const grossPassenger = rev.passengerBoard + rev.passengerDetrain;
|
const grossPassenger = rev.passengerBoard + rev.passengerDetrain;
|
||||||
const gross = grossFreight + grossPassenger;
|
const gross = grossFreight + grossPassenger;
|
||||||
|
|
||||||
@@ -347,7 +360,7 @@ export type StrategyBucket = {
|
|||||||
* consistently outscore passenger specialists (or vice versa), it shows up here.
|
* consistently outscore passenger specialists (or vice versa), it shows up here.
|
||||||
*/
|
*/
|
||||||
export function strategyBuckets(all: GameStats[]): StrategyBucket[] {
|
export function strategyBuckets(all: GameStats[]): StrategyBucket[] {
|
||||||
const scored = all.filter((g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0);
|
const scored = all.filter((g) => g.revenue.freightLoad + g.revenue.freightUnload + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0);
|
||||||
|
|
||||||
const buckets: { label: string; test: (g: GameStats) => boolean }[] = [
|
const buckets: { label: string; test: (g: GameStats) => boolean }[] = [
|
||||||
{ label: 'passenger-only (freight 0%)', test: (g) => g.freightShare === 0 },
|
{ label: 'passenger-only (freight 0%)', test: (g) => g.freightShare === 0 },
|
||||||
@@ -408,6 +421,7 @@ export function formatAggregate(all: GameStats[]): string {
|
|||||||
out.push(' REVENUE (mean per game)');
|
out.push(' REVENUE (mean per game)');
|
||||||
out.push(` net ${num(meanOf(all, (g) => g.revenue.net))}`);
|
out.push(` net ${num(meanOf(all, (g) => g.revenue.net))}`);
|
||||||
out.push(` from freight loads ${num(meanOf(all, (g) => g.revenue.freightLoad))}`);
|
out.push(` from freight loads ${num(meanOf(all, (g) => g.revenue.freightLoad))}`);
|
||||||
|
out.push(` from freight unloads ${num(meanOf(all, (g) => g.revenue.freightUnload))}`);
|
||||||
out.push(` from passengers on ${num(meanOf(all, (g) => g.revenue.passengerBoard))}`);
|
out.push(` from passengers on ${num(meanOf(all, (g) => g.revenue.passengerBoard))}`);
|
||||||
out.push(` from passengers off ${num(meanOf(all, (g) => g.revenue.passengerDetrain))}`);
|
out.push(` from passengers off ${num(meanOf(all, (g) => g.revenue.passengerDetrain))}`);
|
||||||
out.push(` lost to collisions ${num(-meanOf(all, (g) => g.revenue.collisionLoss))}`);
|
out.push(` lost to collisions ${num(-meanOf(all, (g) => g.revenue.collisionLoss))}`);
|
||||||
|
|||||||
+46
-1
@@ -179,6 +179,19 @@ export type Frame = {
|
|||||||
departments: string[];
|
departments: string[];
|
||||||
/** What each face-up Department card does. */
|
/** What each face-up Department card does. */
|
||||||
departmentsWhat: string[];
|
departmentsWhat: string[];
|
||||||
|
/**
|
||||||
|
* The two yards, by car type.
|
||||||
|
*
|
||||||
|
* Rolling stock is finite and the Classification Yard only returns to service when the Division
|
||||||
|
* Yard is BARE, so watching the Division Yard run down is now real information — and the game
|
||||||
|
* showed neither yard at all.
|
||||||
|
*/
|
||||||
|
yards: {
|
||||||
|
division: { type: string; loaded: number; empty: number }[];
|
||||||
|
classification: { type: string; loaded: number; empty: number }[];
|
||||||
|
divisionTotal: number;
|
||||||
|
classificationTotal: number;
|
||||||
|
};
|
||||||
/** 12 slots; the train number due out at each Stage, or null. */
|
/** 12 slots; the train number due out at each Stage, or null. */
|
||||||
timetable: (number | null)[];
|
timetable: (number | null)[];
|
||||||
blocked: Impediment[];
|
blocked: Impediment[];
|
||||||
@@ -598,6 +611,12 @@ export function snapshot(
|
|||||||
deck: s.decks.homeOffice.length,
|
deck: s.decks.homeOffice.length,
|
||||||
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
|
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
|
||||||
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
|
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
|
||||||
|
yards: {
|
||||||
|
division: countStock(s.yards.divisionYard),
|
||||||
|
classification: countStock(s.yards.classificationYard),
|
||||||
|
divisionTotal: s.yards.divisionYard.length,
|
||||||
|
classificationTotal: s.yards.classificationYard.length,
|
||||||
|
},
|
||||||
timetable: [...s.timetable],
|
timetable: [...s.timetable],
|
||||||
decision,
|
decision,
|
||||||
wasted,
|
wasted,
|
||||||
@@ -889,12 +908,38 @@ const MODIFIER_NAMES: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
|
/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
|
||||||
|
/** Cars in a yard, grouped by type and split loaded / empty, in a stable order. */
|
||||||
|
function countStock(
|
||||||
|
stock: readonly { type: string; loaded: boolean }[],
|
||||||
|
): { type: string; loaded: number; empty: number }[] {
|
||||||
|
const order = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
|
||||||
|
const by = new Map<string, { type: string; loaded: number; empty: number }>();
|
||||||
|
for (const c of stock) {
|
||||||
|
const row = by.get(c.type) ?? { type: c.type, loaded: 0, empty: 0 };
|
||||||
|
if (c.loaded) row.loaded += 1;
|
||||||
|
else row.empty += 1;
|
||||||
|
by.set(c.type, row);
|
||||||
|
}
|
||||||
|
return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
|
||||||
|
}
|
||||||
|
|
||||||
function trainChip(s: GameState, id: string): TrainChip {
|
function trainChip(s: GameState, id: string): TrainChip {
|
||||||
const t = s.trays.get(id);
|
const t = s.trays.get(id);
|
||||||
if (!t) return { label: id, consist: [] };
|
if (!t) return { label: id, consist: [] };
|
||||||
|
/**
|
||||||
|
* The engine is drawn IN the consist, at the position it occupies.
|
||||||
|
*
|
||||||
|
* A Crew Tray is an engine plus its Rolling Stock, and the engine may be pulling, pushing, or in
|
||||||
|
* the middle doing both — which is a thing a player has to be able to see, since it decides which
|
||||||
|
* end cars couple onto (§A.3) and which way the train can shove. It was recorded as a boolean
|
||||||
|
* that nothing read, so every consist was drawn as an anonymous row of cars.
|
||||||
|
*/
|
||||||
|
const cars = t.consist.map(carLabel);
|
||||||
|
const at = Math.max(0, Math.min(cars.length, t.engineAt));
|
||||||
|
cars.splice(at, 0, 'ENG');
|
||||||
return {
|
return {
|
||||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||||
consist: t.consist.map(carLabel),
|
consist: cars,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ function render(): void {
|
|||||||
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
|
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
|
||||||
: '<span class="dim">none</span>';
|
: '<span class="dim">none</span>';
|
||||||
|
|
||||||
|
renderYards(f);
|
||||||
|
|
||||||
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
|
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
|
||||||
$('supply').innerHTML =
|
$('supply').innerHTML =
|
||||||
`<div class="dim">${total} pieces left · one may be laid per turn, during the DRAW option</div>` +
|
`<div class="dim">${total} pieces left · one may be laid per turn, during the DRAW option</div>` +
|
||||||
@@ -297,6 +299,38 @@ function render(): void {
|
|||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two yards, and how close the Division Yard is to running out.
|
||||||
|
*
|
||||||
|
* §2 — used Rolling Stock is set out in the Classification Yard, and it only returns when the
|
||||||
|
* Division Yard is BARE. So the interesting number is not how much has been used but how little is
|
||||||
|
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
||||||
|
*/
|
||||||
|
function renderYards(f: ReturnType<typeof view>): void {
|
||||||
|
const row = (rows: { type: string; loaded: number; empty: number }[]): string =>
|
||||||
|
rows.length === 0
|
||||||
|
? '<span class="dim">empty</span>'
|
||||||
|
: rows
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
|
||||||
|
`<b>${esc(r.type)}</b> <span class="ld">${r.loaded}</span>/<span class="mt">${r.empty}</span></span>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
$('divyard').innerHTML = row(f.yards.division);
|
||||||
|
$('clsyard').innerHTML = row(f.yards.classification);
|
||||||
|
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
|
||||||
|
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
|
||||||
|
|
||||||
|
// The one thing worth calling out: the yard about to turn over.
|
||||||
|
const bare = f.yards.divisionTotal === 0;
|
||||||
|
$('divyard').classList.toggle('bare', bare);
|
||||||
|
$('yardnote').textContent = bare
|
||||||
|
? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.`
|
||||||
|
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
||||||
|
}
|
||||||
|
|
||||||
function renderDistrict(f: ReturnType<typeof view>): void {
|
function renderDistrict(f: ReturnType<typeof view>): void {
|
||||||
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
||||||
const sec = $('district');
|
const sec = $('district');
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ header button:hover{border-color:#4d6fa8}
|
|||||||
explicitly not a target. Previously the hand was a list of bare names and the Department slots a
|
explicitly not a target. Previously the hand was a list of bare names and the Department slots a
|
||||||
second list, so nothing looked like a card at all. */
|
second list, so nothing looked like a card at all. */
|
||||||
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
|
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
|
||||||
|
/* THE YARDS. Rolling stock is finite and the Classification Yard only comes back when the Division
|
||||||
|
Yard is bare, so a player has to be able to watch the supply run down. */
|
||||||
|
.yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px}
|
||||||
|
.stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px}
|
||||||
|
.stock b{color:#e6e9ee}
|
||||||
|
.stock .ld{color:#8fd6a0}
|
||||||
|
.stock .mt{color:#9fb6d8}
|
||||||
|
.stock.none{opacity:.4}
|
||||||
|
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
|
||||||
.handcard{background:#242c36;border:1px solid var(--line);border-radius:5px;
|
.handcard{background:#242c36;border:1px solid var(--line);border-radius:5px;
|
||||||
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
|
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
|
||||||
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
|
||||||
@@ -213,6 +222,13 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
|
|||||||
<h3>Department Cards (face up)</h3><div id="depts" class="cardrow ref">—</div>
|
<h3>Department Cards (face up)</h3><div id="depts" class="cardrow ref">—</div>
|
||||||
</section>
|
</section>
|
||||||
<section><h2 class="big">Your Track Supply</h2><div id="supply"></div></section>
|
<section><h2 class="big">Your Track Supply</h2><div id="supply"></div></section>
|
||||||
|
<section><h2 class="big">The Yards</h2>
|
||||||
|
<h3>Division Yard <span class="dim" id="divtot"></span></h3>
|
||||||
|
<div id="divyard" class="yard"></div>
|
||||||
|
<h3>Classification Yard <span class="dim" id="clstot"></span></h3>
|
||||||
|
<div id="clsyard" class="yard"></div>
|
||||||
|
<div class="dim" id="yardnote"></div>
|
||||||
|
</section>
|
||||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Station Master — replays</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#12151a;--fg:#e6e9ee;--dim:#8b94a3;--line:#2c333d;--panel:#1a1f26}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--fg);
|
||||||
|
font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||||
|
a{color:#5aa9e6}
|
||||||
|
header{background:var(--panel);border-bottom:1px solid var(--line);padding:10px 16px;
|
||||||
|
display:flex;gap:18px;align-items:baseline;flex-wrap:wrap;position:sticky;top:0;z-index:5}
|
||||||
|
header b{font-size:16px}
|
||||||
|
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||||||
|
.home:hover{color:#5aa9e6}
|
||||||
|
.build{margin-left:auto;font-size:11px;opacity:.7}
|
||||||
|
main{padding:18px;max-width:1300px;margin:0 auto}
|
||||||
|
h2{font-size:12px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);margin:0 0 8px}
|
||||||
|
section{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin-bottom:14px}
|
||||||
|
.dim{color:var(--dim)}
|
||||||
|
.sm{font-size:11px}
|
||||||
|
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
|
||||||
|
padding:6px 11px;margin:2px 4px 2px 0;cursor:pointer;font:inherit;font-size:13px;text-align:left}
|
||||||
|
button:hover{background:#39424e;border-color:#4d6fa8}
|
||||||
|
button.entry{display:block;width:100%;margin-bottom:6px}
|
||||||
|
#perr{color:#e58080;min-height:18px;font-size:12px}
|
||||||
|
input[type=file]{font:inherit;font-size:12px;color:var(--dim)}
|
||||||
|
/* viewer */
|
||||||
|
#viewer{display:none}
|
||||||
|
.vbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
|
||||||
|
.vgrid{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:14px;align-items:start}
|
||||||
|
@media(max-width:1100px){.vgrid{grid-template-columns:1fr}}
|
||||||
|
.scroll{overflow-x:auto}
|
||||||
|
#vlog{max-height:420px;overflow:auto;font-size:12px}
|
||||||
|
.line{padding:1px 0}
|
||||||
|
/* Auto-hide, matching the playable page and the standalone replay. Folded leaves a summary rather
|
||||||
|
than nothing, because a panel that disappears reads as broken instead of folded away. */
|
||||||
|
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||||||
|
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||||
|
button.ghost:hover{border-color:#4d6fa8;color:#fff}
|
||||||
|
#vdistrict.folded .scroll{display:none}
|
||||||
|
#vdistrict.folded #vdistrictsummary{display:block;padding:2px 0}
|
||||||
|
#vdistrictsummary{display:none}
|
||||||
|
.line.now{background:rgba(90,169,230,.10)}
|
||||||
|
.t-good{color:#8fd6a0}.t-bad{color:#e58080}.t-clock{color:#9fb6d8;font-weight:600}.t-quiet{color:var(--dim)}
|
||||||
|
select{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:5px;font:inherit;font-size:12px}
|
||||||
|
input[type=range]{flex:1;min-width:180px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<b><a class="home" href="./index.html">Station Master</a></b>
|
||||||
|
<span class="dim">replays</span>
|
||||||
|
<span class="build">build __BUILD__</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="picker">
|
||||||
|
<section>
|
||||||
|
<h2>Saved on this site</h2>
|
||||||
|
<div id="hosted"><p class="dim">loading…</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Open a save file</h2>
|
||||||
|
<p class="dim">A Station Master save is a small <code>.json</code> file — a seed and the list of
|
||||||
|
moves made. That is enough to rebuild the whole game, so a replay can be emailed like a text file.
|
||||||
|
Save one from inside a game with <b>Save replay</b>.</p>
|
||||||
|
<input type="file" id="file" accept=".json,application/json">
|
||||||
|
<div id="perr"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="viewer">
|
||||||
|
<section>
|
||||||
|
<div class="vbar">
|
||||||
|
<button id="vclose">← all replays</button>
|
||||||
|
<b id="vtitle">—</b>
|
||||||
|
<span class="dim" id="vnote"></span>
|
||||||
|
</div>
|
||||||
|
<div class="vbar">
|
||||||
|
<button id="vfirst">⏮ start</button>
|
||||||
|
<button id="vback">◀ step</button>
|
||||||
|
<button id="vplay">▶ play</button>
|
||||||
|
<button id="vfwd">step ▶</button>
|
||||||
|
<button id="vstage">next Stage ⏭</button>
|
||||||
|
<button id="vlast">end ⏭</button>
|
||||||
|
<select id="vspeed">
|
||||||
|
<option value="3000">extra slow</option>
|
||||||
|
<option value="1200">slow</option>
|
||||||
|
<option value="700" selected>normal</option>
|
||||||
|
<option value="280">fast</option>
|
||||||
|
<option value="90">very fast</option>
|
||||||
|
</select>
|
||||||
|
<button id="vsound">🔇 muted</button>
|
||||||
|
<input type="range" id="vscrub" min="0" max="0" value="0">
|
||||||
|
<span class="dim" id="vpos">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="vbar">
|
||||||
|
<span id="vclock">—</span>
|
||||||
|
<span>phase: <b id="vphase">—</b></span>
|
||||||
|
<span>Revenue <b id="vrev">0</b></span>
|
||||||
|
<span class="dim">← → to step, space to play</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="vgrid">
|
||||||
|
<div>
|
||||||
|
<section><h2>The Division</h2><div class="scroll" id="vdivision"></div></section>
|
||||||
|
<section id="vdistrict"><h2>Office Area <button id="vdistricttoggle" class="ghost">auto-hide: on</button></h2>
|
||||||
|
<div id="vdistrictsummary" class="dim"></div>
|
||||||
|
<div class="scroll" id="vgrid"></div></section>
|
||||||
|
</div>
|
||||||
|
<section><h2>What happened</h2><div id="vlog"></div></section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="./web/replays.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+51
-2
@@ -12,6 +12,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
|
||||||
|
import { cuesFor } from '../sim/narrate.ts';
|
||||||
|
import { playCue } from './sound.ts';
|
||||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||||
import { narrate } from '../sim/narrate.ts';
|
import { narrate } from '../sim/narrate.ts';
|
||||||
import { cardName, trainName } from '../sim/view.ts';
|
import { cardName, trainName } from '../sim/view.ts';
|
||||||
@@ -35,7 +37,12 @@ const esc = (s: string): string =>
|
|||||||
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||||
|
|
||||||
/** Every position the save passes through, with the lines narrated along the way. */
|
/** Every position the save passes through, with the lines narrated along the way. */
|
||||||
type Step = { frame: Frame; lines: { text: string; tone: string }[] };
|
type Step = {
|
||||||
|
frame: Frame;
|
||||||
|
lines: { text: string; tone: string }[];
|
||||||
|
/** What this step should sound like — from `cuesFor`, the same function the live game uses. */
|
||||||
|
cues: string[];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild a game from a save, keeping a snapshot at each step.
|
* Rebuild a game from a save, keeping a snapshot at each step.
|
||||||
@@ -54,7 +61,7 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
|||||||
const n = narrate(e, ctx);
|
const n = narrate(e, ctx);
|
||||||
return { text: n.text, tone: n.tone };
|
return { text: n.text, tone: n.tone };
|
||||||
});
|
});
|
||||||
steps.push({ frame: snapshot(s, [], null), lines });
|
steps.push({ frame: snapshot(s, [], null), lines, cues: cuesFor(events) });
|
||||||
};
|
};
|
||||||
|
|
||||||
push(pump(s));
|
push(pump(s));
|
||||||
@@ -80,13 +87,28 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
|||||||
let steps: Step[] = [];
|
let steps: Step[] = [];
|
||||||
let at = 0;
|
let at = 0;
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
/**
|
||||||
|
* Sound and auto-hide, matching the playable page and the standalone replay.
|
||||||
|
*
|
||||||
|
* This viewer is a third place a game gets drawn, and it had drifted: three speeds where the
|
||||||
|
* standalone had five, and neither of these. Anything a player learns on one screen should hold on
|
||||||
|
* the others.
|
||||||
|
*/
|
||||||
|
let soundOn = false;
|
||||||
|
let districtMode: 'auto' | 'open' | 'closed' = 'auto';
|
||||||
|
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
|
||||||
|
|
||||||
function show(i: number): void {
|
function show(i: number): void {
|
||||||
|
const from = at;
|
||||||
at = Math.max(0, Math.min(steps.length - 1, i));
|
at = Math.max(0, Math.min(steps.length - 1, i));
|
||||||
const step = steps[at];
|
const step = steps[at];
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
const f = step.frame;
|
const f = step.frame;
|
||||||
|
|
||||||
|
// Only a single step FORWARD sounds. Scrubbing across a hundred steps would fire a hundred
|
||||||
|
// whistles, and stepping back would sound a Stage ending that is being un-done.
|
||||||
|
if (soundOn && at === from + 1) for (const c of step.cues) playCue(c);
|
||||||
|
|
||||||
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
|
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
|
||||||
$('vphase').textContent = f.phase;
|
$('vphase').textContent = f.phase;
|
||||||
$('vrev').textContent = String(f.revenue);
|
$('vrev').textContent = String(f.revenue);
|
||||||
@@ -95,6 +117,21 @@ function show(i: number): void {
|
|||||||
$('vdivision').innerHTML = divisionSvg(f.division);
|
$('vdivision').innerHTML = divisionSvg(f.division);
|
||||||
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
|
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
|
||||||
|
|
||||||
|
// Auto-hide: the district is worth its space during the phases that change it.
|
||||||
|
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
||||||
|
const sec = $('vdistrict');
|
||||||
|
if (open) sec.classList.remove('folded');
|
||||||
|
else sec.classList.add('folded');
|
||||||
|
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
|
||||||
|
$('vdistrictsummary').textContent =
|
||||||
|
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing`;
|
||||||
|
$('vdistricttoggle').textContent =
|
||||||
|
districtMode === 'auto'
|
||||||
|
? (open ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
|
||||||
|
: districtMode === 'open'
|
||||||
|
? 'always showing — click for auto-hide'
|
||||||
|
: 'always hidden — click for auto-hide';
|
||||||
|
|
||||||
// A window of history rather than only this step, so a jump lands in context.
|
// A window of history rather than only this step, so a jump lands in context.
|
||||||
let log = '';
|
let log = '';
|
||||||
for (let k = Math.max(0, at - 18); k <= at; k++) {
|
for (let k = Math.max(0, at - 18); k <= at; k++) {
|
||||||
@@ -215,6 +252,18 @@ function wire(): void {
|
|||||||
show(at + 1);
|
show(at + 1);
|
||||||
}, Number(($('vspeed') as HTMLSelectElement).value));
|
}, Number(($('vspeed') as HTMLSelectElement).value));
|
||||||
};
|
};
|
||||||
|
$('vdistricttoggle').onclick = () => {
|
||||||
|
const f = steps[at]?.frame;
|
||||||
|
const open = districtMode === 'auto' ? (f ? FOCUS_PHASES.has(f.phaseKey) : true) : districtMode === 'open';
|
||||||
|
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
||||||
|
show(at);
|
||||||
|
};
|
||||||
|
$('vsound').onclick = () => {
|
||||||
|
soundOn = !soundOn;
|
||||||
|
$('vsound').textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
|
||||||
|
// Confirms the change, and gives the browser the gesture it needs before audio may start.
|
||||||
|
if (soundOn) playCue('stage');
|
||||||
|
};
|
||||||
$('vclose').onclick = () => {
|
$('vclose').onclick = () => {
|
||||||
if (timer) clearInterval(timer);
|
if (timer) clearInterval(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ describe('Mainline Phase (§8)', () => {
|
|||||||
id: `t${i}`,
|
id: `t${i}`,
|
||||||
trainNumber: spec.n,
|
trainNumber: spec.n,
|
||||||
trainIsExtra: spec.extra,
|
trainIsExtra: spec.extra,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist: [],
|
consist: [],
|
||||||
direction: 'east',
|
direction: 'east',
|
||||||
position: { at: 'divisionPoint', side: 'west' },
|
position: { at: 'divisionPoint', side: 'west' },
|
||||||
@@ -287,7 +287,7 @@ describe('collisions are automatic (Gap 2)', () => {
|
|||||||
id,
|
id,
|
||||||
trainNumber: 2,
|
trainNumber: 2,
|
||||||
trainIsExtra: false,
|
trainIsExtra: false,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist: [{ type: 'coach', loaded: true }],
|
consist: [{ type: 'coach', loaded: true }],
|
||||||
direction: 'east',
|
direction: 'east',
|
||||||
position: { at: 'mainline', index: 1 },
|
position: { at: 'mainline', index: 1 },
|
||||||
@@ -328,7 +328,7 @@ describe('collisions are automatic (Gap 2)', () => {
|
|||||||
id: 'x',
|
id: 'x',
|
||||||
trainNumber: 8,
|
trainNumber: 8,
|
||||||
trainIsExtra: false,
|
trainIsExtra: false,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist: [
|
consist: [
|
||||||
{ type: 'hopper', loaded: true },
|
{ type: 'hopper', loaded: true },
|
||||||
{ type: 'caboose', loaded: true },
|
{ type: 'caboose', loaded: true },
|
||||||
@@ -359,7 +359,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
id: 'ahead',
|
id: 'ahead',
|
||||||
trainNumber: 4,
|
trainNumber: 4,
|
||||||
trainIsExtra: false,
|
trainIsExtra: false,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist: [],
|
consist: [],
|
||||||
direction: 'east',
|
direction: 'east',
|
||||||
position: { at: 'mainline', index: 1 },
|
position: { at: 'mainline', index: 1 },
|
||||||
@@ -379,7 +379,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
id: 'behind',
|
id: 'behind',
|
||||||
trainNumber: 2,
|
trainNumber: 2,
|
||||||
trainIsExtra: false,
|
trainIsExtra: false,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist: [],
|
consist: [],
|
||||||
direction: 'east',
|
direction: 'east',
|
||||||
position: { at: 'divisionPoint', side: 'west' },
|
position: { at: 'divisionPoint', side: 'west' },
|
||||||
@@ -402,7 +402,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
id: 'oncoming',
|
id: 'oncoming',
|
||||||
trainNumber: 3,
|
trainNumber: 3,
|
||||||
trainIsExtra: false,
|
trainIsExtra: false,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist: [],
|
consist: [],
|
||||||
direction: 'west',
|
direction: 'west',
|
||||||
position: { at: 'mainline', index: 1 },
|
position: { at: 'mainline', index: 1 },
|
||||||
@@ -421,7 +421,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
id: 'waiting',
|
id: 'waiting',
|
||||||
trainNumber: 2,
|
trainNumber: 2,
|
||||||
trainIsExtra: false,
|
trainIsExtra: false,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist: [],
|
consist: [],
|
||||||
direction: 'east',
|
direction: 'east',
|
||||||
position: { at: 'divisionPoint', side: 'west' },
|
position: { at: 'divisionPoint', side: 'west' },
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist']
|
|||||||
id,
|
id,
|
||||||
trainNumber: null,
|
trainNumber: null,
|
||||||
trainIsExtra: false,
|
trainIsExtra: false,
|
||||||
engineFront: true,
|
engineAt: 0,
|
||||||
consist,
|
consist,
|
||||||
direction: 'east',
|
direction: 'east',
|
||||||
position: { at: 'grid', owner: 0, coord },
|
position: { at: 'grid', owner: 0, coord },
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function handEnhancement(s: GameState, key: string): string {
|
|||||||
function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string {
|
function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string {
|
||||||
const id = s.freeTrays.pop()!;
|
const id = s.freeTrays.pop()!;
|
||||||
s.trays.set(id, {
|
s.trays.set(id, {
|
||||||
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
|
id, trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||||
consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0,
|
consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
return id;
|
return id;
|
||||||
@@ -185,7 +185,7 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
|||||||
function inbound(s: GameState, consist: TrackCard['standing']) {
|
function inbound(s: GameState, consist: TrackCard['standing']) {
|
||||||
const id = 'inbound';
|
const id = 'inbound';
|
||||||
s.trays.set(id, {
|
s.trays.set(id, {
|
||||||
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
|
id, trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||||
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
const ml = s.division.nodes[1];
|
const ml = s.division.nodes[1];
|
||||||
@@ -264,11 +264,11 @@ describe('ABS Signals amend the collision rule', () => {
|
|||||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||||
}
|
}
|
||||||
s.trays.set('ahead', {
|
s.trays.set('ahead', {
|
||||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
s.trays.set('behind', {
|
s.trays.set('behind', {
|
||||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
|
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
s.clock.phase = 'mainline';
|
s.clock.phase = 'mainline';
|
||||||
@@ -292,7 +292,7 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
|||||||
}
|
}
|
||||||
// An oncoming senior train — §8.1 makes this an absolute bar without a device.
|
// An oncoming senior train — §8.1 makes this an absolute bar without a device.
|
||||||
s.trays.set('oncoming', {
|
s.trays.set('oncoming', {
|
||||||
id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineFront: true,
|
id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
const ml = s.division.nodes[1];
|
const ml = s.division.nodes[1];
|
||||||
@@ -304,7 +304,7 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
|||||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||||
}
|
}
|
||||||
s.trays.set('mine', {
|
s.trays.set('mine', {
|
||||||
id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
s.clock.phase = 'mainline';
|
s.clock.phase = 'mainline';
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ import { advance } from '../src/engine/advance.ts';
|
|||||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||||
import {
|
import {
|
||||||
MAINLINE_MODIFIER_CARDS,
|
MAINLINE_MODIFIER_CARDS,
|
||||||
|
MAINLINE_PROFILES,
|
||||||
MANEUVER_CARDS,
|
MANEUVER_CARDS,
|
||||||
REALIGNMENTS,
|
REALIGNMENTS,
|
||||||
crossingStages,
|
crossingStages,
|
||||||
mainlineModifierRule,
|
mainlineModifierRule,
|
||||||
|
mainlineProfile,
|
||||||
} from '../src/engine/content.ts';
|
} from '../src/engine/content.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
|
import type { GameEvent } from '../src/engine/events.ts';
|
||||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||||
import { coordKey } from '../src/engine/state.ts';
|
import { coordKey } from '../src/engine/state.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
@@ -210,11 +213,11 @@ describe('Red Flags protect a stopped train', () => {
|
|||||||
const node = pinned(s, 1, 'plains');
|
const node = pinned(s, 1, 'plains');
|
||||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||||
s.trays.set('ahead', {
|
s.trays.set('ahead', {
|
||||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
s.trays.set('behind', {
|
s.trays.set('behind', {
|
||||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
|
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
const dp = s.division.nodes[0];
|
const dp = s.division.nodes[0];
|
||||||
@@ -313,7 +316,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
|||||||
area.grid.set(coordKey(at(-2, 0)), industry);
|
area.grid.set(coordKey(at(-2, 0)), industry);
|
||||||
|
|
||||||
s.trays.set('crew', {
|
s.trays.set('crew', {
|
||||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
|
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
||||||
// Facing SOUTH, down the spur. `direction` only carries east/west, so a crew on north-south
|
// Facing SOUTH, down the spur. `direction` only carries east/west, so a crew on north-south
|
||||||
// track needs its actual port — without it the engine looks for an east exit the card has not
|
// track needs its actual port — without it the engine looks for an east exit the card has not
|
||||||
@@ -405,12 +408,17 @@ describe('card coverage', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Poling remains unimplemented, deliberately', () => {
|
it('Poling stays unimplemented AND out of the deck, deliberately', () => {
|
||||||
// Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
|
// Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
|
||||||
// source says otherwise, a silent no-op would be worse than a rejection.
|
// source says otherwise, a silent no-op would be worse than a rejection.
|
||||||
|
//
|
||||||
|
// It is dealt zero copies rather than deleted: a card that cannot be played is worse in a
|
||||||
|
// player's hand than absent from the deck, but the entry stays so the gap remains visible and
|
||||||
|
// the card returns the moment its rule is known.
|
||||||
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
|
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
|
||||||
assert.ok(poling, 'Poling should still be in the deck');
|
assert.ok(poling, 'the Poling entry should stay, so the gap does not vanish');
|
||||||
assert.match(poling!.effect, /TBD/i);
|
assert.match(poling!.effect, /TBD/i);
|
||||||
|
assert.equal(poling!.copies, 0, 'Poling is dealt into the deck but cannot be played');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -495,7 +503,7 @@ describe("a train is made up to its card's consist (§8.2)", () => {
|
|||||||
const s = game();
|
const s = game();
|
||||||
s.clock.phase = 'newTrain';
|
s.clock.phase = 'newTrain';
|
||||||
s.trays.set('t', {
|
s.trays.set('t', {
|
||||||
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
|
consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -525,7 +533,7 @@ describe("a train is made up to its card's consist (§8.2)", () => {
|
|||||||
const s = game();
|
const s = game();
|
||||||
s.clock.phase = 'newTrain';
|
s.clock.phase = 'newTrain';
|
||||||
s.trays.set('t', {
|
s.trays.set('t', {
|
||||||
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [{ type: 'caboose', loaded: true }],
|
consist: [{ type: 'caboose', loaded: true }],
|
||||||
direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
|
direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
@@ -595,3 +603,87 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Q13 — a train that catches the one ahead runs into it', () => {
|
||||||
|
/**
|
||||||
|
* Two trains on one card, same direction, the follower closing. `total 3 / remaining 2` puts the
|
||||||
|
* leader in region 1 after it moves; `total 2 / remaining 2` puts the follower in region 0 and
|
||||||
|
* then region 1 — so it catches up whichever order the phase happens to process them in.
|
||||||
|
*/
|
||||||
|
const twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => {
|
||||||
|
const s = createGame({
|
||||||
|
id: 'rear', seed: 3,
|
||||||
|
config: {
|
||||||
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||||
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['bot'],
|
||||||
|
});
|
||||||
|
const index = s.division.nodes.findIndex(
|
||||||
|
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
|
||||||
|
);
|
||||||
|
assert.ok(index >= 0, 'no single-track Mainline card in this Division');
|
||||||
|
const node = s.division.nodes[index]!;
|
||||||
|
assert.ok(node.kind === 'mainline');
|
||||||
|
if (node.kind !== 'mainline') throw new Error('unreachable');
|
||||||
|
if (opts.absSignals) node.absSignals = true;
|
||||||
|
|
||||||
|
const make = (n: number, total: number, left: number): string => {
|
||||||
|
const id = s.freeTrays.pop()!;
|
||||||
|
s.trays.set(id, {
|
||||||
|
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [{ type: 'boxcar', loaded: false }],
|
||||||
|
direction: 'east',
|
||||||
|
position: { at: 'mainline', index },
|
||||||
|
} as never);
|
||||||
|
node.transits.push({ tray: id, stagesRemaining: left, stagesTotal: total, direction: 'east' });
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
make(9, 3, 2); // the leader
|
||||||
|
make(11, 2, 2); // the follower, closing
|
||||||
|
|
||||||
|
s.clock.phase = 'mainline';
|
||||||
|
const events: GameEvent[] = [];
|
||||||
|
for (let i = 0; i < 4; i++) events.push(...advance(s).events);
|
||||||
|
return { s, events };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('destroys both trains, and it is the Superintendent\'s fault (§10)', () => {
|
||||||
|
// REGRESSION against a rule that was never implemented. §10 makes a Mainline collision the
|
||||||
|
// Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders
|
||||||
|
// — but §8.3's trigger list never named one, so granting clearance was FREE: both trains
|
||||||
|
// survived, no penalty, and ABS Signals protected against nothing.
|
||||||
|
const { events } = twoTrains();
|
||||||
|
const smash = events.find((e) => e.type === 'trainsDestroyed');
|
||||||
|
assert.ok(smash, 'the follower ran into the leader and nothing happened');
|
||||||
|
if (smash.type !== 'trainsDestroyed') throw new Error('unreachable');
|
||||||
|
assert.match(smash.reason, /ran into the train ahead/);
|
||||||
|
assert.equal(smash.trains.length, 2, 'a rear-ender removes BOTH trains');
|
||||||
|
assert.ok(
|
||||||
|
events.some((e) => e.type === 'revenueChanged' && e.delta < 0),
|
||||||
|
'the collision cost nothing',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ABS Signals holds the follower short instead', () => {
|
||||||
|
// "Trains on this card will not rear-end each other; they stop short of a collision."
|
||||||
|
const { events } = twoTrains({ absSignals: true });
|
||||||
|
assert.ok(
|
||||||
|
!events.some((e) => e.type === 'trainsDestroyed'),
|
||||||
|
'ABS Signals did not prevent the collision',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)),
|
||||||
|
'nothing was held short of the train ahead',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves trains alone on a card that prints "trains may pass"', () => {
|
||||||
|
// Double Track and Uncontrolled Siding hold two trains because they HAVE two roads. Catching up
|
||||||
|
// there means going past, which is what the card is for. Without this the mechanic fired 0.41
|
||||||
|
// times a game while the bot never once granted clearance — the tell that they were all
|
||||||
|
// passing cards.
|
||||||
|
const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind);
|
||||||
|
assert.deepEqual(passing, ['doubleTrack', 'uncontrolledSiding']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+12
-7
@@ -53,7 +53,11 @@ describe('card catalogue (component 1)', () => {
|
|||||||
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus 18 extra industry cards
|
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus 18 extra industry cards
|
||||||
// (Gap 12, industries 9 → 27) and 7 extra office cards (Q12, offices 7 → 14). Both are
|
// (Gap 12, industries 9 → 27) and 7 extra office cards (Q12, offices 7 → 14). Both are
|
||||||
// deliberate departures from the sheet and both are flagged provisional in content.ts.
|
// deliberate departures from the sheet and both are flagged provisional in content.ts.
|
||||||
assert.equal(DECK_SIZE, 140);
|
//
|
||||||
|
// 139, not 140: Poling is dealt ZERO copies. Its effect is "TBD in the source", so a card that
|
||||||
|
// cannot be played is worse in a hand than absent from the deck. The entry stays in the
|
||||||
|
// catalogue so the gap remains visible.
|
||||||
|
assert.equal(DECK_SIZE, 139);
|
||||||
assert.equal(buildDeck().length, DECK_SIZE);
|
assert.equal(buildDeck().length, DECK_SIZE);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,20 +73,21 @@ describe('card catalogue (component 1)', () => {
|
|||||||
spaceUse: 12,
|
spaceUse: 12,
|
||||||
enhancement: 18,
|
enhancement: 18,
|
||||||
mainlineModifier: 7,
|
mainlineModifier: 7,
|
||||||
maneuver: 7,
|
// 6, not 7 — Poling is dealt no copies until its rule is known.
|
||||||
|
maneuver: 6,
|
||||||
action: 10,
|
action: 10,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes opponent-directed cards from a solitaire deck', () => {
|
it('removes opponent-directed cards from a solitaire deck', () => {
|
||||||
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
|
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
|
||||||
// game they would be 22 of 140 draws (16%) that do nothing.
|
// game they would be 22 of 139 draws (16%) that do nothing.
|
||||||
assert.equal(SOLITAIRE_DECK_SIZE, 118);
|
assert.equal(SOLITAIRE_DECK_SIZE, 117);
|
||||||
const solo = buildDeck('solitaire');
|
const solo = buildDeck('solitaire');
|
||||||
assert.equal(solo.length, 118);
|
assert.equal(solo.length, SOLITAIRE_DECK_SIZE);
|
||||||
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
|
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
|
||||||
// A competitive deck keeps them.
|
// A competitive deck keeps them. 139, not 140 — Poling is dealt no copies.
|
||||||
assert.equal(buildDeck('competitive').length, 140);
|
assert.equal(buildDeck('competitive').length, 139);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps track OUT of the deck, as a per-player supply', () => {
|
it('keeps track OUT of the deck, as a per-player supply', () => {
|
||||||
|
|||||||
+193
-32
@@ -8,8 +8,11 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { pump } from '../src/engine/advance.ts';
|
import { pump } from '../src/engine/advance.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
|
import { refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
|
||||||
|
import { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||||
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
|
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
|
||||||
import { exitsFrom, hasPort, neighbour, opposite } from '../src/engine/track.ts';
|
import type { Intent } from '../src/engine/intents.ts';
|
||||||
|
import { connectionsFor, exitsFrom, hasPort, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
|
||||||
import type { Port } from '../src/engine/track.ts';
|
import type { Port } from '../src/engine/track.ts';
|
||||||
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
|
||||||
import { simulate } from '../src/sim/harness.ts';
|
import { simulate } from '../src/sim/harness.ts';
|
||||||
@@ -236,8 +239,13 @@ describe('end-of-game statistics', () => {
|
|||||||
const report = simulate({
|
const report = simulate({
|
||||||
games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||||
});
|
});
|
||||||
|
// Both halves of freight, matching `strategyBuckets`. This filter used to omit `freightUnload`
|
||||||
|
// — the same omission the summary itself had — so a game that scored only by unloading was
|
||||||
|
// bucketed but not counted here.
|
||||||
const scored = report.perGame.filter(
|
const scored = report.perGame.filter(
|
||||||
(g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0,
|
(g) =>
|
||||||
|
g.revenue.freightLoad + g.revenue.freightUnload +
|
||||||
|
g.revenue.passengerBoard + g.revenue.passengerDetrain > 0,
|
||||||
);
|
);
|
||||||
const bucketed = strategyBuckets(report.perGame).reduce((n, b) => n + b.games, 0);
|
const bucketed = strategyBuckets(report.perGame).reduce((n, b) => n + b.games, 0);
|
||||||
assert.equal(bucketed, scored.length, 'a scoring game fell outside every bucket');
|
assert.equal(bucketed, scored.length, 'a scoring game fell outside every bucket');
|
||||||
@@ -591,45 +599,49 @@ describe('Enhancements can reach the board at all (regression)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rolling stock stays in circulation (regression)', () => {
|
describe('rolling stock returns to service (regression)', () => {
|
||||||
it('returns the Classification Yard to service overnight', () => {
|
it('empties the Classification Yard into the Division Yard when the Division Yard runs out', () => {
|
||||||
// REGRESSION, and the largest single revenue change measured on this bot: +2.32 ± 0.52 paired
|
// §2 — used Rolling Stock is set out in the Classification Yard, used engines and cabooses go
|
||||||
// over 400 seeds (t = 8.79).
|
// straight back to the Division Yard, and the Classification Yard empties ONLY when the
|
||||||
|
// Division Yard is bare. Then all of it returns at once.
|
||||||
//
|
//
|
||||||
// The Classification Yard was WRITE-ONLY. Seven places pushed cars into it — retired trains,
|
// REGRESSION on two counts. The Classification Yard was write-only to begin with — seven
|
||||||
// collisions, unjams, set-outs — and nothing in the engine ever read it. Rolling stock drained
|
// writers, no readers — so 37% of the stock left the game. The first fix then guessed the wrong
|
||||||
// one way out of the game: 30 cars a game dead by the end, 37% of the 80 dealt at setup.
|
// trigger, returning cars at every DAY boundary, which keeps the yard topped up continuously
|
||||||
|
// and is far more generous than the rule: worth +2.42 revenue a game that the game does not
|
||||||
|
// actually grant.
|
||||||
//
|
//
|
||||||
// It was not obvious this mattered. The Division Yard does not run DRY in five Days (16.6
|
// So this asserts the TRIGGER, not a tidy end state. The Division Yard legitimately still holds
|
||||||
// loaded freight cars left, empty in 2 games of 100), which is why this was ranked third and
|
// cars at the end of a short game, and the Classification Yard legitimately holds a pile.
|
||||||
// predicted not to affect revenue. The aggregate was never the point: what starves freight is
|
|
||||||
// not having the RIGHT commodity when a green box needs stocking, and returning classified
|
|
||||||
// cars keeps the mix alive.
|
|
||||||
const games = 30;
|
|
||||||
let deadAtEnd = 0;
|
|
||||||
let everReturned = 0;
|
|
||||||
for (let i = 0; i < games; i++) {
|
|
||||||
const s = createGame({
|
const s = createGame({
|
||||||
id: `cy-${i}`,
|
id: 'refill',
|
||||||
seed: 1000 + i * 7919,
|
seed: 4,
|
||||||
config: { ...config, length: 'standard' },
|
config: { ...config, length: 'standard' },
|
||||||
playerNames: ['bot'],
|
playerNames: ['bot'],
|
||||||
});
|
});
|
||||||
const r = playGame(s, developerBot, pump);
|
|
||||||
deadAtEnd += s.yards.classificationYard.length;
|
|
||||||
// Cars must have passed THROUGH it, or the test proves nothing about the return path.
|
|
||||||
if (r.events.some((e) => e.type === 'trainCompleted')) everReturned++;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ok(everReturned > 0, 'no train ever completed — nothing reached the Classification Yard');
|
// Drain the Division Yard and park a known car in Classification.
|
||||||
// A Day's worth of arrivals may still be sitting there when the game ends; a whole game's worth
|
const parked = s.yards.divisionYard.splice(0, s.yards.divisionYard.length);
|
||||||
// may not. Before the fix this was ~30 a game.
|
s.yards.classificationYard.push(...parked);
|
||||||
assert.ok(
|
assert.equal(s.yards.divisionYard.length, 0);
|
||||||
deadAtEnd / games < 15,
|
const waiting = s.yards.classificationYard.length;
|
||||||
`${(deadAtEnd / games).toFixed(1)} cars a game are still stranded in the Classification ` +
|
assert.ok(waiting > 0, 'nothing was parked to return');
|
||||||
'Yard at the end — rolling stock is leaving the game and never coming back',
|
|
||||||
|
const moved = refillDivisionYardIfEmpty(s);
|
||||||
|
assert.ok(moved, 'an empty Division Yard did not pull the Classification Yard back');
|
||||||
|
assert.equal(moved!.count, waiting);
|
||||||
|
assert.equal(s.yards.divisionYard.length, waiting, 'the cars did not come back');
|
||||||
|
assert.equal(s.yards.classificationYard.length, 0, 'the Classification Yard was not emptied');
|
||||||
|
|
||||||
|
// And it must NOT fire while the Division Yard still has anything at all.
|
||||||
|
s.yards.classificationYard.push({ type: 'boxcar', loaded: false });
|
||||||
|
assert.equal(
|
||||||
|
refillDivisionYardIfEmpty(s),
|
||||||
|
null,
|
||||||
|
'the Classification Yard returned while the Division Yard still had cars',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('measurement discipline', () => {
|
describe('measurement discipline', () => {
|
||||||
@@ -662,3 +674,152 @@ describe('measurement discipline', () => {
|
|||||||
assert.ok(upgrades > 0, 'no office upgrade was visible in the event log across 25 games');
|
assert.ok(upgrades > 0, 'no office upgrade was visible in the event log across 25 games');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the bot does not lay track that cannot work (regression)', () => {
|
||||||
|
/** Drive a game with the developer bot, reporting every track piece it lays. */
|
||||||
|
const laysIn = (seed: number, prepare?: (s: GameState) => void): Intent[] => {
|
||||||
|
const s = createGame({
|
||||||
|
id: `lay-${seed}`,
|
||||||
|
seed,
|
||||||
|
config: { ...config, length: 'standard' },
|
||||||
|
playerNames: ['Solitaire'],
|
||||||
|
});
|
||||||
|
prepare?.(s);
|
||||||
|
const laid: Intent[] = [];
|
||||||
|
const spy = {
|
||||||
|
name: 'spy',
|
||||||
|
choose(st: GameState, p: 0, opts: Intent[]) {
|
||||||
|
const pick = developerBot.choose(st, p, opts);
|
||||||
|
if (pick.type === 'track.lay') laid.push(pick);
|
||||||
|
return pick;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
playGame(s, spy as never, pump);
|
||||||
|
return laid;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('will not cut a turnout with no arc left to hang beneath it', () => {
|
||||||
|
// A turnout is a hole in the Running Track, worth cutting only for what hangs under it. This
|
||||||
|
// does not happen naturally — measured at 0%, because the bot lays the arc immediately after
|
||||||
|
// the turnout and never runs the supply dry first — so the situation is constructed. It guards
|
||||||
|
// against the supply changing rather than against something happening today.
|
||||||
|
const drained = laysIn(4242, (s) => {
|
||||||
|
const area = s.officeAreas.get(0)!;
|
||||||
|
for (const k of ['curved:left', 'curved:right', 'sharpCurved:left', 'sharpCurved:right']) {
|
||||||
|
area.trackSupply.set(k, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const turnouts = drained.filter((i) => i.type === 'track.lay' && i.geometry === 'turnout');
|
||||||
|
assert.equal(
|
||||||
|
turnouts.length,
|
||||||
|
0,
|
||||||
|
`${turnouts.length} turnouts were cut with no arc left to build beneath them`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the guard must not be doing this by simply stopping the bot from building.
|
||||||
|
const normal = laysIn(4242);
|
||||||
|
assert.ok(normal.length > 5, `the bot laid only ${normal.length} pieces with a full supply`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rarely lays rail that butts a card which cannot accept it', () => {
|
||||||
|
// A port facing an EMPTY square is a promise — something may be built there later. A port
|
||||||
|
// butting an OCCUPIED square whose card has no matching port is not: that square is taken, so
|
||||||
|
// the rail stops dead and always will. Reported from a replay, where an arc was dropped off a
|
||||||
|
// turnout with its far end jammed into a curve.
|
||||||
|
//
|
||||||
|
// A TIE-BREAKER rather than a veto, so this is a rate and not a zero: forbidding it outright
|
||||||
|
// measured WORSE (-0.62 revenue a game), while preferring the cleaner of two equally good
|
||||||
|
// placements measured better and cut these from 28% of pieces to 7%.
|
||||||
|
let laid = 0;
|
||||||
|
let dead = 0;
|
||||||
|
for (const seed of [1000, 8919, 16838, 24757, 32676]) {
|
||||||
|
const s = createGame({
|
||||||
|
id: `dp-${seed}`, seed,
|
||||||
|
config: { ...config, length: 'standard' },
|
||||||
|
playerNames: ['Solitaire'],
|
||||||
|
});
|
||||||
|
const spy = {
|
||||||
|
name: 'spy',
|
||||||
|
choose(st: GameState, p: 0, opts: Intent[]) {
|
||||||
|
const pick = developerBot.choose(st, p, opts);
|
||||||
|
if (pick.type === 'track.lay') {
|
||||||
|
laid++;
|
||||||
|
const area = st.officeAreas.get(p)!;
|
||||||
|
const v = variantsFor(pick.geometry)[pick.variant ?? 0];
|
||||||
|
const probe = {
|
||||||
|
geometry: {
|
||||||
|
kind: 'track', geometry: pick.geometry,
|
||||||
|
...(v?.axis ? { axis: v.axis } : {}),
|
||||||
|
...(v?.arc ? { arc: v.arc } : {}),
|
||||||
|
...(v?.turnout ? { turnout: v.turnout } : {}),
|
||||||
|
},
|
||||||
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||||
|
} as never;
|
||||||
|
for (const port of new Set(connectionsFor(probe).flat())) {
|
||||||
|
const n = neighbour(pick.placement, port);
|
||||||
|
const nb = area.grid.get(`${n.row},${n.col}`);
|
||||||
|
if (nb && !hasPort(nb, opposite(port))) dead++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pick;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
playGame(s, spy as never, pump);
|
||||||
|
}
|
||||||
|
assert.ok(laid > 40, `only ${laid} pieces laid across five games`);
|
||||||
|
const rate = dead / laid;
|
||||||
|
assert.ok(
|
||||||
|
rate < 0.15,
|
||||||
|
`${(rate * 100).toFixed(0)}% of pieces were laid butting a card that cannot accept them`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the freight figures count both halves (regression)', () => {
|
||||||
|
it('scores an unload as Revenue earned, not as an unload started', () => {
|
||||||
|
// REGRESSION, against the instrument rather than the game. `rev.freightUnload` was assigned
|
||||||
|
// `eventCounts['unloadBegan']` — unloads STARTED, not Revenue EARNED, which differ by every
|
||||||
|
// unload that never finished — and `grossFreight` then used `freightLoad` alone, so
|
||||||
|
// `freightShare` reported roughly 60% of the freight actually earned. That is the number the
|
||||||
|
// "freight is only 13-18% of gross" finding was read from, and it drove a density change.
|
||||||
|
//
|
||||||
|
// Asserted against the ENGINE's own events, so the summary cannot drift from what happened.
|
||||||
|
const report = simulate({
|
||||||
|
games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||||||
|
});
|
||||||
|
|
||||||
|
let loads = 0;
|
||||||
|
let unloads = 0;
|
||||||
|
let begun = 0;
|
||||||
|
for (const seed of [1000, 8919, 16838]) {
|
||||||
|
const s = createGame({
|
||||||
|
id: `fu-${seed}`, seed,
|
||||||
|
config: { ...config, length: 'standard' },
|
||||||
|
playerNames: ['bot'],
|
||||||
|
});
|
||||||
|
const r = playGame(s, developerBot, pump);
|
||||||
|
for (const e of r.events) {
|
||||||
|
if (e.type === 'unloadBegan') begun++;
|
||||||
|
if (e.type !== 'revenueChanged') continue;
|
||||||
|
if (e.reason === 'freightLoad') loads += e.delta;
|
||||||
|
if (e.reason === 'freightUnload') unloads += e.delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(unloads > 0, 'no unload ever earned Revenue');
|
||||||
|
// `begun` is what the summary used to count. It is an upper bound on what was EARNED, because
|
||||||
|
// an unload can be started and never finished — so counting it was wrong whether or not the
|
||||||
|
// two happen to agree on a given sample.
|
||||||
|
assert.ok(begun >= unloads, 'more unloads earned Revenue than were ever begun');
|
||||||
|
|
||||||
|
// Every game in the report must count both halves toward its freight share.
|
||||||
|
for (const g of report.perGame) {
|
||||||
|
const gross = g.revenue.freightLoad + g.revenue.freightUnload + g.revenue.passengerBoard + g.revenue.passengerDetrain;
|
||||||
|
if (gross === 0) continue;
|
||||||
|
const expected = (g.revenue.freightLoad + g.revenue.freightUnload) / gross;
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(g.freightShare - expected) < 1e-9,
|
||||||
|
`freightShare ${g.freightShare.toFixed(3)} omits the unload half (expected ${expected.toFixed(3)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+1
-1
@@ -545,7 +545,7 @@ describe('coupling lifts only the cars the crew ran over', () => {
|
|||||||
const area = areaFrom(grid, { row: 0, col: 0 });
|
const area = areaFrom(grid, { row: 0, col: 0 });
|
||||||
const s = gameWith(area);
|
const s = gameWith(area);
|
||||||
s.trays.set('crew', {
|
s.trays.set('crew', {
|
||||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true, consist: [],
|
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0, consist: [],
|
||||||
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
|
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
s.clock.phase = 'localOps';
|
s.clock.phase = 'localOps';
|
||||||
|
|||||||
+165
-2
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
|
|
||||||
import { cardDescription, describeIntent } from '../src/sim/view.ts';
|
import { cardDescription, describeIntent } from '../src/sim/view.ts';
|
||||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||||
|
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||||
import {
|
import {
|
||||||
@@ -529,11 +530,11 @@ describe('the page explains itself', () => {
|
|||||||
const game = newGame(5);
|
const game = newGame(5);
|
||||||
const s = game.state;
|
const s = game.state;
|
||||||
s.trays.set('tray2', {
|
s.trays.set('tray2', {
|
||||||
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
s.trays.set('tray3', {
|
s.trays.set('tray3', {
|
||||||
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineFront: true,
|
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineAt: 0,
|
||||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };
|
s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };
|
||||||
@@ -1191,3 +1192,165 @@ describe('a train says what its card calls for', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the three places a game is drawn stay in step', () => {
|
||||||
|
// A game is drawn on the playable page, in the standalone replay file, and in the website's
|
||||||
|
// replay viewer. They had drifted: the viewer offered three speeds where the standalone offered
|
||||||
|
// five, and had neither the sound nor the auto-hide the other two grew. Anything a player learns
|
||||||
|
// on one screen should hold on the others.
|
||||||
|
|
||||||
|
it('offers the same playback speeds in both replay viewers', () => {
|
||||||
|
const site = readFileSync(join(dist, 'replays.html'), 'utf8');
|
||||||
|
const standalone = renderHtml(record(880009, 'standard', 600));
|
||||||
|
const speeds = (html: string): string[] =>
|
||||||
|
[...html.matchAll(/<option value="\d+"[^>]*>([a-z ]+)<\/option>/g)].map((m) => m[1]!.trim());
|
||||||
|
|
||||||
|
const a = speeds(site);
|
||||||
|
const b = speeds(standalone);
|
||||||
|
assert.ok(a.length >= 5, `the site viewer offers only ${a.length} speeds: ${a.join(', ')}`);
|
||||||
|
assert.deepEqual(
|
||||||
|
new Set(a),
|
||||||
|
new Set(b),
|
||||||
|
`the two viewers offer different speeds — site: ${a.join(', ')} / standalone: ${b.join(', ')}`,
|
||||||
|
);
|
||||||
|
for (const wanted of ['extra slow', 'very fast']) {
|
||||||
|
assert.ok(a.includes(wanted), `the site viewer has no "${wanted}" speed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers sound and auto-hide everywhere a game is drawn', () => {
|
||||||
|
const pages: [string, string][] = [
|
||||||
|
['play.html', readFileSync(join(dist, 'play.html'), 'utf8')],
|
||||||
|
['replays.html', readFileSync(join(dist, 'replays.html'), 'utf8')],
|
||||||
|
['the standalone replay', renderHtml(record(880009, 'standard', 600))],
|
||||||
|
];
|
||||||
|
for (const [name, html] of pages) {
|
||||||
|
assert.match(html, /id="v?sound"/, `${name} has no sound control`);
|
||||||
|
assert.match(html, /id="v?districttoggle"/, `${name} has no auto-hide control`);
|
||||||
|
assert.match(html, /id="v?districtsummary"/, `${name} shows nothing when the district is folded`);
|
||||||
|
assert.match(html, /district\.folded/, `${name} has no rule to fold the district away`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks the replay viewer for no element its page lacks', () => {
|
||||||
|
// The same total check the playable page gets: a `$('vfoo')` left behind after removing #vfoo
|
||||||
|
// throws on load, and the page simply never starts.
|
||||||
|
const src = readFileSync(join(root, 'src/web/replays.ts'), 'utf8');
|
||||||
|
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
|
||||||
|
const html = readFileSync(join(dist, 'replays.html'), 'utf8');
|
||||||
|
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
|
||||||
|
for (const id of asked) {
|
||||||
|
assert.ok(present.has(id), `replays.ts asks for #${id}, which replays.html does not contain`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('every file the build needs is actually in the repo (regression)', () => {
|
||||||
|
it('does not gitignore a source page', () => {
|
||||||
|
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
||||||
|
// repo root — unanchored, so it also matched `src/web/replays.html`, which is a SOURCE file.
|
||||||
|
// That page had therefore NEVER been committed: a fresh clone was missing it, and the build
|
||||||
|
// copies it unconditionally. The pattern is anchored to the root now.
|
||||||
|
//
|
||||||
|
// Asked of git rather than read out of .gitignore: the question is whether the file survives a
|
||||||
|
// clone, and only git can answer that. `check-ignore -q` exits 0 when ignored, 1 when not.
|
||||||
|
for (const page of ['src/web/index.html', 'src/web/play.html', 'src/web/replays.html']) {
|
||||||
|
let ignored: boolean;
|
||||||
|
try {
|
||||||
|
execFileSync('git', ['check-ignore', '-q', page], { cwd: root, stdio: 'pipe' });
|
||||||
|
ignored = true;
|
||||||
|
} catch {
|
||||||
|
ignored = false;
|
||||||
|
}
|
||||||
|
assert.equal(ignored, false, `${page} is gitignored — it would be missing from a fresh clone`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the tray is an engine plus its Rolling Stock', () => {
|
||||||
|
it('shows where the engine sits, because it may pull, push, or do both', () => {
|
||||||
|
// A Crew Tray is an engine and its cars, and the engine may be PULLING (ahead of everything),
|
||||||
|
// PUSHING (behind everything) or in the middle doing both — which decides which end cars couple
|
||||||
|
// onto (§A.3) and which way the train can shove.
|
||||||
|
//
|
||||||
|
// REGRESSION on a dead field: `engineFront` was a boolean written in three places and read in
|
||||||
|
// NONE, so the engine had a position the game recorded and never used, and every consist was
|
||||||
|
// drawn as an anonymous row of cars. It is an index into the consist now.
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'eng', seed: 1038389,
|
||||||
|
config: {
|
||||||
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||||
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['Solitaire'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tray built by hand, so every position can be checked rather than hoping one turns up.
|
||||||
|
// Trays are created when a train is made up, so a fresh game has none.
|
||||||
|
const trayId = s.freeTrays.pop()!;
|
||||||
|
const tray = {
|
||||||
|
id: trayId,
|
||||||
|
trainNumber: 9,
|
||||||
|
trainIsExtra: false,
|
||||||
|
engineAt: 0,
|
||||||
|
consist: [
|
||||||
|
{ type: 'boxcar', loaded: false },
|
||||||
|
{ type: 'hopper', loaded: true },
|
||||||
|
],
|
||||||
|
direction: 'east',
|
||||||
|
position: { at: 'divisionPoint', side: 'west' },
|
||||||
|
} as never;
|
||||||
|
s.trays.set(trayId, tray);
|
||||||
|
const west = s.division.nodes.find((n) => n.kind === 'divisionPoint');
|
||||||
|
if (west && west.kind === 'divisionPoint') west.holding.push(trayId);
|
||||||
|
|
||||||
|
const consistFor = (at: number): string[] => {
|
||||||
|
(tray as unknown as { engineAt: number }).engineAt = at;
|
||||||
|
const f = snapshot(s, [], null);
|
||||||
|
for (const node of f.division) {
|
||||||
|
for (const t of node.trains.flat()) {
|
||||||
|
if (t.consist.includes('ENG')) return t.consist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(consistFor(0)[0], 'ENG', 'an engine at 0 is not drawn pulling');
|
||||||
|
assert.equal(consistFor(2)[2], 'ENG', 'an engine behind the cars is not drawn pushing');
|
||||||
|
const middle = consistFor(1);
|
||||||
|
assert.equal(middle[1], 'ENG', 'an engine between the cars is not drawn in the middle');
|
||||||
|
assert.equal(middle.length, 3, 'the engine displaced a car instead of sitting between them');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows both yards, split loaded and empty', () => {
|
||||||
|
// Rolling stock is finite and the Classification Yard returns to service only when the Division
|
||||||
|
// Yard is BARE, so watching the Division Yard run down is real information — and neither yard
|
||||||
|
// was shown anywhere.
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'yards', seed: 1038389,
|
||||||
|
config: {
|
||||||
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||||
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['Solitaire'],
|
||||||
|
});
|
||||||
|
const f = snapshot(s, [], null);
|
||||||
|
|
||||||
|
assert.ok(f.yards.divisionTotal > 0, 'the Division Yard is dealt nothing');
|
||||||
|
assert.equal(
|
||||||
|
f.yards.division.reduce((n, r) => n + r.loaded + r.empty, 0),
|
||||||
|
f.yards.divisionTotal,
|
||||||
|
'the Division Yard breakdown does not add up to its total',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
f.yards.classification.reduce((n, r) => n + r.loaded + r.empty, 0),
|
||||||
|
f.yards.classificationTotal,
|
||||||
|
'the Classification Yard breakdown does not add up to its total',
|
||||||
|
);
|
||||||
|
// The page must ask for the elements it fills.
|
||||||
|
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||||
|
for (const id of ['divyard', 'clsyard', 'divtot', 'clstot']) {
|
||||||
|
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user