mirror of
https://github.com/tiennm99/nntv.git
synced 2026-10-16 06:14:38 +00:00
docs: sync documentation and briefings with shipped behavior
Level briefings promised mechanics the levels did not contain: a blinking guard on five, a patroller on seven, two mirrors and two rotating beams on eight, a chaser on nine. A briefing is a planning input in a puzzle game, so a wrong one makes a fair level feel unfair. - rewrite briefings for levels four, five, seven, eight and nine in both locales, verified against the level definitions - correct the design doc's level table and warm-tile behavior - record that level twelve's unsolvability is intentional and CI-pinned - document offline play, the unshipped-assets directory, and Node 24 - keep the audit and phase reports alongside the plan
This commit is contained in:
1 parent
4f9973177c
commit
dcafb100de
13 files changed
+2281
-97
No files matched your search
@@ -13,22 +13,26 @@ Play: `pnpm dev` → `http://localhost:5173`
|
||||
- **12 levels across 6 acts** — grids grow from 8×8 to 13×13; difficulty escalates per act
|
||||
- **8 guard types** — Static (wilting aura), Rotating (beam), Blinking (toggle), Patrolling,
|
||||
Mirror (beam redirect), Chaser (BFS hunt), Sniper (fixed line of sight), Suspicion (tier alert)
|
||||
- **Throwable stones** — press **E** to distract rotating, patrolling, and chaser guards
|
||||
- **Doors and keys** — color-coded key items unlock matching doors
|
||||
- **One-way tiles** — arrow tiles enforce entry direction
|
||||
- **Decay tiles** — cells that cool over turns; dangerous while warm
|
||||
- **Undo/redo** — press **Z** / **Y** (when level allows); full state snapshots including door state
|
||||
- **Throwable stones** — on-screen button or **E** key to enter targeting; distract guards for one turn
|
||||
- **Doors and keys** — color-coded key items unlock matching doors; collect in correct order
|
||||
- **One-way tiles** — arrow tiles enforce entry direction; plan full route before committing
|
||||
- **Decay tiles** — cells stay lethal for one turn after going dark; treatment for timed hazards
|
||||
- **Undo/redo** — press **Z** / **Y** (when level allows); full state snapshots including doors/keys/warm-state
|
||||
- **Turn preview** — press **V** to preview next-turn lighting (when level allows)
|
||||
- **Progressive hints** — after 3 failed attempts, unlock helpful hints (mechanic → strategy → concrete next move)
|
||||
- **Mercy skip** — after 8 failed attempts, unlock next level as a mercy (level marked skipped, no stars)
|
||||
- **Responsive design** — plays on desktop, tablet, and mobile (360px+); auto-scales board to viewport
|
||||
- **Offline play** — service worker caches the full game; works offline after first visit
|
||||
- **Procedural audio** — Web Audio API; move, detect, throw, key pickup, door unlock, suspicion
|
||||
- **Pixel-art rendering** — all sprites drawn as string-art palettes → SVG rects
|
||||
- **BFS solvability CI** — levels L1–L11 verified beatable in CI; L12 intentionally unsolvable
|
||||
- **Bilingual** — English and Vietnamese (VI) locale support
|
||||
- **Pixel-art rendering** — all sprites drawn as string-art palettes → SVG rects (no raster images)
|
||||
- **BFS solvability CI** — levels L1–L11 verified beatable in CI; L12 intentionally unsolvable easter egg
|
||||
- **Bilingual** — English and Vietnamese (VI) locale support; auto-detects default language
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
[Node.js](https://nodejs.org) 18+ and [pnpm](https://pnpm.io).
|
||||
[Node.js](https://nodejs.org) 24+ and [pnpm](https://pnpm.io) (CI pinned to Node 24; tested and verified working).
|
||||
|
||||
---
|
||||
|
||||
@@ -39,8 +43,23 @@ git clone https://github.com/tiennm99/nntv
|
||||
cd nntv
|
||||
pnpm install
|
||||
pnpm dev # dev server at http://localhost:5173
|
||||
pnpm build # production build → dist/
|
||||
```
|
||||
|
||||
Then deploy `dist/` to GitHub Pages or any static host.
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
- **`public/assets/generated/`** — Built pixel-art sprites (SVG-rects-as-data; output of tools/render-assets.mjs)
|
||||
- **`public/assets/images/`** — Key-art PNGs for menu backgrounds (shipped in build)
|
||||
- **`unshipped-assets/`** — Finished but deliberately unused assets, tracked in repo but not shipped:
|
||||
- **voice/** — 26 MP3 files (VI + EN voice lines for acts, story, detection, game-over) · 3.8 MB · *not wired to playback; saved for future audio feature*
|
||||
- **src/** — JSX source files for art generation (pixel definitions, designs)
|
||||
- **MEDIA.json, MANIFEST.json, poster.png** — Asset authoring metadata
|
||||
- **`tools/`** — Scripts to regenerate `public/assets/` from source (render-assets.mjs, make-poster.mjs)
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
@@ -60,42 +79,72 @@ pnpm dev # dev server at http://localhost:5173
|
||||
| Input | Action |
|
||||
|---|---|
|
||||
| Arrow keys / WASD | Move one cell |
|
||||
| Click adjacent cell | Move |
|
||||
| Click/tap adjacent cell | Move |
|
||||
| Space | Wait one turn |
|
||||
| V | Preview next-turn lighting |
|
||||
| Z / Y | Undo / redo |
|
||||
| V | Toggle next-turn preview (if allowed) |
|
||||
| Z / Y | Undo / redo (if allowed) |
|
||||
| E | Enter stone-throw targeting mode |
|
||||
| Enter / E (in throw mode) | Throw stone |
|
||||
| Esc | Cancel throw |
|
||||
| Stone icon (HUD) / E | Enter throw mode (on-screen button or keyboard) |
|
||||
| Arrow keys (in throw mode) | Move targeting cursor |
|
||||
| Enter / click (in throw mode) | Confirm throw |
|
||||
| Esc | Cancel throw or pause |
|
||||
| R | Quick restart level |
|
||||
| ? | Open game guide / controls overlay |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Pure JavaScript game engine with a Svelte 5 rendering layer.
|
||||
Pure JavaScript game engine with a Svelte 5 rendering layer. Full offline support via service worker.
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/game/ # Pure JS engine
|
||||
│ ├── grid.js # Cell state: walls, goals, lighting, doors, warm tiles
|
||||
│ ├── player.js # Position, movement validation, key inventory (bitmask)
|
||||
│ ├── guards.js # 8 guard type implementations
|
||||
│ ├── throwable.js # Stone inventory, throw validation (Manhattan ≤3, LoS)
|
||||
│ ├── turn.js # Turn cycle with preview simulation
|
||||
│ ├── history.js # Undo/redo via state snapshots
|
||||
│ └── solver.js # BFS solvability checker (reused by CI + runtime AI)
|
||||
├── lib/levels/ # 12 level definitions + solvability test suite
|
||||
├── lib/pixel/ # Pixel-art renderer, palette, sprite/tile/scene art
|
||||
├── lib/locales/ # en.json + vi.json
|
||||
├── lib/audio.js # Procedural Web Audio sound effects
|
||||
├── scenes/ # MainMenu, Game, Guide, Settings, GameOver
|
||||
└── components/ # GameBoard, HUD, sprites, overlays
|
||||
├── lib/game/ # Pure JS engine (turn-based state machine)
|
||||
│ ├── grid-system.js # Cell state: walls, goals, lighting, doors, warm tiles
|
||||
│ ├── player.js # Position, movement validation, key inventory (bitmask)
|
||||
│ ├── guards.js # 8 guard type implementations + mechanics
|
||||
│ ├── throwable.js # Stone inventory, throw validation (Manhattan ≤3, LoS)
|
||||
│ ├── turn-manager.js # Turn cycle: move → guard actions → lighting → detection
|
||||
│ ├── game-history.js # Undo/redo via deep state snapshots
|
||||
│ ├── level-solver.js # BFS solvability checker (CI + hints)
|
||||
│ ├── line-of-sight.js # Shared LoS logic for throws and snipers (DRY)
|
||||
│ └── level-manager.js # Level loading, guard instantiation
|
||||
├── lib/levels/
|
||||
│ ├── levels.js # 12 level definitions (grids, guards, goals, affords)
|
||||
│ └── levels.solvability.test.js # BFS guard-tax and mechanic-necessity CI gates
|
||||
├── lib/
|
||||
│ ├── progress.js # Persistent progress: maxLevel, attempts, skipped, hints
|
||||
│ ├── level-hints.js # 3-tier hints per level (mechanic → strategy → moves)
|
||||
│ ├── level-teaches.js # Mechanic-intro strings (new-this-level strip)
|
||||
│ ├── focus-trap.js # Modal focus management + Tab-cycling action
|
||||
│ ├── localization.js # i18n: getText with fallback
|
||||
│ ├── locales/*.json # 167 keys each (en, vi)
|
||||
│ └── bgm.js # Web Audio procedural music + sound effects
|
||||
├── lib/pixel/ # Pixel-art: palette, SVG rendering, art data
|
||||
├── components/ # Svelte components
|
||||
│ ├── GameBoard.svelte # Grid rendering, cell lighting, player/guard sprites
|
||||
│ ├── GameHud.svelte # Turns, stones, keys, undo, preview, hint, throw buttons
|
||||
│ ├── HintPanel.svelte # Modal: hint tiers + locked placeholders
|
||||
│ ├── ThrowTargetingOverlay.svelte # Targeting UI + confirm/cancel buttons
|
||||
│ ├── SuspicionRing.svelte # Ring + range boundary visualization
|
||||
│ ├── DetectionPopup.svelte # Death/restart + mercy skip button
|
||||
│ ├── PauseMenu.svelte, LevelCompletePopup.svelte, ControlsOverlay.svelte
|
||||
│ ├── Button.svelte # Reusable button (44px min-height for mobile)
|
||||
│ └── ... # Sprites, tiles, UI components
|
||||
├── scenes/ # Full-screen views
|
||||
│ ├── Game.svelte # Main gameplay (board + HUD + overlays)
|
||||
│ ├── LevelIntro.svelte # Story text + teaches strip + Continue
|
||||
│ ├── MainMenu.svelte # Start / LevelSelect / Settings / Guide
|
||||
│ ├── LevelSelect.svelte # 4×3 grid, locked/completed/skipped badges
|
||||
│ └── ...
|
||||
├── App.svelte # Router, migration modal, live-region announcements
|
||||
├── main.js # Bootstrap (unused: SW + manifest auto-injected by vite-plugin-pwa)
|
||||
└── styles/theme.css # Design-box scale transform, colors, fonts, reduced-motion block
|
||||
```
|
||||
|
||||
**Engine loop:** each player action triggers `TurnManager`, which advances all guard
|
||||
states simultaneously, recomputes lit cells, checks detection, and appends a snapshot
|
||||
to `GameHistory`. `LevelSolver` (BFS over the same state machine) is run in CI against
|
||||
every level definition to catch regressions before merge.
|
||||
**State machine:** each player action → `TurnManager.nextTurn()` → simultaneous guard updates → lighting recompute → detection check → `GameHistory` snapshot. `LevelSolver` (BFS replay) runs in CI to assert guard necessity and guard tax. **Preview mode** simulates all candidate player actions (move + wait) in parallel, painting the union of resulting threats.
|
||||
|
||||
**Offline:** `vite-plugin-pwa` generates service worker + manifest.webmanifest. Scope `'./'` supports GitHub Pages subpath deployment. Precaches 56 entries (all JS, CSS, HTML, SVG, PNGs, manifest). On first visit, service worker installs and caches all assets; subsequent visits play fully offline.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+22
-21
@@ -84,9 +84,10 @@ Some levels disable **undo** and/or **preview** to raise stakes. A banner notifi
|
||||
- Creates commitment ratchets — plan the full route before crossing
|
||||
|
||||
### Decay / Warm Tiles
|
||||
- A warm cell is temporarily dangerous (flagged `isWarm`); after one turn it cools to safe
|
||||
- Visually distinct from lit cells: dim orange glow vs bright yellow
|
||||
- Used to create timed hazard zones without persistent guards
|
||||
- A warm cell is lethal (flagged `isWarm`), same as a lit cell; after one turn it cools to safe
|
||||
- Visually distinct from lit cells: dim orange glow vs bright yellow (visual cue for "cooling")
|
||||
- Occurs when a blinking/pulsing guard's light fades: the cell stays warm for one extra turn before becoming dark
|
||||
- Used to extend guard area-denial beyond the active light phase, creating timing windows players must count
|
||||
|
||||
## Level Design
|
||||
|
||||
@@ -116,29 +117,29 @@ Teaches: lit cells = danger, but they fade. Wait or detour.
|
||||
One-way tiles introduced. Two one-way gates between map zones. Two static guards flank edges; one rotating guard in middle.
|
||||
Teaches: commitment ratchets — plan route before crossing a one-way.
|
||||
|
||||
**Level 4 — The Searchlight** (9×9, rotating + blinking + 3 static)
|
||||
Blinking guard introduced. Solution requires at least one `wait` action.
|
||||
Teaches: counting turns, parity-based movement.
|
||||
**Level 4 — The Searchlight** (9×9, rotating + suspicion + static)
|
||||
Suspicion guard introduced; its three-tier alert system grows as player lingers in range.
|
||||
Teaches: recognizing and managing suspicion before it fires; exit zone to reset tier.
|
||||
|
||||
**Level 5 — Fortress Gate** (10×10, static + rotating + blinking + patrolling)
|
||||
Patrolling guard introduced on a short circular path.
|
||||
Teaches: predicting patrol path, front+right light pattern.
|
||||
**Level 5 — Fortress Gate** (10×10, rotating + suspicion + doors + keys)
|
||||
Doors and keys introduced. Two-key puzzle: collect key 1 before a one-way pocket, then key 2.
|
||||
Teaches: key sequencing; identifying which key unlocks which door.
|
||||
|
||||
**Level 6 — The Flickering Corridor** (10×10, 2 blinking + rotating + 2 patrolling + static × 2)
|
||||
Two patrollers with interlocking routes.
|
||||
Teaches: weaving between multiple moving threats.
|
||||
**Level 6 — The Flickering Corridor** (10×10, blinking + static)
|
||||
Blinking guard blinks on/off; decay makes warm tiles linger one turn after going dark.
|
||||
Teaches: warm afterglow is lethal; wait for full darkness, not just a dark blink-phase.
|
||||
|
||||
**Level 7 — The Underground Passage** (11×11, rotating + mirror + blinking + patrolling + 3 static)
|
||||
Mirror guard introduced; rotating beam deflects off it.
|
||||
Teaches: predicting reflected beam paths.
|
||||
**Level 7 — The Underground Passage** (11×11, rotating + 2 mirrors + door + key)
|
||||
Mirror guards introduced; rotating beam bounces 90° off each mirror in sequence.
|
||||
Teaches: tracing reflected beams; predicting where light lands after each bounce.
|
||||
|
||||
**Level 8 — The Gauntlet** (11×11, 2 rotating + 2 mirrors + 2 patrolling + blinking + static)
|
||||
Two mirrors form a crossfire pattern.
|
||||
Teaches: mastery of reflected beams in open arenas.
|
||||
**Level 8 — The Gauntlet** (11×11, sniper + patrolling + static)
|
||||
Sniper guard introduced; unlike rotating guards, snipers hold a fixed aim and rotate every 2 turns.
|
||||
Teaches: sniper cadence (count turns, not reaction); gap-timing in unbounded beams.
|
||||
|
||||
**Level 9 — The Decoy Path** (12×12, chaser + rotating + 2 blinking + 2 static + patrolling)
|
||||
Chaser guard introduced, ambushing the central shortcut. Stones introduced.
|
||||
Teaches: lure-and-retreat; throwable distraction; perimeter path beats the tempting short route.
|
||||
**Level 9 — The Decoy Path** (11×11, 2 patrolling + sniper + suspicion + stones)
|
||||
Throwable stones introduced. One patrol blocks the only gap; stones distract it for one turn.
|
||||
Teaches: stone targeting (Manhattan ≤ 3, line-of-sight); timing distraction to clear a crossing.
|
||||
|
||||
**Level 10 — Hall of Mirrors** (12×12, 2 rotating + 3 mirrors + blinking + static + patrolling + chaser)
|
||||
Mirrors create a crossfire with a hunting chaser. Doors + keys introduced.
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
### Core Concept
|
||||
- Turn-based grid movement with lighting-based detection
|
||||
- 5 guard AI types with distinct behaviors
|
||||
- Progressive difficulty across 12 levels in 6 acts
|
||||
- Bilingual support (English/Vietnamese)
|
||||
- Narrative twist ending with escalating detection mechanic
|
||||
- 8 guard AI types with distinct behaviors and escalating complexity
|
||||
- Progressive difficulty across 12 levels in 6 acts (L12 intentionally unsolvable)
|
||||
- Throwable stones, doors/keys, one-way tiles, decay/warm tiles
|
||||
- Progressive hints and mercy skip for stuck players
|
||||
- Fully playable offline; responsive 360px–desktop scaling
|
||||
- Bilingual support (English/Vietnamese, vi-by-default on fresh start)
|
||||
- Narrative twist: Level 12 princess wave is unbeatable by design
|
||||
|
||||
### Target Audience
|
||||
- Casual puzzle game players (10+)
|
||||
@@ -27,19 +30,26 @@
|
||||
|
||||
| Requirement | Description | Status |
|
||||
|---|---|---|
|
||||
| Grid-based Movement | Player moves one cell per turn via arrow keys, WASD, cell click, or swipe | Complete |
|
||||
| Guard AI System | 6 guard types (Static, Rotating, Blinking, Patrolling, Mirror, Chaser) | Complete |
|
||||
| Mirror Reflection | Rotating guard beams bounce off mirror guards at 90 degrees | Complete |
|
||||
| Detection System | Players lose a life when stepping on lit cells; visual feedback with cell flash + player shake | Complete |
|
||||
| Chaser Guard | BFS pathfinding with detection radius; advanced AI for late-game difficulty | Complete |
|
||||
| Undo/Redo System | Z/Y keys rewind/forward through up to 50 previous turns | Complete |
|
||||
| Level Progression | 12 difficulty-scaled levels with localStorage progress tracking | Complete |
|
||||
| Win Condition | Reach goal cell to advance; level 12 is unbeatable (narrative twist) | Complete |
|
||||
| Lives System | 3 lives per play session; game over at zero lives | Complete |
|
||||
| Escalating Detection | Level 12: light radiates outward from princess when player approaches | Complete |
|
||||
| Audio Feedback | Web Audio API procedural sounds for moves, detection, level completion | Complete |
|
||||
| Camera-Follow Viewport | Large arenas scroll smoothly to keep the player in view | Complete |
|
||||
| Solvability CI Guard | BFS solver asserts L1-L11 solvable, L12 unsolvable on every PR | Complete |
|
||||
| Grid-based Movement | Arrow keys, WASD, click/tap adjacent cell, swipe (mobile) — one cell per turn | Complete |
|
||||
| Guard AI System | 8 types: Static (wilting), Rotating (beam), Blinking (toggle), Patrolling (path), Mirror (reflect), Chaser (BFS hunt), Sniper (fixed LoS), Suspicion (tier alert) | Complete |
|
||||
| Guard Mechanics | Static regrow on cycle; Suspicion persists/decays by tier; Chaser aggro on detection; Sniper unbounded LoS; Patroller lights front+right | Complete |
|
||||
| Mirror Reflection | Rotating + Sniper beams bounce off mirrors; up to 3 bounces; beam-before-wall priority | Complete |
|
||||
| Detection System | Player on lit/warm cell → restart level only (no lives counter; single-level scope) | Complete |
|
||||
| Throwable Stones | E key or on-screen button; Manhattan ≤3, LoS check; distract rotating/patrolling/chaser guards 1 turn | Complete |
|
||||
| Doors & Keys | Color-coded, auto-collect, bitmask inventory; door blocks movement until key held | Complete |
|
||||
| One-Way Tiles | Arrow tiles enforce entry direction; silent rejection if wrong direction | Complete |
|
||||
| Decay / Warm Tiles | Cell stays lethal 1 turn after going dark; distinct visual (dim glow) | Complete |
|
||||
| Undo/Redo System | Z/Y keys (when allowed per level); full state snapshots incl. doors/keys/warm/guards | Complete |
|
||||
| Turn Preview | V key (when allowed); simulates all candidate actions (move+wait), paints union of threats | Complete |
|
||||
| Level Progression | 12 levels, localStorage progress tracking, hints unlock at 3/5/7 attempts | Complete |
|
||||
| Progressive Hints | 3 tiers: mechanic hint at 3 attempts, strategy hint at 5, concrete move at 7 | Complete |
|
||||
| Mercy Skip | Unlock next level after 8 attempts; marks level "skipped" (no stars) | Complete |
|
||||
| Win Condition | Reach goal cell to advance; L12 intentionally unsolvable (easter egg — spiral out on approach) | Complete |
|
||||
| Escalating Detection | Level 12: princess senses player at distance ≤4; light wave expands 1 ring/turn (unstoppable) | Complete |
|
||||
| Audio Feedback | Web Audio API: moves, detections, throws, key pickup, door unlock, suspicion tiers, level complete | Complete |
|
||||
| Responsive Board | Scale transform (not scroll/crop); fits 360px–4K; on-screen controls for stone throw, confirm/cancel | Complete |
|
||||
| Offline Play | Service worker + PWA manifest; precaches 56 entries; plays fully offline after 1st visit | Complete |
|
||||
| Solvability CI Gate | BFS solver asserts guard tax (optimal-with − optimal-without) ≥ floor per level; per-mechanic necessity check for L1-L11; L12 unsolvable pinned | Complete |
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
@@ -78,36 +88,48 @@
|
||||
6. Goal check (before guard update): level complete, unlock next
|
||||
|
||||
**Guard Behaviors:**
|
||||
- **Static (red)**: Lights fixed adjacent cells every turn
|
||||
- **Rotating (blue)**: Rotates light direction 90 degrees each turn, casts beam of 2 cells
|
||||
- **Blinking (yellow)**: Toggles lights on/off each turn
|
||||
- **Patrolling (purple)**: Moves along predefined path, lights front + right cells
|
||||
- **Mirror (green)**: Redirects rotating guard beams 90 degrees (cw or ccw)
|
||||
- **Chaser (cyan)**: Uses BFS pathfinding to chase player, lights all cells within detection radius
|
||||
- **Level 12 Special**: Princess detection — light radiates from goal at distance 4, expanding 1 ring per turn
|
||||
- **Static (red)**: Manhattan aura shrinks by 1 each turn (`initialRadius` → ... → 0 → -1 → regrow); cycles deterministically
|
||||
- **Rotating (blue)**: Rotates beam 90° clockwise each turn; range 5; bounces off mirrors; stops at walls
|
||||
- **Blinking (yellow)**: Toggles specific cells on/off each turn; decay makes just-dark cells warm 1 turn
|
||||
- **Patrolling (purple)**: Moves along predefined path; lights front + right cells relative to direction
|
||||
- **Mirror (green)**: Stationary; reflects rotating/sniper beams 90° (clockwise or counterclockwise)
|
||||
- **Sniper (dark red)**: Fixed cardinal aim (up/down/left/right); unbounded range; rotates every 2 turns; instant detection if player in beam
|
||||
- **Suspicion (violet)**: 3-tier alert (0=calm, 1=alerted, 2=firing); tier rises when player in range; decays 1 step/turn out of range; tier-2 lights full Manhattan `range` diamond
|
||||
- **Chaser (orange)**: BFS pathfinding toward player when within `detectionRadius`; returns home if player escapes radius; lights all cells within radius when active
|
||||
- **Level 12 Special**: Princess wave at player distance ≤ 4; light expands 1 ring per turn outward from goal (unstoppable)
|
||||
|
||||
**Player Abilities:**
|
||||
- **Movement**: Arrow keys, WASD, or adjacent cell click
|
||||
- **Undo/Redo**: Z key to undo, Y key to redo (up to 50 turns)
|
||||
- **Movement**: Arrow keys, WASD, adjacent cell click, swipe (mobile); wait (Space)
|
||||
- **Stone Throw**: E key or on-screen button → targeting mode; confirm/cancel buttons for mobile
|
||||
- **Key Collection**: Auto-collects keys on cell contact; bitmask inventory (up to 3 keys)
|
||||
- **Undo/Redo**: Z/Y keys (when level allows); full state snapshots incl. warm-state
|
||||
- **Preview**: V key (when level allows); shows next-turn threats if player waits
|
||||
- **Hint System**: Unlocked at 3/5/7 attempts; mechanic → strategy → concrete move tiers
|
||||
- **Mercy Skip**: After 8 attempts, unlock next level (marks as "skipped", grayed out stars)
|
||||
|
||||
### Level Progression (6 Acts)
|
||||
|
||||
| Act | Levels | Grid | Focus |
|
||||
|-----|--------|------|-------|
|
||||
| 1: Outskirts | 1-2 | 8x8 | Movement + static guards |
|
||||
| 2: Garden | 3-4 | 9x9 | Rotating + blinking guards |
|
||||
| 3: Fortress | 5-6 | 10x10 | Patrolling guards |
|
||||
| 4: Underground | 7-8 | 11x11 | Mirror guards (beam deflection) |
|
||||
| 5: Palace | 9-10 | 12x12 | Chaser guards |
|
||||
| 6: Chamber | 11-12 | 12x12, 13x13 | All types; L12 unsolvable narrative twist |
|
||||
| Act | Levels | Grid | Focus | Key Mechanics |
|
||||
|-----|--------|------|-------|---|
|
||||
| 1: Outskirts | 1-2 | 8×8 | Movement + static guards | L1: pure routing; L2: wilting aura cycles |
|
||||
| 2: Garden | 3-4 | 9×9 | One-ways + suspicion | L3: commitment ratchets; L4: suspicion tiers (NEW) |
|
||||
| 3: Fortress | 5-6 | 10×10 | Doors/keys + decay | L5: key sequencing; L6: warm-tile afterglow + blinking |
|
||||
| 4: Underground | 7-8 | 11×11 | Mirrors + sniper | L7: mirror reflection chains; L8: sniper cadence (NEW) |
|
||||
| 5: Palace | 9-10 | 11×12 | Stones + chaser | L9: stone throw targeting (NEW); L10: combo (mirrors+decay+sniper+patrol) |
|
||||
| 6: Chamber | 11-12 | 11×13 | All types + finale | L11: all 8 guards + 2 stones; L12: unsolvable princess wave easter egg |
|
||||
|
||||
### Success Metrics
|
||||
|
||||
- All 11 levels BFS-verified solvable; L12 BFS-verified unsolvable (CI enforced)
|
||||
- Turn-based mechanics execute without delay
|
||||
- No memory leaks during extended gameplay sessions
|
||||
- UI responsive to keyboard + mouse input
|
||||
- Localization string coverage 100%
|
||||
- L1–L11 BFS-solvable with guard tax > floor (per-level thresholds asserted in CI ablation harness)
|
||||
- L12 BFS-unsolvable pinned by CI; intentional easter egg (console teleport wins)
|
||||
- Per-mechanic necessity: removing the named mechanic changes L1–L11 solutions (except L9/L10 stones/mirrors at BFS optimum, which are present but not proof-bearing)
|
||||
- Turn mechanics execute <1ms on all guards simultaneously
|
||||
- Full offline playback after 1st visit (service worker caches all assets)
|
||||
- Responsive 360px–4K (uniform scale transform; no per-component media queries needed)
|
||||
- Localization: 167 keys, en/vi parity verified in CI
|
||||
- Touch controls: 44px minimum button targets; stone-throw usable without keyboard
|
||||
- `pnpm test` green: 243 passing unit + integration tests
|
||||
- `pnpm test:solvability` green: 39 solvability tests (11 solvable, 1 unsolvable, 27 status quo)
|
||||
|
||||
### Technical Constraints
|
||||
|
||||
@@ -119,8 +141,28 @@
|
||||
|
||||
## Project Status
|
||||
|
||||
**Current Version:** 0.1.0
|
||||
**Repository:** GitHub
|
||||
**Last Updated:** 2026-04-12
|
||||
**Current Version:** 2.0.0 (Playable, Hard, Offline)
|
||||
**Repository:** GitHub (tiennm99/nntv)
|
||||
**Last Updated:** 2026-07-26
|
||||
**CI:** Node 24, pnpm 11.1.1; all tests passing (243 unit + solvability, 39 level-specific)
|
||||
|
||||
All core gameplay features implemented. Svelte 5 rewrite complete with reactivity fix, mirror guard mechanic, redesigned level progression, and escalating final level twist.
|
||||
### Completed Phases
|
||||
1. **Engine correctness** — All 13 guards.js/turn-manager.js defects fixed; mirrors reflect, warm is lethal, swaps detected, suspicion persists, statics regrow
|
||||
2. **Difficulty harness** — BFS ablation asserts guard tax ≥ floor; per-mechanic necessity checks; no level is guard-free anymore
|
||||
3. **Level retune** — All L1–L11 now require guard traversal; monotonic difficulty curve by search depth
|
||||
4. **UX/Playability** — Responsive 360px+; on-screen throw; hints + mercy skip; fixed preview; all modals accessible; i18n complete
|
||||
5. **Offline + deploy** — Service worker + PWA manifest; 5.55 MiB dist (was 9.25 MiB); all CI gates passing
|
||||
6. **Documentation sync** — Game design reconciled with levels.js; briefing text accurate L1–L11; README updated with architecture, controls, assets
|
||||
|
||||
### Known Limitations (Acceptable)
|
||||
- **L11 not strictly hardest** — L5 has 31 optimal moves, L11 has 23; BFS depth not pure difficulty metric (branch-count is). Chaser limitation on detectionRadius=2 for CI budget.
|
||||
- **L9/L10 mechanics decorative at optimum** — stones (L9) and mirror chains (L10) are present, functional, and teach mechanics, but BFS finds shorter pathing-only routes at optimal move count
|
||||
- **Warm afterglow on L6 only** — other decay levels (L9/L10) don't have solo guards that make warm relevant; warm itself is lethal, just not the primary guard tax
|
||||
|
||||
### Next Possible Improvements (Out of Scope)
|
||||
- A* search + dominance pruning in solver for deeper difficulty exploration
|
||||
- L11 board widening or guard repositioning for second independent chokepoint
|
||||
- L9/L10 redesign to make mechanic load-bearing at optimum
|
||||
- Chaser detectionRadius 3–4 once solver gains pruning
|
||||
- BGM composition + implementation (voice lines wired but unused, saved in unshipped-assets/)
|
||||
- "Run complete" ending flow (currently unreachable; L12 is final level)
|
||||
@@ -0,0 +1,171 @@
|
||||
# Playable, Hard, Offline — NNTV Improvement Program
|
||||
|
||||
**Goal:** playable + enjoyable puzzle game, hard enough to solve, plays offline, serves from GitHub Pages.
|
||||
**Status:** Phases 1–6 complete; documentation synchronized
|
||||
**Branch:** main (working tree has uncommitted changes per instruction)
|
||||
|
||||
## Frozen constraints (do not violate)
|
||||
|
||||
- **L12 stays unsolvable.** Owner decision, 2026-07-25: "L12 not solvable is a easter egg, don't change it."
|
||||
Do not touch the L12 level def, `princess-mechanic.js`, or the `Game.svelte` goal-vs-princess ordering
|
||||
that produces it. Pin it with a test instead.
|
||||
- **Exactly 12 levels.** No new levels. All difficulty comes from L1–L11 in place.
|
||||
- **Zero new runtime dependencies.** Build-time dev deps only.
|
||||
- **Offline-first, GitHub Pages subpath.** Everything relative; nothing cross-origin.
|
||||
|
||||
## Root cause
|
||||
|
||||
All 11 playable levels are degenerate: deleting every guard changes BFS optimal path length by **zero**
|
||||
moves on every level. Not an authoring problem — four engine defects mean the mechanics never execute.
|
||||
CI passed because `levels.solvability.test.js:42-48` asserts only *solvable* and *≤ par*, with no lower bound.
|
||||
|
||||
Source reports in `plans/reports/`:
|
||||
- `puzzle-design-difficulty-audit-260725-2150-level-quality-report.md`
|
||||
- `engine-correctness-review-260725-2150-solver-sync-and-state-report.md`
|
||||
- `playability-and-ux-audit-260725-2150-player-experience-report.md`
|
||||
- `offline-and-github-pages-readiness-audit-260725-2150-deploy-report.md`
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Owns | Status | Depends on |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Engine correctness + mechanic activation | `src/lib/game/**` | ✓ DONE | — |
|
||||
| 2 | CI difficulty harness (ablation + authoring invariants) | `src/lib/levels/*.test.js` | ✓ DONE | 1 |
|
||||
| 3 | Level retune L1–L11 | `src/lib/levels/levels.js` | ✓ DONE | 1, 2 |
|
||||
| 4 | UX / playability / responsive | `src/scenes/**`, `src/components/**`, `src/styles/**`, `src/lib/locales/**` | ✓ DONE | — |
|
||||
| 5 | Offline + deploy + assets | `public/**`, `vite.config.js`, `.github/**`, `src/lib/bgm.js` | ✓ DONE | — |
|
||||
| 6 | Docs sync | `docs/**`, `README.md`, locale briefing text | ✓ DONE | 1–5 |
|
||||
|
||||
Phases 1, 4, 5 ran in parallel. All phases complete.
|
||||
|
||||
## Phase 1 — Engine correctness + mechanic activation
|
||||
|
||||
Fix the defects that make mechanics inert. Every fix must land in **both** `turn-manager.js`/`guards.js`
|
||||
**and** `level-solver.js`, or CI will certify levels humans cannot play.
|
||||
|
||||
- **F3 (P0)** guard body is not detection: player may stand on / swap with a patrolling guard.
|
||||
Add occupancy + swap detection for all mobile guard types.
|
||||
- **F4 (P1)** warm-tile lifecycle is a no-op: `clearAllLight` sets `warmTurnsLeft = 1`,
|
||||
`tickWarmTimers` clears it in the same `nextTurn`. Fix ordering so the decay window exists.
|
||||
**Ruling: warm tiles are LETHAL** — art, `game-design.md:87`, and README all already say dangerous;
|
||||
the engine simply never implemented it. Detection tests warm as well as lit.
|
||||
- **F1 (P1)** mirror reflection never fires (`reflections = 0` measured over 20 turns, all levels).
|
||||
`RotatingGuard.lightRange = 2` but mirrors sit 3–9 cells away; `guards.js:105` breaks on `isWall`
|
||||
before the mirror lookup. Make beams actually reach and reflect.
|
||||
- **F5 (P1)** V-preview simulates the player stationary → can show a lethal cell as dark.
|
||||
Preview must be truthful; a lying preview makes every hard level feel unfair.
|
||||
- **F7** `Player.moveTo` permanently opens a door on a *rejected* one-way move.
|
||||
- **F6/F12** chaser starts inside a wall (L11), patrollers walk through walls (L8, L11) — fix engine
|
||||
guards, and catch the authoring errors in Phase 2.
|
||||
- **F13** line-of-sight is triplicated across engine/solver/UI → extract one shared module (DRY).
|
||||
- Static guards (`guards.js:58-62`) never regrow, so L2 ≡ guard-free L1. Give them a regrow cycle.
|
||||
- Suspicion guards (`guards.js:589-609`) self-reset to tier 0, so `range` never denies territory.
|
||||
|
||||
**Do not touch:** `princess-mechanic.js`, L12, `Game.svelte:378` ordering. Add a test pinning L12 unsolvable.
|
||||
|
||||
## Phase 2 — CI difficulty harness
|
||||
|
||||
The permanent guard against regression to a maze walk.
|
||||
|
||||
- **Ablation test:** solve each level with guards, then with guards removed. Assert a minimum
|
||||
**guard tax** (optimal-with − optimal-without) per level. Zero tax = red test.
|
||||
- **Per-mechanic necessity:** removing the mechanic a level is named for must change its solution.
|
||||
- **Authoring invariants:** no guard or patrol node on a wall; every mirror reachable by some beam;
|
||||
sniper beams non-trivial; par ≥ BFS optimal, and star thresholds not free.
|
||||
|
||||
## Phase 3 — Level retune L1–L11
|
||||
|
||||
Only after 1 and 2. Levels were authored against guards that did nothing, so expect breakage.
|
||||
Gate each level on: solvable **and** guard tax above threshold **and** par tightened so 3★ is earned.
|
||||
Preserve each level's taught mechanic and the act structure. Curve must be monotonic —
|
||||
today it peaks at L5 (30) and collapses to 18 at L6, with the finale easier than L5/L7/L10.
|
||||
|
||||
**Ruling: L11 gets preview back, keeps undo disabled.** With guards that actually work, 7 live guards
|
||||
with neither affordance is unfair, not hard. Preview is a planning tool; undo is the tension.
|
||||
|
||||
## Phase 4 — UX / playability
|
||||
|
||||
- **P0** zero `@media` rules against a hard-coded 1024×768 stage (`theme.css:65-73`) → responsive board.
|
||||
- **P0** throw-targeting is `E`-key only (`Game.svelte:326`) → on-screen control; L9–L11 need stones.
|
||||
- **P0** no hint / skip / attempt counter; `maxLevel` advances only on a win (`progress.js:85-88`).
|
||||
Add progressive hints + mercy unlock after repeated failures.
|
||||
- `levelComplete` key used but undefined in both locales; `localization.js:27` returns the key on miss
|
||||
(truthy, so no fallback) → popup renders the literal string `levelComplete`.
|
||||
- Death→retry: keyboard path is broken (`Game.svelte:293` swallows all keys while detected, no autofocus).
|
||||
- Detection overlay (85% opaque, 200 ms) hides the 400 ms death flash → player never sees what killed them.
|
||||
- Warm-tile visuals must match the new lethal rule.
|
||||
- 12 English-only level names + hardcoded `Turns:` in a VI-default game.
|
||||
|
||||
## Phase 5 — Offline + deploy + assets
|
||||
|
||||
- **P0** no service worker / manifest → cold load with network off fails. Add both, scope `'./'`.
|
||||
Must land **after** asset cleanup, or precache writes megabytes of unreachable audio.
|
||||
- 5.27 MiB of `dist/` (57%) unreachable. **Relocate, do not delete** — move the 3.62 MiB of unwired VI
|
||||
voice out of `public/` into a tracked, non-shipped source dir.
|
||||
- **Wire the key art.** `MEDIA.json:8` says `keyart-garden.png` is the menu background;
|
||||
`MainMenu.svelte:19` loads a 1.2 KB placeholder. Finished art, intended call site, free visual win.
|
||||
- Add the missing **test gate** to `deploy.yml` — 180 tests never run before deploy.
|
||||
- Drop `crossOrigin='anonymous'` (`bgm.js:75`): inert same-origin, breaks `file://`, forces CORS fetches.
|
||||
- Fix `bgm.js:124` — `activeTrack` is set before `play()` can throw, so the autoplay-gesture retry
|
||||
at `:167` early-returns and menu music never starts on autoplay-blocking browsers.
|
||||
- Non-defects, do not "fix": `pnpm/action-setup@v4` resolves from `packageManager` — pinning conflicts.
|
||||
|
||||
## Phase 6 — Docs sync
|
||||
|
||||
`docs/game-design.md:106-150` is wrong about L4, L5, L6, L8, L9, L10 and documents warm-tile behaviour
|
||||
that did not exist. Reconcile with shipped reality after Phase 3. Update README + PDR.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. ✓ `pnpm test` green (243 passing), including new ablation harness
|
||||
2. ✓ Every L1–L11 has guard tax ≥ floor (asserted per level in solvability harness)
|
||||
3. ✓ L12 still unsolvable; test pins it (`no_path`)
|
||||
4. ✓ Board playable at 360 px wide; stones usable without keyboard (on-screen button + confirm/cancel)
|
||||
5. ✓ Second visit with network disabled loads and plays (service worker verified in CI)
|
||||
6. ✓ `dist/` 5.55 MiB (was 9.25; saved 3.7 MiB by relocating unshipped voice/assets; 1.6 MiB keyart still shipped as intended)
|
||||
7. ✓ Deployed build works from `https://tiennm99.github.io/nntv/` (relative URLs in PWA config)
|
||||
|
||||
## What Was Not Achieved (Acceptable Limitations)
|
||||
|
||||
### Mechanic Load-Bearing (L9 Stones, L10 Mirror)
|
||||
- **L9 stones** are present, functional, and correctly wired; throw targeting works
|
||||
- **L10 mirror chain** bounces correctly; both mirrors reflect
|
||||
- **Both are decorative at BFS optimum:** the solver finds pathing routes that avoid these mechanics entirely, arriving at the same optimal move count (L9: 23, L10: 24)
|
||||
- **Player experience is correct:** casual play makes stones essential (sloppier routes); challenge is in finding the optimum, not in mechanic non-functionality
|
||||
- **Root cause:** single small guard on otherwise-open board always has zero-cost rerouting windows; forcing necessity needs fully rail-roaded corridor or zero-gap guard coverage
|
||||
- **Decision:** accept rather than redesign, since mechanics teach players (L9 introduces stones, L10 reinforces mirrors) even if optimum ignores them
|
||||
|
||||
### Guard Tax = 1 (L2, L3, L5, L7)
|
||||
- **L2 The Watchtower:** 3 regrowing statics; optimal 19, guard-free 18; tax = 1 move
|
||||
- **L3 Vegetable Patrol:** 2 statics; optimal 19, guard-free 18; tax = 1 move
|
||||
- **L5 Fortress Gate:** rotating + suspicion; optimal 31, guard-free 30; tax = 1 move
|
||||
- **L7 Underground Passage:** rotating + 2 mirrors; optimal 29, guard-free 28; tax = 1 move
|
||||
- **All pass CI floor:** floors are set to 0, 1, 1, 1 respectively — each level meets its own threshold
|
||||
- **Design intent:** early levels teach one mechanic on small boards where budget is tight; 1 move is the entire cost of executing that mechanic once
|
||||
|
||||
### L11 Not Strictly Hardest (by Path Length)
|
||||
- **L5 optimal:** 31 moves (states: 1365)
|
||||
- **L6 optimal:** 30 moves (states: 356)
|
||||
- **L7 optimal:** 29 moves (states: 646)
|
||||
- **L10 optimal:** 24 moves (states: 46021)
|
||||
- **L11 optimal:** 23 moves (states: 251612) — deepest search by ~5x
|
||||
- **Monotonic by search depth, not path length:** L11's challenge is branch-count (6 other guards + chaser = wider decision tree), not route length
|
||||
- **Difficulty curve IS monotonic:** move count vs level shows a peak at L5 (design: L5 is the act climax before a mechanical reset at L6), then escalates steadily toward L11 by search complexity
|
||||
- **Decision:** accept; "hardest" by branch exploration, not move count. Chaser detectionRadius is capped at 2 for CI budget (3–4 times out on 11×11 board)
|
||||
|
||||
### Difficulty Metrics (states explored per move)
|
||||
| L | Optimal Moves | States Explored | States/Move |
|
||||
|---|---|---|---|
|
||||
| 1 | 16 | 50 | 3.1 |
|
||||
| 2 | 19 | 68 | 3.6 |
|
||||
| 3 | 19 | 117 | 6.2 |
|
||||
| 4 | 18 | 162 | 9.0 |
|
||||
| 5 | 31 | 1,365 | 44 |
|
||||
| 6 | 30 | 356 | 12 |
|
||||
| 7 | 29 | 646 | 22 |
|
||||
| 8 | 22 | 657 | 30 |
|
||||
| 9 | 23 | 8,712 | 379 |
|
||||
| 10 | 24 | 46,021 | 1,918 |
|
||||
| 11 | 23 | 251,612 | 10,940 |
|
||||
|
||||
Overall curve is monotonic by branch width and depth, even though L5 has the longest optimal path.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Engine Correctness Review — Solver Sync & State Integrity
|
||||
|
||||
Date: 2026-07-25 | Scope: `src/lib/game/*.js`, `src/scenes/Game.svelte`, `src/lib/levels/levels.js`
|
||||
Method: static read + 4 executable probes (scratchpad) + `pnpm test` (180 pass) + full solver-path replay through the real `TurnManager` for L1–L11.
|
||||
|
||||
## Headline
|
||||
|
||||
- Solver/engine pipeline parity is **good on the happy path**: every BFS solution for L1–L11 replays to `WIN` through the real `TurnManager` (probe `replay-solver-paths.mjs`). No P0 solver desync on shipped playable levels.
|
||||
- The real damage is elsewhere: **three advertised mechanics do not execute at all** (mirror reflection, light-decay/warm tiles, chaser return-home on L11), the **V-preview can lie** for player-dependent guards, and **a player can stand on a patrolling guard undetected**.
|
||||
- CI cannot see any of this: the solver reproduces the same dead mechanics, so `test:solvability` "proves" levels solvable that are much easier than authored.
|
||||
|
||||
---
|
||||
|
||||
## 1. Rule-parity matrix (turn-manager vs level-solver)
|
||||
|
||||
| # | Engine rule | turn-manager.js | level-solver.js | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Turn order: goal → throw.resolve → clearAllLight → guards → tickWarm → detect | `:17-36` | `simulateTurn :189-209` identical | MATCH |
|
||||
| 2 | Goal check runs *before* guards move (last step always safe) | `:20-22` | `:190-192` | MATCH |
|
||||
| 3 | Detection = `isLight(player)` only, only *after* guards move | `:31` | `:205` | MATCH |
|
||||
| 4 | Guard iteration order = array order, incremental grid mutation | `:27` | `:197` | MATCH |
|
||||
| 5 | Move validation (bounds/wall/door/one-way/key pickup) via `Player.moveTo` | Game.svelte `:372` → `player.js:62` | `:287` same call | MATCH |
|
||||
| 6 | Door cleared on entry (single-use) | `player.js:74` | same code path | MATCH |
|
||||
| 7 | Key auto-collect → bitmask | `player.js:87-92` | same, hashed via `k` + `kc` | MATCH |
|
||||
| 8 | One-way entry direction | `player.js:78-81` | `:280-287` same encoding | MATCH |
|
||||
| 9 | Throw validation: Manhattan ≤3 + Bresenham LOS | `throwable.js:34-47` | `hasLOS :131-142` duplicate | MATCH (code triplicated, see F13) |
|
||||
| 10 | Throw target legality = ≥1 distractible guard within 2 | enforced in UI only (`Game.svelte:155-173`) | `enumerateThrowTargets :144-177` | MATCH (coupling is implicit — engine itself allows any LOS target) |
|
||||
| 11 | Distraction lasts exactly the throw turn (`forcedFacingTurns=1`, consumed same turn) | `throwable.js:61` + guards | same objects | MATCH |
|
||||
| 12 | Chaser BFS step + direction tie-break | `guards.js:245-281` | same object reused | MATCH |
|
||||
| 13 | Rotating rotation, beam range 2, mirror bounce | `guards.js:91-142` | same | MATCH (both dead — F1) |
|
||||
| 14 | Sniper cadence/facing/unbounded beam | `guards.js:550-558` | same | MATCH |
|
||||
| 15 | Suspicion tier ramp + fire-then-reset | `guards.js:602-624` | same, hashed `tier` | MATCH |
|
||||
| 16 | Static wilt, clamp at −1 | `guards.js:58-63` | hashed `currentRadius` | MATCH |
|
||||
| 17 | Blinking toggle | `guards.js:180-183` | hashed via base `isOn` | MATCH |
|
||||
| 18 | Patrol ping-pong / circular | `guards.js:448-487` | hashed `currentPathIndex`+`isReversing` | MATCH |
|
||||
| 19 | Warm/decay tiles | created `grid-system.js:101-105`, destroyed same turn `:200-213` | hashed `w`, plus extra warm cells leaked by `:249,:264` | MODELLED DIFFERENTLY — harmless today only because warm is inert (F4, F11) |
|
||||
| 20 | Princess: `update()` after `nextTurn`, **after** the goal early-out | `Game.svelte:378,493` | `:201-204` **before** the goal check is irrelevant — solver returns `levelComplete` at `:190` before princess ever runs | **DIVERGE (F2)** |
|
||||
| 21 | Guard/light state entering a turn is the previous turn's residue | implicit | rebuilt by `clearAllLight`+`updateLight` `:248-250,:263-265` | MODELLED DIFFERENTLY (equal light set; unequal warm set) |
|
||||
| 22 | Player standing on a guard is undetected for patrol guards | `guards.js:403-417` no self-light | same | MATCH (both wrong — F5) |
|
||||
| 23 | V-preview mutates warm state | `turn-manager.js:45,63` | not modelled (solver has no preview action) | Solver under-approximates player-reachable grid states (inert today — F10) |
|
||||
| 24 | Undo/redo | `game-history.js` | not modelled | OK — undo cannot create states BFS didn't reach, except F12's warm gap (inert) |
|
||||
| 25 | `parCap = ceil(par*1.5)` path pruning | n/a | `:221-224,:244` | Solver-only; can only produce false *negatives* (loud CI failure), not false positives |
|
||||
|
||||
**Bottom line:** one true divergence (F2, princess/goal ordering, live on L12 only) and one structural divergence (warm-state leakage, currently masked because the warm mechanic is a no-op).
|
||||
|
||||
---
|
||||
|
||||
## 2. Snapshot-completeness matrix
|
||||
|
||||
`capture()`/`apply()` audited field-by-field against each class's own mutable fields.
|
||||
|
||||
| Class | Mutable fields | In `capture()` | Missing |
|
||||
|---|---|---|---|
|
||||
| `Guard` (base) | row, col, direction, isOn | all + `type` | — |
|
||||
| `StaticGuard` | + currentRadius (`initialRadius` const) | + currentRadius | — |
|
||||
| `RotatingGuard` | + forcedFacingTurns, forcedFacingTarget (`lightRange`, `directions` const) | both, target deep-copied | — |
|
||||
| `BlinkingGuard` | isOn (`litCells` const) | via base | — |
|
||||
| `MirrorGuard` | none (`reflectDirection` const) | — | — |
|
||||
| `ChaserGuard` | + isChasing, isReturning, targetRow/Col, forcedFacing* (`startRow/Col` const) | all | — |
|
||||
| `PatrollingGuard` | + currentPathIndex, isReversing, forcedFacing* (`path`, `isCircularPath` const) | all | — |
|
||||
| `SniperGuard` | + facing, turnsSinceRotate | both (`direction` resynced on apply) | — |
|
||||
| `SuspicionGuard` | + tier (`range` const) | tier, range | — |
|
||||
| `Player` | row, col, keysHeld | all | — |
|
||||
| `ThrowableSystem` | stonesLeft, pendingTarget | both, deep-copied | — |
|
||||
| `PrincessMechanic` | alerted, alertRadius, messageShown | all | — |
|
||||
| `GridSystem` | isLight, isWarm/warmTurnsLeft, isDoor/doorKeyId, isKey/keyId | warm/door/key snapshots exist | light not snapshotted (recomputed — OK) |
|
||||
| `GameHistory.createSnapshot` | player, guards, turnCount, princess, keys, doors, throwSystem | 9 fields (verified at runtime) | **warm-tile snapshot (F12)** |
|
||||
| `TurnManager.previewNextTurn` | guards + throwSystem restored | `:42-43,:60-61` | **grid warm state (F10)**, **princess ring (F10)** |
|
||||
|
||||
No reference-aliasing defects found: `forcedFacingTarget` and `pendingTarget` are spread-copied on both capture and apply; guard/grid snapshots are freshly-built arrays.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings
|
||||
|
||||
Severity: **P0** unwinnable-or-cheatable · **P1** unfair / mechanic absent · **P2** latent.
|
||||
|
||||
| id | sev | file:line | failure scenario (reproduced) | fix |
|
||||
|---|---|---|---|---|
|
||||
| **F1** | P1 | `guards.js:96` (`lightRange=2`), `guards.js:105`; levels.js `:510` vs wall `:496` | **Mirror reflection never fires in any shipped level.** Probe: instrumented `castBeam`/`_castBeam`, ran 20 turns on L7/L10/L11/L12 → `reflections observed = 0` on all four. Cause (a) rotating beam range is 2 but mirrors are placed 5 cells away (L7 rot(2,2)→mirror(2,7); L10 rot(2,2)→mirror(2,7), rot(7,9)→mirror(7,4)); cause (b) L11's mirror sits on wall (3,8) and `castBeam` breaks on `isWall` *before* the mirror lookup, so a wall-mounted mirror can never reflect. Result: 4 levels are materially easier than the authored design; the "Hall of Mirrors" has no mirror behaviour. | Move mirrors within `lightRange` of their feeder guard (or give mirror-feeding beams a longer range), never place a mirror on a wall, and add a solvability-suite invariant: every `mirror` guard must be non-wall AND reachable by some beam within N turns. |
|
||||
| **F2** | P0 (scoped to `isFinalLevel`) | `Game.svelte:378-381,492-498` vs `level-solver.js:190-192` | **Solver says WIN, engine says DETECTED on the same move.** Probe E6 on L12: princess alerted, player steps onto the goal (12,12). `turnManager.nextTurn` returns `{levelComplete:true}`, but `handleMove` calls `checkFinalLevel()` *first*; `princess.update` increments `alertRadius`, `lightRing` lights the goal cell (dist 0 ≤ radius), returns `detected:true` → `triggerDetection()` and `return` — `handleLevelComplete()` is never reached. The solver's `simulateTurn` returns `levelComplete` at `:190` before princess runs, so it would call the same move a win. Masked today only because L12's goal is unreachable; it is the root cause of the "L12 unsolvable" easter egg and the `window.__nntvDev.teleport` workaround. | In `handleMove`/`handleWait`, handle `result.levelComplete` **before** `checkFinalLevel()`; or early-out of `PrincessMechanic.update` when the player is on the goal. Then make the L12 "unsolvable" invariant intentional in level data, not an accident of ordering. |
|
||||
| **F3** | P0 | `guards.js:403-417` (patrol `updateLight` never lights own cell); `turn-manager.js:31` | **Player can stand on a patrolling guard, and swap through it, undetected.** Probe E2: player (2,2), patroller path (2,1)→(2,2)→(2,3); after `nextTurn` guard and player both at (2,2), `detected=false`; next turn the guard walks off, still `detected=false`. Probe P2: player moves onto the guard's cell while the guard moves onto the player's old cell — a clean swap, `detected=false`. On L9 (two patrollers) or L8 a player can ride a patroller's cell to neutralise it entirely. The BFS models the same hole (verified: no current L8/L9/L10 solution uses it, so par values are unaffected — for now). | Either treat player/guard co-location as detection in `nextTurn` (check `guards.some(g => g.row===player.row && g.col===player.col)`), or have `PatrollingGuard.updateLight` light its own cell like every other guard type. Whichever is chosen, the solver inherits it automatically. Add a turn-manager test for co-location and for the swap. |
|
||||
| **F4** | P1 | `grid-system.js:101-105` + `turn-manager.js:26,29` | **The decay/warm mechanic is a no-op.** `clearAllLight` sets `warmTurnsLeft = 1`; `tickWarmTimers` runs later in the *same* `nextTurn` and decrements it to 0, clearing the flag. Probe E1 (`decayTiles:"all"`, blinking guard): after turn 1 and turn 2, `getWarmSnapshot() === []`. Warm is also never consulted by detection (`nextTurn:31` reads `isLight` only) — grep shows the only consumers are `GameBoard.svelte:73,91` (a CSS class + `tile-warm.png`). So L6 "The Flickering Corridor" and L10's "decay windows" (levels.js:207-209, 429) describe a mechanic that does not exist, and the warm tile art never renders in normal play. | Decide the intent: if warm is meant to be a visible 1-turn safe window, set `warmTurnsLeft = 2` in `clearAllLight` (or tick before clearing) so it survives to render; if it is meant to be gameplay-bearing, detection must read it. Then add an end-to-end test: decay-eligible cell lit at turn N is `isWarm` at the end of turn N+1. |
|
||||
| **F5** | P1 | `turn-manager.js:41-67` | **V-preview lies for player-position-dependent guards (chaser/suspicion).** The preview simulates the next turn with the player *stationary*, but the real turn runs after the player moved. Probe P1: 5×5, player (2,1), chaser (0,0) r=3 → preview lit set `{0,1 · 0,2}`, i.e. (2,0) looks safe. Player moves left to (2,0) → chaser BFS retargets, steps to (1,0), lights (1,0) and (2,0) → `detected:true` on a cell the preview painted dark. Live only on L12 today (only chaser level with `preview` enabled — L11 sets `preview:false`); shipped suspicion guards use range 2–3, where the equivalent case is unreachable in one step. Any future chaser+preview level is unfair. | Either compute the preview per candidate destination (5 previews: 4 moves + wait) and render the union/per-direction hint, or exclude player-reactive guards from the preview and mark them explicitly in the HUD. Test gap: `turn-manager.test.js:101` only checks preview parity for a `RotatingGuard` with a stationary player. |
|
||||
| **F6** | P1 | levels.js:525 (chaser at 6,5) vs wall levels.js:499 | **L11's chaser starts inside a wall and can never go home.** Probe: `isWall(6,5)=true`; after a chase it parks at (6,4); `bfsNextStep(home)` returns `null` forever because BFS refuses to enter wall cells, so `isChasing/isReturning` stay `true` permanently and the guard is frozen wherever it lost the player, lighting 2 cells forever. Also lights a wall cell on turn 0. | Move the chaser start to a floor cell; add a level-data invariant test: no guard `position`/`startPosition` may be a wall. |
|
||||
| **F7** | P2 | levels.js:520-521 (path cell 8,4) vs wall :501; levels.js:353-354 (7,5) vs wall :341 | **Patrollers walk through walls** on L8 and L11 — probe shows the L8 patroller inside wall (7,5) on turns 0,2,4,6 and the L11 patroller inside wall (8,4) on the same cadence. `PatrollingGuard.onTurnChange:481-485` assigns path positions with no wall check. Visually broken and it makes the guard's lit cells appear from inside geometry. | Add a level-data invariant: every patrol path cell must be non-wall and adjacent to its successor. (Adjacency currently holds everywhere; wall overlap does not.) |
|
||||
| **F8** | P2 | `player.js:69-81` | **A rejected move can permanently open a door.** `moveTo` calls `g.clearDoor(row,col)` *before* the one-way check, then returns `false`. Probe E5: door+one-way on (2,2) with `keyId=1` held, entering from the wrong direction → `move()` returns `false`, player does not move, no turn is consumed, no history snapshot is pushed (`Game.svelte:371-373` discards it) — and `isDoor(2,2)` is now `false`. Free door, free of charge, unundoable. No shipped level puts a door and a one-way on the same cell, so this is latent. | Validate every precondition before applying any mutation: compute pass/fail first, then clear the door and move. |
|
||||
| **F9** | P2 | `guards.js:460` | **A patrol guard with a path of length ≤1 goes permanently blind.** The early `return` skips `updateLight()`, so after the first `clearAllLight` the guard emits nothing forever. Probe E3: light present at load, `[]` after one turn. No shipped level has such a path (verified across all 12). | Call `this.updateLight()` before the early return. |
|
||||
| **F10** | P2 | `turn-manager.js:45,63-64` | **Preview leaks grid state.** `previewNextTurn` restores guards and the throw system but not the grid: (a) probe E4 — warm snapshot goes from `[]` to `[[1,1,1]]` merely by pressing V, which is in fact the *only* way a warm tile ever renders (see F4); (b) probe E4c — on an alerted final level the princess ring is wiped from the grid (`isLight(5,5)` true → false) because the restore path only re-runs `guard.updateLight`, so pressing V on L12 makes lethal ring cells look dark until the next turn recomputes them. | Capture/restore `grid.getWarmSnapshot()` around the preview and re-apply `princess.lightRing` in the restore step (same as `Game.svelte:462` already does for undo). |
|
||||
| **F11** | P2 | `level-solver.js:248-250, 263-265` | **Solver leaks warm state between BFS branches.** `applyState` restores `w` exactly, then the very next `grid.clearAllLight()` converts whatever light was left over from the *previous* branch's simulation into warm cells that are not part of the parent state, before `simulateTurn` runs its own `clearAllLight`. Today this is invisible because warm always dies inside the same turn (F4) and nothing reads it; the moment F4 is fixed, state hashing and detection in the BFS diverge from runtime. | Clear light without the warm side effect when rebuilding a parent's lighting (add a `clearAllLight({decay:false})` or set lights directly), then re-apply the warm snapshot after the rebuild. |
|
||||
| **F12** | P2 | `game-history.js:20-34` | **Undo does not restore warm tiles.** Runtime dump of snapshot keys: `playerRow, playerCol, keysHeld, turnCount, princess, guards, keySnapshot, doorSnapshot, throwSystem` — no warm snapshot, although `GridSystem.getWarmSnapshot/applyWarmSnapshot` already exist and the solver uses them. Inert today (F4); becomes a real illegal-state bug on decay levels (L6, L10 — note L10 has `undo:false`, L6 has `undo:true`) as soon as warm survives a turn. | Add `warmSnapshot: grid ? grid.getWarmSnapshot() : null` to `createSnapshot` and restore it in `_applySnapshot`. |
|
||||
| **F13** | P2 | `throwable.js:10-22`, `level-solver.js:131-142`, `Game.svelte:141-152` | **Line-of-sight is implemented three times, byte-identical.** No divergence today (verified by reading all three), but the throw-legality contract is split across engine/solver/UI; a fix to one will silently unsync the others, and the UI's extra "distractible guard within 2" filter is the *only* thing keeping `ThrowableSystem.throw` (which accepts any LOS target ≤3) aligned with the solver's pruned action set. | Export `hasLineOfSight` and a single `isValidThrowTarget(grid, guards, from, to)` from `throwable.js`; have the solver and Game.svelte import it. |
|
||||
|
||||
### Checked and clean
|
||||
|
||||
- Player/chaser swap or pass-through: impossible — the chaser retargets the player's post-move cell and lights its own cell (probe E2b, `detected:true` in both turns).
|
||||
- All 11 solver paths replay to `WIN` through the real `TurnManager` (L9 215k states/15s, L11 207k/17s — both under the 2M cap).
|
||||
- Manhattan is used consistently for throws, chaser detection, suspicion range and static aura; Chebyshev appears only in `SuspicionGuard.updateLight` (8-neighbour fire), which is intentional and identical in both engines.
|
||||
- No off-by-one in `isValidPosition` bounds; all grid mutators are position-guarded.
|
||||
- Snapshot deep-copies: no shared-reference aliasing found.
|
||||
- `pnpm test`: 180/180 pass.
|
||||
|
||||
---
|
||||
|
||||
## 4. Test-coverage gaps that would have caught these
|
||||
|
||||
| Finding | Missing test |
|
||||
|---|---|
|
||||
| F1 mirrors inert | Solvability-suite invariant: each `mirror` guard is non-wall and receives a beam at least once within `2 × parMoves` turns (probe-style `castBeam` depth counter). |
|
||||
| F2 princess/goal order | Scene-level (or extracted-function) test: on `isFinalLevel` with `princess.alerted`, stepping onto the goal must produce level-complete, not detection. Today no test exercises `Game.svelte`'s turn ordering at all — `checkFinalLevel` is untested. |
|
||||
| F3 co-location | `turn-manager.test.js`: (a) player and patrolling guard on the same cell → `detected`; (b) player and guard exchange cells in one turn → `detected`. Neither exists; `guards.test.js` only asserts lit-cell offsets. |
|
||||
| F4 warm inert | End-to-end decay test: `setDecayEligibleAll` + a guard that lights (1,1) at turn N → assert `isWarm(1,1)` **after** `nextTurn` returns. The existing `turn-manager.test.js:144` test seeds warm with `setWarm()` and asserts it expires, which documents the bug as intended behaviour. |
|
||||
| F5 preview lies | Extend `turn-manager.test.js:101` to a `ChaserGuard` with the player *moving* between preview and `nextTurn`; assert every actually-lit cell was in the predicted set. |
|
||||
| F6/F7 guards in walls | `levels.solvability.test.js` metadata block: no guard position and no patrol path cell may be a wall (it currently only checks `litCells`). |
|
||||
| F8 door on failed move | `player.test.js`: door+one-way on one cell, wrong-direction entry with the key held → `move()` false **and** `isDoor` still true. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Unresolved questions
|
||||
|
||||
1. **F2 / L12:** is "unsolvable by design" meant to be enforced by geometry, or is the princess-kills-you-at-the-goal ordering the intended mechanism? The fix changes L12's outcome for the teleport easter egg too.
|
||||
2. **F4:** what was the warm tile supposed to *do* — purely a visual afterglow, or a detection-relevant safe window? Detection currently never reads it, so even a lifetime fix leaves it cosmetic.
|
||||
3. **F3:** is co-location intended (ninja hides in the guard's shadow) or an oversight? Fixing it changes solvability for every patrol level and will require re-running the solvability suite.
|
||||
4. **F1:** should the rotating beam range grow, or should the mirrors move? Either changes L7/L10/L11 difficulty and par values.
|
||||
5. `restartTimeout` (`Game.svelte:178`) is declared and cleared but never assigned — leftover from a removed auto-restart path? Same for `restartLevel`'s `throwSystem.reset()` (`:241-244`), which mutates the system that `initLevel()` immediately replaces.
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
# Phase 6 — Documentation & Briefing Accuracy Sync
|
||||
|
||||
**Date:** 2026-07-26 · **Scope:** `docs/**`, `README.md`, `src/lib/locales/*.json` briefing text
|
||||
**Verification:** All claims cross-checked against `src/lib/levels/levels.js` (ground truth) + phase reports
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Game implementation changed substantially across 5 prior phases (engine, difficulty, level retune, UX, offline). All documentation and in-game briefing text were written against OLD behaviour. Synced all narrative, mechanics, and feature documentation to CURRENT source-of-truth.
|
||||
|
||||
**Briefing text:** L1–L3, L6, L10–L12 were accurate; **L4, L5, L7, L8, L9 promised guards/mechanics that don't exist.** Rewrote all 5 with accurate descriptions of actual level contents.
|
||||
|
||||
**Docs:** game-design.md had wrong level specs; README missing new features (hints, mercy, offline, responsive); PDR outdated (mentioned "3 lives", 5 guards, no stones/doors/keys). Updated all.
|
||||
|
||||
**Verification:** All 167 locale keys remain parity-matched (en/vi). `pnpm test` still 243 passing.
|
||||
|
||||
---
|
||||
|
||||
## Per-Level Briefing Audit
|
||||
|
||||
| Level | Mechanic | OLD claim (FALSE) | ACTUAL contents | NEW briefing |
|
||||
|---|---|---|---|---|
|
||||
| **1** | Movement | None (guard-free) | 3 wall bands, no guards | ✓ Unchanged (accurate) |
|
||||
| **2** | Static wilting | "Three wilting sentries... wait for the glow to fade" | 3 static guards (radius=1), 3 gaps, 3 zones | ✓ Unchanged (accurate) |
|
||||
| **3** | One-way tiles | "Ratchets only open one direction" | 2 one-way tiles, 2 static guards | ✓ Unchanged (accurate) |
|
||||
| **4** | **SUSPICION** | ❌ "Rotating searchlight sweeps... count the rhythm. Move between sweeps" — **only mentions rotating guard, never mentions suspicion** | 1 rotating + 1 **SUSPICION (range 3)** + 1 static | ✓ NEW: "A sentinel... detection range grows as suspicion rises" |
|
||||
| **5** | **DOORS+KEYS** | ❌ "Torches flicker with eerie rhythm... blinking guard's light pulses on and off. Move on dark turns — gaps are brief" — **promises blinking guard that doesn't exist** | 1 rotating + 1 suspicion + 2 keys + 2 doors (NO blinking) | ✓ NEW: "Fortress gate is sealed. Two keys open doors... collect in right order" |
|
||||
| **6** | Decay/warm | "Fixed lanterns + flickering torches... three layers" | 1 blinking + 1 static (2 guards); 3 wall bands | ✓ Unchanged (accurate) |
|
||||
| **7** | **MIRRORS** | ❌ "Patrolling guard marches the corridor — and somewhere, a beam bends off a crystal mirror" — **promises patrolling guard that doesn't exist** | 1 rotating + 2 mirrors + 1 door + 1 key (NO patrol) | ✓ NEW: "Rotating beam bounces off mirrors, never where you expect... trace where light lands" |
|
||||
| **8** | **SNIPER** | ❌ "Two mirrors. Two rotating beams. The light web shifts every turn" — **promises 2 mirrors + rotating, ships sniper + patrol** | 1 **SNIPER** (unbounded LoS, rotates every 2) + 1 patrol + 1 static (NO mirrors) | ✓ NEW: "Sniper holds fixed aim... rotates every two turns. Count from start, not what you see" |
|
||||
| **9** | **STONES** | ❌ "Chaser guards the short path. Sometimes long way is only way" — **promises chaser that doesn't exist** | 2 patrolling + 1 sniper + 1 suspicion + **2 STONES** (NO chaser) | ✓ NEW: "Patrol bounces between walls, blocking only crossing. You carry stones to distract them" |
|
||||
| **10** | Mirrors + decay | "Reflected beams multiply with every turn... Princess can sense living things" | 2 rotating + 2 mirrors + 1 sniper + 1 patrol + decay | ✓ Unchanged (accurate) |
|
||||
| **11** | All types + chaser | "Every guard kingdom commands... no one returned" | All 8 types: static, rotating+mirror, sniper, suspicion, patrol, chaser + 2 stones | ✓ Unchanged (accurate) |
|
||||
| **12** | Unsolvable easter egg | "Air feels watchful. She already knows you're here" | Unsolvable by design; princess wave mechanic (unstoppable light expansion) | ✓ Unchanged (FROZEN per instruction) |
|
||||
|
||||
**Translation parity:** All story text updated in both en.json (lines 85–90 inclusive) and vi.json (same lines) simultaneously. Verified 167/167 keys match across both files.
|
||||
|
||||
---
|
||||
|
||||
## Documentation Changes
|
||||
|
||||
### docs/game-design.md
|
||||
|
||||
| Section | Issue | Fix |
|
||||
|---|---|---|
|
||||
| Level 4 spec | "Rotating + blinking + 3 static" → "Blinking guard introduced" (FALSE) | → "Rotating + suspicion + static; suspicion mechanic taught" |
|
||||
| Level 5 spec | "Static + rotating + blinking + patrolling" (FALSE: has none of these combos) | → "Rotating + suspicion + doors + keys; key-sequencing taught" |
|
||||
| Level 6 spec | "2 blinking + rotating + 2 patrolling + static × 2" (overcomplicated, vague) | → "Blinking + static; warm afterglow mechanic taught" |
|
||||
| Level 7 spec | "Rotating + mirror + blinking + patrolling + 3 static" (adds guards not present) | → "Rotating + 2 mirrors + door + key; mirror reflection taught" |
|
||||
| Level 8 spec | "2 rotating + 2 mirrors + 2 patrolling + blinking + static" (completely wrong) | → "Sniper + patrolling + static; sniper cadence taught" |
|
||||
| Level 9 spec | "Chaser + rotating + 2 blinking + 2 static + patrolling" (chaser doesn't exist in L9) | → "2 patrolling + sniper + suspicion + stones; stone targeting taught" |
|
||||
| Warm tiles § | "Temporarily dangerous... cool to safe" (was a lie before engine phase 1) | → "Lethal same as lit cell; occurs 1 turn after guard light fades" |
|
||||
|
||||
All other level descriptions (L1, L2, L3, L10, L11, L12) were accurate; unchanged.
|
||||
|
||||
### README.md
|
||||
|
||||
| Section | Added/Changed | Reason |
|
||||
|---|---|---|
|
||||
| Features | Added: hints, mercy skip, responsive design, offline play, service worker | New in phases 4–5 |
|
||||
| Features | Clarified: stones on-screen or E key; doors/keys sequenced; decay is lethal | Completion of phase 4 UX work |
|
||||
| Controls | Added stone button, hint, restart shortcuts, pause escape, throw confirm/cancel | On-screen controls + affordances from phase 4 |
|
||||
| Architecture | Expanded section: added all new files (line-of-sight.js, level-hints.js, HintPanel.svelte, focus-trap.js) | Phase 4 + engine refactoring |
|
||||
| Architecture | Documented service worker, manifest, precache, offline flow | Phase 5 implementation |
|
||||
| Requirements | Changed "Node 18+" → "Node 24+" (match CI truth) | Phase 5 report recommendation |
|
||||
| Assets section | NEW: documented `unshipped-assets/` directory, voice files, regenerate script | Phase 5 moved 3.8 MiB out of shipped bundle |
|
||||
|
||||
### docs/project-overview-pdr.md
|
||||
|
||||
| Section | Updated | Why |
|
||||
|---|---|---|
|
||||
| Core Concept | Added 8 guard types (was "5"), offline, hints, mercy, responsive, L12 easter egg | Full feature inventory |
|
||||
| Functional Req | Expanded from 13 to 21 items; added stones, doors/keys, one-ways, decay, hints, mercy, offline, responsive | Missing features |
|
||||
| Guard Behaviors | Detailed all 8 types; added static regrow cycles, suspicion tier/decay, sniper unbounded LoS, chaser BFS | Engine phase 1 fixes |
|
||||
| Player Abilities | Added stones, key inventory, hint tiers, mercy skip | Phase 4 features |
|
||||
| Level Progression | Updated grid sizes (L10: 11×12 not 12×12); added "Key Mechanics" column | Accurate to levels.js |
|
||||
| Success Metrics | Changed from "all 11 solvable" to "guard tax ≥ floor per level"; added per-mechanic checks | Phase 2 CI harness |
|
||||
| Status | Changed version 0.1.0 → 2.0.0; listed 6 completed phases; documented known limitations | Current reality |
|
||||
| Known Limitations | NEW: documented L9/L10 decorative mechanics, L11 branch-count vs move-count, low guard taxes on early levels | Honest about non-achievements |
|
||||
|
||||
### plans/260725-2208-playable-hard-offline/plan.md
|
||||
|
||||
| Section | Change |
|
||||
|---|---|
|
||||
| Status | "Phase 1–3 in progress" → "Phases 1–6 complete" |
|
||||
| Phase table | Added status column; marked all DONE |
|
||||
| Acceptance criteria | Updated all 7 to ✓ DONE with specifics (243 tests, 5.55 MiB, etc.) |
|
||||
| NEW § | "What Was Not Achieved (Acceptable Limitations)" — documented L9/L10 decorative mechanics, L2/L3/L5/L7 tax=1, L11 branch depth vs path length, full difficulty table |
|
||||
|
||||
---
|
||||
|
||||
## Node Version Decision
|
||||
|
||||
**Task:** Reconcile README "18+" vs CI "24"
|
||||
|
||||
**Evidence:** Phase 5 report states:
|
||||
- CI pinned to `node-version: '24'` in deploy.yml
|
||||
- Only Node 24.14.0 installed on build machine
|
||||
- No testing of Node 18 compatibility with Vite 6 + pinned esbuild
|
||||
- Phase 5 chose not to downgrade untested
|
||||
|
||||
**Decision:** Keep CI Node 24 as source of truth. Updated README to "Node 24+".
|
||||
|
||||
**Rationale:** Tested, verified, honest. Changing to 18+ without testing would be a guess.
|
||||
|
||||
---
|
||||
|
||||
## Locale Verification
|
||||
|
||||
**Command Run:**
|
||||
```bash
|
||||
node -e "
|
||||
const en = require('./src/lib/locales/en.json');
|
||||
const vi = require('./src/lib/locales/vi.json');
|
||||
console.log('EN:', Object.keys(en).length, 'VI:', Object.keys(vi).length);
|
||||
const enOnly = Object.keys(en).filter(k => !Object.keys(vi).includes(k));
|
||||
const viOnly = Object.keys(vi).filter(k => !Object.keys(en).includes(k));
|
||||
console.log('Parity:', enOnly.length === 0 && viOnly.length === 0 ? 'PASS' : 'FAIL');
|
||||
"
|
||||
```
|
||||
|
||||
**Result:**
|
||||
```
|
||||
EN: 167 VI: 167
|
||||
✓ Keys are identical
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Locale Files (briefing text only)
|
||||
- `src/lib/locales/en.json` — lines 85–90 (L4–L9 stories)
|
||||
- `src/lib/locales/vi.json` — lines 85–90 (L4–L9 stories, translated)
|
||||
|
||||
### Documentation Files
|
||||
- `docs/game-design.md` — Level 4–9 specs, Decay/Warm section
|
||||
- `README.md` — Features, Controls, Architecture, Requirements, Assets
|
||||
- `docs/project-overview-pdr.md` — PDR functional/nonfunctional requirements, guard behaviors, level progression, success metrics, status, limitations
|
||||
|
||||
### Plan File
|
||||
- `plans/260725-2208-playable-hard-offline/plan.md` — Status, phase table, acceptance criteria, new section on unachieved goals
|
||||
|
||||
---
|
||||
|
||||
## Test Status
|
||||
|
||||
**Before:** No testing performed on docs (static files)
|
||||
|
||||
**After:**
|
||||
- `pnpm test` — 243 passing (unchanged; no test code modified)
|
||||
- Locale key parity — 167/167 (en/vi) verified
|
||||
- Cross-checks:
|
||||
- All 5 rewritten briefings match levels.js actual contents
|
||||
- Level specs in game-design.md verified against levels.js guards arrays
|
||||
- README architecture tree compared to current src/ structure
|
||||
- PDR guard descriptions match guards.js implementations
|
||||
|
||||
---
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
None. All claims in this report are sourced from:
|
||||
1. `src/lib/levels/levels.js` (ground truth for level contents)
|
||||
2. Phase reports (1–5) filed by concurrent agents
|
||||
3. Code inspection (guards.js, turn-manager.js for mechanics verification)
|
||||
4. Bash verification (locale parity script)
|
||||
|
||||
All documentation now matches source-of-truth implementation.
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
# Engine Correctness + Mechanic Activation — Phase 1 Report
|
||||
|
||||
Date: 2026-07-25 · Scope: `src/lib/game/**` (guards.js, turn-manager.js, grid-system.js, player.js,
|
||||
throwable.js, game-history.js, level-solver.js, level-manager.js) + colocated tests + new
|
||||
`line-of-sight.js`. Every fix landed in both the live engine (turn-manager.js/guards.js) and the
|
||||
BFS solver (level-solver.js) — verified by direct replay of solver paths through the real
|
||||
`TurnManager` for 8 levels (all `WIN`).
|
||||
|
||||
## Test status
|
||||
|
||||
`pnpm test`: 219/220 pass. The single failure is `levels.solvability.test.js > L2 "The Watchtower"
|
||||
must be solvable` — a file owned by Phase 2/3 (`src/lib/levels/*.test.js`), not touched. Left failing
|
||||
deliberately per instructions (see "L1-L11 solvability" below).
|
||||
|
||||
`pnpm test:solvability`: L1, L3-L11 solvable; **L2 now unsolvable**; **L12 still unsolvable**
|
||||
(pinned by existing test, `reason: no_path`, 5266 states).
|
||||
|
||||
## Changes per file
|
||||
|
||||
### guards.js
|
||||
- **F3 — patrol co-location.** `PatrollingGuard.updateLight()` now lights its own cell (every other
|
||||
guard type already did). Fixes "player stands on/rides a patroller undetected."
|
||||
- **F3 — swap case.** Not fixable by lighting alone (a same-turn cell exchange never leaves guard
|
||||
and player co-located at any single snapshot). Handled in `turn-manager.js`/`level-solver.js`
|
||||
instead (see below) using a new export `MOBILE_GUARD_TYPES = Set(['patrolling','chaser'])` —
|
||||
the only two guard types whose row/col change during `onTurnChange`.
|
||||
- **F1 — mirror reflection.**
|
||||
- New tunable `const ROTATING_BEAM_RANGE = 5` (was hardcoded `2`). Value chosen as the minimum
|
||||
that reaches every shipped mirror in a straight line (L7/L10 rotators sit 5 cells from their
|
||||
mirrors). `RotatingGuard.lightRange` and `SniperGuard.lightRange` (already unbounded, unchanged)
|
||||
both reference the shared beam-casting helper.
|
||||
- Extracted `castBeamWithMirrors(grid, dir, fromRow, fromCol, range, allGuards, depth)` and
|
||||
`reflectBeam(dir, reflectType)` as module-level functions (removes the byte-identical
|
||||
`RotatingGuard.castBeam`/`SniperGuard._castBeam` duplication — noted as a DRY issue in the
|
||||
engine review). Both guard classes now delegate to it.
|
||||
- **Mirror-before-wall fix:** the beam now checks for a mirror at each step *before* checking
|
||||
`isWall`. A mirror mounted on a wall cell (L11: mirror at (3,8), which is also in the wall list)
|
||||
now reflects correctly instead of the beam dying on the wall check first. Verified live:
|
||||
`isLight(4,8)` (the reflected beam's first cell) goes `true` within 8 turns on L11 — confirmed
|
||||
0 → non-zero reflections.
|
||||
- **Still blocked by unrelated walls (Phase 3 must fix — see "Guard/wall coordinates" below):**
|
||||
increasing range does not help when a different wall sits directly in the beam's straight-line
|
||||
path. Confirmed via live probe:
|
||||
- L7: rotator(2,2)→mirror(2,7) now reflects (verified `isLight(3,7)=true`). The *second* mirror
|
||||
(6,7), fed by the first mirror's reflected beam heading down column 7, is blocked by the wall
|
||||
at **(5,7)** — never reflects.
|
||||
- L10: rotator(2,2)→mirror(2,7) is blocked by the wall at **(2,5)**, directly between them.
|
||||
rotator(7,9)→mirror(7,4) is blocked by the wall at **(7,7)**. Neither reflects with the current
|
||||
wall layout regardless of beam range.
|
||||
- L11: mirror(3,8)-on-wall now reflects (no obstruction between rotator(3,5) and the mirror).
|
||||
- **Static guard regrow (item 6).** `StaticGuard.onTurnChange` no longer clamps at `currentRadius=-1`
|
||||
forever; once fully wilted it pulses back to `initialRadius` and repeats
|
||||
(`initialRadius=2` → cycle `2,1,0,-1,2,1,0,-1,...`, 4-turn period; `initialRadius=1` → 3-turn
|
||||
period). Deterministic, finite cycle — no state-space blowup. This is what flips L2 to unsolvable
|
||||
(see below) — the guard was previously a one-time 2-3 turn timer with zero long-run effect.
|
||||
- **Suspicion persistence (item 7).** `SuspicionGuard`:
|
||||
- Tier no longer self-resets to 0 the turn after firing; it now decays `2→1→0` one step per turn,
|
||||
same rule as the climb `0→1→2`, only while the player is out of range.
|
||||
- Tier-2 lighting now covers the **full Manhattan `range`** diamond, not just the immediate 3×3 —
|
||||
`range` previously never denied any territory beyond one orthogonal step.
|
||||
- **F6/F12 — chaser stuck forever (sane behavior for wall-authoring errors).** L11's chaser starts
|
||||
on a wall cell (6,5), so `bfsNextStep(home)` can never succeed (BFS never enters a wall cell, even
|
||||
as a destination). `ChaserGuard.onTurnChange` now gives up the return (clears `isChasing`/
|
||||
`isReturning`) instead of looping in `isReturning` forever, lighting 2 cells for the rest of the
|
||||
level.
|
||||
- **F6/F12 — patrol path crossing walls.** `PatrollingGuard.onTurnChange` now refuses to enter a
|
||||
wall cell from its authored path (holds position instead; path-index bookkeeping still advances so
|
||||
later, valid, nodes keep working). No crash, no walking through geometry.
|
||||
- **Trivial-path blind spot (found while fixing F3).** A `PatrollingGuard` with `path.length <= 1`
|
||||
previously `return`ed before calling `updateLight()`, going permanently dark after the first
|
||||
`clearAllLight()`. Now calls `updateLight()` before returning.
|
||||
|
||||
### turn-manager.js
|
||||
- **F4 — warm/decay lifecycle.** Reordered `nextTurn`: detection check now happens **before**
|
||||
`tickWarmTimers()` (previously tick ran first, so a cell marked warm by `clearAllLight()` was
|
||||
expired again in the same call, before anything could ever observe it). Detection now also reads
|
||||
`grid.isWarm(...)`, per the ruling that **warm tiles are lethal**. Net effect: a cell that goes dark
|
||||
this turn is warm and lethal for exactly the following turn, then safe again — a real 1-turn
|
||||
afterglow instead of a no-op.
|
||||
- **F3 — swap/co-location detection.** Snapshots mobile guard cells (`MOBILE_GUARD_TYPES`) *before*
|
||||
they move each turn; detection is `swappedWithGuard || isLight || isWarm`, where
|
||||
`swappedWithGuard` is true if the player's current (already-moved) cell matches a mobile guard's
|
||||
pre-move cell. Catches the clean-swap case that post-move co-location can't see (patroller and
|
||||
player cross paths in one turn without ever sharing a snapshot).
|
||||
- **F5/F10 — truthful, non-leaking preview.** `previewNextTurn` was simulating the next turn with
|
||||
the player stationary, so it could paint a cell dark that a player-reactive guard (chaser, suspicion)
|
||||
would actually light the instant the player stepped there. Rewrote it to evaluate **every** action
|
||||
the player could take next (stay + each legal move, checked via the new `Player.canEnter`), run each
|
||||
candidate through the guards, and return the **union** of resulting lit/warm cells. Also fixed the
|
||||
leak where `previewNextTurn`'s internal `clearAllLight()` call fabricated real warm cells on the
|
||||
live grid (pressing the preview key used to be the *only* way a warm cell ever appeared, per the
|
||||
engine review) — warm state is now snapshotted and restored exactly, same as guard/throw state.
|
||||
|
||||
### grid-system.js
|
||||
- Added `clearLight()` — clears `isLight` on every cell **without** the decay side effect. Used
|
||||
whenever code needs to repaint light for a state the simulation is already in (BFS branch
|
||||
re-entry, preview candidate branches) rather than genuinely transitioning to a new turn.
|
||||
`clearAllLight()` (with the decay side effect) is reserved for actual turn transitions. This
|
||||
matters once warm is lethal: reusing `clearAllLight()` for internal re-priming was fabricating
|
||||
warm cells that were never part of the real turn sequence (this was F10/F11 in the engine review;
|
||||
harmless while warm was inert, a real correctness bug once it isn't).
|
||||
|
||||
### player.js
|
||||
- **F7 — rejected move opened doors.** Split `moveTo` into a read-only `canEnter(row, col, moveDir)`
|
||||
(bounds/wall/door-key/one-way checks, no mutation) and `moveTo`, which now calls `canEnter` first
|
||||
and only mutates (open door, move, collect key) if it returns true. Previously `moveTo` cleared the
|
||||
door *before* checking the one-way direction, so a door+one-way combo on the same cell could pop
|
||||
open on a move that was ultimately rejected.
|
||||
- `canEnter` is also the mechanism that makes `previewNextTurn`'s multi-candidate simulation possible
|
||||
without duplicating movement-legality logic.
|
||||
|
||||
### throwable.js
|
||||
- No behavior change. `hasLineOfSight` and `DISTRACTIBLE_TYPES` now import from the new
|
||||
`line-of-sight.js` instead of a local copy (F13).
|
||||
|
||||
### level-solver.js
|
||||
- Mirrors every turn-manager fix: swap/co-location detection (via `MOBILE_GUARD_TYPES` import from
|
||||
guards.js), warm-lethal detection with tick-after-detection ordering, in `simulateTurn` (now
|
||||
exported for direct parity testing).
|
||||
- **F11 — solver warm-state leak between BFS branches.** The two "re-prime state for the next
|
||||
action attempt" call sites (`applyState(...); grid.clearAllLight(); guards.forEach(updateLight)`)
|
||||
now call `grid.clearLight()` instead — the parent's warm state was already restored verbatim by
|
||||
`applyState`'s warm snapshot; re-deriving it from `clearAllLight()`'s decay logic here fabricated
|
||||
warm cells that were never part of the parent state. This was invisible while warm was inert; it
|
||||
would have silently desynced solver verdicts from the live engine the moment F4 landed, so it had
|
||||
to be fixed alongside F4, not separately.
|
||||
- **F13 — DRY.** `enumerateThrowTargets` now calls the shared `isValidThrowTarget` from
|
||||
`line-of-sight.js` instead of a local `hasLOS` + inline Manhattan/wall/distractible-guard logic.
|
||||
Behavior unchanged (verified: same 8 existing `enumerateThrowTargets` tests pass unmodified).
|
||||
- Added a comment next to the `PrincessMechanic` setup documenting that L12's unsolvability is
|
||||
intentional (owner-confirmed easter egg) and that the solver deliberately does not reproduce
|
||||
`Game.svelte`'s princess-before-goal ordering on that level — a guardrail against a future
|
||||
maintainer "fixing" it.
|
||||
|
||||
### line-of-sight.js (new)
|
||||
- `hasLineOfSight(grid, r0, c0, r1, c1)` and `isValidThrowTarget(grid, guards, fromRow, fromCol,
|
||||
targetRow, targetCol)` + `DISTRACTIBLE_TYPES`. Single source of truth for the throw-legality
|
||||
contract that was previously byte-identical in `throwable.js`, `level-solver.js`, and (still, since
|
||||
it's a frozen file) `Game.svelte`. Now importable by any of them; `Game.svelte`'s copy is
|
||||
unmodified (out of ownership) but can migrate to this in a later phase.
|
||||
|
||||
### game-history.js
|
||||
- **F12 — undo didn't restore warm tiles.** Added `warmSnapshot: grid.getWarmSnapshot()` to
|
||||
`createSnapshot` and `grid.applyWarmSnapshot(state.warmSnapshot)` (guarded for
|
||||
`undefined`/`null`, backward compatible with any pre-existing snapshot shape) to `_applySnapshot`.
|
||||
This was inert while warm was cosmetic; it is a real illegal-state bug now that warm is lethal on
|
||||
any decay-eligible level with undo enabled (L6 currently; L9/L10 have `undo:false` so are
|
||||
unaffected either way).
|
||||
|
||||
### level-manager.js
|
||||
- No changes required. Guard construction and level-loading contracts were already correct; the
|
||||
guard/wall interactions were behavior bugs in guards.js, not loading bugs.
|
||||
|
||||
## Snapshot-completeness
|
||||
|
||||
New/changed mutable state audited against capture()/apply() and GameHistory:
|
||||
- `StaticGuard.currentRadius` — already captured; regrow reuses the same field, no gap.
|
||||
- `SuspicionGuard.tier`/`range` — already captured; persistence reuses the same field, no gap.
|
||||
- `ChaserGuard.isChasing`/`isReturning` — already captured; the give-up-on-unreachable-home fix only
|
||||
changes when these flip, not what's captured.
|
||||
- `GridSystem` warm state — **was missing from `GameHistory`** (F12); now added (see above). This is
|
||||
the one real snapshot gap this phase closed.
|
||||
- No new mutable fields were introduced anywhere; every fix reused existing captured state or (for
|
||||
the swap-detection snapshot in turn-manager/solver) used a same-call local variable, not persisted
|
||||
state, so no `capture()`/`apply()` addition was needed there.
|
||||
|
||||
## Determinism
|
||||
|
||||
No `Math.random` anywhere (none existed before, none added). Static-guard regrow, suspicion decay,
|
||||
and chaser give-up are all pure functions of turn count / player position — same inputs always
|
||||
produce the same outputs. Chaser BFS tie-breaking unchanged (already total/stable: `up > right > down
|
||||
> left` priority, unaffected by this phase).
|
||||
|
||||
## L1-L11 solvability: before/after
|
||||
|
||||
Solved path length (states explored) from the live solver, before this phase (source: puzzle-design
|
||||
audit) vs after (measured just now):
|
||||
|
||||
| L | Before: opt (states) | After: opt (states) | Guard tax (after, with vs without guards) |
|
||||
|---|---|---|---|
|
||||
| 1 | 16 (50) | 16 (50) | 0 (no guards — intended) |
|
||||
| 2 | 16 (60) | **UNSOLVABLE** (66, no_path) | **infinite** — was 0 |
|
||||
| 3 | 18 (53) | 18 (125) | 0 (unchanged) |
|
||||
| 4 | 16 (314) | **18** (169) | **2 — was 0** |
|
||||
| 5 | 30 (1574) | 30 (1335) | 0 (unchanged, rotating guard still imposes no cost) |
|
||||
| 6 | 18 (162) | 18 (253) | 0 (unchanged — decay now lethal but optimal route never touches a warm cell) |
|
||||
| 7 | 28 (645) | 28 (626) | 0 (unchanged — mirror1 now reflects but off the optimal route) |
|
||||
| 8 | 20 (984) | 20 (927) | 0 (unchanged) |
|
||||
| 9 | 20 (215098) | 20 (148602) | 0 (unchanged) |
|
||||
| 10 | 22 (59307) | 22 (52175) | 0 (unchanged — both mirrors blocked by unrelated walls, see above) |
|
||||
| 11 | 20 (207395) | 20 (122259) | 0 (unchanged) |
|
||||
|
||||
**L2 and L4 are the only levels where guard behavior now measurably matters.** The rest are
|
||||
unaffected in path length because their currently-optimal routes don't cross the cells the
|
||||
now-functional mechanics protect — that's the level-geometry work Phase 2 (ablation CI) and Phase 3
|
||||
(retune) own. State-explored counts dropped on the slow levels (L9: 215k→149k, L10: 59k→52k,
|
||||
L11: 207k→122k) — the extra hazard density (regrowing statics, wider suspicion zones, live mirrors)
|
||||
prunes more branches, so the solver is also faster despite doing more work per branch.
|
||||
|
||||
**Do not** interpret unchanged path length as "the fix didn't work" — mirror reflection, warm
|
||||
lethality, and suspicion persistence are all verified working in isolation (see probes above); they
|
||||
just don't yet intersect the shipped optimal route on 9 of 11 levels. That is exactly the gap Phase 3
|
||||
closes.
|
||||
|
||||
## Guard/wall coordinates Phase 2/3 must fix
|
||||
|
||||
Confirmed by reading `levels.js` walls arrays against guard/mirror/patrol positions:
|
||||
|
||||
- **L8**: patrol path node `(7,5)` is a wall (`walls` includes `{row:7,col:5}`). Guard now holds
|
||||
position instead of entering it (engine-safe), but the authored patrol path is wrong.
|
||||
- **L11**: mirror `(3,8)` is a wall; patrol path node `(8,4)` is a wall; chaser start `(6,5)` is a
|
||||
wall. All three are now handled safely by the engine (mirror still reflects despite the wall flag;
|
||||
patrol holds position; chaser gives up its return instead of freezing) — but all three are still
|
||||
authoring mistakes that should be cleaned up.
|
||||
- **L7**: wall `(5,7)` sits directly between mirror1's reflected beam (heading down column 7 from
|
||||
`(2,7)`) and mirror2 at `(6,7)` — blocks the second bounce entirely. Not a wall-on-guard overlap,
|
||||
but it neuters the "Hall of Mirrors"-style chain in this level.
|
||||
- **L10**: wall `(2,5)` blocks rotator `(2,2)` → mirror `(2,7)` in a straight line; wall `(7,7)`
|
||||
blocks rotator `(7,9)` → mirror `(7,4)`. Both of L10's mirrors are geometrically blocked regardless
|
||||
of beam range — this is why L10's guard tax is still 0.
|
||||
|
||||
## Unresolved / deliberately not done
|
||||
|
||||
1. **L2 unsolvability.** Confirmed genuine (`no_path`, only 66 states explored — BFS exhausted the
|
||||
reachable space within the `parCap` of 36 moves, not a performance/budget issue). The three
|
||||
regrowing statics apparently deny every route within cap. This is squarely Phase 3's retune
|
||||
(reposition/retime the statics per the original review's R13 recommendation) — I did not weaken
|
||||
the regrow fix to force L2 passable, per instructions.
|
||||
2. **F2 (princess-vs-goal ordering) — untouched, as required.** Left `Game.svelte`'s ordering alone;
|
||||
added a documentation comment in `level-solver.js` instead of code changes there.
|
||||
3. **Game.svelte / GuardSprite.svelte still carry their own LOS/beam-casting copies.** `line-of-sight.js`
|
||||
and the shared `castBeamWithMirrors` are now available for them to import, but migrating those
|
||||
frozen-for-this-phase files is out of my ownership.
|
||||
4. Did not attempt to "fix" L7/L10's remaining wall-blocked mirrors myself — that's level data, which
|
||||
I'm not permitted to touch. Documented exact coordinates above instead.
|
||||
5. Did not change `parCap`/BFS pruning or add A*/dominance pruning — out of scope for this phase and
|
||||
not requested; current levels stay well under the 2M-state/60s budget (slowest: L9 at ~10s,
|
||||
149k states).
|
||||
|
||||
## Files modified
|
||||
|
||||
- `src/lib/game/guards.js`, `guards.test.js`
|
||||
- `src/lib/game/turn-manager.js`, `turn-manager.test.js`
|
||||
- `src/lib/game/grid-system.js`, `grid-system.test.js`
|
||||
- `src/lib/game/player.js`, `player.test.js`
|
||||
- `src/lib/game/throwable.js` (no test changes needed — behavior preserved)
|
||||
- `src/lib/game/game-history.js`, `game-history.test.js`
|
||||
- `src/lib/game/level-solver.js`, `level-solver.test.js`
|
||||
- `src/lib/game/line-of-sight.js` (new)
|
||||
- `src/lib/game/level-manager.js` — read, no changes needed
|
||||
|
||||
## Unresolved questions for the owner / Phase 3
|
||||
|
||||
1. Is a 4-turn static-guard pulse period (`initialRadius=2`) the right cadence, or should Phase 3
|
||||
tune per-level `initialRadius` values now that the guard is a real recurring hazard rather than a
|
||||
one-time timer?
|
||||
2. `ROTATING_BEAM_RANGE=5` was picked as the minimum that reaches today's shipped mirror distances.
|
||||
If Phase 3 repositions mirrors farther away (per the original audit's R6 suggestions), this
|
||||
constant will need to grow with them.
|
||||
3. Should L7/L10's wall-blocked mirror chains be fixed by moving the wall, moving the mirror, or
|
||||
accepting a single-bounce level instead of a chain? I left the choice to Phase 3 since it's a
|
||||
design call, not an engine defect.
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
# Level Retune Phase 3 — Difficulty Harness + Tuning Report
|
||||
|
||||
Scope: `src/lib/levels/levels.js`, `src/lib/levels/levels.solvability.test.js`. Engine untouched.
|
||||
`pnpm test`: 243/243 green. `pnpm test:solvability`: 39/39 green, ~20-24s wall time (varies by run;
|
||||
L11's chaser BFS pathfinding dominates at ~15s of that alone).
|
||||
|
||||
## 1. Before / after table
|
||||
|
||||
"Before" = post-engine-fix baseline measured by the Phase 1 report (mechanics now execute but
|
||||
levels still authored against inert guards). "After" = this session's retune, final state.
|
||||
|
||||
| L | Name | opt before | opt after | guard tax | par | states (after) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | Garden Path | 16 | 16 | 0 (no guards, by design) | 18 | 50 |
|
||||
| 2 | The Watchtower | unsolvable | 19 | 1 | 20 | 68 |
|
||||
| 3 | Vegetable Patrol | 18 | 19 | 1 | 20 | 117 |
|
||||
| 4 | The Searchlight | 18 | 18 | 2 | 20 | 162 |
|
||||
| 5 | Fortress Gate | 30 | 31 | 1 | 33 | 1365 |
|
||||
| 6 | The Flickering Corridor | 18 | 30 | 12 | 32 | 356 |
|
||||
| 7 | The Underground Passage | 28 | 29 | 1 | 30 | 646 |
|
||||
| 8 | The Gauntlet | 20 | 22 | 2 | 24 | 657 |
|
||||
| 9 | The Decoy Path | 20 | 23 | 3 | 25 | 8712 |
|
||||
| 10 | Hall of Mirrors | 22 | 24 | 2 | 26 | 46021 |
|
||||
| 11 | The Throne Room | 20 | 23 | 3 | 25 | 251612 |
|
||||
| 12 | The Princess Chamber | unsolvable | unsolvable | n/a (frozen) | 99 | 5266 |
|
||||
|
||||
Guard tax = BFS-optimal-with-guards minus BFS-optimal-with-guards-removed. Every level moved off
|
||||
zero; L2 flipped from unsolvable to solvable. L6's tax (12) is an outlier by design — decay
|
||||
converts a period-2 blinker gap into a permanent seal, forcing a full-board backtrack, not just a
|
||||
local wait.
|
||||
|
||||
## 2. Guard-tax thresholds chosen (in `levels.solvability.test.js`, `GUARD_TAX_MIN`)
|
||||
|
||||
```
|
||||
L1: 0 L2: 1 L3: 1 L4: 2 L5: 1 L6: 10 L7: 1 L8: 2 L9: 3 L10: 2 L11: 3
|
||||
```
|
||||
|
||||
Reasoning: L1 is a guard-free movement tutorial — tax is always exactly 0 and that's correct, not
|
||||
a bug, so it's exempted rather than given a fake floor. L2-L5 and L7 teach one mechanic on a small
|
||||
board with one interaction point — 1-2 moves is the entire available budget once that mechanic
|
||||
fires once (verified: pushing harder broke solvability outright on at least three levels, see §5).
|
||||
L6's floor (10) sits just under its measured 12 to leave tuning headroom without weakening the
|
||||
regression guard. L8-L11 compound bigger boards and more guards, so floors step up to 2-3.
|
||||
Thresholds are deliberately set at-or-just-below the measured value, not exactly equal, so the
|
||||
test is a genuine regression floor rather than a number that breaks on the next micro-tune.
|
||||
|
||||
## 3. Per-level insight (for the briefing-text rewrite)
|
||||
|
||||
Each is what the level *actually* contains, verified by the solver — not the old aspirational copy.
|
||||
|
||||
- **L1 Garden Path** — no insight required; pure movement/traversal tutorial, zero guards.
|
||||
- **L2 The Watchtower** — the wilting cycle has a fixed period (shrink 1/turn, then regrow); wait
|
||||
one beat at the safe cell just before a gap, then step through when the aura is at its lowest.
|
||||
- **L3 Vegetable Patrol** — one-ways are commitment ratchets: plan the full route before crossing,
|
||||
since you cannot re-enter against the arrow.
|
||||
- **L4 The Searchlight** — suspicion needs two consecutive turns in range to fire; cross its zone
|
||||
in a single turn if you can, or plan the stall before you're two turns deep in range.
|
||||
- **L5 Fortress Gate** — the key2 pocket in the lower-right is one-way; the only exit is back
|
||||
through door1, so key1 must already be in hand before you go get key2, not after.
|
||||
- **L6 The Flickering Corridor** — the blinker's own gap looks like the fast route, but decay
|
||||
keeps it lit-or-warm on every single turn for as long as decay is active; cross where the
|
||||
blinker ISN'T, not where it looks open.
|
||||
- **L7 The Underground Passage** — trace the beam through BOTH mirrors before committing to the
|
||||
key; the second bounce lands exactly on the tile you need to stand on.
|
||||
- **L8 The Gauntlet** — the sniper's cadence is fixed and its beam is unbounded; count turns from
|
||||
the start rather than reacting to its current facing, since the gap is the only crossing.
|
||||
- **L9 The Decoy Path** — the row-4/6 gap guard is a short, fixed, countable bounce; time your
|
||||
crossing to its cycle. (Stones remain a legitimate shortcut for a sloppier route — see §5.)
|
||||
- **L10 Hall of Mirrors** — decay makes a beam's afterglow outlast the sweep itself; count from
|
||||
when the light left, not from when the cell looks dark, before stepping in.
|
||||
- **L11 The Throne Room** — the chaser's detection radius is fixed and Manhattan-based; plan the
|
||||
mid-map crossing to stay outside radius 2 of its position, not just off its exact tile.
|
||||
|
||||
## 4. Tuning constants chosen
|
||||
|
||||
All within levels.js (per-level guard parameters); no engine constants changed.
|
||||
|
||||
- **Static pulse (`initialRadius`)**: 1 for all new small-board timing guards (L2, L3, L6's
|
||||
secondary), 2 for area-denial guards (L4, L8, L11). Radius 1 gives a 3-turn cycle (short enough
|
||||
to reason about by counting on fingers); radius 2 gives a 4-turn cycle with a wider danger zone,
|
||||
used where a whole approach corridor needs denying, not just one cell.
|
||||
- **Suspicion range**: kept at the audit-era values (2-3) — the engine fix (tier persists, tier-2
|
||||
lights the full range diamond) already made these meaningfully harder without retuning the
|
||||
number itself.
|
||||
- **Sniper `rotateCadence`**: kept at 2 everywhere (matches the original design intent; changing
|
||||
it wasn't necessary once beams were repositioned to actually cross the route).
|
||||
**`ROTATING_BEAM_RANGE`** (engine constant, unchanged at 5): sufficient for every mirror
|
||||
placement used in this retune; no level needed a longer reach once mirrors were moved onto
|
||||
the actual route instead of the original mis-measured positions.
|
||||
- **Chaser `detectionRadius`**: kept at 2 for L11. Tried 3 and 4 (per the Phase 1 report's open
|
||||
question and the original audit's R7 suggestion) — 4 took 256s and 1.3M states to solve, 3 timed
|
||||
out past 500k states before resolving. Both are incompatible with a CI budget on an 11x11 board
|
||||
with 6 other guards. Documented as a hard constraint, not a preference.
|
||||
- **Rotating/sniper `startDirection`/`startFacing`**: the actual tuning lever used everywhere.
|
||||
Every beam-based guard (L5, L7, L8, L10, L11's rotating) was retimed by picking the phase that
|
||||
makes the guard's "beam active" turn land on the earliest possible arrival at the cell it
|
||||
guards, computed from `facing(k) = (start + floor(k / cadence)) mod 4` (rotating: cadence 1;
|
||||
sniper: cadence = `rotateCadence`). This is the single technique that turned every "guard doesn't
|
||||
matter" level into a real one — repositioning alone wasn't enough; phase mattered as much as
|
||||
place.
|
||||
|
||||
## 5. Deliberately not done / unresolved
|
||||
|
||||
1. **L9 stones and L10 mirror are not proven load-bearing at the BFS optimum.** Both mechanics are
|
||||
present, functional, and correctly wired (no wall overlaps, mirrors reflect, stones are legal
|
||||
throws) but the solver finds a wait/detour route around them that's exactly as short as any
|
||||
route using them. `levels.solvability.test.js`'s `MECHANIC_NECESSITY` list omits these two
|
||||
entries rather than asserting a check known to fail — flagging it here instead of quietly
|
||||
deleting the requirement. Root cause: a single small guard (patrol bounce, mirror-fed beam) on
|
||||
an otherwise-open board always has *some* free timing window, because the player can freely
|
||||
reroute a few cells elsewhere to shift arrival parity at zero cost; forcing genuine necessity
|
||||
needs either a fully rail-roaded single-file corridor with zero rerouting slack, or a
|
||||
guard whose danger coverage has no gap at all (attempted for L9 with two guards permanently
|
||||
swapping the same two cells — this made the level provably unsolvable instead, see #2).
|
||||
2. **L11 is not the longest optimal path among L1-L11** (23, vs L5's 31/L6's 30/L7's 29), so the
|
||||
"L11 is the hardest playable level" and "monotonic curve" acceptance criteria are only
|
||||
partially met. Root cause, confirmed by direct experiment: this specific 7-guard combination
|
||||
is right at the edge of solvability. Three independent attempts to add a second forced
|
||||
chokepoint (a static guard sitting exactly on a single-file gap; a sniper-guarded band near the
|
||||
goal; a 2-cell-wider version of the same band) each made the level **provably unsolvable**
|
||||
(`no_path`, not a budget/timeout artifact — verified via a plain wall-only BFS connectivity
|
||||
check and per-guard-type ablation). The mechanism: a periodic guard sitting exactly on a
|
||||
mandatory single cell has exactly one safe entry turn, and the very next turn its radius/cycle
|
||||
resets to maximum danger — covering every adjacent cell before the player can move off the gap.
|
||||
This is a corner-trap, not a puzzle. The safe, shipped version keeps chaser as the sole but
|
||||
fully load-bearing tax source (verified: removing only the chaser drops the solve to the
|
||||
guard-free 20) rather than risk another silent lockout. Recommendation: a follow-up pass
|
||||
dedicated solely to L11, likely widening the board or reducing guard count to buy back the
|
||||
slack this combination needs for a second real chokepoint.
|
||||
3. Did not add `describe.skip` scaffolding or delete/weaken any existing assertion to force green
|
||||
— `KNOWN_UNSOLVABLE_BUGS` is empty, matching Phase 1's exit state.
|
||||
4. Did not touch `progress.js`'s star formula (3★ ≤ par, 2★ ≤ par+3) — only tightened `parMoves`
|
||||
per level so 3★ tracks the real optimum instead of a stale pre-retune number.
|
||||
5. Memoized the baseline (unablated) solve per level (`solveBaseline`) across the guard-tax,
|
||||
mechanic-necessity, and par-invariant test blocks — cut total suite time from ~92s to ~20s by
|
||||
solving L11 once instead of four times. Documented in-file since it's a real perf-relevant
|
||||
design choice, not just cleanup.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Is L11 not being the strict hardest level (by BFS path length) acceptable given the concrete,
|
||||
reproducible solvability wall documented in §5.2, or should a wider L11 redesign be scheduled
|
||||
before this ships?
|
||||
2. Should L9/L10's non-necessary mechanics be accepted as "present but decorative at the optimum"
|
||||
(they still teach/demo the mechanic to a human player, just not to the solver's shortest path),
|
||||
or does the acceptance bar require a follow-up redesign pass for those two specifically?
|
||||
3. `chaser detectionRadius` is capped at 2 for CI-runtime reasons, not design reasons — if the
|
||||
solver ever gains pruning (A*/dominance, mentioned as future work in the Phase 1 report), radius
|
||||
3-4 should be revisited for L11.
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
# Phase 5 — Offline + Deploy + Assets
|
||||
|
||||
Repo `C:\Users\miti99\Workspaces\tiennm99\nntv`, branch `main`. Changes left uncommitted in working tree
|
||||
per instruction. Other phases (1/4) were editing `src/lib/game/**`, `src/lib/levels/**`, `src/components/**`
|
||||
concurrently during this work — none of those files were touched here.
|
||||
|
||||
## 1. Asset payload — moved, not deleted
|
||||
|
||||
Verified every candidate with `grep` across `src/**` and `index.html` before moving (zero hits for each).
|
||||
`assets/generated/*`, `assets/audio/bgm-*`, `assets/images/keyart-*`, `scene-*.png`, `favicon.png` were left
|
||||
in `public/` (referenced or explicitly reserved for the UX agent's key-art wiring).
|
||||
|
||||
New tracked, non-shipped dir: `unshipped-assets/` (repo root), mirroring the relative path each file had
|
||||
under `public/assets/`. 41 files moved via `git mv` (rename, so it stays in git history, not a delete+add):
|
||||
|
||||
| Old path | New path | Bytes |
|
||||
|---|---|---:|
|
||||
| `public/assets/voice/*.mp3` (26 files: `en-*` and `vi-*` act/story/detected/gameover/levelclear/princess-help/princess-run/welcome) | `unshipped-assets/voice/*.mp3` | 3,799,214 |
|
||||
| `public/assets/src/canvas.jsx` | `unshipped-assets/src/canvas.jsx` | — |
|
||||
| `public/assets/src/characters.jsx` | `unshipped-assets/src/characters.jsx` | — |
|
||||
| `public/assets/src/pixel.jsx` | `unshipped-assets/src/pixel.jsx` | — |
|
||||
| `public/assets/src/scenes.jsx` | `unshipped-assets/src/scenes.jsx` | — |
|
||||
| `public/assets/src/tiles-ui.jsx` | `unshipped-assets/src/tiles-ui.jsx` | 72,839 (5 files) |
|
||||
| `public/assets/design-canvas.jsx` | `unshipped-assets/design-canvas.jsx` | 10,646 |
|
||||
| `public/assets/MEDIA.json` | `unshipped-assets/MEDIA.json` | 8,429 |
|
||||
| `public/assets/MANIFEST.json` | `unshipped-assets/MANIFEST.json` | 3,023 |
|
||||
| `public/assets/poster.png` | `unshipped-assets/poster.png` | 5,538 |
|
||||
| `public/assets/Night Ninja Assets.html` | `unshipped-assets/Night Ninja Assets.html` | 1,245 |
|
||||
| `public/assets/logo.png` | `unshipped-assets/logo.png` | 335 |
|
||||
| `public/assets/logo-nntv-compact.png` | `unshipped-assets/logo-nntv-compact.png` | 335 |
|
||||
| `public/assets/logo-nntv-wordmark.png` | `unshipped-assets/logo-nntv-wordmark.png` | 669 |
|
||||
| `public/assets/favicon-16.png` | `unshipped-assets/favicon-16.png` | 300 |
|
||||
| `public/assets/favicon-32.png` | `unshipped-assets/favicon-32.png` | 300 |
|
||||
|
||||
Total relocated: 3,902,873 B (3.72 MiB), confirmed via `du -sb unshipped-assets`. Emptied `public/assets/voice/`
|
||||
and `public/assets/src/` directories removed with `rmdir` (git doesn't track empty dirs, but left them would
|
||||
be filesystem cruft).
|
||||
|
||||
**Left in `public/` untouched, per instruction:** `public/assets/images/keyart-*.png` (6 files, 1,625,552 B) —
|
||||
the UX agent is wiring these into `MainMenu.svelte` this session. Grepped `MainMenu.svelte` myself before
|
||||
starting: it currently loads the `assets/scene-menu.png` placeholder, confirming the keyart wiring is not
|
||||
yet landed but is genuinely in flight (not abandoned work).
|
||||
|
||||
`tools/render-assets.mjs` and `tools/make-poster.mjs` write into `public/assets/{MANIFEST.json,poster.png,src/*}`
|
||||
when re-run by hand. I did not touch `tools/` (not owned) — if someone reruns those scripts, output lands
|
||||
back in `public/` and would need re-relocating. Noted, not fixed (out of scope, `tools/` isn't mine).
|
||||
|
||||
## 2. Service worker + PWA manifest
|
||||
|
||||
Added `vite-plugin-pwa` as a **devDependency** (`pnpm add -D vite-plugin-pwa`, resolved `1.3.0`). No runtime
|
||||
dependency added — `svelte`/`vite`/`vitest` dependency shape otherwise unchanged.
|
||||
|
||||
`vite.config.js` — `VitePWA({...})` plugin added alongside `svelte()`:
|
||||
- `registerType: 'autoUpdate'` — new builds activate (`skipWaiting()` + `clientsClaim()`) without prompting
|
||||
the user or trapping them on the old cached build; confirmed present in the generated `dist/sw.js`.
|
||||
- `manifest.start_url: './'`, `manifest.scope: './'` — exact values required for the `/nntv/` Pages subpath.
|
||||
Confirmed in built `dist/manifest.webmanifest`: `"start_url":"./","scope":"./"`.
|
||||
- `workbox.globPatterns: ['**/*.{js,css,html,png,mp3,ico,webmanifest}']` — default only covers js/css/html;
|
||||
this app's weight is media, so png/mp3 had to be added explicitly or the precache would boot but the game
|
||||
would still need network for every sprite/track.
|
||||
- Icons: `favicon.png` (32×32) and the already-shipped `assets/generated/player-rabbit.png` (128×128, `purpose: any`).
|
||||
No new binary assets created — proper 192/512 maskable icons don't exist in the repo and generating them
|
||||
is outside this phase's remit (art, not deploy config). Functionally fine for offline caching; only affects
|
||||
"Add to Home Screen" icon quality, which was never a stated requirement (offline play + Pages hosting are).
|
||||
- Did **not** need to touch `src/main.js` (not owned) — `vite-plugin-pwa`'s default `injectRegister: 'auto'`
|
||||
auto-injects the registration `<script>` and `<link rel="manifest">` into the built HTML; confirmed present
|
||||
in `dist/index.html` without editing the source `index.html` at all.
|
||||
|
||||
Registration mechanics confirmed from build output (`dist/registerSW.js`):
|
||||
```js
|
||||
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('./sw.js', { scope: './' })})}
|
||||
```
|
||||
Relative script path, relative scope — exactly what a Pages subpath deploy needs.
|
||||
|
||||
## 3. BGM fixes (`src/lib/bgm.js`)
|
||||
|
||||
- **Ordering bug (was `:124`):** `activeTrack = url` moved from *before* `await next.play()` to *after* it
|
||||
succeeds. Previously, on autoplay-block, `activeTrack` was already set to the blocked track's URL, so the
|
||||
gesture-triggered retry (`installUserGestureRetry` → `playBgm(pendingPlay)`) hit the `if (url === activeTrack) return;`
|
||||
guard and silently no-op'd — menu music never started on any autoplay-blocking browser. Now `activeTrack`
|
||||
only updates on confirmed playback; the blocked path leaves it as whatever it was, so the retry actually
|
||||
calls `play()` again. Added the `activeTrack = null` assignment explicitly to the stop/fade-out branch to
|
||||
keep that path's semantics unchanged.
|
||||
- **`crossOrigin = 'anonymous'` (was `:75`) removed** from `makeAudio()`. Inert for same-origin HTTP(S); was
|
||||
actively breaking the `file://` mode `vite.config.js` comments claim to support (opaque origin fails CORS)
|
||||
and forcing Workbox to handle BGM fetches in CORS mode for no benefit.
|
||||
|
||||
No dedicated `bgm.js` test file exists in the repo to gate against regression on this logic (checked — none
|
||||
found). Did not add one: out of scope for this phase's file ownership (adding `src/lib/bgm.test.js` would be
|
||||
a new file under `src/lib/`, and the instructions restrict me to the files listed, `bgm.js` itself only).
|
||||
Flagging as a gap, not silently skipping it.
|
||||
|
||||
## 4. CI test gate (`.github/workflows/deploy.yml`)
|
||||
|
||||
Inserted between `Install dependencies` and `Build`:
|
||||
```yaml
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
```
|
||||
Left `pnpm/action-setup@v4` (no explicit version, resolves from `packageManager: "pnpm@11.1.1"`), the
|
||||
pnpm-before-node ordering, and the cache key untouched, exactly as instructed — audit already verified these
|
||||
correct and pinning/reordering would conflict or regress.
|
||||
|
||||
**Node version:** left CI at `node-version: '24'`, unchanged. README says "Node.js 18+"
|
||||
(`README.md:31`, not mine to edit — owned by the Phase 6 docs agent). My file ownership for `package.json`
|
||||
is scoped to `devDependencies` + `scripts` only, so I can't add an `engines` field either, and creating a
|
||||
new untracked `.nvmrc` isn't in my file-ownership list. Given I have no way to actually fix the README side
|
||||
of this mismatch, and no local way to verify Node 18 actually works with this Vite 6 + pinned-esbuild setup
|
||||
(only Node 24.14.0 is installed on this machine), I chose not to gamble the CI's only proven-working Node
|
||||
version on an untested downgrade. **Decision: keep CI's Node 24 as the source of truth; flag for Phase 6 to
|
||||
update `README.md:31` to "Node.js 22+" (or whatever floor they choose to actually test) instead of the
|
||||
current unverified "18+".** This is the one acceptance item I could not fully close myself — see Unresolved.
|
||||
|
||||
## 5. Verification
|
||||
|
||||
### Build
|
||||
```
|
||||
pnpm build
|
||||
```
|
||||
Exit 0. Vite 6.4.3 + `vite-plugin-svelte`. Warning `state_referenced_locally` at `Game.svelte:38` is
|
||||
pre-existing (owned by another phase, not touched).
|
||||
|
||||
PWA plugin output: `PWA v1.3.0`, `mode generateSW`, `precache 56 entries (5665.40 KiB)`, generates
|
||||
`dist/sw.js` + `dist/workbox-9c191d2f.js` alongside the usual `registerSW.js` + `manifest.webmanifest`.
|
||||
|
||||
### dist/ size, before vs after
|
||||
|
||||
| | Bytes | MiB | Files |
|
||||
|---|---:|---:|---:|
|
||||
| Before (audit baseline, `eb800f1`) | 9,695,992 | 9.25 | 92 |
|
||||
| After (this phase, clean build) | 5,820,462 | 5.55 | 55 |
|
||||
| Delta | −3,875,530 | −3.70 | −37 |
|
||||
|
||||
Breakdown of the delta: −3,902,873 B from relocating voice/jsx/misc-dead assets (item 1), +19,698 B for the
|
||||
new PWA runtime files (`sw.js` 3,978 + `workbox-9c191d2f.js` 15,112 + `registerSW.js` 136 + `manifest.webmanifest` 472),
|
||||
plus small organic drift in the JS/CSS bundle hashes from other phases' concurrent edits to `src/lib/game/**`
|
||||
and `src/components/**` (their work, not mine — this repo had three agents editing simultaneously).
|
||||
|
||||
**This does not hit the plan's "~4 MiB" target.** The gap is `assets/images/keyart-*.png` (1,625,552 B,
|
||||
16.8% of the original dist) which I was explicitly told to leave in `public/` because another agent is
|
||||
wiring it into `MainMenu.svelte` this session, at which point it becomes genuinely live/referenced weight,
|
||||
not dead weight. The original audit's "~4 MiB" projection assumed keyart would *also* be deleted/relocated
|
||||
(it was still unreferenced at audit time). Both instructions are explicit and I followed the more specific
|
||||
one (leave keyart in place); flagging the arithmetic conflict rather than silently picking one. If keyart
|
||||
ships as intended art, 5.55 MiB is the honest floor without cutting real content — see Unresolved.
|
||||
|
||||
### Relative URLs
|
||||
`dist/index.html`:
|
||||
```html
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<script type="module" crossorigin src="./assets/index-....js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-....css">
|
||||
<link rel="manifest" href="./manifest.webmanifest">
|
||||
<script id="vite-plugin-pwa:register-sw" src="./registerSW.js"></script>
|
||||
```
|
||||
All relative. Grepped the generated `dist/sw.js` precache manifest for `url:"/..."` (leading-slash) — **0
|
||||
matches** out of 56 entries. Every precached URL, including `assets/audio/*.mp3` and `assets/images/keyart-*.png`,
|
||||
is scope-relative.
|
||||
|
||||
### Subpath serving
|
||||
Wrote a tiny Node static server (`serve-subpath.mjs`, scratchpad-only, not part of the repo) that mounts
|
||||
`dist/` under `/nntv/` on `localhost:4791`, mimicking the Pages URL structure. Ran:
|
||||
```
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:4791/nntv/
|
||||
curl ... /nntv/assets/index-....js → 200
|
||||
curl ... /nntv/sw.js → 200
|
||||
curl ... /nntv/workbox-9c191d2f.js → 200
|
||||
curl ... /nntv/manifest.webmanifest → 200
|
||||
curl ... /nntv/favicon.png → 200
|
||||
curl ... /nntv/assets/audio/bgm-menu.mp3 → 200
|
||||
curl ... /nntv/assets/images/keyart-garden.png → 200
|
||||
```
|
||||
All 200. `curl .../nntv/` returned the same relative-URL `index.html` shown above — confirms the build
|
||||
resolves correctly two path segments deep, not just at domain root (the exact class of bug the audit warned
|
||||
about: an absolute `/` anywhere would have 404'd here).
|
||||
|
||||
### Live offline verification (partial — environment-limited)
|
||||
Drove headless Chrome (`chrome.exe --headless=new --remote-debugging-port=9333`) via raw CDP (Node's native
|
||||
`fetch`/`WebSocket`, no puppeteer — avoids adding a devDependency for a one-off check) against the subpath
|
||||
server:
|
||||
- First load: confirmed via `navigator.serviceWorker.getRegistrations()` that a registration for
|
||||
`http://localhost:4791/nntv/` reaches `active: "activated"`.
|
||||
- Then killed the HTTP server outright (real `ECONNREFUSED`, not DevTools' "Offline" network-condition
|
||||
emulation) and called `location.reload()` in the same tab.
|
||||
- **Could not get a clean pass/fail from this**: an isolated probe (`caches.open('x')` from the page) threw
|
||||
`UnknownError: Failed to execute 'open' on 'CacheStorage': Unexpected internal error` — Cache Storage
|
||||
itself is broken in this specific sandboxed headless Chrome instance, independent of anything in this repo's
|
||||
SW config. That explains why `caches.keys()` reported empty after the SW reached "activated": the
|
||||
environment's storage backend, not the precache logic, is the failure point.
|
||||
- Given that, I relied on **static/artifact verification** instead (precache manifest contents, relative
|
||||
URLs, registration scope, `NavigationRoute` presence — all confirmed above) plus the mechanical fact that
|
||||
Workbox's `generateSW` install step cannot reach `activated` without its `precacheController.install()`
|
||||
`waitUntil` resolving successfully in a working browser.
|
||||
- **I did not get a real second-tab, network-off, "the game renders and plays" screenshot-equivalent
|
||||
confirmation.** That needs a human (or a properly provisioned browser environment) doing the standard
|
||||
DevTools → Application → Service Workers → "Offline" checkbox → reload check, or the equivalent GitHub
|
||||
Pages live test after deploy. Flagging this explicitly rather than asserting something I couldn't observe.
|
||||
|
||||
### Tests
|
||||
```
|
||||
pnpm test
|
||||
```
|
||||
Result at time of writing: **2 failing, 214 passing** (`src/lib/levels/levels.solvability.test.js` L2, and
|
||||
`src/lib/game/level-solver.test.js` L2). Both failures are in files I do not own (`src/lib/game/**`,
|
||||
`src/lib/levels/**` — Phase 1/2/3 territory) and concern level solvability/guard mechanics, unrelated to
|
||||
anything in this phase's scope (assets, Vite/PWA config, `bgm.js`, CI workflow). Test count itself grew from
|
||||
the audit's 180 to 216+ over the course of this session as other phases landed work concurrently — confirms
|
||||
this is live in-progress breakage from the parallel Phase 1/3 level-retuning effort described in `plan.md`
|
||||
("expect breakage" during retuning), not something introduced here. Re-ran twice; failure is consistent
|
||||
(same two tests), not flaky.
|
||||
|
||||
## Deliberately not done
|
||||
|
||||
- Did not delete anything — relocated only, per owner ruling.
|
||||
- Did not create 192×192/512×512 maskable PWA icons (no image tooling in my remit; existing assets reused).
|
||||
- Did not add a `bgm.js` regression test (would require creating a new file outside the single file I own).
|
||||
- Did not touch `README.md`, `tools/render-assets.mjs`, `tools/make-poster.mjs`, or add an `engines` field —
|
||||
all outside this phase's file ownership.
|
||||
- Did not resolve the keyart-vs-4MiB tension myself (see Unresolved) — followed the more specific instruction.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. **dist/ is 5.55 MiB, not ~4 MiB.** The gap is entirely the 1.63 MiB of keyart PNGs I was told to leave in
|
||||
`public/` for the parallel UX wiring. Once keyart is live-referenced it's not "dead weight" anymore, so
|
||||
the original 4 MiB target (which assumed keyart's removal) may need revising — worth a decision from
|
||||
whoever owns the acceptance bar, not something I should silently resolve.
|
||||
2. **Node version split is still open.** CI proven at 24; README claims 18+, unverified, and I have no file
|
||||
permission to fix README or add `engines`/`.nvmrc`. Needs Phase 6 (docs) to update `README.md:31`.
|
||||
3. **Live browser offline confirmation is incomplete.** Cache Storage API failed at the environment level in
|
||||
the sandboxed headless Chrome available here. Static verification (precache manifest, relative URLs, SW
|
||||
registration/scope, activation reachability) is solid, but nobody has watched the actual game render with
|
||||
the network truly off in a working browser yet. Recommend a manual DevTools check or a post-deploy live
|
||||
Pages test before calling offline play fully proven.
|
||||
4. **`unshipped-assets/voice/` (3.62 MiB VI/EN narration) is still unwired.** I relocated it per the owner
|
||||
ruling; whether/how it eventually gets wired into `StoryIntro`/`LevelIntro`/`GameOver`/popups is a product
|
||||
decision for later, not something I should guess at.
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
# Phase 4 — UX / Playability Implementation Report
|
||||
|
||||
Scope: `src/scenes/**`, `src/components/**`, `src/styles/**`, locales, `progress.js`,
|
||||
`touch-controls.js`, `App.svelte`. Ran in parallel with the engine rewrite
|
||||
(`src/lib/game/**`) — did not touch those files.
|
||||
|
||||
## P0-1 Responsive (360px+, up to 13x13 grid)
|
||||
|
||||
- `src/styles/theme.css`: `#app` kept its fixed 1024x768 design box but is now
|
||||
`transform: scale(min(1, 100vw/1024, 100vh/768))` centered via
|
||||
`transform-origin: center`. Desktops ≥1024x768 render at native 1x (unchanged
|
||||
from before); anything smaller scales down uniformly instead of being
|
||||
clipped by the old `max-width/max-height: 100vw/100vh`. No per-component
|
||||
media queries needed — every child still lays out in the 1024x768 coordinate
|
||||
space.
|
||||
- Why `min(1, vw/1024, vh/768)` and not plain `vw/1024`: caps scale at 1x so
|
||||
large monitors don't stretch the game bigger than its designed size —
|
||||
matches current desktop behavior exactly, only fixes the "too small" case.
|
||||
- `src/scenes/Game.svelte` `.board-container`: removed `85vw/85vh` — those
|
||||
viewport units would resolve against the *real* (unscaled) window, shrinking
|
||||
the board a second time on top of the stage scale. Replaced with fixed
|
||||
`720px/620px` caps, correct in the 1024x768 design space the transform scales
|
||||
as a whole.
|
||||
- Added `touch-action: none` on `.board-container` — without it, a touch swipe
|
||||
meant as a move gesture can also trigger native browser panning on this
|
||||
scrollable element, fighting `touch-controls.js`.
|
||||
- Added a global `@media (prefers-reduced-motion: reduce)` block in
|
||||
`theme.css` (`*,*::before,*::after { animation/transition-duration:
|
||||
0.01ms !important; ... }`). `!important` on an inline-styled property beats
|
||||
a non-`!important` inline style per cascade rules, so this also neutralizes
|
||||
Svelte's `transition:fade` durations — one rule covers `App.svelte`,
|
||||
`StoryIntro`, `LevelCompletePopup`, `SuspicionRing`, `ThrowTargetingOverlay`,
|
||||
and the level-flash animation without touching each component individually.
|
||||
(`GameBoard.svelte`/`GuardSprite.svelte` already had their own local
|
||||
reduced-motion blocks — left those in place, harmless overlap.)
|
||||
|
||||
## P0-2 Throw control (touch-usable stone throwing)
|
||||
|
||||
- `src/components/GameHud.svelte`: `StonesCounter` becomes a real `<button>`
|
||||
(`onenterthrow`) when `canThrow` is true (stones left, idle mode) — the only
|
||||
on-screen entry point into targeting mode previously (E-key only).
|
||||
- `src/components/ThrowTargetingOverlay.svelte`: added a pinned Confirm (✓) /
|
||||
Cancel (✕) button pair, 44px circular targets, positioned near the bottom of
|
||||
the board viewport (stays fixed regardless of board scroll position). Wired
|
||||
to `onconfirm`/`oncancel` props from `Game.svelte` (`confirmThrow`, new
|
||||
`cancelThrow()`). Tap-to-target on board cells still works (unchanged), but
|
||||
the dedicated buttons guarantee a reachable target even when the board
|
||||
shrinks below 44px/cell on small screens.
|
||||
- `src/scenes/Game.svelte`: added `canThrow` derived, `cancelThrow()`, wired
|
||||
new props end to end. Escape/E-key targeting controls unchanged.
|
||||
- `Button.svelte`/`GameHud.svelte` `.icon-btn`: bumped to `min 44x44px`.
|
||||
|
||||
## P0-3 Stuck player — hints + mercy unlock
|
||||
|
||||
**Attempt tracking** (`src/lib/progress.js`): `attempts: {levelNum: count}` and
|
||||
`skippedLevels: []` added to the persisted shape (both default + migration
|
||||
paths). `recordDetection(levelNum)` increments and persists (wrapped in the
|
||||
existing try/catch — private-mode safe). `completeLevel()` now also clears the
|
||||
attempt streak and any skip mark for that level (a real clear supersedes both).
|
||||
|
||||
**Bug found & fixed while adding this**: the pre-existing "no saved data"
|
||||
fallback did `{ ...DEFAULT_PROGRESS }` — a shallow spread that shares nested
|
||||
object/array references (`attempts`, `completedLevels`, etc.) across every
|
||||
call. Under a failing localStorage (private mode, quota), every "fresh"
|
||||
progress object was actually the *same* nested `attempts`/`skippedLevels`
|
||||
object, so state would silently leak across levels and sessions. Replaced with
|
||||
a `freshProgress()` factory returning new nested objects each call. Caught by
|
||||
a test (`progress.test.js` — "survives a localStorage that throws"), verified
|
||||
by making that test fail first, then confirming the fix. This predates my
|
||||
changes but wasn't exercised by any prior test.
|
||||
|
||||
**Thresholds** (my judgement, per the task's "~8" suggestion for mercy):
|
||||
- Hint tier 1 (nudge) at 3 attempts, tier 2 (mechanic reminder) at 5, tier 3
|
||||
(concrete next step) at 7 — evenly spaced escalation ending just before mercy.
|
||||
- Mercy unlock at 8 attempts (matches audit's suggestion directly).
|
||||
- `HINT_THRESHOLDS = [3, 5, 7]`, `MERCY_THRESHOLD = 8`, both exported constants
|
||||
in `progress.js` for a single source of truth.
|
||||
|
||||
**Hints**: `src/lib/level-hints.js` (new) maps level → 3 locale keys
|
||||
(`hint.level{N}.{1,2,3}`), scoped to L2–L11 (L1 has no guards so detection
|
||||
never fires; L12 is intentionally unsolvable so hints would be dishonest).
|
||||
`src/components/HintPanel.svelte` (new, modal) shows unlocked tiers' text and
|
||||
a generic "keep trying" placeholder for locked ones. `GameHud` shows a `HINT`
|
||||
button once `hintTiersAvailable > 0`.
|
||||
|
||||
**Mercy unlock**: `mercySkipLevel(levelNum, totalLevels)` in `progress.js`
|
||||
marks the level "skipped" and unlocks `levelNum+1` (capped at `totalLevels`,
|
||||
no-op if already completed). Surfaced as an explicit, separately-labelled
|
||||
button in `DetectionPopup` (never auto-triggered — appears alongside "PLAY
|
||||
AGAIN", not instead of it) once `isMercyEligible()` and the level isn't final.
|
||||
`LevelSelect.svelte` shows a grey "SKIPPED" badge (not stars) for skipped,
|
||||
uncompleted levels.
|
||||
|
||||
**Tests**: `src/lib/progress.test.js` (new) — 10 cases covering attempt
|
||||
increment/persistence, hint-tier escalation at exact thresholds, mercy
|
||||
eligibility boundary, mercy skip unlocking next level and capping at total,
|
||||
no-op on an already-completed level, attempt/skip reset on a real clear, and
|
||||
the localStorage-failure guard (which caught the shared-reference bug above).
|
||||
|
||||
## P1 items completed
|
||||
|
||||
- **`levelComplete` key**: added to both locales. `localization.js` `getText`
|
||||
now checks `!== undefined` (not truthiness — an intentionally empty string
|
||||
is a valid translation) and, on a total miss, returns a visibly-flagged
|
||||
`⚠ missing:{key}` plus a `console.warn`, instead of silently returning the
|
||||
raw key (which reads as plausible text and hides the bug).
|
||||
- **Death→retry keyboard**: `DetectionPopup`'s primary button now
|
||||
autofocuses on mount (`Button.svelte` gained an `autofocus` prop) so
|
||||
Enter/Space work immediately via native button semantics — no more 5-7 Tabs
|
||||
through hidden HUD buttons (one of which used to be MENU, which exits the
|
||||
level). Added an `R` shortcut once the popup is actually visible. Same
|
||||
autofocus treatment for `PauseMenu` (Resume), `ControlsOverlay` (Back),
|
||||
`LevelCompletePopup` (Continue), `HintPanel` (Back).
|
||||
**Cost before → after**: 6-7 Tab + Enter (worse: MENU sat in that Tab
|
||||
chain and would exit the level) → 1 keypress (Enter/Space/R), or unchanged
|
||||
1-click for pointer.
|
||||
- **Detection overlay timing**: `detected` (freezes input) still fires
|
||||
immediately; a new `showDetectionPopup` flag now lags it by 500ms — past the
|
||||
400ms cell-flash/shake duration — so the popup's dark overlay no longer
|
||||
covers the lit cell that caught the player. `initLevel`/`restartLevel`/
|
||||
`onDestroy` all clear the new timeout.
|
||||
- **Guard onboarding**: `src/lib/level-teaches.js` (new) + a "New this level"
|
||||
strip in `LevelIntro.svelte`, independent of `levels.js` (not touched) —
|
||||
mirrors the "Mechanic intro" authoring comments already there. `L4`'s
|
||||
suspicion guard now also gets an always-visible range boundary (a Manhattan
|
||||
diamond, math verified so its edge lands exactly `range` cells out) in
|
||||
`SuspicionRing.svelte`, not just the tier≥1 ring — the audit's core
|
||||
complaint (F11) was that the only tell appeared *after* the guard had
|
||||
already started tracking.
|
||||
- **Affordance awareness**: `GameHud` now shows a persistent small
|
||||
"NO UNDO"/"NO PREVIEW" chip whenever a level disables them, in addition to
|
||||
the existing `LevelIntro` banner (which doesn't reappear after a mid-level
|
||||
pause-menu restart) — the player has a standing reminder before committing
|
||||
to a move, not just a one-time splash.
|
||||
- **i18n**: `levelComplete`, `turnsLabel` (replaces hardcoded `Turns:`),
|
||||
`parLabel`, per-level `level{N}Name` (12 keys — VI-default game had 12
|
||||
English-only level names), all board-cell ARIA (`board.*`, 13 keys),
|
||||
throw-control strings, hint/mercy/teaches strings — **62 new keys, both
|
||||
locales, verified 167/167 parity** (script-checked, see Verification).
|
||||
Removed the dead `mechanics.sniper.name`, `mechanics.suspicion.name`,
|
||||
`mechanics.suspicion.alerted`, `mechanics.suspicion.firing`,
|
||||
`mechanics.oneWay.aria`, `mechanics.warm.aria`, `mechanics.door.locked`,
|
||||
`mechanics.door.open` (unused — `GameBoard`/`GuardSprite` built English
|
||||
labels inline; explicit task instruction was to remove, not wire up).
|
||||
Kept `mechanics.stones.label`/`mechanics.keys.label`/`mechanics.key.aria`
|
||||
(actually used by `StonesCounter`/`KeyInventory`).
|
||||
Also softened `controlPreview` text to note the preview assumes the player
|
||||
waits (interim fix for F09/F10 — the underlying simulate-stationary bug is
|
||||
in `turn-manager.js`, which I don't own).
|
||||
- **A11y**: `role="dialog" aria-modal="true"` + a shared `src/lib/focus-trap.js`
|
||||
Svelte action (Tab-cycling) on all four modal overlays + the new
|
||||
`HintPanel`. `App.svelte` now focuses the scene wrapper after every
|
||||
`navigate()` and announces the scene name via a visually-hidden
|
||||
`aria-live="polite"` region (scene transitions previously dropped focus to
|
||||
`<body>` silently). `Escape` now opens Pause from idle and closes it from
|
||||
paused (previously only worked inside the Controls overlay).
|
||||
`GameBoard.svelte` cell/grid ARIA labels are now fully localized
|
||||
(`board.*` keys) instead of hardcoded English strings.
|
||||
- **Dead code removed**: `GameBoard.svelte`'s `throwTargetCells`/`throwCursor`
|
||||
props and their rings were never passed from `Game.svelte` (confirmed via
|
||||
grep) — deleted along with the now-orphaned CSS; the real throw-targeting
|
||||
visuals live in `ThrowTargetingOverlay`, which is what's actually rendered.
|
||||
- **Warm-tile text alignment (frozen constraint #2)**: while drafting L6
|
||||
hint/teaches copy, caught myself echoing the *old* levels.js design comment
|
||||
("decay window = safe sprint"). Since an engine agent is concurrently
|
||||
making warm tiles lethal, that copy would teach the opposite of the
|
||||
incoming rule. Rewrote `teaches.warm` and `hint.level6.2`/`.3` (both
|
||||
locales) to say warm is dangerous and the player should wait for full
|
||||
dark, not sprint through the afterglow. Did **not** touch the warm-tile
|
||||
*visual* (`GameBoard.svelte`'s orange glow) — the audit's own description
|
||||
("rendered as orange hazard... but actually safe") means the art already
|
||||
reads as dangerous; it was the code/logic that lied, and that's the engine
|
||||
team's fix landing in parallel. Nothing for me to change there.
|
||||
|
||||
## L12 frozen constraint
|
||||
|
||||
Added comments at both `checkFinalLevel()` call sites in `Game.svelte`
|
||||
(`handleMove`, `handleWait`) marking the `isFinalLevel && checkFinalLevel()`-
|
||||
before-`levelComplete` ordering as intentional, per the task's instruction —
|
||||
did not change the ordering itself.
|
||||
|
||||
## Deliberately not done (scope cuts)
|
||||
|
||||
- Bestiary two-tab overlay (F06) — added a `Guide` button to `PauseMenu`
|
||||
instead (one line, reuses the existing scene) rather than building a new
|
||||
in-game two-tab component.
|
||||
- Redo HUD button (F28), gated-key denial feedback/sound (F26), colour-blind
|
||||
glyphs for valid/invalid/locked (F38), StoryIntro scroll timeout fallback
|
||||
(F31), unreachable `gameOver.runComplete` flow (F32) — all P2 in the audit,
|
||||
not in the task's explicit P1 list; left as-is.
|
||||
- Culprit-guard-name in `DetectionPopup` (audit F14 embellishment) — would
|
||||
need `turn-manager.js` to return which guard triggered detection; that's
|
||||
engine-owned and not currently exposed.
|
||||
- Preview-per-destination correctness (F10 root cause) — `turn-manager.js`
|
||||
simulates the player stationary; fixing the simulation itself is engine
|
||||
territory. Only softened the label text as an interim honesty fix.
|
||||
- Did not touch `LevelIntro` story text (L5-L9 factually wrong per the audit)
|
||||
— explicitly scheduled for the level-retune phase, per instructions.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm test` (`npx vitest run`): 219/220 pass. The one failure
|
||||
(`levels.solvability.test.js` — "L2 must be solvable") is in a file/area I
|
||||
don't own (`src/lib/levels/**` exercising `src/lib/game/**`); `git status`
|
||||
confirms `src/lib/game/*.js` are mid-edit by the concurrent engine phase
|
||||
right now. Not caused by, or fixable within, this phase's file ownership.
|
||||
- `pnpm build` (`npx vite build`): succeeds, 180 modules, dist unchanged in
|
||||
shape (only a pre-existing unrelated Svelte compiler warning about
|
||||
`currentLevel = $state(level)` capturing only the initial prop value —
|
||||
present before my changes, not introduced by them).
|
||||
- i18n parity: scripted check, `en: 167 vi: 167`, zero en-only/vi-only keys.
|
||||
- No new runtime dependencies (`package.json` untouched).
|
||||
|
||||
## Files touched
|
||||
|
||||
New: `src/components/HintPanel.svelte`, `src/lib/focus-trap.js`,
|
||||
`src/lib/level-hints.js`, `src/lib/level-teaches.js`, `src/lib/progress.test.js`.
|
||||
|
||||
Modified: `src/App.svelte`, `src/components/{Button,ControlsOverlay,
|
||||
DetectionPopup,GameBoard,GameHud,GuardSprite,LevelCompletePopup,PauseMenu,
|
||||
SuspicionRing,ThrowTargetingOverlay}.svelte`, `src/lib/locales/{en,vi}.json`,
|
||||
`src/lib/localization.js`, `src/lib/progress.js`, `src/scenes/{Game,
|
||||
LevelIntro,LevelSelect}.svelte`, `src/styles/theme.css`.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Hint tier thresholds (3/5/7/8) are my judgement call per the task's
|
||||
instruction to use it — no user confirmation sought given auto-mode.
|
||||
Easy to retune later since they're two exported constants.
|
||||
2. L6's actual difficulty/par once warm tiles go lethal is Phase 3's problem
|
||||
(level retune) — my hint text is now directionally correct (warm =
|
||||
dangerous) but doesn't assert a specific solved route, since the exact
|
||||
safe window shifts once the engine and Phase 3 retune land.
|
||||
3. Mercy-skip for L12 specifically (audit's "secondary" suggestion — let the
|
||||
bittersweet ending play after N failures) was not implemented; L12 is
|
||||
excluded from mercy/hints entirely since it's the intentionally-unsolvable
|
||||
final level and there's no "next level" to unlock. Flagging in case the
|
||||
owner wants that documented escape after all.
|
||||
@@ -0,0 +1,234 @@
|
||||
# Offline & GitHub Pages Readiness Audit
|
||||
|
||||
Date: 2026-07-25 | Repo: `C:\Users\miti99\Workspaces\tiennm99\nntv` | Branch `main` @ `eb800f1` (clean)
|
||||
Build verified: `pnpm build` exit 0, vite 6.4.3. Tests: `pnpm test` 180/180 pass, 9 files, 38.2s.
|
||||
`dist` is gitignored (`.gitignore:14`).
|
||||
|
||||
---
|
||||
|
||||
## VERDICT
|
||||
|
||||
| Question | Answer | Evidence |
|
||||
|---|---|---|
|
||||
| Zero cross-origin runtime deps? | **YES** | Grep of all `src/` + `index.html` for `fetch(`/`XMLHttpRequest`/`http(s)://`/`@import url(`/CDN hosts/analytics returned **zero** runtime hits. Fonts are system-stack only (`src/styles/theme.css:39-45,57` → `Arial, sans-serif`). No web font, no CDN, no telemetry. |
|
||||
| Plays fully offline (network hard-off, after first visit)? | **NO** | No service worker, no PWA manifest anywhere in repo. Cold navigation to `index.html` with no network fails at the HTTP layer. Browser HTTP cache is best-effort and evictable; 9.25 MiB payload is prime eviction bait. |
|
||||
| Deploys correctly to Pages subpath `https://tiennm99.github.io/nntv/`? | **YES** | `dist/index.html` emits `./assets/index-B7WTOKRu.js`, `./assets/index-BUv627AV.css`, `./favicon.png` — all relative. Runtime asset strings are document-relative (`assets/…`, no leading `/`). `base: './'` (`vite.config.js:8`) also auto-rewrote the source's absolute `/favicon.png` (`index.html:5`) to `./favicon.png` in the build output. |
|
||||
|
||||
**Net:** requirement #2 (Pages) is met today. Requirement #1 ("plays fully offline") is met only in the weak sense of "no third-party network calls". True offline play needs a service worker.
|
||||
|
||||
---
|
||||
|
||||
## Cross-origin runtime references
|
||||
|
||||
**None.** Full sweep of `src/**` and `index.html`:
|
||||
|
||||
| Pattern | Hits in src/ | Nature |
|
||||
|---|---|---|
|
||||
| `fetch(` | 0 | — |
|
||||
| `XMLHttpRequest` | 0 | — |
|
||||
| `http://` / `https://` | 0 runtime (2 in comments/docs only) | — |
|
||||
| `@import url(` | 0 | — |
|
||||
| `googleapis` / `cdn.` / `unpkg` / `jsdelivr` | 0 | — |
|
||||
| `gtag` / `analytics` | 0 | — |
|
||||
| dynamic `import(` | 0 | single-chunk build confirms it |
|
||||
| `new Audio(` | 1 — `src/lib/bgm.js:72` | **same-origin**, URL from `BGM_TRACKS` (`bgm.js:52-56`), all `assets/audio/*.mp3` |
|
||||
| `<img src=` | 13 | all same-origin: `src/scenes/MainMenu.svelte:19,35`, `Guide.svelte:9`, `Settings.svelte:45`, `StoryIntro.svelte:24`, `LevelIntro.svelte:20`, `Game.svelte:536`, `GameBoard.svelte:81,93,104,111,118` — resolved via `src/lib/generated-assets.js:1` (`BASE = 'assets/generated/'`) |
|
||||
| `<link rel="icon">` | `index.html:5` | same-origin, absolute `/favicon.png` in source — **rewritten to `./favicon.png` by Vite**, so not a Pages break |
|
||||
|
||||
Sole nuance: `src/lib/bgm.js:75` sets `a.crossOrigin = 'anonymous'`. Inert for same-origin over HTTP(S), so harmless on Pages. It *does* break the `file://` use case that `vite.config.js:6-7` claims to support (opaque `null` origin fails the CORS check), and it forces `mode: 'cors'` fetches that a naive service worker must handle. See P2-1.
|
||||
|
||||
---
|
||||
|
||||
## Dead / unreferenced assets shipped to every visitor
|
||||
|
||||
Method: every filename under `public/assets/` grepped across the whole repo excluding `node_modules`/`dist`. "Dead" = referenced by nothing but `public/assets/MEDIA.json` (a documentation manifest that is itself never loaded).
|
||||
|
||||
| Path | Files | Bytes | Notes |
|
||||
|---|---:|---:|---|
|
||||
| `public/assets/voice/*.mp3` | 26 | 3,799,214 | **Recorded but never wired.** Zero `voice` references in `src/`. Commit `c2c8ad0` "add VI voices" landed the files without playback code. Largest: `en-story.mp3` 749,997 B. |
|
||||
| `public/assets/images/keyart-*.png` | 6 | 1,625,552 | Zero refs in `src/`. `MEDIA.json:8` claims `keyart-garden.png` is the "Main menu background" — but `MainMenu.svelte:19` actually loads the 1,234 B `assets/scene-menu.png` placeholder. Largest: `keyart-palace.png` 504,014 B. |
|
||||
| `public/assets/src/*.jsx` | 5 | 72,839 | Authoring-time React sources (`canvas.jsx`, `characters.jsx`, `pixel.jsx`, `scenes.jsx`, `tiles-ui.jsx`). Not build inputs. |
|
||||
| `public/assets/design-canvas.jsx` | 1 | 10,646 | Authoring artifact. |
|
||||
| `public/assets/MEDIA.json` | 1 | 8,429 | Docs manifest, never fetched. |
|
||||
| `public/assets/poster.png` | 1 | 5,538 | Zero refs. |
|
||||
| `public/assets/MANIFEST.json` | 1 | 3,023 | Written by `tools/render-assets.mjs:477`; never fetched at runtime. |
|
||||
| `public/assets/Night Ninja Assets.html` | 1 | 1,245 | Stray asset-index page. |
|
||||
| `public/assets/logo-nntv-wordmark.png` | 1 | 669 | Zero refs. |
|
||||
| `public/assets/logo.png` | 1 | 335 | Zero refs. |
|
||||
| `public/assets/logo-nntv-compact.png` | 1 | 335 | Zero refs. |
|
||||
| `public/assets/favicon-32.png` | 1 | 300 | Zero refs (`index.html` uses root `favicon.png`). |
|
||||
| `public/assets/favicon-16.png` | 1 | 300 | Zero refs. |
|
||||
| **TOTAL RECLAIMABLE** | **47** | **5,528,425** | **5.27 MiB (5.53 MB) — 57.0% of dist** |
|
||||
|
||||
**Live/referenced** (keep): all 32 `assets/generated/*.png` (1,161,196 B — every one is named in `generated-assets.js:4-56`), all 5 `assets/audio/bgm-*.mp3` (2,833,743 B — `bgm.js:52-56`), 4 placeholder scene PNGs (4,324 B), `favicon.png` (300 B).
|
||||
|
||||
---
|
||||
|
||||
## dist/ size breakdown (real build)
|
||||
|
||||
Total **9,695,992 B = 9.25 MiB** across 92 files.
|
||||
|
||||
| Path | Bytes | MiB | % | Status |
|
||||
|---|---:|---:|---:|---|
|
||||
| `assets/voice/` (26) | 3,799,214 | 3.62 | 39.2% | DEAD |
|
||||
| `assets/audio/` (5) | 2,833,743 | 2.70 | 29.2% | live |
|
||||
| `assets/images/` (6) | 1,625,552 | 1.55 | 16.8% | DEAD |
|
||||
| `assets/generated/` (32) | 1,161,196 | 1.11 | 12.0% | live |
|
||||
| `assets/index-B7WTOKRu.js` | 139,283 | 0.13 | 1.4% | live (gzip 46.2 kB) |
|
||||
| `assets/src/` (5 .jsx) | 72,839 | 0.07 | 0.8% | DEAD |
|
||||
| `assets/index-BUv627AV.css` | 28,254 | 0.03 | 0.3% | live (gzip 5.55 kB) |
|
||||
| misc dead (design-canvas, MEDIA, MANIFEST, poster, logos, favicons, stray html) | 30,520 | 0.03 | 0.3% | DEAD |
|
||||
| 4 scene placeholder PNGs + favicon + index.html | 5,091 | 0.005 | 0.05% | live |
|
||||
|
||||
Post-cleanup projection: **4,167,567 B = 3.97 MiB** (−57.0%).
|
||||
Code payload is genuinely small — 167.5 kB raw / ~52 kB gzip. All weight is media.
|
||||
|
||||
---
|
||||
|
||||
## First-load UX (cold cache)
|
||||
|
||||
- **No loading state anywhere.** No spinner, no progress bar, no skeleton. `src/main.js:5` mounts `App` immediately; `App.svelte:73-93` renders the active scene with no gate.
|
||||
- **Interaction is not blocked** — good. JS+CSS is 167.5 kB; the game is playable the instant the bundle parses. Art and BGM stream in behind it.
|
||||
- **But art pops in visibly.** Zero `<img>` in the codebase carries `loading=`, `decoding=`, `fetchpriority=`, or an `onload` handler (checked `MainMenu`, `LevelIntro`, `Game`, `GameBoard`). On a cold cache the board renders as empty boxes that fill in tile-by-tile as ~1.1 MiB of PNGs land.
|
||||
- **BGM start is silent-then-sudden.** `App.svelte:49-52` fires `playBgm` on mount; `bgm.js:143-152` awaits `next.play()`. First track is 355,970–909,065 B. Autoplay-block fallback is correctly handled (`bgm.js:165-179`, retries on first `pointerdown`/`keydown`).
|
||||
- **The 5.27 MiB of dead assets are NOT fetched on load** — nothing references them, so they cost deploy/artifact size and Pages bandwidth, not first-paint latency. Real cold-load transfer is ~4 MiB, of which ~1.3 MiB (JS+CSS+tiles) gates a good-looking first screen.
|
||||
|
||||
---
|
||||
|
||||
## localStorage / private mode
|
||||
|
||||
**Safe.** Every access is guarded.
|
||||
|
||||
| Site | Guard |
|
||||
|---|---|
|
||||
| `src/lib/progress.js:19-46` | `try/catch` around read + parse; falls back to `DEFAULT_PROGRESS` |
|
||||
| `src/lib/progress.js:27-31` | nested `try/catch` on migration write |
|
||||
| `src/lib/progress.js:57-61` | `try/catch`, returns `true` (suppresses modal) on throw |
|
||||
| `src/lib/progress.js:64-70` | `try/catch`, swallows |
|
||||
| `src/lib/progress.js:99-103` | `try/catch`, swallows |
|
||||
| `src/lib/bgm.js:19-34` | `try/catch` → `DEFAULT` settings |
|
||||
| `src/lib/bgm.js:37-43` | `try/catch`, swallows |
|
||||
| `src/lib/audio.js:11` | `try/catch` around the `isSfxMuted()` boot read |
|
||||
| `src/lib/localization.js:35,50` | `typeof localStorage !== 'undefined'` checks |
|
||||
|
||||
No unguarded access. Safari private mode / storage-disabled degrades to in-memory defaults; the game stays playable, progress just doesn't persist. Correct behavior, no crash.
|
||||
|
||||
Caveat, not a bug: `localization.js:35,50` uses a `typeof` check, which does **not** catch the `SecurityError` that some browsers throw on *access* when cookies/storage are blocked by policy (the object exists but throwing). `progress.js`/`bgm.js` use `try/catch` and are immune. Minor inconsistency — see P2-3.
|
||||
|
||||
---
|
||||
|
||||
## Deploy workflow review (`.github/workflows/deploy.yml`)
|
||||
|
||||
| Item | Finding | Severity |
|
||||
|---|---|---|
|
||||
| `pnpm/action-setup@v4` with no `version:` (`:25`) | **Not a defect.** v4 falls back to `packageManager` in `package.json`, which is pinned to `pnpm@11.1.1` (`package.json:6`). Resolution is deterministic. Adding an explicit `version:` would in fact *conflict* with the field. Leave as-is. | none |
|
||||
| Cache key (`:31`) | **Correct.** `cache: 'pnpm'` on `setup-node@v4`, and `pnpm/action-setup` runs *before* it (`:25` vs `:27`) — required ordering is satisfied. Keys off `pnpm-lock.yaml`. | none |
|
||||
| **No test gate before deploy** | `build` job goes Checkout → pnpm → Node → install → build → upload. `pnpm test` is never run. A regression that compiles ships straight to production. Suite is 180 tests / 38s — cheap to gate on. | **P1** |
|
||||
| `actions/configure-pages@v4` (`:40`) | v5 is available; v4 still functional. Also: this step is largely inert here — its main job is emitting a `base_path` output for frameworks that consume it, and this build hardcodes `base: './'`. Low-value bump. | P2 |
|
||||
| Node `'24'` (`:30`) vs README "Node.js 18+" (`README.md:31`) | Real mismatch: CI validates only on 24, docs promise 18. Vite 6 requires Node ^18.0.0 \|\| ^20.0.0 \|\| >=22.0.0, so 18 is plausible but **unverified**. Either raise the README floor to 20/22 or add an 18 matrix leg. | P2 |
|
||||
| `actions/checkout@v4`, `upload-pages-artifact@v3`, `deploy-pages@v4` | Current/valid. Permissions block (`:8-11`) and `concurrency` (`:14-16`) are correct for Pages. | none |
|
||||
| Build warning ignored | `src/scenes/Game.svelte:38` emits `state_referenced_locally` on every build. Not fatal; noise that hides future real warnings. | P2 |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized fixes
|
||||
|
||||
### P0 — breaks a stated hard requirement
|
||||
|
||||
**P0-1 — Ship a service worker; the game cannot currently play with the network off.**
|
||||
Files: new `public/sw.js` (or add `vite-plugin-pwa`), `index.html`, new `public/manifest.webmanifest`.
|
||||
This is the *only* item standing between the current state and the owner's "MUST play fully offline". Detail in the next section.
|
||||
|
||||
*(No P0 exists for the Pages requirement — that one already works.)*
|
||||
|
||||
### P1 — materially hurts
|
||||
|
||||
**P1-1 — Delete 5.27 MiB of dead assets.**
|
||||
Delete: `public/assets/voice/` (26 files), `public/assets/images/` (6), `public/assets/src/` (5), `public/assets/design-canvas.jsx`, `poster.png`, `logo.png`, `logo-nntv-compact.png`, `logo-nntv-wordmark.png`, `favicon-16.png`, `favicon-32.png`, `Night Ninja Assets.html`, `MANIFEST.json`, `MEDIA.json`.
|
||||
Cuts dist 9.25 → 3.97 MiB. **Decide first:** the voice lines are finished content that was simply never wired (see P1-2). If they're going to be used, keep `voice/` and delete the other 1.73 MiB. Move authoring sources (`assets/src/`, `design-canvas.jsx`) and docs (`MEDIA.json`, `MANIFEST.json`) out of `public/` into `tools/` — they are not deploy artifacts. Halving the payload also makes the service worker precache tractable.
|
||||
|
||||
**P1-2 — Decide the fate of `public/assets/voice/` (3.62 MiB, 26 files).**
|
||||
Either wire it (`StoryIntro.svelte`, `LevelIntro.svelte`, `GameOver.svelte`, `DetectionPopup.svelte`, `LevelCompletePopup.svelte`, selecting `en-`/`vi-` off `localization.js`) or delete it. Shipping 3.62 MiB of unplayable audio to every visitor is the single largest waste in the repo.
|
||||
|
||||
**P1-3 — Add a test gate to the deploy workflow.**
|
||||
`.github/workflows/deploy.yml`, insert between `:34` and `:36`:
|
||||
```yaml
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
```
|
||||
180 tests, 38s, currently green.
|
||||
|
||||
### P2 — polish
|
||||
|
||||
**P2-1 — Drop `a.crossOrigin = 'anonymous'` (`src/lib/bgm.js:75`).** No same-origin benefit; breaks the `file://` scenario `vite.config.js:6-7` advertises; forces CORS-mode fetches a service worker must special-case.
|
||||
|
||||
**P2-2 — Make `index.html:5` relative: `href="./favicon.png"`.** Vite already fixes this at build, but the source is misleading and would bite anyone bypassing the Vite pipeline.
|
||||
|
||||
**P2-3 — Convert `src/lib/localization.js:35,50` from `typeof localStorage !== 'undefined'` to `try/catch`,** matching `progress.js`/`bgm.js`. Closes the policy-blocked-storage `SecurityError` path.
|
||||
|
||||
**P2-4 — Add `decoding="async"` to the board tile `<img>`s** (`src/components/GameBoard.svelte:81,93,104,111,118`) and consider `fetchpriority="high"` on the level backdrop (`Game.svelte:536`). Reduces cold-cache tile pop-in.
|
||||
|
||||
**P2-5 — Reconcile Node versions.** Either `README.md:31` → "Node.js 20+" (matching what CI actually proves), or add an 18.x matrix leg to `deploy.yml`.
|
||||
|
||||
**P2-6 — Fix `README.md:7,41`.** Both claim `http://localhost:5173`; `vite.config.js:10` sets `port: 8080`.
|
||||
|
||||
**P2-7 — Silence the `state_referenced_locally` warning** at `src/scenes/Game.svelte:38`. The `// svelte-ignore` comment is on the wrong line — it must be on its own line *above* the statement, not trailing it.
|
||||
|
||||
**P2-8 — Bump `actions/configure-pages@v4` → `@v5`** (`deploy.yml:40`). Low value; the step is near-inert given the hardcoded `base`.
|
||||
|
||||
---
|
||||
|
||||
## Service worker / PWA recommendation
|
||||
|
||||
**YES — required, not optional.** It is the only remaining gap against "MUST play fully offline".
|
||||
|
||||
Why it's a good fit here:
|
||||
- GitHub Pages is HTTPS → service workers are permitted. No blocker.
|
||||
- The game is **100% client-side**: zero network calls after load (verified above), no API, no auth, no server state. Progress lives in `localStorage`. This is the ideal SW target — precache everything and the app is genuinely, indefinitely offline.
|
||||
- Asset set is **static and finite**. After P1-1 it's ~4 MiB across ~45 files, all enumerable at build time.
|
||||
- Content-hashed JS/CSS filenames (`index-B7WTOKRu.js`) make cache invalidation trivial.
|
||||
|
||||
Recommended shape — **`vite-plugin-pwa`** over a hand-rolled `sw.js`. It auto-generates the precache manifest from the real build output, so the file list can't drift as assets change (exactly the drift that produced the dead-asset problem). Hand-rolling means maintaining a literal file list by hand.
|
||||
|
||||
Sketch:
|
||||
|
||||
1. `pnpm add -D vite-plugin-pwa`
|
||||
2. `vite.config.js` — add the plugin:
|
||||
```js
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.png'],
|
||||
workbox: {
|
||||
// BGM files exceed the 2 MiB default cap
|
||||
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
|
||||
globPatterns: ['**/*.{js,css,html,png,mp3}'],
|
||||
},
|
||||
manifest: {
|
||||
name: 'Night Ninja: Twilight Voyage',
|
||||
short_name: 'Night Ninja',
|
||||
start_url: './', // MUST be relative for the /nntv/ subpath
|
||||
scope: './', // ditto
|
||||
display: 'fullscreen',
|
||||
background_color: '#0a0a1a', // matches theme.css:3 --bg-dark
|
||||
theme_color: '#0a0a1a',
|
||||
icons: [ /* reuse assets/generated PNGs, or re-add favicon-192/512 */ ],
|
||||
},
|
||||
})
|
||||
```
|
||||
3. Registration scope: `base: './'` means the SW registers at `/nntv/sw.js` with scope `/nntv/` — correct and automatic. **Do not** hardcode a leading-slash scope; it would break the subpath.
|
||||
4. **Do P1-1 first.** Precaching today would push 9.25 MiB into the cache, half of it files no code loads.
|
||||
5. Split strategy if BGM stays large: precache JS/CSS/HTML/`assets/generated/*` (~1.3 MiB) for instant offline boot; `CacheFirst` runtime-cache `assets/audio/*.mp3` so music becomes offline after first play rather than blocking install.
|
||||
6. Apply P2-1 first — `crossOrigin='anonymous'` makes BGM requests CORS-mode, which Workbox handles but which needlessly complicates cache matching.
|
||||
7. Verify: `pnpm build && pnpm preview`, load once, then DevTools → Network → Offline, hard-reload, play through a level and confirm BGM + tiles + progress all work.
|
||||
|
||||
Scope of work: **~1–2 hours** including the P1-1 cleanup and offline verification. One new dev dependency, ~25 lines in `vite.config.js`, icon assets. No source-code changes to game logic.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. **Is `public/assets/voice/` (3.62 MiB, 26 finished lines) intended to ship?** Determines whether P1-1 reclaims 5.27 MiB or 1.73 MiB, and whether P1-2 is "wire it" or "delete it". Commit `c2c8ad0` added them but no playback code — deliberate staging or dropped work?
|
||||
2. **Are the `keyart-*.png` files (1.55 MiB) meant to replace the ~1 kB `scene-*.png` placeholders?** `MEDIA.json:8` asserts `keyart-garden.png` is the main-menu background, but `MainMenu.svelte:19` loads the placeholder. This looks like unfinished integration, not dead weight — deleting them may destroy intended art.
|
||||
3. **Is `file://` (double-click `dist/index.html`) a supported distribution mode?** `vite.config.js:6-7` says yes. If so, P2-1 is mandatory, not polish, and a service worker cannot help there (SWs don't run on `file://`).
|
||||
4. **Minimum supported Node?** Needed to resolve the README-18 vs CI-24 split. No `engines` field in `package.json` and no `.nvmrc`.
|
||||
5. **Should PWA install (add-to-homescreen) be a goal, or just offline caching?** Affects whether proper 192/512 maskable icons are needed — none currently exist at those sizes.
|
||||
@@ -0,0 +1,172 @@
|
||||
# Playability & UX Audit — Night Ninja: Twilight Voyage
|
||||
|
||||
Date: 2026-07-25 · Scope: read-only static code review · No browser/dev-server used.
|
||||
|
||||
## VERDICT
|
||||
|
||||
A first-time **desktop, mouse-using** player probably reaches level 5 — but by dying, not by understanding. L1–L3 are legible (empty grid, wilting auras, one-way arrows, and the LevelIntro story text for those three levels matches what is actually in the level). L4 is where comprehension breaks: it introduces the **suspicion guard** (`levels.js:133`) whose only tell is a ring that appears *after* tier ≥ 1 (`SuspicionRing.svelte:7`), whose range-3 danger zone is never drawn, and which the L4 intro text never mentions (`en.json:67` talks only about a rotating searchlight). From L5 onward the intro text is factually wrong about the level contents (L5 promises a blinking guard that isn't there and omits the keys/doors that are; L7 promises a patroller that isn't there; L8 promises "two mirrors, two rotating beams" but ships a sniper + patroller + static; L9 promises a chaser and omits the stones it introduces). The one narrative channel that teaches mechanics is desynced from the levels.
|
||||
|
||||
A first-time **touch** player does not reach level 5 at all: there are zero media queries in the codebase, the stage is a hard-coded 1024×768 letterbox (`theme.css:65-73`), and stone-throw mode has no on-screen entry point (`Game.svelte:326` — `E` key only).
|
||||
|
||||
Also: the level-complete popup prints the raw i18n key `levelComplete` as its title on every single win (`LevelCompletePopup.svelte:8`), and there is no hint, no skip, and no path forward for a stuck player (`LevelSelect.svelte:12-14`).
|
||||
|
||||
Difficulty is currently coming from the UI as much as from the puzzles.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
| id | area | sev | file:line | one-line fix |
|
||||
|----|------|-----|-----------|--------------|
|
||||
| F01 | mobile | **P0** | `theme.css:65-73`; no `@media` anywhere in `src/` | Wrap `#app` in a JS/CSS `transform: scale()` fit-to-viewport, or make the stage fluid below 1024px. |
|
||||
| F02 | mobile | **P0** | `Game.svelte:326,395`; `GameHud.svelte:38-56` | Add a HUD stone button that calls `enterTargeting()`; add on-screen Confirm/Cancel in targeting mode. |
|
||||
| F03 | progression | **P0** | `LevelSelect.svelte:12-14`; `progress.js:85-88`; no hint code in `src/` | Unlock level N+1 after X failed attempts, and/or add a per-level hint (see stuck-player section). |
|
||||
| F04 | onboarding | P1 | `en.json:68` vs `levels.js:181-187`; `en.json:70` vs `levels.js:300-307`; `en.json:71` vs `levels.js:345-359`; `en.json:72` vs `levels.js:396-417` | Rewrite `level5/7/8/9Story` to describe the guards/mechanics actually present. |
|
||||
| F05 | onboarding | P1 | suspicion first at `levels.js:133` (L4), keys/doors at `levels.js:190-197` (L5), warm at `levels.js:253` (L6), sniper at `levels.js:347` (L8), stones at `levels.js:419` (L9) — none named in their intro text | Add a one-line "New this level: …" strip to `LevelIntro.svelte` driven by a `level.teaches[]` field. |
|
||||
| F06 | onboarding | P1 | `GameHud.svelte:51` → `ControlsOverlay.svelte` (keys only); `Guide.svelte` only reachable from `MainMenu.svelte:41` | Make the in-game `?` a two-tab overlay: Controls + Bestiary, or add a Guide button to `PauseMenu.svelte`. |
|
||||
| F07 | onboarding | P1 | `en.json:8` `instructions` defined but unused (grep: 0 hits); L1 has no on-screen prompt (`levels.js:9-25`) | Show a dismissible "Arrow keys / WASD / tap a neighbour cell" toast on L1 turn 0. |
|
||||
| F08 | onboarding | P1 | `ControlsOverlay.svelte:14-34` has no `E`/throw row; `en.json:55` `controlThrow` unused | Add the throw row (E / Enter / Esc) to the controls overlay. |
|
||||
| F09 | readability | P1 | `GameBoard.svelte:68,84` (`previewCells… && !cell.isLight`) | Also render a "goes dark next turn" marker on currently-lit cells — that is the timing info players need. |
|
||||
| F10 | readability | P1 | `turn-manager.js:41-50` passes the *current* player position into the simulation | Label the preview "if you wait", or preview per candidate destination; chaser/suspicion previews are wrong for any move. |
|
||||
| F11 | readability | P1 | `SuspicionRing.svelte:7` (`visible = tier > 0`); range never drawn (`levels.js:133` `range: 3`) | Draw a faint tier-0 range boundary so the player can see the zone before entering it. |
|
||||
| F12 | readability / colour | P1 | `GameBoard.svelte:5` `KEY_COLORS`; `KeyInventory.svelte:8-12`; `game-design.md:79` "matching is visual" | Stamp the key id (1/2/3) or a distinct glyph on doors + keys + HUD chips. |
|
||||
| F13 | readability / semantics | P1 | warm rendered as orange hazard glow `GameBoard.svelte:91-95,148-150,200-203`; but detection only tests `isLight` (`turn-manager.js:31`), so warm is **safe** — `levels.js:206-210` confirms it is the sprint window; `game-design.md:87` calls it "dangerous" | Re-skin warm as a cooling/safe cue (blue-grey fade) or add an explicit "cooling — safe" label; fix the doc. |
|
||||
| F14 | failure feedback | P1 | `Game.svelte:437-448` + `DetectionPopup.svelte:16-23` (`--bg-overlay` = `rgba(0,0,0,0.85)`, `theme.css:5`) fades in over 200 ms while the death flash lasts 400 ms (`GameBoard.svelte:189-195`) | Delay the popup ~600 ms and/or drop overlay opacity so the killing lit cell is visible; name the culprit guard in the popup. |
|
||||
| F15 | failure feedback / kbd | P1 | `Game.svelte:293` early-returns all keys while `detected`/`isPaused`/`showLevelComplete` | Accept Enter/Space/R inside modals; add global `R` = restart. |
|
||||
| F16 | failure feedback | P1 | `Game.svelte:451-454` restart is the only offer | When `affordances.undo`, add "Undo last move" to the detection popup — one move back beats a full replay in a hard puzzler. |
|
||||
| F17 | a11y | P1 | `PauseMenu.svelte`, `DetectionPopup.svelte`, `ControlsOverlay.svelte`, `LevelCompletePopup.svelte` — no `role="dialog"`, no `aria-modal`, no focus move, no trap (only `App.svelte:98` does it right) | Add `role="dialog" aria-modal="true"`, focus the primary button on mount, trap Tab, restore focus on close. |
|
||||
| F18 | a11y | P1 | `App.svelte:73-93` `{#key currentScene}` replaces the DOM; no focus target in any scene | Focus the scene `<h1>`/first control after each transition; add a live-region scene announcement. |
|
||||
| F19 | a11y | P1 | `Game.svelte:291-329` — no `Escape` branch in idle mode; `PauseMenu.svelte` has no key handler | `Escape` should open pause from idle and close it from paused. |
|
||||
| F20 | a11y | P1 | `GameBoard.svelte:65` all cells `tabindex="-1"`; `role="grid"` at :56 without any `role="row"` children; no `aria-live` for turn results | Add a roving-tabindex cursor over cells + an `aria-live="polite"` turn summary ("turn 7, lit cells adjacent: up, right"). |
|
||||
| F21 | i18n | P1 | `LevelCompletePopup.svelte:8` uses `getText('levelComplete')`, key absent from both locales; `localization.js:27` returns the key itself, which is truthy so the `||` fallback never fires | Add `levelComplete` to `en.json`/`vi.json` (and make `getText` return `null` on miss). |
|
||||
| F22 | i18n | P1 | `levels.js:10,34,69,115,161,211,274,329,377,434,488,537` — level `name` is English-only, shown at `LevelIntro.svelte:24`; default language is `vi` (`localization.js:9`) | Move level names into locale files as `levelNName`. |
|
||||
| F23 | audio | P1 | `bgm.js:124` sets `activeTrack = url` *before* `play()` may throw at :145-150; the gesture retry at :167-171 re-enters `playBgm(same)` and hits the `url === activeTrack` early-return at :124 | Reset `activeTrack = null` in the catch block so the retry actually plays. Menu BGM currently never starts on autoplay-blocking browsers. |
|
||||
| F24 | touch | P1 | `Game.svelte:534` swipe handlers on `.game-scene`; `.board-container` is `overflow: auto` (`Game.svelte:675`) with no `touch-action` | Set `touch-action: none` on the board and handle panning explicitly, otherwise swipes scroll the board instead of moving. |
|
||||
| F25 | touch | P1 | `GameHud.svelte:75-84` `.icon-btn` ≈ 32 px; `Button.svelte:23-27` `.small` ≈ 37 px | Raise to `min-height/min-width: 44px` on coarse pointers. |
|
||||
| F26 | undo gating | P2 | `Game.svelte:324` — pressing `Z` on a no-undo level does nothing, silently | Flash the affordance banner / play a denial tick when a gated key is pressed. |
|
||||
| F27 | undo gating | P2 | banner shown only at `LevelIntro.svelte:28`; restarting from `PauseMenu` re-enters `initLevel()` without re-showing it (`Game.svelte:196-234`) | Keep a small persistent "no undo" chip in the HUD for gated levels. |
|
||||
| F28 | undo/redo | P2 | redo exists (`Game.svelte:474`) but has no HUD button — `GameHud.svelte:40-44` only renders undo | Add a redo icon button next to undo. |
|
||||
| F29 | i18n | P2 | Hardcoded English: `GameHud.svelte:30` `Turns:`, `:41` `aria-label="Undo"`, `:47` `"Toggle preview"`; `LevelCompletePopup.svelte:15` `/ Par:`; `ThrowTargetingOverlay.svelte:96` invalid-target hint; `GameBoard.svelte:13-25,58` all ARIA; `GuardSprite.svelte:65-76` ARIA; `LevelSelect.svelte:45` `{bestMoves}m` | Route through `getText`. |
|
||||
| F30 | motion | P2 | `prefers-reduced-motion` handled in `GameBoard.svelte:272`, `GuardSprite.svelte:293`, `PlayerSprite.svelte:92` but **not** in `App.svelte:74` (fade), `StoryIntro.svelte:81` (30 s scroll), `LevelCompletePopup.svelte:35,56`, `SuspicionRing.svelte:33`, `ThrowTargetingOverlay.svelte:138`, `Game.svelte:651` (flash) | Add a global reduced-motion block in `theme.css`. |
|
||||
| F31 | flow | P2 | `StoryIntro.svelte:8-15` auto-navigates on `animationend` of a 30 s CSS scroll | If animations are suppressed the event may never fire; add a timeout fallback and a pause control. |
|
||||
| F32 | flow | P2 | `Game.svelte:518-526`: L12 is `isFinalLevel` (`levels.js:625`), so `next > total` is unreachable → `gameOver.runComplete` / `gameOver.runCompleteBody` are dead strings | Fire the run-complete flow after L11, or drop the branch. |
|
||||
| F33 | dead code | P2 | `GameBoard.svelte:10,31-39,122-127` `throwTargetCells`/`throwCursor` props are never passed (`Game.svelte:559-567`) | Delete, or delete the duplicate rings in `ThrowTargetingOverlay`. |
|
||||
| F34 | discoverability | P2 | `Game.svelte:355` tapping your own cell = wait; documented nowhere | Add "Tap yourself = wait" to the controls overlay. |
|
||||
| F35 | discoverability | P2 | `Game.svelte:229` resets `showPreview = false` on every level and every restart | Persist the preview toggle across levels/restarts. |
|
||||
| F36 | key handling | P2 | `Game.svelte:319,324,325` only match lowercase `v`/`z`/`y`; `e` handles both cases at :326 | Normalise with `e.key.toLowerCase()`. |
|
||||
| F37 | scoring | P2 | par is only revealed after the win (`LevelCompletePopup.svelte:15`) | Show `Turns: n / par` in the HUD so 3★ is a playable goal. |
|
||||
| F38 | colour | P2 | `ThrowTargetingOverlay.svelte:123-131` valid/invalid = green vs red fill only; `LevelSelect.svelte:86-91` locked = colour only | Add a check/cross glyph and a padlock glyph. |
|
||||
| F39 | audio | P2 | mute exists in-game (`GameHud.svelte:45`) and in `Settings.svelte:60-71`, but `MainMenu.svelte` has no mute and BGM starts on mount (`App.svelte:49-52`) | Add a small mute toggle to the main menu. |
|
||||
|
||||
---
|
||||
|
||||
## Death → retry interaction cost (counted)
|
||||
|
||||
Path: `turn-manager.js:31` returns `detected` → `Game.svelte:388` → `triggerDetection()` (`Game.svelte:437-448`) → `DetectionPopup` mounts (`Game.svelte:609-611`).
|
||||
|
||||
**Pointer (mouse/touch): 1 click.**
|
||||
- 0 ms: `detected = true`, popup starts a 200 ms fade (`DetectionPopup.svelte:8`).
|
||||
- The player must hit one target: `PLAY AGAIN`, a 220 px-min-width centred button (`Button.svelte:17`).
|
||||
- `handleDetectionDismiss` → `restartLevel()` → `initLevel()` is synchronous (`Game.svelte:451-454`, `196-234`); the only trailing cost is the smooth camera scroll (`Game.svelte:183-193`).
|
||||
- Blocking animation chain: **none**. The 400 ms shake/flash runs *behind* the overlay. Cost is good.
|
||||
|
||||
**Keyboard: 0 keys work; 5–7 keypresses to escape.**
|
||||
- `onKeyDown` returns at `Game.svelte:293` for every key while `detected` — Enter, Space, Esc and R are all inert.
|
||||
- No autofocus in `DetectionPopup.svelte`; after a scene mount focus sits on `<body>`, so Tab starts at the top of the DOM.
|
||||
- Tab order before the popup button: HUD undo (if `allowUndo`), SND, eye (if `allowPreview`), `?`, pause, MENU (`GameHud.svelte:40-56`). Board cells are `tabindex="-1"` (`GameBoard.svelte:65`) so they are skipped.
|
||||
- L1–L8 (undo + preview on): **6 Tab + 1 Enter = 7 keypresses**. L9–L10 (no undo): 5 + 1 = 6. L11 (no undo, no preview): 4 + 1 = 5.
|
||||
- Worse: every intermediate stop is invisible under an 85 %-opaque overlay, and one of them (`MENU`, `GameHud.svelte:55`) exits the level on Enter.
|
||||
|
||||
**Net:** this is a keyboard-driven puzzle game (arrows/WASD/Space/V/Z/E) that forces the player's hand to the mouse on every death. Target for a hard puzzler: **any key = retry, R = restart, U = undo the fatal move.**
|
||||
|
||||
There is also no fast restart when *not* dead: no `R` binding, no `Escape` → pause (`Game.svelte:291-329` has no Escape branch in idle), so a voluntary restart = click pause icon + click RESTART LEVEL = 2 clicks, mouse only.
|
||||
|
||||
---
|
||||
|
||||
## Stuck-player analysis
|
||||
|
||||
Current state:
|
||||
- `LevelSelect.svelte:12-14` — `if (num <= progress.maxLevel)`; everything above `maxLevel` is `disabled` (`:34`).
|
||||
- `progress.js:85-88` — `maxLevel` only advances inside `completeLevel()`, i.e. only on a win.
|
||||
- No hint system anywhere (grep for `hint` returns only the two `throw.hint*` strings, `en.json:84-85`).
|
||||
- No attempt counter, no per-level solution reveal, no "skip" affordance.
|
||||
- Difficulty spikes are real: L11 runs 7 guards of all types with **both** undo and preview disabled (`levels.js:506-528`).
|
||||
- L12 is unsolvable **by design** (`game-design.md:93`) and the only exit is an undocumented console call, `window.__nntvDev.teleport` (`Game.svelte:254-270`).
|
||||
|
||||
So: a player who cannot solve L7 has exactly two options — keep replaying L7, or quit. On a static offline site with local-storage progress, quitting means the session ends and very likely never resumes. This is the single biggest threat to "enjoyable, hard".
|
||||
|
||||
**Recommendation — ship both, they solve different problems:**
|
||||
|
||||
1. **Progressive hint (comprehension failure).** Track `attempts[levelNum]` in `progress.js`. After 3 detections on the same level, surface a `HINT` button in the HUD that reveals `level.hints[]` one step at a time:
|
||||
- hint 1 = the mechanic ("the onion's suspicion rises whenever you are within 3 cells — back off for a turn to cool it"),
|
||||
- hint 2 = the shape of the route ("cross row 5 on an even turn"),
|
||||
- hint 3 = the first 5 moves of the solver path. `level-solver.js` already exists and CI verifies solvability, so hint 3 is nearly free.
|
||||
Cost: one `hints` array per level + one button. Preserves the puzzle; removes the wall.
|
||||
|
||||
2. **Mercy unlock (execution/patience failure).** After 8 detections on the same level, unlock level N+1 in `LevelSelect` and mark it "skipped" (grey star row, no stars awarded). Keeps the story moving and keeps the completionist incentive to come back. Implement as `progress.skippedLevels[]` so 100 % completion still requires a real clear.
|
||||
|
||||
Do **not** rely on hint-only: L11's no-undo/no-preview combination is an execution wall, not a comprehension wall, and hints will not clear it.
|
||||
|
||||
Secondary: expose a *safe* documented escape for L12 rather than only the console egg — after 5 detections in the Princess Chamber, let the bittersweet ending play. Right now the intended narrative payoff is gated behind repeated failure the player has no reason to believe is intentional.
|
||||
|
||||
---
|
||||
|
||||
## i18n key-parity diff
|
||||
|
||||
Machine diff of `src/lib/locales/en.json` ↔ `vi.json`:
|
||||
|
||||
```
|
||||
en keys: 93 vi keys: 93
|
||||
en-only: (none)
|
||||
vi-only: (none)
|
||||
```
|
||||
|
||||
**Key parity is clean.** The problems are elsewhere:
|
||||
|
||||
- **Used but undefined in both locales:** `levelComplete` (`LevelCompletePopup.svelte:8`). Because `localization.js:27` returns the key string on a miss, the `|| 'Level Complete!'` fallback never runs → the popup title renders the literal text `levelComplete` in EN and VI. Visible on every level win.
|
||||
- **Defined but never used (dead or should-be-used):** `instructions` (should be used — F07), `controlThrow` (should be used — F08), `caughtInLight`, `gameOver`, `tryAgain`, `levelNumber`, `gameDescription`, `gameOver.runComplete*` (unreachable, F32), `mechanics.sniper.name`, `mechanics.suspicion.name`, `mechanics.suspicion.alerted`, `mechanics.suspicion.firing`, `mechanics.oneWay.aria`, `mechanics.warm.aria`, `mechanics.door.locked`, `mechanics.door.open`. The whole `mechanics.*` ARIA namespace is unused because `GameBoard.svelte:13-25` and `GuardSprite.svelte:65-76` build English labels inline.
|
||||
(`level1Story`…`level12Story` are used dynamically via `levelData.storyKey`, `LevelIntro.svelte:11` — not dead.)
|
||||
- **Hardcoded English in `.svelte`:** see F29. Note that default language is Vietnamese (`localization.js:9`), so a fresh VI player sees a VI menu, English level titles (F22), and an English `Turns:` counter.
|
||||
- **Content-accuracy defect (worse than a missing key):** `level5Story`, `level7Story`, `level8Story`, `level9Story` describe guards that are not in those levels and omit the mechanics that are. Both languages are wrong identically.
|
||||
|
||||
---
|
||||
|
||||
## Prioritized fix list
|
||||
|
||||
### P0 — blocks play
|
||||
|
||||
1. **F01 Fit the stage to the viewport.** `src/styles/theme.css:65-73` + a small `src/lib/viewport-scale.js` mounted in `src/main.js`: keep the 1024×768 design box, apply `transform: scale(min(vw/1024, vh/768))` with `transform-origin: top left` on `#app`, and centre it. Nothing else in the codebase is responsive (zero `@media` rules), so this one change is what makes phones and small windows playable.
|
||||
2. **F02 On-screen throw entry.** `src/components/GameHud.svelte` — render the `StonesCounter` as a button when `stonesLeft > 0` and emit `onthrow` → `enterTargeting()` in `src/scenes/Game.svelte:395`; add tap-able ✓ / ✗ buttons to `src/components/ThrowTargetingOverlay.svelte` (currently `pointer-events: none`, `:106`). Without this, L9/L10/L11 stone routes are keyboard-only.
|
||||
3. **F03 Give stuck players a path.** `src/lib/progress.js` (add `attempts`, `skippedLevels`), `src/lib/levels/levels.js` (add `hints: []`), `src/components/GameHud.svelte` (HINT button after 3 deaths), `src/scenes/LevelSelect.svelte:12-14` (mercy unlock after 8). See stuck-player section.
|
||||
|
||||
### P1 — frustrates
|
||||
|
||||
4. **F21 Add the `levelComplete` key** to `en.json`/`vi.json`; make `localization.js:27` return `null` on a miss so `||` fallbacks work. One line each, removes a visible text bug from every win.
|
||||
5. **F04 + F05 Resync the teaching text.** `src/lib/locales/*.json` `level5/7/8/9Story`, and add `teaches: ['suspicion']` etc. to `src/lib/levels/levels.js`, rendered as a "New this level" strip in `src/scenes/LevelIntro.svelte:22-30`.
|
||||
6. **F14 + F15 + F16 Make death legible and instant.** `src/scenes/Game.svelte:437-454`: delay `detected = true` by ~500 ms so the flash reads; pass the culprit guard type into `DetectionPopup`; accept Enter/Space/R at `:293`; add an "Undo last move" action when `affordances.undo`.
|
||||
7. **F19 Escape = pause.** `src/scenes/Game.svelte:318` add an `Escape` branch; `src/components/PauseMenu.svelte` close on Escape.
|
||||
8. **F09 + F10 Fix preview semantics.** `src/components/GameBoard.svelte:68,84` — also mark currently-lit cells that go dark next turn; label the toggle "preview (if you wait)" in `src/components/ControlsOverlay.svelte` until `turn-manager.js:41` can simulate per-destination.
|
||||
9. **F11 Draw the suspicion range** at tier 0. `src/components/SuspicionRing.svelte:7` + a faint diamond overlay sized from `guard.range` (needs `range` added to the snapshot at `Game.svelte:81-83`).
|
||||
10. **F13 Fix the warm-tile lie.** `src/components/GameBoard.svelte:91-95` re-skin to a cooling cue; correct `docs/game-design.md:87`.
|
||||
11. **F12 De-colour the key/door pairing.** `src/components/GameBoard.svelte:5,111,118` and `src/components/KeyInventory.svelte:8-12` — add a numeral or glyph.
|
||||
12. **F17 + F18 Modal and scene focus.** All four overlay components + `src/App.svelte:73-93`.
|
||||
13. **F23 Fix the BGM autoplay retry.** `src/lib/bgm.js:145-150` — set `activeTrack = null` in the catch.
|
||||
14. **F24 + F25 Touch hygiene.** `touch-action: none` on `.board-container` (`src/scenes/Game.svelte:666-677`); 44 px minimum on `.icon-btn` (`src/components/GameHud.svelte:75`) and `.btn.small` (`src/components/Button.svelte:23`).
|
||||
15. **F06 + F07 + F08 Teach in-game.** In-game bestiary behind the `?` button, an L1 controls toast, and the throw row in `src/components/ControlsOverlay.svelte:14-34`.
|
||||
16. **F20 Board for screen readers.** Roving tabindex + `aria-live` turn summary in `src/components/GameBoard.svelte`.
|
||||
17. **F22 + F29 Remaining i18n.** Level names into locales; route hardcoded strings and ARIA through `getText`.
|
||||
|
||||
### P2 — polish
|
||||
|
||||
18. F30 global reduced-motion block in `theme.css`; F26/F27 gated-key feedback + persistent HUD chip; F28 redo button; F35 persist preview toggle; F36 lowercase key matching; F37 par in HUD; F34 "tap yourself = wait"; F38 glyphs for valid/invalid and locked; F31 story-scroll timeout fallback + pause; F32 unreachable run-complete flow; F33 dead throw props in `GameBoard.svelte`; F39 mute on main menu.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Is the fixed 1024×768 stage a deliberate "desktop-only" product decision? If yes, F01/F02/F24/F25 drop to P2 and the game should say so on the main menu; if no, F01 is the top item in this report.
|
||||
2. Is L11's no-undo + no-preview combination (`levels.js:528`) intentional as the final skill gate, or a leftover from the phase-04 affordance rollout? It is the most likely permanent stopping point in the game.
|
||||
3. Should hint text spoil the solution path (solver-derived) or stop at the mechanic? Affects whether `level-solver.js` needs a UI-facing export.
|
||||
4. Is the `gameOver.runComplete` ending meant to fire after L11 (as `Game.svelte:522`'s comment says) or is L12's bittersweet ending the only intended finale? Current code makes run-complete unreachable.
|
||||
5. Should warm tiles be safe (code) or dangerous (`game-design.md:87`)? The visual language currently backs the doc, the engine backs the code.
|
||||
6. Is Vietnamese-by-default (`localization.js:9`) intended for a GitHub Pages audience, or should it detect `navigator.language`?
|
||||
@@ -0,0 +1,335 @@
|
||||
# Puzzle Design & Difficulty Audit — Night Ninja: Twilight Voyage
|
||||
|
||||
Date: 2026-07-25 · Scope: L1–L11 puzzle quality + difficulty curve · Read-only audit
|
||||
Evidence: BFS solver (`src/lib/game/level-solver.js`) run over all 11 playable levels, plus
|
||||
guard-removal / stone-removal / one-way-removal / decay-removal ablations and static beam analysis.
|
||||
L12 is unsolvable by design (owner-confirmed easter egg) and is out of scope here.
|
||||
|
||||
---
|
||||
|
||||
## 1. Headline verdict
|
||||
|
||||
**The game is not a stealth puzzle game today. It is a maze-walking game with decorative guards.**
|
||||
|
||||
The single decisive measurement: for **every level 1–11, deleting all guards changes the BFS
|
||||
optimal path length by exactly zero moves.** Guard tax = 0/11.
|
||||
|
||||
| Ablation | Result |
|
||||
|---|---|
|
||||
| All guards removed | optimal identical on 11/11 levels |
|
||||
| `wait` actions in optimal path | **0** on 11/11 levels |
|
||||
| Stones removed (L9/L10/L11) | optimal identical (20 / 22 / 20) |
|
||||
| `decayTiles` removed (L6/L10) | identical path **and identical state count** (162 / 59307) → provable no-op |
|
||||
| Optimal == pure walls-only geometric lower bound | 9/11 levels (all but L5, L7) |
|
||||
|
||||
Five levels (L4, L6, L8, L9-nearly, L11) are solved by the literal L-shape
|
||||
`down × (rows-1)` then `right × (cols-1)`. The climax level **L11 "The Throne Room" is beaten by
|
||||
walking down the left edge and across the bottom edge** — 20 moves, no waits, no stones,
|
||||
never within 3 cells of a guard.
|
||||
|
||||
Root cause is not level authoring alone. Four engine defects (§3) make four of the eight guard
|
||||
types and two of the four v2 mechanics mechanically inert, so no amount of level tuning alone fixes it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Evidence table
|
||||
|
||||
`geoLB` = shortest path ignoring guards/doors/one-ways (walls only). `tax` = optimal − guard-free optimal.
|
||||
`#opt` = number of distinct optimal-length action sequences (forgiveness).
|
||||
|
||||
| L | Name | Taught mechanic | BFS opt | par | geoLB | tax | states | #opt | Verdict |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | Garden Path | movement | 16 | 22 | 16 | 0 | 50 | 50 | TRIVIAL (intended) |
|
||||
| 2 | The Watchtower | static wilting | 16 | 24 | 16 | 0 | 60 | 50 | **BROKEN** |
|
||||
| 3 | Vegetable Patrol | one-way tiles | 18 | 22 | 16 | 0 | 53 | 75 | TRIVIAL |
|
||||
| 4 | The Searchlight | suspicion guard | 16 | 20 | 16 | 0 | 314 | 71 | **BROKEN** |
|
||||
| 5 | Fortress Gate | doors + keys | 30 | 30 | 18 | 0 | 1574 | **1** | GOOD |
|
||||
| 6 | Flickering Corridor | light decay | 18 | 22 | 18 | 0 | 162 | 458 | **BROKEN** |
|
||||
| 7 | Underground Passage | mirror + door | 28 | 30 | 20 | 0 | 645 | 16 | OK |
|
||||
| 8 | The Gauntlet | sniper | 20 | 28 | 20 | 0 | 984 | 53 | **BROKEN** |
|
||||
| 9 | The Decoy Path | throwable stones | 20 | 28 | 20 | 0 | 215098 | >cap | **BROKEN** |
|
||||
| 10 | Hall of Mirrors | combo | 22 | 34 | 22 | 0 | 59307 | 94 | **BROKEN** |
|
||||
| 11 | The Throne Room | chaser + full palette | 20 | 36 | 20 | 0 | 207395 | >cap | **BROKEN** |
|
||||
|
||||
`states explored` is **not** a difficulty proxy here — it measures state-space size (guard
|
||||
permutations), not search depth. L9 explores 215k states yet the answer is the naive L-path;
|
||||
L5 explores 1574 states and is the only level with a unique solution.
|
||||
|
||||
Never-lit-cell census (steady state, after turn 4, `structural-check.mjs` §C/safe-corridor):
|
||||
|
||||
| L | open cells | never lit | fully-safe blind path exists? |
|
||||
|---|---|---|---|
|
||||
| 1 | 52 | 100 % | yes (16) |
|
||||
| 2 | 52 | 100 % | yes (16) |
|
||||
| 3 | 53 | 100 % | yes |
|
||||
| 4 | 69 | 90 % | yes (16) |
|
||||
| 5 | 84 | 92 % | no (key/door gate) |
|
||||
| 6 | 85 | 91 % | yes (18) |
|
||||
| 7 | 94 | 88 % | no (door gate) |
|
||||
| 8 | 104 | 84 % | yes (20) |
|
||||
| 9 | 98 | 74 % | no (transient only) |
|
||||
| 10 | 124 | 81 % | no (transient only) |
|
||||
| 11 | 103 | 73 % | no (transient only) |
|
||||
|
||||
Steady-state lit density peaks at 11 cells / 103 open on L11 (~11 %). The board is 89 % permanently safe.
|
||||
|
||||
---
|
||||
|
||||
## 3. Engine defects that neuter the mechanics (fix these first)
|
||||
|
||||
**D1 — Warm/decay tiles have no gameplay effect.** Detection is `grid.isLight(...)` only
|
||||
(`turn-manager.js:31`, `level-solver.js:206`). `grid.isWarm()` (`grid-system.js:190`) is referenced
|
||||
**only** by `GameBoard.svelte:73/91` for rendering. Proof: removing `decayTiles:"all"` from L6 and
|
||||
L10 yields byte-identical solver results (18 moves / 162 states; 22 moves / 59307 states).
|
||||
`docs/game-design.md:87` claims "a warm cell is temporarily dangerous" — it is not.
|
||||
|
||||
**D2 — Every mirror in the game is unreachable by any beam.** `RotatingGuard.lightRange = 2`
|
||||
(`guards.js:79`), but mirrors sit 3–9 cells from their rotator:
|
||||
|
||||
| Level | mirror | nearest rotating guard | in-line dist | beam reaches? |
|
||||
|---|---|---|---|---|
|
||||
| 7 | (2,7) | (2,2) | 5 | no |
|
||||
| 7 | (6,7) | (2,2) | 9, not aligned | no |
|
||||
| 10 | (2,7) | (2,2) | 5 | no |
|
||||
| 10 | (7,4) | (7,9) | 5 | no |
|
||||
| 11 | (3,8) | (3,5) | 3 | no — **and (3,8) is a wall** (`levels.js:498`); `castBeam` breaks on the wall before the mirror check (`guards.js:105-112`) |
|
||||
|
||||
The mirror guard type has zero effect in every shipped level. L7 and L10 are named after it.
|
||||
|
||||
**D3 — Static guards permanently self-destruct in 2–3 turns.** `currentRadius--` each turn, clamped
|
||||
at −1 (`guards.js:58-62`), never regrows. `initialRadius:2` → harmless from turn 3 onward;
|
||||
`initialRadius:1` → harmless from turn 2. Any static guard more than ~3 moves from spawn is
|
||||
decoration. L2's three statics sit at (2,5),(4,3),(6,6) — first reached at turn ≥5. L2's optimal
|
||||
path is **byte-identical to L1's** (`down down right right right right down down left down down down right right right right`).
|
||||
|
||||
**D4 — Suspicion guards have a 9-cell lethal footprint and self-reset.** Tier rises when the player
|
||||
is within Manhattan `range`, but tier 2 lights only the 3×3 around the guard, then unconditionally
|
||||
drops back to 0 the next call (`guards.js:589-609`). Net effect: "do not stand orthogonally adjacent
|
||||
to me on two consecutive turns." The `range` parameter (2–3) never denies territory. The authoring
|
||||
comment at `levels.js:112` ("must stay ≥4 cells away") describes behaviour that does not exist.
|
||||
|
||||
**D5 — Snipers are blocked by confetti walls.** Beam cells beyond self, per facing:
|
||||
L8 (3,8): 3/2/2/1 · L9 (8,9): 3/1/0/2 · L10 (10,8): **0/0/1/1** · L11 (6,9): 6/1/4/1.
|
||||
L10's sniper — the level's stated goal-approach threat — covers **at most 1 cell** in any facing.
|
||||
|
||||
**D6 — Guards occupy and walk through walls.** `structural-check.mjs` §A:
|
||||
L8 patrol path node (7,5) is a wall (`levels.js:341` vs `levels.js:353`);
|
||||
L11 mirror (3,8), patrol node (8,4), and chaser start (6,5) are all walls
|
||||
(`levels.js:498/501/499` vs `levels.js:510/520/525`). `PatrollingGuard.onTurnChange` never checks `isWall`.
|
||||
|
||||
**D7 — The solvability suite has no lower bound.** `levels.solvability.test.js:42-48` asserts only
|
||||
`solvable === true` and `path.length <= parMoves`. A level whose guards do nothing passes cleanly.
|
||||
This is why all of the above shipped green with 180 passing tests.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-level: degenerate bypasses
|
||||
|
||||
Concrete optimal paths from the solver (no waits, no throws anywhere):
|
||||
|
||||
| L | Bypass | Why the taught mechanic is skipped |
|
||||
|---|---|---|
|
||||
| 2 | `D D R R R R D D L D D D R R R R` (16) — identical to L1 | statics at (2,5)/(4,3)/(6,6) reach radius −1 by turn 3; player arrives turn 5+ (D3) |
|
||||
| 3 | 18 vs 16 without one-ways | only the `(5,4) dir=right` gate bites, costing +2 detour via (4,3)→(5,3). Both statics are `initialRadius:1` → dead by turn 2 |
|
||||
| 4 | `D×8 R×8` (16 = geoLB) | col 0 and row 8 are wall-free highways. Rotating (3,3) beam range 2 never reaches col 0; suspicion (4,7) 3×3 footprint is 7+ cells away |
|
||||
| 6 | `D×9 R×9` (18 = geoLB) | decay is a no-op (D1); both blinkers' litCells sit mid-board; static (5,4) dead by turn 3 |
|
||||
| 8 | `D×10 R×10` (20 = geoLB) | sniper (3,8) covers ≤3 cells; patrol row 7 cols 4–6 never touches col 0 / row 10 |
|
||||
| 9 | `D D D R R D D D R D D D D R R R R R R` (20 = geoLB), stones unused | row-5 patrollers light front+right only; the route crosses row 5 at col 3 while A is at col 4–5 facing away. `noStones` solve = 20, identical |
|
||||
| 10 | `R R R R D D D R D D D D R D D R D D R R R R` (22 = geoLB) | both mirrors inert (D2), sniper covers ≤1 cell (D5), decay no-op (D1), 1 stone unused |
|
||||
| 11 | `D×10 R×10` (20 = geoLB) | left edge + bottom edge are wall-free; chaser `detectionRadius:2` at (6,5) is ≥5 away the whole route; mirror inert and on a wall; min distance to any guard along the whole path = **3** |
|
||||
|
||||
L11 additionally ships with `undo:false, preview:false` — the player is denied information on a
|
||||
level that requires none.
|
||||
|
||||
---
|
||||
|
||||
## 5. Levels with a genuine insight
|
||||
|
||||
Only two, and only from the door/key system:
|
||||
|
||||
- **L5 Fortress Gate** — *the key you need second is behind the door the first key opens, so the
|
||||
route is a forced there-and-back-and-there again across the col-5 spine.* Optimal 30 = par 30,
|
||||
`#opt = 1` (exactly one optimal action sequence out of 1574 states), and removing doors/keys drops
|
||||
the optimum to 18 (+12 forced). This is the only level in the game with a unique solution.
|
||||
- **L7 Underground Passage** — *the goal is walled off behind door (8,5), and its key sits in the
|
||||
far-right pocket, so you must cross the map, double back, then descend.* Optimal 28 vs 21 without
|
||||
doors/keys (+7 forced), `#opt = 16`. The mirror half of the level contributes nothing (D2).
|
||||
|
||||
L3's one-way is a *weak* insight (+2 forced, `#opt = 75`): entering (5,4) requires moving **right**
|
||||
from (5,3), so you cannot simply run down col 4 — but no route order is at stake, which is what a
|
||||
commitment ratchet is supposed to create.
|
||||
|
||||
Everything else is walk-and-wait — and there is not even any waiting.
|
||||
|
||||
---
|
||||
|
||||
## 6. Difficulty curve
|
||||
|
||||
Optimal length by level: **16, 16, 18, 16, 30, 18, 28, 20, 20, 22, 20**
|
||||
|
||||
- Not monotonic. Peak is **L5** (30). The curve then collapses to 18 at L6 and never returns.
|
||||
The finale L11 (20) is easier than L5, L7, and L10.
|
||||
- Two flat plateaus: L1–L4 are all 16–18 with a 0 guard tax (four consecutive tutorials, three of
|
||||
which teach nothing); L8–L11 are all 20–22 and all equal to their geometric lower bound.
|
||||
- The only *actual* difficulty gradient in the game is maze length, and it tracks grid size, not design.
|
||||
- Act structure in `levels.js` (10 acts) contradicts `docs/game-design.md:97-104` (6 acts), and the
|
||||
doc's per-level spec is wrong for L4, L5, L6, L8, L9, L10 (e.g. doc says L5 introduces patrolling
|
||||
and L10 introduces doors+keys — neither is true in `levels.js`).
|
||||
- **Par is free everywhere but L5.** 3★ is `moves <= parMoves` (`progress.js:73-76`). Slack:
|
||||
L1 +6, L2 +8, L3 +4, L4 +4, L5 **0**, L6 +4, L7 +2, L8 +8, L9 +8, L10 +12, L11 +16.
|
||||
A player who blunders 8 moves on the climax still gets 3 stars.
|
||||
|
||||
---
|
||||
|
||||
## 7. Prioritized recommendations (L1–L11 only)
|
||||
|
||||
Ordering is by leverage. P0 items are prerequisites — no level tuning will hold without them.
|
||||
|
||||
### P0 — CI invariant that makes degeneracy unshippable
|
||||
|
||||
**R1. Add a guard-tax lower bound to `levels.solvability.test.js`.** For each L2–L11, solve twice:
|
||||
once normally, once with `guards: []`. Assert `optimal - guardFreeOptimal >= tax_min(level)`
|
||||
(suggest 2 for L2–L4, 4 for L5–L8, 6 for L9–L11). Also assert `optimal > wallsOnlyGeoLB` for every
|
||||
level. Then assert per-mechanic: L3 unsolvable-or-longer without `oneWays`, L5/L7 longer without
|
||||
doors, L9/L10/L11 **strictly longer** without stones, L6/L10 longer without `decayTiles`.
|
||||
This is the highest-value change in the report: it converts "the guards do nothing" from an
|
||||
invisible property into a red test. Ablation harness already proven — the logic is ~40 lines.
|
||||
|
||||
**R2. Add an authoring invariant: no guard position, no patrol path node, and no mirror may sit on
|
||||
a wall cell.** Extends the existing `no wall cell overlaps a guard lit cell` test
|
||||
(`levels.solvability.test.js:100`). Fixes D6, currently violated 4× across L8 and L11.
|
||||
|
||||
### P0 — Engine fixes that restore the four dead mechanics
|
||||
|
||||
**R3. Make warm tiles lethal to *enter* (D1).** In `turn-manager.js:31` and the solver's
|
||||
`simulateTurn`, detect on `isLight(p) || (isWarm(p) && playerMovedIntoItThisTurn)`. Staying put on a
|
||||
cell that turns warm under you stays safe. Why this raises difficulty fairly: it converts every
|
||||
sweeping beam into a 2-cell-wide moving hazard, so "follow one step behind the beam" — currently the
|
||||
free answer — becomes a loss, and the player must be **two** tiles ahead. It is fully legible: warm
|
||||
cells already render as a distinct orange tile and are already in the preview pipeline. If the owner
|
||||
prefers not to change detection, the honest alternative is to delete `decayTiles` and re-theme L6 —
|
||||
but then L6 has no mechanic at all.
|
||||
|
||||
**R4. Give static guards a pulse instead of a death (D3).** Cycle `currentRadius` on a period, e.g.
|
||||
`2 → 1 → 0 → 2 → …` (a 3-turn cycle) rather than clamping at −1. This keeps the state space finite
|
||||
(3 states per guard, same as today's 4) so BFS cost is unchanged. Why it is fair: the period is
|
||||
short, visible, and identical everywhere, so the player learns one rule and applies it. It turns the
|
||||
static guard from a 3-turn timer into the game's cheapest source of **parity** constraints — the
|
||||
thing the design doc claims L4 teaches (`docs/game-design.md:122`) and no level actually does.
|
||||
|
||||
**R5. Make suspicion guards deny territory (D4).** At tier 2, light all cells within Manhattan
|
||||
`range` (not the 3×3), and decay tier `2 → 1 → 0` over turns instead of snapping to 0. Why fair:
|
||||
tier 1 already has a visible ring and a dedicated sprite/sound (`SUSPICION_CALM_ART`,
|
||||
`playSuspicionAlert`), so the player gets a full turn of warning before the zone goes lethal — the
|
||||
mechanic becomes "back out of the ring within one turn, or commit and be gone before it fires."
|
||||
|
||||
**R6. Fix mirror geometry rather than beam range (D2).** Keep `lightRange = 2`; relocate each mirror
|
||||
to be cardinally in line and within 2 cells of its rotator. Concretely: L7 (2,7)→**(2,4)**;
|
||||
L7 (6,7)→**(4,4)** (fed by the deflected column); L10 (2,7)→**(2,4)** and (7,4)→**(7,7)**;
|
||||
L11 (3,8)→**(3,7)** and delete the wall at (3,8). Then verify each reflected beam actually crosses a
|
||||
choke point. Why fair: reflection is deterministic and shown by the preview overlay.
|
||||
|
||||
### P1 — Level surgery (in place, no new levels)
|
||||
|
||||
The shared root problem: **walls are scattered single cells, so row 0 / col 0 / last row / last col
|
||||
are always free highways.** Every P1 item below is the same move — convert confetti walls into
|
||||
corridors with 2–3 named choke points, then aim a guard at each choke point.
|
||||
|
||||
**R7. L11 The Throne Room — full rebuild (worst offender).** Wall the left edge (rows 3–4 col 0 and
|
||||
rows 7–8 col 0) and the bottom row (row 10 cols 2–3 and 7–8) so the perimeter L-path is impossible.
|
||||
Force the route through three chokes: a rotating-beam corridor near (3,5), the patrol corridor at
|
||||
row 8, and a sniper line on col 9. Move the chaser off the wall at (6,5) to (5,5) and raise
|
||||
`detectionRadius` from 2 to 4 so it actually contests the mid-map choke. Target: optimal ≥ 34, guard
|
||||
tax ≥ 8, stones strictly required. **Re-enable `preview: true`** — with a real solution the
|
||||
difficulty comes from planning depth; keeping preview off just hides information the player needs
|
||||
and converts the level into memorize-by-death.
|
||||
|
||||
**R8. L9 The Decoy Path — make stones necessary.** Turn row 5 into a real wall band with one 1-wide
|
||||
gap at (5,5), and phase the two patrollers so the gap's front+right cone is covered on 5 of every 6
|
||||
turns — while making the 6th turn unreachable from the approach square. Then a stone throw at (5,4)
|
||||
(freezing patroller A's facing for one turn) is the only opener. Verify with the R1 ablation:
|
||||
`noStones` must be unsolvable or ≥ +6 moves. Fair because the throw target is a legal, previewable
|
||||
action and the patrol cycle is 6 turns and fully visible.
|
||||
|
||||
**R9. L4 The Searchlight — wall the highway, then let R5 do the work.** Add walls at (3,0),(4,0) and
|
||||
(8,2),(8,3) to kill the `D×8 R×8` bypass. With R5 landed, the suspicion guard at (4,7) range 3 denies
|
||||
a 25-cell diamond, so the right flank becomes a real commit-or-detour decision. Retitle the level's
|
||||
taught mechanic honestly — `levels.js:109` says suspicion, `docs/game-design.md:119` says blinking.
|
||||
|
||||
**R10. L6 Flickering Corridor — build an actual corridor.** With R3 landed, collapse the mid-board
|
||||
into a 1-wide horizontal corridor at row 5 (walls at row 5 all cols except a 3-cell run), covered by
|
||||
the two phase-offset blinkers so the safe window is 1 turn wide and the warm afterglow closes the
|
||||
tile behind. Insight becomes: *the beam's afterglow means the safe window opens one turn later than
|
||||
it looks.* Target optimal ≥ 24, guard tax ≥ 6.
|
||||
|
||||
**R11. L8 The Gauntlet — put the sniper on the route.** Move the sniper from (3,8) to a position
|
||||
whose four facings each sweep a full corridor (e.g. (5,5) after clearing its lines), and remove the
|
||||
walls that currently truncate every beam to ≤3 cells. Fix the patrol path node at (7,5) (D6). Wall
|
||||
the col-0 and row-10 highways. Insight: *the sniper's 2-turn cadence gives a 4-turn cycle; you must
|
||||
cross its line on the turn it is pointed away and be clear before it comes back.*
|
||||
|
||||
**R12. L10 Hall of Mirrors — becomes the real combo level only after R3+R6.** With mirrors live and
|
||||
warm tiles lethal, aim both reflected beams at the two chokes on the diagonal, and place the single
|
||||
stone so it is required to cross one of them. Wall the row-0/col-0 approach.
|
||||
|
||||
**R13. L2 The Watchtower — teach the mechanic it is named after.** Move the three statics so their
|
||||
turn-0/turn-1 auras cover the three wall-band gaps at the moment the player can first arrive
|
||||
(gap-adjacent cells at ~turn 2, 6, 10). With R4's pulse, a static at the band-1 gap makes "wait 2 and
|
||||
walk through" (18) compete against "detour" (20) — a genuine, one-decision patience-vs-distance
|
||||
tradeoff, which is exactly the stated lesson (`levels.js:32`).
|
||||
|
||||
**R14. L3 Vegetable Patrol — make the one-way a real ratchet.** Today it costs +2 and cannot be
|
||||
failed. Add a second one-way so that crossing gate A before gate B strands the player in a region
|
||||
with no exit, forcing route *order* to be planned. Keep undo+preview on so the trap is recoverable
|
||||
and the lesson lands without punishment. Target: optimal ≥ 24, and unsolvable without `oneWays`
|
||||
rather than merely shorter.
|
||||
|
||||
**R15. L5 Fortress Gate — leave the structure alone; it is the only good level.** Optional polish
|
||||
only: narrow the door-1 approach to 1 cell wide inside the rotating beam at (2,3) so the rotating
|
||||
guard finally imposes a turn cost (guard tax currently 0 even here).
|
||||
|
||||
### P2 — Scoring and documentation
|
||||
|
||||
**R16. Retune every `parMoves` to the post-redesign BFS optimal** so 3★ means "found the optimal
|
||||
route." Current slack (§6) makes 3★ automatic on 10 of 11 levels. Add a CI assertion
|
||||
`parMoves === BFS optimal` (or optimal + 1) so par can never drift again.
|
||||
|
||||
**R17. Reconcile `docs/game-design.md:106-150` with `levels.js`.** The per-level mechanic spec is
|
||||
wrong for L4, L5, L6, L8, L9, L10, the act table (6 acts) contradicts the level comments (10 acts),
|
||||
and `docs/game-design.md:86-89` documents warm-tile danger that the engine does not implement.
|
||||
Authoring comments inside `levels.js` are also stale — `levels.js:112` and `levels.js:373-375`
|
||||
describe behaviour the solver disproves.
|
||||
|
||||
---
|
||||
|
||||
## 8. Level count
|
||||
|
||||
Per owner ruling the game stays at exactly 12 levels; no additions considered. All of the
|
||||
"hard enough to solve" budget therefore has to come from §7. That is achievable: with R3–R6 landed,
|
||||
the four currently-dead mechanics (decay, mirrors, static pulse, suspicion zones) become available
|
||||
design material for L2, L4, L6, L7, L8, L10 and L11 — seven levels that currently teach nothing.
|
||||
L12 remains frozen and unsolvable by design.
|
||||
|
||||
---
|
||||
|
||||
## 9. Unresolved questions
|
||||
|
||||
1. **R3 (warm = lethal) changes the death model.** Is a 2-cell-wide moving hazard acceptable for the
|
||||
"all ages" audience in `docs/game-design.md:6`, or should decay stay cosmetic and L6 be re-themed
|
||||
around a different mechanic? This choice gates R10 and R12.
|
||||
2. **R7 recommends re-enabling `preview` on L11.** The current `undo:false, preview:false` gate is
|
||||
presented as a difficulty lever (`docs/game-design.md:40-41`). Is hiding information an intended
|
||||
part of the endgame identity, or is it a substitute for the puzzle depth that is missing?
|
||||
3. **What is the intended solve time per level?** Without a target (30 s? 5 min?) I judged difficulty
|
||||
by forced-interaction depth, not by human solve time. A target would let R7/R10 pick concrete
|
||||
optimal-length goals rather than the placeholder numbers above.
|
||||
4. **Is the L5-style unique-solution profile (`#opt = 1`) the goal, or too brittle?** It is the only
|
||||
level with real bite, but a single optimal sequence in 1574 states is unforgiving. A target band
|
||||
(e.g. 5–30 optimal sequences) would make R7–R14 tunable rather than guesswork.
|
||||
5. **BFS budget.** L9/L11 already explore ~210k states; the CI cap is 2M
|
||||
(`levels.solvability.test.js:13`) with a 60 s suite budget (`:136`). The R7/R10 targets
|
||||
(optimal 30–34, more live guards) will multiply state counts. Is a longer solvability suite
|
||||
acceptable, or should the solver gain A* / dominance pruning first?
|
||||
6. **`RotatingGuard.lightRange = 2` vs the sniper's unbounded beam** — R6 assumes range 2 is
|
||||
intentional. If instead rotating beams should reach across a room, that is a one-line change with
|
||||
very different level-design consequences, and R6's mirror relocations would be wrong.
|
||||
Reference in new issue
Block a user