From 90e6958d41d4f24ad54a32754ca1eb64dc1d196f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 21 Apr 2026 21:35:26 +0700 Subject: [PATCH] docs(nntv): document solver, viewport, and L12 easter egg Enable wall-light overlap lint (previously skipped); clean up remaining overlaps in L4, L5, L8, L11. Add solver and easter-egg sections to system-architecture.md. Extend code-standards.md review checklist with solvability requirements. --- docs/code-standards.md | 3 ++ docs/system-architecture.md | 46 ++++++++++++++++++++++- src/lib/levels/levels.js | 20 ++++------ src/lib/levels/levels.solvability.test.js | 5 +-- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/docs/code-standards.md b/docs/code-standards.md index caefcd1..e31b0db 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -276,3 +276,6 @@ audio.toggleMute(); - [ ] Touch input debounced/throttled if needed - [ ] Audio context lazily initialized (autoplay policy compliance) - [ ] Pixel color semantics (`--guard-*` CSS vars ↔ `NNTV.guard*`) kept in sync when modifying theme +- [ ] Any edit to `src/lib/levels/levels.js` runs `npm run test:solvability` and passes +- [ ] L12 remains unsolvable (solver asserts this — never "fix" it) +- [ ] No guard's `litCells` entry overlaps a wall cell in the same level diff --git a/docs/system-architecture.md b/docs/system-architecture.md index fe75ba1..6e3d732 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -25,11 +25,53 @@ ┌──────────────────────────────────────┐ │ Pure JS Game Engine (no framework) │ ├──────────────────────────────────────┤ - │ GridSystem │ Player │ Guards │ - │ TurnManager │ Levels │ Progress │ + │ GridSystem │ Player │ Guards │ + │ TurnManager │ LevelSolver │ Progress │ └──────────────────────────────────────┘ ``` +## Level Solver (Validation Tool) + +BFS-based solvability checker at `src/lib/game/level-solver.js`. Verdicts are +test-only — the solver is not bundled into the runtime. It reuses the real +`guards.js` + `princess-mechanic.js` AI so results match live gameplay. + +- **State key:** `(player.row, player.col, guardCaptures, princessCapture)` via + each guard's `capture()` method. Includes chaser positions and patroller + path indices. +- **Actions:** `up / down / left / right / wait` per turn. +- **Pruning:** after `turnManager.nextTurn` semantics, if player cell is lit + the branch dies. +- **Budget:** 5M states default, caller-overridable. Returns + `{ solvable, path?, reason?, states_explored }`. + +CI-enforced invariants in `src/lib/levels/levels.solvability.test.js`: +- L1–L11 must be solvable. +- L12 must remain unsolvable (Princess Chamber easter-egg rule). +- No guard may light a wall cell (pre-existing authoring bug guard). +- Exactly 12 levels exist. + +## Viewport (Camera-Follow) + +`src/components/GameBoard.svelte` is wrapped by a scrollable `board-container` +div in `Game.svelte` with `max-width/height: min(720px, 85vw, 85vh); overflow: auto`. +A `$effect` in `Game.svelte` watches `player.row/col` and calls `scrollTo` with +smooth behavior so the player stays roughly centered on grids larger than the +viewport. `cellSize` is fixed at 50 — grids no longer shrink cells. + +## L12 Easter Egg (Console Teleport) + +`Game.svelte` `onMount` exposes `window.__nntvDev`: + +```js +window.__nntvDev.teleport(row, col) // Instantly move player; if on goal, win +window.__nntvDev.reveal() // Read player/goal positions +``` + +Intentional escape hatch for L12 "Princess Chamber", which is unsolvable by +normal play. Teleporting to the goal fires the normal level-complete flow. +Cleaned up in `onDestroy`. Not documented in README or in-game UI. + ## Scene Flow ``` diff --git a/src/lib/levels/levels.js b/src/lib/levels/levels.js index 4e545df..3a0d514 100644 --- a/src/lib/levels/levels.js +++ b/src/lib/levels/levels.js @@ -150,8 +150,7 @@ export const LEVELS = [ type: "static", position: { row: 3, col: 3 }, litCells: [ - { row: 2, col: 3 }, { row: 3, col: 2 }, - { row: 3, col: 4 }, { row: 4, col: 3 }, + { row: 3, col: 2 }, { row: 3, col: 4 }, ], }, { @@ -166,8 +165,7 @@ export const LEVELS = [ type: "static", position: { row: 6, col: 7 }, litCells: [ - { row: 5, col: 7 }, { row: 6, col: 6 }, - { row: 6, col: 8 }, { row: 7, col: 7 }, + { row: 6, col: 6 }, { row: 6, col: 8 }, ], }, ], @@ -219,16 +217,14 @@ export const LEVELS = [ type: "static", position: { row: 7, col: 7 }, litCells: [ - { row: 6, col: 7 }, { row: 7, col: 6 }, - { row: 8, col: 7 }, + { row: 6, col: 7 }, { row: 8, col: 7 }, ], }, { type: "static", position: { row: 3, col: 8 }, litCells: [ - { row: 2, col: 8 }, { row: 3, col: 7 }, - { row: 3, col: 9 }, { row: 4, col: 8 }, + { row: 3, col: 7 }, { row: 3, col: 9 }, ], }, { @@ -452,8 +448,8 @@ export const LEVELS = [ position: { row: 5, col: 9 }, startState: true, litCells: [ - { row: 4, col: 9 }, { row: 5, col: 8 }, - { row: 5, col: 10 }, { row: 6, col: 9 }, + { row: 5, col: 8 }, { row: 5, col: 10 }, + { row: 6, col: 9 }, ], }, { @@ -695,8 +691,8 @@ export const LEVELS = [ type: "static", position: { row: 8, col: 2 }, litCells: [ - { row: 7, col: 2 }, { row: 8, col: 1 }, - { row: 8, col: 3 }, { row: 9, col: 2 }, + { row: 8, col: 1 }, { row: 8, col: 3 }, + { row: 9, col: 2 }, ], }, { diff --git a/src/lib/levels/levels.solvability.test.js b/src/lib/levels/levels.solvability.test.js index f399c91..9ee9cc5 100644 --- a/src/lib/levels/levels.solvability.test.js +++ b/src/lib/levels/levels.solvability.test.js @@ -69,10 +69,7 @@ describe('level metadata invariants', () => { expect(l12.isFinalLevel).toBe(true); }); - // Forward regression guard — several current levels (L2, L5 at least) have - // pre-existing wall-on-light overlaps. Full cleanup happens as each level is - // redesigned in its owning phase. Unskip in Phase 11 once all levels clean. - it.skip('no wall cell overlaps a guard lit cell (enable after all redesigns)', () => { + it('no wall cell overlaps a guard lit cell', () => { for (const lvl of LEVELS) { if (KNOWN_UNSOLVABLE_BUGS.has(lvl.id)) continue; const walls = new Set((lvl.walls ?? []).map(w => `${w.row},${w.col}`));