From e23d81eadea1bdcd0a03cde3ae81dfe0c6002e05 Mon Sep 17 00:00:00 2001 From: Jesse Date: Sat, 1 Aug 2026 15:15:28 -0400 Subject: [PATCH] Implement nose coupling so run-arounds work, and stop coupling wiping the district --- CHANGELOG.md | 38 ++++++++++++++++- README.md | 15 +++++-- TODO.md | 97 ++++++++++++++++++++++++++++++++++++++++++++ src/engine/apply.ts | 45 +++++++++++++++++--- src/engine/events.ts | 14 ++++++- src/sim/bot.ts | 64 ++++++++++++++++++++++++++++- src/sim/narrate.ts | 4 +- src/web/main.ts | 9 ++-- test/replay.test.ts | 2 +- test/sim.test.ts | 25 +++++++++--- test/track.test.ts | 64 +++++++++++++++++++++++++++++ test/web.test.ts | 12 +++++- 12 files changed, 366 insertions(+), 23 deletions(-) create mode 100644 TODO.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 631f049..41655f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,43 @@ The target is 20 Revenue over 5 Days. ## Unreleased -Nothing yet. +### Teaching the bot to use a siding + +**Nose coupling, which the rules had and the engine did not.** §A.3: "engines also have couplers on +the front end, so a train can pick cars up onto its nose". Coupling always appended to the back, so +which end cars landed on did not exist — and that is precisely what a run-around is for. Cars met +running FORWARD now couple in front, cars met BACKING couple behind, so the approach decides which +car is next off the tail: + + running forward -> reefer, boxcar, hopper next off: hopper + backing up -> boxcar, hopper, reefer next off: reefer + +The bot prefers a run-around to setting a car down, since it keeps the car — but only when the drop +can actually follow. Without that guard it ran the loop for its own sake (8 a game, 63 Moves), which +the shuttling regression correctly failed. + +**A serious bug found on the way.** The `carsCoupled` reducer cleared **every card in the Office +Area**, not the ones the crew ran over — its own comment said "every card along the path" while the +code iterated the whole grid. One coupling anywhere deleted every standing car and every industry +track in the district, so loads worked over several Stages vanished when a crew picked up an +unrelated boxcar somewhere else. The event now names the cards it lifted from. + +**A test replaced rather than relaxed.** "Under 60 Moves a game" started failing. That threshold was +calibrated when the crew coupled ~0.4 cars a game; it now couples ~8 and drops ~11, and a run-around +is *supposed* to cost several Moves. Counting Moves was always a proxy for aimlessness, so the test +now measures the thing itself — Moves per drop-or-coupling — which still fails when the bot is made +to wander. + +| | before sidings | now | +| --- | --- | --- | +| revenue | 3.9 | 3.2 | +| freight | 0.9 | 1.0 | +| drops per game | 3.3 | **11.1** | +| couplings per game | ~0.4 | **7.9** | + +Switching is transformed; revenue is not. The crew now does real work — roughly 4 Moves per +productive act, which is what running a loop costs — but that work is not yet converting into +Revenue. Where it goes next is an open question rather than a known fix. --- diff --git a/README.md b/README.md index ac9f79b..f32b4db 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,24 @@ single browser. ``` station-master/ +├── CHANGELOG.md ← what changed and why, in detail, commit to commit +├── TODO.md ← open questions, provisional numbers, things to come back to ├── docs/ │ ├── rules/ ← the ruleset, card reference, glossary, decision record -│ └── architecture/ ← how it is built, and what the pieces are +│ ├── architecture/ ← how it is built, and what the pieces are +│ └── design/ ← board layout studies and rendering samples +├── public/replays/ ← saved games published to the site's replay directory +├── scripts/ ← build and deploy the static site ├── src/ -│ └── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed +│ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed +│ ├── sim/ ← bot, harness, replay, board rendering +│ └── web/ ← the playable site: splash, game, replay viewer └── test/ ``` -Start with [`docs/design.md`](docs/design.md) — it indexes everything. +Start with [`docs/design.md`](docs/design.md) — it indexes everything. Commit messages stay high +level; [`CHANGELOG.md`](CHANGELOG.md) carries the reasoning and the measurements, and +[`TODO.md`](TODO.md) is what we have decided not to forget. ## Development diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..9dbf499 --- /dev/null +++ b/TODO.md @@ -0,0 +1,97 @@ +# To do + +Things worth coming back to. Anything noted here should either get done or get an explicit decision +not to — the point is that nothing quietly evaporates. + +Ordered within each section by how much it is currently costing us. + +--- + +## Next + +- [ ] **Find out why switching work does not become Revenue.** The crew now drops 11 cars a game and + couples 8 (was 3.3 and ~0.4), at about 4 Moves per productive act — real railroading. Revenue + did not move (3.9 → 3.2). Either the freight it is now shuffling is not the freight the + industries want, or the loads finish too late in the Day to be worked, or the Revenue is going + somewhere and being lost again. Measure the freight chain end to end before changing anything. + +--- + +## Open questions for Jesse + +Blocked on a decision, not on work. + +- [ ] **Q13 — rear-end collisions on a Mainline card.** §10 says a Mainline collision is the + Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders — + but §8.3's trigger list does not include one, and **none is implemented**. Granting clearance is + currently free: verified, both trains survive, no penalty. Three candidates: collide on entry + (clearance becomes a gamble), collide on catching up (rewards judging the gap), or accept that + clearance is safe and ABS Signals is worth less than it reads. The buttons currently describe + only what the engine does, so nothing promises a consequence that cannot happen. +- [ ] **Poling.** The only card in the deck with no defined behaviour — the sheet records its effect + as "TBD in the source". A test asserts it stays TBD so nobody invents one. +- [ ] **Heavy Grade orientation at setup.** The card says "Player sets orientation", but `createGame` + is synchronous and has no decision point, so it is currently rolled from the seed. Should become + a real choice when setup gains an interactive phase. + +--- + +## Balance, provisional + +Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory +target is settled and freight carries its intended share. + +- [ ] **Office card density** (Depot 4→8, Station 2→4, Terminal 1→2). Chosen to remove a 25% chance + of an unwinnable opening deal. Blunt: it lifts the whole ladder and dilutes every other + category. The better answer may be fewer Terminals, a cheaper first upgrade, or more A/D + capacity at the Whistle Post itself. +- [ ] **Industry density** (9 → 27, Gap 12). Restored roughly the prototype ratio; freight still only + 13–18% of gross revenue. +- [ ] **Train density.** Left alone by decision, but noted: 22 train cards in 140 are drawn less often + than 22 in 115 were, and trains scheduled fell 2.9 → 2.1 as a side effect of the other density + changes. +- [ ] **The victory target itself** (20 over 5 Days). Still only 1 win in 100. Worth revisiting once + the bot exploits sidings, since that is a known unclaimed gain. + +--- + +## Not yet built + +- [ ] **Action cards (10) and Space-use cards (12).** Genuinely multiplayer-only — they are played AT + an opponent. Rejected with `NOT_IMPLEMENTED`. +- [ ] **Multiplayer proper.** 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. + +--- + +## Smaller things + +- [ ] **Carry `links` forward in replay frames.** Static per card and never changes once laid, but + re-sent whenever anything on the board changes. The replay is 3.4 MB, mostly board state. +- [ ] **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. +- [ ] **Curves are drawn as two straight segments meeting**, not true arcs. Fine at this size, angular + close up. +- [ ] **Wide boards scroll.** A 40-card district and a 13-section Division both need horizontal + 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. + +--- + +## Done, kept for the reasoning + +- [x] **Teach the bot what a siding is for.** Nose coupling (§A.3) implemented, so approach direction + decides which car is droppable; the bot runs around rather than setting out, when the drop can + follow. Switching activity transformed, revenue unchanged. +- [x] **Curve geometry.** Curves were topologically identical duplicates of turnouts, and nothing + reached north, so a district could only be a vertical column. Now two-port rotatable arcs. +- [x] **Q10 — when track may be laid.** During the "draw a card" option, one piece a turn. Track was a + card when §6.2 was written; a 26-piece supply has no hand to bound it. +- [x] **Q11 — which way a Heavy Grade climbs.** Answered from the card: it prints "(Up)" and "Player + sets orientation", so it is a property of the placed card, not a compass constant. +- [x] **Q12 — Whistle Post lock-in.** Players always start at a Whistle Post; office density doubled + instead. diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 06a1023..f2c5566 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -689,7 +689,26 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { }, ]; if (dest.couples.length > 0) { - events.push({ type: 'carsCoupled', trayId: i.trayId, at: i.to, stock: dest.couples }); + // Name the cards the cars came from. The reducer used to clear every card in the district + // instead, so one coupling anywhere deleted every standing car and every industry track in + // the Office Area — loads worked over several Stages vanished when a crew picked up a single + // boxcar somewhere else entirely. + const lifted = [ + ...dest.path.map((step) => step.coord), + i.to, + ].filter((c) => carsOn(areaOf(s, player).grid.get(coordKey(c)) ?? emptyCard()).length > 0); + // §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto + // its nose". Running forward the engine meets cars head-on and takes them in front; backing + // up, they couple behind. Which end they land on is the whole point of a run-around: it + // decides which car is next to come off. + events.push({ + type: 'carsCoupled', + trayId: i.trayId, + at: i.to, + stock: dest.couples, + from: lifted, + toNose: !i.reverse, + }); } return events; } @@ -992,10 +1011,14 @@ export function reduce(s: GameState, e: GameEvent): void { case 'carsCoupled': { const tray = s.trays.get(e.trayId)!; const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0); - tray.consist.push(...e.stock); - // Every card along the path is cleared; the event carries what was taken. - for (const card of area.grid.values()) { - if (card.standing.length > 0) card.standing = []; + if (e.toNose) tray.consist.unshift(...e.stock); + else tray.consist.push(...e.stock); + // ONLY the cards the crew ran over. Clearing the whole grid emptied industry tracks the crew + // never went near. + for (const coord of e.from) { + const card = area.grid.get(coordKey(coord)); + if (!card) continue; + card.standing = []; if (card.facility) card.facility.industryTrack.cars = []; } break; @@ -1485,6 +1508,18 @@ export function checkEnhancementPlacement( } } +/** A stand-in with nothing on it, so a missing card reads as "no cars here" rather than throwing. */ +function emptyCard(): TrackCard { + return { + geometry: { kind: 'limits' }, + baseOperationalRail: false, + standing: [], + facility: null, + modifiers: [], + enhancements: [], + }; +} + /** Removes a played card from its owner's hand and sends it to the Salvage Yard. */ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void { s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId)); diff --git a/src/engine/events.ts b/src/engine/events.ts index ecb73ff..3b1b6e8 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -22,7 +22,19 @@ export type GameEvent = // -- local operations | { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption } | { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' } - | { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] } + | { + type: 'carsCoupled'; + trayId: TrayId; + at: GridCoord; + stock: RollingStock[]; + /** The cards the cars were lifted from — the ones the crew actually ran over. */ + from: GridCoord[]; + /** + * Coupled onto the ENGINE'S NOSE rather than behind the train (§A.3). True when the crew was + * running forward: the engine meets the cars head-on and takes them on the front. + */ + toNose: boolean; + } | { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] } | { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] } | { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId } diff --git a/src/sim/bot.ts b/src/sim/bot.ts index 06e2c7c..5a2cb90 100644 --- a/src/sim/bot.ts +++ b/src/sim/bot.ts @@ -19,7 +19,7 @@ * complete at all. */ -import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts'; +import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarType, laborersLeft } from '../engine/apply.ts'; import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts'; import type { GameEvent } from '../engine/events.ts'; import type { Intent } from '../engine/intents.ts'; @@ -442,6 +442,40 @@ function loadCanFinish(s: GameState, player: PlayerIndex, at: GridCoord): boolea return f.industryTrack.cars.some((c) => !c.loaded && c.type === next.type); } +/** Where the crew could go NEXT from a square, so a plan can be checked one move ahead. */ +function reachableFrom( + s: GameState, + player: PlayerIndex, + trayId: string, + from: GridCoord, +): GridCoord[] { + return [ + ...destinationsFor(s, player, trayId, from, false), + ...destinationsFor(s, player, trayId, from, true), + ].map((d) => d.coord); +} + +/** + * The consist this move would leave, coupling included. + * + * Asks the engine's own reachability for what the crew would pick up, so the prediction cannot + * disagree with what actually happens when the move is submitted. + */ +function consistAfterMove( + s: GameState, + player: PlayerIndex, + move: Extract, +): RollingStock[] | null { + const tray = s.trays.get(move.trayId); + if (!tray || tray.position.at !== 'grid') return null; + const dest = destinationsFor(s, player, move.trayId, tray.position.coord, move.reverse).find( + (d) => d.coord.row === move.to.row && d.coord.col === move.to.col, + ); + if (!dest) return null; + // Forward means the engine leads and meets the cars head-on (§A.3). + return move.reverse ? [...tray.consist, ...dest.couples] : [...dest.couples, ...tray.consist]; +} + /** Is this player's crew sitting somewhere it can never depart from? */ function strandedFromOffice(s: GameState, player: PlayerIndex): boolean { const area = areaOf(s, player); @@ -629,6 +663,34 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In if (drop) return because('standing on a facility that wants the back car — spot it', drop); } + /** + * RUN AROUND. §A.3 gives the engine couplers on its nose, so cars met while running + * FORWARD land in front of the train and cars met while BACKING land behind — which decides + * which car is next off the tail. + * + * That is what a siding is for. Rather than setting a useless car down and coming back for + * it, the crew can approach from the side that leaves the car it actually wants droppable. + * Both directions are offered on every move; this picks the one that ends with useful work + * at the tail. + */ + const runAround = options.find((i) => { + if (i.type !== 'switch.move') return false; + const after = consistAfterMove(s, player, i); + if (!after || after.length === 0) return false; + const tail = after[after.length - 1]!; + // The approach must change the answer: a useless car at the tail now, a wanted one after. + if (facilitiesWanting(s, player, endCar).length > 0) return false; + const wants = facilitiesWanting(s, player, tail); + if (wants.length === 0) return false; + // AND the drop has to be able to follow. Without this the crew ran the loop for its own + // sake — 8 run-arounds a game and 63 Moves, which the shuttling guard correctly failed. + return wants.some((w) => w.row === i.to.row && w.col === i.to.col) || + reachableFrom(s, player, i.trayId, i.to).some((c) => + wants.some((w) => w.row === c.row && w.col === c.col), + ); + }); + if (runAround) return because('running around: approach from the side that leaves a wanted car droppable', runAround); + // SET OUT — and do it BEFORE travelling. // // Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index a2619d6..213ab77 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -132,7 +132,9 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { return { tone: 'plain', where: e.at, - text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`, + text: + `Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` + + `: ${carsLabel(e.stock)}`, }; case 'consistSorted': return { diff --git a/src/web/main.ts b/src/web/main.ts index 927907a..d29c0cf 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -341,9 +341,12 @@ function clearSave(): void { } } -const tipStyle = document.createElement('style'); -tipStyle.textContent = TOOLTIP_CSS; -document.head.appendChild(tipStyle); +// BOTH stylesheets. The board is SVG built by the shared renderers, and every shape it draws is +// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black +// background, so the cards are drawn correctly and are simply invisible. +const pageStyle = document.createElement('style'); +pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS; +document.head.appendChild(pageStyle); installTooltips(); const saveBtn = document.getElementById('savefile'); diff --git a/test/replay.test.ts b/test/replay.test.ts index 41b7bc8..c196c2c 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -36,7 +36,7 @@ const SAMPLES: GameEvent[] = [ { type: 'actorChanged', player: 0 }, { type: 'localOpsOptionChosen', player: 0, option: 'switch' }, { type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 }, - { type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }] }, + { type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true }, { type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] }, { type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' }, { type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 }, diff --git a/test/sim.test.ts b/test/sim.test.ts index 8aa9ad4..2de0af1 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -276,15 +276,28 @@ describe('switching accomplishes something (regression)', () => { assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`); }); - it('does not spend its whole Local Operations budget on motion', () => { - // The absolute ratio is no longer a fair test: with only 9 industries in 115 cards there is - // often nothing to switch, so `work` is legitimately near zero. What still must hold is that - // the crew is not burning all six Moves every Stage — the shuttling bug. + it('does not shuttle aimlessly — Moves have to buy something', () => { + // Counting Moves alone stopped being a fair test once the crew could do real work. Run-arounds + // and sidings are SUPPOSED to cost several Moves each: the crew leaves the main, runs the loop, + // and comes back with a different car droppable. Measured before sidings existed the crew + // coupled ~0.4 cars a game; it now couples ~8 and drops ~11. + // + // So test the thing the old threshold was a proxy for: motion must convert into work. A crew + // that shuttles for its own sake shows a high ratio here, however few or many Moves it makes. const report = simulate({ games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot, }); - const moves = report.perGame.reduce((n, g) => n + g.actions.moves, 0) / report.perGame.length; - assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`); + const per = (f: (g: (typeof report.perGame)[number]) => number): number => + report.perGame.reduce((n, g) => n + f(g), 0) / report.perGame.length; + + const moves = per((g) => g.actions.moves); + const work = per((g) => g.actions.drops + g.actions.couples); + assert.ok(work > 2, `only ${work.toFixed(1)} productive acts a game — the crew is doing nothing`); + assert.ok( + moves / work < 8, + `${(moves / work).toFixed(1)} Moves per drop or coupling suggests aimless shuttling ` + + `(${moves.toFixed(0)} Moves, ${work.toFixed(1)} productive acts)`, + ); }); }); diff --git a/test/track.test.ts b/test/track.test.ts index e70b6aa..3f2ac5b 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -7,6 +7,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import type { + GameState, GridCoord, OfficeArea, RollingStock, @@ -14,6 +15,8 @@ import type { TurnoutOrientation, } from '../src/engine/state.ts'; import { coordKey } from '../src/engine/state.ts'; +import { createGame } from '../src/engine/setup.ts'; +import { applyIntent, areaOf } from '../src/engine/apply.ts'; import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts'; import { allReachable, @@ -95,6 +98,27 @@ const lockedFacility = (): TrackCard => ({ const car = (): RollingStock => ({ type: 'boxcar', loaded: false }); +function straightCard(): TrackCard { + return { + geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, + baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], + }; +} + +/** A minimal game wrapped around a prepared Office Area, for engine-level switching tests. */ +function gameWith(area: OfficeArea): GameState { + const s = createGame({ + id: 'g', seed: 5, + config: { + mode: 'solitaire', victory: 'highestAfterDays', length: 'standard', + optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + }, + playerNames: ['p'], + }); + s.officeAreas.set(0, area); + return s; +} + function areaFrom(cards: Record, officeCoord: GridCoord): OfficeArea { const grid = new Map(); for (const [k, v] of Object.entries(cards)) grid.set(k, v); @@ -503,3 +527,43 @@ describe('curves are arcs, and arcs make sidings possible', () => { assert.ok(seen.has('0,1'), 'the siding does not rejoin the Running Track'); }); }); + +describe('coupling lifts only the cars the crew ran over', () => { + it('leaves cars standing elsewhere in the district alone', () => { + // The reducer cleared EVERY card in the Office Area on any coupling, so picking up one boxcar + // deleted every car standing anywhere — including loads worked over several Stages onto an + // industry track the crew never went near. + const grid: Record = { + '0,-1': straightCard(), + '0,0': straightCard(), + '0,1': straightCard(), + '0,2': straightCard(), + }; + grid['0,-1']!.standing.push({ type: 'boxcar', loaded: false }); // on the path + grid['0,2']!.standing.push({ type: 'hopper', loaded: true }); // nowhere near it + + const area = areaFrom(grid, { row: 0, col: 0 }); + const s = gameWith(area); + s.trays.set('crew', { + id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true, consist: [], + direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0, + }); + s.clock.phase = 'localOps'; + s.clock.currentActor = 0; + s.turn.option = 'switch'; + s.turn.movesRemaining = 6; + + const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: { row: 0, col: -1 }, reverse: false }); + assert.ok(r.ok, 'the move should be legal'); + assert.deepEqual( + s.trays.get('crew')!.consist.map((c: RollingStock) => c.type), + ['boxcar'], + 'the crew should have picked up the car it ran over', + ); + assert.equal( + areaOf(s, 0).grid.get('0,2')!.standing.length, + 1, + 'a car standing off the path was deleted by an unrelated coupling', + ); + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts index 94433bc..5f1009c 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -585,6 +585,7 @@ describe('the static build', () => { // came back with highlighted squares wired to handlers. Asserting the data has coordinates says // nothing about whether the page draws them. const els = new Map>(); + const injected: Record[] = []; const make = (): Record => { let html = ''; // Cache per selector and clear it when innerHTML changes. Returning fresh objects each call @@ -663,7 +664,10 @@ describe('the static build', () => { createElement: () => make(), addEventListener: () => {}, body: { appendChild: () => {} }, - head: { appendChild: () => {} }, + // Capture injected stylesheets. The board is SVG styled entirely by class, so a page that + // renders every card correctly and never loads BOARD_CSS draws them black on black — visibly + // broken, and invisible to a stub that ignores CSS. + head: { appendChild: (node: Record) => void injected.push(node) }, }; g['location'] = { search: '?seed=555' }; const store = new Map(); @@ -702,6 +706,12 @@ describe('the static build', () => { assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered'); assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick'); + // Without the board stylesheet every shape is drawn black on a near-black background: the page + // looks empty even though the markup is perfect. + const styles = injected.map((n) => String(n['textContent'] ?? '')).join('\n'); + assert.match(styles, /\.bs-card\s*\{/, 'the page never loaded the board styles — cards would be invisible'); + assert.match(styles, /\.bs-rail\s*\{/, 'the page never loaded the rail styles'); + const html = String(grid['innerHTML']); // The board draws cards as addressable groups; legality is an added class or a drawn ghost. assert.match(html, /data-cell="/, 'the board drew no addressable cards');