From 8c9ec695652d58c682cfa29a361e2aaa950ca9b9 Mon Sep 17 00:00:00 2001 From: LucaP Date: Sun, 11 Oct 2026 16:12:15 +0300 Subject: [PATCH] fix: a chapter's first mile-marker post no longer arrives late --- docs/overnight/LOG.md | 2 +- src/scene/world/milestones.ts | 71 ++++++++++++++++++++++++++++++++--- tests/unit/milestones.test.ts | 55 ++++++++++++++++++++++++++- 3 files changed, 119 insertions(+), 9 deletions(-) diff --git a/docs/overnight/LOG.md b/docs/overnight/LOG.md index 9fe7abe..1340a42 100644 --- a/docs/overnight/LOG.md +++ b/docs/overnight/LOG.md @@ -108,5 +108,5 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page). - **Rules kept:** the road never runs backwards; scrolling back re-arms entries and their posts come up the road again. Predictions only reach the next entry either way and wait 0.3 s after a chapter change and 0.6 s after a sweep, so a navigator jump hands nothing out mid-flight; no glint while sweeping, at most one every 0.6 s. Chapter changes are linear 0.35/0.25 s screen-door ramps (no pop; Profile's left-shoulder posts fade as Experience's right-shoulder posts arrive). Plates stay under the bloom threshold; only the lamp blooms when close. Low tier/touch: no beam, pools, pulse or glint. Reduced motion and SSR untouched. - **Budgets:** initial JS 115.54 kB gz by Vite (+13 B gz: three more export bindings from route-log for the scene), scene chunk 225.05 kB gz (+2.1). 932 tests (79 files), violations []. - **Review (Sonnet, hardware GL):** per-frame allocations removed, non-finite distance guarded; featured plates now hang on a 4.2 m arm over the road (clears traffic at ~6.3 m) so the Experience plate is fully in frame at 1440. -- **Left:** the first entry of a chapter can arrive late when the reader starts scrolling right at the heading (transit from the haze is ~6-10 s at cruise). +- **Fixed after (opening entry):** a chapter's first entry used to arrive up to two entries late when the reader started at the heading, because only posts in the haze (~150 m out) could take an entry. Now, for 0.75 s after a chapter change, the posts already on the road stay dark; once the reading pace is known, the one that will pass closest to the first entry's activation takes it (the nearest when the reader is still), unless a post from the haze would be on time anyway. Haze assignments wait for that, and there is no glint in the 0.3 s after a change or on a post leaving its chapter. Unit tests show entry 0 now passes within 0.4 of an entry's share of its line (it used to miss by up to about 2 shares), and within 6.5 s when the reader stays at the heading. - Screenshots: docs/overnight/polish/posts-*.webp (before/after, idle contact strips before and after, feature plate zoom). diff --git a/src/scene/world/milestones.ts b/src/scene/world/milestones.ts index 2c8d490..6aa7f5e 100644 --- a/src/scene/world/milestones.ts +++ b/src/scene/world/milestones.ts @@ -44,6 +44,12 @@ export const MILESTONE_NEAR_Z = WORLD_NEAR_Z; export const MILESTONE_FAR_Z = MILESTONE_NEAR_Z - MILESTONE_SPACING * MILESTONE_SLOTS; /** Only a post this far up the road (still in the haze) can take a log entry. */ export const MILESTONE_ASSIGN_Z = -165; +/** + * Just after a chapter change the posts already on the road are still dark, + * so the nearer ones (up to here) may take the chapter's first entry too: + * it no longer has to wait for a post to come all the way from the haze. + */ +export const MILESTONE_LATE_ASSIGN_Z = -45; /** The free shoulder: the Experience panel is on the left, Profile's on the right. */ export const MILESTONE_SIDE: Readonly> = { experience: 9.5, profile: -9.5 }; /* The world is drawn ~2.3x life size (the road is 20 m wide), so a 2.8 m post is 6.4 m here. */ @@ -85,6 +91,10 @@ const STILL_SECONDS = 0.4; * scroll rate is not a reading pace yet. */ const SETTLE_CHANGE_SECONDS = 0.3; const SETTLE_SWEEP_SECONDS = 0.6; +/** After a chapter change, dark posts within reach wait this long for an entry before fading in. */ +const HOLD_SECONDS = SETTLE_CHANGE_SECONDS + STILL_SECONDS + 0.05; +/** The opening entry is placed once the reading pace is known (hold left, seconds). */ +const OPENING_AT = 0.1; const PULSE_GAIN = 0.08; const SECTIONS = Object.keys(MILESTONE_COUNTS) as MilestoneSection[]; /** Atlas: 2 x 4 feature cells (512 x 232) over a strip of ten digits. */ @@ -170,6 +180,8 @@ export function createMilestoneRoad(full: boolean) { let drift = 0; let still = 0; let settle = 0; + let hold = 0; + let opening = false; let sinceGlint = Number.POSITIVE_INFINITY; // Entries carried by posts on the road, per frame (refilled, never reallocated). const inFlight = new Set(); @@ -179,7 +191,8 @@ export function createMilestoneRoad(full: boolean) { })); const due = (slot: Slot, section: MilestoneSection, p: number): number => { - if (settle > 0) return -1; + // Not while a chapter has just begun: its opening entry is placed first (open_). + if (settle > 0 || (opening && hold > 0)) return -1; const count = MILESTONE_COUNTS[section]; const share = 1 / count; const open = (i: number): boolean => armed.has(i) && !inFlight.has(i); @@ -200,6 +213,43 @@ export function createMilestoneRoad(full: boolean) { return still >= STILL_SECONDS && open(here) ? here : -1; }; + /** + * A chapter has just begun: the opening entry need not wait for a post to + * come all the way from the haze. Of the posts still dark on the road, the + * one that would pass closest to the entry's activation takes it (nearest + * first on a tie, as when the reader is still at the heading). + */ + const open_ = (section: MilestoneSection, p: number, distance: number): void => { + const count = MILESTONE_COUNTS[section]; + const share = 1 / count; + let entry = milestoneIndex(p, count); + if (drift > IDLE_RATE && passPoint(entry, count) < p - DUE_TOLERANCE * share) entry += 1; + if (entry >= count || !armed.has(entry)) return; + for (const s of slots) if (s.section === section && s.entry === entry) return; + const point = passPoint(entry, count); + // Reading fast enough (or the road slow enough) that a post from the haze will be on time: leave it to them. + if (drift > IDLE_RATE && MILESTONE_PASS_Z - (speed * (point - p)) / drift < MILESTONE_ASSIGN_Z) return; + let best: Slot | null = null; + let miss = Number.POSITIVE_INFINITY; + let bestZ = Number.NEGATIVE_INFINITY; + for (let k = 0; k < slots.length; k += 1) { + const slot = slots[k]!; + const z = milestoneSlot(distance, k).z; + if (slot.section !== section || slot.entry >= 0 || slot.chapter >= 0.01 || z >= MILESTONE_LATE_ASSIGN_Z) continue; + const arrival = p + Math.max(0, drift) * Math.min(30, (MILESTONE_PASS_Z - z) / Math.max(speed, 1)); + const off = Math.abs(arrival - point); + if (off < miss - 1e-6 || (Math.abs(off - miss) <= 1e-6 && z > bestZ)) { + best = slot; + miss = off; + bestZ = z; + } + } + if (best === null) return; + best.entry = entry; + armed.delete(entry); + inFlight.add(entry); + }; + return { posts: (): readonly MilestonePost[] => posts, step({ section, p, distance, pulse, deltaSeconds }: MilestoneFrame): readonly MilestonePost[] { @@ -220,6 +270,8 @@ export function createMilestoneRoad(full: boolean) { const sweeping = rate > MILESTONE_FLASH_MAX_RATE; settle = Math.max(changed ? SETTLE_CHANGE_SECONDS : sweeping ? SETTLE_SWEEP_SECONDS : 0, settle - deltaSeconds); const live = p !== null && isMilestoneSection(section) ? section : null; + hold = changed && live !== null ? HOLD_SECONDS : Math.max(0, hold - deltaSeconds); + if (changed) opening = live !== null; // Every entry is armed again once the reader is well away from it. if (live !== armedFor) { armed.clear(); @@ -236,6 +288,10 @@ export function createMilestoneRoad(full: boolean) { if (changed || sweeping) armed.add(milestoneIndex(p, count)); } sinceGlint += deltaSeconds; + if (live !== null && p !== null && opening && hold > 0 && hold <= OPENING_AT) { + opening = false; + open_(live, p, distance); + } for (let k = 0; k < slots.length; k += 1) { const slot = slots[k]!; const { z, id } = milestoneSlot(distance, k); @@ -245,7 +301,8 @@ export function createMilestoneRoad(full: boolean) { slot.id = id; slot.section = live; slot.entry = -1; - slot.chapter = live === null ? 0 : 1; + // Born in the haze it is hidden anyway; nearer (the first frame) it fades in. + slot.chapter = live === null || z > MILESTONE_ASSIGN_Z ? 0 : 1; } slot.z = z; // An unclaimed or faded post that is still ahead joins the current chapter. @@ -253,9 +310,6 @@ export function createMilestoneRoad(full: boolean) { slot.section = live; slot.entry = -1; } - const target = live !== null && slot.section === live ? 1 : 0; - const ramp = deltaSeconds / (target > slot.chapter ? PRESENCE_IN_SECONDS : PRESENCE_OUT_SECONDS); - slot.chapter = target > slot.chapter ? Math.min(target, slot.chapter + ramp) : Math.max(target, slot.chapter - ramp); if (live !== null && p !== null && slot.section === live && slot.entry < 0 && z < MILESTONE_ASSIGN_Z) { slot.entry = due(slot, live, p); if (slot.entry >= 0) { @@ -263,9 +317,14 @@ export function createMilestoneRoad(full: boolean) { inFlight.add(slot.entry); } } + // Dark posts within reach stay dark while they may still be given the first entry. + const waiting = hold > 0 && slot.entry < 0 && slot.chapter < 0.01 && z < MILESTONE_LATE_ASSIGN_Z; + const target = live !== null && slot.section === live && !waiting ? 1 : 0; + const ramp = deltaSeconds / (target > slot.chapter ? PRESENCE_IN_SECONDS : PRESENCE_OUT_SECONDS); + slot.chapter = target > slot.chapter ? Math.min(target, slot.chapter + ramp) : Math.max(target, slot.chapter - ramp); const presence = slot.section === null ? 0 : slot.chapter * milestoneRangeFade(z); const feature = slot.entry >= 0 && presence > 0; - if (full && feature && crossed && !sweeping && sinceGlint >= GLINT_GAP_SECONDS) { + if (full && feature && crossed && !sweeping && settle <= 0 && slot.section === live && sinceGlint >= GLINT_GAP_SECONDS) { glints[k] = 1; sinceGlint = 0; } else { diff --git a/tests/unit/milestones.test.ts b/tests/unit/milestones.test.ts index a3a061a..37656ad 100644 --- a/tests/unit/milestones.test.ts +++ b/tests/unit/milestones.test.ts @@ -9,6 +9,8 @@ import { MILESTONE_ASSIGN_Z, MILESTONE_FAR_Z, MILESTONE_FLASH_MAX_RATE, + MILESTONE_LATE_ASSIGN_Z, + MILESTONE_NEAR_Z, MILESTONE_PASS_Z, MILESTONE_SIDE, @@ -200,7 +202,53 @@ describe('mile markers on the road', () => { expect(seen.filter((pass) => pass.reverse).map((pass) => pass.entry)).toEqual([2, 1, 0]); }); - it('only hands an entry to a post still far up the road, in the haze', () => { + it('does not keep the first entry waiting when the reader starts scrolling at the heading', () => { + for (const [section, rate, speed] of [ + ['profile', 0.03, 18], + ['profile', 0.05, 18], + ['experience', 0.03, 18], + ['experience', 0.05, 25], + ] as const) { + const count = MILESTONE_COUNTS[section]; + for (const lead of [0, 0.4, 1.7, 2.9]) { + const road = createMilestoneRoad(true); + // Cruising through the chapter before, then straight into reading from the heading. + const state = drive(road, 3 + lead, () => ({ section: 'start', p: null, speed })); + const seen: Array<{ entry: number; p: number }> = []; + let last: readonly number[] = road.posts().map((post) => post.z); + drive(road, 1 / rate, (t) => ({ section, p: Math.min(1, t * rate), speed }), state, (posts, _t, p) => { + posts.forEach((post, k) => { + if (post.entry >= 0 && post.presence > 0 && last[k]! < MILESTONE_PASS_Z && post.z >= MILESTONE_PASS_Z) { + seen.push({ entry: post.entry, p: p! }); + } + }); + last = posts.map((post) => post.z); + }); + expect(seen[0]?.entry).toBe(0); + // Within a post's spacing of the line (the road brings one every 60 m); it used to be up to two entries late. + expect(Math.abs(seen[0]!.p - passPoint(0, count))).toBeLessThan(0.4 / count); + } + } + }); + + it('sends the opening entry by soon when the reader stays at the heading', () => { + for (const lead of [0, 0.4, 1.7, 2.9]) { + const road = createMilestoneRoad(true); + const state = drive(road, 3 + lead, () => ({ section: 'start', p: null, speed: 18 })); + let first = Number.POSITIVE_INFINITY; + let last: readonly number[] = road.posts().map((post) => post.z); + drive(road, 12, () => ({ section: 'profile', p: 0, speed: 18 }), state, (posts, t) => { + posts.forEach((post, k) => { + if (post.entry === 0 && post.presence > 0 && last[k]! < MILESTONE_PASS_Z && post.z >= MILESTONE_PASS_Z) first = Math.min(first, t); + }); + last = posts.map((post) => post.z); + }); + // A post already on the road, not one from the haze (~8.5 s at this speed). + expect(first).toBeLessThan(6.5); + } + }); + + it('only hands an entry to a post still far up the road, or to a dark one as a chapter begins', () => { const road = createMilestoneRoad(true); const born = new Map(); drive(road, 30, (t) => ({ section: 'experience', p: Math.min(1, t * 0.03), speed: 30 }), { distance: 0 }, (posts) => { @@ -209,7 +257,10 @@ describe('mile markers on the road', () => { }); }); expect(born.size).toBeGreaterThan(0); - for (const z of born.values()) expect(z).toBeLessThan(MILESTONE_ASSIGN_Z); + // The opening entry may go to a nearer post that was still dark; every later one comes from the haze. + const zs = [...born.values()]; + expect(zs[0]!).toBeLessThan(MILESTONE_LATE_ASSIGN_Z); + for (const z of zs.slice(1)) expect(z).toBeLessThan(MILESTONE_ASSIGN_Z); }); });