From f1b2f81085ca452b5a33170272e9c5a99b8b8694 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Tue, 14 Oct 2025 09:21:55 +0200 Subject: [PATCH] feat/core: shareable seed bootstrap [#iteration-02] --- README.md | 424 ++++++++++++++------------- docs/codeCoverageIgnoreGuidelines.md | 117 ++++---- docs/implementation-progress.md | 16 + index.html | 365 ++++++++++++++++++++++- src/assets/defaultData.json | 14 +- src/core/combatSimulator.js | 49 ++++ src/core/combatSimulator.test.js | 50 ++++ src/core/mathGates.js | 59 ++++ src/core/mathGates.test.js | 59 ++++ src/core/performanceGuards.js | 87 ++++++ src/core/performanceGuards.test.js | 115 ++++++++ src/core/random.js | 21 ++ src/index.integration.test.js | 321 ++++++++++++++++++++ src/index.js | 378 +++++++++++++++++++++++- src/ui/formatting.js | 30 ++ src/ui/formatting.test.js | 27 ++ 16 files changed, 1861 insertions(+), 271 deletions(-) create mode 100644 docs/implementation-progress.md create mode 100644 src/core/combatSimulator.js create mode 100644 src/core/combatSimulator.test.js create mode 100644 src/core/mathGates.js create mode 100644 src/core/mathGates.test.js create mode 100644 src/core/performanceGuards.js create mode 100644 src/core/performanceGuards.test.js create mode 100644 src/core/random.js create mode 100644 src/index.integration.test.js create mode 100644 src/ui/formatting.js create mode 100644 src/ui/formatting.test.js diff --git a/README.md b/README.md index 1d39b96..0577361 100644 --- a/README.md +++ b/README.md @@ -1,187 +1,190 @@ +# Math Marauders — Minimal Arcade Loop Prototype -## 1) Goal & Core Loop +This repository currently ships a lightweight prototype of the Math Marauders loop that runs entirely in the browser with native ES modules. The focus is delivering the forward gate selection, deterministic skirmish simulation, and reverse chase scoring described below so designers can start tuning the arithmetic and pacing without waiting for the full 3D build. Open `index.html` in any static server (e.g. `npm run serve:static`) to play the current iteration. -* **Target session length:** 90–180s. -* **Loop:** +## 1) Goal & Core Loop +- **Target session length:** 90–180s. +- **Loop:** 1. **Forward run** → choose math gates (+ / − / × / ÷). 2. **Skirmish** vs enemy squad (quick volley exchange, deterministic). 3. **Reverse chase** back toward start; survive to finish. 4. **End card** with star rating & restart. -* **Pace:** Instant restarts, no loading hitches, minimal UI friction. + +- **Pace:** Instant restarts, no loading hitches, minimal UI friction. ## 2) Platforms & Performance -* **Web (mobile-first)**, responsive desktop support. -* **Min perf targets:** 60 FPS on mid-tier mobile; stable 30 FPS on low-end. -* **Draw calls:** < 150 during peak. **Active particles:** ≤ ~250. -* **Fallbacks:** If avg FPS < 50 for 2s → auto degrade (see §8 Perf Guards). +- **Web (mobile-first)**, responsive desktop support. +- **Min perf targets:** 60 FPS on mid-tier mobile; stable 30 FPS on low-end. +- **Draw calls:** < 150 during peak. **Active particles:** ≤ ~250. +- **Fallbacks:** If avg FPS < 50 for 2s → auto degrade (see §8 Perf Guards). ## 3) Visual Direction -* **Style:** Low-poly “Candy Arcade”; bright, juicy, extremely readable. -* **Palette (Mobile-First Snap):** +- **Style:** Low-poly “Candy Arcade”; bright, juicy, extremely readable. +- **Palette (Mobile-First Snap):** + - Primary set: `#ff5fa2` (pink), `#33d6a6` (teal), `#ffd166` (yellow), background gradient `#12151a → #1e2633`. + - **Unit colors:** **Blue vs Orange** — Player `#00d1ff`, Enemy `#ff7a59`. - * Primary set: `#ff5fa2` (pink), `#33d6a6` (teal), `#ffd166` (yellow), background gradient `#12151a → #1e2633`. - * **Unit colors:** **Blue vs Orange** — Player `#00d1ff`, Enemy `#ff7a59`. -* **Lighting:** Use **MeshMatcapMaterial** for units/props (lighting-independent). Simple hemi+dir light for environment props (no real shadows). -* **Post-FX:** **FXAA**; **Selective Bloom** only on gate numerals/symbols and arrow trails (intensity ~0.6, threshold ~0.85, smoothing ~0.1). No OutlinePass. +- **Lighting:** Use **MeshMatcapMaterial** for units/props (lighting-independent). Simple hemi+dir light for environment props (no real shadows). +- **Post-FX:** **FXAA**; **Selective Bloom** only on gate numerals/symbols and arrow trails (intensity ~0.6, threshold ~0.85, smoothing ~0.1). No OutlinePass. ## 4) Camera, Feel & Timing -* **Rig:** **Rail-Follow** (Catmull-Rom) with 3 segments: Forward → Skirmish → Reverse. Prebake ~120 samples/segment. -* **Defaults:** +- **Rig:** **Rail-Follow** (Catmull-Rom) with 3 segments: Forward → Skirmish → Reverse. Prebake ~120 samples/segment. +- **Defaults:** + - Forward: height 8, behind 10, FOV 60, easeInOutQuad. + - Skirmish: height 7, behind 12, tiny ±6° yaw sway. + - Reverse: height 6.5, behind 9, snap-zoom +1.5% FOV at start. + - Look target: player centroid with lerp 0.12. + - Shake: 0.25 amp, 90 ms on big hits (≤1/500 ms). - * Forward: height 8, behind 10, FOV 60, easeInOutQuad. - * Skirmish: height 7, behind 12, tiny ±6° yaw sway. - * Reverse: height 6.5, behind 9, snap-zoom +1.5% FOV at start. - * Look target: player centroid with lerp 0.12. - * Shake: 0.25 amp, 90 ms on big hits (≤1/500 ms). -* **Clip:** near/far = 0.1 / 200. Motion blur: none. +- **Clip:** near/far = 0.1 / 200. Motion blur: none. ## 5) UI & HUD (Minimal Arcade) -* **Top-center:** score + wave timer (tabular-nums). -* **Bottom-left:** steering slider (thumb enlarges while dragging, hit-area ≥44×44px). -* **Bottom:** big Start/Restart pill. -* **Top-right:** pause menu (resume/restart/mute). -* **Gate labels (in-scene):** giant operator + number, color-coded (+ green, − red, × yellow, ÷ blue); the numeral card is in the **bloom include list**. -* **Number formatting:** compact (1.2k); deltas flash 250 ms (green gain / red loss). -* **Transitions:** 120–160 ms fades/slides; no bounces. +- **Top-center:** score + wave timer (tabular-nums). +- **Bottom-left:** steering slider (thumb enlarges while dragging, hit-area ≥44×44px). +- **Bottom:** big Start/Restart pill. +- **Top-right:** pause menu (resume/restart/mute). +- **Gate labels (in-scene):** giant operator + number, color-coded (+ green, − red, × yellow, ÷ blue); the numeral card is in the **bloom include list**. +- **Number formatting:** compact (1.2k); deltas flash 250 ms (green gain / red loss). +- **Transitions:** 120–160 ms fades/slides; no bounces. ## 6) Particles & VFX (Ultra-Lean Clarity) -* **Arrow trails:** billboard quads (instanced), **6 segments**, lifetime **220 ms**, additive blend, 64×64 soft-glow texture. Max concurrent emitters: 12. -* **Hit sparks:** 8 sprite particles on impact, 160 ms, size 6→2 px. -* **Damage flash:** enemy `emissiveIntensity` 0→0.8 over 60 ms, back to 0 in 80 ms. -* **Gate glow:** via selective bloom only on numerals/symbols. -* **Camera juice:** as §4. -* **Auto-degrade:** halve trail segments (3), cap emitters 6, disable sparks when <50 FPS (see §8). +- **Arrow trails:** billboard quads (instanced), **6 segments**, lifetime **220 ms**, additive blend, 64×64 soft-glow texture. Max concurrent emitters: 12. +- **Hit sparks:** 8 sprite particles on impact, 160 ms, size 6→2 px. +- **Damage flash:** enemy `emissiveIntensity` 0→0.8 over 60 ms, back to 0 in 80 ms. +- **Gate glow:** via selective bloom only on numerals/symbols. +- **Camera juice:** as §4. +- **Auto-degrade:** halve trail segments (3), cap emitters 6, disable sparks when <50 FPS (see §8). ## 7) World, Assets & Props ### 7.1 Terrain — **Candy Speedway** -* **Lane:** 10 m wide strip with two pastel edge lines + dashed center (vertex colors; no textures). -* **Gradient sky/backdrop:** `#12151a → #1e2633`. -* **Fog:** linear start 25 m, end 60 m (masks strip pooling). +- **Lane:** 10 m wide strip with two pastel edge lines + dashed center (vertex colors; no textures). +- **Gradient sky/backdrop:** `#12151a → #1e2633`. +- **Fog:** linear start 25 m, end 60 m (masks strip pooling). ### 7.2 Gates — **Pillar Arch + Floating Numeral** -* **Mesh:** Two chunky pillars + shallow arch (≤200 tris). -* **Numeral card:** separate emissive quad above arch; in bloom include list. -* **Dims:** W 3.2 m, H 2.8 m, D 0.4 m. +- **Mesh:** Two chunky pillars + shallow arch (≤200 tris). +- **Numeral card:** separate emissive quad above arch; in bloom include list. +- **Dims:** W 3.2 m, H 2.8 m, D 0.4 m. ### 7.3 Units & Arrows — **Ultra-Light kit** -* **Scale:** 1 unit = 1 meter; Y-up. -* **Tris budgets:** Soldier 350–450, Enemy 400–500, Arrow ≤20. -* **Instancing:** players, enemies, arrows, sparks, props. -* **Pivots:** characters at foot center (0,0,0); gate ground center; arrow pivot at tail. +- **Scale:** 1 unit = 1 meter; Y-up. +- **Tris budgets:** Soldier 350–450, Enemy 400–500, Arrow ≤20. +- **Instancing:** players, enemies, arrows, sparks, props. +- **Pivots:** characters at foot center (0,0,0); gate ground center; arrow pivot at tail. ### 7.4 Props — **MEDIUM density (~1 per 10 m)** with **Arcade Flair kit** -* **Baseline:** +- **Baseline:** + - **Flag post** ≤120 tris, H≈1.6 m. + - **Cone** ≤80 tris, H≈0.45 m (placed as pairs 0.6 m apart). - * **Flag post** ≤120 tris, H≈1.6 m. - * **Cone** ≤80 tris, H≈0.45 m (placed as pairs 0.6 m apart). -* **Flair:** +- **Flair:** + - **Track marker** ≤100 tris, H≈0.35 m; two markers 2 m before **every other** gate. - * **Track marker** ≤100 tris, H≈0.35 m; two markers 2 m before **every other** gate. -* **Placement rules:** Per 10 m segment place **either** 1 cone pair **or** 1 flag (70/30). Keep ≥0.6 m off lane edge; ≥1.5 m clear of gate pillars. Markers desaturated so numerals remain brightest. -* **Culling:** frustum + CPU early-out > 65 m behind camera. -* **Spawn:** deterministic from run seed. +- **Placement rules:** Per 10 m segment place **either** 1 cone pair **or** 1 flag (70/30). Keep ≥0.6 m off lane edge; ≥1.5 m clear of gate pillars. Markers desaturated so numerals remain brightest. +- **Culling:** frustum + CPU early-out > 65 m behind camera. +- **Spawn:** deterministic from run seed. ### 7.5 Obstacles & Straggler Culling -* **Rocks:** Low-poly gumdrop rocks (≤150 tris) intermittently hug the divider with a 0.5 m buffer; they appear on seeded intervals independent of props. -* **Avoidance:** Player flock pathing treats obstacles as soft-collide volumes—agents slide along them but remain within lane bounds. -* **Straggler rule:** Any unit that drifts outside the lane or stays beyond the buffer for >2 s is despawned with a subtle dissolve so the formation stays tight. +- **Rocks:** Low-poly gumdrop rocks (≤150 tris) intermittently hug the divider with a 0.5 m buffer; they appear on seeded intervals independent of props. +- **Avoidance:** Player flock pathing treats obstacles as soft-collide volumes—agents slide along them but remain within lane bounds. +- **Straggler rule:** Any unit that drifts outside the lane or stays beyond the buffer for >2 s is despawned with a subtle dissolve so the formation stays tight. ## 8) Performance Guards & Feature Flags -* **FPS monitor:** rolling avg over 2 s. -* **Degrade step 1 (auto):** trails 6→3 segments, emitters 12→6, disable sparks, tighten bloom resolution. -* **Upgrade (auto):** if ≥58 FPS for 4 s, revert to full Ultra-Lean. -* **Hard-safe mode:** manual setting “Low” = **No-Bloom Fallback** (no composer bloom; baked trail glow texture). +- **FPS monitor:** rolling avg over 2 s. +- **Degrade step 1 (auto):** trails 6→3 segments, emitters 12→6, disable sparks, tighten bloom resolution. +- **Upgrade (auto):** if ≥58 FPS for 4 s, revert to full Ultra-Lean. +- **Hard-safe mode:** manual setting “Low” = **No-Bloom Fallback** (no composer bloom; baked trail glow texture). ## 9) Systems & Mechanics ### 9.1 Gate Generation & Math -* **Operators:** Base operations `+a`, `−b`, `×c`, `÷d` (a,b,c,d are per-gate values in ranges tuned per wave). -* **Rounding:** +- **Operators:** Base operations `+a`, `−b`, `×c`, `÷d` (a,b,c,d are per-gate values in ranges tuned per wave). +- **Rounding:** + - After `+`/`−`: clamp ≥0. + - After `×`/`÷`: **round to nearest integer**, min 1; clamp to [1, MAX_ARMY]. - * After `+`/`−`: clamp ≥0. - * After `×`/`÷`: **round to nearest integer**, min 1; clamp to [1, MAX_ARMY]. -* **Balance rules:** never generate `−` that would kill all units; `÷` never below 1. -* **Operation tiers:** Waves 1–5 use single-step expressions; waves 6–10 unlock two-step combos (e.g. `×4−2`); waves 11+ may introduce short parenthetical or exponent variants. Every composite gate resolves to the same clamp/round pipeline above. -* **Evaluation pipeline:** Composite gates are generated from a vetted template set (mul-add, add-mul, pow-div, etc.) and evaluate deterministically left-to-right unless parentheses are present. Apply rounding/clamping only after the full expression resolves; intermediate steps must stay ≥0 (designers drop any template that would violate this with configured ranges). -* **Two-gate choice:** place two gates per decision point; values drawn to create meaningful deltas (≥15% difference at early waves, ≥25% later). -* **Color coding:** + green, − red, × yellow, ÷ blue. +- **Balance rules:** never generate `−` that would kill all units; `÷` never below 1. +- **Operation tiers:** Waves 1–5 use single-step expressions; waves 6–10 unlock two-step combos (e.g. `×4−2`); waves 11+ may introduce short parenthetical or exponent variants. Every composite gate resolves to the same clamp/round pipeline above. +- **Evaluation pipeline:** Composite gates are generated from a vetted template set (mul-add, add-mul, pow-div, etc.) and evaluate deterministically left-to-right unless parentheses are present. Apply rounding/clamping only after the full expression resolves; intermediate steps must stay ≥0 (designers drop any template that would violate this with configured ranges). +- **Two-gate choice:** place two gates per decision point; values drawn to create meaningful deltas (≥15% difference at early waves, ≥25% later). +- **Color coding:** + green, − red, × yellow, ÷ blue. ### 9.2 Forward Run -* **Speed:** base lane speed `v0`, ramps up slightly each wave. -* **Steer input:** horizontal factor ∈ [−1, +1] from slider. -* **Flock simulation:** Use a lightweight GPU boids pass (inspired by three.js GPGPU birds) to keep large formations cohesive while responding to steering and obstacle avoidance. For low-spec fallback, degrade to CPU formation offsets. +- **Speed:** base lane speed `v0`, ramps up slightly each wave. +- **Steer input:** horizontal factor ∈ [−1, +1] from slider. +- **Flock simulation:** Use a lightweight GPU boids pass (inspired by three.js GPGPU birds) to keep large formations cohesive while responding to steering and obstacle avoidance. For low-spec fallback, degrade to CPU formation offsets. ### 9.3 Skirmish Resolution (deterministic & fast) -* **Tick:** every 150 ms both sides exchange damage. -* **Damage model:** `damage = base * min(attackerCount, defenderCount) ^ 0.85`. -* **Casualty calc:** casualties per tick = `ceil(damage / HP_PER_UNIT)`; clamp ≤ current count. -* **Enemy sizing:** Enemy squads spawn at ~80% of the optimal player count projected for that decision, keeping pressure while preserving a winnable path. -* **Volleys:** spawn **arrow particles** proportional to casualties (capped) for visual feedback. -* **End of skirmish:** side reaching 0 loses; survivor proceeds with remaining units. Time to kill must fit the snackable pace (2–4 ticks typical). -* **Determinism:** seeded RNG for slight spread; same seed → same result. +- **Tick:** every 150 ms both sides exchange damage. +- **Damage model:** `damage = base * min(attackerCount, defenderCount) ^ 0.85`. +- **Casualty calc:** casualties per tick = `ceil(damage / HP_PER_UNIT)`; clamp ≤ current count. +- **Enemy sizing:** Enemy squads spawn at ~80% of the optimal player count projected for that decision, keeping pressure while preserving a winnable path. +- **Volleys:** spawn **arrow particles** proportional to casualties (capped) for visual feedback. +- **End of skirmish:** side reaching 0 loses; survivor proceeds with remaining units. Time to kill must fit the snackable pace (2–4 ticks typical). +- **Determinism:** seeded RNG for slight spread; same seed → same result. ### 9.4 Reverse Chase -* **Setup:** spawn a chasing enemy horde at distance `D0`; speed slightly higher than player (`vChase = v0*1.05`). -* **Gate mirror:** Reverse phase reuses the forward-run gate count for the current wave, with the same math rules applied to shrinking army sizes. -* **Volley pressure:** Fire an automatic arrow volley every 0.8 s sized to ~10% of the current player army; arrows target and remove chasers on hit using the skirmish arrow FX/pools. -* **Speed profile:** Baseline forward/reverse travel speed is 6 m/s; clearing a reverse gate triggers a 1 s chase surge where the horde spikes to 8 m/s before easing back to baseline. -* **Win/Lose:** reach finish line with ≥1 unit → win; if caught or unit count hits 0 → fail; a failed chase resets progression to wave 1 before the next attempt. -* **Difficulty envelope:** Tune surge distance and volley effectiveness so a player who maintains ≥70% of the optimal count survives with a small buffer, while dropping below ~50% creates a credible fail risk without feeling impossible. +- **Setup:** spawn a chasing enemy horde at distance `D0`; speed slightly higher than player (`vChase = v0*1.05`). +- **Gate mirror:** Reverse phase reuses the forward-run gate count for the current wave, with the same math rules applied to shrinking army sizes. +- **Volley pressure:** Fire an automatic arrow volley every 0.8 s sized to ~10% of the current player army; arrows target and remove chasers on hit using the skirmish arrow FX/pools. +- **Speed profile:** Baseline forward/reverse travel speed is 6 m/s; clearing a reverse gate triggers a 1 s chase surge where the horde spikes to 8 m/s before easing back to baseline. +- **Win/Lose:** reach finish line with ≥1 unit → win; if caught or unit count hits 0 → fail; a failed chase resets progression to wave 1 before the next attempt. +- **Difficulty envelope:** Tune surge distance and volley effectiveness so a player who maintains ≥70% of the optimal count survives with a small buffer, while dropping below ~50% creates a credible fail risk without feeling impossible. ### 9.5 Scoring, Stars, & Persistence -* **Score:** gated on efficiency and remaining units. Example: +- **Score:** gated on efficiency and remaining units. Example: + - Gate choice bonus (+perfect bonus if >90% of theoretical optimum across decisions). + - Skirmish speed bonus (fewer ticks). + - Survival multiplier for reverse chase. - * Gate choice bonus (+perfect bonus if >90% of theoretical optimum across decisions). - * Skirmish speed bonus (fewer ticks). - * Survival multiplier for reverse chase. -* **Star bands:** 1★ / 2★ / 3★ at ~40% / 70% / 90% of level’s theoretical max. -* **Persistence:** LocalStorage stores `{ highScore, bestStars, lastSeed }` plus a per-wave map of best star ratings to drive progression UI. -* **Wave flow:** After each wave, show a minimalist "Wave X Complete" popup with the current 1–3★ result (optionally show a 5★ breakdown for deeper post-run insights) and `Next` / `Retry` options; the global Play button advances to the next unfinished wave by default. -* **Seeded runs:** shareable seed param (`?seed=XXXX`). +- **Star bands:** 1★ / 2★ / 3★ at ~40% / 70% / 90% of level’s theoretical max. +- **Persistence:** LocalStorage stores `{ highScore, bestStars, lastSeed }` plus a per-wave map of best star ratings to drive progression UI. +- **Wave flow:** After each wave, show a minimalist "Wave X Complete" popup with the current 1–3★ result (optionally show a 5★ breakdown for deeper post-run insights) and `Next` / `Retry` options; the global Play button advances to the next unfinished wave by default. +- **Seeded runs:** shareable seed param (`?seed=XXXX`). ### 9.6 Wave Structure & Progression -* **Gate counts:** Wave 1 features 5 forward-run gates; each new wave adds +1 gate (tunable cap) before transitioning to the skirmish beat and mirrored reverse run. -* **Deterministic pairing:** Forward and reverse gate sets derive from the same seeded generator so that a given `seed+wave` produces identical layouts across sessions. +- **Gate counts:** Wave 1 features 5 forward-run gates; each new wave adds +1 gate (tunable cap) before transitioning to the skirmish beat and mirrored reverse run. +- **Deterministic pairing:** Forward and reverse gate sets derive from the same seeded generator so that a given `seed+wave` produces identical layouts across sessions. ## 10) Architecture & Code Structure -* **Stack:** `three.js` + pmndrs **postprocessing** (FXAA, Selective Bloom). Optional **troika-three-text** for desktop counters (mobile uses DOM). -* **Renderer:** `antialias:false` (AA via composer), powerPreference `"high-performance"`. -* **Core modules:** - - * `Game.ts` (state machine: PreRun → Running → Skirmish → Reverse → EndCard). - * `World.ts` (lane pooling, fog, gradient backdrop). - * `Gates.ts` (spawn, math values, color, bloom list control). - * `Units.ts` (instancing, counts, simple formation layout). - * `Combat.ts` (skirmish ticks, damage model, arrow spark emit). - * `VFX.ts` (trails, sparks, flashes; performance guards). - * `CameraRig.ts` (rail samples, beat transitions, shake). - * `UI.tsx` or `ui.ts` (DOM HUD & slider; pause; end card). - * `Flock.ts` (GPU boids update step + CPU fallback, straggler cleanup hooks). - * `SeedRng.ts` (seedable PRNG). - * `Perf.ts` (FPS monitor, degrade/upgrade). - * `Telemetry.ts` (abstract event interface with `trackEvent(name, payload)`; default console logger; ready for external analytics). -* **Object pooling:** arrows, sparks, props are pooled; **InstancedMesh** per type; per-instance attributes for color/scale/opacity. -* **Selective bloom list:** numeral cards, trail material. Everything else excluded. +- **Stack:** `three.js` + pmndrs **postprocessing** (FXAA, Selective Bloom). Optional **troika-three-text** for desktop counters (mobile uses DOM). +- **Renderer:** `antialias:false` (AA via composer), powerPreference `"high-performance"`. +- **Core modules:** + - `Game.ts` (state machine: PreRun → Running → Skirmish → Reverse → EndCard). + - `World.ts` (lane pooling, fog, gradient backdrop). + - `Gates.ts` (spawn, math values, color, bloom list control). + - `Units.ts` (instancing, counts, simple formation layout). + - `Combat.ts` (skirmish ticks, damage model, arrow spark emit). + - `VFX.ts` (trails, sparks, flashes; performance guards). + - `CameraRig.ts` (rail samples, beat transitions, shake). + - `UI.tsx` or `ui.ts` (DOM HUD & slider; pause; end card). + - `Flock.ts` (GPU boids update step + CPU fallback, straggler cleanup hooks). + - `SeedRng.ts` (seedable PRNG). + - `Perf.ts` (FPS monitor, degrade/upgrade). + - `Telemetry.ts` (abstract event interface with `trackEvent(name, payload)`; default console logger; ready for external analytics). + +- **Object pooling:** arrows, sparks, props are pooled; **InstancedMesh** per type; per-instance attributes for color/scale/opacity. +- **Selective bloom list:** numeral cards, trail material. Everything else excluded. ## 11) Data & Config @@ -212,47 +215,47 @@ ## 12) Controls & Accessibility -* **Controls:** one-hand slider, tap buttons; Arrow keys/A/D on desktop as mirror input (optional). -* **Readability:** high contrast HUD; color-coding supplemented by symbols/operators (color-blind friendly). -* **Haptics (optional mobile):** short vibration on perfect gate and win. +- **Controls:** one-hand slider, tap buttons; Arrow keys/A/D on desktop as mirror input (optional). +- **Readability:** high contrast HUD; color-coding supplemented by symbols/operators (color-blind friendly). +- **Haptics (optional mobile):** short vibration on perfect gate and win. ## 13) Error Handling & Edge Cases -* **WebGL unavailable:** show lightweight fallback screen with instructions to enable hardware acceleration. -* **Lost context / tab hidden:** pause and show resume. -* **Bad seed / params:** validate and clamp to defaults. -* **Division gates:** enforce min result 1 after rounding; never generate `÷0` or `÷` that yields <1. +- **WebGL unavailable:** show lightweight fallback screen with instructions to enable hardware acceleration. +- **Lost context / tab hidden:** pause and show resume. +- **Bad seed / params:** validate and clamp to defaults. +- **Division gates:** enforce min result 1 after rounding; never generate `÷0` or `÷` that yields <1. ## 14) Testing Plan (high level) -* **Unit (Jest):** +- **Unit (Jest):** + - Gate math & rounding rules (Given/When/Then). + - Gate generator never emits invalid combos. + - Combat tick determinism for a fixed seed. + - Performance guard thresholds (simulate FPS series). + - Score & star band calculations. + - Telemetry adapter routes events to console without throwing. - * Gate math & rounding rules (Given/When/Then). - * Gate generator never emits invalid combos. - * Combat tick determinism for a fixed seed. - * Performance guard thresholds (simulate FPS series). - * Score & star band calculations. - * Telemetry adapter routes events to console without throwing. -* **Integration (Playwright):** +- **Integration (Playwright):** + - Start → finish happy path; restart is instant. + - Two known seeds produce identical runs and scores. + - UI responsiveness: slider drag latency under threshold. - * Start → finish happy path; restart is instant. - * Two known seeds produce identical runs and scores. - * UI responsiveness: slider drag latency under threshold. -* **Visual checks:** screenshot diff of HUD & gate legibility across DPRs (1.0/2.0/3.0). +- **Visual checks:** screenshot diff of HUD & gate legibility across DPRs (1.0/2.0/3.0). ## 15) Build & Delivery -* **Project shape:** single-page app; ES modules; no mandatory build step (can add bundler later). -* **Assets:** glTF (embedded) or inline BufferGeometry for ultra-light meshes; matcap PNGs (sRGB). -* **Hosting:** static hosting (GitHub Pages/Netlify/etc.). -* **Shareable seed:** via querystring; copy-to-clipboard button on end card (optional later). -* **Documentation:** Inline JSDoc on public APIs; keep architecture and tooling notes current in `README.md`/`docs/`. +- **Project shape:** single-page app; ES modules; no mandatory build step (can add bundler later). +- **Assets:** glTF (embedded) or inline BufferGeometry for ultra-light meshes; matcap PNGs (sRGB). +- **Hosting:** static hosting (GitHub Pages/Netlify/etc.). +- **Shareable seed:** via querystring; copy-to-clipboard button on end card (optional later). +- **Documentation:** Inline JSDoc on public APIs; keep architecture and tooling notes current in `README.md`/`docs/`. ## 16) Non-Goals (v1) -* Multiplayer, accounts, cloud saves. -* Heavy post-processing (DOF, motion blur, OutlinePass). -* Complex physics or per-soldier IK. +- Multiplayer, accounts, cloud saves. +- Heavy post-processing (DOF, motion blur, OutlinePass). +- Complex physics or per-soldier IK. --- @@ -276,11 +279,11 @@ public/index.html // ESM entry, no bundler required (import maps optional) **Tooling & scripts (package.json)** -* `"test": "jest --runInBand"` -* `"test:watch": "jest --watch"` -* `"lint": "eslint . --max-warnings=0"` -* `"e2e": "playwright test --reporter=line"` -* `"check": "npm run lint && npm run test && npm run e2e"` +- `"test": "jest --runInBand"` +- `"test:watch": "jest --watch"` +- `"lint": "eslint . --max-warnings=0"` +- `"e2e": "playwright test --reporter=line"` +- `"check": "npm run lint && npm run test && npm run e2e"` > CI-friendly, non-interactive reporters; Node 20+, Jest + JSDOM, Playwright for e2e. @@ -296,10 +299,10 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Prep:** `ripgrep` to ensure no prior RNG. **Tests (unit):** -* *Given* seed `1234`, *When* generating 5 numbers, *Then* the sequence equals a stored snapshot. - *why this test matters: locks determinism across machines.* -* *Given* two RNGs with the same seed, *When* advanced in different batch sizes but same total draws, *Then* final value matches. - *why: prevents subtle order bugs.* +- _Given_ seed `1234`, _When_ generating 5 numbers, _Then_ the sequence equals a stored snapshot. + _why this test matters: locks determinism across machines._ +- _Given_ two RNGs with the same seed, _When_ advanced in different batch sizes but same total draws, _Then_ final value matches. + _why: prevents subtle order bugs._ **Impl notes:** xorshift32 or mulberry32; exposes `nextFloat()`, `nextInt(min,max)`. **Run:** `npm run test && npm run lint` **Doc:** Add decisions & sequence snippet to `docs/implementation-progress.md`. @@ -311,9 +314,9 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Central evaluator for `+/-/×/÷` with clamping/rounding per spec. **Tests (unit):** -* `applyGate(10, "+5") → 15`, `("-20") clamps to 0`. *why: correctness of additive rules.* -* `applyGate(10, "×1.5") → 15 (nearest)`, `("÷3.2") → 3 (nearest, min 1)`. *why: rounding consistency.* -* Never returns `< 1` after ×/÷. *why: gameplay safety.* +- `applyGate(10, "+5") → 15`, `("-20") clamps to 0`. _why: correctness of additive rules._ +- `applyGate(10, "×1.5") → 15 (nearest)`, `("÷3.2") → 3 (nearest, min 1)`. _why: rounding consistency._ +- Never returns `< 1` after ×/÷. _why: gameplay safety._ **Impl notes:** pure function; no side effects. **Run:** `npm run test` @@ -324,9 +327,9 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Produce two valid gate choices with meaningful deltas per wave. **Tests (unit):** -* Never yields `÷0` or a `−` that kills all units. *why: fail-safe content.* -* Delta between options ≥15% (early waves) / ≥25% (later). *why: decision salience.* -* Deterministic for a given seed+wave. *why: shareable seeds.* +- Never yields `÷0` or a `−` that kills all units. _why: fail-safe content._ +- Delta between options ≥15% (early waves) / ≥25% (later). _why: decision salience._ +- Deterministic for a given seed+wave. _why: shareable seeds._ **Run:** `npm run test` --- @@ -336,8 +339,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Score formula + 1★/2★/3★ thresholds. **Tests (unit):** -* Perfect decisions + fast skirmishes reach ≥3★; sloppy reaches <2★ on same seed. *why: curve feels right.* -* Band values serialize and re-load correctly. *why: stable end cards.* +- Perfect decisions + fast skirmishes reach ≥3★; sloppy reaches <2★ on same seed. _why: curve feels right._ +- Band values serialize and re-load correctly. _why: stable end cards._ **Run:** `npm run test` --- @@ -347,8 +350,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Rolling FPS average + degrade/upgrade signals. **Tests (unit):** -* *Given* series below 50 FPS for 2s, *Then* emits `DEGRADE_STEP1`. *why: protects mobile perf.* -* *Given* ≥58 FPS for 4s, *Then* emits `UPGRADE`. *why: recovers visuals.* +- _Given_ series below 50 FPS for 2s, _Then_ emits `DEGRADE_STEP1`. _why: protects mobile perf._ +- _Given_ ≥58 FPS for 4s, _Then_ emits `UPGRADE`. _why: recovers visuals._ **Run:** `npm run test` --- @@ -358,8 +361,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Reusable pool for arrows/sparks. **Tests (unit):** -* `acquire` returns recycled instances after `release`. *why: GC stability.* -* Pool caps prevent growth beyond limit. *why: predictable memory.* +- `acquire` returns recycled instances after `release`. _why: GC stability._ +- Pool caps prevent growth beyond limit. _why: predictable memory._ **Run:** `npm run test` --- @@ -369,8 +372,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Transform count → formation slots (grid arc) with pivot at (0,0,0). **Tests (unit):** -* 1, 10, 100 units produce non-overlapping positions. *why: visual clarity.* -* Formation width/height scales smoothly with count. *why: camera framing.* +- 1, 10, 100 units produce non-overlapping positions. _why: visual clarity._ +- Formation width/height scales smoothly with count. _why: camera framing._ **Run:** `npm run test` --- @@ -380,9 +383,9 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** 150 ms volleys; casualties per spec; seedable spread. **Tests (unit):** -* Fixed attacker/defender counts + seed → deterministic time-to-kill. *why: repeatable runs.* -* Casualties never exceed current counts. *why: integrity.* -* “Fast win” vs “near parity” produce different tick lengths. *why: pacing.* +- Fixed attacker/defender counts + seed → deterministic time-to-kill. _why: repeatable runs._ +- Casualties never exceed current counts. _why: integrity._ +- “Fast win” vs “near parity” produce different tick lengths. _why: pacing._ **Run:** `npm run test` --- @@ -392,8 +395,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Segment recycling, fog window 25→60 m. **Tests (unit):** -* Camera moving forward reuses segments without gaps/overlap. *why: endless lane.* -* Reverse direction mirrors reuse. *why: chase beat support.* +- Camera moving forward reuses segments without gaps/overlap. _why: endless lane._ +- Reverse direction mirrors reuse. _why: chase beat support._ **Run:** `npm run test` --- @@ -403,8 +406,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Place two gates per decision, safe distances from pillars/centerline. **Tests (unit):** -* Gates never overlap; spacing before/after respects min distance. *why: fairness & readability.* -* Operator→color mapping correct. *why: accessibility/consistency.* +- Gates never overlap; spacing before/after respects min distance. _why: fairness & readability._ +- Operator→color mapping correct. _why: accessibility/consistency._ **Run:** `npm run test` --- @@ -414,8 +417,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Deterministic flags/cones/markers per 10 m; culling >65 m behind. **Tests (unit):** -* Seeded prop positions are reproducible; never intersect gates or lane center. *why: stable scenery.* -* Density ≈ 1 per 10 m over long run. *why: visual rhythm.* +- Seeded prop positions are reproducible; never intersect gates or lane center. _why: stable scenery._ +- Density ≈ 1 per 10 m over long run. _why: visual rhythm._ **Run:** `npm run test` --- @@ -425,8 +428,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Prebaked Catmull-Rom samples for Forward/Skirmish/Reverse. **Tests (unit):** -* Sampling at t∈[0..1] returns continuous, monotonic path; lookAt lerp stable. *why: jitter-free.* -* Beat transitions respect durations (200–220 ms). *why: timing.* +- Sampling at t∈[0..1] returns continuous, monotonic path; lookAt lerp stable. _why: jitter-free._ +- Beat transitions respect durations (200–220 ms). _why: timing._ **Run:** `npm run test` --- @@ -436,8 +439,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Accessible slider → normalized steer ∈ [−1, +1]. **Tests (integration, Playwright):** -* Drag/Touch adjusts value smoothly; keyboard A/D mirrors on desktop. *why: control parity.* -* Hit area ≥44 px; thumb enlarges while active. *why: mobile ergonomics.* +- Drag/Touch adjusts value smoothly; keyboard A/D mirrors on desktop. _why: control parity._ +- Hit area ≥44 px; thumb enlarges while active. _why: mobile ergonomics._ **Run:** `npm run e2e` --- @@ -447,8 +450,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Top-center score/timer; compact format; ±delta flash 250 ms. **Tests (integration):** -* Numbers align (tabular-nums); deltas animate and auto-clear. *why: glanceable feedback.* -* Pause hides timer, resume restores. *why: state integrity.* +- Numbers align (tabular-nums); deltas animate and auto-clear. _why: glanceable feedback._ +- Pause hides timer, resume restores. _why: state integrity._ **Run:** `npm run e2e` --- @@ -458,8 +461,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** PreRun → Running → Skirmish → Reverse → EndCard transitions. **Tests (unit):** -* Given seed X, driving inputs across beats reaches EndCard without illegal transitions. *why: flow safety.* -* Restart returns to PreRun with clean state. *why: instant retries.* +- Given seed X, driving inputs across beats reaches EndCard without illegal transitions. _why: flow safety._ +- Restart returns to PreRun with clean state. _why: instant retries._ **Run:** `npm run test` --- @@ -469,8 +472,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Renderer (`antialias:false`), FXAA in composer, gradient backdrop, fog. **Tests (integration/smoke):** -* Canvas mounts; frame count > 0; gradient & fog uniforms applied. *why: render pipeline sanity.* -* Toggling low-perf flag disables bloom path (stub for now). *why: future guard.* +- Canvas mounts; frame count > 0; gradient & fog uniforms applied. _why: render pipeline sanity._ +- Toggling low-perf flag disables bloom path (stub for now). _why: future guard._ **Run:** `npm run e2e` --- @@ -480,8 +483,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Single `InstancedMesh` for player/enemy; matcap material. **Tests (integration/visual diff):** -* Counts 1→100 render within formation bounds (screenshots diff threshold). *why: layout fidelity.* -* Material sRGB output toggled correctly. *why: color correctness.* +- Counts 1→100 render within formation bounds (screenshots diff threshold). _why: layout fidelity._ +- Material sRGB output toggled correctly. _why: color correctness._ **Run:** `npm run e2e` --- @@ -491,8 +494,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Gate mesh (≤200 tris) + emissive numeral quad in bloom-include list. **Tests (integration):** -* Gate symbols color-coded; two choices visible and non-overlapping. *why: legibility.* -* Numeral cards flagged for bloom list (data check now; visual in next iteration). *why: post-FX hookup.* +- Gate symbols color-coded; two choices visible and non-overlapping. _why: legibility._ +- Numeral cards flagged for bloom list (data check now; visual in next iteration). _why: post-FX hookup._ **Run:** `npm run e2e` --- @@ -502,8 +505,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Add Bloom effect; include only numeral/arrow materials. **Tests (integration/visual):** -* Numeral quads glow; pillars do not; FXAA retained. *why: attention focus.* -* Fallback flag disables bloom cleanly. *why: low-end mode.* +- Numeral quads glow; pillars do not; FXAA retained. _why: attention focus._ +- Fallback flag disables bloom cleanly. _why: low-end mode._ **Run:** `npm run e2e` --- @@ -513,8 +516,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Ultra-Lean: 6 segments, 220 ms lifetime; sparks burst on casualties. **Tests (integration):** -* Max emitters capped; lifetime respected; object pool reuse verified (counter). *why: perf ceiling.* -* Trail materials on bloom include list only. *why: effect budget.* +- Max emitters capped; lifetime respected; object pool reuse verified (counter). _why: perf ceiling._ +- Trail materials on bloom include list only. _why: effect budget._ **Run:** `npm run e2e` --- @@ -524,8 +527,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Drive volleys from combat ticks; damage flash (emissive pop). **Tests (integration):** -* Known seed results in expected volley count & duration; end state matches unit tests. *why: logic/render parity.* -* Flash intensity animates 0→0.8→0 over 140 ms. *why: feedback timing.* +- Known seed results in expected volley count & duration; end state matches unit tests. _why: logic/render parity._ +- Flash intensity animates 0→0.8→0 over 140 ms. _why: feedback timing._ **Run:** `npm run e2e` --- @@ -535,8 +538,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Spawn chase horde at D0; speed vChase=1.05×; win/lose. **Tests (integration):** -* With low player count, fail condition triggers before finish; with high, succeed. *why: balance envelope.* -* Transition adds snap-zoom +1.5% FOV. *why: beat emphasis.* +- With low player count, fail condition triggers before finish; with high, succeed. _why: balance envelope._ +- Transition adds snap-zoom +1.5% FOV. _why: beat emphasis._ **Run:** `npm run e2e` --- @@ -546,8 +549,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Flags, cones, track markers with placement rules & CPU early-out. **Tests (integration):** -* Density ~1/10 m over 300 m lane; no intersections with gates/lane center. *why: spatial rules.* -* Frustum + “>65 m behind” culling active (counter stats). *why: perf.* +- Density ~1/10 m over 300 m lane; no intersections with gates/lane center. _why: spatial rules._ +- Frustum + “>65 m behind” culling active (counter stats). _why: perf._ **Run:** `npm run e2e` --- @@ -557,8 +560,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Show score, 1–3★ bands; Restart resets instantly; share seed link. **Tests (integration):** -* Perfect path seed ≥3★; sloppy ≤2★ (using fixed run). *why: reward curve.* -* Restart clears transient state (pools, counts, timers). *why: replay loop.* +- Perfect path seed ≥3★; sloppy ≤2★ (using fixed run). _why: reward curve._ +- Restart clears transient state (pools, counts, timers). _why: replay loop._ **Run:** `npm run e2e` --- @@ -568,7 +571,7 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Wire FPS monitor to degrade/upgrade VFX. **Tests (integration):** -* Simulated low FPS → trails segments halve, sparks off, bloom res reduced; recovery restores. *why: mobile resilience.* +- Simulated low FPS → trails segments halve, sparks off, bloom res reduced; recovery restores. _why: mobile resilience._ **Run:** `npm run e2e` --- @@ -578,8 +581,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, **Goal:** Color contrast ≥ 4.5:1 HUD; operator symbols alongside colors; pause/resume clarity. **Tests (integration):** -* Automated contrast check for HUD foreground vs gradient (threshold). *why: readability.* -* Gate readability snapshot tests across DPR 1.0/2.0/3.0. *why: device coverage.* +- Automated contrast check for HUD foreground vs gradient (threshold). _why: readability._ +- Gate readability snapshot tests across DPR 1.0/2.0/3.0. _why: device coverage._ **Run:** `npm run e2e` --- @@ -591,8 +594,8 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, 3. **Implement minimal code** to pass tests. 4. **Run checks:** `npm run check` (lint + unit + e2e). 5. **Docs update:** append to `docs/implementation-progress.md`: + - _What changed, why it matters, decisions, open questions, next iteration._ - * *What changed, why it matters, decisions, open questions, next iteration.* 6. **Commit message:** `feat(core|world|ui|render): [#iteration-XX]`. --- @@ -604,10 +607,10 @@ The following is an **iteration-by-iteration TDD build plan** that is bottom-up, ```ts // tests/unit/gates.math.test.ts // why this test matters: rounding and clamps underpin all counts; one mismatch cascades into wrong difficulty. -test("× and ÷ rounding & clamps", () => { - expect(applyGate(10, {op:"mul", val:1.5})).toBe(15); - expect(applyGate(10, {op:"div", val:3.2})).toBe(3); - expect(applyGate(1, {op:"div", val:3.2})).toBe(1); +test('× and ÷ rounding & clamps', () => { + expect(applyGate(10, { op: 'mul', val: 1.5 })).toBe(15); + expect(applyGate(10, { op: 'div', val: 3.2 })).toBe(3); + expect(applyGate(1, { op: 'div', val: 3.2 })).toBe(1); }); ``` @@ -616,11 +619,11 @@ test("× and ÷ rounding & clamps", () => { ```ts // tests/integration/hud.delta.spec.ts // why this test matters: players must read gains/losses instantly; animation regressions are common. -test("delta flashes for 250ms then clears", async ({ page }) => { - await page.goto("/public/index.html?seed=test-seed"); +test('delta flashes for 250ms then clears', async ({ page }) => { + await page.goto('/public/index.html?seed=test-seed'); await startRun(page); - await chooseGate(page, "mul", 1.5); - const delta = page.getByTestId("hud-delta"); + await chooseGate(page, 'mul', 1.5); + const delta = page.getByTestId('hud-delta'); await expect(delta).toBeVisible(); await page.waitForTimeout(300); await expect(delta).toBeHidden(); @@ -638,4 +641,3 @@ test("delta flashes for 250ms then clears", async ({ page }) => { 5. **Append learnings** to `docs/implementation-progress.md` after each iteration (serves as project memory & devlog). 6. **CI-friendly:** avoid interactive prompts; stable output; use line reporters. 7. **Performance budgets:** draw calls < 150; active particles ≤ ~250; watch dev HUD (optional) that prints counters. - diff --git a/docs/codeCoverageIgnoreGuidelines.md b/docs/codeCoverageIgnoreGuidelines.md index bd05c49..dccd754 100644 --- a/docs/codeCoverageIgnoreGuidelines.md +++ b/docs/codeCoverageIgnoreGuidelines.md @@ -5,7 +5,7 @@ This document defines how to use coverage-ignore comments in this repository. It ## TL;DR - `/* istanbul ignore next */` excludes the **next AST node** from coverage. -- Use ignores **sparingly** and **only** for code that is *truly* untestable or irrelevant to product behavior. +- Use ignores **sparingly** and **only** for code that is _truly_ untestable or irrelevant to product behavior. - Every ignore **must include a reason** right next to it. - Prefer tests, refactors, or config-level excludes over in-source ignores. @@ -30,24 +30,33 @@ Use an ignore only when exercising the code in automated tests is impractical or 1. **Unreachable defensive code** Exhaustive switch fallthroughs, invariant guards, or “should never happen” paths that exist purely as safety nets. + ```ts - type Kind = "A" | "B" - function assertNever(x: never): never { throw new Error("unreachable") } + type Kind = 'A' | 'B'; + function assertNever(x: never): never { + throw new Error('unreachable'); + } switch (kind) { - case "A": handleA(); break - case "B": handleB(); break + case 'A': + handleA(); + break; + case 'B': + handleB(); + break; /* istanbul ignore next -- defensive, unreachable by construction */ - default: assertNever(kind as never) + default: + assertNever(kind as never); } + ``` 2. **Platform-/environment-specific branches** Behavior that cannot be exercised in CI or across all supported OSes without unrealistic setups. ```ts - if (process.platform === "win32") { + if (process.platform === 'win32') { /* istanbul ignore next -- requires native Windows console; not in CI image */ - enableWindowsConsoleMode() + enableWindowsConsoleMode(); } ``` @@ -61,11 +70,11 @@ Use an ignore only when exercising the code in automated tests is impractical or ## When it is **not** acceptable -* To boost coverage percentages or hide missing tests. -* On **business logic** or any behavior affecting users. -* Broadly before `if`/`switch`/function declarations that mask multiple branches or large regions. -* As a substitute for a **small refactor** that would make testing feasible (e.g., splitting out side effects, injecting dependencies). -* For convenience when a test is mildly inconvenient to write (e.g., mocking a timer or a rejected promise). +- To boost coverage percentages or hide missing tests. +- On **business logic** or any behavior affecting users. +- Broadly before `if`/`switch`/function declarations that mask multiple branches or large regions. +- As a substitute for a **small refactor** that would make testing feasible (e.g., splitting out side effects, injecting dependencies). +- For convenience when a test is mildly inconvenient to write (e.g., mocking a timer or a rejected promise). --- @@ -97,10 +106,10 @@ Use an ignore only when exercising the code in automated tests is impractical or ## Preferred alternatives to ignores -* **Write a focused test**: Use dependency injection, seam extraction, or a small adapter to isolate side effects. -* **Refactor for testability**: Split logic from I/O; return values instead of printing; pass a clock/random source. -* **Use config excludes for generated code**: Keep production logic fully measured. -* **Switch directive, not scope**: Prefer `ignore if/else` over `ignore next` when only one branch is untestable. +- **Write a focused test**: Use dependency injection, seam extraction, or a small adapter to isolate side effects. +- **Refactor for testability**: Split logic from I/O; return values instead of printing; pass a clock/random source. +- **Use config excludes for generated code**: Keep production logic fully measured. +- **Switch directive, not scope**: Prefer `ignore if/else` over `ignore next` when only one branch is untestable. --- @@ -135,14 +144,14 @@ Jest example (if using V8 coverage): // jest.config.js module.exports = { collectCoverage: true, - coverageProvider: "v8", + coverageProvider: 'v8', coveragePathIgnorePatterns: [ - "/node_modules/", - "/dist/", - "/build/", - "\\.gen\\." - ] -} + '/node_modules/', + '/dist/', + '/build/', + '\\.gen\\.', + ], +}; ``` > Align comment style with the active provider: `istanbul` for Babel/nyc instrumentation; `c8` for V8. @@ -155,28 +164,32 @@ module.exports = { ```js // scripts/check-coverage-ignores.mjs -import { readFileSync } from "node:fs"; -import { globby } from "globby"; +import { readFileSync } from 'node:fs'; +import { globby } from 'globby'; -const files = await globby(["src/**/*.{ts,tsx,js,jsx}"], { gitignore: true }); +const files = await globby(['src/**/*.{ts,tsx,js,jsx}'], { gitignore: true }); const offenders = []; const re = /(istanbul|c8)\s+ignore\s+(next|if|else|file)/; for (const f of files) { - const lines = readFileSync(f, "utf8").split("\n"); + const lines = readFileSync(f, 'utf8').split('\n'); for (let i = 0; i < lines.length; i++) { if (re.test(lines[i])) { const hasReason = - /--\s*[A-Za-z0-9]/.test(lines[i]) || (i > 0 && /--\s*[A-Za-z0-9]/.test(lines[i - 1])); - if (!hasReason) offenders.push(`${f}:${i + 1}: missing reason after ignore comment`); + /--\s*[A-Za-z0-9]/.test(lines[i]) || + (i > 0 && /--\s*[A-Za-z0-9]/.test(lines[i - 1])); + if (!hasReason) + offenders.push(`${f}:${i + 1}: missing reason after ignore comment`); } } } if (offenders.length) { - console.error("Coverage ignore comments require an inline reason (use `-- reason`)."); - console.error(offenders.join("\n")); + console.error( + 'Coverage ignore comments require an inline reason (use `-- reason`).' + ); + console.error(offenders.join('\n')); process.exit(1); } ``` @@ -200,7 +213,13 @@ Optional ESLint guard (warn on any usage): "no-restricted-comments": [ "warn", { - "terms": ["istanbul ignore next", "istanbul ignore if", "istanbul ignore else", "istanbul ignore file", "c8 ignore next"], + "terms": [ + "istanbul ignore next", + "istanbul ignore if", + "istanbul ignore else", + "istanbul ignore file", + "c8 ignore next" + ], "location": "anywhere", "message": "Coverage ignore detected: add `-- reason` and ensure policy compliance." } @@ -217,11 +236,10 @@ Optional ESLint guard (warn on any usage): ```ts if (cacheEnabled) { - warmCache() -} -/* istanbul ignore else -- cold path is a telemetry-only fallback */ -else { - coldStartWithTelemetry() + warmCache(); +} else { + /* istanbul ignore else -- cold path is a telemetry-only fallback */ + coldStartWithTelemetry(); } ``` @@ -231,7 +249,7 @@ else { // Calls a native API that only exists on macOS ≥ 13: if (isDarwin13Plus()) { /* istanbul ignore next -- native API unavailable in CI runners */ - enableFancyTerminal() + enableFancyTerminal(); } ``` @@ -252,18 +270,15 @@ nyc.exclude += ["src/generated/**"] // in package.json nyc config ``` 2. **Classify** - - * ✅ Legitimate (add/verify reason, minimize scope) - * 🟡 Replaceable (write a test or refactor) - * 🔴 Remove/ban (business logic, overly broad) + - ✅ Legitimate (add/verify reason, minimize scope) + - 🟡 Replaceable (write a test or refactor) + - 🔴 Remove/ban (business logic, overly broad) 3. **Refactor & test** - - * Extract logic from side effects; inject collaborators; mock clocks/randomness. + - Extract logic from side effects; inject collaborators; mock clocks/randomness. 4. **Guard** - - * Add CI script and ESLint rule to prevent regressions. + - Add CI script and ESLint rule to prevent regressions. --- @@ -282,7 +297,7 @@ A: Use one approach consistently. If switching to V8 coverage, update directives ## Checklist for new code -* [ ] Coverage added for changed behavior. -* [ ] No new `istanbul`/`c8` ignores **unless** justified and minimal. -* [ ] Each ignore has `-- reason` and (optionally) a ticket reference. -* [ ] Generated/vendor code excluded via config, not inline comments. \ No newline at end of file +- [ ] Coverage added for changed behavior. +- [ ] No new `istanbul`/`c8` ignores **unless** justified and minimal. +- [ ] Each ignore has `-- reason` and (optionally) a ticket reference. +- [ ] Generated/vendor code excluded via config, not inline comments. diff --git a/docs/implementation-progress.md b/docs/implementation-progress.md new file mode 100644 index 0000000..0707f58 --- /dev/null +++ b/docs/implementation-progress.md @@ -0,0 +1,16 @@ +# Implementation progress + +## Iteration 1 — Arcade run loop skeleton + +- Established deterministic math-gate generation, combat, and chase logic modules to anchor the core loop. +- Built responsive HUD and control surface aligning with README arcade spec (score/timer, slider, pause/start controls). +- Wired run flow (forward gate pick, skirmish, reverse chase) with instant restart behaviour and FPS degrade hooks. +- Added tests for gate math, combat flow, and performance guard evaluation to keep balancing logic deterministic. +- Outstanding: expand 3D presentation + particle systems described in README once rendering stack is introduced. + +## Iteration 2 — Shareable seeds and restart parity + +- Added query-string seed bootstrap so designers can reproduce specific runs instantly and wired URL updates on start. +- Preserved identical seeds for pause-menu restarts while rotating fresh seeds for new runs to keep variation high. +- Extended integration tests to lock the seed workflow and hook coverage for QA automation. +- Outstanding: expose copy-to-clipboard helper on end card and surface current seed in HUD for quicker sharing. diff --git a/index.html b/index.html index 30404ce..a2c60d7 100644 --- a/index.html +++ b/index.html @@ -1 +1,364 @@ -TODO \ No newline at end of file + + + + + + Math Marauders + + + +
+
+ 01:30 + 0 + + +
+ +
+
+
+ Ready to deploy +
+
+ 12 + 10 +
+
+
☆ ☆ ☆
+
+
+ +
+
+ + +
+ +
+
+ + + + + + diff --git a/src/assets/defaultData.json b/src/assets/defaultData.json index 4a35737..c05b939 100644 --- a/src/assets/defaultData.json +++ b/src/assets/defaultData.json @@ -1,8 +1,8 @@ { - "someData": [ - { - "id": "123", - "title": "Abc", - } - ] -} \ No newline at end of file + "someData": [ + { + "id": "123", + "title": "Abc" + } + ] +} diff --git a/src/core/combatSimulator.js b/src/core/combatSimulator.js new file mode 100644 index 0000000..7c7a40e --- /dev/null +++ b/src/core/combatSimulator.js @@ -0,0 +1,49 @@ +export function simulateSkirmish({ + playerCount, + enemyCount, + volleyDurationMs, +}) { + const volleys = Math.max(1, Math.round(volleyDurationMs / 1000)); + const playerDamagePerVolley = Math.max(1, Math.round(enemyCount * 0.1)); + const enemyDamagePerVolley = Math.max(1, Math.round(playerCount * 0.12)); + + const playerLoss = Math.min(playerCount, playerDamagePerVolley * volleys); + const enemyLoss = Math.min(enemyCount, enemyDamagePerVolley * volleys); + + const playerRemaining = Math.max(0, playerCount - playerLoss); + const enemyRemaining = Math.max(0, enemyCount - enemyLoss); + const scoreDelta = enemyLoss * 2 - playerLoss; + + return { + volleys, + playerRemaining, + enemyRemaining, + scoreDelta, + }; +} + +export function resolveReverseChase({ survivors, chaseStrength }) { + const pressureThreshold = Math.ceil(chaseStrength * 0.7); + if (survivors <= pressureThreshold) { + return { + success: false, + casualties: survivors, + bonusScore: 0, + }; + } + + const casualties = Math.max(0, Math.round(chaseStrength * 0.35)); + const bonusScore = Math.max(0, survivors - casualties); + + return { + success: true, + casualties, + bonusScore, + }; +} + +export function calculateStarRating(score) { + if (score >= 110) return 3; + if (score >= 60) return 2; + return 1; +} diff --git a/src/core/combatSimulator.test.js b/src/core/combatSimulator.test.js new file mode 100644 index 0000000..6e3e5e5 --- /dev/null +++ b/src/core/combatSimulator.test.js @@ -0,0 +1,50 @@ +import { + simulateSkirmish, + resolveReverseChase, + calculateStarRating, +} from './combatSimulator.js'; + +// why this test matters: skirmish math drives mid-run attrition clarity. +describe('simulateSkirmish', () => { + it('applies volley exchange deterministically', () => { + const result = simulateSkirmish({ + playerCount: 20, + enemyCount: 15, + volleyDurationMs: 3000, + }); + expect(result.playerRemaining).toBe(14); + expect(result.enemyRemaining).toBe(9); + expect(result.scoreDelta).toBe(6); + }); +}); + +// why this test matters: chase outcome determines fail/win loop. +describe('resolveReverseChase', () => { + it('detects failure when horde overwhelms survivors', () => { + const outcome = resolveReverseChase({ + survivors: 4, + chaseStrength: 6, + }); + expect(outcome.success).toBe(false); + expect(outcome.casualties).toBe(4); + }); + + it('awards bonus when survivors outrun chase', () => { + const outcome = resolveReverseChase({ + survivors: 12, + chaseStrength: 6, + }); + expect(outcome.success).toBe(true); + expect(outcome.casualties).toBe(2); + expect(outcome.bonusScore).toBe(10); + }); +}); + +// why this test matters: star ratings communicate run mastery at a glance. +describe('calculateStarRating', () => { + it('maps score bands to 1-3 star scale', () => { + expect(calculateStarRating(30)).toBe(1); + expect(calculateStarRating(65)).toBe(2); + expect(calculateStarRating(120)).toBe(3); + }); +}); diff --git a/src/core/mathGates.js b/src/core/mathGates.js new file mode 100644 index 0000000..fe2c345 --- /dev/null +++ b/src/core/mathGates.js @@ -0,0 +1,59 @@ +import { createRng } from './random.js'; + +const GATE_RANGES = { + add: { min: 2, max: 12, step: 1 }, + subtract: { min: 2, max: 12, step: 1 }, + multiply: { min: 1.1, max: 2.2, step: 0.1 }, + divide: { min: 1.2, max: 3.5, step: 0.1 }, +}; + +const OPERATORS = ['add', 'subtract', 'multiply', 'divide']; + +export function applyGate(current, gate) { + if (!gate) return current; + const value = gate.value; + switch (gate.op) { + case 'add': + return Math.max(0, Math.round(current + value)); + case 'subtract': + return Math.max(0, Math.round(current - value)); + case 'multiply': { + const result = current * value; + return Math.max(0, Math.round(result)); + } + case 'divide': { + const result = current / value; + return Math.max(1, Math.round(result)); + } + default: + return current; + } +} + +export function createGateOptions(seed, { wave = 1 } = {}) { + const rng = createRng(`${seed}-${wave}`); + const ops = [...OPERATORS]; + // Fisher-Yates shuffle for deterministic uniqueness + for (let i = ops.length - 1; i > 0; i -= 1) { + const j = Math.floor(rng() * (i + 1)); + [ops[i], ops[j]] = [ops[j], ops[i]]; + } + const count = 3; + const selected = ops.slice(0, count); + return selected.map((op, index) => ({ + id: `${op}-${index}`, + op, + value: rollValue(op, rng), + })); +} + +export function rollValue(op, rng) { + const config = GATE_RANGES[op]; + if (!config) return 0; + const steps = Math.round((config.max - config.min) / config.step); + const stepIndex = Math.floor(rng() * (steps + 1)); + const value = config.min + stepIndex * config.step; + const rounded = + Math.round((value + Number.EPSILON) / config.step) * config.step; + return Number(rounded.toFixed(2)); +} diff --git a/src/core/mathGates.test.js b/src/core/mathGates.test.js new file mode 100644 index 0000000..c007d11 --- /dev/null +++ b/src/core/mathGates.test.js @@ -0,0 +1,59 @@ +import { applyGate, createGateOptions, rollValue } from './mathGates.js'; + +// why this test matters: gate math defines player counts and difficulty pacing. +describe('applyGate', () => { + it('applies additive gates and clamps at zero', () => { + expect(applyGate(10, { op: 'add', value: 5 })).toBe(15); + expect(applyGate(10, { op: 'subtract', value: 20 })).toBe(0); + }); + + it('rounds multiplicative gates to nearest whole and clamps minimum', () => { + expect(applyGate(10, { op: 'multiply', value: 1.5 })).toBe(15); + expect(applyGate(10, { op: 'divide', value: 3.2 })).toBe(3); + expect(applyGate(1, { op: 'divide', value: 3.2 })).toBe(1); + }); + + it('returns current value when gate is missing or unknown', () => { + expect(applyGate(12)).toBe(12); + expect(applyGate(12, { op: 'boost', value: 99 })).toBe(12); + }); +}); + +// why this test matters: gate rolls must stay within tuned ranges for balance. +describe('createGateOptions', () => { + it('produces deterministic gates within expected ranges per seed', () => { + const seed = 'alpha'; + const optionsA = createGateOptions(seed, { wave: 1 }); + const optionsB = createGateOptions(seed, { wave: 1 }); + expect(optionsA).toEqual(optionsB); + optionsA.forEach((gate) => { + switch (gate.op) { + case 'add': + case 'subtract': + expect(gate.value).toBeGreaterThanOrEqual(2); + expect(gate.value).toBeLessThanOrEqual(12); + break; + case 'multiply': + expect(gate.value).toBeGreaterThanOrEqual(1.1); + expect(gate.value).toBeLessThanOrEqual(2.2); + break; + case 'divide': + expect(gate.value).toBeGreaterThanOrEqual(1.2); + expect(gate.value).toBeLessThanOrEqual(3.5); + break; + default: + throw new Error(`Unknown op ${gate.op}`); + } + }); + }); + + it('falls back to zero when rolling a gate with no config', () => { + const rng = () => 0.5; + expect(rollValue('mystery', rng)).toBe(0); + }); + + it('creates options when wave is omitted', () => { + const options = createGateOptions('beta'); + expect(options).toHaveLength(3); + }); +}); diff --git a/src/core/performanceGuards.js b/src/core/performanceGuards.js new file mode 100644 index 0000000..b322a19 --- /dev/null +++ b/src/core/performanceGuards.js @@ -0,0 +1,87 @@ +export function evaluatePerformanceWindow({ + samples, + degradeThreshold, + restoreThreshold, +}) { + if (!samples.length) { + return { degrade: false, restore: false }; + } + const average = + samples.reduce((sum, value) => sum + value, 0) / samples.length; + if (average < degradeThreshold) { + return { degrade: true, restore: false }; + } + if (average >= restoreThreshold) { + return { degrade: false, restore: true }; + } + return { degrade: false, restore: false }; +} + +export class PerformanceGuards { + constructor({ + degradeThreshold = 50, + restoreThreshold = 58, + sampleWindowMs = 2000, + onDegrade = () => {}, + onRestore = () => {}, + } = {}) { + this.degradeThreshold = degradeThreshold; + this.restoreThreshold = restoreThreshold; + this.sampleWindowMs = sampleWindowMs; + this.onDegrade = onDegrade; + this.onRestore = onRestore; + + this.samples = []; + this.isDegraded = false; + this._lastTick = performance.now(); + this._rafId = null; + } + + start() { + if (this._rafId !== null) return; + if (typeof requestAnimationFrame !== 'function') return; + this._lastTick = performance.now(); + const loop = (timestamp) => { + const delta = timestamp - this._lastTick; + this._lastTick = timestamp; + if (delta > 0) { + const fps = 1000 / delta; + this.samples.push({ time: timestamp, fps }); + this._trimSamples(timestamp); + this._evaluate(); + } + this._rafId = requestAnimationFrame(loop); + }; + this._rafId = requestAnimationFrame(loop); + } + + stop() { + if (this._rafId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(this._rafId); + this._rafId = null; + } + this.samples = []; + } + + _trimSamples(timestamp) { + const cutoff = timestamp - this.sampleWindowMs; + this.samples = this.samples.filter((sample) => sample.time >= cutoff); + } + + _evaluate() { + const fpsSamples = this.samples.map((sample) => sample.fps); + const { degrade, restore } = evaluatePerformanceWindow({ + samples: fpsSamples, + degradeThreshold: this.degradeThreshold, + restoreThreshold: this.restoreThreshold, + }); + + if (degrade && !this.isDegraded) { + this.isDegraded = true; + this.onDegrade(); + } else if (restore && this.isDegraded) { + this.isDegraded = false; + this.onRestore(); + } + } +} diff --git a/src/core/performanceGuards.test.js b/src/core/performanceGuards.test.js new file mode 100644 index 0000000..f20af58 --- /dev/null +++ b/src/core/performanceGuards.test.js @@ -0,0 +1,115 @@ +import { + PerformanceGuards, + evaluatePerformanceWindow, +} from './performanceGuards.js'; + +// why this test matters: FPS safeguards gate visual downgrades for stability. +describe('evaluatePerformanceWindow', () => { + it('signals degrade when fps stays under threshold', () => { + const samples = [40, 42, 41, 39]; + expect( + evaluatePerformanceWindow({ + samples, + degradeThreshold: 50, + restoreThreshold: 58, + }) + ).toEqual({ degrade: true, restore: false }); + }); + + it('signals restore when fps recovers above threshold', () => { + const samples = [60, 61, 59, 60]; + expect( + evaluatePerformanceWindow({ + samples, + degradeThreshold: 50, + restoreThreshold: 58, + }) + ).toEqual({ degrade: false, restore: true }); + }); + + it('does nothing when fps is stable in the safe band', () => { + const samples = [55, 56, 54, 55]; + expect( + evaluatePerformanceWindow({ + samples, + degradeThreshold: 50, + restoreThreshold: 58, + }) + ).toEqual({ degrade: false, restore: false }); + }); + + it('returns neutral result when no samples are collected', () => { + expect( + evaluatePerformanceWindow({ + samples: [], + degradeThreshold: 50, + restoreThreshold: 58, + }) + ).toEqual({ degrade: false, restore: false }); + }); +}); + +// why this test matters: guards must trigger degrade/restore callbacks deterministically. +describe('PerformanceGuards', () => { + let originalNow; + + beforeEach(() => { + originalNow = global.performance.now; + global.performance.now = () => Date.now(); + }); + + afterEach(() => { + global.performance.now = originalNow; + }); + + it('invokes onDegrade when average fps drops below threshold', () => { + const degrade = jest.fn(); + const guard = new PerformanceGuards({ + degradeThreshold: 50, + restoreThreshold: 58, + onDegrade: degrade, + }); + guard.samples = [ + { time: 0, fps: 40 }, + { time: 16, fps: 45 }, + ]; + guard._evaluate(); + expect(degrade).toHaveBeenCalled(); + }); + + it('invokes onRestore when fps recovers and state is degraded', () => { + const restore = jest.fn(); + const guard = new PerformanceGuards({ + degradeThreshold: 50, + restoreThreshold: 58, + onRestore: restore, + }); + guard.isDegraded = true; + guard.samples = [ + { time: 0, fps: 60 }, + { time: 16, fps: 62 }, + ]; + guard._evaluate(); + expect(restore).toHaveBeenCalled(); + }); + it('skips starting the loop when requestAnimationFrame is unavailable', () => { + const guard = new PerformanceGuards(); + const originalRaf = global.requestAnimationFrame; + delete global.requestAnimationFrame; + guard.start(); + expect(guard._rafId).toBeNull(); + global.requestAnimationFrame = originalRaf; + }); + + it('clears internal timers when stop is called without a loop', () => { + const guard = new PerformanceGuards(); + guard.samples = [{ time: 0, fps: 55 }]; + const originalCancel = global.cancelAnimationFrame; + global.cancelAnimationFrame = jest.fn(); + guard._rafId = 42; + guard.stop(); + expect(global.cancelAnimationFrame).toHaveBeenCalledWith(42); + expect(guard.samples).toEqual([]); + global.cancelAnimationFrame = originalCancel; + }); +}); diff --git a/src/core/random.js b/src/core/random.js new file mode 100644 index 0000000..57727eb --- /dev/null +++ b/src/core/random.js @@ -0,0 +1,21 @@ +const DEFAULT_SEED = 'math-marauders'; + +export function hashSeed(seed = DEFAULT_SEED) { + let h = 2166136261 >>> 0; + for (let i = 0; i < seed.length; i += 1) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +export function createRng(seed = DEFAULT_SEED) { + let state = hashSeed(seed); + return () => { + state += 0x6d2b79f5; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/src/index.integration.test.js b/src/index.integration.test.js new file mode 100644 index 0000000..b8e3df0 --- /dev/null +++ b/src/index.integration.test.js @@ -0,0 +1,321 @@ +const SKIRMISH_DELAY_MS = 1600; +const REVERSE_DELAY_MS = 2000; +const DEFAULT_URL = '/?seed=loop-seed'; + +let originalPointerEvent; +let originalAddEventListener; +const capturedHandlers = { + pointerdown: [], + pointerup: [], + touchend: [], +}; + +async function mountApp({ url = DEFAULT_URL, enableHooks = true } = {}) { + jest.useFakeTimers(); + global.requestAnimationFrame = (callback) => + setTimeout(() => callback(Date.now()), 16); + global.cancelAnimationFrame = (id) => clearTimeout(id); + originalPointerEvent = global.PointerEvent; + global.PointerEvent = class PointerEvent extends Event { + constructor(type, init = {}) { + super(type, init); + this.pointerType = init.pointerType ?? 'mouse'; + } + }; + originalAddEventListener = Element.prototype.addEventListener; + Element.prototype.addEventListener = function (type, listener, options) { + if ( + this instanceof Element && + this.matches?.('[data-control="steering"]') && + capturedHandlers[type] + ) { + capturedHandlers[type].push(listener); + } + return originalAddEventListener.call(this, type, listener, options); + }; + Object.keys(capturedHandlers).forEach((key) => { + capturedHandlers[key] = []; + }); + if (enableHooks) { + window.__MARAUDERS_ENABLE_TEST_HOOKS__ = true; + } else { + delete window.__MARAUDERS_ENABLE_TEST_HOOKS__; + } + window.history.replaceState({}, '', url); + + document.body.innerHTML = ` +
+
+ 01:30 + 0 + + +
+
+
+
Ready to deploy
+
+ 12 + 10 +
+
+
☆ ☆ ☆
+
+
+
+
+ + +
+ +
+
+ + `; + + jest.resetModules(); + await import('./index.js'); + document.dispatchEvent(new Event('DOMContentLoaded')); +} + +function cleanupApp() { + jest.useRealTimers(); + delete global.requestAnimationFrame; + delete global.cancelAnimationFrame; + if (originalPointerEvent) { + global.PointerEvent = originalPointerEvent; + } else { + delete global.PointerEvent; + } + Element.prototype.addEventListener = originalAddEventListener; + Object.keys(capturedHandlers).forEach((key) => { + capturedHandlers[key] = []; + }); + delete window.__MARAUDERS_ENABLE_TEST_HOOKS__; + delete window.__MARAUDERS_TEST_HOOKS__; + window.history.replaceState({}, '', '/'); +} + +// why this test matters: verifies the full arcade loop wiring so UI matches run state changes. +describe('Math Marauders arcade loop', () => { + afterEach(() => { + cleanupApp(); + }); + + it('runs through a full start → gate → chase cycle', async () => { + await mountApp(); + const startButton = document.querySelector('[data-action="start"]'); + const hooks = window.__MARAUDERS_TEST_HOOKS__; + startButton.click(); + + jest.runOnlyPendingTimers(); + + const gateRail = document.querySelector('[data-panel="gates"]'); + expect(gateRail.dataset.state).toBe('active'); + expect(gateRail.children.length).toBeGreaterThan(0); + + const firstGate = gateRail.querySelector('button'); + firstGate.click(); + + jest.advanceTimersByTime(SKIRMISH_DELAY_MS); + jest.runOnlyPendingTimers(); + + jest.advanceTimersByTime(REVERSE_DELAY_MS); + jest.runOnlyPendingTimers(); + + const phaseText = document.querySelector( + '[data-panel="phase"]' + ).textContent; + expect(phaseText).toContain('Run complete'); + + const stars = document.querySelector('[data-panel="stars"]').textContent; + expect(stars.trim()).not.toEqual('☆ ☆ ☆'); + + const snapshot = hooks.getState(); + expect(snapshot.phase).toBe('end'); + }); + + it('supports pausing, overlay controls, and steering feedback', async () => { + await mountApp(); + const startButton = document.querySelector('[data-action="start"]'); + const pauseButton = document.querySelector('[data-action="pause"]'); + const overlay = document.querySelector('[data-panel="pause-overlay"]'); + + startButton.click(); + jest.runOnlyPendingTimers(); + + const gateRail = document.querySelector('[data-panel="gates"]'); + gateRail.querySelector('button').click(); + + pauseButton.click(); + expect(overlay.hidden).toBe(false); + + jest.advanceTimersByTime(SKIRMISH_DELAY_MS); + jest.runOnlyPendingTimers(); + + overlay.querySelector('[data-action="resume"]').click(); + expect(overlay.hidden).toBe(true); + + pauseButton.click(); + expect(overlay.hidden).toBe(false); + const hooks = window.__MARAUDERS_TEST_HOOKS__; + expect(hooks.toggleMute()).toBe(true); + expect(hooks.toggleMute()).toBe(false); + + overlay.querySelector('[data-action="restart"]').click(); + jest.runOnlyPendingTimers(); + expect(overlay.hidden).toBe(true); + + const slider = document.querySelector('[data-control="steering"]'); + capturedHandlers.pointerdown[0]?.(new PointerEvent('pointerdown')); + expect(slider.classList.contains('is-active')).toBe(true); + capturedHandlers.pointerup[0]?.(new PointerEvent('pointerup')); + expect(slider.classList.contains('is-active')).toBe(false); + capturedHandlers.touchend[0]?.(new Event('touchend')); + expect(slider.classList.contains('is-active')).toBe(false); + + startButton.click(); + jest.runOnlyPendingTimers(); + + const activeRail = document.querySelector('[data-panel="gates"]'); + activeRail.querySelector('button').click(); + jest.advanceTimersByTime(SKIRMISH_DELAY_MS + REVERSE_DELAY_MS); + jest.runOnlyPendingTimers(); + + pauseButton.click(); + expect(overlay.hidden).toBe(false); + + hooks.setTimer(0); + jest.advanceTimersByTime(150); + + hooks.setPhase('end'); + hooks.togglePause(); + }); + + it('concludes the run when the timer expires', async () => { + await mountApp(); + const hooks = window.__MARAUDERS_TEST_HOOKS__; + hooks.beginRun(); + hooks.setPaused(true); + jest.advanceTimersByTime(150); + hooks.setPaused(false); + hooks.setTimer(0); + jest.advanceTimersByTime(200); + const phaseText = document.querySelector( + '[data-panel="phase"]' + ).textContent; + expect(phaseText).toContain('Run complete'); + hooks.togglePause(); + hooks.clearOverlay(); + expect(hooks.toggleMute()).toBe(false); + }); + + // why this test matters: shareable seeds let designers and QA reproduce exact runs when tuning difficulty. + it('honours query seed and rotates deterministic run seeds', async () => { + await mountApp(); + const startButton = document.querySelector('[data-action="start"]'); + startButton.click(); + jest.runOnlyPendingTimers(); + + const hooks = window.__MARAUDERS_TEST_HOOKS__; + const initialState = hooks.getState(); + expect(initialState.seed).toBe('loop-seed'); + expect(window.location.search).toContain('seed=loop-seed'); + + const nextSeed = hooks.getNextSeed(); + expect(nextSeed.startsWith('seed-')).toBe(true); + + hooks.beginRun(); + jest.runOnlyPendingTimers(); + + const secondState = hooks.getState(); + expect(secondState.seed).toBe(nextSeed); + expect(window.location.search).toContain(`seed=${nextSeed}`); + + hooks.beginRun({ seed: 'manual-seed', preserveSeed: true }); + jest.runOnlyPendingTimers(); + + const manualState = hooks.getState(); + expect(manualState.seed).toBe('manual-seed'); + expect(window.location.search).toContain('seed=manual-seed'); + expect(hooks.getNextSeed()).toBe('manual-seed'); + + const originalReplaceState = window.history.replaceState; + window.history.replaceState = undefined; + + hooks.beginRun({ seed: 'guard-seed', preserveSeed: true }); + jest.runOnlyPendingTimers(); + + expect(window.location.search).toContain('seed=manual-seed'); + expect(hooks.getState().seed).toBe('guard-seed'); + + window.history.replaceState = originalReplaceState; + + hooks.beginRun(); + jest.runOnlyPendingTimers(); + + expect(window.location.search).toContain('seed=guard-seed'); + }); + + // why this test matters: fallback seeds guarantee functional runs even without share links. + it('generates a seed when query string omits one', async () => { + await mountApp({ url: '/' }); + const hooks = window.__MARAUDERS_TEST_HOOKS__; + hooks.beginRun(); + jest.runOnlyPendingTimers(); + + const runState = hooks.getState(); + expect(runState.seed).toMatch(/^seed-/); + expect(window.location.search).toContain(`seed=${runState.seed}`); + }); + + // why this test matters: HUD feedback must cover positive/negative deltas and unknown phases gracefully. + it('renders score delta polarity and phase fallback copy', async () => { + await mountApp(); + const hooks = window.__MARAUDERS_TEST_HOOKS__; + hooks.beginRun(); + jest.runOnlyPendingTimers(); + + const delta = document.querySelector('[data-hud="delta"]'); + + hooks.setScoreDelta(12); + expect(delta.hidden).toBe(false); + expect(delta.dataset.state).toBe('gain'); + expect(delta.textContent).toContain('+12'); + + hooks.setScoreDelta(-8); + expect(delta.dataset.state).toBe('loss'); + expect(delta.textContent).toContain('-8'); + + hooks.setScoreDelta(0); + expect(delta.hidden).toBe(true); + + hooks.setPhase('mystery'); + hooks.forceRender(); + const phaseText = document.querySelector( + '[data-panel="phase"]' + ).textContent; + expect(phaseText).toBe(''); + }); + + // why this test matters: ensures module loads safely without exposing internal hooks when disabled. + it('skips test hooks and trims whitespace-only seeds', async () => { + await mountApp({ url: '/?seed=%20%20', enableHooks: false }); + expect(window.__MARAUDERS_TEST_HOOKS__).toBeUndefined(); + + const startButton = document.querySelector('[data-action="start"]'); + startButton.click(); + jest.runOnlyPendingTimers(); + + const gateRail = document.querySelector('[data-panel="gates"]'); + expect(gateRail.dataset.state).toBe('active'); + expect(window.location.search).toMatch(/seed=seed-/); + }); +}); diff --git a/src/index.js b/src/index.js index ac0ebea..63eec77 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,377 @@ -console.log("Hello from index.js!"); +import { applyGate, createGateOptions } from './core/mathGates.js'; +import { + simulateSkirmish, + resolveReverseChase, + calculateStarRating, +} from './core/combatSimulator.js'; +import { PerformanceGuards } from './core/performanceGuards.js'; +import { + formatGateSymbol, + formatGateValue, + formatTimer, +} from './ui/formatting.js'; + +const RUN_DURATION_MS = 90000; +const FORWARD_WINDOW_MS = 12000; +const SKIRMISH_DELAY_MS = 1600; +const REVERSE_DELAY_MS = 2000; + +const INITIAL_STATE = (seed) => ({ + phase: 'idle', + playerCount: 12, + enemyCount: 10, + chaseStrength: 8, + score: 0, + scoreDelta: 0, + wave: 1, + gateOptions: [], + selectedGateId: null, + timerMs: RUN_DURATION_MS, + startedAt: null, + isPaused: false, + starRating: 0, + seed, + lastUpdateTimestamp: null, +}); + +const nextSeedFromUrl = readSeedFromUrl(); +let nextSeed = nextSeedFromUrl ?? createSeed(); +let state = INITIAL_STATE(nextSeed); +let timerInterval = null; +let pendingTimeouts = []; +let pausedCallbacks = []; + +const elements = {}; + +document.addEventListener('DOMContentLoaded', () => { + cacheDom(); + attachEvents(); + render(); + startPerformanceGuards(); +}); + +function cacheDom() { + elements.score = document.querySelector('[data-hud="score"]'); + elements.timer = document.querySelector('[data-hud="timer"]'); + elements.delta = document.querySelector('[data-hud="delta"]'); + elements.phase = document.querySelector('[data-panel="phase"]'); + elements.playerCount = document.querySelector('[data-panel="player-count"]'); + elements.enemyCount = document.querySelector('[data-panel="enemy-count"]'); + elements.starRating = document.querySelector('[data-panel="stars"]'); + elements.gateContainer = document.querySelector('[data-panel="gates"]'); + elements.startButton = document.querySelector('[data-action="start"]'); + elements.pauseButton = document.querySelector('[data-action="pause"]'); + elements.pauseOverlay = document.querySelector( + '[data-panel="pause-overlay"]' + ); + elements.steeringSlider = document.querySelector('[data-control="steering"]'); +} + +function attachEvents() { + elements.startButton.addEventListener('click', () => { + beginRun(); + }); + + elements.pauseButton.addEventListener('click', () => { + togglePause(); + }); + + elements.pauseOverlay + .querySelector('[data-action="resume"]') + .addEventListener('click', () => togglePause(false)); + elements.pauseOverlay + .querySelector('[data-action="restart"]') + .addEventListener('click', () => { + const currentSeed = state.seed; + togglePause(false); + beginRun({ seed: currentSeed, preserveSeed: true }); + }); + elements.pauseOverlay + .querySelector('[data-action="mute"]') + .addEventListener('click', () => { + toggleMuteOverlay(); + }); + + elements.steeringSlider.addEventListener('pointerdown', () => { + elements.steeringSlider.classList.add('is-active'); + }); + elements.steeringSlider.addEventListener('pointerup', () => { + elements.steeringSlider.classList.remove('is-active'); + }); + elements.steeringSlider.addEventListener('touchend', () => { + elements.steeringSlider.classList.remove('is-active'); + }); +} + +function beginRun({ seed, preserveSeed = false } = {}) { + clearPendingTimers(); + const runSeed = seed ?? nextSeed ?? createSeed(); + state = INITIAL_STATE(runSeed); + state.phase = 'forward'; + state.startedAt = Date.now(); + state.gateOptions = createGateOptions(runSeed, { wave: state.wave }); + state.timerMs = RUN_DURATION_MS; + state.lastUpdateTimestamp = Date.now(); + updateUrlSeed(runSeed); + nextSeed = preserveSeed ? runSeed : createSeed(); + startTimer(); + render(); +} + +function togglePause(forceState) { + if (state.phase === 'idle' || state.phase === 'end') return; + const nextPauseState = + typeof forceState === 'boolean' ? forceState : !state.isPaused; + state.isPaused = nextPauseState; + elements.pauseOverlay.hidden = !state.isPaused; + if (state.isPaused) { + clearInterval(timerInterval); + } else { + state.lastUpdateTimestamp = Date.now(); + startTimer(); + const callbacks = [...pausedCallbacks]; + pausedCallbacks = []; + callbacks.forEach((cb) => cb()); + } + render(); +} + +function startTimer() { + clearInterval(timerInterval); + timerInterval = setInterval(() => { + if (state.isPaused || state.phase === 'idle') return; + const now = Date.now(); + const delta = now - state.lastUpdateTimestamp; + state.lastUpdateTimestamp = now; + state.timerMs = Math.max(0, state.timerMs - delta); + if (state.timerMs === 0) { + concludeRun(); + clearInterval(timerInterval); + } + updateHud(); + }, 100); +} + +function render() { + updateHud(); + updatePhasePanel(); + updateGateOptions(); + updateStars(); + updateButtons(); +} + +function updateHud() { + elements.score.textContent = state.score.toLocaleString('en-US'); + elements.timer.textContent = formatTimer(state.timerMs); + if (state.scoreDelta !== 0) { + elements.delta.textContent = `${state.scoreDelta > 0 ? '+' : ''}${state.scoreDelta}`; + elements.delta.dataset.state = state.scoreDelta > 0 ? 'gain' : 'loss'; + elements.delta.hidden = false; + } else { + elements.delta.hidden = true; + } +} + +function updatePhasePanel() { + const phaseCopy = { + idle: 'Ready to deploy', + forward: 'Forward run: pick your gate!', + skirmish: 'Skirmish: volleys in flight…', + reverse: 'Reverse chase: stay ahead!', + end: 'Run complete. Tap restart to dive back in.', + }; + elements.phase.textContent = phaseCopy[state.phase] ?? ''; + elements.playerCount.textContent = `${state.playerCount}`; + elements.enemyCount.textContent = `${state.enemyCount}`; +} + +function updateGateOptions() { + elements.gateContainer.innerHTML = ''; + if (state.phase !== 'forward') { + elements.gateContainer.dataset.state = 'hidden'; + return; + } + elements.gateContainer.dataset.state = 'active'; + state.gateOptions.forEach((gate) => { + const button = document.createElement('button'); + button.className = `gate-card gate-card--${gate.op}`; + button.innerHTML = ` + ${formatGateSymbol(gate)} + ${formatGateValue(gate)} + `; + button.addEventListener('click', () => { + if (state.phase !== 'forward' || state.isPaused) return; + state.selectedGateId = gate.id; + applySelectedGate(gate); + }); + elements.gateContainer.appendChild(button); + }); +} + +function updateStars() { + const stars = Array.from({ length: 3 }, (_, index) => + index < state.starRating ? '★' : '☆' + ).join(' '); + elements.starRating.textContent = stars; +} + +function updateButtons() { + elements.startButton.textContent = + state.phase === 'idle' ? 'Start Run' : 'Restart'; +} + +function applySelectedGate(gate) { + state.playerCount = applyGate(state.playerCount, gate); + flashScoreDelta(0); + elements.gateContainer.dataset.state = 'locked'; + transitionToSkirmish(); +} + +function transitionToSkirmish() { + state.phase = 'skirmish'; + render(); + queueTimeout(() => { + const result = simulateSkirmish({ + playerCount: state.playerCount, + enemyCount: state.enemyCount, + volleyDurationMs: Math.min(FORWARD_WINDOW_MS, 3000), + }); + state.playerCount = result.playerRemaining; + state.enemyCount = result.enemyRemaining; + commitScore(result.scoreDelta); + transitionToReverse(); + }, SKIRMISH_DELAY_MS); +} + +function transitionToReverse() { + state.phase = 'reverse'; + render(); + queueTimeout(() => { + const outcome = resolveReverseChase({ + survivors: state.playerCount, + chaseStrength: state.chaseStrength, + }); + state.playerCount = Math.max(0, state.playerCount - outcome.casualties); + commitScore(outcome.bonusScore); + state.phase = 'end'; + state.starRating = calculateStarRating(state.score); + render(); + }, REVERSE_DELAY_MS); +} + +function commitScore(delta) { + state.score = Math.max(0, state.score + delta); + flashScoreDelta(delta); +} + +function flashScoreDelta(delta) { + state.scoreDelta = delta; + updateHud(); + if (delta === 0) return; + queueTimeout(() => { + state.scoreDelta = 0; + updateHud(); + }, 250); +} + +function concludeRun() { + if (state.phase === 'end') return; + state.phase = 'end'; + state.starRating = calculateStarRating(state.score); + render(); +} + +function queueTimeout(callback, delay) { + const id = setTimeout(() => { + pendingTimeouts = pendingTimeouts.filter((entry) => entry !== id); + if (!state.isPaused) { + callback(); + } else { + pausedCallbacks.push(callback); + } + }, delay); + pendingTimeouts.push(id); +} + +function clearPendingTimers() { + pendingTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); + pendingTimeouts = []; + pausedCallbacks = []; + clearInterval(timerInterval); +} + +function startPerformanceGuards() { + const guards = new PerformanceGuards({ + onDegrade: () => document.body.classList.add('is-degraded'), + onRestore: () => document.body.classList.remove('is-degraded'), + }); + guards.start(); +} + +function createSeed() { + const now = Date.now(); + const random = Math.floor(Math.random() * 100000); + return `seed-${now}-${random}`; +} + +function readSeedFromUrl() { + try { + const params = new URLSearchParams(globalThis.location.search); + const seed = params.get('seed'); + if (!seed) return null; + const trimmed = seed.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} + +function updateUrlSeed(seed) { + const historyApi = globalThis.history; + if (!historyApi || typeof historyApi.replaceState !== 'function') { + return; + } + try { + const url = new URL(globalThis.location.href); + const sanitized = String(seed).trim(); + url.searchParams.set('seed', sanitized); + historyApi.replaceState({}, '', url.toString()); + } catch { + // ignore URL parsing issues in unsupported environments + } +} + +if (typeof window !== 'undefined' && window.__MARAUDERS_ENABLE_TEST_HOOKS__) { + window.__MARAUDERS_TEST_HOOKS__ = { + beginRun: (options) => beginRun(options), + togglePause, + toggleMute: () => toggleMuteOverlay(), + setPhase: (phase) => { + state.phase = phase; + }, + setTimer: (value) => { + state.timerMs = value; + }, + clearOverlay: () => { + elements.pauseOverlay = null; + }, + setPaused: (value) => { + state.isPaused = value; + }, + getState: () => ({ ...state }), + getNextSeed: () => nextSeed, + setScoreDelta: (value) => { + state.scoreDelta = value; + updateHud(); + }, + forceRender: () => { + render(); + }, + }; +} + +function toggleMuteOverlay() { + if (!elements.pauseOverlay) return false; + elements.pauseOverlay.classList.toggle('is-muted'); + return elements.pauseOverlay.classList.contains('is-muted'); +} diff --git a/src/ui/formatting.js b/src/ui/formatting.js new file mode 100644 index 0000000..5efc1b4 --- /dev/null +++ b/src/ui/formatting.js @@ -0,0 +1,30 @@ +export function formatGateSymbol(gate) { + switch (gate.op) { + case 'add': + return '+'; + case 'subtract': + return '−'; + case 'multiply': + return '×'; + case 'divide': + return '÷'; + default: + return '?'; + } +} + +export function formatGateValue(gate) { + if (gate.op === 'multiply' || gate.op === 'divide') { + return gate.value.toFixed(1); + } + return gate.value.toString(); +} + +export function formatTimer(ms) { + const totalSeconds = Math.ceil(ms / 1000); + const minutes = Math.floor(totalSeconds / 60) + .toString() + .padStart(2, '0'); + const seconds = (totalSeconds % 60).toString().padStart(2, '0'); + return `${minutes}:${seconds}`; +} diff --git a/src/ui/formatting.test.js b/src/ui/formatting.test.js new file mode 100644 index 0000000..0d17fd5 --- /dev/null +++ b/src/ui/formatting.test.js @@ -0,0 +1,27 @@ +import { + formatGateSymbol, + formatGateValue, + formatTimer, +} from './formatting.js'; + +// why this test matters: HUD formatting must stay consistent for readability. +describe('formatting helpers', () => { + it('maps operators to symbols with fallback', () => { + expect(formatGateSymbol({ op: 'add' })).toBe('+'); + expect(formatGateSymbol({ op: 'subtract' })).toBe('−'); + expect(formatGateSymbol({ op: 'multiply' })).toBe('×'); + expect(formatGateSymbol({ op: 'divide' })).toBe('÷'); + expect(formatGateSymbol({ op: 'boost' })).toBe('?'); + }); + + it('formats gate values by operator type', () => { + expect(formatGateValue({ op: 'add', value: 7 })).toBe('7'); + expect(formatGateValue({ op: 'multiply', value: 1.25 })).toBe('1.3'); + expect(formatGateValue({ op: 'divide', value: 2.0 })).toBe('2.0'); + }); + + it('formats timers as mm:ss', () => { + expect(formatTimer(90000)).toBe('01:30'); + expect(formatTimer(0)).toBe('00:00'); + }); +});