adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.

This commit is contained in:
Jesse
2026-08-04 21:03:17 -04:00
parent 4bba991f41
commit 821d3cdffa
26 changed files with 6876 additions and 2638 deletions
+6 -2
View File
@@ -22,5 +22,9 @@ __pycache__/
# Logs
*.log
# Generated replays
replay*.html
# Generated replays — ANCHORED to the repo root on purpose.
#
# 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
View File
@@ -10,6 +10,237 @@ The target is 20 Revenue over 5 Days.
## 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
Sound and the district auto-hide were built for the playable page and the replay had neither, which
+43 -17
View File
@@ -9,16 +9,45 @@ Ordered within each section by how much it is currently costing us.
## 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
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
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
a 0.7 improvement on 400.
- [ ] **Confirm the Classification Yard rule against the source.** Cars now return to the Division
Yard at the Day boundary — an ASSUMPTION, not a recovered rule. Gap 2c says everything but
cabooses goes to Classification and never says how it empties. It is worth +2.32 revenue a
game, so if the real rule differs the balance numbers move with it.
- [x] ~~**Confirm the Classification Yard rule against the source.**~~ Confirmed, and the guess was
wrong. The rule is: used Rolling Stock to the Classification Yard, used engines and cabooses
straight back to the Division Yard, and the Classification Yard empties ONLY when the Division
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
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
@@ -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.
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.
- [ ] **`stats.ts` undercounts freight.** `rev.freightUnload` is assigned `eventCounts['unloadBegan']`
— unloads *begun*, not revenue *earned* — and `grossFreight` uses `freightLoad` alone, so
`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.
- [x] ~~**`stats.ts` undercounts freight.**~~ Fixed: both halves counted, freight share 39% → 49%.
Worth revisiting the **industry density** decision below, which was taken on the old number.
---
@@ -54,13 +80,9 @@ Ordered within each section by how much it is currently costing us.
Blocked on a decision, not on work.
- [ ] **Q13 — rear-end collisions on a Mainline card.** §10 says a Mainline collision is the
Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders —
but §8.3's trigger list does not include one, and **none is implemented**. Granting clearance is
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.
- [x] ~~**Q13 — rear-end collisions on a Mainline card.**~~ Answered: collide on catching up.
Implemented, and not on cards that print "trains may pass". Invisible to a bot that always
denies clearance; a bot that always allows drops from 7.34 revenue to **-5.13**.
- [ ] **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.
- [ ] **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
"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
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
View File
@@ -30,6 +30,8 @@ import {
collectiveRevenueFloor,
lengthProfile,
officeProfile,
REGIONS_PER_MAINLINE_CARD,
mainlineProfile,
} from './content.ts';
import type { Direction } from './content.ts';
import type { GameEvent } from './events.ts';
@@ -186,7 +188,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
id: trayId,
trainNumber: due,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction,
position: { at: 'divisionPoint', side },
@@ -219,7 +221,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
id: trayId,
trainNumber: number,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction,
position: { at: 'divisionPoint', side },
@@ -250,7 +252,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
id: trayId,
trainNumber: number,
trainIsExtra: true,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'east',
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
// Fast/Slow class. Count it down rather than stepping through printed cells.
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;
return 'moved';
}
@@ -756,25 +805,6 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
// Telegraph/Telephone/Radio are each usable once a Day.
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.stage = 1;
s.collisionsToday = 0;
+26
View File
@@ -1174,6 +1174,7 @@ export function reduce(s: GameState, e: GameEvent): void {
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
);
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
refillDivisionYardIfEmpty(s);
f.outboundBox.push(e.stock);
s.turn.freightAgentUsed = true;
break;
@@ -1236,6 +1237,7 @@ export function reduce(s: GameState, e: GameEvent): void {
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
);
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
refillDivisionYardIfEmpty(s);
tray.consist.push(e.stock);
break;
}
@@ -1674,6 +1676,30 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
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. */
function limitsCard(): TrackCard {
return {
+6 -1
View File
@@ -584,7 +584,12 @@ export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', co
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: '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. */
+7
View File
@@ -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
// setup gains an interactive phase. See implications.md §10 Q11.
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';
}
return node;
+13 -2
View File
@@ -202,8 +202,19 @@ export type CrewTray = {
/** null while a local crew is switching without a train card. */
trainNumber: number | null;
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). */
consist: RollingStock[];
direction: Direction;
+61 -2
View File
@@ -24,7 +24,7 @@ import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.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 { coordKey } 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 =>
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.
const turnouts = [...area.grid.entries()]
.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
// the most valuable single piece on the board, and below closing a run-around.
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
// 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
@@ -618,6 +672,11 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
if (i.geometry === 'straight') score += 2;
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.
*
+19 -5
View File
@@ -113,6 +113,10 @@ export function summarize(
const reason = e.reason;
if (reason.startsWith('collision')) rev.collisionLoss += -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 === 'detraining') rev.passengerDetrain += e.delta;
}
@@ -129,12 +133,21 @@ export function summarize(
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);
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 gross = grossFreight + grossPassenger;
@@ -347,7 +360,7 @@ export type StrategyBucket = {
* consistently outscore passenger specialists (or vice versa), it shows up here.
*/
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 }[] = [
{ 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(` net ${num(meanOf(all, (g) => g.revenue.net))}`);
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 off ${num(meanOf(all, (g) => g.revenue.passengerDetrain))}`);
out.push(` lost to collisions ${num(-meanOf(all, (g) => g.revenue.collisionLoss))}`);
+46 -1
View File
@@ -179,6 +179,19 @@ export type Frame = {
departments: string[];
/** What each face-up Department card does. */
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. */
timetable: (number | null)[];
blocked: Impediment[];
@@ -598,6 +611,12 @@ export function snapshot(
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((id) => (id ? cardName(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],
decision,
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. */
/** 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 {
const t = s.trays.get(id);
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 {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
consist: t.consist.map(carLabel),
consist: cars,
};
}
+34
View File
@@ -261,6 +261,8 @@ function render(): void {
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
: '<span class="dim">none</span>';
renderYards(f);
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
$('supply').innerHTML =
`<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();
}
/**
* 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 {
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
const sec = $('district');
+16
View File
@@ -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
second list, so nothing looked like a card at all. */
.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;
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
.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>
</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>Facilities</h2><div id="facs"></div></section>
</div>
+124
View File
@@ -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&hellip;</p></div>
</section>
<section>
<h2>Open a save file</h2>
<p class="dim">A Station Master save is a small <code>.json</code> file &mdash; 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">&larr; all replays</button>
<b id="vtitle">&mdash;</b>
<span class="dim" id="vnote"></span>
</div>
<div class="vbar">
<button id="vfirst">&#9198; start</button>
<button id="vback">&#9664; step</button>
<button id="vplay">&#9654; play</button>
<button id="vfwd">step &#9654;</button>
<button id="vstage">next Stage &#9197;</button>
<button id="vlast">end &#9197;</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">&#128263; 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">&mdash;</span>
<span>phase: <b id="vphase">&mdash;</b></span>
<span>Revenue <b id="vrev">0</b></span>
<span class="dim">&larr; &rarr; 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
View File
@@ -12,6 +12,8 @@
*/
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 { narrate } from '../sim/narrate.ts';
import { cardName, trainName } from '../sim/view.ts';
@@ -35,7 +37,12 @@ const esc = (s: string): string =>
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/** 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.
@@ -54,7 +61,7 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
const n = narrate(e, ctx);
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));
@@ -80,13 +87,28 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
let steps: Step[] = [];
let at = 0;
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 {
const from = at;
at = Math.max(0, Math.min(steps.length - 1, i));
const step = steps[at];
if (!step) return;
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}`;
$('vphase').textContent = f.phase;
$('vrev').textContent = String(f.revenue);
@@ -95,6 +117,21 @@ function show(i: number): void {
$('vdivision').innerHTML = divisionSvg(f.division);
$('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.
let log = '';
for (let k = Math.max(0, at - 18); k <= at; k++) {
@@ -215,6 +252,18 @@ function wire(): void {
show(at + 1);
}, 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 = () => {
if (timer) clearInterval(timer);
timer = null;
+7 -7
View File
@@ -257,7 +257,7 @@ describe('Mainline Phase (§8)', () => {
id: `t${i}`,
trainNumber: spec.n,
trainIsExtra: spec.extra,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
@@ -287,7 +287,7 @@ describe('collisions are automatic (Gap 2)', () => {
id,
trainNumber: 2,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [{ type: 'coach', loaded: true }],
direction: 'east',
position: { at: 'mainline', index: 1 },
@@ -328,7 +328,7 @@ describe('collisions are automatic (Gap 2)', () => {
id: 'x',
trainNumber: 8,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [
{ type: 'hopper', loaded: true },
{ type: 'caboose', loaded: true },
@@ -359,7 +359,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
id: 'ahead',
trainNumber: 4,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'mainline', index: 1 },
@@ -379,7 +379,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
id: 'behind',
trainNumber: 2,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
@@ -402,7 +402,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
id: 'oncoming',
trainNumber: 3,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'west',
position: { at: 'mainline', index: 1 },
@@ -421,7 +421,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
id: 'waiting',
trainNumber: 2,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
+1 -1
View File
@@ -38,7 +38,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist']
id,
trainNumber: null,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist,
direction: 'east',
position: { at: 'grid', owner: 0, coord },
+6 -6
View File
@@ -52,7 +52,7 @@ function handEnhancement(s: GameState, key: string): string {
function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string {
const id = s.freeTrays.pop()!;
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,
});
return id;
@@ -185,7 +185,7 @@ describe('Interlocking and Yard Office relieve the Office', () => {
function inbound(s: GameState, consist: TrackCard['standing']) {
const id = 'inbound';
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,
});
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' });
}
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,
});
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,
});
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.
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,
});
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' });
}
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,
});
s.clock.phase = 'mainline';
+99 -7
View File
@@ -16,12 +16,15 @@ import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
REALIGNMENTS,
crossingStages,
mainlineModifierRule,
mainlineProfile,
} from '../src/engine/content.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 { coordKey } from '../src/engine/state.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');
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
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,
});
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,
});
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);
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 }],
// 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
@@ -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
// 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');
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.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();
s.clock.phase = 'newTrain';
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,
});
@@ -525,7 +533,7 @@ describe("a train is made up to its card's consist (§8.2)", () => {
const s = game();
s.clock.phase = 'newTrain';
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 }],
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
View File
@@ -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
// (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.
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);
});
@@ -69,20 +73,21 @@ describe('card catalogue (component 1)', () => {
spaceUse: 12,
enhancement: 18,
mainlineModifier: 7,
maneuver: 7,
// 6, not 7 — Poling is dealt no copies until its rule is known.
maneuver: 6,
action: 10,
});
});
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
// game they would be 22 of 140 draws (16%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 118);
// game they would be 22 of 139 draws (16%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 117);
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'));
// A competitive deck keeps them.
assert.equal(buildDeck('competitive').length, 140);
// A competitive deck keeps them. 139, not 140 — Poling is dealt no copies.
assert.equal(buildDeck('competitive').length, 139);
});
it('keeps track OUT of the deck, as a per-player supply', () => {
+197 -36
View File
@@ -8,8 +8,11 @@ import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.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 { 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 { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import { simulate } from '../src/sim/harness.ts';
@@ -236,8 +239,13 @@ describe('end-of-game statistics', () => {
const report = simulate({
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(
(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);
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)', () => {
it('returns the Classification Yard to service overnight', () => {
// REGRESSION, and the largest single revenue change measured on this bot: +2.32 ± 0.52 paired
// over 400 seeds (t = 8.79).
describe('rolling stock returns to service (regression)', () => {
it('empties the Classification Yard into the Division Yard when the Division Yard runs out', () => {
// §2 — 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.
//
// The Classification Yard was WRITE-ONLY. Seven places pushed cars into it — retired trains,
// collisions, unjams, set-outs — and nothing in the engine ever read it. Rolling stock drained
// one way out of the game: 30 cars a game dead by the end, 37% of the 80 dealt at setup.
// REGRESSION on two counts. The Classification Yard was write-only to begin with — seven
// writers, no readers — so 37% of the stock left the game. The first fix then guessed the wrong
// 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
// loaded freight cars left, empty in 2 games of 100), which is why this was ranked third and
// 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({
id: `cy-${i}`,
seed: 1000 + i * 7919,
config: { ...config, length: 'standard' },
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++;
}
// So this asserts the TRIGGER, not a tidy end state. The Division Yard legitimately still holds
// cars at the end of a short game, and the Classification Yard legitimately holds a pile.
const s = createGame({
id: 'refill',
seed: 4,
config: { ...config, length: 'standard' },
playerNames: ['bot'],
});
assert.ok(everReturned > 0, 'no train ever completed — nothing reached the Classification Yard');
// A Day's worth of arrivals may still be sitting there when the game ends; a whole game's worth
// may not. Before the fix this was ~30 a game.
assert.ok(
deadAtEnd / games < 15,
`${(deadAtEnd / games).toFixed(1)} cars a game are still stranded in the Classification ` +
'Yard at the end — rolling stock is leaving the game and never coming back',
// Drain the Division Yard and park a known car in Classification.
const parked = s.yards.divisionYard.splice(0, s.yards.divisionYard.length);
s.yards.classificationYard.push(...parked);
assert.equal(s.yards.divisionYard.length, 0);
const waiting = s.yards.classificationYard.length;
assert.ok(waiting > 0, 'nothing was parked to return');
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', () => {
@@ -662,3 +674,152 @@ describe('measurement discipline', () => {
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
View File
@@ -545,7 +545,7 @@ describe('coupling lifts only the cars the crew ran over', () => {
const area = areaFrom(grid, { row: 0, col: 0 });
const s = gameWith(area);
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,
});
s.clock.phase = 'localOps';
+165 -2
View File
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path';
import { cardDescription, describeIntent } from '../src/sim/view.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 { createGame as createEngineGame } from '../src/engine/setup.ts';
import {
@@ -529,11 +530,11 @@ describe('the page explains itself', () => {
const game = newGame(5);
const s = game.state;
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,
});
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,
});
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}`);
}
});
});