clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / openspec / changes / archive / 2026-09-24-stabilize-active-level-identity / design.md
design.md 3.6K rendered
1## Context23`createCatalog()` stores normalized pack records in a map and exposes stable4level IDs. `getLevels()` currently deep-clones every level, `getLevel()` calls5that full-catalog path before cloning the selected level again, and the stage6keeps `levelNo` as the primary application identity. `main.js` also calls7`refreshIndex()` from progress and win callbacks while packs may be replaced8or removed.910## Decisions1112### Stable identity is the source of truth1314The application shall store the active proof as `activeLevelId`, using the15existing `${packId}:${localId}` value. A numeric index may remain an input16convenience for level selection, but it is resolved once and not retained as17the identity across catalog mutations. `packId` and `localId` remain unchanged18in returned levels and shared data.1920When a selected level is replaced, the same stable ID remains active if the21replacement still contains that local level. If it does not, selection falls22back deterministically to the replacement pack's first level, then the first23available level. Removing the active pack uses the same deterministic fallback.2425### Read boundaries and invalidation2627The catalog shall maintain normalized internal records and a mutation28generation. Register, replace, and remove invalidate only derived lookup29views affected by that generation. A single-level read resolves the requested30record directly. Full-list reads retain their current fresh-clone behavior so31callers cannot mutate catalog state through returned objects.3233The optimization must not return a mutable cached object to callers. Any34internal cache stores canonical normalized data or immutable derived metadata;35public reads still clone at the boundary. Tests shall prove that mutating one36returned level, pack, or list does not affect a later read.3738### Application handoffs3940`loadLevel()` resolves and records the stable level ID before creating the41game. Progress writes use that level's `packId` and `localId`. Win and42continue callbacks capture the stable ID and verify it still resolves before43showing a transition. Catalog replacement/removal refreshes the index and44re-resolves the active ID before deciding whether to load a fallback.4546### Compatibility and failure behavior4748Existing methods and return shapes remain compatible. Unknown stable IDs still49return `undefined` from reads and `setActiveLevel` returns `false`. A removed50active ID is never left pointing at an unavailable level. Level ordering stays51registration order; identity resolution does not depend on array position.5253## Verification evidence5455`50e4d68` uses a direct ID map for single-level reads and invalidates cached56pack summaries on catalog mutations. In a 64-level catalog, 2,000 reads of the57last level took 16.55 ms through `getLevel(id)` versus 1,079.7 ms through the58prior `getLevels().find(...)` path on the same local run (65.2×). The latter59comparison includes the old full-list cloning cost; it is an architecture60comparison, not a frame-time claim. The full Node suite passed 373/373,61production build passed, and the world-index and pack/share browser flows62passed without console or page errors.6364The delayed Win handoff is guarded by both the stable level ID and the65specific game instance that created the timer. This prevents a replacement66pack from promoting an old solved game when it reuses the same local level ID,67and prevents a removed pack from promoting after catalog fallback. Focused68regression coverage in `tests/level-identity.test.js` covers the valid handoff,69same-ID replacement, removal fallback, and undoing a win. The full Node suite70passed 377/377 and the production build passed after this correction.