Compare commits
4
Commits
9f3b92d08e
...
37b1e5b969
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b1e5b969 | ||
|
|
fbaa3d4147 | ||
|
|
7932bb6ccf | ||
|
|
461c4d9bac |
+6
-1
@@ -8,7 +8,12 @@ Thumbs.db
|
||||
*.swp
|
||||
|
||||
# Dependencies / build output
|
||||
node_modules/
|
||||
#
|
||||
# NO TRAILING SLASH on node_modules, deliberately. `node_modules/` matches a directory and not a
|
||||
# SYMLINK of that name — so one pointing at a shared install was committed in v0.4.7, and because it
|
||||
# pointed at a path that does not exist, a fresh checkout had no TypeScript: `npm run typecheck` said
|
||||
# "tsc: not found" and 20 tests failed for a reason that had nothing to do with the code.
|
||||
node_modules
|
||||
dist/
|
||||
build/
|
||||
target/
|
||||
|
||||
+199
@@ -19,6 +19,205 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.4.8 — 2026-08-19
|
||||
|
||||
Four reports from the same game, all about squares: which ones a card may go on, which one a button
|
||||
in the action list means, when there is more than one legal road to the same square which one a
|
||||
train actually takes, and — when more than one train is standing at the same Office — which is
|
||||
which.
|
||||
|
||||
### The Limits bound the district, not just the Running Track
|
||||
|
||||
**Reported:**
|
||||
|
||||
> "Sidings should not be allowed to be built outside the limits."
|
||||
|
||||
The Limits sign "denotes the limit of your control area" (§2.1) and §3 defines Secondary Track as
|
||||
"all tracks **in your limits** that are not the Running Track" — but the bound was only ever enforced
|
||||
on the Running Track row itself, with a comment in `canPlaceAt` stating outright that "the district
|
||||
below the Running Track is unbounded". So a siding could run east past a player's own sign, take
|
||||
industries with it, and put cars on track that §8.1 and §10 do not consider his territory at all: the
|
||||
rules reason about "the track between the train and the Limits", and running past another player's
|
||||
Limits is what makes a collision his fault.
|
||||
|
||||
`withinLimits` is now the district's rule at **every row**, and a Facility is bounded by it too —
|
||||
§11.2 is explicit that "Facility cards carry their own rails: placing a Facility places track". The
|
||||
refusal is its own code, `OUTSIDE_LIMITS`, rather than `NOT_CONNECTED`, for the same reason
|
||||
`BREAKS_RUNNING_TRACK` is: the card would join perfectly well, and is refused for a different reason.
|
||||
|
||||
**Inclusive of the sign's own column**, which is the half that keeps the game playable. Jesse's call.
|
||||
The sign stands *on* the boundary rather than beyond it, and a district opens with signs at ±1 around
|
||||
the Office — so the strict reading would leave exactly one buildable column and break §11.3's promise
|
||||
that both Secondary rows, and the nine-spot Modifier neighbourhood, are usable from the first Stage.
|
||||
A siding may run under the sign; nothing may go past it.
|
||||
|
||||
**What it costs the bot: nothing measurable.** Out-of-limits building was rare for it to begin with —
|
||||
5 cards across 100 games, in 4 of them, never more than two columns over — and paired over 200 seeds
|
||||
the bound moved 4 games and −0.015 Revenue (t = −0.26). It is a human building deliberately who hits
|
||||
this, which is exactly how it was found.
|
||||
|
||||
### The nine spots really are nine — the four diagonals were legal and unofferable
|
||||
|
||||
**Reported:**
|
||||
|
||||
> "Modifiers should be able to go on any diagonal — we were unable to place it to the south-east."
|
||||
|
||||
§9 places a Modifier "adjacent to a Facility, on any of the nine nearby spots", and `check` has
|
||||
always accepted all eight neighbours. The fault was upstream in `placementCandidates`, which walks
|
||||
north, south, east and west from every occupied square: a diagonal square with no *orthogonal*
|
||||
occupied neighbour was never generated, so it was never offered. Measured on a facility below the
|
||||
Office: `check` says legal at all four diagonals, the menu offered the three orthogonal ones. At a
|
||||
stub industry — the case in the report — every diagonal is in that position.
|
||||
|
||||
Generated in a **second pass, only around a Facility**. Track has to *join* and a diagonal shares no
|
||||
edge, so a Modifier is the only card those squares can ever take; generating diagonals around every
|
||||
card instead costs 54% more simulation time producing candidates `check` then rejects. Appending
|
||||
rather than interleaving leaves the existing candidate order untouched, which matters because the bot
|
||||
breaks ties by first-best — measured over 200 seeded games, **0 played differently**, so every figure
|
||||
in `TODO.md` still stands.
|
||||
|
||||
### A Modifier may hang outside the Limits — but not in the Running Track's row
|
||||
|
||||
Jesse's call, both halves. A Modifier is not track (§9), so unlike a siding it keeps all nine of its
|
||||
spots when its host stands at the limit: refusing the outer three would make the card unplayable
|
||||
exactly where the district ends. What it may not do is stand in the row the Running Track grows
|
||||
along. Inside the Limits that row is always full, so the rule bites only beyond the sign — and that
|
||||
is the ground the main extends onto, where a parked Modifier would block the player's own sign from
|
||||
moving outward (§2.1) with nothing on screen to warn him. Industries have been barred from the
|
||||
Running Track since the "Placed" column was read properly; this is the same rule for the same reason.
|
||||
|
||||
### The board draws where the district ends
|
||||
|
||||
Two signs on one row cannot say that nothing may be built outside their columns at *any* row: a
|
||||
player looking at open ground beyond a sign had no way to know it was unbuildable until the square
|
||||
failed to light up. `officeSvg` now draws a quiet dashed edge down the whole canvas at each limit,
|
||||
labelled, and just **outside** the sign's own column so the picture agrees with the rule — a siding
|
||||
may run under the sign. It moves outward on its own as the Running Track is extended, which is the
|
||||
reward for extending made visible. The Frame carries `limits` for it, resolved server-side like
|
||||
everything else a remote client cannot look up (`multiplayer.md` §5).
|
||||
|
||||
The margin on the viewBox is not cosmetic: the west sign is usually the leftmost card on the canvas,
|
||||
so the line outside its column lands at x = −1.5 and simply is not there on a canvas starting at 0.
|
||||
Caught by drawing one, not by reading the code.
|
||||
|
||||
### Hovering an action points at the square it means
|
||||
|
||||
**Reported:**
|
||||
|
||||
> "When I have my mouse over the action for a particular square, could the corresponding square in
|
||||
> the office area be highlighted to minimize my mistakes of selecting the wrong square — 1,3 versus
|
||||
> −1,3? In a solitaire game with Undo it is not fatal. In a multiplayer game that could be a major
|
||||
> disaster."
|
||||
|
||||
The action list is a column of near-identical sentences separated by a coordinate, and the board
|
||||
beside it said nothing about which was which. Every action that names a square now carries it as
|
||||
`data-square`, and hovering — or focusing, so the keyboard route is not a lesser one — lights that
|
||||
square on the board, ghost targets included, which is precisely the moment a player is choosing
|
||||
between coordinates.
|
||||
|
||||
The coordinate rides on the **menu**, resolved from the intent by `coordOf`, rather than being parsed
|
||||
back out of the label: a remote client holds no `GameState` and cannot look up where a tray is
|
||||
standing, which is the same reason the menu already carries card descriptions. Checked over 12 bot
|
||||
games: 870 direct actions name a square, and all 870 carry it.
|
||||
|
||||
### Two routes to the same square
|
||||
|
||||
**Reported:**
|
||||
|
||||
> "There are times when a train can take two different paths to get to a destination. See game at
|
||||
> undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two different ways. It can go
|
||||
> through the refinery and pick up the tanker on its nose, or it could go straight and then make the
|
||||
> curve and skip the refinery and not pick up the car. The player should have the two different
|
||||
> options."
|
||||
|
||||
A district with a passing loop — turnout off the main, curve down to an industry, curve back up —
|
||||
offers two legal routes between the same two squares, and `exploreMoves` (`track.ts`) only ever found
|
||||
one: it keyed its visited set on destination and entry port, both routes rejoin through the same
|
||||
port, and the shorter one won the race before the longer one could be recorded. Which route survived
|
||||
was an artifact of search order, not a choice.
|
||||
|
||||
**Ruling (Jesse, docs/rules/open-questions.md Gap 14):** the player may choose the path. §A.4 says
|
||||
"cars **on your track**" — the industry spur is a different track, and declining to enter it is not
|
||||
going around the car standing there. The mandatory half still bites in full once a route is chosen:
|
||||
every car standing on it couples.
|
||||
|
||||
**The walk now enumerates every simple route** — a per-path visited set replaces the old global one,
|
||||
so a card may be revisited across different routes but never twice within one — capped at 4,000
|
||||
frontier nodes so a dense district cannot blow the walk up combinatorially; BFS order means the cap
|
||||
loses only the longest routes first. Routes are deduped on **outcome**, not on reaching the square:
|
||||
`(destination, entry side, [(origin card, car)…])`, so two routes coupling identical car *types* off
|
||||
*different* cards are both offered and each sweeps its own card, while two routes with nothing to
|
||||
tell apart collapse to one.
|
||||
|
||||
**`switch.move` gained an optional `via: GridCoord`** — one intermediate square on the chosen route,
|
||||
never the start or the destination. `legal.ts` emits one intent per distinct route with a `via` that
|
||||
distinguishes it from its siblings; `via` absent resolves exactly as before (the first route
|
||||
enumerated), so every existing save and every bot decision replays identically. Checked against
|
||||
`public/replays/*.json` and the full suite: 575 pass, 0 fail, unchanged.
|
||||
|
||||
Threaded through the label (`describeIntent` finds the route `via` names, so "couples 1 tanker"
|
||||
reports the cars *that* route actually lifts), the action-list dedupe (two routes to one square would
|
||||
otherwise print the identical button twice), the hover highlight (`data-route` lights every square a
|
||||
route runs over, not just its destination), and the history (`trayMoved.via`, when the move was
|
||||
ambiguous, names which road the crew took).
|
||||
|
||||
**Balance impact: none expected, and none measured able to be attributed to this.** Instrumented over
|
||||
60 developer-bot games (17,884 destinations enumerated): squares reachable by 2+ distinct paths are
|
||||
~1.2% (208), and in every one of those 208 the routes coupled identically — the bot has never yet
|
||||
built a district where the discarded route would have mattered. 400 full games (200 developer bot,
|
||||
200 random bot) completed 200/200 with no exceptions on the reworked walk. `compare.ts` needs an
|
||||
ablation flag to run at all and this is not a bot heuristic, so paired A/B was not applicable here;
|
||||
the enumeration-based argument above is the actual evidence.
|
||||
|
||||
### The Roster Pass — every train at the Office visible
|
||||
|
||||
**The Office is the one square where more than one train may legally stand at once** — it has
|
||||
several A/D tracks, each holding one — and the board drew it as if only one ever could. Two trays
|
||||
at a Station couple onto the identical `officeCoord`; `trainOnCard()` returned on the first match
|
||||
and `CellView.train` had room for exactly one, so a second train was counted in the old A/D pips
|
||||
and never drawn at all. Reported directly, and analysed in "Two Trains, One Card": the pips and the
|
||||
picture were reading different fields.
|
||||
|
||||
**`CellView.train` is now `CellView.trains: TrainView[]`** — every train standing on the card, each
|
||||
carrying its own `trayId`. The A/D pips are gone; in their place is one roster chip per A/D track,
|
||||
always (`adTracks` of them, not one per train), free tracks reading a dashed, dimmed "free" rather
|
||||
than vanishing. The selected train's chip is lit the same amber the crew strip and action buttons
|
||||
use, and its consist is the one drawn at the rail — clicking any occupied chip sets `selectedCrew`,
|
||||
the same value the "Which train are you switching?" picker already wrote, so the board and the
|
||||
action panel drive one value in both directions.
|
||||
|
||||
**`standingWest` moved from `CrewTray` to `TrackCard`.** The split still means what it always meant
|
||||
— west/east of the row of standing cars — and it is still only meaningful while an engine stands on
|
||||
the card (an unattended cut has no near or far side; that reasoning did not change). What forced the
|
||||
move: two trays sharing one Office card could otherwise hold two different splits of the identical
|
||||
row, and there is no such thing as "west of one particular A/D track" — a cut lies west or east of
|
||||
the *whole block* of A/D tracks, so there has to be exactly one number for the card to carry. No
|
||||
save migration: `Save = {seed, history, rules?}` replays through the engine rather than being
|
||||
loaded, and a stale value on an emptied card is inert because nothing reads a split with no train
|
||||
standing there.
|
||||
|
||||
**The Division map stopped drawing trains on their neighbours.** The Office's Running Track cell was
|
||||
a fixed 78px regardless of how many A/D tracks it had, so two chips centre-spread wider than the
|
||||
cell and spilled onto the Limits cards either side. The cell is now sized by **capacity**
|
||||
(`max(78, adTracks·54+12)`), not by how many tracks are occupied — sized by occupancy instead, the
|
||||
board would have shifted the East Division Point sideways every time a train arrived or left — and
|
||||
chips are laid into fixed slots, one per A/D track, so none can ever overhang the cell.
|
||||
|
||||
**Verified:** two trays at a Station are both drawn, each with its own chip; a Terminal's four
|
||||
tracks all fit; a bare cut with a stale `standingWest` still draws left-aligned; every roster chip's
|
||||
x-extent lies inside its Office cell at full occupancy; and a packed replay row from before this
|
||||
feature — a lone train object, not an array — rehydrates into a one-train roster rather than
|
||||
throwing. 584 tests, 0 failures.
|
||||
|
||||
### Also
|
||||
|
||||
- The three published replays were re-recorded. A save is a seed and its intents, so tightening a
|
||||
placement rule kills every replay containing one — `harness.test.ts` catches it rather than letting
|
||||
them go quietly dead, which has happened twice before. Old saves are not preserved across rule
|
||||
changes and are not meant to be yet.
|
||||
- `node_modules` was a **tracked symlink pointing at a path that does not exist**, so `tsc` was
|
||||
missing and 20 tests failed before any of this was written. Replaced with the real dependencies.
|
||||
|
||||
## 0.4.7 — 2026-08-19
|
||||
|
||||
Eight play reports and one design that had been written up and not built. The through-line is the
|
||||
|
||||
@@ -10,7 +10,7 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed.
|
||||
|
||||
## Status
|
||||
|
||||
**v0.4.3 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
|
||||
**v0.4.8 — 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** — specified, with **three open questions** left. Thirteen gaps in the original prototype
|
||||
|
||||
@@ -52,6 +52,14 @@ Ordered within each section by how much it is currently costing us.
|
||||
this needs re-measuring); or move the Express's freight budget into the Cargo phase, where an
|
||||
expedited train does still get a turn. **Nothing is broken** — this is a contradiction between
|
||||
two lines on one card, and the timing rule itself is behaving exactly as recorded.
|
||||
- [ ] **A DISTRICT CAN NOW ONLY WIDEN AS FAR AS ITS MAIN REACHES (v0.4.8) — worth watching in the
|
||||
rebalance rather than acting on now.** Track stays inside the Limits at every row, so extending
|
||||
the Running Track is the only way to buy room for sidings, and a straight laid on the sign is
|
||||
worth more than it was. The bot barely notices — it built outside its own Limits 5 times in 100
|
||||
games — but the bot also builds close to its Office; a human building deliberately hits this on
|
||||
the first wide district, which is how it was reported. If territory turns out to be the real
|
||||
constraint on freight, this is one of the two places to look (the other is the track supply,
|
||||
below).
|
||||
- [ ] **REBALANCE, once the rules are right — deliberately deferred.** Card counts, industry counts
|
||||
and the track mix all need a pass together, and none of them should move until the rules stop
|
||||
moving. Standing distortions to account for when it happens: offices are doubled (Q12) and
|
||||
@@ -623,6 +631,13 @@ target is settled and freight carries its intended share.
|
||||
- [ ] **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.
|
||||
- [ ] **The test suite fails at random under `npm test`, and it is the runner rather than the code.**
|
||||
`node --test test/**/*.test.ts` runs the files in parallel and three suites write and read the
|
||||
same `dist/` — the static build, the published-replay check and "the three places a game is
|
||||
drawn stay in step". Back-to-back full runs measured **9 failures then 0**; run one file at a
|
||||
time and every suite passes. That is worse than a slow suite: it trains us to shrug at a red
|
||||
run, which is exactly how a real regression gets waved through. Give the build test its own
|
||||
output directory, or mark the trio to run serially.
|
||||
- [ ] **Curves are drawn as two straight segments meeting**, not true arcs. Fine at this size, angular
|
||||
close up.
|
||||
- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# Plan — Train Switching Paths: two routes to the same square
|
||||
|
||||
**Status:** built, v0.4.8. Written against `461c4d9`; see `docs/rules/open-questions.md` Gap 14 and
|
||||
the CHANGELOG's "Two routes to the same square" for the ruling and what shipped.
|
||||
**Reported by Jesse:** *"There are times when a train can take two different paths to get to a
|
||||
destination. See game at undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two
|
||||
different ways. It can go through the refinery and pick up the tanker on its nose, or it could go
|
||||
straight straight and then make the curve and skip the refinery and not pick up the car. The player
|
||||
should have the two different options."*
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect
|
||||
|
||||
A district with a passing loop — turnout down off the main, curve, industry, curve, turnout back up —
|
||||
offers two routes from one end to the rejoin square. Both are legal under §2.4 (a Move runs any
|
||||
distance without changing direction; neither route ever leaves a card by the port it entered). They
|
||||
differ in what they couple.
|
||||
|
||||
**The engine offers only one of them, and which one is an artifact of search order.**
|
||||
|
||||
`exploreMoves` (`src/engine/track.ts:386`) keys its visited set on destination *and entry port*:
|
||||
|
||||
```ts
|
||||
// src/engine/track.ts:492
|
||||
const key = `${coordKey(node.coord)}|${node.entry}`;
|
||||
if (seen.has(key)) continue;
|
||||
```
|
||||
|
||||
Both routes arrive at the rejoin square through the **same port**, so they collide on that key. The
|
||||
walk is breadth-first, so the shorter route is recorded and the longer one is discarded before it can
|
||||
become a destination.
|
||||
|
||||
### Reproduction
|
||||
|
||||
Fixture — Running Track on row 0, industry spur on row −1:
|
||||
|
||||
| | col 0 | col 1 | col 2 | col 3 | col 4 |
|
||||
|---|---|---|---|---|---|
|
||||
| **row 0** | straight | turnout `{w,e,s}` | straight | turnout `{e,w,s}` | Limits (east) |
|
||||
| **row −1** | — | curve `ne` | **Refinery + tanker** | curve `nw` | — |
|
||||
|
||||
Walking west from `(0,4)`, today's engine returns:
|
||||
|
||||
```
|
||||
(0,2) entry=e couples=[] path=(0,3)
|
||||
(-1,3) entry=n couples=[] path=(0,3)
|
||||
(-1,2) entry=e couples=[tanker] path=(0,3)->(-1,3)
|
||||
(0,0) entry=e couples=[] path=(0,3)->(0,2)->(0,1) ← only one route to (0,0)
|
||||
(-1,1) entry=e couples=[tanker] path=(0,3)->(-1,3)->(-1,2)
|
||||
```
|
||||
|
||||
The refinery route to `(0,0)` — which couples the tanker — is absent. A simple-path prototype on the
|
||||
same fixture returns both. Verified identical on v0.4.7 and v0.4.8.
|
||||
|
||||
### It is not cosmetic
|
||||
|
||||
`dest.path` feeds `carsCoupled.from` (`src/engine/apply.ts`, the `switch.move` execute branch at
|
||||
`:1303`) — the list of cards swept clean when cars are lifted. The route reaches the reducer, so
|
||||
"which route" must be part of the intent, not just the button text.
|
||||
|
||||
### How often
|
||||
|
||||
Instrumented over 60 developer-bot games (17,884 destinations enumerated):
|
||||
|
||||
| measure | count |
|
||||
|---|---|
|
||||
| destinations enumerated | 17,884 |
|
||||
| squares reachable by 2+ **distinct paths** | 208 (~1.2%) |
|
||||
| squares where the routes **differed in what they couple** | **0** |
|
||||
|
||||
So the geometry occurs and today's engine silently discards a route about 1% of the time — but the
|
||||
bot never builds a district where the discarded route *matters*. Two consequences:
|
||||
|
||||
- **Balance impact is expected to be nil.** Still confirm with `compare.ts` (see §6).
|
||||
- **The bot will never regression-test this.** It needs explicit unit tests.
|
||||
|
||||
---
|
||||
|
||||
## 2. The rules question, and Jesse's ruling
|
||||
|
||||
§A.4 says: *"If you move your Crew Tray into cars on your track, you **MUST** pick them up. You may
|
||||
not go around them."* Whether taking the main instead of the siding counts as "going around" decides
|
||||
whether this feature is legal at all.
|
||||
|
||||
**Ruling (Jesse):** the player may choose the path. Different paths may pick up different cars, or
|
||||
none.
|
||||
|
||||
The permissive reading is well-supported by the text: §A.4 says *"cars on your track"*, and the
|
||||
industry spur is a different track. Declining to enter it is not going around the tanker — it is not
|
||||
going to the tanker's track at all, which is ordinary railroading. The mandatory half still bites in
|
||||
full: once a route is chosen, every car standing on it couples, and there is no route that passes a
|
||||
car and leaves it.
|
||||
|
||||
**Doc changes:** add the clarifying sentence to §A.4 in `docs/rules/rules-v0.2.md`, and record the
|
||||
ruling in `docs/rules/open-questions.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Design decisions
|
||||
|
||||
**The discriminator is `via`: one intermediate `GridCoord` on the chosen route** (Jesse's call).
|
||||
Not an index into the destination list — intents are the canonical record and `undo` replays history
|
||||
minus one intent, so an order-dependent field would silently reinterpret saved games.
|
||||
|
||||
```ts
|
||||
// src/engine/intents.ts:19
|
||||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean; via?: GridCoord }
|
||||
```
|
||||
|
||||
- `via` names an **intermediate** card — never the start, never the destination.
|
||||
- **Resolution:** among destinations at `to` matching `reverse`, take the first enumerated route whose
|
||||
path contains `via`. Enumeration stays shortest-first.
|
||||
- **`via` absent resolves exactly as today** (first route at `to`), so existing histories replay
|
||||
unchanged. Note v0.4.8 already established that saves are *not* preserved across rule changes —
|
||||
this is belt-and-braces, not a hard constraint.
|
||||
- **`legal.ts` emits a distinguishing `via`** per route, so a generated intent is never ambiguous.
|
||||
|
||||
**Collapse routes whose outcome is identical.** Two routes that lift the same cars from the same
|
||||
cards are not a choice; offering both is noise in the action list and doubles the bot's branching
|
||||
factor for nothing.
|
||||
|
||||
**The dedupe key must include the origin cards, not just car types.** Two routes can couple an
|
||||
identical car *multiset* lifted from *different* cards. Keying on type alone would collapse them and
|
||||
sweep the wrong card clean. Key on `(coord, entry, [(origin card, car)...])`.
|
||||
|
||||
**Simple-path walk with an enumeration cap.** Relaxing `seen` to a per-path visited set keeps
|
||||
termination (no card twice within one path), but the number of simple paths is worst-case exponential
|
||||
in a dense district. Cap enumeration and prefer shortest routes when the cap bites.
|
||||
|
||||
> Note: the 45°-diagonal matching rule appears to make closed loops geometrically impossible — a
|
||||
> rectangle of four curves cannot close, because the slopes do not match at the corners. **Do not
|
||||
> rely on this.** Use a path-local visited set and a cap regardless.
|
||||
|
||||
---
|
||||
|
||||
## 4. Change list
|
||||
|
||||
| File | Site | Change |
|
||||
|---|---|---|
|
||||
| `src/engine/track.ts` | `exploreMoves` :386, `seen` :492 | Per-path visited set; dedupe on outcome; cap. Preserve shortest-first ordering. |
|
||||
| `src/engine/intents.ts` | :19 | Add `via?: GridCoord`. |
|
||||
| `src/engine/apply.ts` | `check` :623 | Select destination by `via`, not first-by-coord. |
|
||||
| `src/engine/apply.ts` | `execute` :1303 | Same selection; `carsCoupled.from` follows the chosen path. |
|
||||
| `src/engine/apply.ts` | `destinationsFor` :1210, `movesFor` :1260 | Unchanged signatures; `movesFor` still collapses to one square per coord for the board. |
|
||||
| `src/engine/legal.ts` | :90 | Emit one intent per distinct route, each with a distinguishing `via`. |
|
||||
| `src/sim/view.ts` | `describeIntent` :724 | Find the destination by `via` so the label names the cars *that route* couples. |
|
||||
| `src/web/game.ts` | `coordOf` :113, label dedupe ~:229 | Labels must differ per route or the label-dedupe drops one button. |
|
||||
| `src/web/main.ts` | `cellRef` :660 | Route-on-hover — see §5. |
|
||||
| `src/sim/narrate.ts` | move narration | Name the route when it is not the only one. |
|
||||
|
||||
**`ownCutFor` (`apply.ts:1241`) needs no change** — it depends only on the exit port, not the route.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI — route on hover (Jesse's choice)
|
||||
|
||||
v0.4.8 already built the mechanism: `coordOf(intent)` → `cellRef` → `data-square="r,c"` on each
|
||||
action button, and hovering or focusing lights that square. This feature extends it from one square
|
||||
to a route.
|
||||
|
||||
- Carry the path on the **menu**, resolved server-side, for the same reason `coordOf` is: a remote
|
||||
client holds no `GameState`.
|
||||
- Emit the route as a second attribute (`data-route="r,c r,c …"`); hovering or focusing lights every
|
||||
card the route runs over, with the destination keeping its existing stronger mark.
|
||||
- **On focus as well as hover**, matching v0.4.8 — the keyboard route is not a lesser one.
|
||||
|
||||
Target rendering:
|
||||
|
||||
```
|
||||
Switching Train 11, standing at (1, 4)
|
||||
[ move to (1,1) ]
|
||||
[ move to (1,1) — couples 1 tanker on the way (onto the nose) ] ← hover
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
- **Unit test the fixture in §1** — both routes offered, one coupling the tanker and one not.
|
||||
- **`via` round-trip** — each intent `legal.ts` emits resolves back to the route it was generated for.
|
||||
- **Back-compat** — replay `public/replays/*.json` and assert identical outcomes with `via` absent.
|
||||
- **Origin-card dedupe** — two routes coupling identical car *types* from *different* cards must both
|
||||
be offered, and each must sweep its own card.
|
||||
- **Cap** — a dense district must terminate and stay bounded.
|
||||
- **Balance** — `node src/sim/compare.ts 1600`. Expect no movement (the bot never builds this
|
||||
geometry); keep only at t ≥ 3 per `README.md`, and read the better/worse/identical split.
|
||||
|
||||
**Run the suite serially: `node --test --test-concurrency=1`.** The `npm test` script runs files in
|
||||
parallel and several of them invoke the static build against a shared `dist/`, which produces ~20
|
||||
spurious failures. Measured on `461c4d9`: parallel 20 fail / serial **575 pass, 0 fail**.
|
||||
|
||||
> v0.4.8's CHANGELOG attributes those 20 failures to the tracked `node_modules` symlink. That fix was
|
||||
> real and worth making, but it is **not** the whole cause: on this checkout `tsc` resolves and
|
||||
> typecheck exits 0, yet the same 20 fail under parallel execution and pass under
|
||||
> `--test-concurrency=1`. The `dist/` race is still there. Worth fixing separately — give each
|
||||
> build-invoking test its own output directory.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open question for Jesse
|
||||
|
||||
A route's cards may be reachable only as a *subset* of a longer route's cards. If that ever happens,
|
||||
no single `via` uniquely selects the shorter route, and it is addressable only by the
|
||||
shortest-first tiebreak. It did not arise in any fixture or in 60 bot games, and it may be
|
||||
geometrically impossible — but if it does arise, the alternatives are to reject the ambiguous intent
|
||||
or to let shortest-first stand. **Shortest-first is assumed here.**
|
||||
@@ -138,8 +138,14 @@ above and below. Upgrades are drop-in and must never disturb a connection.
|
||||
## 5. Modifier cards
|
||||
|
||||
Five cards, one each. A Modifier is **not track**. It is placed adjacent to a Facility, on any of the
|
||||
nine nearby spots, and if adjacent to two Facilities its effect may be used on only one per Stage
|
||||
(§9).
|
||||
nine nearby spots — **diagonals included** — and if adjacent to two Facilities its effect may be used
|
||||
on only one per Stage (§9).
|
||||
|
||||
Two placement rules follow from it not being track. It is **not bounded by the Limits**: a host
|
||||
standing at the edge of the district keeps all nine of its spots, or the card would be unplayable
|
||||
exactly where the district ends. And it may **never stand in the Running Track's row**, which inside
|
||||
the Limits is always full anyway — beyond the sign that row is the ground the main grows onto, and a
|
||||
Modifier parked there would block the Limits sign from moving outward (§2.1).
|
||||
|
||||
| Card | Effect | Applies to |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -28,6 +28,7 @@ left open, with the consequence noted.
|
||||
| 11 | Track card orientation | **SUPERSEDED** — handedness is printed; both hands supplied |
|
||||
| 12 | Balance targets | **INVALIDATED** — measured against a placeholder ruleset |
|
||||
| 13 | Deck scaling by player count | **LARGELY DISSOLVED** — track is per-player by design |
|
||||
| 14 | Two routes to the same square | **DECIDED** |
|
||||
|
||||
---
|
||||
|
||||
@@ -1003,3 +1004,28 @@ needs to get started, while giving each player material to build with.
|
||||
|
||||
**Not yet decided**, because it interacts with Gap 12's variance question: more facilities per player
|
||||
would raise the ceiling on the runaway mode as well as the floor.
|
||||
|
||||
## Gap 14 — Two routes to the same square
|
||||
|
||||
**Status:** DECIDED.
|
||||
|
||||
**Reported by Jesse:** "There are times when a train can take two different paths to get to a
|
||||
destination. See game at undo 379. Train 11 can go from Eastern limits to the straight at 1,1 two
|
||||
different ways. It can go through the refinery and pick up the tanker on its nose, or it could go
|
||||
straight and then make the curve and skip the refinery and not pick up the car. The player should
|
||||
have the two different options."
|
||||
|
||||
**The question:** a passing loop — turnout off the main, curve down, industry, curve back up — offers
|
||||
two legal routes between the same two squares, and they couple different cars. Does §A.4's "you may
|
||||
not go around" a car standing on your track forbid taking the bypass?
|
||||
|
||||
**Ruling: the player may choose the path.** Different paths may pick up different cars, or none.
|
||||
§A.4 says "cars **on your track**", and the industry spur is a different track — declining to enter
|
||||
it is not going around the car standing on it, it is not going to that car's track at all, which is
|
||||
ordinary railroading. The mandatory half still bites in full once a route is chosen: every car
|
||||
standing on the chosen route couples, and there is no route that passes a car and leaves it.
|
||||
|
||||
**Built in v0.4.8** — see `docs/plans/switching-paths.md`. The engine enumerates every distinct
|
||||
simple route, dedupes by outcome (destination, entry side, and origin-tagged cars) rather than by
|
||||
destination alone, and an intent that would otherwise be ambiguous carries a `via` naming one
|
||||
intermediate square on the chosen route.
|
||||
|
||||
@@ -593,10 +593,16 @@ horizontal row through the Office, Limit-to-Limit; everything else is Secondary
|
||||
- Plain track, turnouts and Facility cards are all played from hand into an empty adjacent grid spot,
|
||||
and **must connect to existing track**. Facility cards carry their own rails — placing a Facility
|
||||
places track.
|
||||
- Track stays **inside the Limits at every row**, not only on the Running Track. §2.1 makes the sign
|
||||
"the limit of your control area" and §3 defines Secondary Track as "all tracks in your limits", so
|
||||
a siding may run *under* a sign — the sign stands on the boundary — and never past it. Widening a
|
||||
district therefore means extending the main first.
|
||||
- Extending the **Running Track** horizontally moves that player's Limits sign outward with it (§2.1).
|
||||
Growth is uncapped in the prototype.
|
||||
- Modifier Facility cards are not track. They are placed adjacent to a Facility, on any of the nine
|
||||
nearby spots (§9).
|
||||
nearby spots (§9) — diagonals included, and not bounded by the Limits, since a host at the edge of
|
||||
the district would otherwise have nowhere to put one. The one square they may not take is in the
|
||||
Running Track's own row, which is where the main grows.
|
||||
|
||||
A longer Running Track is more track to keep clear of arriving trains, so territory carries its own
|
||||
collision risk.
|
||||
@@ -795,7 +801,10 @@ same order they originally held, left-to-right.
|
||||
- The exception is your Office track, which has the number of A/D tracks listed (§11.1). As long as
|
||||
fewer trains are holding there than A/D tracks, you may enter and pass through.
|
||||
- If you move your Crew Tray into cars on your track, you **MUST** pick them up. You may not go around
|
||||
them.
|
||||
them. Where a passing loop offers two routes to the same square, choosing not to enter a siding is
|
||||
not "going around" a car standing on it — that car is on a different track, and declining to run
|
||||
onto it is ordinary railroading. Once a route is chosen, every car standing on it still must be
|
||||
coupled in full.
|
||||
- A Crew Tray must always have an engine, and may move a maximum of four Rolling Stock — cabooses
|
||||
included **[Gap 4c]**.
|
||||
- You automatically couple to cars you meet during your Moves. The same is **not** true of trains
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../../node_modules
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.0.1",
|
||||
"version": "0.4.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "station-master",
|
||||
"version": "0.0.1",
|
||||
"version": "0.4.8",
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.2",
|
||||
"typescript": "^7.0.2"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.4.7",
|
||||
"version": "0.4.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+70
-25
@@ -66,7 +66,7 @@ import {
|
||||
trackOrder,
|
||||
turnOf,
|
||||
} from './state.ts';
|
||||
import type { MoveBlock, Occupancy, Port } from './track.ts';
|
||||
import type { MoveBlock, MoveDestination, Occupancy, Port } from './track.ts';
|
||||
import {
|
||||
canDropCarsAt,
|
||||
canPlaceAt,
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
opposite,
|
||||
reachableDestinations,
|
||||
variantsFor,
|
||||
withinLimits,
|
||||
} from './track.ts';
|
||||
|
||||
export type ApplyResult =
|
||||
@@ -630,7 +631,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const from = trayCoord(s, i.trayId);
|
||||
if (!from) return 'ILLEGAL_MOVE';
|
||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
||||
const dest = selectDestination(dests, i.to, i.via);
|
||||
if (!dest) return 'ILLEGAL_MOVE';
|
||||
|
||||
/**
|
||||
@@ -1105,6 +1106,9 @@ function checkPlay(
|
||||
if (placement.row === area.runningRow && !carriesThroughTrack(proto)) {
|
||||
return 'BREAKS_RUNNING_TRACK';
|
||||
}
|
||||
// Same reasoning: a siding reaching past your own sign JOINS perfectly well, and is refused
|
||||
// because it leaves your territory (§2.1). `canPlaceAt` enforces it too — this only names it.
|
||||
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
|
||||
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
@@ -1123,6 +1127,10 @@ function checkPlay(
|
||||
* main line.
|
||||
*/
|
||||
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
||||
// A Facility CARRIES TRACK — "placing a Facility places track" (§11.2) — so it is bounded by
|
||||
// the Limits exactly as a siding is. An industry outside them is how a district used to leave
|
||||
// its own territory.
|
||||
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
|
||||
// Q4 — a lockout prevents BUILDING both in one district: no duplicate, and never a producer
|
||||
// alongside the consumer of the same commodity.
|
||||
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
|
||||
@@ -1133,6 +1141,18 @@ function checkPlay(
|
||||
case 'modifier': {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
||||
/**
|
||||
* NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both
|
||||
* halves.
|
||||
*
|
||||
* A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility
|
||||
* standing at the limit has three of its nine spots out there, and refusing them would make
|
||||
* the card unplayable exactly where the district ends. What it may NOT do is stand in the row
|
||||
* the Running Track grows along. Inside the Limits that row is always full, so this bites only
|
||||
* beyond the sign — which is the ground the main extends onto, and a Modifier parked there
|
||||
* would block your own sign from moving outward (§2.1) with nothing on screen to warn you.
|
||||
*/
|
||||
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
||||
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
|
||||
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||
@@ -1187,6 +1207,23 @@ function checkPlay(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Among destinations at `to`, the one `via` names — or the first `legal.ts` enumerated there, if
|
||||
* `via` is absent or names no route on record. That fallback is what makes an old save (or any
|
||||
* caller that never learned about routing) replay exactly as before: the first-enumerated route is
|
||||
* the only one that ever existed until this feature did.
|
||||
*/
|
||||
export function selectDestination(
|
||||
dests: MoveDestination[],
|
||||
to: GridCoord,
|
||||
via: GridCoord | undefined,
|
||||
): MoveDestination | undefined {
|
||||
const atTo = dests.filter((d) => d.coord.row === to.row && d.coord.col === to.col);
|
||||
if (via === undefined) return atTo[0];
|
||||
const chosen = atTo.find((d) => d.path.some((step) => step.coord.row === via.row && step.coord.col === via.col));
|
||||
return chosen ?? atTo[0];
|
||||
}
|
||||
|
||||
function destinationsFor(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
@@ -1203,7 +1240,6 @@ function destinationsFor(
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
consistSize: tray.consist.length,
|
||||
self: trayId,
|
||||
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
|
||||
},
|
||||
from,
|
||||
exit,
|
||||
@@ -1224,7 +1260,8 @@ export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, rev
|
||||
const here = tray.position.coord;
|
||||
const facing = facingPort(s, trayId);
|
||||
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
|
||||
return cutTowards(tray, carsOn(areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard()), exit);
|
||||
const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard();
|
||||
return cutTowards(card, carsOn(card), exit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1250,7 +1287,6 @@ export function movesFor(
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
consistSize: tray.consist.length,
|
||||
self: trayId,
|
||||
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
|
||||
};
|
||||
const facing = facingPort(s, trayId);
|
||||
const forward = exploreMoves(ctx, from, facing);
|
||||
@@ -1283,7 +1319,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
case 'switch.move': {
|
||||
const from = trayCoord(s, i.trayId)!;
|
||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
|
||||
const dest = selectDestination(dests, i.to, i.via)!;
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
// The port the crew pulls out THROUGH — the same one `destinationsFor` explored from, so the
|
||||
// cut it recouples on the way out is the cut the walk counted.
|
||||
@@ -1323,6 +1359,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
* Move on one (§A.1) — so there is exactly one way out of it.
|
||||
*/
|
||||
facing: i.reverse ? dest.entry : farPort(areaOf(s, player).grid.get(coordKey(i.to)), dest.entry),
|
||||
...(i.via ? { via: i.via } : {}),
|
||||
},
|
||||
];
|
||||
if (dest.couples.length > 0) {
|
||||
@@ -1336,17 +1373,19 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
* from `dest.path` plus the destination, neither of which can ever contain the start — so a
|
||||
* cut recoupled off your own card was added to the consist and left standing on the board at
|
||||
* the same time, one car becoming two. The cars that stay behind (a cut set out off the
|
||||
* OTHER end) ride along on `leaves`, because by the time this is reduced the tray has moved
|
||||
* and `standingWest` no longer describes this card.
|
||||
* OTHER end) ride along on `leaves`, computed here against the card's CURRENT split — the
|
||||
* reducer applying this event goes on to zero `standingWest` on the destination card, never
|
||||
* the origin, so nothing downstream needs this snapshot to have been taken any earlier.
|
||||
*/
|
||||
const grid = areaOf(s, player).grid;
|
||||
const startCut = cutTowards(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()), exitPort);
|
||||
const startCard = grid.get(coordKey(from)) ?? emptyCard();
|
||||
const startCut = cutTowards(startCard, carsOn(startCard), exitPort);
|
||||
const lifted = [
|
||||
...(startCut.length > 0 ? [from] : []),
|
||||
...dest.path.map((step) => step.coord),
|
||||
i.to,
|
||||
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
||||
const sides = standingSides(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()));
|
||||
const sides = standingSides(startCard, carsOn(startCard));
|
||||
const stayed = exitPort === 'e' ? sides.west : exitPort === 'w' ? sides.east : [];
|
||||
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
|
||||
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
|
||||
@@ -1684,14 +1723,17 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
// their turn. The event carries no player of its own.
|
||||
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
|
||||
|
||||
const area = areaAtSeat(s, seat);
|
||||
|
||||
// A MOVE ENDS ON AN EMPTY CARD, always: coupling is mandatory, so anything standing on the
|
||||
// destination has just been lifted into the tray. Whatever this crew had beside it belonged to
|
||||
// the square it left, so its place in that row is gone with it.
|
||||
tray.standingWest = 0;
|
||||
// destination has just been lifted into the tray. Zero the DESTINATION card's split — the
|
||||
// origin's is left alone rather than cleared, which costs nothing: nothing reads `standingWest`
|
||||
// on a card with no train standing there, so a stale value left behind is inert, not wrong.
|
||||
const destCard = area.grid.get(coordKey(e.to));
|
||||
if (destCard) destCard.standingWest = 0;
|
||||
|
||||
// An A/D track is held only while the train is actually standing at the Office (§2.1).
|
||||
// Leaving it out of sync means the Office looks permanently full and every arrival collides.
|
||||
const area = areaAtSeat(s, seat);
|
||||
const atOffice =
|
||||
e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col;
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId);
|
||||
@@ -1739,9 +1781,9 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const card = area.grid.get(coordKey(e.leaves.at));
|
||||
if (card) carsOn(card).push(...e.leaves.stock.map((c) => ({ ...c })));
|
||||
}
|
||||
// A tray that has moved is standing on a card it has just emptied, so nothing of its own is
|
||||
// left beside it. Any cut it did leave behind is on the square it came FROM.
|
||||
tray.standingWest = 0;
|
||||
// `trayMoved`, which always precedes this for the same move, already zeroed the destination
|
||||
// card's `standingWest` — coupling is mandatory, so a tray that has just moved is standing on
|
||||
// a card it has just emptied, and nothing of its own is left beside it there.
|
||||
/**
|
||||
* Charge only the cars that were ALREADY standing where the crew ran — the own cut at the
|
||||
* front of `stock` is a drop being undone, so it is refunded on the square it was left on
|
||||
@@ -1793,20 +1835,20 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
*
|
||||
* Successive cuts off the same end stack up TOWARDS the engine — the first car set out is left
|
||||
* furthest away, and each one after it is left in the gap between the train and the last —
|
||||
* so the insertion point is the train's own position in the row, `standingWest`, from either
|
||||
* side. That is what makes the result batch-invariant: four cars at once, four singles, or two
|
||||
* pairs all park in the same order, which is the reported bug and the regression test.
|
||||
* so the insertion point is the card's own `standingWest`, read against this train's side. That
|
||||
* is what makes the result batch-invariant: four cars at once, four singles, or two pairs all
|
||||
* park in the same order, which is the reported bug and the regression test.
|
||||
*/
|
||||
if (card) {
|
||||
const cars = carsOn(card);
|
||||
const facing = railFacingOf(tray);
|
||||
const onWestSide = e.fromNose ? facing === 'w' : facing === 'e';
|
||||
const k = Math.max(0, Math.min(cars.length, tray.standingWest ?? 0));
|
||||
const k = Math.max(0, Math.min(cars.length, card.standingWest));
|
||||
const cut = trackOrder(e.stock, facing);
|
||||
cars.splice(k, 0, ...cut);
|
||||
// The train has not moved, so cars set out on its WEST side push its index along the row;
|
||||
// The train has not moved, so cars set out on its WEST side push the split along the row;
|
||||
// cars set out to the east leave it where it was.
|
||||
tray.standingWest = onWestSide ? k + cut.length : k;
|
||||
card.standingWest = onWestSide ? k + cut.length : k;
|
||||
}
|
||||
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
|
||||
break;
|
||||
@@ -1863,9 +1905,10 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
geometry: { kind: 'modifier', modifier: card.kind.modifier },
|
||||
baseOperationalRail: false,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
enhancements: [],
|
||||
});
|
||||
applyModifier(area, e.placement, card.kind.modifier);
|
||||
} else if (card.kind.kind !== 'office') {
|
||||
@@ -2206,7 +2249,7 @@ function protoCard(
|
||||
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
|
||||
const base = { standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] };
|
||||
|
||||
if (kind.kind === 'track') {
|
||||
const geometry = kind.geometry as TrackGeometry;
|
||||
@@ -2414,6 +2457,7 @@ function emptyCard(): TrackCard {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: false,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -2686,6 +2730,7 @@ function limitsCard(): TrackCard {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
@@ -30,7 +30,12 @@ export type GameEvent =
|
||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||
// -- local operations
|
||||
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
|
||||
/**
|
||||
* `via` mirrors the intent's `via` (docs/plans/switching-paths.md) — present only when there was
|
||||
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
||||
* choice the player made invisible in their own log.
|
||||
*/
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
trayId: TrayId;
|
||||
|
||||
+13
-1
@@ -16,7 +16,14 @@ export type LocalOpsOption = 'switch' | 'draw' | 'freightAgent';
|
||||
export type Intent =
|
||||
| { type: 'localOps.choose'; option: LocalOpsOption }
|
||||
// -- switch (§6.1, Appendix A)
|
||||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
|
||||
/**
|
||||
* `via` names one INTERMEDIATE card on the chosen route, for the rare case where two routes to
|
||||
* `to` couple different cars (see docs/plans/switching-paths.md) — never the start, never `to`
|
||||
* itself. Absent, it resolves exactly as it always has: the first route `legal.ts` enumerated to
|
||||
* `to`. An index into the destination list would do the same job worse — intents are the
|
||||
* canonical record `undo` replays against, and enumeration order is not a stable thing to save.
|
||||
*/
|
||||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean; via?: GridCoord }
|
||||
/**
|
||||
* Set out a cut. `fromNose` takes it off the FRONT of the train rather than the back — Appendix A's
|
||||
* worked example does exactly this: "Back up and drop off everything on the nose of your train
|
||||
@@ -143,6 +150,11 @@ export type RejectionCode =
|
||||
| 'ON_RUNNING_TRACK'
|
||||
/** A card with no east-west road would dead-end the Running Track it was laid in. */
|
||||
| 'BREAKS_RUNNING_TRACK'
|
||||
/**
|
||||
* §2.1 — track, and the Facilities that carry it, stay inside your own Limits at every row. A
|
||||
* Modifier is not track and is exempt (§9's nine spots), which is why this is not shared with it.
|
||||
*/
|
||||
| 'OUTSIDE_LIMITS'
|
||||
/**
|
||||
* A turnout may be laid on top of a straight, or of a curve on the same arc as its diverging leg
|
||||
* — but only those, and only while the square is idle. See `checkTurnoutUpgrade`.
|
||||
|
||||
+74
-3
@@ -17,9 +17,38 @@ 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';
|
||||
import { seatOf } from './state.ts';
|
||||
import { coordKey, seatOf } from './state.ts';
|
||||
import type { MoveDestination } from './track.ts';
|
||||
import { variantsFor } from './track.ts';
|
||||
|
||||
/**
|
||||
* A square GridCoord this route passes through that no OTHER route to the same destination does —
|
||||
* the discriminator `switch.move.via` needs so `selectDestination` (apply.ts) never has to guess
|
||||
* which of two routes an intent meant. `siblings` is every route the same `destinationsFor` call
|
||||
* returned at this same square; `route` must be one of them.
|
||||
*
|
||||
* Absent when `route` is the only route there (the overwhelmingly common case — `via` need not be
|
||||
* carried at all), or when the destination is one hop away, which is never ambiguous: the square
|
||||
* immediately before a given entry port is fixed by geometry, so a zero-length path cannot have a
|
||||
* sibling.
|
||||
*/
|
||||
function distinguishingVia(route: MoveDestination, siblings: readonly MoveDestination[]): GridCoord | undefined {
|
||||
if (route.path.length === 0) return undefined;
|
||||
const otherSquares = new Set<string>();
|
||||
for (const sibling of siblings) {
|
||||
if (sibling === route) continue;
|
||||
for (const step of sibling.path) otherSquares.add(coordKey(step.coord));
|
||||
}
|
||||
for (const step of route.path) {
|
||||
if (!otherSquares.has(coordKey(step.coord))) return step.coord;
|
||||
}
|
||||
// Every square on this route is shared with some sibling — geometrically possible only if the
|
||||
// routes fork and rejoin more than once, which `track.ts`'s doc comment says not to rely on not
|
||||
// happening. Naming the last square before the destination at least matches today's behaviour:
|
||||
// wrong in the same way a route with no `via` at all would be.
|
||||
return route.path[route.path.length - 1]!.coord;
|
||||
}
|
||||
|
||||
const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
|
||||
|
||||
/** Every intent `player` may legally submit right now. */
|
||||
@@ -86,8 +115,20 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse });
|
||||
const dests = destinationsFor(s, player, trayId, from, reverse);
|
||||
// Grouped by destination square so `distinguishingVia` only ever compares routes that are
|
||||
// actually racing for the same button — two routes to DIFFERENT squares need no `via` to
|
||||
// tell apart, `to` already does that.
|
||||
const byCoord = new Map<string, MoveDestination[]>();
|
||||
for (const d of dests) {
|
||||
const k = coordKey(d.coord);
|
||||
(byCoord.get(k) ?? byCoord.set(k, []).get(k)!).push(d);
|
||||
}
|
||||
for (const group of byCoord.values()) {
|
||||
for (const d of group) {
|
||||
const via = group.length > 1 ? distinguishingVia(d, group) : undefined;
|
||||
out.push({ type: 'switch.move', trayId, to: d.coord, reverse, ...(via ? { via } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
@@ -313,5 +354,35 @@ function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* THE NINE-SPOT MODIFIER NEIGHBOURHOOD (§9), which the four-square walk above cannot reach.
|
||||
*
|
||||
* A Modifier goes "adjacent to a Facility, on any of the nine nearby spots" — diagonals included —
|
||||
* and `check` has always accepted all eight. It was the CANDIDATES that were orthogonal-only, so a
|
||||
* diagonal square with no orthogonal neighbour was legal and never offered: reported by Jesse as
|
||||
* being unable to place a Modifier to the south-east of his industry.
|
||||
*
|
||||
* Only around a Facility, and only in a second pass. Track has to JOIN, and a diagonal shares no
|
||||
* edge, so a Modifier is the only card these squares can ever take — generating diagonals around
|
||||
* every card instead costs 54% more simulation time for candidates `check` then rejects. Appending
|
||||
* rather than interleaving leaves the existing order untouched, which matters because the bot
|
||||
* breaks ties by first-best: measured over 200 seeded games, 0 played differently.
|
||||
*/
|
||||
for (const [key, card] of area.grid) {
|
||||
if (!card.facility) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
for (const c of [
|
||||
{ row: row! + 1, col: col! + 1 },
|
||||
{ row: row! + 1, col: col! - 1 },
|
||||
{ row: row! - 1, col: col! + 1 },
|
||||
{ row: row! - 1, col: col! - 1 },
|
||||
]) {
|
||||
const k = `${c.row},${c.col}`;
|
||||
if (seen.has(k) || area.grid.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
||||
geometry: { kind: 'office' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: buildPassengerFacility('whistlePost'),
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -162,6 +163,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
+27
-20
@@ -137,9 +137,35 @@ export type TrackCard = {
|
||||
* WEST-TO-EAST IS THE BOARD'S ORIENTATION, not the train's, which is exactly why it is the right
|
||||
* one: it is a property of the card, so it does not change when a different train touches it. The
|
||||
* two conversions are stated once, in `apply.ts`'s `carsDropped` and `carsCoupled` reducers, and
|
||||
* `CrewTray.standingWest` records where a train standing here sits among them.
|
||||
* `standingWest` below records where a train standing here sits among them.
|
||||
*/
|
||||
standing: RollingStock[];
|
||||
/**
|
||||
* HOW MANY OF `standing` LIE WEST OF THE TRAIN(S) STANDING HERE.
|
||||
*
|
||||
* `standing` runs west to east and a train sits somewhere IN that row: cars `[0, standingWest)`
|
||||
* are west of it, cars `[standingWest, end)` are east. A train can set out off both ends on the
|
||||
* same square — §A.3 lets a cut come off either outer end — so "which side is this cut on" is not
|
||||
* recoverable from the array alone, and it is the question every remaining rule asks: which cars
|
||||
* are AHEAD of an engine and which BEHIND (combine with `CrewTray.railFacing`), which cut a
|
||||
* departing train must couple back up (the one at the end it leaves by), and which side of the
|
||||
* chip the renderer draws the cut on.
|
||||
*
|
||||
* WITH NO TRAIN STANDING HERE, THIS NUMBER MEANS NOTHING: a card's cars are simply a cut with no
|
||||
* near or far side, and the next train to arrive couples all of them regardless — nothing reads
|
||||
* `standingWest` in that state, so a stale value left over from a train that has since moved on is
|
||||
* inert, not wrong. That reasoning is unchanged from when this lived on `CrewTray`.
|
||||
*
|
||||
* WHAT MOVED IT TO THE CARD: the Office square is the one place more than one train may stand at
|
||||
* once, and only one route lets two trays disagree about the same row of cars — a train already
|
||||
* at the Office sets out a cut in place, without moving, while a second train on another A/D track
|
||||
* still holds whatever split it arrived with. A cut lies west or east of the WHOLE block of A/D
|
||||
* tracks, never of one particular track and never between two trains, so there is exactly one
|
||||
* number for the card to carry and every train standing there reads the same one. Everywhere else
|
||||
* on the board at most one train ever stands on a card, so the two homes for this field are
|
||||
* identical in every other case.
|
||||
*/
|
||||
standingWest: number;
|
||||
facility: Facility | null;
|
||||
modifiers: ModifierKind[];
|
||||
/** Enhancement cards laid on this card (§7 of implications.md). */
|
||||
@@ -345,25 +371,6 @@ export type CrewTray = {
|
||||
* puts the engine on the other end, which is the one thing the arrow exists to show.
|
||||
*/
|
||||
railFacing?: 'e' | 'w';
|
||||
/**
|
||||
* HOW MANY OF THIS CARD'S STANDING CARS LIE WEST OF THIS TRAIN.
|
||||
*
|
||||
* `carsOn(card)` runs west to east and a train standing on the card sits somewhere IN that row:
|
||||
* cars `[0, standingWest)` are west of the engine, cars `[standingWest, end)` are east of it. A
|
||||
* train can set out off both ends on the same square — §A.3 lets a cut come off either outer end —
|
||||
* so "which side is this cut on" is not recoverable from the array alone, and it is the question
|
||||
* every remaining rule asks.
|
||||
*
|
||||
* It answers all three: which cars are AHEAD of the engine and which BEHIND (combine with
|
||||
* `railFacing`), which cut a departing train must couple back up (the one at the end it leaves
|
||||
* by), and which side of the chip the renderer draws the cut on.
|
||||
*
|
||||
* Kept on the TRAY rather than the card because it describes a relationship, not the card: with no
|
||||
* train standing there, a card's cars are simply a cut with no near or far side, and the next
|
||||
* train to arrive couples all of them regardless. Reset to 0 on every move, which is sound because
|
||||
* coupling is mandatory — a train arrives on a card it has just emptied.
|
||||
*/
|
||||
standingWest?: number;
|
||||
position: NodeRef;
|
||||
movesUsed: number;
|
||||
/**
|
||||
|
||||
+101
-27
@@ -316,15 +316,6 @@ export type MoveContext = {
|
||||
consistSize: number;
|
||||
/** The tray making the move, so it does not block itself. */
|
||||
self: TrayId;
|
||||
/**
|
||||
* How many cars on the START card stand west of this train (`CrewTray.standingWest`).
|
||||
*
|
||||
* The walk begins at `neighbour(start, initialExit)` and never looked at the start card at all, so
|
||||
* a train drove straight through the cut it had just set out — in both directions, and without
|
||||
* counting it against the four-car limit. Coupling is mandatory (§A.4) and a cut on your own
|
||||
* square is no exception: pulling out through the end it sits at picks it up again.
|
||||
*/
|
||||
standingWest?: number;
|
||||
};
|
||||
|
||||
function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined {
|
||||
@@ -383,6 +374,28 @@ export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* How many frontier nodes a single `exploreMoves` walk may enqueue before it stops finding new
|
||||
* routes. A per-path visited set (below) guarantees every individual route terminates, but the
|
||||
* NUMBER of simple paths through a dense district is worst-case exponential — a real board never
|
||||
* gets close, but nothing stops a pathological one from being built. BFS order means the queue
|
||||
* fills shortest-route-first, so hitting the cap loses only the longest, least-likely-to-matter
|
||||
* routes; it never loses a shorter one in favour of a longer one.
|
||||
*/
|
||||
const MAX_ENUMERATED_FRONTIER = 4000;
|
||||
|
||||
/**
|
||||
* The identity of a route's OUTCOME, for dedupe: which square it ends at, which side it entered
|
||||
* by, and exactly which cars it couples, each tagged with the card it came off. Two routes that
|
||||
* couple the same car TYPES from DIFFERENT cards are not the same outcome — sweeping the wrong
|
||||
* card clean is exactly the bug this key exists to avoid — so the origin card rides along with
|
||||
* every car, not just its type.
|
||||
*/
|
||||
function routeOutcomeKey(coord: GridCoord, entry: Port, couples: RollingStock[], origins: string[]): string {
|
||||
const cars = couples.map((c, idx) => `${origins[idx]}:${c.type}:${c.loaded ? 1 : 0}`).join(',');
|
||||
return `${coordKey(coord)}|${entry}|${cars}`;
|
||||
}
|
||||
|
||||
export function exploreMoves(
|
||||
ctx: MoveContext,
|
||||
start: GridCoord,
|
||||
@@ -403,9 +416,20 @@ export function exploreMoves(
|
||||
if (!startCard) return { destinations: results, blocked };
|
||||
if (!hasPort(startCard, initialExit)) return { destinations: results, blocked };
|
||||
|
||||
const seen = new Set<string>();
|
||||
const resultKeys = new Set<string>();
|
||||
|
||||
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
|
||||
type Frontier = {
|
||||
coord: GridCoord;
|
||||
entry: Port;
|
||||
path: MoveStep[];
|
||||
couples: RollingStock[];
|
||||
/** Coord key each entry in `couples` came off, aligned by index — see `routeOutcomeKey`. */
|
||||
origins: string[];
|
||||
/** Cards visited on THIS route, start included. A per-path set, not a global one — see the
|
||||
* module doc comment on `MAX_ENUMERATED_FRONTIER` for why a global one would forbid the very
|
||||
* routes this walk exists to find. */
|
||||
visited: Set<string>;
|
||||
};
|
||||
|
||||
// The very first hop is checked here because `start`'s card is not itself enqueued; every later
|
||||
// hop is checked at the push site below, where both sides of the edge are in hand.
|
||||
@@ -426,10 +450,19 @@ export function exploreMoves(
|
||||
*
|
||||
* Ordered nearest-first like every other card's, so it simply seeds the accumulator.
|
||||
*/
|
||||
const ownCut = cutTowards({ standingWest: ctx.standingWest }, carsOn(startCard), initialExit);
|
||||
const ownCut = cutTowards(startCard, carsOn(startCard), initialExit);
|
||||
const startKey = coordKey(start);
|
||||
const queue: Frontier[] = [
|
||||
{ coord: first, entry: opposite(initialExit), path: [], couples: ownCut },
|
||||
{
|
||||
coord: first,
|
||||
entry: opposite(initialExit),
|
||||
path: [],
|
||||
couples: ownCut,
|
||||
origins: ownCut.map(() => startKey),
|
||||
visited: new Set([startKey]),
|
||||
},
|
||||
];
|
||||
let enumerated = 1;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift()!;
|
||||
@@ -476,6 +509,8 @@ export function exploreMoves(
|
||||
*/
|
||||
const met = node.entry === 'e' ? [...carsOn(card)].reverse() : carsOn(card);
|
||||
const couples = [...node.couples, ...met];
|
||||
const nodeKey = coordKey(node.coord);
|
||||
const origins = [...node.origins, ...met.map(() => nodeKey)];
|
||||
if (ctx.consistSize + couples.length > MAX_CONSIST) {
|
||||
const own =
|
||||
ownCut.length > 0
|
||||
@@ -489,18 +524,24 @@ export function exploreMoves(
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${coordKey(node.coord)}|${node.entry}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
|
||||
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
|
||||
// Dedupe on OUTCOME, not on reaching the square: two routes that end here having coupled
|
||||
// different cars (or the same cars off different cards) are a real choice, and both are
|
||||
// offered. Two routes that end here having coupled identically are not a choice — one is
|
||||
// noise doubling the button list and the bot's branching factor for nothing.
|
||||
const rkey = routeOutcomeKey(node.coord, node.entry, couples, origins);
|
||||
if (!resultKeys.has(rkey)) {
|
||||
resultKeys.add(rkey);
|
||||
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');
|
||||
}
|
||||
|
||||
if (enumerated >= MAX_ENUMERATED_FRONTIER) continue;
|
||||
|
||||
for (const exit of exitsFrom(card, node.entry)) {
|
||||
// Slope is a property of the EDGE, not of either card, so it can only be tested with both in
|
||||
// hand. Enqueueing on `hasPort` alone routed trains across a 45° leg that bent back on itself.
|
||||
@@ -510,8 +551,18 @@ export function exploreMoves(
|
||||
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;
|
||||
}
|
||||
// A card twice on ONE route is a loop, not a longer route (§2.4 travels without changing
|
||||
// direction; it never says without repeating ground, but a train cannot occupy the same
|
||||
// track twice at once either). Per-path, not global — a DIFFERENT route may legitimately
|
||||
// pass through a card this one already used.
|
||||
const toKey = coordKey(to);
|
||||
if (node.visited.has(toKey)) continue;
|
||||
if (enumerated >= MAX_ENUMERATED_FRONTIER) break;
|
||||
enumerated++;
|
||||
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
|
||||
const visited = new Set(node.visited);
|
||||
visited.add(toKey);
|
||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples, origins, visited });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +610,31 @@ export function carriesThroughTrack(card: TrackCard): boolean {
|
||||
* a TURNOUT laid on the Running Track — the Office no longer carries a stub of its own, because no
|
||||
* office or industry card on the printed sheet does.
|
||||
*/
|
||||
/**
|
||||
* §2.1 — IS THIS SQUARE INSIDE THE DISTRICT AT ALL?
|
||||
*
|
||||
* The Limits sign denotes "the limit of your control area", and §3 defines Secondary Track as "all
|
||||
* tracks IN YOUR LIMITS that are not the Running Track" — so the bound belongs to the district, not
|
||||
* to the one row the sign happens to stand in. This used to guard the Running Track alone, on the
|
||||
* reasoning that the rows above and below were unbounded; the consequence was a siding snaking east
|
||||
* past your own sign, taking industries with it, and a player switching cars on track outside the
|
||||
* territory §8.1 and §10 reason about. Reported by Jesse: sidings must not be built outside the
|
||||
* Limits.
|
||||
*
|
||||
* INCLUSIVE OF THE SIGN'S OWN COLUMN. The sign stands ON the boundary rather than beyond it, and the
|
||||
* opening district is signs at ±1 around the Office — so the strict reading would leave exactly one
|
||||
* buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier
|
||||
* neighbourhood, are usable from the first Stage.
|
||||
*
|
||||
* MODIFIERS ARE NOT SUBJECT TO THIS, and are not track: §9 places one on any of the nine spots
|
||||
* around a Facility, and a Facility standing at the limit has three of its nine outside them.
|
||||
* Jesse's call. `check` bars them from the Running Track ROW instead, which is the ground the main
|
||||
* grows onto.
|
||||
*/
|
||||
export function withinLimits(area: OfficeArea, coord: GridCoord): boolean {
|
||||
return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col;
|
||||
}
|
||||
|
||||
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
|
||||
const existing = cardAt(area, coord);
|
||||
// A Limits sign on the Running Track is the GROWTH POINT, not an obstacle. Extending means laying
|
||||
@@ -575,14 +651,12 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
|
||||
// the sign itself is the only growth point. Allowing a placement beyond it built track on the far
|
||||
// side of the sign and then planted a SECOND sign further out, leaving the board reading
|
||||
// limits · Whistle Post · limits · straight · limits
|
||||
// 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;
|
||||
}
|
||||
// with a Limits card stranded mid-track.
|
||||
if (!withinLimits(area, coord)) 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 (coord.row === area.runningRow && !carriesThroughTrack(card)) return false;
|
||||
|
||||
const ports: Port[] = ['n', 's', 'e', 'w'];
|
||||
for (const p of ports) {
|
||||
|
||||
+161
-39
@@ -51,6 +51,14 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
const CW = { dp: 118, ml: 152, run: 78 };
|
||||
const CH = 58;
|
||||
const GAP = 6;
|
||||
/**
|
||||
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
|
||||
* them without spilling onto its neighbours (docs/plans/switching-paths.md — "The Roster Pass").
|
||||
* Sized by CAPACITY, not by how many are occupied right now: a cell drawn for the trains it HAS
|
||||
* holds still as they come and go, where sizing by occupancy moved the East Division Point (and
|
||||
* everything past it) sideways every time an A/D track filled or cleared.
|
||||
*/
|
||||
const CHIP_W = 54;
|
||||
/**
|
||||
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
|
||||
*
|
||||
@@ -128,7 +136,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
|
||||
// occupies a card outright rather than a part of one.
|
||||
regions: 0,
|
||||
w: CW.run,
|
||||
w: isOffice && cap !== null ? Math.max(CW.run, cap * CHIP_W + 12) : CW.run,
|
||||
});
|
||||
}
|
||||
// A crew below the Running Track has no position ON it, so it is reported against the
|
||||
@@ -301,12 +309,24 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
|
||||
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
|
||||
|
||||
const w = Math.min(c.w - 8, label.length * 6.6 + 12);
|
||||
/**
|
||||
* THE OFFICE RUNNING CELL GETS FIXED SLOTS, ONE PER A/D TRACK — never a centre spread.
|
||||
*
|
||||
* Centred spreading pushes its outer chips outward as MORE trains arrive, and the cell was
|
||||
* sized for the cards it holds, not for its trains — so two chips at a Station used to land at
|
||||
* x 215–267 and 271–316 inside a cell spanning only 230–308, spilling onto the Limits cards
|
||||
* either side. A fixed slot per A/D track cannot overflow the cell at any occupancy, because
|
||||
* the cell was sized for exactly that many slots (see `CHIP_W` above).
|
||||
*/
|
||||
const isOfficeRun = c.kind === 'run' && c.cap !== null && c.cap > 0;
|
||||
const slotW = isOfficeRun ? (c.w - 12) / c.cap! : 0;
|
||||
const w = isOfficeRun ? Math.min(slotW - 4, label.length * 6.6 + 12) : Math.min(c.w - 8, label.length * 6.6 + 12);
|
||||
// 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 tx = isOfficeRun
|
||||
? c.x + 6 + slotW * (k + 0.5)
|
||||
: (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) +
|
||||
(inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4));
|
||||
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
|
||||
const stages =
|
||||
typeof t.stagesLeft === 'number'
|
||||
@@ -383,6 +403,22 @@ export function officeSvg(
|
||||
* offered without a word. Reported as the Depot lighting up for no visible reason.
|
||||
*/
|
||||
legal: { row: number; col: number; label: string }[] = [],
|
||||
/**
|
||||
* The columns the Limits signs stand in, drawn as the district's east and west edges.
|
||||
*
|
||||
* Omitted by the previews and by the replay viewers' single-card renders, which have no district
|
||||
* to bound. Where it is given, the boundary is drawn down the WHOLE canvas: track may not be laid
|
||||
* outside these columns at any row (§2.1), and two signs sitting on one row could not say that —
|
||||
* a player looking at open ground past a sign had no way to know nothing of his could ever go
|
||||
* there until the square failed to light up.
|
||||
*/
|
||||
limits: { west: number; east: number } | null = null,
|
||||
/**
|
||||
* Which train's consist is drawn at the rail, on an Office card holding more than one — the value
|
||||
* the "Which train are you switching?" picker already carries. Defaults to the first train on the
|
||||
* card, so every call site that has never heard of this feature keeps working unchanged.
|
||||
*/
|
||||
selectedTrayId: string | null = null,
|
||||
): string {
|
||||
const W = 166;
|
||||
const H = 96;
|
||||
@@ -447,7 +483,15 @@ export function officeSvg(
|
||||
return { x: W / 2, y: H };
|
||||
};
|
||||
|
||||
let out = `<svg class="bs" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet">`;
|
||||
/**
|
||||
* ROOM FOR THE BOUNDARY. The Limits edge is drawn just outside the sign's own column, and the west
|
||||
* sign is usually the leftmost card on the canvas — so at x = −1.5 the line lands outside a viewBox
|
||||
* starting at 0 and is simply not there. The margin is added only when there is a boundary to draw.
|
||||
*/
|
||||
const margin = limits ? 3 : 0;
|
||||
let out =
|
||||
`<svg class="bs" viewBox="${-margin} 0 ${width + margin * 2} ${height}" ` +
|
||||
`preserveAspectRatio="xMinYMin meet">`;
|
||||
for (const cell of cells) {
|
||||
const x = px(cell.col);
|
||||
const y = py(cell.row);
|
||||
@@ -462,10 +506,21 @@ export function officeSvg(
|
||||
? 'mod'
|
||||
: 'trk';
|
||||
|
||||
// An Office card's roster band (below) takes the y-band the BOARDS + ALIGHTS flow label used to
|
||||
// print in, so that explanation folds into the card's own tooltip instead of being drawn — it
|
||||
// prints identically on every Office of every tier and is already implied by the card name and
|
||||
// the Porter pill, so nothing is lost by reading it on the card rather than beside it.
|
||||
const isOfficeRoster = cell.adTracks !== null;
|
||||
const flowFold =
|
||||
isOfficeRoster && cell.facility
|
||||
? '\n\nBOARDS + ALIGHTS: passengers only — no freight is handled here. They wait in a green ' +
|
||||
'box, are boarded onto a coach by Porters, and arrive into a red one.'
|
||||
: '';
|
||||
|
||||
// Addressable so a caller can outline a square as a legal destination without re-rendering.
|
||||
// The explanation rides on the group as a tooltip rather than being printed: it is reference
|
||||
// detail, read once, and printing it costs the space the board itself needs.
|
||||
out += `<g data-cell="${cell.row},${cell.col}" data-tip="${esc(cell.label)} — ${esc(cell.what)}" transform="translate(${x},${y})">`;
|
||||
out += `<g data-cell="${cell.row},${cell.col}" data-tip="${esc(cell.label)} — ${esc(cell.what)}${esc(flowFold)}" transform="translate(${x},${y})">`;
|
||||
out += `<rect class="bs-card bs-${kind}${cell.running ? ' bs-run' : ''}" x="0" y="0" width="${W}" height="${H}" rx="4"/>`;
|
||||
|
||||
// The rails. A Modifier is not track and deliberately gets none — that is why nothing can be
|
||||
@@ -497,20 +552,48 @@ export function officeSvg(
|
||||
|
||||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||||
|
||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
||||
|
||||
/**
|
||||
* A/D TRACKS, as pips.
|
||||
* THE ROSTER, one chip per A/D track — not one per train.
|
||||
*
|
||||
* 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.
|
||||
* The Office is the one square where more than one train may legally stand at once (its A/D
|
||||
* tracks, §2.1), and the old single `cell.train` field only ever showed whichever tray
|
||||
* `s.trays` happened to yield first: a second train at a busy Station was counted in the old
|
||||
* A/D pips and never drawn at all. `adTracks` of them, always, whether or not a train is
|
||||
* holding there — the cell is drawn for the tracks it HAS, so a free one reads a dashed,
|
||||
* dimmed chip saying "free" rather than simply vanishing.
|
||||
*
|
||||
* Selected train's chip is lit the same amber the crew strip and action buttons already use,
|
||||
* and it is the one whose consist is drawn at the rail below. Every occupied chip carries
|
||||
* `data-crew`, so the play page can wire a click to switch which train that is.
|
||||
*/
|
||||
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"/>`;
|
||||
let selectedTrain: CellView['trains'][number] | null = null;
|
||||
if (cell.adTracks !== null) {
|
||||
const adTracks = cell.adTracks;
|
||||
const gaps = Math.max(0, adTracks - 1);
|
||||
const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks);
|
||||
selectedTrain = cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
|
||||
for (let i = 0; i < adTracks; i++) {
|
||||
const train = cell.trains[i] ?? null;
|
||||
const cx = 6 + i * (chipW + 4);
|
||||
if (!train) {
|
||||
out +=
|
||||
`<g class="bs-adchip mt"><rect x="${cx}" y="20" width="${chipW}" height="14" rx="3"/>` +
|
||||
`<text x="${cx + chipW / 2}" y="30.5" text-anchor="middle">free</text></g>`;
|
||||
continue;
|
||||
}
|
||||
const arrow = train.facing === 'w' ? '◀' : '▶';
|
||||
const chipLabel = chipW >= 40 ? `${train.label} ${arrow}${train.cars.length}` : `${train.label}${arrow}`;
|
||||
const consistWords = train.cars.length === 0 ? 'no cars' : train.cars.join(', ');
|
||||
const on = train.trayId === selectedTrain?.trayId;
|
||||
out +=
|
||||
`<g class="bs-adchip${on ? ' on' : ''}" data-crew="${esc(train.trayId)}" ` +
|
||||
`data-tip="${esc(`${train.label} — carrying ${consistWords}`)}">` +
|
||||
`<rect x="${cx}" y="20" width="${chipW}" height="14" rx="3"/>` +
|
||||
`<text x="${cx + chipW / 2}" y="30.5" text-anchor="middle">${esc(chipLabel)}</text></g>`;
|
||||
}
|
||||
}
|
||||
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, DRAWN THE WAY IT FLOWS.
|
||||
@@ -590,15 +673,19 @@ export function officeSvg(
|
||||
? 'BOARDS + ALIGHTS'
|
||||
: f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
|
||||
const flowCls = passenger || (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(
|
||||
passenger
|
||||
? 'Passengers only — no freight is handled here. They wait in a green box, are boarded onto a coach by Porters, and arrive into a red one. There is no MEN | AT | WORK sign because that is a Freight Facility fitting.'
|
||||
: 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>`;
|
||||
// An Office card's roster band now occupies this y-band; its tooltip already folded into the
|
||||
// card's own data-tip above, so the label is not printed twice.
|
||||
if (!isOfficeRoster) {
|
||||
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
|
||||
passenger
|
||||
? 'Passengers only — no freight is handled here. They wait in a green box, are boarded onto a coach by Porters, and arrive into a red one. There is no MEN | AT | WORK sign because that is a Freight Facility fitting.'
|
||||
: 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.
|
||||
@@ -654,14 +741,15 @@ export function officeSvg(
|
||||
* means "ahead" or "behind" is the engine's facing, which the tooltip says in words rather than
|
||||
* leaving the player to work out from the compass.
|
||||
*/
|
||||
const split = cell.train ? Math.max(0, Math.min(cell.cars.length, cell.standingWest ?? 0)) : 0;
|
||||
const hasTrain = cell.trains.length > 0;
|
||||
const split = hasTrain ? Math.max(0, Math.min(cell.cars.length, cell.standingWest)) : 0;
|
||||
const shown = Math.min(cell.cars.length, 4);
|
||||
for (let i = 0; i < shown; i++) {
|
||||
// With no train on the card there is no gap to leave, so the whole cut stays left-aligned.
|
||||
const westOfEngine = cell.train ? i < split : true;
|
||||
const side = !cell.train
|
||||
const westOfEngine = hasTrain ? i < split : true;
|
||||
const side = !selectedTrain
|
||||
? 'standing here'
|
||||
: (cell.train.facing === 'e') === westOfEngine
|
||||
: (selectedTrain.facing === 'e') === westOfEngine
|
||||
? 'standing BEHIND the engine — it would couple onto the tail backing up'
|
||||
: 'standing AHEAD of the engine — it would couple onto the nose pulling forward';
|
||||
const carTip = `${cell.cars[i]} ${side}`;
|
||||
@@ -722,17 +810,18 @@ export function officeSvg(
|
||||
(cell.enhancementsWhat ?? []).join(' · '),
|
||||
)}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
}
|
||||
if (cell.train) {
|
||||
if (selectedTrain) {
|
||||
/**
|
||||
* THE TRAIN, DRAWN AS IT SITS IN THE TRAY.
|
||||
* THE SELECTED 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.
|
||||
* is a thing you can see before you choose it. Where an Office holds more than one train, only
|
||||
* this one is drawn here — the rest are the roster chips above, each with its own tooltip.
|
||||
*/
|
||||
const t = cell.train;
|
||||
const t = selectedTrain;
|
||||
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' : '?',
|
||||
@@ -804,6 +893,22 @@ export function officeSvg(
|
||||
`<text x="${W / 2}" y="${H - 36}" text-anchor="middle">${esc(l.label)}</text></g>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE LIMITS, AS THE EDGE OF THE BUILDABLE DISTRICT.
|
||||
*
|
||||
* Drawn just OUTSIDE the sign's own column, because the sign stands on the boundary rather than
|
||||
* beyond it: a siding may run under the sign, and the line has to agree with the rule or it
|
||||
* teaches the wrong one. It moves outward on its own as the Running Track is extended, which is
|
||||
* the reward for extending made visible.
|
||||
*/
|
||||
if (limits) {
|
||||
const edge = (x: number, side: string): string =>
|
||||
`<line class="bs-limitline" x1="${x}" y1="0" x2="${x}" y2="${height}"/>` +
|
||||
`<text class="bs-limitlab" x="${x + (side === 'w' ? 4 : -4)}" y="${height - 4}" ` +
|
||||
`text-anchor="${side === 'w' ? 'start' : 'end'}">LIMITS</text>`;
|
||||
out += edge(px(limits.west) - PAD / 2, 'w') + edge(px(limits.east) + W + PAD / 2, 'e');
|
||||
}
|
||||
|
||||
// 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>';
|
||||
@@ -874,10 +979,16 @@ export const BOARD_CSS = `
|
||||
.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}
|
||||
/* One chip per A/D track, not one per train — a full Office is why the next arrival collides, so
|
||||
the count is worth seeing without hovering, and a free track reads "free" rather than vanishing.
|
||||
The selected train's chip is lit the same amber the crew strip and action buttons use. */
|
||||
.bs-adchip rect{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||||
.bs-adchip text{fill:#cfd6e0;font:600 8px ui-monospace,monospace}
|
||||
.bs-adchip.on rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.4}
|
||||
.bs-adchip.on text{fill:#fff4d6}
|
||||
.bs-adchip.mt rect{fill:none;stroke:#3d4650;stroke-dasharray:3 2}
|
||||
.bs-adchip.mt text{fill:#6b7480}
|
||||
.bs-adchip[data-crew]{cursor:pointer}
|
||||
.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,
|
||||
@@ -914,6 +1025,10 @@ export const BOARD_CSS = `
|
||||
text.bs-mod{fill:#c8a04a}
|
||||
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
||||
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
||||
/* THE EDGE OF THE DISTRICT. Deliberately quiet — it is a boundary, not an action — and dashed, so it
|
||||
reads as a line on the table rather than as rail. Nothing is laid outside it (§2.1). */
|
||||
.bs-limitline{stroke:#5f6b7a;stroke-width:1.5;stroke-dasharray:3 5;opacity:.7}
|
||||
.bs-limitlab{fill:#5f6b7a;font:600 8px ui-monospace,monospace;letter-spacing:.12em}
|
||||
.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
|
||||
@@ -924,10 +1039,17 @@ text.bs-mod{fill:#c8a04a}
|
||||
.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}
|
||||
/* THE SQUARE THE ACTION UNDER THE CURSOR MEANS. White, and brighter than anything else the board
|
||||
marks, because it answers a question being asked RIGHT NOW rather than describing a standing
|
||||
state — and it has to win when it lands on a square that is already legal, focused or blocked. */
|
||||
g[data-cell].bs-point .bs-card,g[data-ghost].bs-point rect{stroke:#f2f5f8;stroke-width:3.5;stroke-dasharray:none}
|
||||
g[data-cell].bs-point .bs-card{fill:#2b3b52}
|
||||
/* 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. */
|
||||
card reads the same way an action button does. bs-from is where it is standing now — marked on
|
||||
the crew STRIP rather than the whole card, because the Office is the one square where a second
|
||||
train may share that card: outlining the card would claim it belongs to both. */
|
||||
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}
|
||||
.bs-crew.bs-from rect:first-of-type{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}
|
||||
|
||||
+4
-1
@@ -126,10 +126,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
|
||||
};
|
||||
case 'trayMoved':
|
||||
// `via` rides on the event only when there was another legal route to the same square
|
||||
// (docs/plans/switching-paths.md) — so naming it here says which one the crew actually took,
|
||||
// rather than leaving a real choice invisible in the crew's own history.
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.to,
|
||||
text: `CREW moved ${at(e.from)} → ${at(e.to)} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
text: `CREW moved ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
};
|
||||
case 'carsCoupled': {
|
||||
/**
|
||||
|
||||
+17
-12
@@ -202,9 +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;
|
||||
// `train` rides whole rather than being interned: it changes almost every frame, so a table
|
||||
// `trains` 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, c.enhancementsWhat, c.standingWest];
|
||||
return [ci, wi, c.enhancements, c.cars, fi, c.trains, c.adTracks, c.enhancementsWhat, c.standingWest];
|
||||
});
|
||||
return { ...f, cells } as unknown as Frame;
|
||||
});
|
||||
@@ -238,21 +238,26 @@ export function rehydrateCells(
|
||||
facs: unknown[],
|
||||
): unknown[] {
|
||||
return packed.map((row) => {
|
||||
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown, string[], number];
|
||||
const p = row as [number, number, string[], string[], number, unknown, unknown, string[], number];
|
||||
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
||||
// Tolerant the same way `standingWest` below is: an array rides through as-is, a lone object
|
||||
// (an older recording's singular `train`) is wrapped into a one-train roster, and null or
|
||||
// undefined reads as no train at all.
|
||||
const rawTrains = p[5];
|
||||
const trains = Array.isArray(rawTrains) ? rawTrains : rawTrains ? [rawTrains] : [];
|
||||
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,
|
||||
what: whats[p[1]], enhancements: p[2], cars: p[3],
|
||||
facility: p[4] < 0 ? null : facs[p[4]],
|
||||
trains,
|
||||
adTracks: (p[6] as number | undefined) ?? null,
|
||||
// Carried rather than recomputed: this function is emitted into the page by toString() and so
|
||||
// cannot reach the card catalogue that produced the text.
|
||||
enhancementsWhat: p[8] ?? [],
|
||||
// Where the train on this card stands among the cars standing on it, so the replay draws a cut
|
||||
// ahead of or behind the engine exactly as the live board does. Absent in older recordings,
|
||||
enhancementsWhat: p[7] ?? [],
|
||||
// Where the train(s) on this card stand among the cars standing on it, so the replay draws a
|
||||
// cut ahead of or behind the engine exactly as the live board does. Absent in older recordings,
|
||||
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
|
||||
standingWest: p[9] ?? 0,
|
||||
standingWest: p[8] ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -499,7 +504,7 @@ function render() {
|
||||
|
||||
$('division').innerHTML = divisionSvg(DIV);
|
||||
// The same office renderer the playable app uses, so replay and game draw one board.
|
||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow);
|
||||
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
|
||||
|
||||
/* Auto-hide: the district is worth its space during the phases that change it, and folded
|
||||
otherwise. A summary stays, because a panel that vanishes reads as broken. */
|
||||
|
||||
+68
-50
@@ -19,6 +19,7 @@ import {
|
||||
movesFor,
|
||||
ownCutFor,
|
||||
portersLeft,
|
||||
selectDestination,
|
||||
} from '../engine/apply.ts';
|
||||
import {
|
||||
ACTION_CARDS,
|
||||
@@ -43,7 +44,7 @@ import {
|
||||
mainlineDescription,
|
||||
} from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import type { Facility, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import { carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
@@ -70,19 +71,25 @@ export type CellView = {
|
||||
* `toString()`), so it cannot reach the card catalogue itself.
|
||||
*/
|
||||
enhancementsWhat: string[];
|
||||
tray: string | null;
|
||||
/**
|
||||
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
|
||||
* EVERY TRAIN STANDING HERE, in order, each 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.
|
||||
*
|
||||
* An ARRAY because the Office square is the one place more than one train may legally stand at
|
||||
* once (docs/plans/switching-paths.md — "The Roster Pass"): the old singular `train` field only
|
||||
* ever showed whichever tray `s.trays` happened to yield first, so a second train at a busy
|
||||
* Station was never drawn at all, only counted in the A/D pips. Empty, not absent, when the card
|
||||
* is bare, so callers never need an `?? []`.
|
||||
*
|
||||
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
|
||||
* `facing` is which way the engine points — EAST OR WEST, never north or south, whatever the
|
||||
* track under it runs. See `railFacingOf` in state.ts for why, and why the type says so.
|
||||
*/
|
||||
train: {
|
||||
trains: {
|
||||
trayId: string;
|
||||
label: string;
|
||||
cars: string[];
|
||||
engineAt: number;
|
||||
@@ -95,26 +102,31 @@ export type CellView = {
|
||||
* standing in front of them — the card is face down in a box somewhere by then.
|
||||
*/
|
||||
what: string;
|
||||
} | null;
|
||||
}[];
|
||||
/**
|
||||
* Office card only: A/D tracks taken and how many the tier has.
|
||||
* Office card only: how many A/D tracks the tier has — null everywhere else.
|
||||
*
|
||||
* 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.
|
||||
* Capacity, not occupancy: `board-svg.ts` draws one roster chip per track regardless of how many
|
||||
* are taken (a free one reads "free"), so occupancy is implicit in how many of `trains` land on
|
||||
* it. Used to carry `{used, of}` and draw one pip per track; the pips are gone (the roster band
|
||||
* takes their place) but the count is still needed to know how many chips to lay out.
|
||||
*/
|
||||
ad: { used: number; of: number } | null;
|
||||
adTracks: number | null;
|
||||
cars: string[];
|
||||
/**
|
||||
* Cars in `cars` that stand WEST of the train on this card — 0 when no train is here.
|
||||
* Cars in `cars` that stand WEST of the train(s) here — 0 when no train is here, and unsplit if
|
||||
* so: with nobody standing on the card, a cut has no near or far side and the whole row simply
|
||||
* reads west to east (`board-svg.ts` draws the split only while `trains.length > 0`).
|
||||
*
|
||||
* `cars` runs west to east like the state it comes from, and a train standing on the card sits
|
||||
* somewhere IN that row rather than beside it. Reported from play: "right after dropping my cars I
|
||||
* need to be able to see if those cars are ahead or behind the train" — and the board drew the
|
||||
* whole cut in one left-aligned strip at the bottom of the card, which cannot answer that at all.
|
||||
*
|
||||
* Combined with `train.facing` it is the answer: for an east-facing engine the cars east of it are
|
||||
* the ones ahead, and the split is drawn as a gap the chip sits in.
|
||||
* Combined with a train's `facing` it is the answer: for an east-facing engine the cars east of it
|
||||
* are the ones ahead. ONE number for the whole card — every train standing here (there is at most
|
||||
* one, except the Office) reads the same split, taken against the block of A/D tracks rather than
|
||||
* against any one engine, straight off `TrackCard.standingWest`.
|
||||
*/
|
||||
standingWest: number;
|
||||
facility: FacilityView | null;
|
||||
@@ -356,6 +368,15 @@ export type Frame = {
|
||||
cells: CellView[];
|
||||
/** Which grid row is the Running Track — the spine the district hangs beneath. */
|
||||
runningRow: number;
|
||||
/**
|
||||
* The columns the Limits signs stand in — the district's east and west edges, at EVERY row.
|
||||
*
|
||||
* Track may not be laid outside them (§2.1), so a player looking at open ground beyond a sign is
|
||||
* looking at ground no card of his will ever go on. Two signs on one row could not say that; the
|
||||
* board draws the boundary down the whole district instead. Carried on the Frame rather than read
|
||||
* off the area for the usual reason: a remote client holds no `GameState`.
|
||||
*/
|
||||
limits: { west: number; east: number };
|
||||
/**
|
||||
* Moves left in this Local Operations turn, or null outside a switching turn.
|
||||
*
|
||||
@@ -581,29 +602,31 @@ function baseOf(
|
||||
* Zero when there is no train, which is also the right answer for a bare cut: with nobody standing
|
||||
* there, a cut has no near or far side and the whole row simply reads west to east.
|
||||
*/
|
||||
function standingWestOn(s: GameState, key: string, cars: number): number {
|
||||
for (const t of s.trays.values()) {
|
||||
if (t.position.at !== 'grid') continue;
|
||||
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
||||
return Math.max(0, Math.min(cars, t.standingWest ?? 0));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function trainOnCard(s: GameState, key: string): CellView['train'] {
|
||||
/**
|
||||
* Every train standing on this card, for THIS viewer's seat.
|
||||
*
|
||||
* KEYED BY COORDINATE, so it must be filtered by seat first — every district uses the same (row,
|
||||
* col) origin, so without the seat check a crew standing at (0,1) in one player's Office Area would
|
||||
* be drawn onto (0,1) of every other player's board too. Collects every match rather than returning
|
||||
* on the first: the Office square is the one place more than one train may legally stand at once
|
||||
* (docs/plans/switching-paths.md), and the old singular version silently drew only whichever tray
|
||||
* `s.trays` happened to yield first.
|
||||
*/
|
||||
function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellView['trains'] {
|
||||
const out: CellView['trains'] = [];
|
||||
for (const [id, t] of s.trays) {
|
||||
if (t.position.at !== 'grid') continue;
|
||||
if (t.position.at !== 'grid' || t.position.seat !== viewerSeat) continue;
|
||||
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
||||
return {
|
||||
out.push({
|
||||
trayId: id,
|
||||
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: railFacingOf(t),
|
||||
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
|
||||
};
|
||||
void id;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
|
||||
@@ -727,9 +750,19 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
const tray = s.trays.get(i.trayId);
|
||||
const here = tray?.position.at === 'grid' ? tray.position.coord : null;
|
||||
let picks = '';
|
||||
let routeNote = '';
|
||||
if (here) {
|
||||
const dest = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse)
|
||||
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
||||
const dests = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse);
|
||||
const dest = selectDestination(dests, i.to, i.via);
|
||||
// Two routes to the same square (docs/plans/switching-paths.md) would otherwise print the
|
||||
// identical button twice — "move to (0,0)" and "move to (0,0)" — and the action list drops
|
||||
// duplicate labels, silently discarding the second choice. `via` is the one thing that
|
||||
// differs in the DATA, so it is the one thing safe to print without guessing at scenery
|
||||
// this function has no other reason to know the name of.
|
||||
const atSameSquare = dests.filter((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
||||
if (dest && i.via && atSameSquare.length > 1) {
|
||||
routeNote = ` via ${at(i.via)}`;
|
||||
}
|
||||
if (dest && dest.couples.length > 0) {
|
||||
/**
|
||||
* NAME THE CARS THE CREW SET OUT HERE SEPARATELY. They are at the front of `couples` — the
|
||||
@@ -747,7 +780,7 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
: ` — couples ${carsLabel(dest.couples)} on the way${end}`;
|
||||
}
|
||||
}
|
||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}${picks}`;
|
||||
return `move to ${at(i.to)}${routeNote}${i.reverse ? ' (reverse)' : ''}${picks}`;
|
||||
}
|
||||
case 'switch.dropCars': {
|
||||
/**
|
||||
@@ -955,18 +988,6 @@ export function snapshot(
|
||||
): Frame {
|
||||
const area = areaOf(s, viewer);
|
||||
const viewerSeat = seatOf(s, viewer);
|
||||
const trayAt = new Map<string, string>();
|
||||
for (const [id, tray] of s.trays) {
|
||||
// KEYED BY COORDINATE, so it must be filtered by seat first. Every district uses the same
|
||||
// (row, col) origin, so without this a crew standing at (0,1) in one player's Office Area is
|
||||
// drawn onto (0,1) of every other player's board — the cells come from `area.grid`, which is
|
||||
// the viewer's, but the train on them came from anybody's.
|
||||
if (tray.position.at === 'grid' && tray.position.seat === viewerSeat) {
|
||||
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
|
||||
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
|
||||
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
|
||||
}
|
||||
}
|
||||
|
||||
const cells: CellView[] = [];
|
||||
const facilities: FacilityView[] = [];
|
||||
@@ -998,14 +1019,10 @@ export function snapshot(
|
||||
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||
enhancements: card.enhancements.map(prettyKey),
|
||||
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
|
||||
tray: trayAt.get(key) ?? null,
|
||||
train: trainOnCard(s, key),
|
||||
ad:
|
||||
card.geometry.kind === 'office'
|
||||
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
|
||||
: null,
|
||||
trains: trainsOnCard(s, viewerSeat, key),
|
||||
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
||||
cars: carsOn(card).map(carLabel),
|
||||
standingWest: standingWestOn(s, key, carsOn(card).length),
|
||||
standingWest: card.standingWest,
|
||||
facility: fv,
|
||||
});
|
||||
}
|
||||
@@ -1201,6 +1218,7 @@ export function snapshot(
|
||||
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
||||
objective: objectiveOf(s, viewer),
|
||||
runningRow: area.runningRow,
|
||||
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
|
||||
movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
|
||||
moves: switchingMoves(s, viewer),
|
||||
blocked: impediments(s, viewer),
|
||||
|
||||
+83
-5
@@ -30,6 +30,7 @@ import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||
import { playerAtSeat } from '../engine/state.ts';
|
||||
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.
|
||||
@@ -53,7 +54,7 @@ import {
|
||||
import type { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
||||
import { areaOf, trainNeedingCars } from '../engine/apply.ts';
|
||||
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
|
||||
export const SOLO_CONFIG: GameConfig = {
|
||||
@@ -87,9 +88,65 @@ export type ActionGroup = {
|
||||
* from the browser, which needs the whole `GameState`. A remote client has no state, so the menu
|
||||
* carries it. See `docs/architecture/multiplayer.md` §5.
|
||||
*/
|
||||
actions: { index: number; label: string; tip?: string }[];
|
||||
/**
|
||||
* `coord` is the square the action HAPPENS ON, when it happens on one.
|
||||
*
|
||||
* Carried as data rather than left inside the label. Half these buttons are near-identical
|
||||
* sentences distinguished only by a coordinate — "(1,3)" against "(-1,3)" — and picking the wrong
|
||||
* one is recoverable in solitaire, where Undo is a click, and a disaster in a multiplayer game
|
||||
* where it is not. The page hovers the matching square on the board instead of asking the player
|
||||
* to read the row and column off the button. Reported by Jesse.
|
||||
*
|
||||
* Resolved HERE for the same reason `tip` is: a remote client holds no `GameState` and cannot look
|
||||
* up where a tray is standing. See `docs/architecture/multiplayer.md` §5.
|
||||
*/
|
||||
actions: {
|
||||
index: number;
|
||||
label: string;
|
||||
tip?: string;
|
||||
coord?: { row: number; col: number };
|
||||
/**
|
||||
* Every square a `switch.move` runs OVER on its way to `coord`, when there is more than one
|
||||
* legal route there (docs/plans/switching-paths.md) — so hovering a route lights the whole
|
||||
* road, not just its destination, which is the only way to tell two buttons reading "move to
|
||||
* (0,0)" apart before clicking one.
|
||||
*/
|
||||
route?: { row: number; col: number }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The square an intent acts on, or null when it acts on none.
|
||||
*
|
||||
* Only squares the intent NAMES. A card played out on the Mainline (`node`) is not a district
|
||||
* coordinate and must not be treated as one — that confusion is exactly why `placement` and `node`
|
||||
* are separate fields on the intent (see `intents.ts`) — and an action like ending a turn has no
|
||||
* square at all.
|
||||
*/
|
||||
export function coordOf(i: Intent): { row: number; col: number } | null {
|
||||
if ('at' in i) return i.at;
|
||||
if ('to' in i) return i.to;
|
||||
if (i.type === 'card.play' && i.placement) return i.placement;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every intermediate square a `switch.move` runs over, resolved the same way `execute` resolves
|
||||
* `via` — so the squares that light up on hover are exactly the squares the move will actually
|
||||
* couple cars from. `undefined` for the overwhelming majority of moves, which run in a straight
|
||||
* line and need nothing beyond the destination `coordOf` already carries.
|
||||
*/
|
||||
function routeFor(state: GameState, i: Intent): { row: number; col: number }[] | undefined {
|
||||
if (i.type !== 'switch.move') return undefined;
|
||||
const tray = state.trays.get(i.trayId);
|
||||
if (!tray || tray.position.at !== 'grid') return undefined;
|
||||
const actor = playerAtSeat(state, tray.position.seat);
|
||||
const dests = destinationsFor(state, actor, i.trayId, tray.position.coord, i.reverse);
|
||||
const dest = selectDestination(dests, i.to, i.via);
|
||||
if (!dest || dest.path.length === 0) return undefined;
|
||||
return dest.path.map((step) => step.coord);
|
||||
}
|
||||
|
||||
export type Game = {
|
||||
state: GameState;
|
||||
seed: number;
|
||||
@@ -212,7 +269,14 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
* to survive every edit to a line nobody would think to check. One stray byte and every crew's
|
||||
* moves silently collapsed back into a single group. The tray is data; it travels as data.
|
||||
*/
|
||||
type Entry = { index: number; label: string; tip?: string; trayId?: string };
|
||||
type Entry = {
|
||||
index: number;
|
||||
label: string;
|
||||
tip?: string;
|
||||
trayId?: string;
|
||||
coord?: { row: number; col: number };
|
||||
route?: { row: number; col: number }[];
|
||||
};
|
||||
const byKind = new Map<string, Entry[]>();
|
||||
options.forEach((intent, index) => {
|
||||
const label = describeIntent(game.state, intent);
|
||||
@@ -227,14 +291,28 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
* on the crew as well makes the de-duplication per crew, which is what it always meant.
|
||||
*/
|
||||
if (!list.some((a) => a.label === label && a.trayId === trayId)) {
|
||||
list.push({ index, label, ...(tip ? { tip } : {}), ...(trayId ? { trayId } : {}) });
|
||||
const coord = coordOf(intent);
|
||||
const route = routeFor(game.state, intent);
|
||||
list.push({
|
||||
index,
|
||||
label,
|
||||
...(tip ? { tip } : {}),
|
||||
...(trayId ? { trayId } : {}),
|
||||
...(coord ? { coord } : {}),
|
||||
...(route ? { route } : {}),
|
||||
});
|
||||
}
|
||||
byKind.set(intent.type, list);
|
||||
});
|
||||
|
||||
/** Groups take the plain shape; the crew was only ever needed to split them. */
|
||||
const plain = (entries: Entry[]): ActionGroup['actions'] =>
|
||||
entries.map(({ index, label, tip }) => ({ index, label, ...(tip ? { tip } : {}) }));
|
||||
entries.map(({ index, label, tip, coord }) => ({
|
||||
index,
|
||||
label,
|
||||
...(tip ? { tip } : {}),
|
||||
...(coord ? { coord } : {}),
|
||||
}));
|
||||
|
||||
const groups: ActionGroup[] = [];
|
||||
const used = new Set<string>();
|
||||
|
||||
+83
-10
@@ -110,8 +110,8 @@ const esc = (s: string): string =>
|
||||
*/
|
||||
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: '',
|
||||
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [], enhancementsWhat: [],
|
||||
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
|
||||
};
|
||||
return officeSvg([cell] as never, 99).replace(
|
||||
/<text class="bs-(coord|rowlab)"[^>]*>[^<]*<\/text>/g,
|
||||
@@ -301,7 +301,14 @@ function render(): void {
|
||||
: 'ATTACH TO THIS CARD';
|
||||
return [{ row: cell.row, col: cell.col, label }];
|
||||
});
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps);
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
|
||||
|
||||
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
|
||||
// you switching?" picker writes, so the board and the action panel drive one value either way.
|
||||
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
|
||||
const trayId = (g as HTMLElement).dataset['crew'];
|
||||
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
|
||||
}
|
||||
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
@@ -337,8 +344,10 @@ function render(): void {
|
||||
*/
|
||||
const crew = pickedCrew(f);
|
||||
if (crew && !forPlay) {
|
||||
const here = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"]`);
|
||||
if (here) here.classList.add('bs-from');
|
||||
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
|
||||
// share, and outlining the card would claim it belongs to both.
|
||||
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
|
||||
if (strip) strip.classList.add('bs-from');
|
||||
for (const c of crew.to) {
|
||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||
if (g) g.classList.add('bs-focus');
|
||||
@@ -629,7 +638,9 @@ function renderDistrict(f: Frame): void {
|
||||
else sec.classList.add('folded');
|
||||
|
||||
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
|
||||
const crew = f.cells.filter((c) => c.tray).length;
|
||||
// Trains, not cards-with-a-crew: the Office is the one card that may hold more than one, and a
|
||||
// card-count silently read "1 crew on the board" with two trains standing at a busy Station.
|
||||
const crew = f.cells.reduce((n, c) => n + c.trains.length, 0);
|
||||
$('districtsummary').textContent =
|
||||
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
||||
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
||||
@@ -650,6 +661,59 @@ function renderDistrict(f: Frame): void {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The square an action button acts on, as an attribute the board can be matched against.
|
||||
*
|
||||
* `null` for the actions that act on no square at all — ending a turn, drawing a card, a train card
|
||||
* that goes to the timetable — so those buttons carry nothing and highlight nothing.
|
||||
*/
|
||||
function cellRef(
|
||||
coord: { row: number; col: number } | null | undefined,
|
||||
route?: { row: number; col: number }[],
|
||||
): string {
|
||||
const square = coord ? ` data-square="${coord.row},${coord.col}"` : '';
|
||||
// Only a `switch.move` with more than one legal route carries this (docs/plans/switching-paths.md)
|
||||
// — every intermediate square the chosen route runs over, so hovering lights the whole road, not
|
||||
// just where it ends.
|
||||
const path = route && route.length > 0 ? ` data-route="${route.map((c) => `${c.row},${c.col}`).join(' ')}"` : '';
|
||||
return square + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* POINT AT THE SQUARE THE BUTTON MEANS.
|
||||
*
|
||||
* The action list is a column of sentences that differ by a coordinate — "(1,3)" against "(-1,3)" —
|
||||
* and the board is right beside it saying nothing about which one is which. Hovering a button now
|
||||
* lights its square up, so the check happens with the eye rather than by reading two numbers off a
|
||||
* button and finding them on a map. Reported by Jesse: recoverable in solitaire, where Undo is a
|
||||
* click; a disaster in multiplayer, where it is not.
|
||||
*
|
||||
* ON FOCUS AS WELL AS HOVER, so tabbing through the list works the same way as pointing at it — the
|
||||
* keyboard route is not a lesser one.
|
||||
*
|
||||
* BOTH KINDS OF SQUARE. A played card is a `data-cell`; a square being placed ONTO is an empty
|
||||
* `data-ghost` target, which is precisely the case where the player is choosing between coordinates.
|
||||
* Matching only one of the two would leave the most mistake-prone moment unhelped.
|
||||
*/
|
||||
function wirePointing(node: HTMLElement, key: string, routeKeys: readonly string[] = []): void {
|
||||
const grid = $('grid');
|
||||
const keys = [key, ...routeKeys];
|
||||
const marks = (): Element[] =>
|
||||
keys
|
||||
.flatMap((k) => [grid.querySelector(`g[data-cell="${k}"]`), grid.querySelector(`g[data-ghost="${k}"]`)])
|
||||
.filter((g): g is Element => g !== null);
|
||||
const on = (): void => {
|
||||
for (const g of marks()) g.classList.add('bs-point');
|
||||
};
|
||||
const off = (): void => {
|
||||
for (const g of marks()) g.classList.remove('bs-point');
|
||||
};
|
||||
node.onmouseenter = on;
|
||||
node.onmouseleave = off;
|
||||
node.onfocus = on;
|
||||
node.onblur = off;
|
||||
}
|
||||
|
||||
function renderActions(
|
||||
menu: Menu,
|
||||
f: Frame,
|
||||
@@ -694,14 +758,20 @@ function renderActions(
|
||||
* 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 = (a: { index: number; label: string; tip?: string }): string => {
|
||||
const actionButton = (a: {
|
||||
index: number;
|
||||
label: string;
|
||||
tip?: string;
|
||||
coord?: { row: number; col: number };
|
||||
route?: { row: number; col: number }[];
|
||||
}): string => {
|
||||
const { label, index } = a;
|
||||
const cut = label.indexOf(' — ');
|
||||
const head = cut > 0 ? label.slice(0, cut) : label;
|
||||
// The menu resolved the card's description server-side, so the page never needs the state.
|
||||
const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? '');
|
||||
return (
|
||||
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
|
||||
`<button class="act" data-i="${index}"${cellRef(a.coord, a.route)}${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -748,7 +818,7 @@ function renderActions(
|
||||
f.moves
|
||||
.map(
|
||||
(m) =>
|
||||
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}" ` +
|
||||
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` +
|
||||
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.row}, ${m.from.col}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
|
||||
`${esc(m.label)} <span class="dim">(${m.from.row}, ${m.from.col})</span></button>`,
|
||||
)
|
||||
@@ -847,7 +917,7 @@ function renderActions(
|
||||
? ` data-tip-html="${esc(piecePreview(sp.links, item.subject))}"`
|
||||
: '';
|
||||
return (
|
||||
`<button class="act" data-i="${sp.index}"${fig}` +
|
||||
`<button class="act" data-i="${sp.index}"${fig}${cellRef(sp.coord)}` +
|
||||
` data-tip="${esc(`${item.subject} — ${sp.label}`)}">${esc(sp.label)}</button>`
|
||||
);
|
||||
})
|
||||
@@ -882,6 +952,9 @@ function renderActions(
|
||||
render();
|
||||
}
|
||||
: () => apply(Number(node.dataset['i']));
|
||||
const square = node.dataset['square'];
|
||||
const route = node.dataset['route'];
|
||||
if (square) wirePointing(node, square, route ? route.split(' ') : []);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ function show(i: number): void {
|
||||
$('vpos').textContent = `${at} / ${steps.length - 1}`;
|
||||
($('vscrub') as HTMLInputElement).value = String(at);
|
||||
$('vdivision').innerHTML = divisionSvg(f.division);
|
||||
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow);
|
||||
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow, [], [], f.limits);
|
||||
|
||||
// Auto-hide: the district is worth its space during the phases that change it.
|
||||
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
|
||||
|
||||
+191
-7
@@ -56,6 +56,7 @@ const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -445,6 +446,7 @@ describe('Freight Agent operations (§6.3)', () => {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
@@ -576,6 +578,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
@@ -757,6 +760,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
subtype: 'mineTipple',
|
||||
@@ -930,6 +934,177 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the Limits bound the district, and the nine spots reach round a Facility', () => {
|
||||
/**
|
||||
* A district whose Running Track has been extended one square east, so the sign stands at col 2
|
||||
* and there is a siding hanging below the main to build on:
|
||||
*
|
||||
* row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track, Limits at -1 and 2
|
||||
* row -1: [curve ne]
|
||||
*
|
||||
* Returns the industry-ready square under the sign, at (-1, 2).
|
||||
*/
|
||||
function district(s: GameState): GridCoord {
|
||||
const area = areaOf(s, 0);
|
||||
const plain = (geometry: object): TrackCard => ({
|
||||
geometry: geometry as TrackCard['geometry'],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const col = area.limitsEast.col;
|
||||
area.grid.set(coordKey(at(area.runningRow, col)), plain({
|
||||
kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left',
|
||||
}));
|
||||
area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({
|
||||
kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left',
|
||||
}));
|
||||
area.limitsEast = at(area.runningRow, col + 1);
|
||||
area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' }));
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
turnOf(s, 0).option = 'draw';
|
||||
return at(area.runningRow - 1, col + 1);
|
||||
}
|
||||
|
||||
const trackInHand = (s: GameState, geometry: string): string => {
|
||||
for (const [id, card] of s.cards) {
|
||||
if (card.kind.kind === 'track' && card.kind.geometry === geometry) {
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no ${geometry} card`);
|
||||
};
|
||||
|
||||
const industryOf = (s: GameState, facility: string): string => {
|
||||
for (const [id, card] of s.cards) {
|
||||
if (card.kind.kind === 'freightFacility' && card.kind.facility === facility) {
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no industry card: ${facility}`);
|
||||
};
|
||||
|
||||
const modifierOf = (s: GameState, modifier: string): string => {
|
||||
for (const [id, card] of s.cards) {
|
||||
if (card.kind.kind === 'modifier' && card.kind.modifier === modifier) {
|
||||
s.decks.hands.set(0, [id]);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no modifier card: ${modifier}`);
|
||||
};
|
||||
|
||||
/** An industry standing on the board, so a Modifier has a host to hang off. */
|
||||
const buildFacility = (s: GameState, kind: string, coord: GridCoord): void => {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), {
|
||||
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
|
||||
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
||||
facility: {
|
||||
kind: 'freight', subtype: kind,
|
||||
allows: { outbound: true, inbound: false },
|
||||
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
} as never);
|
||||
};
|
||||
|
||||
it('accepts a siding in the sign\'s own column and refuses one past it', () => {
|
||||
/**
|
||||
* REPORTED by Jesse: "sidings should not be allowed to be built outside the limits". §3 defines
|
||||
* Secondary Track as "all tracks IN YOUR LIMITS that are not the Running Track", so the bound
|
||||
* belongs to the district rather than to the one row the sign stands in — it used to guard the
|
||||
* Running Track alone, and a siding could run east past a player's own sign, taking industries
|
||||
* with it, onto track that §8.1 and §10 do not consider his territory at all.
|
||||
*
|
||||
* INCLUSIVE of the sign's column, which is the half that keeps the game playable: the opening
|
||||
* district is signs at ±1 around the Office, so the strict reading would leave one buildable
|
||||
* column and break §11.3's promise that both Secondary rows are usable from the first Stage.
|
||||
*/
|
||||
const s = game();
|
||||
const under = district(s);
|
||||
const cardId = trackInHand(s, 'straight');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: under }),
|
||||
null,
|
||||
'a siding may run under the Limits sign — the sign stands ON the boundary, not beyond it',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
|
||||
'OUTSIDE_LIMITS',
|
||||
'a siding was built outside the district it belongs to',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an industry outside the Limits — a Facility carries track', () => {
|
||||
// §11.2: "Facility cards carry their own rails — placing a Facility places track." So the bound
|
||||
// is the same one, and said with the same code rather than left to read as NOT_CONNECTED.
|
||||
const s = game();
|
||||
const under = district(s);
|
||||
const cardId = industryOf(s, 'mineTipple');
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: under }), null);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
|
||||
'OUTSIDE_LIMITS',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a Modifier the DIAGONAL spots around its host, not just the four orthogonal ones', () => {
|
||||
/**
|
||||
* REPORTED by Jesse: a Modifier could not be placed to the south-east of his industry. §9 places
|
||||
* one "adjacent to a Facility, on any of the nine nearby spots" and `check` has always accepted
|
||||
* all eight neighbours — it was `placementCandidates` that walked north, south, east and west
|
||||
* only, so a diagonal square with no orthogonal neighbour was legal and never offered.
|
||||
*/
|
||||
const s = game();
|
||||
const under = district(s);
|
||||
buildFacility(s, 'packingSheds', under);
|
||||
const cardId = modifierOf(s, 'iceHouse');
|
||||
|
||||
const offered = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
|
||||
.map((i) => coordKey((i as { placement: GridCoord }).placement));
|
||||
|
||||
// South-east of the host, and orthogonally adjacent to nothing at all.
|
||||
const southEast = at(under.row - 1, under.col + 1);
|
||||
assert.ok(
|
||||
offered.includes(coordKey(southEast)),
|
||||
`the south-east spot (${southEast.row}, ${southEast.col}) is legal but was never offered — offered: ${offered.join(' ')}`,
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
|
||||
});
|
||||
|
||||
it('lets a Modifier hang outside the Limits, but never in the Running Track row', () => {
|
||||
/**
|
||||
* Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps
|
||||
* all nine of its spots — refusing the outer three would make the card unplayable exactly where
|
||||
* the district ends. The Running Track ROW is the exception: inside the Limits that row is
|
||||
* always full, so this bites only beyond the sign, and that is the ground the main grows onto —
|
||||
* a Modifier parked there would block the player's own sign from moving outward (§2.1).
|
||||
*/
|
||||
const s = game();
|
||||
const under = district(s);
|
||||
buildFacility(s, 'packingSheds', under);
|
||||
const cardId = modifierOf(s, 'iceHouse');
|
||||
const area = areaOf(s, 0);
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
|
||||
null,
|
||||
'a Modifier beside a host at the limit was refused the spot outside it',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
|
||||
'ON_RUNNING_TRACK',
|
||||
'a Modifier was allowed to stand in the row the Running Track grows along',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("a Modifier grants only what its host's flow can use", () => {
|
||||
const withFacility = (kind: string, out: boolean, into: boolean) => ({
|
||||
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
|
||||
@@ -1111,14 +1286,19 @@ describe('Industry cards go on a stub, and lock each other out', () => {
|
||||
* A district with a siding hanging off the Running Track, which is the only place an industry may
|
||||
* go. Returns the siding square east of the curve.
|
||||
*
|
||||
* row 0: [lim] [office] [turnout, leg south] … <- Running Track
|
||||
* row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track
|
||||
* row -1: [curve ne] [siding square]
|
||||
*
|
||||
* The turnout is laid ON the east Limits sign, which is how the Running Track grows — so the sign
|
||||
* MOVES OUT with it (§2.1, Gap 4a), exactly as `extendLimitsIfNeeded` does when the card is played
|
||||
* rather than written straight into the grid. Without that the siding square would be outside the
|
||||
* district's own Limits, which is no longer a place track may go.
|
||||
*/
|
||||
function withSiding(s: GameState): GridCoord {
|
||||
const area = areaOf(s, 0);
|
||||
const plain = (geometry: object): TrackCard => ({
|
||||
geometry: geometry as TrackCard['geometry'],
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const col = area.limitsEast.col;
|
||||
area.grid.set(coordKey(at(area.runningRow, col)), plain({
|
||||
@@ -1127,6 +1307,8 @@ describe('Industry cards go on a stub, and lock each other out', () => {
|
||||
area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({
|
||||
kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left',
|
||||
}));
|
||||
area.limitsEast = at(area.runningRow, col + 1);
|
||||
area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' }));
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
turnOf(s, 0).option = 'draw';
|
||||
@@ -1148,7 +1330,7 @@ describe('Industry cards go on a stub, and lock each other out', () => {
|
||||
function build(s: GameState, kind: string, coord: GridCoord): void {
|
||||
areaOf(s, 0).grid.set(coordKey(coord), {
|
||||
geometry: { kind: 'facility', facility: kind as never },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1443,8 +1625,9 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
|
||||
addCard(s, at(0, 0), straight());
|
||||
reduce(s, moved(id, 's'));
|
||||
const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0);
|
||||
assert.ok(cell?.train, 'the train should be on the card');
|
||||
assert.ok(cell.train.facing === 'e' || cell.train.facing === 'w', `got ${cell.train.facing}`);
|
||||
const train = cell?.trains[0];
|
||||
assert.ok(train, 'the train should be on the card');
|
||||
assert.ok(train.facing === 'e' || train.facing === 'w', `got ${train.facing}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1468,6 +1651,7 @@ describe('a train that rounds a curve points where the curve took it', () => {
|
||||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -1604,11 +1788,11 @@ describe('the switching job a player actually does: put a car in a siding, take
|
||||
*/
|
||||
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const turnout = (o: { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'turnout', turnout: o, hand: 'right' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
|
||||
it('takes a cut off the Running Track into a siding and sets it out', () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -149,7 +150,14 @@ describe('a cut parks in the same order however many cars come off at once', ()
|
||||
|
||||
// Facing east: the nose cut (boxcar) is set out east of the engine, the tail cut (tank) west.
|
||||
assert.deepEqual(standingAt(s, at(1, 0)), ['tank', 'boxcar']);
|
||||
assert.equal(s.trays.get(id)!.standingWest, 1, 'the engine should be standing between the two cuts');
|
||||
// `standingWest` lives on the CARD now, not the tray (docs/plans/switching-paths.md — "The
|
||||
// Roster Pass"): the Office is the one square more than one train may share, and a cut lies
|
||||
// west or east of the block of A/D tracks rather than of any one engine.
|
||||
assert.equal(
|
||||
areaOf(s, 0).grid.get(coordKey(at(1, 0)))!.standingWest,
|
||||
1,
|
||||
'the engine should be standing between the two cuts',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const straight = (): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
@@ -313,6 +313,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
@@ -450,7 +450,7 @@ describe('the view shows one seat at a time', () => {
|
||||
for (let n = 0; n < p; n++) {
|
||||
area.grid.set(coordKey({ row: area.runningRow - 1, col: n }), {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
}
|
||||
s.decks.hands.set(p, source.slice(0, p + 1));
|
||||
@@ -527,7 +527,7 @@ describe('the view shows one seat at a time', () => {
|
||||
readyToLeave(s, 1, 'crew-1', 'east');
|
||||
|
||||
const withTray = (p: PlayerIndex): number =>
|
||||
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.tray != null).length;
|
||||
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.trains.length > 0).length;
|
||||
assert.equal(withTray(1), 1, "the crew is not on its own seat's board");
|
||||
assert.deepEqual([withTray(0), withTray(2)], [0, 0], "a crew was drawn onto another seat's board");
|
||||
});
|
||||
@@ -594,7 +594,7 @@ describe('scoring lands on the right seat', () => {
|
||||
const sizeBefore = areaAtSeat(s, 2).grid.size;
|
||||
mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
assert.equal(areaAtSeat(s, 2).grid.size, sizeBefore, "one seat's track appeared in another's district");
|
||||
assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
|
||||
|
||||
@@ -368,6 +368,34 @@ describe('the replay wire format is lossless (regression)', () => {
|
||||
assert.ok(checked > 100, `only ${checked} frames checked`);
|
||||
});
|
||||
|
||||
it("tolerates an older recording's singular train at the trains slot", () => {
|
||||
// Before "The Roster Pass" (docs/plans/switching-paths.md), the trains slot held one train
|
||||
// object or null — `rehydrateCells` has to make sense of that shape too, with no version flag
|
||||
// anywhere in a packed row to key off.
|
||||
const cards: unknown[] = [[0, 0, 'office', 'Whistle Post', true, ['ew']]];
|
||||
const whats = ['a station'];
|
||||
const facs: unknown[] = [];
|
||||
const oldTrain = { trayId: 'crew', label: 'crew', cars: [], engineAt: 0, facing: 'e', what: '' };
|
||||
|
||||
const [withObject] = rehydrateCells([[0, 0, [], [], -1, oldTrain, 1, [], 0]], cards, whats, facs) as {
|
||||
trains: unknown[];
|
||||
}[];
|
||||
assert.deepEqual(withObject!.trains, [oldTrain], 'a lone train object should wrap into a one-train roster');
|
||||
|
||||
const [withNull] = rehydrateCells([[0, 0, [], [], -1, null, 1, [], 0]], cards, whats, facs) as {
|
||||
trains: unknown[];
|
||||
}[];
|
||||
assert.deepEqual(withNull!.trains, [], 'a null train slot should read as no train at all');
|
||||
|
||||
const [withArray] = rehydrateCells(
|
||||
[[0, 0, [], [], -1, [oldTrain, oldTrain], 1, [], 0]],
|
||||
cards,
|
||||
whats,
|
||||
facs,
|
||||
) as { trains: unknown[] }[];
|
||||
assert.equal(withArray!.trains.length, 2, 'an array should ride through as-is');
|
||||
});
|
||||
|
||||
it('writes each distinct card and description once, not once per frame', () => {
|
||||
const rec = record(880009, 'standard', 4000);
|
||||
const packed = compress(rec.frames);
|
||||
|
||||
+151
-6
@@ -18,6 +18,8 @@ import type {
|
||||
import { coordKey, isOperationalRail, turnOf } from '../src/engine/state.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { Intent } from '../src/engine/intents.ts';
|
||||
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
|
||||
import {
|
||||
allReachable,
|
||||
@@ -46,6 +48,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -60,6 +63,7 @@ const curve = (arc: TrackArc, standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'curved', arc },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -71,6 +75,7 @@ const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
||||
: { kind: 'track', geometry: 'turnout' },
|
||||
baseOperationalRail: false, // no wheel icon — a train may pass through but not stop (§A.1)
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -80,6 +85,7 @@ const officeCard = (): TrackCard => ({
|
||||
geometry: { kind: 'office' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -89,6 +95,7 @@ const lockedFacility = (): TrackCard => ({
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
// A load sitting on MEN|AT|WORK locks the industry track down (§9.3).
|
||||
facility: {
|
||||
kind: 'freight',
|
||||
@@ -112,7 +119,7 @@ const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
||||
function straightCard(): TrackCard {
|
||||
return {
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,6 +215,7 @@ describe('ports and geometry', () => {
|
||||
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
@@ -627,7 +635,7 @@ describe('placement and drop-off', () => {
|
||||
describe('curves are arcs, and arcs make sidings possible', () => {
|
||||
const arcCard = (arc: 'ne' | 'nw' | 'se' | 'sw', geometry: 'curved' | 'sharpCurved' = 'curved'): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry, arc },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
|
||||
it('joins exactly two adjacent edges, with no through track', () => {
|
||||
@@ -645,7 +653,7 @@ describe('curves are arcs, and arcs make sidings possible', () => {
|
||||
c.map((p) => [...p].sort().join('')).sort().join(' ');
|
||||
const turnout: TrackCard = {
|
||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
};
|
||||
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
||||
assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)),
|
||||
@@ -688,11 +696,11 @@ describe('curves are arcs, and arcs make sidings possible', () => {
|
||||
const ew = (): TrackCard => straightCard();
|
||||
const grid: Record<string, TrackCard> = {
|
||||
'0,-2': ew(),
|
||||
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
||||
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] },
|
||||
'-1,-1': arcCard('ne'),
|
||||
'-1,0': ew(),
|
||||
'-1,1': arcCard('nw'),
|
||||
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
||||
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] },
|
||||
'0,0': ew(),
|
||||
};
|
||||
const area = areaFrom(grid, { row: 0, col: 0 });
|
||||
@@ -776,6 +784,7 @@ describe('Operational Rail — where a train may stop and leave cars (Appendix A
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
@@ -917,7 +926,7 @@ 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: [],
|
||||
baseOperationalRail: false, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
|
||||
it('joins a south-diverging turnout to the north-diverging one beneath it', () => {
|
||||
@@ -1025,6 +1034,7 @@ describe('why a square is NOT offered (the same walk, keeping its rejections)',
|
||||
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'mineTipple',
|
||||
allows: { outbound: true, inbound: false },
|
||||
@@ -1110,3 +1120,138 @@ describe('why a square is NOT offered (the same walk, keeping its rejections)',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// docs/plans/switching-paths.md — two routes to the same square
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('two routes to one square', () => {
|
||||
const limitsCard = (): TrackCard => ({
|
||||
geometry: { kind: 'limits' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* A passing loop: a turnout diverges off the Running Track, curves down to an industry, and
|
||||
* curves back up to rejoin the main two columns west. Both routes from (0,4) to (0,0) are legal
|
||||
* under §2.4 (neither ever leaves a card by the port it entered); they differ in what they
|
||||
* couple. This is the fixture in the plan's §1, verified identical on v0.4.7 and v0.4.8.
|
||||
*/
|
||||
function loopArea(midCard: (col: number) => TrackCard = () => straight()): OfficeArea {
|
||||
return areaFrom(
|
||||
{
|
||||
[coordKey(at(0, 0))]: straight(),
|
||||
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
||||
[coordKey(at(0, 2))]: midCard(2),
|
||||
[coordKey(at(0, 3))]: turnout({ stem: 'e', through: 'w', diverge: 's' }),
|
||||
[coordKey(at(0, 4))]: limitsCard(),
|
||||
[coordKey(at(-1, 1))]: curve('ne'),
|
||||
[coordKey(at(-1, 2))]: midCard(-1),
|
||||
[coordKey(at(-1, 3))]: curve('nw'),
|
||||
},
|
||||
at(9, 9), // office coord, deliberately away from the fixture
|
||||
);
|
||||
}
|
||||
|
||||
const tanker: RollingStock = { type: 'tank', loaded: false };
|
||||
|
||||
it('offers both routes, and only one couples the tanker on the spur', () => {
|
||||
const area = loopArea((col) => (col === -1 ? straight([tanker]) : straight()));
|
||||
const dests = reachableDestinations(ctxFor(area), at(0, 4), 'w');
|
||||
const atZero = dests.filter((d) => d.coord.row === 0 && d.coord.col === 0);
|
||||
assert.equal(atZero.length, 2, `expected two distinct routes to (0,0), got ${atZero.length}`);
|
||||
const withTanker = atZero.filter((d) => d.couples.some((c) => c.type === 'tank'));
|
||||
const without = atZero.filter((d) => d.couples.length === 0);
|
||||
assert.equal(withTanker.length, 1, 'exactly one route should couple the tanker');
|
||||
assert.equal(without.length, 1, 'exactly one route should couple nothing');
|
||||
});
|
||||
|
||||
it('offers two routes coupling the SAME car type off DIFFERENT cards, and neither is dropped', () => {
|
||||
// The dedupe key must include the origin card, not just the car type (plan §3) — two boxcars,
|
||||
// one on the bypass and one on the main, must not collapse into one destination.
|
||||
const area = loopArea(() => straight([{ type: 'boxcar', loaded: false }]));
|
||||
const dests = reachableDestinations(ctxFor(area), at(0, 4), 'w');
|
||||
const atZero = dests.filter((d) => d.coord.row === 0 && d.coord.col === 0);
|
||||
assert.equal(atZero.length, 2, `expected two distinct routes to (0,0), got ${atZero.length}`);
|
||||
for (const d of atZero) {
|
||||
assert.deepEqual(d.couples.map((c) => c.type), ['boxcar'], 'each route should couple its own boxcar');
|
||||
}
|
||||
});
|
||||
|
||||
it('a `via`-qualified switch.move resolves to the route it names', () => {
|
||||
function freshGame(): GameState {
|
||||
const s = gameWith(loopArea((col) => (col === -1 ? straight([tanker]) : straight())));
|
||||
s.trays.set('crew', {
|
||||
id: 'crew', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: at(0, 4) }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
turnOf(s, 0).option = 'switch';
|
||||
turnOf(s, 0).movesRemaining = 6;
|
||||
return s;
|
||||
}
|
||||
|
||||
const probe = freshGame();
|
||||
const options = legalActions(probe, 0).filter(
|
||||
(i): i is Extract<Intent, { type: 'switch.move' }> =>
|
||||
i.type === 'switch.move' && i.to.row === 0 && i.to.col === 0,
|
||||
);
|
||||
assert.equal(options.length, 2, 'expected two distinct switch.move intents to (0,0)');
|
||||
assert.ok(options.every((i) => i.via !== undefined), 'an ambiguous route must carry a distinguishing via');
|
||||
const vias = new Set(options.map((i) => `${i.via!.row},${i.via!.col}`));
|
||||
assert.equal(vias.size, 2, 'the two routes must carry DIFFERENT via');
|
||||
|
||||
const outcomes = options.map((intent) => {
|
||||
const s = freshGame();
|
||||
const r = applyIntent(s, 0, intent);
|
||||
assert.ok(r.ok, 'a via-qualified move to a square the engine offered must be legal');
|
||||
return s.trays.get('crew')!.consist.map((c) => c.type);
|
||||
});
|
||||
assert.ok(outcomes.some((c) => c.includes('tank')), 'one via should have coupled the tanker');
|
||||
assert.ok(outcomes.some((c) => c.length === 0), 'the other via should have coupled nothing');
|
||||
|
||||
// `via` absent still resolves — the first route enumerated, exactly as before this feature.
|
||||
const s = freshGame();
|
||||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: at(0, 0), reverse: false });
|
||||
assert.ok(r.ok, 'a plain move with no via must still resolve to SOME legal route');
|
||||
});
|
||||
|
||||
it('a dense district terminates and stays bounded, preferring the routes BFS finds first', () => {
|
||||
// Chained passing loops: each one independently offers "through" or "detour", so N of them
|
||||
// offer 2^N routes to the far end with no global memo to stop it — exactly the exponential case
|
||||
// the cap exists for (plan §3). 15 segments is 32,768 routes; the cap is 4,000 frontier nodes.
|
||||
const segments = 15;
|
||||
const start = 3 * segments;
|
||||
const grid: Record<string, TrackCard> = { [coordKey(at(0, start + 1))]: limitsCard() };
|
||||
for (let k = 0; k < segments; k++) {
|
||||
const divergeCol = start - 3 * k;
|
||||
const midCol = divergeCol - 1;
|
||||
const mergeCol = divergeCol - 2;
|
||||
grid[coordKey(at(0, divergeCol))] = turnout({ stem: 'e', through: 'w', diverge: 's' });
|
||||
grid[coordKey(at(0, midCol))] = straight();
|
||||
grid[coordKey(at(0, mergeCol))] = turnout({ stem: 'w', through: 'e', diverge: 's' });
|
||||
grid[coordKey(at(-1, divergeCol))] = curve('nw');
|
||||
grid[coordKey(at(-1, midCol))] = straight();
|
||||
grid[coordKey(at(-1, mergeCol))] = curve('ne');
|
||||
}
|
||||
grid[coordKey(at(0, 0))] = straight();
|
||||
const area = areaFrom(grid, at(9, 9));
|
||||
|
||||
const startedAt = Date.now();
|
||||
const dests = reachableDestinations(ctxFor(area), at(0, start + 1), 'w');
|
||||
const elapsed = Date.now() - startedAt;
|
||||
|
||||
assert.ok(elapsed < 5000, `exploreMoves took ${elapsed}ms — the cap should keep this fast`);
|
||||
assert.ok(dests.length > 0, 'the cap must not eliminate every route');
|
||||
assert.ok(
|
||||
dests.length < 2 ** segments,
|
||||
`${dests.length} destinations found — the cap should have cut off full 2^${segments} enumeration`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing,
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
|
||||
+224
-15
@@ -598,6 +598,54 @@ describe('the board shows freight work happening', () => {
|
||||
assert.ok(y > 48, `the caption at y=${y} prints over the rail`);
|
||||
});
|
||||
|
||||
it('draws the Limits as the edge of the buildable district, at every row', () => {
|
||||
/**
|
||||
* Track may not be laid outside the Limits at ANY row now (§2.1), and two signs sitting on the
|
||||
* Running Track could not say that: a player looking at open ground beyond a sign had no way to
|
||||
* know nothing of his would ever go there until the square failed to light up.
|
||||
*
|
||||
* Just OUTSIDE the sign's own column, because the sign stands ON the boundary — a siding may run
|
||||
* under it — so a line drawn inside the sign would teach the opposite of the rule.
|
||||
*/
|
||||
const game = newGame(555);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
const f = view(game);
|
||||
assert.deepEqual(
|
||||
f.limits,
|
||||
{ west: area.limitsWest.col, east: area.limitsEast.col },
|
||||
'the Frame does not carry the district edges a remote client cannot look up',
|
||||
);
|
||||
|
||||
const svg = officeSvg(f.cells, f.runningRow, [], [], f.limits);
|
||||
const xs = [...svg.matchAll(/class="bs-limitline" x1="(-?[\d.]+)"/g)].map((m) => Number(m[1]));
|
||||
assert.equal(xs.length, 2, 'the district was drawn without both of its edges');
|
||||
|
||||
// The sign cards themselves must fall INSIDE the two lines.
|
||||
const signX = [...svg.matchAll(/data-cell="(-?\d+),(-?\d+)"[^>]*transform="translate\((-?[\d.]+)/g)]
|
||||
.filter((m) => Number(m[2]) === area.limitsWest.col || Number(m[2]) === area.limitsEast.col)
|
||||
.map((m) => Number(m[3]));
|
||||
assert.ok(signX.length > 0, 'no Limits sign was drawn to check the boundary against');
|
||||
for (const x of signX) {
|
||||
assert.ok(
|
||||
x >= Math.min(...xs) && x <= Math.max(...xs),
|
||||
`a Limits sign at x=${x} sits outside its own boundary (${xs.join(', ')})`,
|
||||
);
|
||||
}
|
||||
assert.match(svg, /bs-limitlab/, 'the boundary is drawn but never named');
|
||||
|
||||
// AND ON THE CANVAS. The west sign is the leftmost card, so a line drawn outside its column sits
|
||||
// at a negative x — off a viewBox that starts at 0, which is a boundary nobody can see.
|
||||
const vb = /viewBox="(-?[\d.]+) (-?[\d.]+) ([\d.]+) ([\d.]+)"/.exec(svg);
|
||||
assert.ok(vb, 'the board produced no viewBox');
|
||||
const x0 = Number(vb![1]);
|
||||
for (const x of xs) {
|
||||
assert.ok(
|
||||
x >= x0 && x <= x0 + Number(vb![3]),
|
||||
`the boundary at x=${x} is drawn outside the canvas (${x0} to ${x0 + Number(vb![3])})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('says on the card which way an industry runs, and how many workers it has', () => {
|
||||
/**
|
||||
* TWO REPORTS, ONE CARD.
|
||||
@@ -708,8 +756,8 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
|
||||
/** One card, alone, so the measurements are in card coordinates. */
|
||||
const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => {
|
||||
const cell = {
|
||||
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [],
|
||||
tray: null, cars: [], facility: null, links, what: '',
|
||||
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
|
||||
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
|
||||
};
|
||||
const svg = officeSvg([cell] as never, 0);
|
||||
return [...svg.matchAll(/<line class="bs-rail" x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"\/>/g)]
|
||||
@@ -883,7 +931,7 @@ describe('the page explains itself', () => {
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
area.grid.set('-1,0', {
|
||||
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
|
||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
|
||||
});
|
||||
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
||||
// Said the way a player would ask it: if my train comes in from over there, where can it go?
|
||||
@@ -1314,7 +1362,7 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders clickable highlights on the board when a card is picked', async () => {
|
||||
it('renders clickable highlights on the board, and points at the square an action names', async () => {
|
||||
// The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board
|
||||
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
|
||||
// nothing about whether the page draws them.
|
||||
@@ -1366,7 +1414,12 @@ describe('the static build', () => {
|
||||
const classes = new Set<string>();
|
||||
found.set(key, {
|
||||
onclick: null,
|
||||
classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) },
|
||||
classList: {
|
||||
add: (c: string) => void classes.add(c),
|
||||
// Pointing at a square is a HOVER: it has to come off again when the cursor leaves.
|
||||
remove: (c: string) => void classes.delete(c),
|
||||
has: (c: string) => classes.has(c),
|
||||
},
|
||||
classes,
|
||||
});
|
||||
}
|
||||
@@ -1493,6 +1546,37 @@ describe('the static build', () => {
|
||||
const lit = (grid['highlighted'] as () => unknown[])();
|
||||
const ghosts = /data-ghost="/.test(html);
|
||||
assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board');
|
||||
|
||||
/**
|
||||
* POINTING AT THE SQUARE A BUTTON MEANS — wired on the emitted bundle, not asserted off the menu.
|
||||
*
|
||||
* The menu carrying a coordinate proves nothing about whether the page draws it, which is the
|
||||
* same reason the highlight check above loads the real bundle. Reported by Jesse: the action list
|
||||
* is a column of near-identical sentences separated by "(1,3)" against "(-1,3)", and picking the
|
||||
* wrong one is a click to undo in solitaire and unrecoverable in a multiplayer game.
|
||||
*/
|
||||
const withSquare = (
|
||||
(actions['querySelectorAll'] as (s: string) => Record<string, unknown>[])
|
||||
.call(actions, 'button.act')
|
||||
.filter((n) => (n['dataset'] as Record<string, string>)['square'])
|
||||
);
|
||||
assert.ok(withSquare.length > 0, 'no action button carries the square it acts on');
|
||||
const btn = withSquare[0]!;
|
||||
const key = (btn['dataset'] as Record<string, string>)['square']!;
|
||||
assert.match(key, /^-?\d+,-?\d+$/, `"${key}" is not a grid coordinate`);
|
||||
(btn['onmouseenter'] as (() => void) | null)?.();
|
||||
const pointed = (grid['highlighted'] as () => Record<string, unknown>[])().filter((g) =>
|
||||
(g['classes'] as Set<string>).has('bs-point'),
|
||||
);
|
||||
assert.equal(pointed.length, 1, `hovering the action for (${key}) marked ${pointed.length} squares`);
|
||||
(btn['onmouseleave'] as (() => void) | null)?.();
|
||||
assert.equal(
|
||||
(grid['highlighted'] as () => Record<string, unknown>[])().filter((g) =>
|
||||
(g['classes'] as Set<string>).has('bs-point'),
|
||||
).length,
|
||||
0,
|
||||
'the square stayed lit after the cursor left the button',
|
||||
);
|
||||
});
|
||||
|
||||
it('busts the cache on every module, so a deploy cannot half-load', () => {
|
||||
@@ -1876,10 +1960,11 @@ describe('the static build', () => {
|
||||
} 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 train = cell.trains[0];
|
||||
assert.ok(train, 'the card carries no train');
|
||||
assert.deepEqual(train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
|
||||
assert.equal(train!.engineAt, 0, 'the engine has no place in the train');
|
||||
assert.equal(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');
|
||||
@@ -1922,16 +2007,97 @@ describe('the static build', () => {
|
||||
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', () => {
|
||||
it('shows A/D tracks on the Office card, one chip per track', () => {
|
||||
// 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).
|
||||
// decides whether the next arrival is an automatic collision (§8.3). The Roster Pass replaced
|
||||
// the pips with one roster chip per A/D track (docs/plans/switching-paths.md), free or occupied.
|
||||
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');
|
||||
assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track');
|
||||
assert.equal(cell.trains.length, 0, 'no train is standing yet');
|
||||
const svg = officeSvg([cell], area.runningRow);
|
||||
assert.match(svg, /bs-adchip/, 'no roster chip is drawn');
|
||||
assert.match(svg, />free</, 'the one A/D track should read free with nothing standing on it');
|
||||
});
|
||||
|
||||
it('draws every train standing at a busy Office, not just one', () => {
|
||||
// REPORTED (docs/plans/switching-paths.md — "The Roster Pass"): the old single `train` field on
|
||||
// a cell showed only whichever tray `s.trays` happened to yield first, so a second train holding
|
||||
// at a Station was counted in the old A/D pips and never drawn at all.
|
||||
const game = newGame(555);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
area.tier = 'terminal'; // 4 A/D tracks — room for every train this test parks
|
||||
const at = area.officeCoord;
|
||||
const trainNumbers = [10, 12, 14, 16];
|
||||
for (const n of trainNumbers) {
|
||||
const id = `t${n}`;
|
||||
game.state.trays.set(id, {
|
||||
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: false }],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
|
||||
} as never);
|
||||
area.adOccupancy.push(id);
|
||||
}
|
||||
|
||||
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
|
||||
assert.equal(cell.trains.length, 4, 'all four trains should be on the Office card');
|
||||
assert.equal(cell.adTracks, 4, 'a Terminal has four A/D tracks');
|
||||
|
||||
const svg = officeSvg([cell], area.runningRow);
|
||||
const chipCount = [...svg.matchAll(/class="bs-adchip/g)].length;
|
||||
assert.equal(chipCount, 4, `expected 4 roster chips at a full Terminal, got ${chipCount}`);
|
||||
assert.doesNotMatch(svg, />free</, 'every track is taken, so nothing should read free');
|
||||
for (const n of trainNumbers) {
|
||||
assert.match(svg, new RegExp(`data-crew="t${n}"`), `T${n} has no roster chip to click`);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
|
||||
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
|
||||
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
|
||||
// standingWest surviving on an emptied card must not draw a gap nobody would recouple.
|
||||
const cell = {
|
||||
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
|
||||
trains: [], adTracks: null,
|
||||
cars: ['boxcar', 'loaded hopper', 'reefer'],
|
||||
standingWest: 2, // stale — as if a train had once stood here and left
|
||||
facility: null, links: ['ew'], what: '',
|
||||
};
|
||||
const svg = officeSvg([cell as never], 0);
|
||||
const xs = [...svg.matchAll(/class="bs-slot[^"]*" x="([-\d.]+)"/g)].map((m) => Number(m[1]));
|
||||
assert.equal(xs.length, 3, 'all three cars should be drawn');
|
||||
const sorted = [...xs].sort((a, b) => a - b);
|
||||
// Left-aligned means each slot sits exactly 30px past the one before it — no gap anywhere in
|
||||
// the row for an engine that is not there.
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
assert.equal(sorted[i]! - sorted[i - 1]!, 30, `a gap survived at index ${i} with no train present`);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads one split off the shared card, not one per train', () => {
|
||||
// The whole justification for moving `standingWest` onto the card rather than the tray
|
||||
// (docs/plans/switching-paths.md): two trays holding at one Office must not be able to disagree
|
||||
// about the same row of cars, because there is only one number for them to read.
|
||||
const game = newGame(555);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
area.tier = 'station'; // 3 A/D tracks
|
||||
const at = area.officeCoord;
|
||||
const card = area.grid.get(`${at.row},${at.col}`)!;
|
||||
card.standing = [{ type: 'boxcar', loaded: false }, { type: 'hopper', loaded: false }];
|
||||
card.standingWest = 1;
|
||||
for (const n of [10, 12]) {
|
||||
const id = `t${n}`;
|
||||
game.state.trays.set(id, {
|
||||
id, trainNumber: n, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
|
||||
} as never);
|
||||
}
|
||||
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
|
||||
assert.equal(cell.trains.length, 2, 'both trains should be on the Office card');
|
||||
assert.equal(cell.standingWest, 1, "the card's split is one number, not one per train");
|
||||
});
|
||||
|
||||
it('names the cars a drop would set out, and which end they come off', () => {
|
||||
@@ -2150,6 +2316,49 @@ describe('the Division map shows the whole route', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every roster chip inside the Office cell it belongs to, at any occupancy', () => {
|
||||
// "Two Trains, One Card": sizing the cell by OCCUPANCY moved the East Division Point sideways
|
||||
// every time an A/D track filled or cleared. Sizing by CAPACITY (docs/plans/switching-paths.md)
|
||||
// holds the map still, and every chip has to actually fit inside the cell that capacity bought.
|
||||
const s = createEngineGame({
|
||||
id: 'div-chips', seed: 7,
|
||||
config: {
|
||||
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Solitaire'],
|
||||
});
|
||||
const area = areaOf(s, 0);
|
||||
area.tier = 'terminal'; // 4 A/D tracks
|
||||
for (const n of [10, 12, 14, 16]) {
|
||||
const id = `t${n}`;
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: false }],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
} as never);
|
||||
area.adOccupancy.push(id);
|
||||
}
|
||||
|
||||
const svg = divisionSvg(snapshot(s, [], null).division);
|
||||
const cells = [...svg.matchAll(/class="bs-dcell bs-drun[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
|
||||
.map((m) => ({ x: +m[1]!, w: +m[3]! }));
|
||||
assert.ok(cells.length > 0, 'no Running Track cell drawn');
|
||||
const office = cells.reduce((a, b) => (b.w > a.w ? b : a));
|
||||
assert.ok(office.w > 78, `the Office cell did not widen for its 4 A/D tracks: ${office.w}`);
|
||||
|
||||
const chips = [...svg.matchAll(/class="bs-train"[^>]*><rect x="([-\d.]+)" y="[-\d.]+" width="([\d.]+)"/g)]
|
||||
.map((m) => ({ x: +m[1]!, w: +m[2]! }));
|
||||
assert.equal(chips.length, 4, `expected 4 train chips, got ${chips.length}`);
|
||||
for (const c of chips) {
|
||||
assert.ok(
|
||||
c.x >= office.x - 0.01 && c.x + c.w <= office.x + office.w + 0.01,
|
||||
`a chip at x=${c.x} w=${c.w} spills outside the Office cell [${office.x}, ${office.x + office.w}]`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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. Both ends are labelled as ends, not as an entrance and an exit: odd trains run west
|
||||
|
||||
Reference in New Issue
Block a user