Add Ship Screen console with status and cargo tabs #1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||||
|
|
||||||
|
<!-- blockCompletion: true -->
|
||||||
|
- [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).
|
||||||
|
|
||||||
|
|
@ -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<approved/>","at":"2026-09-17T05:08:04.224Z"}
|
{"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<approved/>","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_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_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"}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"theme.json",
|
"theme.json",
|
||||||
"menu.json",
|
"menu.json",
|
||||||
"ship.json",
|
"ship.json",
|
||||||
|
"shipScreen.json",
|
||||||
"planets.json",
|
"planets.json",
|
||||||
"asteroids.json",
|
"asteroids.json",
|
||||||
"tether.json",
|
"tether.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:<data path>`, `ship:<Ship field path>`, `live:<CombatSystem field>` 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<base href="../" />
|
||||||
|
<title>Orbit — Ship console (dev shot)</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||||
|
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||||
|
</style>
|
||||||
|
<script src="lib/phaser.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="game"></div>
|
||||||
|
<script type="module" src="dev/ship-shot.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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|<n> the mineral load (default half)
|
||||||
|
* &select=<bayIndex> open that bay's manifest (cargo tab)
|
||||||
|
* &known=<n> 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 <pre> 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);
|
||||||
|
}
|
||||||
|
|
@ -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:<path>' hardware specs as authored (data/ship.json thrust/maxSpeed,
|
||||||
|
* data/combat.json playerShip shield recharge + player.weapon)
|
||||||
|
* 'ship:<path>' state the run mutates (stats.cargoHold / mineralStorage /
|
||||||
|
* miningSpeed — what research & builds already rewrite —
|
||||||
|
* minerals, credits, cargo.used/items)
|
||||||
|
* 'live:<key>' 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);
|
||||||
|
|
@ -41,6 +41,7 @@ import { CombatHud } from '../combat/CombatHud.js';
|
||||||
import { MineralHud } from '../ui/MineralHud.js';
|
import { MineralHud } from '../ui/MineralHud.js';
|
||||||
import { CreditsHud } from '../ui/CreditsHud.js';
|
import { CreditsHud } from '../ui/CreditsHud.js';
|
||||||
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
|
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
|
||||||
|
import { scoutGood } from '../economy/TradeScout.js';
|
||||||
import * as Stock from '../economy/Stock.js';
|
import * as Stock from '../economy/Stock.js';
|
||||||
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
import { MenuSubBar } from '../ui/MenuSubBar.js';
|
||||||
import { SavePanel } from '../ui/SavePanel.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 { CommsPanel } from '../ui/CommsPanel.js';
|
||||||
import { ResearchWindow } from '../ui/ResearchWindow.js';
|
import { ResearchWindow } from '../ui/ResearchWindow.js';
|
||||||
import { MapWindow } from '../ui/MapWindow.js';
|
import { MapWindow } from '../ui/MapWindow.js';
|
||||||
|
import { ShipWindow } from '../ui/ShipWindow.js';
|
||||||
import { QuestWindow } from '../ui/QuestWindow.js';
|
import { QuestWindow } from '../ui/QuestWindow.js';
|
||||||
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
|
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
|
||||||
import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.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
|
// The build console's feed (data/builds.json → video): a muted 2:3
|
||||||
// loop behind the BUILD window on a planet surface (SurfaceScene's
|
// loop behind the BUILD window on a planet surface (SurfaceScene's
|
||||||
// BuildWindow reads the shared cache). A missing file just leaves the
|
// 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.savePanel && this.savePanel.isOpen) ||
|
||||||
(this.researchWindow && this.researchWindow.isOpen) ||
|
(this.researchWindow && this.researchWindow.isOpen) ||
|
||||||
(this.mapWindow && this.mapWindow.isOpen) ||
|
(this.mapWindow && this.mapWindow.isOpen) ||
|
||||||
|
(this.shipWindow && this.shipWindow.isOpen) ||
|
||||||
(this.questWindow && this.questWindow.isOpen) ||
|
(this.questWindow && this.questWindow.isOpen) ||
|
||||||
(this.commsPanel && this.commsPanel.isOpen) ||
|
(this.commsPanel && this.commsPanel.isOpen) ||
|
||||||
(this.menuSubBar && this.menuSubBar.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) ----
|
// ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ----
|
||||||
// The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80):
|
// The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80):
|
||||||
// left the 2:3 mission feed (assets/videos/quests.mp4 — the same
|
// 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;
|
if (this.mapWindow && this.mapWindow.isOpen) return;
|
||||||
// The quests console (depth 80) — the same full-screen contract.
|
// The quests console (depth 80) — the same full-screen contract.
|
||||||
if (this.questWindow && this.questWindow.isOpen) return;
|
if (this.questWindow && this.questWindow.isOpen) return;
|
||||||
// The quests console (depth 80) — the same full-screen contract.
|
// The ship console (depth 80) — the same full-screen contract.
|
||||||
if (this.questWindow && this.questWindow.isOpen) return;
|
if (this.shipWindow && this.shipWindow.isOpen) return;
|
||||||
// Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
|
// 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
|
// its buttons — the buttons fire on their own pointerdown). ANY
|
||||||
// click OUTSIDE — world, deck, HUD, compass — folds it back down
|
// 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.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.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.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._deckResearchBar(_time); // the RESEARCH slot's progress bar
|
||||||
this._applyResearchCue(); // the RESEARCH slot's ready-shimmer (the deck's half)
|
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
|
// 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.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
|
||||||
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
|
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
|
||||||
|
if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
|
||||||
this.researchWindow?.open();
|
this.researchWindow?.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -4139,6 +4191,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
|
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
|
||||||
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
|
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
|
||||||
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
|
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
|
||||||
|
if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
|
||||||
this.questWindow?.open();
|
this.questWindow?.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -4151,9 +4204,24 @@ export class GameScene extends Phaser.Scene {
|
||||||
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
|
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
|
||||||
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
|
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
|
||||||
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
|
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
|
||||||
|
if (this.shipWindow && this.shipWindow.isOpen) this.shipWindow.close();
|
||||||
this.mapWindow?.open();
|
this.mapWindow?.open();
|
||||||
return;
|
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') {
|
if (id === 'menu') {
|
||||||
this.menuAction();
|
this.menuAction();
|
||||||
return;
|
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
|
// ------------------------------------------------------------ map console
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -6927,6 +7071,11 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.questWindow.close();
|
this.questWindow.close();
|
||||||
return;
|
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.
|
// The mining context menu is the topmost open thing after the save UI.
|
||||||
if (this.miningPopup && this.miningPopup.isOpen) {
|
if (this.miningPopup && this.miningPopup.isOpen) {
|
||||||
this.miningPopup.close();
|
this.miningPopup.close();
|
||||||
|
|
@ -7207,6 +7356,7 @@ export class GameScene extends Phaser.Scene {
|
||||||
this.commsPanel?.destroy();
|
this.commsPanel?.destroy();
|
||||||
this.researchWindow?.destroy(); // the console (video + UI)
|
this.researchWindow?.destroy(); // the console (video + UI)
|
||||||
this.mapWindow?.destroy(); // the map console (video + chart canvas)
|
this.mapWindow?.destroy(); // the map console (video + chart canvas)
|
||||||
|
this.shipWindow?.destroy(); // the ship console (video + telemetry plate)
|
||||||
this.creditsHud?.destroy();
|
this.creditsHud?.destroy();
|
||||||
this.mineralHud?.destroy();
|
this.mineralHud?.destroy();
|
||||||
this.mining?.destroy();
|
this.mining?.destroy();
|
||||||
|
|
|
||||||
|
|
@ -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:<dotted path>' a data file (config.get) — the hardware specs
|
||||||
|
* that live in data/ship.json / data/combat.json.
|
||||||
|
* 'ship:<dotted path>' a field of the ship snapshot (stats.cargoHold,
|
||||||
|
* minerals, credits, cargo.used …) — state the run
|
||||||
|
* mutates (upgrades, ore, buys/sells).
|
||||||
|
* 'live:<key>' 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 };
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue