A Heavy Grade shows which way it climbs
Jesse: "heavy grade mainline card tooltip states climbs east, but card doesn't show it." The Frame has carried gradeUp all along and the tip has said it; the card drew nothing, so the one Mainline card whose orientation is set per game was the one you had to hover to read. A brown wedge in the lower right rising toward the climb, with a bone arrow lying along its slope, centred on the triangle's centroid. East is right on this map (Gitea#18), which is what lets a wedge be read without a compass. Four passes. Up the hypotenuse the arrow began at the wedge's thin corner, where there is no height, so its head 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. Parallel to the slope is the shape that fits: the gap to the hypotenuse is then constant, so the arrow can sit centred. The wedge grew to 58x24 to pay for it, since a centred arrow has less room than an off-centre one. Sizes are a search result, clearing every edge by 2.88px. The first containment test bounded the arrow against the CARD, which it never left, while the wedge clipped it — a green check on a visibly broken glyph. It checks the TRIANGLE now with a 2px floor, plus parallelism derived from the wedge and centroid placement. Orientation is always set, measured not assumed: 400 seeds x 4 player counts, 535 grades placed, 0 without one, 276 east / 259 west. Stays in the unshipped v0.7.9. 874 tests pass, one new. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
45cf521a40
commit
cf018b4a5f
@@ -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 = /<polygon class="bs-grade" points="([^"]+)"/.exec(svg);
|
||||
return m ? (m[1]!.split(' ').map((p) => 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 = /<polygon class="bs-gradeup" points="([^"]+)"/.exec(svg)?.[1];
|
||||
assert.ok(arrow, `the ${dir} grade draws no arrow`);
|
||||
|
||||
const av = arrow.trim().split(/\s+/).map((p) => 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.
|
||||
|
||||
Reference in New Issue
Block a user