diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..631f049 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,147 @@ +# Changelog + +Detail behind each commit. Commit messages stay high level; the reasoning, the measurements, and +the things that turned out to be wrong live here. + +Measured figures are 100 solitaire Standard games with the developer bot unless stated otherwise. +The target is 20 Revenue over 5 Days. + +--- + +## Unreleased + +Nothing yet. + +--- + +## Board rendering, three-page site, curve geometry + +Drawing the board as track, splitting the site, tooltips, and the curve fix. + +### Board rendering + +Two renderers in `src/sim/board-svg.ts`, chosen because they fail in opposite places: + +- **Office Area** — the district stays a map. Cards on a grid with the rails drawn edge to edge, so + a join is rail meeting rail rather than two descriptions that happen to agree. The through rail + sits at a constant height on every card, which is the alignment the printed cards use. +- **The Division** — not a map but a queue of sections with hard capacities, so it is a dispatcher's + diagram: one line per track, `1 of 2 free` under each section, `no limit — trains queue` at the + Division Points. + +Both are **self-contained** — no imports, no module-level helpers — because the playable app imports +them normally while the replay is a single HTML file with an inline script that cannot import +anything, and embeds them via `Function.toString()`. One implementation either way; a second copy +would eventually draw a different board from the same state. + +Rails come from the engine's own `connectionsFor`, now exported. A drawn rail cannot claim a +connection the rules do not have — visible in that **Modifier cards draw no rails at all**. + +Capacity, confirmed from the rules and now shown: Division Points unlimited, most Mainline cards 1, +Double Track and Uncontrolled Siding 2, Offices 1/2/3/4 by tier. + +### Three pages + +`index.html` is now a splash with two doors. The game moved to `play.html`; `replays.html` is a +directory. + +**A replay is a save**, and a save is `{seed, history}`. The engine is deterministic and runs in the +browser, so re-submitting the same moves rebuilds the position exactly — **18 KB instead of 3.4 MB**, +about 190× smaller, small enough to email. A save that would describe an impossible position cannot +be replayed at all, because every step goes back through `applyIntent`; the viewer stops and says so +rather than showing a board the rules could not produce. + +Static hosting cannot list a directory, so `replays/manifest.json` is generated at build time from +whatever is in `public/replays/`, with each file validated first. + +### Tooltips + +Reference detail is read once and printing it costs the space the board and action list need. A +single delegated tooltip (`src/web/tooltip.ts`) now carries card effects, action reasoning, and +facility state. Not the native `title`: that waits a second, cannot be styled, and never appears for +keyboard users. + +### Curve geometry — the significant fix + +A curve was modelled as a through track **plus** a diverging leg, which is a turnout. Consequences: + +- Curves were **topologically identical duplicates of turnouts** — same connections, no distinction + beyond the sharp curve's 2-Move cost. +- Every diverging leg went south, so **no piece anywhere reached north** except an n-s straight, + which connects n↔s and nothing else. +- Therefore a district could only ever be a **vertical column**: no siding, no parallel track, no + run-around, no second turnout back to the main. + +The printed cards (`docs/tracks.png`, rows 3–4) show a curve as a single arc from edge to edge with +no through track. A curve is now a **two-port arc**, rotatable to `ne`/`nw`/`se`/`sw`. A sharp curve +is geometrically identical and costs 2 Moves. Turnouts keep §A.1 unchanged — a two-port arc has no +third port, so the absent-edge rule cannot apply to it. + +Verified through the engine, not the types: a crew leaves the main at a turnout, runs a siding +parallel, and rejoins at the far end. + +### Measurements + +| | revenue | freight | sidings built | +| --- | --- | --- | --- | +| before | 4.1 | 0.5 | impossible | +| geometry only | **3.9** | 0.9 | possible, not sought | +| bot builds sidings | 3.3 | **1.1** | **59/60 games** | + +Freight more than doubled and the harness recorded **its first win**, but overall revenue is down +from geometry-alone and the bad tail worsened (−29 → −59). + +Capping turnouts at one or two measured **worse** (revenue 2.5, freight 0.6) — more ways off the main +means more industries the crew can reach, and that beats a tidy Running Track. The uncapped version +stands, with that measurement recorded at the code. + +**The bot builds sidings but does not exploit them.** It pays about 20 of its 26 track pieces for +them while its switching logic still sets out dead weight on a spur rather than planning a +run-around. That is the next piece of work and where the revenue should appear. + +--- + +## Earlier commits + +Recorded from memory of the work rather than written at the time; detail thins going back. + +### `f00255c` — more fixes and tweaks + +Card descriptions on every card in hand and every face-up slot, the three Local Operations options +explained, the objective and pace in the header, and rotations named in words rather than +"rotation 2". Build stamp added (version, git SHA, `-dirty` for an uncommitted tree) because nothing +tracked what was deployed. Extra X22 was **not** a bug — a per-diem train whose card calls for a +caboose and nothing else — but "loaded caboose" was. + +### `d1f689d` — name the caboose, the train and the Running Track hazard + +`maneuver.redFlags` was labelled "Red Flags on tray3": the earlier tray-id fix checked the history +log, and the action list was a surface it missed. An industry on the Running Track does not block +traffic (§11.2 gives it rails) but a car left standing there is hit by the next arrival (§10). + +### `dd300ac` — fix caching, Limits placement, and explain the board better + +The first deploy served a fresh `index.html` against a **cached** `main.js`, which threw +`missing element: target` and never started. Every module import now carries the build tag. The +Limits fix was half-done: the sign could move, but nothing forbade building past it, so one straight +at (0,2) produced `limits · Whistle Post · limits · straight · limits`. + +### `2eca9de` — playable browser build + +The solitaire game as a static site, proven to need no server: full games run with every Node global +replaced by a throwing stub. Train cards stopped accepting a board placement — seed 555 had offered +Extra X15 at six squares with six rotations, all identical. + +### `160190d` — the bot's reasoning in the replay + +Decision panel showing what the bot chose, why, and every option it passed over, with the reasons +reported by the bot itself rather than re-derived by the viewer. + +### `d261ad7` — parking trains, freight deadlock, Whistle Post lock-in + +Three faults found by measurement rather than failing tests. Trains parked because `destinationsFor` +computed reverse as `facing === 'e' ? 'w' : 'e'`, so a crew facing south reversed to east — a port a +north-south card does not have. Freight ran at a **3% load completion rate** because a load started +with no spotted car parks on WORK and locks the industry track, blocking the very car that would +clear it. Office density doubled after 25 of 100 games never drew a Depot and never escaped a +one-track Whistle Post. diff --git a/docs/design/board-first-pass.html b/docs/design/board-first-pass.html new file mode 100644 index 0000000..d7fe2e4 --- /dev/null +++ b/docs/design/board-first-pass.html @@ -0,0 +1,65 @@ + +

Board rendering — first pass

+

Approach B (dispatcher’s diagram) for the Division, approach D (grid with real rails) for the Office Area. Every board below is a real bot-driven game, rendered by the same two functions the playable game and the replay both use. Hover any card or train for detail.

+

MULTIPLAYER DIVISIONS — capacity is countable

+
2 players · seed 79 · Day 1 Stage 11 · 7 sections · 3 train(s) on the Division · revenue [-10, -3]
West DPno limit — trains queueHeavy Gradeclimbs ▲ W1 of 1 freeABS SignalsWhistle PostTX200 of 1 freeHilly1 of 1 freeDepotT5T10 of 2 freeUncontrolled Siding2 of 2 freeEast DPno limit — trains queue
+
3 players · seed 80 · Day 6 Stage 1 · 9 sections · 2 train(s) on the Division · revenue [0, -14, 4]
West DPno limit — trains queuePlains1 of 1 freeABS SignalsTerminal4 of 4 freeDouble Track2 of 2 freeWhistle Post1 of 1 freeHeavy Gradeclimbs E ▲T1 (1)0 of 1 freeStation3 of 3 freeTunnelT10 (2)0 of 1 freeEast DPno limit — trains queue
+
4 players · seed 81 · Day 2 Stage 9 · 11 sections · 5 train(s) on the Division · revenue [3, -2, -3, -8]
West DPno limit — trains queueYardT2 (1)0 of 1 freeABS SignalsTerminal4 of 4 freeDouble Track2 of 2 freeWhistle Post1 of 1 freeDouble TrackT6 (1)1 of 2 freeWhistle Post1 of 1 freeTunnel1 of 1 freeDepotT9T30 of 2 freeDouble TrackT7 (2)1 of 2 freeEast DPno limit — trains queue
+
5 players · seed 82 · Day 2 Stage 11 · 13 sections · 4 train(s) on the Division · revenue [1, 0, -10, 1, 2]
West DPTX22no limit — trains queueHillyTX20 (1)0 of 1 freeStation3 of 3 freeUncontrolled SidingT12 (2)1 of 2 freeWhistle PostT110 of 1 freePlains1 of 1 freeWhistle Post1 of 1 freeUncontrolled Siding2 of 2 freeDepot2 of 2 freeHeavy Gradeclimbs E ▲1 of 1 freeBrakeman · AirbrakesDepot2 of 2 freeYard1 of 1 freeEast DPno limit — trains queue
+

OFFICE AREAS — rails drawn, so a join is visible

+
seed 430 · solitaire · the opening position — Limits, Whistle Post, Limits · 7 cards · 2 facilities · Office is a Depot
Depot0,0Freight House0,-1turnout0,1Limits0,2Rotary Dumps-1,0Freight House0,-2Limits0,-3RUNNING TRACK
+
seed 430 · solitaire · a few turns in: the Running Track starts to grow · 16 cards · 5 facilities · Office is a Depot
Depot0,0Freight House0,-1turnout0,1Freight House0,2Rotary Dumps-1,0Freight House0,-2Refinery0,-3curve-1,1Packing Sheds0,3straight-1,2straight-1,3Limits0,4straight-1,4Limits0,-4straight-1,5straight-1,6RUNNING TRACK
+
seed 430 · solitaire · mid-game, first industries down · 32 cards · 5 facilities · Office is a Terminal
Terminal0,0Freight House0,-1turnout0,1Freight House0,2Rotary Dumps-1,0Freight House0,-2Refinery0,-3curve-1,1Packing Sheds0,3straight-1,2straight-1,3turnout0,4straight-1,4turnout0,-4straight-1,5straight-1,6straight-1,7straight-1,8Local Small Groceries-1,-1straight-1,9curve-1,10turnout0,-5curve-1,-4turnout0,5Truck Dock-1,-2curve-1,-3turnout0,6turnout0,-6curve-1,-5Limits0,7Limits0,-7curve-1,-6RUNNING TRACK
+
seed 430 · solitaire · the winning game, fully grown · 37 cards · 7 facilities · Office is a Terminal
Terminal0,0Freight House0,-1turnout0,1Freight House0,2Rotary Dumps-1,0Freight House0,-2Refinery0,-3curve-1,1Packing Sheds0,3straight-1,2straight-1,3turnout0,4straight-1,4turnout0,-4straight-1,5straight-1,6straight-1,7straight-1,8Local Small Groceries-1,-1straight-1,9curve-1,10turnout0,-5curve-1,-4cabempturnout0,5Truck Dock-1,-2curve-1,-3turnout0,6turnout0,-6curve-1,-5turnout0,7curve0,-7curve-1,-6Freight House0,8Limits0,-8curve0,9Mine Tipple0,10Limits0,11RUNNING TRACK
+
seed 202 · solitaire · a long Running Track with industries standing on it · 34 cards · 4 facilities · Office is a Station
Station0,0OverpassFreight House0,-1empempturnout0,1Freight House0,2empPower Plant-1,0empempempcurve-1,1Packing Sheds0,-2straight-1,2turnout0,-3straight-1,3straight-1,4Ice House-1,-1straight-1,5Forklifts-2,0straight-1,6straight-1,7straight-1,8straight-1,9turnout0,3curve-1,10turnout0,4turnout0,-4curve-1,-3cabcurve-1,-2turnout0,-5curve-1,-4cabturnout0,5turnout0,6curve0,-6Viscosity Breakers-2,1curve-1,-5curve0,7Limits0,-7Limits0,8RUNNING TRACK
+
seed 111 · solitaire · a district hanging below the Running Track · 25 cards · 2 facilities · Office is a Depot
Depot0,0T7Grocer's Warehouse0,-1empturnout0,1turnout0,2Refinery-1,0curve-1,1straight-1,2Yard OfficeT10straight-1,3Pipelines-1,-1straight-1,4straight-1,5straight-1,6straight-1,7turnout0,-2straight-1,8straight-1,9curve-1,10turnout0,-3Hotel-2,0curve-1,-2loaturnout0,3turnout0,4Limits0,-4curve-1,-3Limits0,5RUNNING TRACK
+
seed 80 · 3 players · three-player game, player 1 · 22 cards · 1 facility · Office is a Terminal
Terminal0,0turnout0,-1turnout0,1Power Plant0,2curve-1,1straight-1,2straight-1,3straight-1,4Ice House-1,0T11straight-1,5straight-1,6straight-1,7straight-1,8straight-1,9Transmission Lines-1,-1curve-1,10turnout0,-2turnout0,-3turnout0,3curve-1,-2cabLimits0,4Limits0,-4RUNNING TRACK
+
seed 81 · 4 players · four-player game, player 1 · 19 cards · 2 facilities · Office is a Terminal
Terminal0,0T9Refinery0,-1turnout0,1Limits0,2Grocer's Warehouse-1,0curve-1,1turnout0,-2straight-1,2straight-1,3Restaurant-1,-1straight-1,4straight-1,5straight-1,6straight-1,7straight-1,8straight-1,9curve-1,10Limits0,-3curve-1,-2RUNNING TRACK
\ No newline at end of file diff --git a/docs/design/board-layout-studies.html b/docs/design/board-layout-studies.html new file mode 100644 index 0000000..9e4db9a --- /dev/null +++ b/docs/design/board-layout-studies.html @@ -0,0 +1,41 @@ +

Station Master — board layout studies

Four ways to draw the same position, so that where a train is, which track it is on, and whether two cards actually join can be read at a glance rather than from a sentence. Nothing here is wired to the engine — these are drawings.

First: what the rules actually limit

WhereTrains allowedWhat happens if exceeded
Division Point (either end)No limittrains simply queue
Mainline card (most)1a follower needs the Superintendent’s clearance (§8.1)
Double Track / Uncontrolled Siding2+ — “trains may pass”no ruling needed at all
Whistle Post1 A/D tracknext arrival is an automatic collision
Depot / Station / Terminal2 / 3 / 4same — collision, −5

So capacity is real and worth drawing: it is 1 almost everywhere on the Mainline, 2+ on exactly two card types, fixed by tier at an Office, and unbounded at the Division Points. That maps cleanly onto count the slots.

Office — fixed A/D tracksDivision Point — unlimitedMainline cardLimitsIndustry

A. Faithful cards — the tabletop, redrawn

The through rail sits at the same height on every card, so cards butt together and the line runs unbroken — which is exactly how the printed cards work. Every place a marker may stand is a printed square, so capacity is something you count rather than read.

THE DIVISION — west to eastWest DPunlimitedHeavy Grade1 train · climbs EYOUR DEPOT2 A/D tracksDouble Track2 trains may passEast DPunlimitedT3T7T12T4T5Rail meets rail across every join. A square = one place a train may stand; ∞ = no limit.

Strength: closest to the physical game; a player who has seen the cards needs no explanation. Cost: wide. Five cards already fill the strip, and a real Division has more.

B. Dispatcher’s diagram — count the lines

How a real dispatcher sees a division: one continuous line per running track, section boundaries as thin seams. Capacity is the number of parallel lines, and a train sits on a line.

THE DIVISION — dispatcher's track diagramWest DPno limit — trains queue hereHeavy Grade1 trackYOUR DEPOT2 tracksDouble Track2 tracksEast DPno limit — trains queue hereT3T9T7T12A/D 2 freeT4T5Count the lines to know the capacity. A train sits ON a line, so "which track" is never ambiguous.Loses the card-by-card feel, but nothing about connection or occupancy can be misread.

Strength: nothing about occupancy or capacity can be misread, and it stays compact at any Division length. Cost: abandons the card metaphor entirely — terrain becomes a label rather than a picture.

C. Swimlanes — one lane per track

The Running Track is the top lane and never moves. Each further track — A/D tracks, industry sidings — is its own lane beneath. Cards become columns, so a card that owns several tracks is simply taller.

YOUR OFFICE AREA — the Running Track never leaves the top laneRUNNINGA/D 1A/D 2SIDINGLimitsstraightDEPOT2 A/D tracks — a 3rd arrival collidesFreight Houseindustry siding · 2 spotsstraightLimitsT7T12One lane per track, running the full width. "Which track is that train on?" is answered by its row.Tall cards where a card owns several tracks; the Running Track stays a single unbroken line.

Strength: “which track is that train on?” is answered by which row it is in, with no ambiguity anywhere. Cost: a tall card next to a short one looks ragged, and the district stops resembling a map.

D. The current grid, with the rails actually drawn

The smallest change: keep today’s grid and layout, but draw real rail edge to edge and real squares for standing room. A join becomes rail meeting rail instead of two descriptions that happen to agree.

YOUR OFFICE AREA — grid, with the rails actually drawnLimitsstraightDEPOT2 A/DFreight HouseLimitsturnoutRefinerysiding full — nothing more can be spottedT12T7Nearest to what exists now: same grid, but a join is visible as rail meeting rail.A curve leaving the Running Track is drawn, so a turnout that goes nowhere is obvious at a glance.

Strength: preserves the map-like district, and a turnout whose leg goes nowhere becomes obvious. Cost: does least for the Mainline strip, which is where capacity confusion actually bites.

What I would do

D for your Office Area, B for the Division strip.

They fail in opposite places. The district is a map you build, and it wants to stay a map — so draw the rails on the grid you already have (D). The Division is not a map; it is a queue of sections with hard capacities, which is precisely what a dispatcher’s diagram is for (B), and it stays readable however long the Division grows.

A is the most faithful and the most charming, and I would keep it in reserve: it is the right answer if this ever becomes a table you look down on rather than a panel you read. C solves the one question the others answer least well — which of several A/D tracks a train occupies — so if that turns out to be the confusion that matters most, C for the Office alone is worth revisiting.

Every drawing above shares one idea worth keeping whichever you pick: a square is a place a train may stand, and an empty square is spare capacity. That single convention makes “how many more trains fit here” answerable without a legend.

\ No newline at end of file diff --git a/public/replays/seed-202.json b/public/replays/seed-202.json new file mode 100644 index 0000000..8acb6af --- /dev/null +++ b/public/replays/seed-202.json @@ -0,0 +1,1986 @@ +{ + "seed": 202, + "history": [ + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "card.play", + "cardId": "c8" + }, + { + "type": "mainline.modify", + "cardId": "c106", + "node": 1 + }, + { + "type": "card.play", + "cardId": "c94", + "placement": { + "row": 0, + "col": 0 + }, + "variant": 0 + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "left", + "placement": { + "row": 0, + "col": 1 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c56", + "placement": { + "row": -1, + "col": 0 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "left", + "placement": { + "row": -1, + "col": 1 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c38", + "placement": { + "row": 0, + "col": -1 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 1 + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 2 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c57", + "placement": { + "row": 0, + "col": -2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 4 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c83", + "placement": { + "row": -1, + "col": -1 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 5 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c73", + "placement": { + "row": -2, + "col": 0 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "mainline.modify", + "cardId": "c107", + "node": 3 + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 6 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 7 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c0" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 8 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 9 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c40", + "placement": { + "row": 0, + "col": 2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c18" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "left", + "placement": { + "row": -1, + "col": 10 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": false + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "left", + "placement": { + "row": 0, + "col": 3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "left", + "placement": { + "row": 0, + "col": -3 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c46", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "left", + "placement": { + "row": -1, + "col": -3 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c33", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray1", + "to": { + "row": -1, + "col": -3 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray1", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray1", + "to": { + "row": -1, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray1", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray1", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray1", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray1", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 0 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 2 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 0 + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 0 + }, + "carIndex": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": -1, + "col": 0 + }, + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 0 + }, + { + "type": "laborer.beginUnload", + "at": { + "row": -1, + "col": 0 + }, + "carIndex": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": -1, + "col": 0 + }, + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 2 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": -1, + "col": 0 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": -1, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c28" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "left", + "placement": { + "row": -1, + "col": -2 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "card.play", + "cardId": "c33" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "left", + "placement": { + "row": 0, + "col": -4 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray1", + "carType": "coach", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "card.play", + "cardId": "c14" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "right", + "placement": { + "row": -1, + "col": -4 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "caboose", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "right", + "placement": { + "row": 0, + "col": 4 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c60", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "right", + "placement": { + "row": 0, + "col": 5 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c115", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": -1, + "col": -4 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 2 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": -1 + }, + "carIndex": 0 + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": 2 + }, + "carIndex": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 2 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -1 + }, + "carType": "boxcar" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 2 + }, + "carType": "boxcar" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": -1 + }, + "index": 0 + }, + { + "type": "laborer.startLoad", + "at": { + "row": 0, + "col": -1 + } + }, + { + "type": "laborer.startLoad", + "at": { + "row": 0, + "col": 2 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 2 + }, + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -1 + }, + "carType": "boxcar" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 2 + }, + "carType": "boxcar" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 2 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "right", + "placement": { + "row": 0, + "col": -5 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c79", + "placement": { + "row": -2, + "col": 1 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": -1 + }, + "carIndex": 0 + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": 2 + }, + "carIndex": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "right", + "placement": { + "row": -1, + "col": -5 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c46", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 2 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "right", + "placement": { + "row": 0, + "col": 6 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c113", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "right", + "placement": { + "row": 0, + "col": -6 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c60", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "laborer.startLoad", + "at": { + "row": 0, + "col": -1 + } + }, + { + "type": "laborer.startLoad", + "at": { + "row": 0, + "col": 2 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": -1 + }, + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 2 + }, + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -2 + }, + "carType": "reefer" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 2 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "right", + "placement": { + "row": 0, + "col": 7 + }, + "variant": 1 + }, + { + "type": "card.discard", + "cardId": "c110", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": -1 + }, + "carIndex": 0 + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": 2 + }, + "carIndex": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.unjam", + "at": { + "row": 0, + "col": 0 + }, + "from": "outbound", + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 2 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.unjam", + "at": { + "row": 0, + "col": 0 + }, + "from": "outbound", + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.unjam", + "at": { + "row": 0, + "col": -1 + }, + "from": "menAtWork", + "index": 0 + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": 2 + }, + "box": 0 + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": -1 + }, + "carIndex": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 2 + }, + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.unjam", + "at": { + "row": 0, + "col": -1 + }, + "from": "menAtWork", + "index": 1 + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "boxcar", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.unjam", + "at": { + "row": 0, + "col": -2 + }, + "from": "outbound", + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -2 + }, + "carType": "reefer" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.unjam", + "at": { + "row": 0, + "col": -2 + }, + "from": "outbound", + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -2 + }, + "carType": "reefer" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.unjam", + "at": { + "row": 0, + "col": -2 + }, + "from": "outbound", + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -2 + }, + "carType": "reefer" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.unjam", + "at": { + "row": 0, + "col": -2 + }, + "from": "outbound", + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -2 + }, + "carType": "reefer" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.unjam", + "at": { + "row": 0, + "col": -2 + }, + "from": "outbound", + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "card.discard", + "cardId": "c46", + "toSlot": 0 + }, + { + "type": "card.discard", + "cardId": "c105", + "toSlot": 0 + }, + { + "type": "card.discard", + "cardId": "c60", + "toSlot": 0 + }, + { + "type": "loadUnload.end" + } + ], + "title": "A busy district", + "note": "freight worked through a siding, and a collision to inspect" +} \ No newline at end of file diff --git a/public/replays/seed-430.json b/public/replays/seed-430.json new file mode 100644 index 0000000..fc90272 --- /dev/null +++ b/public/replays/seed-430.json @@ -0,0 +1,1808 @@ +{ + "seed": 430, + "history": [ + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c25" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "left", + "placement": { + "row": 0, + "col": 1 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c81", + "placement": { + "row": -1, + "col": 0 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c38", + "placement": { + "row": 0, + "col": -1 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c39", + "placement": { + "row": 0, + "col": -2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "left", + "placement": { + "row": -1, + "col": 1 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c37", + "placement": { + "row": 0, + "col": 2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 3 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c59", + "placement": { + "row": 0, + "col": 3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 4 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c48", + "placement": { + "row": 0, + "col": -3 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 5 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c14" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 6 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "caboose", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c16" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 7 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 8 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c85", + "placement": { + "row": -1, + "col": -1 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "straight", + "hand": "none", + "placement": { + "row": -1, + "col": 9 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "left", + "placement": { + "row": -1, + "col": 10 + }, + "variant": 1 + }, + { + "type": "card.discard", + "cardId": "c22", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c103", + "placement": { + "row": 0, + "col": 1 + }, + "variant": 0 + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "left", + "placement": { + "row": 0, + "col": -4 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c6" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "left", + "placement": { + "row": -1, + "col": -4 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "left", + "placement": { + "row": 0, + "col": 4 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c70", + "placement": { + "row": -1, + "col": -2 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c31" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "left", + "placement": { + "row": -1, + "col": -3 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "left", + "placement": { + "row": 0, + "col": 5 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c109", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "right", + "placement": { + "row": 0, + "col": -5 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c97", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "right", + "placement": { + "row": -1, + "col": -5 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c101", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "mainline.modify", + "cardId": "c108", + "node": 1 + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "right", + "placement": { + "row": 0, + "col": 6 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "right", + "placement": { + "row": 0, + "col": -6 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c99", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c34" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "right", + "placement": { + "row": -1, + "col": -6 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "turnout", + "hand": "right", + "placement": { + "row": 0, + "col": 7 + }, + "variant": 0 + }, + { + "type": "card.discard", + "cardId": "c79", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.end" + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": -1 + }, + "carIndex": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": -1 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -1 + }, + "carType": "boxcar" + }, + { + "type": "laborer.startLoad", + "at": { + "row": 0, + "col": -1 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -1 + }, + "carType": "boxcar" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "right", + "placement": { + "row": 0, + "col": -7 + }, + "variant": 0 + }, + { + "type": "card.play", + "cardId": "c40", + "placement": { + "row": 0, + "col": 8 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "track.lay", + "geometry": "curved", + "hand": "right", + "placement": { + "row": 0, + "col": 9 + }, + "variant": 1 + }, + { + "type": "card.discard", + "cardId": "c56", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromDepartment", + "slot": 0 + }, + { + "type": "card.discard", + "cardId": "c112", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": true + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "boxcar", + "loaded": true + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": -1 + }, + "carIndex": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.discard", + "cardId": "c117", + "toSlot": 0 + }, + { + "type": "draw.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c102", + "placement": { + "row": 0, + "col": 1 + }, + "variant": 0 + }, + { + "type": "draw.end" + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 1 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -2 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.end" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -1 + }, + "box": 0 + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": -2 + }, + "carIndex": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": -1 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": -2 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 0 + }, + "carType": "coach" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 2 + }, + "carType": "boxcar" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -2 + }, + "carType": "boxcar" + }, + { + "type": "laborer.startLoad", + "at": { + "row": 0, + "col": -2 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -2 + }, + "carType": "boxcar" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c44", + "placement": { + "row": 0, + "col": 10 + }, + "variant": 1 + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "hopper", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray3", + "carType": "coach", + "loaded": false + }, + { + "type": "laborer.startLoad", + "at": { + "row": 0, + "col": -2 + } + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -2 + }, + "carType": "boxcar" + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "draw" + }, + { + "type": "draw.fromHomeOffice" + }, + { + "type": "card.play", + "cardId": "c17" + }, + { + "type": "draw.end" + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "hopper", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "hopper", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "caboose", + "loaded": true + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "laborer.beginUnload", + "at": { + "row": 0, + "col": -2 + }, + "carIndex": 0 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 2 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": -4 + }, + "reverse": false + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": -4 + }, + "reverse": true + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": -1 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": -1, + "col": -4 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray3", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray3", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 1 + }, + { + "type": "laborer.advanceLoad", + "at": { + "row": 0, + "col": -2 + }, + "box": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "porter.board", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "porter.detrain", + "at": { + "row": 0, + "col": 0 + } + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "switch" + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": -1, + "col": -4 + }, + "reverse": true + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.dropCars", + "trayId": "tray2", + "count": 1 + }, + { + "type": "switch.move", + "trayId": "tray2", + "to": { + "row": 0, + "col": 0 + }, + "reverse": true + }, + { + "type": "switch.end" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": 0 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.clearInbound", + "at": { + "row": 0, + "col": -2 + }, + "index": 0 + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -3 + }, + "carType": "tank" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": -3 + }, + "carType": "tank" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 3 + }, + "carType": "reefer" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.stockOutbound", + "at": { + "row": 0, + "col": 10 + }, + "carType": "hopper" + }, + { + "type": "loadUnload.end" + }, + { + "type": "localOps.choose", + "option": "freightAgent" + }, + { + "type": "freightAgent.unjam", + "at": { + "row": 0, + "col": -1 + }, + "from": "outbound", + "index": 0 + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "hopper", + "loaded": false + }, + { + "type": "newTrain.placeCar", + "trayId": "tray2", + "carType": "coach", + "loaded": false + }, + { + "type": "loadUnload.end" + } + ], + "title": "A near miss", + "note": "revenue 19 against a target of 20 \u2014 one point short" +} \ No newline at end of file diff --git a/scripts/build-web.ts b/scripts/build-web.ts index 23d0887..73bee41 100644 --- a/scripts/build-web.ts +++ b/scripts/build-web.ts @@ -10,7 +10,7 @@ */ import { execFileSync } from 'node:child_process'; -import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -26,6 +26,8 @@ execFileSync( 'tsc', '--ignoreConfig', 'src/web/main.ts', + 'src/web/splash.ts', + 'src/web/replays.ts', '--outDir', dist, '--rootDir', 'src', '--target', 'es2022', @@ -107,10 +109,47 @@ function bustImports(dir: string): number { const busted = bustImports(dist); -const page = readFileSync(join(root, 'src/web/index.html'), 'utf8') - .replaceAll('__BUILD__', stamp) - .replace(/( + diff --git a/src/web/main.ts b/src/web/main.ts index 306514c..927907a 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -5,6 +5,8 @@ * is answered by the engine or by the shared view helpers. */ +import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from '../sim/board-svg.ts'; +import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import type { Game } from './game.ts'; import { actionMenu, @@ -73,99 +75,46 @@ function render(): void { $('seed').textContent = String(game.seed); // -- division - $('division').innerHTML = f.division - .map( - (n) => - `
` + - `
${esc(n.label)}` + - (n.gradeUp - ? ` ${n.gradeUp === 'east' ? 'climbs E ▲' : '▲ W climbs'}` - : '') + - `
` + - (n.modifiers.length ? `
${n.modifiers.map(esc).join(' · ')}
` : '') + - `
` + - n.trains - .map( - (r) => - `
` + - r - .map( - (t) => - `${esc(t.label)}` + - `
${t.consist.length ? esc(t.consist.join(', ')) : 'empty'}
`, - ) - .join('') + - `
`, - ) - .join('') + - `
`, - ) - .join(''); + $('division').innerHTML = divisionSvg(f.division); - // -- grid - // Include the legal squares in the bounds. A placement is usually just OUTSIDE the current - // extent — that is what extending the district means — so a grid sized to the existing cards - // would highlight nothing at exactly the moment highlighting matters. - const spotCoords = [...spotsAt.keys()].map((k) => k.split(',').map(Number)); - const rows = [...f.cells.map((c) => c.row), ...spotCoords.map((c) => c[0]!)]; - const cols = [...f.cells.map((c) => c.col), ...spotCoords.map((c) => c[1]!)]; - const r0 = Math.min(...rows); - const r1 = Math.max(...rows); - const c0 = Math.min(...cols); - const c1 = Math.max(...cols); + // -- board. Both renderers are shared with the replay so the two can never draw different + // pictures of the same position. const grid = $('grid'); - grid.style.gridTemplateColumns = `repeat(${c1 - c0 + 1}, minmax(104px, 1fr))`; - let cells = ''; - for (let r = r1; r >= r0; r--) { - for (let c = c0; c <= c1; c++) { - const cell = f.cells.find((x) => x.row === r && x.col === c); - const spots = spotsAt.get(`${r},${c}`); - if (!cell) { - // An empty square is still a legal destination — render it as a target rather than a gap, - // or the most common placement of all would have nothing to click. - cells += spots - ? `` - : '
'; - continue; - } - // The full explanation goes in `title` so hovering any card says what it does, with a short - // version printed on the card itself — a name alone tells a player nothing once it is played. - cells += - `
` + - `
${esc(cell.label)}
` + - `
(${r},${c})
` + - `
${esc(short(cell.what))}
` + - (cell.enhancements.length - ? `
${cell.enhancements.map(esc).join(' · ')}
` - : '') + - (cell.tray ? `
🚂 ${esc(cell.tray)}
` : '') + - (cell.cars.length ? `
${esc(cell.cars.join(', '))}
` : '') + - `
`; + grid.innerHTML = officeSvg(f.cells, f.runningRow); + + // Highlighting rides on top of the drawing: outline the legal squares and make them clickable. + for (const [key, list] of spotsAt) { + const [gr, gc] = key.split(',').map(Number); + const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`); + if (g) { + g.classList.add('bs-legal'); + (g as unknown as HTMLElement).onclick = () => pick(key, list); + } + } + // A legal EMPTY square has no card to outline, so draw a target for it. + const ghosts = [...spotsAt.entries()].filter(([k]) => !f.cells.some((c) => `${c.row},${c.col}` === k)); + if (ghosts.length > 0) { + const coords = ghosts.map(([k]) => { + const [gr, gc] = k.split(',').map(Number); + return { row: gr!, col: gc! }; + }); + grid.innerHTML = grid.innerHTML.replace('', ghostSvg(coords, f.cells, f.runningRow) + ''); + for (const [key, list] of ghosts) { + const g = grid.querySelector(`g[data-ghost="${key}"]`); + if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list); } } - grid.innerHTML = cells; - // Clicking a highlighted square plays there. With more than one rotation available it narrows to - // that square instead, so the rotation is a second click rather than a guess. - for (const el of Array.from(grid.querySelectorAll('[data-at]'))) { - const node = el as HTMLElement; - node.onclick = () => { - const spots = spotsAt.get(node.dataset['at'] ?? '') ?? []; - if (spots.length === 1) { - const intent = menu.options[spots[0]!.index]; - if (intent) submit(game, intent); - selected = null; - pendingAt = null; - } else if (spots.length > 1) { - pendingAt = node.dataset['at'] ?? null; - } - render(); - }; + function pick(key: string, list: { label: string; index: number }[]): void { + if (list.length === 1) { + const intent = menu.options[list[0]!.index]; + if (intent) submit(game, intent); + selected = null; + pendingAt = null; + } else { + pendingAt = key; + } + render(); } // -- facilities @@ -175,8 +124,8 @@ function render(): void { : f.facilities .map( (x) => - `
${esc(x.name)}
` + - `
${esc(x.commodity)} · ${esc(x.flow)} · laborers ${esc(x.laborers)}
` + + `
` + + `
${esc(x.name)} ${esc(x.commodity)}
` + `
green${boxes(x.green, x.greenCap)}
` + `
MEN AT WORK` + x.maw @@ -187,21 +136,24 @@ function render(): void { `
` + `
red${boxes(x.red, x.redCap)}
` + `
siding${boxes(x.track, x.trackCap)}
` + - `
` + - (x.jammed - ? 'JAMMED — a load is on WORK with no car spotted; the siding is locked' - : x.canFinish - ? 'ready — a matching empty car is spotted' - : 'no car spotted — a load started here would jam') + + `
` + + (x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') + `
`, ) .join(''); // Name AND effect. A hand of names alone tells a player nothing about what they can do. + // Name and status stay on the page; what the card DOES is reference detail, so it hovers. const cardRow = (name: string, why: string, playable: boolean | null): string => - `
${esc(name)}` + - (playable === false ? ' not playable yet' : '') + - (why ? `
${esc(why)}
` : '') + + `
` + + `${esc(name)}` + + (playable === false ? ' not yet' : '') + `
`; const canPlay = handPlayable(game); @@ -241,12 +193,6 @@ function render(): void { save(); } -/** First clause only — the cards are small, and the full text is on the tooltip. */ -function short(what: string): string { - const head = what.split(' · ')[0] ?? what; - return head.length > 64 ? head.slice(0, 61) + '…' : head; -} - function boxes(items: string[], cap: number): string { let out = ''; for (let i = 0; i < Math.max(cap, items.length); i++) { @@ -286,11 +232,22 @@ function renderActions(menu: ReturnType): void { render(); }; + // A long label is two things: the action, and why it is offered. Put the first on the button and + // the second on the tooltip, or the list crowds out the board. + const actionButton = (label: string, index: number): string => { + const cut = label.indexOf(' — '); + const head = cut > 0 ? label.slice(0, cut) : label; + const rest = cut > 0 ? label.slice(cut + 3) : ''; + return ( + `` + ); + }; + let html = menu.direct .map( (g) => `

${esc(g.title)}

` + - g.actions.map((a) => ``).join('') + + g.actions.map((a) => actionButton(a.label, a.index)).join('') + `
`, ) .join(''); @@ -341,6 +298,24 @@ function renderActions(menu: ReturnType): void { // Saving. localStorage only — nothing leaves the browser. // --------------------------------------------------------------------------- +/** + * Write the game out as a file. + * + * The save IS the replay: a seed and the moves made, which the engine can replay exactly. A few + * hundred bytes, so a finished game can be emailed or dropped on the site's replay directory — + * where a rendered page would have been megabytes. + */ +function downloadSave(): void { + const data = JSON.stringify(toSave(game), null, 1); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `station-master-seed${game.seed}-day${game.state.clock.day}.json`; + a.click(); + URL.revokeObjectURL(url); +} + function save(): void { try { localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game))); @@ -366,4 +341,12 @@ function clearSave(): void { } } +const tipStyle = document.createElement('style'); +tipStyle.textContent = TOOLTIP_CSS; +document.head.appendChild(tipStyle); +installTooltips(); + +const saveBtn = document.getElementById('savefile'); +if (saveBtn) saveBtn.onclick = downloadSave; + start(); diff --git a/src/web/play.html b/src/web/play.html new file mode 100644 index 0000000..2df6fed --- /dev/null +++ b/src/web/play.html @@ -0,0 +1,139 @@ + + + + + +Station Master — play + + + + +
+ Station Master + + phase: + Revenue 0 + + seed + saved in this browser · add ?seed=1234 for a set deal + + replays + __BUILD__ +
+ +
+
+

The Division — west to east

+

Your Office Area — hover any card for the full explanation

+

History

+
+ +
+

Your move

+

Cards

+
hand:
+
face-up Department slots:
+
+

Your track supply

+

Blocked — why nothing is moving

    +

    Facilities

    +
    +
    + + + + diff --git a/src/web/replays.ts b/src/web/replays.ts new file mode 100644 index 0000000..4af4e61 --- /dev/null +++ b/src/web/replays.ts @@ -0,0 +1,241 @@ +/** + * The replay directory, and the viewer that plays one back. + * + * A REPLAY IS A SAVE. `{ seed, history }` — a few hundred bytes — because the engine is + * deterministic and runs in the browser: re-submitting the same intents against the same seed + * reconstructs the position exactly. So sharing a game means sharing a small JSON file, not a + * multi-megabyte page, and a save that would describe an impossible position simply cannot be + * replayed, because every step goes through `applyIntent`. + * + * Static hosting cannot list a directory, so site-hosted replays are enumerated by + * `replays/manifest.json`, written at build time from whatever is in `public/replays/`. + */ + +import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts'; +import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; +import { narrate } from '../sim/narrate.ts'; +import { cardName, trainName } from '../sim/view.ts'; +import type { Frame } from '../sim/view.ts'; +import { applyIntent } from '../engine/apply.ts'; +import { pump } from '../engine/advance.ts'; +import { createGame } from '../engine/setup.ts'; +import { snapshot } from '../sim/view.ts'; +import type { Intent } from '../engine/intents.ts'; +import { SOLO_CONFIG } from './game.ts'; + +type Save = { seed: number; history: Intent[] }; +type Entry = { file: string; title: string; note?: string; seed?: number }; + +const $ = (id: string): HTMLElement => { + const el = document.getElementById(id); + if (!el) throw new Error(`missing element: ${id}`); + return el; +}; +const esc = (s: string): string => + String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); + +/** Every position the save passes through, with the lines narrated along the way. */ +type Step = { frame: Frame; lines: { text: string; tone: string }[] }; + +/** + * Rebuild a game from a save, keeping a snapshot at each step. + * + * An intent that no longer applies stops the rebuild rather than being forced — better a short + * replay than one showing a position the rules could not produce. + */ +function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } { + const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] }); + const steps: Step[] = []; + const ctx = { cardName: (id: string) => cardName(s, id), trainName: (id: string) => trainName(s, id) }; + const push = (events: ReturnType): void => { + const lines = events + .filter((e) => e.type !== 'actorChanged') + .map((e) => { + const n = narrate(e, ctx); + return { text: n.text, tone: n.tone }; + }); + steps.push({ frame: snapshot(s, [], null), lines }); + }; + + push(pump(s)); + let stoppedEarly = false; + for (const intent of save.history) { + const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor; + if (actor === null || s.status !== 'active') break; + const r = applyIntent(s, actor, intent); + if (!r.ok) { + stoppedEarly = true; + break; + } + const events = [...r.events, ...pump(s)]; + push(events); + } + return { steps, stoppedEarly }; +} + +// --------------------------------------------------------------------------- +// Viewer +// --------------------------------------------------------------------------- + +let steps: Step[] = []; +let at = 0; +let timer: ReturnType | null = null; + +function show(i: number): void { + at = Math.max(0, Math.min(steps.length - 1, i)); + const step = steps[at]; + if (!step) return; + const f = step.frame; + + $('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`; + $('vphase').textContent = f.phase; + $('vrev').textContent = String(f.revenue); + $('vpos').textContent = `${at} / ${steps.length - 1}`; + ($('vscrub') as HTMLInputElement).value = String(at); + $('vdivision').innerHTML = divisionSvg(f.division); + $('vgrid').innerHTML = officeSvg(f.cells, f.runningRow); + + // 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++) { + for (const l of steps[k]?.lines ?? []) { + log += `
    ${esc(l.text)}
    `; + } + } + $('vlog').innerHTML = log; + const logEl = $('vlog'); + logEl.scrollTop = logEl.scrollHeight; +} + +function openSave(save: Save, title: string): void { + const built = rebuild(save); + steps = built.steps; + $('picker').style.display = 'none'; + $('viewer').style.display = 'block'; + $('vtitle').textContent = title; + $('vnote').textContent = built.stoppedEarly + ? 'this save stopped early — an action in it is no longer legal under the current rules' + : `${steps.length} steps · seed ${save.seed}`; + const scrub = $('vscrub') as HTMLInputElement; + scrub.max = String(Math.max(0, steps.length - 1)); + show(0); +} + +function parseSave(text: string, from: string): Save | null { + try { + const raw = JSON.parse(text) as Partial; + if (typeof raw.seed !== 'number' || !Array.isArray(raw.history)) { + throw new Error('not a Station Master save'); + } + return { seed: raw.seed, history: raw.history as Intent[] }; + } catch (e) { + $('perr').textContent = `Could not read ${from}: ${(e as Error).message}`; + return null; + } +} + +// --------------------------------------------------------------------------- +// Directory +// --------------------------------------------------------------------------- + +async function listHosted(): Promise { + const el = $('hosted'); + try { + const res = await fetch('./replays/manifest.json', { cache: 'no-cache' }); + if (!res.ok) throw new Error(String(res.status)); + const entries = (await res.json()) as Entry[]; + if (entries.length === 0) { + el.innerHTML = '

    No replays have been published to this site yet.

    '; + return; + } + el.innerHTML = entries + .map( + (e, i) => + ``, + ) + .join(''); + for (const b of Array.from(el.querySelectorAll('button.entry'))) { + (b as HTMLElement).onclick = async () => { + const entry = entries[Number((b as HTMLElement).dataset['i'])]; + if (!entry) return; + const r = await fetch(`./replays/${entry.file}`, { cache: 'no-cache' }); + const save = parseSave(await r.text(), entry.file); + if (save) openSave(save, entry.title); + }; + } + } catch { + el.innerHTML = + '

    No replay index on this site yet. You can still open a save file from your computer below.

    '; + } +} + +function wire(): void { + const file = $('file') as HTMLInputElement; + file.onchange = () => { + const f = file.files?.[0]; + if (!f) return; + const reader = new FileReader(); + reader.onload = () => { + const save = parseSave(String(reader.result), f.name); + if (save) openSave(save, f.name.replace(/\.json$/i, '')); + }; + reader.readAsText(f); + }; + + $('vback').onclick = () => show(at - 1); + $('vfwd').onclick = () => show(at + 1); + $('vfirst').onclick = () => show(0); + $('vlast').onclick = () => show(steps.length - 1); + ($('vscrub') as HTMLInputElement).oninput = (e) => show(Number((e.target as HTMLInputElement).value)); + $('vstage').onclick = () => { + const now = steps[at]?.frame; + for (let k = at + 1; k < steps.length; k++) { + const f = steps[k]!.frame; + if (f.stage !== now?.stage || f.day !== now?.day) return show(k); + } + show(steps.length - 1); + }; + $('vplay').onclick = () => { + if (timer) { + clearInterval(timer); + timer = null; + $('vplay').textContent = '▶ play'; + return; + } + $('vplay').textContent = '⏸ pause'; + timer = setInterval(() => { + if (at >= steps.length - 1) { + clearInterval(timer!); + timer = null; + $('vplay').textContent = '▶ play'; + return; + } + show(at + 1); + }, Number(($('vspeed') as HTMLSelectElement).value)); + }; + $('vclose').onclick = () => { + if (timer) clearInterval(timer); + timer = null; + $('viewer').style.display = 'none'; + $('picker').style.display = 'block'; + }; + + document.onkeydown = (e) => { + if ($('viewer').style.display === 'none') return; + if (e.key === 'ArrowRight') show(at + 1); + else if (e.key === 'ArrowLeft') show(at - 1); + else if (e.key === ' ') { + e.preventDefault(); + $('vplay').click(); + } + }; +} + +const style = document.createElement('style'); +style.textContent = BOARD_CSS + TOOLTIP_CSS; +document.head.appendChild(style); +installTooltips(); +wire(); +void listHosted(); diff --git a/src/web/splash.ts b/src/web/splash.ts new file mode 100644 index 0000000..ab747f6 --- /dev/null +++ b/src/web/splash.ts @@ -0,0 +1,9 @@ +/** The splash page. Draws ties on the rule so the line reads as track rather than a border. */ +const ties = document.getElementById('ties'); +if (ties) { + let out = ''; + for (let x = 4; x < 700; x += 9) { + out += ``; + } + ties.innerHTML = out; +} diff --git a/src/web/tooltip.ts b/src/web/tooltip.ts new file mode 100644 index 0000000..77b275b --- /dev/null +++ b/src/web/tooltip.ts @@ -0,0 +1,90 @@ +/** + * One floating tooltip, shared by every page. + * + * Reference detail — what a card does, why an action is offered, what a limit means — is needed + * occasionally and read once. Printing all of it costs the space that the board, the hand and the + * action list actually need. So anything carrying `data-tip` shows its text on hover or focus and + * nothing the rest of the time. + * + * Not the browser's native `title`: that waits about a second, cannot be styled, wraps badly at + * this length, and never appears for keyboard users. + */ + +let tip: HTMLElement | null = null; + +function ensure(): HTMLElement { + if (tip) return tip; + const el = document.createElement('div'); + el.id = 'tip'; + el.setAttribute('role', 'tooltip'); + document.body.appendChild(el); + tip = el; + return el; +} + +function place(el: HTMLElement, text: string): void { + const t = ensure(); + t.textContent = text; + t.style.display = 'block'; + + // Measure, then keep it on screen: flip above when it would fall off the bottom, and pull back + // from the right edge rather than letting the text run off it. + const r = el.getBoundingClientRect(); + const box = t.getBoundingClientRect(); + const margin = 8; + let left = r.left + r.width / 2 - box.width / 2; + left = Math.max(margin, Math.min(left, window.innerWidth - box.width - margin)); + let top = r.bottom + 6; + if (top + box.height > window.innerHeight - margin) top = r.top - box.height - 6; + t.style.left = `${Math.round(left + window.scrollX)}px`; + t.style.top = `${Math.round(top + window.scrollY)}px`; +} + +function hide(): void { + if (tip) tip.style.display = 'none'; +} + +/** + * Start listening. Delegated from the document, so markup replaced later — the board redraws on + * every action — needs no re-binding. + */ +export function installTooltips(): void { + const find = (node: EventTarget | null): HTMLElement | null => { + let el = node as HTMLElement | null; + while (el && el !== document.body) { + // SVG elements have no closest() in older engines, so walk manually. + if (el.getAttribute && el.getAttribute('data-tip')) return el; + el = (el.parentNode as HTMLElement) ?? null; + } + return null; + }; + + document.addEventListener('mouseover', (e) => { + const el = find(e.target); + if (el) place(el, el.getAttribute('data-tip') ?? ''); + else hide(); + }); + document.addEventListener('mouseout', (e) => { + if (!find((e as MouseEvent).relatedTarget)) hide(); + }); + document.addEventListener('focusin', (e) => { + const el = find(e.target); + if (el) place(el, el.getAttribute('data-tip') ?? ''); + }); + document.addEventListener('focusout', hide); + document.addEventListener('scroll', hide, true); + // Escape closes it, which matters when one is pinned open by focus. + document.addEventListener('keydown', (e) => { + if ((e as KeyboardEvent).key === 'Escape') hide(); + }); +} + +/** Styling, kept here so every page that installs tooltips gets the same one. */ +export const TOOLTIP_CSS = ` +#tip{position:absolute;z-index:9999;display:none;max-width:340px; + background:#0b0e12;color:#e6e9ee;border:1px solid #4d6fa8;border-radius:6px; + padding:7px 10px;font:12px/1.45 ui-monospace,Menlo,Consolas,monospace; + box-shadow:0 6px 22px rgba(0,0,0,.55);pointer-events:none;white-space:pre-wrap} +[data-tip]{cursor:help} +button[data-tip],a[data-tip]{cursor:pointer} +`; diff --git a/test/track.test.ts b/test/track.test.ts index 5ee46d9..e70b6aa 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -24,6 +24,8 @@ import { neighbour, opposite, reachableDestinations, + connectionsFor, + variantsFor, } from '../src/engine/track.ts'; // --------------------------------------------------------------------------- @@ -408,3 +410,96 @@ describe('placement and drop-off', () => { assert.ok(!canDropCarsAt(area, at(0, 1))); }); }); + +describe('curves are arcs, and arcs make sidings possible', () => { + const arcCard = (arc: 'ne' | 'nw' | 'se' | 'sw', geometry: 'curved' | 'sharpCurved' = 'curved'): TrackCard => ({ + geometry: { kind: 'track', geometry, arc }, + baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], + }); + + it('joins exactly two adjacent edges, with no through track', () => { + // The printed cards (docs/tracks.png, rows 3-4) are a single sweep from edge to edge. Modelling + // a curve as through-track PLUS a diverging leg made it a turnout, and an exact duplicate of one. + for (const arc of ['ne', 'nw', 'se', 'sw'] as const) { + const links = connectionsFor(arcCard(arc)); + assert.equal(links.length, 1, `${arc} should be ONE connection, not a through track plus a leg`); + assert.deepEqual([...links[0]!].sort(), [...arc].sort(), `${arc} joins the wrong edges`); + } + }); + + it('is no longer a duplicate of a turnout', () => { + const norm = (c: readonly (readonly string[])[]): string => + c.map((p) => [...p].sort().join('')).sort().join(' '); + const turnout: TrackCard = { + geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } }, + baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], + }; + for (const arc of ['ne', 'nw', 'se', 'sw'] as const) { + assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)), + `a ${arc} curve still has the same connections as a turnout`); + } + }); + + it('can reach NORTH, which nothing but an n-s straight could before', () => { + // This is the whole point. With every leg diverging south, a district could only ever be a + // vertical column — no siding, no parallel track, no way back up to the Running Track. + const reachesNorth = (['ne', 'nw'] as const).every((arc) => + connectionsFor(arcCard(arc)).some((p) => p.includes('n')), + ); + assert.ok(reachesNorth, 'a curve still cannot reach north'); + }); + + it('offers all four rotations when placed', () => { + // A two-port arc has no handedness that survives turning — its mirror IS one of its rotations — + // so the printed hand governs supply, not what can be built. + for (const g of ['curved', 'sharpCurved'] as const) { + const arcs = variantsFor(g).map((v) => v.arc).sort(); + assert.deepEqual(arcs, ['ne', 'nw', 'se', 'sw'], `${g} does not offer all four rotations`); + } + }); + + it('a sharp curve differs from a curve only in the Moves it costs', () => { + for (const arc of ['ne', 'nw', 'se', 'sw'] as const) { + assert.deepEqual( + connectionsFor(arcCard(arc, 'sharpCurved')), + connectionsFor(arcCard(arc, 'curved')), + 'a sharp curve should be geometrically identical', + ); + } + }); + + it('lets a crew run a siding and rejoin the main', () => { + // The shape the whole change exists for: leave the Running Track, run parallel, come back up. + // A crew must ENTER a turnout through its stem to take the diverging leg (§A.1). + const grid: Record = { + '0,-2': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] }, + '0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] }, + '-1,-1': arcCard('ne'), + '-1,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] }, + '-1,1': arcCard('nw'), + '0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] }, + '0,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] }, + }; + const area = areaFrom(grid, { row: 0, col: 0 }); + const ctx = { area, occupancy: { trayAt: () => null, freeAdTracks: () => 2 }, consistSize: 0, self: 'crew' as const }; + + const seen = new Set(['0,-2']); + const queue = [{ row: 0, col: -2 }]; + for (let i = 0; i < 40 && queue.length > 0; i++) { + const at = queue.shift()!; + for (const facing of ['e', 'w', 'n', 's'] as const) { + for (const d of reachableDestinations(ctx, at, facing)) { + const key = `${d.coord.row},${d.coord.col}`; + if (!seen.has(key)) { + seen.add(key); + queue.push(d.coord); + } + } + } + } + for (const cell of ['-1,-1', '-1,0', '-1,1']) { + assert.ok(seen.has(cell), `the siding cell ${cell} is unreachable — no run-around is possible`); + } + assert.ok(seen.has('0,1'), 'the siding does not rejoin the Running Track'); + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts index 995d133..94433bc 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -24,6 +24,9 @@ import { view, } from '../src/web/game.ts'; +const root = join(import.meta.dirname, '..'); +const dist = join(root, 'dist'); + /** Play a whole game by always taking the first offered action. */ function playThrough(seed: number, maxTurns = 20_000) { const game = newGame(seed); @@ -490,9 +493,54 @@ describe('the page explains itself', () => { }); }); +describe('replays are saves', () => { + it('rebuilds the exact position a save describes', () => { + // A replay is `{seed, history}` — a few hundred bytes — because the engine is deterministic and + // runs in the browser. Re-submitting the same intents must land on the same board, or a shared + // replay shows something the player never saw. + const game = newGame(430); + for (let i = 0; i < 250; i++) { + if (currentActor(game) === null) break; + const { options } = actionGroups(game); + if (options.length === 0) break; + if (!submit(game, options[0]!)) break; + } + const restored = fromSave(toSave(game)); + assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue); + assert.equal(restored.state.clock.day, game.state.clock.day); + assert.equal(restored.state.clock.stage, game.state.clock.stage); + assert.deepEqual(view(restored).cells, view(game).cells, 'the rebuilt board differs'); + assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs'); + }); + + it('stays small enough to email', () => { + const game = newGame(202); + for (let i = 0; i < 400; i++) { + if (currentActor(game) === null) break; + const { options } = actionGroups(game); + if (options.length === 0) break; + if (!submit(game, options[0]!)) break; + } + const kb = Buffer.byteLength(JSON.stringify(toSave(game))) / 1024; + assert.ok(kb < 200, `a save is ${kb.toFixed(0)} KB — too big to be worth sharing as a file`); + }); + + it('publishes every save in public/replays with an index', () => { + // Static hosting cannot list a directory, so the page needs a manifest or it shows nothing. + const manifestPath = join(dist, 'replays/manifest.json'); + assert.ok(existsSync(manifestPath), 'no replay manifest was built'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { file: string; title: string }[]; + for (const entry of manifest) { + assert.ok(existsSync(join(dist, 'replays', entry.file)), `${entry.file} is indexed but not published`); + assert.ok(entry.title.length > 0, `${entry.file} has no title`); + const save = JSON.parse(readFileSync(join(dist, 'replays', entry.file), 'utf8')) as Record; + assert.equal(typeof save['seed'], 'number', `${entry.file} has no seed`); + assert.ok(Array.isArray(save['history']), `${entry.file} has no history`); + } + }); +}); + describe('the static build', () => { - const root = join(import.meta.dirname, '..'); - const dist = join(root, 'dist'); it('builds, and needs nothing but a static host', () => { execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' }); @@ -543,8 +591,27 @@ describe('the static build', () => { // would drop the handlers the page assigns — the test would then report "no button" for a // page that works perfectly. let cache = new Map[]>(); + const found = new Map>(); const node: Record = { textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0, + // The board is SVG now, and highlighting works by finding a card group and adding a class. + // The stub has to model that much or it cannot see whether highlighting happened at all. + querySelector(sel: string) { + const m = /\[data-(cell|ghost)="([^"]+)"\]/.exec(sel); + if (!m) return null; + const key = `${m[1]}:${m[2]}`; + if (!html.includes(`data-${m[1]}="${m[2]}"`)) return null; + if (!found.has(key)) { + const classes = new Set(); + found.set(key, { + onclick: null, + classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) }, + classes, + }); + } + return found.get(key); + }, + highlighted: () => [...found.values()].filter((f) => (f['classes'] as Set).size > 0), querySelectorAll(sel: string) { const hit = cache.get(sel); if (hit) return hit; @@ -581,17 +648,22 @@ describe('the static build', () => { // for any id asked for, so a `$('target')` left behind after removing #target from the HTML // would pass here and throw on load in a browser — the page would simply never start. const served = new Set( - [...readFileSync(join(dist, 'index.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map( + [...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map( (m) => m[1]!, ), ); const g = globalThis as Record; + // The page installs a tooltip layer on load, so the stub needs enough DOM to let it. g['document'] = { getElementById: (id: string) => { if (!served.has(id)) return null; if (!els.has(id)) els.set(id, make()); return els.get(id); }, + createElement: () => make(), + addEventListener: () => {}, + body: { appendChild: () => {} }, + head: { appendChild: () => {} }, }; g['location'] = { search: '?seed=555' }; const store = new Map(); @@ -631,9 +703,11 @@ describe('the static build', () => { assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick'); const html = String(grid['innerHTML']); - assert.match(html, /class="cell[^"]*legal/, 'picking a card highlighted nothing'); - assert.match(html, /data-at="/, 'highlighted squares carry no click target'); - assert.match(html, /ghost/, 'no empty square was offered as a destination'); + // The board draws cards as addressable groups; legality is an added class or a drawn ghost. + assert.match(html, /data-cell="/, 'the board drew no addressable cards'); + const lit = (grid['highlighted'] as () => unknown[])(); + const ghosts = /data-ghost="/.test(html); + assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board'); }); it('busts the cache on every module, so a deploy cannot half-load', () => { @@ -689,7 +763,7 @@ describe('the static build', () => { // Getting this wrong does not degrade the page, it stops the game starting at all. const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8'); const asked = new Set([...src.matchAll(/\$\('([a-zA-Z]+)'\)/g)].map((m) => m[1]!)); - const html = readFileSync(join(dist, 'index.html'), 'utf8'); + const html = readFileSync(join(dist, 'play.html'), 'utf8'); const present = new Set([...html.matchAll(/id="([a-zA-Z]+)"/g)].map((m) => m[1]!)); // `again` is created by the game-over screen before it is looked up. present.add('again'); @@ -698,8 +772,25 @@ describe('the static build', () => { } }); + it('serves all three pages, each stamped and cache-busted', () => { + // The splash is the front door now; the game and the replay directory are separate pages. + for (const [name, entry] of [ + ['index.html', 'splash'], + ['play.html', 'main'], + ['replays.html', 'replays'], + ] as const) { + const html = readFileSync(join(dist, name), 'utf8'); + assert.match(html, new RegExp(`src="\\./web/${entry}\\.js\\?v=`), `${name} is not cache-busted`); + assert.doesNotMatch(html, /__BUILD__/, `${name} was published without a build stamp`); + assert.ok(!/https?:\/\//.test(html.replace(//g, '')), `${name} fetches something external`); + } + const splash = readFileSync(join(dist, 'index.html'), 'utf8'); + assert.match(splash, /href="\.\/play\.html"/, 'the splash does not link to the game'); + assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays'); + }); + it('serves a page that loads the game as a module', () => { - const html = readFileSync(join(dist, 'index.html'), 'utf8'); + const html = readFileSync(join(dist, 'play.html'), 'utf8'); assert.match(html, /