From f07021e27149ade0deb6f2034fc73682656ffea3 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 17:07:42 +0800 Subject: [PATCH] feat(06-07): PURE projectTurnCards + makeStreetAt + the 21 turn-card copy keys (GREEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cron.ts: linear progress projection (clamped), stateless 180 s cadence (due = nowSec % 180 < 60), dead-trip gate, classifyTurn-authority next-turn detection over consecutive-leg bearings, 4-line HTML caption anatomy - D-12 street by DEFAULT: makeStreetAt hops snapToGraph → matched segment's nameId → nameAt (never the node index); snap miss → bare-verb degradation; matched-unnamed → cardStreetUnnamed - strings.ts: cardAction* (8) + cardActionBare* (8) + cardStreetUnnamed + cardEtaTemplate + cardHonesty + cardOpenButton, en + ms verbatim - GC1-GC8 + CS7 green: 116 passed, 0 failed; bot:typecheck exit 0 --- bot/src/guidance/cron.ts | 261 ++++++++++++++++++++++++++++++++ bot/src/i18n/strings.ts | 70 +++++++++ bot/test/guidance-cron.test.mjs | 36 ++--- 3 files changed, 349 insertions(+), 18 deletions(-) create mode 100644 bot/src/guidance/cron.ts diff --git a/bot/src/guidance/cron.ts b/bot/src/guidance/cron.ts new file mode 100644 index 0000000..6dce35a --- /dev/null +++ b/bot/src/guidance/cron.ts @@ -0,0 +1,261 @@ +/* + * cron.ts — the minimized turn-card projection (06-07, GUID-05; D-11/D-12/D-13). + * --------------------------------------------------------------------------- + * PURE LAW: projectTurnCards and makeStreetAt are PURE — injected nowSec and + * graph, no KV, no R2, no Telegram, no clock reads — Node-testable. The + * scheduled() wiring lives in webhook.ts (the ExportedHandler's scheduled + * arm): it lists guidance:* keys, reads each envelope through the + * defensively-validated getGuidanceSession, and pushes due cards. ALL logging + * lives there too — this module has no console at all. + * + * D-12 (street by DEFAULT): the FROZEN envelope carries geometry only (GE2 — + * no steps field, and no envelope extension: a 5th property means drift), so + * the cron reconstructs the street itself. makeStreetAt snaps the CARD TURN's + * vertex to the memoized R2 graph — snapToGraph → the matched segment's + * nameId → graph.nameAt(nameId) — the SAME one-authority hop the app-side + * banner relies on. Edges carry names; nodes don't: the node index is NEVER + * fed to nameAt. DEGRADATION (D-03's designed-degradation pattern): the graph + * absent/corrupt in the cron isolate, or a snap miss → the resolver returns + * null → the card composes street-less with the bare verbs — honest absence, + * never a guessed name. A MATCHED but unnamed edge composes with + * cardStreetUnnamed ("UNNAMED ROAD") — still street-bearing, instrument voice. + * + * D-13 (honesty): the caption's strongest claim is "best-effort projection"; + * the copy never promises live tracking (banned-word gate in the cron suite). + * CADENCE (D-11 discretion, RESEARCH Open Q 3): the crons config ticks every + * 60 s; a card is DUE on the first tick of each CARD_CADENCE_S window — + * stateless (zero cursor keys, zero KV writes; the 1,000-writes/day free + * budget holds — pinned structurally in guidance-cron.test.mjs). + * + * Card language: the envelope contract carries NO language field (GE2), so + * per pickLocale's fallback law the cards are EN; the ms table is kept in + * parity by the bot copy gate (CS7) for the day the contract grows a + * language surface. + * + * HTML-escape surface: ZERO. Every caption part is code-composed from + * numbers and string-table constants — no envelope-derived string + * (destinationName included) enters the caption; street text arrives only + * through makeStreetAt's graph lookup (the repo's own OSM-derived graph). + */ +import { + classifyTurn, + decodePolyline6, + deriveCumulativeDistanceM, + haversineM, + snapToGraph, + type GuidanceTurnKind, + type RoadGraph, + type RoutePoint, +} from "@araharah/routing/src/index.ts"; +import type { GuidanceSessionEnvelope } from "../kv/guidance-sessions.ts"; +import { strings } from "../i18n/strings.ts"; + +/** The chat card's cadence (D-11/D-13): one card window every 3 minutes; + * the window's first tick is the due tick. */ +export const CARD_CADENCE_S = 180; + +/** The Worker's cron scan tick — wrangler's crons entry ticks every 60 s. */ +const SCAN_TICK_S = 60; + +/** Segments shorter than this are GPS/codec noise — no bearing is derived from them. */ +const DEGENERATE_SEGMENT_M = 1; + +/** v1 is KL-metro only and Malaysia has no DST — the card's ETA clock is KL + * wall time (the envelope carries no driver timezone; the Worker's clock is + * UTC). Named constant, replay-tunable. */ +const KL_UTC_OFFSET_S = 8 * 60 * 60; + +/** The turn-card's glyph vocabulary — cron.ts's local union mirroring the 8 + * committed PNG names (web/public/glyphs/.png); structurally the + * RouteStep.kind union (one authority with engine A's classifyTurn). */ +export type GlyphKindName = GuidanceTurnKind; + +/** One projection outcome: due + the card to push, or all-null. */ +export interface TurnCard { + due: boolean; + glyph: GlyphKindName | null; + caption: string | null; + maneuverIdx: number | null; +} + +function noCard(): TurnCard { + return { due: false, glyph: null, caption: null, maneuverIdx: null }; +} + +/** The instrument register prefix — GUIDE // T-{nn} is code-composed (UI-SPEC). */ +const GUIDE_REGISTER = "GUIDE // T-"; + +/** kind → the strings key carrying that maneuver's street-bearing verb. */ +const CARD_ACTION_KEYS: Record = { + depart: "cardActionDepart", + straight: "cardActionStraight", + left: "cardActionLeft", + right: "cardActionRight", + "slight-left": "cardActionSlightLeft", + "slight-right": "cardActionSlightRight", + uturn: "cardActionUturn", + arrive: "cardActionArrive", +}; + +/** kind → the strings key carrying that maneuver's street-less (bare) verb. */ +const CARD_ACTION_BARE_KEYS: Record = { + depart: "cardActionBareDepart", + straight: "cardActionBareStraight", + left: "cardActionBareLeft", + right: "cardActionBareRight", + "slight-left": "cardActionBareSlightLeft", + "slight-right": "cardActionBareSlightRight", + uturn: "cardActionBareUturn", + arrive: "cardActionBareArrive", +}; + +const DEG_TO_RAD = Math.PI / 180; + +/** Initial compass bearing a → b, degrees clockwise (the astar.ts convention). */ +function initialBearingDeg(a: RoutePoint, b: RoutePoint): number { + const lat1 = a.lat * DEG_TO_RAD; + const lat2 = b.lat * DEG_TO_RAD; + const dLng = (b.lng - a.lng) * DEG_TO_RAD; + const y = Math.sin(dLng) * Math.cos(lat2); + const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng); + return (Math.atan2(y, x) / DEG_TO_RAD + 360) % 360; +} + +/** A maneuver register entry: the carded kind, its geometry vertex (the + * streetAt argument) and its meters-along-the-line. */ +interface TurnEntry { + kind: GlyphKindName; + vertexIdx: number; + alongM: number; +} + +/** + * The maneuver register from geometry ALONE (the envelope carries no steps + * field): depart at the origin, one classified entry per bearing-change + * vertex (classifyTurn is the ONE turn authority — "straight" results are + * cardable CONTINUE-ON maneuvers), arrive at the drawn end. + */ +function buildTurnRegister(points: readonly RoutePoint[], cumulative: readonly number[]): TurnEntry[] { + const register: TurnEntry[] = [{ kind: "depart", vertexIdx: 0, alongM: 0 }]; + let prevBearing: number | null = null; + let prevSegEnd = -1; + for (let i = 0; i < points.length - 1; i++) { + const segM = cumulative[i + 1]! - cumulative[i]!; + if (segM < DEGENERATE_SEGMENT_M) continue; // sub-m noise — no bearing, no maneuver + const bearing = initialBearingDeg(points[i]!, points[i + 1]!); + if (prevBearing !== null) { + register.push({ + kind: classifyTurn(prevBearing, bearing), + vertexIdx: prevSegEnd, + alongM: cumulative[prevSegEnd]!, + }); + } + prevBearing = bearing; + prevSegEnd = i + 1; + } + register.push({ + kind: "arrive", + vertexIdx: points.length - 1, + alongM: cumulative[cumulative.length - 1]!, + }); + return register; +} + +/** "{n} M" / "{n.n} KM" — the app's instrument distance formats (UI-SPEC). */ +function formatDistance(m: number): string { + const meters = Math.max(0, Math.round(m)); + if (meters < 1000) return `${meters} M`; + return `${(meters / 1000).toFixed(1)} KM`; +} + +/** The 24-hour HH:MM arrival clock, KL wall time (see KL_UTC_OFFSET_S). */ +function etaClock(etaSec: number): string { + const at = new Date((etaSec + KL_UTC_OFFSET_S) * 1000); + const hh = String(at.getUTCHours()).padStart(2, "0"); + const mm = String(at.getUTCMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; +} + +/** + * Project one guidance envelope at `nowSec` into its turn-card decision — + * PURE: no KV, no R2, no Telegram, no clock reads. + * + * - progress: linear along the snapshot (elapsed/duration → distanceM), + * clamped to [0, distanceM] + * - dead trip (progress ≥ distanceM) → due false, no card (D-13) + * - cadence: nowSec % CARD_CADENCE_S < SCAN_TICK_S (D-11, stateless) + * - next maneuver: the FIRST register entry whose along-distance ≥ progress + * ("straight" results included — CONTINUE ON is a real card); none → the + * "straight" fallback at register index 00 (geometry/distance drift) + * - maneuver line: street present → "{verb} {STREET}"; null/absent resolver + * → the bare verb (honest absence, D-03) + */ +export function projectTurnCards( + envelope: GuidanceSessionEnvelope, + nowSec: number, + streetAt?: (vertexIdx: number) => string | null, +): TurnCard { + const snap = envelope.route_snapshot; + let points: RoutePoint[]; + try { + points = decodePolyline6(snap.geometry); + } catch { + return noCard(); // malformed geometry — the mint-side validation refuses it; honest skip + } + if (points.length < 2) return noCard(); + + const elapsedS = nowSec - snap.startedAtSec; + const progressM = + snap.durationS > 0 + ? Math.max(0, Math.min(snap.distanceM, (snap.distanceM * elapsedS) / snap.durationS)) + : snap.distanceM; + if (progressM >= snap.distanceM) return noCard(); // the trip would have ended — never card a dead trip + const due = nowSec % CARD_CADENCE_S < SCAN_TICK_S; + if (!due) return noCard(); // a non-due tick composes nothing — the card IS the due decision + + const cumulative = deriveCumulativeDistanceM(points); + const register = buildTurnRegister(points, cumulative); + const entry = register.find((e) => e.alongM >= progressM) ?? null; + const maneuverIdx = entry !== null ? register.indexOf(entry) : 0; + const glyph: GlyphKindName = entry !== null ? entry.kind : "straight"; + const vertexIdx = entry !== null ? entry.vertexIdx : points.length - 1; + + const loc = strings.en; + const street = streetAt ? streetAt(vertexIdx) : null; + const verb = street ? loc[CARD_ACTION_KEYS[glyph]] : loc[CARD_ACTION_BARE_KEYS[glyph]]; + // The line's number: the carded turn's remaining meters — or, on the + // overshoot fallback, the trip's remaining meters (the only honest number). + const distToTurn = entry !== null ? entry.alongM - progressM : snap.distanceM - progressM; + + const remainingM = snap.distanceM - progressM; + const remainingS = (snap.durationS * remainingM) / snap.distanceM; + const caption = [ + `${GUIDE_REGISTER}${String(maneuverIdx).padStart(2, "0")}`, + `${formatDistance(distToTurn)} — ${verb}${street ? ` ${street.toUpperCase()}` : ""}`, + loc.cardEtaTemplate.replace("{time}", etaClock(nowSec + remainingS)).replace("{km}", (remainingM / 1000).toFixed(1)), + loc.cardHonesty, + ].join("\n"); + return { due, glyph, caption, maneuverIdx }; +} + +/** + * The graph→street resolver factory — PURE: graph + decoded points in, + * resolver out; snapToGraph + nameAt imported from @araharah/routing, zero + * I/O. The cron wiring builds ONE resolver per carded envelope (its own + * geometry) and calls it ONLY for the card turn's vertex — one snap per + * carded tick, never a walk of the whole line (the bounded-snapping law; + * the CPU tradeoff is a named ratification item in 06-08's checkpoint). + */ +export function makeStreetAt( + graph: RoadGraph, + points: readonly RoutePoint[], +): (vertexIdx: number) => string | null { + return (vertexIdx: number): string | null => { + const point = points[vertexIdx]; + if (!point) return null; + const snapped = snapToGraph(graph, point); + if (snapped === null) return null; // snap miss — honest absence (D-03), never a guessed name + if (snapped.nameId === null) return strings.en.cardStreetUnnamed; // matched but unnamed — instrument fallback + return graph.nameAt(snapped.nameId) ?? null; // the matched segment's nameId — NEVER the node index + }; +} diff --git a/bot/src/i18n/strings.ts b/bot/src/i18n/strings.ts index c33bb9f..8a0bb80 100644 --- a/bot/src/i18n/strings.ts +++ b/bot/src/i18n/strings.ts @@ -49,6 +49,36 @@ export interface BotStrings { * both locales (legal attribution, not prose; same string as * web/src/i18n/strings.ts). */ osmAttribution: string; + /* ————— GUID-05 turn-card copy (06-07; D-12/D-13, UI-SPEC verbatim) ————— */ + + /** The street-BEARING maneuver verbs (the DEFAULT card line "{dist} — {verb} {STREET}"): + * en carries ONTO, ms carries KE. */ + cardActionDepart: string; + cardActionStraight: string; + cardActionLeft: string; + cardActionRight: string; + cardActionSlightLeft: string; + cardActionSlightRight: string; + cardActionUturn: string; + cardActionArrive: string; + /** The street-LESS degradation verbs (graph absent / snap miss — D-03's honest + * absence pattern): bare verbs, never a guessed name. */ + cardActionBareDepart: string; + cardActionBareStraight: string; + cardActionBareLeft: string; + cardActionBareRight: string; + cardActionBareSlightLeft: string; + cardActionBareSlightRight: string; + cardActionBareUturn: string; + cardActionBareArrive: string; + /** nameAt's unnamed id → the instrument fallback — still street-bearing. */ + cardStreetUnnamed: string; + /** The projected-ETA telemetry template ({time} → HH:MM, {km} → one-decimal km). */ + cardEtaTemplate: string; + /** D-13: the strongest claim the mechanism supports — never promises live tracking. */ + cardHonesty: string; + /** The inline button resuming a killed webview from its envelope (?g=, GUID-06). */ + cardOpenButton: string; } export type Locale = "en" | "ms"; @@ -71,6 +101,26 @@ export const strings: Record = { searchQueryTooLong: "That query is too long. Send just the place name.", searchOpenButton: "OPEN IN ARAHARAH", osmAttribution: "© OpenStreetMap contributors", + cardActionDepart: "DEPART", + cardActionStraight: "CONTINUE ON", + cardActionLeft: "TURN LEFT ONTO", + cardActionRight: "TURN RIGHT ONTO", + cardActionSlightLeft: "KEEP LEFT ONTO", + cardActionSlightRight: "KEEP RIGHT ONTO", + cardActionUturn: "MAKE A U-TURN ONTO", + cardActionArrive: "ARRIVE AT", + cardActionBareDepart: "DEPART", + cardActionBareStraight: "CONTINUE", + cardActionBareLeft: "TURN LEFT", + cardActionBareRight: "TURN RIGHT", + cardActionBareSlightLeft: "KEEP LEFT", + cardActionBareSlightRight: "KEEP RIGHT", + cardActionBareUturn: "MAKE A U-TURN", + cardActionBareArrive: "ARRIVE", + cardStreetUnnamed: "UNNAMED ROAD", + cardEtaTemplate: "ETA {time} · {km} KM LEFT", + cardHonesty: "Best-effort projection — updates while the app is minimized.", + cardOpenButton: "OPEN GUIDANCE", }, ms: { startIntro: @@ -89,6 +139,26 @@ export const strings: Record = { searchQueryTooLong: "Pertanyaan itu terlalu panjang. Hantar nama tempat sahaja.", searchOpenButton: "OPEN IN ARAHARAH", osmAttribution: "© OpenStreetMap contributors", + cardActionDepart: "MULA", + cardActionStraight: "TERUS KE", + cardActionLeft: "BELOK KIRI KE", + cardActionRight: "BELOK KANAN KE", + cardActionSlightLeft: "SEDAKIT KIRI KE", + cardActionSlightRight: "SEDAKIT KANAN KE", + cardActionUturn: "BUAT PUSARAN KE", + cardActionArrive: "TIBA DI", + cardActionBareDepart: "MULA", + cardActionBareStraight: "TERUS", + cardActionBareLeft: "BELOK KIRI", + cardActionBareRight: "BELOK KANAN", + cardActionBareSlightLeft: "SEDAKIT KIRI", + cardActionBareSlightRight: "SEDAKIT KANAN", + cardActionBareUturn: "BUAT PUSARAN", + cardActionBareArrive: "TIBA", + cardStreetUnnamed: "JALAN TANPA NAMA", + cardEtaTemplate: "ETA {masa} · {km} KM LAGI", + cardHonesty: "Unjuran cuba terbaik — dikemas kini semasa aplikasi diminimumkan.", + cardOpenButton: "BUKA PANDUAN", }, }; diff --git a/bot/test/guidance-cron.test.mjs b/bot/test/guidance-cron.test.mjs index e03cc23..7041751 100644 --- a/bot/test/guidance-cron.test.mjs +++ b/bot/test/guidance-cron.test.mjs @@ -96,22 +96,22 @@ function envelope({ points, distanceM, durationS, startedAtSec, chatId = 101 }) /* ————— GC1 — the projection math (fixed nowSec, fake street resolver) ————— */ -test("GC1 projection: T+300 of 900 → the 90° left carded at T-01, 667 M, ETA 13:15 · 6.7 KM LEFT", async () => { +test("GC1 projection: elapsed 180 s of 900 → the 90° left carded at T-01, 2.0 KM, ETA 13:15 · 8.0 KM LEFT", async () => { const cron = await import("../src/guidance/cron.ts"); const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 300, () => "Jalan Sultan Ismail"); - assert.equal(card.due, true, "T0 % 180 === 0 → the window's first tick is due"); - assert.equal(card.glyph, "left", "the first classified turn ahead (progress 3333 < along 4000) is the 90° left"); + const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail"); + assert.equal(card.due, true, "T0 + 180 % 180 === 0 → the window's first tick is due"); + assert.equal(card.glyph, "left", "the first classified turn ahead (progress 2000 < along 4000) is the 90° left"); assert.equal(card.maneuverIdx, 1, "register: depart(00) → left(01)"); const lines = card.caption.split("\n"); assert.equal(lines.length, 4, "the anatomy is exactly four lines"); assert.equal(lines[0], "GUIDE // T-01", "instrument register, zero-padded index"); assert.equal( lines[1], - "667 M — TURN LEFT ONTO JALAN SULTAN ISMAIL", + "2.0 KM — TURN LEFT ONTO JALAN SULTAN ISMAIL", "maneuver line: street-bearing DEFAULT — {dist} — {verb} {STREET} (D-12)", ); - assert.equal(lines[2], "ETA 13:15 · 6.7 KM LEFT", "telemetry line from cardEtaTemplate: remaining 6667 m / 600 s"); + assert.equal(lines[2], "ETA 13:15 · 8.0 KM LEFT", "telemetry line from cardEtaTemplate: remaining 8000 m / 720 s"); assert.equal(lines[3], strings.en.cardHonesty, "honesty line verbatim (D-13)"); }); @@ -152,7 +152,7 @@ test("GC3 dead trip: elapsed ≥ duration → due false, no card — the cron ne test("GC4 caption law: ≤ 1024 chars, honesty verbatim, register zero-padded, no envelope string, D-13 banned words absent", async () => { const cron = await import("../src/guidance/cron.ts"); const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 300, () => "Jalan Sultan Ismail"); + const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail"); assert.ok(card.caption.length <= 1024, "sendPhoto's caption budget is 1024 chars"); assert.ok(card.caption.includes(strings.en.cardHonesty), "the honesty line rides verbatim"); assert.match(card.caption, /^GUIDE \/\/ T-\d{2}\n/m, "the register is GUIDE // T-{zero-padded nn}"); @@ -165,34 +165,34 @@ test("GC4 caption law: ≤ 1024 chars, honesty verbatim, register zero-padded, n test("GC5 street-less DEGRADATION: omitted resolver → the bare verb, same 4-line anatomy (honest absence, D-03)", async () => { const cron = await import("../src/guidance/cron.ts"); const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 300); // no streetAt + const card = cron.projectTurnCards(env, T0 + 180); // no streetAt assert.equal(card.due, true); assert.equal(card.glyph, "left"); const lines = card.caption.split("\n"); assert.equal(lines.length, 4, "the degraded form keeps the anatomy"); - assert.equal(lines[1], "667 M — TURN LEFT", "bare verb — street-less honest absence, never a guessed name"); + assert.equal(lines[1], "2.0 KM — TURN LEFT", "bare verb — street-less honest absence, never a guessed name"); }); test("GC5b street resolver returning null → the bare verb too (a snap miss is honest absence)", async () => { const cron = await import("../src/guidance/cron.ts"); const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 300, () => null); + const card = cron.projectTurnCards(env, T0 + 180, () => null); const lines = card.caption.split("\n"); - assert.equal(lines[1], "667 M — TURN LEFT", "null street → the bare-verb degradation"); + assert.equal(lines[1], "2.0 KM — TURN LEFT", "null street → the bare-verb degradation"); }); test("GC5c a 180° reversal carded as uturn — classifyTurn is the ONE turn authority", async () => { const cron = await import("../src/guidance/cron.ts"); const p0 = { lat: KL_LAT, lng: KL_LNG }; - const p1 = { lat: KL_LAT, lng: KL_LNG + 2000 / M_PER_DEG_LNG }; // east, bearing 90 + const p1 = { lat: KL_LAT, lng: KL_LNG + 2500 / M_PER_DEG_LNG }; // east, bearing 90 const p2 = { lat: KL_LAT, lng: KL_LNG }; // back west, bearing 270 → delta +180 → uturn - const env = envelope({ points: [p0, p1, p2], distanceM: 4000, durationS: 360, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 90, () => "Jalan Sultan Ismail"); + const env = envelope({ points: [p0, p1, p2], distanceM: 5000, durationS: 400, startedAtSec: T0 }); + const card = cron.projectTurnCards(env, T0 + 180, () => "Jalan Sultan Ismail"); assert.equal(card.glyph, "uturn", "delta 180 ≥ TURN_UTURN_MIN_DEG 150 → uturn"); assert.equal(card.maneuverIdx, 1); assert.equal( card.caption.split("\n")[1], - "1000 M — MAKE A U-TURN ONTO JALAN SULTAN ISMAIL", + "250 M — MAKE A U-TURN ONTO JALAN SULTAN ISMAIL", ); }); @@ -295,13 +295,13 @@ test("GC7 overshoot: progress past the drawn geometry (below distanceM) → stra const cron = await import("../src/guidance/cron.ts"); const points = threeLegPoints(); // drawn length ≈ 10000 m const env = envelope({ points, distanceM: 20000, durationS: 900, startedAtSec: T0 }); - const card = cron.projectTurnCards(env, T0 + 675, () => "Jalan Sultan Ismail"); // progress 15000 > geometry - assert.equal(card.due, true); + const card = cron.projectTurnCards(env, T0 + 720, () => "Jalan Sultan Ismail"); // progress 16000 > drawn + assert.equal(card.due, true, "T0 + 720 % 180 === 0 → due"); assert.equal(card.glyph, "straight", "no maneuver ahead on the drawn line → the straight fallback"); assert.equal(card.maneuverIdx, 0, "the fallback register index is 00"); const lines = card.caption.split("\n"); assert.equal(lines[0], "GUIDE // T-00"); - assert.equal(lines[1], "5.0 KM — CONTINUE ON JALAN SULTAN ISMAIL", "the honest remaining trip distance is the line's number"); + assert.equal(lines[1], "4.0 KM — CONTINUE ON JALAN SULTAN ISMAIL", "the honest remaining trip distance is the line's number"); }); /* ————— GC8 — structural laws (comment-stripped source introspection) ————— */