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

History

@@ -192,6 +219,15 @@ ul.blocked li{padding:2px 0}

Timetable — who is due out, and when

+ +
+ Train due + this Stage + already gone + just rolled +
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.

Blocked — why nothing is moving

    diff --git a/test/apply.test.ts b/test/apply.test.ts index b48265c..96e39da 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -991,3 +991,93 @@ describe('the Crew Tray is a train, and must be made up to leave (§8.2, Appendi assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }), 'CONSIST_EMPTY'); }); }); + +// --------------------------------------------------------------------------- + +describe('only the train being made up may take cars (§7)', () => { + /** A tray with a short consist, placed wherever the test needs it. */ + function trayAt(s: GameState, position: CrewTray['position']): void { + s.clock.phase = 'newTrain'; + s.trays.set('t', { + id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0, + consist: [], direction: 'west', position, movesUsed: 0, + }); + } + + const addHopper: Intent = { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }; + + it('accepts a car for a train standing at a Division Point', () => { + const s = game(); + trayAt(s, { at: 'divisionPoint', side: 'east' }); + assert.equal(check(s, 0, addHopper), null); + }); + + it('refuses to load a train standing in the district', () => { + // The New Train phase fills the train it is WAITING on — `trainNeedingCars` requires a Division + // Point. `check` did not, so the Division Yard would hand cars to a train sitting on a siding + // in your own district: cars appeared on a train nobody was making up. + const s = game(); + trayAt(s, { at: 'grid', owner: 0, coord: at(0, 1) }); + assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP'); + }); + + it('refuses to load a train out on the Mainline', () => { + const s = game(); + trayAt(s, { at: 'mainline', index: 1 }); + assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP'); + }); + + it('refuses to load a train whose consist is already what its card calls for', () => { + // Train 9 is freight 3 + caboose 1. A full train is not the one being made up, even standing + // at a Division Point — otherwise the phase and `check` disagree about who is being filled. + const s = game(); + trayAt(s, { at: 'divisionPoint', side: 'east' }); + const tray = s.trays.get('t')!; + tray.consist = [ + { type: 'hopper', loaded: true }, + { type: 'hopper', loaded: true }, + { type: 'hopper', loaded: true }, + { type: 'caboose', loaded: true }, + ]; + assert.equal(check(s, 0, addHopper), 'NOT_BEING_MADE_UP'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('an Office upgrade keeps what Modifiers added (§9)', () => { + it('does not wipe a Restaurant when the Depot becomes a Station', () => { + // The upgrade wrote the tier's printed numbers straight over the facility, so a Restaurant + // played beside a Depot — +1 passenger out, +1 porter — was silently deleted by the next + // upgrade. The card had been spent and the effect vanished with no message. + const s = game(); + const office = () => areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!.facility!; + + const play = (tier: 'depot' | 'station'): void => { + for (const [id, card] of s.cards) { + if (card.kind.kind !== 'office' || card.kind.tier !== tier) continue; + s.decks.hands.set(0, [id]); + s.turn.option = null; + applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' }); + const r = applyIntent(s, 0, { type: 'card.play', cardId: id }); + assert.ok(r.ok, `${tier} upgrade should apply`); + return; + } + throw new Error(`no ${tier} card`); + }; + + play('depot'); + const base = { out: office().capacity.outbound, porters: office().porters }; + + // A Restaurant beside the Office: +1 passenger out, +1 porter. + office().capacity.outbound += 1; + office().porters += 1; + + play('station'); + + const station = { out: 2, porters: 2 }; // Station's printed numbers + assert.equal(office().capacity.outbound, station.out + 1, 'the Modifier survives the upgrade'); + assert.equal(office().porters, station.porters + 1, 'the porter survives the upgrade'); + assert.ok(office().capacity.outbound > base.out, 'and the upgrade still raised the tier'); + }); +}); diff --git a/test/track.test.ts b/test/track.test.ts index ffb6382..1943438 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -22,6 +22,7 @@ import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts'; import { allReachable, canDropCarsAt, + exploreMoves, canPlaceAt, carriesThroughTrack, 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, 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`, + ); + } + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts index 2b0b1ec..414d3b5 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path'; import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts'; import { variantsFor } from '../src/engine/track.ts'; import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts'; -import { timetableHtml } from '../src/web/panels.ts'; +import { facilitiesHtml, timetableHtml } from '../src/web/panels.ts'; import { record, renderHtml } from '../src/sim/replay.ts'; import { snapshot } from '../src/sim/view.ts'; import { createGame as createEngineGame } from '../src/engine/setup.ts'; @@ -28,6 +28,7 @@ import { newGame, submit, toSave, + undo, view, } from '../src/web/game.ts'; @@ -253,7 +254,7 @@ describe('the action menu presents choices the way they are made', () => { const spots = actionMenu(game) .placeable.flatMap((g) => g.items) .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.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'); }); + 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"[^>]*>(?: 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]*>L 1\/1 { + // 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 { // `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. @@ -517,13 +616,21 @@ describe('the board draws the printed card (docs/tracks.png)', () => { }); describe('board highlighting', () => { - it('gives every spot a coordinate to highlight', () => { + it('gives every spot a coordinate to highlight, or says it is not on this board', () => { const game = newGame(555); submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!); const items = actionMenu(game).placeable.flatMap((g) => g.items); assert.ok(items.length > 0); for (const it of items) { for (const sp of it.spots) { + // A spot with no coordinate is a placement out on the Mainline, and it must say so — the + // board cannot highlight it, so the label is the only thing telling the player where it + // goes. It used to carry the fake coordinate (-1, node), which is a real district row. + if (sp.coord === null) { + assert.equal(typeof sp.node, 'number', `${it.subject} has neither a coordinate nor a node`); + assert.match(sp.label, /out on the Mainline/, `${it.subject}: no coordinate and no explanation`); + continue; + } assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`); assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`); assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree'); @@ -559,7 +666,7 @@ describe('board highlighting', () => { const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots); assert.ok( - spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0), + spots.some((sp) => sp.coord !== null && (sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0)), 'no legal spot lies outside the current cards — the bounds test would be vacuous', ); }); @@ -753,7 +860,7 @@ describe('the page explains itself', () => { .placeable.flatMap((g) => g.items) .filter((it) => it.subject.includes('turnout')) .flatMap((it) => it.spots) - .filter((sp) => sp.coord.row === under.row && sp.coord.col === under.col); + .filter((sp) => sp.coord?.row === under.row && sp.coord.col === under.col); assert.equal(spots.length, 1, 'exactly one turnout rotation should meet the leg coming down'); assert.match(spots[0]!.label, /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. const perSquare = new Map(); for (const sp of item.spots) { + if (sp.coord === null) continue; const k = `${sp.coord.row},${sp.coord.col}`; perSquare.set(k, (perSquare.get(k) ?? 0) + 1); } @@ -917,6 +1025,61 @@ describe('replays are saves', () => { assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs'); }); + it('takes the last action back, and keeps taking', () => { + /** + * "Is there a way to remove a card that has been misplayed? In a solitaire game it might be + * nice to say oops, take that back." + * + * The save IS the game, so undo is a replay without the last intent — which means the position + * it lands on must be EXACTLY the position that was there before, board, Division, clock and + * revenue. Anything less and undo becomes its own source of divergence. + */ + const game = newGame(430); + const marks: { cells: unknown; division: unknown; revenue: number; day: number; stage: number }[] = []; + const mark = (g: typeof game) => ({ + cells: view(g).cells, + division: view(g).division, + revenue: g.state.players[0]!.revenue, + day: g.state.clock.day, + stage: g.state.clock.stage, + }); + + for (let i = 0; i < 60; i++) { + if (currentActor(game) === null) break; + const { options } = actionGroups(game); + if (options.length === 0) break; + marks.push(mark(game)); + if (!submit(game, options[0]!)) break; + } + assert.ok(marks.length > 20, 'the game did not get far enough to prove anything'); + + // Walk all the way back, checking each step lands on the position it came from. + let now = game; + for (let i = marks.length - 1; i >= 0; i--) { + const back = undo(now); + assert.ok(back, `undo refused with ${now.history.length} moves still on the clock`); + now = back; + assert.deepEqual(mark(now), marks[i], `undo ${marks.length - i} landed somewhere else`); + } + assert.equal(now.history.length, 0, 'walking all the way back did not empty the history'); + assert.equal(undo(now), null, 'undo must refuse when there is nothing to take back'); + }); + + it('leaves nothing in the log describing a move that was taken back', () => { + // The history panel is rebuilt with the game, so it cannot be left narrating an action that no + // longer happened — the single most confusing thing a half-undo could do. + const game = newGame(202); + for (let i = 0; i < 30; i++) { + if (currentActor(game) === null) break; + const { options } = actionGroups(game); + if (options.length === 0 || !submit(game, options[0]!)) break; + } + const before = game.log.length; + const back = undo(game)!; + assert.ok(back.log.length <= before, 'the log grew when a move was taken back'); + assert.deepEqual(back.log, fromSave(toSave(back)).log, 'the rebuilt log is not the replayed log'); + }); + it('stays small enough to email', () => { const game = newGame(202); for (let i = 0; i < 400; i++) { @@ -1393,6 +1556,43 @@ describe('the static build', () => { assert.ok(worst <= 8, `the action list still reaches ${worst} buttons`); }); + it('makes up ONE train at a time, and names that train', () => { + // REPORTED: the history said Train 9 was being made up at the East Division Point while the + // panel above it read "Making up Train 10 — 18 kinds it may take are highlighted". Two trains + // can be built in the same Stage (a timetabled train plus a Second Section or an Extra), and + // every option from every tray was collected into one panel titled with whichever tray came + // first out of the map. Reproduced at seed 99, Day 3 Stage 12: eighteen chips, two trays. + // + // The panel must belong to exactly one tray — otherwise clicking a car in the yard couples it + // to whichever train owns the first matching option, which may not be the one named. + for (const seed of [99, 430, 270861860]) { + const game = newGame(seed); + for (let i = 0; i < 600 && currentActor(game) !== null; i++) { + const menu = actionMenu(game); + if (menu.makeUp) { + const trays = new Set( + menu.makeUp.cars.map((c) => (menu.options[c.index] as { trayId: string }).trayId), + ); + if (menu.makeUp.pass !== null) { + trays.add((menu.options[menu.makeUp.pass] as { trayId: string }).trayId); + } + assert.ok(trays.size <= 1, `seed ${seed}: the make-up panel covers ${trays.size} trains at once`); + for (const t of trays) { + assert.equal(t, menu.makeUp.trayId, `seed ${seed}: a car belongs to a train the panel does not name`); + } + const tray = game.state.trays.get(menu.makeUp.trayId); + assert.ok(tray, `seed ${seed}: the panel names a tray that does not exist`); + assert.ok( + menu.makeUp.title.includes(String(tray.trainNumber)), + `seed ${seed}: "${menu.makeUp.title}" does not name Train ${tray.trainNumber}`, + ); + } + const { options } = actionGroups(game); + if (options.length === 0 || !submit(game, options[0]!)) break; + } + } + }); + it('keys each make-up car to the yard chip that shows it', () => { // Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing // exactly those cars by type and load state. The yard is the surface. @@ -1416,11 +1616,15 @@ describe('the static build', () => { }); 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 - // Office Area. `checkEnhancementPlacement` reads `placement.col` as a Division NODE index for a - // mainline-card enhancement, while the candidate list handed it occupied grid cells — so "(0,1)" - // was accepted because node 1 happened to be a Mainline card. The label and the meaning were - // different things. + // REPORTED TWICE. First: the card says "any Mainline card" and every option offered was a + // square in the Office Area, because the candidate list handed grid cells to a check that read + // the column as a Division node index. + // + // Then the fix carried its own bug: a Mainline placement travelled as `{ row: -1, col: node }`, + // and row −1 is an ORDINARY DISTRICT ROW — the first one below the Running Track. So ABS + // Signals highlighted whichever district card sat at that column, and a real enhancement laid + // one row down was described as being out on the Mainline. A Mainline placement now carries a + // node and no coordinate at all, which is what this asserts. const game = newGame(775569289); submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!); for (const [id, c] of game.state.cards) { @@ -1433,7 +1637,8 @@ describe('the static build', () => { assert.ok(spots.length > 0, 'ABS Signals was not offered anywhere'); for (const sp of spots) { assert.match(sp.label, /out on the Mainline/, `not a Mainline card: ${sp.label}`); - 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`); } }); @@ -1468,6 +1673,41 @@ describe('the static build', () => { assert.match(svg, /cab { + /** + * 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"[^>]*>([^<]+) m[1]!); + }; + + // Nose first is engine, hopper, hopper, caboose. Running west the engine leads at the west end; + // running east the same train is drawn the other way round. + assert.deepEqual(order('w'), ['◀', 'hop', 'hop', 'cab'], 'a westbound train is drawn backwards'); + assert.deepEqual(order('e'), ['cab', 'hop', 'hop', '▶'], 'an eastbound train is not turned round'); + }); + it('shows A/D tracks on the Office card, taken and total', () => { // REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that // decides whether the next arrival is an automatic collision (§8.3). @@ -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.match(svg, /west end · in and out/, `${players}p does not label the west end`); assert.match(svg, /east end · in and out/, `${players}p does not label the east end`); + + /** + * AND THE LABELS MUST BE ON THE CANVAS. + * + * REPORTED: both were clipped. They hung off the OUTSIDE of the end cells, anchored away from + * the board, so fitting them meant padding wider than the caption — which the board did not + * have, and which would have spent that width on two captions rather than on the map. Centred + * under their own Division Point they cost nothing and cannot be cut off. + */ + const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg); + const W = Number(vb![1]); + const ends = [...svg.matchAll(/class="bs-end" x="([\d.]+)" y="([\d.]+)" text-anchor="middle">([^<]+)= -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); for (const item of menu.placeable.flatMap((g) => g.items)) { - const uniq = [...new Map(item.spots.map((sp) => [`${sp.coord.row},${sp.coord.col}`, sp.coord])).values()]; + // Only the spots that ARE on this board: a Mainline placement has no square to draw. + const onBoard = item.spots.flatMap((sp) => (sp.coord === null ? [] : [sp.coord])); + const uniq = [...new Map(onBoard.map((c) => [`${c.row},${c.col}`, c])).values()]; const ghosts = uniq.filter((c) => !f.cells.some((x) => x.row === c.row && x.col === c.col)); const svg = officeSvg(f.cells, f.runningRow, ghosts); const vb = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);