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
+36
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + RAIL_Y - 14}" x2="${c.x + 6 + RW * r}" y2="${c.y + RAIL_Y + 10}"/>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 += `<polygon class="bs-grade" points="${gx1},${yb} ${gx2},${yb} ${peakX},${yb - RISE}"/>`;
|
||||
|
||||
/**
|
||||
* 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 +=
|
||||
`<polygon class="bs-gradeup" points="${pt(-H, -T)} ${pt(H - HL, -T)} ${pt(H - HL, -HW)} ` +
|
||||
`${pt(H, 0)} ${pt(H - HL, HW)} ${pt(H - HL, T)} ${pt(-H, T)}"/>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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