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.
This commit is contained in:
tiennm99 committed 2026-04-21 21:35:26 +07:00
1 parent 02f85ef115
commit 90e6958d41
4 files changed
+56 -18

No files matched your search

+3
View File
@@ -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
+44 -2
View File
@@ -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
```
+8 -12
View File
@@ -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 },
],
},
{
+1 -4
View File
@@ -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}`));