Compare commits

...
4 Commits
Author SHA1 Message Date
JesseandClaude Opus 5 5d825b97d2 v0.1.0 — undo, the crew's reach on the board, workers on the card, and four engine bugs
Fifteen items from two playtest sessions. Three that read as drawing faults were engine
bugs: cars could be added to a train that was not being made up (50 offers in 8 games),
the make-up panel merged two trains and could couple a car to the wrong one, and an
Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel
inside a coordinate's own value range — a Mainline placement travelling as row -1, which
is an ordinary district row.

Trains are now drawn the way they stand: west on the left, nose toward the way the engine
faces, on both the district card and the Division chip. Undo steps back through the game
by replaying the save without its last intent. The switching walk keeps its rejections, so
the board can say why a square is not offered. Laborers and Porters are on the card, and
the rule that a district only grows outwards is finally written down.

Versions start here: third digit for fixes, second for a feature set, 1.0 for a release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
2026-08-08 05:23:17 -04:00
Jesse f4c0f49604 More bug fixes. Show the timetable, and stop the arrival message contradicting itself. Fixed division point in labels. And highlighted discard as targets. 2026-08-05 23:08:03 -04:00
Jesse 543584a2ca user card Hand becomes the action surface, the board shows the train, and a reverse stops turning it around, other bug fixes too. 2026-08-05 19:18:26 -04:00
Jesse 1c321d4cea make turnout description more clear. fix curves from breaking running track. 2026-08-05 10:03:50 -04:00
28 changed files with 3971 additions and 498 deletions
+454
View File
@@ -6,10 +6,464 @@ 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.
## Versions
Every commit carries a bump, decided with Jesse rather than assumed:
- **third digit** — bug fixes to what is already there.
- **second digit** — a new set of features.
- **1.0** — the first release we think is solid enough to call one.
The number lives in `package.json` and nowhere else; `scripts/build-web.ts` stamps it into every
page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identified from the page itself.
---
## Unreleased
## 0.1.0 — 2026-08-08
The first numbered build. Everything below the "second playtest pass" heading was made under
`0.0.1`, across twenty commits, which is exactly the problem the version convention above exists to
fix: "the current build" was only ever answerable by a commit hash.
### A third playtest pass — and three of the "display problems" were engine bugs
Fifteen items came back from two playtest sessions. Several of the ones that read like drawing
faults were not: the board was reporting the game accurately and the game was wrong.
**Cars could be added to a train that was not being made up.** `newTrain.placeCar` accepted any tray
with room in its consist, while the New Train Phase's own idea of the train it is waiting on —
`trainNeedingCars` — requires the tray to be **at a Division Point** and short of its card. So during
any New Train Phase the Division Yard would hand cars to a train standing on a siding in your own
district, or one halfway across the Division. Measured before the fix: **50 such offers across 8
solitaire games**, including Train 9 mid-crossing with three cars already aboard. That is the
"cars magically appeared on my train" report, and the answer to it.
The two questions — "is this the train being assembled?" and "may this car be added?" — now come from
one predicate (`isBeingMadeUp`, in apply.ts beside `check`), which is what stopped them disagreeing.
`newTrain.passCar` takes the same guard.
**The make-up panel merged two trains into one.** Two trains can be built in the same Stage — a
timetabled train and a Second Section, or an Extra — and the panel collected every option from every
tray, titling the result with whichever tray came first out of the map. Reproduced at seed 99,
Day 3 Stage 12: eighteen car chips under "Making up Train 8", covering two different trains. Worse
than the wrong caption: the Division Yard chip binds to the FIRST matching option, so clicking a
hopper could couple it to the other train. The panel is now scoped to the one tray the engine is
waiting on. This was reported as the history announcing Train 9 while the panel said Train 10.
**Upgrading the Office deleted Modifier bonuses.** `officeUpgraded` wrote the new tier's printed
numbers straight over the facility, so a Restaurant beside a Depot — +1 passenger out, +1 porter —
was silently erased by the next upgrade, after the card had been spent. The tier is applied as a
**difference** now, so the upgrade raises the Office by exactly what it is worth and leaves what is
standing beside it alone.
**A sentinel inside a coordinate's own value range.** ABS Signals goes on a Mainline card, and the
node index travelled as the fake coordinate `{ row: -1, col: node }` — but row −1 is an ordinary
district row, the first one below the Running Track, where most districts start. So ABS Signals
highlighted whichever district card sat at that column, and an ordinary Enhancement laid one row down
was described as being "out on the Mainline, node −2". `card.play` now carries `node` as its own
field and a Mainline placement has no coordinate at all; the board simply does not light up for it.
This was the other half of "why is my Depot highlighted?" — the first half being that Enhancements
legitimately attach to played cards, which nothing on screen said.
### The train, drawn the way it stands
**`consist` is ordered nose first, and both renderers drew index 0 leftmost whatever the train was
doing.** A westbound train therefore came out right and an eastbound one came out mirrored —
reported from seed 270861860, Train 10 running east and drawn engine-first at the WEST end, which
reads as an engine shoving its whole train ahead of it. The board is a map, so the drawing obeys the
map: west on the left, nose toward the way the engine faces. A crew on a north-south spur keeps
nose-left and lets its ▲/▼ say the rest.
**The Division chip draws the train now.** It was a name and a figure, and the figure was Stages left
to cross — read once as the car count, and once it was labelled `· 2⧗`, read as redundant beside the
position the card already draws. It is: the region position is DERIVED from that countdown. So the
chip carries the consist instead, in the same vocabulary the district card uses, and the countdown
moved into the tooltip where there is room to say what it is. Stages are not regions — a card is two
regions of fixed distance, and how many Stages a train needs over them depends on the card speed and
the train (`crossingStages`); they coincide only for a 30 card and a normal train.
**Loaded or empty, told the same way in both places.** An occupied slot on a district card took a
generic blue fill and only a LOADED car overrode it, so at 26×15px empty read as blue-ish and loaded
as brown-ish — while the tray beside it made the same distinction unmistakable. Reported as "I left a
car in a siding and now I can't remember if it was loaded". The card now matches the tray, and every
slot and every car in a consist carries its own words as a tooltip.
### Saying what the game already knew
**Why a switching move is not offered.** The switching game was played off a list of coordinates —
`move to (0, -2)` as a button, nothing on the board, and no account at all of the squares missing
from the list. `exploreMoves` returns the walk's REJECTIONS alongside its destinations, out of the
same traversal, so a reason on screen is the rule that actually refused the square rather than a
second guess at it. Five conditions: the rails do not meet, another train is on the card, the
industry is locked by MEN AT WORK (§9.3 — no train may enter it *or cross it*), the coupling would
overfill the consist, or it is a turnout you may run through but not stop on.
The board now shows where the crew is, where it may go, and marks the rest with its reason on the
card's own tooltip. A turnout is drawn differently from an obstruction, because it is not one — and
it is kept out of the Blocked panel, where every turnout in the district would otherwise appear.
**Mandatory coupling is named on the button.** "Move to (0, -2)" becomes "move to (0, -2) — couples
loaded hopper, caboose on the way (onto the nose)". Coupling is compulsory (§A.4) and was announced
only in the history panel, which is the one place a player is not looking while switching. 70 move
buttons in five games now say what they will pick up.
**The workers are on the card.** "How do I see the number of laborers in an industry card?" — you
could not; the number that decides every Cargo phase was in a side panel. Porters were worse:
`porters` had been on the view-model all along and **no renderer had ever drawn it**, which is why a
Restaurant's +1 porter had "no indication anywhere". Both are on the card now as free-over-total, and
the Facilities panel shows porters beside laborers.
**Ships-out and receives, in words.** A Refinery and a Grocer's Warehouse were distinguished by the
stroke colour of one 13×12px box and the direction of a 10px chevron. Rendered both and diffed the
SVG to check the complaint: identical shape, identical sign, same box count in the same place. The
card says `SHIPS OUT` or `RECEIVES` now.
**And the Office told the truth about itself.** `baseOf` returned zeros for a passenger facility
despite a comment claiming it read the Office tier, so a plain Depot displayed `out 1 +1` — crediting
a Modifier that was never played. The card tooltip had the mirror-image bug, reading the printed tier
rather than the Office as it stands, so a Modifier's effect never showed there either.
### Undo, and the chrome around it
**Undo, as far back as you like.** The save is the seed plus the intents, so undo is a replay without
the last one: no undo stack, no inverse of each action, and no way to reach a position the rules could
not have produced. Solitaire only. It is a deliberate take-back rather than a rewind — the RNG
advances with the replay, so a train card re-rolls the same Stage, but you can see that roll and then
spend the turn differently. `TODO.md` carries the question of whether a Stage boundary should become
a commit point.
**Both Division end labels were clipped.** They hung off the outside of the end cells and needed
padding wider than the caption to survive, which the board did not have — and giving it to them would
have spent that width on two captions instead of on the map. Centred under their own Division Point
they cost nothing, and `PAD` drops from 92 to 22.
**The turn chart scrolls away no more.** The title bar was sticky and the row under it — Day, Stage,
phase, waiting-on — was not, so scrolling the board took away the thing most worth glancing at. They
are one sticky block now.
**The timetable has a key.** Blue is a train due, violet is the Stage you are in, dimmed is gone, and
the green is a *flash* marking the slot the die just filled — which is exactly why it needed saying.
**And the rule that shapes every district is finally written down.** A district only grows outwards:
the Limits signs are the growth point on the Running Track and move outward with the card, and no
card anywhere can be inserted between two cards already down. Enforced since the beginning, stated
nowhere. It now sits beside the district, and a legal square that already carries a card says what
playing there would do — `EXTEND THE RUNNING TRACK HERE`, `ATTACH TO THIS CARD` — instead of lighting
up blue and saying nothing.
### Measurements
The two engine fixes take things away, so both were measured rather than assumed: 200 paired games
before and after come to **revenue 1.4 either way**, cards played 24.4 → 24.2. Neither was paying for
anything the bot relied on.
Worth stating plainly, since the changelog above still carries older numbers: the developer bot now
averages **1.4 revenue against a target of 20**, with 1 win in 200. That is not a regression from this
work — it is the same at `f4c0f49` — and it is consistent with what `TODO.md` already records about
barring curves from the Running Track and making industries stub-only. The bot has not been retaught
since those rules tightened.
Tests 386 → 402.
### A second playtest pass
**The arrival message told you to do work you could not do.** Train 4 is the *Express*, which carries
`expedite: true` — Q3 departs an expedited train in the SAME Stage it arrives. So it correctly
arrived and highballed in one Mainline Phase and correctly did not wait at the Depot. But
`trainArrived` narrated *"it will highball again next Mainline Phase, so any work must happen now"*
to every arrival, which for an expedited train is exactly backwards, and the log then contradicted
itself two lines later. The event carries `expedited` now and the message says which case it is.
**`TX14 (2)` was Stages remaining to cross that Mainline card**, not the car count — a bare figure
beside a train's name, read as the consist twice by the same player because that is the obvious
guess. Now `TX14 · 2⧗`.
**"stock a coach at (0, 0)"** reads as putting a car on the track, and was reported as exactly that
confusion: no train at the Depot, so how is a coach being stocked there? It is a load taken from the
Division Yard into the green Loading box, waiting for a train that can carry it — for a passenger
facility, people on the platform. It now says so.
**"trains enter" / "trains leave"** claimed the Division ran one way. Odd trains run west and even
run east, so both Division Points are a way on AND a way off; the buffer stops only mean it is a line
rather than a loop. Both ends now read `in and out`.
**The discard targets were "a very tiny highlight".** Choosing a card to PLAY lights big ghost squares
on the board; choosing one to discard lit a 2px outline on a small tile in a panel you might not be
looking at. The piles now grow, brighten, say DISCARD HERE, pulse once, and dim the Salvage Yard
beside them so the three that are choices read as a choice.
### The timetable, and watching the die land
Playing a train card rolls 1D12 for the Stage it departs, and the card simply left the hand — the
answer arrived as one line in the history panel, among many. The twelve slots have been in the Frame
all along, and only the standalone replay ever drew them.
The play page now has a **Timetable panel**: twelve Stages across, the train due out at each, the
current Stage lit, Stages already gone dimmed. Playing a train card **flashes the slot the die just
filled**, sounds a die-and-chime cue, and says it in words above the actions — *"Train 4 is scheduled
to depart at Stage 11 — see the Timetable"*. The flash marks the moment rather than the state: it is
gone by the next render, which is what makes it read as "that just happened".
No acknowledge-click. It would stop the game to say something the highlighted timetable says better,
and would be clicked through by the third time.
### The load pipeline is drawn the way freight actually flows
Reported after unloading at a warehouse: *"it went W, A, M, and then to the red box at the far right."*
**The engine was right.** §9.3 is explicit — *"The first Laborer replaces the load with an empty car
of that type on the industry's track and places the load on WORK. Additional Laborers move it to AT
then MEN. The last one places the load on a red Unloading box."* An unload ends in red, and it did.
**The drawing was wrong.** The pipeline was laid out `green → MEN|AT|WORK → red`, left to right, with
both arrows pointing right. But loading runs Green → MEN → AT → WORK → **car**, and unloading runs the
other way entirely: **car** → WORK → AT → MEN → red. Green and red therefore both belong beside MEN,
and the car belongs beside WORK — so putting red at the far right put it exactly where the car is,
and an unload appeared to run backwards across the whole row and land on the end it had just left.
Now green and red both sit before the sign with their arrows pointing opposite ways, and **only the
boxes an industry actually uses are drawn**: a Grocer's Warehouse has no green box and a Mine Tipple
no red one, where before every industry showed both and half of them were dead squares.
The Facilities panel had the same left-to-right assumption in its row labels. `green` and `red` are
now `waiting to load` and `cleared inbound`, each with the direction its loads travel spelled out,
and a row an industry cannot use is omitted rather than shown empty.
### A playtest report, worked through
**Backing up turned the train around.** `facing` is which way the ENGINE points, and it was reset to
the direction of travel on every move — so one reverse move silently spun the train about, everything
read "forward" again, and a run-around became pointless: you could change ends for free by backing up
twice. Running forward the engine leads and points the way it went; backing up it trails, still
pointing the way it came, which is the port it arrived through. Both hold around a curve.
**"drop 1 car(s)" hid an option entirely.** It never said which car, and it read identically for a
nose drop and a tail drop — so the action list's duplicate-label filter discarded one outright, and
setting out from the front of the train could not be chosen at all. The same failure as the turnout
rotation earlier, in a feature added two commits ago to fix the make-up deadlock. Now: *"set out the
caboose off the back"*.
**ABS Signals offered the Office Area.** The card says "any Mainline card", and
`checkEnhancementPlacement` reads `placement.col` as a Division NODE index for it — while the
candidate list handed it occupied grid cells. So "(0, 1)" was accepted because node 1 happened to be
a Mainline card: the label and the meaning were different things. It now offers the Mainline cards by
name, *"on the Uncontrolled Siding, out on the Mainline"*.
**Two T10 chips on one Office.** A train standing at the Office is on the Office grid card AND on an
A/D track, so it arrived in both lists and was drawn twice.
**The train was invisible on the board**, which is what made the switching game unplayable: every
decision is about car ORDER — which car comes off next, which end a cut couples onto — and the card
showed a name badge. The train is now drawn as it sits in the tray: engine in its place with an arrow
for which way it points, cars in order, loaded solid and empty hollow. That arrow is also what makes
"(reverse)" mean something.
**Modifier effects were applied and invisible.** The Ice House and Local Small Groceries both worked
— `capacity.outbound` and `laborers` went up — but the card draws `Math.max(1, greenCap)` green
boxes, so 0 → 1 looked identical, and laborers were never drawn at all. The Facilities panel now
shows laborers, outbound and inbound as numbers with what a Modifier added (`2 +1`), names the
Modifier cards beside the industry, and says in the tooltip what the card itself prints. Laborers had
been in a hover tooltip only, which is the wrong place for the number that decides every Cargo phase.
**Coupling was silent and left no trace.** A car simply vanished from the board with only a line of
history to say where it went. There are now two new sounds — a knuckle-coupler clank for coupling and
a quieter one for setting out — alongside the whistle, bell and conductor.
**Moves left were reported only in the history panel**, which is the one place a player is not
looking while switching. Now beside the buttons, and struck red at zero.
**A/D tracks were a tooltip.** "3 A/D tracks" with nothing on the card — the number that decides
whether the next arrival is an automatic collision. Drawn as pips, filled for taken; there is no room
on the card for more rails and the count is what matters.
**The phase changed under you.** Local Operations ends the moment the last Move is spent and the
automatic phases then run themselves, so the page could change between two clicks with no notice. A
banner now names the phase it moved to.
#### What was already right, from the same report
The car type and colour on the board (read as "cab in red" without being told), the Blocked panel
explaining a full industry track, the Facilities panel's accepted car types and spotted cars, and the
Division chip's `T10 (2)` with its consist on hover. All four were reported as useful, and none of
them changed.
### Softlock: a train being made up with no visible way to make it up
Reported at Stage 10 of seed 775569289 — Train 10 at the West Division Point, history saying "now
taking cars", and **nothing at all under Your Move**.
Moving train make-up onto the Division Yard chips took the "Making up …" group out of the action
list, and everything that was not a car went with it: the heading naming the train and what its card
calls for, and the "no more cars" button. The nine cars the train could take WERE clickable on the
yard chips the whole time — nothing on screen said so, and the panel a player looks at was empty.
The panel is back, and now says where to click: *"Click a car in the Division Yard below to add it —
9 kinds it may take are highlighted there."* It carries the pass button when passing is legal, which
§7 allows only when the Division Yard is bare — "must make every effort to find a suitable car".
**The engine was never at fault.** 2065 New Train decisions across 60 campaign games, and not one
offered zero options; the pass/place pair covers the phase. The panel still words the third case
honestly rather than implying a button that is not there.
**Two guards, because the obvious one would not have caught it.** A menu-level invariant — every
option the engine offers must be reachable through something the menu exposes — passes on this bug,
because `makeUp.pass` was in the menu and correct all along. What failed was the page never reading
it. So there is also a coarse check that `main.ts` references every field the menu offers: a field
nothing reads is either dead or a control that has gone missing. Verified by putting the regression
back and watching it fail.
### Four reports from playing seed 775569289
**A curve was described as a turnout.** Both read "east-west track with a 45° leg", which is a
turnout — a road straight across the card plus a leg off it. A curve has ONE road: in from the east
or west edge, along the centre line to the frog, out at 45° through the middle of a north or south
edge, and **nothing runs past it**. Which is precisely why it may not be laid in the Running Track,
so describing it as though it had a through track contradicted the rule that stops you.
**A curve in hand showed no preview.** The shapes were read off the card's legal PLACEMENTS, so a
card with nowhere legal to go had nothing to draw — and that is exactly when a player most wants to
see what the piece is. They now come from the card itself, which is where they belong: what a piece
looks like does not depend on whether there is currently a square for it.
**"Realignment on Mainline card 3"** named a raw node index. It said nothing about which stretch of
the Division it meant or what it would do, and it had no tooltip either — because the action list
attaches one only when a label happens to contain an em-dash, which this one did not. It now reads
> Realignment on the Uncontrolled Siding — the second Mainline card west to east; converts it to
> Double Track
and any action naming a card falls back to that card's own description when its label carries no
explanation of its own. That was the actual complaint: the same card explained itself perfectly in
hand and said nothing in the action list.
**It offered only one Mainline card, and that was correct.** `REALIGNMENTS` converts Plains, Curves,
Uncontrolled Siding and Trestle; the Division on that seed is a Heavy Grade and an Uncontrolled
Siding, so only the second could be converted. The engine was right and the label was hiding it —
"Mainline card 3" gave no way to tell a considered restriction from a bug. Naming the card fixes the
report without changing the rule.
### The hand is the action surface, and the yard makes up the train
The action list reached **22 buttons**, and most of it was a cross-product. A card appeared in two
panels under two different models: as a *subject* under "Play a card from my hand", which then
highlighted squares on the board, and as one flat button per Department under "Discard a card from
my hand". Four cards times three Departments was **twelve buttons repeating the same three choices
four times**, about 290px of the list. Separately, making up a train offered up to ten buttons
reading "add loaded hopper", "add empty boxcar" — while the Division Yard sat on screen already
showing exactly those cars by type and load state.
Both are now on the objects already being looked at, using the pattern board placement always had:
**pick the thing, then pick where it goes.**
- **Every card in hand carries its own verbs.** `play` highlights the squares it may go on, exactly
as before; a card needing no square (an Office upgrade, a train, a maneuver) goes down in one
click. `discard` lights up the three Department piles as targets — they already show their top
card and their depth, which is precisely what you choose between.
- **A make-up car is picked off the Division Yard chip** that shows it. The loaded and empty counts
are separate targets, because a car of a type and a load state is exactly what the choice is.
- The action list keeps what is not about a card or a car: the Local Operations choice, drawing,
switching moves, the Freight Agent, and finishing.
**Measured over a full game of seed 430: the widest action list went from 22 buttons to 5.** A test
now walks the same game and fails if it climbs back above 8.
The rotation step stays where it was and is now the only thing the placement panel shows — the card
is picked in the hand and the square on the board, so a rotation is the one question neither of those
can ask. Its hover previews, added earlier, are unchanged.
### A note on the two replay viewers
`TODO.md` now carries an item to decide between them. The standalone `node src/sim/replay.ts` writes
a self-contained HTML file that nothing links to and that `.gitignore` excludes; the site reads JSON
saves from `public/replays/`. The standalone one carries the bot's decision trace and a timetable
panel, which is debugging material rather than something a player wants. No action taken.
### A replay now looks like the game it is a replay of
**"Extra slow" was there and did nothing.** The site's replay viewer builds its interval with
whatever the speed select held when play started, and nothing re-read it — so changing pace
mid-replay had no effect at all and the pace looked stuck. The standalone replay had always
restarted its timer on change; this viewer was missed. Both offer the same five paces, extra slow
through very fast, and a test now asserts they stay in step.
**The turn chart lived on one screen out of three.** Where you are in the Day — the five phases with
the violet "you are here" — was in `main.ts` alone, so both replays reported the Day and the phase as
two plain strings. The same position looked like a different game depending on which screen you were
on. It is now `sim/turnchart.ts`, shared exactly as the board renderers are: the playable page and
the site viewer import it, and the standalone replay embeds it by `Function.toString()` because it
is a single file with an inline script and cannot import anything.
**And the side panels were three against eight.** The viewer showed the Division, the Office Area and
the log; the play page shows those plus cards in hand, the Department decks, the yards, the blockers
and the facilities. A replay could not answer *"why is nothing moving?"* — which is most of what a
replay is for. `web/panels.ts` now renders all of them for both pages, and the duplicated CSS is gone
from `play.html`.
Three tests hold it there: both screens must call the same panel renderers, all three must use the
shared turn chart and none may keep a private copy of the phase table, and the two viewers must offer
the same paces.
### You could not rotate a turnout at all, and now you can see what you are laying
**The rotation was being thrown away before it reached the menu.** `actionGroups` drops duplicate
labels, and `describeIntent` for a card play said only `play right-hand turnout at (0, 1)` — no
rotation in it. So a turnout's two orientations produced the same label and the second was silently
discarded. The "choose a rotation" step existed and worked; it was never given more than one
rotation to choose between. The label now names the orientation, and both survive.
**And the buttons are pictures now.** Hovering a rotation draws the piece as it will land on the
board, and hovering the card itself draws every shape it could be laid as — which is how a player
sees a turnout has two orientations before picking a square at all. Rendered by `officeSvg`, the
board's own renderer, on a one-card board: the preview and the board cannot disagree about what the
piece looks like, and the rails come from the engine's `connectionsFor`, so a preview cannot promise
a shape the placement will not produce.
`Placeable.spots` carries the links for this. `data-tip-html` on the tooltip renders a figure above
the caption; it is only ever set from markup this app builds.
### Turnouts say what they do, and a curve may not break the Running Track
**"Right-hand turnout, stem east, through west, diverges north at 45°"** is three pieces of jargon
and a compass reading, and none of it answers the only question being asked: *if my train comes in
from over there, where can it go?* Turnouts now read
> allows traffic from the east to travel west or turn to the south
and curves
> carries traffic from the west round to the south
everywhere they appear — on the card in hand, on the placement it would make, and on the card once it
is down. §A.1's rule falls out of the same sentence rather than needing a shouty clause of its own:
a train coming the other way, from the through end or the diverging one, may only leave by the stem,
so the two roads never join.
**A curve laid in the Running Track dead-ends the main.** A curve has ONE road — from an east or west
edge round to its 45° leg — so a card of it standing in the running row stops the through route at
that square and cuts the Office off from its own Limits. Nothing forbade it, and the bot did it: an
early trace shows it laying a `ne` curve at (0,−1), turning the west end of its own Running Track
into a stub. Every card that may stand in that row now has to carry the road across it —
`carriesThroughTrack` — which straights, turnouts, Limits signs, the Office and industries all do.
Reported as `BREAKS_RUNNING_TRACK` rather than `NOT_CONNECTED`, because it is a different mistake:
the card would join perfectly well and would still leave the main stopping dead at it.
**The crossover is confirmed, both hands.** A turnout that *"allows traffic from the east to travel
west or turn to the south"* joins the one directly beneath it that *"allows traffic from the west to
travel east or turn to the north"* — the two 45° legs are one continuous rail across the card edge.
It already worked; it now has a test that says so in those words, along with its mirror built from
the other hand, the mismatched pair that must NOT join, and a crew actually running down through it
onto the parallel track.
**Measured, and worth flagging:** barring curves from the running row cost the bot a good deal —
districts 28.0 → 19.7 cards, facilities 2.23 → 1.85, revenue about 2.0 → 0.8. The rule is right and
the bot was partly living off an illegal placement. Two tests needed widening rather than weakening
as a result: the unload regression sampled three deals and now samples twelve (unloads still happen,
34 across a 40-deal sweep, just not on those three), and the canvas bounds test now holds a straight
as well as a curve, since a curve alone is offered only one square once the running row is closed to
it.
### The Crew Tray is a train, and a train must be made up to leave
**§8.2 was not checked at all.** A train could highball onto the Mainline engine-last with its
+12 -4
View File
@@ -10,15 +10,23 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed.
## Status
Design complete, implementation just begun.
**v0.1.0 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs.
- **Rules** — fully specified. Ten gaps in the original prototype rules found and resolved.
- **Card faces** — every card's printed values specified.
- **Architecture** — six documents, including a 20-component build plan.
- **Code** — build step 1 of 12 complete: card catalogue, state model, seeded RNG.
- **Code** — the engine, the bot, the balance harness, the replay viewer and the playable page. A
game can be saved, shared, replayed and stepped back through.
- **Not built** — multiplayer (the engine runs 2–5 player games and the bot plays them, but there is
no server, no turn submission and no per-player view), the 22 opponent-directed cards, and real
audio.
The MVP target is **solitaire, one Office, a fixed number of Days**, with a server talking to a
single browser.
Balance is *not* where it should be: the developer bot averages 1.4 Revenue against a target of 20.
`TODO.md` says why, and says which of it is the bot and which is the deck.
Versions follow the convention at the top of [`CHANGELOG.md`](CHANGELOG.md): third digit for fixes,
second for a set of features, 1.0 for the first release that deserves the name.
## Layout
+21 -2
View File
@@ -16,6 +16,18 @@ Ordered within each section by how much it is currently costing us.
holds 243 of which 104 are track, so every draw is diluted by 43% — precisely the pressure
those multipliers exist to relieve. Until then, read no balance conclusion from the revenue
numbers; they are a functionality signal only.
- [ ] **Review the standalone replay against the site's replay viewer.** `node src/sim/replay.ts
--seed 1234 --out replay.html` writes a self-contained HTML file; the site instead reads JSON
saves from `public/replays/`. Nothing links to the standalone one and its output is gitignored,
so it is a developer tool that happens to look like a product feature. It carries two panels
the site viewer does not — the bot's decision trace ("what it chose, why, and what it passed
over") and the timetable — which is debugging material rather than something a player wants.
Decide: fold the decision trace into the JSON viewer and delete the standalone, or keep it and
accept that it is a tool. No action for now.
- [ ] **The bot was partly living off an illegal placement.** Barring curves from the Running Track
(they have no east-west road and dead-end the main) cost it districts 28.0 → 19.7 cards and
revenue ~2.0 → 0.8. It has no plan for where a curve should go once the easy square is gone.
Same root cause as the two items below; fix them together, after the rebalance.
- [ ] **The bot cannot get a crew next to an industry, so Flying Switch never fires.** Industries are
now stub-only and the bot places 2.23 a game (was 3.84), in districts averaging under two rows
deep. `flyingSwitch` is exempted by name in the reachability sweep in `sim.test.ts`; deleting
@@ -195,6 +207,15 @@ target is settled and freight carries its intended share.
(32%), the facility object stored a second time inside its own cell (24%) and the rest of the
static identity (26%). All three are interned now — 3415 KB → 1877 KB, and a round-trip test
runs the page's own unpacking function.
- [ ] **Undo is unlimited step-back, and that is a decision to revisit.** The save is the seed plus
the intents, so `undo()` replays without the last one and can walk all the way to the deal. The
RNG advances with the replay, so the same play re-rolls the same 1D12 — you cannot undo your
way to a better die. But you CAN see a train's departure Stage and then spend the turn
differently, which is an ordinary solitaire take-back and also a real information leak. Options
if it starts to feel like cheating: make the Stage boundary a commit point, or cap the depth at
the current Stage. Deliberately left open until it has been played with. Multiplayer gets
nothing until there is a proposal/agreement flow — undo there is a table decision, not a
button.
- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
its place — it caught a 5.2 MB payload that turned out to be the whole grid re-serialised every
frame — but the number itself deserves a reason.
@@ -202,8 +223,6 @@ target is settled and freight carries its intended share.
close up.
- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal
scrolling. Legible, not compact.
- [ ] **No way to start a fresh game from inside the page.** `?seed=` gives a reproducible deal and
"new game" only appears once a game has ended, so abandoning a bad opening means editing the URL.
- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
failing loudly, which is the safe direction but says little about what changed. **This has now
bitten once**: both published replays were dead — one got 42 intents into 360, the other 4 of
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.0.1",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+4 -21
View File
@@ -20,7 +20,6 @@ import {
COLLISION_PENALTY,
MAINLINE_PROFILES,
enhancementRule,
consistSize,
crossingStages,
trainProfile,
MOVES_PER_LOCAL_OPS,
@@ -35,7 +34,9 @@ import {
} from './content.ts';
import type { Direction } from './content.ts';
import type { GameEvent } from './events.ts';
import { acceptsCar, areaOf } from './apply.ts';
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
// the legality test cannot disagree about which train is being assembled.
import { areaOf, trainNeedingCars } from './apply.ts';
import { legalActions } from './legal.ts';
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts';
import { coordKey, freshTurn, totalRevenue } from './state.ts';
@@ -286,25 +287,6 @@ function trainRunning(s: GameState, number: number, isExtra: boolean): boolean {
return false;
}
/** A made-up train still short of its consist spec, with a suitable car available. */
function trainNeedingCars(s: GameState): TrayId | null {
for (const [id, tray] of s.trays) {
if (tray.trainNumber === null) continue;
if (tray.position.at !== 'divisionPoint') continue;
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
if (!profile) continue;
const want = consistSize(profile.consist);
if (tray.consist.length >= want) continue;
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
// the yard is potentially suitable unless the card narrows it.
// Ask the SAME predicate `check` uses. A separate copy of this test stalled the game: the phase
// believed a car could be added while check rejected every option, so the Stage never ended.
const suitable = s.yards.divisionYard.some((c) => acceptsCar(tray, c.type));
if (suitable) return id;
}
return null;
}
// ---------------------------------------------------------------------------
// Mainline Phase (§8) — automatic, except the Superintendent's clearance ruling
// ---------------------------------------------------------------------------
@@ -756,6 +738,7 @@ function arriveAtOffice(
trainNumber: tray.trainNumber ?? 0,
consist: tray.consist.map((c) => ({ ...c })),
office: officeProfile(area.tier).name,
expedited: isExpedited(tray),
});
// Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to
+153 -23
View File
@@ -20,6 +20,7 @@ import {
LABORER_ACTIONS_PER_LOAD,
MAX_CONSIST,
REALIGNMENTS,
consistSize,
enhancementRule,
industryProfile,
mainlineModifierRule,
@@ -46,10 +47,12 @@ import type {
} from './state.ts';
import { createRng } from './rng.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
import type { Occupancy, Port } from './track.ts';
import type { MoveBlock, Occupancy, Port } from './track.ts';
import {
canDropCarsAt,
canPlaceAt,
carriesThroughTrack,
exploreMoves,
facilityVariants,
opposite,
reachableDestinations,
@@ -350,7 +353,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
const hand = s.decks.hands.get(player) ?? [];
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
return checkPlay(s, player, i.cardId, i.placement, i.variant);
return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node);
}
case 'card.discard': {
@@ -481,6 +484,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
// §7 — cars are added to the train being ASSEMBLED, at a Division Point. Any other tray is a
// train that is running, and loading one from the yard is teleporting cars onto it.
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL';
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
return 'NO_SUITABLE_CAR';
@@ -508,6 +514,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
// Same scope as placeCar: only the train being assembled has a make-up round to finish.
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
// §7 — "must make every effort to find a suitable car". A pass is only legal when none exists.
return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null;
}
@@ -571,10 +579,14 @@ function checkPlay(
cardId: string,
placement: GridCoord | undefined,
variant: number | undefined,
node?: number,
): RejectionCode | null {
const card = s.cards.get(cardId);
if (!card) return 'NO_SUCH_CARD';
const area = areaOf(s, player);
// A Division node and an Office Area square are different boards. Naming both is not a placement
// with extra detail, it is two contradictory answers to "where?".
if (node !== undefined && placement) return 'NO_PLACEMENT';
switch (card.kind.kind) {
case 'office': {
@@ -590,6 +602,11 @@ function checkPlay(
{
const proto = protoCard(card.kind, variant);
if (!proto) return 'NO_PLACEMENT';
// Said separately from NOT_CONNECTED because it is a different mistake: the card would join
// perfectly well, and would still leave the Running Track stopping dead at it.
if (placement.row === area.runningRow && !carriesThroughTrack(proto)) {
return 'BREAKS_RUNNING_TRACK';
}
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
}
@@ -623,6 +640,12 @@ function checkPlay(
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
}
case 'enhancement': {
// ABS Signals goes out on the Mainline, so it takes a Division node and no square.
if (enhancementRule(card.kind.key)?.placement === 'mainlineCard') {
if (node === undefined) return 'NO_PLACEMENT';
const target = s.division.nodes[node];
return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED';
}
if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, card.kind.key, placement);
}
@@ -690,6 +713,46 @@ function destinationsFor(
);
}
/**
* WHERE THIS CREW MAY GO, AND WHY IT MAY NOT GO ELSEWHERE.
*
* Both directions at once, because a player is not thinking in terms of "forward" and "reverse" when
* looking at a card two squares away — a square reachable only by backing up is still reachable, and
* a reason that applies in one direction should not be reported when the other direction works.
*
* Straight out of the movement walk (`exploreMoves`), so the reasons cannot drift from the rules
* that produced them.
*/
export function movesFor(
s: GameState,
player: PlayerIndex,
trayId: TrayId,
): { to: GridCoord[]; blocked: MoveBlock[] } {
const tray = s.trays.get(trayId);
if (!tray || tray.position.at !== 'grid') return { to: [], blocked: [] };
const from = tray.position.coord;
const ctx = {
area: areaOf(s, player),
occupancy: occupancyFor(s, player, trayId),
consistSize: tray.consist.length,
self: trayId,
};
const facing = facingPort(s, trayId);
const forward = exploreMoves(ctx, from, facing);
const back = exploreMoves(ctx, from, opposite(facing));
const to = new Map<string, GridCoord>();
for (const d of [...forward.destinations, ...back.destinations]) to.set(coordKey(d.coord), d.coord);
const blocked = new Map<string, MoveBlock>();
for (const b of [...forward.blocked, ...back.blocked]) {
if (to.has(coordKey(b.coord))) continue; // reachable the other way round; not a blocker
if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b);
}
return { to: [...to.values()], blocked: [...blocked.values()] };
}
// ---------------------------------------------------------------------------
// execute — reads state, emits events, never mutates
// ---------------------------------------------------------------------------
@@ -713,7 +776,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
from,
to: i.to,
movesRemaining: s.turn.movesRemaining - 1,
facing: opposite(dest.entry),
/**
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND.
*
* `facing` is which way the ENGINE points, and this set it to the direction of travel on
* every move — so one reverse move silently spun the train about. Everything then read
* "forward" again, and a run-around became pointless: you could change ends for free by
* backing up twice.
*
* Running forward the engine leads, so it points the way the train went: `opposite(entry)`.
* Backing up it trails, still pointing the way it came, which is the port it arrived
* through. Both hold around a curve, where the compass heading changes but the engine's
* relationship to its train does not.
*/
facing: i.reverse ? dest.entry : opposite(dest.entry),
},
];
if (dest.couples.length > 0) {
@@ -831,13 +907,12 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
to: card.kind.tier,
});
}
if (card.kind.kind === 'enhancement' && i.placement) {
events.push({
type: 'enhancementPlaced',
player,
key: card.kind.key,
at: i.placement,
});
if (card.kind.kind === 'enhancement' && (i.placement || i.node !== undefined)) {
events.push(
i.node !== undefined
? { type: 'enhancementPlaced', player, key: card.kind.key, node: i.node }
: { type: 'enhancementPlaced', player, key: card.kind.key, at: i.placement! },
);
}
if (card.kind.kind === 'extraTrain') {
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
@@ -1186,13 +1261,32 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'officeUpgraded': {
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
const area = areaOf(s, e.player);
const from = officeProfile(e.from);
const to = officeProfile(e.to);
area.tier = e.to;
const officeCard = area.grid.get(coordKey(area.officeCoord));
if (officeCard?.facility) {
const p = officeProfile(e.to);
officeCard.facility.porters = p.porters;
officeCard.facility.capacity = { outbound: p.passengerOut, inbound: p.passengerIn };
officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility };
/**
* THE TIER IS A DELTA, NOT AN OVERWRITE.
*
* This wrote the new tier's printed numbers straight over the facility, which silently
* deleted everything a Modifier had added: a Waiting Area, Restaurant or Hotel beside the
* Office is +1 passenger out and +1 porter, and upgrading Depot → Station threw both away
* with no message, after the card had been spent. Reported from a playtest where a
* Restaurant's porter never appeared — it had appeared and then been erased.
*
* Applying the DIFFERENCE between the two tiers raises the Office by exactly what the
* upgrade is worth and leaves anything standing beside it untouched.
*/
const f = officeCard.facility;
f.porters += to.porters - from.porters;
f.capacity = {
outbound: f.capacity.outbound + (to.passengerOut - from.passengerOut),
inbound: f.capacity.inbound + (to.passengerIn - from.passengerIn),
};
// Becoming a Passenger Facility at all is a state change, not a delta (Gap 8): a Whistle
// Post has no passenger boxes to add to.
f.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
}
break;
}
@@ -1244,12 +1338,11 @@ export function reduce(s: GameState, e: GameEvent): void {
}
case 'enhancementPlaced': {
const rule = enhancementRule(e.key);
if (rule?.placement === 'mainlineCard') {
const node = s.division.nodes[e.at.col];
if (e.node !== undefined) {
const node = s.division.nodes[e.node];
// ABS Signals: trains on this card stop short rather than rear-ending each other.
if (node?.kind === 'mainline') node.absSignals = true;
} else {
} else if (e.at) {
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
if (card) card.enhancements.push(e.key);
}
@@ -1545,11 +1638,9 @@ export function checkEnhancementPlacement(
const rule = enhancementRule(key);
if (!rule) return 'NOT_IMPLEMENTED';
// ABS Signals goes on a Mainline card; `placement.col` names which one.
if (rule.placement === 'mainlineCard') {
const node = s.division.nodes[placement.col];
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
}
// A Mainline-card enhancement never reaches here: it has no grid square, and `checkPlay` answers
// it against `division.nodes` directly. This function is only ever asked about the Office Area.
if (rule.placement === 'mainlineCard') return 'WRONG_INTENT';
const card = area.grid.get(coordKey(placement));
if (!card) return 'NOT_CONNECTED';
@@ -1722,6 +1813,45 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
return true;
}
/**
* §7 — IS THIS TRAY THE ONE BEING MADE UP?
*
* A train is made up where it is built, standing at a Division Point, and only until its consist
* matches its card. Everything else with a Crew Tray — a train working your district, a train
* halfway across the Division — is running, not being assembled.
*
* SHARED with the New Train Phase, which uses it to decide whether to stop and ask. It has to be:
* `check` accepted any tray with room in its consist, so during a New Train Phase the Division Yard
* would hand cars to a train standing on your own siding or out on the Mainline — cars appearing on
* a train nobody was making up. Measured before the fix: 50 such offers across 8 solitaire games,
* including Train 9 mid-crossing with three cars already aboard.
*/
export function isBeingMadeUp(tray: CrewTray): boolean {
if (tray.trainNumber === null) return false;
if (tray.position.at !== 'divisionPoint') return false;
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
if (!profile) return false;
return tray.consist.length < consistSize(profile.consist);
}
/**
* The tray the New Train Phase is waiting on, or null.
*
* `isBeingMadeUp` plus "and there is something in the yard it will take" — the phase must not stop
* to ask for a car that cannot be supplied.
*/
export function trainNeedingCars(s: GameState): TrayId | null {
for (const [id, tray] of s.trays) {
if (!isBeingMadeUp(tray)) continue;
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
// uses: a separate copy of this test stalled the game, because the phase believed a car could
// be added while `check` rejected every option, so the Stage never ended.
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
}
return null;
}
/**
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
*
+12 -2
View File
@@ -78,13 +78,23 @@ export type GameEvent =
* which made the replay say "New Train" and nothing else. A train being made up, departing,
* arriving or finishing its run are four distinct facts and deserve four event types.
*/
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at: GridCoord }
/**
* `at` is a square in the Office Area; `node` is a Division node, for an Enhancement that goes on
* a Mainline card. Exactly one is set — see `card.play` in intents.ts for why they are not one
* field wearing a sentinel.
*/
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at?: GridCoord; node?: number }
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
| { type: 'trainHeld'; trainNumber: number; reason: string }
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string }
/**
* `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train
* departs the same Stage it arrives, so there is no Local Operations turn in which to work it;
* every other train stands until the next Mainline Phase and can be switched in between.
*/
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
+14 -1
View File
@@ -38,8 +38,17 @@ export type Intent =
* `variant` indexes `variantsFor(geometry, hand)` — 0° or 180°, the only two ways a printed card
* can be laid. Track is played through here like every other card; there is no separate lay
* intent, because there is no separate supply to lay from.
*
* `placement` is a square in the player's Office Area; `node` indexes `division.nodes` and is for
* the one card that goes out on the Mainline instead (ABS Signals). They are alternatives, and
* they are SEPARATE FIELDS on purpose. A Mainline placement used to travel as the fake coordinate
* `{ row: -1, col: node }`, and row −1 is an ordinary district row — the first one below the
* Running Track, where most districts start. So a real placement on row −1 was labelled "out on
* the Mainline, node −2", and an ABS Signals placement highlighted whichever district card
* happened to sit at that column. A sentinel inside a coordinate's own value range is not a
* sentinel.
*/
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number; node?: number }
| { type: 'card.discard'; cardId: CardId; toSlot: number }
| { type: 'draw.end' }
// -- freight agent (§6.3)
@@ -108,6 +117,8 @@ export type RejectionCode =
| 'BOX_EMPTY'
| 'NO_SUITABLE_CAR'
| 'SUITABLE_CAR_EXISTS'
/** §7 — cars go onto the train being assembled at a Division Point, not onto one already running. */
| 'NOT_BEING_MADE_UP'
| 'WRONG_CAR_TYPE'
| 'NOT_SUPERINTENDENT'
| 'NO_PENDING_DECISION'
@@ -115,6 +126,8 @@ export type RejectionCode =
| 'FACILITY_LOCKED'
/** An Industry goes on a straight stub, never on the Running Track (sheet: "Placed" column). */
| 'ON_RUNNING_TRACK'
/** A card with no east-west road would dead-end the Running Track it was laid in. */
| 'BREAKS_RUNNING_TRACK'
| 'NOT_IMPLEMENTED'
| 'WRONG_INTENT'
| 'NOT_A_GRADE'
+17 -2
View File
@@ -13,6 +13,7 @@
*/
import type { CarType, Hand, TrackGeometry } from './content.ts';
import { enhancementRule } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
@@ -140,8 +141,22 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
for (const cardId of s.decks.hands.get(player) ?? []) {
out.push({ type: 'card.play', cardId });
const kind = s.cards.get(cardId)?.kind;
// Enhancements ATTACH to a card already down, so their candidates are the occupied cells.
const targets = kind?.kind === 'enhancement' ? attachments : placements;
/**
* Enhancements ATTACH to a card already down, so their candidates are the occupied cells — with
* one exception. ABS Signals goes on a MAINLINE card, which is not a grid square at all, so it
* is offered as a Division NODE and never as a coordinate.
*
* It used to be offered as `{ row: -1, col: node }`. Row −1 is a real district row — the first
* one below the Running Track, where most districts start — so the card appeared to be playable
* all over the Office Area, and ordinary placements on that row were labelled as Mainline ones.
*/
const onMainline = kind?.kind === 'enhancement' && enhancementRule(kind.key)?.placement === 'mainlineCard';
if (onMainline) {
for (let node = 0; node < s.division.nodes.length; node++) {
out.push({ type: 'card.play', cardId, node });
}
}
const targets = onMainline ? [] : kind?.kind === 'enhancement' ? attachments : placements;
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
+105 -10
View File
@@ -323,12 +323,57 @@ export function reachableDestinations(
start: GridCoord,
initialExit: Port,
): MoveDestination[] {
const { area, occupancy } = ctx;
const startCard = cardAt(area, start);
if (!startCard) return [];
if (!hasPort(startCard, initialExit)) return [];
return exploreMoves(ctx, start, initialExit).destinations;
}
/**
* A card the walk reached and refused, with the rule that refused it, in a player's words.
*
* `kind` separates the OBSTRUCTIONS — another train, a locked industry, a consist that would
* overfill, rails that do not meet — from `noStopping`, which is not an obstruction at all: a train
* runs through a turnout freely and simply may not finish a Move on one. They are drawn differently
* and only the obstructions are worth listing in the "why nothing is moving" panel, where every
* turnout in the district would otherwise appear.
*/
export type MoveBlockKind = 'noJoin' | 'occupied' | 'locked' | 'tooManyCars' | 'noStopping';
export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
/**
* THE SAME WALK, KEEPING ITS REJECTIONS.
*
* `reachableDestinations` answers "where may I go", and the UI could show that much — but a player
* looking at a siding two cards away and no button for it is asking the opposite question, and the
* answer was nowhere on screen. Reported as "trains seem to be blocked from moving onto industry in
* certain conditions; make it clear what those conditions are".
*
* Both answers come out of ONE traversal on purpose. A second function that worked out why a square
* was missing would be a second implementation of the movement rules, and the failure mode is a
* reason that does not match the refusal — worse than no reason at all.
*
* A block is recorded for every card the walk actually touched and turned down, plus every card
* beyond a reachable one whose rails do not meet. Squares the walk never came near are not listed:
* "there is no track between here and there" is not news.
*/
export function exploreMoves(
ctx: MoveContext,
start: GridCoord,
initialExit: Port,
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
const { area, occupancy } = ctx;
const results: MoveDestination[] = [];
const blocked: MoveBlock[] = [];
const noted = new Set<string>();
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
const k = coordKey(coord);
if (noted.has(k)) return;
noted.add(k);
blocked.push({ coord, kind, why });
};
const startCard = cardAt(area, start);
if (!startCard) return { destinations: results, blocked };
if (!hasPort(startCard, initialExit)) return { destinations: results, blocked };
const seen = new Set<string>();
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
@@ -337,7 +382,10 @@ export function reachableDestinations(
// hop is checked at the push site below, where both sides of the edge are in hand.
const first = neighbour(start, initialExit);
const firstCard = cardAt(area, first);
if (!firstCard || !joins(startCard, initialExit, firstCard)) return [];
if (!firstCard || !joins(startCard, initialExit, firstCard)) {
if (firstCard) block(first, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
return { destinations: results, blocked };
}
const queue: Frontier[] = [
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
];
@@ -352,7 +400,16 @@ export function reachableDestinations(
const occupant = occupancy.trayAt(node.coord);
if (occupant !== null && occupant !== ctx.self) {
const isOffice = sameCoord(node.coord, area.officeCoord);
if (!isOffice || occupancy.freeAdTracks() <= 0) continue;
if (!isOffice || occupancy.freeAdTracks() <= 0) {
block(
node.coord,
'occupied',
isOffice
? 'the Office is full — every A/D track is taken, so there is no room to enter or pass through (§A.4)'
: 'another train is standing here — two trains may not share a card, or move through each other (§A.4)',
);
continue;
}
}
if (!hasPort(card, node.entry)) continue;
@@ -361,12 +418,22 @@ export function reachableDestinations(
// stops a train FINISHING here; a turnout is not Operational Rail either and trains run through
// one all day. Without this a crew rolled straight over a locked industry, and coupled the cars
// spotted on it on the way past — the two things the safety lockout exists to prevent.
if (isLockedByWork(card)) continue;
if (isLockedByWork(card)) {
block(node.coord, 'locked', 'MEN AT WORK — a load is on the sign, so this industry track is locked for safety: no train may enter it or cross it, and no car may be picked up or set out (§9.3)');
continue;
}
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
// overfill the tray is illegal, not a move that picks up fewer cars.
const couples = [...node.couples, ...carsOn(card)];
if (ctx.consistSize + couples.length > MAX_CONSIST) continue;
if (ctx.consistSize + couples.length > MAX_CONSIST) {
block(
node.coord,
'tooManyCars',
`too many cars — running here couples ${couples.length} standing car(s) onto a train already holding ${ctx.consistSize}, and ${MAX_CONSIST} is the limit. Coupling is mandatory: you may not run past a car and leave it (§A.4)`,
);
continue;
}
const key = `${coordKey(node.coord)}|${node.entry}`;
if (seen.has(key)) continue;
@@ -374,6 +441,10 @@ export function reachableDestinations(
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
} else if (!sameCoord(node.coord, start)) {
// Reached, crossable, but not somewhere a train may STOP — a turnout carries no wheel icon
// (§A.1). Trains run through one all day; they just cannot finish a Move on it.
block(node.coord, 'noStopping', 'a train may run through here but not stop — this is not Operational Rail (§A.1), so it cannot be the end of a Move');
}
for (const exit of exitsFrom(card, node.entry)) {
@@ -381,13 +452,19 @@ export function reachableDestinations(
// hand. Enqueueing on `hasPort` alone routed trains across a 45° leg that bent back on itself.
const to = neighbour(node.coord, exit);
const next = cardAt(area, to);
if (!next || !joins(card, exit, next)) continue;
if (!next || !joins(card, exit, next)) {
if (next) block(to, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
continue;
}
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
}
}
return results;
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
// from a bad angle first and a good one later.
const reached = new Set(results.map((r) => coordKey(r.coord)));
return { destinations: results, blocked: blocked.filter((b) => !reached.has(coordKey(b.coord))) };
}
/** Both directions at once — what the UI highlights when a tray is selected. */
@@ -406,6 +483,20 @@ export function allReachable(
// Placement
// ---------------------------------------------------------------------------
/**
* Does this card carry the through route — an east-west road straight across it?
*
* The Running Track is the road trains run on, Limits to Limits, and every card standing in it has
* to pass traffic along the row. A curve does not: it has one road, from an east or west edge round
* to a 45° leg, and dropping one into the running row DEAD-ENDS the main there. Straights, turnouts,
* Limits signs, the Office and industries all carry it.
*/
export function carriesThroughTrack(card: TrackCard): boolean {
return connectionsFor(card).some(
([a, b]) => (a === 'e' && b === 'w') || (a === 'w' && b === 'e'),
);
}
/**
* Gap 4a — a placed card must connect to existing track, and "connect" means `joins`: ports meeting
* AND, on a north or south edge, 45° legs on the same diagonal.
@@ -433,6 +524,10 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
// with a Limits card stranded mid-track. The district below the Running Track is unbounded.
if (coord.row === area.runningRow) {
if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false;
// And it must not BREAK the Running Track. A curve laid here has no east-west road, so the main
// stops dead at it — the bot did exactly this, turning the west end of its own Running Track
// into a stub and cutting the Office off from the Limits.
if (!carriesThroughTrack(card)) return false;
}
const ports: Port[] = ['n', 's', 'e', 'w'];
+325 -45
View File
@@ -52,11 +52,15 @@ export function divisionSvg(nodes: DivisionView[]): string {
const CH = 58;
const GAP = 6;
/**
* Room for the buffer stops AND their labels. "trains enter" hangs off the west end and "trains
* leave" off the east, so a padding sized for the stop alone clipped both to their middle three
* letters — the west end read "TER" and the east "tra".
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
*
* They used to hang off the ends — "west end · in and out" anchored outside the first cell — which
* needed about 126px of padding to fit and had 92, so both ends were clipped mid-word. Widening
* the padding to fit them would have spent that space on two captions instead of on the map.
* They now sit UNDER their own Division Point, where they are neither clipped nor in the way, and
* this is back to what a buffer stop actually needs.
*/
const PAD = 92;
const PAD = 22;
const SIDE_GAP = 34;
const esc = (t: string): string =>
@@ -67,7 +71,17 @@ export function divisionSvg(nodes: DivisionView[]): string {
kind: 'dp' | 'ml' | 'run';
label: string;
sub: string;
trains: { label: string; consist: string[]; region?: number; direction?: string }[];
trains: {
label: string;
consist: string[];
/** Nose first, no engine — drawn as blocks, loaded solid and empty hollow. */
cars?: string[];
engineAt?: number;
facing?: string;
region?: number;
direction?: string;
stagesLeft?: number;
}[];
cap: number | null;
tip: string;
owner: number | null;
@@ -96,8 +110,14 @@ export function divisionSvg(nodes: DivisionView[]): string {
kind: 'run',
label: rc.label,
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '',
// A train standing at the Office occupies an A/D track, which is where it is.
trains: isOffice ? [...rc.trains, ...ad] : rc.trains,
/**
* A train standing at the Office occupies an A/D track, which is where it is — but it is
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
* drawn twice. Reported as two T10 chips on one Office.
*/
trains: isOffice
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
: rc.trains,
cap: isOffice ? cap : null,
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
owner: n.owner ?? null,
@@ -126,7 +146,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
trains: n.trains.flat(),
cap: n.capacity,
tip: dp
? 'A Division Point — trains enter and leave the Division here, and queue without limit'
? 'A Division Point — the end of the line. Trains both enter and leave the Division here (odd numbers run west, even run east), and queue without limit'
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`,
owner: null,
// A Division Point is one region — the queue trains enter and leave the Division through.
@@ -256,19 +276,66 @@ export function divisionSvg(nodes: DivisionView[]): string {
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
}
/**
* A TRAIN, DRAWN AS A TRAIN — the same vocabulary the Office Area card uses.
*
* The chip was a name and a figure, and the figure was Stages left to cross this card: read
* once as the car count, and once the unit was spelled out, read as redundant beside the
* position the card already draws. What it never said is the thing worth knowing at a glance —
* what the train is carrying, whether those cars are loaded, and which end the engine is on.
*
* WEST ON THE LEFT here too. `cars` is nose first, so an east-facing train is drawn reversed and
* its engine lands at the east end, exactly as on the district card.
*/
c.trains.forEach((t, k) => {
const w = 30 + Math.min(t.consist.length, 4) * 8;
const kindOf = (car: string): string =>
/caboose/.test(car) ? 'cab' : /hopper/.test(car) ? 'hop' : /boxcar/.test(car) ? 'box'
: /reefer/.test(car) ? 'rfr' : /tank/.test(car) ? 'tnk' : /coach/.test(car) ? 'cch' : '?';
const cars = t.cars ?? [];
const arrow = t.facing === 'w' ? '◀' : t.facing === 'e' ? '▶' : t.facing === 'n' ? '▲' : '▼';
const items: { label: string; kind: string }[] = cars.map((car) => ({
label: kindOf(car),
kind: /^loaded/.test(car) || /caboose/.test(car) ? 'ld' : 'mt',
}));
items.splice(Math.max(0, Math.min(items.length, t.engineAt ?? 0)), 0, { label: arrow, kind: 'eng' });
const laid = t.facing === 'e' ? [...items].reverse() : items;
const nameW = t.label.length * 6.5 + 6;
const w = Math.min(c.w - 8, nameW + laid.length * 13 + 6);
/**
* NARROW THE CARS RATHER THAN DROP THEM.
*
* A Running Track cell is 78 wide and a four-car train plus its engine wants more than that.
* Squeezing is honest; leaving cars out is not — a chip showing three of five cars is a chip
* that lies about the train. Below 11px the three-letter type will not fit, so the blocks
* carry the load state alone and the tooltip carries the words.
*/
const CARW = Math.max(5, Math.min(13, (w - nameW - 6) / Math.max(1, laid.length)));
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
const inRegion = c.regions > 1 && typeof t.region === 'number';
const slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2;
const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
const tx = slotX + spread;
const dir = t.direction === 'west' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : '';
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}${
const stages =
typeof t.stagesLeft === 'number'
? ` · ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` +
' (Stages, not regions: a card is two regions of fixed distance, and how many Stages a' +
' train takes over them depends on the card speed and the train)'
: '';
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(cars.join(', ') || 'no cars')}, nose first${
inRegion ? ` · region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''
}">` +
}${esc(stages)}">` +
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(t.label)}</text></g>`;
`<text class="bs-tlab" x="${tx - w / 2 + 4}" y="${c.y + 35}">${esc(t.label)}</text>`;
laid.forEach((it, n) => {
const bx = tx - w / 2 + nameW + n * CARW;
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${c.y + 25}" width="${Math.max(3, CARW - 2)}" height="13" rx="2"/>`;
if (CARW >= 11) {
out += `<text class="bs-tcarlab" x="${bx + (CARW - 2) / 2}" y="${c.y + 35}" text-anchor="middle">${esc(it.label)}</text>`;
}
});
out += '</g>';
});
out += '</g>';
});
@@ -277,15 +344,27 @@ export function divisionSvg(nodes: DivisionView[]): string {
// what stops a seated layout being read as a loop.
const first = cells[0];
const last = cells[cells.length - 1];
/**
* UNDER THE DIVISION POINT, NOT BESIDE IT.
*
* Both labels used to hang off the outside of the end cells, anchored away from the board — which
* needed padding wider than the caption to survive, did not get it, and so printed clipped at both
* ends while spending the space anyway. Centred under their own cell they are neither cut off nor
* competing with the map for width.
*
* BOTH ENDS DO BOTH. Odd trains run west and even trains run east (content.ts `pair`), so each
* Division Point is a way on AND a way off. "trains enter" here and "trains leave" there said the
* Division ran one way, which it does not — the buffer stops only mean it is a LINE, not a loop.
*/
if (first) {
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
out += `<text class="bs-end" x="${first.x - 12}" y="${first.y + CH + 12}" text-anchor="end">trains enter</text>`;
out += `<text class="bs-end" x="${first.x + first.w / 2}" y="${first.y + CH + 13}" text-anchor="middle">west end · in and out</text>`;
}
if (last && last !== first) {
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
out += `<text class="bs-end" x="${last.x + last.w + 12}" y="${last.y + CH + 12}">trains leave</text>`;
out += `<text class="bs-end" x="${last.x + last.w / 2}" y="${last.y + CH + 13}" text-anchor="middle">east end · in and out</text>`;
}
out += '</svg>';
@@ -307,6 +386,15 @@ export function officeSvg(
cells: CellView[],
runningRow: number,
ghosts: { row: number; col: number }[] = [],
/**
* Legal squares that already HAVE a card on them, each with what playing there would do.
*
* An empty legal square gets a whole ghost card saying "place here"; an occupied one used to get a
* blue outline and nothing else, so the two most important placements in the game — extending the
* Running Track at a Limits sign, and attaching an Enhancement to a card already down — were
* offered without a word. Reported as the Depot lighting up for no visible reason.
*/
legal: { row: number; col: number; label: string }[] = [],
): string {
const W = 166;
const H = 96;
@@ -420,15 +508,36 @@ export function officeSvg(
}
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
/**
* A/D TRACKS, as pips.
*
* The Office tooltip said "3 A/D tracks" and the card showed nothing — so the number that
* decides whether the next arrival is an automatic collision (§8.3) was invisible on the card it
* belongs to. There is no room to draw more rails, and the count is what matters: one pip per
* track, filled for taken.
*/
if (cell.ad) {
for (let i = 0; i < cell.ad.of; i++) {
out += `<circle class="bs-ad${i < cell.ad.used ? ' bs-occ' : ''}" cx="${W - 9 - i * 9}" cy="10" r="3"/>`;
}
}
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
/**
* THE LOAD PIPELINE, on the card itself.
* THE LOAD PIPELINE, on the card itself, DRAWN THE WAY IT FLOWS.
*
* A load crosses green -> MEN | AT | WORK -> the spotted car, and that journey is the whole of
* freight. It was drawn only in the side panel, so advancing a load changed nothing on the card
* the player was looking at. The printed cards put these squares under the track for exactly
* this reason.
* §9.3 runs loading Green → MEN → AT → WORK → the spotted car, and unloading the other way
* entirely: car → WORK → AT → MEN → red. So green and red BOTH belong beside MEN, and the car
* belongs beside WORK.
*
* This drew green, then MEN|AT|WORK, then red — putting the red box at the far right, which is
* exactly where the car is. An unload therefore appeared to run backwards across the whole row
* and then land on the end it had just come from. Reported after unloading at a warehouse:
* "it went W, A, M, and then to the red box at the far right".
*
* Only the boxes an industry actually uses are drawn, and the arrows point the way its freight
* really moves.
*/
const f = cell.facility;
if (f) {
@@ -439,24 +548,73 @@ export function officeSvg(
let bx = 7;
const by = H - 42;
// Green: what is waiting to be worked.
// Green feeds INTO the sign, red comes OUT of it, so both sit on this side and their arrows
// point opposite ways. An industry that only receives has no green boxes at all.
if (f.allowsOut) {
for (let i = 0; i < Math.max(1, f.greenCap); i++) {
out += box(bx, by, i < f.green.length, 'bs-green');
bx += cellW + 2;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
bx += 8;
// MEN | AT | WORK — a load sits in one of these while it is being worked.
bx += 9;
}
if (f.allowsIn) {
for (let i = 0; i < Math.max(1, f.redCap); i++) {
out += box(bx, by, i < f.red.length, 'bs-red');
bx += cellW + 2;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">‹</text>`;
bx += 9;
}
// MEN | AT | WORK — one physical sign, worked left to right loading and right to left
// unloading. The spotted car sits past WORK, on the siding drawn below.
for (let i = 0; i < f.maw.length; i++) {
out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
bx += cellW + 2;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
bx += 8;
// Red: what has come off an arriving car and is waiting to be cleared.
for (let i = 0; i < Math.max(1, f.redCap); i++) {
out += box(bx, by, i < f.red.length, 'bs-red');
bx += cellW + 2;
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
/**
* WHICH WAY THIS INDUSTRY RUNS, IN WORDS.
*
* A Refinery ships out and a Grocer's Warehouse receives, and the drawing told them apart by
* the STROKE COLOUR of one 13x12px box and the direction of a 10px chevron. Asked directly in
* playtesting whether the two were supposed to look the same: they were not, and effectively
* they did. A word costs almost nothing and cannot be misread.
*/
const flow = f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
const flowCls = f.allowsOut && f.allowsIn ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
f.allowsOut && f.allowsIn
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
: f.allowsOut
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
)}">${esc(flow)}</text>`;
/**
* THE WORKERS, ON THE CARD.
*
* "How do I see the number of laborers in an industry card?" — you could not. The number that
* decides every Cargo phase lived only in a side panel, so a Modifier's +1 Laborer landed
* where the player was not looking and an Oil Depot appeared to do nothing at all. Porters
* were worse: nothing on the page drew them anywhere.
*
* FREE OVER TOTAL, because free is what can be spent right now. A facility has one kind of
* worker or the other — Laborers work freight, Porters work passengers — so the card carries
* whichever one it actually has.
*/
const isPorter = f.laborers.startsWith('0/0');
const crew = isPorter ? f.porters : f.laborers;
if (!crew.startsWith('0/0')) {
const free = Number(crew.split('/')[0]);
out += `<g data-tip="${esc(
isPorter
? `${crew} Porters free this Stage. A Porter boards or detrains passengers — one action each, and the Revenue arrives in that single action.`
: `${crew} Laborers free this Stage. One Laborer moves one load one square along MEN | AT | WORK, so this is the whole of what this industry can do before the Stage turns over.`,
)}">` +
`<rect class="bs-crewpill${free > 0 ? ' bs-ready' : ''}" x="${W - 44}" y="${by - 1}" width="38" height="14" rx="7"/>` +
`<text class="bs-crewlab" x="${W - 25}" y="${by + 9}" text-anchor="middle">${esc(isPorter ? 'P' : 'L')} ${esc(crew)}</text></g>`;
}
}
@@ -464,6 +622,9 @@ export function officeSvg(
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
for (let i = 0; i < Math.min(spots, 4); i++) {
const filled = i < cell.cars.length;
const carTip = filled
? `${cell.cars[i]} standing here`
: 'an empty space on this track — a car may be spotted here';
/**
* WHICH CAR IS STANDING HERE, not just that one is.
*
@@ -489,11 +650,24 @@ export function officeSvg(
: /coach/.test(car)
? 'cch'
: '';
const loaded = /^loaded/.test(car);
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ''}` : '';
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
// A caboose is never "loaded" in the freight sense (carLabel says so), so it is drawn solid:
// it is a car that is doing its job, not an empty waiting for one.
const loaded = /^loaded/.test(car) || /caboose/.test(car);
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ' bs-mt'}` : '';
/**
* LOADED OR EMPTY, TOLD THE SAME WAY THE TRAY TELLS IT.
*
* REPORTED: "I left a car in a siding and cannot remember whether it was loaded. In the train
* tray it is obvious; on the office cards it is hard to tell." It was: an occupied slot took a
* generic blue fill first and only a LOADED car overrode it with its type colour, so empty
* read as blue-ish and loaded as brown-ish at 26x15px, and neither said which it was.
*
* Now the card matches the tray — solid face for loaded, hollow for empty — and every slot
* carries the car's own words as a tooltip, so "loaded hopper" is one hover away.
*/
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2" data-tip="${esc(carTip)}"/>`;
if (filled) {
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc(type)}</text>`;
out += `<text class="bs-carlab${loaded ? ' bs-lab-ld' : ''}" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle" data-tip="${esc(carTip)}">${esc(type)}</text>`;
}
}
@@ -503,9 +677,54 @@ export function officeSvg(
// card, the old y = H-46 baseline printed the label straight along the rail itself.
out += `<text class="bs-enh" x="6" y="26">${esc(cell.enhancements.join(' · '))}</text>`;
}
if (cell.tray) {
out += `<g class="bs-crew"><rect x="${W / 2 - 26}" y="${RAIL - 10}" width="52" height="20" rx="3"/>` +
`<text class="bs-tlab" x="${W / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(cell.tray.split(' ')[0] ?? 'crew')}</text></g>`;
if (cell.train) {
/**
* THE TRAIN, DRAWN AS IT SITS IN THE TRAY.
*
* The badge used to be a name and nothing more, which makes the switching game unplayable:
* every decision is about car ORDER — which car comes off next, which end a cut couples onto —
* and none of it was on the board. Now the engine and its cars are drawn in sequence, nose
* first, with an arrow for which way the engine points, so "set out the caboose off the back"
* is a thing you can see before you choose it.
*/
const t = cell.train;
const items: { label: string; kind: string; what: string }[] = t.cars.map((c) => ({
label: /caboose/.test(c) ? 'cab' : /hopper/.test(c) ? 'hop' : /boxcar/.test(c) ? 'box'
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
kind: /^loaded/.test(c) || /caboose/.test(c) ? 'ld' : 'mt',
what: c,
}));
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng', what: 'the engine' });
/**
* WEST ON THE LEFT, AND THE NOSE POINTING THE WAY THE ENGINE FACES.
*
* `consist` is ordered NOSE FIRST (state.ts), and this drew index 0 leftmost whatever the
* train was doing \u2014 so a westbound train came out right and an eastbound one came out
* MIRRORED. Reported from seed 270861860: Train 10 running east arrived at the Whistle Post
* drawn engine-first at the WEST end, which reads as an engine shoving four cars ahead of it.
*
* The board is a map, so the drawing has to obey the map: reverse the seating order for an
* east-facing train and its nose lands at the east end, where it is. A crew on a north-south
* spur has no left or right to be right about, so it keeps nose-left and its \u25b2/\u25bc says the rest.
*/
const laid = t.facing === 'e' ? [...items].reverse() : items;
const cw = 17;
const tw = Math.min(W - 8, laid.length * cw + 30);
const tx = W / 2 - tw / 2;
const facingWord =
t.facing === 'e' ? 'east' : t.facing === 'w' ? 'west' : t.facing === 'n' ? 'north' : 'south';
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
out += `<g class="bs-crew" data-tip="${esc(
`${t.label} \u2014 engine pointing ${facingWord}, carrying ${consistWords} (nose first). Drawn as it stands: west is on the left.`,
)}"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
laid.forEach((it, n) => {
const bx = tx + 28 + n * cw;
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2" data-tip="${esc(it.what)}"/>` +
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle" data-tip="${esc(it.what)}">${esc(it.label)}</text>`;
});
out += '</g>';
}
out += '</g>';
}
@@ -519,6 +738,23 @@ export function officeSvg(
`</g>`;
}
/**
* A LEGAL SQUARE THAT ALREADY HAS A CARD ON IT, captioned like the empty ones.
*
* The two are the same question — you have picked a card, now pick where it goes — and they were
* answered in two different registers: a full ghost card reading "place here" for an empty square,
* and a 2px blue outline for an occupied one. So the Limits sign (which is how a district grows at
* all) and the Depot (which is where an Enhancement attaches) both lit up saying nothing.
*
* Drawn as a band across the card rather than over its middle: there is a rail, a train and a load
* pipeline under here, and the caption must not sit on top of them.
*/
for (const l of legal) {
out += `<g class="bs-legalcap" transform="translate(${px(l.col)},${py(l.row)})">` +
`<rect x="6" y="${H - 46}" width="${W - 12}" height="14" rx="7"/>` +
`<text x="${W / 2}" y="${H - 36}" text-anchor="middle">${esc(l.label)}</text></g>`;
}
// Mark the Running Track so the spine of the district is unmistakable.
out += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
out += '</svg>';
@@ -561,26 +797,44 @@ export const BOARD_CSS = `
train is measured against, not something to look at instead of the train. */
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL. An industry takes one commodity and nothing else, so the
type has to be readable at a glance; and a loaded car and an empty one of the same type are
opposite halves of the same job. Empty rides as an outline, loaded as a solid face. */
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they
are the same fact about the same car. An occupied slot used to take a generic blue fill and only
a loaded car overrode it, so a 26x15px square said "a car" and left "which car, carrying what?"
to be worked out. Empty is now hollow with a dark face, loaded a solid one, exactly as in the
tray; the type colour is the stroke either way. */
.bs-slot.bs-car-box{stroke:#c08a4a}
.bs-slot.bs-car-hop{stroke:#8d97a5}
.bs-slot.bs-car-tnk{stroke:#7a6ab0}
.bs-slot.bs-car-rfr{stroke:#6fb5a0}
.bs-slot.bs-car-cch{stroke:#5aa9e6}
.bs-slot.bs-car-cab{stroke:#c05a5a}
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.42)}
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.42)}
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.42)}
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.42)}
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.42)}
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
.bs-slot.bs-occ.bs-mt{fill:#181d24}
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.85)}
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.85)}
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.85)}
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.85)}
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)}
.bs-slot.bs-car-cab.bs-loaded{fill:rgba(192,90,90,.85)}
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
engine is the one that carries the arrow — which is what makes "reverse" mean something. */
.bs-tcar{stroke:#3a2f14;stroke-width:.8}
.bs-tcar.bs-t-ld{fill:#e8dcc0}
.bs-tcar.bs-t-mt{fill:#6b5a2e}
.bs-tcar.bs-t-eng{fill:#2b2313;stroke:#e0c060;stroke-width:1.4}
.bs-tcarlab{font:8px ui-monospace,monospace;fill:#2b2313;font-weight:700}
/* One pip per A/D track, filled when a train is holding on it. A full Office is why the next
arrival collides, so this is the count worth seeing without hovering. */
.bs-ad{fill:none;stroke:#4d6fa8;stroke-width:1.2}
.bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee}
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
/* The three letters sit ON the slot, so they invert with it: dark on a loaded car's solid face,
light on an empty one's hollow. */
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
.bs-carlab.bs-lab-ld{fill:#12161b;font-weight:700}
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
.bs-wb.bs-green{stroke:#3f7a52}
.bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)}
@@ -589,6 +843,17 @@ export const BOARD_CSS = `
.bs-wb.bs-red{stroke:#8a5a5a}
.bs-wb.bs-red.bs-occ{fill:rgba(170,70,70,.6)}
.bs-wl{fill:#8d97a5;font:7px ui-monospace,monospace}
/* WHICH WAY THE INDUSTRY RUNS. Green for ships-out, red for receives, in the same hues as the boxes
they describe — the word and the colour saying the same thing twice. */
.bs-flow{font:700 8px ui-monospace,monospace;letter-spacing:.06em;cursor:help}
.bs-flow-out{fill:#5f9b74}
.bs-flow-in{fill:#b07272}
.bs-flow-both{fill:#8b94a3}
/* THE WORKERS. Free over total, on the card, because a Laborer that is already spent this Stage is
not a Laborer you can use. Lit while any remain, dim once they are all gone. */
.bs-crewpill{fill:#20262e;stroke:#4a5361;stroke-width:1}
.bs-crewpill.bs-ready{fill:rgba(190,150,40,.22);stroke:#8a6d1f}
.bs-crewlab{fill:#cfd6e0;font:600 9px ui-monospace,monospace}
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
@@ -602,9 +867,24 @@ text.bs-mod{fill:#c8a04a}
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
.bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4}
.bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace}
/* The caption on a legal square that already carries a card. Same blue as the ghost squares and the
same words in kind, because it is the same question: you have picked a card, now pick where. */
.bs-legalcap rect{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-width:1}
.bs-legalcap text{fill:#cfe0f5;font:700 8px ui-monospace,monospace;letter-spacing:.07em}
.bs-legalcap{pointer-events:none}
.bs-ghost,g[data-cell].bs-legal{cursor:pointer}
g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5}
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
/* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable
card reads the same way an action button does. bs-from is where it is standing now. */
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
/* AND WHERE IT CANNOT. Muted red with a barred face — a card that is refusing traffic, not one that
is merely uninteresting. The reason rides on the card's own tooltip. */
g[data-cell].bs-blocked .bs-card{stroke:#8a5a5a;stroke-width:2;stroke-dasharray:2 3}
/* NOT THE SAME THING. A turnout carries no wheel icon (§A.1): a train runs through it freely and
simply cannot finish a Move on it. Drawn as a faint amber dash — related to the reachable amber,
because it IS reachable, just not a place to stop. */
g[data-cell].bs-nostop .bs-card{stroke:#6a5f3a;stroke-width:1.6;stroke-dasharray:2 4}
g[data-cell].bs-blocked,g[data-cell].bs-nostop{cursor:help}
`;
+59 -8
View File
@@ -17,7 +17,7 @@
import { adTrackCount, coordKey } from '../engine/state.ts';
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
// ---------------------------------------------------------------------------
@@ -212,12 +212,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'good',
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
};
case 'enhancementPlaced':
return {
tone: 'good',
where: e.at,
text: `ENHANCEMENT built: ${e.key.replace(/([A-Z])/g, ' $1')} at ${at(e.at)}`,
};
case 'enhancementPlaced': {
const name = e.key.replace(/([A-Z])/g, ' $1');
// Out on the Mainline is not a square in anyone's district, so it is named rather than
// given a coordinate the Office Area does not have.
if (e.at === undefined) {
return { tone: 'good', text: `ENHANCEMENT built: ${name} on Mainline card ${e.node}` };
}
return { tone: 'good', where: e.at, text: `ENHANCEMENT built: ${name} at ${at(e.at)}` };
}
case 'secondSectionOrdered':
return {
tone: 'bad',
@@ -239,9 +242,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`,
};
case 'trainArrived':
/**
* AN EXPEDITED TRAIN IS NOT COMING BACK ROUND.
*
* This said "it will highball again next Mainline Phase, so any work must happen now" to every
* arrival. For an expedited train that is exactly backwards — Q3 departs it in the SAME Stage
* it arrived, so there is no turn in which to work it — and the log then contradicted itself
* two lines later when the train left. Reported after Train 4, the Express, did precisely that.
*/
return {
tone: 'plain',
text: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it will highball again next Mainline Phase, so any work must happen now`,
text: e.expedited
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — and EXPEDITES, so it leaves again this same Mainline Phase; there is no turn in which to work it`
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands until the next Mainline Phase, so any work must happen before then`,
};
case 'trainDiverted':
return {
@@ -481,6 +494,37 @@ export function impediments(s: GameState, player = 0): Impediment[] {
}
}
/**
* WHY THE CREW CANNOT GET THERE.
*
* The panel is called "why nothing is moving" and covered everything except movement: jammed
* facilities, held trains, a full Office. A crew standing one card short of an industry it cannot
* enter had nothing here at all — reported as trains being blocked "in certain conditions", with
* no way to find out which.
*
* Only while switching, and only the cards actually in the crew's way: `movesFor` reports the
* squares the movement walk reached and refused, not every square on the board.
*/
if (s.clock.phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining > 0) {
for (const [id, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
const { blocked } = movesFor(s, player, id);
for (const b of blocked) {
// A turnout is not an obstruction — a train runs through one all day and simply may not
// STOP on it. Listing every one would bury the four reasons that are genuinely in the way;
// the board says it on the card instead.
if (b.kind === 'noStopping') continue;
out.push({
where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.row},${b.coord.col})`,
why: b.why,
// Not "stuck": these are the shape of the district and of the other trains in it, which is
// the puzzle rather than a fault. Amber, not red.
severity: 'waiting',
});
}
}
}
// Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
const due = s.timetable[s.clock.stage - 1];
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
@@ -547,6 +591,13 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
const out: string[] = [];
for (const e of events) {
if (e.type === 'trainMadeUp') out.push('train');
// Coupling and setting out are what a switching move IS, and both were silent. A car leaving the
// board with only a line of history to say where it went is the thing that most needs a noise.
if (e.type === 'carsCoupled') out.push('couple');
if (e.type === 'carsDropped' || e.type === 'flyingSwitch') out.push('drop');
// The 1D12 that sets a train's departure Stage. It is the one roll a player makes and it landed
// silently, so the card was gone and the answer to "when does it run?" was a line of history.
if (e.type === 'trainScheduled') out.push('schedule');
if (e.type === 'stageBegan') {
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
+15 -8
View File
@@ -37,6 +37,7 @@ import type { Frame } from './view.ts';
import type { Decision } from './view.ts';
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from './turnchart.ts';
import { playCue } from '../web/sound.ts';
// ---------------------------------------------------------------------------
@@ -201,7 +202,9 @@ export function compress(frames: Frame[]): Packed {
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
// cell and the frame's `facilities`, so this always resolves.
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
return [ci, wi, c.enhancements, c.tray, c.cars, fi];
// `train` rides whole rather than being interned: it changes almost every frame, so a table
// of them would be as long as the frames are and buy nothing.
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad];
});
return { ...f, cells } as unknown as Frame;
});
@@ -235,12 +238,14 @@ export function rehydrateCells(
facs: unknown[],
): unknown[] {
return packed.map((row) => {
const p = row as [number, number, string[], string | null, string[], number];
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown];
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
return {
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4],
facility: p[5] < 0 ? null : facs[p[5]],
train: p[6] ?? null,
ad: p[7] ?? null,
};
});
}
@@ -253,6 +258,7 @@ export function renderHtml(rec: Recording): string {
<title>Station Master — replay seed ${rec.seed}</title>
<style>
${BOARD_CSS}
${TURNCHART_CSS}
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
@@ -344,15 +350,17 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
<header>
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
<div class="bar">
<span class="big" id="when">—</span>
<span>phase <b id="phase">—</b></span>
<span class="dim">actor <span id="actor">—</span> · fedora <span id="super">—</span></span>
<span class="dim">fedora <span id="super">—</span></span>
<span>revenue <b class="big" id="rev">0</b></span>
<span class="dim">deck <span id="deck">0</span></span>
<span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
</div>
</header>
<!-- The live game's turn chart, from the same renderer, so where you are in the Day looks the same
whether you are playing or watching. -->
<div id="turnchart"></div>
<div class="wrap">
<div>
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
@@ -414,6 +422,7 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0
from the same state, which is the one failure a track diagram exists to prevent. */
const divisionSvg = ${divisionSvg.toString()};
const officeSvg = ${officeSvg.toString()};
const turnChartHtml = ${turnChartHtml.toString()};
const playCue = ${playCue.toString()};
const PACKED = ${JSON.stringify(compress(rec.frames))};
@@ -458,9 +467,7 @@ function boxes(items, cap, cls) {
function render() {
const f = FRAMES[i];
$('when').textContent = 'Day ' + f.day + ' · Stage ' + f.stage + ' · ' + f.clock;
$('phase').textContent = f.phase;
$('actor').textContent = f.actor === null ? 'automatic' : 'P' + f.actor;
$('turnchart').innerHTML = turnChartHtml(f, f.actor === null ? null : 'Player ' + (f.actor + 1));
$('super').textContent = 'P' + f.superintendent;
$('rev').textContent = f.revenue;
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
+126
View File
@@ -0,0 +1,126 @@
/**
* The turn chart — where in the Day we are, and which of the five phases is running.
*
* SELF-CONTAINED ON PURPOSE, exactly like the two board renderers in `board-svg.ts`. The playable
* app and the site's replay viewer import it normally; the standalone replay is a single HTML file
* with an inline script and cannot import anything, so it embeds this via `Function.toString()`.
* That keeps ONE implementation across all three screens.
*
* It lived only in `main.ts` before, so a replay showed the Day and the phase as plain text while
* the live game showed the chart — and the violet "you are here" highlight, which is the single
* most-glanced-at thing on the page, appeared on one screen out of three.
*/
/** What a chart needs from a frame. A `Frame` satisfies this; so does a replay's rehydrated one. */
export type TurnChartFrame = {
day: number;
stage: number;
clock: string;
phase: string;
phaseKey: string;
actor: number | null;
};
/**
* The five phases of a Stage, in order, each with what actually happens in it.
*
* The first three are the rules text; Cargo and Supervisor Shift are written from what the engine
* does, since the recovered sheet does not spell them out.
*/
export function turnChartHtml(f: TurnChartFrame, actorName: string | null): string {
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
{
key: 'localOps',
label: 'Local Operations',
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
// a turnout: the choice of three roads
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
},
{
key: 'newTrain',
label: 'New Train',
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
// a locomotive being made up
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
},
{
key: 'mainline',
label: 'Mainline',
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
// open road
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
},
{
key: 'loadUnload',
label: 'Cargo',
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
// a loaded box on a flat
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
},
{
key: 'shiftChange',
label: 'Supervisor Shift',
tip: "End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent's hat passes to the next player.",
// a pocket watch
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
},
];
const esc = (t: string): string =>
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
const chips = PHASES.map((p, i) => {
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
return (
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
`<span>${esc(p.label)}</span></li>`
);
}).join('');
// An automatic phase is waiting on nobody, and saying so is more use than a blank.
const who = actorName ?? 'nobody — the Division is running itself';
return (
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
`<span class="dim">${esc(f.clock)}</span></div>` +
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` +
`<ol class="tc-phases">${chips}</ol>`
);
}
/**
* Styling, shared so the three screens cannot drift.
*
* VIOLET, not the amber used for actions. The palette already spends amber on "you can click this",
* blue on "this is a legal square or a link", green on good and red on bad. The turn chart is none
* of those — it reports WHERE YOU ARE, and a player should never read it as something to press.
* Violet is the one hue not already carrying a meaning, so it cannot be confused with any of them.
*/
export const TURNCHART_CSS = `
#turnchart{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
background:#161b22;border-bottom:1px solid #2c333d;padding:7px 14px}
.tc-when{display:flex;align-items:baseline;gap:10px}
.tc-when b{font-size:15px;color:#cfe0f5}
.tc-now{font-size:12px;color:#8b94a3}
.tc-now b{color:#b98cf0;font-size:13px}
/* WHO EVERYONE IS WAITING ON. Solitaire has one player, but the seat still has a name — the log
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
glances at most often, so it gets its own chip rather than hiding in the phase text. */
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3}
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
border-radius:11px;padding:1px 9px;font-size:12px}
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px;
padding:3px 10px 3px 7px;font-size:11px;color:#8b94a3;background:#1a1f26;cursor:help}
.tc-phase svg{display:block;flex:0 0 auto}
.tc-phase .ic{stroke:#5f6b7a;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.tc-phase .icf{fill:#5f6b7a;stroke:none}
.tc-phase.done{opacity:.55}
.tc-phase.now{border-color:#b98cf0;background:rgba(150,110,230,.15);color:#e7dcfb;font-weight:700;
box-shadow:0 0 0 2px rgba(150,110,230,.18)}
.tc-phase.now .ic{stroke:#b98cf0}
.tc-phase.now .icf{fill:#b98cf0}
`;
+372 -45
View File
@@ -10,29 +10,38 @@
* drift into two different pictures of the same board.
*/
import { areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import {
areaOf,
destinationsFor,
facilityCarType,
laborersLeft,
movesFor,
portersLeft,
} from '../engine/apply.ts';
import {
ACTION_CARDS,
ENHANCEMENT_CARDS,
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
REALIGNMENTS,
REGIONS_PER_MAINLINE_CARD,
OFFICE_ORDER,
SPACE_USE_CARDS,
industryProfile,
mainlineProfile,
modifierProfile,
lengthProfile,
officeProfile,
trainProfile,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, TrackCard } from '../engine/state.ts';
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
import { carLabel, carsLabel, clockTime, impediments, phaseLabel } from './narrate.ts';
export type CellView = {
row: number;
@@ -47,6 +56,25 @@ export type CellView = {
*/
enhancements: string[];
tray: string | null;
/**
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
*
* The switching game is entirely about car ORDER — which car is next to come off, which end a cut
* couples onto — and the board showed a crew badge with a name and nothing else. A player could
* not plan a move at all: "drop 1 car" tells you nothing when you cannot see what is on the back.
*
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
* `facing` is the port it points at on this card.
*/
train: { label: string; cars: string[]; engineAt: number; facing: string } | null;
/**
* Office card only: A/D tracks taken and how many the tier has.
*
* The tooltip said "3 A/D tracks" and the card showed nothing, so the number that decides whether
* the next arrival collides was invisible on the card it belongs to. Drawn as pips rather than
* extra rails — there is no room on the card for more track, and the count is what matters.
*/
ad: { used: number; of: number } | null;
cars: string[];
facility: FacilityView | null;
/**
@@ -79,6 +107,27 @@ export type FacilityView = {
trackCap: number;
laborers: string;
porters: string;
/**
* What the INDUSTRY CARD itself prints, before any Modifier beside it.
*
* A modifier's whole effect is a number going up, and the panel showed only the number — so an Ice
* House raising outbound capacity from 0 to 1 and laborers from 1 to 2 looked like nothing had
* happened. Reported after playing an Ice House and Local Small Groceries and seeing no change
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
*/
base: { out: number; in: number; laborers: number; porters: number };
/**
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
*
* §9.3 runs loading Green → MEN → AT → WORK → car, and unloading the other way: car → WORK → AT →
* MEN → red. So green and red both sit beside MEN, and the car sits beside WORK. Drawing red at
* the far right — where the car is — made an unload look like it ran backwards across the whole
* row and then landed on the wrong end.
*/
allowsOut: boolean;
allowsIn: boolean;
/** The Modifier cards standing beside it, by name. */
modifiers: string[];
/**
* Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
* WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
@@ -91,7 +140,19 @@ export type FacilityView = {
export type TrainChip = {
label: string;
/** Nose first, with `ENG` seated where the engine actually is. The words, for a tooltip. */
consist: string[];
/**
* THE SAME TRAIN THE OFFICE CARD DRAWS, so the Division map can draw it the same way.
*
* `cars` is nose first and carries no engine; `engineAt` is where the engine sits among them and
* `facing` is the port it points at. The Division chip used to be a name and a number — and the
* number was Stages left to cross, which reads as redundant beside the position already drawn on
* the card. A train is worth drawing: what it is carrying, loaded or empty, and which end leads.
*/
cars: string[];
engineAt: number;
facing: string;
/**
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
@@ -99,6 +160,15 @@ export type TrainChip = {
*/
region?: number;
direction?: string;
/**
* Stages still to run before it is off this Mainline card — NOT the same as regions left.
*
* A card is two regions of fixed distance; the Stages are how long this train takes over them
* (`crossingStages`): a 60 card is one Stage, a 30 card two, a Slow train adds one. So they
* coincide only in the middle case. It rode on the chip as "· 2⧗" and was read as a car count;
* it belongs in the tooltip, where there is room to say which it is.
*/
stagesLeft?: number;
};
/**
* One card of a player's Running Track, as the Division sees it.
@@ -173,6 +243,30 @@ export type Frame = {
cells: CellView[];
/** Which grid row is the Running Track — the spine the district hangs beneath. */
runningRow: number;
/**
* Moves left in this Local Operations turn, or null outside a switching turn.
*
* It was reported only in the history, which is the one panel a player is NOT looking at while
* switching — the count that decides whether a run-around is still possible belongs next to the
* moves themselves.
*/
movesLeft: number | null;
/**
* WHERE THE CREW CAN GO, AND WHY NOT ELSEWHERE — while it is switching, and null otherwise.
*
* The switching game was played off a list of coordinates: "move to (0, -2)" as a button, with
* nothing on the board and no account of the squares that were missing from the list. Reported as
* trains being blocked from entering an industry "in certain conditions", with no way to see what
* the conditions were.
*
* `blocked` comes out of the movement walk itself (`movesFor`), so a reason on screen is the rule
* that actually refused the square rather than a second guess at it.
*/
moves: {
from: { row: number; col: number };
to: { row: number; col: number }[];
blocked: { coord: { row: number; col: number }; kind: string; why: string }[];
} | null;
facilities: FacilityView[];
hand: string[];
/** What each hand card does, in the same order — names alone are not a playable hand. */
@@ -256,7 +350,7 @@ const FACILITY_NAMES: Record<string, string> = {
};
function facilityView(
card: { geometry: { kind: string; facility?: string }; facility: unknown },
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
officeName: string,
): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
@@ -282,9 +376,60 @@ function facilityView(
porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f),
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
allowsOut: f.allows.outbound,
allowsIn: f.allows.inbound,
base: baseOf(card, officeName),
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
};
}
/**
* What the card itself prints, before any Modifier beside it.
*
* Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card
* the player is holding. A passenger facility takes its numbers from the Office tier instead.
*/
function baseOf(
card: { geometry: { kind: string; facility?: string } },
officeName: string,
): { out: number; in: number; laborers: number; porters: number } {
const g = card.geometry;
if (g.kind === 'facility' && g.facility) {
const p = industryProfile(g.facility as never);
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders, porters: 0 };
}
/**
* A PASSENGER FACILITY TAKES ITS NUMBERS FROM THE OFFICE TIER.
*
* The comment above this function has said so for a long time and the code returned zeros, so the
* panel worked out its "+N from a Modifier" against a base of nothing: a plain Depot with no
* Modifier anywhere near it displayed `out 1 +1`, crediting a card that had never been played.
* The tier is the printed number here, exactly as the industry card is for an industry.
*/
if (g.kind === 'office') {
const tier = OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost';
const p = officeProfile(tier);
return { out: p.passengerOut, in: p.passengerIn, laborers: 0, porters: p.porters };
}
return { out: 0, in: 0, laborers: 0, porters: 0 };
}
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
function trainOnCard(s: GameState, key: string): CellView['train'] {
for (const [id, t] of s.trays) {
if (t.position.at !== 'grid') continue;
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
cars: t.consist.map(carLabel),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
};
void id;
}
return null;
}
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
function canFinishHere(f: Facility): boolean {
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
@@ -344,8 +489,22 @@ export function describeIntent(s: GameState, i: Intent): string {
: i.option === 'draw'
? 'DRAW — take a card and play one, and you may lay a piece of track'
: 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
case 'card.play':
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
case 'card.play': {
/**
* NAME THE ROTATION, or the choice disappears.
*
* The action list drops duplicate labels, and this said only "play X at (0, 1)" — so a
* turnout's two orientations produced one identical label each and the second was silently
* discarded before the menu ever saw it. The rotation is the entire decision for a turnout or
* a curve, and it could not be made.
*/
const kind = s.cards.get(i.cardId)?.kind;
const turn =
i.placement && kind?.kind === 'track'
? variantLabel(kind.geometry, i.variant, kind.hand)
: '';
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`;
}
case 'card.discard': {
/**
* NAME THE DEPARTMENT, and what the card would land on.
@@ -360,14 +519,63 @@ export function describeIntent(s: GameState, i: Intent): string {
const onto = top ? `, burying ${cardName(s, top)}` : ' (empty)';
return `discard ${cardName(s, i.cardId)} onto Department ${i.toSlot + 1}${onto}`;
}
case 'switch.move':
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
case 'switch.dropCars':
return `drop ${i.count} car(s)`;
case 'switch.move': {
/**
* SAY WHAT THE MOVE WILL PICK UP.
*
* Coupling is mandatory (§A.4): run over a card with cars standing on it and they join the
* train, whether or not you wanted them. The button said "move to (0, -2)" and the only
* account of the coupling was a line in the history panel — which is how a playtester ended up
* reporting that "cars magically appeared on my train".
*
* Taken from the engine's own destination list, so the count on the button is the count that
* will actually couple.
*/
const tray = s.trays.get(i.trayId);
const here = tray?.position.at === 'grid' ? tray.position.coord : null;
let picks = '';
if (here) {
const dest = destinationsFor(s, tray!.position.at === 'grid' ? tray!.position.owner : 0, i.trayId, here, i.reverse)
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
if (dest && dest.couples.length > 0) {
picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`;
}
}
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}${picks}`;
}
case 'switch.dropCars': {
/**
* NAME THE CARS AND THE END THEY COME OFF.
*
* This said "drop 1 car(s)", which is two failures at once. It never said WHICH car, so a
* player who knew the caboose was on the back still had to guess; and it read identically for
* a nose drop and a tail drop, so — the action list dropping duplicate labels — setting out
* from the front of the train was silently discarded and could not be chosen at all.
*/
const tray = s.trays.get(i.trayId);
if (!tray) return `drop ${i.count} car(s)`;
const cut = i.fromNose
? tray.consist.slice(0, i.count)
: tray.consist.slice(tray.consist.length - i.count);
const end = i.fromNose ? 'off the front' : 'off the back';
return `set out ${carsLabel(cut)} ${end}`;
}
case 'switch.sortConsist':
return `re-order consist [${i.order.join(',')}]`;
case 'freightAgent.stockOutbound':
return `stock a ${i.carType} at ${at(i.at)}`;
case 'freightAgent.stockOutbound': {
/**
* "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly
* that confusion: no train at the Depot, so how is a coach being stocked there? It is not a
* car on the track — it is a load taken from the Division Yard into the green Loading box,
* waiting for a train that can carry it. For a passenger facility that load is passengers on
* the platform.
*/
const f = areaOf(s, 0).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
const where = f?.kind === 'passenger' ? 'onto the platform' : 'into the green Loading box';
return i.carType === 'coach' && f?.kind === 'passenger'
? `bring passengers ${where} at ${at(i.at)} — they wait there for a train with an empty coach`
: `bring a ${i.carType} load ${where} at ${at(i.at)} — it waits there for a car to be spotted`;
}
case 'freightAgent.unjam':
return `unjam ${i.from} at ${at(i.at)}`;
case 'freightAgent.clearInbound':
@@ -386,8 +594,35 @@ export function describeIntent(s: GameState, i: Intent): string {
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
// "add loaded caboose" on a button.
return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`;
case 'mainline.modify':
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
case 'mainline.modify': {
/**
* NAME THE CARD, NOT ITS INDEX, AND SAY WHAT HAPPENS TO IT.
*
* This read "Realignment on Mainline card 3" — a raw node index, which tells a player nothing
* about which stretch of the Division it means, and no clue what the card would do to it.
* Worse, the action list attaches a tooltip only when a label carries an em-dash, so this one
* silently had none at all while the same card in hand did.
*/
const node = s.division.nodes[i.node];
const key = (s.cards.get(i.cardId)?.kind as { key?: string } | undefined)?.key;
const shortWhere = node?.kind === 'mainline' ? `the ${mainlineProfile(node.card).name}` : `Mainline card ${i.node}`;
const where =
node?.kind === 'mainline'
? `the ${ordinal(mainlineIndex(s, i.node))} Mainline card west to east`
: `node ${i.node}`;
let effect = '';
if (key === 'realignment' && node?.kind === 'mainline') {
const to = REALIGNMENTS.find((r) => r.from === node.card);
effect = to ? `converts it to ${mainlineProfile(to.to).name}` : 'nothing here to convert';
} else if (key === 'brakeman' || key === 'airbrakes') {
effect = 'one Stage off the descent for a train running downhill';
} else if (key === 'helpers') {
effect = 'one Stage off the climb for a train running uphill';
}
// Short head, full detail after the em-dash — `actionButton` puts the first on the button and
// the second in the tooltip, so the list stays narrow without losing the explanation.
return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`;
}
case 'maneuver.redFlags':
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
case 'maneuver.flyingSwitch':
@@ -495,6 +730,11 @@ export function snapshot(
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
enhancements: card.enhancements.map(prettyKey),
tray: trayAt.get(key) ?? null,
train: trainOnCard(s, key),
ad:
card.geometry.kind === 'office'
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
: null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
facility: fv,
});
@@ -549,7 +789,15 @@ export function snapshot(
const chip = trainChip(s, t.tray);
return {
...chip,
label: `${chip.label} (${t.stagesRemaining})`,
/**
* THE NUMBER COMES OFF THE CHIP.
*
* It read "TX14 (2)" and was taken for the car count — twice, by the same player — so
* it was named "· 2⧗", Stages left to cross. Named, it was then correctly read as
* redundant: the card already draws WHERE the train is, and how many Stages it still
* needs is a detail for the tooltip. The chip draws the train instead.
*/
stagesLeft: t.stagesRemaining,
region: place(t),
direction: t.direction,
};
@@ -660,6 +908,8 @@ export function snapshot(
wasted,
objective: objectiveOf(s),
runningRow: area.runningRow,
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
moves: switchingMoves(s, 0),
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
@@ -772,15 +1022,24 @@ export function cardDescription(s: GameState, id: string): string {
if (k.geometry === 'straight') {
return `east-west through track · lay it anywhere the rail continues${cost}`;
}
const arcs = variantsFor(k.geometry, k.hand)
.map((v) => v.arc ?? `${v.turnout?.stem}-${v.turnout?.diverge}`)
.join(' or ');
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
return (
`east-west track with a 45° leg through the middle of the north or south edge · ` +
`lay it as ${arcs} · its leg is on the ${diagonal} diagonal and only meets a card on the ` +
`same one${stop}${cost}`
);
const ways = variantsFor(k.geometry, k.hand)
.map((v) => (v.arc ? curvePhrase(v.arc) : v.turnout ? turnoutPhrase(v.turnout) : ''))
.join(', or turned about, ');
/**
* A CURVE IS NOT A TURNOUT, and it used to be described as one.
*
* Both said "east-west track with a 45° leg", which is a turnout: a road straight across the
* card plus a leg off it. A curve has ONE road and no choice to make — it comes in from an
* east or west edge, runs along the centre line to the frog, and leaves at 45° through the
* middle of a north or south edge. Nothing runs past it, which is exactly why it may not be
* laid in the Running Track.
*/
const what =
k.geometry === 'turnout'
? 'east-west track with a 45° leg through the middle of the north or south edge'
: 'a single road: in from the east or west edge, then out at 45° through the middle of the north or south edge — no track runs past it';
return `${what} · lay it so it ${ways} · its 45° leg is on the ${diagonal} diagonal and only meets a card on the same one${stop}${cost}`;
}
default: {
// The recovered categories carry their own prose — effect plus where it may be played.
@@ -803,15 +1062,44 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
const g = card.geometry;
switch (g.kind) {
case 'limits':
return 'the edge of your control area — lay track HERE to extend the Running Track';
/**
* THE WHOLE RULE, not half of it.
*
* This said "lay track HERE to extend the Running Track", which is true and leaves out the
* part a player has to know: the sign is the ONLY growth point on the main, it moves outward
* with the card, and nothing anywhere on the board can be inserted between two cards already
* down. Asked for directly after a playtest — "somewhere it should be clear that track can
* only be added to expand outwards".
*/
return (
'the edge of your control area. Lay track HERE and the sign moves one card further out — ' +
'this is the only way the Running Track grows, and nothing may be built beyond the sign. ' +
'A district only ever expands: no card can be inserted between two cards already down.'
);
case 'office': {
const p = officeProfile(
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
);
/**
* READ THE OFFICE AS IT STANDS, not as its card was printed.
*
* This took the porter count and the passenger slots from the TIER PROFILE, so a Waiting Area,
* Restaurant or Hotel standing beside the Office — each +1 porter and +1 passenger out —
* changed the Office and left this line saying what a bare Depot has. Reported as an extra
* porter with "no indication of it anywhere": the Modifier had worked and nothing said so.
*
* The facility record is the Office; the profile is only what it started as.
*/
const f = card.facility;
const porters = f ? f.porters : p.porters;
const out = f ? f.capacity.outbound : p.passengerOut;
const inb = f ? f.capacity.inbound : p.passengerIn;
const added = porters - p.porters + (out - p.passengerOut) + (inb - p.passengerIn);
return (
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
(p.isPassengerFacility
? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
? `${porters} porter${porters === 1 ? '' : 's'}, passengers ${out} out / ${inb} in` +
(added > 0 ? ` (the card prints ${p.porters}/${p.passengerOut}/${p.passengerIn}; the Modifiers beside it add the rest)` : '')
: 'not a Passenger Facility — no porters, no passenger boxes')
);
}
@@ -859,32 +1147,53 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
case 'sharpCurved': {
const arc = g.arc ?? (g.hand === 'right' ? 'se' : 'sw');
const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
return `${arcPhrase(arc)}${cost}${slopePhrase(arc[0] as Port, arc[1] as Port)}`;
return `curve — ${curvePhrase(arc)}${cost}${slopePhrase(arc[0] as Port, arc[1] as Port)}`;
}
case 'turnout': {
const t = g.turnout;
if (!t) return 'turnout';
// §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street.
return (
`turnout — stem ${compass(t.stem)}, through ${compass(t.through)}, diverges ${compass(t.diverge)} at 45° · ` +
`a train may run stem↔through or stem↔diverge, but NEVER between ${compass(t.through)} and ` +
`${compass(t.diverge)}${slopePhrase(t.stem as Port, t.diverge as Port)}`
`turnout — ${turnoutPhrase(t)} · a train coming the other way, from the ` +
`${compass(t.through)} or the ${compass(t.diverge)}, may only leave by the ` +
`${compass(t.stem)} — the two roads never join` +
`${slopePhrase(t.stem as Port, t.diverge as Port)}`
);
}
}
}
}
/** Which Mainline card this is, counting only the Mainline cards from the west end. */
function mainlineIndex(s: GameState, node: number): number {
let n = 0;
for (let k = 0; k <= node; k++) if (s.division.nodes[k]?.kind === 'mainline') n++;
return n;
}
const ORDINALS = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth'];
const ordinal = (n: number): string => ORDINALS[n] ?? `${n}th`;
const compass = (p: string): string => ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
/**
* A curve, as the printed card draws it: along the centre line to a frog, then out at 45° through
* the MIDDLE of an edge. Naming both halves is what stops it reading as a quarter-circle corner.
* WHAT A TURNOUT DOES, in the words a player would use at the table.
*
* "Right-hand turnout, stem east, through west, diverges north" is three pieces of jargon and a
* compass reading, and none of it answers the only question being asked: if my train comes in from
* over there, where can it go? §A.1's rule falls straight out of the same sentence — traffic from
* the stem may take either road, and traffic arriving on either road may only leave by the stem, so
* the two roads never join.
*/
function arcPhrase(arc: string): string {
function turnoutPhrase(t: TurnoutOrientation): string {
return `allows traffic from the ${compass(t.stem)} to travel ${compass(t.through)} or turn to the ${compass(t.diverge)}`;
}
/** The same, for a curve: it has one road and no choice to make. */
function curvePhrase(arc: string): string {
const [a, b] = [arc[0] as Port, arc[1] as Port];
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
return `curve — runs ${compass(side!)} along the centre line, then leaves at 45° through the middle of the ${compass(leg!)} edge`;
return `carries traffic from the ${compass(side!)} round to the ${compass(leg!)}`;
}
/**
@@ -944,15 +1253,9 @@ export function variantLabel(
): string {
const v = variantsFor(geometry, hand)[variant ?? 0];
if (!v) return '';
if (v.turnout) {
return ` — stem ${compass(v.turnout.stem)}, through ${compass(v.turnout.through)}, diverges ${compass(v.turnout.diverge)} at 45°`;
}
if (v.arc) {
const [a, b] = [v.arc[0] as Port, v.arc[1] as Port];
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
return ` — ${compass(side!)} to the middle of the ${compass(leg!)} edge`;
}
return ' — east–west';
if (v.turnout) return ` — ${turnoutPhrase(v.turnout)}`;
if (v.arc) return ` — ${curvePhrase(v.arc)}`;
return ' — straight through, east to west';
}
/**
@@ -1003,9 +1306,29 @@ function countStock(
return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
}
/**
* The switching crew's reach, for the board to draw.
*
* Only while a switching turn is actually running and only while Moves remain: a highlight that
* survives into the Cargo phase is an invitation to click something that is no longer offered.
*
* One crew. Solitaire has one, and with more the answer would depend on which is selected — a
* question the page does not yet ask.
*/
function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
if (s.clock.phase !== 'localOps' || s.turn.option !== 'switch') return null;
if (s.turn.movesRemaining < 1) return null;
for (const [id, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
const { to, blocked } = movesFor(s, player, id);
return { from: tray.position.coord, to, blocked };
}
return null;
}
function trainChip(s: GameState, id: string): TrainChip {
const t = s.trays.get(id);
if (!t) return { label: id, consist: [] };
if (!t) return { label: id, consist: [], cars: [], engineAt: 0, facing: 'e' };
/**
* The engine is drawn IN the consist, at the position it occupies.
*
@@ -1016,10 +1339,14 @@ function trainChip(s: GameState, id: string): TrainChip {
*/
const cars = t.consist.map(carLabel);
const at = Math.max(0, Math.min(cars.length, t.engineAt));
cars.splice(at, 0, 'ENG');
const seated = [...cars];
seated.splice(at, 0, 'ENG');
return {
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
consist: cars,
consist: seated,
cars,
engineAt: at,
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
};
}
+260 -13
View File
@@ -33,6 +33,7 @@ import { cuesFor, narrate } from '../sim/narrate.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
import {
cardDescription,
cardName,
describeIntent,
geometryLabel,
@@ -40,11 +41,11 @@ import {
trainName,
variantLabel,
} from '../sim/view.ts';
import { HAND_LIMIT, trainProfile } from '../engine/content.ts';
import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { joins, neighbour, variantsFor } from '../engine/track.ts';
import { areaOf } from '../engine/apply.ts';
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
import { areaOf, trainNeedingCars } from '../engine/apply.ts';
import type { Frame } from '../sim/view.ts';
export const SOLO_CONFIG: GameConfig = {
@@ -90,6 +91,14 @@ export type Game = {
* would be guesswork from a rendered frame.
*/
cues: string[];
/**
* The timetable slot the last batch of events filled, or null.
*
* Playing a train card rolls 1D12 for a Stage and the answer landed nowhere the player could see.
* Carrying the slot lets the timetable flash the one that just changed — the roll becomes
* something you watch land rather than something you are told about afterwards.
*/
scheduled: number | null;
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -124,7 +133,7 @@ export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [] };
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
@@ -180,7 +189,10 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
// "Pee-Dee" — a per-diem train whose consist is one caboose and nothing else — offers a
// single button to add a caboose and no reason why, which reads as a broken game rather than
// as the card doing exactly what it prints.
const headed = prefix === 'newTrain.' ? consistTitle(game, options, actions) ?? title : title;
// The tray the phase is waiting on, so the heading names the train the yard chips will load
// rather than whichever tray happened to come first out of the map.
const filling = prefix === 'newTrain.' ? trainNeedingCars(game.state) : null;
const headed = filling !== null ? (consistTitle(game, filling) ?? title) : title;
groups.push({ kind: prefix, title: headed, actions });
}
}
@@ -208,15 +220,88 @@ export type Placeable = {
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
* piece has more than one legal rotation there, which is why the label carries the rotation too.
*/
spots: { label: string; index: number; coord: { row: number; col: number } }[];
spots: {
label: string;
index: number;
/**
* The square on the board this spot would fill, or **null** when the placement is not on the
* board at all — ABS Signals goes out on the Mainline, and `node` names which card.
*
* Null rather than a stand-in coordinate. A Mainline placement used to travel as
* `{ row: -1, col: node }`, and row −1 is an ordinary district row, so the board lit up a
* district card for a placement that was never going there.
*/
coord: { row: number; col: number } | null;
/** Division node index, for a placement out on the Mainline. */
node?: number;
/**
* The rails this placement would put on the card, as `connectionsFor` codes.
*
* So the UI can DRAW the piece as it would land rather than only describing it. Words alone are
* not enough for a turnout: "allows traffic from the east to travel west or turn to the south"
* is exact and still leaves a player working out which way the leg points on the board. Taken
* from the engine's own connections, so a preview cannot promise a shape the placement will not
* produce.
*/
links: string[];
}[];
};
/**
* A CARD IN HAND, AND WHAT MAY BE DONE WITH IT.
*
* The hand is the action surface. A card used to appear in two panels under two different models:
* as a *subject* under "Play a card from my hand", which then highlighted squares on the board, and
* as one flat button per Department under "Discard a card from my hand". One card, two mental
* models, and the discard block was a cross-product — four cards x three Departments was twelve
* buttons and about 290px of the action list, repeating the same three choices four times.
*
* Both verbs now hang off the card itself, and both use the pattern the board placement already
* had: pick the thing, then pick where it goes. A discard's "where" is a Department pile, which is
* already on screen with its top card and depth — exactly what you need to choose between them.
*/
export type HandAction = {
cardId: string;
name: string;
what: string;
/** Playable with NO placement — an Office upgrade, a train card, a maneuver. One click does it. */
playNow: number | null;
/** Playable onto the board: the `subjectKey` of the matching `placeable` item. */
placeKey: string | null;
/** How many squares it may go on, for the count on the button. */
spots: number;
/** The option index for discarding onto each Department pile, or null where that is illegal. */
discard: (number | null)[];
/**
* Every distinct shape the card could be laid as, for the hover preview.
*
* Derived from the CARD, not from its legal placements: what a piece looks like does not depend
* on whether there is currently a square for it, and a player picking through a hand needs to see
* the shape most when there is nowhere obvious to put it. Reading it off the placements meant a
* curve with no legal square showed no preview at all.
*/
shapes: string[][];
};
/** A car that may be added to the train being made up, keyed to its chip in the Division Yard. */
export type MakeUpAction = { carType: string; loaded: boolean; index: number };
export type Menu = {
options: Intent[];
/** Actions with no further choice to make. */
direct: ActionGroup[];
/** Actions needing a location, grouped under their card or track piece. */
placeable: { title: string; items: Placeable[] }[];
/** One entry per card in hand, with the verbs available to it. */
hand: HandAction[];
/**
* Making up a train: the cars that may be added, and the option to add none.
*
* Ten buttons reading "add loaded hopper", "add empty boxcar" and so on, when the Division Yard
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
* these key each chip to the option that adds it.
*/
makeUp: { trayId: string; title: string; cars: MakeUpAction[]; pass: number | null } | null;
};
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
@@ -241,7 +326,13 @@ export function actionMenu(game: Game): Menu {
spots: [],
};
if (!entry.spots.some((sp) => sp.label === key.spot)) {
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord });
entry.spots.push({
label: key.spot,
index: a.index,
coord: key.coord,
links: key.links,
...(key.node === undefined ? {} : { node: key.node }),
});
}
bucket.set(key.subjectKey, entry);
placeableByTitle.set(g.title, bucket);
@@ -253,15 +344,121 @@ export function actionMenu(game: Game): Menu {
title,
items: [...m.values()],
}));
return { options, direct, placeable };
/**
* THE HAND, AND WHAT EACH CARD CAN DO.
*
* Built from the same `options` everything else reads, so a verb offered here is one `check` has
* already accepted. Cards keep their order in hand rather than being regrouped: the hand is a row
* of objects the player is looking at, not a list to sort.
*/
const handIds = game.state.decks.hands.get(0) ?? [];
const hand: HandAction[] = handIds.map((cardId) => {
const place = placeable.flatMap((g) => g.items).find((it) => it.subjectKey === `card:${cardId}`);
let playNow: number | null = null;
const discard: (number | null)[] = [null, null, null];
options.forEach((i, index) => {
if (i.type === 'card.play' && i.cardId === cardId && i.placement === undefined) playNow = index;
if (i.type === 'card.discard' && i.cardId === cardId) discard[i.toSlot] = index;
});
const kind = game.state.cards.get(cardId)?.kind;
const shapes =
kind?.kind === 'track'
? variantsFor(kind.geometry, kind.hand).map((v) =>
connectionsFor({
geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never).map(([a, b]) => `${a}${b}`),
)
: [];
return {
cardId,
name: cardName(game.state, cardId),
what: cardDescription(game.state, cardId),
playNow,
placeKey: place ? place.subjectKey : null,
spots: place ? place.spots.length : 0,
discard,
shapes: [...new Map(shapes.map((l) => [l.join('|'), l])).values()],
};
});
/**
* MAKING UP A TRAIN — ONE TRAIN.
*
* `newTrain.placeCar` carries the car, and the Division Yard chip showing that car is where the
* click belongs. But two trains can be built in the same Stage — a timetabled train and a Second
* Section, or an Extra — and this collected every option from every tray into one panel titled
* with whichever tray came first. Reproduced at seed 99, Day 3 Stage 12: eighteen car chips under
* "Making up Train 8", covering two different trains.
*
* Worse than a wrong caption: the yard chip binds to the FIRST matching option, so clicking a
* hopper could couple it to the other train entirely.
*
* So the panel is scoped to ONE tray — the one the engine is actually waiting on
* (`trainNeedingCars`, the same predicate the New Train Phase stops for). The second train comes
* up as soon as the first is done, which is how the phase runs anyway.
*/
const filling =
trainNeedingCars(game.state) ??
options.find((i): i is Extract<Intent, { type: 'newTrain.placeCar' | 'newTrain.passCar' }> =>
i.type === 'newTrain.placeCar' || i.type === 'newTrain.passCar',
)?.trayId ??
null;
const makeUpCars: MakeUpAction[] = [];
let pass: number | null = null;
options.forEach((i, index) => {
if (i.type === 'newTrain.placeCar' && i.trayId === filling) {
makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
}
if (i.type === 'newTrain.passCar' && i.trayId === filling) pass = index;
});
const makeUp =
filling !== null && (makeUpCars.length > 0 || pass !== null)
? {
trayId: filling,
title: consistTitle(game, filling) ?? 'Making up the train',
cars: makeUpCars,
pass,
}
: null;
return { options, direct, placeable, hand, makeUp };
}
/** Split an intent into "what" and "where", or null if it needs no location. */
function subjectOf(
game: Game,
i: Intent,
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
): {
subjectKey: string;
subject: string;
spot: string;
coord: { row: number; col: number } | null;
node?: number;
links: string[];
} | null {
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
/**
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
* row −1 is an ordinary district row: an enhancement laid on a real card one row below the
* Running Track was described as being "out on the Mainline", and ABS Signals itself lit up
* whichever district card sat at that column.
*/
if (i.type === 'card.play' && i.node !== undefined) {
const node = game.state.division.nodes[i.node];
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `Mainline card ${i.node}`;
return {
subjectKey: `card:${i.cardId}`,
subject: cardName(game.state, i.cardId),
spot: `on the ${where}, out on the Mainline`,
coord: null,
node: i.node,
links: [],
};
}
if (i.type === 'card.play' && i.placement) {
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
// the decision, so they ride on the spot rather than being left for the player to work out.
@@ -276,11 +473,35 @@ function subjectOf(
subject: cardName(game.state, i.cardId),
spot: `${at(i.placement)}${track}`,
coord: i.placement,
links: placementLinks(game, i.cardId, i.variant),
};
}
return null;
}
/**
* The rails a card would lay, in `connectionsFor` codes — the same source the board draws from.
*
* Built by asking the engine what the card becomes, not by re-deriving it: a preview that worked out
* the shape for itself would eventually show a different piece from the one the placement lays, which
* is precisely the confusion it exists to remove.
*/
function placementLinks(game: Game, cardId: string, variant: number | undefined): string[] {
const kind = game.state.cards.get(cardId)?.kind;
if (!kind) return [];
if (kind.kind === 'track') {
const v = variantsFor(kind.geometry, kind.hand)[variant ?? 0];
if (!v) return [];
return connectionsFor({
geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never).map(([a, b]) => `${a}${b}`);
}
// Everything else that goes on the grid is a plain through track, or is not track at all.
if (kind.kind === 'freightFacility' || kind.kind === 'office') return ['ew'];
return [];
}
/**
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
@@ -341,10 +562,8 @@ function joinsNote(
* the consist is the reason a button is missing. Naming it turns an unexplained restriction into a
* card the player can read.
*/
function consistTitle(game: Game, options: Intent[], actions: { index: number }[]): string | null {
const first = actions.map((a) => options[a.index]).find((i) => i && 'trayId' in i);
if (!first || !('trayId' in first)) return null;
const tray = game.state.trays.get(first.trayId);
function consistTitle(game: Game, trayId: string): string | null {
const tray = game.state.trays.get(trayId);
if (!tray) return null;
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
if (!p) return null;
@@ -431,6 +650,9 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
}
game.cues.push(...cuesFor(events));
// Which timetable slot the die just filled, so the panel can flash it. Last one wins: a batch can
// schedule more than one train, and the most recent is the one the eye should be sent to.
for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot;
// Keep the log bounded; the full history lives in `history` and can be replayed.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
}
@@ -445,6 +667,31 @@ export function toSave(game: Game): Save {
return { seed: game.seed, history: game.history };
}
/**
* TAKE THE LAST ACTION BACK.
*
* The save IS the game — a seed and the intents submitted — so undo is "replay everything except the
* last one". That is why this is a few lines rather than a feature: there is no undo stack to keep,
* no inverse of each action to write, and no way for it to produce a position the rules could not
* have reached, because the position is reached by the rules.
*
* WHAT IT COSTS. Replaying is O(history), which at a few hundred intents is imperceptible, and the
* whole log is rebuilt with it — so the history panel matches the board afterwards rather than still
* describing the move that was taken back.
*
* WHAT IT ALLOWS, deliberately: the RNG advances with the replay, so playing the same train card
* again rolls the same Stage — you cannot undo your way to a better die. You CAN see the roll and
* then spend the turn differently, which is an ordinary solitaire take-back and is the reason this
* is solitaire-only. `TODO.md` carries the open question of whether a Stage boundary should become a
* commit point.
*
* Returns null when there is nothing to undo, so the caller can leave the button disabled.
*/
export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null {
if (game.history.length === 0) return null;
return fromSave({ seed: game.seed, history: game.history.slice(0, -1) }, config);
}
/**
* Rebuild a game from a save.
*
+404 -193
View File
@@ -6,17 +6,21 @@
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts';
import type { Game } from './game.ts';
import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts';
import { cardDescription } from '../sim/view.ts';
import {
actionMenu,
handPlayable,
currentActor,
fromSave,
newGame,
submit,
toSave,
undo,
view,
} from './game.ts';
@@ -25,6 +29,15 @@ const SAVE_KEY = 'station-master.save.v1';
let game: Game;
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/**
* WHAT the picked card is being used for.
*
* The hand is the action surface, and a card has two verbs: play it onto the board, or discard it
* onto a Department. Both then ask "where?", and the answer is highlighted in place — squares on the
* board for a play, the three Department piles for a discard. Without this the two flows would need
* two selections, which is the thing being removed.
*/
let mode: 'play' | 'discard' | null = null;
/** A square picked on the board, waiting for a rotation. */
let pendingAt: string | null = null;
/**
@@ -48,6 +61,14 @@ let districtMode: 'auto' | 'open' | 'closed' = 'auto';
* before any audio may start.
*/
let soundOn = false;
/**
* The phase the page last drew, so a change of phase can be announced.
*
* Local Operations ends the moment the last Move is spent, and New Train and Mainline then run
* themselves — so a player looking at the board finds themselves in Cargo with no idea their turn
* ended or what happened in between. Reported exactly that way.
*/
let lastPhase: string | null = null;
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
const $ = (id: string): HTMLElement => {
@@ -59,76 +80,37 @@ const $ = (id: string): HTMLElement => {
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/**
* A DRAWING OF THE PIECE A PLACEMENT WOULD LAY.
*
* "Allows traffic from the east to travel west or turn to the south" is exact and still leaves a
* player working out which way the leg will point once the card is down. Rendered by `officeSvg` —
* the board's own renderer, on a one-card board — so the preview and the board cannot disagree about
* what the piece looks like. The coordinate stamp and the RUNNING TRACK caption are stripped: they
* belong to a position, and this is a card on its own.
*/
function piecePreview(links: string[], label: string): string {
const cell = {
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [],
tray: null, cars: [], facility: null, links, what: '',
};
return officeSvg([cell] as never, 99).replace(
/<text class="bs-(coord|rowlab)"[^>]*>[^<]*<\/text>/g,
'',
);
}
// ---------------------------------------------------------------------------
/**
* THE TURN CHART — the five phases of a Stage, in order, with the current one lit.
*
* The title bar used to say `phase: Mainline`, which names the moment and nothing around it: a
* player could not see what had already happened this Stage or what was coming. The printed game
* has a turn chart for exactly this reason.
*
* Each phase carries a tooltip describing what actually happens in it — the first three are the
* rules text; Cargo and Supervisor Shift are written from what the engine does, since the recovered
* sheet does not spell them out.
* Built by `turnChartHtml`, shared with both replay viewers, so all three screens report where you
* are in the Day the same way and with the same violet highlight. It used to live here alone.
*/
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
{
key: 'localOps',
label: 'Local Operations',
tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.',
// a turnout: the choice of three roads
icon: '<path class="ic" d="M1 11h6l6-6h4M7 11h10"/>',
},
{
key: 'newTrain',
label: 'New Train',
tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.',
// a locomotive being made up
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
},
{
key: 'mainline',
label: 'Mainline',
tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.',
// open road
icon: '<path class="ic" d="M2 7h16M2 13h16"/><path class="ic" d="M6 7v6M12 7v6"/>',
},
{
key: 'loadUnload',
label: 'Cargo',
tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.',
// a loaded box on a flat
icon: '<rect class="ic" x="3" y="7" width="8" height="6"/><path class="ic" d="M3 10h8M7 7v6"/><path class="ic" d="M13 13h4"/>',
},
{
key: 'shiftChange',
label: 'Supervisor Shift',
tip: 'End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent\'s hat passes to the next player.',
// a pocket watch
icon: '<circle class="ic" cx="10" cy="11" r="6"/><path class="ic" d="M10 8v3l2 2M8 2h4"/>',
},
];
function renderTurnChart(f: ReturnType<typeof view>): void {
$('tc-day').textContent = `Day ${f.day}`;
$('tc-stage').textContent = `Stage ${f.stage} of 12`;
$('tc-clock').textContent = f.clock;
$('tc-phase').textContent = f.phase;
// WHO THE TABLE IS WAITING ON. Solitaire has one seat, but the question is the same one a
// multiplayer table asks constantly, and an automatic phase is waiting on nobody.
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
$('tc-who').textContent = actorName ?? 'nobody — the Division is running itself';
const at = PHASES.findIndex((p) => p.key === f.phaseKey);
$('tc-phases').innerHTML = PHASES.map((p, i) => {
const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : '';
return (
`<li class="tc-phase ${state}" data-tip="${esc(p.label)} — ${esc(p.tip)}" tabindex="0">` +
`<svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">${p.icon}</svg>` +
`<span>${esc(p.label)}</span></li>`
);
}).join('');
$('turnchart').innerHTML = turnChartHtml(f, actorName);
}
function start(): void {
@@ -152,9 +134,18 @@ function render(): void {
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected);
// Squares light up only while a card is picked FOR PLAY. `selected` names the card; the placeable
// entry that carries its squares is keyed the same way the menu keys it.
const forPlay =
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
const chosen = forPlay
? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey)
: undefined;
// A spot with no coordinate is not on this board — ABS Signals goes out on the Mainline — so it
// is chosen from the action list and lights nothing in the district.
const spotsAt = new Map<string, { label: string; index: number }[]>();
for (const sp of chosen?.spots ?? []) {
if (sp.coord === null) continue;
const key = `${sp.coord.row},${sp.coord.col}`;
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
}
@@ -181,7 +172,27 @@ function render(): void {
const [gr, gc] = k.split(',').map(Number);
return { row: gr!, col: gc! };
});
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords);
/**
* WHAT PLAYING ON AN OCCUPIED SQUARE WOULD DO.
*
* Three different acts light up the same blue: building on empty ground, EXTENDING the Running
* Track at a Limits sign (the sign moves outward with the card — the only way a district grows
* along the main), and ATTACHING an Enhancement to a card already down. The first says "place
* here" on a ghost card; the other two said nothing at all, which is why the Depot lighting up
* read as a bug rather than as an offer.
*/
const legalCaps = [...spotsAt.keys()].flatMap((k) => {
const cell = f.cells.find((c) => `${c.row},${c.col}` === k);
if (!cell) return [];
const label =
cell.kind === 'limits'
? 'EXTEND THE RUNNING TRACK HERE'
: cell.kind === 'office'
? 'ATTACH TO YOUR OFFICE'
: 'ATTACH TO THIS CARD';
return [{ row: cell.row, col: cell.col, label }];
});
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps);
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
for (const [key, list] of spotsAt) {
@@ -199,6 +210,37 @@ function render(): void {
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
}
/**
* THE SWITCHING MOVE, ON THE BOARD.
*
* Every switching decision is about geography — which card the crew can reach, what it will couple
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
* months; the play page simply never used them.
*
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
*/
if (f.moves && !forPlay) {
const here = grid.querySelector(`g[data-cell="${f.moves.from.row},${f.moves.from.col}"]`);
if (here) here.classList.add('bs-from');
for (const c of f.moves.to) {
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
if (g) g.classList.add('bs-focus');
}
for (const b of f.moves.blocked) {
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
if (!g) continue;
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
// your way — you run through it — so it must not be drawn like an industry that is locked.
const passable = b.kind === 'noStopping';
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
const own = g.getAttribute('data-tip') ?? '';
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
}
}
function pick(key: string, list: { label: string; index: number }[]): void {
if (list.length === 1) {
const intent = menu.options[list[0]!.index];
@@ -212,47 +254,52 @@ function render(): void {
}
// -- facilities
$('facs').innerHTML =
f.facilities.length === 0
? '<div class="dim">no facilities yet</div>'
: f.facilities
.map(
(x) =>
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)} · laborers ${esc(x.laborers)} · porters ${esc(x.porters)}" tabindex="0">` +
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
`<div class="boxes"><span class="dim">green</span>${boxes(x.green, x.greenCap)}</div>` +
`<div class="boxes"><span class="dim">MEN AT WORK</span>` +
x.maw
.map(
(m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`,
)
.join('') +
`</div>` +
`<div class="boxes"><span class="dim">red</span>${boxes(x.red, x.redCap)}</div>` +
`<div class="boxes"><span class="dim">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
x.jammed
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
: x.canFinish
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
}">` +
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
`</div></div>`,
)
.join('');
$('facs').innerHTML = facilitiesHtml(f);
// 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.
// The crosshatch says "not yet playable"; it used to be a `not yet` caption, which had to be read
// rather than seen. The tooltip still explains WHY, which a hatch cannot.
const cardRow = (name: string, why: string, playable: boolean | null): string =>
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b></div>`;
const canPlay = handPlayable(game);
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
/**
* THE HAND, AS THE ACTION SURFACE.
*
* Each card carries its own verbs. This used to be a read-only row, with the same cards appearing
* again in the action list — once as subjects to play and once as one button per Department to
* discard, which for a four-card hand was twelve buttons repeating three choices four times.
*/
$('hand').innerHTML = menu.hand.length
? menu.hand
.map((h) => {
const canPlace = h.placeKey !== null && h.spots > 0;
const canPlay = canPlace || h.playNow !== null;
const canDiscard = h.discard.some((d) => d !== null);
const picked = selected === `hand:${h.cardId}`;
const verb = (kind: string, on: boolean, text: string, tip: string): string =>
on
? `<button class="cardact ${kind}${picked && mode === kind ? ' on' : ''}" ` +
`data-card="${esc(h.cardId)}" data-verb="${kind}" data-tip="${esc(tip)}">${text}</button>`
: '';
// A track card shows what it would look like on the board, in every orientation it has —
// which is how a player sees a turnout has two at all, and what a curve actually is.
const fig = h.shapes.length
? ` data-tip-html="${esc(h.shapes.map((l) => piecePreview(l, h.name)).join(''))}"`
: '';
return (
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}"${fig}` +
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
`<b>${esc(h.name)}</b>` +
`<div class="cardacts">` +
verb(
'play',
canPlay,
canPlace ? `play <span class="dim">${h.spots}</span>` : 'play',
canPlace
? `Play it onto the board — ${h.spots} square${h.spots === 1 ? '' : 's'} will light up.`
: 'Play it. This card needs no square on the board.',
) +
verb('discard', canDiscard, 'discard', 'Discard it face up on top of a Department — pick which one.') +
`</div></div>`
);
})
.join('')
: '<span class="dim">empty</span>';
/**
* THE THREE DEPARTMENT PILES, and how deep each one is.
@@ -263,47 +310,95 @@ function render(): void {
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself.
*/
const pileCard = (label: string, top: string, depth: number, why: string, tipExtra = ''): string => {
const tip = [why, tipExtra].filter(Boolean).join(' · ');
return (
`<div class="handcard"${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
$('depts').innerHTML =
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pileCard(
`Dept ${i + 1}`,
d,
depth,
f.departmentsWhat[i] ?? '',
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
);
})
.join('') +
// The Salvage Yard beside them, because §6.2 sweeps it back into the deck and it is the only
// visible sign that the reshuffle is coming.
pileCard(
'Salvage',
f.salvage.top,
f.salvage.depth,
'',
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
);
$('depts').innerHTML = pilesHtml(f);
renderYards(f);
$('blocked').innerHTML =
f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
.join('');
// -- the hand's verbs. Picking one selects the card and says what it is for; the "where" is then
// highlighted in place, on the board or on the Department piles.
for (const b of Array.from($('hand').querySelectorAll('button.cardact'))) {
const node = b as HTMLElement;
node.onclick = () => {
const cardId = node.dataset['card']!;
const verb = node.dataset['verb'] as 'play' | 'discard';
const entry = menu.hand.find((h) => h.cardId === cardId);
if (!entry) return;
// A card that needs no square goes straight down; there is nothing to ask.
if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) {
const intent = menu.options[entry.playNow];
if (intent) submit(game, intent);
selected = null;
mode = null;
pendingAt = null;
render();
return;
}
const key = `hand:${cardId}`;
const same = selected === key && mode === verb;
selected = same ? null : key;
mode = same ? null : verb;
pendingAt = null;
render();
};
}
// -- discarding: the three Department piles become the targets. They already show their top card
// and their depth, which is exactly what you choose between.
const discarding = mode === 'discard' && selected !== null
? menu.hand.find((h) => `hand:${h.cardId}` === selected)
: undefined;
if (discarding) {
// Dim the piles that are not targets, so the three that are read as a choice being offered
// rather than as four cards that happen to be there.
$('depts').classList.add('aiming');
for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) {
const node = el as HTMLElement;
const slot = Number(node.dataset['dept']);
const index = discarding.discard[slot];
if (index === null || index === undefined) continue;
node.classList.add('target');
node.onclick = () => {
const intent = menu.options[index];
if (intent) submit(game, intent);
selected = null;
mode = null;
render();
};
}
}
else {
$('depts').classList.remove('aiming');
}
// -- making up a train: the Division Yard chip that shows the car IS the button.
if (menu.makeUp) {
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
const node = el as HTMLElement;
const car = menu.makeUp!.cars.find(
(c) => c.carType === node.dataset['car'] && c.loaded === (node.dataset['loaded'] === '1'),
);
if (!car) continue;
node.classList.add('addable');
node.onclick = () => {
const intent = menu.options[car.index];
if (intent) submit(game, intent);
render();
};
}
}
/**
* THE TIMETABLE, with the slot the die just filled flashed.
*
* `scheduled` is cleared as it is consumed, so the flash marks the moment rather than the state —
* it is gone by the next render, which is what makes it read as "that just happened".
*/
const justSet = game.scheduled;
game.scheduled = null;
$('timetable').innerHTML = timetableHtml(f, justSet);
$('blocked').innerHTML = blockedHtml(f);
// -- log
const log = $('log');
@@ -313,8 +408,26 @@ function render(): void {
.join('');
log.scrollTop = log.scrollHeight;
/**
* SAY WHEN THE PHASE TURNS OVER.
*
* The five phases run themselves once Local Operations ends, so the page can change out from under
* a player between one click and the next. The turn chart already shows WHERE you are; this says
* that it moved, which is the part you miss when you were looking at the board.
*/
if (lastPhase !== null && lastPhase !== f.phaseKey) {
const el = $('phasenote');
el.textContent = `${f.phase} — Day ${f.day}, Stage ${f.stage}`;
el.className = 'shown';
window.setTimeout(() => {
if (el.textContent?.startsWith(f.phase)) el.className = '';
}, 2600);
}
lastPhase = f.phaseKey;
renderDistrict(f);
renderActions(menu);
renderActions(menu, f, justSet);
renderUndo();
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
// not then play a backlog of everything that happened while it was off.
@@ -324,6 +437,40 @@ function render(): void {
save();
}
/**
* UNDO, as far back as you like.
*
* The game is a seed and a list of intents, so stepping back is replaying without the last one —
* see `undo()` in game.ts. The button says how many moves are behind you, because "can I still get
* back?" is the question it exists to answer; it goes quiet when there is nothing to take back.
*
* The whole page is re-rendered from the rebuilt game, including the history panel, so nothing on
* screen is left describing a move that no longer happened. Any card picked up mid-choice is
* dropped: the position it was going to be played into may not exist any more.
*/
function renderUndo(): void {
const btn = document.getElementById('undo') as HTMLButtonElement | null;
if (!btn) return;
const n = game.history.length;
btn.disabled = n === 0;
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
btn.onclick = () => {
const back = undo(game);
if (!back) return;
game = back;
selected = null;
mode = null;
pendingAt = null;
// The rebuilt game replays its own cues from the beginning; none of them are news.
game.cues.length = 0;
game.scheduled = null;
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
// different phase is not that event, so the banner is suppressed rather than fired backwards.
lastPhase = null;
render();
};
}
/**
* The two yards, and how close the Division Yard is to running out.
*
@@ -332,19 +479,8 @@ function render(): void {
* left, and the moment the Division Yard empties a whole pile comes back at once.
*/
function renderYards(f: ReturnType<typeof view>): void {
const row = (rows: { type: string; loaded: number; empty: number }[]): string =>
rows.length === 0
? '<span class="dim">empty</span>'
: rows
.map(
(r) =>
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
`<b>${esc(r.type)}</b> <span class="ld">${r.loaded}</span>/<span class="mt">${r.empty}</span></span>`,
)
.join('');
$('divyard').innerHTML = row(f.yards.division);
$('clsyard').innerHTML = row(f.yards.classification);
$('divyard').innerHTML = yardHtml(f.yards.division);
$('clsyard').innerHTML = yardHtml(f.yards.classification);
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
@@ -384,16 +520,11 @@ function renderDistrict(f: ReturnType<typeof view>): void {
};
}
function boxes(items: string[], cap: number): string {
let out = '';
for (let i = 0; i < Math.max(cap, items.length); i++) {
const v = items[i];
out += `<span class="box ${v ? 'f' : 'empty'}">${v ? esc(v) : '·'}</span>`;
}
return out || '<span class="dim">—</span>';
}
function renderActions(menu: ReturnType<typeof actionMenu>): void {
function renderActions(
menu: ReturnType<typeof actionMenu>,
f: ReturnType<typeof view>,
justSet: number | null,
): void {
const el = $('actions');
if (game.state.status !== 'active') {
@@ -419,27 +550,74 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
const intent = menu.options[index];
if (intent) submit(game, intent);
selected = null;
mode = null;
pendingAt = null;
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.
/**
* 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.
*
* AND IF THE LABEL CARRIES NO EXPLANATION, ask the card. Tooltips used to depend entirely on a
* label happening to contain an em-dash, so an action naming a card could have none at all while
* the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3",
* which had neither a name for the card it meant nor a word about what it would do.
*/
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) : '';
let rest = cut > 0 ? label.slice(cut + 3) : '';
if (!rest) {
const intent = menu.options[index];
const cardId = intent && 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
if (cardId) rest = cardDescription(game.state, cardId);
}
return (
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
};
/**
* MOVES LEFT, WHERE THE MOVES ARE.
*
* §6.1 gives six Moves a turn and every switching decision is really "can I still get back?" — so
* the count belongs beside the buttons. It was reported only in the history panel, which is the
* one place a player is not looking while switching.
*/
/**
* WHAT THE DIE DID, said where the player is looking.
*
* Playing a train card rolls 1D12 for its departure Stage, and the card simply left the hand — the
* answer arrived only in the history panel, one line among many. The timetable now shows it and
* flashes the slot; this says it in words at the same moment.
*/
const scheduledNote =
justSet !== null && f.timetable[justSet] != null
? `<div class="scheduled" data-tip="§7 — the Stage is rolled on 1D12 when the card is played; if that Stage is taken the train works down the column to the next free one. From now on it runs at this time every Day.">` +
`Train ${f.timetable[justSet]} is scheduled to depart at Stage ${justSet + 1} — see the Timetable</div>`
: '';
const movesNote =
f.movesLeft !== null
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="§6.1 — six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
`${f.movesLeft} of ${MOVES_PER_LOCAL_OPS} Moves left</div>`
: '';
// A heading over the buttons, so the panel says what it is before it says what is in it. The
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
// no worker able to act, the only thing offered is "End my Cargo phase".
let html = '<h3 class="actions-hd">Actions</h3>';
let html = `<h3 class="actions-hd">Actions</h3>${scheduledNote}${movesNote}`;
/**
* WHAT IS LEFT IN THE ACTION LIST.
*
* Everything about a card in hand now lives on the card, and everything about making up a train
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
* drawing, switching moves, the Freight Agent, and finishing.
*/
html += menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
@@ -455,26 +633,67 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
)
.join('');
// Subject first, location second. Picking a card then a square is how the choice is actually made;
// one flat list of every card-square-rotation combination was unreadable.
for (const group of menu.placeable) {
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
for (const item of group.items) {
const open = selected === item.subjectKey;
/**
* MAKING UP A TRAIN.
*
* The cars are added from the Division Yard chips, but the panel still has to exist: it names the
* train and what its card calls for, and it carries the "no more cars" button, which is the ONLY
* way to finish when the yard holds nothing the train may take.
*
* Moving the cars onto the yard chips without this left a train being made up with no control on
* screen at all whenever no chip was addable — a hard softlock, reported at Stage 10 of seed
* 775569289 with Train 10 waiting at the West Division Point.
*/
if (menu.makeUp) {
const addable = menu.makeUp.cars.length;
html +=
`<button class="subj ${open ? 'open' : ''}" data-subj="${esc(item.subjectKey)}">` +
`${esc(item.subject)} <span class="dim">${item.spots.length} spot${item.spots.length === 1 ? '' : 's'}</span>` +
`</button>`;
if (open) {
`<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` +
`<div class="dim makeup-note">` +
(addable > 0
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.`
: menu.makeUp.pass !== null
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
: 'Nothing in the Division Yard may join this train, and §7 does not allow passing while the yard holds cars.') +
`</div>` +
(menu.makeUp.pass !== null
? `<button class="act" data-i="${menu.makeUp.pass}" data-tip="§8.2 — a train may depart with FEWER cars than its card lists, but never with the wrong ones. This sends it out as it stands.">no more cars — send it out</button>`
: '') +
`</div>`;
}
/**
* The card is picked in the hand and the square on the board, so all that is left here is the
* ROTATION — the one question neither of those can ask, and only for the card actually picked.
*/
const picked =
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
for (const group of menu.placeable) {
const relevant = group.items.filter((it) => picked && it.subjectKey === picked.placeKey);
if (relevant.length === 0) continue;
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
for (const item of relevant) {
{
// Once a square is picked, show only that square's rotations — the rest is noise.
const shown = pendingAt
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
? item.spots.filter((sp) => sp.coord !== null && `${sp.coord.row},${sp.coord.col}` === pendingAt)
: item.spots;
html +=
`<div class="spots"><div class="dim">` +
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
`</div>` +
shown.map((sp) => `<button class="act" data-i="${sp.index}">${esc(sp.label)}</button>`).join('') +
shown
.map((sp) => {
// The picture rides on the button that would lay it, so hovering a rotation shows
// that rotation — which is the question "which of these two do I want?" answered.
const fig = sp.links.length
? ` data-tip-html="${esc(piecePreview(sp.links, item.subject))}"`
: '';
return (
`<button class="act" data-i="${sp.index}"${fig}` +
` data-tip="${esc(`${item.subject} — ${sp.label}`)}">${esc(sp.label)}</button>`
);
})
.join('') +
`</div>`;
}
}
@@ -497,15 +716,6 @@ function renderActions(menu: ReturnType<typeof actionMenu>): void {
const node = b as HTMLElement;
node.onclick = () => apply(Number(node.dataset['i']));
}
for (const b of Array.from(el.querySelectorAll('button.subj'))) {
const node = b as HTMLElement;
node.onclick = () => {
const key = node.dataset['subj'] ?? null;
selected = selected === key ? null : key;
pendingAt = null;
render();
};
}
}
// ---------------------------------------------------------------------------
@@ -559,7 +769,7 @@ function clearSave(): void {
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
// background, so the cards are drawn correctly and are simply invisible.
const pageStyle = document.createElement('style');
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS;
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
document.head.appendChild(pageStyle);
installTooltips();
@@ -571,8 +781,9 @@ if (saveBtn) saveBtn.onclick = downloadSave;
*
* The only way out used to be finishing the game — `start()` restores from localStorage on every
* load, so a game you no longer wanted followed you across reloads, and the "new game" button
* appeared solely on the game-over screen. Confirmed because the save is the game: there is no undo,
* and the replay download is right beside it.
* appeared solely on the game-over screen. Confirmed because it throws the whole game away — Undo
* steps back one action at a time, but nothing brings back a game that has been dealt over, and the
* replay download is right beside it.
*
* `location.search = ''` rather than a direct re-render, so a `?seed=` in the URL goes too — leaving
* it would deal the same game again and look like the button had done nothing.
+328
View File
@@ -0,0 +1,328 @@
/**
* The side panels a game is read from — cards, yards, blockers, facilities.
*
* SHARED between the playable page and the site's replay viewer, for the same reason the board
* renderers are: a replay is the game being WATCHED rather than played, so it should look like the
* game. The viewer had three panels against the play page's eight — no facilities, no blockers, no
* cards, no yards — which meant a replay could not answer "why is nothing moving?", the question a
* replay mostly exists to answer.
*
* Each function returns HTML rather than writing to the DOM, so the two pages can keep their own
* element ids and their own layout while drawing the same contents.
*/
import type { Frame } from '../sim/view.ts';
const esc = (s: string): string =>
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/**
* A card, drawn as a card.
*
* `playable === false` crosshatches it: "not yet playable" had to be read, where a hatch is seen.
* The tooltip still explains WHY, which a hatch cannot. A replay passes null throughout — nothing
* there is playable, because nothing there is being played.
*/
export function cardRow(name: string, why: string, playable: boolean | null): string {
return (
`<div class="handcard${playable === false ? ' unplayable' : ''}"${why ? ` data-tip="${esc(why)}"` : ''} tabindex="0">` +
`<b>${esc(name)}</b></div>`
);
}
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
return f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
: '<span class="dim">empty</span>';
}
/**
* The three Department piles and the Salvage Yard, each with its top card and its depth.
*
* Only the top card may ever be drawn, so the depth is a count and not a hint: everything below it
* is out of reach, and choosing where to discard is choosing what to put there.
*/
export function pilesHtml(f: Frame): string {
const pile = (label: string, top: string, depth: number, why: string, extra = '', slot = -1): string => {
const tip = [why, extra].filter(Boolean).join(' · ');
// A Department is a DROP TARGET for a discard. The attribute is always emitted; only the play
// page binds a click to it, and only while a card is waiting to be discarded — so the replay
// viewer draws exactly the same markup and nothing there is clickable.
const target = slot >= 0 ? ` data-dept="${slot}"` : '';
return (
`<div class="handcard"${target}${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
return (
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pile(
`Dept ${i + 1}`,
d,
depth,
f.departmentsWhat[i] ?? '',
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
i,
);
})
.join('') +
pile(
'Salvage',
f.salvage.top,
f.salvage.depth,
'',
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
)
);
}
/** One yard, by car type. Loaded and empty are separate numbers because they do different jobs. */
export function yardHtml(rows: { type: string; loaded: number; empty: number }[]): string {
return rows.length === 0
? '<span class="dim">empty</span>'
: rows
.map(
(r) =>
`<span class="stock" data-tip="${esc(r.type)} — ${r.loaded} loaded, ${r.empty} empty">` +
`<b>${esc(r.type)}</b> ` +
// Each count is its own target: making up a train is choosing a car OF A TYPE AND A LOAD
// STATE, which is exactly what these two numbers already distinguish.
`<span class="ld" data-car="${esc(r.type)}" data-loaded="1">${r.loaded}</span>/` +
`<span class="mt" data-car="${esc(r.type)}" data-loaded="0">${r.empty}</span></span>`,
)
.join('');
}
/**
* THE TIMETABLE: twelve Stages of a Day, and which train is due out at each.
*
* Playing a train card rolls 1D12 for a slot, and the roll landed nowhere a player could see — the
* card was gone and the answer to "so when does it run?" was one line of history. The Frame has
* carried the twelve slots all along and only the standalone replay ever drew them.
*
* `justSet` is the slot filled by the roll being reported this frame, so it can be flashed. It marks
* the moment rather than the state: the highlight is gone by the next render, which is what makes it
* read as "that just happened" rather than "this is special".
*/
export function timetableHtml(f: Frame, justSet: number | null): string {
const slots = f.timetable
.map((t, k) => {
const stage = k + 1;
const cls = [
t !== null ? 'due' : '',
stage === f.stage ? 'now' : '',
stage < f.stage ? 'past' : '',
k === justSet ? 'fresh' : '',
]
.filter(Boolean)
.join(' ');
const tip =
t !== null
? `Train ${t} departs at Stage ${stage}. A train's NUMBER is its seniority and direction — odd runs west, even runs east — and the Stage it leaves is set by the 1D12 roll made when its card was played, so Train 8 at Stage 6 is normal.`
: `Stage ${stage} — no train due out.`;
return (
`<div class="tt-slot ${cls}" data-tip="${esc(tip)}" tabindex="0">` +
`<span class="tt-s">${stage}</span>` +
`<span class="tt-t">${t !== null ? `T${t}` : '·'}</span></div>`
);
})
.join('');
return `<div class="tt">${slots}</div>`;
}
export function blockedHtml(f: Frame): string {
return f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
: f.blocked
.map((b) => `<li class="sev-${b.severity}"><b>${esc(b.where)}</b> — ${esc(b.why)}</li>`)
.join('');
}
/** The load pipeline at each industry: green → MEN | AT | WORK → red, and the siding. */
export function facilitiesHtml(f: Frame): string {
/**
* One number, with what the card printed and what a Modifier added.
*
* `laborers` arrives as "left/total" and the rest as plain totals, so the total is taken from the
* end either way and compared with the base.
*/
const stat = (label: string, shown: string, base: number, why: string): string => {
const total = Number(shown.split('/').pop() ?? shown);
const added = Number.isFinite(total) ? total - base : 0;
const delta = added > 0 ? `<span class="added">+${added}</span>` : '';
return (
`<span class="cap" data-tip="${esc(why)}${added > 0 ? ` The card prints ${base}; Modifiers beside it add ${added}.` : ''}">` +
`<span class="dim">${esc(label)}</span> <b>${esc(shown)}</b>${delta}</span>`
);
};
const boxes = (filled: string[], cap: number): string => {
let h = '';
for (let i = 0; i < Math.max(cap, filled.length); i++) {
h += `<span class="box ${i < filled.length ? 'f' : 'empty'}">${i < filled.length ? esc(filled[i] ?? '') : '·'}</span>`;
}
return h || '<span class="dim">—</span>';
};
return f.facilities.length === 0
? '<div class="dim">no facilities yet</div>'
: f.facilities
.map(
(x) =>
`<div class="fac" data-tip="${esc(x.name)} — ${esc(x.flow)} ${esc(x.commodity)}" tabindex="0">` +
`<div class="nm">${esc(x.name)} <span class="dim">${esc(x.commodity)}</span></div>` +
/**
* THE WORKERS AND THE CAPACITIES, ON THE PANEL RATHER THAN IN A TOOLTIP.
*
* Laborers were only ever in the hover text, so "how many actions can I take here this
* Stage?" — the question that decides every Cargo phase — had to be hunted for. And a
* Modifier's whole effect is one of these numbers going up, so showing the number alone
* made an Ice House look like it had done nothing. Each now says what the card prints
* and what the Modifiers beside it added.
*/
`<div class="caps">` +
/**
* PORTERS TOO, NOT JUST LABORERS.
*
* `porters` has been on the view-model all along and no renderer ever printed it — so a
* Waiting Area, Restaurant or Hotel beside the Office, whose whole effect is +1 porter
* and +1 passenger slot, showed exactly half of what it did. Reported: "it gave me an
* extra porter that I see no indication of anywhere."
*
* A facility has one kind of worker or the other: Laborers work freight, Porters work
* passengers. Showing the row that is always 0/0 would be noise, so each shows its own.
*/
(Number(x.laborers.split('/').pop()) > 0 || x.base.laborers > 0
? stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square. Left of the slash is how many are still free this Stage.')
: '') +
(Number(x.porters.split('/').pop()) > 0 || x.base.porters > 0
? stat('porters', x.porters, x.base.porters, 'Passenger work, one action each per Stage: a Porter boards or detrains passengers, and earns the Revenue in a single action. Left of the slash is how many are still free this Stage.')
: '') +
(x.allowsOut ? stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') : '') +
(x.allowsIn ? stat('in', String(x.redCap), x.base.in, 'Red boxes: loads cleared off an arriving car.') : '') +
(x.modifiers.length
? `<span class="mods" data-tip="Modifier cards standing beside this industry, each raising one of the numbers above.">+ ${esc(x.modifiers.join(', '))}</span>`
: '') +
`</div>` +
/**
* IN THE ORDER THE LOAD TRAVELS. §9.3 loads Green → MEN → AT → WORK → the spotted car,
* and unloads the other way: car → WORK → AT → MEN → red. Labelling the rows by colour
* alone left a player to work out which direction their industry ran; naming the step
* says it outright, and an industry that only receives has no green row to puzzle over.
*/
(x.allowsOut
? `<div class="boxes"><span class="dim" data-tip="Loads waiting to be worked out onto a car. They travel green → MEN → AT → WORK, then onto a spotted empty car.">waiting to load</span>${boxes(x.green, x.greenCap)}</div>`
: '') +
`<div class="boxes"><span class="dim" data-tip="One physical sign, worked in whichever direction this industry runs. Only one load may sit on each of the three boxes.">MEN AT WORK</span>` +
x.maw.map((m) => `<span class="box ${m ? 'm' : 'empty'}">${m ? esc(m) : '·'}</span>`).join('') +
`</div>` +
(x.allowsIn
? `<div class="boxes"><span class="dim" data-tip="Loads that have come off an arriving car and been cleared. They travel car → WORK → AT → MEN, then into a red box — the opposite direction to loading.">cleared inbound</span>${boxes(x.red, x.redCap)}</div>`
: '') +
`<div class="boxes"><span class="dim" data-tip="Cars spotted on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one.">siding</span>${boxes(x.track, x.trackCap)}</div>` +
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
x.jammed
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
: x.canFinish
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
}">` +
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
`</div></div>`,
)
.join('');
}
/** Styling for all of the above, so the two pages cannot drift apart visually. */
export const PANEL_CSS = `
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;margin:9px 0 4px}
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
.handcard{background:#242c36;border:1px solid #2c333d;border-radius:5px;
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
.handcard.unplayable{color:#7d8794;border-color:#39424e}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
/* A pile shows two things: which card is face up on top, and how many are under it. */
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3}
.pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px;
padding:0 6px;color:#cfe0f5}
/* Rolling stock is finite and the Classification Yard only returns it when the Division Yard is
bare, so watching the Division Yard run down is real information. */
.yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px}
.stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px}
.stock b{color:#e6e9ee}
.stock .ld{color:#8fd6a0}
.stock .mt{color:#9fb6d8}
.stock.none{opacity:.4}
/* A PILE THAT IS WAITING TO BE PICKED, drawn like the board's ghost squares because it is the same
question: you have chosen a card, now choose where it goes. It was a 2px outline on a small tile
in a panel the player might not be looking at, and was reported as "a very tiny highlight". Now it
grows, brightens, says DISCARD HERE outright, and pulses once so the eye finds it. */
.handcard.target{border:2px dashed #5aa9e6;background:#233246;cursor:pointer;color:#e6e9ee;
box-shadow:0 0 0 4px rgba(90,169,230,.22);transform:translateY(-2px);
animation:pick 1.1s ease-out 2}
.handcard.target:hover{background:#31485f;border-style:solid;box-shadow:0 0 0 6px rgba(90,169,230,.30)}
.handcard.target::before{content:"DISCARD HERE";display:block;font-size:9px;letter-spacing:.09em;
color:#8fc4ee;font-weight:700;margin-bottom:2px}
@keyframes pick{0%{box-shadow:0 0 0 4px rgba(90,169,230,.22)}
50%{box-shadow:0 0 0 9px rgba(90,169,230,.10)}
100%{box-shadow:0 0 0 4px rgba(90,169,230,.22)}}
@media(prefers-reduced-motion:reduce){.handcard.target{animation:none}}
/* And the piles that are NOT targets step back while a discard is being aimed, so the three that
are stand out from the Salvage Yard beside them. */
.cardrow.aiming .handcard:not(.target){opacity:.4}
.stock .ld.addable,.stock .mt.addable{cursor:pointer;border-radius:3px;padding:0 4px;
outline:1px solid #5aa9e6;background:rgba(90,169,230,.16)}
.stock .ld.addable:hover,.stock .mt.addable:hover{background:rgba(90,169,230,.34)}
/* Twelve Stages across, so a Day is one glance. The current Stage is lit, Stages already gone are
dimmed, and a slot the die has just filled flashes once. */
.tt{display:flex;gap:3px;flex-wrap:wrap}
.tt-slot{flex:1 1 0;min-width:26px;text-align:center;border:1px solid #2c333d;border-radius:4px;
padding:2px 0;background:#1a1f26;font-size:10px;cursor:help;line-height:1.25}
.tt-slot .tt-s{display:block;color:#5f6b7a;font-size:9px}
.tt-slot .tt-t{display:block;color:#5f6b7a;font-variant-numeric:tabular-nums}
.tt-slot.due{background:#222c38;border-color:#4d6fa8}
.tt-slot.due .tt-t{color:#cfe0f5;font-weight:700}
.tt-slot.past{opacity:.45}
.tt-slot.now{border-color:#b98cf0;box-shadow:0 0 0 2px rgba(150,110,230,.20)}
.tt-slot.now .tt-s{color:#b98cf0;font-weight:700}
.tt-slot.fresh{animation:ttflash 1.5s ease-out 1}
@keyframes ttflash{0%{background:#2f6b47;border-color:#8fd6a0;transform:scale(1.18)}
100%{background:#222c38;border-color:#4d6fa8;transform:scale(1)}}
@media(prefers-reduced-motion:reduce){.tt-slot.fresh{animation:none;outline:2px solid #8fd6a0}}
/* THE KEY TO THE FOUR STATES ABOVE. A slot can be lit blue, ringed violet, dimmed, or flashing
green, and nothing said which was which — reported as "what do the colours mean?" The green one
in particular is a MOMENT, not a state: it marks the slot the die just filled and is gone by the
next render, so without a key it reads as a category of train. */
.tt-key{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px;font-size:10px;color:#8b94a3}
.tt-key span{display:inline-flex;align-items:center;gap:4px}
.tt-key span::before{content:"";width:9px;height:9px;border-radius:2px;border:1px solid #2c333d;
background:#1a1f26}
.tt-key .k-due::before{background:#222c38;border-color:#4d6fa8}
.tt-key .k-now::before{border-color:#b98cf0;box-shadow:0 0 0 1px rgba(150,110,230,.35)}
.tt-key .k-past::before{opacity:.45}
.tt-key .k-fresh::before{background:#2f6b47;border-color:#8fd6a0}
ul.blocked{margin:0;padding-left:18px}
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
.fac{border-top:1px solid #2c333d;padding:7px 0}
.fac:first-child{border-top:0}
.fac .nm{font-size:12px;font-weight:600}
.caps{display:flex;flex-wrap:wrap;gap:9px;margin-top:3px;font-size:10px;align-items:baseline}
.cap b{color:#e6e9ee;font-variant-numeric:tabular-nums}
.cap .added{color:#8fd6a0;margin-left:2px;font-weight:700}
.caps .mods{color:#c8a04a}
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
.box.empty{background:#242a32;color:#5a6472}
.box.f{background:#2f6b3d}
.box.m{background:#8a6d1f}
.fstat{margin-top:3px;font-size:10px;padding:1px 6px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
.fstat.idle{opacity:.6}
`;
+73 -79
View File
@@ -16,48 +16,27 @@ h2.big{font-size:15px;text-transform:none;letter-spacing:.01em;color:#cfe0f5;
margin:0 0 9px;padding-bottom:5px;border-bottom:1px solid #2c333d}
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
.dim{color:var(--dim)}
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
/* THE TOP TWO ROWS TRAVEL TOGETHER.
The title bar was sticky and the turn chart under it was not, so scrolling down took away the one
thing you most need while reading the board — which Day and Stage it is, which phase is running,
and who it is waiting on. They are one block now, and it sticks as one. */
.topbar{position:sticky;top:0;z-index:5}
header{background:var(--panel);border-bottom:1px solid var(--line);
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
header b{font-size:16px}
header button{background:#2a3038;color:inherit;border:1px solid #2c333d;border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px}
header button:hover{border-color:#4d6fa8}
/* Nothing to take back yet. It keeps its place — a button that disappears reads as a bug — but it
stops inviting the press. */
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
header button:disabled:hover{border-color:#2c333d}
.build{margin-left:auto;font-size:11px;opacity:.7}
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
.home:hover{color:#5aa9e6}
.pace{font-size:12px;padding:1px 7px;border-radius:10px}
.pace.good{background:rgba(40,140,60,.32)}
.pace.behind{background:rgba(190,120,40,.28)}
/* CARDS YOU HOLD vs CARDS YOU READ.
Both are drawn as cards, because they are cards. What separates them is the frame: a card in
Your Move is clickable and carries the action border; a reference card is flat, unlit and
explicitly not a target. Previously the hand was a list of bare names and the Department slots a
second list, so nothing looked like a card at all. */
.cardrow{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 2px}
/* THE YARDS. Rolling stock is finite and the Classification Yard only comes back when the Division
Yard is bare, so a player has to be able to watch the supply run down. */
.yard{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 6px}
.stock{background:#1c2129;border:1px solid #39424e;border-radius:4px;padding:2px 7px;font-size:11px}
.stock b{color:#e6e9ee}
.stock .ld{color:#8fd6a0}
.stock .mt{color:#9fb6d8}
.stock.none{opacity:.4}
.yard.bare{outline:1px dashed #e0a060;outline-offset:3px;border-radius:4px;padding:3px}
.handcard{background:#242c36;border:1px solid var(--line);border-radius:5px;
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
/* A pile shows two things: which card is face up on top, and how many are under it. The depth is a
count and not a hint — only the top card may ever be drawn, so everything below it is out of
reach, and choosing where to discard is choosing what to put there. */
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim)}
.pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px;
padding:0 6px;color:#cfe0f5}
/* NOT YET PLAYABLE, drawn rather than captioned. A "not yet" tag had to be read; a crosshatch is
seen. The card stays legible underneath — it is unavailable, not irrelevant. */
.handcard.unplayable{color:#7d8794;border-color:#39424e}
.handcard.unplayable::after{content:"";position:absolute;inset:0;border-radius:5px;pointer-events:none;
background:repeating-linear-gradient(45deg,transparent 0 5px,rgba(150,160,175,.20) 5px 6px)}
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
@media(max-width:1100px){main{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
@@ -90,18 +69,32 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
.crew{font-size:11px;margin-top:3px;color:#8fd6a0}
.cars{font-size:10px;color:var(--dim)}
/* facilities */
.fac{border-top:1px solid var(--line);padding:7px 0}
.fac:first-child{border-top:0}
.boxes{display:flex;gap:3px;align-items:center;margin-top:3px;flex-wrap:wrap}
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
.box.empty{background:#242a32;color:#5a6472}
.box.f{background:#2f6b3d}
.box.m{background:#8a6d1f}
.fstat{margin-top:3px;font-size:10px;padding:1px 6px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
.fstat.idle{opacity:.6}
/* THE HAND IS THE ACTION SURFACE. Each card carries its own verbs, so the twelve-button discard
block and the separate "play a card" subject list are both gone. The buttons are small and quiet
until the card is picked — the card is the thing being read, they are what you do with it. */
.handcard .cardacts{display:flex;gap:4px;margin-top:5px}
button.cardact{margin:0;padding:1px 7px;font-size:10px;background:#2f3a48;border:1px solid #46536440;
border-radius:9px;color:#cfd6e0;text-align:center}
button.cardact:hover{background:#3d4c60;border-color:#5aa9e6}
button.cardact.on{background:#2b3444;border-color:#c8912f;color:#f2e6cf;font-weight:700}
button.cardact.discard{color:#d6b48a}
.handcard.picked{border-color:#c8912f;box-shadow:0 0 0 2px rgba(200,145,47,.18)}
#phasenote{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:40;
background:#241d33;border:1px solid var(--now);color:#e7dcfb;border-radius:14px;padding:5px 16px;
font-size:13px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
box-shadow:0 6px 22px rgba(0,0,0,.5)}
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
/* The die's answer, said where the player is looking. Green like a revenue gain — it is a good
thing that just happened — and it disappears with the next render, as a moment should. */
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
background:rgba(40,140,60,.22);border:1px solid #2f6b47;color:#bfe8cd;font-weight:600}
.makeup-note{font-size:11px;margin:0 0 5px}
/* Moves left, beside the moves. Amber like the actions, because it is a budget being spent by the
buttons underneath it; struck through in red once it is gone. */
.moves{display:inline-block;font-size:11px;margin:0 0 7px;padding:1px 8px;border-radius:10px;
background:rgba(200,145,47,.16);border:1px solid #8a6d1f;color:#f2e6cf}
.moves.spent{background:rgba(190,50,50,.20);border-color:#8a4040;color:#e58080}
/* actions */
#actions{max-height:none}
.grp{margin-bottom:6px}
@@ -122,6 +115,12 @@ button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
#district.folded #grid{display:none}
#district.folded .districtrule{display:none}
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
and never again, so it must not shout. */
.districtrule{font-size:11px;line-height:1.4;color:#8b94a3;margin:0 0 7px;padding:5px 8px;
border-left:2px solid #39424e;background:#171c23;border-radius:0 4px 4px 0}
.districtrule b{color:#cfd6e0}
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
#districtsummary{display:none}
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
@@ -154,39 +153,11 @@ h3.actions-hd{font-size:13px;text-transform:none;letter-spacing:.01em;color:#cfe
ul.blocked{list-style:none;margin:0;padding:0;font-size:12px}
ul.blocked li{padding:2px 0}
.sev-warn{color:#e0b060}.sev-stop{color:#e58080}
/* turn chart */
#turnchart{display:flex;align-items:center;gap:16px;flex-wrap:wrap;
background:#161b22;border-bottom:1px solid var(--line);padding:7px 14px}
.tc-when{display:flex;align-items:baseline;gap:10px}
.tc-when b{font-size:15px;color:#cfe0f5}
.tc-now{font-size:12px;color:var(--dim)}
.tc-now b{color:var(--now);font-size:13px}
/* WHO EVERYONE IS WAITING ON. Solitaire has one player, but the seat still has a name — the log
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
glances at most often, so it gets its own chip rather than hiding in the phase text. */
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
.tc-who b{color:var(--now);background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
border-radius:11px;padding:1px 9px;font-size:12px}
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:14px;
padding:3px 10px 3px 7px;font-size:11px;color:var(--dim);background:#1a1f26;cursor:help}
.tc-phase svg{display:block;flex:0 0 auto}
.tc-phase .ic{stroke:#5f6b7a;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.tc-phase .icf{fill:#5f6b7a;stroke:none}
.tc-phase.done{opacity:.55}
/* VIOLET, not the amber used for actions.
The palette already spends amber on "you can click this", blue on "this is a legal square or a
link", green on good and red on bad. The turn chart is none of those — it reports WHERE YOU ARE,
and a player should never read it as something to press. Violet is the one hue not already
carrying a meaning here, so it cannot be confused with any of them. */
.tc-phase.now{border-color:var(--now);background:rgba(150,110,230,.15);color:#e7dcfb;font-weight:700;
box-shadow:0 0 0 2px rgba(150,110,230,.18)}
.tc-phase.now .ic{stroke:var(--now)}
.tc-phase.now .icf{fill:var(--now)}
</style>
</head>
<body>
<div class="topbar">
<header>
<b><a href="./index.html" class="home">Station Master</a></b>
<span>Revenue <b id="revenue">0</b></span>
@@ -194,20 +165,22 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<span class="dim">seed <span id="seed">—</span></span>
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Forget the game saved in this browser and deal a fresh one. There is no undo — download the replay first if you want to keep it.">New game</button>
<button id="newgame" title="Forget the game saved in this browser and deal a fresh one. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
<!-- The turn chart: where in the Day we are, and which of the five phases is running. Lifted out
of the title bar, where "phase: Mainline" said nothing about what came before or after it. -->
<div id="turnchart">
<div class="tc-when"><b id="tc-day">Day —</b><span id="tc-stage">Stage —</span><span class="dim" id="tc-clock">—</span></div>
<div class="tc-now">phase <b id="tc-phase">—</b></div>
<div class="tc-who">waiting on <b id="tc-who">—</b></div>
<ol class="tc-phases" id="tc-phases"></ol>
</div>
<!-- Filled by `turnChartHtml`, shared with both replay viewers so all three screens report where
you are in the Day identically. -->
<div id="turnchart"></div>
</div><!-- /topbar -->
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
what you miss when the automatic phases run between two clicks. -->
<div id="phasenote"></div>
<main>
<div>
@@ -218,6 +191,15 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
</h2>
<div id="districtsummary" class="dim"></div>
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
It is enforced everywhere and was stated nowhere: the Limits signs are the only growth
point on the Running Track and they move outward as you build, and no card anywhere can
be inserted between two cards already down. -->
<div class="districtrule">A district only ever grows <b>outwards</b>. On the Running Track the
Limits signs are the growth point — lay a card on a sign and the sign moves one further out;
nothing may be built beyond it. Everywhere else a card needs an empty square that its rails
reach. <b>No card is ever inserted between two cards already down</b>, so nothing you have
built is pushed along by something laid later.</div>
<div id="grid"></div>
</section>
<section><h2>History</h2><div id="log"></div></section>
@@ -236,6 +218,18 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<div id="clsyard" class="yard"></div>
<div class="dim" id="yardnote"></div>
</section>
<section><h2>Timetable — who is due out, and when</h2><div id="timetable"></div>
<!-- WHAT THE COLOURS MEAN. Asked directly in playtesting — "what do green and purple mean?"
Violet is "where you are" everywhere on this page, and the green is a flash rather than a
state, which is exactly why it needed saying. -->
<div class="tt-key">
<span class="k-due">Train due</span>
<span class="k-now">this Stage</span>
<span class="k-past">already gone</span>
<span class="k-fresh">just rolled</span>
</div>
<div class="dim" style="font-size:11px;margin-top:5px">A train's number is its seniority and direction — odd runs west, even runs east. The Stage it departs is set by a 1D12 roll when its card is played, and it then runs at that time every Day.</div>
</section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Facilities</h2><div id="facs"></div></section>
</div>
+23 -4
View File
@@ -99,23 +99,42 @@ input[type=range]{flex:1;min-width:180px}
<input type="range" id="vscrub" min="0" max="0" value="0">
<span class="dim" id="vpos">0 / 0</span>
</div>
<!-- The same turn chart the live game shows, from the same renderer — where you are in the
Day should look identical whether you are playing or watching. -->
<div id="turnchart"></div>
<div class="vbar">
<span id="vclock">&mdash;</span>
<span>phase: <b id="vphase">&mdash;</b></span>
<span>Revenue <b id="vrev">0</b></span>
<span class="dim">&larr; &rarr; to step, space to play</span>
</div>
</section>
<!-- SAME PANELS, SAME ORDER as the playable page. A replay is the game being watched rather
than played, so the only things it drops are the ones that ask you to act. It used to show
three panels against the play page's eight: no facilities, no blockers, no cards, no yards,
so a replay could not answer "why is nothing moving?" — the question a replay exists for. -->
<div class="vgrid">
<div>
<section><h2>The Division</h2><div class="scroll" id="vdivision"></div></section>
<section><h2>The Division — west to east</h2><div class="scroll" id="vdivision"></div></section>
<section id="vdistrict"><h2>Office Area <button id="vdistricttoggle" class="ghost">auto-hide: on</button></h2>
<div id="vdistrictsummary" class="dim"></div>
<div class="scroll" id="vgrid"></div></section>
</div>
<section><h2>What happened</h2><div id="vlog"></div></section>
</div>
<div>
<section><h2>Cards Available</h2>
<h3>Cards in Hand</h3><div id="vhand" class="cardrow">&mdash;</div>
<h3>Department Decks (top card face up)</h3><div id="vdepts" class="cardrow ref">&mdash;</div>
</section>
<section><h2>The Yards</h2>
<h3>Division Yard <span class="dim" id="vdivtot"></span></h3>
<div id="vdivyard" class="yard"></div>
<h3>Classification Yard <span class="dim" id="vclstot"></span></h3>
<div id="vclsyard" class="yard"></div>
</section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="vblocked"></ul></section>
<section><h2>Facilities</h2><div id="vfacs"></div></section>
</div>
</div>
</div>
</main>
+33 -3
View File
@@ -12,6 +12,8 @@
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, handHtml, pilesHtml, yardHtml } from './panels.ts';
import { cuesFor } from '../sim/narrate.ts';
import { playCue } from './sound.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
@@ -109,8 +111,13 @@ function show(i: number): void {
// whistles, and stepping back would sound a Stage ending that is being un-done.
if (soundOn && at === from + 1) for (const c of step.cues) playCue(c);
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
$('vphase').textContent = f.phase;
// The live game's turn chart, from the shared renderer: same five phases, same violet "you are
// here". A replay used to report the Day and phase as two plain strings, so the one thing a player
// glances at most often looked nothing like it does in play.
// A replay carries the seat names in the frame's own lines rather than a player table, so the
// solitaire seat is named directly; a multi-player replay reports the index it has.
const actorName = f.actor === null ? null : `Player ${f.actor + 1}`;
$('turnchart').innerHTML = turnChartHtml(f, actorName);
$('vrev').textContent = String(f.revenue);
$('vpos').textContent = `${at} / ${steps.length - 1}`;
($('vscrub') as HTMLInputElement).value = String(at);
@@ -139,6 +146,17 @@ function show(i: number): void {
log += `<div class="line t-${l.tone}${k === at ? ' now' : ''}">${esc(l.text)}</div>`;
}
}
// The same side panels the playable page shows, from the same renderers. A replay is the game
// being watched, so it drops only what asks you to act.
$('vhand').innerHTML = handHtml(f);
$('vdepts').innerHTML = pilesHtml(f);
$('vdivyard').innerHTML = yardHtml(f.yards.division);
$('vclsyard').innerHTML = yardHtml(f.yards.classification);
$('vdivtot').textContent = `${f.yards.divisionTotal} cars`;
$('vclstot').textContent = `${f.yards.classificationTotal} cars`;
$('vblocked').innerHTML = blockedHtml(f);
$('vfacs').innerHTML = facilitiesHtml(f);
$('vlog').innerHTML = log;
const logEl = $('vlog');
logEl.scrollTop = logEl.scrollHeight;
@@ -252,6 +270,18 @@ function wire(): void {
show(at + 1);
}, Number(($('vspeed') as HTMLSelectElement).value));
};
/**
* A SPEED CHANGE HAS TO TAKE EFFECT WHILE IT IS PLAYING.
*
* The interval is created with whatever the select held at the moment play started, and nothing
* re-read it — so choosing "extra slow" mid-replay did nothing at all and the pace looked stuck.
* The standalone replay has always restarted its timer on change; this viewer was missed.
*/
($('vspeed') as HTMLSelectElement).onchange = () => {
if (!timer) return;
$('vplay').click();
$('vplay').click();
};
$('vdistricttoggle').onclick = () => {
const f = steps[at]?.frame;
const open = districtMode === 'auto' ? (f ? FOCUS_PHASES.has(f.phaseKey) : true) : districtMode === 'open';
@@ -283,7 +313,7 @@ function wire(): void {
}
const style = document.createElement('style');
style.textContent = BOARD_CSS + TOOLTIP_CSS;
style.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
document.head.appendChild(style);
installTooltips();
wire();
+31
View File
@@ -93,6 +93,37 @@ export function playCue(cue: string): void {
return;
}
if (cue === 'couple') {
/**
* KNUCKLE COUPLERS MEETING: a low metallic clank and the slack running out behind it.
*
* Coupling a cut was reported as invisible — the car simply left the board and only history said
* where it went. It is the single most consequential thing a switching move does, so it gets the
* one sound a player will learn to listen for.
*/
for (const fg of [[86, 0.22], [131, 0.13], [197, 0.07], [61, 0.18]]) {
tone(t, fg[0]!, 0.26, fg[1]!, 'square', fg[0]! * 0.82);
}
tone(t + 0.1, 74, 0.34, 0.1, 'triangle', 58);
return;
}
if (cue === 'drop') {
// Setting out is the same act in reverse and quieter: one clank, no slack behind it.
for (const fg of [[104, 0.14], [156, 0.08]]) {
tone(t, fg[0]!, 0.18, fg[1]!, 'square', fg[0]! * 0.85);
}
return;
}
if (cue === 'schedule') {
// A die landing on the despatcher's desk, then the slot taking it: a short clatter and a chime.
for (const at of [t, t + 0.05, t + 0.09]) tone(at, 200 + Math.random() * 90, 0.05, 0.07, 'square');
tone(t + 0.16, 880, 0.3, 0.09, 'sine');
tone(t + 0.16, 1320, 0.28, 0.05, 'sine');
return;
}
if (cue === 'day') {
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
for (const at of [t, t + 0.22]) {
+23
View File
@@ -24,7 +24,25 @@ function ensure(): HTMLElement {
function place(el: HTMLElement, text: string): void {
const t = ensure();
/**
* A PICTURE WHERE ONE HELPS, otherwise text.
*
* `data-tip-html` carries pre-built markup — a drawing of the track piece a placement would lay,
* rendered by the board's own renderer so it cannot show a shape the placement will not produce.
* Only ever set from markup this app built; nothing here interpolates anything a player typed.
*/
const html = el.getAttribute('data-tip-html');
if (html) {
t.innerHTML = `<div class="tip-fig">${html}</div>`;
if (text) {
const p = document.createElement('div');
p.className = 'tip-txt';
p.textContent = text;
t.appendChild(p);
}
} else {
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
@@ -87,4 +105,9 @@ export const TOOLTIP_CSS = `
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}
/* A drawn piece inside a tooltip. The figure sits above its caption, and the caption keeps the
tooltip's own text treatment — the drawing is the answer, the words are the confirmation. */
#tip .tip-fig{display:flex;gap:6px;justify-content:center;margin:1px 0 6px}
#tip .tip-fig svg{width:132px;height:auto;display:block}
#tip .tip-txt{white-space:pre-wrap}
`;
+90
View File
@@ -991,3 +991,93 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }), 'CONSIST_EMPTY');
});
});
// ---------------------------------------------------------------------------
describe('only the train being made up may take cars (§7)', () => {
/** A tray with a short consist, placed wherever the test needs it. */
function trayAt(s: GameState, position: CrewTray['position']): void {
s.clock.phase = 'newTrain';
s.trays.set('t', {
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'west', position, movesUsed: 0,
});
}
const addHopper: Intent = { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true };
it('accepts a car for a train standing at a Division Point', () => {
const s = game();
trayAt(s, { at: 'divisionPoint', side: 'east' });
assert.equal(check(s, 0, addHopper), null);
});
it('refuses to load a train standing in the district', () => {
// The New Train phase fills the train it is WAITING on — `trainNeedingCars` requires a Division
// Point. `check` did not, so the Division Yard would hand cars to a train sitting on a siding
// in your own district: cars appeared on a train nobody was making up.
const s = game();
trayAt(s, { at: 'grid', owner: 0, coord: at(0, 1) });
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
});
it('refuses to load a train out on the Mainline', () => {
const s = game();
trayAt(s, { at: 'mainline', index: 1 });
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
});
it('refuses to load a train whose consist is already what its card calls for', () => {
// Train 9 is freight 3 + caboose 1. A full train is not the one being made up, even standing
// at a Division Point — otherwise the phase and `check` disagree about who is being filled.
const s = game();
trayAt(s, { at: 'divisionPoint', side: 'east' });
const tray = s.trays.get('t')!;
tray.consist = [
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: true },
{ type: 'caboose', loaded: true },
];
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
});
});
// ---------------------------------------------------------------------------
describe('an Office upgrade keeps what Modifiers added (§9)', () => {
it('does not wipe a Restaurant when the Depot becomes a Station', () => {
// The upgrade wrote the tier's printed numbers straight over the facility, so a Restaurant
// played beside a Depot — +1 passenger out, +1 porter — was silently deleted by the next
// upgrade. The card had been spent and the effect vanished with no message.
const s = game();
const office = () => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!;
const play = (tier: 'depot' | 'station'): void => {
for (const [id, card] of s.cards) {
if (card.kind.kind !== 'office' || card.kind.tier !== tier) continue;
s.decks.hands.set(0, [id]);
s.turn.option = null;
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
assert.ok(r.ok, `${tier} upgrade should apply`);
return;
}
throw new Error(`no ${tier} card`);
};
play('depot');
const base = { out: office().capacity.outbound, porters: office().porters };
// A Restaurant beside the Office: +1 passenger out, +1 porter.
office().capacity.outbound += 1;
office().porters += 1;
play('station');
const station = { out: 2, porters: 2 }; // Station's printed numbers
assert.equal(office().capacity.outbound, station.out + 1, 'the Modifier survives the upgrade');
assert.equal(office().porters, station.porters + 1, 'the porter survives the upgrade');
assert.ok(office().capacity.outbound > base.out, 'and the upgrade still raised the tier');
});
});
+17 -5
View File
@@ -254,8 +254,12 @@ describe('replay HTML', () => {
() => run(stub, () => 0, () => undefined),
'the page threw while rendering its first frame',
);
// render() ran, so the clock element should have been filled in.
assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
// render() ran, so the turn chart should have been filled in — with the same Day/Stage/phase
// chart the live game draws, from the shared renderer.
const chart = String(els.get('turnchart')?.innerHTML ?? '');
assert.match(chart, /Day \d+/, 'the turn chart was not rendered');
assert.match(chart, /Stage \d+ of 12/);
assert.match(chart, /class="tc-phase now"/, 'no phase is marked as the current one');
});
it('records what the bot chose, why, and what it passed over', () => {
@@ -394,16 +398,24 @@ describe('the replay behaves like the game it is replaying', () => {
assert.ok(kinds.has('stage'), 'no Stage ever sounded');
assert.ok(kinds.has('day'), 'no Day ever sounded');
assert.ok(kinds.has('train'), 'no train was ever announced');
// Coupling and setting out are what a switching move IS, and both were silent — a car left the
// board and only history said where it went.
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
// One cue per Stage boundary, the bell replacing the whistle at a Day — the same accounting the
// live game is held to.
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
// accounting the live game is held to. The switching cues are counted separately: they follow
// what a crew does, not what the clock does.
let boundaries = 0;
for (let k = 1; k < rec.frames.length; k++) {
const a = rec.frames[k - 1]!;
const b = rec.frames[k]!;
if (a.day !== b.day || a.stage !== b.stage) boundaries++;
}
const clock = withCues.flatMap((f) => f.cues ?? []).filter((c) => c !== 'train').length;
const clock = withCues
.flatMap((f) => f.cues ?? [])
.filter((c) => c === 'stage' || c === 'day').length;
assert.equal(clock, boundaries, 'Stage boundaries and Stage-or-Day cues disagree');
});
+7 -1
View File
@@ -969,7 +969,13 @@ describe('the freight figures count both halves (regression)', () => {
let loads = 0;
let unloads = 0;
let begun = 0;
for (const seed of [1000, 8919, 16838]) {
// A WIDER SAMPLE than the three seeds this used to take. The subject is the instrument — does
// `freightUnload` count Revenue EARNED rather than unloads STARTED — and `unloads > 0` is only
// the precondition that makes the comparison mean anything. Three deals is a thin thread to hang
// that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it,
// and whether the bot manages all three on a given deal is luck, not the thing under test.
// Measured across these twelve: 34 unloads earned, on 12 of 40 deals sampled more widely.
for (const seed of [1000, 8919, 16838, 24757, 32676, 40595, 48514, 56433, 64352, 72271, 80190, 88109]) {
const s = createGame({
id: `fu-${seed}`, seed,
config: { ...config, length: 'standard' },
+248
View File
@@ -22,7 +22,9 @@ import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
import {
allReachable,
canDropCarsAt,
exploreMoves,
canPlaceAt,
carriesThroughTrack,
exitsFrom,
hasPort,
neighbour,
@@ -803,3 +805,249 @@ describe('Operational Rail — where a train may stop and leave cars (Appendix A
}
});
});
// ---------------------------------------------------------------------------
describe('the Running Track must stay a through route', () => {
const runningArea = (): OfficeArea =>
areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
},
at(0, 0),
);
it('refuses a curve in the running row, which would dead-end the main', () => {
// A curve has ONE road — from an east or west edge round to a 45° leg — so a card of it standing
// in the Running Track stops the main dead at that square, cutting the Office off from its own
// Limits. Every other card that may stand there carries an east-west road across it.
const area = runningArea();
for (const arc of ['ne', 'nw', 'se', 'sw'] as TrackArc[]) {
assert.ok(!carriesThroughTrack(curve(arc)), `a ${arc} curve has no through road`);
assert.ok(!canPlaceAt(area, at(0, 2), curve(arc)), `a ${arc} curve must not go in the Running Track`);
}
});
it('still accepts everything that does carry the road through', () => {
const area = runningArea();
assert.ok(carriesThroughTrack(straight()));
assert.ok(carriesThroughTrack(turnout({ stem: 'w', through: 'e', diverge: 's' })));
assert.ok(carriesThroughTrack(officeCard()));
assert.ok(canPlaceAt(area, at(0, 2), straight()), 'a straight extends the main');
assert.ok(
canPlaceAt(area, at(0, 2), turnout({ stem: 'w', through: 'e', diverge: 's' })),
'a turnout keeps the road AND opens a leg — which is why a district hangs off one',
);
});
it('leaves a curve free to go anywhere off the main', () => {
const area = areaFrom(
{
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
},
at(0, 0),
);
assert.ok(canPlaceAt(area, at(-1, 1), curve('ne')), 'the siding is exactly where a curve belongs');
});
});
describe('a turnout may be laid to face another, making a crossover', () => {
/** A turnout stated the way the cards read: who may come in, and where they may go. */
const facing = (stem: Port, through: Port, diverge: Port, hand: 'left' | 'right'): TrackCard => ({
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem, through, diverge }, hand },
baseOperationalRail: false, standing: [], facility: null, modifiers: [], enhancements: [],
});
it('joins a south-diverging turnout to the north-diverging one beneath it', () => {
// "Traffic from the east to travel west or turn to the south", with directly beneath it "traffic
// from the west to travel east or turn to the north". The two 45° legs are one continuous rail
// across the card edge — a crossover, and the way a siding gets a track parallel to the main.
const above = facing('e', 'w', 's', 'right');
const below = facing('w', 'e', 'n', 'right');
assert.ok(joins(above, 's', below), 'the crossover does not join');
// And its mirror, which is the same move built from the other hand.
const aboveL = facing('w', 'e', 's', 'left');
const belowL = facing('e', 'w', 'n', 'left');
assert.ok(joins(aboveL, 's', belowL), 'the mirrored crossover does not join');
});
it('refuses the pair whose legs lie on opposite diagonals', () => {
// Both cards have the port and both legs meet the same point on the shared edge. They still form
// a V: one descends to the right and the other to the left.
assert.ok(!joins(facing('e', 'w', 's', 'right'), 's', facing('e', 'w', 'n', 'left')));
assert.ok(!joins(facing('w', 'e', 's', 'left'), 's', facing('w', 'e', 'n', 'right')));
});
it('lets a crew run down the crossover onto the parallel track', () => {
// The point of building one: the lower turnout carries its own east-west road, so the siding
// runs alongside the main rather than dead-ending under it.
const area = areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: facing('w', 'e', 's', 'left'),
[coordKey(at(0, 1))]: straight(),
// Coming down the leg, the lower turnout is entered through its diverging north end, so the
// only way on is its stem — east, along the parallel track.
[coordKey(at(-1, 0))]: facing('e', 'w', 'n', 'left'),
[coordKey(at(-1, 1))]: straight(),
},
at(0, 5),
);
const dests = reachableDestinations(ctxFor(area), at(0, -1), 'e');
assert.ok(has(dests, -1, 1), 'a crew cannot reach the parallel track through the crossover');
assert.ok(!has(dests, -1, 0), 'and may not stop on the turnout itself');
});
});
// ---------------------------------------------------------------------------
describe('backing up does not turn the train around', () => {
it('keeps the engine pointing the same way through a reverse move', () => {
// REPORTED as forward/reverse being scrambled. `facing` is which way the ENGINE points, and it
// was reset to the direction of travel on EVERY move — so one reverse move silently spun the
// train about, everything read "forward" again, and a run-around became pointless: you could
// change ends for free by backing up twice.
const s = gameWith(
areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(0, 2))]: straight(),
},
at(0, 5),
),
);
const id = 'crew';
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: at(0, 1) }, movesUsed: 0,
} as never);
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
s.turn.movesRemaining = 6;
// Running forward, east: the engine leads, so it still points east.
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 2), reverse: false }).ok);
assert.equal(s.trays.get(id)!.facing, 'e', 'a forward move must leave the engine leading');
// Backing up, west: the engine trails. It is still pointing east.
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.equal(
s.trays.get(id)!.facing,
'e',
'backing up turned the train around — a run-around would then be pointless',
);
});
});
// ---------------------------------------------------------------------------
describe('why a square is NOT offered (the same walk, keeping its rejections)', () => {
/**
* REPORTED: "during switching, it seems like trains are blocked from moving onto industry in
* certain conditions. Can you make it clear what those conditions are?" They were nowhere on
* screen — a square that is not a destination simply had no button.
*
* `exploreMoves` returns the reasons out of the SAME traversal that produces the destinations, so
* a reason shown to a player is the rule that actually refused the square. A second function
* working out "why is this missing" would be a second implementation of movement, and its failure
* mode is a confident explanation that does not match the refusal.
*/
const cars = (n: number): RollingStock[] =>
Array.from({ length: n }, () => ({ type: 'boxcar', loaded: false }) as RollingStock);
const workingIndustry = (): TrackCard => ({
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
// A load on MEN locks the track (§9.3).
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null] as never,
industryTrack: { length: 2, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
const reasonFor = (area: OfficeArea, over: Partial<MoveContext>, r: number, c: number): string | null => {
const { blocked } = exploreMoves(ctxFor(area, over), at(0, 0), 'e');
return blocked.find((b) => b.coord.row === r && b.coord.col === c)?.why ?? null;
};
it('says MEN AT WORK when an industry is locked, and refuses the square', () => {
const area = areaFrom(
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: workingIndustry() },
at(0, 5),
);
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
assert.ok(!has(destinations, 0, 1), 'a locked industry must not be a destination');
const why = blocked.find((b) => b.coord.col === 1);
assert.equal(why?.kind, 'locked');
assert.match(why!.why, /MEN AT WORK/);
});
it('says which card is occupied, and by what rule', () => {
const area = areaFrom(
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: straight() },
at(0, 5),
);
const occupancy: Occupancy = { trayAt: (c) => (c.col === 1 ? 'tray9' : null), freeAdTracks: () => 0 };
assert.match(reasonFor(area, { occupancy }, 0, 1) ?? '', /another train is standing here/);
});
it('says when a move would overfill the train, and counts the cars it would couple', () => {
const area = areaFrom(
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: straight(cars(3)) },
at(0, 5),
);
const why = reasonFor(area, { consistSize: 3 }, 0, 1) ?? '';
assert.match(why, /too many cars/);
assert.match(why, /couples 3 standing car/, 'the count must be the cars actually there');
});
it('tells a pass-through apart from an obstruction', () => {
// A turnout is not in the way: a train runs through one all day and simply may not finish a
// Move on it. Reporting that as "blocked" would bury the reasons that ARE obstructions.
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
[coordKey(at(0, 2))]: straight(),
},
at(0, 5),
);
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
assert.ok(has(destinations, 0, 2), 'the train must still be able to run THROUGH the turnout');
assert.equal(blocked.find((b) => b.coord.col === 1)?.kind, 'noStopping');
});
it('never explains away a square that is actually reachable', () => {
// The walk can meet a card from a bad angle first and a good one later. A reason attached to a
// square the player CAN reach is worse than none: it argues with the button beside it.
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(0, 2))]: straight(),
},
at(0, 5),
);
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
for (const b of blocked) {
assert.ok(
!has(destinations, b.coord.row, b.coord.col),
`(${b.coord.row},${b.coord.col}) is both reachable and explained away`,
);
}
});
});
+740 -24
View File
@@ -14,6 +14,7 @@ import { dirname, join, resolve } from 'node:path';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import { facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
import { snapshot } from '../src/sim/view.ts';
import { createGame as createEngineGame } from '../src/engine/setup.ts';
@@ -27,6 +28,7 @@ import {
newGame,
submit,
toSave,
undo,
view,
} from '../src/web/game.ts';
@@ -240,6 +242,31 @@ describe('the action menu presents choices the way they are made', () => {
assert.equal(f.salvage.top, cardName(game.state, spare), 'the top of the Salvage Yard is not named');
});
it('offers BOTH rotations of a turnout, each with the shape it would lay', () => {
// REGRESSION, and the reason rotating a turnout felt impossible. The action list drops duplicate
// labels, and `describeIntent` for a card play said only "play X at (0, 1)" — so a turnout's two
// orientations produced the same label and the second was discarded before the menu ever saw it.
// The rotation is the entire decision for a turnout, and it could not be made.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
dealTrack(game, 'turnout', 'right');
const spots = actionMenu(game)
.placeable.flatMap((g) => g.items)
.flatMap((it) => it.spots)
.filter((sp) => sp.coord?.row === 0 && sp.coord.col === 1);
assert.equal(spots.length, 2, 'both rotations must be offered on the same square');
assert.ok(spots.some((sp) => /turn to the south/.test(sp.label)), 'the south-diverging rotation is missing');
assert.ok(spots.some((sp) => /turn to the north/.test(sp.label)), 'the north-diverging rotation is missing');
// And each carries the rails it would actually lay, so the UI can draw it rather than only
// describe it. Taken from the engine's connections, so a preview cannot promise a shape the
// placement will not produce.
const shapes = spots.map((sp) => [...sp.links].sort().join('|')).sort();
assert.deepEqual(shapes, ['es|ew', 'we|wn'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
});
it('says how deep a Department pile is, so a discard can be aimed', () => {
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
@@ -318,18 +345,54 @@ describe('the board shows freight work happening', () => {
);
};
assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw', 'red'], 'the waiting load is not drawn');
// A Mine Tipple only ships OUT, so it has no red Unloading box at all — only the boxes an
// industry actually uses are drawn now.
assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw'], 'the waiting load is not drawn');
game.state.clock.phase = 'loadUnload';
game.state.clock.currentActor = 0;
assert.ok(submit(game, { type: 'laborer.startLoad', at: { row: -1, col: 0 } }));
assert.deepEqual(filled(), ['green', 'maw=FULL', 'maw', 'maw', 'red'], 'starting a load is invisible');
assert.deepEqual(filled(), ['green', 'maw=FULL', 'maw', 'maw'], 'starting a load is invisible');
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 0 }));
assert.deepEqual(filled(), ['green', 'maw', 'maw=FULL', 'maw', 'red'], 'advancing a load is invisible');
assert.deepEqual(filled(), ['green', 'maw', 'maw=FULL', 'maw'], 'advancing a load is invisible');
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 1 }));
assert.deepEqual(filled(), ['green', 'maw', 'maw', 'maw=FULL', 'red'], 'the load did not reach WORK');
assert.deepEqual(filled(), ['green', 'maw', 'maw', 'maw=FULL'], 'the load did not reach WORK');
});
it('draws the pipeline the way the freight actually flows', () => {
// REPORTED after unloading at a warehouse: "it went W, A, M, and then to the red box at the far
// right." §9.3 runs loading Green → MEN → AT → WORK → car, and unloading the other way entirely:
// car → WORK → AT → MEN → red. So green and red BOTH sit beside MEN and the car sits beside
// WORK — but red was drawn at the far right, which is exactly where the car is, so an unload
// looked like it ran backwards across the row and landed on the end it came from.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
const fac = (kind: string, out: boolean, into: boolean) => ({
geometry: { kind: 'facility', facility: kind },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
allows: { outbound: out, inbound: into },
outboundBox: [], inboundBox: [], capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
});
area.grid.set('-1,0', fac('grocersWarehouse', false, true) as never);
area.grid.set('-1,1', fac('mineTipple', true, false) as never);
const cells = view(game).cells;
const order = (row: number, col: number): string[] => {
const cell = cells.find((c) => c.row === row && c.col === col)!;
return [...officeSvg([cell], 0).matchAll(/class="bs-wb (bs-\w+)/g)].map((m) => m[1]!.replace('bs-', ''));
};
// Receives only: the red box sits BEFORE the sign, which is the end an unload arrives at.
assert.deepEqual(order(-1, 0), ['red', 'maw', 'maw', 'maw'], 'the red box is not beside MEN');
// Ships out only: green before the sign, and no red box at all.
assert.deepEqual(order(-1, 1), ['green', 'maw', 'maw', 'maw'], 'the green box is not beside MEN');
});
it('names where the work has got to, in the tooltip', () => {
@@ -352,6 +415,104 @@ describe('the board shows freight work happening', () => {
assert.match(cell.what, /a load is on AT/, 'the card does not say where the work has got to');
});
it('captions a legal square that already has a card on it', () => {
/**
* REPORTED: "it shows on the map where the possible places are. It also looks like my depot is
* highlighted as well... New squares where I could place my track say PLACE HERE. Limits are
* also highlighted but do not."
*
* Three different acts light up the same blue — building on empty ground, EXTENDING the Running
* Track at a Limits sign, and ATTACHING an Enhancement to a card already down — and only the
* first said anything. A blue outline with no words reads as a bug, not as an offer.
*/
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const cells = view(game).cells;
const limits = cells.find((c) => c.kind === 'limits')!;
const svg = officeSvg(cells, area.runningRow, [], [
{ row: limits.row, col: limits.col, label: 'EXTEND THE RUNNING TRACK HERE' },
]);
assert.match(svg, /EXTEND THE RUNNING TRACK HERE/, 'the Limits sign is offered without a word');
// And the caption must clear the rail down the middle of the card, where the train is drawn.
const y = Number(/class="bs-legalcap"[^>]*>(?:<rect x="\d+" y="(\d+)")/.exec(svg)?.[1]);
assert.ok(y > 48, `the caption at y=${y} prints over the rail`);
});
it('says on the card which way an industry runs, and how many workers it has', () => {
/**
* TWO REPORTS, ONE CARD.
*
* "Should the display be any different for industries that are send-only versus receive-only?
* At Grocer's Warehouse, which is receive only, and Refinery, which is send only, the men at
* work boxes, the arrows and the green-red boxes look the same in both." They were different —
* by the stroke colour of one 13x12px box and the direction of a 10px chevron, which is not a
* difference a player can see.
*
* And: "how do I see the number of laborers in an industry card?" You could not. The number
* that decides every Cargo phase was in a side panel only.
*/
const game = newGame(5);
const industry = (kind: string, out: boolean, into: boolean, laborers: number) => {
game.state.officeAreas.get(0)!.grid.set('-1,0', {
geometry: { kind: 'facility', facility: kind },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
allows: { outbound: out, inbound: into },
outboundBox: [], inboundBox: [],
capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [] },
laborers, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
return officeSvg([cell], 0);
};
const ships = industry('refinery', true, false, 2);
const receives = industry('grocersWarehouse', false, true, 1);
assert.match(ships, /SHIPS OUT/, 'a send-only industry does not say so');
assert.match(receives, /RECEIVES/, 'a receive-only industry does not say so');
assert.doesNotMatch(ships, /RECEIVES/, 'a send-only industry claims to receive');
// And the two cards must not be one another with a different tint.
const strip = (svg: string): string => svg.replace(/(SHIPS OUT|RECEIVES|L \d\/\d)/g, '');
assert.notEqual(strip(ships), strip(receives), 'the two industries draw identically');
assert.match(ships, /bs-crewlab[^>]*>L 2\/2</, 'the Laborer count is not on the card');
assert.match(receives, /bs-crewlab[^>]*>L 1\/1</, 'the Laborer count is not on the card');
});
it('shows the Office its Porters, and what a Modifier added', () => {
// REPORTED: "I added a restaurant. That gave me an extra outbound slot, so I see an extra green
// box. And it gave me an extra porter that I see no indication of anywhere." Porters were on
// the view-model and no renderer had ever drawn them.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
const office = area.grid.get(`${area.runningRow},0`)!;
area.tier = 'depot';
office.facility!.porters = 2; // a Depot prints 1; a Restaurant beside it adds one
office.facility!.capacity = { outbound: 2, inbound: 1 };
office.facility!.allows = { outbound: true, inbound: true };
const cell = view(game).cells.find((c) => c.kind === 'office')!;
assert.match(officeSvg([cell], area.runningRow), /bs-crewlab[^>]*>P 2\/2</, 'the card does not show Porters');
assert.match(cell.what, /2 porters/, 'the tooltip still reads the printed tier, not the Office as it stands');
assert.match(cell.what, /Modifiers beside it add/, 'nothing says where the extra came from');
// And the panel must not invent a Modifier that is not there.
const plain = newGame(5);
const parea = plain.state.officeAreas.get(0)!;
parea.tier = 'depot';
const poffice = parea.grid.get(`${parea.runningRow},0`)!;
poffice.facility!.porters = 1;
poffice.facility!.capacity = { outbound: 1, inbound: 1 };
poffice.facility!.allows = { outbound: true, inbound: true };
const html = facilitiesHtml(view(plain));
assert.doesNotMatch(html, /class="added"/, 'a plain Depot is credited with a Modifier it does not have');
});
it('keeps the enhancement label clear of the pipeline squares', () => {
// `overpass` and `facingPointLocks` are placed `onCard`, so they can land on a facility. At its
// old baseline the label printed straight through the green/MEN|AT|WORK/red row.
@@ -455,13 +616,21 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
});
describe('board highlighting', () => {
it('gives every spot a coordinate to highlight', () => {
it('gives every spot a coordinate to highlight, or says it is not on this board', () => {
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const items = actionMenu(game).placeable.flatMap((g) => g.items);
assert.ok(items.length > 0);
for (const it of items) {
for (const sp of it.spots) {
// A spot with no coordinate is a placement out on the Mainline, and it must say so — the
// board cannot highlight it, so the label is the only thing telling the player where it
// goes. It used to carry the fake coordinate (-1, node), which is a real district row.
if (sp.coord === null) {
assert.equal(typeof sp.node, 'number', `${it.subject} has neither a coordinate nor a node`);
assert.match(sp.label, /out on the Mainline/, `${it.subject}: no coordinate and no explanation`);
continue;
}
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
@@ -497,7 +666,7 @@ describe('board highlighting', () => {
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots);
assert.ok(
spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0),
spots.some((sp) => sp.coord !== null && (sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0)),
'no legal spot lies outside the current cards — the bounds test would be vacuous',
);
});
@@ -557,9 +726,14 @@ describe('the page explains itself', () => {
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
assert.match(cell.what, /stem east/);
assert.match(cell.what, /diverges south/);
assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out');
// Said the way a player would ask it: if my train comes in from over there, where can it go?
// "stem east, through west, diverges south" is three pieces of jargon and a compass reading.
assert.match(
cell.what,
/allows traffic from the east to travel west or turn to the south/,
`the turnout does not say what it does: ${cell.what}`,
);
assert.match(cell.what, /the two roads never join/, 'the missing edge is not spelled out');
});
it('describes every card kind in the deck, not just the easy ones', () => {
@@ -649,13 +823,20 @@ describe('the page explains itself', () => {
variantsFor(geometry, hand).forEach((v, variant) => {
const label = variantLabel(geometry, variant, hand);
if (v.turnout) {
assert.match(label, new RegExp(`stem ${say[v.turnout.stem]}`), `${geometry}/${hand} v${variant}: ${label}`);
assert.match(label, new RegExp(`diverges ${say[v.turnout.diverge]}`), `${geometry}/${hand} v${variant}: ${label}`);
assert.equal(
label.trim(),
`— allows traffic from the ${say[v.turnout.stem]} to travel ${say[v.turnout.through]} ` +
`or turn to the ${say[v.turnout.diverge]}`,
`${geometry}/${hand} v${variant}`,
);
} else if (v.arc) {
const [a, b] = [v.arc[0]!, v.arc[1]!];
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
assert.match(label, new RegExp(say[side]!), `${geometry}/${hand} v${variant}: ${label}`);
assert.match(label, new RegExp(`${say[leg]} edge`), `${geometry}/${hand} v${variant}: ${label}`);
assert.equal(
label.trim(),
`— carries traffic from the ${say[side!]} round to the ${say[leg!]}`,
`${geometry}/${hand} v${variant}`,
);
}
});
}
@@ -679,10 +860,10 @@ describe('the page explains itself', () => {
.placeable.flatMap((g) => g.items)
.filter((it) => it.subject.includes('turnout'))
.flatMap((it) => it.spots)
.filter((sp) => sp.coord.row === under.row && sp.coord.col === under.col);
.filter((sp) => sp.coord?.row === under.row && sp.coord.col === under.col);
assert.equal(spots.length, 1, 'exactly one turnout rotation should meet the leg coming down');
assert.match(spots[0]!.label, /diverges north/, 'the crossover turnout must point back up');
assert.match(spots[0]!.label, /turn to the north/, 'the crossover turnout must point back up');
assert.match(spots[0]!.label, /joins the track above/, 'the spot must say what it connects to');
});
@@ -810,6 +991,7 @@ describe('the page explains itself', () => {
// A piece with more than one orientation must say which is which, or the spots are ambiguous.
const perSquare = new Map<string, number>();
for (const sp of item.spots) {
if (sp.coord === null) continue;
const k = `${sp.coord.row},${sp.coord.col}`;
perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
}
@@ -843,6 +1025,61 @@ describe('replays are saves', () => {
assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs');
});
it('takes the last action back, and keeps taking', () => {
/**
* "Is there a way to remove a card that has been misplayed? In a solitaire game it might be
* nice to say oops, take that back."
*
* The save IS the game, so undo is a replay without the last intent — which means the position
* it lands on must be EXACTLY the position that was there before, board, Division, clock and
* revenue. Anything less and undo becomes its own source of divergence.
*/
const game = newGame(430);
const marks: { cells: unknown; division: unknown; revenue: number; day: number; stage: number }[] = [];
const mark = (g: typeof game) => ({
cells: view(g).cells,
division: view(g).division,
revenue: g.state.players[0]!.revenue,
day: g.state.clock.day,
stage: g.state.clock.stage,
});
for (let i = 0; i < 60; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
marks.push(mark(game));
if (!submit(game, options[0]!)) break;
}
assert.ok(marks.length > 20, 'the game did not get far enough to prove anything');
// Walk all the way back, checking each step lands on the position it came from.
let now = game;
for (let i = marks.length - 1; i >= 0; i--) {
const back = undo(now);
assert.ok(back, `undo refused with ${now.history.length} moves still on the clock`);
now = back;
assert.deepEqual(mark(now), marks[i], `undo ${marks.length - i} landed somewhere else`);
}
assert.equal(now.history.length, 0, 'walking all the way back did not empty the history');
assert.equal(undo(now), null, 'undo must refuse when there is nothing to take back');
});
it('leaves nothing in the log describing a move that was taken back', () => {
// The history panel is rebuilt with the game, so it cannot be left narrating an action that no
// longer happened — the single most confusing thing a half-undo could do.
const game = newGame(202);
for (let i = 0; i < 30; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
const before = game.log.length;
const back = undo(game)!;
assert.ok(back.log.length <= before, 'the log grew when a move was taken back');
assert.deepEqual(back.log, fromSave(toSave(back)).log, 'the rebuilt log is not the replayed log');
});
it('stays small enough to email', () => {
const game = newGame(202);
for (let i = 0; i < 400; i++) {
@@ -1035,8 +1272,10 @@ describe('the static build', () => {
const actions = els.get('actions')!;
const grid = els.get('grid')!;
const hand = els.get('hand')!;
// Take the draw option, then pick the first placeable subject.
// Take the draw option, then pick a card's PLAY verb — the hand is the action surface now, so
// the subject is chosen on the card rather than in a separate list.
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
const nodes = fn.call(el, sel);
@@ -1047,7 +1286,18 @@ describe('the static build', () => {
};
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
// Specifically the PLAY verb: a hand usually holds cards that can only be discarded, and
// discarding highlights the Department piles rather than the board.
const clickVerb = (el: Record<string, unknown>, verb: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
const target = fn
.call(el, 'button.cardact')
.find((n) => (n['dataset'] as Record<string, string>)['verb'] === verb);
if (!target) return false;
(target['onclick'] as (() => void) | null)?.();
return true;
};
assert.ok(clickVerb(hand, 'play'), 'no card in hand offers a play verb');
// Without the board stylesheet every shape is drawn black on a near-black background: the page
// looks empty even though the markup is perfect.
@@ -1125,6 +1375,445 @@ describe('the static build', () => {
}
});
it('draws the same turn chart on all three screens', () => {
// A game gets drawn in three places — the playable page, the site's replay viewer and the
// standalone replay file — and where you are in the Day is the thing a player glances at most
// often. It lived only in the playable page: a replay reported the Day and phase as two plain
// strings with none of the violet "you are here" the live game uses, so the same position looked
// like a different game depending on which screen you were on.
//
// ONE renderer, asserted here by source rather than by eye, because three copies of a chart is
// exactly the kind of thing that drifts quietly.
for (const f of ['src/web/main.ts', 'src/web/replays.ts', 'src/sim/replay.ts']) {
const src = readFileSync(join(root, f), 'utf8');
assert.match(src, /turnChartHtml/, `${f} does not use the shared turn chart`);
assert.match(src, /TURNCHART_CSS/, `${f} does not ship the shared turn-chart styling`);
}
// And no screen may keep a private copy of the phase table.
for (const f of ['src/web/main.ts', 'src/web/replays.ts', 'src/sim/replay.ts']) {
const src = readFileSync(join(root, f), 'utf8');
assert.doesNotMatch(src, /key: 'shiftChange'/, `${f} has its own copy of the phase table`);
}
});
it('puts every verb on the card it belongs to', () => {
// THE HAND IS THE ACTION SURFACE. A card used to appear twice under two different models: as a
// subject to play, and as one flat button per Department to discard. For a four-card hand that
// discard block alone was twelve buttons repeating three choices four times.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const menu = actionMenu(game);
const held = game.state.decks.hands.get(0) ?? [];
assert.equal(menu.hand.length, held.length, 'every card in hand needs an entry');
for (const h of menu.hand) {
assert.ok(h.name.length > 0, 'a hand entry with no name');
// A verb is only offered when the engine would accept it.
for (let slot = 0; slot < 3; slot++) {
const idx = h.discard[slot];
if (idx === null || idx === undefined) continue;
const i = menu.options[idx]!;
assert.equal(i.type, 'card.discard');
assert.equal((i as { cardId: string }).cardId, h.cardId);
assert.equal((i as { toSlot: number }).toSlot, slot, 'a discard is keyed to the wrong Department');
}
if (h.playNow !== null) {
const i = menu.options[h.playNow]!;
assert.equal(i.type, 'card.play');
assert.equal((i as { placement?: unknown }).placement, undefined, 'playNow must need no square');
}
if (h.placeKey !== null) {
const item = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === h.placeKey);
assert.ok(item, 'placeKey points at no placeable entry');
assert.equal(item!.spots.length, h.spots);
}
}
});
it('previews a track card in hand even when it has nowhere legal to go', () => {
// REPORTED on a left-hand curve, which showed no preview at all. The shape was read off the
// card's legal PLACEMENTS, so a card with no legal square had nothing to draw — and that is
// exactly when a player most wants to see what the piece is.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const { cardId } = dealTrack(game, 'curved', 'left');
const entry = actionMenu(game).hand.find((h) => h.cardId === cardId);
assert.ok(entry, 'the curve is not in the hand menu');
assert.deepEqual(entry!.shapes, [['sw'], ['ne']], 'both orientations must be previewable');
assert.equal(entry!.spots, 0, 'this is the case worth guarding: nowhere legal to lay it');
});
it('describes a curve as a curve, not as a turnout', () => {
// REPORTED: the curve read "east-west track with a 45° leg", which is a turnout — a road across
// the card plus a leg off it. A curve has ONE road and nothing runs past it, which is precisely
// why it may not be laid in the Running Track.
const game = newGame(775569289);
const { cardId } = dealTrack(game, 'curved', 'left');
const what = cardDescription(game.state, cardId);
assert.doesNotMatch(what, /east-west track with/, 'a curve is still described as a turnout');
assert.match(what, /single road/);
assert.match(what, /no track runs past it/);
// And a turnout must still say it HAS a road across it.
const t = dealTrack(game, 'turnout', 'left');
assert.match(cardDescription(game.state, t.cardId), /east-west track with/);
});
it('names the Mainline card a modifier would change, and what it becomes', () => {
// REPORTED: "Realignment on Mainline card 3" — a raw node index, with no clue which stretch of
// the Division it meant or what it would do, and no tooltip either, because the action list only
// attaches one when the label happens to contain an em-dash.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
for (const [id, c] of game.state.cards) {
if (c.kind.kind === 'mainlineModifier' && c.kind.key === 'realignment') {
game.state.decks.hands.set(0, [id]);
break;
}
}
const menu = actionMenu(game);
const labels = menu.direct
.filter((g) => g.title === 'Mainline modifiers')
.flatMap((g) => g.actions)
.map((a) => a.label);
assert.ok(labels.length > 0, 'Realignment was not offered at all');
for (const l of labels) {
assert.doesNotMatch(l, /Mainline card \d/, `still naming a raw node index: ${l}`);
assert.match(l, / — /, `no tooltip half, so the button gets none: ${l}`);
assert.match(l, /converts it to|nothing here to convert/, `does not say what it does: ${l}`);
}
});
it('never strands the player with a legal move and no way to make it', () => {
// REGRESSION, and a hard softlock. Moving train make-up onto the Division Yard chips took the
// "Making up …" group out of the action list, and the PASS option went with it — so a train
// being made up when the yard held nothing it could take had no control on screen at all.
// Reported at Stage 10 of seed 775569289, Train 10 waiting at the West Division Point.
//
// The invariant is the general one: every option the engine offers must be reachable through
// SOMETHING the page renders. Menu-level, so it holds for every phase rather than the handful a
// click-through happens to visit.
const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => {
const out = new Set<number>();
for (const g of menu.direct) for (const a of g.actions) out.add(a.index);
for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index);
for (const h of menu.hand) {
if (h.playNow !== null) out.add(h.playNow);
for (const d of h.discard) if (d !== null && d !== undefined) out.add(d);
}
if (menu.makeUp) {
for (const c of menu.makeUp.cars) out.add(c.index);
if (menu.makeUp.pass !== null) out.add(menu.makeUp.pass);
}
return out;
};
for (const seed of [775569289, 555, 430]) {
const game = newGame(seed);
for (let i = 0; i < 900 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.options.length === 0) break;
const got = reachable(menu);
assert.ok(
got.size > 0,
`seed ${seed}: ${menu.options.length} legal options and not one of them is on screen ` +
`(phase ${game.state.clock.phase}, Day ${game.state.clock.day} Stage ${game.state.clock.stage})`,
);
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
}
});
it('renders every part of the menu it is given', () => {
// THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along —
// `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu
// shape passed while the page stranded the player.
//
// Coarse on purpose: it asks only that every field the menu offers is referenced by the page. A
// field nothing reads is either dead or a control that has gone missing, and both are worth a
// failing test.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
for (const field of ['makeUp.title', 'makeUp.cars', 'makeUp.pass', 'h.playNow', 'h.discard', 'h.shapes']) {
assert.match(src, new RegExp(field.replace('.', '\\.')), `main.ts never reads menu ${field}`);
}
});
it('leaves the action list to what is not a card in hand', () => {
// Measured across a full game of seed 430: the widest action list was 22 buttons, of which up to
// 12 were the discard cross-product and up to 10 were "add loaded hopper"-style make-up buttons.
// Both now live on the objects already on screen — the card, and the Division Yard chip.
const game = newGame(430);
let worst = 0;
for (let i = 0; i < 400 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
const shown = menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
.reduce((n, g) => n + g.actions.length, 0);
worst = Math.max(worst, shown);
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
assert.ok(worst <= 8, `the action list still reaches ${worst} buttons`);
});
it('makes up ONE train at a time, and names that train', () => {
// REPORTED: the history said Train 9 was being made up at the East Division Point while the
// panel above it read "Making up Train 10 — 18 kinds it may take are highlighted". Two trains
// can be built in the same Stage (a timetabled train plus a Second Section or an Extra), and
// every option from every tray was collected into one panel titled with whichever tray came
// first out of the map. Reproduced at seed 99, Day 3 Stage 12: eighteen chips, two trays.
//
// The panel must belong to exactly one tray — otherwise clicking a car in the yard couples it
// to whichever train owns the first matching option, which may not be the one named.
for (const seed of [99, 430, 270861860]) {
const game = newGame(seed);
for (let i = 0; i < 600 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.makeUp) {
const trays = new Set(
menu.makeUp.cars.map((c) => (menu.options[c.index] as { trayId: string }).trayId),
);
if (menu.makeUp.pass !== null) {
trays.add((menu.options[menu.makeUp.pass] as { trayId: string }).trayId);
}
assert.ok(trays.size <= 1, `seed ${seed}: the make-up panel covers ${trays.size} trains at once`);
for (const t of trays) {
assert.equal(t, menu.makeUp.trayId, `seed ${seed}: a car belongs to a train the panel does not name`);
}
const tray = game.state.trays.get(menu.makeUp.trayId);
assert.ok(tray, `seed ${seed}: the panel names a tray that does not exist`);
assert.ok(
menu.makeUp.title.includes(String(tray.trainNumber)),
`seed ${seed}: "${menu.makeUp.title}" does not name Train ${tray.trainNumber}`,
);
}
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
}
});
it('keys each make-up car to the yard chip that shows it', () => {
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
// exactly those cars by type and load state. The yard is the surface.
const game = newGame(430);
for (let i = 0; i < 400 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.makeUp && menu.makeUp.cars.length > 0) {
for (const car of menu.makeUp.cars) {
const intent = menu.options[car.index]!;
assert.equal(intent.type, 'newTrain.placeCar');
assert.equal((intent as { carType: string }).carType, car.carType);
assert.equal((intent as { loaded: boolean }).loaded, car.loaded);
}
assert.match(menu.makeUp.title, /Making up/, 'the make-up panel does not name the train');
return;
}
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
assert.fail('no train was ever made up, so this proves nothing');
});
it('offers ABS Signals the Mainline, not the Office Area', () => {
// REPORTED TWICE. First: the card says "any Mainline card" and every option offered was a
// square in the Office Area, because the candidate list handed grid cells to a check that read
// the column as a Division node index.
//
// Then the fix carried its own bug: a Mainline placement travelled as `{ row: -1, col: node }`,
// and row −1 is an ORDINARY DISTRICT ROW — the first one below the Running Track. So ABS
// Signals highlighted whichever district card sat at that column, and a real enhancement laid
// one row down was described as being out on the Mainline. A Mainline placement now carries a
// node and no coordinate at all, which is what this asserts.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
for (const [id, c] of game.state.cards) {
if (c.kind.kind === 'enhancement' && c.kind.key === 'absSignals') {
game.state.decks.hands.set(0, [id]);
break;
}
}
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((it) => it.spots);
assert.ok(spots.length > 0, 'ABS Signals was not offered anywhere');
for (const sp of spots) {
assert.match(sp.label, /out on the Mainline/, `not a Mainline card: ${sp.label}`);
assert.equal(sp.coord, null, 'a Mainline placement must not claim a square in the district');
const node = game.state.division.nodes[sp.node!];
assert.equal(node?.kind, 'mainline', `spot points at a ${node?.kind}, not a Mainline card`);
}
});
it('draws a train standing on a card, in order, with which way it points', () => {
// REPORTED: with a train in the station there was no way to see which cars it held or in what
// order, so the switching game could not be planned at all — "drop 1 car" says nothing when you
// cannot see what is on the back.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const id = 'crew';
game.state.trays.set(id, {
id, trainNumber: 10, trainIsExtra: false, engineAt: 0,
consist: [
{ type: 'tank', loaded: true },
{ type: 'boxcar', loaded: true },
{ type: 'caboose', loaded: false },
],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
assert.ok(cell.train, 'the card carries no train');
assert.deepEqual(cell.train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
assert.equal(cell.train!.engineAt, 0, 'the engine has no place in the train');
assert.equal(cell.train!.facing, 'e', 'which way it points is not carried');
const svg = officeSvg([cell], area.runningRow);
assert.match(svg, /bs-t-eng/, 'the engine is not drawn');
assert.match(svg, /bs-t-ld/, 'a loaded car is not drawn');
assert.match(svg, /cab</, 'the caboose is not named on the board');
});
it('draws the train the way it stands on the map — nose toward the way it faces', () => {
/**
* REPORTED at seed 270861860: Train 10 running EAST arrived at the Whistle Post drawn engine
* first at the WEST end, with the caboose at the east — which reads as an engine shoving its
* whole train ahead of it. "I'd expect it to look just like this, except with the engine and
* caboose reversed so that the engine is the eastmost of the train."
*
* `consist` is ordered nose first, and the renderer drew index 0 leftmost whatever the train was
* doing. A westbound train therefore came out right and an eastbound one came out mirrored.
*/
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const order = (facing: 'e' | 'w'): string[] => {
game.state.trays.set('crew', {
id: 'crew', trainNumber: 10, trainIsExtra: false, engineAt: 0,
consist: [
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: false },
{ type: 'caboose', loaded: false },
],
direction: facing === 'e' ? 'east' : 'west', facing,
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
const svg = officeSvg([cell], area.runningRow);
// The three-letter labels in the order they are drawn, west to east.
return [...svg.matchAll(/class="bs-tcarlab"[^>]*>([^<]+)</g)].map((m) => m[1]!);
};
// Nose first is engine, hopper, hopper, caboose. Running west the engine leads at the west end;
// running east the same train is drawn the other way round.
assert.deepEqual(order('w'), ['◀', 'hop', 'hop', 'cab'], 'a westbound train is drawn backwards');
assert.deepEqual(order('e'), ['cab', 'hop', 'hop', '▶'], 'an eastbound train is not turned round');
});
it('shows A/D tracks on the Office card, taken and total', () => {
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
// decides whether the next arrival is an automatic collision (§8.3).
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const cell = view(game).cells.find((c) => c.kind === 'office')!;
assert.ok(cell.ad, 'the Office card carries no A/D count');
assert.equal(cell.ad!.of, 1, 'a Whistle Post has one A/D track');
assert.equal(cell.ad!.used, 0);
assert.match(officeSvg([cell], area.runningRow), /bs-ad/, 'no A/D pips are drawn');
});
it('names the cars a drop would set out, and which end they come off', () => {
// REPORTED: "drop 1 car(s)" — which car? And because a nose drop and a tail drop read
// identically, the action list's duplicate-label filter discarded one of them outright, so
// setting out from the front of the train could not be chosen at all.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const id = 'crew';
game.state.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 1,
consist: [{ type: 'boxcar', loaded: true }, { type: 'caboose', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0,
} as never);
const front = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true });
const back = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1 });
assert.match(front, /loaded boxcar/, `the front cut is not named: ${front}`);
assert.match(front, /off the front/);
assert.match(back, /caboose/, `the back cut is not named: ${back}`);
assert.match(back, /off the back/);
assert.notEqual(front, back, 'the two ends still read identically, so one is dropped from the list');
});
it('shows where a played train card landed on the timetable', () => {
// REPORTED: playing a train card rolls 1D12 for its departure Stage and the card simply left the
// hand — the answer arrived only as one line in the history panel. The twelve slots have been in
// the Frame all along and only the standalone replay ever drew them.
const game = newGame(430);
let landed: number | null = null;
for (let i = 0; i < 900 && currentActor(game) !== null; i++) {
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
if (game.scheduled !== null) {
landed = game.scheduled;
break;
}
}
assert.ok(landed !== null, 'no train was ever scheduled');
const f = view(game);
assert.ok(f.timetable[landed!] !== null, 'the slot the roll reported is empty');
// The roll gets a sound, because it is the one die a player rolls and it landed silently.
assert.ok(game.cues.includes('schedule'), 'scheduling a train made no sound');
// And the panel flashes the slot that changed, marking the moment rather than the state.
const html = timetableHtml(f, landed);
assert.match(html, /tt-slot[^"]*fresh/, 'the new slot is not flagged');
assert.equal((timetableHtml(f, null).match(/fresh/g) ?? []).length, 0, 'the flash must not persist');
assert.match(html, new RegExp(`T${f.timetable[landed!]}`), 'the train is not named in its slot');
});
it('draws the side panels from one place, on both screens', () => {
// A replay is the game being WATCHED rather than played, so it should look like the game. The
// site's viewer had three panels against the play page's eight — no facilities, no blockers, no
// cards, no yards — which meant it could not answer "why is nothing moving?", the question a
// replay mostly exists to answer.
for (const f of ['src/web/main.ts', 'src/web/replays.ts']) {
const src = readFileSync(join(root, f), 'utf8');
for (const fn of ['pilesHtml', 'yardHtml', 'blockedHtml', 'facilitiesHtml']) {
assert.match(src, new RegExp(fn), `${f} does not use the shared ${fn}`);
}
assert.match(src, /PANEL_CSS/, `${f} does not ship the shared panel styling`);
}
// The HAND is the one panel that differs, and deliberately: on the play page every card carries
// its own play and discard verbs, while a replay's hand is a read-only row of what was held.
assert.match(readFileSync(join(root, 'src/web/replays.ts'), 'utf8'), /handHtml/);
assert.match(readFileSync(join(root, 'src/web/main.ts'), 'utf8'), /button class="cardact/);
// And the viewer's page must actually have somewhere to put them.
const html = readFileSync(join(dist, 'replays.html'), 'utf8');
for (const id of ['vhand', 'vdepts', 'vdivyard', 'vclsyard', 'vblocked', 'vfacs']) {
assert.match(html, new RegExp(`id="${id}"`), `the replay viewer has no #${id} panel`);
}
});
it('lets a replay change pace while it is playing', () => {
// The interval is created with whatever the speed select held when play started. Nothing re-read
// it in the site's viewer, so choosing "extra slow" mid-replay did nothing at all and the pace
// looked stuck — the standalone replay had always restarted its timer on change.
for (const [file, id] of [['src/web/replays.ts', 'vspeed'], ['src/sim/replay.ts', 'speed']]) {
const src = readFileSync(join(root, file!), 'utf8');
assert.match(
src,
new RegExp(`\\$\\('${id}'\\)[^;]*\\.onchange`),
`${file} never reacts to a speed change`,
);
}
});
it('offers the same five paces in both replay viewers', () => {
const standalone = readFileSync(join(root, 'src/sim/replay.ts'), 'utf8');
const viewer = readFileSync(join(root, 'src/web/replays.html'), 'utf8');
const paces = (src: string): string[] =>
[...src.matchAll(/<option value="\d+"[^>]*>([^<]+)<\/option>/g)].map((m) => m[1]!);
assert.deepEqual(paces(standalone), ['extra slow', 'slow', 'normal', 'fast', 'very fast']);
assert.deepEqual(paces(viewer), paces(standalone), 'the two replay viewers offer different paces');
});
it('gives every phase the engine can be in a pill on the turn chart', () => {
// The turn chart exists to say where in the Stage you are, so a phase with no pill is a hole
// exactly when the player most needs it. `Phase` is a closed union in the engine; this fails if
@@ -1135,12 +1824,12 @@ describe('the static build', () => {
const phases = [...decl![1]!.matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]!);
assert.ok(phases.length >= 5, `only found ${phases.length} phases`);
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
for (const p of phases) {
assert.match(src, new RegExp(`key: '${p}'`), `no turn-chart pill for the ${p} phase`);
}
// And every pill must carry a tooltip: an icon alone does not explain a phase.
const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: '/g)];
const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: ["']/g)];
assert.equal(tips.length, phases.length, 'a turn-chart pill has no tooltip');
const html = readFileSync(join(dist, 'play.html'), 'utf8');
@@ -1246,12 +1935,31 @@ describe('the Division map shows the whole route', () => {
it('draws the Division as a line with two ends, never as a loop', () => {
// Seating players around a table invites exactly one misreading: that the route joins up. It
// does not — trains enter at one Division Point and leave at the other.
// does not. Both ends are labelled as ends, not as an entrance and an exit: odd trains run west
// and even run east, so each Division Point is a way on AND a way off.
for (const players of [1, 2, 3, 4]) {
const svg = divisionFor(players);
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`);
assert.match(svg, /trains enter/, `${players}p does not label where trains enter`);
assert.match(svg, /trains leave/, `${players}p does not label where trains leave`);
assert.match(svg, /west end · in and out/, `${players}p does not label the west end`);
assert.match(svg, /east end · in and out/, `${players}p does not label the east end`);
/**
* AND THE LABELS MUST BE ON THE CANVAS.
*
* REPORTED: both were clipped. They hung off the OUTSIDE of the end cells, anchored away from
* the board, so fitting them meant padding wider than the caption — which the board did not
* have, and which would have spent that width on two captions rather than on the map. Centred
* under their own Division Point they cost nothing and cannot be cut off.
*/
const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg);
const W = Number(vb![1]);
const ends = [...svg.matchAll(/class="bs-end" x="([\d.]+)" y="([\d.]+)" text-anchor="middle">([^<]+)</g)];
assert.equal(ends.length, 2, `${players}p: an end label is not centred under its cell`);
for (const e of ends) {
const x = Number(e[1]);
const half = e[3]!.length * 3; // ~6px a character in the 10px monospace this uses
assert.ok(x - half >= -24 && x + half <= W + 24, `${players}p: "${e[3]}" is clipped at x=${x} of ${W}`);
}
}
});
});
@@ -1278,13 +1986,21 @@ describe('every square the menu offers can actually be clicked (regression)', ()
// is offered a square OFF the Running Track, which is the case that used to draw off-canvas.
const { option: turnout } = dealTrack(game, 'turnout', 'left');
if (turnout) submit(game, turnout);
dealTrack(game, 'curved', 'left');
// Hold BOTH the curve its leg calls for and a straight: the curve is offered the square off
// the Running Track, the straight the growth points on it. A curve may no longer be laid in
// the running row at all — it has no east-west road and would dead-end the main — so on its
// own it now offers a single square and the bounds check would barely exercise anything.
const curve = dealTrack(game, 'curved', 'left');
const straight = dealTrack(game, 'straight', 'none');
game.state.decks.hands.set(0, [curve.cardId, straight.cardId]);
const menu = actionMenu(game);
const f = view(game);
for (const item of menu.placeable.flatMap((g) => g.items)) {
const uniq = [...new Map(item.spots.map((sp) => [`${sp.coord.row},${sp.coord.col}`, sp.coord])).values()];
// Only the spots that ARE on this board: a Mainline placement has no square to draw.
const onBoard = item.spots.flatMap((sp) => (sp.coord === null ? [] : [sp.coord]));
const uniq = [...new Map(onBoard.map((c) => [`${c.row},${c.col}`, c])).values()];
const ghosts = uniq.filter((c) => !f.cells.some((x) => x.row === c.row && x.col === c.col));
const svg = officeSvg(f.cells, f.runningRow, ghosts);
const vb = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);