Compare commits
4
Commits
9c76b1a5e2
...
5d825b97d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d825b97d2 | ||
|
|
f4c0f49604 | ||
|
|
543584a2ca | ||
|
|
1c321d4cea |
+454
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'];
|
||||
|
||||
+329
-49
@@ -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.
|
||||
for (let i = 0; i < Math.max(1, f.greenCap); i++) {
|
||||
out += box(bx, by, i < f.green.length, 'bs-green');
|
||||
bx += cellW + 2;
|
||||
// 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 += 9;
|
||||
}
|
||||
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.
|
||||
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
@@ -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
@@ -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' : '');
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
+403
-192
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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.
|
||||
/**
|
||||
* 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 +=
|
||||
`<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 group.items) {
|
||||
const open = selected === item.subjectKey;
|
||||
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) {
|
||||
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.
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
@@ -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
@@ -99,22 +99,41 @@ 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">—</span>
|
||||
<span>phase: <b id="vphase">—</b></span>
|
||||
<span>Revenue <b id="vrev">0</b></span>
|
||||
<span class="dim">← → to step, space to play</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
<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">—</div>
|
||||
<h3>Department Decks (top card face up)</h3><div id="vdepts" class="cardrow ref">—</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>
|
||||
<section><h2>What happened</h2><div id="vlog"></div></section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+33
-3
@@ -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();
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
+24
-1
@@ -24,7 +24,25 @@ function ensure(): HTMLElement {
|
||||
|
||||
function place(el: HTMLElement, text: string): void {
|
||||
const t = ensure();
|
||||
t.textContent = text;
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
|
||||
@@ -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
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user