From c3c5c59a45e3ec3f17cc6fdd136c6e5c2dc0396a Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 4 Aug 2026 11:26:18 -0400 Subject: [PATCH] Show the whole Division mainline. district now has option for auto hiding on phases where not needed. scaffold for multi-player geometry showing division. regions on mainline cards implemented. bug fix on laying rail cards. optimized replay (carried links forward). Add sounds, first pass --- CHANGELOG.md | 137 ++++++++++++++ TODO.md | 40 +++- src/engine/advance.ts | 2 +- src/engine/state.ts | 19 +- src/sim/board-svg.ts | 365 ++++++++++++++++++++++++++++-------- src/sim/replay.ts | 96 +++++++++- src/sim/view.ts | 127 ++++++++++++- src/web/game.ts | 19 +- src/web/main.ts | 96 ++++++++-- src/web/play.html | 18 +- src/web/sound.ts | 136 ++++++++++++++ test/advance.test.ts | 8 +- test/enhancements.test.ts | 6 +- test/mainline-cards.test.ts | 65 ++++++- test/replay.test.ts | 56 +++++- test/web.test.ts | 188 ++++++++++++++++++- 16 files changed, 1249 insertions(+), 129 deletions(-) create mode 100644 src/web/sound.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3050c9f..a5e74af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,143 @@ The target is 20 Revenue over 5 Days. ## Unreleased +### A legal move you could not click + +Reported from playtesting: laying a straight offered three places in the list and highlighted one on +the board. + +Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG afterwards. It computed +its origin over cells PLUS the offered spots, while `officeSvg` sized itself over cells alone — so +the two disagreed the moment a legal square lay outside the played cards, which is every square that +would EXTEND the district. Reproduced on seed 555: three placements offered, the empty one drawn at +y=99 on a canvas 103 tall, i.e. off it entirely. + +`officeSvg` now takes the ghosts and sizes itself over both, so there is one origin and one canvas, +and `ghostSvg` is deleted rather than fixed — a second coordinate system was the bug, not a detail +of it. It was also imported by the replay and never called there. + +A test walks six seeds and asserts every square the menu offers has a target drawn inside the +canvas: 129 squares across 47 placeable subjects. It fails on the old code with the exact +coordinates above. + +### The railroad, heard + +A whistle at the end of each Stage, the grade-crossing bell at the end of each Day, and the +conductor when a train is built. **Off by default** — everything here is synthesised rather than +recorded, so it is a placeholder for real audio and a playtester who did not ask for noise should +not get any. One click in the title bar turns it on, and that click doubles as the gesture browsers +require before audio may start. + +**Synthesised, not sampled**, and that is a constraint rather than a preference: the site is a static +folder that fetches nothing — there is a test asserting no page reaches an external host — so audio +would have to be committed to the repo, and a plausible whistle is not something to invent. A real +steam whistle is a CHORD of several chambers slightly out of tune with each other plus the breath of +the steam, which three detuned partials and a band of filtered noise get most of the way to. The +bell is inharmonic partials struck twice, which is what separates a bell from a beep. + +**"All aboard" is speech, and speech cannot be faked with oscillators.** `speechSynthesis` is built +into the browser, needs no asset and works offline, so it says the words; where the platform has no +voice installed a two-note conductor's call takes its place rather than nothing happening. + +The model names WHAT happened — a Stage ended, a Day turned, a train was made up — and the page +decides what that sounds like. A Day boundary rings the bell only: both would collide, and the bell +is the bigger event. The first Stage of the game announces nothing, because a Stage beginning is the +previous one ending and there is no previous one. + +Counted against the clock rather than trusted: over a full game, 60 Stage boundaries produced 55 +whistles and 5 bells, and 5 Days produced 5 bells. + +### Regions, drawn from what the crossing already cost + +§2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. The +engine had replaced that with `crossingStages()` — `implications.md` records it plainly: "The +`Region` model is gone" — because ten card types with real speeds cannot be expressed by one region +a Stage. Both `REGIONS_PER_MAINLINE_CARD = 2` and `entryPoints` survived as dead constants. + +The map draws regions again without reinstating the mechanic, because the printed cards say how: +they carry **Start positions**, so a train with Brakemen *enters further along the card*. That is +the same fact as "takes a Stage off the crossing", in different coordinates. So position falls out +of what the engine already knows: + + entry = REGIONS - stagesTotal position = clamp(entry + elapsed) + +| crossing | drawn as | | +| --- | --- | --- | +| 1 Stage (a 60 card) | enters at region 2, gone next Stage | the printed Start position | +| 2 Stages (a 30 card) | region 1 → region 2 | **§8.2 exactly** | +| 3 Stages (slow train) | region 1 → region 1 → region 2 | fixed distance, slow train | + +`entry` is deliberately allowed to go negative and only the final position is clamped: that keeps a +slow train's extra Stage at the START, where being slow shows. Clamping the entry instead parked it +at the exit, reading as a train that raced across and then waited — which is what the first version +did, and what the test now forbids. + +`Transit` gains `stagesTotal`, set on entry. State, not rules: nothing reads it to decide anything, +so the bot and every balance figure are untouched, and a save is a seed plus intents so there is +nothing to migrate. It cannot be recomputed later — a modifier played onto the card mid-crossing +would change the answer and make the train jump backwards. + +Division Points draw one region, the queue. Running Track cards inside a district draw none: a crew +moves there by Moves, not Stages, so it occupies a card outright. + +### The Division map shows the whole route + +The map drew one box per node, which collapsed each player's entire district into a single "Office" +tile — so the track a train actually runs along was invisible on the only view that shows where +trains are. + +An Office now expands into its **Running Track, Limits to Limits**. That is the right cut rather than +a compromise: the through route IS the Running Track, and everything hanging beneath it is secondary +track a crossing train never touches. Sidings, industries and the load pipeline stay in the district +view, which is where they can be read. + + West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP + +**Trains are shown wherever they are.** On a Running Track card, on a Mainline card, queued at a +Division Point. A crew working BELOW the Running Track has no position on the through route, so it +is reported against the district — "2 switching below" — rather than drawn somewhere it is not. +Projecting a siding onto the through line would be a lie the map cannot support. + +**Seating.** Players sit around a table, so the route is laid out the way they do: one row alone, +two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop — +trains enter at one Division Point and leave at the other — so the shape is deliberately left open, +with buffer stops at both ends and the gap labelled "trains enter" and "trains leave". Closing it +into a ring would promise a connection the rules do not have. + +Layout is geometry with no visual feedback loop, so it is tested rather than eyeballed: for 1, 2, 3 +and 4 players no two cells may overlap and none may fall outside the canvas. + +### The district folds itself away + +Auto-focus. The district is worth its vertical space during the phases that change it — Local +Operations and Cargo — and not during New Train, Mainline or Supervisor Shift, where the Division +map is what matters. Folded, it leaves a summary line rather than vanishing, because a panel that +disappears entirely reads as broken. + +A manual toggle overrides it and stays put. This is DISPLAY state and never game state: two players +at the same table may reasonably want it set differently. + +### The replay was storing the same things over and over + +Halved, near enough: **3415 KB → 1877 KB** on a 735-frame game. + +The TODO said to carry `links` forward. Measured first, that would have bought 5% of the `cells` +payload — `cells` is 62% of the file, and inside it the cost is elsewhere: + + what 32% of cells long prose, identical on every turnout in the district + facility 24% of cells the SAME object already serialised in the frame's `facilities` + identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid + +So all three are interned. A card's identity and its description are written once for the whole +recording and referenced by integer, and a cell points at its facility instead of carrying a second +copy of it. What stays per-frame is what genuinely changes: the cars standing there, the crew, and +any Enhancement laid on the card. + +`rehydrateCells` is exported and emitted into the page by `toString()`, the same trick the two board +renderers use — a second copy inside the page's inline script could drift from the packing and the +symptom would be a board drawing the wrong cards rather than an error. The round-trip test runs that +exact function over every frame, resolving nulls the way the page does. + ### Rolling stock was leaving the game Four things were tried against bot revenue. **One of them was worth more than everything else in diff --git a/TODO.md b/TODO.md index 1b0e39e..ee44446 100644 --- a/TODO.md +++ b/TODO.md @@ -93,6 +93,39 @@ target is settled and freight carries its intended share. ## Not yet built +- [ ] **Real audio, as committed assets.** Everything the game plays is synthesised from oscillators + (`src/web/sound.ts`), which was the honest choice for a site that fetches nothing — but it is a + placeholder, not the finished sound. Sound therefore defaults to OFF. + - **"All aboard" most of all.** It currently goes through the browser's `speechSynthesis`, so + it is whatever system voice the player happens to have — a robot, not a conductor. A real + clip is the single biggest improvement available here. + - **Find and add the rest as assets**: steam whistle, grade-crossing bell, couplers clashing, + a train pulling away. Needs licences that permit redistribution (CC0 or similar), files small + enough to commit, and a check that the "fetches nothing external" test still passes — assets + must be served from the site's own folder, never hot-linked. + - Keep the synthesised versions as the fallback for anything not sourced, so a missing file is + a quieter game rather than a broken one. + +- [ ] **Regions as the primary model (the other half of §8.2).** The Division map now DRAWS regions, + deriving position from what the crossing already cost. The engine still models a crossing as a + countdown of Stages, so two things printed on the cards remain unimplemented: + - `entryPoints` is declared on every Mainline profile and read nowhere. The Heavy Grade card + has five named Start positions, and playing Brakeman is supposed to move your entry point + along the card. The engine gets the same ANSWER by taking a Stage off the crossing, which is + why the derived drawing looks right — but the mechanism is not the printed one, so a card + whose starts do not correspond to its speed would be drawn wrong. + - `implications.md` §6 calls this "the single largest mechanical gap" and asks for typed cards + with speeds and named entries, with crossing time DERIVED from the region walk. + Doing it properly changes movement, so it invalidates every balance figure — revenue 8.7, the + freight numbers, all of it — and needs a full paired re-measure over 400 seeds. Needs the + source Start-position art for the ten card types before it can begin. + +- [ ] **Player settings, saved.** The district's auto-focus is the first of these: it is DISPLAY + state, so in a multiplayer game two players may reasonably want it set differently and it must + never become part of game state. It currently resets on reload. Worth a settings object in + localStorage — auto-focus mode to start with, and whatever else earns a preference — kept + strictly separate from the save, which is the seed plus the intents and has to stay portable. + - [ ] **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom, Teams or Jitsi call and post into its chat, it could carry the whole table's shared state without anyone alt-tabbing: the history of actions as they happen, and a prompt when someone @@ -117,8 +150,11 @@ target is settled and freight carries its intended share. ## 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. +- [x] **Carry `links` forward in replay frames.** Done, and the premise was wrong in an instructive + way: measured, `links` was 5% of the `cells` payload. What actually cost was the `what` prose + (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. - [ ] **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. diff --git a/src/engine/advance.ts b/src/engine/advance.ts index c285d16..24162d1 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -357,7 +357,7 @@ function enterMainline( tray.direction, node.gradeUp ?? 'east', ); - node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction }); + node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction }); tray.position = { at: 'mainline', index }; } diff --git a/src/engine/state.ts b/src/engine/state.ts index 8bd0cf3..7cb32a6 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -228,7 +228,24 @@ export type CrewTray = { * A train part-way across a Mainline card. Crossing time is measured in STAGES (Q1/Q2), so the * regions the placeholder used are gone — the cells printed on the cards are decoration. */ -export type Transit = { tray: TrayId; stagesRemaining: number; direction: Direction }; +/** + * A train crossing a Mainline card. + * + * `stagesTotal` is what the crossing cost when the train ENTERED, and it exists so the Division map + * can say where on the card a train is. §2.1 divides a Mainline card into two regions and §8.2 has + * a train move one region per Stage; the engine models crossing as a countdown of Stages instead, + * so position has to be derived — and it cannot be recomputed later, because a modifier played onto + * the card mid-crossing would change the answer and make the train appear to jump backwards. + * + * Read by nothing that decides anything: no legality check, no movement, no bot. It records what + * already happened so the picture can be drawn. + */ +export type Transit = { + tray: TrayId; + stagesRemaining: number; + stagesTotal: number; + direction: Direction; +}; export type DivisionNode = | { kind: 'divisionPoint'; side: Direction; holding: TrayId[] } diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 55129ac..79c8a72 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -29,82 +29,274 @@ export type BoardTrain = { label: string; consist: string[] }; * separated by thin seams, capacity legible because the lines can be counted. */ export function divisionSvg(nodes: DivisionView[]): string { - const COL = 190; - const TOP = 34; - const LANE = 30; - const width = Math.max(1, nodes.length) * COL; - const maxCap = nodes.reduce((n, d) => Math.max(n, d.capacity ?? 3), 1); - const height = TOP + maxCap * LANE + 54; + /** + * THE WHOLE DIVISION, west to east, as one continuous route. + * + * It used to draw one box per node, which collapsed every player's district into a single + * "Office" tile — so the thing a train actually runs along, the Running Track between the Limits, + * was invisible on the only map that shows where trains are. Now an Office expands into its + * Running Track, Limits to Limits, and the route reads end to end: + * + * West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP + * + * SEATING. Players sit around a table, so the route is laid out the way they do: one row alone, + * two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop + * — trains enter at one Division Point and leave at the other — so the shape is deliberately left + * open, with the two ends drawn as buffer stops facing each other across a marked gap. Closing it + * into a ring would promise a connection the rules do not have. + * + * Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for + * anything outside its own body. + */ + const CW = { dp: 118, ml: 152, run: 78 }; + const CH = 58; + const GAP = 6; + const PAD = 26; + const SIDE_GAP = 34; const esc = (t: string): string => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); + // -- flatten the route into drawable cells, remembering which belong to whose district -------- + type Cell = { + kind: 'dp' | 'ml' | 'run'; + label: string; + sub: string; + trains: { label: string; consist: string[]; region?: number; direction?: string }[]; + cap: number | null; + tip: string; + owner: number | null; + /** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */ + regions: number; + w: number; + x: number; + y: number; + }; + const cells: Cell[] = []; + const sides: number[][] = []; + let side: number[] = []; + + const push = (c: Omit): void => { + side.push(cells.length); + cells.push({ ...c, x: 0, y: 0 }); + }; + + for (const n of nodes) { + if (n.kind === 'office') { + const cap = n.capacity; + const ad = n.trains.flat(); + for (const rc of n.running ?? []) { + const isOffice = rc.kind === 'office'; + push({ + kind: 'run', + label: rc.label, + sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '', + // A train standing at the Office occupies an A/D track, which is where it is. + trains: isOffice ? [...rc.trains, ...ad] : rc.trains, + cap: isOffice ? cap : null, + tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`, + owner: n.owner ?? null, + // No regions inside a district: a crew moves by Moves there, not by Stages, so it + // occupies a card outright rather than a part of one. + regions: 0, + w: CW.run, + }); + } + // A crew below the Running Track has no position ON it, so it is reported against the + // district rather than drawn somewhere it is not. + const below = n.switching ?? []; + if (below.length > 0) { + const last = cells[cells.length - 1]; + if (last) last.sub = `${below.length} switching below`; + } + sides.push(side); + side = []; + continue; + } + const dp = n.kind === 'dp'; + push({ + kind: dp ? 'dp' : 'ml', + label: n.label, + sub: n.capacity === null ? 'no limit — trains queue' : `${Math.max(0, n.capacity - n.trains.flat().length)} of ${n.capacity} free`, + trains: n.trains.flat(), + cap: n.capacity, + tip: dp + ? 'A Division Point — trains enter and leave the Division here, and queue without limit' + : `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`, + owner: null, + // A Division Point is one region — the queue trains enter and leave the Division through. + regions: dp ? 1 : (n.regions ?? 0), + w: dp ? CW.dp : CW.ml, + }); + } + if (side.length > 0) sides.push(side); + + // Each player's side carries their district and the Mainline card leading into it; whatever is + // left over (the last Mainline and the East DP) joins the final side. + const seats = Math.max(1, Math.min(4, nodes.filter((n) => n.kind === 'office').length)); + const lanes: number[][] = []; + for (let i = 0; i < seats; i++) lanes.push([]); + sides.forEach((grp, i) => { + const target = Math.min(i, seats - 1); + for (const idx of grp) lanes[target]!.push(idx); + }); + + // -- lay the sides out around the table ------------------------------------------------------- + // top → right → bottom (reversed) → left (reversed), which gives a row, two facing rows, a + // horseshoe open to the west, and a square broken at the same place. + const dir: ('top' | 'right' | 'bottom' | 'left')[] = + seats === 1 ? ['top'] : seats === 2 ? ['top', 'bottom'] : seats === 3 ? ['top', 'right', 'bottom'] : ['top', 'right', 'bottom', 'left']; + + const runLen = (idxs: number[]): number => + idxs.reduce((n, i) => n + cells[i]!.w + GAP, -GAP); + const widest = Math.max(...lanes.map((l) => runLen(l)), 200); + const tall = lanes.length > 1 ? Math.max(...lanes.map((l) => l.length), 1) * (CH + GAP) : CH; + + const vertCount = dir.filter((d) => d === 'right' || d === 'left').length; + const boardW = PAD * 2 + widest + (vertCount > 0 ? CW.run + SIDE_GAP : 0); + const boardH = PAD * 2 + (dir.includes('bottom') ? CH * 2 + SIDE_GAP + (vertCount ? tall : 0) : CH) + 30; + + lanes.forEach((idxs, i) => { + const d = dir[i]!; + if (d === 'top' || d === 'bottom') { + const y = d === 'top' ? PAD : boardH - PAD - CH - 22; + const order = d === 'bottom' ? [...idxs].reverse() : idxs; + let x = PAD; + for (const idx of order) { + const c = cells[idx]!; + c.x = x; + c.y = y; + x += c.w + GAP; + } + } else { + const x = d === 'right' ? boardW - PAD - CW.run : PAD; + const order = d === 'left' ? [...idxs].reverse() : idxs; + let y = PAD + CH + SIDE_GAP; + for (const idx of order) { + const c = cells[idx]!; + c.x = x; + c.y = y; + c.w = CW.run; + y += CH + GAP; + } + } + }); + + // -- draw ------------------------------------------------------------------------------------- const rail = (x1: number, y: number, x2: number): string => { - let out = + let o = `` + ``; const n = Math.max(2, Math.floor(Math.abs(x2 - x1) / 9)); for (let i = 0; i <= n; i++) { const tx = x1 + ((x2 - x1) * i) / n; - out += ``; + o += ``; } - return out; + return o; }; - const chip = (x: number, y: number, t: BoardTrain): string => { - const w = 30 + Math.min(t.consist.length, 4) * 9; - return ( - `` + - `` + - `${esc(t.label)}` - ); + // The same rail turned through ninety degrees, for the sides of the table. + const railV = (x: number, y1: number, y2: number): string => { + let o = + `` + + ``; + const n = Math.max(2, Math.floor(Math.abs(y2 - y1) / 9)); + for (let i = 0; i <= n; i++) { + const ty = y1 + ((y2 - y1) * i) / n; + o += ``; + } + return o; }; - let out = ``; - nodes.forEach((node, i) => { - const x0 = i * COL; - const x1 = x0 + COL; - const cap = node.capacity; - const lanes = cap ?? 3; - const kind = node.kind === 'dp' ? 'dp' : node.kind === 'office' ? 'office' : 'ml'; + let out = ``; - out += ``; - out += `${esc(node.label)}`; - if (node.gradeUp) { - out += `climbs ${node.gradeUp === 'east' ? 'E ▲' : '▲ W'}`; + // The joins between consecutive cells, drawn as rail so a connection is rail meeting rail. A join + // that crosses from one player's side to the next is drawn heavier and labelled: that boundary is + // the Mainline card two districts share, and it is the thing a table needs to see. + for (let i = 0; i + 1 < cells.length; i++) { + const a = cells[i]!; + const b = cells[i + 1]!; + const sameRow = Math.abs(a.y - b.y) < 1; + const sameCol = Math.abs(a.x - b.x) < 1; + if (sameRow && b.x > a.x) out += rail(a.x + a.w, a.y + CH / 2, b.x); + else if (sameRow && b.x < a.x) out += rail(b.x + b.w, a.y + CH / 2, a.x); + else if (sameCol) { + // Stacked down one side of the table: still one straight run of track, not a turn. + const top = Math.min(a.y + CH, b.y + CH); + const bot = Math.max(a.y, b.y); + out += railV(a.x + a.w / 2, top, bot); + } else { + // A turn between sides: an elbow, so the route is visibly continuous around the table. + const ax = a.x + a.w / 2; + const bx = b.x + b.w / 2; + const ay = a.y + CH; + const by = b.y; + out += ``; + } + } + + cells.forEach((c) => { + const full = c.cap !== null && c.trains.length >= c.cap; + out += ``; + out += ``; + out += `${esc(c.label)}`; + out += rail(c.x + 6, c.y + 32, c.x + c.w - 6); + if (c.sub) out += `${esc(c.sub)}`; + + // REGIONS. §2.1 divides a Mainline card into two, and §8.2 moves a train one region per Stage. + // The bars are the card's DISTANCE and never vary; what varies is how fast a train covers them, + // so a 60 card is crossed in one Stage and a slow train on a 30 takes three. + const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0; + for (let r = 0; r < c.regions; r++) { + out += ``; } - // One rail per track this section can hold. Unlimited sections are drawn as three faded lines - // and labelled, rather than pretending to a number they do not have. - for (let k = 0; k < lanes; k++) { - out += `${rail(x0 + 12, TOP + 14 + k * LANE, x1 - 12)}`; - } - - const here = node.trains.flat(); - here.forEach((t, k) => { - const lane = Math.min(k, lanes - 1); - const across = here.length > lanes ? (k - lane) * 34 : 0; - out += chip(x0 + COL / 2 + across, TOP + 14 + lane * LANE, t); + c.trains.forEach((t, k) => { + const w = 30 + Math.min(t.consist.length, 4) * 8; + // 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(t.label)}`; }); - - const free = cap === null ? '∞' : `${Math.max(0, cap - here.length)} of ${cap} free`; - const warn = cap !== null && here.length >= cap ? ' bs-full' : ''; - out += `${cap === null ? 'no limit — trains queue' : free}`; - if (node.modifiers.length > 0) { - out += `${esc(node.modifiers.join(' · '))}`; - } + out += ''; }); + + // THE ENDS. The route stops at both Division Points; drawing buffer stops and naming the gap is + // what stops a seated layout being read as a loop. + const first = cells[0]; + const last = cells[cells.length - 1]; + if (first) { + out += `` + + ``; + out += `trains enter`; + } + if (last && last !== first) { + out += `` + + ``; + out += `trains leave`; + } + out += ''; return out; } - /** * The Office Area as a map: one card per grid square, rails drawn edge to edge. * * The through rail sits at a constant height on every card — the alignment the printed cards use — * so abutting cards produce one unbroken line and a gap is visibly a gap. */ -export function officeSvg(cells: CellView[], runningRow: number): string { +export function officeSvg( + cells: CellView[], + runningRow: number, + ghosts: { row: number; col: number }[] = [], +): string { const W = 132; const H = 96; const PAD = 3; @@ -113,9 +305,22 @@ export function officeSvg(cells: CellView[], runningRow: number): string { const esc = (t: string): string => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); - if (cells.length === 0) return ''; - const rows = cells.map((c) => c.row); - const cols = cells.map((c) => c.col); + /** + * ONE COORDINATE SYSTEM, AND ONE CANVAS. + * + * Empty squares a card may be placed on used to be drawn by a separate `ghostSvg` and spliced in + * afterwards. It computed its own origin over cells PLUS spots while this function sized itself + * over cells alone, so the two disagreed the moment a legal square lay outside the played cards — + * and every legal square that extends the district does. Measured on seed 555: three placements + * offered, the one empty square drawn at y=99 on a canvas 103 tall, i.e. off it entirely. The + * list said three, the board showed two, and the third could not be clicked. + * + * Sizing over cells AND ghosts together is what makes that class of bug impossible. + */ + const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...ghosts]; + if (all.length === 0) return ''; + const rows = all.map((c) => c.row); + const cols = all.map((c) => c.col); const r0 = Math.min(...rows); const r1 = Math.max(...rows); const c0 = Math.min(...cols); @@ -254,44 +459,21 @@ export function officeSvg(cells: CellView[], runningRow: number): string { out += ''; } + // A legal EMPTY square has no card to outline, so it gets a target drawn in the same frame. + for (const g of ghosts) { + out += `` + + `` + + `place here` + + `${g.row},${g.col}` + + ``; + } + // Mark the Running Track so the spine of the district is unmistakable. out += `RUNNING TRACK`; out += ''; return out; } -/** - * Targets for legal EMPTY squares. They have no card to outline, so the board draws a dashed - * placeholder — the commonest placement of all is onto a blank square, and without this the - * highlight would have nothing to attach to. - */ -export function ghostSvg( - spots: { row: number; col: number }[], - cells: CellView[], - runningRow: number, -): string { - const W = 132; - const H = 96; - const PAD = 3; - const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...spots]; - if (all.length === 0) return ''; - const r1 = Math.max(...all.map((c) => c.row)); - const c0 = Math.min(...all.map((c) => c.col)); - let out = ''; - for (const s of spots) { - const x = (s.col - c0) * (W + PAD); - const y = (r1 - s.row) * (H + PAD); - out += - `` + - `` + - `place here` + - `${s.row},${s.col}` + - ``; - } - void runningRow; - return out; -} - /** Styling for both renderers. Shared so the replay and the app cannot drift apart visually. */ export const BOARD_CSS = ` .bs{width:100%;height:auto;background:#0e1116;border-radius:6px;padding:6px;overflow:visible} @@ -308,6 +490,25 @@ export const BOARD_CSS = ` .bs-sec{fill:#161b21;stroke:#39424e} .bs-sec.bs-dp{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3} .bs-sec.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2} +/* THE DIVISION, drawn as the route rather than as a row of boxes. + Three kinds of cell, and they must be told apart at a glance: a Division Point is where trains + come from and go to, a Mainline card is shared ground between two districts, and a Running Track + card belongs to one player's Office Area. */ +.bs-dcell rect{fill:#161b21;stroke:#39424e;stroke-width:1} +.bs-ddp rect{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3} +.bs-dml rect{fill:#1d222b;stroke:#6b7686;stroke-width:1.6} +.bs-drun rect{fill:#1b2534;stroke:#4d6fa8} +.bs-dcell.bs-full rect{stroke:#e0a060;stroke-width:2} +/* The elbow where the route turns from one player's side of the table to the next. Drawn heavier + than a plain join because that turn is the boundary two districts share. */ +.bs-turn{fill:none;stroke:#7f8b9c;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round} +/* Buffer stops at both ends. The Division is a line, not a loop: trains enter at one Division Point + and leave at the other, and a seated layout must not be read as a ring. */ +.bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round} +.bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em} +/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the + 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} .bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2} diff --git a/src/sim/replay.ts b/src/sim/replay.ts index ad6c721..baa2678 100644 --- a/src/sim/replay.ts +++ b/src/sim/replay.ts @@ -36,7 +36,7 @@ export { cardName, describeIntent, snapshot } from './view.ts'; import type { Frame } from './view.ts'; import type { Decision } from './view.ts'; import { cardName, describeDecision, snapshot, trainName } from './view.ts'; -import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from './board-svg.ts'; +import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts'; // --------------------------------------------------------------------------- // Frame shape — only what the viewer draws @@ -147,19 +147,94 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re * * Only the wire format changes: `record()` still returns complete frames, so tests and any other * consumer are unaffected. The page resolves a null by walking back to the last frame that carried - * the field. + * the field, and rebuilds each cell from the dictionaries below. + * + * MEASURED, on a 735-frame game, before any of this: `cells` was 62% of 7.1 MB of JSON, and within + * it the cost was not where the TODO said. Carrying `links` forward — the item this started as — + * would have saved 5% of cells. What actually costs: + * + * what 32% of cells long prose, and the SAME prose on every turnout in the district + * facility 24% of cells the identical object is already in the frame's `facilities` + * identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid + * + * So each of the three is interned instead. A card's identity and its description are written once + * for the whole recording and referenced by integer, and a cell points at its facility rather than + * carrying a second copy of it. What stays per-frame is only what genuinely changes: the cars + * standing there, the crew, and any Enhancement laid on it. */ -function compress(frames: Frame[]): unknown[] { +type Packed = { + /** Static per square, written once: row, col, kind, label, running, links. */ + cards: unknown[]; + /** Distinct `what` descriptions, written once. */ + whats: string[]; + frames: unknown[]; +}; + +export function compress(frames: Frame[]): Packed { + const cards: unknown[] = []; + const cardAt = new Map(); + const whats: string[] = []; + const whatAt = new Map(); + + const packed = frames.map((f) => { + const cells = f.cells.map((c) => { + const idKey = JSON.stringify([c.row, c.col, c.kind, c.label, c.running, c.links]); + let ci = cardAt.get(idKey); + if (ci === undefined) { + ci = cards.length; + cards.push([c.row, c.col, c.kind, c.label, c.running, c.links]); + cardAt.set(idKey, ci); + } + let wi = whatAt.get(c.what); + if (wi === undefined) { + wi = whats.length; + whats.push(c.what); + whatAt.set(c.what, wi); + } + // Identity, not equality: `snapshot` pushes the very same FacilityView object into both the + // cell and the frame's `facilities`, so this always resolves. + const fi = c.facility ? f.facilities.indexOf(c.facility) : -1; + return [ci, wi, c.enhancements, c.tray, c.cars, fi]; + }); + return { ...f, cells } as unknown as Frame; + }); + const keys = ['cells', 'facilities', 'division'] as const; let prev: Record = {}; - return frames.map((f, i) => { - const out: Record = { ...f }; + const out = packed.map((f, i) => { + const o: Record = { ...f }; for (const k of keys) { const json = JSON.stringify(f[k]); - if (i > 0 && prev[k] === json) out[k] = null; + if (i > 0 && prev[k] === json) o[k] = null; prev[k] = json; } - return out; + return o; + }); + + return { cards, whats, frames: out }; +} + +/** + * Rebuild whole CellViews from the compact wire rows. + * + * Exported and then emitted into the page by `toString()`, exactly as the two board renderers are: + * a second copy of this in the page's inline script could drift from the packing above, and the + * failure would be a board that draws the wrong cards rather than an error. + */ +export function rehydrateCells( + packed: unknown[], + cards: unknown[], + whats: string[], + facs: unknown[], +): unknown[] { + return packed.map((row) => { + const p = row as [number, number, string[], string | null, string[], number]; + const c = cards[p[0]] as [number, number, string, string, boolean, string[]]; + return { + row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5], + what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4], + facility: p[5] < 0 ? null : facs[p[5]], + }; }); } @@ -322,7 +397,8 @@ kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0 const divisionSvg = ${divisionSvg.toString()}; const officeSvg = ${officeSvg.toString()}; -const FRAMES = ${JSON.stringify(compress(rec.frames))}; +const PACKED = ${JSON.stringify(compress(rec.frames))}; +const FRAMES = PACKED.frames, CARDS = PACKED.cards, WHATS = PACKED.whats; /* The board changes rarely, so cells/facilities/division are emitted as null when identical to the previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of a 5.2 MB page. The scrubber jumps anywhere, hence the walk rather than a running pointer. */ @@ -330,6 +406,8 @@ function carry(i, key) { for (let k = i; k >= 0; k--) if (FRAMES[k][key] !== null) return FRAMES[k][key]; return []; } +const rehydrateCells = ${rehydrateCells.toString()}; +function cellsAt(i) { return rehydrateCells(carry(i, 'cells'), CARDS, WHATS, carry(i, 'facilities')); } let i = 0, timer = null; const $ = (id) => document.getElementById(id); const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c])); @@ -376,7 +454,7 @@ function render() { $('fno').textContent = i; $('scrub').value = i; - const CELLS = carry(i, 'cells'), FACS = carry(i, 'facilities'), DIV = carry(i, 'division'); + const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division'); $('division').innerHTML = divisionSvg(DIV); // The same office renderer the playable app uses, so replay and game draw one board. diff --git a/src/sim/view.ts b/src/sim/view.ts index 96148ea..1081acc 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -17,6 +17,7 @@ import { MAINLINE_MODIFIER_CARDS, MAINLINE_PROFILES, MANEUVER_CARDS, + REGIONS_PER_MAINLINE_CARD, OFFICE_ORDER, SPACE_USE_CARDS, industryProfile, @@ -86,7 +87,35 @@ export type FacilityView = { jammed: boolean; }; -export type TrainChip = { label: string; consist: string[] }; +export type TrainChip = { + label: string; + consist: 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 + * Moves rather than by Stages, so it occupies a card outright. + */ + region?: number; + direction?: string; +}; +/** + * One card of a player's Running Track, as the Division sees it. + * + * The through route between the Limits IS the Running Track — everything hanging beneath it is + * secondary, and a train crossing the Division never touches it. So the Division map expands an + * Office into this row and leaves sidings, industries and the load pipeline to the district view. + */ +export type RunningCardView = { + row: number; + col: number; + kind: string; + label: string; + /** Port pairs, from the engine's `connectionsFor`, so the drawn rail cannot invent a join. */ + links: string[]; + /** Trains standing ON this card of the Running Track. */ + trains: TrainChip[]; +}; + export type DivisionView = { kind: string; label: string; @@ -104,6 +133,19 @@ export type DivisionView = { * on that grade has no visible meaning. */ gradeUp: string | null; + /** Mainline cards only: how many regions the card is divided into (§2.1 — two). */ + regions?: number; + /** Office nodes only: the Running Track, Limits to Limits, west to east. */ + running?: RunningCardView[]; + /** Office nodes only: whose district this is. */ + owner?: number; + /** + * Office nodes only: crews working BELOW the Running Track. + * + * A crew down a siding has no position on the through route — projecting one would be a lie — so + * it is reported against the district as a whole and drawn exactly where it is in the zoomed view. + */ + switching?: TrainChip[]; }; export type Frame = { @@ -431,12 +473,43 @@ export function snapshot( // with how long each still has to run. const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card; const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade'; + /** + * WHERE ON THE CARD, from what the crossing already cost. + * + * §2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. + * The engine crosses in `crossingStages` Stages instead, which varies by card speed, train + * speed, passengers and modifiers — so the printed model is recovered by treating the entry + * point as the thing that varies, exactly as the cards do: + * + * entry = REGIONS - stagesTotal position = entry + elapsed + * + * A 60 card is one Stage, so the train enters at the second region and is gone — which is + * what "Start positions further along the card" means on the printed art. A 30 card is two + * Stages, giving one region per Stage, which is §8.2 exactly. A slow train needing three + * Stages cannot fit three steps into two regions, so it holds in the first for a Stage: the + * card's distance is fixed and the train is simply slow across it. + */ + const place = (t: { stagesRemaining: number; stagesTotal: number }): number => { + // `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into + // two regions, so it notionally starts before the card and spends the extra Stage getting + // to the first region. Clamping only the final position keeps that Stage at the START, + // where being slow shows; clamping `entry` first would have parked it at the exit instead. + const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal; + const elapsed = t.stagesTotal - t.stagesRemaining; + return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed)); + }; return { kind: 'ml', label: name, + regions: REGIONS_PER_MAINLINE_CARD, trains: [n.transits.map((t) => { const chip = trainChip(s, t.tray); - return { ...chip, label: `${chip.label} (${t.stagesRemaining})` }; + return { + ...chip, + label: `${chip.label} (${t.stagesRemaining})`, + region: place(t), + direction: t.direction, + }; })], capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1, modifiers: [ @@ -446,13 +519,57 @@ export function snapshot( gradeUp: isGrade ? (n.gradeUp ?? 'east') : null, }; } + const oa = areaOf(s, n.owner); + + // Where every crew in this district actually is: on a Running Track card, or below it. + const onRunning = new Map(); + const below: TrainChip[] = []; + for (const [id, tray] of s.trays) { + const pos = tray.position; + if (pos.at !== 'grid' || pos.owner !== n.owner) continue; + const c = trainChip(s, id); + if (pos.coord.row === oa.runningRow) { + const k = `${pos.coord.row},${pos.coord.col}`; + onRunning.set(k, [...(onRunning.get(k) ?? []), c]); + } else { + below.push(c); + } + } + + // Limits to Limits, west to east. §2.1 — the Running Track runs BETWEEN the Limits, so the + // signs are the ends of the through route rather than obstacles on it. + const running: RunningCardView[] = []; + for (let col = oa.limitsWest.col; col <= oa.limitsEast.col; col++) { + const card = oa.grid.get(`${oa.runningRow},${col}`); + if (!card) continue; + const g = card.geometry; + let label: string; + if (g.kind === 'office') label = officeProfile(oa.tier).name; + else if (g.kind === 'limits') label = 'Limits'; + else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility); + else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier); + else if (g.kind === 'spaceUse') label = prettyKey(g.key); + else label = geometryLabel(g.geometry); + running.push({ + row: oa.runningRow, + col, + kind: g.kind, + label, + links: connectionsFor(card).map(([a, b]) => `${a}${b}`), + trains: onRunning.get(`${oa.runningRow},${col}`) ?? [], + }); + } + return { kind: 'office', - label: officeProfile(areaOf(s, n.owner).tier).name, - trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))], - capacity: officeProfile(areaOf(s, n.owner).tier).adTracks, + label: officeProfile(oa.tier).name, + trains: [oa.adOccupancy.map((id) => trainChip(s, id))], + capacity: officeProfile(oa.tier).adTracks, modifiers: [], gradeUp: null, + owner: n.owner, + running, + switching: below, }; }); diff --git a/src/web/game.ts b/src/web/game.ts index 62b39f1..6de7124 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -79,6 +79,14 @@ export type Game = { * against the current rules. */ mustPlayCard: boolean; + /** + * Sounds the last batch of events earned, for the page to play and clear. + * + * The model names WHAT happened — a Stage ended, a Day turned, a train was built — and the view + * decides what that sounds like. Detection lives here because this is where events are seen; it + * would be guesswork from a rendered frame. + */ + cues: string[]; }; /** How each intent kind is introduced in the action list, in the order they should appear. */ @@ -114,7 +122,7 @@ export const SOLO_PLAYER = 'Solitaire'; export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game { const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] }); - const game: Game = { state, seed, history: [], log: [], mustPlayCard: false }; + const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [] }; // A history that opens mid-Stage reads as though something was missed. Say what the game IS // first, then let the clock take over. game.log.push({ text: 'Game Begins', tone: 'start' }); @@ -336,6 +344,15 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul const mine = who !== null && 'player' in e; const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text; game.log.push({ text, tone: mine ? 'act' : n.tone }); + + if (e.type === 'trainMadeUp') game.cues.push('train'); + if (e.type === 'stageBegan') { + // A Stage BEGINNING is the previous one ending — except the first, which is the game opening + // and has nothing behind it. A Day boundary gets the bell only: sounding both there would + // collide, and the bell is the bigger event. + if (e.day > 1 && e.stage === 1) game.cues.push('day'); + else if (e.day > 1 || e.stage > 1) game.cues.push('stage'); + } } // Keep the log bounded; the full history lives in `history` and can be replayed. if (game.log.length > 400) game.log.splice(0, game.log.length - 400); diff --git a/src/web/main.ts b/src/web/main.ts index ae08560..adefd35 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -5,8 +5,9 @@ * is answered by the engine or by the shared view helpers. */ -import { BOARD_CSS, divisionSvg, ghostSvg, officeSvg } from '../sim/board-svg.ts'; +import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; +import { play } from './sound.ts'; import type { Game } from './game.ts'; import { actionMenu, @@ -26,6 +27,28 @@ let game: Game; let selected: string | null = null; /** A square picked on the board, waiting for a rotation. */ let pendingAt: string | null = null; +/** + * AUTO-FOCUS on the district. + * + * It is only worth the vertical space during the phases that change it — Local Operations, where + * cards and track are placed and the crew switches, and Cargo, where loads move. During New Train, + * Mainline and Supervisor Shift nothing in the district moves and the Division map is what matters. + * + * 'auto' follows the phase; 'open' and 'closed' are the player overriding it and stay put until + * they change it again. Display only — in a multiplayer game two players may reasonably want it + * set differently, so this must never become part of game state. + */ +let districtMode: 'auto' | 'open' | 'closed' = 'auto'; +/** + * Sound, OFF until asked for. + * + * Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio + * rather than the finished thing — and a playtester who did not ask for noise should not get any. + * One click in the title bar turns it on, and that click is also the gesture browsers require + * before any audio may start. + */ +let soundOn = false; +const FOCUS_PHASES = new Set(['localOps', 'loadUnload']); const $ = (id: string): HTMLElement => { const el = document.getElementById(id); @@ -149,7 +172,16 @@ function render(): void { // -- board. Both renderers are shared with the replay so the two can never draw different // pictures of the same position. const grid = $('grid'); - grid.innerHTML = officeSvg(f.cells, f.runningRow); + // The empty squares a legal placement would extend the district onto. Drawn by `officeSvg` in the + // same pass so they share its origin and its canvas: a target outside the canvas is a legal move + // the player cannot click. + const ghostCoords = [...spotsAt.keys()] + .filter((k) => !f.cells.some((c) => `${c.row},${c.col}` === k)) + .map((k) => { + const [gr, gc] = k.split(',').map(Number); + return { row: gr!, col: gc! }; + }); + grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords); // Highlighting rides on top of the drawing: outline the legal squares and make them clickable. for (const [key, list] of spotsAt) { @@ -160,18 +192,11 @@ function render(): void { (g as unknown as HTMLElement).onclick = () => pick(key, list); } } - // A legal EMPTY square has no card to outline, so draw a target for it. - const ghosts = [...spotsAt.entries()].filter(([k]) => !f.cells.some((c) => `${c.row},${c.col}` === k)); - if (ghosts.length > 0) { - const coords = ghosts.map(([k]) => { - const [gr, gc] = k.split(',').map(Number); - return { row: gr!, col: gc! }; - }); - grid.innerHTML = grid.innerHTML.replace('', ghostSvg(coords, f.cells, f.runningRow) + ''); - for (const [key, list] of ghosts) { - const g = grid.querySelector(`g[data-ghost="${key}"]`); - if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list); - } + // Wire the targets. They are already in the SVG, so nothing is re-serialised here. + for (const [key, list] of spotsAt) { + if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue; + const g = grid.querySelector(`g[data-ghost="${key}"]`); + if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list); } function pick(key: string, list: { label: string; index: number }[]): void { @@ -261,10 +286,38 @@ function render(): void { .join(''); log.scrollTop = log.scrollHeight; + renderDistrict(f); renderActions(menu); + + // 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. + const cues = game.cues.splice(0, game.cues.length); + if (soundOn) for (const c of cues) play(c); + save(); } +function renderDistrict(f: ReturnType): void { + const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open'; + const sec = $('district'); + if (open) sec.classList.remove('folded'); + else sec.classList.add('folded'); + + const cars = f.cells.reduce((n, c) => n + c.cars.length, 0); + const crew = f.cells.filter((c) => c.tray).length; + $('districtsummary').textContent = + `${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` + + (crew > 0 ? ` · ${crew} crew on the board` : ''); + + const btn = $('districttoggle'); + btn.textContent = districtMode === 'auto' ? (open ? 'auto · open' : 'auto · folded') : districtMode; + btn.onclick = () => { + // auto -> the opposite of what it is doing now -> back to auto. + districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto'; + render(); + }; +} + function boxes(items: string[], cap: number): string { let out = ''; for (let i = 0; i < Math.max(cap, items.length); i++) { @@ -439,4 +492,19 @@ installTooltips(); const saveBtn = document.getElementById('savefile'); if (saveBtn) saveBtn.onclick = downloadSave; +const soundBtn = document.getElementById('sound'); +if (soundBtn) { + const paint = (): void => { + soundBtn.textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted'; + }; + soundBtn.onclick = () => { + soundOn = !soundOn; + paint(); + // Confirm the change audibly — the one press where a sound is unambiguously wanted, and it + // doubles as the user gesture the browser needs before any audio may start. + if (soundOn) play('stage'); + }; + paint(); +} + start(); diff --git a/src/web/play.html b/src/web/play.html index ff3a9f5..6e3302e 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -100,6 +100,14 @@ button:hover{background:#39424e;border-color:#4d6fa8} #actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)} #actions button:focus-visible{outline:2px solid #f0b64a;outline-offset:2px} button.act{display:inline-block} +/* The district collapses when nothing in it is changing. A summary line stays, because a panel + that vanishes entirely reads as broken rather than as folded away. */ +button.ghost{background:transparent;border:1px solid var(--line);color:var(--dim);font-size:10px; + padding:1px 7px;margin-left:8px;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 #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", so a disabled button must not wear it. */ #actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361; @@ -169,6 +177,7 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w — seed — saved in this browser · add ?seed=1234 for a set deal + replays __BUILD__ @@ -186,7 +195,14 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w

The Division — west to east

-

Your Office Area — hover any card for the full explanation

+
+

Your Office Area + — hover any card for the full explanation + +

+
+
+

History

diff --git a/src/web/sound.ts b/src/web/sound.ts new file mode 100644 index 0000000..da9e004 --- /dev/null +++ b/src/web/sound.ts @@ -0,0 +1,136 @@ +/** + * The railroad, heard. + * + * SYNTHESISED, NOT SAMPLED, and that is a constraint rather than a preference. The site is a static + * folder that fetches nothing — there is a test asserting no page reaches an external host — so an + * audio file would have to be committed to the repo, and a plausible-sounding whistle is not + * something to invent. The Web Audio API can build these from oscillators at no download cost, and + * what it produces is honestly synthetic rather than a fake recording. + * + * A steam whistle is the one that repays the effort: a real one is a CHORD of several chambers + * slightly out of tune with each other, plus the breath of the steam itself. Three detuned partials + * and a band of noise is most of the way there. + * + * "All aboard" is speech, and speech cannot be faked with oscillators. `speechSynthesis` is built + * into the browser, needs no asset and works offline, so it says the words — and where the platform + * has no voice installed, a two-note call takes its place rather than nothing happening. + */ + +let ctx: AudioContext | null = null; + +/** + * Browsers refuse to start audio until the player has interacted with the page, so the context is + * created on first use — by which time they have clicked something — and resumed if it was + * suspended in the meantime. + */ +function audio(): AudioContext | null { + try { + const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return null; + ctx ??= new Ctor(); + if (ctx.state === 'suspended') void ctx.resume(); + return ctx; + } catch { + return null; + } +} + +/** A tone with an attack/decay envelope, so nothing starts or stops with a click. */ +function tone( + c: AudioContext, + at: number, + freq: number, + dur: number, + gain: number, + type: OscillatorType = 'sine', + bendTo?: number, +): void { + const osc = c.createOscillator(); + const amp = c.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, at); + if (bendTo !== undefined) osc.frequency.linearRampToValueAtTime(bendTo, at + dur); + amp.gain.setValueAtTime(0, at); + amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.04, dur / 4)); + amp.gain.exponentialRampToValueAtTime(0.0001, at + dur); + osc.connect(amp).connect(c.destination); + osc.start(at); + osc.stop(at + dur + 0.02); +} + +/** The escaping steam under a whistle — filtered noise, without which it is just a chord. */ +function breath(c: AudioContext, at: number, dur: number, gain: number): void { + const frames = Math.floor(c.sampleRate * dur); + const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1; + const src = c.createBufferSource(); + src.buffer = buf; + const band = c.createBiquadFilter(); + band.type = 'bandpass'; + band.frequency.value = 1400; + band.Q.value = 0.8; + const amp = c.createGain(); + amp.gain.setValueAtTime(0, at); + amp.gain.linearRampToValueAtTime(gain, at + 0.05); + amp.gain.exponentialRampToValueAtTime(0.0001, at + dur); + src.connect(band).connect(amp).connect(c.destination); + src.start(at); + src.stop(at + dur); +} + +/** End of a Stage. A three-chime whistle, one long call. */ +export function whistle(): void { + const c = audio(); + if (!c) return; + const t = c.currentTime + 0.02; + const d = 0.9; + // A minor-third-ish cluster, deliberately not in tune with itself — that beating IS the sound. + for (const [f, g] of [[392, 0.16], [466, 0.13], [587, 0.10], [784, 0.05]] as const) { + tone(c, t, f, d, g, 'sawtooth', f * 0.985); + } + breath(c, t, d, 0.05); +} + +/** End of a Day. The grade-crossing bell: two strikes, metallic and inharmonic. */ +export function clang(): void { + const c = audio(); + if (!c) return; + const t = c.currentTime + 0.02; + for (const at of [t, t + 0.22]) { + // Inharmonic partials are what separate a bell from a beep. + for (const [f, g] of [[1046, 0.13], [1570, 0.09], [2350, 0.06], [3140, 0.03]] as const) { + tone(c, at, f, 0.4, g, 'square'); + } + } +} + +/** A train is added to the board. */ +export function allAboard(): void { + try { + const s = window.speechSynthesis; + if (s && typeof SpeechSynthesisUtterance === 'function') { + const u = new SpeechSynthesisUtterance('All aboard!'); + u.rate = 0.95; + u.pitch = 0.85; + u.volume = 0.9; + s.speak(u); + return; + } + } catch { + // fall through to the call below + } + // No voice on this platform: a two-note conductor's call rather than silence. + const c = audio(); + if (!c) return; + const t = c.currentTime + 0.02; + tone(c, t, 523, 0.22, 0.14, 'triangle'); + tone(c, t + 0.2, 784, 0.34, 0.14, 'triangle'); +} + +/** Cue names the model emits; the view decides what they sound like. */ +export function play(cue: string): void { + if (cue === 'stage') whistle(); + else if (cue === 'day') clang(); + else if (cue === 'train') allAboard(); +} diff --git a/test/advance.test.ts b/test/advance.test.ts index 0c902d5..5ec8b33 100644 --- a/test/advance.test.ts +++ b/test/advance.test.ts @@ -299,7 +299,7 @@ describe('collisions are automatic (Gap 2)', () => { // would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set // `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here. ml.card = 'plains'; - ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' }); + ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' }); } s.clock.phase = 'mainline'; @@ -322,7 +322,7 @@ describe('collisions are automatic (Gap 2)', () => { // would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set // `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here. mlx.card = 'plains'; - mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' }); + mlx.transits.push({ tray: 'x', stagesRemaining: 1, stagesTotal: 1, direction: 'east' }); } s.trays.set('x', { id: 'x', @@ -371,7 +371,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => { // would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set // `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here. ml.card = 'plains'; - ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' }); + ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' }); } // A second train at the Western Division Point wanting to follow it. @@ -414,7 +414,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => { // would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set // `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here. ml.card = 'plains'; - ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' }); + ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' }); } s.trays.set('waiting', { diff --git a/test/enhancements.test.ts b/test/enhancements.test.ts index 638e762..2f64569 100644 --- a/test/enhancements.test.ts +++ b/test/enhancements.test.ts @@ -194,7 +194,7 @@ describe('Interlocking and Yard Office relieve the Office', () => { // would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set // `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here. ml.card = 'plains'; - ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' }); + ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' }); } s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); @@ -261,7 +261,7 @@ describe('ABS Signals amend the collision rule', () => { // pass, so the following train would never be held and there would be nothing for ABS to do. ml.card = 'plains'; ml.absSignals = true; - ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' }); + ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' }); } s.trays.set('ahead', { id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true, @@ -301,7 +301,7 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => { // otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set // `trainsMayPass`, which legitimately removes the §8.1 bar this test is about. ml.card = 'plains'; - ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' }); + ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' }); } s.trays.set('mine', { id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true, diff --git a/test/mainline-cards.test.ts b/test/mainline-cards.test.ts index aee5529..5455fb2 100644 --- a/test/mainline-cards.test.ts +++ b/test/mainline-cards.test.ts @@ -24,6 +24,7 @@ import { import { createGame } from '../src/engine/setup.ts'; import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; import { coordKey } from '../src/engine/state.ts'; +import { snapshot } from '../src/sim/view.ts'; const config: GameConfig = { mode: 'solitaire', @@ -146,7 +147,7 @@ describe('placing a Mainline modifier', () => { it('will not modify a card with a train on it', () => { const s = game(); const node = pinned(s, 1, 'heavyGrade'); - node.transits.push({ tray: 'someone', stagesRemaining: 1, direction: 'east' }); + node.transits.push({ tray: 'someone', stagesRemaining: 1, stagesTotal: 1, direction: 'east' }); drawTurn(s); const cardId = hand(s, 'mainlineModifier', 'brakeman'); assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD'); @@ -207,7 +208,7 @@ describe('Red Flags protect a stopped train', () => { /** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */ function rearEnder(s: GameState) { const node = pinned(s, 1, 'plains'); - node.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' }); + node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' }); s.trays.set('ahead', { id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true, consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0, @@ -534,3 +535,63 @@ 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 s = createGame({ + id: 'reg', + seed: 4, + config: { + mode: 'solitaire', victory: 'highestAfterDays', length: 'standard', + optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + }, + playerNames: ['Solitaire'], + }); + 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' }); + 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]!; + return { region: t.region as number, regions: ml!.regions as number }; + }; + + it('divides every Mainline card into two regions', () => { + // §2.1, and open-questions.md settled on two deliberately: at three, a Day of 12 Stages leaves + // trains still on the road when the next Day begins, locking Crew Trays permanently. + assert.equal(regionFor(2, 2).regions, 2); + }); + + it('moves one region per Stage for an ordinary train on a 30 card', () => { + // §8.2 exactly. This is the case the printed rule was written for. + assert.equal(regionFor(2, 2).region, 0); + assert.equal(regionFor(2, 1).region, 1); + }); + + it('starts a fast train further along the card rather than moving it twice', () => { + // The printed cards carry Start positions — a 60 card is crossed in one Stage, which the map + // shows as entering at the second region. That is the same fact as "Brakemen moves your entry + // point further along", which is how the modifier cards are drawn. + assert.equal(regionFor(1, 1).region, 1); + }); + + it('holds a slow train at the START, not at the exit', () => { + // Three Stages will not fit three steps into two regions. The card's distance is fixed, so the + // extra Stage is spent getting going — clamping the entry instead would park it at the exit and + // read as a train that raced across and then waited. + assert.equal(regionFor(3, 3).region, 0); + assert.equal(regionFor(3, 2).region, 0); + assert.equal(regionFor(3, 1).region, 1); + }); + + it('never leaves the card it is on', () => { + for (let total = 1; total <= 4; total++) { + for (let left = total; left >= 1; left--) { + const r = regionFor(total, left).region; + assert.ok(r >= 0 && r <= 1, `total ${total}, ${left} left put the train in region ${r}`); + } + } + }); +}); diff --git a/test/replay.test.ts b/test/replay.test.ts index 4e279e7..bfa003f 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -14,7 +14,7 @@ import { createGame } from '../src/engine/setup.ts'; import type { GameConfig } from '../src/engine/state.ts'; import { developerBot, playGame } from '../src/sim/bot.ts'; import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts'; -import { record, renderHtml } from '../src/sim/replay.ts'; +import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts'; import { summarize } from '../src/sim/stats.ts'; const config: GameConfig = { @@ -189,8 +189,16 @@ describe('replay HTML', () => { assert.deepEqual(externals, [], `external references: ${externals.join(', ')}`); }); - it('embeds the frame data', () => { - assert.match(html, /const FRAMES = \[/); + it('embeds the frame data and the dictionaries it is packed against', () => { + // The frames are packed now: a card's identity and its description are written once into + // CARDS/WHATS and referenced by integer. A page with FRAMES but no dictionaries would draw an + // empty board rather than fail, so all three are checked. + assert.match(html, /const PACKED = \{/); + assert.match(html, /FRAMES = PACKED\.frames/); + assert.match(html, /CARDS = PACKED\.cards/); + assert.match(html, /WHATS = PACKED\.whats/); + // And the unpacking function must be emitted, not just referenced. + assert.match(html, /const rehydrateCells = function/); }); it('offers the controls that make it usable', () => { @@ -300,3 +308,45 @@ describe('replay HTML', () => { assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`); }); }); + +describe('the replay wire format is lossless (regression)', () => { + it('rebuilds every cell exactly, through the same function the page runs', () => { + // The frames are packed before they reach the page: a card's identity and its description are + // written once and referenced by integer, and a cell points at its facility instead of carrying + // a second copy of it. That is 45% of the file — and it is only safe if unpacking is exact. + // + // `rehydrateCells` is the function the page itself runs, emitted by toString(), so this checks + // the real thing rather than a second implementation of it. + const rec = record(880009, 'standard', 4000); + const packed = compress(rec.frames); + + let checked = 0; + let carried: unknown[] = []; + let carriedFacs: unknown[] = []; + for (let i = 0; i < rec.frames.length; i++) { + const wire = packed.frames[i] as Record; + // null means "unchanged since the last frame that carried it", exactly as the page resolves it + if (wire['cells'] !== null) carried = wire['cells'] as unknown[]; + if (wire['facilities'] !== null) carriedFacs = wire['facilities'] as unknown[]; + + const back = rehydrateCells(carried, packed.cards, packed.whats, carriedFacs); + assert.deepEqual(back, rec.frames[i]!.cells, `frame ${i} did not survive the round trip`); + checked++; + } + assert.ok(checked > 100, `only ${checked} frames checked`); + }); + + it('writes each distinct card and description once, not once per frame', () => { + const rec = record(880009, 'standard', 4000); + const packed = compress(rec.frames); + const cellsSeen = rec.frames.reduce((n, f) => n + f.cells.length, 0); + assert.ok( + packed.cards.length < cellsSeen / 20, + `${packed.cards.length} card entries for ${cellsSeen} cell-frames — identity is not interned`, + ); + assert.ok( + packed.whats.length < cellsSeen / 20, + `${packed.whats.length} descriptions for ${cellsSeen} cell-frames — prose is not interned`, + ); + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts index c444de6..5d4e9fe 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -12,7 +12,9 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { cardDescription, describeIntent } from '../src/sim/view.ts'; -import { officeSvg } from '../src/sim/board-svg.ts'; +import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts'; +import { snapshot } from '../src/sim/view.ts'; +import { createGame as createEngineGame } from '../src/engine/setup.ts'; import { actionGroups, actionMenu, @@ -696,8 +698,19 @@ describe('the static build', () => { // page that works perfectly. let cache = new Map[]>(); const found = new Map>(); + // Every element carries a classList. The page folds the district panel by toggling a class on + // it, and a stub without one throws on load — which is a page that never starts, not a + // cosmetic gap. + const ownClasses = new Set(); const node: Record = { textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0, + classList: { + add: (c: string) => void ownClasses.add(c), + remove: (c: string) => void ownClasses.delete(c), + contains: (c: string) => ownClasses.has(c), + toggle: (c: string) => (ownClasses.has(c) ? ownClasses.delete(c) : ownClasses.add(c)), + }, + classes: ownClasses, // The board is SVG now, and highlighting works by finding a card group and adding a class. // The stub has to model that much or it cannot see whether highlighting happened at all. querySelector(sel: string) { @@ -936,3 +949,176 @@ describe('the static build', () => { } }); }); + +describe('the Division map shows the whole route', () => { + const divisionFor = (players: number): string => { + const s = createEngineGame({ + id: `div-${players}`, + seed: 7, + config: { + mode: players === 1 ? 'solitaire' : 'competitive', + victory: 'highestAfterDays', + length: 'standard', + optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + }, + playerNames: ['A', 'B', 'C', 'D'].slice(0, players), + }); + return divisionSvg(snapshot(s, [], null).division); + }; + + it('expands each Office into its Running Track, Limits to Limits', () => { + // The map used to collapse a whole district into one "Office" box, so the track a train + // actually runs along was invisible on the only view that shows where trains are. + const s = createEngineGame({ + id: 'div-run', + seed: 7, + config: { + mode: 'solitaire', victory: 'highestAfterDays', length: 'standard', + optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false }, + }, + playerNames: ['Solitaire'], + }); + const f = snapshot(s, [], null); + const office = f.division.find((n) => n.kind === 'office'); + assert.ok(office, 'no Office node in the Division'); + assert.ok(office!.running && office!.running.length >= 3, 'the Office carries no Running Track'); + // The through route runs BETWEEN the Limits, so both ends must be Limits cards (§2.1). + assert.equal(office!.running![0]!.kind, 'limits', 'the district does not start at a Limits sign'); + assert.equal(office!.running![office!.running!.length - 1]!.kind, 'limits', 'the district does not end at a Limits sign'); + // And the columns must run west to east without a hole. + const cols = office!.running!.map((c) => c.col); + for (let i = 1; i < cols.length; i++) { + assert.ok(cols[i]! > cols[i - 1]!, 'the Running Track is not ordered west to east'); + } + }); + + it('seats 1 to 4 players without overlapping or spilling off the canvas', () => { + // A row, two facing rows, a horseshoe and a square. The layout is geometry with no visual + // feedback loop, so this is the only thing standing between a change and an unreadable board. + for (const players of [1, 2, 3, 4]) { + const svg = divisionFor(players); + const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg); + assert.ok(vb, `${players}p produced no viewBox`); + const W = Number(vb![1]); + const H = Number(vb![2]); + const rects = [...svg.matchAll(/class="bs-dcell[^"]*"[^>]*> ({ x: +m[1]!, y: +m[2]!, w: +m[3]!, h: +m[4]! })); + assert.ok(rects.length >= 5, `${players}p drew only ${rects.length} cells`); + for (let i = 0; i < rects.length; i++) { + const a = rects[i]!; + assert.ok( + a.x >= 0 && a.y >= 0 && a.x + a.w <= W && a.y + a.h <= H, + `${players}p: a cell falls outside the canvas`, + ); + for (let j = i + 1; j < rects.length; j++) { + const b = rects[j]!; + const hit = a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; + assert.ok(!hit, `${players}p: two cells overlap`); + } + } + } + }); + + it('draws the Division as a line with two ends, never as a loop', () => { + // Seating players around a table invites exactly one misreading: that the route joins up. It + // does not — trains enter at one Division Point and leave at the other. + for (const players of [1, 2, 3, 4]) { + const svg = divisionFor(players); + assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`); + assert.match(svg, /trains enter/, `${players}p does not label where trains enter`); + assert.match(svg, /trains leave/, `${players}p does not label where trains leave`); + } + }); +}); + +describe('every square the menu offers can actually be clicked (regression)', () => { + it('draws a target for it, inside the canvas', () => { + // REGRESSION. Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG after + // the fact. It sized its origin over cells PLUS spots while `officeSvg` sized itself over cells + // alone, so the two disagreed the moment a legal square lay outside the played cards — which is + // every square that would EXTEND the district, i.e. most of them. + // + // Seed 555 offered three placements for a straight; the empty square was drawn at y=99 on a + // canvas 103 tall. The list said three, the board showed two, and the third could not be + // clicked at all. Ghosts are drawn by `officeSvg` in the same pass now, so there is one origin + // and one canvas. + let checked = 0; + for (const seed of [555, 42, 909, 7, 88, 1234]) { + const game = newGame(seed); + const draw = actionMenu(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw'); + if (draw) submit(game, draw); + const menu = actionMenu(game); + 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()]; + 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); + assert.ok(vb, 'the board produced no viewBox'); + const W = Number(vb![1]); + const H = Number(vb![2]); + + for (const c of uniq) { + checked++; + const key = `${c.row},${c.col}`; + const isCard = f.cells.some((x) => x.row === c.row && x.col === c.col); + const target = isCard ? `data-cell="${key}"` : `data-ghost="${key}"`; + assert.ok(svg.includes(target), `${item.subject}: (${key}) is offered but has no target on the board`); + + // `data-tip` sits between the id and the transform on a real card, so match loosely. + const m = new RegExp( + 'data-(?:cell|ghost)="' + key + '"[^>]*transform="translate\\((-?[\\d.]+),(-?[\\d.]+)\\)"', + ).exec(svg); + assert.ok(m, `${item.subject}: (${key}) has no position`); + const x = Number(m![1]); + const y = Number(m![2]); + assert.ok( + x >= 0 && y >= 0 && x + 132 <= W + 0.01 && y + 96 <= H + 0.01, + `${item.subject}: (${key}) is drawn off-canvas at (${x}, ${y}) on ${W}x${H} — a legal move that cannot be clicked`, + ); + } + } + } + assert.ok(checked > 50, `only ${checked} squares checked`); + }); +}); + +describe('the sounds fire on the events they name', () => { + it('one cue per Stage boundary, the bell replacing the whistle at a Day', () => { + // The model names WHAT happened and the page decides what it sounds like. Getting this wrong is + // not a silent failure — it is a whistle every few seconds, or a bell that never rings — so the + // count is checked against the clock rather than trusted. + const game = newGame(555); + const cues: Record = {}; + let stageBoundaries = 0; + let dayBoundaries = 0; + + for (let i = 0; i < 4000; i++) { + if (currentActor(game) === null) break; + const { options, groups } = actionGroups(game); + if (groups.length === 0 || options.length === 0) break; + const before = { d: game.state.clock.day, s: game.state.clock.stage }; + submit(game, options[groups[0]!.actions[0]!.index]!); + for (const c of game.cues) cues[c] = (cues[c] ?? 0) + 1; + game.cues.length = 0; + if (game.state.clock.day !== before.d) dayBoundaries++; + if (game.state.clock.stage !== before.s || game.state.clock.day !== before.d) stageBoundaries++; + } + + assert.ok(stageBoundaries > 20, `only ${stageBoundaries} Stages elapsed — the game did not run`); + assert.equal(cues['day'] ?? 0, dayBoundaries, 'the Day bell does not match the Days played'); + assert.equal( + (cues['stage'] ?? 0) + (cues['day'] ?? 0), + stageBoundaries, + 'Stage boundaries and Stage-or-Day cues disagree — some Stage ended silently, or one sounded twice', + ); + assert.ok((cues['train'] ?? 0) > 0, 'no train was ever announced'); + }); + + it('says nothing at all before the first Stage has ended', () => { + // A Stage BEGINNING is the previous one ending — except the first, which is the game opening. + const game = newGame(555); + assert.deepEqual(game.cues, [], 'the game announced a Stage ending before one had'); + }); +});