Skip to content

Commit 5d0fc79

Browse files
CodeManEthanclaude
andcommitted
Genesis: polish pass — ledger cap, pending leak, mobile bar, shareable t
The ticker now keys on last-entry identity instead of log length, so lines keep advancing after the 60-entry cap (seed 38 pace 4 hits the cap at 17:18; verified stepping 17:19 -> 20:48 past it). The pre-built midnight world is dropped whenever t scrubs back below 23.5 instead of leaking ~35MB. Below 620px the transport pill wraps to two rows with a full-width scrubber and 40px targets — zero clipped controls at 360px. Paused player moments write ?t= (1 decimal, debounced) so a held scene is shareable; LIVE and playing keep clean URLs. Harness: generateMapUncached bypasses the LRU so the determinism check compares genuinely fresh generations again; npm run genesis:check / genesis:sweep wire both harnesses into package.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B7t8Qgo2z11gAegjSKAr7b
1 parent 7fe7402 commit 5d0fc79

4 files changed

Lines changed: 98 additions & 17 deletions

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
"dev": "astro dev",
1010
"build": "astro build",
1111
"preview": "astro preview",
12-
"astro": "astro"
12+
"astro": "astro",
13+
"genesis:check": "node scripts/genesis-stats.mjs && node scripts/genesis-timeline-stats.mjs",
14+
"genesis:sweep": "node scripts/genesis-stats.mjs --sweep 200"
1315
},
1416
"dependencies": {
1517
"@astrojs/react": "^6.0.1",

scripts/genesis-stats.mjs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import { dirname, join } from 'node:path';
1919
import { fileURLToPath } from 'node:url';
2020

2121
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
22-
const { generateMap, woodCharacter } = await import(join(root, 'src/components/designs/genesis/gen.ts'));
22+
const { generateMap, generateMapUncached, woodCharacter } = await import(
23+
join(root, 'src/components/designs/genesis/gen.ts')
24+
);
2325
const { TW } = await import(join(root, 'src/components/designs/genesis/types.ts'));
2426

2527
/* ---------------------------- renderer coverage --------------------------- */
@@ -288,10 +290,11 @@ function checks(seed, map, scale = 1) {
288290
check('e clears reference live trees', bad === 0, `${bad} dangling`);
289291
}
290292

291-
/* (f) determinism */
293+
/* (f) determinism — deliberately uncached, or the second call would be the
294+
very same object coming back out of generateMap's LRU. */
292295
{
293-
const a = JSON.stringify(generateMap(seed, scale));
294-
const b = JSON.stringify(generateMap(seed, scale));
296+
const a = JSON.stringify(generateMapUncached(seed, scale));
297+
const b = JSON.stringify(generateMapUncached(seed, scale));
295298
check('f deterministic', a === b, `${a.length} bytes`);
296299
}
297300

src/components/designs/genesis/TheGenesis.tsx

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ function paceIndex(p: number): number {
117117
return best;
118118
}
119119

120+
/**
121+
* A cheap identity for "what the ticker is currently showing".
122+
*
123+
* The ledger is capped, so once a busy day passes the cap its *length* stops
124+
* changing while new lines keep arriving — length alone would silently freeze
125+
* the ticker for the rest of the day. The last entry, on the other hand, is
126+
* new every time something is appended, and the ticker only ever shows the
127+
* tail, so this is exactly as sensitive as the display is.
128+
*/
129+
function logSig(log: { t: number; text: string }[]): string {
130+
const last = log[log.length - 1];
131+
return last ? `${log.length}|${last.t}|${last.text}` : '0';
132+
}
133+
/** Never equal to any real signature: forces the next push through. */
134+
const LOG_FORCE = '!';
135+
120136
const randomSeed = () => Math.floor(Math.random() * 4294967296) >>> 0;
121137
const stepSeed = (seed: number, d: 1 | -1) => ((seed + d + 4294967296) % 4294967296) >>> 0;
122138

@@ -205,7 +221,9 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
205221
const ladderRef = useRef<number[]>([1]);
206222
const dirtyRef = useRef(true);
207223
const reducedRef = useRef(false);
208-
const logLenRef = useRef(0);
224+
/** Identity of the ledger's last line, so "is there anything new?" survives
225+
* the log's own cap. See `logSig` below. */
226+
const logSigRef = useRef('');
209227
const seedRef = useRef(0);
210228
const paceRef = useRef(1);
211229

@@ -227,7 +245,15 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
227245
const q = url.searchParams;
228246
// A rolled-over day has left its deep-linked hour far behind; keeping it
229247
// would make a reload of this URL show something else entirely.
248+
//
249+
// Otherwise the hour is in the address bar exactly when it is a decision:
250+
// a *held* moment is a framing worth sharing, so it is written down.
251+
// LIVE and playback are not moments at all — they keep the URL clean, and
252+
// a link to them opens on the visitor's own clock.
230253
if (dropT) q.delete('t');
254+
else if (modeRef.current === 'player' && !playingRef.current) {
255+
q.set('t', clamp(tRef.current, 0, 24).toFixed(1));
256+
} else q.delete('t');
231257
if (nextSeed === todayRef.current) q.delete('seed');
232258
else q.set('seed', String(nextSeed >>> 0));
233259
if (nextPace === 1) q.delete('pace');
@@ -302,7 +328,7 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
302328
sceneRef.current = buildGenesisScene(world.map);
303329
ambRef.current = makeAmbient(pace0);
304330
settleAmbient(sceneRef.current, ambRef.current, snapRef.current);
305-
logLenRef.current = snapRef.current.log.length;
331+
logSigRef.current = logSig(snapRef.current.log);
306332
setLines(snapRef.current.log.slice(-3));
307333

308334
// Dev hooks for the screenshot harness: start the transport rolling at a
@@ -396,8 +422,9 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
396422

397423
const pushLog = () => {
398424
const snap = snapRef.current!;
399-
if (snap.log.length === logLenRef.current) return;
400-
logLenRef.current = snap.log.length;
425+
const sig = logSig(snap.log);
426+
if (sig === logSigRef.current) return;
427+
logSigRef.current = sig;
401428
setLines(snap.log.slice(-3));
402429
};
403430

@@ -413,7 +440,7 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
413440
// cheap, and it is the only way the derived state stays honest.
414441
snapRef.current = world.snapshotAt(world.map, world.timeline, t);
415442
resetAmbient(scene, amb, snapRef.current);
416-
logLenRef.current = -1;
443+
logSigRef.current = LOG_FORCE;
417444
} else {
418445
world.advance(snap, world.timeline, t);
419446
}
@@ -459,7 +486,7 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
459486
resetAmbient(scene, amb, snapRef.current);
460487
settleAmbient(scene, amb, snapRef.current);
461488
camRef.current = clampCam(fitCam());
462-
logLenRef.current = -1;
489+
logSigRef.current = LOG_FORCE;
463490
pushLog();
464491
setSeed(s);
465492
setValley(world.map.valleyName);
@@ -496,7 +523,7 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
496523

497524
// The ledger starts over: yesterday's closing line goes out with the
498525
// light, and the founding of the new valley fades in with the pre-dawn.
499-
logLenRef.current = -1;
526+
logSigRef.current = LOG_FORCE;
500527
pushLog();
501528
setSeed(nextSeed);
502529
setValley(world.map.valleyName);
@@ -608,7 +635,11 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
608635
if (raw >= 24) roll(stepSeed(seedRef.current, 1), raw - 24, false);
609636
else applyT(raw);
610637
}
611-
if (running && tRef.current >= PREGEN_AT) ensurePending(comingSeed());
638+
// A pre-built world is only worth its ~35MB of baked canvases while
639+
// midnight is actually coming. Scrub or pause back into the day and it is
640+
// dropped again; the next approach to 23:30 rebuilds it.
641+
if (tRef.current < PREGEN_AT) pending = null;
642+
else if (running) ensurePending(comingSeed());
612643
syncAmbient(scene, amb, snapRef.current!);
613644
if (running && !reducedRef.current) {
614645
clock += dt;
@@ -692,22 +723,36 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
692723
setMode('player');
693724
}, []);
694725

726+
/** Dragging the scrubber fires a change per pixel; the address bar only has
727+
* to catch up once the hand comes to rest. */
728+
const momentTimer = useRef(0);
729+
const syncMoment = useCallback(() => {
730+
window.clearTimeout(momentTimer.current);
731+
momentTimer.current = window.setTimeout(
732+
() => syncUrl(seedRef.current, paceRef.current, false),
733+
220
734+
);
735+
}, [syncUrl]);
736+
useEffect(() => () => window.clearTimeout(momentTimer.current), []);
737+
695738
const seek = useCallback(
696739
(t: number) => {
697740
detach();
698741
api.current?.applyT(t);
699742
setTDisp(clamp(t, 0, 24));
700743
api.current?.paint();
744+
syncMoment();
701745
},
702-
[detach]
746+
[detach, syncMoment]
703747
);
704748

705749
const togglePlay = useCallback(() => {
706750
detach();
707751
playingRef.current = !playingRef.current;
708752
setPlaying(playingRef.current);
709753
api.current?.paint();
710-
}, [detach]);
754+
syncMoment();
755+
}, [detach, syncMoment]);
711756

712757
const cycleSpeed = useCallback(() => {
713758
setSpeedIdx((i) => {
@@ -744,7 +789,9 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
744789
api.current?.applyT(wallClockHours());
745790
setTDisp(wallClockHours());
746791
api.current?.paint();
747-
}, []);
792+
// Back on the wall clock, so the deep-linked hour goes out of the URL.
793+
syncMoment();
794+
}, [syncMoment]);
748795

749796
useEffect(() => {
750797
const onKey = (e: KeyboardEvent) => {
@@ -1249,9 +1296,29 @@ const CSS = `
12491296
.gen-hint { display: none; }
12501297
}
12511298
1299+
/* A phone cannot hold the transport in one pill without clipping the end off
1300+
it, so the bar stops being a pill: the controls keep the top row, the day
1301+
gets the full width underneath to scrub along, and the buttons grow to
1302+
something a thumb can actually hit. */
12521303
@media (max-width: 620px) {
1304+
.gen-bar {
1305+
left: 10px;
1306+
right: 10px;
1307+
bottom: 12px;
1308+
transform: none;
1309+
max-width: none;
1310+
flex-wrap: wrap;
1311+
gap: 8px;
1312+
padding: 8px 10px;
1313+
border-radius: 20px;
1314+
}
12531315
.gen-clock { display: none; }
1254-
.gen-ticker { max-width: 62vw; bottom: 74px; }
1316+
.gen-ico { width: 40px; height: 40px; }
1317+
.gen-speed, .gen-pace, .gen-live { height: 40px; }
1318+
/* The one control that has to sit at the far end of the row. */
1319+
.gen-live { margin-left: auto; }
1320+
.gen-scrub { order: 2; flex: 1 0 100%; min-width: 0; height: 26px; }
1321+
.gen-ticker { max-width: 62vw; bottom: 108px; }
12551322
.gen-wid span { display: none; }
12561323
.gen-world button { font-size: 0.68rem; padding: 0 7px; }
12571324
.gen-corner { right: 10px; top: 10px; gap: 6px; }

src/components/designs/genesis/gen.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,15 @@ export function generateMap(seed: number, scale = 1): GenesisMap {
904904
return map;
905905
}
906906

907+
/**
908+
* The same world, built fresh every call and never cached. Only the test
909+
* harness wants this: a determinism check against `generateMap` would compare
910+
* an object with itself the moment the cache hits.
911+
*/
912+
export function generateMapUncached(seed: number, scale = 1): GenesisMap {
913+
return buildMap(seed, scale);
914+
}
915+
907916
function buildMap(seed: number, scale: number): GenesisMap {
908917
const rng = mulberry32(seed >>> 0);
909918
const S = clamp(scale, SCALE_MIN, SCALE_MAX);

0 commit comments

Comments
 (0)