v0.1.0 — undo, the crew's reach on the board, workers on the card, and four engine bugs
Fifteen items from two playtest sessions. Three that read as drawing faults were engine bugs: cars could be added to a train that was not being made up (50 offers in 8 games), the make-up panel merged two trains and could couple a car to the wrong one, and an Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel inside a coordinate's own value range — a Mainline placement travelling as row -1, which is an ordinary district row. Trains are now drawn the way they stand: west on the left, nose toward the way the engine faces, on both the district card and the Division chip. Undo steps back through the game by replaying the save without its last intent. The switching walk keeps its rejections, so the board can say why a square is not offered. Laborers and Porters are on the card, and the rule that a district only grows outwards is finally written down. Versions start here: third digit for fixes, second for a feature set, 1.0 for a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
This commit is contained in:
+157
@@ -6,10 +6,167 @@ the things that turned out to be wrong live here.
|
|||||||
Measured figures are 100 solitaire Standard games with the developer bot unless stated otherwise.
|
Measured figures are 100 solitaire Standard games with the developer bot unless stated otherwise.
|
||||||
The target is 20 Revenue over 5 Days.
|
The target is 20 Revenue over 5 Days.
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
Every commit carries a bump, decided with Jesse rather than assumed:
|
||||||
|
|
||||||
|
- **third digit** — bug fixes to what is already there.
|
||||||
|
- **second digit** — a new set of features.
|
||||||
|
- **1.0** — the first release we think is solid enough to call one.
|
||||||
|
|
||||||
|
The number lives in `package.json` and nowhere else; `scripts/build-web.ts` stamps it into every
|
||||||
|
page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identified from the page itself.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.1.0 — 2026-08-08
|
||||||
|
|
||||||
|
The first numbered build. Everything below the "second playtest pass" heading was made under
|
||||||
|
`0.0.1`, across twenty commits, which is exactly the problem the version convention above exists to
|
||||||
|
fix: "the current build" was only ever answerable by a commit hash.
|
||||||
|
|
||||||
|
### A third playtest pass — and three of the "display problems" were engine bugs
|
||||||
|
|
||||||
|
Fifteen items came back from two playtest sessions. Several of the ones that read like drawing
|
||||||
|
faults were not: the board was reporting the game accurately and the game was wrong.
|
||||||
|
|
||||||
|
**Cars could be added to a train that was not being made up.** `newTrain.placeCar` accepted any tray
|
||||||
|
with room in its consist, while the New Train Phase's own idea of the train it is waiting on —
|
||||||
|
`trainNeedingCars` — requires the tray to be **at a Division Point** and short of its card. So during
|
||||||
|
any New Train Phase the Division Yard would hand cars to a train standing on a siding in your own
|
||||||
|
district, or one halfway across the Division. Measured before the fix: **50 such offers across 8
|
||||||
|
solitaire games**, including Train 9 mid-crossing with three cars already aboard. That is the
|
||||||
|
"cars magically appeared on my train" report, and the answer to it.
|
||||||
|
|
||||||
|
The two questions — "is this the train being assembled?" and "may this car be added?" — now come from
|
||||||
|
one predicate (`isBeingMadeUp`, in apply.ts beside `check`), which is what stopped them disagreeing.
|
||||||
|
`newTrain.passCar` takes the same guard.
|
||||||
|
|
||||||
|
**The make-up panel merged two trains into one.** Two trains can be built in the same Stage — a
|
||||||
|
timetabled train and a Second Section, or an Extra — and the panel collected every option from every
|
||||||
|
tray, titling the result with whichever tray came first out of the map. Reproduced at seed 99,
|
||||||
|
Day 3 Stage 12: eighteen car chips under "Making up Train 8", covering two different trains. Worse
|
||||||
|
than the wrong caption: the Division Yard chip binds to the FIRST matching option, so clicking a
|
||||||
|
hopper could couple it to the other train. The panel is now scoped to the one tray the engine is
|
||||||
|
waiting on. This was reported as the history announcing Train 9 while the panel said Train 10.
|
||||||
|
|
||||||
|
**Upgrading the Office deleted Modifier bonuses.** `officeUpgraded` wrote the new tier's printed
|
||||||
|
numbers straight over the facility, so a Restaurant beside a Depot — +1 passenger out, +1 porter —
|
||||||
|
was silently erased by the next upgrade, after the card had been spent. The tier is applied as a
|
||||||
|
**difference** now, so the upgrade raises the Office by exactly what it is worth and leaves what is
|
||||||
|
standing beside it alone.
|
||||||
|
|
||||||
|
**A sentinel inside a coordinate's own value range.** ABS Signals goes on a Mainline card, and the
|
||||||
|
node index travelled as the fake coordinate `{ row: -1, col: node }` — but row −1 is an ordinary
|
||||||
|
district row, the first one below the Running Track, where most districts start. So ABS Signals
|
||||||
|
highlighted whichever district card sat at that column, and an ordinary Enhancement laid one row down
|
||||||
|
was described as being "out on the Mainline, node −2". `card.play` now carries `node` as its own
|
||||||
|
field and a Mainline placement has no coordinate at all; the board simply does not light up for it.
|
||||||
|
This was the other half of "why is my Depot highlighted?" — the first half being that Enhancements
|
||||||
|
legitimately attach to played cards, which nothing on screen said.
|
||||||
|
|
||||||
|
### The train, drawn the way it stands
|
||||||
|
|
||||||
|
**`consist` is ordered nose first, and both renderers drew index 0 leftmost whatever the train was
|
||||||
|
doing.** A westbound train therefore came out right and an eastbound one came out mirrored —
|
||||||
|
reported from seed 270861860, Train 10 running east and drawn engine-first at the WEST end, which
|
||||||
|
reads as an engine shoving its whole train ahead of it. The board is a map, so the drawing obeys the
|
||||||
|
map: west on the left, nose toward the way the engine faces. A crew on a north-south spur keeps
|
||||||
|
nose-left and lets its ▲/▼ say the rest.
|
||||||
|
|
||||||
|
**The Division chip draws the train now.** It was a name and a figure, and the figure was Stages left
|
||||||
|
to cross — read once as the car count, and once it was labelled `· 2⧗`, read as redundant beside the
|
||||||
|
position the card already draws. It is: the region position is DERIVED from that countdown. So the
|
||||||
|
chip carries the consist instead, in the same vocabulary the district card uses, and the countdown
|
||||||
|
moved into the tooltip where there is room to say what it is. Stages are not regions — a card is two
|
||||||
|
regions of fixed distance, and how many Stages a train needs over them depends on the card speed and
|
||||||
|
the train (`crossingStages`); they coincide only for a 30 card and a normal train.
|
||||||
|
|
||||||
|
**Loaded or empty, told the same way in both places.** An occupied slot on a district card took a
|
||||||
|
generic blue fill and only a LOADED car overrode it, so at 26×15px empty read as blue-ish and loaded
|
||||||
|
as brown-ish — while the tray beside it made the same distinction unmistakable. Reported as "I left a
|
||||||
|
car in a siding and now I can't remember if it was loaded". The card now matches the tray, and every
|
||||||
|
slot and every car in a consist carries its own words as a tooltip.
|
||||||
|
|
||||||
|
### Saying what the game already knew
|
||||||
|
|
||||||
|
**Why a switching move is not offered.** The switching game was played off a list of coordinates —
|
||||||
|
`move to (0, -2)` as a button, nothing on the board, and no account at all of the squares missing
|
||||||
|
from the list. `exploreMoves` returns the walk's REJECTIONS alongside its destinations, out of the
|
||||||
|
same traversal, so a reason on screen is the rule that actually refused the square rather than a
|
||||||
|
second guess at it. Five conditions: the rails do not meet, another train is on the card, the
|
||||||
|
industry is locked by MEN AT WORK (§9.3 — no train may enter it *or cross it*), the coupling would
|
||||||
|
overfill the consist, or it is a turnout you may run through but not stop on.
|
||||||
|
|
||||||
|
The board now shows where the crew is, where it may go, and marks the rest with its reason on the
|
||||||
|
card's own tooltip. A turnout is drawn differently from an obstruction, because it is not one — and
|
||||||
|
it is kept out of the Blocked panel, where every turnout in the district would otherwise appear.
|
||||||
|
|
||||||
|
**Mandatory coupling is named on the button.** "Move to (0, -2)" becomes "move to (0, -2) — couples
|
||||||
|
loaded hopper, caboose on the way (onto the nose)". Coupling is compulsory (§A.4) and was announced
|
||||||
|
only in the history panel, which is the one place a player is not looking while switching. 70 move
|
||||||
|
buttons in five games now say what they will pick up.
|
||||||
|
|
||||||
|
**The workers are on the card.** "How do I see the number of laborers in an industry card?" — you
|
||||||
|
could not; the number that decides every Cargo phase was in a side panel. Porters were worse:
|
||||||
|
`porters` had been on the view-model all along and **no renderer had ever drawn it**, which is why a
|
||||||
|
Restaurant's +1 porter had "no indication anywhere". Both are on the card now as free-over-total, and
|
||||||
|
the Facilities panel shows porters beside laborers.
|
||||||
|
|
||||||
|
**Ships-out and receives, in words.** A Refinery and a Grocer's Warehouse were distinguished by the
|
||||||
|
stroke colour of one 13×12px box and the direction of a 10px chevron. Rendered both and diffed the
|
||||||
|
SVG to check the complaint: identical shape, identical sign, same box count in the same place. The
|
||||||
|
card says `SHIPS OUT` or `RECEIVES` now.
|
||||||
|
|
||||||
|
**And the Office told the truth about itself.** `baseOf` returned zeros for a passenger facility
|
||||||
|
despite a comment claiming it read the Office tier, so a plain Depot displayed `out 1 +1` — crediting
|
||||||
|
a Modifier that was never played. The card tooltip had the mirror-image bug, reading the printed tier
|
||||||
|
rather than the Office as it stands, so a Modifier's effect never showed there either.
|
||||||
|
|
||||||
|
### Undo, and the chrome around it
|
||||||
|
|
||||||
|
**Undo, as far back as you like.** The save is the seed plus the intents, so undo is a replay without
|
||||||
|
the last one: no undo stack, no inverse of each action, and no way to reach a position the rules could
|
||||||
|
not have produced. Solitaire only. It is a deliberate take-back rather than a rewind — the RNG
|
||||||
|
advances with the replay, so a train card re-rolls the same Stage, but you can see that roll and then
|
||||||
|
spend the turn differently. `TODO.md` carries the question of whether a Stage boundary should become
|
||||||
|
a commit point.
|
||||||
|
|
||||||
|
**Both Division end labels were clipped.** They hung off the outside of the end cells and needed
|
||||||
|
padding wider than the caption to survive, which the board did not have — and giving it to them would
|
||||||
|
have spent that width on two captions instead of on the map. Centred under their own Division Point
|
||||||
|
they cost nothing, and `PAD` drops from 92 to 22.
|
||||||
|
|
||||||
|
**The turn chart scrolls away no more.** The title bar was sticky and the row under it — Day, Stage,
|
||||||
|
phase, waiting-on — was not, so scrolling the board took away the thing most worth glancing at. They
|
||||||
|
are one sticky block now.
|
||||||
|
|
||||||
|
**The timetable has a key.** Blue is a train due, violet is the Stage you are in, dimmed is gone, and
|
||||||
|
the green is a *flash* marking the slot the die just filled — which is exactly why it needed saying.
|
||||||
|
|
||||||
|
**And the rule that shapes every district is finally written down.** A district only grows outwards:
|
||||||
|
the Limits signs are the growth point on the Running Track and move outward with the card, and no
|
||||||
|
card anywhere can be inserted between two cards already down. Enforced since the beginning, stated
|
||||||
|
nowhere. It now sits beside the district, and a legal square that already carries a card says what
|
||||||
|
playing there would do — `EXTEND THE RUNNING TRACK HERE`, `ATTACH TO THIS CARD` — instead of lighting
|
||||||
|
up blue and saying nothing.
|
||||||
|
|
||||||
|
### Measurements
|
||||||
|
|
||||||
|
The two engine fixes take things away, so both were measured rather than assumed: 200 paired games
|
||||||
|
before and after come to **revenue 1.4 either way**, cards played 24.4 → 24.2. Neither was paying for
|
||||||
|
anything the bot relied on.
|
||||||
|
|
||||||
|
Worth stating plainly, since the changelog above still carries older numbers: the developer bot now
|
||||||
|
averages **1.4 revenue against a target of 20**, with 1 win in 200. That is not a regression from this
|
||||||
|
work — it is the same at `f4c0f49` — and it is consistent with what `TODO.md` already records about
|
||||||
|
barring curves from the Running Track and making industries stub-only. The bot has not been retaught
|
||||||
|
since those rules tightened.
|
||||||
|
|
||||||
|
Tests 386 → 402.
|
||||||
|
|
||||||
### A second playtest pass
|
### A second playtest pass
|
||||||
|
|
||||||
**The arrival message told you to do work you could not do.** Train 4 is the *Express*, which carries
|
**The arrival message told you to do work you could not do.** Train 4 is the *Express*, which carries
|
||||||
|
|||||||
@@ -10,15 +10,23 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Design complete, implementation just begun.
|
**v0.1.0 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
|
||||||
|
imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs.
|
||||||
|
|
||||||
- **Rules** — fully specified. Ten gaps in the original prototype rules found and resolved.
|
- **Rules** — fully specified. Ten gaps in the original prototype rules found and resolved.
|
||||||
- **Card faces** — every card's printed values specified.
|
- **Card faces** — every card's printed values specified.
|
||||||
- **Architecture** — six documents, including a 20-component build plan.
|
- **Architecture** — six documents, including a 20-component build plan.
|
||||||
- **Code** — build step 1 of 12 complete: card catalogue, state model, seeded RNG.
|
- **Code** — the engine, the bot, the balance harness, the replay viewer and the playable page. A
|
||||||
|
game can be saved, shared, replayed and stepped back through.
|
||||||
|
- **Not built** — multiplayer (the engine runs 2–5 player games and the bot plays them, but there is
|
||||||
|
no server, no turn submission and no per-player view), the 22 opponent-directed cards, and real
|
||||||
|
audio.
|
||||||
|
|
||||||
The MVP target is **solitaire, one Office, a fixed number of Days**, with a server talking to a
|
Balance is *not* where it should be: the developer bot averages 1.4 Revenue against a target of 20.
|
||||||
single browser.
|
`TODO.md` says why, and says which of it is the bot and which is the deck.
|
||||||
|
|
||||||
|
Versions follow the convention at the top of [`CHANGELOG.md`](CHANGELOG.md): third digit for fixes,
|
||||||
|
second for a set of features, 1.0 for the first release that deserves the name.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,15 @@ target is settled and freight carries its intended share.
|
|||||||
(32%), the facility object stored a second time inside its own cell (24%) and the rest of the
|
(32%), the facility object stored a second time inside its own cell (24%) and the rest of the
|
||||||
static identity (26%). All three are interned now — 3415 KB → 1877 KB, and a round-trip test
|
static identity (26%). All three are interned now — 3415 KB → 1877 KB, and a round-trip test
|
||||||
runs the page's own unpacking function.
|
runs the page's own unpacking function.
|
||||||
|
- [ ] **Undo is unlimited step-back, and that is a decision to revisit.** The save is the seed plus
|
||||||
|
the intents, so `undo()` replays without the last one and can walk all the way to the deal. The
|
||||||
|
RNG advances with the replay, so the same play re-rolls the same 1D12 — you cannot undo your
|
||||||
|
way to a better die. But you CAN see a train's departure Stage and then spend the turn
|
||||||
|
differently, which is an ordinary solitaire take-back and also a real information leak. Options
|
||||||
|
if it starts to feel like cheating: make the Stage boundary a commit point, or cap the depth at
|
||||||
|
the current Stage. Deliberately left open until it has been played with. Multiplayer gets
|
||||||
|
nothing until there is a proposal/agreement flow — undo there is a table decision, not a
|
||||||
|
button.
|
||||||
- [ ] **The 5 MB replay size limit is arbitrary.** Invented, not a browser constraint. It has earned
|
- [ ] **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
|
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.
|
frame — but the number itself deserves a reason.
|
||||||
@@ -214,8 +223,6 @@ target is settled and freight carries its intended share.
|
|||||||
close up.
|
close up.
|
||||||
- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal
|
- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal
|
||||||
scrolling. Legible, not compact.
|
scrolling. Legible, not compact.
|
||||||
- [ ] **No way to start a fresh game from inside the page.** `?seed=` gives a reproducible deal and
|
|
||||||
"new game" only appears once a game has ended, so abandoning a bad opening means editing the URL.
|
|
||||||
- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
|
- [ ] **Save/restore is not version-aware.** A save from an older ruleset stops replaying rather than
|
||||||
failing loudly, which is the safe direction but says little about what changed. **This has now
|
failing loudly, which is the safe direction but says little about what changed. **This has now
|
||||||
bitten once**: both published replays were dead — one got 42 intents into 360, the other 4 of
|
bitten once**: both published replays were dead — one got 42 intents into 360, the other 4 of
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+3
-21
@@ -20,7 +20,6 @@ import {
|
|||||||
COLLISION_PENALTY,
|
COLLISION_PENALTY,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
enhancementRule,
|
enhancementRule,
|
||||||
consistSize,
|
|
||||||
crossingStages,
|
crossingStages,
|
||||||
trainProfile,
|
trainProfile,
|
||||||
MOVES_PER_LOCAL_OPS,
|
MOVES_PER_LOCAL_OPS,
|
||||||
@@ -35,7 +34,9 @@ import {
|
|||||||
} from './content.ts';
|
} from './content.ts';
|
||||||
import type { Direction } from './content.ts';
|
import type { Direction } from './content.ts';
|
||||||
import type { GameEvent } from './events.ts';
|
import type { GameEvent } from './events.ts';
|
||||||
import { acceptsCar, areaOf } from './apply.ts';
|
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||||
|
// the legality test cannot disagree about which train is being assembled.
|
||||||
|
import { areaOf, trainNeedingCars } from './apply.ts';
|
||||||
import { legalActions } from './legal.ts';
|
import { legalActions } from './legal.ts';
|
||||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
||||||
import { coordKey, freshTurn, totalRevenue } from './state.ts';
|
import { coordKey, freshTurn, totalRevenue } from './state.ts';
|
||||||
@@ -286,25 +287,6 @@ function trainRunning(s: GameState, number: number, isExtra: boolean): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A made-up train still short of its consist spec, with a suitable car available. */
|
|
||||||
function trainNeedingCars(s: GameState): TrayId | null {
|
|
||||||
for (const [id, tray] of s.trays) {
|
|
||||||
if (tray.trainNumber === null) continue;
|
|
||||||
if (tray.position.at !== 'divisionPoint') continue;
|
|
||||||
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
|
||||||
if (!profile) continue;
|
|
||||||
const want = consistSize(profile.consist);
|
|
||||||
if (tray.consist.length >= want) continue;
|
|
||||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
|
||||||
// the yard is potentially suitable unless the card narrows it.
|
|
||||||
// Ask the SAME predicate `check` uses. A separate copy of this test stalled the game: the phase
|
|
||||||
// believed a car could be added while check rejected every option, so the Stage never ended.
|
|
||||||
const suitable = s.yards.divisionYard.some((c) => acceptsCar(tray, c.type));
|
|
||||||
if (suitable) return id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mainline Phase (§8) — automatic, except the Superintendent's clearance ruling
|
// Mainline Phase (§8) — automatic, except the Superintendent's clearance ruling
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+133
-22
@@ -20,6 +20,7 @@ import {
|
|||||||
LABORER_ACTIONS_PER_LOAD,
|
LABORER_ACTIONS_PER_LOAD,
|
||||||
MAX_CONSIST,
|
MAX_CONSIST,
|
||||||
REALIGNMENTS,
|
REALIGNMENTS,
|
||||||
|
consistSize,
|
||||||
enhancementRule,
|
enhancementRule,
|
||||||
industryProfile,
|
industryProfile,
|
||||||
mainlineModifierRule,
|
mainlineModifierRule,
|
||||||
@@ -46,11 +47,12 @@ import type {
|
|||||||
} from './state.ts';
|
} from './state.ts';
|
||||||
import { createRng } from './rng.ts';
|
import { createRng } from './rng.ts';
|
||||||
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
|
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
|
||||||
import type { Occupancy, Port } from './track.ts';
|
import type { MoveBlock, Occupancy, Port } from './track.ts';
|
||||||
import {
|
import {
|
||||||
canDropCarsAt,
|
canDropCarsAt,
|
||||||
canPlaceAt,
|
canPlaceAt,
|
||||||
carriesThroughTrack,
|
carriesThroughTrack,
|
||||||
|
exploreMoves,
|
||||||
facilityVariants,
|
facilityVariants,
|
||||||
opposite,
|
opposite,
|
||||||
reachableDestinations,
|
reachableDestinations,
|
||||||
@@ -351,7 +353,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||||
const hand = s.decks.hands.get(player) ?? [];
|
const hand = s.decks.hands.get(player) ?? [];
|
||||||
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
||||||
return checkPlay(s, player, i.cardId, i.placement, i.variant);
|
return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'card.discard': {
|
case 'card.discard': {
|
||||||
@@ -482,6 +484,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
||||||
const tray = s.trays.get(i.trayId);
|
const tray = s.trays.get(i.trayId);
|
||||||
if (!tray) return 'NO_SUCH_TRAY';
|
if (!tray) return 'NO_SUCH_TRAY';
|
||||||
|
// §7 — cars are added to the train being ASSEMBLED, at a Division Point. Any other tray is a
|
||||||
|
// train that is running, and loading one from the yard is teleporting cars onto it.
|
||||||
|
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
|
||||||
if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL';
|
if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL';
|
||||||
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
|
if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
|
||||||
return 'NO_SUITABLE_CAR';
|
return 'NO_SUITABLE_CAR';
|
||||||
@@ -509,6 +514,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
||||||
const tray = s.trays.get(i.trayId);
|
const tray = s.trays.get(i.trayId);
|
||||||
if (!tray) return 'NO_SUCH_TRAY';
|
if (!tray) return 'NO_SUCH_TRAY';
|
||||||
|
// Same scope as placeCar: only the train being assembled has a make-up round to finish.
|
||||||
|
if (!isBeingMadeUp(tray)) return 'NOT_BEING_MADE_UP';
|
||||||
// §7 — "must make every effort to find a suitable car". A pass is only legal when none exists.
|
// §7 — "must make every effort to find a suitable car". A pass is only legal when none exists.
|
||||||
return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null;
|
return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null;
|
||||||
}
|
}
|
||||||
@@ -572,10 +579,14 @@ function checkPlay(
|
|||||||
cardId: string,
|
cardId: string,
|
||||||
placement: GridCoord | undefined,
|
placement: GridCoord | undefined,
|
||||||
variant: number | undefined,
|
variant: number | undefined,
|
||||||
|
node?: number,
|
||||||
): RejectionCode | null {
|
): RejectionCode | null {
|
||||||
const card = s.cards.get(cardId);
|
const card = s.cards.get(cardId);
|
||||||
if (!card) return 'NO_SUCH_CARD';
|
if (!card) return 'NO_SUCH_CARD';
|
||||||
const area = areaOf(s, player);
|
const area = areaOf(s, player);
|
||||||
|
// A Division node and an Office Area square are different boards. Naming both is not a placement
|
||||||
|
// with extra detail, it is two contradictory answers to "where?".
|
||||||
|
if (node !== undefined && placement) return 'NO_PLACEMENT';
|
||||||
|
|
||||||
switch (card.kind.kind) {
|
switch (card.kind.kind) {
|
||||||
case 'office': {
|
case 'office': {
|
||||||
@@ -629,6 +640,12 @@ function checkPlay(
|
|||||||
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
|
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
|
||||||
}
|
}
|
||||||
case 'enhancement': {
|
case 'enhancement': {
|
||||||
|
// ABS Signals goes out on the Mainline, so it takes a Division node and no square.
|
||||||
|
if (enhancementRule(card.kind.key)?.placement === 'mainlineCard') {
|
||||||
|
if (node === undefined) return 'NO_PLACEMENT';
|
||||||
|
const target = s.division.nodes[node];
|
||||||
|
return target && target.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
||||||
|
}
|
||||||
if (!placement) return 'NO_PLACEMENT';
|
if (!placement) return 'NO_PLACEMENT';
|
||||||
return checkEnhancementPlacement(s, area, card.kind.key, placement);
|
return checkEnhancementPlacement(s, area, card.kind.key, placement);
|
||||||
}
|
}
|
||||||
@@ -696,6 +713,46 @@ function destinationsFor(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHERE THIS CREW MAY GO, AND WHY IT MAY NOT GO ELSEWHERE.
|
||||||
|
*
|
||||||
|
* Both directions at once, because a player is not thinking in terms of "forward" and "reverse" when
|
||||||
|
* looking at a card two squares away — a square reachable only by backing up is still reachable, and
|
||||||
|
* a reason that applies in one direction should not be reported when the other direction works.
|
||||||
|
*
|
||||||
|
* Straight out of the movement walk (`exploreMoves`), so the reasons cannot drift from the rules
|
||||||
|
* that produced them.
|
||||||
|
*/
|
||||||
|
export function movesFor(
|
||||||
|
s: GameState,
|
||||||
|
player: PlayerIndex,
|
||||||
|
trayId: TrayId,
|
||||||
|
): { to: GridCoord[]; blocked: MoveBlock[] } {
|
||||||
|
const tray = s.trays.get(trayId);
|
||||||
|
if (!tray || tray.position.at !== 'grid') return { to: [], blocked: [] };
|
||||||
|
const from = tray.position.coord;
|
||||||
|
const ctx = {
|
||||||
|
area: areaOf(s, player),
|
||||||
|
occupancy: occupancyFor(s, player, trayId),
|
||||||
|
consistSize: tray.consist.length,
|
||||||
|
self: trayId,
|
||||||
|
};
|
||||||
|
const facing = facingPort(s, trayId);
|
||||||
|
const forward = exploreMoves(ctx, from, facing);
|
||||||
|
const back = exploreMoves(ctx, from, opposite(facing));
|
||||||
|
|
||||||
|
const to = new Map<string, GridCoord>();
|
||||||
|
for (const d of [...forward.destinations, ...back.destinations]) to.set(coordKey(d.coord), d.coord);
|
||||||
|
|
||||||
|
const blocked = new Map<string, MoveBlock>();
|
||||||
|
for (const b of [...forward.blocked, ...back.blocked]) {
|
||||||
|
if (to.has(coordKey(b.coord))) continue; // reachable the other way round; not a blocker
|
||||||
|
if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { to: [...to.values()], blocked: [...blocked.values()] };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// execute — reads state, emits events, never mutates
|
// execute — reads state, emits events, never mutates
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -850,13 +907,12 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
to: card.kind.tier,
|
to: card.kind.tier,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (card.kind.kind === 'enhancement' && i.placement) {
|
if (card.kind.kind === 'enhancement' && (i.placement || i.node !== undefined)) {
|
||||||
events.push({
|
events.push(
|
||||||
type: 'enhancementPlaced',
|
i.node !== undefined
|
||||||
player,
|
? { type: 'enhancementPlaced', player, key: card.kind.key, node: i.node }
|
||||||
key: card.kind.key,
|
: { type: 'enhancementPlaced', player, key: card.kind.key, at: i.placement! },
|
||||||
at: i.placement,
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (card.kind.kind === 'extraTrain') {
|
if (card.kind.kind === 'extraTrain') {
|
||||||
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
|
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
|
||||||
@@ -1205,13 +1261,32 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
case 'officeUpgraded': {
|
case 'officeUpgraded': {
|
||||||
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
||||||
const area = areaOf(s, e.player);
|
const area = areaOf(s, e.player);
|
||||||
|
const from = officeProfile(e.from);
|
||||||
|
const to = officeProfile(e.to);
|
||||||
area.tier = e.to;
|
area.tier = e.to;
|
||||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||||
if (officeCard?.facility) {
|
if (officeCard?.facility) {
|
||||||
const p = officeProfile(e.to);
|
/**
|
||||||
officeCard.facility.porters = p.porters;
|
* THE TIER IS A DELTA, NOT AN OVERWRITE.
|
||||||
officeCard.facility.capacity = { outbound: p.passengerOut, inbound: p.passengerIn };
|
*
|
||||||
officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility };
|
* This wrote the new tier's printed numbers straight over the facility, which silently
|
||||||
|
* deleted everything a Modifier had added: a Waiting Area, Restaurant or Hotel beside the
|
||||||
|
* Office is +1 passenger out and +1 porter, and upgrading Depot → Station threw both away
|
||||||
|
* with no message, after the card had been spent. Reported from a playtest where a
|
||||||
|
* Restaurant's porter never appeared — it had appeared and then been erased.
|
||||||
|
*
|
||||||
|
* Applying the DIFFERENCE between the two tiers raises the Office by exactly what the
|
||||||
|
* upgrade is worth and leaves anything standing beside it untouched.
|
||||||
|
*/
|
||||||
|
const f = officeCard.facility;
|
||||||
|
f.porters += to.porters - from.porters;
|
||||||
|
f.capacity = {
|
||||||
|
outbound: f.capacity.outbound + (to.passengerOut - from.passengerOut),
|
||||||
|
inbound: f.capacity.inbound + (to.passengerIn - from.passengerIn),
|
||||||
|
};
|
||||||
|
// Becoming a Passenger Facility at all is a state change, not a delta (Gap 8): a Whistle
|
||||||
|
// Post has no passenger boxes to add to.
|
||||||
|
f.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1263,12 +1338,11 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'enhancementPlaced': {
|
case 'enhancementPlaced': {
|
||||||
const rule = enhancementRule(e.key);
|
if (e.node !== undefined) {
|
||||||
if (rule?.placement === 'mainlineCard') {
|
const node = s.division.nodes[e.node];
|
||||||
const node = s.division.nodes[e.at.col];
|
|
||||||
// ABS Signals: trains on this card stop short rather than rear-ending each other.
|
// ABS Signals: trains on this card stop short rather than rear-ending each other.
|
||||||
if (node?.kind === 'mainline') node.absSignals = true;
|
if (node?.kind === 'mainline') node.absSignals = true;
|
||||||
} else {
|
} else if (e.at) {
|
||||||
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
|
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
|
||||||
if (card) card.enhancements.push(e.key);
|
if (card) card.enhancements.push(e.key);
|
||||||
}
|
}
|
||||||
@@ -1564,11 +1638,9 @@ export function checkEnhancementPlacement(
|
|||||||
const rule = enhancementRule(key);
|
const rule = enhancementRule(key);
|
||||||
if (!rule) return 'NOT_IMPLEMENTED';
|
if (!rule) return 'NOT_IMPLEMENTED';
|
||||||
|
|
||||||
// ABS Signals goes on a Mainline card; `placement.col` names which one.
|
// A Mainline-card enhancement never reaches here: it has no grid square, and `checkPlay` answers
|
||||||
if (rule.placement === 'mainlineCard') {
|
// it against `division.nodes` directly. This function is only ever asked about the Office Area.
|
||||||
const node = s.division.nodes[placement.col];
|
if (rule.placement === 'mainlineCard') return 'WRONG_INTENT';
|
||||||
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
|
||||||
}
|
|
||||||
|
|
||||||
const card = area.grid.get(coordKey(placement));
|
const card = area.grid.get(coordKey(placement));
|
||||||
if (!card) return 'NOT_CONNECTED';
|
if (!card) return 'NOT_CONNECTED';
|
||||||
@@ -1741,6 +1813,45 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §7 — IS THIS TRAY THE ONE BEING MADE UP?
|
||||||
|
*
|
||||||
|
* A train is made up where it is built, standing at a Division Point, and only until its consist
|
||||||
|
* matches its card. Everything else with a Crew Tray — a train working your district, a train
|
||||||
|
* halfway across the Division — is running, not being assembled.
|
||||||
|
*
|
||||||
|
* SHARED with the New Train Phase, which uses it to decide whether to stop and ask. It has to be:
|
||||||
|
* `check` accepted any tray with room in its consist, so during a New Train Phase the Division Yard
|
||||||
|
* would hand cars to a train standing on your own siding or out on the Mainline — cars appearing on
|
||||||
|
* a train nobody was making up. Measured before the fix: 50 such offers across 8 solitaire games,
|
||||||
|
* including Train 9 mid-crossing with three cars already aboard.
|
||||||
|
*/
|
||||||
|
export function isBeingMadeUp(tray: CrewTray): boolean {
|
||||||
|
if (tray.trainNumber === null) return false;
|
||||||
|
if (tray.position.at !== 'divisionPoint') return false;
|
||||||
|
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||||
|
if (!profile) return false;
|
||||||
|
return tray.consist.length < consistSize(profile.consist);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tray the New Train Phase is waiting on, or null.
|
||||||
|
*
|
||||||
|
* `isBeingMadeUp` plus "and there is something in the yard it will take" — the phase must not stop
|
||||||
|
* to ask for a car that cannot be supplied.
|
||||||
|
*/
|
||||||
|
export function trainNeedingCars(s: GameState): TrayId | null {
|
||||||
|
for (const [id, tray] of s.trays) {
|
||||||
|
if (!isBeingMadeUp(tray)) continue;
|
||||||
|
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||||
|
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
|
||||||
|
// uses: a separate copy of this test stalled the game, because the phase believed a car could
|
||||||
|
// be added while `check` rejected every option, so the Stage never ended.
|
||||||
|
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
|
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -78,7 +78,12 @@ export type GameEvent =
|
|||||||
* which made the replay say "New Train" and nothing else. A train being made up, departing,
|
* which made the replay say "New Train" and nothing else. A train being made up, departing,
|
||||||
* arriving or finishing its run are four distinct facts and deserve four event types.
|
* arriving or finishing its run are four distinct facts and deserve four event types.
|
||||||
*/
|
*/
|
||||||
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at: GridCoord }
|
/**
|
||||||
|
* `at` is a square in the Office Area; `node` is a Division node, for an Enhancement that goes on
|
||||||
|
* a Mainline card. Exactly one is set — see `card.play` in intents.ts for why they are not one
|
||||||
|
* field wearing a sentinel.
|
||||||
|
*/
|
||||||
|
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at?: GridCoord; node?: number }
|
||||||
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
|
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
|
||||||
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
|
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
|
||||||
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
||||||
|
|||||||
+12
-1
@@ -38,8 +38,17 @@ export type Intent =
|
|||||||
* `variant` indexes `variantsFor(geometry, hand)` — 0° or 180°, the only two ways a printed card
|
* `variant` indexes `variantsFor(geometry, hand)` — 0° or 180°, the only two ways a printed card
|
||||||
* can be laid. Track is played through here like every other card; there is no separate lay
|
* can be laid. Track is played through here like every other card; there is no separate lay
|
||||||
* intent, because there is no separate supply to lay from.
|
* intent, because there is no separate supply to lay from.
|
||||||
|
*
|
||||||
|
* `placement` is a square in the player's Office Area; `node` indexes `division.nodes` and is for
|
||||||
|
* the one card that goes out on the Mainline instead (ABS Signals). They are alternatives, and
|
||||||
|
* they are SEPARATE FIELDS on purpose. A Mainline placement used to travel as the fake coordinate
|
||||||
|
* `{ row: -1, col: node }`, and row −1 is an ordinary district row — the first one below the
|
||||||
|
* Running Track, where most districts start. So a real placement on row −1 was labelled "out on
|
||||||
|
* the Mainline, node −2", and an ABS Signals placement highlighted whichever district card
|
||||||
|
* happened to sit at that column. A sentinel inside a coordinate's own value range is not a
|
||||||
|
* sentinel.
|
||||||
*/
|
*/
|
||||||
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
|
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number; node?: number }
|
||||||
| { type: 'card.discard'; cardId: CardId; toSlot: number }
|
| { type: 'card.discard'; cardId: CardId; toSlot: number }
|
||||||
| { type: 'draw.end' }
|
| { type: 'draw.end' }
|
||||||
// -- freight agent (§6.3)
|
// -- freight agent (§6.3)
|
||||||
@@ -108,6 +117,8 @@ export type RejectionCode =
|
|||||||
| 'BOX_EMPTY'
|
| 'BOX_EMPTY'
|
||||||
| 'NO_SUITABLE_CAR'
|
| 'NO_SUITABLE_CAR'
|
||||||
| 'SUITABLE_CAR_EXISTS'
|
| 'SUITABLE_CAR_EXISTS'
|
||||||
|
/** §7 — cars go onto the train being assembled at a Division Point, not onto one already running. */
|
||||||
|
| 'NOT_BEING_MADE_UP'
|
||||||
| 'WRONG_CAR_TYPE'
|
| 'WRONG_CAR_TYPE'
|
||||||
| 'NOT_SUPERINTENDENT'
|
| 'NOT_SUPERINTENDENT'
|
||||||
| 'NO_PENDING_DECISION'
|
| 'NO_PENDING_DECISION'
|
||||||
|
|||||||
+12
-9
@@ -143,17 +143,20 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
|||||||
const kind = s.cards.get(cardId)?.kind;
|
const kind = s.cards.get(cardId)?.kind;
|
||||||
/**
|
/**
|
||||||
* Enhancements ATTACH to a card already down, so their candidates are the occupied cells — with
|
* Enhancements ATTACH to a card already down, so their candidates are the occupied cells — with
|
||||||
* one exception. ABS Signals goes on a MAINLINE card, and `checkEnhancementPlacement` reads
|
* one exception. ABS Signals goes on a MAINLINE card, which is not a grid square at all, so it
|
||||||
* `placement.col` as a Division node index for it. Offering it grid squares meant the engine
|
* is offered as a Division NODE and never as a coordinate.
|
||||||
* accepted "(0, 1)" because node 1 happened to be a Mainline card: the label said one thing and
|
*
|
||||||
* the placement meant another, so the card appeared to be playable all over the Office Area.
|
* It used to be offered as `{ row: -1, col: node }`. Row −1 is a real district row — the first
|
||||||
|
* one below the Running Track, where most districts start — so the card appeared to be playable
|
||||||
|
* all over the Office Area, and ordinary placements on that row were labelled as Mainline ones.
|
||||||
*/
|
*/
|
||||||
const onMainline = kind?.kind === 'enhancement' && enhancementRule(kind.key)?.placement === 'mainlineCard';
|
const onMainline = kind?.kind === 'enhancement' && enhancementRule(kind.key)?.placement === 'mainlineCard';
|
||||||
const targets = onMainline
|
if (onMainline) {
|
||||||
? s.division.nodes.map((_, node) => ({ row: -1, col: node }))
|
for (let node = 0; node < s.division.nodes.length; node++) {
|
||||||
: kind?.kind === 'enhancement'
|
out.push({ type: 'card.play', cardId, node });
|
||||||
? attachments
|
}
|
||||||
: placements;
|
}
|
||||||
|
const targets = onMainline ? [] : kind?.kind === 'enhancement' ? attachments : placements;
|
||||||
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
|
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
|
||||||
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
|
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
|
||||||
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
|
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
|
||||||
|
|||||||
+87
-10
@@ -323,12 +323,57 @@ export function reachableDestinations(
|
|||||||
start: GridCoord,
|
start: GridCoord,
|
||||||
initialExit: Port,
|
initialExit: Port,
|
||||||
): MoveDestination[] {
|
): MoveDestination[] {
|
||||||
const { area, occupancy } = ctx;
|
return exploreMoves(ctx, start, initialExit).destinations;
|
||||||
const startCard = cardAt(area, start);
|
}
|
||||||
if (!startCard) return [];
|
|
||||||
if (!hasPort(startCard, initialExit)) return [];
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card the walk reached and refused, with the rule that refused it, in a player's words.
|
||||||
|
*
|
||||||
|
* `kind` separates the OBSTRUCTIONS — another train, a locked industry, a consist that would
|
||||||
|
* overfill, rails that do not meet — from `noStopping`, which is not an obstruction at all: a train
|
||||||
|
* runs through a turnout freely and simply may not finish a Move on one. They are drawn differently
|
||||||
|
* and only the obstructions are worth listing in the "why nothing is moving" panel, where every
|
||||||
|
* turnout in the district would otherwise appear.
|
||||||
|
*/
|
||||||
|
export type MoveBlockKind = 'noJoin' | 'occupied' | 'locked' | 'tooManyCars' | 'noStopping';
|
||||||
|
export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE SAME WALK, KEEPING ITS REJECTIONS.
|
||||||
|
*
|
||||||
|
* `reachableDestinations` answers "where may I go", and the UI could show that much — but a player
|
||||||
|
* looking at a siding two cards away and no button for it is asking the opposite question, and the
|
||||||
|
* answer was nowhere on screen. Reported as "trains seem to be blocked from moving onto industry in
|
||||||
|
* certain conditions; make it clear what those conditions are".
|
||||||
|
*
|
||||||
|
* Both answers come out of ONE traversal on purpose. A second function that worked out why a square
|
||||||
|
* was missing would be a second implementation of the movement rules, and the failure mode is a
|
||||||
|
* reason that does not match the refusal — worse than no reason at all.
|
||||||
|
*
|
||||||
|
* A block is recorded for every card the walk actually touched and turned down, plus every card
|
||||||
|
* beyond a reachable one whose rails do not meet. Squares the walk never came near are not listed:
|
||||||
|
* "there is no track between here and there" is not news.
|
||||||
|
*/
|
||||||
|
export function exploreMoves(
|
||||||
|
ctx: MoveContext,
|
||||||
|
start: GridCoord,
|
||||||
|
initialExit: Port,
|
||||||
|
): { destinations: MoveDestination[]; blocked: MoveBlock[] } {
|
||||||
|
const { area, occupancy } = ctx;
|
||||||
const results: MoveDestination[] = [];
|
const results: MoveDestination[] = [];
|
||||||
|
const blocked: MoveBlock[] = [];
|
||||||
|
const noted = new Set<string>();
|
||||||
|
const block = (coord: GridCoord, kind: MoveBlockKind, why: string): void => {
|
||||||
|
const k = coordKey(coord);
|
||||||
|
if (noted.has(k)) return;
|
||||||
|
noted.add(k);
|
||||||
|
blocked.push({ coord, kind, why });
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCard = cardAt(area, start);
|
||||||
|
if (!startCard) return { destinations: results, blocked };
|
||||||
|
if (!hasPort(startCard, initialExit)) return { destinations: results, blocked };
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
|
type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };
|
||||||
@@ -337,7 +382,10 @@ export function reachableDestinations(
|
|||||||
// hop is checked at the push site below, where both sides of the edge are in hand.
|
// hop is checked at the push site below, where both sides of the edge are in hand.
|
||||||
const first = neighbour(start, initialExit);
|
const first = neighbour(start, initialExit);
|
||||||
const firstCard = cardAt(area, first);
|
const firstCard = cardAt(area, first);
|
||||||
if (!firstCard || !joins(startCard, initialExit, firstCard)) return [];
|
if (!firstCard || !joins(startCard, initialExit, firstCard)) {
|
||||||
|
if (firstCard) block(first, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
|
||||||
|
return { destinations: results, blocked };
|
||||||
|
}
|
||||||
const queue: Frontier[] = [
|
const queue: Frontier[] = [
|
||||||
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
|
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
|
||||||
];
|
];
|
||||||
@@ -352,7 +400,16 @@ export function reachableDestinations(
|
|||||||
const occupant = occupancy.trayAt(node.coord);
|
const occupant = occupancy.trayAt(node.coord);
|
||||||
if (occupant !== null && occupant !== ctx.self) {
|
if (occupant !== null && occupant !== ctx.self) {
|
||||||
const isOffice = sameCoord(node.coord, area.officeCoord);
|
const isOffice = sameCoord(node.coord, area.officeCoord);
|
||||||
if (!isOffice || occupancy.freeAdTracks() <= 0) continue;
|
if (!isOffice || occupancy.freeAdTracks() <= 0) {
|
||||||
|
block(
|
||||||
|
node.coord,
|
||||||
|
'occupied',
|
||||||
|
isOffice
|
||||||
|
? 'the Office is full — every A/D track is taken, so there is no room to enter or pass through (§A.4)'
|
||||||
|
: 'another train is standing here — two trains may not share a card, or move through each other (§A.4)',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPort(card, node.entry)) continue;
|
if (!hasPort(card, node.entry)) continue;
|
||||||
@@ -361,12 +418,22 @@ export function reachableDestinations(
|
|||||||
// stops a train FINISHING here; a turnout is not Operational Rail either and trains run through
|
// stops a train FINISHING here; a turnout is not Operational Rail either and trains run through
|
||||||
// one all day. Without this a crew rolled straight over a locked industry, and coupled the cars
|
// one all day. Without this a crew rolled straight over a locked industry, and coupled the cars
|
||||||
// spotted on it on the way past — the two things the safety lockout exists to prevent.
|
// spotted on it on the way past — the two things the safety lockout exists to prevent.
|
||||||
if (isLockedByWork(card)) continue;
|
if (isLockedByWork(card)) {
|
||||||
|
block(node.coord, 'locked', 'MEN AT WORK — a load is on the sign, so this industry track is locked for safety: no train may enter it or cross it, and no car may be picked up or set out (§9.3)');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
|
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
|
||||||
// overfill the tray is illegal, not a move that picks up fewer cars.
|
// overfill the tray is illegal, not a move that picks up fewer cars.
|
||||||
const couples = [...node.couples, ...carsOn(card)];
|
const couples = [...node.couples, ...carsOn(card)];
|
||||||
if (ctx.consistSize + couples.length > MAX_CONSIST) continue;
|
if (ctx.consistSize + couples.length > MAX_CONSIST) {
|
||||||
|
block(
|
||||||
|
node.coord,
|
||||||
|
'tooManyCars',
|
||||||
|
`too many cars — running here couples ${couples.length} standing car(s) onto a train already holding ${ctx.consistSize}, and ${MAX_CONSIST} is the limit. Coupling is mandatory: you may not run past a car and leave it (§A.4)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const key = `${coordKey(node.coord)}|${node.entry}`;
|
const key = `${coordKey(node.coord)}|${node.entry}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
@@ -374,6 +441,10 @@ export function reachableDestinations(
|
|||||||
|
|
||||||
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
|
if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
|
||||||
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
|
results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
|
||||||
|
} else if (!sameCoord(node.coord, start)) {
|
||||||
|
// Reached, crossable, but not somewhere a train may STOP — a turnout carries no wheel icon
|
||||||
|
// (§A.1). Trains run through one all day; they just cannot finish a Move on it.
|
||||||
|
block(node.coord, 'noStopping', 'a train may run through here but not stop — this is not Operational Rail (§A.1), so it cannot be the end of a Move');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const exit of exitsFrom(card, node.entry)) {
|
for (const exit of exitsFrom(card, node.entry)) {
|
||||||
@@ -381,13 +452,19 @@ export function reachableDestinations(
|
|||||||
// hand. Enqueueing on `hasPort` alone routed trains across a 45° leg that bent back on itself.
|
// hand. Enqueueing on `hasPort` alone routed trains across a 45° leg that bent back on itself.
|
||||||
const to = neighbour(node.coord, exit);
|
const to = neighbour(node.coord, exit);
|
||||||
const next = cardAt(area, to);
|
const next = cardAt(area, to);
|
||||||
if (!next || !joins(card, exit, next)) continue;
|
if (!next || !joins(card, exit, next)) {
|
||||||
|
if (next) block(to, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
|
||||||
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
|
queue.push({ coord: to, entry: opposite(exit), path: [...node.path, step], couples });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
// A card that turned out to be reachable after all is not a blocker: the walk may meet a square
|
||||||
|
// from a bad angle first and a good one later.
|
||||||
|
const reached = new Set(results.map((r) => coordKey(r.coord)));
|
||||||
|
return { destinations: results, blocked: blocked.filter((b) => !reached.has(coordKey(b.coord))) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Both directions at once — what the UI highlights when a tray is selected. */
|
/** Both directions at once — what the UI highlights when a tray is selected. */
|
||||||
|
|||||||
+242
-37
@@ -52,11 +52,15 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
const CH = 58;
|
const CH = 58;
|
||||||
const GAP = 6;
|
const GAP = 6;
|
||||||
/**
|
/**
|
||||||
* Room for the buffer stops AND their labels. "trains enter" hangs off the west end and "trains
|
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
|
||||||
* leave" off the east, so a padding sized for the stop alone clipped both to their middle three
|
*
|
||||||
* letters — the west end read "TER" and the east "tra".
|
* They used to hang off the ends — "west end · in and out" anchored outside the first cell — which
|
||||||
|
* needed about 126px of padding to fit and had 92, so both ends were clipped mid-word. Widening
|
||||||
|
* the padding to fit them would have spent that space on two captions instead of on the map.
|
||||||
|
* They now sit UNDER their own Division Point, where they are neither clipped nor in the way, and
|
||||||
|
* this is back to what a buffer stop actually needs.
|
||||||
*/
|
*/
|
||||||
const PAD = 92;
|
const PAD = 22;
|
||||||
const SIDE_GAP = 34;
|
const SIDE_GAP = 34;
|
||||||
|
|
||||||
const esc = (t: string): string =>
|
const esc = (t: string): string =>
|
||||||
@@ -67,7 +71,17 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
kind: 'dp' | 'ml' | 'run';
|
kind: 'dp' | 'ml' | 'run';
|
||||||
label: string;
|
label: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
trains: { label: string; consist: string[]; region?: number; direction?: string }[];
|
trains: {
|
||||||
|
label: string;
|
||||||
|
consist: string[];
|
||||||
|
/** Nose first, no engine — drawn as blocks, loaded solid and empty hollow. */
|
||||||
|
cars?: string[];
|
||||||
|
engineAt?: number;
|
||||||
|
facing?: string;
|
||||||
|
region?: number;
|
||||||
|
direction?: string;
|
||||||
|
stagesLeft?: number;
|
||||||
|
}[];
|
||||||
cap: number | null;
|
cap: number | null;
|
||||||
tip: string;
|
tip: string;
|
||||||
owner: number | null;
|
owner: number | null;
|
||||||
@@ -262,19 +276,66 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
|
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TRAIN, DRAWN AS A TRAIN — the same vocabulary the Office Area card uses.
|
||||||
|
*
|
||||||
|
* The chip was a name and a figure, and the figure was Stages left to cross this card: read
|
||||||
|
* once as the car count, and once the unit was spelled out, read as redundant beside the
|
||||||
|
* position the card already draws. What it never said is the thing worth knowing at a glance —
|
||||||
|
* what the train is carrying, whether those cars are loaded, and which end the engine is on.
|
||||||
|
*
|
||||||
|
* WEST ON THE LEFT here too. `cars` is nose first, so an east-facing train is drawn reversed and
|
||||||
|
* its engine lands at the east end, exactly as on the district card.
|
||||||
|
*/
|
||||||
c.trains.forEach((t, k) => {
|
c.trains.forEach((t, k) => {
|
||||||
const w = 30 + Math.min(t.consist.length, 4) * 8;
|
const kindOf = (car: string): string =>
|
||||||
|
/caboose/.test(car) ? 'cab' : /hopper/.test(car) ? 'hop' : /boxcar/.test(car) ? 'box'
|
||||||
|
: /reefer/.test(car) ? 'rfr' : /tank/.test(car) ? 'tnk' : /coach/.test(car) ? 'cch' : '?';
|
||||||
|
const cars = t.cars ?? [];
|
||||||
|
const arrow = t.facing === 'w' ? '◀' : t.facing === 'e' ? '▶' : t.facing === 'n' ? '▲' : '▼';
|
||||||
|
const items: { label: string; kind: string }[] = cars.map((car) => ({
|
||||||
|
label: kindOf(car),
|
||||||
|
kind: /^loaded/.test(car) || /caboose/.test(car) ? 'ld' : 'mt',
|
||||||
|
}));
|
||||||
|
items.splice(Math.max(0, Math.min(items.length, t.engineAt ?? 0)), 0, { label: arrow, kind: 'eng' });
|
||||||
|
const laid = t.facing === 'e' ? [...items].reverse() : items;
|
||||||
|
|
||||||
|
const nameW = t.label.length * 6.5 + 6;
|
||||||
|
const w = Math.min(c.w - 8, nameW + laid.length * 13 + 6);
|
||||||
|
/**
|
||||||
|
* NARROW THE CARS RATHER THAN DROP THEM.
|
||||||
|
*
|
||||||
|
* A Running Track cell is 78 wide and a four-car train plus its engine wants more than that.
|
||||||
|
* Squeezing is honest; leaving cars out is not — a chip showing three of five cars is a chip
|
||||||
|
* that lies about the train. Below 11px the three-letter type will not fit, so the blocks
|
||||||
|
* carry the load state alone and the tooltip carries the words.
|
||||||
|
*/
|
||||||
|
const CARW = Math.max(5, Math.min(13, (w - nameW - 6) / Math.max(1, laid.length)));
|
||||||
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
|
// 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 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 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 spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
|
||||||
const tx = slotX + spread;
|
const tx = slotX + spread;
|
||||||
const dir = t.direction === 'west' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : '';
|
const dir = t.direction === 'west' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : '';
|
||||||
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(t.consist.join(', ') || 'no cars')}${
|
const stages =
|
||||||
|
typeof t.stagesLeft === 'number'
|
||||||
|
? ` · ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` +
|
||||||
|
' (Stages, not regions: a card is two regions of fixed distance, and how many Stages a' +
|
||||||
|
' train takes over them depends on the card speed and the train)'
|
||||||
|
: '';
|
||||||
|
out += `<g class="bs-train" data-tip="${esc(t.label)} — carrying ${esc(cars.join(', ') || 'no cars')}, nose first${
|
||||||
inRegion ? ` · region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''
|
inRegion ? ` · region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''
|
||||||
}">` +
|
}${esc(stages)}">` +
|
||||||
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
|
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
|
||||||
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(t.label)}</text></g>`;
|
`<text class="bs-tlab" x="${tx - w / 2 + 4}" y="${c.y + 35}">${esc(t.label)}</text>`;
|
||||||
|
laid.forEach((it, n) => {
|
||||||
|
const bx = tx - w / 2 + nameW + n * CARW;
|
||||||
|
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${c.y + 25}" width="${Math.max(3, CARW - 2)}" height="13" rx="2"/>`;
|
||||||
|
if (CARW >= 11) {
|
||||||
|
out += `<text class="bs-tcarlab" x="${bx + (CARW - 2) / 2}" y="${c.y + 35}" text-anchor="middle">${esc(it.label)}</text>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out += '</g>';
|
||||||
});
|
});
|
||||||
out += '</g>';
|
out += '</g>';
|
||||||
});
|
});
|
||||||
@@ -283,19 +344,27 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
|||||||
// what stops a seated layout being read as a loop.
|
// what stops a seated layout being read as a loop.
|
||||||
const first = cells[0];
|
const first = cells[0];
|
||||||
const last = cells[cells.length - 1];
|
const last = cells[cells.length - 1];
|
||||||
|
/**
|
||||||
|
* UNDER THE DIVISION POINT, NOT BESIDE IT.
|
||||||
|
*
|
||||||
|
* Both labels used to hang off the outside of the end cells, anchored away from the board — which
|
||||||
|
* needed padding wider than the caption to survive, did not get it, and so printed clipped at both
|
||||||
|
* ends while spending the space anyway. Centred under their own cell they are neither cut off nor
|
||||||
|
* competing with the map for width.
|
||||||
|
*
|
||||||
|
* BOTH ENDS DO BOTH. Odd trains run west and even trains run east (content.ts `pair`), so each
|
||||||
|
* Division Point is a way on AND a way off. "trains enter" here and "trains leave" there said the
|
||||||
|
* Division ran one way, which it does not — the buffer stops only mean it is a LINE, not a loop.
|
||||||
|
*/
|
||||||
if (first) {
|
if (first) {
|
||||||
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
|
out += `<g class="bs-stop"><line x1="${first.x - 9}" y1="${first.y + 16}" x2="${first.x - 9}" y2="${first.y + CH - 16}"/>` +
|
||||||
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
|
`<line x1="${first.x - 9}" y1="${first.y + CH / 2}" x2="${first.x - 2}" y2="${first.y + CH / 2}"/></g>`;
|
||||||
// BOTH ENDS DO BOTH. Odd trains run west and even trains run east (content.ts `pair`), so each
|
out += `<text class="bs-end" x="${first.x + first.w / 2}" y="${first.y + CH + 13}" text-anchor="middle">west end · in and out</text>`;
|
||||||
// Division Point is a way on AND a way off. "trains enter" here and "trains leave" there said
|
|
||||||
// the Division ran one way, which it does not — the point of the buffer stops is only that it is
|
|
||||||
// a LINE and not a loop.
|
|
||||||
out += `<text class="bs-end" x="${first.x - 12}" y="${first.y + CH + 12}" text-anchor="end">west end · in and out</text>`;
|
|
||||||
}
|
}
|
||||||
if (last && last !== first) {
|
if (last && last !== first) {
|
||||||
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
|
out += `<g class="bs-stop"><line x1="${last.x + last.w + 9}" y1="${last.y + 16}" x2="${last.x + last.w + 9}" y2="${last.y + CH - 16}"/>` +
|
||||||
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
|
`<line x1="${last.x + last.w + 2}" y1="${last.y + CH / 2}" x2="${last.x + last.w + 9}" y2="${last.y + CH / 2}"/></g>`;
|
||||||
out += `<text class="bs-end" x="${last.x + last.w + 12}" y="${last.y + CH + 12}">east end · in and out</text>`;
|
out += `<text class="bs-end" x="${last.x + last.w / 2}" y="${last.y + CH + 13}" text-anchor="middle">east end · in and out</text>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
out += '</svg>';
|
out += '</svg>';
|
||||||
@@ -317,6 +386,15 @@ export function officeSvg(
|
|||||||
cells: CellView[],
|
cells: CellView[],
|
||||||
runningRow: number,
|
runningRow: number,
|
||||||
ghosts: { row: number; col: number }[] = [],
|
ghosts: { row: number; col: number }[] = [],
|
||||||
|
/**
|
||||||
|
* Legal squares that already HAVE a card on them, each with what playing there would do.
|
||||||
|
*
|
||||||
|
* An empty legal square gets a whole ghost card saying "place here"; an occupied one used to get a
|
||||||
|
* blue outline and nothing else, so the two most important placements in the game — extending the
|
||||||
|
* Running Track at a Limits sign, and attaching an Enhancement to a card already down — were
|
||||||
|
* offered without a word. Reported as the Depot lighting up for no visible reason.
|
||||||
|
*/
|
||||||
|
legal: { row: number; col: number; label: string }[] = [],
|
||||||
): string {
|
): string {
|
||||||
const W = 166;
|
const W = 166;
|
||||||
const H = 96;
|
const H = 96;
|
||||||
@@ -495,12 +573,58 @@ export function officeSvg(
|
|||||||
bx += cellW + 2;
|
bx += cellW + 2;
|
||||||
}
|
}
|
||||||
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
|
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHICH WAY THIS INDUSTRY RUNS, IN WORDS.
|
||||||
|
*
|
||||||
|
* A Refinery ships out and a Grocer's Warehouse receives, and the drawing told them apart by
|
||||||
|
* the STROKE COLOUR of one 13x12px box and the direction of a 10px chevron. Asked directly in
|
||||||
|
* playtesting whether the two were supposed to look the same: they were not, and effectively
|
||||||
|
* they did. A word costs almost nothing and cannot be misread.
|
||||||
|
*/
|
||||||
|
const flow = f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
|
||||||
|
const flowCls = f.allowsOut && f.allowsIn ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
|
||||||
|
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
|
||||||
|
f.allowsOut && f.allowsIn
|
||||||
|
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
|
||||||
|
: f.allowsOut
|
||||||
|
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
|
||||||
|
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
|
||||||
|
)}">${esc(flow)}</text>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE WORKERS, ON THE CARD.
|
||||||
|
*
|
||||||
|
* "How do I see the number of laborers in an industry card?" — you could not. The number that
|
||||||
|
* decides every Cargo phase lived only in a side panel, so a Modifier's +1 Laborer landed
|
||||||
|
* where the player was not looking and an Oil Depot appeared to do nothing at all. Porters
|
||||||
|
* were worse: nothing on the page drew them anywhere.
|
||||||
|
*
|
||||||
|
* FREE OVER TOTAL, because free is what can be spent right now. A facility has one kind of
|
||||||
|
* worker or the other — Laborers work freight, Porters work passengers — so the card carries
|
||||||
|
* whichever one it actually has.
|
||||||
|
*/
|
||||||
|
const isPorter = f.laborers.startsWith('0/0');
|
||||||
|
const crew = isPorter ? f.porters : f.laborers;
|
||||||
|
if (!crew.startsWith('0/0')) {
|
||||||
|
const free = Number(crew.split('/')[0]);
|
||||||
|
out += `<g data-tip="${esc(
|
||||||
|
isPorter
|
||||||
|
? `${crew} Porters free this Stage. A Porter boards or detrains passengers — one action each, and the Revenue arrives in that single action.`
|
||||||
|
: `${crew} Laborers free this Stage. One Laborer moves one load one square along MEN | AT | WORK, so this is the whole of what this industry can do before the Stage turns over.`,
|
||||||
|
)}">` +
|
||||||
|
`<rect class="bs-crewpill${free > 0 ? ' bs-ready' : ''}" x="${W - 44}" y="${by - 1}" width="38" height="14" rx="7"/>` +
|
||||||
|
`<text class="bs-crewlab" x="${W - 25}" y="${by + 9}" text-anchor="middle">${esc(isPorter ? 'P' : 'L')} ${esc(crew)}</text></g>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standing room, drawn as the printed squares: filled means occupied.
|
// Standing room, drawn as the printed squares: filled means occupied.
|
||||||
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
|
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
|
||||||
for (let i = 0; i < Math.min(spots, 4); i++) {
|
for (let i = 0; i < Math.min(spots, 4); i++) {
|
||||||
const filled = i < cell.cars.length;
|
const filled = i < cell.cars.length;
|
||||||
|
const carTip = filled
|
||||||
|
? `${cell.cars[i]} standing here`
|
||||||
|
: 'an empty space on this track — a car may be spotted here';
|
||||||
/**
|
/**
|
||||||
* WHICH CAR IS STANDING HERE, not just that one is.
|
* WHICH CAR IS STANDING HERE, not just that one is.
|
||||||
*
|
*
|
||||||
@@ -526,11 +650,24 @@ export function officeSvg(
|
|||||||
: /coach/.test(car)
|
: /coach/.test(car)
|
||||||
? 'cch'
|
? 'cch'
|
||||||
: '';
|
: '';
|
||||||
const loaded = /^loaded/.test(car);
|
// A caboose is never "loaded" in the freight sense (carLabel says so), so it is drawn solid:
|
||||||
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ''}` : '';
|
// it is a car that is doing its job, not an empty waiting for one.
|
||||||
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
|
const loaded = /^loaded/.test(car) || /caboose/.test(car);
|
||||||
|
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ' bs-mt'}` : '';
|
||||||
|
/**
|
||||||
|
* LOADED OR EMPTY, TOLD THE SAME WAY THE TRAY TELLS IT.
|
||||||
|
*
|
||||||
|
* REPORTED: "I left a car in a siding and cannot remember whether it was loaded. In the train
|
||||||
|
* tray it is obvious; on the office cards it is hard to tell." It was: an occupied slot took a
|
||||||
|
* generic blue fill first and only a LOADED car overrode it with its type colour, so empty
|
||||||
|
* read as blue-ish and loaded as brown-ish at 26x15px, and neither said which it was.
|
||||||
|
*
|
||||||
|
* Now the card matches the tray — solid face for loaded, hollow for empty — and every slot
|
||||||
|
* carries the car's own words as a tooltip, so "loaded hopper" is one hover away.
|
||||||
|
*/
|
||||||
|
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2" data-tip="${esc(carTip)}"/>`;
|
||||||
if (filled) {
|
if (filled) {
|
||||||
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc(type)}</text>`;
|
out += `<text class="bs-carlab${loaded ? ' bs-lab-ld' : ''}" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle" data-tip="${esc(carTip)}">${esc(type)}</text>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,22 +688,41 @@ export function officeSvg(
|
|||||||
* is a thing you can see before you choose it.
|
* is a thing you can see before you choose it.
|
||||||
*/
|
*/
|
||||||
const t = cell.train;
|
const t = cell.train;
|
||||||
const items: { label: string; kind: string }[] = t.cars.map((c) => ({
|
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'
|
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' : '?',
|
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
|
||||||
kind: /^loaded/.test(c) ? 'ld' : 'mt',
|
kind: /^loaded/.test(c) || /caboose/.test(c) ? 'ld' : 'mt',
|
||||||
|
what: c,
|
||||||
}));
|
}));
|
||||||
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
|
const arrow = t.facing === 'w' ? '\u25c0' : t.facing === 'e' ? '\u25b6' : t.facing === 'n' ? '\u25b2' : '\u25bc';
|
||||||
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng' });
|
items.splice(t.engineAt, 0, { label: arrow, kind: 'eng', what: 'the engine' });
|
||||||
|
/**
|
||||||
|
* WEST ON THE LEFT, AND THE NOSE POINTING THE WAY THE ENGINE FACES.
|
||||||
|
*
|
||||||
|
* `consist` is ordered NOSE FIRST (state.ts), and this drew index 0 leftmost whatever the
|
||||||
|
* train was doing \u2014 so a westbound train came out right and an eastbound one came out
|
||||||
|
* MIRRORED. Reported from seed 270861860: Train 10 running east arrived at the Whistle Post
|
||||||
|
* drawn engine-first at the WEST end, which reads as an engine shoving four cars ahead of it.
|
||||||
|
*
|
||||||
|
* The board is a map, so the drawing has to obey the map: reverse the seating order for an
|
||||||
|
* east-facing train and its nose lands at the east end, where it is. A crew on a north-south
|
||||||
|
* spur has no left or right to be right about, so it keeps nose-left and its \u25b2/\u25bc says the rest.
|
||||||
|
*/
|
||||||
|
const laid = t.facing === 'e' ? [...items].reverse() : items;
|
||||||
const cw = 17;
|
const cw = 17;
|
||||||
const tw = Math.min(W - 8, items.length * cw + 30);
|
const tw = Math.min(W - 8, laid.length * cw + 30);
|
||||||
const tx = W / 2 - tw / 2;
|
const tx = W / 2 - tw / 2;
|
||||||
out += `<g class="bs-crew"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
|
const facingWord =
|
||||||
|
t.facing === 'e' ? 'east' : t.facing === 'w' ? 'west' : t.facing === 'n' ? 'north' : 'south';
|
||||||
|
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
|
||||||
|
out += `<g class="bs-crew" data-tip="${esc(
|
||||||
|
`${t.label} \u2014 engine pointing ${facingWord}, carrying ${consistWords} (nose first). Drawn as it stands: west is on the left.`,
|
||||||
|
)}"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
|
||||||
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
|
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
|
||||||
items.forEach((it, n) => {
|
laid.forEach((it, n) => {
|
||||||
const bx = tx + 28 + n * cw;
|
const bx = tx + 28 + n * cw;
|
||||||
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2"/>` +
|
out += `<rect class="bs-tcar bs-t-${it.kind}" x="${bx}" y="${RAIL - 8}" width="${cw - 2}" height="16" rx="2" data-tip="${esc(it.what)}"/>` +
|
||||||
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle">${esc(it.label)}</text>`;
|
`<text class="bs-tcarlab" x="${bx + (cw - 2) / 2}" y="${RAIL + 4}" text-anchor="middle" data-tip="${esc(it.what)}">${esc(it.label)}</text>`;
|
||||||
});
|
});
|
||||||
out += '</g>';
|
out += '</g>';
|
||||||
}
|
}
|
||||||
@@ -582,6 +738,23 @@ export function officeSvg(
|
|||||||
`</g>`;
|
`</g>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A LEGAL SQUARE THAT ALREADY HAS A CARD ON IT, captioned like the empty ones.
|
||||||
|
*
|
||||||
|
* The two are the same question — you have picked a card, now pick where it goes — and they were
|
||||||
|
* answered in two different registers: a full ghost card reading "place here" for an empty square,
|
||||||
|
* and a 2px blue outline for an occupied one. So the Limits sign (which is how a district grows at
|
||||||
|
* all) and the Depot (which is where an Enhancement attaches) both lit up saying nothing.
|
||||||
|
*
|
||||||
|
* Drawn as a band across the card rather than over its middle: there is a rail, a train and a load
|
||||||
|
* pipeline under here, and the caption must not sit on top of them.
|
||||||
|
*/
|
||||||
|
for (const l of legal) {
|
||||||
|
out += `<g class="bs-legalcap" transform="translate(${px(l.col)},${py(l.row)})">` +
|
||||||
|
`<rect x="6" y="${H - 46}" width="${W - 12}" height="14" rx="7"/>` +
|
||||||
|
`<text x="${W / 2}" y="${H - 36}" text-anchor="middle">${esc(l.label)}</text></g>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Mark the Running Track so the spine of the district is unmistakable.
|
// 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 += `<text class="bs-rowlab" x="2" y="${py(runningRow) + 92}">RUNNING TRACK</text>`;
|
||||||
out += '</svg>';
|
out += '</svg>';
|
||||||
@@ -624,22 +797,25 @@ export const BOARD_CSS = `
|
|||||||
train is measured against, not something to look at instead of the train. */
|
train is measured against, not something to look at instead of the train. */
|
||||||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||||
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
||||||
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
|
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
|
||||||
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL. An industry takes one commodity and nothing else, so the
|
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they
|
||||||
type has to be readable at a glance; and a loaded car and an empty one of the same type are
|
are the same fact about the same car. An occupied slot used to take a generic blue fill and only
|
||||||
opposite halves of the same job. Empty rides as an outline, loaded as a solid face. */
|
a loaded car overrode it, so a 26x15px square said "a car" and left "which car, carrying what?"
|
||||||
|
to be worked out. Empty is now hollow with a dark face, loaded a solid one, exactly as in the
|
||||||
|
tray; the type colour is the stroke either way. */
|
||||||
.bs-slot.bs-car-box{stroke:#c08a4a}
|
.bs-slot.bs-car-box{stroke:#c08a4a}
|
||||||
.bs-slot.bs-car-hop{stroke:#8d97a5}
|
.bs-slot.bs-car-hop{stroke:#8d97a5}
|
||||||
.bs-slot.bs-car-tnk{stroke:#7a6ab0}
|
.bs-slot.bs-car-tnk{stroke:#7a6ab0}
|
||||||
.bs-slot.bs-car-rfr{stroke:#6fb5a0}
|
.bs-slot.bs-car-rfr{stroke:#6fb5a0}
|
||||||
.bs-slot.bs-car-cch{stroke:#5aa9e6}
|
.bs-slot.bs-car-cch{stroke:#5aa9e6}
|
||||||
.bs-slot.bs-car-cab{stroke:#c05a5a}
|
.bs-slot.bs-car-cab{stroke:#c05a5a}
|
||||||
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.42)}
|
.bs-slot.bs-occ.bs-mt{fill:#181d24}
|
||||||
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.42)}
|
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.85)}
|
||||||
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.42)}
|
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.85)}
|
||||||
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.42)}
|
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.85)}
|
||||||
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.42)}
|
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.85)}
|
||||||
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
|
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.85)}
|
||||||
|
.bs-slot.bs-car-cab.bs-loaded{fill:rgba(192,90,90,.85)}
|
||||||
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
|
||||||
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
|
||||||
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
|
/* Each car in the train, in the order it is seated. Loaded is solid, empty is hollow, and the
|
||||||
@@ -655,7 +831,10 @@ export const BOARD_CSS = `
|
|||||||
.bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee}
|
.bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee}
|
||||||
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
|
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
|
||||||
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
|
||||||
|
/* The three letters sit ON the slot, so they invert with it: dark on a loaded car's solid face,
|
||||||
|
light on an empty one's hollow. */
|
||||||
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
|
.bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace}
|
||||||
|
.bs-carlab.bs-lab-ld{fill:#12161b;font-weight:700}
|
||||||
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
.bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||||||
.bs-wb.bs-green{stroke:#3f7a52}
|
.bs-wb.bs-green{stroke:#3f7a52}
|
||||||
.bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)}
|
.bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)}
|
||||||
@@ -664,6 +843,17 @@ export const BOARD_CSS = `
|
|||||||
.bs-wb.bs-red{stroke:#8a5a5a}
|
.bs-wb.bs-red{stroke:#8a5a5a}
|
||||||
.bs-wb.bs-red.bs-occ{fill:rgba(170,70,70,.6)}
|
.bs-wb.bs-red.bs-occ{fill:rgba(170,70,70,.6)}
|
||||||
.bs-wl{fill:#8d97a5;font:7px ui-monospace,monospace}
|
.bs-wl{fill:#8d97a5;font:7px ui-monospace,monospace}
|
||||||
|
/* WHICH WAY THE INDUSTRY RUNS. Green for ships-out, red for receives, in the same hues as the boxes
|
||||||
|
they describe — the word and the colour saying the same thing twice. */
|
||||||
|
.bs-flow{font:700 8px ui-monospace,monospace;letter-spacing:.06em;cursor:help}
|
||||||
|
.bs-flow-out{fill:#5f9b74}
|
||||||
|
.bs-flow-in{fill:#b07272}
|
||||||
|
.bs-flow-both{fill:#8b94a3}
|
||||||
|
/* THE WORKERS. Free over total, on the card, because a Laborer that is already spent this Stage is
|
||||||
|
not a Laborer you can use. Lit while any remain, dim once they are all gone. */
|
||||||
|
.bs-crewpill{fill:#20262e;stroke:#4a5361;stroke-width:1}
|
||||||
|
.bs-crewpill.bs-ready{fill:rgba(190,150,40,.22);stroke:#8a6d1f}
|
||||||
|
.bs-crewlab{fill:#cfd6e0;font:600 9px ui-monospace,monospace}
|
||||||
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
|
.bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace}
|
||||||
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
|
||||||
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
|
||||||
@@ -677,9 +867,24 @@ text.bs-mod{fill:#c8a04a}
|
|||||||
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
||||||
.bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4}
|
.bs-ghost 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}
|
.bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace}
|
||||||
|
/* The caption on a legal square that already carries a card. Same blue as the ghost squares and the
|
||||||
|
same words in kind, because it is the same question: you have picked a card, now pick where. */
|
||||||
|
.bs-legalcap rect{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-width:1}
|
||||||
|
.bs-legalcap text{fill:#cfe0f5;font:700 8px ui-monospace,monospace;letter-spacing:.07em}
|
||||||
|
.bs-legalcap{pointer-events:none}
|
||||||
.bs-ghost,g[data-cell].bs-legal{cursor:pointer}
|
.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 .bs-card{stroke:#5aa9e6;stroke-width:2.5}
|
||||||
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
|
g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
|
||||||
|
/* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable
|
||||||
|
card reads the same way an action button does. bs-from is where it is standing now. */
|
||||||
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
|
g[data-cell].bs-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}
|
g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
|
||||||
|
/* AND WHERE IT CANNOT. Muted red with a barred face — a card that is refusing traffic, not one that
|
||||||
|
is merely uninteresting. The reason rides on the card's own tooltip. */
|
||||||
|
g[data-cell].bs-blocked .bs-card{stroke:#8a5a5a;stroke-width:2;stroke-dasharray:2 3}
|
||||||
|
/* NOT THE SAME THING. A turnout carries no wheel icon (§A.1): a train runs through it freely and
|
||||||
|
simply cannot finish a Move on it. Drawn as a faint amber dash — related to the reachable amber,
|
||||||
|
because it IS reachable, just not a place to stop. */
|
||||||
|
g[data-cell].bs-nostop .bs-card{stroke:#6a5f3a;stroke-width:1.6;stroke-dasharray:2 4}
|
||||||
|
g[data-cell].bs-blocked,g[data-cell].bs-nostop{cursor:help}
|
||||||
`;
|
`;
|
||||||
|
|||||||
+41
-7
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
import { adTrackCount, coordKey } from '../engine/state.ts';
|
import { adTrackCount, coordKey } from '../engine/state.ts';
|
||||||
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
|
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
|
||||||
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
|
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
||||||
import type { GameEvent } from '../engine/events.ts';
|
import type { GameEvent } from '../engine/events.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -212,12 +212,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
tone: 'good',
|
tone: 'good',
|
||||||
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
|
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
|
||||||
};
|
};
|
||||||
case 'enhancementPlaced':
|
case 'enhancementPlaced': {
|
||||||
return {
|
const name = e.key.replace(/([A-Z])/g, ' $1');
|
||||||
tone: 'good',
|
// Out on the Mainline is not a square in anyone's district, so it is named rather than
|
||||||
where: e.at,
|
// given a coordinate the Office Area does not have.
|
||||||
text: `ENHANCEMENT built: ${e.key.replace(/([A-Z])/g, ' $1')} at ${at(e.at)}`,
|
if (e.at === undefined) {
|
||||||
};
|
return { tone: 'good', text: `ENHANCEMENT built: ${name} on Mainline card ${e.node}` };
|
||||||
|
}
|
||||||
|
return { tone: 'good', where: e.at, text: `ENHANCEMENT built: ${name} at ${at(e.at)}` };
|
||||||
|
}
|
||||||
case 'secondSectionOrdered':
|
case 'secondSectionOrdered':
|
||||||
return {
|
return {
|
||||||
tone: 'bad',
|
tone: 'bad',
|
||||||
@@ -491,6 +494,37 @@ export function impediments(s: GameState, player = 0): Impediment[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHY THE CREW CANNOT GET THERE.
|
||||||
|
*
|
||||||
|
* The panel is called "why nothing is moving" and covered everything except movement: jammed
|
||||||
|
* facilities, held trains, a full Office. A crew standing one card short of an industry it cannot
|
||||||
|
* enter had nothing here at all — reported as trains being blocked "in certain conditions", with
|
||||||
|
* no way to find out which.
|
||||||
|
*
|
||||||
|
* Only while switching, and only the cards actually in the crew's way: `movesFor` reports the
|
||||||
|
* squares the movement walk reached and refused, not every square on the board.
|
||||||
|
*/
|
||||||
|
if (s.clock.phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining > 0) {
|
||||||
|
for (const [id, tray] of s.trays) {
|
||||||
|
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||||
|
const { blocked } = movesFor(s, player, id);
|
||||||
|
for (const b of blocked) {
|
||||||
|
// A turnout is not an obstruction — a train runs through one all day and simply may not
|
||||||
|
// STOP on it. Listing every one would bury the four reasons that are genuinely in the way;
|
||||||
|
// the board says it on the card instead.
|
||||||
|
if (b.kind === 'noStopping') continue;
|
||||||
|
out.push({
|
||||||
|
where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.row},${b.coord.col})`,
|
||||||
|
why: b.why,
|
||||||
|
// Not "stuck": these are the shape of the district and of the other trains in it, which is
|
||||||
|
// the puzzle rather than a fault. Amber, not red.
|
||||||
|
severity: 'waiting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
|
// Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
|
||||||
const due = s.timetable[s.clock.stage - 1];
|
const due = s.timetable[s.clock.stage - 1];
|
||||||
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
|
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
|
||||||
|
|||||||
+156
-19
@@ -10,7 +10,14 @@
|
|||||||
* drift into two different pictures of the same board.
|
* drift into two different pictures of the same board.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
|
import {
|
||||||
|
areaOf,
|
||||||
|
destinationsFor,
|
||||||
|
facilityCarType,
|
||||||
|
laborersLeft,
|
||||||
|
movesFor,
|
||||||
|
portersLeft,
|
||||||
|
} from '../engine/apply.ts';
|
||||||
import {
|
import {
|
||||||
ACTION_CARDS,
|
ACTION_CARDS,
|
||||||
ENHANCEMENT_CARDS,
|
ENHANCEMENT_CARDS,
|
||||||
@@ -29,7 +36,7 @@ import {
|
|||||||
trainProfile,
|
trainProfile,
|
||||||
} from '../engine/content.ts';
|
} from '../engine/content.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import type { Facility, GameState, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||||
@@ -108,7 +115,7 @@ export type FacilityView = {
|
|||||||
* happened. Reported after playing an Ice House and Local Small Groceries and seeing no change
|
* happened. Reported after playing an Ice House and Local Small Groceries and seeing no change
|
||||||
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
|
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
|
||||||
*/
|
*/
|
||||||
base: { out: number; in: number; laborers: number };
|
base: { out: number; in: number; laborers: number; porters: number };
|
||||||
/**
|
/**
|
||||||
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
|
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
|
||||||
*
|
*
|
||||||
@@ -133,7 +140,19 @@ export type FacilityView = {
|
|||||||
|
|
||||||
export type TrainChip = {
|
export type TrainChip = {
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Nose first, with `ENG` seated where the engine actually is. The words, for a tooltip. */
|
||||||
consist: string[];
|
consist: string[];
|
||||||
|
/**
|
||||||
|
* THE SAME TRAIN THE OFFICE CARD DRAWS, so the Division map can draw it the same way.
|
||||||
|
*
|
||||||
|
* `cars` is nose first and carries no engine; `engineAt` is where the engine sits among them and
|
||||||
|
* `facing` is the port it points at. The Division chip used to be a name and a number — and the
|
||||||
|
* number was Stages left to cross, which reads as redundant beside the position already drawn on
|
||||||
|
* the card. A train is worth drawing: what it is carrying, loaded or empty, and which end leads.
|
||||||
|
*/
|
||||||
|
cars: string[];
|
||||||
|
engineAt: number;
|
||||||
|
facing: string;
|
||||||
/**
|
/**
|
||||||
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
|
* Which region of a Mainline card the train is standing in, and which way it is going. Absent
|
||||||
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
|
* everywhere else: a Division Point is a single queue, and inside a district a train moves by
|
||||||
@@ -141,6 +160,15 @@ export type TrainChip = {
|
|||||||
*/
|
*/
|
||||||
region?: number;
|
region?: number;
|
||||||
direction?: string;
|
direction?: string;
|
||||||
|
/**
|
||||||
|
* Stages still to run before it is off this Mainline card — NOT the same as regions left.
|
||||||
|
*
|
||||||
|
* A card is two regions of fixed distance; the Stages are how long this train takes over them
|
||||||
|
* (`crossingStages`): a 60 card is one Stage, a 30 card two, a Slow train adds one. So they
|
||||||
|
* coincide only in the middle case. It rode on the chip as "· 2⧗" and was read as a car count;
|
||||||
|
* it belongs in the tooltip, where there is room to say which it is.
|
||||||
|
*/
|
||||||
|
stagesLeft?: number;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* One card of a player's Running Track, as the Division sees it.
|
* One card of a player's Running Track, as the Division sees it.
|
||||||
@@ -223,6 +251,22 @@ export type Frame = {
|
|||||||
* moves themselves.
|
* moves themselves.
|
||||||
*/
|
*/
|
||||||
movesLeft: number | null;
|
movesLeft: number | null;
|
||||||
|
/**
|
||||||
|
* WHERE THE CREW CAN GO, AND WHY NOT ELSEWHERE — while it is switching, and null otherwise.
|
||||||
|
*
|
||||||
|
* The switching game was played off a list of coordinates: "move to (0, -2)" as a button, with
|
||||||
|
* nothing on the board and no account of the squares that were missing from the list. Reported as
|
||||||
|
* trains being blocked from entering an industry "in certain conditions", with no way to see what
|
||||||
|
* the conditions were.
|
||||||
|
*
|
||||||
|
* `blocked` comes out of the movement walk itself (`movesFor`), so a reason on screen is the rule
|
||||||
|
* that actually refused the square rather than a second guess at it.
|
||||||
|
*/
|
||||||
|
moves: {
|
||||||
|
from: { row: number; col: number };
|
||||||
|
to: { row: number; col: number }[];
|
||||||
|
blocked: { coord: { row: number; col: number }; kind: string; why: string }[];
|
||||||
|
} | null;
|
||||||
facilities: FacilityView[];
|
facilities: FacilityView[];
|
||||||
hand: string[];
|
hand: string[];
|
||||||
/** What each hand card does, in the same order — names alone are not a playable hand. */
|
/** What each hand card does, in the same order — names alone are not a playable hand. */
|
||||||
@@ -334,7 +378,7 @@ function facilityView(
|
|||||||
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
|
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
|
||||||
allowsOut: f.allows.outbound,
|
allowsOut: f.allows.outbound,
|
||||||
allowsIn: f.allows.inbound,
|
allowsIn: f.allows.inbound,
|
||||||
base: baseOf(card),
|
base: baseOf(card, officeName),
|
||||||
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
|
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -345,13 +389,29 @@ function facilityView(
|
|||||||
* Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card
|
* Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card
|
||||||
* the player is holding. A passenger facility takes its numbers from the Office tier instead.
|
* the player is holding. A passenger facility takes its numbers from the Office tier instead.
|
||||||
*/
|
*/
|
||||||
function baseOf(card: { geometry: { kind: string; facility?: string } }): { out: number; in: number; laborers: number } {
|
function baseOf(
|
||||||
|
card: { geometry: { kind: string; facility?: string } },
|
||||||
|
officeName: string,
|
||||||
|
): { out: number; in: number; laborers: number; porters: number } {
|
||||||
const g = card.geometry;
|
const g = card.geometry;
|
||||||
if (g.kind === 'facility' && g.facility) {
|
if (g.kind === 'facility' && g.facility) {
|
||||||
const p = industryProfile(g.facility as never);
|
const p = industryProfile(g.facility as never);
|
||||||
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders };
|
return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders, porters: 0 };
|
||||||
}
|
}
|
||||||
return { out: 0, in: 0, laborers: 0 };
|
/**
|
||||||
|
* A PASSENGER FACILITY TAKES ITS NUMBERS FROM THE OFFICE TIER.
|
||||||
|
*
|
||||||
|
* The comment above this function has said so for a long time and the code returned zeros, so the
|
||||||
|
* panel worked out its "+N from a Modifier" against a base of nothing: a plain Depot with no
|
||||||
|
* Modifier anywhere near it displayed `out 1 +1`, crediting a card that had never been played.
|
||||||
|
* The tier is the printed number here, exactly as the industry card is for an industry.
|
||||||
|
*/
|
||||||
|
if (g.kind === 'office') {
|
||||||
|
const tier = OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost';
|
||||||
|
const p = officeProfile(tier);
|
||||||
|
return { out: p.passengerOut, in: p.passengerIn, laborers: 0, porters: p.porters };
|
||||||
|
}
|
||||||
|
return { out: 0, in: 0, laborers: 0, porters: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
|
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
|
||||||
@@ -459,8 +519,30 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
const onto = top ? `, burying ${cardName(s, top)}` : ' (empty)';
|
const onto = top ? `, burying ${cardName(s, top)}` : ' (empty)';
|
||||||
return `discard ${cardName(s, i.cardId)} onto Department ${i.toSlot + 1}${onto}`;
|
return `discard ${cardName(s, i.cardId)} onto Department ${i.toSlot + 1}${onto}`;
|
||||||
}
|
}
|
||||||
case 'switch.move':
|
case 'switch.move': {
|
||||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
|
/**
|
||||||
|
* SAY WHAT THE MOVE WILL PICK UP.
|
||||||
|
*
|
||||||
|
* Coupling is mandatory (§A.4): run over a card with cars standing on it and they join the
|
||||||
|
* train, whether or not you wanted them. The button said "move to (0, -2)" and the only
|
||||||
|
* account of the coupling was a line in the history panel — which is how a playtester ended up
|
||||||
|
* reporting that "cars magically appeared on my train".
|
||||||
|
*
|
||||||
|
* Taken from the engine's own destination list, so the count on the button is the count that
|
||||||
|
* will actually couple.
|
||||||
|
*/
|
||||||
|
const tray = s.trays.get(i.trayId);
|
||||||
|
const here = tray?.position.at === 'grid' ? tray.position.coord : null;
|
||||||
|
let picks = '';
|
||||||
|
if (here) {
|
||||||
|
const dest = destinationsFor(s, tray!.position.at === 'grid' ? tray!.position.owner : 0, i.trayId, here, i.reverse)
|
||||||
|
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
||||||
|
if (dest && dest.couples.length > 0) {
|
||||||
|
picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}${picks}`;
|
||||||
|
}
|
||||||
case 'switch.dropCars': {
|
case 'switch.dropCars': {
|
||||||
/**
|
/**
|
||||||
* NAME THE CARS AND THE END THEY COME OFF.
|
* NAME THE CARS AND THE END THEY COME OFF.
|
||||||
@@ -708,13 +790,14 @@ export function snapshot(
|
|||||||
return {
|
return {
|
||||||
...chip,
|
...chip,
|
||||||
/**
|
/**
|
||||||
* SAY WHAT THE NUMBER IS.
|
* THE NUMBER COMES OFF THE CHIP.
|
||||||
*
|
*
|
||||||
* This read "TX14 (2)", a bare figure next to a train's name — and it was read as the
|
* It read "TX14 (2)" and was taken for the car count — twice, by the same player — so
|
||||||
* car count twice, by the same player, because that is the obvious guess. It is Stages
|
* it was named "· 2⧗", Stages left to cross. Named, it was then correctly read as
|
||||||
* left to cross this Mainline card. Naming the unit costs three characters.
|
* redundant: the card already draws WHERE the train is, and how many Stages it still
|
||||||
|
* needs is a detail for the tooltip. The chip draws the train instead.
|
||||||
*/
|
*/
|
||||||
label: `${chip.label} · ${t.stagesRemaining}⧗`,
|
stagesLeft: t.stagesRemaining,
|
||||||
region: place(t),
|
region: place(t),
|
||||||
direction: t.direction,
|
direction: t.direction,
|
||||||
};
|
};
|
||||||
@@ -826,6 +909,7 @@ export function snapshot(
|
|||||||
objective: objectiveOf(s),
|
objective: objectiveOf(s),
|
||||||
runningRow: area.runningRow,
|
runningRow: area.runningRow,
|
||||||
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
|
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
|
||||||
|
moves: switchingMoves(s, 0),
|
||||||
blocked: impediments(s, 0),
|
blocked: impediments(s, 0),
|
||||||
trains: [...s.trays.values()].map((t) => ({
|
trains: [...s.trays.values()].map((t) => ({
|
||||||
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||||
@@ -978,15 +1062,44 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
|||||||
const g = card.geometry;
|
const g = card.geometry;
|
||||||
switch (g.kind) {
|
switch (g.kind) {
|
||||||
case 'limits':
|
case 'limits':
|
||||||
return 'the edge of your control area — lay track HERE to extend the Running Track';
|
/**
|
||||||
|
* THE WHOLE RULE, not half of it.
|
||||||
|
*
|
||||||
|
* This said "lay track HERE to extend the Running Track", which is true and leaves out the
|
||||||
|
* part a player has to know: the sign is the ONLY growth point on the main, it moves outward
|
||||||
|
* with the card, and nothing anywhere on the board can be inserted between two cards already
|
||||||
|
* down. Asked for directly after a playtest — "somewhere it should be clear that track can
|
||||||
|
* only be added to expand outwards".
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
'the edge of your control area. Lay track HERE and the sign moves one card further out — ' +
|
||||||
|
'this is the only way the Running Track grows, and nothing may be built beyond the sign. ' +
|
||||||
|
'A district only ever expands: no card can be inserted between two cards already down.'
|
||||||
|
);
|
||||||
case 'office': {
|
case 'office': {
|
||||||
const p = officeProfile(
|
const p = officeProfile(
|
||||||
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
|
(OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
|
||||||
);
|
);
|
||||||
|
/**
|
||||||
|
* READ THE OFFICE AS IT STANDS, not as its card was printed.
|
||||||
|
*
|
||||||
|
* This took the porter count and the passenger slots from the TIER PROFILE, so a Waiting Area,
|
||||||
|
* Restaurant or Hotel standing beside the Office — each +1 porter and +1 passenger out —
|
||||||
|
* changed the Office and left this line saying what a bare Depot has. Reported as an extra
|
||||||
|
* porter with "no indication of it anywhere": the Modifier had worked and nothing said so.
|
||||||
|
*
|
||||||
|
* The facility record is the Office; the profile is only what it started as.
|
||||||
|
*/
|
||||||
|
const f = card.facility;
|
||||||
|
const porters = f ? f.porters : p.porters;
|
||||||
|
const out = f ? f.capacity.outbound : p.passengerOut;
|
||||||
|
const inb = f ? f.capacity.inbound : p.passengerIn;
|
||||||
|
const added = porters - p.porters + (out - p.passengerOut) + (inb - p.passengerIn);
|
||||||
return (
|
return (
|
||||||
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
|
`${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
|
||||||
(p.isPassengerFacility
|
(p.isPassengerFacility
|
||||||
? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
|
? `${porters} porter${porters === 1 ? '' : 's'}, passengers ${out} out / ${inb} in` +
|
||||||
|
(added > 0 ? ` (the card prints ${p.porters}/${p.passengerOut}/${p.passengerIn}; the Modifiers beside it add the rest)` : '')
|
||||||
: 'not a Passenger Facility — no porters, no passenger boxes')
|
: 'not a Passenger Facility — no porters, no passenger boxes')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1193,9 +1306,29 @@ function countStock(
|
|||||||
return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
|
return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The switching crew's reach, for the board to draw.
|
||||||
|
*
|
||||||
|
* Only while a switching turn is actually running and only while Moves remain: a highlight that
|
||||||
|
* survives into the Cargo phase is an invitation to click something that is no longer offered.
|
||||||
|
*
|
||||||
|
* One crew. Solitaire has one, and with more the answer would depend on which is selected — a
|
||||||
|
* question the page does not yet ask.
|
||||||
|
*/
|
||||||
|
function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
|
||||||
|
if (s.clock.phase !== 'localOps' || s.turn.option !== 'switch') return null;
|
||||||
|
if (s.turn.movesRemaining < 1) return null;
|
||||||
|
for (const [id, tray] of s.trays) {
|
||||||
|
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||||
|
const { to, blocked } = movesFor(s, player, id);
|
||||||
|
return { from: tray.position.coord, to, blocked };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function trainChip(s: GameState, id: string): TrainChip {
|
function trainChip(s: GameState, id: string): TrainChip {
|
||||||
const t = s.trays.get(id);
|
const t = s.trays.get(id);
|
||||||
if (!t) return { label: id, consist: [] };
|
if (!t) return { label: id, consist: [], cars: [], engineAt: 0, facing: 'e' };
|
||||||
/**
|
/**
|
||||||
* The engine is drawn IN the consist, at the position it occupies.
|
* The engine is drawn IN the consist, at the position it occupies.
|
||||||
*
|
*
|
||||||
@@ -1206,10 +1339,14 @@ function trainChip(s: GameState, id: string): TrainChip {
|
|||||||
*/
|
*/
|
||||||
const cars = t.consist.map(carLabel);
|
const cars = t.consist.map(carLabel);
|
||||||
const at = Math.max(0, Math.min(cars.length, t.engineAt));
|
const at = Math.max(0, Math.min(cars.length, t.engineAt));
|
||||||
cars.splice(at, 0, 'ENG');
|
const seated = [...cars];
|
||||||
|
seated.splice(at, 0, 'ENG');
|
||||||
return {
|
return {
|
||||||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||||||
consist: cars,
|
consist: seated,
|
||||||
|
cars,
|
||||||
|
engineAt: at,
|
||||||
|
facing: t.facing ?? (t.direction === 'west' ? 'w' : 'e'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+102
-38
@@ -45,7 +45,7 @@ import { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts'
|
|||||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
||||||
import { areaOf } from '../engine/apply.ts';
|
import { areaOf, trainNeedingCars } from '../engine/apply.ts';
|
||||||
import type { Frame } from '../sim/view.ts';
|
import type { Frame } from '../sim/view.ts';
|
||||||
|
|
||||||
export const SOLO_CONFIG: GameConfig = {
|
export const SOLO_CONFIG: GameConfig = {
|
||||||
@@ -189,7 +189,10 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
|||||||
// "Pee-Dee" — a per-diem train whose consist is one caboose and nothing else — offers a
|
// "Pee-Dee" — a per-diem train whose consist is one caboose and nothing else — offers a
|
||||||
// single button to add a caboose and no reason why, which reads as a broken game rather than
|
// single button to add a caboose and no reason why, which reads as a broken game rather than
|
||||||
// as the card doing exactly what it prints.
|
// as the card doing exactly what it prints.
|
||||||
const headed = prefix === 'newTrain.' ? consistTitle(game, options, actions) ?? title : title;
|
// The tray the phase is waiting on, so the heading names the train the yard chips will load
|
||||||
|
// rather than whichever tray happened to come first out of the map.
|
||||||
|
const filling = prefix === 'newTrain.' ? trainNeedingCars(game.state) : null;
|
||||||
|
const headed = filling !== null ? (consistTitle(game, filling) ?? title) : title;
|
||||||
groups.push({ kind: prefix, title: headed, actions });
|
groups.push({ kind: prefix, title: headed, actions });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +223,17 @@ export type Placeable = {
|
|||||||
spots: {
|
spots: {
|
||||||
label: string;
|
label: string;
|
||||||
index: number;
|
index: number;
|
||||||
coord: { row: number; col: number };
|
/**
|
||||||
|
* The square on the board this spot would fill, or **null** when the placement is not on the
|
||||||
|
* board at all — ABS Signals goes out on the Mainline, and `node` names which card.
|
||||||
|
*
|
||||||
|
* Null rather than a stand-in coordinate. A Mainline placement used to travel as
|
||||||
|
* `{ row: -1, col: node }`, and row −1 is an ordinary district row, so the board lit up a
|
||||||
|
* district card for a placement that was never going there.
|
||||||
|
*/
|
||||||
|
coord: { row: number; col: number } | null;
|
||||||
|
/** Division node index, for a placement out on the Mainline. */
|
||||||
|
node?: number;
|
||||||
/**
|
/**
|
||||||
* The rails this placement would put on the card, as `connectionsFor` codes.
|
* The rails this placement would put on the card, as `connectionsFor` codes.
|
||||||
*
|
*
|
||||||
@@ -288,7 +301,7 @@ export type Menu = {
|
|||||||
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
|
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
|
||||||
* these key each chip to the option that adds it.
|
* these key each chip to the option that adds it.
|
||||||
*/
|
*/
|
||||||
makeUp: { title: string; cars: MakeUpAction[]; pass: number | null } | null;
|
makeUp: { trayId: string; title: string; cars: MakeUpAction[]; pass: number | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||||||
@@ -313,7 +326,13 @@ export function actionMenu(game: Game): Menu {
|
|||||||
spots: [],
|
spots: [],
|
||||||
};
|
};
|
||||||
if (!entry.spots.some((sp) => sp.label === key.spot)) {
|
if (!entry.spots.some((sp) => sp.label === key.spot)) {
|
||||||
entry.spots.push({ label: key.spot, index: a.index, coord: key.coord, links: key.links });
|
entry.spots.push({
|
||||||
|
label: key.spot,
|
||||||
|
index: a.index,
|
||||||
|
coord: key.coord,
|
||||||
|
links: key.links,
|
||||||
|
...(key.node === undefined ? {} : { node: key.node }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
bucket.set(key.subjectKey, entry);
|
bucket.set(key.subjectKey, entry);
|
||||||
placeableByTitle.set(g.title, bucket);
|
placeableByTitle.set(g.title, bucket);
|
||||||
@@ -364,23 +383,42 @@ export function actionMenu(game: Game): Menu {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Making up a train. `newTrain.placeCar` carries the car; the Division Yard chip that shows that
|
/**
|
||||||
// car is where the click belongs.
|
* MAKING UP A TRAIN — ONE TRAIN.
|
||||||
|
*
|
||||||
|
* `newTrain.placeCar` carries the car, and the Division Yard chip showing that car is where the
|
||||||
|
* click belongs. But two trains can be built in the same Stage — a timetabled train and a Second
|
||||||
|
* Section, or an Extra — and this collected every option from every tray into one panel titled
|
||||||
|
* with whichever tray came first. Reproduced at seed 99, Day 3 Stage 12: eighteen car chips under
|
||||||
|
* "Making up Train 8", covering two different trains.
|
||||||
|
*
|
||||||
|
* Worse than a wrong caption: the yard chip binds to the FIRST matching option, so clicking a
|
||||||
|
* hopper could couple it to the other train entirely.
|
||||||
|
*
|
||||||
|
* So the panel is scoped to ONE tray — the one the engine is actually waiting on
|
||||||
|
* (`trainNeedingCars`, the same predicate the New Train Phase stops for). The second train comes
|
||||||
|
* up as soon as the first is done, which is how the phase runs anyway.
|
||||||
|
*/
|
||||||
|
const filling =
|
||||||
|
trainNeedingCars(game.state) ??
|
||||||
|
options.find((i): i is Extract<Intent, { type: 'newTrain.placeCar' | 'newTrain.passCar' }> =>
|
||||||
|
i.type === 'newTrain.placeCar' || i.type === 'newTrain.passCar',
|
||||||
|
)?.trayId ??
|
||||||
|
null;
|
||||||
|
|
||||||
const makeUpCars: MakeUpAction[] = [];
|
const makeUpCars: MakeUpAction[] = [];
|
||||||
let pass: number | null = null;
|
let pass: number | null = null;
|
||||||
options.forEach((i, index) => {
|
options.forEach((i, index) => {
|
||||||
if (i.type === 'newTrain.placeCar') makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
|
if (i.type === 'newTrain.placeCar' && i.trayId === filling) {
|
||||||
if (i.type === 'newTrain.passCar') pass = index;
|
makeUpCars.push({ carType: i.carType, loaded: i.loaded, index });
|
||||||
|
}
|
||||||
|
if (i.type === 'newTrain.passCar' && i.trayId === filling) pass = index;
|
||||||
});
|
});
|
||||||
const makeUp =
|
const makeUp =
|
||||||
makeUpCars.length > 0 || pass !== null
|
filling !== null && (makeUpCars.length > 0 || pass !== null)
|
||||||
? {
|
? {
|
||||||
title:
|
trayId: filling,
|
||||||
consistTitle(
|
title: consistTitle(game, filling) ?? 'Making up the train',
|
||||||
game,
|
|
||||||
options,
|
|
||||||
options.map((_, index) => ({ index })).filter((a) => options[a.index]?.type.startsWith('newTrain.')),
|
|
||||||
) ?? 'Making up the train',
|
|
||||||
cars: makeUpCars,
|
cars: makeUpCars,
|
||||||
pass,
|
pass,
|
||||||
}
|
}
|
||||||
@@ -397,28 +435,31 @@ function subjectOf(
|
|||||||
subjectKey: string;
|
subjectKey: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
spot: string;
|
spot: string;
|
||||||
coord: { row: number; col: number };
|
coord: { row: number; col: number } | null;
|
||||||
|
node?: number;
|
||||||
links: string[];
|
links: string[];
|
||||||
} | null {
|
} | null {
|
||||||
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
|
||||||
|
/**
|
||||||
|
* ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names
|
||||||
|
* a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and
|
||||||
|
* row −1 is an ordinary district row: an enhancement laid on a real card one row below the
|
||||||
|
* Running Track was described as being "out on the Mainline", and ABS Signals itself lit up
|
||||||
|
* whichever district card sat at that column.
|
||||||
|
*/
|
||||||
|
if (i.type === 'card.play' && i.node !== undefined) {
|
||||||
|
const node = game.state.division.nodes[i.node];
|
||||||
|
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `Mainline card ${i.node}`;
|
||||||
|
return {
|
||||||
|
subjectKey: `card:${i.cardId}`,
|
||||||
|
subject: cardName(game.state, i.cardId),
|
||||||
|
spot: `on the ${where}, out on the Mainline`,
|
||||||
|
coord: null,
|
||||||
|
node: i.node,
|
||||||
|
links: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
if (i.type === 'card.play' && i.placement) {
|
if (i.type === 'card.play' && i.placement) {
|
||||||
/**
|
|
||||||
* ABS Signals is placed on a MAINLINE card, and the engine carries which one in `placement.col`
|
|
||||||
* as a Division node index. Showing that as a grid coordinate read as an Office Area square —
|
|
||||||
* "(0, 1)" — which is the wrong half of the board entirely.
|
|
||||||
*/
|
|
||||||
const k = game.state.cards.get(i.cardId)?.kind;
|
|
||||||
if (k?.kind === 'enhancement' && i.placement.row === -1) {
|
|
||||||
const node = game.state.division.nodes[i.placement.col];
|
|
||||||
const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `node ${i.placement.col}`;
|
|
||||||
return {
|
|
||||||
subjectKey: `card:${i.cardId}`,
|
|
||||||
subject: cardName(game.state, i.cardId),
|
|
||||||
spot: `on the ${where}, out on the Mainline`,
|
|
||||||
coord: i.placement,
|
|
||||||
links: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
|
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
|
||||||
// the decision, so they ride on the spot rather than being left for the player to work out.
|
// the decision, so they ride on the spot rather than being left for the player to work out.
|
||||||
const kind = game.state.cards.get(i.cardId)?.kind;
|
const kind = game.state.cards.get(i.cardId)?.kind;
|
||||||
@@ -521,10 +562,8 @@ function joinsNote(
|
|||||||
* the consist is the reason a button is missing. Naming it turns an unexplained restriction into a
|
* the consist is the reason a button is missing. Naming it turns an unexplained restriction into a
|
||||||
* card the player can read.
|
* card the player can read.
|
||||||
*/
|
*/
|
||||||
function consistTitle(game: Game, options: Intent[], actions: { index: number }[]): string | null {
|
function consistTitle(game: Game, trayId: string): string | null {
|
||||||
const first = actions.map((a) => options[a.index]).find((i) => i && 'trayId' in i);
|
const tray = game.state.trays.get(trayId);
|
||||||
if (!first || !('trayId' in first)) return null;
|
|
||||||
const tray = game.state.trays.get(first.trayId);
|
|
||||||
if (!tray) return null;
|
if (!tray) return null;
|
||||||
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
@@ -628,6 +667,31 @@ export function toSave(game: Game): Save {
|
|||||||
return { seed: game.seed, history: game.history };
|
return { seed: game.seed, history: game.history };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TAKE THE LAST ACTION BACK.
|
||||||
|
*
|
||||||
|
* The save IS the game — a seed and the intents submitted — so undo is "replay everything except the
|
||||||
|
* last one". That is why this is a few lines rather than a feature: there is no undo stack to keep,
|
||||||
|
* no inverse of each action to write, and no way for it to produce a position the rules could not
|
||||||
|
* have reached, because the position is reached by the rules.
|
||||||
|
*
|
||||||
|
* WHAT IT COSTS. Replaying is O(history), which at a few hundred intents is imperceptible, and the
|
||||||
|
* whole log is rebuilt with it — so the history panel matches the board afterwards rather than still
|
||||||
|
* describing the move that was taken back.
|
||||||
|
*
|
||||||
|
* WHAT IT ALLOWS, deliberately: the RNG advances with the replay, so playing the same train card
|
||||||
|
* again rolls the same Stage — you cannot undo your way to a better die. You CAN see the roll and
|
||||||
|
* then spend the turn differently, which is an ordinary solitaire take-back and is the reason this
|
||||||
|
* is solitaire-only. `TODO.md` carries the open question of whether a Stage boundary should become a
|
||||||
|
* commit point.
|
||||||
|
*
|
||||||
|
* Returns null when there is nothing to undo, so the caller can leave the button disabled.
|
||||||
|
*/
|
||||||
|
export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null {
|
||||||
|
if (game.history.length === 0) return null;
|
||||||
|
return fromSave({ seed: game.seed, history: game.history.slice(0, -1) }, config);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild a game from a save.
|
* Rebuild a game from a save.
|
||||||
*
|
*
|
||||||
|
|||||||
+95
-4
@@ -20,6 +20,7 @@ import {
|
|||||||
newGame,
|
newGame,
|
||||||
submit,
|
submit,
|
||||||
toSave,
|
toSave,
|
||||||
|
undo,
|
||||||
view,
|
view,
|
||||||
} from './game.ts';
|
} from './game.ts';
|
||||||
|
|
||||||
@@ -140,8 +141,11 @@ function render(): void {
|
|||||||
const chosen = forPlay
|
const chosen = forPlay
|
||||||
? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey)
|
? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// A spot with no coordinate is not on this board — ABS Signals goes out on the Mainline — so it
|
||||||
|
// is chosen from the action list and lights nothing in the district.
|
||||||
const spotsAt = new Map<string, { label: string; index: number }[]>();
|
const spotsAt = new Map<string, { label: string; index: number }[]>();
|
||||||
for (const sp of chosen?.spots ?? []) {
|
for (const sp of chosen?.spots ?? []) {
|
||||||
|
if (sp.coord === null) continue;
|
||||||
const key = `${sp.coord.row},${sp.coord.col}`;
|
const key = `${sp.coord.row},${sp.coord.col}`;
|
||||||
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
|
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
|
||||||
}
|
}
|
||||||
@@ -168,7 +172,27 @@ function render(): void {
|
|||||||
const [gr, gc] = k.split(',').map(Number);
|
const [gr, gc] = k.split(',').map(Number);
|
||||||
return { row: gr!, col: gc! };
|
return { row: gr!, col: gc! };
|
||||||
});
|
});
|
||||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords);
|
/**
|
||||||
|
* WHAT PLAYING ON AN OCCUPIED SQUARE WOULD DO.
|
||||||
|
*
|
||||||
|
* Three different acts light up the same blue: building on empty ground, EXTENDING the Running
|
||||||
|
* Track at a Limits sign (the sign moves outward with the card — the only way a district grows
|
||||||
|
* along the main), and ATTACHING an Enhancement to a card already down. The first says "place
|
||||||
|
* here" on a ghost card; the other two said nothing at all, which is why the Depot lighting up
|
||||||
|
* read as a bug rather than as an offer.
|
||||||
|
*/
|
||||||
|
const legalCaps = [...spotsAt.keys()].flatMap((k) => {
|
||||||
|
const cell = f.cells.find((c) => `${c.row},${c.col}` === k);
|
||||||
|
if (!cell) return [];
|
||||||
|
const label =
|
||||||
|
cell.kind === 'limits'
|
||||||
|
? 'EXTEND THE RUNNING TRACK HERE'
|
||||||
|
: cell.kind === 'office'
|
||||||
|
? 'ATTACH TO YOUR OFFICE'
|
||||||
|
: 'ATTACH TO THIS CARD';
|
||||||
|
return [{ row: cell.row, col: cell.col, label }];
|
||||||
|
});
|
||||||
|
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps);
|
||||||
|
|
||||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||||
for (const [key, list] of spotsAt) {
|
for (const [key, list] of spotsAt) {
|
||||||
@@ -186,6 +210,37 @@ function render(): void {
|
|||||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE SWITCHING MOVE, ON THE BOARD.
|
||||||
|
*
|
||||||
|
* Every switching decision is about geography — which card the crew can reach, what it will couple
|
||||||
|
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
|
||||||
|
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
|
||||||
|
* months; the play page simply never used them.
|
||||||
|
*
|
||||||
|
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
|
||||||
|
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
|
||||||
|
*/
|
||||||
|
if (f.moves && !forPlay) {
|
||||||
|
const here = grid.querySelector(`g[data-cell="${f.moves.from.row},${f.moves.from.col}"]`);
|
||||||
|
if (here) here.classList.add('bs-from');
|
||||||
|
for (const c of f.moves.to) {
|
||||||
|
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||||
|
if (g) g.classList.add('bs-focus');
|
||||||
|
}
|
||||||
|
for (const b of f.moves.blocked) {
|
||||||
|
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
|
||||||
|
if (!g) continue;
|
||||||
|
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
|
||||||
|
// your way — you run through it — so it must not be drawn like an industry that is locked.
|
||||||
|
const passable = b.kind === 'noStopping';
|
||||||
|
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
|
||||||
|
const own = g.getAttribute('data-tip') ?? '';
|
||||||
|
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
|
||||||
|
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pick(key: string, list: { label: string; index: number }[]): void {
|
function pick(key: string, list: { label: string; index: number }[]): void {
|
||||||
if (list.length === 1) {
|
if (list.length === 1) {
|
||||||
const intent = menu.options[list[0]!.index];
|
const intent = menu.options[list[0]!.index];
|
||||||
@@ -372,6 +427,7 @@ function render(): void {
|
|||||||
|
|
||||||
renderDistrict(f);
|
renderDistrict(f);
|
||||||
renderActions(menu, f, justSet);
|
renderActions(menu, f, justSet);
|
||||||
|
renderUndo();
|
||||||
|
|
||||||
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
|
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
|
||||||
// not then play a backlog of everything that happened while it was off.
|
// not then play a backlog of everything that happened while it was off.
|
||||||
@@ -381,6 +437,40 @@ function render(): void {
|
|||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNDO, as far back as you like.
|
||||||
|
*
|
||||||
|
* The game is a seed and a list of intents, so stepping back is replaying without the last one —
|
||||||
|
* see `undo()` in game.ts. The button says how many moves are behind you, because "can I still get
|
||||||
|
* back?" is the question it exists to answer; it goes quiet when there is nothing to take back.
|
||||||
|
*
|
||||||
|
* The whole page is re-rendered from the rebuilt game, including the history panel, so nothing on
|
||||||
|
* screen is left describing a move that no longer happened. Any card picked up mid-choice is
|
||||||
|
* dropped: the position it was going to be played into may not exist any more.
|
||||||
|
*/
|
||||||
|
function renderUndo(): void {
|
||||||
|
const btn = document.getElementById('undo') as HTMLButtonElement | null;
|
||||||
|
if (!btn) return;
|
||||||
|
const n = game.history.length;
|
||||||
|
btn.disabled = n === 0;
|
||||||
|
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
|
||||||
|
btn.onclick = () => {
|
||||||
|
const back = undo(game);
|
||||||
|
if (!back) return;
|
||||||
|
game = back;
|
||||||
|
selected = null;
|
||||||
|
mode = null;
|
||||||
|
pendingAt = null;
|
||||||
|
// The rebuilt game replays its own cues from the beginning; none of them are news.
|
||||||
|
game.cues.length = 0;
|
||||||
|
game.scheduled = null;
|
||||||
|
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
|
||||||
|
// different phase is not that event, so the banner is suppressed rather than fired backwards.
|
||||||
|
lastPhase = null;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two yards, and how close the Division Yard is to running out.
|
* The two yards, and how close the Division Yard is to running out.
|
||||||
*
|
*
|
||||||
@@ -585,7 +675,7 @@ function renderActions(
|
|||||||
{
|
{
|
||||||
// Once a square is picked, show only that square's rotations — the rest is noise.
|
// Once a square is picked, show only that square's rotations — the rest is noise.
|
||||||
const shown = pendingAt
|
const shown = pendingAt
|
||||||
? item.spots.filter((sp) => `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
? item.spots.filter((sp) => sp.coord !== null && `${sp.coord.row},${sp.coord.col}` === pendingAt)
|
||||||
: item.spots;
|
: item.spots;
|
||||||
html +=
|
html +=
|
||||||
`<div class="spots"><div class="dim">` +
|
`<div class="spots"><div class="dim">` +
|
||||||
@@ -691,8 +781,9 @@ if (saveBtn) saveBtn.onclick = downloadSave;
|
|||||||
*
|
*
|
||||||
* The only way out used to be finishing the game — `start()` restores from localStorage on every
|
* The only way out used to be finishing the game — `start()` restores from localStorage on every
|
||||||
* load, so a game you no longer wanted followed you across reloads, and the "new game" button
|
* load, so a game you no longer wanted followed you across reloads, and the "new game" button
|
||||||
* appeared solely on the game-over screen. Confirmed because the save is the game: there is no undo,
|
* appeared solely on the game-over screen. Confirmed because it throws the whole game away — Undo
|
||||||
* and the replay download is right beside it.
|
* steps back one action at a time, but nothing brings back a game that has been dealt over, and the
|
||||||
|
* replay download is right beside it.
|
||||||
*
|
*
|
||||||
* `location.search = ''` rather than a direct re-render, so a `?seed=` in the URL goes too — leaving
|
* `location.search = ''` rather than a direct re-render, so a `?seed=` in the URL goes too — leaving
|
||||||
* it would deal the same game again and look like the button had done nothing.
|
* it would deal the same game again and look like the button had done nothing.
|
||||||
|
|||||||
+29
-1
@@ -183,7 +183,23 @@ export function facilitiesHtml(f: Frame): string {
|
|||||||
* and what the Modifiers beside it added.
|
* and what the Modifiers beside it added.
|
||||||
*/
|
*/
|
||||||
`<div class="caps">` +
|
`<div class="caps">` +
|
||||||
stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square.') +
|
/**
|
||||||
|
* PORTERS TOO, NOT JUST LABORERS.
|
||||||
|
*
|
||||||
|
* `porters` has been on the view-model all along and no renderer ever printed it — so a
|
||||||
|
* Waiting Area, Restaurant or Hotel beside the Office, whose whole effect is +1 porter
|
||||||
|
* and +1 passenger slot, showed exactly half of what it did. Reported: "it gave me an
|
||||||
|
* extra porter that I see no indication of anywhere."
|
||||||
|
*
|
||||||
|
* A facility has one kind of worker or the other: Laborers work freight, Porters work
|
||||||
|
* passengers. Showing the row that is always 0/0 would be noise, so each shows its own.
|
||||||
|
*/
|
||||||
|
(Number(x.laborers.split('/').pop()) > 0 || x.base.laborers > 0
|
||||||
|
? stat('laborers', x.laborers, x.base.laborers, 'Actions this facility can take each Stage — one Laborer moves one load one square. Left of the slash is how many are still free this Stage.')
|
||||||
|
: '') +
|
||||||
|
(Number(x.porters.split('/').pop()) > 0 || x.base.porters > 0
|
||||||
|
? stat('porters', x.porters, x.base.porters, 'Passenger work, one action each per Stage: a Porter boards or detrains passengers, and earns the Revenue in a single action. Left of the slash is how many are still free this Stage.')
|
||||||
|
: '') +
|
||||||
(x.allowsOut ? stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') : '') +
|
(x.allowsOut ? stat('out', String(x.greenCap), x.base.out, 'Green boxes: loads waiting to be worked onto a car.') : '') +
|
||||||
(x.allowsIn ? stat('in', String(x.redCap), x.base.in, 'Red boxes: loads cleared off an arriving car.') : '') +
|
(x.allowsIn ? stat('in', String(x.redCap), x.base.in, 'Red boxes: loads cleared off an arriving car.') : '') +
|
||||||
(x.modifiers.length
|
(x.modifiers.length
|
||||||
@@ -279,6 +295,18 @@ h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:#8b94a3;ma
|
|||||||
@keyframes ttflash{0%{background:#2f6b47;border-color:#8fd6a0;transform:scale(1.18)}
|
@keyframes ttflash{0%{background:#2f6b47;border-color:#8fd6a0;transform:scale(1.18)}
|
||||||
100%{background:#222c38;border-color:#4d6fa8;transform:scale(1)}}
|
100%{background:#222c38;border-color:#4d6fa8;transform:scale(1)}}
|
||||||
@media(prefers-reduced-motion:reduce){.tt-slot.fresh{animation:none;outline:2px solid #8fd6a0}}
|
@media(prefers-reduced-motion:reduce){.tt-slot.fresh{animation:none;outline:2px solid #8fd6a0}}
|
||||||
|
/* THE KEY TO THE FOUR STATES ABOVE. A slot can be lit blue, ringed violet, dimmed, or flashing
|
||||||
|
green, and nothing said which was which — reported as "what do the colours mean?" The green one
|
||||||
|
in particular is a MOMENT, not a state: it marks the slot the die just filled and is gone by the
|
||||||
|
next render, so without a key it reads as a category of train. */
|
||||||
|
.tt-key{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px;font-size:10px;color:#8b94a3}
|
||||||
|
.tt-key span{display:inline-flex;align-items:center;gap:4px}
|
||||||
|
.tt-key span::before{content:"";width:9px;height:9px;border-radius:2px;border:1px solid #2c333d;
|
||||||
|
background:#1a1f26}
|
||||||
|
.tt-key .k-due::before{background:#222c38;border-color:#4d6fa8}
|
||||||
|
.tt-key .k-now::before{border-color:#b98cf0;box-shadow:0 0 0 1px rgba(150,110,230,.35)}
|
||||||
|
.tt-key .k-past::before{opacity:.45}
|
||||||
|
.tt-key .k-fresh::before{background:#2f6b47;border-color:#8fd6a0}
|
||||||
ul.blocked{margin:0;padding-left:18px}
|
ul.blocked{margin:0;padding-left:18px}
|
||||||
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
|
.sev-waiting{color:#8b94a3}.sev-risk{color:#e0b060}.sev-stuck{color:#e58080}
|
||||||
.fac{border-top:1px solid #2c333d;padding:7px 0}
|
.fac{border-top:1px solid #2c333d;padding:7px 0}
|
||||||
|
|||||||
+38
-2
@@ -16,11 +16,20 @@ h2.big{font-size:15px;text-transform:none;letter-spacing:.01em;color:#cfe0f5;
|
|||||||
margin:0 0 9px;padding-bottom:5px;border-bottom:1px solid #2c333d}
|
margin:0 0 9px;padding-bottom:5px;border-bottom:1px solid #2c333d}
|
||||||
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
|
h3{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:9px 0 4px}
|
||||||
.dim{color:var(--dim)}
|
.dim{color:var(--dim)}
|
||||||
header{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
|
/* THE TOP TWO ROWS TRAVEL TOGETHER.
|
||||||
|
The title bar was sticky and the turn chart under it was not, so scrolling down took away the one
|
||||||
|
thing you most need while reading the board — which Day and Stage it is, which phase is running,
|
||||||
|
and who it is waiting on. They are one block now, and it sticks as one. */
|
||||||
|
.topbar{position:sticky;top:0;z-index:5}
|
||||||
|
header{background:var(--panel);border-bottom:1px solid var(--line);
|
||||||
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
|
padding:9px 14px;display:flex;gap:20px;align-items:baseline;flex-wrap:wrap}
|
||||||
header b{font-size:16px}
|
header b{font-size:16px}
|
||||||
header button{background:#2a3038;color:inherit;border:1px solid #2c333d;border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px}
|
header button{background:#2a3038;color:inherit;border:1px solid #2c333d;border-radius:5px;padding:3px 9px;cursor:pointer;font:inherit;font-size:12px}
|
||||||
header button:hover{border-color:#4d6fa8}
|
header button:hover{border-color:#4d6fa8}
|
||||||
|
/* Nothing to take back yet. It keeps its place — a button that disappears reads as a bug — but it
|
||||||
|
stops inviting the press. */
|
||||||
|
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
|
||||||
|
header button:disabled:hover{border-color:#2c333d}
|
||||||
.build{margin-left:auto;font-size:11px;opacity:.7}
|
.build{margin-left:auto;font-size:11px;opacity:.7}
|
||||||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||||||
.home:hover{color:#5aa9e6}
|
.home:hover{color:#5aa9e6}
|
||||||
@@ -106,6 +115,12 @@ button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size
|
|||||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||||
#district.folded #grid{display:none}
|
#district.folded #grid{display:none}
|
||||||
|
#district.folded .districtrule{display:none}
|
||||||
|
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
||||||
|
and never again, so it must not shout. */
|
||||||
|
.districtrule{font-size:11px;line-height:1.4;color:#8b94a3;margin:0 0 7px;padding:5px 8px;
|
||||||
|
border-left:2px solid #39424e;background:#171c23;border-radius:0 4px 4px 0}
|
||||||
|
.districtrule b{color:#cfd6e0}
|
||||||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||||
#districtsummary{display:none}
|
#districtsummary{display:none}
|
||||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||||
@@ -142,6 +157,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
<header>
|
<header>
|
||||||
<b><a href="./index.html" class="home">Station Master</a></b>
|
<b><a href="./index.html" class="home">Station Master</a></b>
|
||||||
<span>Revenue <b id="revenue">0</b></span>
|
<span>Revenue <b id="revenue">0</b></span>
|
||||||
@@ -149,8 +165,9 @@ ul.blocked li{padding:2px 0}
|
|||||||
<span class="dim">seed <span id="seed">—</span></span>
|
<span class="dim">seed <span id="seed">—</span></span>
|
||||||
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
|
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
|
||||||
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||||||
|
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||||
<button id="newgame" title="Forget the game saved in this browser and deal a fresh one. There is no undo — download the replay first if you want to keep it.">New game</button>
|
<button id="newgame" title="Forget the game saved in this browser and deal a fresh one. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
|
||||||
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -160,6 +177,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
<!-- Filled by `turnChartHtml`, shared with both replay viewers so all three screens report where
|
<!-- Filled by `turnChartHtml`, shared with both replay viewers so all three screens report where
|
||||||
you are in the Day identically. -->
|
you are in the Day identically. -->
|
||||||
<div id="turnchart"></div>
|
<div id="turnchart"></div>
|
||||||
|
</div><!-- /topbar -->
|
||||||
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
|
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
|
||||||
what you miss when the automatic phases run between two clicks. -->
|
what you miss when the automatic phases run between two clicks. -->
|
||||||
<div id="phasenote"></div>
|
<div id="phasenote"></div>
|
||||||
@@ -173,6 +191,15 @@ ul.blocked li{padding:2px 0}
|
|||||||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="districtsummary" class="dim"></div>
|
<div id="districtsummary" class="dim"></div>
|
||||||
|
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||||
|
It is enforced everywhere and was stated nowhere: the Limits signs are the only growth
|
||||||
|
point on the Running Track and they move outward as you build, and no card anywhere can
|
||||||
|
be inserted between two cards already down. -->
|
||||||
|
<div class="districtrule">A district only ever grows <b>outwards</b>. On the Running Track the
|
||||||
|
Limits signs are the growth point — lay a card on a sign and the sign moves one further out;
|
||||||
|
nothing may be built beyond it. Everywhere else a card needs an empty square that its rails
|
||||||
|
reach. <b>No card is ever inserted between two cards already down</b>, so nothing you have
|
||||||
|
built is pushed along by something laid later.</div>
|
||||||
<div id="grid"></div>
|
<div id="grid"></div>
|
||||||
</section>
|
</section>
|
||||||
<section><h2>History</h2><div id="log"></div></section>
|
<section><h2>History</h2><div id="log"></div></section>
|
||||||
@@ -192,6 +219,15 @@ ul.blocked li{padding:2px 0}
|
|||||||
<div class="dim" id="yardnote"></div>
|
<div class="dim" id="yardnote"></div>
|
||||||
</section>
|
</section>
|
||||||
<section><h2>Timetable — who is due out, and when</h2><div id="timetable"></div>
|
<section><h2>Timetable — who is due out, and when</h2><div id="timetable"></div>
|
||||||
|
<!-- WHAT THE COLOURS MEAN. Asked directly in playtesting — "what do green and purple mean?"
|
||||||
|
Violet is "where you are" everywhere on this page, and the green is a flash rather than a
|
||||||
|
state, which is exactly why it needed saying. -->
|
||||||
|
<div class="tt-key">
|
||||||
|
<span class="k-due">Train due</span>
|
||||||
|
<span class="k-now">this Stage</span>
|
||||||
|
<span class="k-past">already gone</span>
|
||||||
|
<span class="k-fresh">just rolled</span>
|
||||||
|
</div>
|
||||||
<div class="dim" style="font-size:11px;margin-top:5px">A train's number is its seniority and direction — odd runs west, even runs east. The Stage it departs is set by a 1D12 roll when its card is played, and it then runs at that time every Day.</div>
|
<div class="dim" style="font-size:11px;margin-top:5px">A train's number is its seniority and direction — odd runs west, even runs east. The Stage it departs is set by a 1D12 roll when its card is played, and it then runs at that time every Day.</div>
|
||||||
</section>
|
</section>
|
||||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||||
|
|||||||
@@ -991,3 +991,93 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi
|
|||||||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }), 'CONSIST_EMPTY');
|
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }), 'CONSIST_EMPTY');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('only the train being made up may take cars (§7)', () => {
|
||||||
|
/** A tray with a short consist, placed wherever the test needs it. */
|
||||||
|
function trayAt(s: GameState, position: CrewTray['position']): void {
|
||||||
|
s.clock.phase = 'newTrain';
|
||||||
|
s.trays.set('t', {
|
||||||
|
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [], direction: 'west', position, movesUsed: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const addHopper: Intent = { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true };
|
||||||
|
|
||||||
|
it('accepts a car for a train standing at a Division Point', () => {
|
||||||
|
const s = game();
|
||||||
|
trayAt(s, { at: 'divisionPoint', side: 'east' });
|
||||||
|
assert.equal(check(s, 0, addHopper), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to load a train standing in the district', () => {
|
||||||
|
// The New Train phase fills the train it is WAITING on — `trainNeedingCars` requires a Division
|
||||||
|
// Point. `check` did not, so the Division Yard would hand cars to a train sitting on a siding
|
||||||
|
// in your own district: cars appeared on a train nobody was making up.
|
||||||
|
const s = game();
|
||||||
|
trayAt(s, { at: 'grid', owner: 0, coord: at(0, 1) });
|
||||||
|
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to load a train out on the Mainline', () => {
|
||||||
|
const s = game();
|
||||||
|
trayAt(s, { at: 'mainline', index: 1 });
|
||||||
|
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to load a train whose consist is already what its card calls for', () => {
|
||||||
|
// Train 9 is freight 3 + caboose 1. A full train is not the one being made up, even standing
|
||||||
|
// at a Division Point — otherwise the phase and `check` disagree about who is being filled.
|
||||||
|
const s = game();
|
||||||
|
trayAt(s, { at: 'divisionPoint', side: 'east' });
|
||||||
|
const tray = s.trays.get('t')!;
|
||||||
|
tray.consist = [
|
||||||
|
{ type: 'hopper', loaded: true },
|
||||||
|
{ type: 'hopper', loaded: true },
|
||||||
|
{ type: 'hopper', loaded: true },
|
||||||
|
{ type: 'caboose', loaded: true },
|
||||||
|
];
|
||||||
|
assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('an Office upgrade keeps what Modifiers added (§9)', () => {
|
||||||
|
it('does not wipe a Restaurant when the Depot becomes a Station', () => {
|
||||||
|
// The upgrade wrote the tier's printed numbers straight over the facility, so a Restaurant
|
||||||
|
// played beside a Depot — +1 passenger out, +1 porter — was silently deleted by the next
|
||||||
|
// upgrade. The card had been spent and the effect vanished with no message.
|
||||||
|
const s = game();
|
||||||
|
const office = () => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!;
|
||||||
|
|
||||||
|
const play = (tier: 'depot' | 'station'): void => {
|
||||||
|
for (const [id, card] of s.cards) {
|
||||||
|
if (card.kind.kind !== 'office' || card.kind.tier !== tier) continue;
|
||||||
|
s.decks.hands.set(0, [id]);
|
||||||
|
s.turn.option = null;
|
||||||
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||||
|
const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
|
||||||
|
assert.ok(r.ok, `${tier} upgrade should apply`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`no ${tier} card`);
|
||||||
|
};
|
||||||
|
|
||||||
|
play('depot');
|
||||||
|
const base = { out: office().capacity.outbound, porters: office().porters };
|
||||||
|
|
||||||
|
// A Restaurant beside the Office: +1 passenger out, +1 porter.
|
||||||
|
office().capacity.outbound += 1;
|
||||||
|
office().porters += 1;
|
||||||
|
|
||||||
|
play('station');
|
||||||
|
|
||||||
|
const station = { out: 2, porters: 2 }; // Station's printed numbers
|
||||||
|
assert.equal(office().capacity.outbound, station.out + 1, 'the Modifier survives the upgrade');
|
||||||
|
assert.equal(office().porters, station.porters + 1, 'the porter survives the upgrade');
|
||||||
|
assert.ok(office().capacity.outbound > base.out, 'and the upgrade still raised the tier');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
|
|||||||
import {
|
import {
|
||||||
allReachable,
|
allReachable,
|
||||||
canDropCarsAt,
|
canDropCarsAt,
|
||||||
|
exploreMoves,
|
||||||
canPlaceAt,
|
canPlaceAt,
|
||||||
carriesThroughTrack,
|
carriesThroughTrack,
|
||||||
exitsFrom,
|
exitsFrom,
|
||||||
@@ -944,3 +945,109 @@ describe('backing up does not turn the train around', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('why a square is NOT offered (the same walk, keeping its rejections)', () => {
|
||||||
|
/**
|
||||||
|
* REPORTED: "during switching, it seems like trains are blocked from moving onto industry in
|
||||||
|
* certain conditions. Can you make it clear what those conditions are?" They were nowhere on
|
||||||
|
* screen — a square that is not a destination simply had no button.
|
||||||
|
*
|
||||||
|
* `exploreMoves` returns the reasons out of the SAME traversal that produces the destinations, so
|
||||||
|
* a reason shown to a player is the rule that actually refused the square. A second function
|
||||||
|
* working out "why is this missing" would be a second implementation of movement, and its failure
|
||||||
|
* mode is a confident explanation that does not match the refusal.
|
||||||
|
*/
|
||||||
|
const cars = (n: number): RollingStock[] =>
|
||||||
|
Array.from({ length: n }, () => ({ type: 'boxcar', loaded: false }) as RollingStock);
|
||||||
|
|
||||||
|
const workingIndustry = (): TrackCard => ({
|
||||||
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
||||||
|
baseOperationalRail: true,
|
||||||
|
standing: [],
|
||||||
|
facility: {
|
||||||
|
kind: 'freight', subtype: 'mineTipple',
|
||||||
|
allows: { outbound: true, inbound: false },
|
||||||
|
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
|
||||||
|
// A load on MEN locks the track (§9.3).
|
||||||
|
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null] as never,
|
||||||
|
industryTrack: { length: 2, cars: [] },
|
||||||
|
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||||
|
},
|
||||||
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const reasonFor = (area: OfficeArea, over: Partial<MoveContext>, r: number, c: number): string | null => {
|
||||||
|
const { blocked } = exploreMoves(ctxFor(area, over), at(0, 0), 'e');
|
||||||
|
return blocked.find((b) => b.coord.row === r && b.coord.col === c)?.why ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('says MEN AT WORK when an industry is locked, and refuses the square', () => {
|
||||||
|
const area = areaFrom(
|
||||||
|
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: workingIndustry() },
|
||||||
|
at(0, 5),
|
||||||
|
);
|
||||||
|
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
|
||||||
|
assert.ok(!has(destinations, 0, 1), 'a locked industry must not be a destination');
|
||||||
|
const why = blocked.find((b) => b.coord.col === 1);
|
||||||
|
assert.equal(why?.kind, 'locked');
|
||||||
|
assert.match(why!.why, /MEN AT WORK/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says which card is occupied, and by what rule', () => {
|
||||||
|
const area = areaFrom(
|
||||||
|
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: straight() },
|
||||||
|
at(0, 5),
|
||||||
|
);
|
||||||
|
const occupancy: Occupancy = { trayAt: (c) => (c.col === 1 ? 'tray9' : null), freeAdTracks: () => 0 };
|
||||||
|
assert.match(reasonFor(area, { occupancy }, 0, 1) ?? '', /another train is standing here/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says when a move would overfill the train, and counts the cars it would couple', () => {
|
||||||
|
const area = areaFrom(
|
||||||
|
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: straight(cars(3)) },
|
||||||
|
at(0, 5),
|
||||||
|
);
|
||||||
|
const why = reasonFor(area, { consistSize: 3 }, 0, 1) ?? '';
|
||||||
|
assert.match(why, /too many cars/);
|
||||||
|
assert.match(why, /couples 3 standing car/, 'the count must be the cars actually there');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells a pass-through apart from an obstruction', () => {
|
||||||
|
// A turnout is not in the way: a train runs through one all day and simply may not finish a
|
||||||
|
// Move on it. Reporting that as "blocked" would bury the reasons that ARE obstructions.
|
||||||
|
const area = areaFrom(
|
||||||
|
{
|
||||||
|
[coordKey(at(0, 0))]: straight(),
|
||||||
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
||||||
|
[coordKey(at(0, 2))]: straight(),
|
||||||
|
},
|
||||||
|
at(0, 5),
|
||||||
|
);
|
||||||
|
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
|
||||||
|
assert.ok(has(destinations, 0, 2), 'the train must still be able to run THROUGH the turnout');
|
||||||
|
assert.equal(blocked.find((b) => b.coord.col === 1)?.kind, 'noStopping');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never explains away a square that is actually reachable', () => {
|
||||||
|
// The walk can meet a card from a bad angle first and a good one later. A reason attached to a
|
||||||
|
// square the player CAN reach is worse than none: it argues with the button beside it.
|
||||||
|
const area = areaFrom(
|
||||||
|
{
|
||||||
|
[coordKey(at(0, 0))]: straight(),
|
||||||
|
[coordKey(at(0, 1))]: straight(),
|
||||||
|
[coordKey(at(0, 2))]: straight(),
|
||||||
|
},
|
||||||
|
at(0, 5),
|
||||||
|
);
|
||||||
|
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
|
||||||
|
for (const b of blocked) {
|
||||||
|
assert.ok(
|
||||||
|
!has(destinations, b.coord.row, b.coord.col),
|
||||||
|
`(${b.coord.row},${b.coord.col}) is both reachable and explained away`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+272
-12
@@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||||
import { variantsFor } from '../src/engine/track.ts';
|
import { variantsFor } from '../src/engine/track.ts';
|
||||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||||
import { timetableHtml } from '../src/web/panels.ts';
|
import { facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
||||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
newGame,
|
newGame,
|
||||||
submit,
|
submit,
|
||||||
toSave,
|
toSave,
|
||||||
|
undo,
|
||||||
view,
|
view,
|
||||||
} from '../src/web/game.ts';
|
} from '../src/web/game.ts';
|
||||||
|
|
||||||
@@ -253,7 +254,7 @@ describe('the action menu presents choices the way they are made', () => {
|
|||||||
const spots = actionMenu(game)
|
const spots = actionMenu(game)
|
||||||
.placeable.flatMap((g) => g.items)
|
.placeable.flatMap((g) => g.items)
|
||||||
.flatMap((it) => it.spots)
|
.flatMap((it) => it.spots)
|
||||||
.filter((sp) => sp.coord.row === 0 && sp.coord.col === 1);
|
.filter((sp) => sp.coord?.row === 0 && sp.coord.col === 1);
|
||||||
|
|
||||||
assert.equal(spots.length, 2, 'both rotations must be offered on the same square');
|
assert.equal(spots.length, 2, 'both rotations must be offered on the same square');
|
||||||
assert.ok(spots.some((sp) => /turn to the south/.test(sp.label)), 'the south-diverging rotation is missing');
|
assert.ok(spots.some((sp) => /turn to the south/.test(sp.label)), 'the south-diverging rotation is missing');
|
||||||
@@ -414,6 +415,104 @@ describe('the board shows freight work happening', () => {
|
|||||||
assert.match(cell.what, /a load is on AT/, 'the card does not say where the work has got to');
|
assert.match(cell.what, /a load is on AT/, 'the card does not say where the work has got to');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('captions a legal square that already has a card on it', () => {
|
||||||
|
/**
|
||||||
|
* REPORTED: "it shows on the map where the possible places are. It also looks like my depot is
|
||||||
|
* highlighted as well... New squares where I could place my track say PLACE HERE. Limits are
|
||||||
|
* also highlighted but do not."
|
||||||
|
*
|
||||||
|
* Three different acts light up the same blue — building on empty ground, EXTENDING the Running
|
||||||
|
* Track at a Limits sign, and ATTACHING an Enhancement to a card already down — and only the
|
||||||
|
* first said anything. A blue outline with no words reads as a bug, not as an offer.
|
||||||
|
*/
|
||||||
|
const game = newGame(555);
|
||||||
|
const area = game.state.officeAreas.get(0)!;
|
||||||
|
const cells = view(game).cells;
|
||||||
|
const limits = cells.find((c) => c.kind === 'limits')!;
|
||||||
|
const svg = officeSvg(cells, area.runningRow, [], [
|
||||||
|
{ row: limits.row, col: limits.col, label: 'EXTEND THE RUNNING TRACK HERE' },
|
||||||
|
]);
|
||||||
|
assert.match(svg, /EXTEND THE RUNNING TRACK HERE/, 'the Limits sign is offered without a word');
|
||||||
|
// And the caption must clear the rail down the middle of the card, where the train is drawn.
|
||||||
|
const y = Number(/class="bs-legalcap"[^>]*>(?:<rect x="\d+" y="(\d+)")/.exec(svg)?.[1]);
|
||||||
|
assert.ok(y > 48, `the caption at y=${y} prints over the rail`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says on the card which way an industry runs, and how many workers it has', () => {
|
||||||
|
/**
|
||||||
|
* TWO REPORTS, ONE CARD.
|
||||||
|
*
|
||||||
|
* "Should the display be any different for industries that are send-only versus receive-only?
|
||||||
|
* At Grocer's Warehouse, which is receive only, and Refinery, which is send only, the men at
|
||||||
|
* work boxes, the arrows and the green-red boxes look the same in both." They were different —
|
||||||
|
* by the stroke colour of one 13x12px box and the direction of a 10px chevron, which is not a
|
||||||
|
* difference a player can see.
|
||||||
|
*
|
||||||
|
* And: "how do I see the number of laborers in an industry card?" You could not. The number
|
||||||
|
* that decides every Cargo phase was in a side panel only.
|
||||||
|
*/
|
||||||
|
const game = newGame(5);
|
||||||
|
const industry = (kind: string, out: boolean, into: boolean, laborers: number) => {
|
||||||
|
game.state.officeAreas.get(0)!.grid.set('-1,0', {
|
||||||
|
geometry: { kind: 'facility', facility: kind },
|
||||||
|
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
||||||
|
facility: {
|
||||||
|
kind: 'freight', subtype: kind,
|
||||||
|
allows: { outbound: out, inbound: into },
|
||||||
|
outboundBox: [], inboundBox: [],
|
||||||
|
capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
|
||||||
|
menAtWork: [null, null, null],
|
||||||
|
industryTrack: { length: 2, cars: [] },
|
||||||
|
laborers, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
||||||
|
return officeSvg([cell], 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ships = industry('refinery', true, false, 2);
|
||||||
|
const receives = industry('grocersWarehouse', false, true, 1);
|
||||||
|
assert.match(ships, /SHIPS OUT/, 'a send-only industry does not say so');
|
||||||
|
assert.match(receives, /RECEIVES/, 'a receive-only industry does not say so');
|
||||||
|
assert.doesNotMatch(ships, /RECEIVES/, 'a send-only industry claims to receive');
|
||||||
|
|
||||||
|
// And the two cards must not be one another with a different tint.
|
||||||
|
const strip = (svg: string): string => svg.replace(/(SHIPS OUT|RECEIVES|L \d\/\d)/g, '');
|
||||||
|
assert.notEqual(strip(ships), strip(receives), 'the two industries draw identically');
|
||||||
|
|
||||||
|
assert.match(ships, /bs-crewlab[^>]*>L 2\/2</, 'the Laborer count is not on the card');
|
||||||
|
assert.match(receives, /bs-crewlab[^>]*>L 1\/1</, 'the Laborer count is not on the card');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Office its Porters, and what a Modifier added', () => {
|
||||||
|
// REPORTED: "I added a restaurant. That gave me an extra outbound slot, so I see an extra green
|
||||||
|
// box. And it gave me an extra porter that I see no indication of anywhere." Porters were on
|
||||||
|
// the view-model and no renderer had ever drawn them.
|
||||||
|
const game = newGame(5);
|
||||||
|
const area = game.state.officeAreas.get(0)!;
|
||||||
|
const office = area.grid.get(`${area.runningRow},0`)!;
|
||||||
|
area.tier = 'depot';
|
||||||
|
office.facility!.porters = 2; // a Depot prints 1; a Restaurant beside it adds one
|
||||||
|
office.facility!.capacity = { outbound: 2, inbound: 1 };
|
||||||
|
office.facility!.allows = { outbound: true, inbound: true };
|
||||||
|
|
||||||
|
const cell = view(game).cells.find((c) => c.kind === 'office')!;
|
||||||
|
assert.match(officeSvg([cell], area.runningRow), /bs-crewlab[^>]*>P 2\/2</, 'the card does not show Porters');
|
||||||
|
assert.match(cell.what, /2 porters/, 'the tooltip still reads the printed tier, not the Office as it stands');
|
||||||
|
assert.match(cell.what, /Modifiers beside it add/, 'nothing says where the extra came from');
|
||||||
|
|
||||||
|
// And the panel must not invent a Modifier that is not there.
|
||||||
|
const plain = newGame(5);
|
||||||
|
const parea = plain.state.officeAreas.get(0)!;
|
||||||
|
parea.tier = 'depot';
|
||||||
|
const poffice = parea.grid.get(`${parea.runningRow},0`)!;
|
||||||
|
poffice.facility!.porters = 1;
|
||||||
|
poffice.facility!.capacity = { outbound: 1, inbound: 1 };
|
||||||
|
poffice.facility!.allows = { outbound: true, inbound: true };
|
||||||
|
const html = facilitiesHtml(view(plain));
|
||||||
|
assert.doesNotMatch(html, /class="added"/, 'a plain Depot is credited with a Modifier it does not have');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the enhancement label clear of the pipeline squares', () => {
|
it('keeps the enhancement label clear of the pipeline squares', () => {
|
||||||
// `overpass` and `facingPointLocks` are placed `onCard`, so they can land on a facility. At its
|
// `overpass` and `facingPointLocks` are placed `onCard`, so they can land on a facility. At its
|
||||||
// old baseline the label printed straight through the green/MEN|AT|WORK/red row.
|
// old baseline the label printed straight through the green/MEN|AT|WORK/red row.
|
||||||
@@ -517,13 +616,21 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('board highlighting', () => {
|
describe('board highlighting', () => {
|
||||||
it('gives every spot a coordinate to highlight', () => {
|
it('gives every spot a coordinate to highlight, or says it is not on this board', () => {
|
||||||
const game = newGame(555);
|
const game = newGame(555);
|
||||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||||
const items = actionMenu(game).placeable.flatMap((g) => g.items);
|
const items = actionMenu(game).placeable.flatMap((g) => g.items);
|
||||||
assert.ok(items.length > 0);
|
assert.ok(items.length > 0);
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
for (const sp of it.spots) {
|
for (const sp of it.spots) {
|
||||||
|
// A spot with no coordinate is a placement out on the Mainline, and it must say so — the
|
||||||
|
// board cannot highlight it, so the label is the only thing telling the player where it
|
||||||
|
// goes. It used to carry the fake coordinate (-1, node), which is a real district row.
|
||||||
|
if (sp.coord === null) {
|
||||||
|
assert.equal(typeof sp.node, 'number', `${it.subject} has neither a coordinate nor a node`);
|
||||||
|
assert.match(sp.label, /out on the Mainline/, `${it.subject}: no coordinate and no explanation`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
|
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
|
||||||
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
|
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
|
||||||
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
|
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
|
||||||
@@ -559,7 +666,7 @@ describe('board highlighting', () => {
|
|||||||
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots);
|
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0),
|
spots.some((sp) => sp.coord !== null && (sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0)),
|
||||||
'no legal spot lies outside the current cards — the bounds test would be vacuous',
|
'no legal spot lies outside the current cards — the bounds test would be vacuous',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -753,7 +860,7 @@ describe('the page explains itself', () => {
|
|||||||
.placeable.flatMap((g) => g.items)
|
.placeable.flatMap((g) => g.items)
|
||||||
.filter((it) => it.subject.includes('turnout'))
|
.filter((it) => it.subject.includes('turnout'))
|
||||||
.flatMap((it) => it.spots)
|
.flatMap((it) => it.spots)
|
||||||
.filter((sp) => sp.coord.row === under.row && sp.coord.col === under.col);
|
.filter((sp) => sp.coord?.row === under.row && sp.coord.col === under.col);
|
||||||
|
|
||||||
assert.equal(spots.length, 1, 'exactly one turnout rotation should meet the leg coming down');
|
assert.equal(spots.length, 1, 'exactly one turnout rotation should meet the leg coming down');
|
||||||
assert.match(spots[0]!.label, /turn to the north/, 'the crossover turnout must point back up');
|
assert.match(spots[0]!.label, /turn to the north/, 'the crossover turnout must point back up');
|
||||||
@@ -884,6 +991,7 @@ describe('the page explains itself', () => {
|
|||||||
// A piece with more than one orientation must say which is which, or the spots are ambiguous.
|
// A piece with more than one orientation must say which is which, or the spots are ambiguous.
|
||||||
const perSquare = new Map<string, number>();
|
const perSquare = new Map<string, number>();
|
||||||
for (const sp of item.spots) {
|
for (const sp of item.spots) {
|
||||||
|
if (sp.coord === null) continue;
|
||||||
const k = `${sp.coord.row},${sp.coord.col}`;
|
const k = `${sp.coord.row},${sp.coord.col}`;
|
||||||
perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
|
perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
@@ -917,6 +1025,61 @@ describe('replays are saves', () => {
|
|||||||
assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs');
|
assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('takes the last action back, and keeps taking', () => {
|
||||||
|
/**
|
||||||
|
* "Is there a way to remove a card that has been misplayed? In a solitaire game it might be
|
||||||
|
* nice to say oops, take that back."
|
||||||
|
*
|
||||||
|
* The save IS the game, so undo is a replay without the last intent — which means the position
|
||||||
|
* it lands on must be EXACTLY the position that was there before, board, Division, clock and
|
||||||
|
* revenue. Anything less and undo becomes its own source of divergence.
|
||||||
|
*/
|
||||||
|
const game = newGame(430);
|
||||||
|
const marks: { cells: unknown; division: unknown; revenue: number; day: number; stage: number }[] = [];
|
||||||
|
const mark = (g: typeof game) => ({
|
||||||
|
cells: view(g).cells,
|
||||||
|
division: view(g).division,
|
||||||
|
revenue: g.state.players[0]!.revenue,
|
||||||
|
day: g.state.clock.day,
|
||||||
|
stage: g.state.clock.stage,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
if (currentActor(game) === null) break;
|
||||||
|
const { options } = actionGroups(game);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
marks.push(mark(game));
|
||||||
|
if (!submit(game, options[0]!)) break;
|
||||||
|
}
|
||||||
|
assert.ok(marks.length > 20, 'the game did not get far enough to prove anything');
|
||||||
|
|
||||||
|
// Walk all the way back, checking each step lands on the position it came from.
|
||||||
|
let now = game;
|
||||||
|
for (let i = marks.length - 1; i >= 0; i--) {
|
||||||
|
const back = undo(now);
|
||||||
|
assert.ok(back, `undo refused with ${now.history.length} moves still on the clock`);
|
||||||
|
now = back;
|
||||||
|
assert.deepEqual(mark(now), marks[i], `undo ${marks.length - i} landed somewhere else`);
|
||||||
|
}
|
||||||
|
assert.equal(now.history.length, 0, 'walking all the way back did not empty the history');
|
||||||
|
assert.equal(undo(now), null, 'undo must refuse when there is nothing to take back');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves nothing in the log describing a move that was taken back', () => {
|
||||||
|
// The history panel is rebuilt with the game, so it cannot be left narrating an action that no
|
||||||
|
// longer happened — the single most confusing thing a half-undo could do.
|
||||||
|
const game = newGame(202);
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
if (currentActor(game) === null) break;
|
||||||
|
const { options } = actionGroups(game);
|
||||||
|
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||||
|
}
|
||||||
|
const before = game.log.length;
|
||||||
|
const back = undo(game)!;
|
||||||
|
assert.ok(back.log.length <= before, 'the log grew when a move was taken back');
|
||||||
|
assert.deepEqual(back.log, fromSave(toSave(back)).log, 'the rebuilt log is not the replayed log');
|
||||||
|
});
|
||||||
|
|
||||||
it('stays small enough to email', () => {
|
it('stays small enough to email', () => {
|
||||||
const game = newGame(202);
|
const game = newGame(202);
|
||||||
for (let i = 0; i < 400; i++) {
|
for (let i = 0; i < 400; i++) {
|
||||||
@@ -1393,6 +1556,43 @@ describe('the static build', () => {
|
|||||||
assert.ok(worst <= 8, `the action list still reaches ${worst} buttons`);
|
assert.ok(worst <= 8, `the action list still reaches ${worst} buttons`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('makes up ONE train at a time, and names that train', () => {
|
||||||
|
// REPORTED: the history said Train 9 was being made up at the East Division Point while the
|
||||||
|
// panel above it read "Making up Train 10 — 18 kinds it may take are highlighted". Two trains
|
||||||
|
// can be built in the same Stage (a timetabled train plus a Second Section or an Extra), and
|
||||||
|
// every option from every tray was collected into one panel titled with whichever tray came
|
||||||
|
// first out of the map. Reproduced at seed 99, Day 3 Stage 12: eighteen chips, two trays.
|
||||||
|
//
|
||||||
|
// The panel must belong to exactly one tray — otherwise clicking a car in the yard couples it
|
||||||
|
// to whichever train owns the first matching option, which may not be the one named.
|
||||||
|
for (const seed of [99, 430, 270861860]) {
|
||||||
|
const game = newGame(seed);
|
||||||
|
for (let i = 0; i < 600 && currentActor(game) !== null; i++) {
|
||||||
|
const menu = actionMenu(game);
|
||||||
|
if (menu.makeUp) {
|
||||||
|
const trays = new Set(
|
||||||
|
menu.makeUp.cars.map((c) => (menu.options[c.index] as { trayId: string }).trayId),
|
||||||
|
);
|
||||||
|
if (menu.makeUp.pass !== null) {
|
||||||
|
trays.add((menu.options[menu.makeUp.pass] as { trayId: string }).trayId);
|
||||||
|
}
|
||||||
|
assert.ok(trays.size <= 1, `seed ${seed}: the make-up panel covers ${trays.size} trains at once`);
|
||||||
|
for (const t of trays) {
|
||||||
|
assert.equal(t, menu.makeUp.trayId, `seed ${seed}: a car belongs to a train the panel does not name`);
|
||||||
|
}
|
||||||
|
const tray = game.state.trays.get(menu.makeUp.trayId);
|
||||||
|
assert.ok(tray, `seed ${seed}: the panel names a tray that does not exist`);
|
||||||
|
assert.ok(
|
||||||
|
menu.makeUp.title.includes(String(tray.trainNumber)),
|
||||||
|
`seed ${seed}: "${menu.makeUp.title}" does not name Train ${tray.trainNumber}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { options } = actionGroups(game);
|
||||||
|
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('keys each make-up car to the yard chip that shows it', () => {
|
it('keys each make-up car to the yard chip that shows it', () => {
|
||||||
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
|
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
|
||||||
// exactly those cars by type and load state. The yard is the surface.
|
// exactly those cars by type and load state. The yard is the surface.
|
||||||
@@ -1416,11 +1616,15 @@ describe('the static build', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('offers ABS Signals the Mainline, not the Office Area', () => {
|
it('offers ABS Signals the Mainline, not the Office Area', () => {
|
||||||
// REPORTED: the card says "any Mainline card" and every option offered was a square in the
|
// REPORTED TWICE. First: the card says "any Mainline card" and every option offered was a
|
||||||
// Office Area. `checkEnhancementPlacement` reads `placement.col` as a Division NODE index for a
|
// square in the Office Area, because the candidate list handed grid cells to a check that read
|
||||||
// mainline-card enhancement, while the candidate list handed it occupied grid cells — so "(0,1)"
|
// the column as a Division node index.
|
||||||
// was accepted because node 1 happened to be a Mainline card. The label and the meaning were
|
//
|
||||||
// different things.
|
// Then the fix carried its own bug: a Mainline placement travelled as `{ row: -1, col: node }`,
|
||||||
|
// and row −1 is an ORDINARY DISTRICT ROW — the first one below the Running Track. So ABS
|
||||||
|
// Signals highlighted whichever district card sat at that column, and a real enhancement laid
|
||||||
|
// one row down was described as being out on the Mainline. A Mainline placement now carries a
|
||||||
|
// node and no coordinate at all, which is what this asserts.
|
||||||
const game = newGame(775569289);
|
const game = newGame(775569289);
|
||||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||||
for (const [id, c] of game.state.cards) {
|
for (const [id, c] of game.state.cards) {
|
||||||
@@ -1433,7 +1637,8 @@ describe('the static build', () => {
|
|||||||
assert.ok(spots.length > 0, 'ABS Signals was not offered anywhere');
|
assert.ok(spots.length > 0, 'ABS Signals was not offered anywhere');
|
||||||
for (const sp of spots) {
|
for (const sp of spots) {
|
||||||
assert.match(sp.label, /out on the Mainline/, `not a Mainline card: ${sp.label}`);
|
assert.match(sp.label, /out on the Mainline/, `not a Mainline card: ${sp.label}`);
|
||||||
const node = game.state.division.nodes[sp.coord.col];
|
assert.equal(sp.coord, null, 'a Mainline placement must not claim a square in the district');
|
||||||
|
const node = game.state.division.nodes[sp.node!];
|
||||||
assert.equal(node?.kind, 'mainline', `spot points at a ${node?.kind}, not a Mainline card`);
|
assert.equal(node?.kind, 'mainline', `spot points at a ${node?.kind}, not a Mainline card`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1468,6 +1673,41 @@ describe('the static build', () => {
|
|||||||
assert.match(svg, /cab</, 'the caboose is not named on the board');
|
assert.match(svg, /cab</, 'the caboose is not named on the board');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws the train the way it stands on the map — nose toward the way it faces', () => {
|
||||||
|
/**
|
||||||
|
* REPORTED at seed 270861860: Train 10 running EAST arrived at the Whistle Post drawn engine
|
||||||
|
* first at the WEST end, with the caboose at the east — which reads as an engine shoving its
|
||||||
|
* whole train ahead of it. "I'd expect it to look just like this, except with the engine and
|
||||||
|
* caboose reversed so that the engine is the eastmost of the train."
|
||||||
|
*
|
||||||
|
* `consist` is ordered nose first, and the renderer drew index 0 leftmost whatever the train was
|
||||||
|
* doing. A westbound train therefore came out right and an eastbound one came out mirrored.
|
||||||
|
*/
|
||||||
|
const game = newGame(555);
|
||||||
|
const area = game.state.officeAreas.get(0)!;
|
||||||
|
const order = (facing: 'e' | 'w'): string[] => {
|
||||||
|
game.state.trays.set('crew', {
|
||||||
|
id: 'crew', trainNumber: 10, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [
|
||||||
|
{ type: 'hopper', loaded: true },
|
||||||
|
{ type: 'hopper', loaded: false },
|
||||||
|
{ type: 'caboose', loaded: false },
|
||||||
|
],
|
||||||
|
direction: facing === 'e' ? 'east' : 'west', facing,
|
||||||
|
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
|
||||||
|
} as never);
|
||||||
|
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
|
||||||
|
const svg = officeSvg([cell], area.runningRow);
|
||||||
|
// The three-letter labels in the order they are drawn, west to east.
|
||||||
|
return [...svg.matchAll(/class="bs-tcarlab"[^>]*>([^<]+)</g)].map((m) => m[1]!);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nose first is engine, hopper, hopper, caboose. Running west the engine leads at the west end;
|
||||||
|
// running east the same train is drawn the other way round.
|
||||||
|
assert.deepEqual(order('w'), ['◀', 'hop', 'hop', 'cab'], 'a westbound train is drawn backwards');
|
||||||
|
assert.deepEqual(order('e'), ['cab', 'hop', 'hop', '▶'], 'an eastbound train is not turned round');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows A/D tracks on the Office card, taken and total', () => {
|
it('shows A/D tracks on the Office card, taken and total', () => {
|
||||||
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
|
// 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).
|
||||||
@@ -1702,6 +1942,24 @@ describe('the Division map shows the whole route', () => {
|
|||||||
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`);
|
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`);
|
||||||
assert.match(svg, /west end · in and out/, `${players}p does not label the west end`);
|
assert.match(svg, /west end · in and out/, `${players}p does not label the west end`);
|
||||||
assert.match(svg, /east end · in and out/, `${players}p does not label the east end`);
|
assert.match(svg, /east end · in and out/, `${players}p does not label the east end`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AND THE LABELS MUST BE ON THE CANVAS.
|
||||||
|
*
|
||||||
|
* REPORTED: both were clipped. They hung off the OUTSIDE of the end cells, anchored away from
|
||||||
|
* the board, so fitting them meant padding wider than the caption — which the board did not
|
||||||
|
* have, and which would have spent that width on two captions rather than on the map. Centred
|
||||||
|
* under their own Division Point they cost nothing and cannot be cut off.
|
||||||
|
*/
|
||||||
|
const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg);
|
||||||
|
const W = Number(vb![1]);
|
||||||
|
const ends = [...svg.matchAll(/class="bs-end" x="([\d.]+)" y="([\d.]+)" text-anchor="middle">([^<]+)</g)];
|
||||||
|
assert.equal(ends.length, 2, `${players}p: an end label is not centred under its cell`);
|
||||||
|
for (const e of ends) {
|
||||||
|
const x = Number(e[1]);
|
||||||
|
const half = e[3]!.length * 3; // ~6px a character in the 10px monospace this uses
|
||||||
|
assert.ok(x - half >= -24 && x + half <= W + 24, `${players}p: "${e[3]}" is clipped at x=${x} of ${W}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1740,7 +1998,9 @@ describe('every square the menu offers can actually be clicked (regression)', ()
|
|||||||
const f = view(game);
|
const f = view(game);
|
||||||
|
|
||||||
for (const item of menu.placeable.flatMap((g) => g.items)) {
|
for (const item of menu.placeable.flatMap((g) => g.items)) {
|
||||||
const uniq = [...new Map(item.spots.map((sp) => [`${sp.coord.row},${sp.coord.col}`, sp.coord])).values()];
|
// Only the spots that ARE on this board: a Mainline placement has no square to draw.
|
||||||
|
const onBoard = item.spots.flatMap((sp) => (sp.coord === null ? [] : [sp.coord]));
|
||||||
|
const uniq = [...new Map(onBoard.map((c) => [`${c.row},${c.col}`, c])).values()];
|
||||||
const ghosts = uniq.filter((c) => !f.cells.some((x) => x.row === c.row && x.col === c.col));
|
const ghosts = uniq.filter((c) => !f.cells.some((x) => x.row === c.row && x.col === c.col));
|
||||||
const svg = officeSvg(f.cells, f.runningRow, ghosts);
|
const svg = officeSvg(f.cells, f.runningRow, ghosts);
|
||||||
const vb = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);
|
const vb = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);
|
||||||
|
|||||||
Reference in New Issue
Block a user