Context
createCatalog() stores normalized pack records in a map and exposes stable
level IDs. getLevels() currently deep-clones every level, getLevel() calls
that full-catalog path before cloning the selected level again, and the stage
keeps levelNo as the primary application identity. main.js also calls
refreshIndex() from progress and win callbacks while packs may be replaced
or removed.
Decisions
Stable identity is the source of truth
The application shall store the active proof as activeLevelId, using the
existing ${packId}:${localId} value. A numeric index may remain an input
convenience for level selection, but it is resolved once and not retained as
the identity across catalog mutations. packId and localId remain unchanged
in returned levels and shared data.
When a selected level is replaced, the same stable ID remains active if the replacement still contains that local level. If it does not, selection falls back deterministically to the replacement pack's first level, then the first available level. Removing the active pack uses the same deterministic fallback.
Read boundaries and invalidation
The catalog shall maintain normalized internal records and a mutation generation. Register, replace, and remove invalidate only derived lookup views affected by that generation. A single-level read resolves the requested record directly. Full-list reads retain their current fresh-clone behavior so callers cannot mutate catalog state through returned objects.
The optimization must not return a mutable cached object to callers. Any internal cache stores canonical normalized data or immutable derived metadata; public reads still clone at the boundary. Tests shall prove that mutating one returned level, pack, or list does not affect a later read.
Application handoffs
loadLevel() resolves and records the stable level ID before creating the
game. Progress writes use that level's packId and localId. Win and
continue callbacks capture the stable ID and verify it still resolves before
showing a transition. Catalog replacement/removal refreshes the index and
re-resolves the active ID before deciding whether to load a fallback.
Compatibility and failure behavior
Existing methods and return shapes remain compatible. Unknown stable IDs still
return undefined from reads and setActiveLevel returns false. A removed
active ID is never left pointing at an unavailable level. Level ordering stays
registration order; identity resolution does not depend on array position.
Verification evidence
50e4d68 uses a direct ID map for single-level reads and invalidates cached
pack summaries on catalog mutations. In a 64-level catalog, 2,000 reads of the
last level took 16.55 ms through getLevel(id) versus 1,079.7 ms through the
prior getLevels().find(...) path on the same local run (65.2×). The latter
comparison includes the old full-list cloning cost; it is an architecture
comparison, not a frame-time claim. The full Node suite passed 373/373,
production build passed, and the world-index and pack/share browser flows
passed without console or page errors.
The delayed Win handoff is guarded by both the stable level ID and the
specific game instance that created the timer. This prevents a replacement
pack from promoting an old solved game when it reuses the same local level ID,
and prevents a removed pack from promoting after catalog fallback. Focused
regression coverage in tests/ covers the valid handoff,
same-ID replacement, removal fallback, and undoing a win. The full Node suite
passed 377/377 and the production build passed after this correction.