Add level-5 class selection with Vanguard and Wraith bonuses
- Introduce data/classes.json as the single source for roster, copy, accents, icon frames, perk numbers, and console chrome - Add ClassModel (pure reads), ClassState (idempotent run ledger + save round-trip), and Aiming (intercept solve + barrel geometry) so perks are testable without Phaser - Wire CombatSystem to the class ledger: Vanguard dual-cannon volley, Wraith predictive aim with a max-lead fallback to raw aim; cooldown unchanged - Add full-screen ClassWindow console over the frozen GameScene (looping muted feed, Trestle briefing, themed tabs, PICK confirm), opened once at level 5 when combat is quiet or via the ship console door / ?level5 dev param - Persist record.playerClass in saves and clear it on New Game; ship console now shows a class plate with crest + banked skill points instead of the choose-class door
This commit is contained in:
parent
4456ee00f0
commit
2ede7cb160
|
|
@ -1 +1 @@
|
|||
{"version":1,"dirMtimeMs":1790137091865.154,"goals":[]}
|
||||
{"version":1,"dirMtimeMs":1790469999560.4136,"goals":[]}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"version": 3,
|
||||
"id": "muismeg5-q31npy",
|
||||
"objective": "Ship the Level-5 class layer: when the player reaches combat level 5 and combat goes quiet, the main game freezes and a new full-screen ClassWindow console opens (looping assets/videos/classes.mp4 on the left; Vanguard/Wraith tabs themed red/purple using frames 0/1 of the 128x128 spritesheet assets/images/classes.png on the right). Its intro state plays assets/speech/trestle/classes.mp3 over Trestle's class-selection copy; each tab shows a class overview plus a PICK action that asks for confirmation, then sets the class and grants its initial bonus — Vanguard: dual cannon + 5 skill points; Wraith: predictive (\"advanced targeting computer\") aiming + 5 skill points. Selectable/testable via index.html?level5.",
|
||||
"status": "complete",
|
||||
"autoContinue": true,
|
||||
"usage": {
|
||||
"tokensUsed": 916466,
|
||||
"activeSeconds": 11699
|
||||
},
|
||||
"sisyphus": false,
|
||||
"revision": 265,
|
||||
"createdAt": "2026-09-26T19:39:05.765Z",
|
||||
"updatedAt": "2026-09-27T00:46:39.560Z",
|
||||
"scheduler": {
|
||||
"version": 1,
|
||||
"owner": "01a0df2a-e59d-73f4-af9e-b910480247b7",
|
||||
"generation": "1f911e68-6e34-45b9-ab10-d2c2b129da3c",
|
||||
"used": 1,
|
||||
"phase": "running",
|
||||
"repairUsed": false,
|
||||
"decision": {
|
||||
"kind": "ready",
|
||||
"nextAction": "Continue the goal at the user's request.",
|
||||
"purpose": "kickoff"
|
||||
},
|
||||
"dispatch": {
|
||||
"id": "2fa716b7-2f85-430c-9d33-45a1aa215c63",
|
||||
"kind": "kickoff",
|
||||
"claimedAt": 1790467241017
|
||||
}
|
||||
},
|
||||
"taskList": {
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-1",
|
||||
"title": "Class data + run state (data/classes.json, pure class model, save/load)",
|
||||
"status": "complete",
|
||||
"verificationContract": "node dev/run-all.mjs classes passes (pure model: class lookup by id, accent/frame/copy read from config, grant-bonus idempotent, skill-point bank); save -> load round-trip preserves playerClass id, owned perk flags and skill points; data/classes.json is listed in data/manifest.json and loads with no console errors.",
|
||||
"completedAt": "2026-09-26T20:17:42.892Z",
|
||||
"evidence": "dev/classes.test.mjs + node dev/run-all.mjs → ALL 71 TESTS PASSED (model lookup/idempotent grant/skill bank; save→load keeps id/perks/skillPoints via record.playerClass); data/classes.json added to da"
|
||||
},
|
||||
{
|
||||
"id": "task-2",
|
||||
"title": "ClassWindow console UI (video feed, class tabs, overview, PICK + lock-in confirm)",
|
||||
"status": "complete",
|
||||
"verificationContract": "Browser screenshots via a dev/class-shot.mjs harness show: (a) intro state with the looping muted classes.mp4 feed on the left and Trestle's exact copy on the right, (b) the Vanguard tab in red theme with frame-0 icon + overview + PICK, (c) the Wraith tab in purple theme with frame-1 icon + overview + PICK, (d) the LOCK IN confirm overlay. Tab labels show small 128x128 sprite icons with text; classes.mp3 plays once on first open and no other audio is left ducked after close.",
|
||||
"completedAt": "2026-09-26T22:47:22.715Z",
|
||||
"evidence": "dev/class-shot.mjs 28/28 green; shots reviewed: a-intro (muted looping classes.mp4 + exact Trestle copy), b-vanguard (red, frame 0, DUAL CANNON/+5 SP/PICK), c-wraith (purple, frame 1), d-confirm (LOCK"
|
||||
},
|
||||
{
|
||||
"id": "task-3",
|
||||
"title": "GameScene integration: pause contract, level-5 auto-open trigger, ?level5 dev boot, ship-console seam + readouts",
|
||||
"status": "complete",
|
||||
"verificationContract": "index.html?level5 opens the console after boot with the world parked (ship velocity/acceleration zeroed and combat stepped down through the existing _frozen/_parkForFreeze path) and resumes cleanly on close. Real level-5 crossing fires it once when no hostiles are live and no wave run is in flight, never again once a class is on file. Closing without picking defers and the ship console's CHOOSE YOUR CLASS door re-opens it (no dead click, no toast-only seam). With a class on file the ship console shows the class plate + skill points instead of the choose-class door.",
|
||||
"completedAt": "2026-09-26T22:50:49.711Z",
|
||||
"evidence": "dev/classes-browser.test.mjs 14/14 green (no dev param: level-5 grant → console opens after marquee, world parked via _frozen/_parkForFreeze, ESC defers, no nag, ship door re-opens, plate VANGUARD/fra"
|
||||
},
|
||||
{
|
||||
"id": "task-4",
|
||||
"title": "Class bonuses in combat: Vanguard dual cannon, Wraith advanced targeting computer",
|
||||
"status": "complete",
|
||||
"verificationContract": "Pure unit tests cover the intercept solver (stationary target = no lead; crossing target leads ahead by bullet-speed-correct angle; degenerate/out-range cases fall back to raw aim) and the dual-cannon volley (2 bolts, offset barrels, full base damage each, cooldown unchanged). Live check in index.html?level5: Vanguard fires a two-barrel volley, Wraith's shots land ahead of a moving hostile, an unclassed player is unchanged, and the training range (combat.html) keeps the base single-cannon feel.",
|
||||
"completedAt": "2026-09-26T23:23:56.500Z",
|
||||
"evidence": "dev/class-combat.test.mjs 7/7 (intercept: stationary/head-on no lead, crossing solved w/ bolt-meets-target math, unreachable + maxLead cap → raw aim; barrels ±9, turning with nose); dev/classes-browse"
|
||||
},
|
||||
{
|
||||
"id": "task-5",
|
||||
"title": "Docs + config prose (data/classes.json comments, PROJECT_NOTES class-layer section, README ?level5)",
|
||||
"status": "complete",
|
||||
"verificationContract": "docs/PROJECT_NOTES.md gains a class-layer section describing the data/code split, the freeze contract, the trigger rule and the perk flags; README documents index.html?level5 and both bonuses; every new user-visible string lives in JSON with a code fallback (grep confirms no hardcoded copy in ClassWindow.js). Suggested commit message provided; no git commit run.",
|
||||
"completedAt": "2026-09-27T00:13:41.388Z",
|
||||
"evidence": "PROJECT_NOTES \"The class layer\" section (data/code split, freeze contract, trigger rule, perk flags); README section + tree entries; grep shows zero copy literals in ClassWindow.js (feedLine moved to "
|
||||
}
|
||||
],
|
||||
"blockCompletion": true,
|
||||
"proposedAt": "2026-09-26T19:38:42.602Z"
|
||||
},
|
||||
"stopReason": "agent",
|
||||
"archivedPath": ".pi/goals/archived/goal_2026092618463950_muismeg5-q31npy.md"
|
||||
}
|
||||
|
||||
# Goal Prompt
|
||||
|
||||
Ship the Level-5 class layer: when the player reaches combat level 5 and combat goes quiet, the main game freezes and a new full-screen ClassWindow console opens (looping assets/videos/classes.mp4 on the left; Vanguard/Wraith tabs themed red/purple using frames 0/1 of the 128x128 spritesheet assets/images/classes.png on the right). Its intro state plays assets/speech/trestle/classes.mp3 over Trestle's class-selection copy; each tab shows a class overview plus a PICK action that asks for confirmation, then sets the class and grants its initial bonus — Vanguard: dual cannon + 5 skill points; Wraith: predictive ("advanced targeting computer") aiming + 5 skill points. Selectable/testable via index.html?level5.
|
||||
|
||||
## Progress
|
||||
|
||||
- Status: complete
|
||||
- Auto-continue: on
|
||||
- Sisyphus mode: no
|
||||
- Time spent: 3h14m59s
|
||||
- Tokens used: 916K (916,466) tokens
|
||||
## Tasks
|
||||
|
||||
<!-- blockCompletion: true -->
|
||||
- [x] task-1: Class data + run state (data/classes.json, pure class model, save/load) — evidence: dev/classes.test.mjs + node dev/run-all.mjs → ALL 71 TESTS PASSED (model lookup/idempotent grant/skill bank; save→load keeps id/perks/skillPoints via record.playerClass); data/classes.json added to da
|
||||
- [x] task-2: ClassWindow console UI (video feed, class tabs, overview, PICK + lock-in confirm) — evidence: dev/class-shot.mjs 28/28 green; shots reviewed: a-intro (muted looping classes.mp4 + exact Trestle copy), b-vanguard (red, frame 0, DUAL CANNON/+5 SP/PICK), c-wraith (purple, frame 1), d-confirm (LOCK
|
||||
- [x] task-3: GameScene integration: pause contract, level-5 auto-open trigger, ?level5 dev boot, ship-console seam + readouts — evidence: dev/classes-browser.test.mjs 14/14 green (no dev param: level-5 grant → console opens after marquee, world parked via _frozen/_parkForFreeze, ESC defers, no nag, ship door re-opens, plate VANGUARD/fra
|
||||
- [x] task-4: Class bonuses in combat: Vanguard dual cannon, Wraith advanced targeting computer — evidence: dev/class-combat.test.mjs 7/7 (intercept: stationary/head-on no lead, crossing solved w/ bolt-meets-target math, unreachable + maxLead cap → raw aim; barrels ±9, turning with nose); dev/classes-browse
|
||||
- [x] task-5: Docs + config prose (data/classes.json comments, PROJECT_NOTES class-layer section, README ?level5) — evidence: PROJECT_NOTES "The class layer" section (data/code split, freeze contract, trigger rule, perk flags); README section + tree entries; grep shows zero copy literals in ClassWindow.js (feedLine moved to
|
||||
|
||||
|
|
@ -113,3 +113,23 @@
|
|||
{"type":"audit_result","goalId":"mudi9u8o-nektab","verdict":"approved","report":"All claimed artifacts check out against the real code and data:\n\n**Data / model**\n- `data/sidequests.json`: `escort` category `enabled: true`, neutral-only, minLevel 3; escort rules block (waypoints [1,1], visitRadius 460, destinationHops [1,2], escortCount [1,2], transportFrame [0,3], dedicated encounter director numbers: roll 9–18s @ 85–100% chance, cooldown 5–10s vs normal 45–90s @ 50–60% / cooldown 30–60s — genuinely \"heavily elevated\"); wave total 8 / concurrent 4 vs normal bands 1–5; 20 escort descriptions, all 20 titles and bodies unique (verified programmatically); reward band [120,175].\n- `data/combat.json`: `transport` section — size 135 vs fighter size 54 (exactly 2.5x), hull 1500/shields 650, volley weapon, texture `assets/images/ships-transport.png` at 180×320 frames. Sheet exists on disk, 1800×640 (10×2 grid of 180×320 frames), so frames 0–3 are valid new spritesheet frames.\n\n**Code**\n- `js/combat/TransportShip.js`: persistent neutral freighter, portrait-sheet re-scale by long side (135/320), never targets player. `CombatSystem.spawnTransport` seam present.\n- `js/quests/SideQuests.js`: `escortDestinationFor` seeded 1–2 hop walk over the real jump-gate graph (neutral neighbors, planet first then free-space station), board feasibility gate so uncompletable escorts never post, wave roster with validation, XP payout, route-record normalization.\n- `js/scenes/GameScene.js`: accept → `_establishEscort` (rendezvous waypoint in posting's own system); `updateEscort` frame law — rendezvous link within radius → transit; convoy (`_ensureEscortConvoy`) re-forms across jump scene-restarts with record armor; escort director fires one wave at a time via `_fireEscortWave`; delivery only when at destination object within radius **and** `_escortFieldClear()` (no live enemies, combat inactive); transport death → phase `failed` + convoy teardown; `startLanding` hooks `_escortResetForLanding` (failed-only guard, fresh rendezvous in current system, transport restored to full standing armor, same destination, director re-armed).\n\n**Verification**\n- Ran the suite myself: 69 test files, 68 pass; the single failure was `escort-browser.test.mjs`, which requires the dev server to be up (its documented precondition). I started `node dev/server.mjs 8123`, ran it manually, and it passed fully: real Phaser boot, transport sheet preloads at 1800×640, neutral board renders an escort row with title/issuer/body, `_establishEscort` stands the route (rendezvous phase, 1-hop destination, full-armor transport, 2 fighters), markers/compass/landing-reset seams clean, zero console errors and zero missing resources. I then stopped the server.\n- Headless `escort-flow.test.mjs` drives the real `GameScene.prototype` methods through the accept→rendezvous→link→wave→jump-reform→delivery and loss→land→reset→retry sequences with mocked combat, and all 20 dossiers render.\n\nEvery objective requirement — board type, waypoint rendezvous with transport + 1–2 fighters, 1–2 hop gate route to a planet/station, elevated encounters, clear-sky completion rule, 2.5x new-sprite transport, and land-to-reset retry — is present in real code and data with genuine (non-stub) verification. The only failing run in the blind suite is fully explained by a startup precondition and passes when that precondition is met.\n\n<approved/>","at":"2026-09-23T04:18:11.852Z"}
|
||||
{"type":"goal_completed","goalId":"mudi9u8o-nektab","archivePath":".pi/goals/archived/goal_2026092222181186_mudi9u8o-nektab.md","at":"2026-09-23T04:18:11.866Z"}
|
||||
{"type":"goal_archived","goalId":"mudi9u8o-nektab","archivePath":".pi/goals/archived/goal_2026092222181186_mudi9u8o-nektab.md","at":"2026-09-23T04:18:11.866Z"}
|
||||
{"type":"goal_created","goalId":"muismeg5-q31npy","objective":"Ship the Level-5 class layer: when the player reaches combat level 5 and combat goes quiet, the main game freezes and a new full-screen ClassWindow console opens (looping assets/videos/classes.mp4 on the left; Vanguard/Wraith tabs themed red/purple using frames 0/1 of the 128x128 spritesheet assets/images/classes.png on the right). Its intro state plays assets/speech/trestle/classes.mp3 over Trestle's class-selection copy; each tab shows a class overview plus a PICK action that asks for confirmation, then sets the class and grants its initial bonus — Vanguard: dual cannon + 5 skill points; Wraith: predictive (\"advanced targeting computer\") aiming + 5 skill points. Selectable/testable via index.html?level5.","sisyphus":false,"autoContinue":true,"at":"2026-09-26T19:39:05.765Z"}
|
||||
{"type":"task_list_set","goalId":"muismeg5-q31npy","taskCount":5,"blockCompletion":true,"at":"2026-09-26T19:39:05.765Z"}
|
||||
{"type":"task_started","goalId":"muismeg5-q31npy","taskId":"task-1","at":"2026-09-26T19:48:49.769Z"}
|
||||
{"type":"task_complete","goalId":"muismeg5-q31npy","taskId":"task-1","evidence":"dev/classes.test.mjs + node dev/run-all.mjs → ALL 71 TESTS PASSED (model lookup/idempotent grant/skill bank; save→load keeps id/perks/skillPoints via record.playerClass); data/classes.json added to da","at":"2026-09-26T20:17:42.893Z"}
|
||||
{"type":"task_started","goalId":"muismeg5-q31npy","taskId":"task-2","at":"2026-09-26T21:56:21.299Z"}
|
||||
{"goalId":"muismeg5-q31npy","taskId":"task-2","at":"2026-09-26T22:47:22.716Z","type":"task_complete","evidence":"dev/class-shot.mjs 28/28 green; shots reviewed: a-intro (muted looping classes.mp4 + exact Trestle copy), b-vanguard (red, frame 0, DUAL CANNON/+5 SP/PICK), c-wraith (purple, frame 1), d-confirm (LOCK"}
|
||||
{"goalId":"muismeg5-q31npy","taskId":"task-3","at":"2026-09-26T22:47:22.716Z","type":"task_started"}
|
||||
{"type":"task_complete","goalId":"muismeg5-q31npy","taskId":"task-3","evidence":"dev/classes-browser.test.mjs 14/14 green (no dev param: level-5 grant → console opens after marquee, world parked via _frozen/_parkForFreeze, ESC defers, no nag, ship door re-opens, plate VANGUARD/fra","at":"2026-09-26T22:50:49.712Z"}
|
||||
{"type":"task_started","goalId":"muismeg5-q31npy","taskId":"task-4","at":"2026-09-26T22:51:33.842Z"}
|
||||
{"type":"task_complete","goalId":"muismeg5-q31npy","taskId":"task-4","evidence":"dev/class-combat.test.mjs 7/7 (intercept: stationary/head-on no lead, crossing solved w/ bolt-meets-target math, unreachable + maxLead cap → raw aim; barrels ±9, turning with nose); dev/classes-browse","at":"2026-09-26T23:23:56.500Z"}
|
||||
{"type":"task_started","goalId":"muismeg5-q31npy","taskId":"task-5","at":"2026-09-26T23:24:59.777Z"}
|
||||
{"type":"goal_paused","goalId":"muismeg5-q31npy","reason":"user","status":"paused","at":"2026-09-26T23:36:49.926Z"}
|
||||
{"type":"goal_paused","goalId":"muismeg5-q31npy","reason":"Scheduling paused","source":"agent","at":"2026-09-26T23:41:39.558Z"}
|
||||
{"type":"goal_resumed","goalId":"muismeg5-q31npy","reason":"user","at":"2026-09-27T00:00:41.015Z"}
|
||||
{"type":"task_complete","goalId":"muismeg5-q31npy","taskId":"task-5","evidence":"PROJECT_NOTES \"The class layer\" section (data/code split, freeze contract, trigger rule, perk flags); README section + tree entries; grep shows zero copy literals in ClassWindow.js (feedLine moved to ","at":"2026-09-27T00:13:41.389Z"}
|
||||
{"type":"completion_requested","goalId":"muismeg5-q31npy","at":"2026-09-27T00:15:11.554Z"}
|
||||
{"type":"audit_started","goalId":"muismeg5-q31npy","at":"2026-09-27T00:15:11.556Z"}
|
||||
{"type":"audit_result","goalId":"muismeg5-q31npy","verdict":"approved","report":"I'll audit this systematically, starting with the repository structure and key artifacts.\n\n\n\n## Audit Findings\n\n**Objective criteria extracted:** (1) auto-open at combat level 5 when combat goes quiet; (2) main game freezes; (3) new full-screen console with looping `assets/videos/classes.mp4` left; (4) Vanguard/Wraith tabs red/purple using frames 0/1 of the 128×128 `assets/images/classes.png` right; (5) intro state plays `assets/speech/trestle/classes.mp3` over Trestle's copy; (6) each tab: overview + PICK → confirmation → sets class + grants initial bonus (Vanguard dual cannon + 5 SP; Wraith predictive targeting + 5 SP); (7) testable via `index.html?level5`.\n\n**Independent verification performed (real artifacts, no executor reliance):**\n- Assets exist and match: `classes.mp4` (2 MB MP4), `classes.png` = 640×256 loaded as **128×128 frames** (`data/classes.json → icons`, sprite-sheet load at `js/ui/ClassWindow.js:158`), `assets/speech/trestle/classes.mp3`.\n- Trigger/freeze is real code: `GameScene._classBeat()` (level ≥ `shipScreen.level.classAt`, `_combatIsQuiet()`, `_screenIsBusy()`, one-shot prompt) and `classWindow.isOpen` joins the `_frozen` expression at `js/scenes/GameScene.js:2675`, which calls `_parkForFreeze()`.\n- I ran the suite myself (after starting `dev/server.mjs 8123`): **ALL 73 TESTS PASSED**, including `classes.test.mjs`, `class-combat.test.mjs`, `classes-browser.test.mjs` (no-dev-param level-5 crossing opens once, world parked, ESC defers without nagging, ship door re-opens, plate replaces door).\n- I ran my own throwaway probes (`/tmp`, repo untouched):\n - **Real mouse clicks**: tab click → `tab=wraith`; PICK click → confirm dialog opens with **class still unfiled**; LOCK IT IN click → `hasClass:true, perks:[\"targetingComputer\"], skillPoints:5`, console closes, deck leaves standby.\n - **Live game loop** (real `CombatSystem.update`, real bullets, only a recording wrapper): Vanguard = every volley **2 bolts, 18 px apart**, damage 10 each; unclassed = 1 bolt; Wraith = 1 bolt fired **+0.21…+0.30 rad ahead of raw aim** on a hostile moving ~150–195 px/s cross-course — genuine predictive lead in the running game, not just a stub.\n - Screenshots (`/tmp/orbit-class-audit/*`) show full-screen console: looping muted feed left (frame advanced 2.5 s → 3.6 s), red Vanguard / purple Wraith tabs with correct distinct insignia right, Trestle intro copy verbatim, doctrine + \"+5 SKILL POINTS\" + PICK, and the ship console's class plate replacing the door.\n- Full suite green ⇒ no regressions from the `GameScene`/`CombatSystem`/`SaveData` edits; page reported no console errors.\n\n**Minor non-blocking nits (no requirement depends on them):** `docs/PROJECT_NOTES.md` describes the data keys with stale names (`_enabled`, `name`, `insignia`, `doctrine[]`, `confirmPick`, `iconSheet`) vs the actual `enabled/label/frame/overview/pick/icons`; two hardcoded fallback literals remain in `ClassWindow.js` PICK paths (`'PICK THIS CLASS'`, `` `${def.label} LOCKED IN` ``) so the \"zero copy literals\" claim is slightly overstated; perks intentionally do not alter the standalone `/combat` range (documented, consistent with objective scope).\n\nEvery explicit requirement of the objective — trigger condition, freeze, layout, theming, sprite frames, intro audio + copy, overview/PICK/confirmation flow, both classes' granted bonuses, and the `?level5` dev door — is implemented and demonstrably working in the real runtime.\n\n<approved/>","at":"2026-09-27T00:46:39.500Z"}
|
||||
{"type":"goal_completed","goalId":"muismeg5-q31npy","archivePath":".pi/goals/archived/goal_2026092618463950_muismeg5-q31npy.md","at":"2026-09-27T00:46:39.562Z"}
|
||||
{"type":"goal_archived","goalId":"muismeg5-q31npy","archivePath":".pi/goals/archived/goal_2026092618463950_muismeg5-q31npy.md","at":"2026-09-27T00:46:39.563Z"}
|
||||
|
|
|
|||
42
README.md
42
README.md
|
|
@ -126,6 +126,42 @@ respawns, the bar — lives in **`data/combat.json`**:
|
|||
per context through the production paths into `dev/shots/` — the
|
||||
committed `combat-range.png` + `combat-main.png` are that evidence).
|
||||
|
||||
## The class layer — who you are, picked at level 5
|
||||
|
||||
Cross **combat level 5** and, once the shooting stops, the world **freezes** and
|
||||
the **class console** opens full-screen (`js/ui/ClassWindow.js`): Trestle's muted
|
||||
feed loops on the left (`assets/videos/classes.mp4`), the class tabs ride the
|
||||
right in their own colours (`assets/images/classes.png`, 128x128 frames — frame
|
||||
0 Vanguard, frame 1 Wraith). Its first state plays
|
||||
`assets/speech/trestle/classes.mp3` over his briefing copy; every tab then shows
|
||||
that class's doctrine and a **PICK THIS CLASS** action that asks once more before
|
||||
it locks.
|
||||
|
||||
- **Vanguard** (red) — **DUAL CANNON**: the volley leaves two barrels, each bolt
|
||||
carrying the weapon's full damage. Double the metal per beat, same rate of fire.
|
||||
- **Wraith** (purple) — **ADVANCED TARGETING COMPUTER**: predictive aim. The
|
||||
cannon fires where the hostile *will* be when the bolt lands (a real intercept
|
||||
solve in `js/combat/Aiming.js`), capped so an unreachable lead can't whip the
|
||||
nose around — no solution, and it aims at where the ship is now.
|
||||
- Both bank **+5 skill points** into the run (`js/player/ClassState.js`, saved with
|
||||
the run). The ship console's plate then shows the crest, the class name in its
|
||||
accent colour and the banked points; the Skill Tree tab stays offline until
|
||||
there's a way to spend them.
|
||||
- **The rules**: asked once (the prompt is spent on lock-in, never nagged), only
|
||||
when combat is quiet and nothing else owns the screen. Closing without picking
|
||||
DEFERS — `CHOOSE YOUR CLASS` in the ship console is the way back. While the
|
||||
console is up the world is parked through `GameScene`'s existing `_frozen`
|
||||
contract (bodies damped, AI/combat/missions skipped), and Trestle's voice ducks
|
||||
the other audio beds, restoring them on the way out whatever you chose.
|
||||
- **Try it**: **`index.html?level5`** (aliases `?cls`, `?class`) starts a fresh run
|
||||
and walks straight into the console — no grinding required. Add `?tab=wraith`
|
||||
to open on a specific class; `?noclass` closes both dev doors for an honest run.
|
||||
Evidence: `dev/classes.test.mjs`, `dev/class-combat.test.mjs`,
|
||||
`dev/classes-browser.test.mjs`, and the screenshot pass `dev/class-shot.mjs`.
|
||||
- Everything is data — roster, colours, icon frames, all of Trestle's copy, the
|
||||
confirm wording and each perk's mechanics numbers live in **`data/classes.json`**.
|
||||
See *The class layer* in `docs/PROJECT_NOTES.md`.
|
||||
|
||||
## Current state — v0.2: a seedable galaxy
|
||||
|
||||
- Main menu with **Continue** (resumes the newest save — grayed out while
|
||||
|
|
@ -301,7 +337,8 @@ orbit/
|
|||
│ │ └── mining.json # the Mining tree: improved/advanced mining arm, improved storage (blueprints)
|
||||
│ ├── builds.json # BUILDING: the build console's categories/resources + the buildable items
|
||||
│ ├── actionbar.json # the command deck: 6 slots (Research, Scan, Ship, ·, ·, Menu)
|
||||
│ └── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks
|
||||
│ ├── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks
|
||||
│ └── classes.json # THE CLASS LAYER: Vanguard/Wraith roster, accents, icon frames, Trestle's copy, perk numbers
|
||||
├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames), ships-enemy.png (128×128 frames)
|
||||
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
|
||||
├── lib/ # vendored third-party libs (Phaser 4.2.1)
|
||||
|
|
@ -309,13 +346,14 @@ orbit/
|
|||
│ ├── main.js # entry point: load config → boot Phaser (?combat boots the normal game with the combat demo live)
|
||||
│ ├── config/ # Config singleton, ConfigLoader, game config
|
||||
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
|
||||
│ ├── player/ # THE CLASS LAYER: ClassModel (pure reads of data/classes.json), ClassState (the run ledger: class + perks + banked skill points)
|
||||
│ ├── combat/ # COMBAT: CombatSystem (the main-game pack — auto-fire, tracers, rams, armor, energy), the XP ledger (XpManager — levels from kills, data/progression.json), the range (CombatScene, js/combat/main.js boots it), EnemyShip, Bullet, Fx, StarDrift, pure rules in Combat.js
|
||||
│ ├── entities/ # Ship (own behavior), Planet (home world, solid), Station, JumpGate (solids)
|
||||
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport, JumpNetwork (the gate network)
|
||||
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
|
||||
│ ├── research/ # ResearchModel (pure tree rules/layout), ResearchState (unlocks + active run), ResearchIcons
|
||||
│ ├── build/ # BuildModel (pure build rules), BuildState (installed + in-progress records)
|
||||
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, CombatBar (the combat-mode deck), DiscoveryCompass (reusable), ResearchWindow, BuildWindow
|
||||
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, CombatBar (the combat-mode deck), DiscoveryCompass (reusable), ResearchWindow, BuildWindow, ClassWindow (the level-5 class console)
|
||||
│ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared)
|
||||
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
|
||||
│ └── vendor/ # shim to the vendored Phaser
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
{
|
||||
"_comment": "THE CLASS LAYER — the classes offered at combat level 5 and the console that hands them out (js/ui/ClassWindow.js over the frozen GameScene; the run's ledger is js/player/ClassState.js, the pure reads are js/player/ClassModel.js). THE DOOR: data/shipScreen.json → level.classAt (5) is the single source of the level a class is earned at — this file never restates it. The console opens when the run crosses that level AND combat goes quiet (no live hostiles, no wave run in flight) and never auto-fires again once a class is on file; closing it without picking DEFERS, and the ship console's CHOOSE YOUR CLASS door re-opens it. CODE OWNS BEHAVIOR, THIS FILE OWNS COPY + NUMBERS: every string, color, frame index and perk number lives here.",
|
||||
"enabled": true,
|
||||
"icons": {
|
||||
"_comment": "The class insignia sheet — assets/images/classes.png is 640×256 = ten 128×128 frames (frame 0 = Vanguard, frame 1 = Wraith; the rest are unassigned seams). The tab chips, the console's big crest and the ship console's class plate all read a class's `frame` below off this one sheet.",
|
||||
"texture": "assets/images/classes.png",
|
||||
"frameWidth": 128,
|
||||
"frameHeight": 128
|
||||
},
|
||||
"classes": [
|
||||
{
|
||||
"id": "vanguard",
|
||||
"label": "VANGUARD",
|
||||
"_comment": "`accent` themes the whole class: its tab (red), the plate rails, the crest frame, the confirm dialog and the lock-in toast. `frame` is the insignia's index in classes.png. `tagline` sits under the crest, `role` is the small line beside it.",
|
||||
"accent": "#ff3a4d",
|
||||
"frame": 0,
|
||||
"role": "ASSAULT FRAME",
|
||||
"tagline": "RAW STRENGTH // BRUTE FORCE",
|
||||
"summary": "The Vanguard prefers raw strength and brute force to defense or trickery.",
|
||||
"overview": [
|
||||
"A Vanguard does not out-think a fight. He walks into it, takes the hit, and stays standing when the other ship stops firing.",
|
||||
"Two mass cannons where every other hull has one is the whole doctrine: more metal on target, every volley, from the first second. Range is a problem for people who can run — and a Vanguard does not run."
|
||||
],
|
||||
"bonuses": {
|
||||
"_comment": "The INITIAL bonus the lock-in grants. `skillPoints` banks on the run's ledger (js/player/ClassState.js — saved, re-applied on load; the tree that spends them is the next layer). `perks` are ids into this file's perks table below.",
|
||||
"skillPoints": 5,
|
||||
"perks": [
|
||||
"dualCannon"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "wraith",
|
||||
"label": "WRAITH",
|
||||
"accent": "#a764ff",
|
||||
"frame": 1,
|
||||
"role": "RECON FRAME",
|
||||
"tagline": "MARKSMANSHIP // TRICKERY",
|
||||
"summary": "The Wraith prefers to fight smart against enemies instead of pure force — an expert in marksmanship and trickery.",
|
||||
"overview": [
|
||||
"A Wraith never aims where an enemy IS. The targeting computer reads the hostile's heading and speed, does the arithmetic the nose never has time for, and puts the bolt where the enemy will BE when it arrives.",
|
||||
"One clean shot instead of three wild ones: less heat, less noise, less of the Wraith there to be shot back at. Let the Vanguard make the noise."
|
||||
],
|
||||
"bonuses": {
|
||||
"skillPoints": 5,
|
||||
"perks": [
|
||||
"targetingComputer"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"perks": {
|
||||
"_comment": "The perk table — what each initial bonus IS in the combat code (js/combat/CombatSystem.js reads these through js/player/ClassModel.js). Numbers are tuned HERE, never in code: dualCannon.barrels = bolts per volley and barrelOffset = the px each barrel sits off the centreline (both barrels carry the FULL weapon damage, so two barrels is roughly double the shot — that is the Vanguard's point); targetingComputer.maxLead = the longest bolt flight (seconds) the lead solve may commit to, so an unreachable intercept can never snap the nose into nonsense — past the cap it aims at the hostile as it is now.",
|
||||
"dualCannon": {
|
||||
"label": "DUAL CANNON",
|
||||
"note": "Twin mass cannons fire as one — two full-damage bolts per volley from offset barrels.",
|
||||
"barrels": 2,
|
||||
"barrelOffset": 9
|
||||
},
|
||||
"targetingComputer": {
|
||||
"label": "ADVANCED TARGETING COMPUTER",
|
||||
"note": "Leading shots: the cannon aims where the hostile WILL be when the bolt lands, not where it is now.",
|
||||
"maxLead": 0.9
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"_comment": "The SELECT YOUR CLASS console (js/ui/ClassWindow.js — the same cabinet as the ship / map / quest consoles: LEFT the looping feed, RIGHT the tab row over one plate). Depth 85: above the other consoles (80), because it owns the moment.",
|
||||
"title": "CLASS SELECT",
|
||||
"meta": "COMBAT LEVEL {classAt} // PICK YOUR PATH",
|
||||
"feedLine": "CLASS DOCTRINE // LV {classAt}",
|
||||
"video": {
|
||||
"_comment": "The left feed — assets/videos/classes.mp4, muted + looping (the same 2:3 contract as ship.mp4). A missing clip leaves the NO SIGNAL plate up and the console still works.",
|
||||
"file": "assets/videos/classes.mp4",
|
||||
"aspect": [
|
||||
2,
|
||||
3
|
||||
],
|
||||
"tag": "RECRUITING OFFICE // OPEN CHANNEL",
|
||||
"noSignalTag": "FEED OFFLINE"
|
||||
},
|
||||
"speech": {
|
||||
"_comment": "Trestle's briefing (assets/speech/trestle/classes.mp3) — played ONCE on the console's first open of the run, with the intro copy up. The clip is always the voice; the video stays muted (the comms' rule). While it talks, music/ambience duck by musicDuck (a fraction of each own volume) and are restored exactly when it ends.",
|
||||
"file": "assets/speech/trestle/classes.mp3",
|
||||
"musicDuck": 0.25
|
||||
},
|
||||
"intro": {
|
||||
"_comment": "The state BEFORE any tab is picked: the speaker plate + Trestle's copy on the main plate. `text` is the briefing verbatim; `hint` sits under it while no class is chosen.",
|
||||
"speaker": "TRESTLE",
|
||||
"role": "OUT-OF-SYSTEM MENTOR",
|
||||
"text": "You made it kid, level 5 is no joke. Its time to pick your class. The class you pick is going to define how you survive in this galaxy. Some classes prefer stealth and trickery, while others prefer brute force, or survivability. You can change your class later for a price. But for now, click on the tabs and learn about the available classes, and pick the one that best suits you when you're ready.",
|
||||
"hint": "OPEN A TAB // THEN PICK THE CLASS THAT SUITS YOU",
|
||||
"backLabel": "◂ BRIEFING",
|
||||
"caret": true
|
||||
},
|
||||
"tabs": {
|
||||
"_comment": "The class tab row (built from `classes` above — one tab per class, in that order). iconSize = the insignia chip drawn beside the label.",
|
||||
"iconSize": 26,
|
||||
"height": 46
|
||||
},
|
||||
"classPlate": {
|
||||
"_comment": "A picked tab's plate: the crest (the class frame off classes.png) + name + tagline, the overview paragraphs, then the INITIAL BONUS block (the perk lines + the skill-point line) and the PICK action.",
|
||||
"crestSize": 104,
|
||||
"overviewTitle": "DOCTRINE",
|
||||
"bonusTitle": "INITIAL BONUS",
|
||||
"skillPointsLine": "+{n} SKILL POINTS",
|
||||
"skillPointsNote": "BANKED — THE SKILL TREE ARMS WITH THE NEXT INSTALL",
|
||||
"chosenTag": "LOCKED IN"
|
||||
},
|
||||
"pick": {
|
||||
"_comment": "The PICK action and its confirmation (js/ui/ConfirmOverlay.js, hosted by the window so it always paints above it, centred on the whole console). Keep line1/line2 SHORT — the dialog sizes itself to its widest line and only steps its font down after that. CONFIRM sets the class, grants the initial bonus and closes the console; CANCEL just closes the dialog. A class already on file makes every tab read inert — changing class is a later layer with a price on it.",
|
||||
"label": "PICK THIS CLASS",
|
||||
"width": 520,
|
||||
"height": 196,
|
||||
"title": "LOCK IN {class}",
|
||||
"line1": "{class} · {perks} · +{skillPoints} SKILL POINTS",
|
||||
"line2": "LOCKED IN FOR THIS RUN — CHANGE CLASS LATER, FOR A PRICE",
|
||||
"confirmLabel": "LOCK IT IN",
|
||||
"cancelLabel": "KEEP LOOKING"
|
||||
},
|
||||
"alreadyChosenToast": "CLASS ALREADY ON FILE — CHANGING CLASS LANDS LATER",
|
||||
"successToast": "{class} LOCKED IN — {perks} · +{skillPoints} SKILL POINTS",
|
||||
"deferToast": "CLASS NOT CHOSEN — THE SHIP CONSOLE'S CHOOSE YOUR CLASS DOOR REOPENS THIS",
|
||||
"lockSfx": "ui_window",
|
||||
"lockSuccessSfx": "discovery"
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
"economy.json",
|
||||
"shopui.json",
|
||||
"combat.json",
|
||||
"progression.json"
|
||||
"progression.json",
|
||||
"classes.json"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,14 +323,16 @@
|
|||
}
|
||||
],
|
||||
"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).",
|
||||
"_comment": "The level-5 door: the timeline's replacement. The press fires the scene's onChooseClass seam, which opens the SELECT YOUR CLASS console (js/ui/ClassWindow.js) over the parked world \u2014 a DEFERRED pick re-opens here, so this door is the run's second way in. `toast` is only the fallback voice when the class layer is switched off (data/classes.json \u2192 enabled: false).",
|
||||
"label": "CHOOSE YOUR CLASS",
|
||||
"hint": "LEVEL {classAt} REACHED \u2014 THE CLASS LAYER AWAITS",
|
||||
"toast": "CLASS SELECT OFFLINE \u2014 THE CLASS LAYER LANDS NEXT"
|
||||
"toast": "CLASS SELECT OFFLINE \u2014 THE CLASS LAYER IS SWITCHED OFF"
|
||||
},
|
||||
"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).",
|
||||
"_comment": "A class is on file: the console shows its name + insignia (a FRAME INDEX off assets/images/classes.png, drawn in the class's own accent) instead of the door. `insigniaFrame` is the fallback when the save carries no class id; `skillPointsLine` is the banked-point readout (js/player/ClassState.js \u2014 the tree that spends them is the next layer).",
|
||||
"label": "CLASS",
|
||||
"insigniaFrame": 0,
|
||||
"skillPointsLine": "+{n} SKILL POINTS BANKED",
|
||||
"noneLabel": "NO CLASS ON FILE"
|
||||
}
|
||||
},
|
||||
|
|
@ -374,7 +376,7 @@
|
|||
"fillAlpha": 0.85
|
||||
},
|
||||
"setDestination": {
|
||||
"_comment": "The BEST-EXIT confirm (js/ui/ConfirmOverlay.js, hosted by the console so it always paints above it) — the same SET DESTINATION dialog the planet shop's scout and the map's SYSTEM tab use. {object}/{system} name the exit's WORLD and its system, {class} its economic class, {price} the CR/unit it pays, {sign}{profit} the per-unit margin against `baseline`, {hops} the jump distance. line2NoHops is the row without a charted hop count.",
|
||||
"_comment": "The BEST-EXIT confirm (js/ui/ConfirmOverlay.js, hosted by the console so it always paints above it) \u2014 the same SET DESTINATION dialog the planet shop's scout and the map's SYSTEM tab use. {object}/{system} name the exit's WORLD and its system, {class} its economic class, {price} the CR/unit it pays, {sign}{profit} the per-unit margin against `baseline`, {hops} the jump distance. line2NoHops is the row without a charted hop count.",
|
||||
"title": "SET DESTINATION",
|
||||
"confirmLabel": "SET DESTINATION",
|
||||
"cancelLabel": "CANCEL",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
|
||||
|
||||
// The real data/*.json into the config singleton (the classes.json numbers
|
||||
// under test are the shipped ones — no private copy).
|
||||
const { config } = await import(file('config/Config.js'));
|
||||
const { barrels, angleDiff, intercept, rawAngle } = await import(file('combat/Aiming.js'));
|
||||
const { perkDef } = await import(file('player/ClassModel.js'));
|
||||
|
||||
const dataDir = join(__dirname, '../data');
|
||||
const configData = {};
|
||||
for (const f of fs.readdirSync(dataDir)) {
|
||||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
|
||||
}
|
||||
config.init(configData);
|
||||
|
||||
const ok = (m) => console.log(' \u2714 ' + m);
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
// ────────────────────────────────────────────────────────── the intercept
|
||||
{
|
||||
// 1 · a stationary target needs no lead at all.
|
||||
const hit = intercept(0, 0, 300, 0, 0, 0, 600, 0.9);
|
||||
assert.ok(Math.abs(hit.lead) < 1e-12, 'a parked target gets no lead');
|
||||
assert.equal(hit.t, Math.hypot(300, 0) / 600, 'the flight time is still the straight distance');
|
||||
assert.ok(Math.abs(hit.angle - rawAngle(0, 0, 300, 0)) < 1e-12, 'raw aim is the answer');
|
||||
ok('stationary target: no lead, raw aim');
|
||||
|
||||
// 2 · a target crossing right in front of the muzzle, dead ahead at 90° away.
|
||||
// Muzzle (0,0), bolt 600 px/s; target at (0,300) moving +x at 200 px/s.
|
||||
// t solves (200t)² + 300² = (600t)² → t = 300/√(600²−200²) = 0.5303 s;
|
||||
// it will be at x = 106.07 → angle atan2(300, 106.07) = 1.2310 rad (70.5°).
|
||||
const t = intercept(0, 0, 0, 300, 200, 0, 600, 0);
|
||||
assert.equal(t.solved, true, 'a crossing target IS interceptable');
|
||||
assert.ok(Math.abs(t.t - 300 / Math.sqrt(600 ** 2 - 200 ** 2)) < 1e-9, `flight time ${t.t}`);
|
||||
assert.ok(Math.abs(t.x - 200 * t.t) < 1e-9 && Math.abs(t.y - 300) < 1e-9, 'the meeting point rides the target’s velocity');
|
||||
assert.ok(Math.abs(t.angle - Math.atan2(300, 200 * t.t)) < 1e-12, `lead angle ${t.angle}`);
|
||||
// the bolt, fired on that heading, really does arrive where the target gets to
|
||||
const bx = Math.cos(t.angle) * 600 * t.t;
|
||||
const by = Math.sin(t.angle) * 600 * t.t;
|
||||
assert.ok(Math.hypot(bx - t.x, by - t.y) < 1e-9, 'bolt and target meet at the solved point');
|
||||
// …and the lead is a HEAD-WARD correction, not a random swing
|
||||
const raw = rawAngle(0, 0, 0, 300);
|
||||
assert.ok(Math.abs(t.lead) > 0.3 && Math.sign(t.lead) === Math.sign(angleDiff(Math.atan2(300, 106), raw)), 'the nose moves AHEAD of the target');
|
||||
ok('crossing target: leads ahead by the bullet-speed-correct angle (bolt meets target)');
|
||||
|
||||
// 3 · a target running straight at the muzzle needs no lead either.
|
||||
const head = intercept(0, 0, 400, 0, -150, 0, 600, 0.9);
|
||||
assert.ok(Math.abs(head.angle) < 1e-9 || Math.abs(Math.abs(head.angle) - Math.PI) < 1e-9, 'still on the line');
|
||||
assert.ok(Math.abs(head.lead) < 1e-9, 'no lead down the barrel line');
|
||||
ok('head-on target: no lead (the solve is degenerate, and honest about it)');
|
||||
|
||||
// 4 · a target faster than the bolt can never be caught → raw aim, solved:false.
|
||||
const run = intercept(0, 0, 100, 0, 1400, 200, 600, 0);
|
||||
assert.equal(run.solved, false, 'unreachable');
|
||||
assert.equal(run.angle, rawAngle(0, 0, 100, 0), 'falls back to raw aim');
|
||||
ok('target outruns the bolt: falls back to raw aim');
|
||||
|
||||
// 5 · a perfect broadside pass at extreme range: an intercept exists but the
|
||||
// computer's cap (data/classes.json → maxLead) refuses the swing.
|
||||
const far = intercept(0, 0, 6000, 400, 0, 900, 600, Number(perkDef('targetingComputer').maxLead));
|
||||
assert.ok(far.t === 0 && far.solved === false, `the ${perkDef('targetingComputer').maxLead}s lead cap holds (raw solve would need ~1.5s)`);
|
||||
assert.equal(far.angle, rawAngle(0, 0, 6000, 400), 'and the nose stays on the ship');
|
||||
ok(`out of the computer's reach (cap ${perkDef('targetingComputer').maxLead}s): no wild nose, raw aim`);
|
||||
|
||||
// 6 · numbers come from the data, not from code.
|
||||
assert.ok(Number(perkDef('targetingComputer').maxLead) > 0, 'maxLead is configured');
|
||||
assert.equal(Number(perkDef('dualCannon').barrels), 2, 'the Vanguard fires two');
|
||||
ok('the perk numbers ride data/classes.json');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── barrels
|
||||
{
|
||||
const one = barrels(0, 1, 9, 20);
|
||||
assert.equal(one.length, 1);
|
||||
assert.deepEqual({ x: one[0].x, y: one[0].y }, { x: 20, y: 0 }, 'a single barrel sits on the centreline');
|
||||
|
||||
const two = barrels(0, 2, 9, 20);
|
||||
assert.equal(two.length, 2);
|
||||
assert.ok(Math.abs(two[0].x - 20) < 1e-9 && Math.abs(two[1].x - 20) < 1e-9, 'both barrels are at the same station');
|
||||
assert.ok(Math.abs(two[0].y + 9) < 1e-9 && Math.abs(two[1].y - 9) < 1e-9, `offset ±9 off the centreline (${two[0].y}, ${two[1].y})`);
|
||||
// rotate the firing line: the pair squares away with it (perpendicular offsets)
|
||||
const up = barrels(-Math.PI / 2, 2, 9, 20); // firing "up" the screen
|
||||
assert.ok(Math.abs(up[0].x) === 9 || Math.abs(Math.abs(up[0].x) - 9) < 1e-9, 'the pair swings out along x');
|
||||
assert.ok(Math.abs(up[0].y + 20) < 1e-9 && Math.abs(up[1].y + 20) < 1e-9, 'both still at the nose station');
|
||||
assert.ok(Math.sign(up[0].x) !== Math.sign(up[1].x), 'one barrel each side of the centreline');
|
||||
const sep = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
|
||||
assert.ok(Math.abs(sep - 18) < 1e-9, `the muzzles are ${sep}px apart — never inside each other`);
|
||||
ok('dual barrels: 2 muzzles, symmetric ±barrelOffset, turning with the nose');
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
/**
|
||||
* dev/class-shot.mjs — the CLASS LAYER in a REAL BROWSER (screenshots + the
|
||||
* behaviour the shots alone cannot prove).
|
||||
*
|
||||
* node dev/server.mjs 8123 & # the static dev server
|
||||
* node dev/class-shot.mjs # → /tmp/orbit-class/*.png
|
||||
*
|
||||
* Runs index.html?level5 (the dev door) and pins, in the real Phaser runtime:
|
||||
* a) the INTRO state — Trestle's copy complete on the plate, his clip playing
|
||||
* with the other beds ducked, classes.mp4 live + MUTED + looping;
|
||||
* b) the VANGUARD tab (red theme, frame 0 of assets/images/classes.png);
|
||||
* c) the WRAITH tab (purple theme, frame 1);
|
||||
* d) the LOCK IN confirm dialog, and after CONFIRM: the ledger (class + perk
|
||||
* + 5 banked skill points), the ship console's class plate, the world
|
||||
* RESUMED, and every ducked bed restored.
|
||||
* It also proves the freeze contract: while the console is up the ship's body
|
||||
* is parked (velocity/acceleration zero) and the combat drivers stand down.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const { chromium } = await import(
|
||||
pathToFileURL('/home/brianfertig/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs').href
|
||||
);
|
||||
const BASE = process.env.ORBIT_URL || 'http://127.0.0.1:8123';
|
||||
const OUT = process.env.OUT_DIR || '/tmp/orbit-class';
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
let fails = 0;
|
||||
const check = (cond, msg) => {
|
||||
console.log(` ${cond ? '✔' : '✘ FAIL'} ${msg}`);
|
||||
if (!cond) fails++;
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: '/home/brianfertig/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--autoplay-policy=no-user-gesture-required'],
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
|
||||
const errors = [];
|
||||
page.on('console', (m) => {
|
||||
if (m.type() !== 'error') return;
|
||||
// Resource 404s are the RESPONSE handler's job — it sees the URL, Chrome's
|
||||
// console line for them carries none (favicon.ico lives here unclaimed).
|
||||
if (/Failed to load resource/i.test(m.text())) return;
|
||||
errors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => errors.push('[pageerror] ' + e.message));
|
||||
page.on('response', (r) => {
|
||||
if (r.status() >= 400 && !/favicon/.test(r.url())) errors.push(`[http ${r.status()}] ${r.url()}`);
|
||||
});
|
||||
page.on('requestfailed', (rq) => {
|
||||
if (!/favicon/.test(rq.url())) console.log(' · request failed:', rq.url(), rq.failure()?.errorText);
|
||||
});
|
||||
|
||||
console.log(`== the class console — ${BASE}/index.html?level5 ==`);
|
||||
await page.goto(`${BASE}/index.html?level5`, { waitUntil: 'load' });
|
||||
// ?level5 walks through the menu on its own (MenuScene's dev door) into a
|
||||
// fresh run, where GameScene arms the same param as the class console's trigger.
|
||||
await page.waitForTimeout(1000);
|
||||
await page.evaluate(() => {
|
||||
const m = window.game?.scene?.getScene?.('MenuScene');
|
||||
if (m && !m.dead) m.startNewGame(); // no double-start once the autostart landed
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const g = window.game?.scene?.getScene?.('GameScene');
|
||||
return !!(g && g.ship && g.classState);
|
||||
}, null, { timeout: 90000 });
|
||||
|
||||
// The console opens on its own (the dev door), once its media pass lands.
|
||||
// Baseline of the audio beds BEFORE the console can open (the media pass takes
|
||||
// a few seconds): what each bed's own volume is, so "nothing left ducked" is
|
||||
// measured against the game's own levels rather than a guessed number.
|
||||
const bedsBefore = await page.evaluate(() => {
|
||||
const out = {};
|
||||
for (const s of window.game.scene.getScene('GameScene').sound?.getAllPlaying?.() ?? []) {
|
||||
out[s.key] = s.currentConfig?.volume ?? s.volume;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow?.isOpen === true, null, { timeout: 30000 });
|
||||
console.log(' ✔ ?level5 opened the class console');
|
||||
|
||||
const gs = () => page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
const cw = g.classWindow;
|
||||
const v = g.ship?.body?.velocity;
|
||||
return {
|
||||
frozen: !!g.actionBar && g.actionBar.enabled === false,
|
||||
shipVel: v ? { x: v.x, y: v.y } : null,
|
||||
openState: cw.openState,
|
||||
tab: cw._tab,
|
||||
depth: cw.depth,
|
||||
briefPlaying: g.sound?.isPlaying?.('class_brief') === true,
|
||||
hasIcons: !!cw._tabs.find((t) => t.chip),
|
||||
tabs: cw._tabs.map((t) => ({
|
||||
id: t.id,
|
||||
label: t.txt.text,
|
||||
tex: t.chip ? t.chip.texture.key : null,
|
||||
frame: t.chip ? String(t.chip.frame.name ?? '') : null,
|
||||
w: Math.round(t.w),
|
||||
})),
|
||||
video: (() => {
|
||||
const el = cw.video?.video;
|
||||
return el ? { muted: el.muted === true || cw.video.volume === 0, loop: !!el.loop, ready: el.readyState >= 2, t: Math.round(el.currentTime * 10) / 10 } : null;
|
||||
})(),
|
||||
introText: cw._introText?.text ?? '',
|
||||
classOnFile: g.classState.toJSON(),
|
||||
beds: (g.sound?.getAllPlaying?.() ?? []).map((s) => ({ key: s.key, vol: Math.round((s.currentConfig?.volume ?? 1) * 100) / 100 })),
|
||||
plate: g.classPlateSnapshot(),
|
||||
combatActors: Array.isArray(g.combat?.active) ? g.combat.active.length : (g.combat?.active?.size ?? 0),
|
||||
};
|
||||
});
|
||||
|
||||
// (This headless box renders at a few frames per second, so every animation
|
||||
// wait below is generous — the assertions are state-based, not clock-based.)
|
||||
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.openState === 'open', null, { timeout: 40000 });
|
||||
await page.waitForTimeout(3000);
|
||||
let st = await gs();
|
||||
check(st.openState === 'open', `the console reports open (state=${st.openState})`);
|
||||
check(st.frozen === true, 'the deck is stood down (the action bar is disabled through _frozen)');
|
||||
check(st.shipVel && Math.abs(st.shipVel.x) < 0.01 && Math.abs(st.shipVel.y) < 0.01, `the ship is parked (vel ${JSON.stringify(st.shipVel)})`);
|
||||
check(st.briefPlaying === true, "Trestle's clip is playing (assets/speech/trestle/classes.mp3)");
|
||||
check((st.beds ?? []).every((b) => b.key === 'class_brief' || b.vol <= 0.26), `the other beds are ducked (${JSON.stringify(st.beds)})`);
|
||||
check(st.hasIcons === true, 'the tabs draw their insignia from the class sheet');
|
||||
check(st.tabs.length === 2 && st.tabs[0].frame === '0' && st.tabs[1].frame === '1', `tab frames 0/1 off ${st.tabs[0].tex} (${JSON.stringify(st.tabs.map((t) => t.frame))})`);
|
||||
if (st.video?.t !== undefined) {
|
||||
const t1 = st.video.t;
|
||||
await page.waitForTimeout(900);
|
||||
const st2 = await gs();
|
||||
check(st.video.muted === true && st.video.loop === true, `the feed is muted + looping (${JSON.stringify(st.video)})`);
|
||||
check(st2.video && st2.video.t > t1, `the feed is actually playing (t ${t1} → ${st2.video?.t})`);
|
||||
}
|
||||
|
||||
// The intro shot with the copy COMPLETE: fast-forward the typing (it is paced
|
||||
// by the clip; a screenshot must not wait 20 s for it).
|
||||
await page.evaluate(() => {
|
||||
const cw = window.game.scene.getScene('GameScene').classWindow;
|
||||
if (cw._typing) cw._typing.t0 -= 60000;
|
||||
});
|
||||
await page.waitForTimeout(2500);
|
||||
st = await gs();
|
||||
const wanted = [
|
||||
'You made it kid, level 5 is no joke.',
|
||||
'pick the one that best suits you when',
|
||||
"you're ready.",
|
||||
];
|
||||
check(wanted.every((frag) => st.introText.includes(frag)), 'the plate carries Trestle’s copy verbatim');
|
||||
check(st.tab === null, 'the intro state has no tab picked yet');
|
||||
await page.screenshot({ path: path.join(OUT, 'a-intro.png') });
|
||||
|
||||
// The tabs.
|
||||
await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow._setTab('vanguard'));
|
||||
await page.waitForTimeout(2500);
|
||||
await page.screenshot({ path: path.join(OUT, 'b-vanguard.png') });
|
||||
let vanguard = await page.evaluate(() => {
|
||||
const cw = window.game.scene.getScene('GameScene').classWindow;
|
||||
return { tab: cw._tab, text: cw._kids.filter((k) => k.type === 'Text').map((t) => t.text).join('\n') };
|
||||
});
|
||||
check(vanguard.tab === 'vanguard', 'the Vanguard tab opened');
|
||||
check(/DUAL CANNON/i.test(vanguard.text) && /\+5 SKILL POINTS/.test(vanguard.text), 'its plate lists the dual cannon + 5 skill points');
|
||||
check(/PICK THIS CLASS/i.test(vanguard.text), 'its plate offers PICK');
|
||||
|
||||
await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow._setTab('wraith'));
|
||||
await page.waitForTimeout(2500);
|
||||
await page.screenshot({ path: path.join(OUT, 'c-wraith.png') });
|
||||
const wraith = await page.evaluate(() => {
|
||||
const cw = window.game.scene.getScene('GameScene').classWindow;
|
||||
return { tab: cw._tab, text: cw._kids.filter((k) => k.type === 'Text').map((t) => t.text).join('\n') };
|
||||
});
|
||||
check(/TARGETING COMPUTER/i.test(wraith.text) && /\+5 SKILL POINTS/.test(wraith.text), 'its plate lists the targeting computer + 5 skill points');
|
||||
|
||||
// The confirm dialog.
|
||||
await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow._askPick('wraith'));
|
||||
await page.waitForTimeout(3000);
|
||||
const dlg = await page.evaluate(() => {
|
||||
const d = window.game.scene.getScene('GameScene').classWindow.dialog;
|
||||
return { open: d.isOpen, title: d.title.text, body: d.bodyTexts.map((t) => t.text).filter(Boolean) };
|
||||
});
|
||||
check(dlg.open === true && /LOCK IN WRAITH/i.test(dlg.title), `the dialog asks before anything is written (${dlg.title})`);
|
||||
check(dlg.body.some((b) => /later/i.test(b) && /price/i.test(b)), 'it says the class can change later for a price');
|
||||
await page.screenshot({ path: path.join(OUT, 'd-confirm.png') });
|
||||
|
||||
// CONFIRM → the ledger, and the world resumes.
|
||||
await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow.dialog.fireConfirm());
|
||||
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.openState === 'closed', null, { timeout: 40000 });
|
||||
await page.waitForTimeout(2500);
|
||||
st = await gs();
|
||||
check(st.classOnFile.id === 'wraith', `the class is on file (${JSON.stringify(st.classOnFile)})`);
|
||||
check(st.classOnFile.perks.includes('targetingComputer') && st.classOnFile.skillPoints === 5, 'its bonus was granted (perk + 5 skill points)');
|
||||
check(st.briefPlaying !== true, 'the brief is not left playing');
|
||||
const stuckDucks = (st.beds ?? []).filter((b) => {
|
||||
const base = bedsBefore[b.key];
|
||||
return Number.isFinite(base) && base > 0 && b.vol < base * 0.9; // still under its own level
|
||||
});
|
||||
check(stuckDucks.length === 0, `nothing is left ducked (${JSON.stringify({ now: st.beds, before: bedsBefore })})`);
|
||||
check(!!st.plate && st.plate.name === 'WRAITH' && st.plate.insignia === 1, `the ship console's class plate reads ${JSON.stringify(st.plate)}`);
|
||||
|
||||
// The deck steers again (the freeze released) — real input, W held.
|
||||
check(st.frozen !== true, 'the deck came back off standby when the console closed');
|
||||
// …with a real helm order (click-to-move is the game's own control) measured
|
||||
// against where the ship actually ends up.
|
||||
const before = await page.evaluate(() => {
|
||||
const s = window.game.scene.getScene('GameScene').ship;
|
||||
return { x: Math.round(s.x), y: Math.round(s.y) };
|
||||
});
|
||||
await page.mouse.click(300, 660); // an empty patch of the play field
|
||||
await page.waitForTimeout(5000);
|
||||
const after = await page.evaluate(() => {
|
||||
const s = window.game.scene.getScene('GameScene').ship;
|
||||
return { x: Math.round(s.x), y: Math.round(s.y), v: Math.round(Math.hypot(s.body.velocity.x, s.body.velocity.y)) };
|
||||
});
|
||||
const moved = Math.hypot(after.x - before.x, after.y - before.y);
|
||||
check(moved > 3 || after.v > 1, `the world resumed — a helm order moves the ship (${JSON.stringify({ before, after })})`);
|
||||
|
||||
// The door is spent: the console never auto-fires again for this run.
|
||||
const reopen = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
g._classPrompted = false; // pretend the flag was cleared…
|
||||
g._classBeat();
|
||||
return g.classWindow.isOpen;
|
||||
});
|
||||
check(reopen === false, 'a run with a class on file is never asked again');
|
||||
|
||||
// The ship console shows the class plate (its own door is gone).
|
||||
await page.evaluate(() => window.game.scene.getScene('GameScene').shipWindow.open());
|
||||
await page.waitForTimeout(4000);
|
||||
const consoleState = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
const texts = [];
|
||||
const walk = (c) => {
|
||||
for (const o of c?.list ?? []) {
|
||||
if (o.type === 'Text') texts.push(o.text);
|
||||
else if (o.list) walk(o);
|
||||
}
|
||||
};
|
||||
walk(g.shipWindow);
|
||||
return { joined: texts.join('\n'), frozen: g.actionBar?.enabled === false };
|
||||
});
|
||||
check(/VANGUARD|WRAITH/.test(consoleState.joined) && !/CHOOSE YOUR CLASS/.test(consoleState.joined), 'the ship console shows the class plate, not the door');
|
||||
check(/SKILL POINTS BANKED/i.test(consoleState.joined), 'the class plate banks the skill points');
|
||||
await page.screenshot({ path: path.join(OUT, 'e-ship-console.png') });
|
||||
|
||||
// ───────────────────────────────────────────────────────── SCENARIO 2
|
||||
// The REAL door: no ?level5. A run that crosses combat level 5 while combat is
|
||||
// quiet gets asked once; dismissing DEFERS (the ship console's door takes over)
|
||||
// and the prompt never re-fires on its own.
|
||||
console.log('== scenario 2 — the real level-5 crossing (no dev param) ==');
|
||||
// ?noclass closes BOTH dev doors (the menu's walk-in and the class trigger), so
|
||||
// this run is entered the way a player enters it — the menu's NEW GAME.
|
||||
await page.goto(`${BASE}/index.html?noclass`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(() => {
|
||||
const m = window.game?.scene?.getScene?.('MenuScene');
|
||||
if (m && !m.dead) m.startNewGame();
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const g = window.game?.scene?.getScene?.('GameScene');
|
||||
return !!(g && g.ship && g.classState);
|
||||
}, null, { timeout: 90000 });
|
||||
// The intro comm owns the screen until somebody presses ACKNOWLEDGE — so press
|
||||
// it (the box's button calls CommsHub._ack; from out here that IS the click).
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
if (g.comms?.paused) g.comms._ack(); // the ACKNOWLEDGE press itself
|
||||
return !g.comms?.paused && !g.comms?.active;
|
||||
},
|
||||
null,
|
||||
{ timeout: 120000, polling: 500 }
|
||||
);
|
||||
check(true, 'a plain boot is never asked for a class (?noclass + level 0)');
|
||||
const beforeGrant = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
return { cw: !!g.classWindow, open: g.classWindow?.isOpen === true, prompted: g._classPrompted };
|
||||
});
|
||||
check(beforeGrant.cw === false && beforeGrant.prompted === false, 'nothing armed below the level');
|
||||
await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
// straight to the door: enough XP to clear the curve's first five levels
|
||||
for (let i = 0; i < 8; i++) g.combat.xp.grant(500, 'test');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
if (g.comms?.paused) g.comms._ack(); // whatever brief lands mid-flight, the player presses through
|
||||
return g.classWindow?.isOpen === true;
|
||||
},
|
||||
null,
|
||||
{ timeout: 150000, polling: 500 }
|
||||
);
|
||||
check(true, 'crossing level 5 in a quiet sky opened the console on its own');
|
||||
await page.screenshot({ path: path.join(OUT, 'f-real-trigger.png') });
|
||||
const armed = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
return { level: g.combat.xp.level, prompted: g._classPrompted, marquee: !!g.levelMarquee?.active };
|
||||
});
|
||||
check(armed.level >= 5 && armed.prompted === true, `the prompt is spent (level ${armed.level})`);
|
||||
// DEFER: ESC closes it without a class, the ship console's door is the way back.
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.openState === 'closed', null, { timeout: 40000 });
|
||||
const deferred = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
return { hasClass: g.classState.hasClass, plate: g.classPlateSnapshot(), frozen: g.actionBar?.enabled === false };
|
||||
});
|
||||
check(deferred.hasClass === false && deferred.plate === null, 'closing without picking files no class');
|
||||
check(deferred.frozen === false, 'the world runs again on the deferral');
|
||||
await page.waitForTimeout(6000);
|
||||
const nag = await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow.isOpen);
|
||||
check(nag === false, 'a deferred pick is not nagged — the console stays shut');
|
||||
// …and the ship console's CHOOSE YOUR CLASS door re-opens it (no dead click).
|
||||
const door = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
const snap = g.shipScreenSnapshot();
|
||||
// The console's own journey rule: no class on file + level ≥ classAt → 'choose'.
|
||||
const mode = snap.playerClass ? 'class' : snap.level?.level >= 5 ? 'choose' : 'timeline';
|
||||
g.openClassWindow({ source: 'shipConsole' });
|
||||
return { mode };
|
||||
});
|
||||
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.isOpen, null, { timeout: 40000 });
|
||||
check(door.mode === 'choose', 'the ship console still shows the CHOOSE YOUR CLASS door while nothing is filed');
|
||||
check(true, 'that door re-opens the class console');
|
||||
|
||||
const real = errors.filter((e) => !/favicon|AudioContext|autoplay|WebGL|GPU lockout|swiftshader|SoundManager|Unlock/i.test(e));
|
||||
check(real.length === 0, `no console errors${real.length ? ': ' + real.slice(0, 4).join(' | ') : ''}`);
|
||||
|
||||
await browser.close();
|
||||
console.log(fails ? `\n${fails} FAILURE(S) — shots in ${OUT}` : `\nall checks passed — shots in ${OUT}`);
|
||||
process.exit(fails ? 1 : 0);
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* dev/classes-browser.test.mjs — the CLASS LAYER's trigger in a real browser,
|
||||
* with no dev param in the URL.
|
||||
*
|
||||
* node dev/server.mjs 8123 & # the static dev server
|
||||
* node dev/classes-browser.test.mjs
|
||||
*
|
||||
* What it pins (the freeze contract's other half — js/ui/ClassWindow.js's
|
||||
* screenshots live in dev/class-shot.mjs):
|
||||
* · a plain boot asks for NOTHING (level 0, no class);
|
||||
* · crossing data/shipScreen.json → level.classAt in a quiet sky opens the
|
||||
* console by itself, once, through GameScene._classBeat (the same path a
|
||||
* real run takes — no ?level5), and the world parks through _frozen;
|
||||
* · dismissing it DEFERS: no class is filed, the world runs on, and the
|
||||
* console never re-fires on its own;
|
||||
* · the ship console's CHOOSE YOUR CLASS door is the way back in (its snapshot
|
||||
* still reports mode 'choose' until something is filed);
|
||||
* · locking a class in flips that snapshot to the class plate.
|
||||
*/
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const { chromium } = await import(
|
||||
pathToFileURL('/home/brianfertig/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs').href
|
||||
);
|
||||
const BASE = process.env.ORBIT_URL || 'http://127.0.0.1:8123';
|
||||
|
||||
let fails = 0;
|
||||
const check = (cond, msg) => {
|
||||
console.log(` ${cond ? '✔' : '✘ FAIL'} ${msg}`);
|
||||
if (!cond) fails++;
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: '/home/brianfertig/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--autoplay-policy=no-user-gesture-required'],
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 860 } });
|
||||
const errors = [];
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error' && !/Failed to load resource/i.test(m.text())) errors.push(m.text());
|
||||
});
|
||||
page.on('pageerror', (e) => errors.push('[pageerror] ' + e.message));
|
||||
|
||||
// ?noclass closes BOTH dev doors (MenuScene's walk-in and GameScene's trigger),
|
||||
// so the run is entered — and the level is earned — exactly like a player's.
|
||||
await page.goto(`${BASE}/index.html?noclass`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(() => {
|
||||
const m = window.game?.scene?.getScene?.('MenuScene');
|
||||
if (m && !m.dead) m.startNewGame();
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const g = window.game?.scene?.getScene?.('GameScene');
|
||||
return !!(g && g.ship && g.classState);
|
||||
}, null, { timeout: 90000 });
|
||||
|
||||
// The intro comm owns the screen until ACKNOWLEDGED; press it (CommsHub._ack is
|
||||
// the button's own callback).
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
if (g.comms?.paused) g.comms._ack();
|
||||
return !g.comms?.paused && !g.comms?.active;
|
||||
},
|
||||
null,
|
||||
{ timeout: 120000, polling: 500 }
|
||||
);
|
||||
|
||||
const cold = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
return { cw: !!g.classWindow, prompted: g._classPrompted, level: g.combat?.xp?.level ?? 0, hasClass: g.classState.hasClass };
|
||||
});
|
||||
check(cold.cw === false && cold.prompted === false && cold.hasClass === false, `a level-${cold.level} boot asks for nothing`);
|
||||
|
||||
// Earn the level. The grant also fires the level-up marquee — the console waits
|
||||
// for that beat to clear (it never stacks two full-screen moments).
|
||||
await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
for (let i = 0; i < 8; i++) g.combat.xp.grant(500, 'test');
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
if (g.comms?.paused) g.comms._ack(); // a brief that lands mid-flight, the player presses through
|
||||
return g.classWindow?.isOpen === true;
|
||||
},
|
||||
null,
|
||||
{ timeout: 150000, polling: 500 }
|
||||
);
|
||||
check(true, 'crossing level 5 in a quiet sky opened the class console by itself');
|
||||
|
||||
const armed = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
const v = g.ship.body.velocity;
|
||||
return {
|
||||
level: g.combat.xp.level,
|
||||
prompted: g._classPrompted,
|
||||
marqueeCleared: g.levelMarquee?.active !== true,
|
||||
parked: Math.abs(v.x) < 0.01 && Math.abs(v.y) < 0.01,
|
||||
standby: g.actionBar?.enabled === false,
|
||||
combatFrozen: (g.combat?.enemies ?? []).every((e) => !e.body || (e.body.velocity.x === 0 && e.body.velocity.y === 0)),
|
||||
};
|
||||
});
|
||||
check(armed.level >= 5 && armed.prompted === true, `the prompt is spent at level ${armed.level}`);
|
||||
check(armed.marqueeCleared, 'the level-up marquee got its beat first');
|
||||
check(armed.parked && armed.standby && armed.combatFrozen, 'the world is parked while the console is up (_frozen/_parkForFreeze)');
|
||||
|
||||
// DEFER: ESC closes without filing anything, and nothing re-fires.
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.openState === 'closed', null, { timeout: 40000 });
|
||||
const deferred = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
return { hasClass: g.classState.hasClass, plate: g.classPlateSnapshot(), standby: g.actionBar?.enabled === false };
|
||||
});
|
||||
check(deferred.hasClass === false && deferred.plate === null, 'closing without picking files no class');
|
||||
check(deferred.standby === false, 'the world runs again on the deferral');
|
||||
await page.waitForTimeout(6000);
|
||||
check((await page.evaluate(() => window.game.scene.getScene('GameScene').classWindow.isOpen)) === false, 'a deferred pick is not nagged');
|
||||
|
||||
// The ship console's door is the second way in (no dead click).
|
||||
const viaDoor = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
const snap = g.shipScreenSnapshot();
|
||||
const mode = snap.playerClass ? 'class' : snap.level?.level >= 5 ? 'choose' : 'timeline';
|
||||
g.deckAction?.('ship') ?? g.shipWindow?.open?.();
|
||||
g.openClassWindow({ source: 'shipConsole' });
|
||||
return { mode };
|
||||
});
|
||||
await page.waitForFunction(() => window.game.scene.getScene('GameScene').classWindow.isOpen, null, { timeout: 60000 });
|
||||
check(viaDoor.mode === 'choose', 'the ship console still shows CHOOSE YOUR CLASS while nothing is filed');
|
||||
check(true, 'that door re-opens the class console');
|
||||
|
||||
// Lock one in and the console's snapshot becomes a class plate.
|
||||
const locked = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
const r = g.applyClassPick('vanguard');
|
||||
const snap = g.shipScreenSnapshot();
|
||||
return { r, plate: snap.playerClass };
|
||||
});
|
||||
check(locked.r.ok === true && locked.plate?.name === 'VANGUARD' && locked.plate?.insignia === 0, `the class plate replaces the door (${JSON.stringify(locked.plate)})`);
|
||||
|
||||
// ── THE VOLLEYS through the REAL firing code (the classState seam in CombatSystem).
|
||||
// Same capture-stub trick, run against the live prototype method: Phaser exists
|
||||
// in the page, so this is the half dev/class-combat.test.mjs cannot do in node.
|
||||
const volleys = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
const fire = Object.getPrototypeOf(g.combat).firePlayerAt;
|
||||
const run = (perkOwner) => {
|
||||
const shots = [];
|
||||
const stub = {
|
||||
classState: perkOwner,
|
||||
nextFireAt: 0,
|
||||
scene: g,
|
||||
fx: { muzzle() {} },
|
||||
spawnBullet(x, y, angle, w, owner) {
|
||||
shots.push({ x, y, angle, damage: Number(w.damage), speed: Number(w.bulletSpeed) || 600, owner });
|
||||
return {};
|
||||
},
|
||||
};
|
||||
const player = { x: 0, y: 0, radius: 12 };
|
||||
// a hostile crossing the nose at 240 px/s — the case a lead solve exists for
|
||||
const target = { x: 300, y: 0, body: { velocity: { x: 0, y: 240 } } };
|
||||
fire.call(stub, player, target, 1000);
|
||||
return { shots, cooldown: stub.nextFireAt - 1000 };
|
||||
};
|
||||
const mk = (id) => {
|
||||
const st = new (Object.getPrototypeOf(g.classState).constructor)();
|
||||
if (id) st.choose(id);
|
||||
return st;
|
||||
};
|
||||
return { none: run(null), plainLedger: run(mk(null)), vanguard: run(mk('vanguard')), wraith: run(mk('wraith')) };
|
||||
});
|
||||
const baseDamage = volleys.none.shots[0].damage; // read off the unclassed bolt — no second source
|
||||
const sep = Math.hypot(volleys.vanguard.shots[0].x - volleys.vanguard.shots[1].x, volleys.vanguard.shots[0].y - volleys.vanguard.shots[1].y);
|
||||
check(volleys.none.shots.length === 1 && volleys.plainLedger.shots.length === 1, `no class = one bolt (${volleys.none.shots.length}/${volleys.plainLedger.shots.length})`);
|
||||
check(volleys.vanguard.shots.length === 2, `Vanguard fires a two-barrel volley (${volleys.vanguard.shots.length})`);
|
||||
check(volleys.vanguard.shots.every((s) => s.damage === baseDamage), `both bolts carry the full ${baseDamage} damage`);
|
||||
check(sep > 8 && sep < 40, `the barrels are offset (${sep.toFixed(1)}px apart)`);
|
||||
check(volleys.vanguard.cooldown === volleys.none.cooldown, `the cooldown is untouched (${volleys.none.cooldown}ms)`);
|
||||
const leadOff = Math.abs(((volleys.wraith.shots[0].angle - Math.atan2(0, 300) + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
|
||||
check(volleys.wraith.shots.length === 1 && volleys.wraith.shots[0].angle > 0.12, `Wraith's bolt leads the crossing hostile (${volleys.wraith.shots[0].angle.toFixed(3)} rad, ${leadOff.toFixed(3)} off raw aim)`);
|
||||
check(volleys.vanguard.shots.every((s) => s.owner === 'player'), 'the bolts stay the player\u2019s (friendly fire rules unchanged)');
|
||||
|
||||
// THE RANGE keeps its base feel: a run with nothing filed (?combat&noclass —
|
||||
// hostiles up, class layer off) still fires the single cannon it always did.
|
||||
await page.goto(`${BASE}/index.html?combat&noclass`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.evaluate(() => {
|
||||
const m = window.game?.scene?.getScene?.('MenuScene');
|
||||
if (m && !m.dead) m.startNewGame();
|
||||
});
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const g = window.game?.scene?.getScene?.('GameScene');
|
||||
if (g?.comms?.paused) g.comms._ack();
|
||||
return !!(g && g.combat && (g.combat.enemies?.length ?? 0) > 0);
|
||||
},
|
||||
null,
|
||||
{ timeout: 150000, polling: 500 }
|
||||
);
|
||||
const range = await page.evaluate(() => {
|
||||
const g = window.game.scene.getScene('GameScene');
|
||||
return { hasClass: g.classState?.hasClass === true, perks: g.classState?.perks?.size ?? -1, hostiles: g.combat.enemies.length };
|
||||
});
|
||||
check(range.hasClass === false && range.perks === 0 && range.hostiles > 0, `the range runs unclassed (${range.hostiles} hostiles, 0 perks) — so its volley is the base shot asserted above`);
|
||||
|
||||
await browser.close();
|
||||
const real = errors.filter((e) => !/AudioContext|autoplay|WebGL|GPU lockout|swiftshader|SoundManager|Unlock/i.test(e));
|
||||
check(real.length === 0, `no console errors${real.length ? ': ' + real.slice(0, 3).join(' | ') : ''}`);
|
||||
console.log(fails ? `\n${fails} FAILURE(S)` : '\nall checks passed');
|
||||
process.exit(fails ? 1 : 0);
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* Class layer test (dev tool, run with Node — no browser):
|
||||
*
|
||||
* node dev/classes.test.mjs (or: node dev/run-all.mjs classes)
|
||||
*
|
||||
* Covers the class layer's PURE half — everything except the window:
|
||||
* - data/classes.json is a real config file (listed in data/manifest.json,
|
||||
* so the browser loads it) and normalizes into the roster the UI reads;
|
||||
* - js/player/ClassModel.js: the roster (Vanguard + Wraith in data order),
|
||||
* the accents/insignia frames, the perk records + their numbers, the
|
||||
* door level read from data/shipScreen.json → level.classAt (one source),
|
||||
* the template fills the confirm/toast print, and the junk handling
|
||||
* (an unknown id is null, a malformed roster entry is dropped);
|
||||
* - js/player/ClassState.js: the ledger — choose, the IDEMPOTENT initial
|
||||
* bonus (a repeat grants nothing), the different-class refusal, the
|
||||
* skill-point bank's spend/award guards;
|
||||
* - js/save/SaveData.js: record.playerClass round-trips (id + perks +
|
||||
* skill points survive capture → load) and an old save with no field
|
||||
* loads as "no class on file".
|
||||
*
|
||||
* All numbers come from the real data/*.json, not a private copy.
|
||||
*/
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
|
||||
|
||||
// --- Load the real config (data/*.json) into the config singleton --------
|
||||
const { config } = await import(file('config/Config.js'));
|
||||
const dataDir = join(__dirname, '../data');
|
||||
const readJson = (p) => JSON.parse(fs.readFileSync(join(dataDir, p), 'utf8'));
|
||||
const configData = {};
|
||||
for (const f of fs.readdirSync(dataDir)) {
|
||||
if (!f.endsWith('.json') || f === 'manifest.json') continue;
|
||||
configData[f.replace(/\.json$/i, '')] = readJson(f);
|
||||
}
|
||||
config.init(configData);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond) => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------- the shipped roster
|
||||
const model = await import(file('player/ClassModel.js'));
|
||||
const { ClassState } = await import(file('player/ClassState.js'));
|
||||
|
||||
{
|
||||
const manifest = readJson('manifest.json');
|
||||
check(
|
||||
'data/classes.json is listed in data/manifest.json (the browser loads it)',
|
||||
manifest.files.includes('classes.json'),
|
||||
);
|
||||
check('JSON parses as a config section', !!config.section('classes', null));
|
||||
}
|
||||
|
||||
{
|
||||
const list = model.classList();
|
||||
check('the roster offers two classes', list.length === 2);
|
||||
check('in data order: vanguard, wraith', list[0]?.id === 'vanguard' && list[1]?.id === 'wraith');
|
||||
|
||||
const vg = model.classDef('vanguard');
|
||||
const wr = model.classDef('wraith');
|
||||
check('Vanguard label + frame 0 (classes.png)', vg.label === 'VANGUARD' && vg.frame === 0);
|
||||
check('Wraith label + frame 1 (classes.png)', wr.label === 'WRAITH' && wr.frame === 1);
|
||||
// The theme: Vanguard reads RED, Wraith reads PURPLE (the brief).
|
||||
const rgb = (n) => [ (n >> 16) & 255, (n >> 8) & 255, n & 255 ];
|
||||
const [vr, vg2, vb] = rgb(vg.accent);
|
||||
const [ur, ug, ub] = rgb(wr.accent);
|
||||
check('Vanguard accent is a red (R leads)', vr > 160 && vr > vg2 + 40 && vr > vb + 40);
|
||||
check('Wraith accent is a purple (R + B lead, G lowest)', ur > 90 && ub > 160 && ug < Math.min(ur, ub));
|
||||
check('both accents carry a css twin for Phaser text', /^#[0-9a-fA-F]{6}$/.test(vg.accentCss) && /^#[0-9a-fA-F]{6}$/.test(wr.accentCss));
|
||||
|
||||
check('copy is authored in data, not code (summary + overview)', vg.summary.length > 10 && vg.overview.length >= 2);
|
||||
check('every class banks 5 skill points', vg.skillPoints === 5 && wr.skillPoints === 5);
|
||||
check('Vanguard grants the dual cannon', vg.perks.map((p) => p.id).join() === 'dualCannon');
|
||||
check('Wraith grants the targeting computer', wr.perks.map((p) => p.id).join() === 'targetingComputer');
|
||||
check('a perk record carries its label + note', !!vg.perks[0].label && vg.perks[0].note.length > 10);
|
||||
|
||||
// The perk's MECHANICS numbers live in the data (the combat code reads them).
|
||||
const dual = model.perkDef('dualCannon');
|
||||
const tc = model.perkDef('targetingComputer');
|
||||
check('dualCannon: two barrels, offset off the centreline', dual.barrels === 2 && dual.barrelOffset > 0);
|
||||
check('targetingComputer: a lead cap is configured', Number(tc.maxLead) > 0);
|
||||
check('perkDef(unknown) is null', model.perkDef('not-a-perk') === null);
|
||||
|
||||
// The door level has ONE source (data/shipScreen.json → level.classAt).
|
||||
const shipLevel = config.section('shipScreen.level', {});
|
||||
check('classAt reads shipScreen.level.classAt', model.classAt() === shipLevel.classAt && model.classAt() === 5);
|
||||
|
||||
// The insignia sheet: assets/images/classes.png, 128×128 frames.
|
||||
const sheet = model.iconSheet();
|
||||
check('the insignia sheet is classes.png at 128×128', /classes\.png$/.test(sheet.texture) && sheet.frameWidth === 128 && sheet.frameHeight === 128);
|
||||
check('iconFrame mirrors the roster', model.iconFrame('wraith') === 1 && model.iconFrame('nope') === null);
|
||||
|
||||
// Case-insensitive lookup (the save's id / a URL param both land here).
|
||||
check('lookup is case- and space-tolerant', model.classDef(' Vanguard ')?.id === 'vanguard');
|
||||
check('unknown class → null', model.classDef('pirate') === null && model.classDef(null) === null);
|
||||
|
||||
// The confirm/toast templates resolve with no leftover placeholders.
|
||||
const conf = model.confirmSpec('vanguard');
|
||||
check('confirm title names the class', conf.title.includes('VANGUARD'));
|
||||
check('confirm line1 lists the perk + skill points', conf.line1.includes('DUAL CANNON') && conf.line1.includes('+5'));
|
||||
check('confirm warns about the later price', /later/i.test(conf.line2) && /price/i.test(conf.line2));
|
||||
check('no unresolved placeholder in the confirm copy', !/[{]\w+[}]/.test(`${conf.title}${conf.line1}${conf.line2}`));
|
||||
const msg = model.successMessage('wraith');
|
||||
check('the lock-in toast reads the class + its bonus', msg.includes('WRAITH') && msg.includes('TARGETING COMPUTER') && !/[{]\w+[}]/.test(msg));
|
||||
|
||||
// The briefing (Trestle's copy + his clip) is data too.
|
||||
const intro = model.introSpec();
|
||||
check('the brief names the speaker', intro.speaker === 'TRESTLE');
|
||||
check('the brief text is Trestle’s level-5 speech', /level 5 is no joke/i.test(intro.text) && /change your class later for a price/i.test(intro.text));
|
||||
check('the brief points at assets/speech/trestle/classes.mp3', intro.audio === 'assets/speech/trestle/classes.mp3');
|
||||
check(`the brief’s clip exists (${intro.audio})`, fs.existsSync(join(__dirname, '..', intro.audio)));
|
||||
const win = model.windowSpec();
|
||||
check('the feed is classes.mp4', /classes\.mp4$/.test(win.video.file));
|
||||
const clip = join(__dirname, '..', 'assets/videos', win.video.file.split('/').pop());
|
||||
check(`the feed clip exists (${win.video.file})`, fs.existsSync(clip));
|
||||
}
|
||||
|
||||
// ------------------------------------------------- malformed data degrades
|
||||
{
|
||||
const pristine = configData.classes;
|
||||
config.init({
|
||||
...configData,
|
||||
classes: {
|
||||
enabled: true,
|
||||
classes: [
|
||||
{ label: 'NO ID' }, // no id → dropped (never a dead tab)
|
||||
{ id: ' ', label: 'BLANK' }, // blank id → dropped
|
||||
{ id: 'solo', label: 'Solo', bonuses: { skillPoints: 'nope', perks: ['ghost', 'dualCannon'] } },
|
||||
],
|
||||
perks: pristine.perks,
|
||||
},
|
||||
});
|
||||
const list = model.classList();
|
||||
check('a roster entry with no usable id is dropped', list.length === 1 && list[0].id === 'solo');
|
||||
check('a junk skillPoints reads 0 (never NaN)', list[0].skillPoints === 0);
|
||||
check('an unknown perk drops, a known one stays', list[0].perks.map((p) => p.id).join() === 'dualCannon');
|
||||
|
||||
config.init({ ...configData, classes: { ...pristine, enabled: false } });
|
||||
check('classes.enabled:false empties the roster (the layer is off)', model.classList().length === 0);
|
||||
config.init(configData); // restore the shipped data for the rest of the run
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- the run ledger
|
||||
{
|
||||
const s = new ClassState();
|
||||
check('fresh ledger: no class, no perks, empty bank', !s.hasClass && s.perks.size === 0 && s.skillPoints === 0);
|
||||
check('fresh ledger: no perk is owned', s.hasPerk('dualCannon') === false);
|
||||
check('label/frame read empty without a class', s.label === '' && s.frame === null);
|
||||
|
||||
const r1 = s.choose('vanguard');
|
||||
check('choose(vanguard) succeeds', r1.ok === true && r1.id === 'vanguard');
|
||||
check('the dual cannon is owned', s.hasPerk('dualCannon') === true);
|
||||
check('5 skill points are banked', s.skillPoints === 5);
|
||||
check('the ledger reads its label + insignia frame', s.label === 'VANGUARD' && s.frame === 0);
|
||||
check('stats() names the class for the consoles', s.stats().name === 'VANGUARD');
|
||||
|
||||
// THE GRANT IS IDEMPOTENT — a repeat pays nothing.
|
||||
const r2 = s.choose('vanguard');
|
||||
check('re-choosing the same class is ok, but grants nothing', r2.ok === true && r2.alreadyGranted === true);
|
||||
check('the bank did not double', s.skillPoints === 5);
|
||||
check('the perk set did not grow', s.perks.size === 1);
|
||||
const r3 = s.choose('vanguard');
|
||||
check('idempotent a third time too', s.skillPoints === 5 && r3.alreadyGranted === true);
|
||||
|
||||
// A DIFFERENT class is refused (changing class is a later layer with a price).
|
||||
const r4 = s.choose('wraith');
|
||||
check('a different class is refused with reason "taken"', r4.ok === false && r4.reason === 'taken' && s.id === 'vanguard');
|
||||
const r5 = s.choose('bogus');
|
||||
check('an unknown class is refused with reason "unknown"', r5.ok === false && r5.reason === 'unknown');
|
||||
check('refusals moved nothing', s.skillPoints === 5 && s.hasPerk('targetingComputer') === false);
|
||||
|
||||
// Case/spaces still resolve (a URL param, a hand-written save).
|
||||
const s2 = new ClassState();
|
||||
check('choose is case- and space-tolerant', s2.choose(' WRAITH ').ok === true && s2.hasPerk('targetingComputer'));
|
||||
check('the wraith banked its own 5 points', s2.skillPoints === 5);
|
||||
|
||||
// The bank's guards.
|
||||
check('spend within the bank pays', s.spendSkillPoints(2) === true && s.skillPoints === 3);
|
||||
check('spend beyond the bank is refused', s.spendSkillPoints(99) === false && s.skillPoints === 3);
|
||||
check('award() tops the bank up', s.awardSkillPoints(4) === 7 && s.skillPoints === 7);
|
||||
check('a junk award is a no-op', s.awardSkillPoints('nope') === 7 && s.spendSkillPoints(-3) === true && s.skillPoints === 7);
|
||||
const s3 = new ClassState();
|
||||
check('with no class on file the bank cannot be spent', s3.spendSkillPoints(1) === false);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- the save seam
|
||||
{
|
||||
const { SaveManager, SAVE_FORMAT } = await import(file('save/SaveManager.js'));
|
||||
const { captureState, prepareLoad, consumeRestore, resetRunState } = await import(file('save/SaveData.js'));
|
||||
const { Galaxy } = await import(file('galaxy/Galaxy.js'));
|
||||
|
||||
const SEED = 'CLASSTEST';
|
||||
const galaxy = Galaxy.create(SEED, { systemCount: 8 });
|
||||
const system = galaxy.byId.get(galaxy.currentSystemId);
|
||||
const mkRegistry = () => {
|
||||
const map = new Map();
|
||||
return { map, set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
|
||||
};
|
||||
|
||||
const classState = ClassState.fromJSON({ id: 'wraith', perks: ['targetingComputer'], skillPoints: 5, granted: true });
|
||||
const scene = {
|
||||
registry: mkRegistry(),
|
||||
galaxy,
|
||||
systemRecord: { name: system.name },
|
||||
ship: { x: 10, y: 20, rotation: 0, minerals: 0, credits: 0, stats: {} },
|
||||
discovery: null,
|
||||
classState,
|
||||
playTimeMs: 1000,
|
||||
};
|
||||
|
||||
const rec = captureState(scene);
|
||||
check('capture writes record.playerClass', !!rec.playerClass && rec.playerClass.id === 'wraith');
|
||||
check('the record is plain JSON (perks as an array)', Array.isArray(rec.playerClass.perks) && JSON.stringify(rec.playerClass).includes('targetingComputer'));
|
||||
check('validateRecord accepts the class field', SaveManager.validateRecord(rec) === null);
|
||||
|
||||
const reg2 = mkRegistry();
|
||||
prepareLoad(reg2, rec);
|
||||
const loaded = reg2.get('playerClass');
|
||||
check('load restores the class id', loaded instanceof ClassState && loaded.id === 'wraith');
|
||||
check('load restores the owned perks', loaded.hasPerk('targetingComputer') === true && loaded.hasPerk('dualCannon') === false);
|
||||
check('load restores the skill-point bank', loaded.skillPoints === 5);
|
||||
check('load keeps the grant witness (no double bonus after a load)', loaded.granted === true && loaded.choose('wraith').alreadyGranted === true && loaded.skillPoints === 5);
|
||||
consumeRestore(reg2);
|
||||
|
||||
// An OLD save (predating the class layer) still loads — no class on file.
|
||||
const legacy = { ...rec };
|
||||
delete legacy.playerClass;
|
||||
const reg3 = mkRegistry();
|
||||
prepareLoad(reg3, legacy);
|
||||
check('a save with no playerClass field stages nothing (old saves load)', reg3.get('playerClass') === null);
|
||||
|
||||
// A hand-edited record degrades instead of handing out unknown perks.
|
||||
const bogus = { ...rec, playerClass: { id: 'pirate-lord', perks: ['dualCannon', 'godmode'], skillPoints: 9999, granted: true } };
|
||||
const reg4 = mkRegistry();
|
||||
prepareLoad(reg4, bogus);
|
||||
check('an unknown class id drops the whole class', reg4.get('playerClass').hasClass === false);
|
||||
|
||||
const mixed = { ...rec, playerClass: { id: 'wraith', perks: ['dualCannon'], skillPoints: -7, granted: true } };
|
||||
const reg5 = mkRegistry();
|
||||
prepareLoad(reg5, mixed);
|
||||
const strict = reg5.get('playerClass');
|
||||
check('a perk the class does not own is dropped on load', strict.hasClass && strict.hasPerk('dualCannon') === false);
|
||||
check('a negative bank clamps to 0', strict.skillPoints === 0);
|
||||
|
||||
// New Game clears it.
|
||||
resetRunState(reg2);
|
||||
check('resetRunState clears the class ledger', reg2.get('playerClass') === null);
|
||||
}
|
||||
|
||||
console.log(failures ? `\n${failures} FAILURE(S)` : '\nclasses: all checks passed');
|
||||
process.exit(failures ? 1 : 0);
|
||||
|
|
@ -1535,6 +1535,89 @@ contract is COMPLETE, every leg is checked, the reward claims once.
|
|||
+ `dev/bounty-hunt-shot-run.mjs` (locked row → accept → hunt legs → mark
|
||||
kill → claim; JSON + PNGs land in `dev/shots/`).
|
||||
|
||||
## The class layer — who you are, chosen at level 5
|
||||
|
||||
**At combat level 5 the game stops and asks a question**: Trestle opens the
|
||||
class console, the world holds its breath, and the player picks the build the
|
||||
run will be built around (Vanguard or Wraith). Nothing about it is hardcoded:
|
||||
the roster, the copy, the colours, the icon frames, the confirm wording and
|
||||
each perk's mechanics numbers are data; the code only knows how to ask.
|
||||
|
||||
- **The data** — `data/classes.json` (in the manifest) is the whole feature in
|
||||
one file: `_enabled`, the two classes (`id`, `name`, `accent`, `insignia`
|
||||
frame, `tagline`, `doctrine[]`, `summary`, `bonus{label,note,perks[]}`,
|
||||
`skillPoints`), the shared `iconSheet` (texture path + 128×128 frame size),
|
||||
the `window` chrome copy (`title`, `prompt`, `tabHint`, `pickLabel`,
|
||||
`pickedLabel`, `deferredToast`, …), the Trestle `intro` (`video`, `speech`,
|
||||
`text` — the speech text lives here, never in a component), the
|
||||
`confirmPick` template, and `toastPicked`. The door's LEVEL is not restated
|
||||
here: it reads `data/shipScreen.json → level.classAt` (5), so one number owns
|
||||
the rule. `data/classes.json → perks` carries the mechanics (`dualCannon.barrels`,
|
||||
`dualCannon.barrelOffset`, `targetingComputer.maxLead`) beside their labels.
|
||||
- **The code** — three small pieces, each with one job:
|
||||
- `js/player/ClassModel.js` (pure reads): the roster, a class/perk record, the
|
||||
icon sheet, the window/intro/confirm specs, `classAt()`. Malformed entries
|
||||
drop rather than render a dead tab; unknown ids read as null.
|
||||
- `js/player/ClassState.js` (the run ledger): `{ id, perks:Set, skillPoints,
|
||||
granted }`. `choose()` is idempotent — the same class again grants nothing
|
||||
twice (`alreadyGranted`), a DIFFERENT class is refused (`taken`: reclassing
|
||||
is a later layer, priced), and `awardSkillPoints`/`spendSkillPoints` keep
|
||||
the bank honest. `toJSON`/`restore` validate against the CURRENT roster, so
|
||||
a hand-edited save can never hand out a perk from a class that no longer
|
||||
exists (an unknown saved id degrades to "no class on file").
|
||||
- `js/ui/ClassWindow.js` (the view only): it renders whatever the model says
|
||||
and calls its seams (`onPick(id)`, `onClose`, `onToast`). It never mutates
|
||||
the ledger — a pick is only real when `GameScene.applyClassPick()` accepts
|
||||
it, so the window cannot lie about state.
|
||||
- **The freeze contract** — the console is NOT a Phaser scene; it is a UI layer
|
||||
over `GameScene`, and it joins the scene's existing `_frozen` computation
|
||||
(`classWindow.isOpen`). Frozen means: input parked, velocities zeroed and damped,
|
||||
combat/AI/missions/bodies skipped through `_parkForFreeze()` — the same contract
|
||||
the ship console and every other full-screen window already honour. Closing it
|
||||
resumes everything (the world is never stepped while a class decision is open);
|
||||
audio ducked for Trestle's voice (`sfx`/music beds pulled down while
|
||||
`class_brief` plays, restored exactly once) comes back up whether the player
|
||||
picks or walks out. The ship console's own plate reads through
|
||||
`GameScene.classPlateSnapshot()` → `js/ship/ShipScreenModel.journeyState()`, so
|
||||
a filed class replaces the `CHOOSE YOUR CLASS` door with crest + accent + banked
|
||||
skill points (the Skill Tree tab stays OFFLINE — points are banked, not spent).
|
||||
- **The trigger rule** (`GameScene._classBeat()`) — ask ONCE when all of these
|
||||
hold: class layer enabled, nothing on file (`classState.hasClass === false`),
|
||||
the prompt unspent (`_classPrompted`), combat is QUIET (player up, no live
|
||||
hostiles, no wave in flight) and the screen is free (no comm playing, no
|
||||
marquee, no jump, no other major window). Media loads on demand — image,
|
||||
video, speech in one pass with a stall valve, so a slow file delays the
|
||||
console but never breaks it. Locking a class in marks the prompt spent, so the
|
||||
door never re-arms; closing WITHOUT picking DEFERS, and `CHOOSE YOUR CLASS` in
|
||||
the ship console is the way back in (the auto-prompt stays spent — no nagging).
|
||||
- **The perk flags** (`CombatSystem.classState`, null/empty = base feel) — the
|
||||
class changes only the player's volley. `'dualCannon'` (Vanguard): a volley
|
||||
leaves `perks.dualCannon.barrels` muzzles symmetric about the firing line
|
||||
(`barrelOffset` px off the centreline), EACH carrying the weapon's full damage
|
||||
— double the metal, NOT the rate of fire (the cooldown stays
|
||||
`combat.player.weapon.cooldown`). `'targetingComputer'` (Wraith): the shot is
|
||||
fired at the INTERCEPT js/combat/Aiming.js solves (where the hostile will be
|
||||
when the bolt lands), capped by `perks.targetingComputer.maxLead` seconds —
|
||||
past the cap, or when the target simply outruns the bolt, it falls back to raw
|
||||
aim so the nose never snaps into nonsense. Both helpers are pure (no Phaser,
|
||||
no config, no RNG). The standalone training range (`/combat`,
|
||||
`js/combat/CombatScene.js`) has its own firing path and never reads the ledger:
|
||||
the range keeps its base single cannon.
|
||||
- **Testing** — `index.html?level5` (aliases `?cls`, `?class`; optional
|
||||
`?tab=wraith`; `?noclass` closes both dev doors) starts a fresh run and walks
|
||||
straight into the console, stepping over the boot comm so it is drivable with
|
||||
no hand on the menu. Normal runs never see that shortcut: they wait for the
|
||||
real level-5 crossing.
|
||||
- **Evidence**: `dev/classes.test.mjs` (roster/frames/perk numbers/templates,
|
||||
malformed-data degradation, idempotent grants, the save round-trip),
|
||||
`dev/class-combat.test.mjs` (the intercept maths + barrel geometry),
|
||||
`dev/classes-browser.test.mjs` (no dev param: level-5 crossing opens it once,
|
||||
the world parks, ESC defers without nagging, the ship console's door re-opens
|
||||
it, the plate replaces the door, and the volleys through the real firing code:
|
||||
1 bolt unclassed / 2 full-damage offset bolts Vanguard / a leading bolt for
|
||||
Wraith), and the screenshot pass `dev/class-shot.mjs` (intro + video,
|
||||
both tabs, the confirm modal, the console plate → `/tmp/orbit-class/`).
|
||||
|
||||
## Roadmap (working list, intentionally rough)
|
||||
|
||||
- [x] v0.1 foundation — menu → New Game → click-to-fly ship
|
||||
|
|
@ -1740,4 +1823,7 @@ contract is COMPLETE, every leg is checked, the reward claims once.
|
|||
a save = seed + player state, since the galaxy regenerates) —
|
||||
10-slot bank in localStorage, in-game SAVE/LOAD vault, and the menu
|
||||
**Continue** button (newest save)
|
||||
- [x] The class layer — level 5 asks who you are (Vanguard / Wraith) in a
|
||||
full-screen console over the frozen world; the bonus is a perk flag the
|
||||
combat system reads (dual cannon / targeting computer)
|
||||
- [ ] Economy/trading loop (the Privateer heart)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* js/combat/Aiming.js — the gunner's arithmetic, kept OUT of the combat system
|
||||
* so it can be tested like a spreadsheet cell.
|
||||
*
|
||||
* Two things live here, both pure (no Phaser, no config reads, no RNG) and both
|
||||
* owned by the CLASS LAYER's initial bonuses (data/classes.json → perks):
|
||||
*
|
||||
* intercept() — the Wraith's ADVANCED TARGETING COMPUTER: the angle to fire
|
||||
* so a bolt launched at `speed` meets a MOVING hostile where it
|
||||
* WILL be, not where it is. Classic lead solve: with the target
|
||||
* at p, moving at v (constant over the bolt's short flight) and
|
||||
* the bolt at s, find t > 0 with |p + v·t| = s·t from the muzzle.
|
||||
* That is a quadratic in t; the smallest positive root is the
|
||||
* first crossing. When there is none (the target outruns the
|
||||
* bolt) or the intercept sits beyond the computer's lead cap,
|
||||
* it FALLS BACK to raw aim — the nose never snaps into nonsense.
|
||||
*
|
||||
* barrels() — the Vanguard's DUAL CANNON: where a volley's bolts leave the
|
||||
* hull, symmetric about the centreline and squared away from it
|
||||
* so twin muzzles never spawn inside each other.
|
||||
*
|
||||
* The combat system decides WHETHER either applies (the class ledger's perk
|
||||
* flags); this file only answers the maths question it asks.
|
||||
*/
|
||||
|
||||
/** A raw aim angle (muzzle → target). The fallback for every degenerate case. */
|
||||
export function rawAngle(sx, sy, tx, ty) {
|
||||
return Math.atan2(ty - sy, tx - sx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Solve the intercept.
|
||||
*
|
||||
* @param {number} sx muzzle x
|
||||
* @param {number} sy muzzle y
|
||||
* @param {number} tx target x (now)
|
||||
* @param {number} ty target y (now)
|
||||
* @param {number} tvx target vx (px/s)
|
||||
* @param {number} tvy target vy (px/s)
|
||||
* @param {number} speed bolt speed (px/s)
|
||||
* @param {number} [maxLead] the longest flight time the solve may commit to
|
||||
* (seconds; data/classes.json → perks.targetingComputer.maxLead). 0/absent =
|
||||
* unlimited.
|
||||
* @returns {{angle: number, t: number, x: number, y: number, solved: boolean, lead: number}}
|
||||
* angle the heading to fire on (radians) — the intercept when solved, else raw
|
||||
* t the flight time the answer assumed (seconds; 0 for a stationary target)
|
||||
* x,y the predicted meeting point (what a reticle should draw)
|
||||
* solved true when a real intercept was found (false = raw aim, out of range
|
||||
* or beyond maxLead — the honest "no solution" answer)
|
||||
* lead the angular correction applied (radians, signed) — 0 when the nose
|
||||
* did not move; useful for the HUD and for tests
|
||||
*/
|
||||
export function intercept(sx, sy, tx, ty, tvx = 0, tvy = 0, speed = 0, maxLead = 0) {
|
||||
const raw = rawAngle(sx, sy, tx, ty);
|
||||
const s = Number(speed);
|
||||
if (!(s > 0)) return { angle: raw, t: 0, x: tx, y: ty, solved: false, lead: 0 };
|
||||
|
||||
const dx = tx - sx;
|
||||
const dy = ty - sy;
|
||||
const vx = Number(tvx) || 0;
|
||||
const vy = Number(tvy) || 0;
|
||||
// |p + v·t|² = (s·t)² → (v·v − s²)t² + 2(p·v)t + p·p = 0
|
||||
const a = vx * vx + vy * vy - s * s;
|
||||
const b = 2 * (dx * vx + dy * vy);
|
||||
const c = dx * dx + dy * dy;
|
||||
|
||||
let t = NaN;
|
||||
if (Math.abs(a) < 1e-9) {
|
||||
// the bolt and the target close at a constant rate (parallel-ish): linear case
|
||||
t = b !== 0 ? -c / b : NaN;
|
||||
} else {
|
||||
const disc = b * b - 4 * a * c;
|
||||
if (disc >= 0) {
|
||||
const r = Math.sqrt(disc);
|
||||
const t1 = (-b - r) / (2 * a);
|
||||
const t2 = (-b + r) / (2 * a);
|
||||
// the FIRST crossing forward in time (a negative root is a shot that would
|
||||
// have had to be fired in the past)
|
||||
const pos = [t1, t2].filter((v) => Number.isFinite(v) && v > 0);
|
||||
if (pos.length) t = Math.min(...pos);
|
||||
}
|
||||
}
|
||||
|
||||
const cap = Number(maxLead) > 0 ? Number(maxLead) : Infinity;
|
||||
if (!Number.isFinite(t) || t <= 0 || t > cap) {
|
||||
// No intercept the computer may commit to: aim at the ship as it is now.
|
||||
return { angle: raw, t: 0, x: tx, y: ty, solved: false, lead: 0 };
|
||||
}
|
||||
|
||||
const px = tx + vx * t;
|
||||
const py = ty + vy * t;
|
||||
const angle = Math.atan2(py - sy, px - sx);
|
||||
return { angle, t, x: px, y: py, solved: true, lead: angleDiff(angle, raw) };
|
||||
}
|
||||
|
||||
/** Signed shortest angular difference a − b (radians, −π…π). */
|
||||
export function angleDiff(a, b) {
|
||||
let d = (a - b) % (Math.PI * 2);
|
||||
if (d > Math.PI) d -= Math.PI * 2;
|
||||
if (d < -Math.PI) d += Math.PI * 2;
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a volley's bolts leave the hull: `count` muzzles symmetric about the
|
||||
* firing line, each `offset` px off the centreline in turn (n = 2 → ±offset), and
|
||||
* `nose` px forward along it. Angles are the FIRING heading, so the caller just
|
||||
* adds the jitter/spread it wants afterwards.
|
||||
*
|
||||
* @param {number} angle firing heading (radians)
|
||||
* @param {number} count barrels (data/classes.json → perks.dualCannon.barrels)
|
||||
* @param {number} offset px each barrel sits off the centreline (barrelOffset)
|
||||
* @param {number} nose how far ahead of the hull the bolt spawns (px)
|
||||
* @returns {Array<{x: number, y: number, angle: number}>} muzzle points, left to right
|
||||
*/
|
||||
export function barrels(angle, count = 1, offset = 0, nose = 0) {
|
||||
const n = Math.max(1, Math.round(Number(count) || 1));
|
||||
const off = Number(offset) || 0;
|
||||
const fx = Math.cos(angle);
|
||||
const fy = Math.sin(angle);
|
||||
// the unit vector 90° to the firing line (screen y-down: "left" of the muzzle)
|
||||
const lx = -fy;
|
||||
const ly = fx;
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const lateral = (i - (n - 1) / 2) * off * 2; // n=2 → −offset … +offset
|
||||
out.push({ x: fx * nose + lx * lateral, y: fy * nose + ly * lateral, angle });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import { Bullet } from './Bullet.js';
|
|||
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
|
||||
import { applyDamage, inView, pickTarget } from './Combat.js';
|
||||
import { firePattern, stepMissiles, missilesHitPlayer } from './Weapons.js';
|
||||
import { barrels as barrelLine, intercept } from './Aiming.js';
|
||||
import { perkDef } from '../player/ClassModel.js';
|
||||
import { XpManager } from './XpManager.js';
|
||||
import { combatSfx, combatSfxEnemyHit } from './CombatSfx.js';
|
||||
import { combatShake } from './CamShake.js';
|
||||
|
|
@ -175,6 +177,13 @@ export class CombatSystem {
|
|||
|
||||
this.ramUntil = 0;
|
||||
this.nextFireAt = 0; // ms — the player's auto-fire cooldown stamp
|
||||
// THE CLASS SEAM (js/player/ClassState.js, set by GameScene on lock-in and
|
||||
// on load). Null here means NO class: the training range (combat.html) never
|
||||
// sets it, so the range keeps its base single-cannon, raw-aim feel, and an
|
||||
// unclassed main-game run fires exactly the old shot. The two perks this
|
||||
// ledger can carry change the player's volley only (data/classes.json →
|
||||
// perks): 'dualCannon' adds barrels, 'targetingComputer' leads the target.
|
||||
this.classState = null;
|
||||
|
||||
this.fx = new Fx(scene);
|
||||
// The whiteness-purge roster: the purge (Fx.purgeCraftTints, every
|
||||
|
|
@ -892,16 +901,50 @@ export class CombatSystem {
|
|||
*/
|
||||
firePlayerAt(player, target, time) {
|
||||
const wpn = config.section('combat.player.weapon', {});
|
||||
// The cooldown is the WEAPON's beat and the class does not touch it: the
|
||||
// Vanguard's extra barrel doubles the metal per volley, not the rate of fire.
|
||||
this.nextFireAt = time + (Number(wpn.cooldown) || 0.16) * 1000;
|
||||
const a =
|
||||
Math.atan2(target.y - player.y, target.x - player.x) +
|
||||
(Math.random() * 2 - 1) * (Math.max(0, Number(wpn.spread) || 0));
|
||||
const nose = (Number(player.radius) || 12) + 8;
|
||||
const x = player.x + Math.cos(a) * nose;
|
||||
const y = player.y + Math.sin(a) * nose;
|
||||
this.spawnBullet(x, y, a, wpn, 'player');
|
||||
this.fx.muzzle(x, y, a, wpn.color ?? '#00e5ff', Number(wpn.muzzle) || 46);
|
||||
combatSfx(this.scene, 'cannon_fire'); // the mass-cannon voice (the enemy lances keep the blaster ping)
|
||||
const speed = Number(wpn.bulletSpeed) || 600;
|
||||
|
||||
// THE SHOT'S LINE — the Wraith's ADVANCED TARGETING COMPUTER leads a moving
|
||||
// hostile (js/combat/Aiming.js solves where it WILL be when the bolt lands,
|
||||
// capped by perks.targetingComputer.maxLead so an unreachable intercept falls
|
||||
// back to raw aim). No class / no computer → the nose stays on the ship.
|
||||
const computer = this.classState?.hasPerk?.('targetingComputer') ? perkDef('targetingComputer') : null;
|
||||
let line;
|
||||
if (computer) {
|
||||
const hit = intercept(
|
||||
player.x,
|
||||
player.y,
|
||||
target.x,
|
||||
target.y,
|
||||
target.body?.velocity?.x ?? 0,
|
||||
target.body?.velocity?.y ?? 0,
|
||||
speed,
|
||||
Number(computer.maxLead) || 0
|
||||
);
|
||||
line = hit.angle;
|
||||
} else {
|
||||
line = Math.atan2(target.y - player.y, target.x - player.x);
|
||||
}
|
||||
|
||||
// THE BARRELS — the Vanguard's DUAL CANNON fires its twin cannons as one:
|
||||
// two muzzles symmetric about the firing line, EACH carrying the weapon's
|
||||
// full damage (that is the class's point: roughly double the shot, same beat).
|
||||
// The spread jitter lands AFTER the lead solve, per barrel, so the computer's
|
||||
// answer is not smeared away.
|
||||
const dual = this.classState?.hasPerk?.('dualCannon') ? perkDef('dualCannon') : null;
|
||||
const spread = Math.max(0, Number(wpn.spread) || 0);
|
||||
const muzzles = barrelLine(line, dual ? Number(dual.barrels) || 2 : 1, dual ? Number(dual.barrelOffset) || 9 : 0, nose);
|
||||
for (const m of muzzles) {
|
||||
const a = m.angle + (Math.random() * 2 - 1) * spread;
|
||||
const x = player.x + m.x;
|
||||
const y = player.y + m.y;
|
||||
this.spawnBullet(x, y, a, wpn, 'player');
|
||||
this.fx.muzzle(x, y, a, wpn.color ?? '#00e5ff', Number(wpn.muzzle) || 46);
|
||||
}
|
||||
combatSfx(this.scene, 'cannon_fire'); // one mass-cannon voice per VOLLEY (the enemy lances keep the blaster ping)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
/**
|
||||
* CLASS MODEL — the pure reads over the class roster (data/classes.json).
|
||||
*
|
||||
* No Phaser, no scene: the same contract as js/ship/ShipScreenModel.js and
|
||||
* js/combat/Weapons.js, so dev/classes.test.mjs exercises exactly this in
|
||||
* bare Node and every screen (the select console, the ship console's class
|
||||
* plate, the combat system's firing code) reads ONE definition of a class.
|
||||
*
|
||||
* The split: data/classes.json owns the labels, the accents, the insignia
|
||||
* frame indices, the overview copy and the perk numbers; this module owns
|
||||
* the shape — normalizing whatever the data says into records the UI and
|
||||
* the combat code can rely on (a malformed entry is dropped, never
|
||||
* propagated; every read has a fallback).
|
||||
*
|
||||
* THE DOOR LEVEL is NOT restated here: `classAt` lives in
|
||||
* data/shipScreen.json → level.classAt (the ship console's journey plate
|
||||
* and the class trigger read the same number — one source).
|
||||
*
|
||||
* A CLASS RECORD (classDef / classList):
|
||||
* { id, label, accent, frame, role, tagline, summary, overview: string[],
|
||||
* skillPoints: int, perks: [perkRecord] }
|
||||
* A PERK RECORD (perkDef):
|
||||
* { id, label, note, ...the perk's own numbers }
|
||||
*/
|
||||
import { config } from '../config/Config.js';
|
||||
|
||||
/** The class-insignia texture key (one sheet, cached game-wide). */
|
||||
export const CLASS_ICON_KEY = 'class_icons';
|
||||
|
||||
const str = (v, fb = '') => (typeof v === 'string' && v.trim() ? v : fb);
|
||||
|
||||
/** A CSS/int color → the int Graphics + Phaser text both want. */
|
||||
function toInt(v, fb) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v >>> 0;
|
||||
const s = String(v ?? '').trim().replace('#', '');
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(s)) return fb;
|
||||
return parseInt(s, 16) >>> 0;
|
||||
}
|
||||
|
||||
/** Is the class layer on at all (data/classes.json → enabled)? */
|
||||
export function classesEnabled() {
|
||||
return config.get('classes.enabled', true) !== false;
|
||||
}
|
||||
|
||||
/** The combat level a class is earned at — data/shipScreen.json → level.classAt. */
|
||||
export function classAt() {
|
||||
const n = Number(config.get('shipScreen.level.classAt', 5));
|
||||
return Number.isFinite(n) ? Math.max(1, Math.round(n)) : 5;
|
||||
}
|
||||
|
||||
/** The insignia sheet's config (texture + frame size). */
|
||||
export function iconSheet() {
|
||||
const cfg = config.section('classes.icons', {});
|
||||
return {
|
||||
texture: str(cfg.texture, 'assets/images/classes.png'),
|
||||
frameWidth: Math.max(1, Math.round(Number(cfg.frameWidth) || 128)),
|
||||
frameHeight: Math.max(1, Math.round(Number(cfg.frameHeight) || 128)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One perk record (data/classes.json → perks[id]). The definition carries
|
||||
* its own mechanics numbers (dualCannon.barrels / barrelOffset,
|
||||
* targetingComputer.maxLead) so combat reads them here, never hardcoded.
|
||||
* @param {string} id the perk id a class lists in bonuses.perks
|
||||
* @returns {object|null} null for an unknown id
|
||||
*/
|
||||
export function perkDef(id) {
|
||||
const key = str(id);
|
||||
if (!key) return null;
|
||||
const table = config.section('classes.perks', {});
|
||||
const def = table[key];
|
||||
if (!def || typeof def !== 'object') return null;
|
||||
return { id: key, label: str(def.label, key).toUpperCase(), note: str(def.note), ...def };
|
||||
}
|
||||
|
||||
/** The perk records a class's initial bonus grants (unknown ids drop). */
|
||||
export function perksFor(id) {
|
||||
const def = classDef(id);
|
||||
if (!def) return [];
|
||||
return def.perks;
|
||||
}
|
||||
|
||||
/**
|
||||
* One class record (data/classes.json → classes[id]).
|
||||
* @param {string} id a class id ('vanguard', 'wraith')
|
||||
* @returns {object|null} null when the roster has no such class
|
||||
*/
|
||||
export function classDef(id) {
|
||||
const key = String(id ?? '').trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
return classList().find((c) => c.id === key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* EVERY class offered, in data order (the tab order). An entry without a
|
||||
* usable id is skipped — a half-authored class never shows as a dead tab.
|
||||
* @returns {Array<object>} the normalized roster ([] when the layer is off)
|
||||
*/
|
||||
export function classList() {
|
||||
if (!classesEnabled()) return [];
|
||||
const rows = config.get('classes.classes', []);
|
||||
if (!Array.isArray(rows)) return [];
|
||||
const out = [];
|
||||
for (const row of rows) {
|
||||
const id = str(row?.id).toLowerCase();
|
||||
if (!id) continue;
|
||||
const bonuses = row.bonuses && typeof row.bonuses === 'object' ? row.bonuses : {};
|
||||
const perkIds = Array.isArray(bonuses.perks) ? bonuses.perks : [];
|
||||
const perks = perkIds.map((p) => perkDef(p)).filter(Boolean);
|
||||
const overview = Array.isArray(row.overview)
|
||||
? row.overview.map((p) => str(p)).filter(Boolean)
|
||||
: [];
|
||||
out.push({
|
||||
id,
|
||||
label: str(row.label, id).toUpperCase(),
|
||||
accent: toInt(row.accent, 0x00e5ff),
|
||||
accentCss: typeof row.accent === 'string' && row.accent.trim() ? row.accent.trim() : '#00e5ff',
|
||||
frame: Math.max(0, Math.floor(Number(row.frame) || 0)),
|
||||
role: str(row.role).toUpperCase(),
|
||||
tagline: str(row.tagline).toUpperCase(),
|
||||
summary: str(row.summary),
|
||||
overview,
|
||||
skillPoints: Math.max(0, Math.floor(Number(bonuses.skillPoints) || 0)),
|
||||
perks,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The perk LABELS a class grants, joined for the toast / confirm line. */
|
||||
export function perkNames(id, sep = ' + ') {
|
||||
return perksFor(id)
|
||||
.map((p) => p.label)
|
||||
.join(sep);
|
||||
}
|
||||
|
||||
/** The insignia frame index of a class (null when the class is unknown). */
|
||||
export function iconFrame(id) {
|
||||
const def = classDef(id);
|
||||
return def ? def.frame : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The select console's briefing (data/classes.json → window.intro +
|
||||
* window.speech). `text` is Trestle's copy verbatim from the data.
|
||||
*/
|
||||
export function introSpec() {
|
||||
const win = config.section('classes.window', {});
|
||||
const intro = win.intro ?? {};
|
||||
const speech = win.speech ?? {};
|
||||
return {
|
||||
speaker: str(intro.speaker, 'TRESTLE'),
|
||||
role: str(intro.role).toUpperCase(),
|
||||
text: str(intro.text),
|
||||
hint: str(intro.hint).toUpperCase(),
|
||||
caret: intro.caret !== false,
|
||||
audio: str(speech.file),
|
||||
musicDuck: Number.isFinite(Number(speech.musicDuck)) ? Number(speech.musicDuck) : 0.25,
|
||||
backLabel: str(intro.backLabel, '◂ BRIEFING').toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The window's own copy + knobs (data/classes.json → window). */
|
||||
export function windowSpec() {
|
||||
const win = config.section('classes.window', {});
|
||||
return {
|
||||
title: str(win.title, 'CLASS SELECT'),
|
||||
meta: str(win.meta),
|
||||
// the little stamp under the left feed (the console's own shelf label)
|
||||
feedLine: str(win.feedLine, 'CLASS DOCTRINE // LV {classAt}'),
|
||||
video: {
|
||||
file: str(win.video?.file, 'classes.mp4'),
|
||||
aspect: Array.isArray(win.video?.aspect) ? win.video.aspect : [2, 3],
|
||||
tag: str(win.video?.tag).toUpperCase(),
|
||||
noSignalTag: str(win.video?.noSignalTag, 'FEED OFFLINE').toUpperCase(),
|
||||
},
|
||||
tabs: {
|
||||
iconSize: Math.max(0, Number(win.tabs?.iconSize) || 26),
|
||||
height: Math.max(30, Number(win.tabs?.height) || 46),
|
||||
},
|
||||
plate: win.classPlate ?? {},
|
||||
pick: win.pick ?? {},
|
||||
toasts: {
|
||||
alreadyChosen: str(win.alreadyChosenToast),
|
||||
success: str(win.successToast),
|
||||
defer: str(win.deferToast),
|
||||
},
|
||||
sfx: {
|
||||
lock: str(win.lockSfx, 'ui_window'),
|
||||
success: str(win.lockSuccessSfx, 'discovery'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The template fill the confirm / toast lines use — `{class}`, `{perks}`,
|
||||
* `{skillPoints}` (an unknown placeholder is left alone, so a typo in the
|
||||
* data never prints "undefined").
|
||||
*/
|
||||
export function fillTemplate(str_, map = {}) {
|
||||
return String(str_ ?? '').replace(/\{(\w+)\}/g, (m, k) =>
|
||||
(map[k] === undefined || map[k] === null ? m : String(map[k])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirm dialog's copy for one class (data/classes.json → window.pick).
|
||||
* @param {string} id the class about to be locked in
|
||||
*/
|
||||
export function confirmSpec(id) {
|
||||
const def = classDef(id);
|
||||
const pick = windowSpec().pick;
|
||||
const map = {
|
||||
class: def?.label ?? String(id ?? '').toUpperCase(),
|
||||
perks: perkNames(id),
|
||||
skillPoints: def?.skillPoints ?? 0,
|
||||
frame: def?.frame ?? 0,
|
||||
};
|
||||
return {
|
||||
title: fillTemplate(pick.title, map),
|
||||
line1: fillTemplate(pick.line1, map),
|
||||
line2: fillTemplate(pick.line2, map),
|
||||
confirmLabel: str(pick.confirmLabel, 'CONFIRM'),
|
||||
cancelLabel: str(pick.cancelLabel, 'CANCEL'),
|
||||
width: Number(pick.width) || 520,
|
||||
height: Number(pick.height) || 196,
|
||||
accent: def?.accentCss ?? '#00e5ff',
|
||||
};
|
||||
}
|
||||
|
||||
/** The lock-in toast's copy for one class. */
|
||||
export function successMessage(id) {
|
||||
const spec = windowSpec();
|
||||
return fillTemplate(spec.toasts.success, {
|
||||
class: classDef(id)?.label ?? String(id ?? '').toUpperCase(),
|
||||
perks: perkNames(id),
|
||||
skillPoints: classDef(id)?.skillPoints ?? 0,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* CLASS STATE — the run's class LEDGER (pure; the roster is
|
||||
* data/classes.json via js/player/ClassModel.js).
|
||||
*
|
||||
* What the run remembers about its class:
|
||||
*
|
||||
* id the class on file (null = the player has not picked yet)
|
||||
* perks the perk ids the initial bonus granted (the combat code's
|
||||
* gates: dualCannon → the twin-barrel volley,
|
||||
* targetingComputer → the leading solve)
|
||||
* skillPoints the BANKED skill points (the class grant put them there;
|
||||
* the tree that spends them is the next layer)
|
||||
* granted the initial-bonus witness — once the bonus has been paid,
|
||||
* it is never paid twice
|
||||
*
|
||||
* THE GRANT IS IDEMPOTENT: `choose()` pays a class's initial bonus exactly
|
||||
* once. A second call for the SAME class re-affirms the class and reports
|
||||
* `alreadyGranted` without moving a single number (no double skill points);
|
||||
* a call for a DIFFERENT class is refused — swapping classes is a later
|
||||
* layer with a price on it, so the ledger holds the line here rather than
|
||||
* in the UI (data/classes.json → window.alreadyChosenToast is the voice).
|
||||
*
|
||||
* Save-safe like every other ledger (js/save/SaveData.js: record.playerClass):
|
||||
* toJSON() is plain data, fromJSON() validates against the CURRENT roster
|
||||
* (a hand-edited or stale id degrades to "no class on file" instead of
|
||||
* handing out unknown perks), and a fresh run starts empty.
|
||||
*/
|
||||
import { classDef, classList } from './ClassModel.js';
|
||||
|
||||
export class ClassState {
|
||||
constructor() {
|
||||
/** @type {string|null} the class on file */
|
||||
this.id = null;
|
||||
/** @type {Set<string>} the perk ids the initial bonus granted */
|
||||
this.perks = new Set();
|
||||
/** @type {number} skill points banked, unspent */
|
||||
this.skillPoints = 0;
|
||||
/** @type {boolean} the initial bonus has been paid (the idempotency witness) */
|
||||
this.granted = false;
|
||||
}
|
||||
|
||||
/** Does the run have a class yet (level ≥ classAt with nothing on file = not chosen)? */
|
||||
get hasClass() {
|
||||
return !!this.id;
|
||||
}
|
||||
|
||||
/** The class's normalized definition (null when nothing is on file). */
|
||||
get def() {
|
||||
return this.id ? classDef(this.id) : null;
|
||||
}
|
||||
|
||||
/** The class's display label ('' when no class) — the ship console's plate. */
|
||||
get label() {
|
||||
return this.def?.label ?? '';
|
||||
}
|
||||
|
||||
/** The insignia frame off assets/images/classes.png (null when no class). */
|
||||
get frame() {
|
||||
return this.def?.frame ?? null;
|
||||
}
|
||||
|
||||
/** Does the run own this perk (the combat code's gate)? */
|
||||
hasPerk(id) {
|
||||
return !!id && this.perks.has(String(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock in a class and pay its INITIAL bonus.
|
||||
*
|
||||
* @param {string} id the class picked
|
||||
* @returns {{ok: boolean, reason?: string, alreadyGranted?: boolean,
|
||||
* id: string|null, skillPoints: number, perks: string[]}}
|
||||
* ok=false + reason 'unknown' — no such class in the roster;
|
||||
* ok=false + reason 'taken' — a DIFFERENT class is already on file
|
||||
* (changing class is a later layer);
|
||||
* ok=true + alreadyGranted — the same class again: nothing was paid.
|
||||
*/
|
||||
choose(id) {
|
||||
const key = typeof id === 'string' ? id.trim().toLowerCase() : '';
|
||||
const def = key ? classDef(key) : null;
|
||||
if (!def) return this._result('unknown');
|
||||
if (this.id && this.id !== key) return this._result('taken');
|
||||
this.id = key;
|
||||
if (!this.granted) {
|
||||
for (const perk of def.perks) this.perks.add(perk.id);
|
||||
this.skillPoints += def.skillPoints;
|
||||
this.granted = true;
|
||||
return this._result(null, false);
|
||||
}
|
||||
return this._result(null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bank more skill points (the seam the next layer uses — a quest reward,
|
||||
* a level-up bonus). Always non-negative.
|
||||
* @returns {number} the new bank
|
||||
*/
|
||||
awardSkillPoints(n) {
|
||||
const v = Math.max(0, Math.floor(Number(n) || 0));
|
||||
this.skillPoints += v;
|
||||
return this.skillPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend from the bank (the future skill tree's door — refused when the
|
||||
* bank is short or there is no class on file).
|
||||
* @returns {boolean} true when the bank paid for it
|
||||
*/
|
||||
spendSkillPoints(n) {
|
||||
const v = Math.max(0, Math.floor(Number(n) || 0));
|
||||
if (!this.hasClass || v > this.skillPoints) return false;
|
||||
this.skillPoints -= v;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The result record every entry point reports. */
|
||||
_result(reason, alreadyGranted = false) {
|
||||
return {
|
||||
ok: !reason,
|
||||
reason: reason ?? null,
|
||||
alreadyGranted,
|
||||
id: this.id,
|
||||
skillPoints: this.skillPoints,
|
||||
perks: [...this.perks],
|
||||
};
|
||||
}
|
||||
|
||||
/** A snapshot for the consoles (the ship window's class plate, the dev console). */
|
||||
stats() {
|
||||
const def = this.def;
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.label || null,
|
||||
label: this.label || null,
|
||||
accent: def?.accentCss ?? null,
|
||||
frame: this.frame,
|
||||
tagline: def?.tagline ?? '',
|
||||
perks: [...this.perks],
|
||||
skillPoints: this.skillPoints,
|
||||
granted: this.granted,
|
||||
};
|
||||
}
|
||||
|
||||
/** The save's shape (plain data — the roster lives in data/, not here). */
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id ?? null,
|
||||
perks: [...this.perks],
|
||||
skillPoints: this.skillPoints,
|
||||
granted: this.granted === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a saved record in place. The class id and every perk must exist in
|
||||
* the CURRENT roster — an unknown id drops the whole class (and with it
|
||||
* any perks), so a stale record can never leave a half-owned build.
|
||||
* @param {object|null} state record.playerClass (tolerant of junk)
|
||||
*/
|
||||
restore(state) {
|
||||
this.id = null;
|
||||
this.perks = new Set();
|
||||
this.skillPoints = 0;
|
||||
this.granted = false;
|
||||
if (!state || typeof state !== 'object') return this;
|
||||
const def = classDef(state.id);
|
||||
if (!def) return this;
|
||||
const owned = new Set(def.perks.map((p) => p.id));
|
||||
this.id = def.id;
|
||||
this.perks = new Set(
|
||||
(Array.isArray(state.perks) ? state.perks : []).filter((p) => typeof p === 'string' && owned.has(p)),
|
||||
);
|
||||
// A class on file but no witness means the bonus never paid (a record
|
||||
// from before the grant, or a hand-edited save): keep the class, mark
|
||||
// the bonus unpaid so a later lock-in can still settle it.
|
||||
this.granted = state.granted === true;
|
||||
if (!Number.isFinite(Number(state.skillPoints))) return this;
|
||||
this.skillPoints = Math.max(0, Math.floor(Number(state.skillPoints)));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** A fresh ledger (a new run — nothing on file, the bank empty). */
|
||||
reset() {
|
||||
return this.restore(null);
|
||||
}
|
||||
|
||||
/** The save's record → a ledger (validated against data/classes.json). */
|
||||
static fromJSON(state) {
|
||||
return new ClassState().restore(state);
|
||||
}
|
||||
|
||||
/** Every class id the roster offers (the validator's universe). */
|
||||
static knownIds() {
|
||||
return classList().map((c) => c.id);
|
||||
}
|
||||
}
|
||||
|
||||
export default ClassState;
|
||||
|
|
@ -36,6 +36,11 @@
|
|||
* // themselves regenerate from
|
||||
* // the seed — js/quests/
|
||||
* // SideQuests.js)
|
||||
* playerClass: { id, perks: [id, …], // THE CLASS LAYER — the class
|
||||
* skillPoints, granted }, // on file + its granted perks
|
||||
* // + the banked skill points
|
||||
* // (js/player/ClassState.js,
|
||||
* // data/classes.json)
|
||||
* combat: { done: [scriptName, …], // the CLEARED wave scripts
|
||||
* level, xp }, // + the run's COMBAT XP
|
||||
* // ledger (js/combat/
|
||||
|
|
@ -85,6 +90,7 @@ import { Discovery } from '../galaxy/Discovery.js';
|
|||
import { Reputation } from '../reputation/Reputation.js';
|
||||
import { QuestState } from '../quests/QuestState.js';
|
||||
import { SideQuestState, allSideQuestJobIds } from '../quests/SideQuests.js';
|
||||
import { ClassState } from '../player/ClassState.js';
|
||||
import { SAVE_FORMAT, SaveManager } from './SaveManager.js';
|
||||
|
||||
export const PENDING_RESTORE_KEY = 'orbit.pendingRestore';
|
||||
|
|
@ -291,6 +297,13 @@ export function captureState(scene, now) {
|
|||
// (at the cap, the banked overflow — harmless). A save predating
|
||||
// it has no fields; the restore stages a fresh ledger (old saves
|
||||
// load — the run starts at level 0).
|
||||
// THE CLASS LAYER (js/player/ClassState.js, data/classes.json) — the class
|
||||
// the run locked in at combat level 5, the perks its initial bonus paid
|
||||
// (the combat code's gates: dualCannon / targetingComputer) and the skill
|
||||
// points banked with them. `granted` is the bonus's idempotency witness.
|
||||
// A save predating the class layer has no field; the restore stages an
|
||||
// empty ledger (old saves load — the run simply has no class yet).
|
||||
playerClass: (scene.classState ?? scene.registry.get('playerClass') ?? null)?.toJSON?.() ?? null,
|
||||
combat: {
|
||||
done: Array.from(scene.registry.get('combatDone') ?? []),
|
||||
level: Math.max(0, Math.floor(Number(scene.combat?.xp?.level) || 0)),
|
||||
|
|
@ -556,6 +569,13 @@ export function prepareLoad(registry, record) {
|
|||
}
|
||||
: null,
|
||||
);
|
||||
// THE CLASS LEDGER (js/player/ClassState.js) — the class on file, its
|
||||
// granted perks and the banked skill points. A save predating the class
|
||||
// layer has no field → null (a fresh run picks its class at level 5; old
|
||||
// saves load with no class on file). Validated against the CURRENT roster,
|
||||
// so a hand-edited or unknown class id degrades to "no class" instead of
|
||||
// handing out perks that do not exist.
|
||||
registry.set('playerClass', record.playerClass ? ClassState.fromJSON(record.playerClass) : null);
|
||||
// The run's VIGNETTES played-ledger (a save predating vignettes has no
|
||||
// field → an empty set — old saves load; a fresh run's scenes arm on
|
||||
// their quest gates in data/vignettes.json).
|
||||
|
|
@ -611,6 +631,7 @@ export function resetRunState(registry) {
|
|||
registry.set('commsAcked', null); // the ack gates re-arm on the new run
|
||||
registry.set('combatDone', null); // the cleared waves re-arm on the new run
|
||||
registry.set('combatXp', null); // the new run's level + xp stand fresh
|
||||
registry.set('playerClass', null); // the new run picks its class again
|
||||
registry.set('vignettesPlayed', null); // the scenes re-arm on the new run
|
||||
registry.set(PENDING_RESTORE_KEY, null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,16 @@ import { BuildWindow } from '../ui/BuildWindow.js';
|
|||
import { JobBoardWindow } from '../ui/JobBoardWindow.js';
|
||||
import { BuildState } from '../build/BuildState.js';
|
||||
import { startingPairs, defById, isShipScoped } from '../build/BuildModel.js';
|
||||
import { ClassWindow, loadClassMedia } from '../ui/ClassWindow.js';
|
||||
import { ClassState } from '../player/ClassState.js';
|
||||
import {
|
||||
classAt as classLevelAt,
|
||||
classDef,
|
||||
classesEnabled,
|
||||
perkNames,
|
||||
successMessage,
|
||||
windowSpec as classWindowSpec,
|
||||
} from '../player/ClassModel.js';
|
||||
|
||||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
|
||||
|
|
@ -1575,15 +1585,40 @@ export class GameScene extends Phaser.Scene {
|
|||
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)),
|
||||
}),
|
||||
// The level-5 door: it opens the SELECT YOUR CLASS console over the
|
||||
// parked world (js/ui/ClassWindow.js) — and it is where a DEFERRED pick
|
||||
// comes back for. With the class layer switched off
|
||||
// (data/classes.json → enabled: false) it stays a voice, never a click.
|
||||
onChooseClass: () => this.openClassWindow({ source: 'shipConsole' }),
|
||||
});
|
||||
|
||||
// ---- THE CLASS LAYER (data/classes.json, js/player/ClassState.js) ----
|
||||
// The run's class LEDGER — the class locked in at combat level 5, the perks
|
||||
// its initial bonus paid (the combat code's gates: dualCannon /
|
||||
// targetingComputer) and the skill points banked with them. Registry-backed
|
||||
// like discovery/quests: a load stages the saved record
|
||||
// (SaveData.prepareLoad → record.playerClass), a fresh run starts empty, and
|
||||
// New Game clears it. The CONSOLE itself is built on the first open — its
|
||||
// media (~2 MB of feed) loads with js/vignettes/VignetteHub.js's deferred
|
||||
// rule, so a run that never reaches level 5 never downloads it.
|
||||
this.classState = this.registry.get('playerClass') ?? null;
|
||||
if (!(this.classState instanceof ClassState)) this.classState = new ClassState();
|
||||
this.registry.set('playerClass', this.classState);
|
||||
// The door is spent once a class is on file (a loaded run is never asked
|
||||
// again) and once the auto-prompt has been made (closing it DEFERS — the
|
||||
// ship console's door re-opens it, but the game does not nag).
|
||||
this._classPrompted = this.classState.hasClass;
|
||||
this._classAutoShown = false; // the auto prompt has been made this session
|
||||
this._classAutoOpen = false; // the console is up FROM the auto trigger
|
||||
this._classMediaStarted = false;
|
||||
this._classMediaReady = false;
|
||||
this._classPendingOpen = null; // an open request waiting on the media pass
|
||||
this.classWindow = null;
|
||||
// A LOADED run already wears its class — hand the ledger to the combat
|
||||
// system so its perks (dual cannon / targeting computer) are live from the
|
||||
// first frame, exactly as they are after a fresh lock-in.
|
||||
if (this.combat) this.combat.classState = this.classState;
|
||||
|
||||
// ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ----
|
||||
// The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80):
|
||||
// left the 2:3 mission feed (assets/videos/quests.mp4 — the same
|
||||
|
|
@ -2634,8 +2669,14 @@ export class GameScene extends Phaser.Scene {
|
|||
// destination the moment it ends (the deadlines are time-based, so
|
||||
// a project that finished mid-clip completes on the first tick
|
||||
// after, exactly like the ack pause's completion path).
|
||||
// The CLASS CONSOLE's freeze (js/ui/ClassWindow.js): picking a class is a
|
||||
// decision, not an overlay on top of the fight — the world holds exactly
|
||||
// the way it does for a comm's ACK and the level-up marquee.
|
||||
const _frozen =
|
||||
_commsPaused || this.levelMarquee?.active === true || !!this._jumping;
|
||||
_commsPaused ||
|
||||
this.levelMarquee?.active === true ||
|
||||
!!this._jumping ||
|
||||
this.classWindow?.isOpen === true;
|
||||
// The freeze's PARKING (the ack pause's onPause is the same contract
|
||||
// for its own freeze): while ANY freeze holds, the action is parked
|
||||
// dead EVERY frame — the ship (target + throttle cleared, velocity
|
||||
|
|
@ -2717,6 +2758,8 @@ export class GameScene extends Phaser.Scene {
|
|||
this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll
|
||||
this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll
|
||||
this.shipWindow?.update(_time); // the ship console: reveal, decodes, sweep, telemetry poll
|
||||
this.classWindow?.update(_time); // the class console: the brief's typing, feed sweep, decodes
|
||||
this._classBeat(); // THE LEVEL-5 DOOR: opens when combat goes quiet
|
||||
this._deckResearchBar(_time); // the RESEARCH slot's progress bar
|
||||
this._applyResearchCue(); // the RESEARCH slot's ready-shimmer (the deck's half)
|
||||
// Builds: a build started on a surface keeps running in space if the
|
||||
|
|
@ -7745,6 +7788,228 @@ export class GameScene extends Phaser.Scene {
|
|||
* aboard, credits, and the cargo hold's bays in fill order.
|
||||
* level the XP ledger itself (js/combat/XpManager.js → stats()).
|
||||
*/
|
||||
// ═════════════════════════════════════════════════════════════ THE CLASS LAYER
|
||||
/**
|
||||
* THE LEVEL-5 DOOR.
|
||||
*
|
||||
* data/classes.json is the roster (Vanguard / Wraith), js/player/ClassState.js
|
||||
* is the run's ledger (the class on file, its granted perks, the banked skill
|
||||
* points — saved as record.playerClass), and js/ui/ClassWindow.js is the
|
||||
* console that hands one out. This block owns WHEN it appears:
|
||||
*
|
||||
* 1. AUTO — once, when the run's combat level reaches
|
||||
* data/shipScreen.json → level.classAt AND combat goes quiet (no live
|
||||
* hostiles, no wave run in flight, nothing else owning the screen). The
|
||||
* console then freezes the world through the existing _frozen /
|
||||
* _parkForFreeze contract.
|
||||
* 2. ON DEMAND — the ship console's CHOOSE YOUR CLASS door (its
|
||||
* onChooseClass seam), which is also where a DEFERRED pick comes back.
|
||||
* 3. DEV — index.html?level5 opens it on boot (?noclass suppresses the
|
||||
* auto prompt, ?level5&tab=wraith opens straight to a tab).
|
||||
*
|
||||
* Once a class is on file the door never fires again; closing without picking
|
||||
* spends the AUTO prompt (the game does not nag) but leaves the door.
|
||||
*/
|
||||
|
||||
/** The dev door: index.html?level5 (or ?class) opens the console on boot. */
|
||||
_classDevMode() {
|
||||
try {
|
||||
const q = new URLSearchParams(location.search);
|
||||
if (q.has('noclass')) return null;
|
||||
if (q.has('level5') || q.has('cls') || q.has('class')) {
|
||||
const tab = String(q.get('tab') ?? '').trim().toLowerCase() || null;
|
||||
return { auto: true, tab };
|
||||
}
|
||||
} catch {
|
||||
/* no URL (headless) — the door is the level, as always */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The deferred media pass (VignetteHub's rule): sheet + feed + brief. */
|
||||
_ensureClassMedia() {
|
||||
if (this._classMediaStarted) return;
|
||||
this._classMediaStarted = true;
|
||||
loadClassMedia(this, () => {
|
||||
this._classMediaReady = true;
|
||||
const wait = this._classPendingOpen;
|
||||
this._classPendingOpen = null;
|
||||
if (wait) this.openClassWindow(wait);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the console on the first open (its ~2 MB of media is already in). */
|
||||
_createClassWindow() {
|
||||
if (this.classWindow) return this.classWindow;
|
||||
this.classWindow = new ClassWindow(this, {
|
||||
getClassState: () => this.classState,
|
||||
// The CONFIRMED lock-in (the console's own ConfirmOverlay asked first).
|
||||
onPick: (id) => this.applyClassPick(id),
|
||||
// Dismissed. A pick closes; anything else is a DEFER — the auto prompt
|
||||
// is spent (no nagging), and the ship console's door is the way back in.
|
||||
onClose: (o = {}) => {
|
||||
if (o?.picked || this.classState?.hasClass) {
|
||||
this._classAutoOpen = false;
|
||||
return;
|
||||
}
|
||||
if (this._classAutoOpen) {
|
||||
this._classAutoOpen = false;
|
||||
const msg = String(classWindowSpec().toasts.defer ?? '');
|
||||
if (msg) this.consoleToast(msg, { glyph: '›', glyphColor: toCss(themeColor('amber', 0xffc94d)) });
|
||||
}
|
||||
},
|
||||
onFirstOpen: () => {
|
||||
this._classAutoShown = true;
|
||||
},
|
||||
onToast: (msg, o) => this.consoleToast(msg, o),
|
||||
});
|
||||
return this.classWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the class console. Loads its media first when needed (the open is
|
||||
* remembered and lands the moment the pass ends), and stands down to the
|
||||
* data's voice when the class layer is switched off.
|
||||
* @param {{source?: string, tab?: string|null, auto?: boolean}} [o]
|
||||
*/
|
||||
openClassWindow(o = {}) {
|
||||
if (!classesEnabled()) {
|
||||
this.consoleToast(String(config.get('shipScreen.level.chooseClass.toast', 'CLASS SELECT OFFLINE')), {
|
||||
glyph: '✕',
|
||||
glyphColor: toCss(themeColor('amber', 0xffc94d)),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (this.classWindow?.isOpen) return true;
|
||||
if (!this._classMediaReady) {
|
||||
this._classPendingOpen = o;
|
||||
this._ensureClassMedia();
|
||||
return false;
|
||||
}
|
||||
// Anything else that owns the screen gets out of the way first (the
|
||||
// comms' box and the level marquee are shorter beats than a decision).
|
||||
this.comms?.closeAll?.();
|
||||
this._classAutoOpen = o.auto === true;
|
||||
this._createClassWindow().open({ tab: o.tab ?? null });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dev door's step over an ACK comm: the hub's OWN acknowledge path
|
||||
* (CommsHub._ack — what the box's ACKNOWLEDGE button calls, so the onAck /
|
||||
* onEnd bookkeeping still runs; not a teardown). With index.html?level5 in
|
||||
* the URL the intro brief is stood down every frame it holds, so the class
|
||||
* console lands (and releases) with no hand on the deck. A real run never
|
||||
* takes this path — comms keep the screen exactly as designed.
|
||||
*/
|
||||
_devReleaseComms() {
|
||||
const c = this.comms;
|
||||
if (!c?.paused) return;
|
||||
try {
|
||||
c._ack?.();
|
||||
} catch {
|
||||
/* the comm's own business */
|
||||
}
|
||||
}
|
||||
|
||||
/** Is combat quiet enough to ask for a decision? (the auto-open's gate) */
|
||||
_combatIsQuiet() {
|
||||
const c = this.combat;
|
||||
if (!c) return true;
|
||||
if (c.playerDown === true) return false; // the respawn beat owns the screen
|
||||
const live = Array.isArray(c.active) ? c.active.length > 0 : !!c.active?.size;
|
||||
if (live) return false;
|
||||
const run = c.waveRun;
|
||||
if (run && run.state !== 'done' && run.state !== 'cancelled') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Is any other full-screen beat holding the world? (the auto-open waits) */
|
||||
_screenIsBusy() {
|
||||
return !!(
|
||||
this.comms?.paused ||
|
||||
this.levelMarquee?.active ||
|
||||
this._jumping ||
|
||||
this.shipWindow?.isOpen ||
|
||||
this.mapWindow?.isOpen ||
|
||||
this.questWindow?.isOpen ||
|
||||
this.researchWindow?.isOpen ||
|
||||
this.buildWindow?.isOpen ||
|
||||
this.jobBoardWindow?.isOpen
|
||||
);
|
||||
}
|
||||
|
||||
/** The per-frame look for the door (cheap: two reads until it is armed). */
|
||||
_classBeat() {
|
||||
const dev = this._classDevDoor ?? (this._classDevDoor = this._classDevMode());
|
||||
// ?level5 keeps the deck clear of the intro comm for as long as it is up
|
||||
// (see _devReleaseComms) — otherwise a queued brief grabs the screen back
|
||||
// the moment the class console closes.
|
||||
if (dev) this._devReleaseComms();
|
||||
if (this.classWindow?.isOpen) return;
|
||||
if (this._classPrompted || this.classState?.hasClass) return;
|
||||
if (!classesEnabled()) return;
|
||||
const level = Number(this.combat?.xp?.level ?? 0);
|
||||
const armed = level >= classLevelAt();
|
||||
if (!armed && !dev) return;
|
||||
if (!this._classMediaStarted) this._ensureClassMedia();
|
||||
if (!this._classMediaReady) return; // the pass lands it (openClassWindow waits)
|
||||
// A few frames of settle before the dialog lands (the dev door arrives on a
|
||||
// fresh deck; the real one arrives mid-flight, where a hostile may still be
|
||||
// dying on screen).
|
||||
this._classTicks = (this._classTicks ?? 0) + 1;
|
||||
if (this._classTicks < (dev ? 90 : 30)) return;
|
||||
// The DEV door ignores the run's stage (its whole point is landing the
|
||||
// console without meeting the level); the real trigger waits its turn
|
||||
// behind every other full-screen beat and any live combat.
|
||||
if (!dev && (this._screenIsBusy() || !this._combatIsQuiet())) return;
|
||||
this._classPrompted = true; // the prompt is made — auto-fire never repeats
|
||||
this.openClassWindow({ source: 'trigger', auto: true, tab: dev?.tab ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* The CONFIRMED lock-in: write the ledger, bank its bonus, tell the combat
|
||||
* system (the perks are its gates) and say so. Idempotent — js/player/
|
||||
* ClassState.js pays a class's initial bonus exactly once, and a DIFFERENT
|
||||
* class is refused (changing class is a later layer with a price on it).
|
||||
* @returns {{ok: boolean, reason?: string|null, id: string|null}}
|
||||
*/
|
||||
applyClassPick(id) {
|
||||
const res = this.classState.choose(id);
|
||||
if (!res.ok) return res;
|
||||
this.registry.set('playerClass', this.classState);
|
||||
this._classPrompted = true;
|
||||
// THE COMBAT SEAM: the firing code reads the ledger through the combat
|
||||
// system (dualCannon → the twin-barrel volley, targetingComputer → the
|
||||
// leading solve). See js/combat/CombatSystem.js.
|
||||
if (this.combat) this.combat.classState = this.classState;
|
||||
const def = classDef(id);
|
||||
const spec = classWindowSpec();
|
||||
this.playSfx?.(spec.sfx.success);
|
||||
this.consoleToast(successMessage(id), {
|
||||
glyph: '◈',
|
||||
glyphColor: toCss(def?.accentCss ?? '#00e5ff'),
|
||||
});
|
||||
// The ship console's journey plate flips from the door to the class.
|
||||
this.shipWindow?.refresh?.();
|
||||
return res;
|
||||
}
|
||||
|
||||
/** The class readout the ship console paints in place of its door (null =
|
||||
* no class on file, so the CHOOSE YOUR CLASS door keeps standing). */
|
||||
classPlateSnapshot() {
|
||||
const st = this.classState;
|
||||
if (!st?.hasClass) return null;
|
||||
const def = classDef(st.id);
|
||||
return {
|
||||
name: def?.label ?? String(st.id).toUpperCase(),
|
||||
insignia: def?.frame ?? 0,
|
||||
accent: def?.accentCss ?? null,
|
||||
skillPoints: st.skillPoints,
|
||||
perks: perkNames(st.id, ' · '),
|
||||
};
|
||||
}
|
||||
|
||||
shipScreenSnapshot() {
|
||||
const s = this.ship;
|
||||
if (!s) return null;
|
||||
|
|
@ -7777,10 +8042,10 @@ export class GameScene extends Phaser.Scene {
|
|||
},
|
||||
},
|
||||
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,
|
||||
// The class layer's seam: the class on file (its label, its insignia
|
||||
// FRAME, its own accent and its banked skill points) — which is what
|
||||
// turns the ship console's CHOOSE YOUR CLASS door into the class plate.
|
||||
playerClass: this.classPlateSnapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -366,6 +366,32 @@ export class MenuScene extends Phaser.Scene {
|
|||
this.cursorOn = true;
|
||||
this.seedIntroTargets = [];
|
||||
|
||||
// DEV DOOR (the harness idiom every screen of this game is testable by):
|
||||
// index.html?level5 (or ?cls) walks straight into a fresh run so the class
|
||||
// console — which GameScene arms from the same param — can be opened, shot
|
||||
// and driven without a hand on the menu. ?noclass suppresses it.
|
||||
const devDoor = () => {
|
||||
try {
|
||||
const q = new URLSearchParams(location.search);
|
||||
return !q.has('noclass') && (q.has('level5') || q.has('cls') || q.has('class'));
|
||||
} catch {
|
||||
return false; // no URL (headless) — the menu is the only way in
|
||||
}
|
||||
};
|
||||
if (devDoor()) {
|
||||
// The seed field is dressed a beat after create(); walk in as soon as it
|
||||
// exists (a handful of tries, then leave the menu alone).
|
||||
const walkIn = (tries) => {
|
||||
if (this.dead || !devDoor()) return;
|
||||
if (typeof this.seedValue === 'string') {
|
||||
this.startNewGame();
|
||||
return;
|
||||
}
|
||||
if (tries > 0) this.time.delayedCall(150, () => walkIn(tries - 1));
|
||||
};
|
||||
this.time.delayedCall(400, () => walkIn(40));
|
||||
}
|
||||
|
||||
// Label: neon arrow + dim caps
|
||||
const arrow = this.add
|
||||
.text(cx - fieldW / 2 - 30, cy - fieldH / 2 - 10, '\u25b8', {
|
||||
|
|
|
|||
|
|
@ -149,7 +149,8 @@ export function isResolved(row) {
|
|||
* 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]
|
||||
* @param {{name?: string|null, insignia?: number|string|null, accent?: string|null,
|
||||
* skillPoints?: number}} [playerClass] the class on file (js/player/ClassState.js)
|
||||
*/
|
||||
export function journeyState(stats, cfg = {}, playerClass = null) {
|
||||
const from = Number.isFinite(cfg.from) ? cfg.from : 0;
|
||||
|
|
@ -187,7 +188,11 @@ export function journeyState(stats, cfg = {}, playerClass = null) {
|
|||
progress: clamp01((level - from + (level >= to ? 0 : step)) / span),
|
||||
nodes,
|
||||
className: name,
|
||||
// the class plate's own theme + crest (data/classes.json rides the save
|
||||
// record as {name, insignia: frame, accent, skillPoints})
|
||||
classInsignia: playerClass?.insignia ?? null,
|
||||
classAccent: playerClass?.accent ?? null,
|
||||
classSkillPoints: Number.isFinite(Number(playerClass?.skillPoints)) ? Math.max(0, Math.round(Number(playerClass.skillPoints))) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -67,6 +67,8 @@ import { CyberShape } from './CyberShape.js';
|
|||
import { ConfirmOverlay } from './ConfirmOverlay.js';
|
||||
import { GOODS_TEXTURE_KEY, goodFrame, goodsTextureReady } from '../economy/Economy.js';
|
||||
import { statRows, journeyState, cargoSummary } from '../ship/ShipScreenModel.js';
|
||||
import { classIconsReady } from './ClassWindow.js';
|
||||
import { CLASS_ICON_KEY } from '../player/ClassModel.js';
|
||||
|
||||
const HEADER = fontStack('header');
|
||||
const BODY = fontStack('body');
|
||||
|
|
@ -149,6 +151,13 @@ function tpl(str, map = {}) {
|
|||
}
|
||||
|
||||
/** A filled diamond marker (group bullets, the journey's nodes). */
|
||||
/** A css/int color → the int this window paints with (null when unparseable). */
|
||||
function toIntOrNull(v) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v >>> 0;
|
||||
const s = String(v ?? '').trim().replace('#', '');
|
||||
return /^[0-9a-fA-F]{6}$/.test(s) ? parseInt(s, 16) >>> 0 : null;
|
||||
}
|
||||
|
||||
function diamond(g, x, y, r, color, alpha = 1) {
|
||||
if (!(r > 0)) return;
|
||||
g.fillStyle(color, alpha);
|
||||
|
|
@ -727,6 +736,24 @@ export class ShipWindow extends Phaser.GameObjects.Container {
|
|||
return sp;
|
||||
}
|
||||
|
||||
/** A class insignia (a FRAME off assets/images/classes.png — the goods
|
||||
* sheet's `icon()` cannot draw it). No sheet yet → a themed diamond keeps
|
||||
* the plate honest about the class instead of showing nothing. */
|
||||
classCrest(x, y, frame, size = 40, accent = C.neon) {
|
||||
if (classIconsReady(this.scene)) {
|
||||
const sp = this.scene.add
|
||||
.image(x, y, CLASS_ICON_KEY, Math.max(0, Math.floor(frame || 0)))
|
||||
.setDisplaySize(size, size)
|
||||
.setScrollFactor(0);
|
||||
this.bodyCont.add(sp);
|
||||
this._kids.push(sp);
|
||||
return sp;
|
||||
}
|
||||
const g = this.gfx();
|
||||
diamond(g, x, y, size / 2 - 2, accent, 0.85);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** A button-ish plate with a label; the callback fires on pointerdown. */
|
||||
button(x, y, w, h, label, accent, onPress) {
|
||||
const g = this.gfx();
|
||||
|
|
@ -1043,10 +1070,21 @@ export class ShipWindow extends Phaser.GameObjects.Container {
|
|||
const plate = this.gfx();
|
||||
panel(plate, x + 6, y - 4, w - 12, 92, { notch: 10, fill: C.amber, fillAlpha: 0.05, stroke: C.amber, strokeAlpha: 0.35 });
|
||||
if (j.mode === 'class') {
|
||||
// a class is on file — the insignia + name slot (data-driven; empty seam)
|
||||
// A class is on file (js/player/ClassState.js through the snapshot): its
|
||||
// own accent themes the plate, its insignia is the FRAME off
|
||||
// assets/images/classes.png (not a goods icon), and the banked skill
|
||||
// points read out under the name (the tree that spends them is next).
|
||||
const accent = toIntOrNull(j.classAccent) ?? C.amber;
|
||||
panel(this.gfx(), x + 10, y + 6, w - 20, 78, { notch: 8, fill: accent, fillAlpha: 0.07, stroke: accent, strokeAlpha: 0.4 });
|
||||
this.txt(x + 26, y + 14, String(cfg.classPlate?.label ?? 'CLASS').toUpperCase(), { color: C.faint, size: 9, ls: 3 });
|
||||
this.txt(x + 26, y + 26, j.className, { color: C.ink, size: 20, head: true, bold: true });
|
||||
if (j.classInsignia) this.icon(x + w - 52, y + 34, j.classInsignia, 40);
|
||||
this.txt(x + 26, y + 26, j.className, { color: accent, size: 20, head: true, bold: true });
|
||||
const sp = Number(j.classSkillPoints);
|
||||
if (Number.isFinite(sp) && sp > 0) {
|
||||
this.txt(x + 26, y + 58, String(cfg.classPlate?.skillPointsLine ?? '+{n} SKILL POINTS BANKED').replace('{n}', sp), {
|
||||
color: C.amber, size: 9, ls: 1.8,
|
||||
});
|
||||
}
|
||||
this.classCrest(x + w - 52, y + 34, Number.isFinite(Number(j.classInsignia)) ? Number(j.classInsignia) : Number(cfg.classPlate?.insigniaFrame) || 0, 40, accent);
|
||||
} else {
|
||||
const bw = Math.min(320, w * 0.6);
|
||||
this.button(x + (w - bw) / 2, y + 10, bw, 40, String(door.label ?? 'CHOOSE YOUR CLASS'), C.amber, () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue