diff --git a/.pi/.goals-pool-snapshot.json b/.pi/.goals-pool-snapshot.json index 8577458..6f4fecb 100644 --- a/.pi/.goals-pool-snapshot.json +++ b/.pi/.goals-pool-snapshot.json @@ -1 +1 @@ -{"version":1,"dirMtimeMs":1789621684232.1963,"goals":[]} \ No newline at end of file +{"version":1,"dirMtimeMs":1789829735005.69,"goals":[{"id":"mu7ieab2-f24hte","objective":"Build the Ship Screen console — opened by the command deck's SHIP slot in the main game (GameScene), on the MapWindow bones (left looping video panel reading assets/videos/ship.mp4 via config with a skip-if-missing guard, right column of tabs) — and deliver two fully working tabs now: \"Ship Status\" (a good-looking stat plate covering hull integrity/current hull strength, shields/shield recharge rate & delay, acceleration/max speed, fire rate, damage, cargo bays, mineral storage, mining speed + combat level with a progress bar toward the next level and a timeline-style presentation of the player's journey from level 0 to level 5 that swaps to a \"Choose your Class\" button at level 5) and \"Cargo Holds\" (a summary of goods aboard by bay plus aggregate minerals aboard vs capacity, where selecting a held good shows its product summary and top charted exits in the same style as the planet buy/sell screens); plus a locked/OFFLINE \"Skill Tree\" tab so the final structure is visible. Success = deck-press opens/closes the console exclusively with the other consoles, both live tabs render correct real run data at multiple forced states, screenshots of every tab/state are reviewed, and `node dev/run-all.mjs` passes with a new Ship test registered in it.","status":"paused","autoContinue":false,"usage":{"tokensUsed":1569625,"activeSeconds":19608},"sisyphus":false,"revision":383,"createdAt":"2026-09-18T22:07:23.053Z","updatedAt":"2026-09-19T14:55:35.006Z","scheduler":{"version":1,"owner":"01a0b67f-b950-7128-bde1-b1647328e168","generation":"e38a024d-7b6d-4c97-91a3-728e8b1479a3","used":2,"phase":"idle","repairUsed":false},"taskList":{"tasks":[{"id":"task-1","title":"Ship console shell: data/shipScreen.json + ShipWindow.js + deck wiring (video panel, tab bar with locked Skill Tree, open/close/exclusivity)","status":"complete","verificationContract":"data/shipScreen.json exists and is listed in data/manifest.json (layout, colors, copy, video {file:'assets/videos/ship.mp4'}, tab order, stat-source table all data-driven); js/ui/ShipWindow.js follows the MapWindow contract (static VIDEO_KEY, isOpen, update(time), depth 80, cut-corner plate, decoded title, ✕ close, ESC/scrim close, glitch/SFX voice); GameScene preloads the clip with the same skip-if-missing guard as map/quests and boots clean with assets/videos/ship.mp4 ABSENT (no console errors, procedural plate shows instead); deckAction('ship') toggles the console and closes/closed-by research, quests, map and the sub-bar/comms panel exactly like 'map'; SurfaceScene untouched. Evidence: node --check on touched files + browser/dev-page boot log showing no errors with the mp4 missing.","completedAt":"2026-09-19T01:02:52.692Z","evidence":"node --check clean on ShipWindow/Model/GameScene/ship-shot; headless boot (mp4 absent) report \"SMOKE OK — no console errors\", video: NO SIGNAL plate, window open/tab switch ok; deckAction exclusivity "},{"id":"task-2","title":"Ship Status tab — stat readout plate (hull, current hull strength, shields, shield recharge rate/delay, acceleration, max speed, fire rate, damage, cargo/mining) driven by real run state","status":"complete","verificationContract":"Every requested stat renders from its live source of truth — data/ship.json → stats (hullIntegrity, shields, cargoHold, mineralStorage, miningSpeed), data/combat.json → playerShip (shieldRegenRate/shieldRegenDelay) and player.weapon (cooldown→shots/sec, damage, bulletSpeed), plus flight feel from ship.thrust/maxSpeed — with NO hardcoded numbers in code (labels/units/order/format come from data/shipScreen.json); deltas from research/builds are reflected where they already mutate ship.stats; hull/shield read the live combat session (this.combat.hull/.shields) while combat is active and otherwise show current max with an explicit 'nominal' state per the confirmed decision; values refresh while open. Evidence: pure test asserts every configured stat row resolves to a finite number from real data files + screenshot of the tab.","completedAt":"2026-09-19T04:54:26.328Z","evidence":"dev/ship-window.test.mjs: every configured row resolves finite from real data; fireRate=1/cd, DPS=damage/cd, accel=ship.thrust, recharge=combat.playerShield. Screenshots reviewed: nominal (HULL 100/CU"},{"id":"task-3","title":"Combat level + XP progress bar and the level 0→5 journey timeline, with the level-5 'Choose your Class' state seam","status":"complete","verificationContract":"Level, xp, xpForNext and progress read GameScene.combat.xp (XpManager.stats()) — no parallel ledger; segmented 0→5 timeline renders milestone nodes with the in-between progress fill, current-level marker, and per-milestone labels/copy from data/shipScreen.json; bar shows partial progress to next level; at level ≥5 the timeline is REPLACED by a 'Choose your Class' button that fires a documented onChooseClass seam (visible placeholder response, no dead click) and the class+insignia display slot exists as an empty data-driven seam (selection UI explicitly out of scope); maxLevel 80 in data/progression.json stays untouched. Evidence: pure test drives XpManager through levels 0..5 asserting timeline-vs-button state flips exactly at 5; screenshots at forced levels 0, 2 partial, 5.","completedAt":"2026-09-19T05:04:37.254Z","evidence":"dev/ship-window.test.mjs drives real XpManager: levels 0-4 → timeline, 5 & 6 → CHOOSE YOUR CLASS door, class on file → class plate (mode flips exactly at data classAt=5; maxLevel untouched). Screensho"},{"id":"task-4","title":"Cargo Holds tab — hold summary (bay grid with filled/locked bays, good icons + names) and aggregate minerals aboard vs capacity","status":"complete","verificationContract":"Renders ship.cargo (CargoHold: one good per bay, fill order) against unlocked bays from ship.stats.cargoHold and economy.cargo.maxSlots, showing used/room counts plus locked-bay upgrade room; minerals shown as aggregate only — ship.minerals vs ship.stats.mineralStorage with a fill bar and % (no new mineral data model), matching what MineralHud reads; empty-hold state reads as an intentional empty plate, not a broken layout; updates live after buys/sells/mining while the console is open. Evidence: pure test asserts summary math for edge states (0 bays, full hold, minerals 0/at cap) + screenshots of those states.","completedAt":"2026-09-19T05:21:51.901Z","evidence":"Screenshots reviewed at 1920x1080: empty hold (\"HOLD EMPTY — EVERY BAY IS CLEAN\", EMPTY tiles, +12 LOCKED pips, 0% / NO ORE ABOARD) and loaded (icons+names, MANIFEST tags, \"4/6 BAYS · 2 FREE\", +10 LOC"},{"id":"task-5","title":"Cargo item detail — selected bay shows the good's product summary and top charted exits in the buy/sell style","status":"complete","verificationContract":"Selecting a filled bay opens a detail panel with icon, name, category/tier, flavor, credits value, origin/producer/demand classes and the ranked top-N charted exits (price + per-unit profit bar) built from js/economy/TradeScout.js scoutGood() using the run's galaxy, current system and visitedSystems — reusing ScoutPanel.js or its rendering recipe rather than duplicating economy logic; since there is no local shop on this screen, the profit baseline is documented and data-driven (goods.json baseValue / Economy buy price) and asserted by test; de-selected state restores the hold summary; unknown/goods-less bays degrade gracefully. Evidence: pure test asserts scout report fields for a held good against real data/goods.json + screenshots with a good selected.","completedAt":"2026-09-19T06:10:11.477Z","evidence":"Selecting a filled bay opens BAY MANIFEST (js/ui/ShipWindow.js _paintManifest): goods icon frame, name, CLASS/TIER/BASE VALUE chips (labels from data), flavor, MADE IN origin|hub pool + producer class"},{"id":"task-6","title":"Verification pass — new dev/ship-window.test.mjs in run-all, cdp-shot screenshot harness per tab/state, full suite green","status":"pending","verificationContract":"New dev/ship-window.test.mjs runs under bare node and is picked up by dev/run-all.mjs; new dev/ship-shot.html boots the game and opens the console (with a documented way to force cargo/minerals/level for shots), captured via dev/cdp-shot.mjs for: Ship Status at levels 0 / mid-progress / level 5 (button state), Cargo Holds empty + loaded, and cargo item detail; screenshots inspected for layout overlap, off-screen text, dark-on-dark copy and tab-state legibility; `node dev/run-all.mjs` finishes with zero failures; no changes to committed game behavior outside the new console beyond the deckAction('ship') seam; work left unstaged with a suggested commit message (Brian commits)."}],"blockCompletion":true,"proposedAt":"2026-09-18T21:58:39.258Z"},"activePath":".pi/goals/active_goal_2026091816072305_mu7ieab2-f24hte.md","stopReason":"user"}]} \ No newline at end of file diff --git a/.pi/goals/.goal-ledger-checkpoint.json b/.pi/goals/.goal-ledger-checkpoint.json index 07b1908..a6bcf7e 100644 --- a/.pi/goals/.goal-ledger-checkpoint.json +++ b/.pi/goals/.goal-ledger-checkpoint.json @@ -1 +1 @@ -{"version":3,"format":"goal-ledger-checkpoint","createdAt":"2026-09-17T05:08:04.224Z","coveredBytes":23879,"coveredEvents":28,"acc":{"goals":[{"goalId":"mu3lln4y-51f5mi","latestStatus":"active","latestFocus":false,"latestAuditorResult":{"verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"},"createdAt":"2026-09-16T04:26:00.418Z"},{"goalId":"mu4xefwj-ebnq8h","latestStatus":"active","latestFocus":false,"latestAuditorResult":{"verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"},"createdAt":"2026-09-17T02:44:06.019Z"}],"terminalGoals":[],"focusedGoalId":null,"focusGeneration":0,"focusGenByGoal":[]},"recentEventsByGoal":[["mu3lln4y-51f5mi",[{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-1","at":"2026-09-16T04:26:15.002Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-1","evidence":"Audit doc at .pi/goals/combat-feedback-audit.md; baseline node dev/combat.test.mjs passes (all checks green)","at":"2026-09-16T04:33:51.611Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-2","at":"2026-09-16T04:35:30.972Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-2","evidence":"Committed 5949bac: pure handleFeel (exact integration) + combat.player tuning (thrust 2000/drag 1.8/rot 6.4/brake 0.9); WASD fixed (addKeys comma-list bug → event API); dev/combat.test.mjs 0 failures;","at":"2026-09-16T06:19:40.253Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-3","evidence":"Committed: shared js/combat/CombatSfx.js (7 distinct voices from existing sfx.json: fire=scan, impact=ui_click, shield=solar_buzz, hull=mining_split, kill=thunder×4 random, down=deconstruct, respawn=d","at":"2026-09-16T06:48:29.383Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-4","evidence":"Committed ecff203: Fx.js gains damage() (cyan=shield/orange=hull numbers), color-coded hit flashes, enhanced kill burst (delayed 2nd shockwave), down() + respawn() cues, shared FX_COLORS. Both context","at":"2026-09-16T07:07:02.597Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-5","evidence":"Commit 0d51184: CombatHud shared (lock reticle, threat cues, off-screen arrows, kill feed, gauge flash); pickTarget/feedPush/offscreenArrow in Combat.js; resyncAroundShip fixes main-game pack placemen","at":"2026-09-16T08:03:23.498Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-6","at":"2026-09-16T08:03:51.673Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-6","evidence":"combat.test.mjs 96 checks + sfx.test.mjs 21 checks = 0 failures; sfxcheck PASS both contexts (7 events); gauge-flash/lock/threat/feed verified in both; combat-shot.mjs → dev/shots/combat-range.png + c","at":"2026-09-16T08:59:41.075Z"},{"type":"completion_requested","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.729Z"},{"type":"audit_started","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.730Z"},{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"}]],["mu4xefwj-ebnq8h",[{"type":"task_list_set","goalId":"mu4xefwj-ebnq8h","taskCount":4,"blockCompletion":false,"at":"2026-09-17T02:44:06.019Z"},{"type":"task_started","goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T02:59:45.101Z"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Bolt texture (128x32 gradient trail+core+bloom) + white core head in js/combat/Bullet.js; fx.muzzle in CombatScene/CombatSystem; boltLength/boltWidth/muzzle knobs in data/combat.json; verified in /com"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-2","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.enemyHit: core flash, dir sparks along travel vector, craft white tintFill flash, cyan arc ripple (shield) vs orange shards (hull), damage-number pop; called identically in CombatScene.collide + Co"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-3","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.shieldImpact: sphere outline + arc centered on bearing (travel+PI), ripple, sparks, k-scaled by damage, 520ms decay; shieldBreak implosion+shockwave; rams use same treatment with bearing; verified "},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-4","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"combatShake on hit/kill/break; live probe: main cam shakeEffect isRunning=true duration=230, fx-ui cam isRunning=false; knobs in data/combat.json fx.shake/fx.shield; node dev/combat.test.mjs = 146 che"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.875Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.876Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"disapproved","report":"The headless browser is already up. Let me run my own live probe against both contexts to verify boot, the FX paths, and the camera-shake split.\n\nI found some console errors in the probe (a `setTintFill` deprecation and a `wallClamp` TypeError). Let me investigate both.\n\n## Audit report\n\nI verified the goal against the real artifacts, including live browser probes of both contexts (dev server + CDP). Much of the visual work is genuinely present, but the implementation contains a fatal regression in the shared FX path that breaks \"both contexts boot and play\" and silently fails one explicit success criterion.\n\n### Verified as delivered\n- **Bolts in both contexts** — `js/combat/Bullet.js` builds a 128×32 canvas bolt (fading trail band + core streak + head bloom) with an untinted white core dot riding the tip; tinted cyan (`#00e5ff`) / magenta-pink (`#ff2d6f`) from `data/combat.json` weapon colors; `boltLength/boltWidth` knobs read and applied (live probe: `boltScale [54,12]`, textures present, bolts visible in both committed screenshots).\n- **Directional muzzle flash** — `Fx.muzzle` (bore spike + bloom + cross glint, rotated to firing axis) called at every fire site in `CombatScene.firePlayer/fireEnemy` and `CombatSystem.firePlayerAt/fireEnemy`.\n- **Enemy impact read** — `Fx.enemyHit`: core flash, sparks along the travel vector, cyan arc + ring on shield hits vs orange shards + tight ring on hull, damage-number pop with scale-settle. Live-verified on the range (`arcFlash`, `ring`, 17 sparks, `tintFlash`, `text` all spawned).\n- **Shield-sphere hit from the laser's bearing** — `Fx.shieldImpact`: full-sphere outline + bright arc centered on the bearing (live check: bearing 135° → texture rotated exactly 135°), ripple, bearing-biased sparks, damage-scaled `k`, 520 ms decay; `shieldBreak` collapse cue (implosion + shockwave + shedding) fired on shield→hull break; rams route through the same bearing-based `damagePlayer`. Both contexts call the identical shared methods.\n- **Shake** — `combatShake` on hit/kill/break with `fx.shake` knobs (0.0045/0.007/0.011, 230 ms); live probe: world camera shake running, `fx-ui` camera shake inactive.\n- **Tuning surface, tests, constraints** — all knobs in `data/combat.json`; `node dev/combat.test.mjs` = 146 checks, 0 failures; no new dependencies (Phaser vendored), no build step, hand-rolled FX pattern (no emitters); depths in the 19–22 band, ships 10, HUD ≥60–120.\n\n### Blocking findings (verified live, not speculation)\n1. **`Fx.update` destroys the ships.** The expiry branch unconditionally calls `it.img.destroy()` — but `craftFlash` pushes items whose `img` is the ship itself (`kind:'tintFlash'`, 150–160 ms). Evidence:\n - Unit: real `Fx` class, `shieldImpact` + `fx.update(700ms)` → `shipDestroyedAfterShieldImpactExpire: true`.\n - Range: runtime stack captured `Ship.destroy ← Fx.update (Fx.js:599)`; after one non-lethal hit the enemy craft is gone 150 ms later with `dead:false` (no kill FX, no respawn bookkeeping — a ghost), then `CombatScene.update` throws `TypeError: Cannot read properties of undefined (reading 'velocity')` (`wallClamp`/`updatePlayer`) and the game loop dies. I reproduced this on a fresh page with no interaction beyond letting the pack fight.\n - Main game: the moment `combat.update` stepped, all 21 FX items — including the player ship's `tintFlash` — were consumed/destroyed.\n This contradicts \"both contexts boot and play\", \"no regressions\", and corrupts hit/kill/respawn bookkeeping in both contexts.\n2. **\"Visible white hit-flash on the enemy craft\" is not delivered.** In this vendored Phaser 4.2.1 build, `setTintFill` is a removed no-op that only `console.error`s (verified in `lib/phaser.min.js`: `` setTintFill:function(){console.error(...)}``). So `craftFlash` sets `setTint(0xffffff)` (invisible on an untinted sprite), the FILL mode is never applied, and the fallback bloom never triggers (nothing throws). Every hit spams a console error; the required white craft flash is absent.\n\n### Minor observations\n- Three untracked, unreferenced MP3s under `assets/fx/` (not wired into `data/sfx.json` or `CombatSfx.js`) — noise against the \"audio changes out of scope\" boundary.\n- In my main-game probes the `GameScene.update` loop was idle (tutorial comms hold), and the committed main-game screenshot shows a straight line of static bolts — the \"main game plays\" evidence is weaker than the range evidence, independent of the defects above.\n\nThe laser/impact/shield-sphere visuals are mostly real and well-built, but the shared `Fx` class killing the hit craft on flash expiry is a fatal, reproducible regression in the range (and a latent player-ship destroy in the main game), and the white hit-flash requirement is silently unmet.\n\n","at":"2026-09-17T04:42:45.235Z"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.034Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.036Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"}]]],"runtimeIndex":[["mu3lln4y-51f5mi",{"recent":[{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-1","at":"2026-09-16T04:26:15.002Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-1","evidence":"Audit doc at .pi/goals/combat-feedback-audit.md; baseline node dev/combat.test.mjs passes (all checks green)","at":"2026-09-16T04:33:51.611Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-2","at":"2026-09-16T04:35:30.972Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-2","evidence":"Committed 5949bac: pure handleFeel (exact integration) + combat.player tuning (thrust 2000/drag 1.8/rot 6.4/brake 0.9); WASD fixed (addKeys comma-list bug → event API); dev/combat.test.mjs 0 failures;","at":"2026-09-16T06:19:40.253Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-3","evidence":"Committed: shared js/combat/CombatSfx.js (7 distinct voices from existing sfx.json: fire=scan, impact=ui_click, shield=solar_buzz, hull=mining_split, kill=thunder×4 random, down=deconstruct, respawn=d","at":"2026-09-16T06:48:29.383Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-4","evidence":"Committed ecff203: Fx.js gains damage() (cyan=shield/orange=hull numbers), color-coded hit flashes, enhanced kill burst (delayed 2nd shockwave), down() + respawn() cues, shared FX_COLORS. Both context","at":"2026-09-16T07:07:02.597Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-5","evidence":"Commit 0d51184: CombatHud shared (lock reticle, threat cues, off-screen arrows, kill feed, gauge flash); pickTarget/feedPush/offscreenArrow in Combat.js; resyncAroundShip fixes main-game pack placemen","at":"2026-09-16T08:03:23.498Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-6","at":"2026-09-16T08:03:51.673Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-6","evidence":"combat.test.mjs 96 checks + sfx.test.mjs 21 checks = 0 failures; sfxcheck PASS both contexts (7 events); gauge-flash/lock/threat/feed verified in both; combat-shot.mjs → dev/shots/combat-range.png + c","at":"2026-09-16T08:59:41.075Z"},{"type":"completion_requested","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.729Z"},{"type":"audit_started","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.730Z"},{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"}],"activity":[{"type":"goal_created","goalId":"mu3lln4y-51f5mi","objective":"=== Goal ===\nObjective: Make Orbit's combat both feel and read — sharper ship handling plus clear hit/kill/death feedback (VFX + SFX) and a readability layer (target indicator, damage numbers, kill feed, bar hit-flash), kept in sync across the training range (/combat) and the main-game combat (index.html?combat).\n\nSuccess criteria (observable):\n- In BOTH contexts, each of: firing, bullet impact, shield hit, hull hit, enemy kill, player-down, and player-respawn produces a distinct VFX and a distinct SFX.\n- Readability layer is live in both contexts: damage numbers on every hit (enemy and player), a target/lock indicator on the current target, threat cues for hostiles, a kill feed, and a visible hit-flash on the combat bar's shields/hull gauges.\n- Ship handling feels noticeably better in the range (WASD) — responsive thrust/rotation/brake, all values living in data/combat.json and shared with the main game.\n- node dev/combat.test.mjs passes with 0 failures; CDP screenshots (dev/cdp-shot.mjs pattern) show the new feedback in both /combat and index.html?combat; the README combat section documents the new feedback set and dev hooks.\n\nBoundaries:\n- IN SCOPE: js/combat/* (Combat.js, CombatSystem.js, CombatScene.js, Fx.js, EnemyShip.js, Bullet.js), js/ui/CombatBar.js, data/combat.json, SFX wiring, both combat contexts.\n- OUT OF SCOPE: real skill implementations (the 1–8 seats stay as stubs on the system's cast() stub), new enemy types/AI/behavior variety, waves/difficulty/escalation, permanent death or economy flows, non-combat game systems (map, jump gates, economy), new sprite art.\n\nConstraints:\n- Phaser 4 UMD + plain ES6 modules, no build step; hand-rolled circle collision checks only (no arcade colliders), per the existing combat architecture.\n- No new binary assets: VFX must be procedural (canvas dot textures / additive glows, Fx.js style); SFX must go through the existing data/sfx.json + playSfx() infrastructure using existing sound files only.\n- Pure rules stay in js/combat/Combat.js (pure, unit-tested); ALL tuning numbers live in data/combat.json; the range and the main game must share the same rules and numbers (no divergent copies).\n- Skill seats 1–8, the energy gauge, and the auto-fire model are left as-is (stub/placeholder behavior unchanged).\n\nVerification contract:\n1) node dev/combat.test.mjs → 0 failures. 2) CDP-driven visual checks of BOTH /combat and index.html?combat (dev/cdp-shot.mjs pattern) capturing: damage numbers, target indicator, kill feed, bar hit-flash, kill burst, player-down/respawn cues. 3) Manual flow checklist passed in both contexts: fire → muzzle flash + shot SFX; enemy hit → impact flash + damage number; shield/hull hit → bar flash + appropriate SFX; enemy kill → burst + kill-feed entry + explosion SFX; player down → down cue + respawn cue. 4) Re-read this goal's success criteria and confirm each is addressed before marking complete.\n\nIf blocked: stop and ask the user (default blocker rule).","sisyphus":false,"autoContinue":true,"at":"2026-09-16T04:26:00.418Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-1","at":"2026-09-16T04:26:15.002Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-1","evidence":"Audit doc at .pi/goals/combat-feedback-audit.md; baseline node dev/combat.test.mjs passes (all checks green)","at":"2026-09-16T04:33:51.611Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-2","at":"2026-09-16T04:35:30.972Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-2","evidence":"Committed 5949bac: pure handleFeel (exact integration) + combat.player tuning (thrust 2000/drag 1.8/rot 6.4/brake 0.9); WASD fixed (addKeys comma-list bug → event API); dev/combat.test.mjs 0 failures;","at":"2026-09-16T06:19:40.253Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-3","evidence":"Committed: shared js/combat/CombatSfx.js (7 distinct voices from existing sfx.json: fire=scan, impact=ui_click, shield=solar_buzz, hull=mining_split, kill=thunder×4 random, down=deconstruct, respawn=d","at":"2026-09-16T06:48:29.383Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-4","evidence":"Committed ecff203: Fx.js gains damage() (cyan=shield/orange=hull numbers), color-coded hit flashes, enhanced kill burst (delayed 2nd shockwave), down() + respawn() cues, shared FX_COLORS. Both context","at":"2026-09-16T07:07:02.597Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-5","evidence":"Commit 0d51184: CombatHud shared (lock reticle, threat cues, off-screen arrows, kill feed, gauge flash); pickTarget/feedPush/offscreenArrow in Combat.js; resyncAroundShip fixes main-game pack placemen","at":"2026-09-16T08:03:23.498Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-6","at":"2026-09-16T08:03:51.673Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-6","evidence":"combat.test.mjs 96 checks + sfx.test.mjs 21 checks = 0 failures; sfxcheck PASS both contexts (7 events); gauge-flash/lock/threat/feed verified in both; combat-shot.mjs → dev/shots/combat-range.png + c","at":"2026-09-16T08:59:41.075Z"},{"type":"completion_requested","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.729Z"},{"type":"audit_started","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.730Z"},{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"}],"oracle":[],"lifecycle":{"type":"goal_created","goalId":"mu3lln4y-51f5mi","objective":"=== Goal ===\nObjective: Make Orbit's combat both feel and read — sharper ship handling plus clear hit/kill/death feedback (VFX + SFX) and a readability layer (target indicator, damage numbers, kill feed, bar hit-flash), kept in sync across the training range (/combat) and the main-game combat (index.html?combat).\n\nSuccess criteria (observable):\n- In BOTH contexts, each of: firing, bullet impact, shield hit, hull hit, enemy kill, player-down, and player-respawn produces a distinct VFX and a distinct SFX.\n- Readability layer is live in both contexts: damage numbers on every hit (enemy and player), a target/lock indicator on the current target, threat cues for hostiles, a kill feed, and a visible hit-flash on the combat bar's shields/hull gauges.\n- Ship handling feels noticeably better in the range (WASD) — responsive thrust/rotation/brake, all values living in data/combat.json and shared with the main game.\n- node dev/combat.test.mjs passes with 0 failures; CDP screenshots (dev/cdp-shot.mjs pattern) show the new feedback in both /combat and index.html?combat; the README combat section documents the new feedback set and dev hooks.\n\nBoundaries:\n- IN SCOPE: js/combat/* (Combat.js, CombatSystem.js, CombatScene.js, Fx.js, EnemyShip.js, Bullet.js), js/ui/CombatBar.js, data/combat.json, SFX wiring, both combat contexts.\n- OUT OF SCOPE: real skill implementations (the 1–8 seats stay as stubs on the system's cast() stub), new enemy types/AI/behavior variety, waves/difficulty/escalation, permanent death or economy flows, non-combat game systems (map, jump gates, economy), new sprite art.\n\nConstraints:\n- Phaser 4 UMD + plain ES6 modules, no build step; hand-rolled circle collision checks only (no arcade colliders), per the existing combat architecture.\n- No new binary assets: VFX must be procedural (canvas dot textures / additive glows, Fx.js style); SFX must go through the existing data/sfx.json + playSfx() infrastructure using existing sound files only.\n- Pure rules stay in js/combat/Combat.js (pure, unit-tested); ALL tuning numbers live in data/combat.json; the range and the main game must share the same rules and numbers (no divergent copies).\n- Skill seats 1–8, the energy gauge, and the auto-fire model are left as-is (stub/placeholder behavior unchanged).\n\nVerification contract:\n1) node dev/combat.test.mjs → 0 failures. 2) CDP-driven visual checks of BOTH /combat and index.html?combat (dev/cdp-shot.mjs pattern) capturing: damage numbers, target indicator, kill feed, bar hit-flash, kill burst, player-down/respawn cues. 3) Manual flow checklist passed in both contexts: fire → muzzle flash + shot SFX; enemy hit → impact flash + damage number; shield/hull hit → bar flash + appropriate SFX; enemy kill → burst + kill-feed entry + explosion SFX; player down → down cue + respawn cue. 4) Re-read this goal's success criteria and confirm each is addressed before marking complete.\n\nIf blocked: stop and ask the user (default blocker rule).","sisyphus":false,"autoContinue":true,"at":"2026-09-16T04:26:00.418Z"},"lastActivityEvent":{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"},"completion":{"type":"completion_requested","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.729Z"},"audit":{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"}}],["mu4xefwj-ebnq8h",{"recent":[{"type":"task_list_set","goalId":"mu4xefwj-ebnq8h","taskCount":4,"blockCompletion":false,"at":"2026-09-17T02:44:06.019Z"},{"type":"task_started","goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T02:59:45.101Z"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Bolt texture (128x32 gradient trail+core+bloom) + white core head in js/combat/Bullet.js; fx.muzzle in CombatScene/CombatSystem; boltLength/boltWidth/muzzle knobs in data/combat.json; verified in /com"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-2","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.enemyHit: core flash, dir sparks along travel vector, craft white tintFill flash, cyan arc ripple (shield) vs orange shards (hull), damage-number pop; called identically in CombatScene.collide + Co"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-3","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.shieldImpact: sphere outline + arc centered on bearing (travel+PI), ripple, sparks, k-scaled by damage, 520ms decay; shieldBreak implosion+shockwave; rams use same treatment with bearing; verified "},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-4","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"combatShake on hit/kill/break; live probe: main cam shakeEffect isRunning=true duration=230, fx-ui cam isRunning=false; knobs in data/combat.json fx.shake/fx.shield; node dev/combat.test.mjs = 146 che"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.875Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.876Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"disapproved","report":"The headless browser is already up. Let me run my own live probe against both contexts to verify boot, the FX paths, and the camera-shake split.\n\nI found some console errors in the probe (a `setTintFill` deprecation and a `wallClamp` TypeError). Let me investigate both.\n\n## Audit report\n\nI verified the goal against the real artifacts, including live browser probes of both contexts (dev server + CDP). Much of the visual work is genuinely present, but the implementation contains a fatal regression in the shared FX path that breaks \"both contexts boot and play\" and silently fails one explicit success criterion.\n\n### Verified as delivered\n- **Bolts in both contexts** — `js/combat/Bullet.js` builds a 128×32 canvas bolt (fading trail band + core streak + head bloom) with an untinted white core dot riding the tip; tinted cyan (`#00e5ff`) / magenta-pink (`#ff2d6f`) from `data/combat.json` weapon colors; `boltLength/boltWidth` knobs read and applied (live probe: `boltScale [54,12]`, textures present, bolts visible in both committed screenshots).\n- **Directional muzzle flash** — `Fx.muzzle` (bore spike + bloom + cross glint, rotated to firing axis) called at every fire site in `CombatScene.firePlayer/fireEnemy` and `CombatSystem.firePlayerAt/fireEnemy`.\n- **Enemy impact read** — `Fx.enemyHit`: core flash, sparks along the travel vector, cyan arc + ring on shield hits vs orange shards + tight ring on hull, damage-number pop with scale-settle. Live-verified on the range (`arcFlash`, `ring`, 17 sparks, `tintFlash`, `text` all spawned).\n- **Shield-sphere hit from the laser's bearing** — `Fx.shieldImpact`: full-sphere outline + bright arc centered on the bearing (live check: bearing 135° → texture rotated exactly 135°), ripple, bearing-biased sparks, damage-scaled `k`, 520 ms decay; `shieldBreak` collapse cue (implosion + shockwave + shedding) fired on shield→hull break; rams route through the same bearing-based `damagePlayer`. Both contexts call the identical shared methods.\n- **Shake** — `combatShake` on hit/kill/break with `fx.shake` knobs (0.0045/0.007/0.011, 230 ms); live probe: world camera shake running, `fx-ui` camera shake inactive.\n- **Tuning surface, tests, constraints** — all knobs in `data/combat.json`; `node dev/combat.test.mjs` = 146 checks, 0 failures; no new dependencies (Phaser vendored), no build step, hand-rolled FX pattern (no emitters); depths in the 19–22 band, ships 10, HUD ≥60–120.\n\n### Blocking findings (verified live, not speculation)\n1. **`Fx.update` destroys the ships.** The expiry branch unconditionally calls `it.img.destroy()` — but `craftFlash` pushes items whose `img` is the ship itself (`kind:'tintFlash'`, 150–160 ms). Evidence:\n - Unit: real `Fx` class, `shieldImpact` + `fx.update(700ms)` → `shipDestroyedAfterShieldImpactExpire: true`.\n - Range: runtime stack captured `Ship.destroy ← Fx.update (Fx.js:599)`; after one non-lethal hit the enemy craft is gone 150 ms later with `dead:false` (no kill FX, no respawn bookkeeping — a ghost), then `CombatScene.update` throws `TypeError: Cannot read properties of undefined (reading 'velocity')` (`wallClamp`/`updatePlayer`) and the game loop dies. I reproduced this on a fresh page with no interaction beyond letting the pack fight.\n - Main game: the moment `combat.update` stepped, all 21 FX items — including the player ship's `tintFlash` — were consumed/destroyed.\n This contradicts \"both contexts boot and play\", \"no regressions\", and corrupts hit/kill/respawn bookkeeping in both contexts.\n2. **\"Visible white hit-flash on the enemy craft\" is not delivered.** In this vendored Phaser 4.2.1 build, `setTintFill` is a removed no-op that only `console.error`s (verified in `lib/phaser.min.js`: `` setTintFill:function(){console.error(...)}``). So `craftFlash` sets `setTint(0xffffff)` (invisible on an untinted sprite), the FILL mode is never applied, and the fallback bloom never triggers (nothing throws). Every hit spams a console error; the required white craft flash is absent.\n\n### Minor observations\n- Three untracked, unreferenced MP3s under `assets/fx/` (not wired into `data/sfx.json` or `CombatSfx.js`) — noise against the \"audio changes out of scope\" boundary.\n- In my main-game probes the `GameScene.update` loop was idle (tutorial comms hold), and the committed main-game screenshot shows a straight line of static bolts — the \"main game plays\" evidence is weaker than the range evidence, independent of the defects above.\n\nThe laser/impact/shield-sphere visuals are mostly real and well-built, but the shared `Fx` class killing the hit craft on flash expiry is a fatal, reproducible regression in the range (and a latent player-ship destroy in the main game), and the white hit-flash requirement is silently unmet.\n\n","at":"2026-09-17T04:42:45.235Z"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.034Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.036Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"}],"activity":[{"type":"goal_created","goalId":"mu4xefwj-ebnq8h","objective":"=== Goal ===\nObjective: A visual overhaul of combat FX in Orbit — visually impressive laser bolts from both the player and enemies, a strong impact read when enemies are hit, and a shield-sphere hit effect that shows the player's shield struck from the laser's incoming bearing with the hit region highlighted — delivered consistently in both combat contexts (the /combat range and the main game's ?combat boot).\n\nSuccess criteria:\n- Player and enemy shots render as elongated glowing bolts: hot-white core, colored halo (cyan player / magenta enemy), a fading motion trail along the flight axis, and a directional muzzle flash on fire — in BOTH the range (js/combat/CombatScene.js) and the main game (js/combat/CombatSystem.js).\n- Enemy hits show impact: a hot core flash at the point of impact, directional sparks along the bullet's travel vector, a visible white hit-flash on the enemy craft, a cyan arc ripple on shield hits vs orange sparks/debris on hull hits, and a polished damage-number pop.\n- Player shield hits read as a sphere being struck: a brief full-sphere outline flash, a bright arc segment centered on the laser's incoming bearing (the highlighted hit region), a radial ripple from the impact point, and a spark burst — intensity scaled with damage, decaying over ~0.5s. A shield→hull break shows a distinct shield-collapse cue. Rams (ship collisions) get the same impact treatment.\n- Subtle world-camera shake on player hits, enemy kills, and shield-break — the separate fx-ui camera keeps the HUD/combat bar perfectly still.\n- New visual parameters (bolt size/trail, muzzle, arc span, shake intensity, etc.) are tunable in data/combat.json, consistent with the project's single-tuning-surface convention.\n- No regressions: dev/combat.test.mjs passes with 0 failures; both contexts boot and play; no new dependencies, no build step.\n\nBoundaries:\n- In scope: js/combat/Bullet.js, js/combat/Fx.js, the FX call sites in js/combat/CombatScene.js and js/combat/CombatSystem.js (firing, collisions, damage, kills), camera shake wiring, and tuning knobs in data/combat.json.\n- Out of scope: gameplay numbers (damage, cooldowns, speeds, hull/shield values), enemy AI, new weapons or skills, audio changes (existing SFX events stay as-is), HUD/combat-bar redesign, and non-combat main-game visuals.\n\nConstraints:\n- Plain ES6 modules, no build step, no new dependencies (Phaser 4.2.1 stays vendored).\n- No particle emitters — this build's emitter velocity semantics are fiddly (documented in js/combat/Fx.js); follow the existing hand-rolled pattern: canvas-generated textures + additive-blended images stepped in update().\n- Keep the FX cheap enough for 60fps with 3+ enemies and auto-fire (small per-hit item counts, shared textures, depth discipline: ships 10, FX 19–22, HUD 100).\n- Implement in the SHARED code (Fx.js / Bullet.js) so the range and the main game stay identical; both contexts' FX call sites must be updated together.\n- Camera shake on the world camera only (the fx-ui UI camera must remain unshaken).\n\nVerification contract:\n- Run `node dev/combat.test.mjs` → 0 failures.\n- Manual pass in the range (/combat via the dev server) AND the main game (index.html?combat): player cannon fires elongated bolt + trail + directional muzzle flash; enemy hits show the impact event with distinct shield (cyan ripple) vs hull (orange debris) reads; an enemy lance landing on the player shows the sphere struck — arc highlight centered on the laser's bearing + ripple + sparks, with a shield-collapse cue when shields break; kills show the burst + subtle shake; HUD stays still throughout.\n- Re-read this goal's requirements (lasers, enemy impact, shield sphere hit from the laser's angle, hit-region highlight, shake) and confirm every item is addressed in both contexts before marking complete.\n\nIf blocked: stop and ask the user.","sisyphus":false,"autoContinue":true,"at":"2026-09-17T02:44:06.019Z"},{"type":"task_started","goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T02:59:45.101Z"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Bolt texture (128x32 gradient trail+core+bloom) + white core head in js/combat/Bullet.js; fx.muzzle in CombatScene/CombatSystem; boltLength/boltWidth/muzzle knobs in data/combat.json; verified in /com"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-2","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.enemyHit: core flash, dir sparks along travel vector, craft white tintFill flash, cyan arc ripple (shield) vs orange shards (hull), damage-number pop; called identically in CombatScene.collide + Co"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-3","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.shieldImpact: sphere outline + arc centered on bearing (travel+PI), ripple, sparks, k-scaled by damage, 520ms decay; shieldBreak implosion+shockwave; rams use same treatment with bearing; verified "},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-4","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"combatShake on hit/kill/break; live probe: main cam shakeEffect isRunning=true duration=230, fx-ui cam isRunning=false; knobs in data/combat.json fx.shake/fx.shield; node dev/combat.test.mjs = 146 che"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.875Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.876Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"disapproved","report":"The headless browser is already up. Let me run my own live probe against both contexts to verify boot, the FX paths, and the camera-shake split.\n\nI found some console errors in the probe (a `setTintFill` deprecation and a `wallClamp` TypeError). Let me investigate both.\n\n## Audit report\n\nI verified the goal against the real artifacts, including live browser probes of both contexts (dev server + CDP). Much of the visual work is genuinely present, but the implementation contains a fatal regression in the shared FX path that breaks \"both contexts boot and play\" and silently fails one explicit success criterion.\n\n### Verified as delivered\n- **Bolts in both contexts** — `js/combat/Bullet.js` builds a 128×32 canvas bolt (fading trail band + core streak + head bloom) with an untinted white core dot riding the tip; tinted cyan (`#00e5ff`) / magenta-pink (`#ff2d6f`) from `data/combat.json` weapon colors; `boltLength/boltWidth` knobs read and applied (live probe: `boltScale [54,12]`, textures present, bolts visible in both committed screenshots).\n- **Directional muzzle flash** — `Fx.muzzle` (bore spike + bloom + cross glint, rotated to firing axis) called at every fire site in `CombatScene.firePlayer/fireEnemy` and `CombatSystem.firePlayerAt/fireEnemy`.\n- **Enemy impact read** — `Fx.enemyHit`: core flash, sparks along the travel vector, cyan arc + ring on shield hits vs orange shards + tight ring on hull, damage-number pop with scale-settle. Live-verified on the range (`arcFlash`, `ring`, 17 sparks, `tintFlash`, `text` all spawned).\n- **Shield-sphere hit from the laser's bearing** — `Fx.shieldImpact`: full-sphere outline + bright arc centered on the bearing (live check: bearing 135° → texture rotated exactly 135°), ripple, bearing-biased sparks, damage-scaled `k`, 520 ms decay; `shieldBreak` collapse cue (implosion + shockwave + shedding) fired on shield→hull break; rams route through the same bearing-based `damagePlayer`. Both contexts call the identical shared methods.\n- **Shake** — `combatShake` on hit/kill/break with `fx.shake` knobs (0.0045/0.007/0.011, 230 ms); live probe: world camera shake running, `fx-ui` camera shake inactive.\n- **Tuning surface, tests, constraints** — all knobs in `data/combat.json`; `node dev/combat.test.mjs` = 146 checks, 0 failures; no new dependencies (Phaser vendored), no build step, hand-rolled FX pattern (no emitters); depths in the 19–22 band, ships 10, HUD ≥60–120.\n\n### Blocking findings (verified live, not speculation)\n1. **`Fx.update` destroys the ships.** The expiry branch unconditionally calls `it.img.destroy()` — but `craftFlash` pushes items whose `img` is the ship itself (`kind:'tintFlash'`, 150–160 ms). Evidence:\n - Unit: real `Fx` class, `shieldImpact` + `fx.update(700ms)` → `shipDestroyedAfterShieldImpactExpire: true`.\n - Range: runtime stack captured `Ship.destroy ← Fx.update (Fx.js:599)`; after one non-lethal hit the enemy craft is gone 150 ms later with `dead:false` (no kill FX, no respawn bookkeeping — a ghost), then `CombatScene.update` throws `TypeError: Cannot read properties of undefined (reading 'velocity')` (`wallClamp`/`updatePlayer`) and the game loop dies. I reproduced this on a fresh page with no interaction beyond letting the pack fight.\n - Main game: the moment `combat.update` stepped, all 21 FX items — including the player ship's `tintFlash` — were consumed/destroyed.\n This contradicts \"both contexts boot and play\", \"no regressions\", and corrupts hit/kill/respawn bookkeeping in both contexts.\n2. **\"Visible white hit-flash on the enemy craft\" is not delivered.** In this vendored Phaser 4.2.1 build, `setTintFill` is a removed no-op that only `console.error`s (verified in `lib/phaser.min.js`: `` setTintFill:function(){console.error(...)}``). So `craftFlash` sets `setTint(0xffffff)` (invisible on an untinted sprite), the FILL mode is never applied, and the fallback bloom never triggers (nothing throws). Every hit spams a console error; the required white craft flash is absent.\n\n### Minor observations\n- Three untracked, unreferenced MP3s under `assets/fx/` (not wired into `data/sfx.json` or `CombatSfx.js`) — noise against the \"audio changes out of scope\" boundary.\n- In my main-game probes the `GameScene.update` loop was idle (tutorial comms hold), and the committed main-game screenshot shows a straight line of static bolts — the \"main game plays\" evidence is weaker than the range evidence, independent of the defects above.\n\nThe laser/impact/shield-sphere visuals are mostly real and well-built, but the shared `Fx` class killing the hit craft on flash expiry is a fatal, reproducible regression in the range (and a latent player-ship destroy in the main game), and the white hit-flash requirement is silently unmet.\n\n","at":"2026-09-17T04:42:45.235Z"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.034Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.036Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"}],"oracle":[],"lifecycle":{"type":"goal_created","goalId":"mu4xefwj-ebnq8h","objective":"=== Goal ===\nObjective: A visual overhaul of combat FX in Orbit — visually impressive laser bolts from both the player and enemies, a strong impact read when enemies are hit, and a shield-sphere hit effect that shows the player's shield struck from the laser's incoming bearing with the hit region highlighted — delivered consistently in both combat contexts (the /combat range and the main game's ?combat boot).\n\nSuccess criteria:\n- Player and enemy shots render as elongated glowing bolts: hot-white core, colored halo (cyan player / magenta enemy), a fading motion trail along the flight axis, and a directional muzzle flash on fire — in BOTH the range (js/combat/CombatScene.js) and the main game (js/combat/CombatSystem.js).\n- Enemy hits show impact: a hot core flash at the point of impact, directional sparks along the bullet's travel vector, a visible white hit-flash on the enemy craft, a cyan arc ripple on shield hits vs orange sparks/debris on hull hits, and a polished damage-number pop.\n- Player shield hits read as a sphere being struck: a brief full-sphere outline flash, a bright arc segment centered on the laser's incoming bearing (the highlighted hit region), a radial ripple from the impact point, and a spark burst — intensity scaled with damage, decaying over ~0.5s. A shield→hull break shows a distinct shield-collapse cue. Rams (ship collisions) get the same impact treatment.\n- Subtle world-camera shake on player hits, enemy kills, and shield-break — the separate fx-ui camera keeps the HUD/combat bar perfectly still.\n- New visual parameters (bolt size/trail, muzzle, arc span, shake intensity, etc.) are tunable in data/combat.json, consistent with the project's single-tuning-surface convention.\n- No regressions: dev/combat.test.mjs passes with 0 failures; both contexts boot and play; no new dependencies, no build step.\n\nBoundaries:\n- In scope: js/combat/Bullet.js, js/combat/Fx.js, the FX call sites in js/combat/CombatScene.js and js/combat/CombatSystem.js (firing, collisions, damage, kills), camera shake wiring, and tuning knobs in data/combat.json.\n- Out of scope: gameplay numbers (damage, cooldowns, speeds, hull/shield values), enemy AI, new weapons or skills, audio changes (existing SFX events stay as-is), HUD/combat-bar redesign, and non-combat main-game visuals.\n\nConstraints:\n- Plain ES6 modules, no build step, no new dependencies (Phaser 4.2.1 stays vendored).\n- No particle emitters — this build's emitter velocity semantics are fiddly (documented in js/combat/Fx.js); follow the existing hand-rolled pattern: canvas-generated textures + additive-blended images stepped in update().\n- Keep the FX cheap enough for 60fps with 3+ enemies and auto-fire (small per-hit item counts, shared textures, depth discipline: ships 10, FX 19–22, HUD 100).\n- Implement in the SHARED code (Fx.js / Bullet.js) so the range and the main game stay identical; both contexts' FX call sites must be updated together.\n- Camera shake on the world camera only (the fx-ui UI camera must remain unshaken).\n\nVerification contract:\n- Run `node dev/combat.test.mjs` → 0 failures.\n- Manual pass in the range (/combat via the dev server) AND the main game (index.html?combat): player cannon fires elongated bolt + trail + directional muzzle flash; enemy hits show the impact event with distinct shield (cyan ripple) vs hull (orange debris) reads; an enemy lance landing on the player shows the sphere struck — arc highlight centered on the laser's bearing + ripple + sparks, with a shield-collapse cue when shields break; kills show the burst + subtle shake; HUD stays still throughout.\n- Re-read this goal's requirements (lasers, enemy impact, shield sphere hit from the laser's angle, hit-region highlight, shake) and confirm every item is addressed in both contexts before marking complete.\n\nIf blocked: stop and ask the user.","sisyphus":false,"autoContinue":true,"at":"2026-09-17T02:44:06.019Z"},"lastActivityEvent":{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"},"completion":{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.034Z"},"audit":{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"}}]]} \ No newline at end of file +{"version":3,"format":"goal-ledger-checkpoint","createdAt":"2026-09-19T14:55:35.009Z","coveredBytes":28389,"coveredEvents":46,"acc":{"goals":[{"goalId":"mu3lln4y-51f5mi","latestStatus":"active","latestFocus":false,"latestAuditorResult":{"verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"},"createdAt":"2026-09-16T04:26:00.418Z"},{"goalId":"mu4xefwj-ebnq8h","latestStatus":"active","latestFocus":false,"latestAuditorResult":{"verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"},"createdAt":"2026-09-17T02:44:06.019Z"},{"goalId":"mu7ieab2-f24hte","latestStatus":"paused","latestFocus":false,"latestPauseReason":"user","createdAt":"2026-09-18T22:07:23.053Z","resumedAt":"2026-09-19T04:17:54.636Z"}],"terminalGoals":[],"focusedGoalId":null,"focusGeneration":0,"focusGenByGoal":[]},"recentEventsByGoal":[["mu3lln4y-51f5mi",[{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-1","at":"2026-09-16T04:26:15.002Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-1","evidence":"Audit doc at .pi/goals/combat-feedback-audit.md; baseline node dev/combat.test.mjs passes (all checks green)","at":"2026-09-16T04:33:51.611Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-2","at":"2026-09-16T04:35:30.972Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-2","evidence":"Committed 5949bac: pure handleFeel (exact integration) + combat.player tuning (thrust 2000/drag 1.8/rot 6.4/brake 0.9); WASD fixed (addKeys comma-list bug → event API); dev/combat.test.mjs 0 failures;","at":"2026-09-16T06:19:40.253Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-3","evidence":"Committed: shared js/combat/CombatSfx.js (7 distinct voices from existing sfx.json: fire=scan, impact=ui_click, shield=solar_buzz, hull=mining_split, kill=thunder×4 random, down=deconstruct, respawn=d","at":"2026-09-16T06:48:29.383Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-4","evidence":"Committed ecff203: Fx.js gains damage() (cyan=shield/orange=hull numbers), color-coded hit flashes, enhanced kill burst (delayed 2nd shockwave), down() + respawn() cues, shared FX_COLORS. Both context","at":"2026-09-16T07:07:02.597Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-5","evidence":"Commit 0d51184: CombatHud shared (lock reticle, threat cues, off-screen arrows, kill feed, gauge flash); pickTarget/feedPush/offscreenArrow in Combat.js; resyncAroundShip fixes main-game pack placemen","at":"2026-09-16T08:03:23.498Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-6","at":"2026-09-16T08:03:51.673Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-6","evidence":"combat.test.mjs 96 checks + sfx.test.mjs 21 checks = 0 failures; sfxcheck PASS both contexts (7 events); gauge-flash/lock/threat/feed verified in both; combat-shot.mjs → dev/shots/combat-range.png + c","at":"2026-09-16T08:59:41.075Z"},{"type":"completion_requested","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.729Z"},{"type":"audit_started","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.730Z"},{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"}]],["mu4xefwj-ebnq8h",[{"type":"task_list_set","goalId":"mu4xefwj-ebnq8h","taskCount":4,"blockCompletion":false,"at":"2026-09-17T02:44:06.019Z"},{"type":"task_started","goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T02:59:45.101Z"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Bolt texture (128x32 gradient trail+core+bloom) + white core head in js/combat/Bullet.js; fx.muzzle in CombatScene/CombatSystem; boltLength/boltWidth/muzzle knobs in data/combat.json; verified in /com"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-2","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.enemyHit: core flash, dir sparks along travel vector, craft white tintFill flash, cyan arc ripple (shield) vs orange shards (hull), damage-number pop; called identically in CombatScene.collide + Co"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-3","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.shieldImpact: sphere outline + arc centered on bearing (travel+PI), ripple, sparks, k-scaled by damage, 520ms decay; shieldBreak implosion+shockwave; rams use same treatment with bearing; verified "},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-4","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"combatShake on hit/kill/break; live probe: main cam shakeEffect isRunning=true duration=230, fx-ui cam isRunning=false; knobs in data/combat.json fx.shake/fx.shield; node dev/combat.test.mjs = 146 che"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.875Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.876Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"disapproved","report":"The headless browser is already up. Let me run my own live probe against both contexts to verify boot, the FX paths, and the camera-shake split.\n\nI found some console errors in the probe (a `setTintFill` deprecation and a `wallClamp` TypeError). Let me investigate both.\n\n## Audit report\n\nI verified the goal against the real artifacts, including live browser probes of both contexts (dev server + CDP). Much of the visual work is genuinely present, but the implementation contains a fatal regression in the shared FX path that breaks \"both contexts boot and play\" and silently fails one explicit success criterion.\n\n### Verified as delivered\n- **Bolts in both contexts** — `js/combat/Bullet.js` builds a 128×32 canvas bolt (fading trail band + core streak + head bloom) with an untinted white core dot riding the tip; tinted cyan (`#00e5ff`) / magenta-pink (`#ff2d6f`) from `data/combat.json` weapon colors; `boltLength/boltWidth` knobs read and applied (live probe: `boltScale [54,12]`, textures present, bolts visible in both committed screenshots).\n- **Directional muzzle flash** — `Fx.muzzle` (bore spike + bloom + cross glint, rotated to firing axis) called at every fire site in `CombatScene.firePlayer/fireEnemy` and `CombatSystem.firePlayerAt/fireEnemy`.\n- **Enemy impact read** — `Fx.enemyHit`: core flash, sparks along the travel vector, cyan arc + ring on shield hits vs orange shards + tight ring on hull, damage-number pop with scale-settle. Live-verified on the range (`arcFlash`, `ring`, 17 sparks, `tintFlash`, `text` all spawned).\n- **Shield-sphere hit from the laser's bearing** — `Fx.shieldImpact`: full-sphere outline + bright arc centered on the bearing (live check: bearing 135° → texture rotated exactly 135°), ripple, bearing-biased sparks, damage-scaled `k`, 520 ms decay; `shieldBreak` collapse cue (implosion + shockwave + shedding) fired on shield→hull break; rams route through the same bearing-based `damagePlayer`. Both contexts call the identical shared methods.\n- **Shake** — `combatShake` on hit/kill/break with `fx.shake` knobs (0.0045/0.007/0.011, 230 ms); live probe: world camera shake running, `fx-ui` camera shake inactive.\n- **Tuning surface, tests, constraints** — all knobs in `data/combat.json`; `node dev/combat.test.mjs` = 146 checks, 0 failures; no new dependencies (Phaser vendored), no build step, hand-rolled FX pattern (no emitters); depths in the 19–22 band, ships 10, HUD ≥60–120.\n\n### Blocking findings (verified live, not speculation)\n1. **`Fx.update` destroys the ships.** The expiry branch unconditionally calls `it.img.destroy()` — but `craftFlash` pushes items whose `img` is the ship itself (`kind:'tintFlash'`, 150–160 ms). Evidence:\n - Unit: real `Fx` class, `shieldImpact` + `fx.update(700ms)` → `shipDestroyedAfterShieldImpactExpire: true`.\n - Range: runtime stack captured `Ship.destroy ← Fx.update (Fx.js:599)`; after one non-lethal hit the enemy craft is gone 150 ms later with `dead:false` (no kill FX, no respawn bookkeeping — a ghost), then `CombatScene.update` throws `TypeError: Cannot read properties of undefined (reading 'velocity')` (`wallClamp`/`updatePlayer`) and the game loop dies. I reproduced this on a fresh page with no interaction beyond letting the pack fight.\n - Main game: the moment `combat.update` stepped, all 21 FX items — including the player ship's `tintFlash` — were consumed/destroyed.\n This contradicts \"both contexts boot and play\", \"no regressions\", and corrupts hit/kill/respawn bookkeeping in both contexts.\n2. **\"Visible white hit-flash on the enemy craft\" is not delivered.** In this vendored Phaser 4.2.1 build, `setTintFill` is a removed no-op that only `console.error`s (verified in `lib/phaser.min.js`: `` setTintFill:function(){console.error(...)}``). So `craftFlash` sets `setTint(0xffffff)` (invisible on an untinted sprite), the FILL mode is never applied, and the fallback bloom never triggers (nothing throws). Every hit spams a console error; the required white craft flash is absent.\n\n### Minor observations\n- Three untracked, unreferenced MP3s under `assets/fx/` (not wired into `data/sfx.json` or `CombatSfx.js`) — noise against the \"audio changes out of scope\" boundary.\n- In my main-game probes the `GameScene.update` loop was idle (tutorial comms hold), and the committed main-game screenshot shows a straight line of static bolts — the \"main game plays\" evidence is weaker than the range evidence, independent of the defects above.\n\nThe laser/impact/shield-sphere visuals are mostly real and well-built, but the shared `Fx` class killing the hit craft on flash expiry is a fatal, reproducible regression in the range (and a latent player-ship destroy in the main game), and the white hit-flash requirement is silently unmet.\n\n","at":"2026-09-17T04:42:45.235Z"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.034Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.036Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"}]],["mu7ieab2-f24hte",[{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-1","evidence":"node --check clean on ShipWindow/Model/GameScene/ship-shot; headless boot (mp4 absent) report \"SMOKE OK — no console errors\", video: NO SIGNAL plate, window open/tab switch ok; deckAction exclusivity ","at":"2026-09-19T01:02:52.692Z"},{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-2","at":"2026-09-19T01:03:09.694Z"},{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"user","status":"paused","at":"2026-09-19T04:06:34.695Z"},{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"Scheduling paused","source":"agent","at":"2026-09-19T04:06:46.015Z"},{"type":"goal_resumed","goalId":"mu7ieab2-f24hte","reason":"user","at":"2026-09-19T04:17:54.636Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-2","evidence":"dev/ship-window.test.mjs: every configured row resolves finite from real data; fireRate=1/cd, DPS=damage/cd, accel=ship.thrust, recharge=combat.playerShield. Screenshots reviewed: nominal (HULL 100/CU","at":"2026-09-19T04:54:26.329Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-3","evidence":"dev/ship-window.test.mjs drives real XpManager: levels 0-4 → timeline, 5 & 6 → CHOOSE YOUR CLASS door, class on file → class plate (mode flips exactly at data classAt=5; maxLevel untouched). Screensho","at":"2026-09-19T05:04:37.254Z"},{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-4","at":"2026-09-19T05:04:55.422Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-4","evidence":"Screenshots reviewed at 1920x1080: empty hold (\"HOLD EMPTY — EVERY BAY IS CLEAN\", EMPTY tiles, +12 LOCKED pips, 0% / NO ORE ABOARD) and loaded (icons+names, MANIFEST tags, \"4/6 BAYS · 2 FREE\", +10 LOC","at":"2026-09-19T05:21:51.901Z"},{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-5","at":"2026-09-19T05:22:27.127Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-5","evidence":"Selecting a filled bay opens BAY MANIFEST (js/ui/ShipWindow.js _paintManifest): goods icon frame, name, CLASS/TIER/BASE VALUE chips (labels from data), flavor, MADE IN origin|hub pool + producer class","at":"2026-09-19T06:10:11.478Z"},{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"user","status":"paused","at":"2026-09-19T14:55:34.986Z"}]]],"runtimeIndex":[["mu3lln4y-51f5mi",{"recent":[{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-1","at":"2026-09-16T04:26:15.002Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-1","evidence":"Audit doc at .pi/goals/combat-feedback-audit.md; baseline node dev/combat.test.mjs passes (all checks green)","at":"2026-09-16T04:33:51.611Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-2","at":"2026-09-16T04:35:30.972Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-2","evidence":"Committed 5949bac: pure handleFeel (exact integration) + combat.player tuning (thrust 2000/drag 1.8/rot 6.4/brake 0.9); WASD fixed (addKeys comma-list bug → event API); dev/combat.test.mjs 0 failures;","at":"2026-09-16T06:19:40.253Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-3","evidence":"Committed: shared js/combat/CombatSfx.js (7 distinct voices from existing sfx.json: fire=scan, impact=ui_click, shield=solar_buzz, hull=mining_split, kill=thunder×4 random, down=deconstruct, respawn=d","at":"2026-09-16T06:48:29.383Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-4","evidence":"Committed ecff203: Fx.js gains damage() (cyan=shield/orange=hull numbers), color-coded hit flashes, enhanced kill burst (delayed 2nd shockwave), down() + respawn() cues, shared FX_COLORS. Both context","at":"2026-09-16T07:07:02.597Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-5","evidence":"Commit 0d51184: CombatHud shared (lock reticle, threat cues, off-screen arrows, kill feed, gauge flash); pickTarget/feedPush/offscreenArrow in Combat.js; resyncAroundShip fixes main-game pack placemen","at":"2026-09-16T08:03:23.498Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-6","at":"2026-09-16T08:03:51.673Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-6","evidence":"combat.test.mjs 96 checks + sfx.test.mjs 21 checks = 0 failures; sfxcheck PASS both contexts (7 events); gauge-flash/lock/threat/feed verified in both; combat-shot.mjs → dev/shots/combat-range.png + c","at":"2026-09-16T08:59:41.075Z"},{"type":"completion_requested","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.729Z"},{"type":"audit_started","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.730Z"},{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"}],"activity":[{"type":"goal_created","goalId":"mu3lln4y-51f5mi","objective":"=== Goal ===\nObjective: Make Orbit's combat both feel and read — sharper ship handling plus clear hit/kill/death feedback (VFX + SFX) and a readability layer (target indicator, damage numbers, kill feed, bar hit-flash), kept in sync across the training range (/combat) and the main-game combat (index.html?combat).\n\nSuccess criteria (observable):\n- In BOTH contexts, each of: firing, bullet impact, shield hit, hull hit, enemy kill, player-down, and player-respawn produces a distinct VFX and a distinct SFX.\n- Readability layer is live in both contexts: damage numbers on every hit (enemy and player), a target/lock indicator on the current target, threat cues for hostiles, a kill feed, and a visible hit-flash on the combat bar's shields/hull gauges.\n- Ship handling feels noticeably better in the range (WASD) — responsive thrust/rotation/brake, all values living in data/combat.json and shared with the main game.\n- node dev/combat.test.mjs passes with 0 failures; CDP screenshots (dev/cdp-shot.mjs pattern) show the new feedback in both /combat and index.html?combat; the README combat section documents the new feedback set and dev hooks.\n\nBoundaries:\n- IN SCOPE: js/combat/* (Combat.js, CombatSystem.js, CombatScene.js, Fx.js, EnemyShip.js, Bullet.js), js/ui/CombatBar.js, data/combat.json, SFX wiring, both combat contexts.\n- OUT OF SCOPE: real skill implementations (the 1–8 seats stay as stubs on the system's cast() stub), new enemy types/AI/behavior variety, waves/difficulty/escalation, permanent death or economy flows, non-combat game systems (map, jump gates, economy), new sprite art.\n\nConstraints:\n- Phaser 4 UMD + plain ES6 modules, no build step; hand-rolled circle collision checks only (no arcade colliders), per the existing combat architecture.\n- No new binary assets: VFX must be procedural (canvas dot textures / additive glows, Fx.js style); SFX must go through the existing data/sfx.json + playSfx() infrastructure using existing sound files only.\n- Pure rules stay in js/combat/Combat.js (pure, unit-tested); ALL tuning numbers live in data/combat.json; the range and the main game must share the same rules and numbers (no divergent copies).\n- Skill seats 1–8, the energy gauge, and the auto-fire model are left as-is (stub/placeholder behavior unchanged).\n\nVerification contract:\n1) node dev/combat.test.mjs → 0 failures. 2) CDP-driven visual checks of BOTH /combat and index.html?combat (dev/cdp-shot.mjs pattern) capturing: damage numbers, target indicator, kill feed, bar hit-flash, kill burst, player-down/respawn cues. 3) Manual flow checklist passed in both contexts: fire → muzzle flash + shot SFX; enemy hit → impact flash + damage number; shield/hull hit → bar flash + appropriate SFX; enemy kill → burst + kill-feed entry + explosion SFX; player down → down cue + respawn cue. 4) Re-read this goal's success criteria and confirm each is addressed before marking complete.\n\nIf blocked: stop and ask the user (default blocker rule).","sisyphus":false,"autoContinue":true,"at":"2026-09-16T04:26:00.418Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-1","at":"2026-09-16T04:26:15.002Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-1","evidence":"Audit doc at .pi/goals/combat-feedback-audit.md; baseline node dev/combat.test.mjs passes (all checks green)","at":"2026-09-16T04:33:51.611Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-2","at":"2026-09-16T04:35:30.972Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-2","evidence":"Committed 5949bac: pure handleFeel (exact integration) + combat.player tuning (thrust 2000/drag 1.8/rot 6.4/brake 0.9); WASD fixed (addKeys comma-list bug → event API); dev/combat.test.mjs 0 failures;","at":"2026-09-16T06:19:40.253Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-3","evidence":"Committed: shared js/combat/CombatSfx.js (7 distinct voices from existing sfx.json: fire=scan, impact=ui_click, shield=solar_buzz, hull=mining_split, kill=thunder×4 random, down=deconstruct, respawn=d","at":"2026-09-16T06:48:29.383Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-4","evidence":"Committed ecff203: Fx.js gains damage() (cyan=shield/orange=hull numbers), color-coded hit flashes, enhanced kill burst (delayed 2nd shockwave), down() + respawn() cues, shared FX_COLORS. Both context","at":"2026-09-16T07:07:02.597Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-5","evidence":"Commit 0d51184: CombatHud shared (lock reticle, threat cues, off-screen arrows, kill feed, gauge flash); pickTarget/feedPush/offscreenArrow in Combat.js; resyncAroundShip fixes main-game pack placemen","at":"2026-09-16T08:03:23.498Z"},{"type":"task_started","goalId":"mu3lln4y-51f5mi","taskId":"task-6","at":"2026-09-16T08:03:51.673Z"},{"type":"task_complete","goalId":"mu3lln4y-51f5mi","taskId":"task-6","evidence":"combat.test.mjs 96 checks + sfx.test.mjs 21 checks = 0 failures; sfxcheck PASS both contexts (7 events); gauge-flash/lock/threat/feed verified in both; combat-shot.mjs → dev/shots/combat-range.png + c","at":"2026-09-16T08:59:41.075Z"},{"type":"completion_requested","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.729Z"},{"type":"audit_started","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.730Z"},{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"}],"oracle":[],"lifecycle":{"type":"goal_created","goalId":"mu3lln4y-51f5mi","objective":"=== Goal ===\nObjective: Make Orbit's combat both feel and read — sharper ship handling plus clear hit/kill/death feedback (VFX + SFX) and a readability layer (target indicator, damage numbers, kill feed, bar hit-flash), kept in sync across the training range (/combat) and the main-game combat (index.html?combat).\n\nSuccess criteria (observable):\n- In BOTH contexts, each of: firing, bullet impact, shield hit, hull hit, enemy kill, player-down, and player-respawn produces a distinct VFX and a distinct SFX.\n- Readability layer is live in both contexts: damage numbers on every hit (enemy and player), a target/lock indicator on the current target, threat cues for hostiles, a kill feed, and a visible hit-flash on the combat bar's shields/hull gauges.\n- Ship handling feels noticeably better in the range (WASD) — responsive thrust/rotation/brake, all values living in data/combat.json and shared with the main game.\n- node dev/combat.test.mjs passes with 0 failures; CDP screenshots (dev/cdp-shot.mjs pattern) show the new feedback in both /combat and index.html?combat; the README combat section documents the new feedback set and dev hooks.\n\nBoundaries:\n- IN SCOPE: js/combat/* (Combat.js, CombatSystem.js, CombatScene.js, Fx.js, EnemyShip.js, Bullet.js), js/ui/CombatBar.js, data/combat.json, SFX wiring, both combat contexts.\n- OUT OF SCOPE: real skill implementations (the 1–8 seats stay as stubs on the system's cast() stub), new enemy types/AI/behavior variety, waves/difficulty/escalation, permanent death or economy flows, non-combat game systems (map, jump gates, economy), new sprite art.\n\nConstraints:\n- Phaser 4 UMD + plain ES6 modules, no build step; hand-rolled circle collision checks only (no arcade colliders), per the existing combat architecture.\n- No new binary assets: VFX must be procedural (canvas dot textures / additive glows, Fx.js style); SFX must go through the existing data/sfx.json + playSfx() infrastructure using existing sound files only.\n- Pure rules stay in js/combat/Combat.js (pure, unit-tested); ALL tuning numbers live in data/combat.json; the range and the main game must share the same rules and numbers (no divergent copies).\n- Skill seats 1–8, the energy gauge, and the auto-fire model are left as-is (stub/placeholder behavior unchanged).\n\nVerification contract:\n1) node dev/combat.test.mjs → 0 failures. 2) CDP-driven visual checks of BOTH /combat and index.html?combat (dev/cdp-shot.mjs pattern) capturing: damage numbers, target indicator, kill feed, bar hit-flash, kill burst, player-down/respawn cues. 3) Manual flow checklist passed in both contexts: fire → muzzle flash + shot SFX; enemy hit → impact flash + damage number; shield/hull hit → bar flash + appropriate SFX; enemy kill → burst + kill-feed entry + explosion SFX; player down → down cue + respawn cue. 4) Re-read this goal's success criteria and confirm each is addressed before marking complete.\n\nIf blocked: stop and ask the user (default blocker rule).","sisyphus":false,"autoContinue":true,"at":"2026-09-16T04:26:00.418Z"},"lastActivityEvent":{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"},"completion":{"type":"completion_requested","goalId":"mu3lln4y-51f5mi","at":"2026-09-16T09:00:25.729Z"},"audit":{"type":"audit_result","goalId":"mu3lln4y-51f5mi","verdict":"approved","report":"The dev server (8080) and a CDP browser (9333) are live, so I can verify directly. First, let me look at the committed screenshot evidence and the README combat section.\n\nI've confirmed the screenshots show the combat HUD, reticles, and kill feed elements. Now I'll run the live CDP checks to verify SFX wiring and flight feel in the running dev environment.\n\nAll verification paths are now confirmed. Let me do a final constraint sweep before deciding.\n\n## Audit summary\n\nI verified the executor's claims against the actual repository and ran the live verification contract myself (dev server + CDP browser were available):\n\n**1. Distinct VFX + SFX per event, both contexts**\n- Code: `CombatScene.js` (range) and `CombatSystem.js` (main) both funnel every event through the shared `combatSfx`/`combatSfxEnemyHit` (7 distinct voices — scan/ui_click/solar_buzz/mining_split/thunder×4/deconstruct/discovery, all existing `data/sfx.json` files, all confirmed on disk) and the shared procedural `Fx` (muzzle flash, color-coded shield-cyan/hull-orange hit flash + damage number, kill burst with double ring, DOWN burst, REFORMED bloom — all canvas dots/additive).\n- Live: `node dev/combat.sfxcheck.mjs` → **PASS** in both `/combat` and `index.html?combat`, all seven events through the shared path (I ran it, exit 0).\n\n**2. Readability layer live in both contexts** (my own CDP probes)\n- Range: `lastFlash={kind:shield}`, kill feed entry landed, lock target acquired; gauge-flash overlay live (peak alpha 0.72, decaying).\n- Main: same, with the flash positioned exactly on the `CombatBar.gaugeRect('shield')` track (peak alpha 1.0), bar visible, feed entry, target true.\n- Committed screenshots `dev/shots/combat-range.png` / `combat-main.png` visually show: lock reticle, threat dots, kill feed \"HOSTILE DESTROYED\", damage numbers (\"10\"), kill burst, tracer, combat bar gauges.\n\n**3. Ship handling (range WASD)**\n- `node dev/combat-flycheck.mjs` → **PASS**: 337 ms to 60% maxSpeed, 133 ms to 30° turn, 7.45 rad/s sustained spin, 308 ms stop from 560 px/s.\n- Real keyboard check: actual 'W' keydown accelerated the ship 0→526 px/s; keyup released it.\n- Values live in `data/combat.json` → `combat.player`; pure `handleFeel` in `js/combat/Combat.js`; both contexts read the same file (weapon, armor, readability, bar).\n\n**4. Tests & docs**\n- `node dev/combat.test.mjs` → 96 checks, **0 failures** (exit 0); `sfx.test.mjs` 21/21.\n- `dev/combat-shot.mjs` follows the `dev/cdp-shot.mjs` CDP pattern; README combat section fully documents feel, the VFX set, the SFX map, the readability layer, and all three dev checkers + hooks.\n\n**Constraints honored**: pure rules in `Combat.js` (unit-tested), single tuning surface `data/combat.json`, shared modules across both contexts (no divergent copies), no new binary game assets (only the two evidence screenshots in the diff), hand-rolled circle collision only, skill stubs (`cast()` no-op), energy gauge (regen 0, full), and auto-fire left as-is, Phaser 4 UMD + plain ES6.\n\nEvery explicit success criterion and the verification contract are genuinely satisfied; the executor's claims match the evidence I independently reproduced.\n\n","at":"2026-09-16T09:04:06.559Z"}}],["mu4xefwj-ebnq8h",{"recent":[{"type":"task_list_set","goalId":"mu4xefwj-ebnq8h","taskCount":4,"blockCompletion":false,"at":"2026-09-17T02:44:06.019Z"},{"type":"task_started","goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T02:59:45.101Z"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Bolt texture (128x32 gradient trail+core+bloom) + white core head in js/combat/Bullet.js; fx.muzzle in CombatScene/CombatSystem; boltLength/boltWidth/muzzle knobs in data/combat.json; verified in /com"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-2","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.enemyHit: core flash, dir sparks along travel vector, craft white tintFill flash, cyan arc ripple (shield) vs orange shards (hull), damage-number pop; called identically in CombatScene.collide + Co"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-3","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.shieldImpact: sphere outline + arc centered on bearing (travel+PI), ripple, sparks, k-scaled by damage, 520ms decay; shieldBreak implosion+shockwave; rams use same treatment with bearing; verified "},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-4","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"combatShake on hit/kill/break; live probe: main cam shakeEffect isRunning=true duration=230, fx-ui cam isRunning=false; knobs in data/combat.json fx.shake/fx.shield; node dev/combat.test.mjs = 146 che"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.875Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.876Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"disapproved","report":"The headless browser is already up. Let me run my own live probe against both contexts to verify boot, the FX paths, and the camera-shake split.\n\nI found some console errors in the probe (a `setTintFill` deprecation and a `wallClamp` TypeError). Let me investigate both.\n\n## Audit report\n\nI verified the goal against the real artifacts, including live browser probes of both contexts (dev server + CDP). Much of the visual work is genuinely present, but the implementation contains a fatal regression in the shared FX path that breaks \"both contexts boot and play\" and silently fails one explicit success criterion.\n\n### Verified as delivered\n- **Bolts in both contexts** — `js/combat/Bullet.js` builds a 128×32 canvas bolt (fading trail band + core streak + head bloom) with an untinted white core dot riding the tip; tinted cyan (`#00e5ff`) / magenta-pink (`#ff2d6f`) from `data/combat.json` weapon colors; `boltLength/boltWidth` knobs read and applied (live probe: `boltScale [54,12]`, textures present, bolts visible in both committed screenshots).\n- **Directional muzzle flash** — `Fx.muzzle` (bore spike + bloom + cross glint, rotated to firing axis) called at every fire site in `CombatScene.firePlayer/fireEnemy` and `CombatSystem.firePlayerAt/fireEnemy`.\n- **Enemy impact read** — `Fx.enemyHit`: core flash, sparks along the travel vector, cyan arc + ring on shield hits vs orange shards + tight ring on hull, damage-number pop with scale-settle. Live-verified on the range (`arcFlash`, `ring`, 17 sparks, `tintFlash`, `text` all spawned).\n- **Shield-sphere hit from the laser's bearing** — `Fx.shieldImpact`: full-sphere outline + bright arc centered on the bearing (live check: bearing 135° → texture rotated exactly 135°), ripple, bearing-biased sparks, damage-scaled `k`, 520 ms decay; `shieldBreak` collapse cue (implosion + shockwave + shedding) fired on shield→hull break; rams route through the same bearing-based `damagePlayer`. Both contexts call the identical shared methods.\n- **Shake** — `combatShake` on hit/kill/break with `fx.shake` knobs (0.0045/0.007/0.011, 230 ms); live probe: world camera shake running, `fx-ui` camera shake inactive.\n- **Tuning surface, tests, constraints** — all knobs in `data/combat.json`; `node dev/combat.test.mjs` = 146 checks, 0 failures; no new dependencies (Phaser vendored), no build step, hand-rolled FX pattern (no emitters); depths in the 19–22 band, ships 10, HUD ≥60–120.\n\n### Blocking findings (verified live, not speculation)\n1. **`Fx.update` destroys the ships.** The expiry branch unconditionally calls `it.img.destroy()` — but `craftFlash` pushes items whose `img` is the ship itself (`kind:'tintFlash'`, 150–160 ms). Evidence:\n - Unit: real `Fx` class, `shieldImpact` + `fx.update(700ms)` → `shipDestroyedAfterShieldImpactExpire: true`.\n - Range: runtime stack captured `Ship.destroy ← Fx.update (Fx.js:599)`; after one non-lethal hit the enemy craft is gone 150 ms later with `dead:false` (no kill FX, no respawn bookkeeping — a ghost), then `CombatScene.update` throws `TypeError: Cannot read properties of undefined (reading 'velocity')` (`wallClamp`/`updatePlayer`) and the game loop dies. I reproduced this on a fresh page with no interaction beyond letting the pack fight.\n - Main game: the moment `combat.update` stepped, all 21 FX items — including the player ship's `tintFlash` — were consumed/destroyed.\n This contradicts \"both contexts boot and play\", \"no regressions\", and corrupts hit/kill/respawn bookkeeping in both contexts.\n2. **\"Visible white hit-flash on the enemy craft\" is not delivered.** In this vendored Phaser 4.2.1 build, `setTintFill` is a removed no-op that only `console.error`s (verified in `lib/phaser.min.js`: `` setTintFill:function(){console.error(...)}``). So `craftFlash` sets `setTint(0xffffff)` (invisible on an untinted sprite), the FILL mode is never applied, and the fallback bloom never triggers (nothing throws). Every hit spams a console error; the required white craft flash is absent.\n\n### Minor observations\n- Three untracked, unreferenced MP3s under `assets/fx/` (not wired into `data/sfx.json` or `CombatSfx.js`) — noise against the \"audio changes out of scope\" boundary.\n- In my main-game probes the `GameScene.update` loop was idle (tutorial comms hold), and the committed main-game screenshot shows a straight line of static bolts — the \"main game plays\" evidence is weaker than the range evidence, independent of the defects above.\n\nThe laser/impact/shield-sphere visuals are mostly real and well-built, but the shared `Fx` class killing the hit craft on flash expiry is a fatal, reproducible regression in the range (and a latent player-ship destroy in the main game), and the white hit-flash requirement is silently unmet.\n\n","at":"2026-09-17T04:42:45.235Z"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.034Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.036Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"}],"activity":[{"type":"goal_created","goalId":"mu4xefwj-ebnq8h","objective":"=== Goal ===\nObjective: A visual overhaul of combat FX in Orbit — visually impressive laser bolts from both the player and enemies, a strong impact read when enemies are hit, and a shield-sphere hit effect that shows the player's shield struck from the laser's incoming bearing with the hit region highlighted — delivered consistently in both combat contexts (the /combat range and the main game's ?combat boot).\n\nSuccess criteria:\n- Player and enemy shots render as elongated glowing bolts: hot-white core, colored halo (cyan player / magenta enemy), a fading motion trail along the flight axis, and a directional muzzle flash on fire — in BOTH the range (js/combat/CombatScene.js) and the main game (js/combat/CombatSystem.js).\n- Enemy hits show impact: a hot core flash at the point of impact, directional sparks along the bullet's travel vector, a visible white hit-flash on the enemy craft, a cyan arc ripple on shield hits vs orange sparks/debris on hull hits, and a polished damage-number pop.\n- Player shield hits read as a sphere being struck: a brief full-sphere outline flash, a bright arc segment centered on the laser's incoming bearing (the highlighted hit region), a radial ripple from the impact point, and a spark burst — intensity scaled with damage, decaying over ~0.5s. A shield→hull break shows a distinct shield-collapse cue. Rams (ship collisions) get the same impact treatment.\n- Subtle world-camera shake on player hits, enemy kills, and shield-break — the separate fx-ui camera keeps the HUD/combat bar perfectly still.\n- New visual parameters (bolt size/trail, muzzle, arc span, shake intensity, etc.) are tunable in data/combat.json, consistent with the project's single-tuning-surface convention.\n- No regressions: dev/combat.test.mjs passes with 0 failures; both contexts boot and play; no new dependencies, no build step.\n\nBoundaries:\n- In scope: js/combat/Bullet.js, js/combat/Fx.js, the FX call sites in js/combat/CombatScene.js and js/combat/CombatSystem.js (firing, collisions, damage, kills), camera shake wiring, and tuning knobs in data/combat.json.\n- Out of scope: gameplay numbers (damage, cooldowns, speeds, hull/shield values), enemy AI, new weapons or skills, audio changes (existing SFX events stay as-is), HUD/combat-bar redesign, and non-combat main-game visuals.\n\nConstraints:\n- Plain ES6 modules, no build step, no new dependencies (Phaser 4.2.1 stays vendored).\n- No particle emitters — this build's emitter velocity semantics are fiddly (documented in js/combat/Fx.js); follow the existing hand-rolled pattern: canvas-generated textures + additive-blended images stepped in update().\n- Keep the FX cheap enough for 60fps with 3+ enemies and auto-fire (small per-hit item counts, shared textures, depth discipline: ships 10, FX 19–22, HUD 100).\n- Implement in the SHARED code (Fx.js / Bullet.js) so the range and the main game stay identical; both contexts' FX call sites must be updated together.\n- Camera shake on the world camera only (the fx-ui UI camera must remain unshaken).\n\nVerification contract:\n- Run `node dev/combat.test.mjs` → 0 failures.\n- Manual pass in the range (/combat via the dev server) AND the main game (index.html?combat): player cannon fires elongated bolt + trail + directional muzzle flash; enemy hits show the impact event with distinct shield (cyan ripple) vs hull (orange debris) reads; an enemy lance landing on the player shows the sphere struck — arc highlight centered on the laser's bearing + ripple + sparks, with a shield-collapse cue when shields break; kills show the burst + subtle shake; HUD stays still throughout.\n- Re-read this goal's requirements (lasers, enemy impact, shield sphere hit from the laser's angle, hit-region highlight, shake) and confirm every item is addressed in both contexts before marking complete.\n\nIf blocked: stop and ask the user.","sisyphus":false,"autoContinue":true,"at":"2026-09-17T02:44:06.019Z"},{"type":"task_started","goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T02:59:45.101Z"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-1","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Bolt texture (128x32 gradient trail+core+bloom) + white core head in js/combat/Bullet.js; fx.muzzle in CombatScene/CombatSystem; boltLength/boltWidth/muzzle knobs in data/combat.json; verified in /com"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-2","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.enemyHit: core flash, dir sparks along travel vector, craft white tintFill flash, cyan arc ripple (shield) vs orange shards (hull), damage-number pop; called identically in CombatScene.collide + Co"},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-3","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"Fx.shieldImpact: sphere outline + arc centered on bearing (travel+PI), ripple, sparks, k-scaled by damage, 520ms decay; shieldBreak implosion+shockwave; rams use same treatment with bearing; verified "},{"goalId":"mu4xefwj-ebnq8h","taskId":"task-4","at":"2026-09-17T04:34:16.884Z","type":"task_complete","evidence":"combatShake on hit/kill/break; live probe: main cam shakeEffect isRunning=true duration=230, fx-ui cam isRunning=false; knobs in data/combat.json fx.shake/fx.shield; node dev/combat.test.mjs = 146 che"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.875Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T04:35:11.876Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"disapproved","report":"The headless browser is already up. Let me run my own live probe against both contexts to verify boot, the FX paths, and the camera-shake split.\n\nI found some console errors in the probe (a `setTintFill` deprecation and a `wallClamp` TypeError). Let me investigate both.\n\n## Audit report\n\nI verified the goal against the real artifacts, including live browser probes of both contexts (dev server + CDP). Much of the visual work is genuinely present, but the implementation contains a fatal regression in the shared FX path that breaks \"both contexts boot and play\" and silently fails one explicit success criterion.\n\n### Verified as delivered\n- **Bolts in both contexts** — `js/combat/Bullet.js` builds a 128×32 canvas bolt (fading trail band + core streak + head bloom) with an untinted white core dot riding the tip; tinted cyan (`#00e5ff`) / magenta-pink (`#ff2d6f`) from `data/combat.json` weapon colors; `boltLength/boltWidth` knobs read and applied (live probe: `boltScale [54,12]`, textures present, bolts visible in both committed screenshots).\n- **Directional muzzle flash** — `Fx.muzzle` (bore spike + bloom + cross glint, rotated to firing axis) called at every fire site in `CombatScene.firePlayer/fireEnemy` and `CombatSystem.firePlayerAt/fireEnemy`.\n- **Enemy impact read** — `Fx.enemyHit`: core flash, sparks along the travel vector, cyan arc + ring on shield hits vs orange shards + tight ring on hull, damage-number pop with scale-settle. Live-verified on the range (`arcFlash`, `ring`, 17 sparks, `tintFlash`, `text` all spawned).\n- **Shield-sphere hit from the laser's bearing** — `Fx.shieldImpact`: full-sphere outline + bright arc centered on the bearing (live check: bearing 135° → texture rotated exactly 135°), ripple, bearing-biased sparks, damage-scaled `k`, 520 ms decay; `shieldBreak` collapse cue (implosion + shockwave + shedding) fired on shield→hull break; rams route through the same bearing-based `damagePlayer`. Both contexts call the identical shared methods.\n- **Shake** — `combatShake` on hit/kill/break with `fx.shake` knobs (0.0045/0.007/0.011, 230 ms); live probe: world camera shake running, `fx-ui` camera shake inactive.\n- **Tuning surface, tests, constraints** — all knobs in `data/combat.json`; `node dev/combat.test.mjs` = 146 checks, 0 failures; no new dependencies (Phaser vendored), no build step, hand-rolled FX pattern (no emitters); depths in the 19–22 band, ships 10, HUD ≥60–120.\n\n### Blocking findings (verified live, not speculation)\n1. **`Fx.update` destroys the ships.** The expiry branch unconditionally calls `it.img.destroy()` — but `craftFlash` pushes items whose `img` is the ship itself (`kind:'tintFlash'`, 150–160 ms). Evidence:\n - Unit: real `Fx` class, `shieldImpact` + `fx.update(700ms)` → `shipDestroyedAfterShieldImpactExpire: true`.\n - Range: runtime stack captured `Ship.destroy ← Fx.update (Fx.js:599)`; after one non-lethal hit the enemy craft is gone 150 ms later with `dead:false` (no kill FX, no respawn bookkeeping — a ghost), then `CombatScene.update` throws `TypeError: Cannot read properties of undefined (reading 'velocity')` (`wallClamp`/`updatePlayer`) and the game loop dies. I reproduced this on a fresh page with no interaction beyond letting the pack fight.\n - Main game: the moment `combat.update` stepped, all 21 FX items — including the player ship's `tintFlash` — were consumed/destroyed.\n This contradicts \"both contexts boot and play\", \"no regressions\", and corrupts hit/kill/respawn bookkeeping in both contexts.\n2. **\"Visible white hit-flash on the enemy craft\" is not delivered.** In this vendored Phaser 4.2.1 build, `setTintFill` is a removed no-op that only `console.error`s (verified in `lib/phaser.min.js`: `` setTintFill:function(){console.error(...)}``). So `craftFlash` sets `setTint(0xffffff)` (invisible on an untinted sprite), the FILL mode is never applied, and the fallback bloom never triggers (nothing throws). Every hit spams a console error; the required white craft flash is absent.\n\n### Minor observations\n- Three untracked, unreferenced MP3s under `assets/fx/` (not wired into `data/sfx.json` or `CombatSfx.js`) — noise against the \"audio changes out of scope\" boundary.\n- In my main-game probes the `GameScene.update` loop was idle (tutorial comms hold), and the committed main-game screenshot shows a straight line of static bolts — the \"main game plays\" evidence is weaker than the range evidence, independent of the defects above.\n\nThe laser/impact/shield-sphere visuals are mostly real and well-built, but the shared `Fx` class killing the hit craft on flash expiry is a fatal, reproducible regression in the range (and a latent player-ship destroy in the main game), and the white hit-flash requirement is silently unmet.\n\n","at":"2026-09-17T04:42:45.235Z"},{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.034Z"},{"type":"audit_started","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.036Z"},{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"}],"oracle":[],"lifecycle":{"type":"goal_created","goalId":"mu4xefwj-ebnq8h","objective":"=== Goal ===\nObjective: A visual overhaul of combat FX in Orbit — visually impressive laser bolts from both the player and enemies, a strong impact read when enemies are hit, and a shield-sphere hit effect that shows the player's shield struck from the laser's incoming bearing with the hit region highlighted — delivered consistently in both combat contexts (the /combat range and the main game's ?combat boot).\n\nSuccess criteria:\n- Player and enemy shots render as elongated glowing bolts: hot-white core, colored halo (cyan player / magenta enemy), a fading motion trail along the flight axis, and a directional muzzle flash on fire — in BOTH the range (js/combat/CombatScene.js) and the main game (js/combat/CombatSystem.js).\n- Enemy hits show impact: a hot core flash at the point of impact, directional sparks along the bullet's travel vector, a visible white hit-flash on the enemy craft, a cyan arc ripple on shield hits vs orange sparks/debris on hull hits, and a polished damage-number pop.\n- Player shield hits read as a sphere being struck: a brief full-sphere outline flash, a bright arc segment centered on the laser's incoming bearing (the highlighted hit region), a radial ripple from the impact point, and a spark burst — intensity scaled with damage, decaying over ~0.5s. A shield→hull break shows a distinct shield-collapse cue. Rams (ship collisions) get the same impact treatment.\n- Subtle world-camera shake on player hits, enemy kills, and shield-break — the separate fx-ui camera keeps the HUD/combat bar perfectly still.\n- New visual parameters (bolt size/trail, muzzle, arc span, shake intensity, etc.) are tunable in data/combat.json, consistent with the project's single-tuning-surface convention.\n- No regressions: dev/combat.test.mjs passes with 0 failures; both contexts boot and play; no new dependencies, no build step.\n\nBoundaries:\n- In scope: js/combat/Bullet.js, js/combat/Fx.js, the FX call sites in js/combat/CombatScene.js and js/combat/CombatSystem.js (firing, collisions, damage, kills), camera shake wiring, and tuning knobs in data/combat.json.\n- Out of scope: gameplay numbers (damage, cooldowns, speeds, hull/shield values), enemy AI, new weapons or skills, audio changes (existing SFX events stay as-is), HUD/combat-bar redesign, and non-combat main-game visuals.\n\nConstraints:\n- Plain ES6 modules, no build step, no new dependencies (Phaser 4.2.1 stays vendored).\n- No particle emitters — this build's emitter velocity semantics are fiddly (documented in js/combat/Fx.js); follow the existing hand-rolled pattern: canvas-generated textures + additive-blended images stepped in update().\n- Keep the FX cheap enough for 60fps with 3+ enemies and auto-fire (small per-hit item counts, shared textures, depth discipline: ships 10, FX 19–22, HUD 100).\n- Implement in the SHARED code (Fx.js / Bullet.js) so the range and the main game stay identical; both contexts' FX call sites must be updated together.\n- Camera shake on the world camera only (the fx-ui UI camera must remain unshaken).\n\nVerification contract:\n- Run `node dev/combat.test.mjs` → 0 failures.\n- Manual pass in the range (/combat via the dev server) AND the main game (index.html?combat): player cannon fires elongated bolt + trail + directional muzzle flash; enemy hits show the impact event with distinct shield (cyan ripple) vs hull (orange debris) reads; an enemy lance landing on the player shows the sphere struck — arc highlight centered on the laser's bearing + ripple + sparks, with a shield-collapse cue when shields break; kills show the burst + subtle shake; HUD stays still throughout.\n- Re-read this goal's requirements (lasers, enemy impact, shield sphere hit from the laser's angle, hit-region highlight, shake) and confirm every item is addressed in both contexts before marking complete.\n\nIf blocked: stop and ask the user.","sisyphus":false,"autoContinue":true,"at":"2026-09-17T02:44:06.019Z"},"lastActivityEvent":{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"},"completion":{"type":"completion_requested","goalId":"mu4xefwj-ebnq8h","at":"2026-09-17T05:01:50.034Z"},"audit":{"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"}}],["mu7ieab2-f24hte",{"recent":[{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-1","evidence":"node --check clean on ShipWindow/Model/GameScene/ship-shot; headless boot (mp4 absent) report \"SMOKE OK — no console errors\", video: NO SIGNAL plate, window open/tab switch ok; deckAction exclusivity ","at":"2026-09-19T01:02:52.692Z"},{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-2","at":"2026-09-19T01:03:09.694Z"},{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"user","status":"paused","at":"2026-09-19T04:06:34.695Z"},{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"Scheduling paused","source":"agent","at":"2026-09-19T04:06:46.015Z"},{"type":"goal_resumed","goalId":"mu7ieab2-f24hte","reason":"user","at":"2026-09-19T04:17:54.636Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-2","evidence":"dev/ship-window.test.mjs: every configured row resolves finite from real data; fireRate=1/cd, DPS=damage/cd, accel=ship.thrust, recharge=combat.playerShield. Screenshots reviewed: nominal (HULL 100/CU","at":"2026-09-19T04:54:26.329Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-3","evidence":"dev/ship-window.test.mjs drives real XpManager: levels 0-4 → timeline, 5 & 6 → CHOOSE YOUR CLASS door, class on file → class plate (mode flips exactly at data classAt=5; maxLevel untouched). Screensho","at":"2026-09-19T05:04:37.254Z"},{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-4","at":"2026-09-19T05:04:55.422Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-4","evidence":"Screenshots reviewed at 1920x1080: empty hold (\"HOLD EMPTY — EVERY BAY IS CLEAN\", EMPTY tiles, +12 LOCKED pips, 0% / NO ORE ABOARD) and loaded (icons+names, MANIFEST tags, \"4/6 BAYS · 2 FREE\", +10 LOC","at":"2026-09-19T05:21:51.901Z"},{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-5","at":"2026-09-19T05:22:27.127Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-5","evidence":"Selecting a filled bay opens BAY MANIFEST (js/ui/ShipWindow.js _paintManifest): goods icon frame, name, CLASS/TIER/BASE VALUE chips (labels from data), flavor, MADE IN origin|hub pool + producer class","at":"2026-09-19T06:10:11.478Z"},{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"user","status":"paused","at":"2026-09-19T14:55:34.986Z"}],"activity":[{"type":"goal_created","goalId":"mu7ieab2-f24hte","objective":"Build the Ship Screen console — opened by the command deck's SHIP slot in the main game (GameScene), on the MapWindow bones (left looping video panel reading assets/videos/ship.mp4 via config with a skip-if-missing guard, right column of tabs) — and deliver two fully working tabs now: \"Ship Status\" (a good-looking stat plate covering hull integrity/current hull strength, shields/shield recharge rate & delay, acceleration/max speed, fire rate, damage, cargo bays, mineral storage, mining speed + combat level with a progress bar toward the next level and a timeline-style presentation of the player's journey from level 0 to level 5 that swaps to a \"Choose your Class\" button at level 5) and \"Cargo Holds\" (a summary of goods aboard by bay plus aggregate minerals aboard vs capacity, where selecting a held good shows its product summary and top charted exits in the same style as the planet buy/sell screens); plus a locked/OFFLINE \"Skill Tree\" tab so the final structure is visible. Success = deck-press opens/closes the console exclusively with the other consoles, both live tabs render correct real run data at multiple forced states, screenshots of every tab/state are reviewed, and `node dev/run-all.mjs` passes with a new Ship test registered in it.","sisyphus":false,"autoContinue":true,"at":"2026-09-18T22:07:23.053Z"},{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-1","at":"2026-09-18T22:10:43.158Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-1","evidence":"node --check clean on ShipWindow/Model/GameScene/ship-shot; headless boot (mp4 absent) report \"SMOKE OK — no console errors\", video: NO SIGNAL plate, window open/tab switch ok; deckAction exclusivity ","at":"2026-09-19T01:02:52.692Z"},{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-2","at":"2026-09-19T01:03:09.694Z"},{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"user","status":"paused","at":"2026-09-19T04:06:34.695Z"},{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"Scheduling paused","source":"agent","at":"2026-09-19T04:06:46.015Z"},{"type":"goal_resumed","goalId":"mu7ieab2-f24hte","reason":"user","at":"2026-09-19T04:17:54.636Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-2","evidence":"dev/ship-window.test.mjs: every configured row resolves finite from real data; fireRate=1/cd, DPS=damage/cd, accel=ship.thrust, recharge=combat.playerShield. Screenshots reviewed: nominal (HULL 100/CU","at":"2026-09-19T04:54:26.329Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-3","evidence":"dev/ship-window.test.mjs drives real XpManager: levels 0-4 → timeline, 5 & 6 → CHOOSE YOUR CLASS door, class on file → class plate (mode flips exactly at data classAt=5; maxLevel untouched). Screensho","at":"2026-09-19T05:04:37.254Z"},{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-4","at":"2026-09-19T05:04:55.422Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-4","evidence":"Screenshots reviewed at 1920x1080: empty hold (\"HOLD EMPTY — EVERY BAY IS CLEAN\", EMPTY tiles, +12 LOCKED pips, 0% / NO ORE ABOARD) and loaded (icons+names, MANIFEST tags, \"4/6 BAYS · 2 FREE\", +10 LOC","at":"2026-09-19T05:21:51.901Z"},{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-5","at":"2026-09-19T05:22:27.127Z"},{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-5","evidence":"Selecting a filled bay opens BAY MANIFEST (js/ui/ShipWindow.js _paintManifest): goods icon frame, name, CLASS/TIER/BASE VALUE chips (labels from data), flavor, MADE IN origin|hub pool + producer class","at":"2026-09-19T06:10:11.478Z"},{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"user","status":"paused","at":"2026-09-19T14:55:34.986Z"}],"oracle":[],"lifecycle":{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"user","status":"paused","at":"2026-09-19T14:55:34.986Z"},"lastActivityEvent":{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"user","status":"paused","at":"2026-09-19T14:55:34.986Z"}}]]} \ No newline at end of file diff --git a/.pi/goals/active_goal_2026091816072305_mu7ieab2-f24hte.md b/.pi/goals/active_goal_2026091816072305_mu7ieab2-f24hte.md new file mode 100644 index 0000000..e8bbd11 --- /dev/null +++ b/.pi/goals/active_goal_2026091816072305_mu7ieab2-f24hte.md @@ -0,0 +1,99 @@ +{ + "version": 3, + "id": "mu7ieab2-f24hte", + "objective": "Build the Ship Screen console — opened by the command deck's SHIP slot in the main game (GameScene), on the MapWindow bones (left looping video panel reading assets/videos/ship.mp4 via config with a skip-if-missing guard, right column of tabs) — and deliver two fully working tabs now: \"Ship Status\" (a good-looking stat plate covering hull integrity/current hull strength, shields/shield recharge rate & delay, acceleration/max speed, fire rate, damage, cargo bays, mineral storage, mining speed + combat level with a progress bar toward the next level and a timeline-style presentation of the player's journey from level 0 to level 5 that swaps to a \"Choose your Class\" button at level 5) and \"Cargo Holds\" (a summary of goods aboard by bay plus aggregate minerals aboard vs capacity, where selecting a held good shows its product summary and top charted exits in the same style as the planet buy/sell screens); plus a locked/OFFLINE \"Skill Tree\" tab so the final structure is visible. Success = deck-press opens/closes the console exclusively with the other consoles, both live tabs render correct real run data at multiple forced states, screenshots of every tab/state are reviewed, and `node dev/run-all.mjs` passes with a new Ship test registered in it.", + "status": "paused", + "autoContinue": false, + "usage": { + "tokensUsed": 1569625, + "activeSeconds": 19608 + }, + "sisyphus": false, + "revision": 383, + "createdAt": "2026-09-18T22:07:23.053Z", + "updatedAt": "2026-09-19T14:55:35.006Z", + "scheduler": { + "version": 1, + "owner": "01a0b67f-b950-7128-bde1-b1647328e168", + "generation": "e38a024d-7b6d-4c97-91a3-728e8b1479a3", + "used": 2, + "phase": "idle", + "repairUsed": false + }, + "taskList": { + "tasks": [ + { + "id": "task-1", + "title": "Ship console shell: data/shipScreen.json + ShipWindow.js + deck wiring (video panel, tab bar with locked Skill Tree, open/close/exclusivity)", + "status": "complete", + "verificationContract": "data/shipScreen.json exists and is listed in data/manifest.json (layout, colors, copy, video {file:'assets/videos/ship.mp4'}, tab order, stat-source table all data-driven); js/ui/ShipWindow.js follows the MapWindow contract (static VIDEO_KEY, isOpen, update(time), depth 80, cut-corner plate, decoded title, ✕ close, ESC/scrim close, glitch/SFX voice); GameScene preloads the clip with the same skip-if-missing guard as map/quests and boots clean with assets/videos/ship.mp4 ABSENT (no console errors, procedural plate shows instead); deckAction('ship') toggles the console and closes/closed-by research, quests, map and the sub-bar/comms panel exactly like 'map'; SurfaceScene untouched. Evidence: node --check on touched files + browser/dev-page boot log showing no errors with the mp4 missing.", + "completedAt": "2026-09-19T01:02:52.692Z", + "evidence": "node --check clean on ShipWindow/Model/GameScene/ship-shot; headless boot (mp4 absent) report \"SMOKE OK — no console errors\", video: NO SIGNAL plate, window open/tab switch ok; deckAction exclusivity " + }, + { + "id": "task-2", + "title": "Ship Status tab — stat readout plate (hull, current hull strength, shields, shield recharge rate/delay, acceleration, max speed, fire rate, damage, cargo/mining) driven by real run state", + "status": "complete", + "verificationContract": "Every requested stat renders from its live source of truth — data/ship.json → stats (hullIntegrity, shields, cargoHold, mineralStorage, miningSpeed), data/combat.json → playerShip (shieldRegenRate/shieldRegenDelay) and player.weapon (cooldown→shots/sec, damage, bulletSpeed), plus flight feel from ship.thrust/maxSpeed — with NO hardcoded numbers in code (labels/units/order/format come from data/shipScreen.json); deltas from research/builds are reflected where they already mutate ship.stats; hull/shield read the live combat session (this.combat.hull/.shields) while combat is active and otherwise show current max with an explicit 'nominal' state per the confirmed decision; values refresh while open. Evidence: pure test asserts every configured stat row resolves to a finite number from real data files + screenshot of the tab.", + "completedAt": "2026-09-19T04:54:26.328Z", + "evidence": "dev/ship-window.test.mjs: every configured row resolves finite from real data; fireRate=1/cd, DPS=damage/cd, accel=ship.thrust, recharge=combat.playerShield. Screenshots reviewed: nominal (HULL 100/CU" + }, + { + "id": "task-3", + "title": "Combat level + XP progress bar and the level 0→5 journey timeline, with the level-5 'Choose your Class' state seam", + "status": "complete", + "verificationContract": "Level, xp, xpForNext and progress read GameScene.combat.xp (XpManager.stats()) — no parallel ledger; segmented 0→5 timeline renders milestone nodes with the in-between progress fill, current-level marker, and per-milestone labels/copy from data/shipScreen.json; bar shows partial progress to next level; at level ≥5 the timeline is REPLACED by a 'Choose your Class' button that fires a documented onChooseClass seam (visible placeholder response, no dead click) and the class+insignia display slot exists as an empty data-driven seam (selection UI explicitly out of scope); maxLevel 80 in data/progression.json stays untouched. Evidence: pure test drives XpManager through levels 0..5 asserting timeline-vs-button state flips exactly at 5; screenshots at forced levels 0, 2 partial, 5.", + "completedAt": "2026-09-19T05:04:37.254Z", + "evidence": "dev/ship-window.test.mjs drives real XpManager: levels 0-4 → timeline, 5 & 6 → CHOOSE YOUR CLASS door, class on file → class plate (mode flips exactly at data classAt=5; maxLevel untouched). Screensho" + }, + { + "id": "task-4", + "title": "Cargo Holds tab — hold summary (bay grid with filled/locked bays, good icons + names) and aggregate minerals aboard vs capacity", + "status": "complete", + "verificationContract": "Renders ship.cargo (CargoHold: one good per bay, fill order) against unlocked bays from ship.stats.cargoHold and economy.cargo.maxSlots, showing used/room counts plus locked-bay upgrade room; minerals shown as aggregate only — ship.minerals vs ship.stats.mineralStorage with a fill bar and % (no new mineral data model), matching what MineralHud reads; empty-hold state reads as an intentional empty plate, not a broken layout; updates live after buys/sells/mining while the console is open. Evidence: pure test asserts summary math for edge states (0 bays, full hold, minerals 0/at cap) + screenshots of those states.", + "completedAt": "2026-09-19T05:21:51.901Z", + "evidence": "Screenshots reviewed at 1920x1080: empty hold (\"HOLD EMPTY — EVERY BAY IS CLEAN\", EMPTY tiles, +12 LOCKED pips, 0% / NO ORE ABOARD) and loaded (icons+names, MANIFEST tags, \"4/6 BAYS · 2 FREE\", +10 LOC" + }, + { + "id": "task-5", + "title": "Cargo item detail — selected bay shows the good's product summary and top charted exits in the buy/sell style", + "status": "complete", + "verificationContract": "Selecting a filled bay opens a detail panel with icon, name, category/tier, flavor, credits value, origin/producer/demand classes and the ranked top-N charted exits (price + per-unit profit bar) built from js/economy/TradeScout.js scoutGood() using the run's galaxy, current system and visitedSystems — reusing ScoutPanel.js or its rendering recipe rather than duplicating economy logic; since there is no local shop on this screen, the profit baseline is documented and data-driven (goods.json baseValue / Economy buy price) and asserted by test; de-selected state restores the hold summary; unknown/goods-less bays degrade gracefully. Evidence: pure test asserts scout report fields for a held good against real data/goods.json + screenshots with a good selected.", + "completedAt": "2026-09-19T06:10:11.477Z", + "evidence": "Selecting a filled bay opens BAY MANIFEST (js/ui/ShipWindow.js _paintManifest): goods icon frame, name, CLASS/TIER/BASE VALUE chips (labels from data), flavor, MADE IN origin|hub pool + producer class" + }, + { + "id": "task-6", + "title": "Verification pass — new dev/ship-window.test.mjs in run-all, cdp-shot screenshot harness per tab/state, full suite green", + "status": "pending", + "verificationContract": "New dev/ship-window.test.mjs runs under bare node and is picked up by dev/run-all.mjs; new dev/ship-shot.html boots the game and opens the console (with a documented way to force cargo/minerals/level for shots), captured via dev/cdp-shot.mjs for: Ship Status at levels 0 / mid-progress / level 5 (button state), Cargo Holds empty + loaded, and cargo item detail; screenshots inspected for layout overlap, off-screen text, dark-on-dark copy and tab-state legibility; `node dev/run-all.mjs` finishes with zero failures; no changes to committed game behavior outside the new console beyond the deckAction('ship') seam; work left unstaged with a suggested commit message (Brian commits)." + } + ], + "blockCompletion": true, + "proposedAt": "2026-09-18T21:58:39.258Z" + }, + "activePath": ".pi/goals/active_goal_2026091816072305_mu7ieab2-f24hte.md", + "stopReason": "user" +} + +# Goal Prompt + +Build the Ship Screen console — opened by the command deck's SHIP slot in the main game (GameScene), on the MapWindow bones (left looping video panel reading assets/videos/ship.mp4 via config with a skip-if-missing guard, right column of tabs) — and deliver two fully working tabs now: "Ship Status" (a good-looking stat plate covering hull integrity/current hull strength, shields/shield recharge rate & delay, acceleration/max speed, fire rate, damage, cargo bays, mineral storage, mining speed + combat level with a progress bar toward the next level and a timeline-style presentation of the player's journey from level 0 to level 5 that swaps to a "Choose your Class" button at level 5) and "Cargo Holds" (a summary of goods aboard by bay plus aggregate minerals aboard vs capacity, where selecting a held good shows its product summary and top charted exits in the same style as the planet buy/sell screens); plus a locked/OFFLINE "Skill Tree" tab so the final structure is visible. Success = deck-press opens/closes the console exclusively with the other consoles, both live tabs render correct real run data at multiple forced states, screenshots of every tab/state are reviewed, and `node dev/run-all.mjs` passes with a new Ship test registered in it. + +## Progress + +- Status: paused +- Auto-continue: off +- Sisyphus mode: no +- Time spent: 5h26m48s +- Tokens used: 1.6M (1,569,625) tokens +## Tasks + + +- [x] task-1: Ship console shell: data/shipScreen.json + ShipWindow.js + deck wiring (video panel, tab bar with locked Skill Tree, open/close/exclusivity) — evidence: node --check clean on ShipWindow/Model/GameScene/ship-shot; headless boot (mp4 absent) report "SMOKE OK — no console errors", video: NO SIGNAL plate, window open/tab switch ok; deckAction exclusivity +- [x] task-2: Ship Status tab — stat readout plate (hull, current hull strength, shields, shield recharge rate/delay, acceleration, max speed, fire rate, damage, cargo/mining) driven by real run state — evidence: dev/ship-window.test.mjs: every configured row resolves finite from real data; fireRate=1/cd, DPS=damage/cd, accel=ship.thrust, recharge=combat.playerShield. Screenshots reviewed: nominal (HULL 100/CU +- [x] task-3: Combat level + XP progress bar and the level 0→5 journey timeline, with the level-5 'Choose your Class' state seam — evidence: dev/ship-window.test.mjs drives real XpManager: levels 0-4 → timeline, 5 & 6 → CHOOSE YOUR CLASS door, class on file → class plate (mode flips exactly at data classAt=5; maxLevel untouched). Screensho +- [x] task-4: Cargo Holds tab — hold summary (bay grid with filled/locked bays, good icons + names) and aggregate minerals aboard vs capacity — evidence: Screenshots reviewed at 1920x1080: empty hold ("HOLD EMPTY — EVERY BAY IS CLEAN", EMPTY tiles, +12 LOCKED pips, 0% / NO ORE ABOARD) and loaded (icons+names, MANIFEST tags, "4/6 BAYS · 2 FREE", +10 LOC +- [x] task-5: Cargo item detail — selected bay shows the good's product summary and top charted exits in the buy/sell style — evidence: Selecting a filled bay opens BAY MANIFEST (js/ui/ShipWindow.js _paintManifest): goods icon frame, name, CLASS/TIER/BASE VALUE chips (labels from data), flavor, MADE IN origin|hub pool + producer class +- [ ] task-6: Verification pass — new dev/ship-window.test.mjs in run-all, cdp-shot screenshot harness per tab/state, full suite green — contract: New dev/ship-window.test.mjs runs under bare node and is picked up by dev/run-all.mjs; new dev/ship-shot.html boots the game and opens the console (with a documented way to force cargo/minerals/level for shots), captured via dev/cdp-shot.mjs for: Ship Status at levels 0 / mid-progress / level 5 (button state), Cargo Holds empty + loaded, and cargo item detail; screenshots inspected for layout overlap, off-screen text, dark-on-dark copy and tab-state legibility; `node dev/run-all.mjs` finishes with zero failures; no changes to committed game behavior outside the new console beyond the deckAction('ship') seam; work left unstaged with a suggested commit message (Brian commits). + diff --git a/.pi/goals/goal_events.jsonl b/.pi/goals/goal_events.jsonl index f1d252d..fb3cde8 100644 --- a/.pi/goals/goal_events.jsonl +++ b/.pi/goals/goal_events.jsonl @@ -29,3 +29,20 @@ {"type":"audit_result","goalId":"mu4xefwj-ebnq8h","verdict":"approved","report":"Fx.js and Bullet.js look substantial. Now the shake module and tuning surface.\n\nNow let me inspect the call sites in both contexts.\n\nThe dev server and headless browser are live. Let me check the debug seams exposed by both contexts, then run a read-only live probe.\n\nI've verified the code paths on both sides. Now I'll run a live probe of both contexts through the dev server to confirm everything's working.\n\nThe two \"FAIL\"s within the range are actually bugs in my probe (scale is 46×10 world px = elongated; -180° ≡ 180°). The main game result needs investigation — let me inspect the actual shape of `__ORBIT_COMBAT__`:\n\nAll checks are now resolved. Final audit summary:\n\n**Verified via code inspection + live CDP probes in both contexts (range `/combat` and `index.html?combat`), with zero console errors:**\n\n1. **Laser bolts (both contexts):** 128×32 canvas bolt texture (fading trail band + bright core streak + head bloom) scaled to 46×10 px player / 54×12 px enemy (elongated 4.6:1, measured), tinted halo (cyan `00e5ff` player, `#ff2d6f` enemy from weapon config), untinted white core dot riding the tip (measured offset 17.25 px ahead of center), directional muzzle flare — bore spike measured at the exact firing angle in both contexts. `fx.muzzle` + `spawnBullet(boltLength/boltWidth)` call sites are symmetric in `CombatScene.js` and `CombatSystem.js`.\n\n2. **Enemy impact reads:** hot white + colored core flash, directional sparks/shards measured within the cone along the bullet's actual travel vector, white craft hit-flash via `tintMode=1` FILL (measured `ffffff`/mode 1 during flash, restored to 0 on expiry — and the previously-flagged ship-destruction defect is fixed: enemy and player ship both survive expiry, verified live), cyan arc ripple at the exact incoming bearing (travel +180°, measured 134.2°/134.2° in range and −42.5°/−42.5° in main) vs orange bolt-textured shards + tight orange ring with **no** arc on hull hits (measured), damage-number pops (measured \"10\"/\"8\").\n\n3. **Shield-sphere strike:** faint full-sphere outline + bright arc segment (1.90 rad, span-keyed texture `__combat_shield_s1.90`) centered exactly on the laser's incoming bearing (measured 135° and 45°), radial ripple from the impact point, outward-biased spark burst, ship white flash, damage-scaled intensity, ~0.5 s decay (config `decayMs: 520`); shield→hull break shows the `ringIn` implosion + shockwave (measured); rams funnel through the same bearing-based `damagePlayer` treatment in both contexts.\n\n4. **Shake:** world camera `shakeEffect` measured running on player hit, enemy kill, and shield-break; `fx-ui` camera measured inactive in both contexts (HUD/bar on the untouched UI pass).\n\n5. **Tuning surface:** `boltLength/boltWidth/muzzle` + `fx.shake` + `fx.shield` knobs in `data/combat.json`, all read via `config.get/section`; live values match config; git diff confirms only visual knobs were added (no gameplay numbers touched).\n\n6. **No regressions:** `node dev/combat.test.mjs` → 146 checks, 0 failures, exit 0; only that suite imports the changed modules; both contexts boot and play (range fully exercised; main game ran a live 3 s auto-fire fight with the pack intact, ship alive); diff confined to `js/combat/*` + `CamShake.js` + `data/combat.json` — no new dependencies, no build step, hand-rolled FX (no emitters), depth discipline (ships 10 / FX 19–22 / HUD 100) holds.\n\nThe eight \"failures\" in my first probe round were all arithmetic/sequencing bugs in my own probe (angle wraparound, aspect-ratio math, a missing target fallback) — each was re-verified individually and passes.\n\n","at":"2026-09-17T05:08:04.224Z"} {"type":"goal_completed","goalId":"mu4xefwj-ebnq8h","archivePath":".pi/goals/archived/goal_2026091623080422_mu4xefwj-ebnq8h.md","at":"2026-09-17T05:08:04.233Z"} {"type":"goal_archived","goalId":"mu4xefwj-ebnq8h","archivePath":".pi/goals/archived/goal_2026091623080422_mu4xefwj-ebnq8h.md","at":"2026-09-17T05:08:04.234Z"} +{"type":"goal_created","goalId":"mu7ieab2-f24hte","objective":"Build the Ship Screen console — opened by the command deck's SHIP slot in the main game (GameScene), on the MapWindow bones (left looping video panel reading assets/videos/ship.mp4 via config with a skip-if-missing guard, right column of tabs) — and deliver two fully working tabs now: \"Ship Status\" (a good-looking stat plate covering hull integrity/current hull strength, shields/shield recharge rate & delay, acceleration/max speed, fire rate, damage, cargo bays, mineral storage, mining speed + combat level with a progress bar toward the next level and a timeline-style presentation of the player's journey from level 0 to level 5 that swaps to a \"Choose your Class\" button at level 5) and \"Cargo Holds\" (a summary of goods aboard by bay plus aggregate minerals aboard vs capacity, where selecting a held good shows its product summary and top charted exits in the same style as the planet buy/sell screens); plus a locked/OFFLINE \"Skill Tree\" tab so the final structure is visible. Success = deck-press opens/closes the console exclusively with the other consoles, both live tabs render correct real run data at multiple forced states, screenshots of every tab/state are reviewed, and `node dev/run-all.mjs` passes with a new Ship test registered in it.","sisyphus":false,"autoContinue":true,"at":"2026-09-18T22:07:23.053Z"} +{"type":"task_list_set","goalId":"mu7ieab2-f24hte","taskCount":6,"blockCompletion":true,"at":"2026-09-18T22:07:23.053Z"} +{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-1","at":"2026-09-18T22:10:43.158Z"} +{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-1","at":"2026-09-18T22:51:44.070Z"} +{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-1","at":"2026-09-18T23:58:09.711Z"} +{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-1","evidence":"node --check clean on ShipWindow/Model/GameScene/ship-shot; headless boot (mp4 absent) report \"SMOKE OK — no console errors\", video: NO SIGNAL plate, window open/tab switch ok; deckAction exclusivity ","at":"2026-09-19T01:02:52.692Z"} +{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-2","at":"2026-09-19T01:03:09.694Z"} +{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"user","status":"paused","at":"2026-09-19T04:06:34.695Z"} +{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"Scheduling paused","source":"agent","at":"2026-09-19T04:06:46.015Z"} +{"type":"goal_resumed","goalId":"mu7ieab2-f24hte","reason":"user","at":"2026-09-19T04:17:54.636Z"} +{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-2","evidence":"dev/ship-window.test.mjs: every configured row resolves finite from real data; fireRate=1/cd, DPS=damage/cd, accel=ship.thrust, recharge=combat.playerShield. Screenshots reviewed: nominal (HULL 100/CU","at":"2026-09-19T04:54:26.329Z"} +{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-3","evidence":"dev/ship-window.test.mjs drives real XpManager: levels 0-4 → timeline, 5 & 6 → CHOOSE YOUR CLASS door, class on file → class plate (mode flips exactly at data classAt=5; maxLevel untouched). Screensho","at":"2026-09-19T05:04:37.254Z"} +{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-4","at":"2026-09-19T05:04:55.422Z"} +{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-4","evidence":"Screenshots reviewed at 1920x1080: empty hold (\"HOLD EMPTY — EVERY BAY IS CLEAN\", EMPTY tiles, +12 LOCKED pips, 0% / NO ORE ABOARD) and loaded (icons+names, MANIFEST tags, \"4/6 BAYS · 2 FREE\", +10 LOC","at":"2026-09-19T05:21:51.901Z"} +{"type":"task_started","goalId":"mu7ieab2-f24hte","taskId":"task-5","at":"2026-09-19T05:22:27.127Z"} +{"type":"task_complete","goalId":"mu7ieab2-f24hte","taskId":"task-5","evidence":"Selecting a filled bay opens BAY MANIFEST (js/ui/ShipWindow.js _paintManifest): goods icon frame, name, CLASS/TIER/BASE VALUE chips (labels from data), flavor, MADE IN origin|hub pool + producer class","at":"2026-09-19T06:10:11.478Z"} +{"type":"goal_paused","goalId":"mu7ieab2-f24hte","reason":"user","status":"paused","at":"2026-09-19T14:55:34.986Z"} diff --git a/data/manifest.json b/data/manifest.json index 67ab4bc..362bdda 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -4,6 +4,7 @@ "theme.json", "menu.json", "ship.json", + "shipScreen.json", "planets.json", "asteroids.json", "tether.json", diff --git a/data/shipScreen.json b/data/shipScreen.json new file mode 100644 index 0000000..b8c443f --- /dev/null +++ b/data/shipScreen.json @@ -0,0 +1,374 @@ +{ + "_comment": "SHIP CONSOLE \u2014 the command deck's SHIP button (js/ui/ShipWindow.js, depth 80, the MapWindow/QuestWindow idiom). LEFT = the bay camera (assets/videos/ship.mp4, a muted 2:3 loop; the clip is authored separately \u2014 a missing file leaves the NO SIGNAL plate up and the console still works). RIGHT = the tab row (Ship Status / Skill Tree / Cargo Holds \u2014 `standby: true` renders the socket dimmed with its standbyTag and a press fires the locked toast instead of switching) over the tab's plate. The Ship Status plate reads the ship's LIVE numbers through the `status.rows` table below: code owns behavior, this file owns labels, units, order, formatting and where each number comes from (`from` = a namespaced path \u2014 `config:`, `ship:`, `live:` read while a fight is running and off the maxima otherwise; `op: reciprocal` turns a cooldown into a rate). Ship Skill Tree stays OFFLINE until the class/skill layer lands; Cargo Holds lists the trade bays + the mineral aggregate, and a filled bay opens the good's scout summary (js/economy/TradeScout.js \u2014 the buy/sell screens' report).", + "enabled": true, + "title": "SHIP SYSTEMS", + "meta": "HULL & HARDWARE // LIVE TELEMETRY", + "video": { + "_comment": "The bay camera feed (same contract as map.video / quests.video): `file` is a clip under assets/videos/ (or a full relative path / URL). Muted, looping, 2:3 portrait. A missing file leaves the NO SIGNAL plate up.", + "file": "ship.mp4", + "aspect": [ + 2, + 3 + ], + "tag": "BAY CAMERA // LIVE FEED", + "noSignalTag": "BAY CAMERA OFFLINE" + }, + "tabs": [ + { + "id": "status", + "label": "Ship Status", + "accent": "#7ce8a4" + }, + { + "id": "skills", + "label": "Skill Tree", + "accent": "#ffc94d", + "standby": true, + "standbyTag": "OFFLINE" + }, + { + "id": "cargo", + "label": "Cargo Holds", + "accent": "#00e5ff" + } + ], + "skillsLockedToast": "SKILL TREE OFFLINE \u2014 NO CLASS ON FILE", + "skillsPanel": { + "_comment": "The OFFLINE tab's body (the final structure stays visible): the locked plate's headline + the copy that explains what lands there.", + "headline": "SKILL TREE OFFLINE", + "note": "COMBAT SKILLS ARM WHEN THE CLASS LAYER LANDS.\nCHOOSE A CLASS AT COMBAT LEVEL 5 \u2014 THE TREE GROWS FROM THERE." + }, + "glitch": { + "_comment": "The console's glitch voice (the quests window's): intervalMs between bursts, durationMs the burst holds, slices RGB bands across the feed, maxOffset px of slide.", + "enabled": true, + "intervalMs": [ + 4200, + 9200 + ], + "durationMs": [ + 110, + 260 + ], + "slices": [ + 1, + 4 + ], + "maxOffset": 9 + }, + "sweep": { + "_comment": "The scan band that crawls down the feed while the console is up.", + "enabled": true, + "intervalMs": [ + 5200, + 9800 + ], + "durationMs": [ + 1400, + 2200 + ] + }, + "status": { + "_comment": "The SHIP STATUS tab. readout = the hardware table (order = draw order); level = the combat record + the level 0\u21925 journey.", + "hardwareTitle": "HARDWARE READOUT", + "groups": [ + { + "id": "armor", + "label": "ARMOR" + }, + { + "id": "flight", + "label": "FLIGHT" + }, + { + "id": "guns", + "label": "GUNS" + }, + { + "id": "hold", + "label": "HOLD" + } + ], + "rows": [ + { + "_comment": "HULL \u2014 the ship's max structure (data/ship.json \u2192 stats.hullIntegrity) with the live combat hull as the meter fill.", + "id": "hull", + "group": "armor", + "label": "HULL STRENGTH", + "from": "live:hullMax", + "unit": "HP", + "format": "int", + "meter": { + "of": "live:hull" + }, + "accent": "#ff2d6f", + "note": "STRUCTURAL LIMIT", + "hotBelow": 0.34 + }, + { + "_comment": "CURRENT HULL \u2014 the armor actually on the ship right now (the live fight's hull; full strength with no fight running).", + "id": "hullNow", + "group": "armor", + "label": "CURRENT HULL", + "from": "live:hull", + "unit": "HP", + "format": "int", + "meter": { + "_comment": "The bar is the SAME armor meter as HULL STRENGTH (current over max) \u2014 written as an explicit pair because this row's own value is the numerator.", + "fill": "live:hull", + "max": "live:hullMax" + }, + "accent": "#ff7ba0", + "nominalLabel": "NOMINAL", + "hotBelow": 0.34 + }, + { + "id": "shields", + "group": "armor", + "label": "SHIELDS", + "from": "live:shieldsMax", + "unit": "HP", + "format": "int", + "accent": "#00e5ff", + "hotBelow": 0.34 + }, + { + "_comment": "SHIELD LEVEL \u2014 the live shield bar (recharges after shieldRegenDelay s without a hit at shieldRegenRate/s).", + "id": "shieldNow", + "group": "armor", + "label": "SHIELD LEVEL", + "from": "live:shields", + "unit": "HP", + "format": "int", + "meter": { + "_comment": "Same meter as SHIELDS (current over max).", + "fill": "live:shields", + "max": "live:shieldsMax" + }, + "accent": "#78b4ff", + "nominalLabel": "FULL", + "hotBelow": 0.34 + }, + { + "id": "shieldRegen", + "group": "armor", + "label": "SHIELD RECHARGE", + "from": "config:combat.playerShip.shieldRegenRate", + "unit": "/s", + "format": "int", + "accent": "#00e5ff", + "meta": { + "_comment": "The recharge's dead time after a hit (data/combat.json \u2192 playerShip.shieldRegenDelay), drawn as the row's meta line.", + "label": "ARM AFTER", + "from": "config:combat.playerShip.shieldRegenDelay", + "unit": "s", + "format": "int" + } + }, + { + "id": "accel", + "group": "flight", + "label": "ACCELERATION", + "from": "config:ship.thrust", + "unit": "px/s\u00b2", + "format": "int", + "accent": "#7ce8a4" + }, + { + "id": "maxSpeed", + "group": "flight", + "label": "MAX VELOCITY", + "from": "config:ship.maxSpeed", + "unit": "px/s", + "format": "int", + "accent": "#7ce8a4" + }, + { + "id": "turn", + "group": "flight", + "label": "TURN RATE", + "from": "config:ship.rotSpeed", + "unit": "rad/s", + "format": "1dp", + "accent": "#7ce8a4" + }, + { + "_comment": "FIRE RATE \u2014 the cannon's shots per second (the reciprocal of data/combat.json \u2192 player.weapon.cooldown).", + "id": "fireRate", + "group": "guns", + "label": "FIRE RATE", + "from": "config:combat.player.weapon.cooldown", + "op": "reciprocal", + "unit": "/s", + "format": "2dp", + "accent": "#ffc94d" + }, + { + "id": "damage", + "group": "guns", + "label": "DAMAGE", + "from": "config:combat.player.weapon.damage", + "unit": "/hit", + "format": "int", + "accent": "#ffc94d" + }, + { + "_comment": "DPS \u2014 damage \u00d7 fire rate (the row's meta read, not a stored number).", + "id": "dps", + "group": "guns", + "label": "DPS", + "from": [ + { + "from": "config:combat.player.weapon.damage" + }, + { + "from": "config:combat.player.weapon.cooldown", + "op": "reciprocal" + } + ], + "op": "product", + "unit": "/s", + "format": "1dp", + "accent": "#ffd84d", + "meta": { + "label": "SHOT SPEED", + "from": "config:combat.player.weapon.bulletSpeed", + "unit": "px/s", + "format": "int" + } + }, + { + "id": "cargoBays", + "group": "hold", + "label": "CARGO BAYS", + "from": "ship:stats.cargoHold", + "unit": "bays", + "format": "int", + "meter": { + "of": "ship:cargo.used" + }, + "accent": "#00e5ff", + "meta": { + "label": "GRID", + "from": "config:economy.cargo.maxSlots", + "unit": "SLOTS", + "format": "int" + } + }, + { + "id": "mineralHold", + "group": "hold", + "label": "MINERAL STORAGE", + "from": "ship:stats.mineralStorage", + "unit": "u", + "format": "int", + "meter": { + "of": "ship:minerals" + }, + "accent": "#78b4ff" + }, + { + "id": "miningSpeed", + "group": "hold", + "label": "MINING RATE", + "from": "ship:stats.miningSpeed", + "unit": "\u00d7 base", + "format": "2dp", + "accent": "#a8ffc4" + } + ], + "creditsLabel": "CREDITS", + "noCombatNote": "DRIVE DARK \u2014 NO CONTACTS" + }, + "level": { + "_comment": "The combat record (js/combat/XpManager.js \u2014 the curve is data/progression.json). The journey plate shows levels `from`\u2026`to` as a timeline of milestone nodes with the XP fill between them; at `classAt` (and above) the timeline gives way to CHOOSE YOUR CLASS, and once a class is on file (playerClass.name) it shows the class + insignia instead. Every string lives here.", + "title": "COMBAT RECORD", + "levelWord": "LEVEL", + "xpLabel": "XP", + "nextLabel": "NEXT LV", + "maxLabel": "MAX LEVEL", + "journeyTitle": "THE JOURNEY // LV {from}\u2013{to}", + "from": 0, + "to": 5, + "classAt": 5, + "milestones": [ + { + "level": 0, + "label": "SADDLE", + "note": "FIRST SEAT" + }, + { + "level": 1, + "label": "SOLO", + "note": "FIRST KILL" + }, + { + "level": 2, + "label": "SCUFFED", + "note": "HULL HOLDING" + }, + { + "level": 3, + "label": "HUNTED", + "note": "ON THE BOARD" + }, + { + "level": 4, + "label": "VETERAN", + "note": "GUNS HOT" + }, + { + "level": 5, + "label": "CHOSEN", + "note": "PICK A CLASS" + } + ], + "chooseClass": { + "_comment": "The level-5 door: the timeline's replacement. The press fires the scene's onChooseClass seam (the class-selection flow lands with the class layer \u2014 a toast for now, never a dead click).", + "label": "CHOOSE YOUR CLASS", + "hint": "LEVEL {classAt} REACHED \u2014 THE CLASS LAYER AWAITS", + "toast": "CLASS SELECT OFFLINE \u2014 THE CLASS LAYER LANDS NEXT" + }, + "classPlate": { + "_comment": "The class + insignia slot (data-driven seam: shipClass/insignia come from the save once class selection exists; until then the console shows the CHOOSE YOUR CLASS door).", + "label": "CLASS", + "noneLabel": "NO CLASS ON FILE" + } + }, + "cargo": { + "_comment": "The CARGO HOLDS tab. The hold is trade goods only \u2014 one good per bay, in fill order (js/economy/CargoHold.js); locked bays are the hold's upgrade room. MINERALS is the aggregate aboard vs capacity (the Mineral HUD's numbers \u2014 js/entities/Ship.js). A filled bay is tappable: it opens the good's summary + best exits (data below \u2192 detail).", + "holdTitle": "TRADE BAYS", + "holdMeta": "{used} / {capacity} BAYS \u00b7 {room} FREE", + "emptyNote": "HOLD EMPTY \u2014 EVERY BAY IS CLEAN", + "noBaysNote": "NO CARGO BAYS INSTALLED \u2014 UPGRADE THE HOLD", + "lockedTag": "LOCKED", + "manifestTag": "MANIFEST \u25b8", + "lockedRoomMeta": "+{locked} LOCKED BAYS \u00b7 UPGRADE THE HOLD AT HARGUN ({slots} TOTAL)", + "mineralsTitle": "MINERAL HOLDS", + "mineralsMeta": "{minerals} / {capacity} u", + "emptyMineralsNote": "NO ORE ABOARD", + "creditsLabel": "CREDITS", + "detail": { + "_comment": "The selected good's summary (the shop scout's report \u2014 js/economy/TradeScout.js scoutGood). `baseline` is the profit reference with no shop under it: baseValue = the catalog value (data/goods.json), buy = this system's own buy price.", + "title": "BAY MANIFEST", + "categoryLabel": "CLASS", + "tierLabel": "T", + "valueLabel": "BASE VALUE", + "originLabel": "MADE IN", + "hubLabel": "HUB POOL", + "producerLabel": "MADE OF", + "demandLabel": "SOUGHT ON", + "exitsTitle": "BEST EXITS", + "exitsEmpty": "NO CHARTED EXIT YET \u2014 EVERY CAPITAL BUYS EVERYTHING", + "exitMeta": "{hops} HOPS", + "profitBar": { + "_comment": "the per-unit margin rail under each exit row \u2014 scaled against the list's best row, so goods of different value compare the same way", + "track": "#14324f", + "fill": "#7ce8a4", + "fillNeg": "#ff5a6e" + }, + "baseline": "baseValue", + "topN": 3, + "backLabel": "BACK TO BAYS" + } + } +} diff --git a/dev/ship-shot.html b/dev/ship-shot.html new file mode 100644 index 0000000..a34d5ea --- /dev/null +++ b/dev/ship-shot.html @@ -0,0 +1,17 @@ + + + + + + Orbit — Ship console (dev shot) + + + + +
+ + + diff --git a/dev/ship-shot.mjs b/dev/ship-shot.mjs new file mode 100644 index 0000000..f44915d --- /dev/null +++ b/dev/ship-shot.mjs @@ -0,0 +1,234 @@ +/** + * Dev-only: boot GameScene, force the run's ship state, and open the SHIP + * console (depth 80) so a headless screenshot shows the whole feature — left + * the bay-cam feed plate (assets/videos/ship.mp4; with the clip absent the + * procedural NO SIGNAL plate stands in), right the SHIP STATUS / SKILL TREE + * (STANDBY) / CARGO HOLDS tabs over one plate. + * + * The state is forced from the query string so every shot is one URL: + * + * ?tab=status|skills|cargo which tab is open (default status) + * &level=0…80 the XP ledger's level (default 0) + * &step=0..1 partial progress to next level (default .4) + * &bays=0…16 unlocked bays (default ship.json) + * &goods=a,b,c good ids loaded into the bays ('none' = empty) + * &minerals=0|half|full| the mineral load (default half) + * &select= open that bay's manifest (cargo tab) + * &known= chart n systems (TradeScout ranks exits only + * among systems the run has entered — a fresh + * run has one, so the list needs this to show) + * &pressLocked=1 press the locked SKILL TREE socket + * &mutate=1 change cargo/ore AFTER opening (live repaint) + * &fight=1 a live combat session with damaged armor + * + * node dev/server.mjs 8082 # static server + * node dev/cdp-shot.mjs \ + * "http://127.0.0.1:8082/dev/ship-shot.html?tab=cargo&goods=iron-ore,grain-crates&minerals=half" \ + * SHIP_CARGO.png "window.__SHIP_SHOT ? window.__SHIP_SHOT.ready : null" 30000 \ + * "document.getElementById('report').style.display='none'; true" + * + * The report (top-left) lists console errors + the console's paint state. + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { GameScene } from '../js/scenes/GameScene.js'; + +const data = await ConfigLoader.load(); +config.init(data); + +// Capture console errors + uncaught exceptions for the report. +const errors = []; +const origErr = console.error.bind(console); +console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); }; +window.addEventListener('error', (e) => errors.push(String(e.message))); +window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`)); + +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +// The report lives OUTSIDE the canvas — a DOM
 the screenshot can
+// always read (headless canvases don't have to cooperate).
+const report = document.createElement('pre');
+report.id = 'report';
+report.style.cssText =
+  'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
+document.body.appendChild(report);
+const setReport = (lines) => {
+  report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n');
+};
+setReport('booting…');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function waitScene() {
+  for (let i = 0; i < 100; i++) {
+    const s = game.scene.getScene('GameScene');
+    if (s && s.ship && s.shipWindow) return s;
+    await sleep(100);
+  }
+  throw new Error('GameScene never booted');
+}
+
+try {
+  const s = await waitScene();
+  await sleep(800); // let the boot settle (dossier decode, deck flicker)
+
+  const q = new URLSearchParams(location.search);
+  const tab = q.get('tab') || 'status';
+  const level = Math.max(0, Number(q.get('level') ?? 0) || 0);
+  const step = Math.min(1, Math.max(0, Number(q.get('step') ?? 0.4) || 0));
+  const goodsRaw = q.get('goods');
+  const mineralsRaw = q.get('minerals') ?? 'half';
+  const select = q.has('select') ? Number(q.get('select')) : null;
+  const fight = q.get('fight') === '1';
+
+  // ---- the XP ledger (js/combat/XpManager.js is the only ledger) --------
+  // restore() writes { level, xp } with no events; the bar's fine fill comes
+  // from the level's own threshold, so take a data-driven slice of it.
+  const xp = s.combat?.xp;
+  if (xp) {
+    const need = level < xp.maxLevel ? xp.xpForLevel(level + 1) : 0;
+    xp.restore({ level, xp: Math.round(need * step) });
+  }
+
+  // ---- the cargo hold (CargoHold — one good per bay, fill order) --------
+  const wantBays = q.has('bays') ? Number(q.get('bays')) : null;
+  if (wantBays !== null) s.ship.setCargoHold(wantBays);
+  const goods =
+    !goodsRaw || goodsRaw === 'none'
+      ? []
+      : String(goodsRaw)
+          .split(',')
+          .map((g) => g.trim())
+          .filter(Boolean);
+  s.ship.cargo.clear?.();
+  if (goods.length) s.ship.cargo.add(goods);
+
+  // ---- a LIVE combat session (?fight=1) --------------------------------
+  // `CombatSystem.active` is true while a hostile is up, and that is exactly
+  // what flips the plate's armor rows from "nominal maxima" to the session's
+  // real hull/shields — so spawn hostiles through the system's own seam, then
+  // take hits hard enough to break the shields and dent the hull.
+  if (fight && s.combat) {
+    const c = s.combat;
+    const sx = s.ship?.x ?? 0;
+    const sy = s.ship?.y ?? 0;
+    c.spawnEnemy(sx + 520, sy - 260, 901);
+    c.spawnEnemy(sx - 480, sy + 300, 902);
+    c.damagePlayer(Math.round(c.maxShields * 0.55), { bearing: 0 });
+    c.damagePlayer(Math.round(c.maxShields * 0.6 + c.maxHull * 0.3), { bearing: Math.PI });
+  }
+
+  // ---- the mineral load (aggregate — what the Mineral HUD reads) --------
+  const cap = s.ship.stats.mineralStorage;
+  s.ship.setMinerals(
+    mineralsRaw === 'zero' || mineralsRaw === '0'
+      ? 0
+      : mineralsRaw === 'half'
+        ? Math.round(cap * 0.5)
+        : mineralsRaw === 'full'
+          ? cap
+          : Number(mineralsRaw) || 0,
+  );
+  s.ship.credits = Math.max(s.ship.credits, 12_480);
+
+  // ---- the chart (visitedSystems is what TradeScout ranks exits across) ---
+  const known = q.has('known') ? Math.max(0, Number(q.get('known')) || 0) : null;
+  if (known !== null && s.galaxy) {
+    let n = 0;
+    for (const id of s.galaxy.byId.keys()) {
+      if (n >= known) break;
+      s.visitedSystems.add(id);
+      n++;
+    }
+  }
+
+  // The fresh run's opening ACK comm owns the screen (deckAction swallows the
+  // press while it holds) — clear the chain through the hub's own finish path
+  // so the deck is live, exactly like dev/combat-shot.mjs does.
+  const hub = s.comms;
+  if (hub) {
+    if (hub.current) { try { hub._finish(); } catch { /* already ended */ } }
+    if (Array.isArray(hub.queue)) hub.queue.length = 0;
+    hub._setPaused(false);
+    try { hub.box?.close(); } catch { /* no box */ }
+    hub.box = null;
+    // _commInputBlocked() holds the deck for 200 ms after a comm ends —
+    // give that window time to close before the deck press.
+    await sleep(400);
+  }
+
+  // Open the console (the deck SHIP button does exactly this). The press is a
+  // no-op while ANY input owner still holds the deck, and that clearance races
+  // this script — so nudge until the console says it is open (a press while it
+  // is already open would TOGGLE it shut, hence the guard).
+  const win = s.shipWindow;
+  for (let i = 0; i < 12 && !win.isOpen; i++) {
+    s.deckAction('ship');
+    await sleep(350);
+  }
+  await sleep(1500); // the boot reveal + the first paint
+  if (tab !== 'status') {
+    win._setTab(tab);
+    await sleep(700);
+  }
+  if (select !== null) {
+    win._selectedBay = select;
+    win.refresh();
+    await sleep(700);
+  }
+
+  // ---- live refresh while open (?mutate=1) -------------------------------
+  // The console polls the scene's snapshot seam on its own beat; changing the
+  // hold through the run's OWN api (a buy landing in a bay, ore aboard) must
+  // repaint it — no reopen, no manual refresh.
+  let liveUpdate = 'n/a';
+  if (q.get('mutate') === '1' && win) {
+    const before = win._fpOf?.(s.shipScreenSnapshot()) ?? null;
+    s.ship.cargo.add(['iron-ore']);
+    s.ship.minerals = Math.round((s.ship.stats.mineralStorage || 0) * 0.9);
+    await sleep(1200); // longer than the window's poll interval
+    const after = win._fpOf?.(s.shipScreenSnapshot()) ?? null;
+    liveUpdate = `${before !== after ? 'fingerprint changed → repaint' : 'STALE (no repaint)'} · ${s.ship.cargo.used}/${s.ship.cargo.capacity} bays, ${s.ship.minerals} u`;
+  }
+
+  // The locked SKILL TREE socket must shake + toast, never crash.
+  let locked = 'n/a';
+  if (q.get('pressLocked') === '1') {
+    try {
+      const t = win._tabs.find((x) => x.id === 'skills');
+      win._shakeTab(t);
+      win.onLocked?.('skills');
+      locked = 'press ok';
+    } catch (e) {
+      locked = 'press err: ' + e.message;
+    }
+  }
+
+  const snap = s.shipScreenSnapshot?.() ?? null;
+  const lines = [
+    errors.length === 0 ? 'SMOKE OK — no console errors' : `ERRORS:\n${errors.slice(0, 4).join('\n')}`,
+    `window: ${win?.openState} · tab=${win?._activeTab} · selected=${win?._selectedBay ?? '—'}`,
+    `video: ${win?.video ? (win.video.video?.paused ? 'paused' : 'playing') : 'NO SIGNAL (clip absent)'}`,
+    snap
+      ? `live hull ${snap.live.hull}/${snap.live.hullMax} · shields ${snap.live.shields}/${snap.live.shieldsMax}${fight ? ' (combat ACTIVE)' : ''}`
+      : 'snapshot: NONE',
+    `level ${snap?.level?.level ?? '?'} (${Math.round((snap?.level?.progress ?? 0) * 100)}% → next) · ledger=${xp ? 'XpManager' : 'none'}`,
+    `cargo ${s.ship.cargo.used}/${s.ship.cargo.capacity} bays (${s.ship.cargo.items.join(', ') || 'empty'}) · locked ${s.ship.maxCargoSlots - s.ship.cargo.capacity}`,
+    `minerals ${s.ship.minerals}/${s.ship.stats.mineralStorage} · credits ${s.ship.credits}`,
+    `locked tab: ${locked}`,
+    `live update: ${liveUpdate}`,
+  ];
+  setReport(lines);
+  window.__SHIP_SHOT = { ready: true, lines, errors };
+  console.info('ship-shot: report painted');
+} catch (err) {
+  errors.push(`FATAL: ${err.message}`);
+  setReport(['FATAL: ' + err.message, ...errors.slice(0, 4)]);
+  window.__SHIP_SHOT = { ready: true, fatal: String(err.message), errors };
+  console.error('ship-shot: fatal', err);
+}
diff --git a/dev/ship-window.test.mjs b/dev/ship-window.test.mjs
new file mode 100644
index 0000000..1633039
--- /dev/null
+++ b/dev/ship-window.test.mjs
@@ -0,0 +1,275 @@
+/**
+ * SHIP console test (dev tool, run with Node — no browser needed):
+ *
+ *   node dev/ship-window.test.mjs
+ *
+ * The pure half of the SHIP screen: js/ship/ShipScreenModel.js (the read-only
+ * brain behind js/ui/ShipWindow.js) driven over the REAL data files. The
+ * console's doctrine is that it never invents a number — every value, label,
+ * unit, order and format comes from data/shipScreen.json → status.rows,
+ * resolved against references into data/*.json and the run snapshot:
+ *
+ *   'config:'  hardware specs as authored (data/ship.json thrust/maxSpeed,
+ *                    data/combat.json playerShip shield recharge + player.weapon)
+ *   'ship:'    state the run mutates (stats.cargoHold / mineralStorage /
+ *                    miningSpeed — what research & builds already rewrite —
+ *                    minerals, credits, cargo.used/items)
+ *   'live:'     the live combat session's armor (hull/shields + maxima),
+ *                    folded by GameScene.shipScreenSnapshot()
+ *
+ * Asserted here:
+ *   - EVERY configured readout row resolves to a finite number from a realistic
+ *     snapshot, formats without a NaN hole, and its meter stays in 0…1;
+ *   - the numbers ARE the data files' numbers (fire rate = 1/cooldown, DPS =
+ *     damage × shots/sec, flight feel = ship.thrust / ship.maxSpeed, shield
+ *     recharge = playerShield regen + the row's own meta delay …), so the plate
+ *     cannot drift from combat;
+ *   - deltas already flowing through ship.stats (a cargo-hold upgrade, mined
+ *     ore) move the rows with them, and a missing source reads as an em-dash
+ *     instead of a fabricated zero;
+ *   - the journey's ledger is XpManager alone: over the REAL progression curve
+ *     the plate shows the 0→5 timeline below data classAt and CHOOSE YOUR
+ *     CLASS exactly at it (and a class on file wins over both);
+ *   - the hold summary matches CargoHold (one good per bay, fill order) +
+ *     economy.cargo.maxSlots, with minerals aggregate-only;
+ *   - TradeScout.scoutGood() prices a held good's exits so the bay manifest has
+ *     ranked data to draw (price + profit per hop, bestExit first).
+ */
+import { readdirSync, readFileSync } from 'node:fs';
+import assert from 'node:assert/strict';
+
+// ---------------------------------------------------------------------------
+// Real data → the config singleton (the same shape Config.load() produces)
+// ---------------------------------------------------------------------------
+const { config } = await import('../js/config/Config.js');
+const data = {};
+for (const f of readdirSync(new URL('../data', import.meta.url))) {
+  if (f.endsWith('.json')) data[f.slice(0, -5)] = JSON.parse(readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8'));
+}
+config.init(data);
+
+const M = await import('../js/ship/ShipScreenModel.js');
+const { CargoHold } = await import('../js/economy/CargoHold.js');
+
+let failures = 0;
+const check = (label, cond) => {
+  console.log(`${cond ? '✔' : '✘ FAIL'}  ${label}`);
+  if (!cond) failures++;
+};
+const near = (a, b, eps = 1e-6) => Number.isFinite(a) && Math.abs(a - b) <= eps;
+
+// ---------------------------------------------------------------------------
+// A snapshot in the shape GameScene.shipScreenSnapshot() produces
+// ---------------------------------------------------------------------------
+function snapshot(o = {}) {
+  // The run's invariant: the hold's capacity IS ship.stats.cargoHold (the
+  // cargo upgrades raise both) — a snapshot that disagrees would let the plate
+  // and the grid drift apart, which is exactly what must not happen.
+  const capacity = o.capacity ?? data.ship.stats.cargoHold;
+  const hold = new CargoHold(capacity);
+  if (o.items?.length) hold.add(o.items);
+  // Armor maxima come from the combat system (data/combat.json → playerShip —
+  // note ship.stats.shields is authored 0, so the plate MUST read combat's
+  // numbers; GameScene folds combat.maxHull/maxShields into the snapshot).
+  const hullMax = o.hullMax ?? data.combat.playerShip.hull;
+  const shieldsMax = o.shieldsMax ?? data.combat.playerShip.shields;
+  return {
+    live: {
+      hull: o.hull ?? hullMax,
+      hullMax,
+      shields: o.shields ?? shieldsMax,
+      shieldsMax,
+    },
+    ship: {
+      stats: { ...data.ship.stats, cargoHold: capacity, ...(o.stats || {}) },
+      minerals: o.minerals ?? 0,
+      credits: o.credits ?? 12480,
+      cargo: { used: hold.used, capacity: hold.capacity, items: hold.snapshot() },
+    },
+    level: o.level ?? null,
+    playerClass: o.playerClass ?? null,
+  };
+}
+
+const CFG = config.section('shipScreen', {});
+const rowsCfg = CFG.status.rows;
+const byId = (rows, id) => rows.find((r) => r.id === id);
+
+// --- 1. the plate's rows all resolve from the data files -------------------
+{
+  const snap = snapshot({ capacity: 4, minerals: 125, items: ['fresh-produce', 'grain-crates'] });
+  const rows = M.statRows(rowsCfg, snap);
+  check(`status.rows resolves every configured row (${rows.length})`, rows.length === rowsCfg.length && rows.length > 0);
+  for (const r of rows) {
+    check(`  ${r.id} → finite value (${r.text}${r.unit ? ' ' + r.unit : ''})`, M.isResolved(r));
+    check(`  ${r.id} label/copy comes from data`, !!r.label && r.label === String(rowsCfg.find((d) => d.id === r.id).label).toUpperCase());
+    if (rowsCfg.find((d) => d.id === r.id).meter) {
+      check(`  ${r.id} meter stays in 0…1`, r.frac !== null && r.frac >= 0 && r.frac <= 1);
+    }
+  }
+  // meta lines (the row's small right-hand read) resolve too
+  for (const d of rowsCfg.filter((x) => x.meta)) {
+    const r = byId(rows, d.id);
+    check(`  ${d.id} meta resolves (${r.meta})`, /\d/.test(r.meta));
+  }
+}
+
+// --- 2. the values ARE the data files' numbers -----------------------------
+{
+  const snap = snapshot();
+  const rows = M.statRows(rowsCfg, snap);
+  const W = data.combat.player.weapon;
+  const expect = {
+    hull: data.combat.playerShip.hull,
+    shields: data.combat.playerShip.shields,
+    shieldRegen: data.combat.playerShip.shieldRegenRate,
+    accel: data.ship.thrust,
+    maxSpeed: data.ship.maxSpeed,
+    fireRate: 1 / W.cooldown,
+    damage: W.damage,
+    dps: W.damage / W.cooldown,
+    cargoBays: data.ship.stats.cargoHold,
+    mineralHold: data.ship.stats.mineralStorage,
+    miningSpeed: data.ship.stats.miningSpeed,
+  };
+  for (const [id, v] of Object.entries(expect)) {
+    const r = byId(rows, id);
+    check(`  ${id} = data value ${v}`, !!r && near(r.value, v));
+  }
+  // the recharge row carries the delay; the gun rows carry shot speed
+  assert.match(byId(rows, 'shieldRegen').meta, new RegExp(String(data.combat.playerShip.shieldRegenDelay)));
+  assert.match(byId(rows, 'dps').meta, new RegExp(String(data.combat.player.weapon.bulletSpeed)));
+  check('  DPS = damage × shots/sec (product of two sources)', near(byId(rows, 'dps').value, W.damage * (1 / W.cooldown)));
+}
+
+// --- 3. live combat vs the nominal (out-of-combat) state -------------------
+{
+  const full = snapshot();
+  const fight = snapshot({ hull: 62, shields: 0, hullMax: 140, shieldsMax: 56 });
+  const a = M.statRows(rowsCfg, full);
+  const b = M.statRows(rowsCfg, fight);
+  check('nominal: current hull reads its max with a full meter', near(byId(a, 'hullNow').frac, 1) && byId(a, 'hullNow').value === byId(a, 'hull').value);
+  check('nominal copy is authored in data (not coded)', byId(a, 'hullNow').nominalLabel === rowsCfg.find((d) => d.id === 'hullNow').nominalLabel);
+  check('combat session: hull reads the session (62/140)', byId(b, 'hullNow').value === 62 && near(byId(b, 'hullNow').frac, 62 / 140));
+  check('combat session: an emptied shield meter floors at 0', near(byId(b, 'shieldNow').frac, 0) && byId(b, 'shieldNow').value === 0);
+}
+
+// --- 4. run deltas + missing sources --------------------------------------
+{
+  const upgraded = snapshot({ capacity: 8, minerals: 240, stats: { cargoHold: 8, miningSpeed: 1.35 } });
+  const rows = M.statRows(rowsCfg, upgraded);
+  check('a cargo-hold upgrade moves the row (ship.stats.cargoHold)', byId(rows, 'cargoBays').value === 8);
+  check('mining research moves the row (ship.stats.miningSpeed)', near(byId(rows, 'miningSpeed').value, 1.35));
+  const ore = snapshot({ minerals: 250 });
+  check('the mineral meter tracks what is aboard', near(byId(M.statRows(rowsCfg, ore), 'mineralHold').frac, Math.min(1, 250 / data.ship.stats.mineralStorage)));
+  // a source that does not exist must never print a number
+  const bare = M.resolveRow({ id: 'ghost', label: 'GHOST', from: 'ship:nope.nothere', format: 'int' }, snapshot());
+  check('a missing source reads as a dash, never a fabricated 0', bare.value === null && bare.text === '—');
+  check('formats: int rounds / 1dp / 2dp', M.formatValue(6.49, 'int') === '6' && M.formatValue(62.5, '1dp') === '62.5' && M.formatValue(1, '2dp') === '1.00');
+}
+
+// --- 5. the journey's ledger is XpManager alone ---------------------------
+{
+  const { XpManager } = await import('../js/combat/XpManager.js');
+  const cfg = CFG.level;
+  const xp = new XpManager();
+  const modes = [];
+  for (let lv = 0; lv <= cfg.classAt + 1; lv++) {
+    // walk the REAL curve: pay xpForLevel(1..lv) through grant()
+    xp.reset();
+    let total = 0;
+    for (let n = 1; n <= lv; n++) total += xp.xpForLevel(n);
+    if (total > 0) xp.grant(total, 'test');
+    assert.equal(xp.stats().level, lv, `xp curve reached level ${lv}`);
+    const j = M.journeyState(xp.stats(), cfg);
+    modes.push(`${j.level}:${j.mode}`);
+    if (lv < cfg.classAt) {
+      check(`  level ${lv} → timeline`, j.mode === 'timeline');
+      const cur = j.nodes.filter((n) => n.state === 'current');
+      check(`  level ${lv} marks exactly one milestone current`, cur.length === 1 && cur[0].level === lv);
+      check(`  level ${lv} fills ${(lv / cfg.to).toFixed(2)} of the rail`, near(j.progress, lv / cfg.to));
+    } else {
+      check(`  level ${lv} → CHOOSE YOUR CLASS door`, j.mode === 'choose');
+    }
+  }
+  // a class on file wins over the door; no ledger at all still renders
+  check('class on file → class plate', M.journeyState(xp.stats(), cfg, { name: 'MARAUDER', insignia: 'marauder' }).mode === 'class');
+  const dark = M.journeyState(null, cfg);
+  check('no combat ledger yet → level-0 timeline, no crash', dark.mode === 'timeline' && dark.level === cfg.from && dark.progress === 0);
+  xp.restore({ level: 80 });
+  check('the cap reads MAX (ledger untouched by the console)', M.journeyState(xp.stats(), cfg).isMax === true);
+}
+
+// --- 6. the hold summary matches CargoHold + maxSlots ---------------------
+{
+  const catalog = data.goods.goods ?? data.goods;
+  const items = ['fresh-produce', 'grain-crates', 'fresh-produce'];
+  const sum = M.cargoSummary({ ship: snapshot({ capacity: 4, items, minerals: 125 }).ship }, catalog);
+  check(`the grid follows the hold's fill order (${sum.bays.map((b) => b.state).join(',')})`, sum.bays.map((b) => b.state).join(',') === 'filled,filled,filled,empty');
+  check('duplicate goods occupy one bay each', sum.bays[0].goodId === 'fresh-produce' && sum.bays[2].goodId === 'fresh-produce');
+  check(`the locked upgrade room is reported up to economy.cargo.maxSlots (${sum.lockedBays})`, sum.lockedBays === config.get('economy.cargo.maxSlots', 16) - 4);
+  check('names/icons come from data/goods.json', sum.bays[0].name === catalog.find((g) => g.id === items[0]).name && Number.isInteger(sum.bays[0].icon));
+  check('used/room counts agree with the hold', sum.used === 3 && sum.room === 1 && sum.capacity === 4 && sum.empty === false);
+  check('minerals stay aggregate (aboard vs capacity, no per-mineral model)', sum.minerals === 125 && sum.mineralCapacity === data.ship.stats.mineralStorage && !Array.isArray(sum.mineralLines));
+  const empty = M.cargoSummary({ ship: { stats: { ...data.ship.stats, cargoHold: 4 }, cargo: { used: 0, items: [] }, minerals: 0 } }, catalog);
+  check('an empty hold is an intentional empty plate (not a broken layout)', empty.empty === true && empty.bays.every((b) => b.state !== 'filled'));
+  const none = M.cargoSummary({ ship: { stats: { ...data.ship.stats, cargoHold: 0 }, cargo: { used: 0, items: [] } } }, catalog);
+  check('no bays installed still summarizes without a crash', none.capacity === 0 && none.used === 0 && none.empty === true);
+  // the two edges the plate must never get wrong: a FULL hold and a full
+  // mineral load (the numbers are CargoHold's + MineralHud's, not ours)
+  const full = M.cargoSummary(
+    { ship: { stats: { ...data.ship.stats, cargoHold: 4, mineralStorage: 250 }, cargo: { used: 4, items: ['a', 'b', 'c', 'd'] }, minerals: 250 } },
+    catalog
+  );
+  check(
+    'a full hold reads full (no room, no negative)',
+    full.used === 4 && full.room === 0 && full.empty === false && full.bays.every((b) => b.state === 'filled') && full.pct >= 1
+  );
+  check('minerals at capacity read 100% and stay aggregate', full.mineralPct === 1 && full.minerals === full.mineralCapacity);
+  const dry = M.cargoSummary({ ship: { stats: { ...data.ship.stats, cargoHold: 4, mineralStorage: 250 }, cargo: { used: 0, items: [] }, minerals: 0 } }, catalog);
+  check('no ore aboard reads 0%', dry.mineralPct === 0 && dry.minerals === 0);
+  const unknown = M.cargoSummary({ ship: { stats: { ...data.ship.stats, cargoHold: 2 }, cargo: { used: 1, items: ['not-a-good'] } } }, catalog);
+  check('an unknown good id still draws its bay (name falls back to the id)', unknown.bays[0].state === 'filled' && unknown.bays[0].name === 'not-a-good');
+}
+
+// --- 7. a held good's exits (the bay manifest's data) ---------------------
+{
+  const { Galaxy } = await import('../js/galaxy/Galaxy.js');
+  const { scoutGood } = await import('../js/economy/TradeScout.js');
+  const g = Galaxy.create('orbit-ship-window-test');
+  const sysId = g.homeSystemId;
+  const goods = data.goods.goods ?? data.goods;
+  const goodId = goods[0].id;
+  const rep = scoutGood(goodId, { galaxy: g, systemId: sysId, buyUnit: goods.find((x) => x.id === goodId).baseValue, topN: CFG.cargo.detail.topN ?? 3 });
+  check(`scout report for ${goodId}: charted=${rep.charted} exits=${rep.exits.length}`, rep && Array.isArray(rep.exits));
+  const profits = rep.exits.map((e) => e.profit);
+  check('exits are ranked by per-unit profit (best first)', profits.every((p, i) => i === 0 || profits[i - 1] >= p));
+  check('the best exit is the first row', rep.exits.length === 0 || rep.bestExit === rep.exits[0]);
+  check(`topN caps the list (${rep.exits.length} ≤ ${CFG.cargo.detail.topN ?? 3})`, rep.exits.length <= (CFG.cargo.detail.topN ?? 3));
+  check('rows carry what the manifest draws (price/hops/system)', rep.exits.every((e) => Number.isFinite(e.price) && Number.isFinite(e.hops) && !!e.systemName));
+
+  // THE BASELINE (data: cargo.detail.baseline). This screen has no shop, so
+  // data picks the reference the exits' profits are measured against — the
+  // catalog value (goods.json → baseValue) or the local buy price. The panel
+  // must never invent one.
+  const baseline = String(CFG.cargo.detail.baseline ?? '');
+  check(`the profit baseline is documented in data ("${baseline}")`, baseline === 'baseValue' || baseline === 'buy');
+  if (baseline === 'baseValue') {
+    const base = goods.find((x) => x.id === goodId).baseValue;
+    check(`profit = exit price − goods.json baseValue (${base}) on every row`, rep.exits.length > 0 && rep.exits.every((e) => e.profit === e.price - base));
+    const other = scoutGood(goodId, { galaxy: g, systemId: sysId, topN: 3 }); // no buyUnit → the local market's own price
+    check('the other documented baseline (Economy buy price) really is a different reference', other.localBuy !== base || rep.exits.length === 0);
+  } else {
+    const local = rep.localBuy;
+    check('profit = exit price − local buy price on every row', rep.exits.length > 0 && rep.exits.every((e) => e.profit === e.price - local));
+  }
+
+  // A bay whose good isn't in the catalog (a save from an older build, a
+  // stripped data file) degrades: cargoSummary keeps the id as the name and
+  // the manifest simply has no report to draw.
+  const ghost = M.cargoSummary({ ship: { stats: { ...data.ship.stats, cargoHold: 1 }, cargo: { used: 1, items: ['no-such-good'] } } }, goods);
+  check('an unparseable good still yields a selectable bay without a report', ghost.bays[0].state === 'filled' && ghost.bays[0].icon === null);
+}
+
+console.log(failures === 0 ? '\nSHIP console model: ALL PASS' : `\n${failures} FAILURE(S)`);
+process.exit(failures ? 1 : 0);
diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js
index 56bfe3d..aa6f368 100644
--- a/js/scenes/GameScene.js
+++ b/js/scenes/GameScene.js
@@ -41,6 +41,7 @@ import { CombatHud } from '../combat/CombatHud.js';
 import { MineralHud } from '../ui/MineralHud.js';
 import { CreditsHud } from '../ui/CreditsHud.js';
 import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
+import { scoutGood } from '../economy/TradeScout.js';
 import * as Stock from '../economy/Stock.js';
 import { MenuSubBar } from '../ui/MenuSubBar.js';
 import { SavePanel } from '../ui/SavePanel.js';
@@ -57,6 +58,7 @@ import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
 import { CommsPanel } from '../ui/CommsPanel.js';
 import { ResearchWindow } from '../ui/ResearchWindow.js';
 import { MapWindow } from '../ui/MapWindow.js';
+import { ShipWindow } from '../ui/ShipWindow.js';
 import { QuestWindow } from '../ui/QuestWindow.js';
 import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
 import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
@@ -273,6 +275,20 @@ export class GameScene extends Phaser.Scene {
       }
     }
 
+    // The ship console's bay camera (data/shipScreen.json → video): a muted
+    // 2:3 loop behind the SHIP window. Same contract — the clip is planned
+    // but not authored yet (assets/videos/ship.mp4 is not in the repo), and a
+    // missing file just leaves the window's procedural NO SIGNAL plate up.
+    if (config.get('shipScreen.enabled', true)) {
+      const shipVideo = String(config.get('shipScreen.video.file') ?? '');
+      if (shipVideo) {
+        const url = /^(https?:)?\/\//.test(shipVideo) || shipVideo.startsWith('assets/')
+          ? shipVideo
+          : `assets/videos/${shipVideo}`;
+        this.load.video(ShipWindow.VIDEO_KEY, url);
+      }
+    }
+
     // The build console's feed (data/builds.json → video): a muted 2:3
     // loop behind the BUILD window on a planet surface (SurfaceScene's
     // BuildWindow reads the shared cache). A missing file just leaves the
@@ -1143,6 +1159,7 @@ export class GameScene extends Phaser.Scene {
           (this.savePanel && this.savePanel.isOpen) ||
           (this.researchWindow && this.researchWindow.isOpen) ||
           (this.mapWindow && this.mapWindow.isOpen) ||
+          (this.shipWindow && this.shipWindow.isOpen) ||
           (this.questWindow && this.questWindow.isOpen) ||
           (this.commsPanel && this.commsPanel.isOpen) ||
           (this.menuSubBar && this.menuSubBar.isOpen) ||
@@ -1348,6 +1365,39 @@ export class GameScene extends Phaser.Scene {
           },
         ),
     });
+    // ---- SHIP CONSOLE (the deck's SHIP button) ------------------------
+    // The hull & hardware console (data/shipScreen.json, js/ui/ShipWindow.js,
+    // depth 80): left the bay camera (assets/videos/ship.mp4 — a muted 2:3
+    // loop; until the clip is authored the window's procedural NO SIGNAL
+    // plate stands in), right the SHIP STATUS / SKILL TREE / CARGO HOLDS tabs
+    // over one plate. Ship Status reads the run through the data-driven
+    // source table (data/shipScreen.json → status.rows, resolved by the pure
+    // js/ship/ShipScreenModel.js — ship.stats, the live combat session's
+    // armor, the cannon's cooldown→rate, the hold's bays) and shows the
+    // XP ledger (js/combat/XpManager.js) as a bar + the level 0→5 journey,
+    // which gives way to CHOOSE YOUR CLASS at the class level (the class
+    // layer's seam — onChooseClass). Skill Tree is a STANDBY socket until the
+    // class layer lands: pressing it shakes the tab and toasts. Cargo Holds
+    // reads js/economy/CargoHold.js (one good per bay) + the aggregate
+    // mineral load, and a filled bay opens its manifest with the charted
+    // exits (js/economy/TradeScout.js — the planet shop's own report).
+    this.shipWindow = new ShipWindow(this, {
+      getSnapshot: () => this.shipScreenSnapshot(),
+      getScoutReport: (goodId) => this.shipScreenScoutReport(goodId),
+      onLocked: () =>
+        this.consoleToast(String(config.get('shipScreen.skillsLockedToast', 'SKILL TREE OFFLINE')), {
+          glyph: '✕',
+          glyphColor: toCss(themeColor('amber', 0xffc94d)),
+        }),
+      // The level-5 door. Until the class-selection screen exists this is a
+      // voice, never a dead click (the data's own line).
+      onChooseClass: () =>
+        this.consoleToast(String(config.get('shipScreen.level.chooseClass.toast', 'CLASS SELECT OFFLINE')), {
+          glyph: '✕',
+          glyphColor: toCss(themeColor('amber', 0xffc94d)),
+        }),
+    });
+
     // ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ----
     // The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80):
     // left the 2:3 mission feed (assets/videos/quests.mp4 — the same
@@ -1626,8 +1676,8 @@ export class GameScene extends Phaser.Scene {
       if (this.mapWindow && this.mapWindow.isOpen) return;
       // The quests console (depth 80) — the same full-screen contract.
       if (this.questWindow && this.questWindow.isOpen) return;
-      // The quests console (depth 80) — the same full-screen contract.
-      if (this.questWindow && this.questWindow.isOpen) return;
+      // The ship console (depth 80) — the same full-screen contract.
+      if (this.shipWindow && this.shipWindow.isOpen) return;
       // Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
       // its buttons — the buttons fire on their own pointerdown). ANY
       // click OUTSIDE — world, deck, HUD, compass — folds it back down
@@ -2408,6 +2458,7 @@ export class GameScene extends Phaser.Scene {
     this.researchWindow?.update(_time); // the console's living details (open state)
     this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll
     this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll
+    this.shipWindow?.update(_time); // the ship console: reveal, decodes, sweep, telemetry poll
     this._deckResearchBar(_time); // the RESEARCH slot's progress bar
     this._applyResearchCue(); // the RESEARCH slot's ready-shimmer (the deck's half)
     // Builds: a build started on a surface keeps running in space if the
@@ -4127,6 +4178,7 @@ export class GameScene extends Phaser.Scene {
       }
       if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
       if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
+      if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
       this.researchWindow?.open();
       return;
     }
@@ -4139,6 +4191,7 @@ export class GameScene extends Phaser.Scene {
       if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
       if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
       if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
+      if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
       this.questWindow?.open();
       return;
     }
@@ -4151,9 +4204,24 @@ export class GameScene extends Phaser.Scene {
       if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
       if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
       if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
+      if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
       this.mapWindow?.open();
       return;
     }
+    // The ship console (full-screen, depth 80) — the deck's hardware tab.
+    if (id === 'ship') {
+      if (this.shipWindow && this.shipWindow.isOpen) {
+        this.shipWindow.close();
+        return;
+      }
+      if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
+      if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
+      if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
+      if (this.mapWindow && this.mapWindow.isOpen) this.mapWindow.close();
+      if (this.questWindow && this.questWindow.isOpen) this.questWindow.close();
+      this.shipWindow?.open();
+      return;
+    }
     if (id === 'menu') {
       this.menuAction();
       return;
@@ -5773,6 +5841,82 @@ export class GameScene extends Phaser.Scene {
     });
   }
 
+  // ------------------------------------------------------------ ship console
+
+  /**
+   * The SHIP console's snapshot (js/ui/ShipWindow.js polls this while the
+   * deck's SHIP slot is open; js/ship/ShipScreenModel.js resolves every row
+   * of data/shipScreen.json → status.rows against it). Plain data on purpose
+   * — a copy, so the view can never mutate the run:
+   *
+   *   live:*   the armor AS IT STANDS. Out of combat a ship is never damage
+   *            (no persistent hull state exists by design), so the read is
+   *            the maxima and the plate's rows carry their 'nominal' tag;
+   *            while a fight runs these are the session's own numbers.
+   *   ship:*   the run's mutable state — stats AFTER upgrades/builds, ore
+   *            aboard, credits, and the cargo hold's bays in fill order.
+   *   level    the XP ledger itself (js/combat/XpManager.js → stats()).
+   */
+  shipScreenSnapshot() {
+    const s = this.ship;
+    if (!s) return null;
+    const c = this.combat;
+    const fighting = !!(c && c.active);
+    // The maxima come from the combat system when it exists — that's where
+    // the per-level armor bump lands (progression.reward.hullPerLevel /
+    // shieldPerLevel), so the plate never shows a stale base number.
+    const hullMax = Math.max(1, Math.round(Number(c?.maxHull ?? s.stats?.hullIntegrity) || 0));
+    const shieldsMax = Math.max(0, Math.round(Number(c?.maxShields ?? s.stats?.shields) || 0));
+    return {
+      live: {
+        hull: fighting ? Math.max(0, Math.round(Number(c.hull) || 0)) : hullMax,
+        hullMax,
+        shields: fighting ? Math.max(0, Math.round(Number(c.shields) || 0)) : shieldsMax,
+        shieldsMax,
+      },
+      ship: {
+        stats: { ...(s.stats ?? {}) },
+        minerals: Math.max(0, Math.round(Number(s.minerals) || 0)),
+        credits: Math.max(0, Math.round(Number(s.credits) || 0)),
+        cargo: {
+          used: Math.max(0, Math.round(Number(s.cargo?.used) || 0)),
+          capacity: Math.max(0, Math.round(Number(s.cargo?.capacity) || 0)),
+          items: Array.isArray(s.cargo?.items) ? [...s.cargo.items] : [],
+        },
+      },
+      level: typeof c?.xp?.stats === 'function' ? c.xp.stats() : null,
+      // The class layer's seam — a chosen class lands on the save when the
+      // class selection screen exists (js/ui/ShipWindow.js then shows the
+      // class plate instead of the CHOOSE YOUR CLASS door).
+      playerClass: this.playerClass ?? null,
+    };
+  }
+
+  /**
+   * A held good's report for the SHIP console's bay manifest — the SAME
+   * scoutGood() the planet shop's scout panel renders (js/ui/shop/ScoutPanel.js),
+   * over the run's chart (galaxy + current system + visitedSystems). There is
+   * no shop under the ship, so the profit baseline is the catalog's own value
+   * (data/goods.json → baseValue — data/shipScreen.json → cargo.detail.baseline
+   * documents the choice); the exits themselves are priced exactly as the
+   * shop prices them.
+   */
+  shipScreenScoutReport(goodId) {
+    if (!goodId || !this.galaxy) return null;
+    const good = (config.get('goods.goods', []) ?? []).find((g) => g?.id === goodId) ?? null;
+    const baseline = String(config.get('shipScreen.cargo.detail.baseline', 'baseValue'));
+    const base = Math.max(0, Math.round(Number(good?.baseValue) || 0));
+    return scoutGood(goodId, {
+      galaxy: this.galaxy,
+      systemId: this.systemRecord?.id ?? null,
+      // no local market → the catalog value is the reference (a non-finite
+      // buyUnit would make TradeScout price against the current system)
+      buyUnit: baseline === 'buy' ? undefined : base,
+      visited: this.visitedSystems,
+      topN: Math.max(1, Math.floor(Number(config.get('shipScreen.cargo.detail.topN', 3)) || 3)),
+    });
+  }
+
   // ------------------------------------------------------------ map console
 
   /**
@@ -6927,6 +7071,11 @@ export class GameScene extends Phaser.Scene {
       this.questWindow.close();
       return;
     }
+    // The ship console (depth 80) — the same contract.
+    if (this.shipWindow && this.shipWindow.isOpen) {
+      this.shipWindow.close();
+      return;
+    }
     // The mining context menu is the topmost open thing after the save UI.
     if (this.miningPopup && this.miningPopup.isOpen) {
       this.miningPopup.close();
@@ -7207,6 +7356,7 @@ export class GameScene extends Phaser.Scene {
     this.commsPanel?.destroy();
     this.researchWindow?.destroy(); // the console (video + UI)
     this.mapWindow?.destroy(); // the map console (video + chart canvas)
+    this.shipWindow?.destroy(); // the ship console (video + telemetry plate)
     this.creditsHud?.destroy();
     this.mineralHud?.destroy();
     this.mining?.destroy();
diff --git a/js/ship/ShipScreenModel.js b/js/ship/ShipScreenModel.js
new file mode 100644
index 0000000..11e40f0
--- /dev/null
+++ b/js/ship/ShipScreenModel.js
@@ -0,0 +1,241 @@
+/**
+ * ShipScreenModel — the SHIP console's read-only brain (data/shipScreen.json,
+ * rendered by js/ui/ShipWindow.js). Pure: no Phaser, no scene, no game
+ * objects, so dev/ship-window.test.mjs drives it in bare Node with a plain
+ * snapshot object.
+ *
+ * The console never invents a number. Every value on the Ship Status plate is
+ * resolved from a path written in data/shipScreen.json → status.rows[*].from:
+ *
+ *   'config:'  a data file (config.get) — the hardware specs
+ *                           that live in data/ship.json / data/combat.json.
+ *   'ship:'    a field of the ship snapshot (stats.cargoHold,
+ *                           minerals, credits, cargo.used …) — state the run
+ *                           mutates (upgrades, ore, buys/sells).
+ *   'live:'            the LIVE combat session's armor (hull / hullMax /
+ *                           shields / shieldsMax), folded by GameScene: while
+ *                           a fight runs these are the session's values, and
+ *                           with no fight they read the maxima (the ship is
+ *                           never damaged out of combat — the plate says so
+ *                           through the row's `nominalLabel`).
+ *
+ * Optional per-row math: op 'reciprocal' (a cooldown → a rate) and, for an
+ * array of sources, op 'product' (damage × shots/sec = DPS). A row's meter is
+ * likewise two paths — fill / max — so bars are data too.
+ */
+import { config } from '../config/Config.js';
+
+// A missing source stays MISSING (null) — never a fabricated 0, which would
+// let a broken path masquerade as a real reading on the plate.
+const num = (v) => {
+  if (v === null || v === undefined || v === '' || typeof v === 'boolean') return null;
+  const n = Number(v);
+  return Number.isFinite(n) ? n : null;
+};
+const clamp01 = (v) => Math.min(1, Math.max(0, v));
+
+/**
+ * Resolve one `from` reference against the snapshot.
+ * @param {string} ref 'config:ship.thrust' | 'ship:stats.cargoHold' | 'live:hull'
+ * @param {{ship?: object, live?: object}} snap the console snapshot
+ * @returns {number|null} a finite number, or null when the path is missing
+ */
+export function resolveRef(ref, snap = {}) {
+  const s = String(ref ?? '');
+  const i = s.indexOf(':');
+  if (i < 0) return null;
+  const ns = s.slice(0, i);
+  const path = s.slice(i + 1);
+  let node;
+  if (ns === 'config') node = config.get(path, undefined);
+  else if (ns === 'ship') node = path.split('.').reduce((o, k) => (o == null ? o : o[k]), snap?.ship ?? null);
+  else if (ns === 'live') node = (snap?.live ?? {})[path];
+  else return null;
+  return num(node);
+}
+
+/** The row's value — one source (+ op), or an array of sources + 'product'. */
+export function resolveValue(def, snap) {
+  const list = Array.isArray(def?.from) ? def.from : def?.from ? [{ from: def.from, op: def.op }] : [];
+  if (!list.length) return null;
+  if (def?.op === 'product') {
+    let p = 1;
+    let any = false;
+    for (const part of list) {
+      const v = resolveValue(part, snap);
+      if (v === null) return null;
+      p *= v;
+      any = true;
+    }
+    return any ? p : null;
+  }
+  const v = resolveRef(list[0].from ?? list[0], snap);
+  if (v === null) return null;
+  const op = list[0].op ?? def?.op;
+  if (op === 'reciprocal') return v > 0 ? 1 / v : null;
+  return v;
+}
+
+/** The plate's number formatting (data picks it per row). */
+export function formatValue(v, format) {
+  const n = num(v);
+  if (n === null) return '—';
+  if (format === '1dp') return n.toFixed(1);
+  if (format === '2dp') return n.toFixed(2);
+  return String(Math.round(n));
+}
+
+/**
+ * One readout row, resolved + formatted. `meta` (the row's small right-hand
+ * read — a recharge delay, a shot speed) is resolved the same way.
+ * @returns {{id,label,text,unit,value,frac,meta,note,accent}}
+ */
+export function resolveRow(def, snap) {
+  const v = resolveValue(def, snap);
+  const meter = def?.meter ?? null;
+  let frac = null;
+  if (meter) {
+    const fill = resolveRef(meter.fill ?? meter.of, snap);
+    const max = meter.max ? resolveRef(meter.max, snap) : v;
+    frac = fill !== null && max !== null && max > 0 ? clamp01(fill / max) : null;
+  }
+  const m = def?.meta ?? null;
+  const mv = m ? resolveValue(m, snap) : null;
+  return {
+    id: String(def?.id ?? ''),
+    group: String(def?.group ?? ''),
+    label: String(def?.label ?? '').toUpperCase(),
+    value: v,
+    text: formatValue(v, def?.format),
+    unit: String(def?.unit ?? ''),
+    frac,
+    note: def?.note ? String(def.note) : '',
+    accent: def?.accent ?? null,
+    // the row's own DANGER line (data picks it; damage meters declare one, an
+    // empty cargo bay is not an alarm). null = the row never reads hot.
+    hotBelow: Number.isFinite(def?.hotBelow) ? Number(def.hotBelow) : null,
+    meta: m && mv !== null ? `${String(m.label ?? '').toUpperCase()} ${formatValue(mv, m.format)}${m.unit ? ' ' + m.unit : ''}` : '',
+    nominalLabel: def?.nominalLabel ? String(def.nominalLabel) : '',
+  };
+}
+
+/** Every configured row (data order), grouped by status.groups. */
+export function statRows(rows, snap) {
+  const defs = Array.isArray(rows) ? rows : [];
+  return defs.filter((d) => d && d.id).map((d) => resolveRow(d, snap));
+}
+
+/** The rows of one group, with the group's label (status.groups). */
+export function statGroups(rows, snap, groups) {
+  const all = statRows(rows, snap);
+  const gs = Array.isArray(groups) && groups.length ? groups : [...new Set(all.map((r) => r.group))].map((g) => ({ id: g, label: g }));
+  return gs
+    .map((g) => ({ id: String(g.id), label: String(g.label ?? g.id).toUpperCase(), rows: all.filter((r) => r.group === String(g.id)) }))
+    .filter((g) => g.rows.length);
+}
+
+/** A row resolves (or explicitly reads a dash — never a NaN hole in the plate). */
+export function isResolved(row) {
+  return row && row.value !== null && Number.isFinite(row.value);
+}
+
+/**
+ * The COMBAT RECORD's journey state (data/shipScreen.json → level). The
+ * ledger itself is js/combat/XpManager.js — stats() hands over {level, xp,
+ * xpForNext, maxLevel, isMax, progress}; this only decides what the plate
+ * shows:
+ *   mode 'timeline' the level `from`…`to` milestone journey (below `classAt`)
+ *   mode 'choose'   the CHOOSE YOUR CLASS door (at/above `classAt`, no class)
+ *   mode 'class'    a class is on file — its name + insignia slot
+ * @param {object|null} stats XpManager.stats() (null: no combat ledger yet)
+ * @param {object} cfg data/shipScreen.json → level
+ * @param {{name?: string|null, insignia?: string|null}} [playerClass]
+ */
+export function journeyState(stats, cfg = {}, playerClass = null) {
+  const from = Number.isFinite(cfg.from) ? cfg.from : 0;
+  const to = Number.isFinite(cfg.to) ? cfg.to : 5;
+  const classAt = Number.isFinite(cfg.classAt) ? cfg.classAt : 5;
+  const s = stats ?? null;
+  const level = s ? Math.max(0, Math.round(Number(s.level) || 0)) : from;
+  const xp = s ? Math.max(0, Math.round(Number(s.xp) || 0)) : 0;
+  const xpForNext = s ? Math.max(0, Math.round(Number(s.xpForNext) || 0)) : 0;
+  const isMax = !!(s && s.isMax);
+  // the partial step toward the NEXT level (the bar's fine fill)
+  const step = clamp01(Number(s?.progress) || 0);
+  const span = Math.max(1, to - from);
+  const nodes = (Array.isArray(cfg.milestones) ? cfg.milestones : []).map((m) => {
+    const lv = Number(m?.level);
+    return {
+      level: Number.isFinite(lv) ? lv : from,
+      label: String(m?.label ?? `LV ${lv}`).toUpperCase(),
+      note: String(m?.note ?? ''),
+      state: !Number.isFinite(lv) ? 'future' : level > lv ? 'done' : level === lv ? 'current' : 'future',
+    };
+  });
+  const name = typeof playerClass?.name === 'string' && playerClass.name ? playerClass.name : null;
+  return {
+    mode: name ? 'class' : level >= classAt ? 'choose' : 'timeline',
+    from,
+    to,
+    classAt,
+    level,
+    xp,
+    xpForNext,
+    step,
+    isMax,
+    // the journey's overall fill (0…1 across `from`…`to`) — the timeline bar
+    progress: clamp01((level - from + (level >= to ? 0 : step)) / span),
+    nodes,
+    className: name,
+    classInsignia: playerClass?.insignia ?? null,
+  };
+}
+
+/**
+ * The CARGO HOLDS summary (js/economy/CargoHold.js — one good per bay, in
+ * fill order — plus the aggregate mineral load the Mineral HUD reads).
+ * @param {object} snap the console snapshot ({ ship: { stats, minerals,
+ *   cargo: { used, items }, credits } })
+ * @param {Array<{id:string,name:string,icon?:number}>} [catalog] goods.json
+ *   (icon frame + display name; ShopView's texture is keyed off it)
+ */
+export function cargoSummary(snap = {}, catalog = []) {
+  const hold = snap?.ship?.cargo ?? null;
+  const items = Array.isArray(hold?.items) ? hold.items : [];
+  const capacity = Math.max(0, Math.round(num(snap?.ship?.stats?.cargoHold) ?? 0));
+  const slots = Math.max(1, Math.round(num(config.get('economy.cargo.maxSlots', 16)) || 16));
+  const used = Math.max(0, Math.min(capacity, Math.round(num(hold?.used) ?? items.length)));
+  const byId = new Map((Array.isArray(catalog) ? catalog : []).map((g) => [String(g.id), g]));
+  return {
+    // the bays as drawn: filled first (the held good), then the unlocked-but-
+    // empty bays, then the grid's LOCKED bays (the hold's upgrade room)
+    bays: Array.from({ length: Math.max(used, Math.min(slots, Math.max(capacity, used))) }).map((_, i) => {
+      const id = items[i] != null ? String(items[i]) : null;
+      const g = id ? byId.get(id) ?? null : null;
+      return {
+        bay: i,
+        goodId: id,
+        name: g?.name ?? id ?? '',
+        icon: Number.isFinite(Number(g?.icon)) ? Number(g.icon) : null,
+        state: id ? 'filled' : i < capacity ? 'empty' : 'locked',
+      };
+    }),
+    used,
+    capacity,
+    room: Math.max(0, capacity - used),
+    pct: capacity > 0 ? Math.min(1, used / capacity) : 0,
+    slots,
+    lockedBays: Math.max(0, slots - capacity),
+    empty: used === 0,
+    minerals: Math.max(0, Math.round(num(snap?.ship?.minerals) ?? 0)),
+    mineralCapacity: Math.max(0, Math.round(num(snap?.ship?.stats?.mineralStorage) ?? 0)),
+    // the same fill the Mineral HUD draws (js/ui/MineralHud.js is fed
+    // ship.minerals / stats.mineralStorage — one aggregate, no ore model)
+    mineralPct: Number(num(snap?.ship?.stats?.mineralStorage)) > 0
+      ? Math.min(1, Math.max(0, num(snap?.ship?.minerals) ?? 0) / num(snap?.ship?.stats?.mineralStorage))
+      : 0,
+    credits: Math.max(0, Math.round(num(snap?.ship?.credits) ?? 0)),
+  };
+}
+
+export default { resolveRef, resolveValue, formatValue, resolveRow, statRows, statGroups, isResolved, journeyState, cargoSummary };
diff --git a/js/ui/ShipWindow.js b/js/ui/ShipWindow.js
new file mode 100644
index 0000000..895a067
--- /dev/null
+++ b/js/ui/ShipWindow.js
@@ -0,0 +1,1584 @@
+/**
+ * ShipWindow — the full-screen SHIP SYSTEMS console (the deck's SHIP button,
+ * main deck). Same console as RESEARCH / MAP / QUESTS
+ * (js/ui/ResearchWindow.js, js/ui/MapWindow.js, js/ui/QuestWindow.js):
+ *
+ *  ┌────────────────────────────────────────────────────────────────────┐
+ *  │  SHIP SYSTEMS ▌ HULL & HARDWARE // LIVE TELEMETRY              [✕] │
+ *  ├───────────────────────┬────────────────────────────────────────────┤
+ *  │  ● BAY CAMERA // LIVE │  [ SHIP STATUS ] [SKILL ▢] [ CARGO HOLDS ] │
+ *  │  ┌─────────────────┐  │  ┌──────────────────────────────────────┐  │
+ *  │  │  2:3 video feed  │  │  │ ◆ HARDWARE READOUT      ARMOR/GUNS…  │  │
+ *  │  │ (muted, looping) │  │  │  ▸ ARMOR                               │  │
+ *  │  │ + scanlines      │  │  │    HULL STRENGTH        100 HP  ████  │  │
+ *  │  │ + sweep band     │  │  │    CURRENT HULL         100 HP NOMINAL│  │
+ *  │  └─────────────────┘  │  │    SHIELD RECHARGE      12 /s ARM 3 s  │  │
+ *  │  ● REC                 │  │  ▸ FLIGHT / GUNS / HOLD …              │  │
+ *  │  LV 3 · 240/700 XP     │  ├────────────── COMBAT RECORD ──────────┤  │
+ *  │                        │  │  LEVEL 3   ▓▓▓▓░░░  240/700 XP        │  │
+ *  │                        │  │  THE JOURNEY // LV 0–5                 │  │
+ *  │                        │  │  SADDLE─SOLO─SCUFFED─◉╌╌○ CHOSEN       │  │
+ *  └───────────────────────┴────────────────────────────────────────────┘
+ *
+ * Left: the bay camera — assets/videos/ship.mp4, a 2:3 portrait (the same
+ * dimensions as the research / map / quests feeds) that LOOPS MUTED while the
+ * console is open; the procedural NO SIGNAL plate stands in until the clip is
+ * authored (data/shipScreen.json → video). Right: the tab row over ONE plate:
+ *
+ *   SHIP STATUS  the hardware readout — every number resolved through the
+ *                data-driven source table (data/shipScreen.json → status.rows
+ *                via js/ship/ShipScreenModel.js): the ship's stats, the live
+ *                combat session's hull/shields, the cannon's cooldown→rate and
+ *                the hold's bays/minerals — plus the COMBAT RECORD: the XP
+ *                bank (js/combat/XpManager.js) as a bar + the level 0→5
+ *                journey of milestone nodes. At the class level the journey
+ *                gives way to the CHOOSE YOUR CLASS door.
+ *   SKILL TREE   a STANDBY socket until the class layer lands — press it and
+ *                the tab shakes, the scene toasts (the locked plate stays
+ *                reachable so the console's final shape is visible).
+ *   CARGO HOLDS  the bays aboard (js/economy/CargoHold.js — one good per bay)
+ *                + the aggregate mineral load; a filled bay opens its manifest
+ *                with the charted exits (js/economy/TradeScout.js), the same
+ *                report the planet shop's scout panel renders.
+ *
+ * The window is a VIEW. Every number comes from the scene's snapshot seam
+ * (GameScene.shipScreenSnapshot — ship.stats, the live combat session, the XP
+ * ledger, the cargo hold) polled while open and repainted on any real change;
+ * labels / units / order / format / copy all live in data/shipScreen.json.
+ *
+ * Cyberpunk dressing in the Map/Quest idiom: cut-corner plates (CyberShape),
+ * decode text (ScrambleDecode), a crawling sweep band, an occasional RGB-split
+ * glitch burst, a breathing REC dot.
+ */
+import Phaser from '../vendor/phaser.js';
+import { config } from '../config/Config.js';
+import { toCss } from '../utils/Color.js';
+import { fontStack, themeColor } from '../utils/Theme.js';
+import { ScrambleDecode } from '../utils/Decode.js';
+import { playSfxOn } from '../utils/Sfx.js';
+import { CyberShape } from './CyberShape.js';
+import { GOODS_TEXTURE_KEY, goodFrame, goodsTextureReady } from '../economy/Economy.js';
+import { statRows, journeyState, cargoSummary } from '../ship/ShipScreenModel.js';
+
+const HEADER = fontStack('header');
+const BODY = fontStack('body');
+const rand = (a, b) => a + Math.random() * (b - a);
+const clamp01 = (v) => Math.min(1, Math.max(0, v));
+const easeIO = (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(2 - 2 * u, 2) / 2);
+
+/** Theme palette (data/theme.json), resolved once at build time. */
+const C = {
+  ink: themeColor('ink', 0xeaf6ff),
+  dim: themeColor('dim', 0x7d92c4),
+  faint: themeColor('faint', 0x3d4c74),
+  neon: themeColor('neon', 0x00e5ff),
+  neon2: themeColor('neon2', 0xff2d6f),
+  amber: themeColor('amber', 0xffc94d),
+  green: themeColor('green', 0x67e863),
+  red: themeColor('red', 0xff5c77),
+  panel: themeColor('panel', 0x0a1120),
+  bg: themeColor('bg', 0x04060d),
+};
+
+/** The built-in tab row (data/shipScreen.json → tabs overrides it). */
+const DEFAULT_TABS = [
+  { id: 'status', label: 'Ship Status', accent: '#7ce8a4' },
+  { id: 'skills', label: 'Skill Tree', accent: '#ffc94d', standby: true, standbyTag: 'OFFLINE' },
+  { id: 'cargo', label: 'Cargo Holds', accent: '#00e5ff' },
+];
+
+/** A config accent (CSS string or int) → the int Graphics wants. */
+function col(v, fallback = C.neon) {
+  if (typeof v === 'number' && Number.isFinite(v)) return v >>> 0;
+  const s = String(v ?? '').trim();
+  if (!s) return fallback;
+  const n = parseInt(s.replace('#', ''), 16);
+  return Number.isFinite(n) ? n >>> 0 : fallback;
+}
+
+/** Fill a cut-corner plate (CyberShape.points is centred — translate to centre). */
+function panel(g, x, y, w, h, o = {}) {
+  const cx = x + w / 2;
+  const cy = y + h / 2;
+  const pts = CyberShape.points(w, h, o.notch ?? Math.min(14, h * 0.28)).map((p) => ({ x: p.x + cx, y: p.y + cy }));
+  if (o.fill !== undefined) {
+    g.fillStyle(o.fill, o.fillAlpha ?? 1);
+    g.fillPoints(pts, true);
+  }
+  if (o.stroke !== undefined) {
+    g.lineStyle(o.lineWidth ?? 1.5, o.stroke, o.strokeAlpha ?? 1);
+    g.strokePoints(pts, true);
+  }
+  if (o.glow !== undefined) {
+    g.lineStyle((o.lineWidth ?? 1.5) + 4, o.glow, o.glowAlpha ?? 0.22);
+    g.strokePoints(pts, true);
+  }
+}
+
+/** Corner brackets framing a rect (x, y = top-left). */
+function brackets(g, x, y, w, h, o = {}) {
+  const color = o.color ?? C.neon;
+  const alpha = o.alpha ?? 0.7;
+  const len = o.length ?? 14;
+  g.lineStyle(o.lineWidth ?? 2, color, alpha);
+  const x2 = x + w;
+  const y2 = y + h;
+  g.lineBetween(x, y, x + len, y);
+  g.lineBetween(x, y, x, y + len);
+  g.lineBetween(x2, y, x2 - len, y);
+  g.lineBetween(x2, y, x2, y + len);
+  g.lineBetween(x, y2, x + len, y2);
+  g.lineBetween(x, y2, x, y2 - len);
+  g.lineBetween(x2, y2, x2 - len, y2);
+  g.lineBetween(x2, y2, x2, y2 - len);
+}
+
+/** A filled diamond marker (group bullets, the journey's nodes). */
+function diamond(g, x, y, r, color, alpha = 1) {
+  if (!(r > 0)) return;
+  g.fillStyle(color, alpha);
+  g.fillPoints([{ x, y: y - r }, { x: x + r, y }, { x, y: y + r }, { x: x - r, y }], true);
+}
+
+export class ShipWindow extends Phaser.GameObjects.Container {
+  /** The left-panel clip (assets/videos/ship.mp4, loaded by the scene). */
+  static VIDEO_KEY = 'ship_feed';
+
+  /**
+   * @param {Phaser.Scene} scene the owning scene (GameScene)
+   * @param {object} [o]
+   * @param {() => object|null} [o.getSnapshot] — the live console snapshot
+   *   (GameScene.shipScreenSnapshot; polled while open, repaint on change)
+   * @param {(tabId: string) => void} [o.onLocked] — a STANDBY tab was hit
+   *   (the scene toasts — the Skill Tree socket)
+   * @param {() => void} [o.onChooseClass] — the level-5 CHOOSE YOUR CLASS door
+   *   was pressed (the class layer's seam; the scene answers it — a toast
+   *   until the class selection screen exists)
+   * @param {(goodId: string) => object|null} [o.getScoutReport] — a held
+   *   good's trade report (TradeScout.scoutGood over the run's chart), shown
+   *   by the bay manifest's BEST EXITS list
+   */
+  constructor(scene, o = {}) {
+    super(scene, 0, 0);
+    // v4 quirk: a directly-constructed GameObject must register itself.
+    this.scene.add.existing(this);
+    this.setScrollFactor(0); // UI — pinned to the screen
+    this.setDepth(80); // above save panel (70), sub-bar (60), deck (50)
+
+    this.getSnapshot = typeof o.getSnapshot === 'function' ? o.getSnapshot : null;
+    this.onLocked = typeof o.onLocked === 'function' ? o.onLocked : null;
+    this.onChooseClass = typeof o.onChooseClass === 'function' ? o.onChooseClass : null;
+    this.getScoutReport = typeof o.getScoutReport === 'function' ? o.getScoutReport : null;
+
+    this.openState = 'closed'; // closed | opening | open | closing
+    this.reveal = []; // { o, d, dur, t0 } — the boot stagger
+    this.decodes = []; // { txt, dec } — polled ScrambleDecodes
+    this._activeTab = 'status';
+    this._selectedBay = null; // the bay whose manifest is open (null = grid)
+    this._tabs = [];
+    this._tabShakes = [];
+    this._kids = []; // every child of the current body repaint
+    this._hits = [];
+    this._glitch = { until: 0, next: 0 };
+    this._sweep = null;
+    this._pollNext = 0;
+    this._fp = null; // the last painted fingerprint
+    this._snap = null; // the last snapshot (kept fresh between repaints)
+    this._destroyed = false;
+
+    this._build();
+    this.setVisible(true);
+    this.setAlpha(0); // hidden until open() — built once, shown on demand
+  }
+
+  sfx(name) {
+    playSfxOn(this.scene, name);
+  }
+
+  hasVideo(key) {
+    const c = this.scene.cache?.video;
+    return !!(c && typeof c.has === 'function' && c.has(key));
+  }
+
+  /** Decode text into `txt` starting at `t0` (driven by update()). */
+  decodeTo(txt, str, t0, dur = 520) {
+    this.decodes = this.decodes.filter((d) => d.txt !== txt);
+    if (txt.text === str) return;
+    txt.setText('');
+    this.decodes.push({ txt, dec: new ScrambleDecode(str, t0, dur) });
+  }
+
+  // ------------------------------------------------------------ geometry
+  _measure() {
+    const W = this.scene.scale.width;
+    const H = this.scene.scale.height;
+    const m = 10;
+    const pad = 14;
+    const rect = { x: m, y: m, w: W - 2 * m, h: H - 2 * m };
+    const titleH = 46;
+    const bodyY = rect.y + titleH + pad;
+    const bodyH = rect.h - titleH - 2 * pad;
+    // Left: the bay camera — 2:3 (data/shipScreen.json → video.aspect), the
+    // same frame as the research / map / quest feeds.
+    const a = config.get('shipScreen.video.aspect', [2, 3]);
+    const ar = a[0] > 0 && a[1] > 0 ? a[0] / a[1] : 2 / 3;
+    const videoH = Math.max(120, bodyH - 22 - 36 - 24 - 8);
+    const videoW = Math.max(90, videoH * ar);
+    const leftX = rect.x + pad;
+    const leftW = videoW + 30;
+    // Right: the tab row over ONE plate whose body each tab paints.
+    const rightX = leftX + leftW + pad;
+    const rightW = rect.x + rect.w - pad - rightX;
+    const tabH = 38;
+    const tabGap = 12;
+    const plateY = bodyY + tabH + tabGap;
+    const plateH = Math.max(160, rect.y + rect.h - pad - plateY);
+    return {
+      W, H, rect, titleH, pad,
+      bodyY, bodyH,
+      leftX, leftW, videoW, videoH,
+      rightX, rightW, tabH, tabGap,
+      plateX: rightX, plateY, plateW: rightW, plateH,
+    };
+  }
+
+  _build() {
+    const s = this.scene.add;
+    const geo = (this.geo = this._measure());
+
+    // window background
+    const bg = s.graphics().setScrollFactor(0);
+    panel(bg, geo.rect.x, geo.rect.y, geo.rect.w, geo.rect.h, {
+      notch: 16,
+      fill: 0x04060d,
+      fillAlpha: 0.94,
+      stroke: 0x1b3a5a,
+      strokeAlpha: 0.65,
+    });
+    this.add(bg);
+
+    // title: SHIP SYSTEMS + meta (decoded on open)
+    const titleY = geo.rect.y + 12;
+    const title = String(config.get('shipScreen.title', 'SHIP SYSTEMS'));
+    const titleStyle = { fontFamily: HEADER, fontSize: '19px', fontStyle: 'bold' };
+    this.titleTxt = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.ink) }).setScrollFactor(0);
+    this.add(this.titleTxt);
+    // the glitch's RGB-split partners (the Map/Quest voice)
+    this.titleGhostA = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.neon) }).setScrollFactor(0).setAlpha(0);
+    this.add(this.titleGhostA);
+    this.titleGhostB = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.neon2) }).setScrollFactor(0).setAlpha(0);
+    this.add(this.titleGhostB);
+    const tw = Math.max(150, 8 + title.length * 12);
+    this.titleMeta = s
+      .text(geo.rect.x + 18 + tw, titleY + 6, String(config.get('shipScreen.meta', 'HULL & HARDWARE // LIVE TELEMETRY')), {
+        fontFamily: BODY,
+        fontSize: '10px',
+        color: toCss(C.faint),
+        letterSpacing: 2.5,
+      })
+      .setScrollFactor(0);
+    this.add(this.titleMeta);
+
+    // close button — the interactivity rides the VISIBLE plate (v4 needs
+    // willRender() for a hit candidate; a transparent rect is never clicked)
+    const csize = 30;
+    const cx = geo.rect.x + geo.rect.w - csize - 14;
+    const cy = geo.rect.y + 11;
+    this.closeG = s.graphics().setScrollFactor(0);
+    this.add(this.closeG);
+    this._paintClose(false);
+    this.closeTxt = s
+      .text(cx + csize / 2, cy + csize / 2, '×', { fontFamily: BODY, fontSize: '14px', color: toCss(C.ink) })
+      .setOrigin(0.5)
+      .setScrollFactor(0);
+    this.add(this.closeTxt);
+    this.closeG.setInteractive({
+      useHandCursor: true,
+      hitArea: new Phaser.Geom.Rectangle(cx, cy, csize, csize),
+      hitAreaCallback: (area, px, py) => area.contains(px, py),
+    });
+    this.closeG.on('pointerover', () => {
+      if (!this.isOpen) return;
+      this.sfx('ui_hover');
+      this._paintClose(true);
+    });
+    this.closeG.on('pointerout', () => this._paintClose(false));
+    this.closeG.on('pointerdown', () => {
+      if (this.isOpen) this.close();
+    });
+
+    // the left feed + the right column
+    this.videoPanel = this._buildVideoPanel();
+    this.add(this.videoPanel);
+    this._buildTabs();
+    this.plateG = s.graphics().setScrollFactor(0);
+    this.add(this.plateG);
+    this.bodyCont = new Phaser.GameObjects.Container(this.scene, 0, 0).setScrollFactor(0);
+    this.add(this.bodyCont);
+
+    // Scanlines live on the FEED ONLY (the tile sprite inside
+    // _buildVideoPanel) — dragging the Map/Quest full-plate veil over the
+    // whole window dimmed the stat readouts and made small tab copy muddy.
+    // `this.scan` stays a valid handle for the shake slot; it simply has no
+    // full-window layer to shove any more (the video panel shakes with it).
+    this.scan = null;
+
+    // the glitch layer (RGB-split slices over the feed + title)
+    this.glitchG = s.graphics().setScrollFactor(0);
+    this.add(this.glitchG);
+
+    this._paintPlate();
+    this._paintAll(null); // the empty state until the first snapshot
+  }
+
+  /** Repaint the close plate (one persistent graphics object — swapping it
+   *  out would orphan its handlers). */
+  _paintClose(hot) {
+    const csize = 30;
+    const cx = this.geo.rect.x + this.geo.rect.w - csize - 14;
+    const cy = this.geo.rect.y + 11;
+    const g = this.closeG;
+    g.clear();
+    g.lineStyle(1.5, C.neon, hot ? 0.9 : 0.55);
+    g.strokeRect(cx, cy, csize, csize);
+    g.lineStyle(2, hot ? C.ink : C.dim, 1);
+    g.lineBetween(cx + 9, cy + 9, cx + csize - 9, cy + csize - 9);
+    g.lineBetween(cx + csize - 9, cy + 9, cx + 9, cy + csize - 9);
+  }
+
+  // ------------------------------------------------------------ video panel
+  _buildVideoPanel() {
+    const { leftX, bodyY, leftW, videoW, videoH } = this.geo;
+    const s = this.scene.add;
+    const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY).setScrollFactor(0);
+
+    // header: ● BAY CAMERA // LIVE FEED (data/shipScreen.json → video.tag)
+    this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0);
+    cont.add(this.recDot);
+    cont.add(
+      s.text(20, 6, String(config.get('shipScreen.video.tag', 'BAY CAMERA // LIVE FEED')), {
+        fontFamily: BODY,
+        fontSize: '10px',
+        color: toCss(C.faint),
+        letterSpacing: 2.5,
+      }).setScrollFactor(0)
+    );
+
+    // frame + the clip
+    const vx = (leftW - videoW) / 2;
+    const vy = 26;
+    const frameG = s.graphics().setScrollFactor(0);
+    brackets(frameG, vx - 6, vy - 6, videoW + 12, videoH + 12, { length: 12, color: C.neon, alpha: 0.7 });
+    frameG.lineStyle(1, 0x14324f, 0.55);
+    frameG.lineBetween(vx - 6, vy + videoH / 2, vx - 2, vy + videoH / 2);
+    frameG.lineBetween(vx + videoW + 2, vy + videoH / 2, vx + videoW + 6, vy + videoH / 2);
+    cont.add(frameG);
+
+    const vcx = vx + videoW / 2;
+    const vcy = vy + videoH / 2;
+    const vrect = { x: vx, y: vy, w: videoW, h: videoH };
+
+    // The NO SIGNAL plate is ALWAYS the base layer: the clip is planned but
+    // not authored yet (assets/videos/ship.mp4 is not in the repo), and even a
+    // file that 404s still registers its cache key — cache.video.has() cannot
+    // tell the two apart, so the plate is the only reliable fallback. A live
+    // clip simply covers it the moment it decodes.
+    this._paintVideoFallback(cont, s, vx, vy, videoW, videoH, vcx, vcy);
+    this.video = null;
+    if (this.hasVideo(ShipWindow.VIDEO_KEY)) {
+      const v = s.video(0, 0, ShipWindow.VIDEO_KEY);
+      v.setOrigin(0.5).setScrollFactor(0);
+      // ambience: muted + looping (v4's setVolume only sets el.volume — the
+      // autoplay policy looks at el.muted, so mute it properly)
+      v.setVolume(0);
+      v.setLoop(true);
+      if (v.video) v.video.muted = true;
+      v.setVisible(false); // the plate stands in until 'created'
+      const fit = (vv, iw = 0, ih = 0) => {
+        const el = vv.video;
+        const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 544;
+        const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 800;
+        vv.setPosition(vcx, vcy);
+        vv.setScale(Math.min(videoW / vw, videoH / vh));
+      };
+      fit(v);
+      v.on('created', (vv, w, h) => {
+        if (vv !== v || v.destroyed) return;
+        fit(vv, w, h);
+        vv.setVisible(true);
+      });
+      v.on('error', () => {
+        // 404 or undecodable — the plate stays up; the console still works.
+        if (v.destroyed) return;
+        if (this.video === v) this.video = null;
+        v.destroy();
+      });
+      cont.add(v);
+      this.video = v;
+    }
+
+    // scanlines over the feed
+    const scanKey = 'shipfeed_scanlines';
+    if (!this.scene.textures.exists(scanKey)) {
+      const c = document.createElement('canvas');
+      c.width = 4;
+      c.height = 8;
+      const ctx = c.getContext('2d');
+      ctx.fillStyle = 'rgba(2,6,12,0.5)';
+      ctx.fillRect(0, 0, 4, 3);
+      ctx.fillStyle = 'rgba(120,220,255,0.05)';
+      ctx.fillRect(0, 4, 4, 1);
+      this.scene.textures.addCanvas(scanKey, c);
+    }
+    cont.add(s.tileSprite(vcx, vcy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5));
+
+    // the sweep band (crawls down the feed on a loop)
+    const swKey = 'shipscreen_sweep';
+    if (!this.scene.textures.exists(swKey)) {
+      const c = document.createElement('canvas');
+      c.width = 8;
+      c.height = 64;
+      const ctx = c.getContext('2d');
+      const grad = ctx.createLinearGradient(0, 0, 0, 64);
+      grad.addColorStop(0, 'rgba(127,223,255,0)');
+      grad.addColorStop(0.5, 'rgba(127,223,255,0.5)');
+      grad.addColorStop(1, 'rgba(127,223,255,0)');
+      ctx.fillStyle = grad;
+      ctx.fillRect(0, 0, 8, 64);
+      this.scene.textures.addCanvas(swKey, c);
+    }
+    this.sweepBand = s
+      .image(vcx, vrect.y - 40, swKey)
+      .setDisplaySize(videoW, 48)
+      .setScrollFactor(0)
+      .setAlpha(0)
+      .setBlendMode(Phaser.BlendModes.ADD);
+    cont.add(this.sweepBand);
+    this.sweepCfg = config.get('shipScreen.sweep', {});
+
+    // bottom status strip — the ship line + the level bar (the quick read)
+    const sy = vy + videoH + 14;
+    const strip = s.graphics().setScrollFactor(0);
+    strip.lineStyle(1, 0x14324f, 0.8);
+    strip.lineBetween(2, sy, leftW - 2, sy);
+    strip.fillStyle(C.neon, 0.7);
+    strip.fillRect(2, sy - 1, 26, 2);
+    cont.add(strip);
+    this.statusTxt = s
+      .text(2, sy + 10, 'SHIP SYSTEMS ONLINE', { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2 })
+      .setScrollFactor(0);
+    cont.add(this.statusTxt);
+    this.statusBar = s.graphics().setScrollFactor(0);
+    this.statusBarY = sy + 32;
+    this.statusBarW = Math.max(40, leftW - 60);
+    cont.add(this.statusBar);
+
+    this.videoCx = vcx;
+    this.videoCy = vcy;
+    this.videoRect = vrect;
+    return cont;
+  }
+
+  /** The 'NO SIGNAL' plate — a procedural static frame (no stand-in clip),
+   *  the ship's own silhouette drawn from the plate's geometry. */
+  _paintVideoFallback(cont, s, vx, vy, videoW, videoH, vcx, vcy) {
+    const ph = s.graphics().setScrollFactor(0);
+    panel(ph, vx, vy, videoW, videoH, { notch: 6, fill: 0x050a12, fillAlpha: 0.9, stroke: 0x1b3a5a, strokeAlpha: 0.5 });
+    for (let i = 0; i < 40; i++) {
+      ph.fillStyle(C.neon, rand(0.02, 0.08));
+      ph.fillRect(vx + rand(0, videoW - 4), vy + rand(0, videoH), rand(8, 60), 1);
+    }
+    const r = Math.min(videoW, videoH) * 0.16;
+    ph.lineStyle(1.5, C.neon, 0.3);
+    ph.strokePoints(
+      [
+        { x: vcx, y: vcy - r * 1.5 },
+        { x: vcx + r, y: vcy + r * 0.6 },
+        { x: vcx + r * 0.35, y: vcy + r * 0.25 },
+        { x: vcx - r * 0.35, y: vcy + r * 0.25 },
+        { x: vcx - r, y: vcy + r * 0.6 },
+      ],
+      true
+    );
+    cont.add(ph);
+    cont.add(
+      s.text(vcx, vcy - 34, 'NO SIGNAL', {
+        fontFamily: HEADER, fontSize: '14px', color: toCss(C.ink), fontStyle: 'bold', letterSpacing: 5, align: 'center',
+      }).setScrollFactor(0)
+    );
+    cont.add(
+      s.text(vcx, vcy - 14, String(config.get('shipScreen.video.noSignalTag', 'BAY CAMERA OFFLINE')), {
+        fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 3, align: 'center',
+      }).setScrollFactor(0)
+    );
+  }
+
+  // ------------------------------------------------------------ tabs
+  _tabDefs() {
+    const defs = config.get('shipScreen.tabs', DEFAULT_TABS);
+    const arr = Array.isArray(defs) && defs.length ? defs : DEFAULT_TABS;
+    return arr.filter((t) => t && typeof t.id === 'string');
+  }
+
+  _buildTabs() {
+    const s = this.scene.add;
+    const { rightX, bodyY, rightW, tabH } = this.geo;
+    const defs = this._tabDefs();
+    const gap = 10;
+    // fixed-width sockets when they fit; share the width when they don't
+    const tabW = Math.max(96, Math.min(172, (rightW - gap * (defs.length - 1)) / defs.length));
+    let x = rightX + Math.max(0, (rightW - (defs.length * tabW + (defs.length - 1) * gap)) / 2);
+    for (const t of defs) {
+      const id = String(t.id);
+      const label = String(t.label ?? id).toUpperCase();
+      const accent = t.accent ?? null;
+      const standby = t.standby === true;
+      const standbyTag = String(t.standbyTag ?? 'STANDBY').toUpperCase();
+      const g = s.graphics().setScrollFactor(0);
+      const txt = s.text(0, 0, label, { fontFamily: BODY, fontSize: '12px', color: toCss(C.dim), letterSpacing: 2 })
+        .setOrigin(0.5).setScrollFactor(0);
+      const tagTxt = standby
+        ? s.text(0, 0, standbyTag, { fontFamily: BODY, fontSize: '8px', color: toCss(C.faint), letterSpacing: 1.5 })
+            .setOrigin(0.5).setScrollFactor(0)
+        : null;
+      const tab = { id, label, accent, standby, g, txt, tagTxt, x, w: tabW };
+      g.setInteractive({
+        useHandCursor: true,
+        hitArea: new Phaser.Geom.Rectangle(x, bodyY, tabW, tabH),
+        hitAreaCallback: (area, px, py) => area.contains(px, py),
+      });
+      g.on('pointerover', () => {
+        if (!this.isOpen) return;
+        this.sfx('ui_hover');
+        this._paintTab(tab, standby ? 'standby' : this._activeTab === id ? 'active' : 'hot');
+      });
+      g.on('pointerout', () => this._paintTab(tab, standby ? 'standby' : this._activeTab === id ? 'idle' : 'idle'));
+      g.on('pointerdown', () => {
+        if (!this.isOpen) return;
+        if (standby) {
+          // the socket is not live — shake it and let the scene toast why
+          this.sfx('ui_click');
+          this._shakeTab(tab);
+          this.onLocked?.(id);
+          return;
+        }
+        this._setTab(id);
+      });
+      this._paintTab(tab, standby ? 'standby' : id === this._activeTab ? 'active' : 'idle');
+      this.add(g);
+      this.add(txt);
+      if (tagTxt) this.add(tagTxt);
+      this._tabs.push(tab);
+      x += tabW + gap;
+    }
+  }
+
+  /** One tab's visual state: 'active' | 'hot' | 'idle' | 'standby'. */
+  _paintTab(tab, mode) {
+    const { g, txt, tagTxt, x, w } = tab;
+    const y = this.geo.bodyY;
+    const h = this.geo.tabH;
+    const accent = col(tab.accent);
+    g.clear();
+    let stroke, strokeA, fillA, ink;
+    if (mode === 'active') {
+      stroke = accent; strokeA = 0.95; fillA = 0.16; ink = C.ink;
+    } else if (mode === 'hot') {
+      stroke = C.neon; strokeA = 0.7; fillA = 0.08; ink = C.ink;
+    } else if (mode === 'standby') {
+      stroke = 0x14324f; strokeA = 0.8; fillA = 0.25; ink = C.faint;
+    } else {
+      stroke = 0x14324f; strokeA = 0.9; fillA = 0.5; ink = C.dim;
+    }
+    panel(g, x, y, w, h, { notch: 8, fill: C.panel, fillAlpha: fillA, stroke, strokeAlpha: strokeA });
+    if (mode === 'active') {
+      g.fillStyle(accent, 0.9);
+      g.fillRect(x + 10, y + h - 4, w - 20, 2);
+    }
+    if (tab.standby) {
+      g.lineStyle(1, C.faint, 0.9); // the standby square (the socket is not live)
+      g.strokeRect(x + 12, y + h / 2 - 4, 8, 8);
+    }
+    txt.color = toCss(ink);
+    txt.setText(tab.standby ? '  ' + tab.label : tab.label);
+    txt.x = x + w / 2 + (tab.standby ? 6 : 0);
+    txt.y = y + (tagTxt ? h / 2 - 6 : h / 2);
+    if (tagTxt) {
+      tagTxt.x = txt.x;
+      tagTxt.y = y + h / 2 + 9;
+    }
+  }
+
+  _paintTabs() {
+    for (const t of this._tabs) this._paintTab(t, t.standby ? 'standby' : this._activeTab === t.id ? 'active' : 'idle');
+  }
+
+  /** The standby tab's rejected press — a shake (the map's STANDBY tabs do it). */
+  _shakeTab(tab) {
+    this._tabShakes.push({ tab, t0: this.scene.time.now, x: tab.txt.x });
+  }
+
+  /** Switch the live tab (STANDBY sockets never reach here). */
+  _setTab(id) {
+    if (this._activeTab === id) return;
+    this._activeTab = id;
+    this._selectedBay = null; // every tab opens on its own summary
+    this.sfx('ui_click');
+    this._paintTabs();
+    this._buildBody(this._snap);
+  }
+
+  get activeTab() {
+    return this._activeTab;
+  }
+
+  // ------------------------------------------------------------ body plumbing
+  /** The one right-hand plate every tab shares. */
+  _paintPlate() {
+    const { plateX, plateY, plateW, plateH } = this.geo;
+    const g = this.plateG;
+    g.clear();
+    panel(g, plateX, plateY, plateW, plateH, { notch: 10, fill: 0x060b16, fillAlpha: 0.85, stroke: 0x14324f, strokeAlpha: 0.9 });
+    brackets(g, plateX - 4, plateY - 4, plateW + 8, plateH + 8, { length: 12, color: C.neon, alpha: 0.5 });
+  }
+
+  /** A text child of the current body repaint. */
+  txt(x, y, str, o = {}) {
+    const t = this.scene.add
+      .text(x, y, String(str ?? ''), {
+        fontFamily: o.head ? HEADER : BODY,
+        fontSize: `${o.size ?? 11}px`,
+        color: toCss(o.color ?? C.dim),
+        letterSpacing: o.ls ?? 0,
+        align: o.align ?? 'left',
+        wordWrap: o.wrap ? { width: o.wrap, breakWord: true } : undefined,
+        ...(o.bold ? { fontStyle: 'bold' } : {}),
+      })
+      .setScrollFactor(0);
+    // origin accepts a number or [x, y] — right-aligned copy needs the x
+    // anchor alone (a numeric origin would also move the baseline).
+    if (Array.isArray(o.origin)) t.setOrigin(o.origin[0], o.origin[1]);
+    else if (o.origin !== undefined) t.setOrigin(o.origin);
+    this.bodyCont.add(t);
+    this._kids.push(t);
+    return t;
+  }
+
+  /** A graphics child of the current body repaint. */
+  gfx() {
+    const g = this.scene.add.graphics().setScrollFactor(0);
+    this.bodyCont.add(g);
+    this._kids.push(g);
+    return g;
+  }
+
+  /** An icon child (the goods spritesheet — the shop's texture, when loaded). */
+  icon(x, y, frame, size = 40) {
+    if (!goodsTextureReady(this.scene)) return null;
+    const sp = this.scene.add
+      .image(x, y, GOODS_TEXTURE_KEY, goodFrame(frame))
+      .setDisplaySize(size, size)
+      .setScrollFactor(0);
+    this.bodyCont.add(sp);
+    this._kids.push(sp);
+    return sp;
+  }
+
+  /** A button-ish plate with a label; the callback fires on pointerdown. */
+  button(x, y, w, h, label, accent, onPress) {
+    const g = this.gfx();
+    const paint = (hot) => {
+      g.clear();
+      panel(g, x, y, w, h, {
+        notch: 8,
+        fill: col(accent),
+        fillAlpha: hot ? 0.26 : 0.14,
+        stroke: col(accent),
+        strokeAlpha: hot ? 1 : 0.7,
+        glow: hot ? col(accent) : undefined,
+      });
+    };
+    paint(false);
+    const t = this.txt(x + w / 2, y + h / 2, label, { color: C.ink, size: 12, ls: 2.4, align: 'center', origin: 0.5, bold: true });
+    g.setInteractive({
+      useHandCursor: true,
+      hitArea: new Phaser.Geom.Rectangle(x, y, w, h),
+      hitAreaCallback: (area, px, py) => area.contains(px, py),
+    });
+    g.on('pointerover', () => {
+      if (!this.isOpen) return;
+      this.sfx('ui_hover');
+      paint(true);
+    });
+    g.on('pointerout', () => paint(false));
+    g.on('pointerdown', () => {
+      if (!this.isOpen) return;
+      this.sfx('ui_click');
+      onPress?.();
+    });
+    this._hits.push(g);
+    return { g, t, paint };
+  }
+
+  /** Drop the body and rebuild it (the repaint idiom). */
+  _buildBody(snap) {
+    for (const k of this._kids) {
+      try {
+        k.destroy();
+      } catch {
+        /* already gone */
+      }
+    }
+    this._kids = [];
+    this._hits = [];
+    if (this.bodyCont.list) this.bodyCont.list.length = 0;
+    const s = snap ?? this._snap ?? {};
+    if (this._activeTab === 'cargo') this._buildCargoBody(s);
+    else if (this._activeTab === 'skills') this._buildSkillsBody();
+    else this._buildStatusBody(s);
+  }
+
+  /** A plate headline: ◆ LABEL ─────────── meta */
+  head(x, y, w, label, meta, accent) {
+    const g = this.gfx();
+    diamond(g, x + 5, y + 8, 4, col(accent ?? C.neon), 0.9);
+    g.lineStyle(1, 0x14324f, 0.9);
+    g.lineBetween(x + 16, y + 8, x + w, y + 8);
+    this.txt(x + 16, y - 2, label, { color: C.ink, size: 11, ls: 2.4, bold: true });
+    if (meta) this.txt(x + w, y, meta, { color: C.faint, size: 9, ls: 1.6, align: 'right', origin: 1 });
+  }
+
+  /** A thin fill bar (the meters). */
+  bar(g, x, y, w, h, frac, accent, o = {}) {
+    const f = clamp01(frac ?? 0);
+    g.lineStyle(1, o.stroke ?? 0x1e3a5c, o.strokeAlpha ?? 1);
+    g.strokeRect(x, y, w, h);
+    if (o.track) {
+      g.fillStyle(o.track, 0.18);
+      g.fillRect(x + 1, y + 1, w - 2, h - 2);
+    }
+    g.fillStyle(col(accent), o.alpha ?? 0.75);
+    g.fillRect(x + 1, y + 1, Math.max(0, (w - 2) * f), h - 2);
+    if (f > 0 && f < 1) {
+      // the leading edge reads brighter (the meter is alive)
+      g.fillStyle(C.ink, 0.85);
+      g.fillRect(x + Math.max(0, (w - 2) * f) - 1, y + 1, 2, h - 2);
+    }
+  }
+
+  // ------------------------------------------------------------ SHIP STATUS
+  /** The hardware readout + the combat record. Rows come from the data table
+   *  (data/shipScreen.json → status.rows), resolved by the pure model. */
+  _buildStatusBody(snap) {
+    const cfg = config.section('shipScreen.status', {});
+    const { plateX, plateY, plateW, plateH } = this.geo;
+    const x = plateX + 18;
+    const w = plateW - 36;
+    const rows = statRows(cfg.rows, snap);
+
+    // ---- COMBAT RECORD (top: the level bank + the journey) --------------
+    const recH = Math.min(196, Math.max(140, plateH * 0.27));
+    this._paintRecord(snap, x, plateY + 16, w, recH);
+
+    // ---- HARDWARE READOUT (below: every source-table row) --------------
+    const hwY = plateY + 16 + recH + 22;
+    const hwH = Math.max(80, plateY + plateH - 18 - hwY);
+    this.head(x, hwY, w, cfg.hardwareTitle ?? 'HARDWARE READOUT', rows.every((r) => r.value !== null) ? '' : 'PARTIAL TELEMETRY', C.neon);
+    this._paintRowsTable(rows, Array.isArray(cfg.groups) ? cfg.groups : [], x, hwY + 22, w, hwH - 22);
+  }
+
+  /** The readout's rows as TWO columns of stacked group blocks (four
+   *  side-by-side columns leave a row no room for "MINERAL STORAGE ··· 250 u"
+   *  plus its meter). Each block: the group's rail + name, then its rows —
+   *  label over the meter, value right-aligned, meta line beneath. */
+  _paintRowsTable(rows, groups, x, y, w, h) {
+    const gs = (Array.isArray(groups) && groups.length ? groups : [...new Set(rows.map((r) => r.group))].map((g) => ({ id: g, label: String(g).toUpperCase() })))
+      .map((g) => ({ id: String(g.id), label: String(g.label ?? g.id).toUpperCase(), rows: rows.filter((r) => r.group === String(g.id)) }))
+      .filter((g) => g.rows.length);
+    if (!gs.length) return;
+    const cols = Math.min(2, gs.length);
+    const colGap = Math.round(Math.min(110, Math.max(56, w * 0.09)));
+    const cw = (w - colGap * (cols - 1)) / cols;
+    const headH = 26; // the group's rail line
+    const blockGap = 18;
+    // One column's worth of vertical rhythm: the column's height minus its
+    // heads/gaps, shared by every row it carries (clamped so a tall plate
+    // never stretches the rows into a ghost town and a short one stays legible).
+    const layout = (colBlocks) => {
+      const nRows = colBlocks.reduce((n, g) => n + g.rows.length, 0);
+      const heads = colBlocks.length * headH + Math.max(0, colBlocks.length - 1) * blockGap;
+      // the row rhythm the column's own height affords…
+      let pitch = Math.max(40, Math.min(58, (h - heads) / Math.max(1, nRows)));
+      // …but a short plate wins over the rhythm: never let the last row hang
+      // off the foot (the meta line below the meter has to stay inside too).
+      const need = pitch * nRows + heads;
+      if (need > h) pitch = Math.max(30, (h - heads) / Math.max(1, nRows));
+      return pitch;
+    };
+    // Two columns balanced by ROW COUNT (data order preserved — a group is
+    // never split), so neither column can run past the plate's foot.
+    let blocks = [gs];
+    if (cols === 2) {
+      blocks = [[], []];
+      const tally = [0, 0];
+      for (const g of gs) {
+        const c = tally[0] === tally[1] ? 0 : tally[0] < tally[1] ? 0 : 1;
+        blocks[c].push(g);
+        tally[c] += g.rows.length;
+      }
+      blocks = blocks.filter((b) => b.length);
+    }
+    blocks.forEach((colBlocks, ci) => {
+      const bx = x + ci * (cw + colGap);
+      const pitch = layout(colBlocks);
+      let by = y;
+      colBlocks.forEach((grp, gi) => {
+        // the group's rail + name
+        const rg = this.gfx();
+        rg.lineStyle(1.5, C.neon, 0.45);
+        rg.fillPoints([{ x: bx, y: by + 2 }, { x: bx + 7, y: by + 8 }, { x: bx, y: by + 14 }], true);
+        this.txt(bx + 13, by, grp.label, { color: C.neon, size: 9, ls: 3.4, bold: true });
+        rg.lineStyle(1, 0x14324f, 0.9);
+        rg.lineBetween(bx + 22 + grp.label.length * 7.4, by + 8, bx + cw - 6, by + 8);
+        let ry = by + headH;
+        for (const r of grp.rows) {
+          this._paintStatRow(r, bx, ry, cw - 6);
+          ry += pitch;
+        }
+        by = ry + (gi < colBlocks.length - 1 ? blockGap : 0);
+      });
+      // the column divider (the two blocks should never read as one list)
+      if (ci < blocks.length - 1) {
+        const dv = this.gfx();
+        dv.lineStyle(1, 0x14324f, 0.75);
+        dv.lineBetween(bx + cw + colGap / 2, y + 4, bx + cw + colGap / 2, Math.min(y + h, by));
+      }
+    });
+  }
+
+  /** One readout row: label · value+unit over its meter, meta line beneath. */
+  _paintStatRow(r, gx, ry, cw) {
+    const accent = col(r.accent ?? C.neon, C.neon);
+    // the danger read is the ROW's call (data → hotBelow) — an empty cargo bay
+    // is not an alarm, a hull down to a third is.
+    const hot = r.frac !== null && r.hotBelow !== null && r.frac <= r.hotBelow;
+    // a row at rest (no meter) still reads as a row — the hairline under it
+    const nominal = !!(r.nominalLabel && r.frac !== null && r.frac >= 0.999);
+    this.txt(gx, ry, r.label, { color: C.dim, size: 10, ls: 1.4 });
+    const vs = nominal
+      ? String(r.nominalLabel).toUpperCase()
+      : `${r.text}${r.unit ? ' ' + r.unit : ''}`;
+    this.txt(gx + cw, ry - 2, vs, {
+      color: nominal ? C.green : hot ? C.red : C.ink,
+      size: nominal ? 10 : 13,
+      ls: 1.4,
+      origin: [1, 0],
+      bold: !nominal,
+    });
+    const g = this.gfx();
+    if (r.frac !== null) this.bar(g, gx, ry + 17, cw, 5, r.frac, hot ? C.red : accent, { track: true });
+    else {
+      g.fillStyle(accent, 0.28);
+      g.fillRect(gx, ry + 19, cw, 2);
+    }
+    const sub = r.meta || r.note;
+    if (sub) this.txt(gx, ry + 27, sub, { color: C.faint, size: 8, ls: 1.6 });
+  }
+
+  /** The COMBAT RECORD plate — the XP bank's bar + the level 0→5 journey (or
+   *  the CHOOSE YOUR CLASS door at the class level). */
+  _paintRecord(snap, x, y, w, h) {
+    const cfg = config.section('shipScreen.level', {});
+    const j = journeyState(snap?.level, cfg, snap?.playerClass ?? null);
+    const g = this.gfx();
+    panel(g, x - 6, y - 6, w + 12, h, { notch: 10, fill: 0x04070f, fillAlpha: 0.75, stroke: 0x1b3a5a, strokeAlpha: 0.8 });
+    this.head(x, y + 4, w, cfg.title ?? 'COMBAT RECORD', j ? `${cfg.levelWord ?? 'LEVEL'} ${j.level}` : 'NO LEDGER', C.amber);
+
+    if (!j) {
+      this.txt(x + w / 2, y + h * 0.6, String(config.get('shipScreen.status.noCombatNote', 'DRIVE DARK — NO CONTACTS')).toUpperCase(), {
+        color: C.faint, size: 10, ls: 3, align: 'center', origin: 0.5,
+      });
+      return;
+    }
+
+    // the big level read + the XP bar toward the next level
+    const lvWord = String(cfg.levelWord ?? 'LEVEL');
+    this.txt(x + 6, y + 26, lvWord, { color: C.faint, size: 9, ls: 3 });
+    this.txt(x + 6, y + 34, String(j.level), { color: C.ink, size: 34, head: true, bold: true });
+    const lvW = 12 + String(j.level).length * 20;
+    const barX = x + 12 + lvW + 18;
+    const barW = Math.max(120, x + w - 12 - barX);
+    const xpLabel = j.isMax
+      ? String(cfg.maxLabel ?? 'MAX LEVEL').toUpperCase()
+      : `${String(cfg.xpLabel ?? 'XP')} ${j.xp}/${j.xpForNext}`;
+    this.txt(barX, y + 30, xpLabel, { color: C.faint, size: 9, ls: 2 });
+    const bg = this.gfx();
+    this.bar(bg, barX, y + 46, barW, 8, j.isMax ? 1 : j.step, j.isMax ? C.amber : C.neon, { track: true, alpha: 0.85 });
+    // the next-level tick (the bank's own number, not a guess)
+    if (!j.isMax) {
+      this.txt(barX + barW, y + 30, `${String(cfg.nextLabel ?? 'NEXT LV')} ${j.level + 1}`, {
+        color: C.faint, size: 8, ls: 1.6, align: 'right',
+      });
+    }
+
+    const jy = y + 74;
+    if (j.mode === 'timeline') this._paintJourney(j, cfg, x, jy, w, Math.max(60, y + h - 12 - jy));
+    else this._paintClassDoor(j, cfg, x, jy, w);
+  }
+
+  /** The journey's timeline: milestone nodes with the fill between them. */
+  _paintJourney(j, cfg, x, y, w, h) {
+    const title = String(cfg.journeyTitle ?? 'THE JOURNEY // LV {from}–{to}')
+      .replace('{from}', j.from)
+      .replace('{to}', j.to);
+    this.txt(x + 6, y, title, { color: C.dim, size: 9, ls: 2.6 });
+
+    const n = Math.max(1, j.nodes.length);
+    const padX = 26;
+    const lineY = y + 40;
+    const span = Math.max(1, w - padX * 2);
+    const stepX = (n > 1 ? span / (n - 1) : 0);
+    const g = this.gfx();
+    // the rail + the fill up to the current node (the journey's own progress)
+    g.lineStyle(2, 0x1e3a5c, 1);
+    g.lineBetween(x + padX, lineY, x + padX + span, lineY);
+    const fx = x + padX + span * clamp01(j.progress);
+    g.lineStyle(2.5, C.neon, 0.9);
+    g.lineBetween(x + padX, lineY, fx, lineY);
+
+    j.nodes.forEach((node, i) => {
+      const nx = x + padX + stepX * i;
+      const done = node.state === 'done';
+      const current = node.state === 'current';
+      const accent = done ? C.neon : current ? C.amber : C.faint;
+      // the node: a diamond (past), a ring+dot (now), a hollow pip (future)
+      if (current) {
+        diamond(g, nx, lineY, 9, C.amber, 0.25);
+        g.lineStyle(1.5, C.amber, 0.95);
+        g.strokeCircle(nx, lineY, 6.5);
+        g.fillStyle(C.amber, 1);
+        g.fillCircle(nx, lineY, 3);
+      } else if (done) {
+        diamond(g, nx, lineY, 6, C.neon, 0.9);
+      } else {
+        g.lineStyle(1.2, C.faint, 0.85);
+        g.strokeCircle(nx, lineY, 4);
+      }
+      // The milestone's label + note (the copy is data). A centered string at
+      // the rail's two ends would hang off the plate — each line is pulled
+      // inside by half its own measured width.
+      const tuck = (str, size) => {
+        const g2 = this.scene.add.text(0, 0, String(str), {
+          fontFamily: BODY,
+          fontSize: `${size}px`,
+          letterSpacing: 1.6,
+        });
+        const half = g2.width / 2;
+        g2.destroy();
+        return Math.min(Math.max(nx, x + half + 4), x + w - half - 4);
+      };
+      const lx = tuck(node.label, current ? 10 : 9);
+      this.txt(lx, lineY + 12, node.label, {
+        color: current ? C.amber : done ? C.ink : C.faint,
+        size: current ? 10 : 9,
+        ls: 1.6,
+        align: 'center',
+        origin: 0.5,
+        bold: current || done,
+      });
+      if (node.note) {
+        this.txt(tuck(node.note, 8), lineY + 25, node.note, { color: C.faint, size: 8, ls: 1, align: 'center', origin: 0.5 });
+      }
+      this.txt(tuck(`LV ${node.level}`, 7), lineY - 22, `LV ${node.level}`, { color: C.faint, size: 7, ls: 1, align: 'center', origin: 0.5 });
+    });
+  }
+
+  /** The class-level door — the timeline's replacement (level ≥ classAt). */
+  _paintClassDoor(j, cfg, x, y, w) {
+    const door = cfg.chooseClass ?? {};
+    const plate = this.gfx();
+    panel(plate, x + 6, y - 4, w - 12, 92, { notch: 10, fill: C.amber, fillAlpha: 0.05, stroke: C.amber, strokeAlpha: 0.35 });
+    if (j.mode === 'class') {
+      // a class is on file — the insignia + name slot (data-driven; empty seam)
+      this.txt(x + 26, y + 14, String(cfg.classPlate?.label ?? 'CLASS').toUpperCase(), { color: C.faint, size: 9, ls: 3 });
+      this.txt(x + 26, y + 26, j.className, { color: C.ink, size: 20, head: true, bold: true });
+      if (j.classInsignia) this.icon(x + w - 52, y + 34, j.classInsignia, 40);
+    } else {
+      const bw = Math.min(320, w * 0.6);
+      this.button(x + (w - bw) / 2, y + 10, bw, 40, String(door.label ?? 'CHOOSE YOUR CLASS'), C.amber, () => {
+        // the class layer's seam — the scene answers (a toast until the
+        // selection screen exists), so the door is never a dead click
+        if (this.onChooseClass) this.onChooseClass();
+        else this.onLocked?.('skills');
+      });
+      this.txt(x + w / 2, y + 66, String(door.hint ?? '').replace('{classAt}', j.classAt), {
+        color: C.dim, size: 9, ls: 2.4, align: 'center', origin: 0.5,
+      });
+    }
+  }
+
+  // ------------------------------------------------------------ SKILL TREE
+  /** The OFFLINE tab's body — the locked plate that keeps the console's final
+   *  structure visible (copy from data/shipScreen.json → skillsPanel). */
+  _buildSkillsBody() {
+    const cfg = config.section('shipScreen.skillsPanel', {});
+    const { plateX, plateY, plateW, plateH } = this.geo;
+    const x = plateX + 18;
+    const w = plateW - 36;
+    this.head(x, plateY + 20, w, 'Skill Interface', 'OFFLINE', C.amber);
+
+    const g = this.gfx();
+    const boxY = plateY + plateH / 2 - 66;
+    panel(g, x, boxY, w, 132, { notch: 10, fill: 0x04070f, fillAlpha: 0.8, stroke: C.amber, strokeAlpha: 0.35 });
+    g.lineStyle(1, C.amber, 0.08); // the locked plate's hatch
+    for (let i = 1; i < 9; i++) {
+      const t = (w / 9) * i;
+      g.lineBetween(x + t, boxY + 4, x + t - 30, boxY + 128);
+    }
+    this.txt(x + w / 2, boxY + 36, String(cfg.headline ?? 'SKILL TREE OFFLINE'), {
+      color: C.amber, size: 15, ls: 4, align: 'center', origin: 0.5, bold: true, head: true,
+    });
+    String(cfg.note ?? '')
+      .split('\n')
+      .forEach((line, i) => {
+        this.txt(x + w / 2, boxY + 68 + i * 16, line, { color: C.dim, size: 10, ls: 1.4, align: 'center', origin: 0.5 });
+      });
+    // the tree's ghost sockets (what lands with the class layer)
+    const rowY = boxY + 152;
+    const n = 5;
+    const cell = (w - 40) / n;
+    for (let i = 0; i < n; i++) {
+      const cx = x + 20 + cell * (i + 0.5);
+      g.lineStyle(1, C.faint, 0.5);
+      g.strokeRect(cx - 30, rowY, 60, 28);
+      this.txt(cx, rowY + 14, i === 0 ? String(config.get('shipScreen.level.classPlate.label', 'CLASS')) : '—', {
+        color: C.faint, size: 9, ls: 1.6, align: 'center', origin: 0.5,
+      });
+      if (i < n - 1) {
+        g.lineStyle(1, C.faint, 0.35);
+        g.lineBetween(cx + 30, rowY + 14, cx + cell - 30, rowY + 14);
+      }
+    }
+  }
+
+  // ------------------------------------------------------------ CARGO HOLDS
+  /** The hold's summary — the bay grid + the mineral aggregate — or the
+   *  selected bay's manifest (the shop scout's report). */
+  _buildCargoBody(snap) {
+    const cfg = config.section('shipScreen.cargo', {});
+    const sum = cargoSummary(snap, config.get('goods.goods', []));
+    if (this._selectedBay != null && sum.bays[this._selectedBay]?.goodId) {
+      this._paintManifest(sum.bays[this._selectedBay], cfg.detail ?? {}, snap);
+      return;
+    }
+    const { plateX, plateY, plateW, plateH } = this.geo;
+    const x = plateX + 18;
+    const w = plateW - 36;
+
+    // ---- TRADE BAYS ---------------------------------------------------
+    const meta = String(cfg.holdMeta ?? '{used} / {capacity} BAYS · {room} FREE')
+      .replace('{used}', sum.used)
+      .replace('{capacity}', sum.capacity)
+      .replace('{room}', sum.room);
+    this.head(x, plateY + 20, w, cfg.holdTitle ?? 'TRADE BAYS', meta, C.neon);
+
+    // the grid: filled bays first, then empty, then the LOCKED room left by
+    // the hold's upgrades (economy.cargo.maxSlots is the full grid)
+    const top = plateY + 46;
+    const gap = 12;
+    const cols = Math.max(4, Math.min(8, Math.floor((w + gap) / (98 + gap))));
+    const tileW = (w - gap * (cols - 1)) / cols;
+    const gridH = Math.max(120, plateH * 0.5);
+    this._paintBayGrid(sum, cfg, x, top, w, gridH, tileW, gap, cols);
+
+    // the hold's upgrade room (economy.cargo.maxSlots is the full grid) and
+    // the empty-hold line — both read as states, never as a broken plate
+    const rows = Math.ceil(sum.bays.length / cols);
+    const noteY = top + rows * (Math.max(56, Math.min(86, (gridH - gap * 2) / 2)) + gap) + 4;
+    if (sum.empty && cfg.emptyNote) {
+      this.txt(x, noteY, String(cfg.emptyNote).toUpperCase(), { color: C.dim, size: 10, ls: 2.6 });
+    }
+    if (sum.lockedBays > 0) {
+      const ly = sum.empty ? noteY + 20 : noteY;
+      const g2 = this.gfx();
+      // the locked pips themselves (the room, seen at a glance)
+      const pw = 16,
+        pg = 5;
+      const shown = Math.min(sum.lockedBays, Math.floor((w * 0.34) / (pw + pg)));
+      for (let i = 0; i < shown; i++) {
+        g2.lineStyle(1, 0x648fb0, 0.35);
+        g2.strokeRect(x + i * (pw + pg), ly + 1, pw, 9);
+      }
+      const line = String(cfg.lockedRoomMeta ?? '+{locked} LOCKED BAYS · UPGRADE THE HOLD AT HARGUN')
+        .replace('{locked}', sum.lockedBays)
+        .replace('{slots}', sum.slots);
+      this.txt(x + shown * (pw + pg) + 12, ly, line, { color: C.faint, size: 9, ls: 2 });
+    }
+
+    // ---- MINERAL HOLDS (the aggregate — the Mineral HUD's numbers) -----
+    const my = top + gridH + 26;
+    this.head(x, my, w, cfg.mineralsTitle ?? 'MINERAL HOLDS', String(cfg.mineralsMeta ?? '{minerals} / {capacity} u')
+      .replace('{minerals}', sum.minerals)
+      .replace('{capacity}', sum.mineralCapacity), C.neon2);
+    const g = this.gfx();
+    const barY = my + 24;
+    const frac = sum.mineralCapacity > 0 ? sum.minerals / sum.mineralCapacity : 0;
+    this.bar(g, x, barY, w, 12, frac, C.neon2, { track: true, alpha: 0.8 });
+    // the capacity ticks (the hold's quarters)
+    g.lineStyle(1, 0x04070f, 0.9);
+    for (let i = 1; i < 4; i++) g.lineBetween(x + (w / 4) * i, barY + 2, x + (w / 4) * i, barY + 10);
+    this.txt(x, barY + 20, `${Math.round(clamp01(frac) * 100)}%`, { color: frac >= 0.999 ? C.red : C.neon2, size: 10, ls: 2, bold: true });
+    if (sum.minerals === 0) {
+      this.txt(x + w, barY + 20, String(cfg.emptyMineralsNote ?? 'NO ORE ABOARD').toUpperCase(), { color: C.faint, size: 9, ls: 2, origin: [1, 0] });
+    }
+    // the credits line (the hold's other cargo)
+    const cy = Math.min(plateY + plateH - 12, barY + 46);
+    this.txt(x, cy, `${String(cfg.creditsLabel ?? 'CREDITS').toUpperCase()} ${sum.credits}`, { color: C.amber, size: 10, ls: 2.4 });
+  }
+
+  /** The bay grid — filled bays carry the good's icon + name and open its
+   *  manifest; empty bays read as room to fill; locked ones as upgrade room. */
+  _paintBayGrid(sum, cfg, x, y, w, h, tileW, gap, cols) {
+    const bays = sum.bays;
+    if (!bays.length) {
+      this.txt(x + w / 2, y + 60, String(cfg.noBaysNote ?? 'NO CARGO BAYS INSTALLED').toUpperCase(), {
+        color: C.faint, size: 10, ls: 3, align: 'center', origin: 0.5,
+      });
+      return;
+    }
+    const tileH = Math.max(56, Math.min(86, (h - gap * 2) / 2));
+    bays.forEach((b, i) => {
+      const bx = x + (i % cols) * (tileW + gap);
+      const by = y + Math.floor(i / cols) * (tileH + gap);
+      if (by + tileH > y + h + tileH) return; // rows beyond the plate's room
+      const filled = b.state === 'filled';
+      const locked = b.state === 'locked';
+      const accent = filled ? C.neon : locked ? C.faint : C.dim;
+      const g = this.gfx();
+      const paint = (hot) => {
+        g.clear();
+        panel(g, bx, by, tileW, tileH, {
+          notch: 6,
+          fill: filled ? col(C.neon) : 0x04070f,
+          fillAlpha: filled ? (hot ? 0.2 : 0.1) : locked ? 0.35 : 0.6,
+          stroke: accent,
+          strokeAlpha: locked ? 0.35 : hot && filled ? 1 : 0.6,
+          lineWidth: 1.2,
+        });
+      };
+      paint(false);
+      this.txt(bx + 7, by + 4, `BAY ${String(i + 1).padStart(2, '0')}`, { color: C.faint, size: 8, ls: 1.4 });
+      if (filled) {
+        this.icon(bx + 26, by + tileH / 2 + 6, b.goodId, 32);
+        const name = this.txt(bx + 46, by + tileH / 2 - 1, b.name, { color: C.ink, size: 9.5, ls: 0.6, wrap: Math.floor((tileW - 56) / 5.6) });
+        name.setOrigin(0, 0.5);
+        this.txt(bx + tileW - 7, by + tileH - 9, String(cfg.manifestTag ?? 'MANIFEST ▸'), { color: C.neon, size: 7, ls: 1.2, origin: [1, 0] });
+      } else if (locked) {
+        g.lineStyle(1, C.faint, 0.4);
+        g.strokeRect(bx + tileW / 2 - 7, by + tileH / 2 - 2, 14, 11);
+        g.beginPath?.();
+        g.lineBetween(bx + tileW / 2 - 4, by + tileH / 2 - 2, bx + tileW / 2 - 4, by + tileH / 2 - 7);
+        g.lineBetween(bx + tileW / 2 - 4, by + tileH / 2 - 7, bx + tileW / 2 + 4, by + tileH / 2 - 7);
+        g.lineBetween(bx + tileW / 2 + 4, by + tileH / 2 - 7, bx + tileW / 2 + 4, by + tileH / 2 - 2);
+        this.txt(bx + tileW / 2, by + tileH - 14, String(cfg.lockedTag ?? 'LOCKED'), { color: C.faint, size: 7.5, ls: 1.6, align: 'center' });
+      } else {
+        diamond(g, bx + tileW / 2, by + tileH / 2, 3, C.dim, 0.5);
+        this.txt(bx + tileW / 2, by + tileH - 14, 'EMPTY', { color: C.faint, size: 7.5, ls: 1.6, align: 'center' });
+      }
+      if (!filled) return;
+      // the manifest opens on press (the visible plate carries the input)
+      g.setInteractive({
+        useHandCursor: true,
+        hitArea: new Phaser.Geom.Rectangle(bx, by, tileW, tileH),
+        hitAreaCallback: (area, px, py) => area.contains(px, py),
+      });
+      g.on('pointerover', () => {
+        if (!this.isOpen) return;
+        this.sfx('ui_hover');
+        paint(true);
+      });
+      g.on('pointerout', () => paint(false));
+      g.on('pointerdown', () => {
+        if (!this.isOpen) return;
+        this.sfx('ui_click');
+        this._selectedBay = b.bay;
+        this._buildBody(this._snap);
+      });
+      this._hits.push(g);
+    });
+  }
+
+  /** A held good's manifest — the product summary + the charted exits (the
+   *  same scoutGood() report the planet shop's scout panel renders). */
+  _paintManifest(bay, cfg, snap) {
+    const { plateX, plateY, plateW, plateH } = this.geo;
+    const x = plateX + 18;
+    const w = plateW - 36;
+    const rep = this.getScoutReport ? safe(() => this.getScoutReport(bay.goodId)) : null;
+
+    this.head(x, plateY + 20, w, cfg.title ?? 'BAY MANIFEST', `BAY ${String(bay.bay + 1).padStart(2, '0')}`, C.neon);
+    // the good itself
+    const g = this.gfx();
+    panel(g, x, plateY + 40, w, 86, { notch: 8, fill: col(C.neon), fillAlpha: 0.06, stroke: 0x1b3a5a, strokeAlpha: 0.9 });
+    this.icon(x + 46, plateY + 83, bay.goodId, 56);
+    this.txt(x + 84, plateY + 52, String(rep?.name ?? bay.name), { color: C.ink, size: 17, head: true, bold: true });
+    const chips = [
+      rep?.category ? `${String(cfg.categoryLabel ?? 'CLASS').toUpperCase()} ${String(rep.category).toUpperCase()}` : '',
+      rep?.tier != null ? `${String(cfg.tierLabel ?? 'T')}${rep.tier}` : '',
+      rep?.baseValue != null ? `${String(cfg.valueLabel ?? 'BASE VALUE')} ${rep.baseValue}` : '',
+    ].filter(Boolean);
+    this.txt(x + 84, plateY + 76, chips.join('   ·   '), { color: C.neon, size: 9, ls: 2 });
+    const flavor = String(rep?.flavor ?? '');
+    this.txt(x + 84, plateY + 92, flavor, { color: C.dim, size: 9.5, ls: 0.4, wrap: Math.floor((w - 110) / 5.4) });
+
+    // the origin / demand line (the trade story in one row)
+    let ly = plateY + 138;
+    // the trade story (ScoutPanel's recipe): MADE IN 
+    // + the producer classes, SOUGHT ON the demand classes
+    const origin = rep?.origin?.hub ? String(cfg.hubLabel ?? 'HUB POOL') : rep?.origin?.systemName ?? '—';
+    const producers = (Array.isArray(rep?.producer) ? rep.producer : [])
+      .map((p) => p?.label ?? p?.cls ?? p)
+      .slice(0, 4)
+      .join(' · ');
+    const demand = (rep?.demand ?? []).map((d) => d.label ?? d.cls ?? d).slice(0, 6).join(' · ');
+    // two rows of their own — an origin list long enough to wrap must never
+    // land on top of the exits' header
+    this.txt(x, ly, `${String(cfg.originLabel ?? 'MADE IN').toUpperCase()} ${String(origin).toUpperCase()}`, { color: C.dim, size: 9, ls: 2 });
+    if (demand) {
+      const dLabel = String(cfg.demandLabel ?? 'SOUGHT ON').toUpperCase();
+      this.txt(x + w, ly, `${dLabel} ${String(demand).toUpperCase()}`, { color: C.faint, size: 9, ls: 2, origin: [1, 0] });
+    }
+    const story = [
+      producers ? `${String(cfg.producerLabel ?? 'MADE OF').toUpperCase()} ${String(producers).toUpperCase()}` : '',
+    ].filter(Boolean);
+    if (story.length) this.txt(x, ly + 15, story.join('   ·   '), { color: C.faint, size: 8.5, ls: 2 });
+
+    // ---- BEST EXITS (the charted markets that pay best) ----------------
+    const ety = ly + (story.length ? 34 : 26);
+    const exits = Array.isArray(rep?.exits) ? rep.exits : [];
+    const topN = Math.max(1, Math.floor(Number(cfg.topN) || 3));
+    this.head(x, ety, w, cfg.exitsTitle ?? 'BEST EXITS', rep ? `${rep.charted} CHARTED` : '', C.amber);
+    if (!exits.length) {
+      this.txt(x + 6, ety + 34, String(cfg.exitsEmpty ?? 'NO CHARTED EXIT YET').toUpperCase(), { color: C.faint, size: 10, ls: 2.4 });
+    }
+    const rows = exits.slice(0, topN);
+    // the margin rail is comparative — ScoutPanel scales it against the
+    // report's own best row (rep.maxProfit), which is what this list ranks
+    const maxProfit = Math.max(1, Number(rep?.maxProfit) || 0, ...rows.map((e) => Math.abs(Number(e.profit) || 0)));
+    const pb = cfg.profitBar ?? null;
+    const nameW = w - 210;
+    rows.forEach((e, i) => {
+      const ry = ety + 30 + i * 46;
+      const rg = this.gfx();
+      panel(rg, x, ry, w, 40, { notch: 6, fill: 0x04070f, fillAlpha: 0.7, stroke: i === 0 ? C.amber : 0x1b3a5a, strokeAlpha: i === 0 ? 0.8 : 0.7 });
+      this.txt(x + 12, ry + 6, `${i + 1}`, { color: i === 0 ? C.amber : C.faint, size: 14, head: true, bold: true });
+      this.txt(x + 38, ry + 4, String(e.objectName ?? e.systemName), { color: C.ink, size: 12, ls: 1.2, bold: true, wrap: nameW });
+      const sub = [String(e.clsLabel ?? '').toUpperCase(), String(cfg.exitMeta ?? '{hops} HOPS').replace('{hops}', e.hops ?? '?')].filter(Boolean).join(' · ');
+      if (sub) this.txt(x + 38, ry + 19, sub, { color: C.faint, size: 8.5, ls: 1.6 });
+      const p = Number(e.profit) || 0;
+      if (pb) {
+        const rw = Math.max(60, nameW);
+        rg.fillStyle(col(pb.track ?? '#14324f'), 0.9);
+        rg.fillRect(x + 38, ry + 33, rw, 3);
+        rg.fillStyle(col(p > 0 ? pb.fill ?? '#7ce8a4' : pb.fillNeg ?? '#ff5a6e'), 0.95);
+        rg.fillRect(x + 38, ry + 33, Math.max(2, (Math.abs(p) / maxProfit) * rw), 3);
+      }
+      this.txt(x + w - 14, ry + 3, `${e.price}`, { color: C.amber, size: 13, bold: true, origin: [1, 0] });
+      this.txt(x + w - 14, ry + 20, `${p >= 0 ? '+' : '−'}${Math.abs(p)} / UNIT`, { color: p > 0 ? C.green : C.red, size: 9, ls: 1.4, origin: [1, 0] });
+    });
+
+    // back to the grid
+    const bw = 200;
+    this.button(x + w - bw, plateY + plateH - 34, bw, 28, String(cfg.backLabel ?? 'BACK TO BAYS'), C.neon, () => {
+      this._selectedBay = null;
+      this.sfx('ui_click');
+      this._buildBody(this._snap);
+    });
+    // the local reference line (what it cost / would pay HERE)
+    if (rep) {
+      const baseKey = cfg.baseline === 'buy' ? 'localBuy' : 'baseValue';
+      const base = rep[baseKey] ?? rep.baseValue ?? 0;
+      this.txt(x, plateY + plateH - 24, `REF ${base} · LOCAL BUY ${rep.localBuy ?? '—'}${rep.localSell != null ? ` · LOCAL SELL ${rep.localSell}` : ''}`, {
+        color: C.faint, size: 9, ls: 1.8,
+      });
+    }
+  }
+
+  // ------------------------------------------------------------ repaint
+  /** Repaint the whole console from a snapshot (the tab body + the strip). */
+  _paintAll(snap) {
+    this._snap = snap ?? this._snap;
+    this._buildBody(this._snap);
+    this._paintFeedStrip(this._snap);
+  }
+
+  /** The feed's bottom strip — the ship line + the level bar. */
+  _paintFeedStrip(snap) {
+    const g = this.statusBar;
+    g.clear();
+    const j = journeyState(snap?.level, config.section('shipScreen.level', {}), snap?.playerClass ?? null);
+    if (!j) {
+      this.statusTxt.setText(String(config.get('shipScreen.status.noCombatNote', 'DRIVE DARK — NO CONTACTS')).toUpperCase());
+      return;
+    }
+    const lvWord = String(config.get('shipScreen.level.levelWord', 'LEVEL'));
+    this.statusTxt.setText(
+      j.isMax
+        ? `${lvWord} ${j.level} · MAX LEVEL`
+        : `${lvWord} ${j.level} · ${j.xp}/${j.xpForNext} XP`.toUpperCase?.() ?? ''
+    );
+    this.bar(g, 0, 6, this.statusBarW, 5, j.isMax ? 1 : clamp01((j.level - j.from + j.step) / Math.max(1, j.to - j.from)), j.isMax ? C.amber : C.neon);
+  }
+
+  // ------------------------------------------------------------ open/close
+  open() {
+    if (this.openState === 'open' || this.openState === 'opening') return;
+    this.openState = 'opening';
+    this.setAlpha(0);
+    // resume() not play(): v4's play() is a no-op after pause() (its
+    // _playCalled flag stays true) — the feed would freeze after reopen.
+    this.video?.resume?.();
+    this.sfx('ui_window');
+    this._startReveal(this.scene.time.now);
+  }
+
+  /** Open straight on a tab (the scene's tutorial / diag seams). */
+  openTab(id) {
+    if (this._tabs.some((t) => t.id === id && !t.standby)) this._activeTab = id;
+    this.open();
+  }
+
+  close() {
+    if (this.openState === 'closed' || this.openState === 'closing') return;
+    this.openState = 'closing';
+    this.video?.pause?.();
+    this.sfx('ui_close');
+    this.scene.tweens.add({
+      targets: this,
+      alpha: 0,
+      duration: 150,
+      ease: 'Power2',
+      onComplete: () => {
+        this.openState = 'closed';
+        this.glitchG.clear();
+        this.titleGhostA.setAlpha(0);
+        this.titleGhostB.setAlpha(0);
+      },
+    });
+  }
+
+  // 'closing' counts as open: the close button flips openState in the SAME
+  // input pass, before the scene's pointerdown guard runs — while the window
+  // is still on screen (fading out) it keeps owning the click.
+  get isOpen() {
+    return this.openState === 'open' || this.openState === 'opening' || this.openState === 'closing';
+  }
+
+  // ------------------------------------------------------------ boot reveal
+  _startReveal(now) {
+    const list = [];
+    const push = (o, d, dur) => list.push({ o, d, dur, t0: now });
+    this.decodeTo(this.titleTxt, String(config.get('shipScreen.title', 'SHIP SYSTEMS')).toUpperCase(), now, 520);
+    push(this, 0, 240);
+    push(this.titleMeta, 140, 300);
+    push(this.closeG, 200, 200);
+    push(this.closeTxt, 200, 200);
+    if (this.videoPanel) push(this.videoPanel, 260, 380);
+    for (const t of this._tabs) {
+      push(t.g, 300, 260);
+      push(t.txt, 300, 260);
+      if (t.tagTxt) push(t.tagTxt, 300, 260);
+    }
+    push(this.plateG, 380, 300);
+    this.reveal = list;
+    // the glitch clock + the sweep clock
+    const g = config.get('shipScreen.glitch', {});
+    const [ia, ib] = g.intervalMs ?? [4200, 9200];
+    this._glitch.next = now + rand(ia, ib);
+    this._glitch.until = 0;
+    this._sweep = { next: now + 1500, active: null };
+    // the first poll lands on the reveal
+    this._pollNext = 0;
+    this._fp = null;
+    this.openState = 'open';
+  }
+
+  _glitchBurst() {
+    const g = config.get('shipScreen.glitch', {});
+    if (g.enabled === false) return;
+    const [da, db] = g.durationMs ?? [110, 260];
+    this._glitch.until = this.scene.time.now + rand(da, db);
+    const [sa, sb] = g.slices ?? [1, 4];
+    const maxOff = g.maxOffset ?? 9;
+    const vr = this.videoRect ?? { x: 0, y: 0, w: 100, h: 100 };
+    const gg = this.glitchG;
+    gg.clear();
+    const n = Math.max(1, Math.round(rand(sa, sb)));
+    for (let i = 0; i < n; i++) {
+      gg.fillStyle(Math.random() < 0.5 ? C.neon : C.neon2, rand(0.06, 0.2));
+      gg.fillRect(vr.x + rand(-maxOff, maxOff), vr.y + rand(0, Math.max(1, vr.h - 6)), vr.w, rand(2, 7));
+    }
+    // the title's RGB split
+    const t = this.titleTxt;
+    if (!t.text) return;
+    const off = rand(2, 5);
+    this.titleGhostA.setText(t.text).setPosition(t.x - off, t.y).setAlpha(0.55);
+    this.titleGhostB.setText(t.text).setPosition(t.x + off, t.y).setAlpha(0.55);
+  }
+
+  // ------------------------------------------------------------ per-frame
+  update(time) {
+    if (!this.isOpen) return;
+
+    // the reveal stagger
+    if (this.reveal.length) {
+      let done = true;
+      for (const r of this.reveal) {
+        const u = clamp01((time - r.t0 - r.d) / Math.max(1, r.dur));
+        if (u < 1) done = false;
+        r.o.setAlpha(easeIO(u));
+      }
+      if (done) this.reveal = [];
+    }
+
+    // decodes
+    if (this.decodes.length) {
+      for (const d of this.decodes) {
+        if (d.dec.started(time)) d.txt.setText(d.dec.display(time));
+      }
+      this.decodes = this.decodes.filter((d) => !d.dec.finished(time));
+    }
+
+    // the standby tab's rejected shake
+    if (this._tabShakes.length) {
+      const keep = [];
+      for (const sh of this._tabShakes) {
+        const u = (time - sh.t0) / 260;
+        if (u >= 1) {
+          sh.tab.txt.x = sh.x;
+          continue;
+        }
+        sh.tab.txt.x = sh.x + Math.sin(u * 28) * 3 * (1 - u);
+        keep.push(sh);
+      }
+      this._tabShakes = keep;
+    }
+
+    // glitch bursts
+    const g = this._glitch;
+    if (time >= g.next && time > g.until) {
+      this._glitchBurst();
+      const [ia, ib] = config.get('shipScreen.glitch.intervalMs', [4200, 9200]);
+      g.next = time + rand(ia, ib);
+    }
+    if (time > g.until && (this.titleGhostA.alpha > 0 || this.titleGhostB.alpha > 0)) {
+      this.titleGhostA.setAlpha(0);
+      this.titleGhostB.setAlpha(0);
+    }
+
+    // the sweep band down the feed
+    if (this._sweep && (this.sweepCfg ?? {}).enabled !== false) {
+      if (this._sweep.active) {
+        const a = this._sweep.active;
+        const u = (time - a.t0) / a.dur;
+        if (u >= 1) {
+          this.sweepBand.setAlpha(0);
+          this._sweep.active = null;
+        } else {
+          this.sweepBand.setPosition(this.videoCx, this.videoRect.y + u * (this.videoRect.h + 80) - 40);
+          this.sweepBand.setAlpha(0.5 * Math.sin(Math.PI * u));
+        }
+      } else if (time >= this._sweep.next) {
+        const [ia, ib] = this.sweepCfg.intervalMs ?? [5200, 9800];
+        const [da, db] = this.sweepCfg.durationMs ?? [1400, 2200];
+        this._sweep.next = time + rand(ia, ib);
+        this._sweep.active = { t0: time, dur: rand(da, db) };
+      }
+    }
+
+    // the REC dot breathes
+    this.recDot?.setAlpha(0.45 + 0.4 * (0.5 + 0.5 * Math.sin(time * 0.004)));
+
+    // poll the scene — repaint on any real change (a hull hit, a level-up,
+    // a buy/sell, ore mined)
+    if (time >= this._pollNext) {
+      this._pollNext = time + 250;
+      const snap = this.getSnapshot ? safe(() => this.getSnapshot()) : null;
+      if (snap) {
+        const fp = this._fpOf(snap);
+        this._snap = snap;
+        if (fp !== this._fp) {
+          this._fp = fp;
+          this._paintAll(snap);
+        }
+      }
+    }
+  }
+
+  /** The repaint fingerprint — every painted number is in here. */
+  _fpOf(snap) {
+    const rows = statRows(config.get('shipScreen.status.rows', []), snap ?? {});
+    const j = journeyState(snap?.level, config.section('shipScreen.level', {}), snap?.playerClass ?? null);
+    const c = cargoSummary(snap, config.get('goods.goods', []));
+    return [
+      this._activeTab,
+      this._selectedBay ?? '-',
+      rows.map((r) => `${r.id}=${r.text}`).join(','),
+      j ? `${j.mode}:${j.level}:${j.xp}:${Math.round(j.step * 100)}:${j.className ?? '-'}` : 'none',
+      c.used,
+      sum(c.bays.filter((b) => b.goodId).map((b) => (b.goodId === null ? '' : String(b.goodId).length))),
+      c.minerals,
+      c.credits,
+    ].join('|');
+  }
+
+  /** The scene calls this after anything the console reads so it repaints on
+   *  its next poll instead of waiting out the interval. */
+  refresh() {
+    this._pollNext = 0;
+    this._fp = null;
+  }
+
+  destroy() {
+    this._destroyed = true;
+    for (const k of this._kids) {
+      try {
+        k.destroy();
+      } catch {
+        /* already gone */
+      }
+    }
+    this._kids = [];
+    for (const t of this._tabs) {
+      for (const k of [t.g, t.txt, t.tagTxt]) {
+        if (!k) continue;
+        try {
+          k.destroy();
+        } catch {
+          /* already gone */
+        }
+      }
+    }
+    if (this.closeG) {
+      try {
+        this.closeG.destroy();
+      } catch {
+        /* already gone */
+      }
+    }
+    // v4 quirk: there is no removeChildren() — super.destroy(true) recurses
+    // through the display list (the Map/Quest idiom).
+    super.destroy(true);
+  }
+}
+
+const sum = (a) => a.reduce((t, v) => t + Number(v || 0), 0);
+function safe(fn) {
+  try {
+    return fn();
+  } catch {
+    return null;
+  }
+}
+
+export default ShipWindow;