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 source

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/level-identity.test.js 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.