diff --git a/.gitignore b/.gitignore index 8b81af9..75225df 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,9 @@ __pycache__/ # one that is worth publishing. /playtests/* !/playtests/README.md + +# The Jitsi harness is NOT scrubbed — four of its files carry a real domain and real +# participant names (workspace AGENTS.local.md). Ignored so that a `git add -A` cannot sweep it +# into a history that would be permanently exposed if this repo is ever made public. When it is +# promoted in Phase 1, scrub it FIRST, then `git add -f tools/`. +tools/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4804f..5a7aada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,87 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.7.9.1 — 2026-09-07 + +Two playtest bugs from one session (seed 550943578). Both were reported as the game getting a rule +wrong; in both the engine was right and what failed was what the screen said about it. + +### The map drew westbound trains in the wrong half of the card (Gitea#22) + +Reported as a collision that hit the wrong train: "the display graphically showed train 17 further +west than train 5… I allowed train 3 and it collided w/train 17, not train 5 as expected." + +`regionOfTransit` answers **how far along its crossing a train is**, counted from the end it +entered — everything still to run is region 0. That is the question the collision rules ask, both +directions share the one index space, and it was correct throughout. The Division map was asking a +different question with the same number: **which printed box, left to right.** East is right on +this map (Gitea#18), so for an eastbound train the two coincide by luck — it enters at the west end, +so "just entered" and "leftmost box" are the same box. A westbound train enters at the **east** end, +so its region 0 is the right-hand box, and using the travel index directly drew the whole card +mirrored. + +Replayed from the attached save at intent 186, which is the position the ruling was made in: T5 was +a Stage from the far end (travel index 1) and TX17 had just entered behind it (index 0). Both +westbound, so TX17 was physically **east** of T5 — behind it, in the direction they had both come +from. The map drew TX17 at the left, which reads as further west, which reads as further ahead. Asked +whether Train 3 could follow Train 5 onto the card, the Superintendent said yes, and Train 3 entered +behind — into TX17, exactly where the rules had always had it. + +**So the fix is one mirror in `view.ts`, at the boundary the map is drawn from, and the collision +rules are untouched.** `regionOfTransit` keeps its meaning. This is the same class of bug as the +consist row at the Whistle Post (seed 270861860), which came out mirrored for the same reason: a +number that means "distance run" used where the screen means "place". + +The tooltip carried the same number and now says which way it is counted — "region 2 of 2, counted +west to east". Beside "2 Stages still to run" the bare number reads as a contradiction, and the +reporter quoted the tooltip as part of what misled them. + +Proved by mutation rather than by assertion: reverting the mirror fails two tests in +`mainline-cards.test.ts`, and making the renderer ignore the region fails the SVG placement test in +`web.test.ts`. The existing region tests all ran eastbound, where the mirror is the identity, which +is why the bug survived them. + +### A refusal the Blocked panel explained wrongly (Gitea#21) + +Reported as "could not drop second tank car at refinery… I dropped the first tank car, but that was +all I was allowed to do." + +Replayed from the attached save: the crew was **Train 3**, and the engine's answer was +`FREIGHT_WORKED_HERE`. Train 3 is the Express, which prints *"May drop or pick up one freight car at +every location"* — **the refusal was correct**, and the budget is per location rather than per turn, +so the same train may work another car at the next square it reaches. No rule changed. + +What failed is that nothing said so. The player checked "Blocked — why nothing is moving" and got +`refinery 1,0 — green box empty — nothing to load (needs a Freight Agent action)`. That is a true +statement about the facility and has nothing to do with why the drop was refused — so it sent them +to spend a Freight Agent action that could not have helped. **A panel that answers the wrong +question is worse than one that stays silent**, because it looks like an answer. The rule was on the +train card's own tooltip, which is not where anyone looks when a button they expected is absent. + +The panel now names it, and only when the crew still has a freight car it could otherwise set out — +a spent budget on a train with nothing to drop is blocking nothing, and this panel earns its keep by +staying short enough to read. It asks `freightBudgetLeft`, the same predicate the reducer refuses +on, through a new exported `freightRuleSpentHere`, so the words cannot drift from the rule. + +Against the reporter's own save the panel now reads: + +``` +[waiting] refinery 1,0 — green box empty — nothing to load (needs a Freight Agent action) +[waiting] Train 3 at (0,1) — ONE FREIGHT CAR PER LOCATION — this train has already worked a + freight car on this square, so no more come off or on here until next turn. +``` + +### Both were verified against the saves attached to the issues + +Not against a reconstruction. Each save was replayed through `fromSave` to the exact intent the +report names, and the fix checked in that state — which is what the process item added after v0.7.5 +through v0.7.8 asks for, three releases that each reported the same bug fixed and each fixed +something that was not the reported fault. + +891 tests pass, up from 884. + +--- + ## 0.7.9 — 2026-08-30 Four pieces of feedback on the solitaire setup screen, plus the rules bug the second one exposed. diff --git a/TODO.md b/TODO.md index 4c59d22..efb5ede 100644 --- a/TODO.md +++ b/TODO.md @@ -43,13 +43,21 @@ Not items. Things that are true of every change, and that have gone wrong when s - **Keep `CHANGELOG.md` current in the same change.** Commit messages stay high level; the reasoning, the measurements and the things that turned out to be wrong live there. -30. **Close each Gitea issue by hand, with a comment naming the commit that fixed it** and, where the - fix was not what the report implied, the ruling that decided it. Auto-closing leaves an issue with - no record of which commit or which release answered it. Done for every issue closed so far: #2, - #6, #8, #9 and #10 from v0.7.1; #3, #14, #15, #17 and #18 from v0.7.2 — including #15's REVERSAL - on review (the placement is legal; what was confirmed is that no train crosses the gap) and #14's - list of the ten unbuilt cards, so they do not vanish with the issue. The token and the API calls - are in the workspace's `AGENTS.local.md`. +30. **Close a Gitea issue with `Closes #` in the commit — not by hand** (Jesse, 2026-08-30). + Gitea records the linking commit itself when it auto-closes, so a manual close adds nothing but a + step, and the same rule is what the workspace `AGENTS.md` already prescribes. + + **What auto-closing does NOT carry is a RULING, and that still has to be written by hand.** When + the fix was not what the report implied, comment on the issue before it closes, saying what was + decided and why. That is the half worth keeping: Gitea#15 was REVERSED on review — the placement + is legal, and what was actually confirmed is that no train crosses the gap — and Gitea#14's + comment lists the ten unbuilt cards so they do not vanish along with the issue. A bare + `Closes #` would have lost both. + + This item said the opposite until 2026-08-30 ("close each issue by hand… auto-closing leaves no + record"), which was written from how the issues closed in v0.7.1 and v0.7.2 rather than from a + decision. The token and the API calls, for reading issues and for leaving a ruling, are in the + workspace's `AGENTS.local.md`. 37. **The wrapper release sequence, unchanged since 0.7.2 and worth following exactly.** Tag the app, fetch the tag into the wrapper's submodule, bump `current.ts` **in place** (the outgoing `up` has @@ -1721,11 +1729,33 @@ re-verified turn out to have been re-verified against a premise rather than agai Closed items, kept because several are the only record of a ruling or a lesson. Newest first within each group. -### Shipped through v0.7.9, from the queue +### Shipped through v0.7.9.1, from the queue Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson; the numbers stay so cross-references above and below still resolve. +89. ~~**The map drew westbound trains in the wrong half of a Mainline card.**~~ — done 2026-09-07 in + v0.7.9.1, Gitea#22. `regionOfTransit` counts from the end a train ENTERED, which is what the + collision rules want; the map wanted "which printed box, left to right" and used the same number, + so every westbound card came out mirrored. It cost a collision: the Superintendent cleared Train 3 + to follow T5 and it ran into TX17, which the picture had drawn ahead of T5 instead of behind it. + **Worth knowing:** the engine was right and only the picture lied, so the fix is one mirror in + `view.ts` at the drawing boundary — and every existing region test ran eastbound, where the mirror + is the identity, which is exactly why it survived them. The same shape as the mirrored consist row + at the Whistle Post (seed 270861860): a number meaning "distance run" used where the screen means + "place". **Ask of any new Frame field whether it is a distance or a position.** + +90. ~~**The Blocked panel explained a refusal by describing something else entirely.**~~ — done + 2026-09-07 in v0.7.9.1, Gitea#21. A second tank car would not come off at a refinery; the panel + said the refinery's green box was empty. The real answer was Train 3's printed rule — the Express + may work one freight car per location — so the refusal was correct and the panel sent the player + to spend a Freight Agent action that could not have helped. **The ruling: no rule changed, only + what the screen says about it.** The panel asks the reducer's own `freightBudgetLeft` through a + new exported `freightRuleSpentHere`, so its words cannot drift from the rule. **Worth knowing:** + a panel that answers the wrong question is worse than one that stays silent, because it looks + like an answer — and a rule that only lives on a card tooltip is invisible at the moment a player + notices a button is missing. + 43. ~~**"Waiting on" said nobody while the game was stopped on the Superintendent.**~~ — done 2026-08-30 in v0.7.9. `Frame.actor` carried `clock.currentActor`, null for the whole Mainline Phase, so all three interruptions (clearance, Yard Office, Red Flag) reported that the Division diff --git a/package.json b/package.json index 9e2bbb7..9c3490a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.9", + "version": "0.7.9.1", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 99d45e7..d39650d 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -607,7 +607,8 @@ function freightWorkedKey(trayId: TrayId, at: GridCoord): string { return `${trayId}@${coordKey(at)}`; } -const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose'; +/** Exported so the Blocked panel counts a freight car the same way the reducers do (Gitea#21). */ +export const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose'; /** * IS THIS CAR CARRYING A LOAD? A CABOOSE NEVER IS, whatever its `loaded` flag says. @@ -657,6 +658,20 @@ function switchingRefusal(tray: CrewTray): RejectionCode | null { return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null; } +/** + * WHETHER TRAINS 3/4's PRINTED RULE IS WHAT IS STOPPING THIS CREW WHERE IT STANDS (Gitea#21). + * + * Exported for the Blocked panel, which needs to say so — and asks `freightBudgetLeft`, the same + * predicate the reducer refuses on, rather than rebuilding the key for itself. `narrate.ts` cannot + * then drift from the rule it is describing, which is the whole premise of that panel. + */ +export function freightRuleSpentHere(s: GameState, player: PlayerIndex, trayId: TrayId): boolean { + const tray = s.trays.get(trayId); + if (!tray || !rulesOf(tray).oneFreightPerLocation) return false; + if (tray.position.at !== 'grid') return false; + return !freightBudgetLeft(s, player, tray, tray.position.coord, 1); +} + /** * Charge freight cars against this train's per-location budget (trains 3/4). * diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index f226261..8667527 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -452,6 +452,14 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul * things the Division map is for — where a train is and which way it is going — and leaves the * cars to the tooltip and to the district. */ + /** + * THE REGION NUMBER IS A PLACE ON THE MAP, NOT A DISTANCE RUN (Gitea#22). + * + * `view.ts` mirrors a westbound train before it gets here, so this counts boxes west to east + * for every train regardless of which way it is going — and the tooltip says so, because + * "region 2 of 2" beside "2 Stages still to run" reads as a contradiction otherwise. It is the + * second box from the west end; a westbound train in it has its whole crossing ahead of it. + */ const chip = (t: NonNullable[number], tx: number, ty: number, w: number): void => { const cars = t.cars ?? []; const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6'; @@ -465,7 +473,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul : ''; out += ` 0) { for (const [id, tray] of s.trays) { diff --git a/src/sim/view.ts b/src/sim/view.ts index 755ce68..3618482 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -48,7 +48,7 @@ import { import type { Intent } from '../engine/intents.ts'; import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts'; import { actingPlayer, carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts'; -import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts'; +import type { Direction, Hand, HouseRules, TrackGeometry } from '../engine/content.ts'; import type { Port } from '../engine/track.ts'; import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts'; import type { Impediment } from './narrate.ts'; @@ -1259,10 +1259,31 @@ export function snapshot( * one per Stage — and the entry point is what the rules actually move. There is nothing left * to reconstruct. */ - // One region per Stage, straight off the card's own count: what a train has LEFT to run says - // where it is standing. `regionOfTransit` is the engine's own answer, so the picture and the - // collision rule cannot disagree about who is where. - const place = (t: { stagesRemaining: number }): number => regionOfTransit(n.card, t.stagesRemaining); + /** + * AND WHICH WAY IT CAME IN (Gitea#22). `regionOfTransit` counts from the end the train + * ENTERED — everything still to run is region 0 — and both directions share that one index + * space, which is what the collision rules want and why the engine asks it directly. + * + * The map is asking a different question: which printed box, LEFT TO RIGHT. East is right + * here and always has been, so for an eastbound train the two questions have the same answer + * by luck — it enters at the west end, so "just entered" and "leftmost box" coincide. A + * westbound train enters at the EAST end, so its region 0 is the right-hand box, and using + * the travel index directly drew the whole card mirrored. + * + * That cost a collision (seed 550943578, undo 187): a westbound TX17 that had just entered + * was drawn WEST of a westbound T5 that was nearly across, so the train physically behind + * appeared to be the one in front. Train 3 was cleared to follow T5 and ran into TX17 — + * where the rules had always had it. + * + * So the engine's index is turned into a place on the map here, once, at the boundary the + * map is drawn from. `regionOfTransit` keeps its meaning and the collision rules are + * untouched; only the picture changes. + */ + const place = (t: { stagesRemaining: number; direction: Direction }): number => { + const travelled = regionOfTransit(n.card, t.stagesRemaining); + const regions = mainlineProfile(n.card).regions; + return t.direction === 'west' ? regions - 1 - travelled : travelled; + }; return { kind: 'ml', label: name, diff --git a/test/mainline-cards.test.ts b/test/mainline-cards.test.ts index bd6718b..2e1a503 100644 --- a/test/mainline-cards.test.ts +++ b/test/mainline-cards.test.ts @@ -1014,7 +1014,11 @@ describe("a train is made up to its card's consist (§8.2)", () => { describe('regions on a Mainline card (§2.1, §8.2)', () => { /** Put one train mid-crossing and ask the view where the map should draw it. */ - const regionFor = (stagesTotal: number, stagesRemaining: number): { region: number; regions: number } => { + const regionFor = ( + stagesTotal: number, + stagesRemaining: number, + direction: 'east' | 'west' = 'east', + ): { region: number; regions: number } => { const s = createGame({ id: 'reg', seed: 4, @@ -1027,7 +1031,7 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => { const node = s.division.nodes.find((n) => n.kind === 'mainline'); assert.ok(node && node.kind === 'mainline'); const tray = [...s.trays.keys()][0]!; - node.transits.push({ tray, stagesRemaining, stagesTotal, direction: 'east' }); + node.transits.push({ tray, stagesRemaining, stagesTotal, direction }); const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml'); assert.ok(ml, 'no Mainline node in the view'); const t = ml!.trains.flat()[0]!; @@ -1070,6 +1074,74 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => { } } }); + + it('never leaves the card it is on, whichever way it runs', () => { + for (const direction of ['east', 'west'] as const) { + for (let total = 1; total <= 4; total++) { + for (let left = total; left >= 1; left--) { + const r = regionFor(total, left, direction).region; + assert.ok(r >= 0 && r <= 1, `${direction}, total ${total}, ${left} left put the train in region ${r}`); + } + } + } + }); + + /** + * GITEA#22 — A WESTBOUND TRAIN WAS DRAWN IN THE WRONG HALF OF THE CARD. + * + * `regionOfTransit` answers "how far along its crossing is this train", counted from the end it + * ENTERED: a train with everything still to run is in region 0. That is the right question for the + * collision rules, which is what the engine asks it, and both directions share the one index space. + * + * The map asks a different question — WHICH PRINTED BOX, left to right — and used the same number + * for it. East is right on this map and always has been, so for an eastbound train the two agree by + * luck: it enters at the west end, so "just entered" and "leftmost box" are the same box. A + * westbound train enters at the EAST end, so its region 0 is the card's RIGHT-hand box, and drawing + * it at index 0 put it at the left — the whole card mirrored. + * + * Reported from seed 550943578, undo 187, and it cost a collision. Three westbound trains: TX17 had + * just entered (2 Stages still to run, so travel index 0) and T5 was nearly across (1 Stage left, + * index 1). Physically TX17 was BEHIND T5 — further east, the direction they had both come from. + * The map drew TX17 at the left and so put it further WEST, which reads as further ahead. Asked + * whether Train 3 could follow Train 5 onto the card, the Superintendent said yes, and Train 3 + * entered behind — into TX17, exactly where the rules had it and nowhere near where the map did. + * + * The engine was right throughout. Only the picture lied, so the fix is one mirror in the view and + * the collision rules are untouched. This is the same class of bug as the consist row at the + * Whistle Post (`board-svg.ts`, seed 270861860), which came out mirrored for the same reason. + */ + describe('Gitea#22 — the map draws a westbound train where it actually is', () => { + it('mirrors a westbound train, because it entered from the east end', () => { + // Two-region card. Just entered, 2 Stages still to run: an eastbound train is in the WEST box + // and a westbound one is in the EAST box, because they came in at opposite ends. + assert.equal(regionFor(2, 2, 'east').region, 0); + assert.equal(regionFor(2, 2, 'west').region, 1); + + // One Stage left, nearly across: the two swap. + assert.equal(regionFor(2, 1, 'east').region, 1); + assert.equal(regionFor(2, 1, 'west').region, 0); + }); + + it('puts the follower behind the leader, not in front of it — the seed 550943578 collision', () => { + // TX17 had just entered; T5 was a Stage from the far end. Both westbound, so BEHIND means to + // the east, which is to the right, which is the higher index. + const tx17 = regionFor(2, 2, 'west').region; + const t5 = regionFor(2, 1, 'west').region; + assert.ok( + tx17 > t5, + `a westbound train that has just entered must be drawn east of one that is nearly across, ` + + `but TX17 was drawn at ${tx17} and T5 at ${t5}`, + ); + }); + + it('leaves an eastbound train where it has always been drawn', () => { + // The mirror must not disturb the direction that was right, which is every existing region test + // above — those all run east — and the case the printed rule was written for. + assert.equal(regionFor(2, 2, 'east').region, 0); + assert.equal(regionFor(2, 1, 'east').region, 1); + assert.equal(regionFor(1, 1, 'east').region, 1); + }); + }); }); describe('Q13 — a train that catches the one ahead runs into it', () => { diff --git a/test/replay.test.ts b/test/replay.test.ts index 67e8a69..a84ca1c 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -13,8 +13,8 @@ import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiv import type { GameEvent } from '../src/engine/events.ts'; import { areaOf } from '../src/engine/apply.ts'; import { createGame } from '../src/engine/setup.ts'; -import { coordKey } from '../src/engine/state.ts'; -import type { GameConfig, GameState } from '../src/engine/state.ts'; +import { coordKey, turnOf } from '../src/engine/state.ts'; +import type { GameConfig, GameState, GridCoord } from '../src/engine/state.ts'; import { developerBot, playGame } from '../src/sim/bot.ts'; import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts'; import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts'; @@ -138,6 +138,89 @@ describe('impediments', () => { s.freeTrays = []; assert.ok(impediments(s, 0).some((b) => /HELD/.test(b.why))); }); + + /** A crew standing on `at`, working the given train, with one empty tank car on the drawbar. */ + const express = (s: GameState, trainNumber: number, at: GridCoord): string => { + const id = s.freeTrays.pop()!; + s.trays.set(id, { + id, trainNumber, trainIsExtra: false, engineAt: 0, + consist: [{ type: 'tank', loaded: false, origin: 0 }], + direction: 'east', position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0, + }); + return id; + }; + + /** + * GITEA#21 — THE GAME REFUSED, AND THE PANEL EXPLAINED SOMETHING ELSE. + * + * "I wanted to drop two empty tank cars so that the freight agents and men at work could load + * them later. I dropped the first tank car, but that was all I was allowed to do. Checked + * 'Blocked — why nothing is moving' and saw: refinery 1,0 — green box empty — nothing to load + * (needs a Freight Agent action)." + * + * Replayed from the attached save (seed 550943578, 181 intents): the crew was Train 3, and the + * engine's answer was `FREIGHT_WORKED_HERE`. Train 3 is the Express, and the Express prints "May + * drop or pick up one freight car at every location" — so THE REFUSAL WAS CORRECT and the rule + * is not what is wrong here. It resets next turn, and the Express may work a car at the next + * square this turn; that is what makes it an Express rather than a one-car-a-Stage train. + * + * What was wrong is that nothing said so. The panel whose entire job is "why is nothing moving?" + * listed the refinery's green box — a true statement about the FACILITY, and nothing to do with + * why the drop was refused — so the player was sent to fix a Freight Agent action that would not + * have helped. The rule was on the train card's own tooltip, which is not where somebody looks + * when a button they expected is missing. + * + * The panel is where a refusal gets explained, so the budget belongs in it. + */ + it('says when the Express has spent its one freight car on this square (Gitea#21)', () => { + const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] }); + const area = areaOf(s, 0); + const at = area.officeCoord; + + // A crew standing at the Office working Train 3 — the Express — with a tank car still on it. + const trayId = express(s, 3, at); + + s.clock.phase = 'localOps'; + const turn = turnOf(s, 0); + turn.option = 'switch'; + + // Nothing to say before it has worked anything here. + assert.ok( + !impediments(s, 0).some((b) => /FREIGHT CAR PER LOCATION/i.test(b.why)), + 'the budget was reported spent before the train had worked a car at all', + ); + + // Now it has set one car out here — exactly the state the save is in at intent 181. + turn.freightWorked[`${trayId}@${coordKey(at)}`] = 1; + + const row = impediments(s, 0).find((b) => /FREIGHT CAR PER LOCATION/i.test(b.why)); + assert.ok( + row, + `nothing explained the refusal:\n${JSON.stringify(impediments(s, 0), null, 2)}`, + ); + // It must name the train, or a player with three crews out cannot tell which one it means. + assert.match(row!.where, /Train 3/); + // And it must say the limit lifts, or it reads as "this train can never work here again". + assert.match(row!.why, /turn/i); + // Amber: this is the printed rule doing its job, not a fault. + assert.equal(row!.severity, 'waiting'); + }); + + it('leaves every other train alone — the rule is printed on 3 and 4 only (Gitea#21)', () => { + const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] }); + const area = areaOf(s, 0); + const at = area.officeCoord; + // Train 5 is The Sparrow, which prints no per-location freight limit. + const trayId = express(s, 5, at); + s.clock.phase = 'localOps'; + turnOf(s, 0).option = 'switch'; + turnOf(s, 0).freightWorked[`${trayId}@${coordKey(at)}`] = 1; + + assert.ok( + !impediments(s, 0).some((b) => /FREIGHT CAR PER LOCATION/i.test(b.why)), + 'a train with no such rule was told it had spent a budget it does not have', + ); + }); }); /** diff --git a/test/web.test.ts b/test/web.test.ts index 3c27b3c..90fa01c 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -3253,6 +3253,45 @@ describe('the Division map shows the whole route', () => { } } }); + + /** + * GITEA#22 — and the same invariant, applied to the trains standing on a card. + * + * The mirror itself is proved on the view in `mainline-cards.test.ts`. This is the other end of + * it: that the number the view hands over actually reaches the canvas as a position, so the chip + * a player looks at is on the correct half. The report was about the PICTURE, and a view that is + * right behind a renderer that ignores it would read to a player as no fix at all. + * + * Asserted on x, the way the Heavy Grade wedge above is: east is right on this map, so a + * westbound train that has just entered belongs to the RIGHT of one that is nearly across, and + * an eastbound pair in the same state belongs the other way round. + */ + it('draws a westbound train on the half of the card it is actually standing on (Gitea#22)', () => { + const card = (trains: { label: string; region: number }[]): DivisionView => + ({ + kind: 'ml', label: 'Curves', capacity: 1, modifiers: [], gradeUp: null, regions: 2, + what: 'two regions', + trains: [trains.map((t) => ({ ...t, cars: [], facing: 'w' }))], + } as unknown as DivisionView); + + const xOf = (svg: string, label: string): number => { + const m = new RegExp(`]*>${label}[^<]*<`).exec(svg); + assert.ok(m, `no chip drawn for ${label}`); + return Number(m![1]); + }; + + // The two regions the view now reports for the seed 550943578 card: TX17 had just entered + // westbound (the east box, index 1) and T5 was nearly across (the west box, index 0). + const svg = divisionSvg([card([{ label: 'TX17', region: 1 }, { label: 'T5', region: 0 }])]); + assert.ok( + xOf(svg, 'TX17') > xOf(svg, 'T5'), + 'the train that has just entered westbound was not drawn east of the one nearly across', + ); + + // And the halves are genuinely distinct — a renderer that centred both would satisfy nothing + // above but would still tell a player nothing. + assert.notEqual(xOf(svg, 'TX17'), xOf(svg, 'T5'), 'both chips were drawn at the same x'); + }); }); describe('every square the menu offers can actually be clicked (regression)', () => {