diff --git a/CHANGELOG.md b/CHANGELOG.md index a3decac..7cb62d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,41 @@ extension bug above, which passed only `days` — which is exactly how the next it. At the default day count the answer is unchanged, since `SOLO_CONFIG`'s own floor is this same formula at `DEFAULT_DAYS`; the three new tests pin that as well as the derivation. +### A Heavy Grade shows which way it climbs + +Jesse, 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card doesn't show it." +The Frame has carried `gradeUp` since the Division map was rebuilt and the tip has read "climbs +east" all along — but the one Mainline card whose orientation is set per game, and the one where +Helpers and Brakeman mean opposite things at opposite ends, drew nothing to tell the two apart. + +A brown wedge in the card's lower right, rising toward the climb, with a bone arrow lying along its +slope. **East is right on this map** (Gitea#18), which is what lets a wedge be read without a +compass — and is the layout decision paying for itself again. + +**Four passes, and the first three are worth recording because each failed differently.** An arrow +up the hypotenuse had to start at the wedge's thin corner, where there is no height to draw in, so +its head sat over the edge and read as clipped. Level, it was contained but did not read as +climbing. At 30° — steeper than the wedge's own 22.5° — it had to be tucked into the fat half to +survive. Lying **along** the slope is the shape that fits: the perpendicular gap to the hypotenuse +is then constant down the whole arrow instead of closing at one end, so it can sit on the triangle's +centroid. The wedge grew 44×19 → 58×24 to pay for that, because a centred arrow has *less* room than +an off-centre one — the centroid is about 7px from the hypotenuse. + +The final sizes are a search result rather than an eyeballed nudge: the roomiest arrow keeping all +seven vertices clear of both edges, at 2.88px. + +**A test that passed a visibly broken glyph is the reason this is pinned properly.** The first +containment check bounded the arrow against the CARD, which it never left — while the wedge clipped +it. The check is against the *triangle* now, with a 2px floor, plus the arrow being parallel to the +wedge (derived from the wedge, so resizing it cannot leave a stale angle) and centred on the +centroid. + +**Orientation is always set — measured, not assumed**, since a wedge that invented a direction would +be worse than no wedge. Across 400 seeds × 4 player counts: **535 Heavy Grades placed, 0 without an +orientation**, 276 east / 259 west. `setup.ts` is the only place a Mainline node is created and it +rolls the direction from the seed for every grade, so the `?? 'east'` fallbacks in `view.ts` and +`advance.ts` are unreachable. + ### Three wording and layout fixes - **The collision entries** on all three screens now read "The game ends immediately and results in @@ -103,7 +138,7 @@ formula at `DEFAULT_DAYS`; the three new tests pin that as well as the derivatio cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal". -873 tests pass, six of them new; one existing test asserted the opposite of the collision ruling +874 tests pass, seven of them new; one existing test asserted the opposite of the collision ruling above and says so where it was reversed. --- diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index c5c733c..2d04de4 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -146,6 +146,12 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul below?: Cell['trains']; /** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */ regions: number; + /** + * Which way a Heavy Grade climbs, or null on every other card. Drawn as a wedge, because the + * tooltip said "climbs east" and the card itself showed nothing — so the one card whose + * orientation the PLAYER chooses was the one card you had to hover to read (Jesse, 2026-08-30). + */ + gradeUp?: string | null; w: number; x: number; y: number; @@ -259,6 +265,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul seat: null, // A Division Point is one region — the queue trains enter and leave the Division through. regions: dp ? 1 : (n.regions ?? 0), + gradeUp: dp ? null : (n.gradeUp ?? null), w: dp ? CW.dp : CW.ml, }); } @@ -357,6 +364,73 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul out += ``; } + /** + * WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said. + * + * The tooltip has said "climbs east" since the Frame carried `gradeUp`, and the card showed + * nothing — so the one Mainline card whose orientation the PLAYER sets, and the one where a + * Helpers or Brakeman modifier means opposite things at opposite ends, was the one you had to + * hover to read (Jesse, 2026-08-30). + * + * A wedge rising toward the climb, with an arrow up its slope. Two cues rather than one: the + * wedge alone asks the reader to judge which end is taller, which at eleven pixels of rise is a + * comparison rather than a glance. Bottom-right, clear of the left-aligned capacity line and + * below the region bars, so it never lands under a train chip. + * + * `east` is RIGHT on this map and always has been (Gitea#18) — that is what makes a wedge + * readable without a compass, and it is why the row layout is worth its width. + */ + if (c.gradeUp === 'east' || c.gradeUp === 'west') { + const s = c.gradeUp === 'east' ? 1 : -1; + const GW = 58; + const RISE = 24; + const gx1 = c.x + c.w - 9 - GW; + const gx2 = c.x + c.w - 9; + const yb = c.y + CH - 6; + const peakX = s === 1 ? gx2 : gx1; + out += ``; + + /** + * THE ARROW LIES ALONG THE WEDGE'S OWN SLOPE, CENTRED IN IT (Jesse, 2026-08-30). + * + * Parallel to the hypotenuse is the shape that fits: the perpendicular gap to the slope is + * then CONSTANT along the whole arrow, instead of closing at one end the way a steeper line + * does. The earlier 30° pass had to be tucked into the fat half to survive, because 30° is + * steeper than this wedge climbs — at 58×24 the slope is 22.5°, and the arrow simply lies on + * it. + * + * Centred on the TRIANGLE'S CENTROID (2/3 along the base, 1/3 up), which is the balance point + * of the form rather than of its bounding box — centring on the box would push the arrow into + * the thin corner where there is no height for it. + * + * The wedge grew 50×22 → 58×24 to pay for that: the centroid sits only ~7px from the + * hypotenuse, so a centred arrow has less room than an off-centre one and needs a bigger form + * to keep it. Sizes are the best fit found by search, clearing every edge by 2.88px; + * `web.test.ts` re-derives it and fails under 2px. + * + * Worked in the wedge's own frame — `u` along the base from the thin corner, `h` up from it — + * so a westward climb is one sign on `u` rather than a second set of coordinates. + */ + const L = 20; + const HL = 6; + const HW = 3.5; + const T = 1.4; + const A = Math.atan2(RISE, GW); + const cos = Math.cos(A); + const sin = Math.sin(A); + const cu = (2 * GW) / 3; + const ch = RISE / 3; + const pt = (lx: number, ly: number): string => { + const u = cu + lx * cos - ly * sin; + const h = ch + lx * sin + ly * cos; + return `${s === 1 ? gx1 + u : gx2 - u},${yb - h}`; + }; + const H = L / 2; + out += + ``; + } + /** * ON THE DIVISION MAP, A TRAIN IS A CHIP — name, which way it points, how many cars. * @@ -1123,6 +1197,14 @@ export const BOARD_CSS = ` /* 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} +/* THE HEAVY GRADE WEDGE. Terrain, so it is coloured as terrain rather than as a warning. + SOLID BROWN, fill and border the same (Jesse, 2026-08-30) — the first pass paired a desaturated + fill with an amber arrow and the pair read reddish, which on a map that spends amber on "it is + happening here" made a fixed piece of landscape look like a live alert. One flat brown recedes + into scenery; the arrow is bone so the DIRECTION, which is the fact being reported, is the part + that carries. */ +.bs-grade{fill:#6b5334;stroke:#6b5334;stroke-width:1} +.bs-gradeup{fill:#f2e8d5} .bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2} .bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6} /* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they diff --git a/test/web.test.ts b/test/web.test.ts index 4b4c294..90f768a 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -21,6 +21,7 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url'; import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts'; import { variantsFor } from '../src/engine/track.ts'; import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts'; +import type { DivisionView } from '../src/sim/view.ts'; import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts'; import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts'; import { turnChartHtml } from '../src/sim/turnchart.ts'; @@ -2834,6 +2835,107 @@ describe('the Division map shows the whole route', () => { return divisionSvg(snapshot(s, [], null).division); }; + it('draws which way a Heavy Grade climbs, instead of only saying it in the tooltip', () => { + /** + * REPORTED BY JESSE 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card + * doesn't show it." The Frame has carried `gradeUp` since the Division map was rebuilt, and the + * tip has read "climbs east" all along — but the one Mainline card whose orientation the PLAYER + * sets, and the one where Helpers and Brakeman mean opposite things at opposite ends, drew + * nothing to distinguish the two. + * + * Asserted on the geometry rather than on a screenshot: what can actually go wrong here is the + * wedge pointing the wrong way or hanging off the card, and both are numbers. + */ + const card = (gradeUp: string | null): DivisionView => + ({ + kind: 'ml', label: 'Heavy Grade', trains: [], capacity: 1, + modifiers: [], gradeUp, regions: 3, what: 'three regions', + } as unknown as DivisionView); + + const peakOf = (svg: string): [number, number][] | null => { + const m = / p.split(',').map(Number) as [number, number])) : null; + }; + + // EAST IS RIGHT on this map (Gitea#18), which is the whole reason a wedge can be read without a + // compass — so the apex belongs at the greater x when the grade climbs east, and the lesser when + // it climbs west. + const east = peakOf(divisionSvg([card('east')])); + assert.ok(east, 'a Heavy Grade climbing east draws no wedge at all'); + const [ea, eb, epeak] = east; + assert.equal(epeak![0], Math.max(ea![0], eb![0]), 'the wedge climbs the wrong way for east'); + + const west = peakOf(divisionSvg([card('west')])); + assert.ok(west, 'a Heavy Grade climbing west draws no wedge at all'); + const [wa, wb, wpeak] = west; + assert.equal(wpeak![0], Math.min(wa![0], wb![0]), 'the wedge climbs the wrong way for west'); + + // Every other Mainline card is flat and must stay unmarked, or the wedge stops meaning anything. + assert.equal(peakOf(divisionSvg([card(null)])), null, 'a card with no grade drew one anyway'); + + /** + * THE ARROW LIVES INSIDE THE WEDGE, with room to spare. + * + * The first pass ran it up the hypotenuse, which starts at the wedge's thin corner where there + * is no height to draw in, so the head sat over the edge and read as clipped — reported exactly + * that way. A bounding-box check would have passed it: every point was on the card. What matters + * is containment in the TRIANGLE, and a margin big enough to see, so both are asserted. + */ + for (const dir of ['east', 'west'] as const) { + const svg = divisionSvg([card(dir)]); + const wedge = peakOf(svg)!; + const [w1, w2, apex] = wedge; + const yBase = w1![1]; + const xMin = Math.min(w1![0], w2![0]); + const xMax = Math.max(w1![0], w2![0]); + const arrow = / p.split(',').map(Number) as [number, number]); + for (const [ax, ay] of av) { + // 0 at the thin corner, 1 at the apex — the wedge's height scales with it. + const t = apex![0] === xMax ? (ax - xMin) / (xMax - xMin) : (xMax - ax) / (xMax - xMin); + const ceiling = yBase - (yBase - apex![1]) * t; + assert.ok(ax >= xMin && ax <= xMax, `the ${dir} arrow leaves the wedge sideways at ${ax}`); + assert.ok( + yBase - ay >= 2 && ay - ceiling >= 2, + `the ${dir} arrow comes within 2px of the wedge edge at ${ax},${ay} — it will read as clipped`, + ); + } + + /** + * IT LIES ALONG THE WEDGE'S OWN SLOPE, AND IS CENTRED IN THE FORM. + * + * Derived from the wedge rather than hardcoded, so resizing the wedge cannot leave the arrow + * at a stale angle — the assertion is "parallel", which is the property that makes the gap to + * the hypotenuse constant along the arrow instead of closing at one end. + * + * Measured on the AXIS: the two tail vertices straddle it by the shaft's half-thickness, so + * measuring from a corner under-reads by a few degrees. That mistake cost a wrong reading once + * already. + */ + const tail: [number, number] = [(av[0]![0] + av[6]![0]) / 2, (av[0]![1] + av[6]![1]) / 2]; + const tip = av[3]!; + const dx = tip[0] - tail[0]; + const climb = (Math.atan2(-(tip[1] - tail[1]), Math.abs(dx)) * 180) / Math.PI; + const slope = (Math.atan2(yBase - apex![1], xMax - xMin) * 180) / Math.PI; + assert.ok( + Math.abs(climb - slope) < 0.5, + `the ${dir} arrow climbs at ${climb.toFixed(1)}° but the wedge rises at ${slope.toFixed(1)}°`, + ); + assert.equal(dx > 0, dir === 'east', `the ${dir} arrow points away from the summit`); + + // Centred on the TRIANGLE's centroid — 2/3 along the base toward the apex, 1/3 up — not on + // the bounding box, which would put it in the thin corner where there is no height for it. + const midX = (tail[0] + tip[0]) / 2; + const midY = (tail[1] + tip[1]) / 2; + const cX = apex![0] === xMax ? xMin + ((xMax - xMin) * 2) / 3 : xMax - ((xMax - xMin) * 2) / 3; + const cY = yBase - (yBase - apex![1]) / 3; + assert.ok(Math.abs(midX - cX) < 1, `the ${dir} arrow is off the centroid horizontally`); + assert.ok(Math.abs(midY - cY) < 1, `the ${dir} arrow is off the centroid vertically`); + } + }); + 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.