fix: a chapter's first mile-marker post no longer arrives late

This commit is contained in:
2026-10-11 16:12:15 +03:00
parent 388289f2fe
commit 8c9ec69565
3 changed files with 119 additions and 9 deletions

View File

@@ -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).

View File

@@ -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<Record<MilestoneSection, number>> = { 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<number>();
@@ -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 {

View File

@@ -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<number, number>();
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);
});
});