diff --git a/README.md b/README.md index 147c9c0..aa86ad3 100644 --- a/README.md +++ b/README.md @@ -165,15 +165,28 @@ it locks. ## The skill layer — spend the points in the SKILL TREE The bank the class layer fills has somewhere to go. Open the **ship console** -and press **SKILL TREE** for the full-screen console (`js/ui/SkillWindow.js`): -the class plate on the left with the points still unspent, and one row per skill -of that class — icon, label, TYPE, cost/recharge, rank **`n/5`**, the effect line, -and its own actions. +and press **SKILL TREE** for the full-screen console (`js/ui/SkillWindow.js`) — a +cabinet in three regions: the **class plate** upper-left (crest, feed tag, the points +still unspent), the **tree** upper-right — square icon tiles on the grid the data +authored, joined by elbow lines and badged **`n/m`** on the corner — and a **detail +drawer** across the bottom third with that skill's copy and its actions lower-right. -- **One point per press.** RANK UP spends a single banked point (`1/5` → `5/5`); - points are never lost and a rank 0 skill is inert. The ledger +- **One point per press.** ADD A SKILL POINT spends a single banked point + (`0/5` → `5/5`) — for the selected skill, whether you press the button or click + its tile again; points are never lost and a rank 0 skill is inert. The ledger (`js/skills/SkillState.js`) travels with the run in the save; an old save simply starts with an empty ledger. +- **A session is undoable, a closed console is not.** Opening the console opens a + SESSION: every point spent inside it stays PENDING (the plate says `+n THIS SESSION`), + and **RESET** hands them back — down a branch this session bought, never past a point + committed earlier (a reset that would strand one says so instead of de-powering the + ship). Closing the console — ✕, ESC or a click on the scrim — COMMITS what is left (and + so does leaving the run: an open session never survives a save/load). Handing a skill to + a seat steps the console aside but does NOT commit — the undo stays live while a seat is + being picked. +- **The tree scrolls.** Wheel, drag or the thumb; this Phaser build has no clip mask, + so a tile is drawn only while it fits the pane WHOLE and the join lines are clipped by + hand — the same rule the shipyard's buy list follows. - **ACTIVE skills take a seat.** ASSIGN SEAT puts the skill "in the hand" — the console steps aside, the combat bar breathes, and a click on a seat (or the key **1**–**8**) binds it. Casting spends its energy and starts its own recharge; @@ -191,8 +204,11 @@ and its own actions. boot. `?noclass` closes these doors too. No flag, no effect — normal runs are untouched. - Everything is data — tree membership, ids, labels, types, energy, recharge, max - points, `requires` edges, effect numbers, icon frames, tooltips, toasts and the - bar's whole look live in **`data/skills.json`**. Code owns behaviour only: adding + points, `requires` edges, effect numbers, icon frames, tooltips, toasts, the + **authored grid** each skill sits on (`node: {col, row}`), the console's whole + geometry/palette block (`window.tree`: region split, tile size, join colours, + rank badge, empty sockets, scroller) and the bar's whole look live in + **`data/skills.json`**. Code owns behaviour only: adding skill #3 is a data row (plus one branch in `js/skills/SkillEffects.js` if it needs a new kind of effect). See *The skill layer* in `docs/PROJECT_NOTES.md`. diff --git a/assets/images/planets.png b/assets/images/planets.png index 1f820c9..708459d 100644 Binary files a/assets/images/planets.png and b/assets/images/planets.png differ diff --git a/assets/images/planets.psd b/assets/images/planets.psd index 974c091..56d9229 100644 Binary files a/assets/images/planets.psd and b/assets/images/planets.psd differ diff --git a/assets/skilltree.png b/assets/skilltree.png new file mode 100644 index 0000000..0db4c8e Binary files /dev/null and b/assets/skilltree.png differ diff --git a/data/skills.json b/data/skills.json index 3965993..857a928 100644 --- a/data/skills.json +++ b/data/skills.json @@ -56,7 +56,7 @@ }, "classes": { - "_comment": "ONE TREE PER CLASS, keyed by the class id in data/classes.json. `skills` is the tree in DISPLAY order (the rows the console draws, top to bottom); the graph is the skills' own `requires` edges. A class with no authored skills shows the console's empty-trees plate rather than a dead window.", + "_comment": "ONE TREE PER CLASS, keyed by the class id in data/classes.json. `skills` is the tree in DISPLAY order (the order the console selects through); the GRAPH is the skills' own `requires` edges. Where each skill SITS is its own `node: { col, row }` — a grid cell, row 0 at the top of the console's tree pane and one row further down for every generation below it (`window.tree`). Leave `node` out and the console lays it out itself: roots across the top, each child one row under its deepest parent (js/skills/SkillModel.js → treeLayout). A class with no authored skills shows the console's empty-trees plate rather than a dead window.", "vanguard": { "_comment": "THE VANGUARD TREE — install 1 holds the two doctrines the class ships with: Ramming Charge (the opening move) and Reinforced Plating (the reason it survives one). Both are ROOTS: no `requires`, so a Vanguard that just locked in can spend into either immediately.", "skills": [ @@ -71,6 +71,7 @@ "maxPoints": 5, "requires": [], "seat": 1, + "node": { "col": 1, "row": 0 }, "description": "Charge the nearest enemy at 3x speed over 1 second at ramming speed. Inflict 4x regular damage upon hit, 2x additional damage per additional skill point.", "shortDescription": "Ram the nearest hostile for 4x weapon damage.", "effect": { @@ -103,6 +104,7 @@ "maxPoints": 5, "requires": [], "seat": null, + "node": { "col": 3, "row": 0 }, "description": "Strengthen your ship's hull, adding a 5% increase in hull strength, with an additional 5% increase per additional skill point.", "shortDescription": "+5% hull. +5% more per point.", "effect": { @@ -121,7 +123,7 @@ }, "window": { - "_comment": "THE SKILL TREE CONSOLE (js/ui/SkillWindow.js, depth 85 — the class console's height, over the ship console's 80, because it is opened FROM that console's Skill Tree tab). Same cabinet as every other window: LEFT the looping feed, RIGHT the plate over the class's tree.", + "_comment": "THE SKILL TREE CONSOLE (js/ui/SkillWindow.js, depth 80). Same cabinet as every other window, but a three-region body: the class PLATE upper-left, the TREE upper-right (a scrollable grid of icon tiles wearing n/m rank badges and unlock lines — see `tree` below), and the DETAIL drawer along the bottom third for whichever tile is selected (big icon left, copy right, ACTIONS lower-right).", "title": "SKILL TREE", "meta": "{class} // {points} SKILL POINTS", "feedLine": "COMBAT DOCTRINE // {class}", @@ -164,11 +166,89 @@ }, "lockedNote": "REQUIRES {parent}", "spendLabel": "+ RANK UP", + "_commentDetail": "THE DETAIL DRAWER (bottom third): what a selected tile says about itself, and the three actions in its lower-right corner. `detailPrompt` is the drawer with nothing selected; `addPointLabel` spends one point from the bank into the selected skill, `resetLabel` gives back ONLY what was ranked since this console opened, `assignLabel`/`unbindLabel` hand the skill to a combat-bar seat. Each action goes inert (grayed) when it has nothing to do — and `{points}` on the add button is the bank, so the player sees what the click costs.", + "detailPrompt": "SELECT A SKILL", + "detailPromptSub": "CLICK A TILE IN THE TREE ABOVE", + "addPointLabel": "+ ADD A SKILL POINT", + "addPointNoPoints": "NO POINTS LEFT", + "resetLabel": "RESET", + "_commentChip": "The two tallies the console keeps beside it: the bank in the title bar and the session's own pending spend in the drawer. Both are counts, so they live here as templates.", + "pointsChip": "{n} PTS", + "pendingNote": "+{n} THIS SESSION", + "resetToast": "{skill} — {n} {points} RETURNED TO THE BANK", + "cascadeToast": "{child} UNLOCKED WITH IT TOO", + "resetRefusals": { + "_comment": "Why RESET does nothing (and what the console says). `nothing` = this skill holds no point from THIS session; `noSession` = the ledger is not inside a console session (a save/load mid-window); `blocked` = giving these back would strand a skill that was bought under it.", + "nothing": "NOTHING TO RESET — {skill} WAS ALREADY AT {rank}", + "noSession": "NO OPEN SESSION TO RESET", + "blocked": "{child} IS SPOKEN FOR BY {skill} — RESET {child} FIRST" + }, + "sessionNote": "POINTS YOU SPEND HERE ARE HELD UNTIL THIS CONSOLE CLOSES — RESET GIVES BACK ONLY WHAT YOU RANKED IN THIS SESSION, AND CLOSING COMMITS THE REST", + "commitToast": "{n} {points} COMMITTED TO THE TREE", "assignLabel": "ASSIGN SEAT", "assignHint": "PICK A COMBAT BAR SEAT // 1-{maxSeat}", "assignedToast": "{skill} → SEAT {seat}", "unbindLabel": "CLEAR SEAT", - "refundNote": "POINTS SPENT ARE SPENT — RESPEC LANDS LATER", + "tree": { + "_comment": "THE TREE PANE (upper-right region): a GRID of icon tiles. `cols` is the authored grid width (a skill's own `node: {col,row}` places it; an unplaced skill falls into the auto-layout); the console fits `cols` cells across whatever room it has and clamps the tile between minPx and maxPx. Every look-knob below is the data's — the code restates none of them.", + "cols": 5, + "_commentRegions": "How the console's body is cut up: the tree pane takes the upper `splitFraction` of the body and the class plate `plateFraction` of its width (the drawer lives under both). The remaining third is the detail drawer.", + "regions": { "splitFraction": 0.66, "plateFraction": 0.27, "gapPx": 14 }, + "tile": { "minPx": 46, "maxPx": 92 }, + "gap": { "colPx": 34, "rowPx": 44, "padPx": 18 }, + "edge": { + "_comment": "The unlock lines between a parent tile and its child. `spent` = the requirement is met AND the child has a rank (the chosen path — the bright gold), `open` = the requirement is met but the child is still unranked, `locked` = the edge is not satisfied yet. Lines are drawn as an elbow: straight down out of the parent, across, straight into the child.", + "spentColor": "#ffd84d", + "spentAlpha": 0.95, + "openColor": "#7d92c4", + "openAlpha": 0.5, + "lockedColor": "#3d4c74", + "lockedAlpha": 0.42, + "widthPx": 4, + "notchPx": 8 + }, + "frame": { + "_comment": "A tile's frame, four states — the same idiom as the screenshot this console is dressed after: a lit bevel for the affordable, a gold bevel on the path, dead iron for locked, and a pulsing ring around the selected tile.", + "idleEdge": "#5f7099", + "availableEdge": "#cfe3f7", + "spentEdge": "#ffd84d", + "maxedEdge": "#7ce8a4", + "lockedEdge": "#2c3a5c", + "selectedEdge": "#00e5ff", + "faceColor": "#0b1424", + "faceAlpha": 0.92, + "bevelPx": 3, + "selectedPulseHz": 1.8 + }, + "badge": { + "_comment": "The n/m rank plate on a tile's lower-right corner (the screenshot's own badge). Drawn only when the skill holds more than one point.", + "color": "#eaf6ff", + "spentColor": "#ffd84d", + "bgColor": "#04060d", + "bgAlpha": 0.88, + "edgeColor": "#3d4c74", + "fontSizePx": 12 + }, + "_commentSocket": "Every cell inside the tree's bounds that holds NO skill is plated as a dim socket (the reference sheet's empty nodes): the authored grid stays legible when a tree is young instead of leaving icons adrift in an empty pane.", + "socket": { + "enabled": true, + "faceColor": "#080f1c", + "faceAlpha": 0.5, + "edgeColor": "#3d4c74", + "edgeAlpha": 0.35, + "tickPx": 7 + }, + "veil": { "lockedColor": "#050a13", "lockedAlpha": 0.62, "pendingGlow": "#ffd84d", "pendingAlpha": 0.5 }, + "scrollbar": { + "_comment": "The tree scrolls (wheel, drag, or the thumb) whenever the laid-out content is taller than the pane — a long install's tree must never be cut off.", + "wPx": 8, + "trackColor": "#0b1424", + "thumbColor": "#3f5a86", + "thumbHotColor": "#00e5ff", + "wheelPxPerNotch": 64 + }, + "hintLine": "WHEEL OR DRAG TO SCROLL THE TREE" + }, "emptyNote": "NO SKILLS ON FILE FOR THIS CLASS", "noClassNote": "PICK A CLASS AT COMBAT LEVEL {classAt} — THE TREE GROWS FROM THERE", "closeLabel": "◂ CLOSE", @@ -176,6 +256,8 @@ "_comment": "Rank-up + window sfx ids (data/sfx.json). spend = the point lands (the discovery chime), denied = a refused spend (the deconstruct voice — it has to sound like a step backwards), assign = a seat bound, open/close = the cabinet.", "spend": "discovery", "denied": "deconstruct", + "_commentRefund": "RESET is a step backwards, so it wears the same voice as a refusal — its own key only so the two decisions stay separable.", + "refund": "deconstruct", "assign": "ui_click", "open": "ui_window", "close": "ui_window" @@ -188,7 +270,7 @@ }, "_skillTemplate": { - "_comment": "THE CONTRACT one skill record owes the model (defaults in brackets when a key is missing/wrong): id [required — dropped without it], label [id], type ['Utility' → see types], frame [0], energy [0], cooldown [0], maxPoints [1], requires [[] — ids, or {skill, points} for a 'put N points into X first' edge], seat [null = no default binding], description [''], shortDescription [''], effect [{kind:'none'}].", + "_comment": "THE CONTRACT one skill record owes the model (defaults in brackets when a key is missing/wrong): id [required — dropped without it], label [id], type ['Utility' → see types], frame [0], energy [0], cooldown [0], maxPoints [1], requires [[] — ids, or {skill, points} for a 'put N points into X first' edge], seat [null = no default binding], node [{col,row} on the console's tree grid — auto-laid-out when absent], description [''], shortDescription [''], effect [{kind:'none'}].", "id": "skill-id", "label": "Display Name", "type": "Offense", @@ -198,6 +280,7 @@ "maxPoints": 5, "requires": ["ramming_charge"], "seat": null, + "node": { "col": 1, "row": 1 }, "description": "The full effect text the console prints.", "shortDescription": "One line for the tooltip / bar seat.", "effect": { "kind": "none" } diff --git a/dev/run-all.mjs b/dev/run-all.mjs index 2fa6023..d76c3d6 100644 --- a/dev/run-all.mjs +++ b/dev/run-all.mjs @@ -35,6 +35,9 @@ const TIMEOUT_MS = 120_000; // a test should never take this long * and refusing to fake a recharge means waiting one (data/skills.json → cooldown). */ const BUDGETS = { 'skills-bar-browser.test.mjs': 300_000, // seat screenshots + one real 6s recharge + // The skill console drives a real compositor: injected trees, wheel/drag input and + // a presented-frame pixel read for the join lines. + 'skills-window-browser.test.mjs': 240_000, }; const FILTER = argv .filter((a, i) => a !== '--jobs' && !(jobsIdx !== -1 && i === jobsIdx + 1)) diff --git a/dev/shots/skills-bar-assign.png b/dev/shots/skills-bar-assign.png index 586299c..13081ab 100644 Binary files a/dev/shots/skills-bar-assign.png and b/dev/shots/skills-bar-assign.png differ diff --git a/dev/shots/skills-bar-no-energy.png b/dev/shots/skills-bar-no-energy.png index a205ca5..e538399 100644 Binary files a/dev/shots/skills-bar-no-energy.png and b/dev/shots/skills-bar-no-energy.png differ diff --git a/dev/shots/skills-bar-ready.png b/dev/shots/skills-bar-ready.png index b7cbabc..974d9e6 100644 Binary files a/dev/shots/skills-bar-ready.png and b/dev/shots/skills-bar-ready.png differ diff --git a/dev/shots/skills-bar-recharging.png b/dev/shots/skills-bar-recharging.png index 485aa30..80b75dd 100644 Binary files a/dev/shots/skills-bar-recharging.png and b/dev/shots/skills-bar-recharging.png differ diff --git a/dev/shots/skills-bar.json b/dev/shots/skills-bar.json index 9a9c207..68a4493 100644 --- a/dev/shots/skills-bar.json +++ b/dev/shots/skills-bar.json @@ -10,6 +10,10 @@ "maxPoints": 5, "requires": [], "seat": 1, + "node": { + "col": 1, + "row": 0 + }, "description": "Charge the nearest enemy at 3x speed over 1 second at ramming speed. Inflict 4x regular damage upon hit, 2x additional damage per additional skill point.", "shortDescription": "Ram the nearest hostile for 4x weapon damage.", "effect": { @@ -42,6 +46,10 @@ "maxPoints": 5, "requires": [], "seat": null, + "node": { + "col": 3, + "row": 0 + }, "description": "Strengthen your ship's hull, adding a 5% increase in hull strength, with an additional 5% increase per additional skill point.", "shortDescription": "+5% hull. +5% more per point.", "effect": { @@ -282,8 +290,8 @@ "dashing": false, "down": false, "ship": { - "x": 1207.1227297313349, - "y": 1844.695206312328 + "x": 3810.5218340666174, + "y": 4088.361289263925 }, "ledgerSeats": [ "ramming_charge", @@ -329,8 +337,8 @@ "maxPoints": 5, "energy": 60, "cooldown": 6, - "cooldownLeft": 5.840039999999998, - "cooldownFrac": 0.02666000000000024, + "cooldownLeft": 5.722706666666667, + "cooldownFrac": 0.046215555555555454, "affordable": false, "ready": false, "inert": false @@ -534,7 +542,7 @@ "secs": "" } ], - "energy": 40.95975999999999, + "energy": 41.66376000000001, "maxEnergy": 100, "hull": 6000, "maxHull": 6000, @@ -542,8 +550,8 @@ "dashing": true, "down": false, "ship": { - "x": 948.7609222230176, - "y": 2147.887956982253 + "x": 3870.629477981919, + "y": 4033.226599552123 }, "ledgerSeats": [ "ramming_charge", @@ -796,14 +804,14 @@ ], "energy": 59.384, "maxEnergy": 100, - "hull": 111, + "hull": 129, "maxHull": 129, "level": 1, "dashing": false, "down": false, "ship": { - "x": 759.5978007842048, - "y": 2369.874664953772 + "x": 3893.832344512312, + "y": 4012.409104914982 }, "ledgerSeats": [ "ramming_charge", @@ -1054,16 +1062,16 @@ "secs": "" } ], - "energy": 67.44800000000001, + "energy": 68.21600000000001, "maxEnergy": 100, - "hull": 111, + "hull": 121, "maxHull": 129, "level": 1, "dashing": false, "down": false, "ship": { - "x": 759.5978007842048, - "y": 2369.874664953772 + "x": 3893.832344512312, + "y": 4012.409104914982 }, "ledgerSeats": [ null, @@ -1314,16 +1322,16 @@ "secs": "" } ], - "energy": 70.52000000000001, + "energy": 71.28800000000001, "maxEnergy": 100, - "hull": 111, + "hull": 81, "maxHull": 129, "level": 1, "dashing": false, "down": false, "ship": { - "x": 759.5978007842048, - "y": 2369.874664953772 + "x": 3893.832344512312, + "y": 4012.409104914982 }, "ledgerSeats": [ null, @@ -1574,16 +1582,16 @@ "secs": "" } ], - "energy": 73.20800000000001, + "energy": 73.97600000000001, "maxEnergy": 100, - "hull": 111, + "hull": 41, "maxHull": 129, "level": 1, "dashing": false, "down": false, "ship": { - "x": 759.5978007842048, - "y": 2369.874664953772 + "x": 3893.832344512312, + "y": 4012.409104914982 }, "ledgerSeats": [ "ramming_charge", diff --git a/dev/shots/skills-seat.png b/dev/shots/skills-seat.png index 2a528c6..618ff11 100644 Binary files a/dev/shots/skills-seat.png and b/dev/shots/skills-seat.png differ diff --git a/dev/shots/skills-window-chain.png b/dev/shots/skills-window-chain.png new file mode 100644 index 0000000..e11a7c9 Binary files /dev/null and b/dev/shots/skills-window-chain.png differ diff --git a/dev/shots/skills-window-closed.png b/dev/shots/skills-window-closed.png new file mode 100644 index 0000000..61bb9ef Binary files /dev/null and b/dev/shots/skills-window-closed.png differ diff --git a/dev/shots/skills-window-long-tree.png b/dev/shots/skills-window-long-tree.png new file mode 100644 index 0000000..6c08568 Binary files /dev/null and b/dev/shots/skills-window-long-tree.png differ diff --git a/dev/shots/skills-window-ranked.png b/dev/shots/skills-window-ranked.png index 1573551..cb6fee1 100644 Binary files a/dev/shots/skills-window-ranked.png and b/dev/shots/skills-window-ranked.png differ diff --git a/dev/shots/skills-window-selected.png b/dev/shots/skills-window-selected.png new file mode 100644 index 0000000..cca2346 Binary files /dev/null and b/dev/shots/skills-window-selected.png differ diff --git a/dev/shots/skills-window.json b/dev/shots/skills-window.json index b52755b..732237b 100644 --- a/dev/shots/skills-window.json +++ b/dev/shots/skills-window.json @@ -10,6 +10,10 @@ "maxPoints": 5, "requires": [], "seat": 1, + "node": { + "col": 1, + "row": 0 + }, "description": "Charge the nearest enemy at 3x speed over 1 second at ramming speed. Inflict 4x regular damage upon hit, 2x additional damage per additional skill point.", "shortDescription": "Ram the nearest hostile for 4x weapon damage.", "effect": { @@ -42,6 +46,10 @@ "maxPoints": 5, "requires": [], "seat": null, + "node": { + "col": 3, + "row": 0 + }, "description": "Strengthen your ship's hull, adding a 5% increase in hull strength, with an additional 5% increase per additional skill point.", "shortDescription": "+5% hull. +5% more per point.", "effect": { @@ -50,11 +58,67 @@ "hullBonusPerRank": 0.05 } }, + "regions": { + "panel": { + "x": 29, + "y": 29, + "w": 1222, + "h": 662 + }, + "plate": { + "x": 45, + "y": 85, + "w": 321, + "h": 389 + }, + "tree": { + "x": 380, + "y": 85, + "w": 855, + "h": 389 + }, + "drawer": { + "x": 45, + "y": 488, + "w": 1190, + "h": 187 + } + }, "final": { "open": false, "shipsOpen": false, "door": true, - "rows": 0, + "selected": null, + "regions": { + "panel": { + "x": 29, + "y": 29, + "w": 1222, + "h": 662 + }, + "plate": { + "x": 45, + "y": 85, + "w": 321, + "h": 389 + }, + "tree": { + "x": 380, + "y": 85, + "w": 855, + "h": 389 + }, + "drawer": { + "x": 45, + "y": 488, + "w": 1190, + "h": 187 + } + }, + "scroll": { + "scroll": 0, + "maxScroll": 0 + }, "snap": { "hasClass": true, "classId": "vanguard", @@ -63,8 +127,10 @@ "frame": 0, "iconKey": "class_icons", "iconReady": true, - "points": 7, - "spent": 3, + "points": 9, + "spent": 1, + "pending": 0, + "inSession": false, "seatCount": 8, "rows": [ { @@ -86,6 +152,10 @@ "maxPoints": 5, "requires": [], "seatDefault": 1, + "layout": { + "col": 1, + "row": 0 + }, "description": "Charge the nearest enemy at 3x speed over 1 second at ramming speed. Inflict 4x regular damage upon hit, 2x additional damage per additional skill point.", "shortDescription": "Ram the nearest hostile for 4x weapon damage.", "effect": { @@ -108,12 +178,15 @@ "passive": false, "castable": true }, - "rank": 2, + "id": "ramming_charge", + "rank": 1, "max": 5, "state": "upgradable", "locked": null, - "seat": null, - "spendable": true + "seat": 3, + "spendable": true, + "pending": 0, + "resettable": false }, { "def": { @@ -134,6 +207,10 @@ "maxPoints": 5, "requires": [], "seatDefault": null, + "layout": { + "col": 3, + "row": 0 + }, "description": "Strengthen your ship's hull, adding a 5% increase in hull strength, with an additional 5% increase per additional skill point.", "shortDescription": "+5% hull. +5% more per point.", "effect": { @@ -143,39 +220,44 @@ "passive": true, "castable": false }, - "rank": 1, + "id": "reinforced_plating", + "rank": 0, "max": 5, - "state": "upgradable", + "state": "available", "locked": null, "seat": null, - "spendable": true + "spendable": true, + "pending": 0, + "resettable": false } ] }, "hits": [ { - "kind": "rankUp", - "id": "ramming_charge", - "cx": 1106, - "cy": 240 + "kind": "close", + "id": null, + "cx": 1234, + "cy": 49 }, { - "kind": "assign", + "kind": "tile", "id": "ramming_charge", - "cx": 1014, - "cy": 240 + "cx": 745, + "cy": 279 }, { - "kind": "rankUp", + "kind": "tile", "id": "reinforced_plating", - "cx": 1106, - "cy": 382 + "cx": 997, + "cy": 279 } ], + "tiles": [], + "texts": [], "seats": [ null, null, - null, + "ramming_charge", null, null, null, @@ -183,9 +265,12 @@ null ], "ranks": { - "ramming_charge": 2, - "reinforced_plating": 1 + "ramming_charge": 1 }, - "points": 7 + "pending": { + "ramming_charge": 0 + }, + "points": 9, + "session": false } } \ No newline at end of file diff --git a/dev/skills-window-browser.test.mjs b/dev/skills-window-browser.test.mjs index f92cd9e..7cbac5e 100644 --- a/dev/skills-window-browser.test.mjs +++ b/dev/skills-window-browser.test.mjs @@ -1,14 +1,17 @@ /** - * dev/skills-window-browser.test.mjs — the SKILL TREE CONSOLE in a real browser: - * the ship console's door, the tree, spending points with RANK UP, handing a skill - * to a bar seat and getting back to the console. + * dev/skills-window-browser.test.mjs — the SKILL TREE CONSOLE in a real browser. + * + * The layout the player is promised (data/skills.json → window.tree): the class + * PLATE upper-left, the TREE of icon tiles upper-right (two-thirds of the body), + * the DETAIL DRAWER along the bottom third, and the three actions — RESET, + * ASSIGN/CLEAR SEAT, + ADD A SKILL POINT — in that drawer's lower-right corner. + * Presses are REAL mouse clicks on the rects the console publishes + * (SkillWindow.actionRects()) mapped through the canvas's scale; a REAL Digit press + * places the handed skill. The session rule is walked too: rank up, RESET gives it + * back while the console is open, closing commits what is left. * * node dev/server.mjs 8123 & * node dev/skills-window-browser.test.mjs - * - * Presses are REAL mouse clicks on the button rects the window publishes - * (SkillWindow.actionRects()) mapped through the canvas's scale, and a REAL Digit - * press places the handed skill — the flow a player takes. */ import fs from 'node:fs'; import { pathToFileURL } from 'node:url'; @@ -24,8 +27,11 @@ fs.mkdirSync(SHOTS, { recursive: true }); const check = (c, m) => { console.log(` ${c ? '✔' : '✘ FAIL'} ${m}`); if (!c) fails++; }; const data = (f) => JSON.parse(fs.readFileSync(new URL(`../data/${f}`, import.meta.url), 'utf8')); const skills = data('skills.json'); -const RAM = skills.classes.vanguard.skills.find((s) => s.effect?.kind === 'dashBlast'); -const PLATE = skills.classes.vanguard.skills.find((s) => s.effect?.kind === 'hullPool'); +const VANGUARD = skills.classes.vanguard; +const RAM = VANGUARD.skills.find((s) => s.effect?.kind === 'dashBlast'); +const PLATE = VANGUARD.skills.find((s) => s.effect?.kind === 'hullPool'); +const RAM_CELL = RAM.node ?? { col: 1, row: 0 }; +const PLATE_CELL = PLATE.node ?? { col: 3, row: 0 }; const browser = await chromium.launch({ headless: true, @@ -37,35 +43,94 @@ 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)); +/** Everything the console will say about itself, in one round-trip. */ const view = () => page.evaluate(() => { const g = window.game.scene.getScene('GameScene'); const w = g.skillWindow; + const texts = []; + const walk = (go) => { + if (go.type === 'Text' && go.text) texts.push({ text: String(go.text), bounds: go.getBounds() }); + for (const child of go.list ?? []) walk(child); + }; + if (w) walk(w); return { open: !!w?.isOpen, shipsOpen: !!g.shipWindow?.isOpen, door: g.shipScreenSnapshot().skillDoor, - rows: (w?._hits ? [] : []).length, + selected: w?.selected ?? null, + regions: w?.regions?.() ?? null, + scroll: w?.scrollState?.() ?? null, snap: w?.getSnapshot ? w.getSnapshot() : null, - hits: w?.actionRects?.() ?? [], + hits: (w?.actionRects?.() ?? []).map((h) => ({ kind: h.kind, id: h.id, cx: Math.round(h.cx), cy: Math.round(h.cy) })), + tiles: (w?._tree?.nodes ?? []).map((n) => ({ + id: n.id, + visible: !!n.visible, + lx: n.lx, + ly: n.ly, + size: n.size, + x: Math.round(n.rect.x), + y: Math.round(n.rect.y), + })), + texts: texts.map((t) => ({ text: t.text, r: Math.round(t.bounds.right), b: Math.round(t.bounds.bottom), l: Math.round(t.bounds.left), t: Math.round(t.bounds.top) })), seats: [...g.skillState.seats], ranks: Object.fromEntries(g.skillState.ranks), + pending: Object.fromEntries([...g.skillState.ranks].map(([id]) => [id, g.skillState.pendingOf(id)])), points: g.classState.skillPoints, + session: g.skillState.inSession, }; }); -/** A real click on one of the console's buttons (game point → page pixel). */ -const clickButton = async (kind, id) => { + +/** A real click at one of the console's published rects (game point → page pixel). */ +const clickAt = async (cx, cy) => { + const r = await page.evaluate(() => { + const c = document.querySelector('canvas').getBoundingClientRect(); + return { left: c.left, top: c.top, w: c.width, h: c.height, gw: window.game.scale.width, gh: window.game.scale.height }; + }); + await page.mouse.click(r.left + cx * (r.w / r.gw), r.top + cy * (r.h / r.gh)); + await page.waitForTimeout(150); +}; +const clickKind = async (kind, id) => { const pt = await page.evaluate(({ kind, id }) => { - const g = window.game.scene.getScene('GameScene'); - const h = (g.skillWindow.actionRects() ?? []).find((r) => r.kind === kind && r.id === id); - if (!h) return null; - const r = document.querySelector('canvas').getBoundingClientRect(); - return { x: r.left + h.cx * (r.width / window.game.scale.width), y: r.top + h.cy * (r.height / window.game.scale.height) }; + const w = window.game.scene.getScene('GameScene').skillWindow; + const h = (w.actionRects() ?? []).find((r) => r.kind === kind && (id === undefined || r.id === id)); + return h ? { cx: h.cx, cy: h.cy } : null; }, { kind, id }); if (!pt) return false; - await page.mouse.click(pt.x, pt.y); - await page.waitForTimeout(150); + await clickAt(pt.cx, pt.cy); return true; }; +/** Click a tile where it actually is (its rect moves with the scroll). */ +const clickTile = async (id) => { + const t = await page.evaluate((id) => { + const w = window.game.scene.getScene('GameScene').skillWindow; + const n = (w._tree?.nodes ?? []).find((n) => n.id === id); + return n && n.visible ? { cx: n.rect.x + n.size / 2, cy: n.rect.y + n.size / 2 } : null; + }, id); + if (!t) return false; + await clickAt(t.cx, t.cy); + return true; +}; +const selectTile = async (id) => { + await page.evaluate((id) => window.game.scene.getScene('GameScene').skillWindow.select(id), id); + await page.waitForTimeout(150); +}; + +/** ESC, with the comms politely stepped around: a comm owns the keyboard, so the + * escape is re-issued once the screen gives it back. */ +const closeComms = () => page.evaluate(() => { + const g = window.game.scene.getScene('GameScene'); + if (g.comms?.paused) g.comms._ack(); +}); +const pressEscape = async () => { + for (let i = 0; i < 4; i++) { + await closeComms(); + await page.waitForTimeout(200); + await page.keyboard.press('Escape'); + await page.waitForTimeout(250); + if (!(await page.evaluate(() => window.game.scene.getScene('GameScene').skillWindow.isOpen))) return true; + } + return false; +}; await page.goto(`${BASE}/index.html?skill&combat`, { waitUntil: 'load' }); await page.waitForFunction(() => window.game?.scene?.getScene?.('MenuScene')?.seedValue, null, { timeout: 90000 }); @@ -76,6 +141,8 @@ await page.waitForFunction(() => { return !!(g.skillState && g.classState?.hasClass && !g.comms?.paused); }, null, { timeout: 120000, polling: 400 }); +// ---------------------------------------------------------------- THE DOOR + // The ship console's SKILL TREE door is LIVE for a class that owns a tree. await page.evaluate(() => window.game.scene.getScene('GameScene').shipWindow.open()); await page.waitForFunction(() => window.game.scene.getScene('GameScene').shipWindow.isOpen, null, { timeout: 40000 }); @@ -97,113 +164,363 @@ await page.waitForFunction(() => (window.game.scene.getScene('GameScene').skillW await page.waitForFunction(() => !window.game.scene.getScene('GameScene').shipWindow.isOpen, null, { timeout: 40000 }); v = await view(); check(v.open === true && v.shipsOpen === false, 'pressing it opens the Skill Tree console and steps the ship console aside'); -check(v.snap.rows.length === 2 && v.snap.rows.every((r) => r.def?.label), `the tree lists the class's ${v.snap.rows.length} skills (${v.snap.rows.map((r) => r.def.label).join(', ')})`); -// No template ever reaches the screen half-filled. -const leaks = await page.evaluate(() => { - const w = window.game.scene.getScene('GameScene').skillWindow; - const out = []; - w.list.forEach((go) => { if (go.type === 'Text' && /\{\w+\}/.test(go.text ?? '')) out.push(go.text); }); - return out; +check(v.snap.rows.length === 2 && v.snap.rows.every((r) => r.def?.label), `the tree carries the class's ${v.snap.rows.length} skills (${v.snap.rows.map((r) => r.def.label).join(', ')})`); +check(v.session === true, 'opening the console opens a SESSION (SkillState.beginSession) — the snapshot RESET unwinds to'); + +// --------------------------------------------------------------- THE LAYOUT + +const reg = v.regions; +check(reg.tree.x > reg.plate.x + reg.plate.w && reg.tree.y === reg.plate.y, `the tree sits to the RIGHT of the class plate, top-aligned with it (tree x ${reg.tree.x}, plate ${reg.plate.x}+${reg.plate.w})`); +check( + reg.tree.w > reg.plate.w * 2 && Math.abs(reg.tree.x + reg.tree.w - (reg.panel.x + reg.panel.w) + 17) < 4, + `the tree is the upper-right two-thirds (${reg.tree.w}px across against the plate's ${reg.plate.w}px, flush with the panel's inner edge)`, +); +check( + reg.drawer.h / (reg.panel.h - 40 - 32) >= 0.3 && reg.drawer.y > reg.tree.y + reg.tree.h, + `the detail drawer is the bottom third (${reg.drawer.h}px of ${reg.panel.h - 40 - 32}px, below the tree at y ${reg.tree.y + reg.tree.h})`, +); +check(v.tiles.length === 2 && v.tiles.every((t) => t.visible), `both tiles are on the grid (${v.tiles.map((t) => `${t.id}@${t.lx},${t.ly}`).join(' ')})`); +const GRID = skills.window.tree.gap; // the tile pitch is the data's, not a guess +const cellOf = (t) => ({ + col: Math.round((t.lx - v.tiles[0].lx) / (t.size + GRID.colPx)), + row: Math.round((t.ly - v.tiles[0].ly) / (t.size + GRID.rowPx)), }); +const ramTile = v.tiles.find((t) => t.id === RAM.id); +const plateTile = v.tiles.find((t) => t.id === PLATE.id); +check( + cellOf(ramTile).col === 0 && cellOf(plateTile).col - cellOf(ramTile).col === PLATE_CELL.col - RAM_CELL.col && cellOf(plateTile).row === cellOf(ramTile).row, + `the tiles sit on the grid the data authored (${RAM.id} col ${RAM_CELL.col}, ${PLATE.id} col ${PLATE_CELL.col} → ${(cellOf(ramTile).col, cellOf(plateTile).col)})`, +); +check(v.tiles.every((t) => t.x >= reg.tree.x - 1 && t.x + t.size <= reg.tree.x + reg.tree.w + 1), 'no tile crosses out of the tree pane'); + +// No template ever reaches the screen half-filled. +const leaks = v.texts.filter((t) => /\{\w+\}/.test(t.text)).map((t) => t.text); check(leaks.length === 0, `every template is filled on the face${leaks.length ? ': ' + leaks.join(' / ') : ''}`); + // The console's own blocks are LIVE: the feed tag (no clip authored → NO SIGNAL) and // window.fx's sweep/glitch, which must paint at least once while the console is up. const live = await page.evaluate(async () => { - const g = window.game.scene.getScene('GameScene'); - const w = g.skillWindow; - const texts = []; - w.list.forEach((go) => { if (go.type === 'Text' && go.text) texts.push(String(go.text)); }); + const w = window.game.scene.getScene('GameScene').skillWindow; const t0 = { ...(w.fxPainted?.() ?? {}) }; await new Promise((r) => setTimeout(r, 9000)); // long enough to cross the data's own windows - return { texts, before: t0, after: w.fxPainted() }; + return { before: t0, after: w.fxPainted() }; }); -check(live.texts.some((t) => t.includes(live.after.feedTag)), `the plate prints the feed's own tag (${live.after.feedTag}) — window.video is read, not decorative`); +check(v.texts.some((t) => t.text.includes(live.after.feedTag)), `the plate prints the feed's own tag (${live.after.feedTag}) — window.video is read, not decorative`); check(live.after.sweeps > live.before.sweeps || live.after.glitches > live.before.glitches, `window.fx is live off its data timings (sweeps ${live.before.sweeps} → ${live.after.sweeps}, glitches ${live.before.glitches} → ${live.after.glitches})`); -// Nothing hangs off the panel: every string stays inside its console. -const spill = await page.evaluate(() => { - const g = window.game.scene.getScene('GameScene'); - const w = g.skillWindow; - const pad = Math.max(16, Math.round(Math.min(g.scale.width, g.scale.height) * 0.05)); - const right = (g.scale.width - Math.min(1080, g.scale.width - pad * 2)) / 2 + Math.min(1080, g.scale.width - pad * 2); - const bottom = (g.scale.height - Math.min(620, g.scale.height - pad * 2)) / 2 + Math.min(620, g.scale.height - pad * 2); - const out = []; - w.list.forEach((go) => { - if (go.type !== 'Text') return; - const r = go.getBounds(); - if (r.right > right + 1 || r.bottom > bottom + 1) out.push(`${String(go.text).slice(0, 22)}@${Math.round(r.right)},${Math.round(r.bottom)}`); - }); - return { out, right: Math.round(right), bottom: Math.round(bottom) }; -}); -check(spill.out.length === 0, `every line stays inside the panel (${spill.out.join(' | ')})`); -const rankBtn = v.hits.filter((h) => h.kind === 'rankUp').map((h) => h.id); -check(rankBtn.includes(RAM.id) && rankBtn.includes(PLATE.id), 'every skill wears a RANK UP action'); -// SPEND: two real clicks on Ramming Charge's RANK UP = 2/5 and the bank pays. +// Nothing hangs off the panel: every string stays inside the cabinet. +const spill = { out: [], right: reg.panel.x + reg.panel.w, bottom: reg.panel.y + reg.panel.h }; +for (const t of v.texts) if (t.r > spill.right + 1 || t.b > spill.bottom + 1 || t.l < reg.panel.x - 1 || t.t < reg.panel.y - 1) spill.out.push(`${t.text.slice(0, 22)}@${t.r},${t.b}`); +check(spill.out.length === 0, `every line stays inside the panel (${spill.out.join(' | ')})`); + +// ------------------------------------------------------- THE DRAWER (SELECT) + +await clickTile(RAM.id); +v = await view(); +check(v.selected === RAM.id, 'a tile click makes that skill the drawer\u2019s subject'); +const drawerKinds = v.hits.filter((h) => h.cy > reg.drawer.y).map((h) => h.kind); +check( + drawerKinds.includes('rankUp') && drawerKinds.includes('reset') && (drawerKinds.includes('clearSeat') || drawerKinds.includes('assign')), + `the drawer carries the three actions lower-right (${[...new Set(drawerKinds)].join(', ')})`, +); +const rankBtn = v.hits.find((h) => h.kind === 'rankUp' && h.id === RAM.id); +check(!!rankBtn && rankBtn.cy > reg.drawer.y, 'the ADD A SKILL POINT button belongs to the selected skill'); +await page.screenshot({ path: `${SHOTS}skills-window-selected.png`, timeout: 60000 }); + +// -------------------------------------------------- SPEND · RESET · COMMIT + const pts0 = v.points; -await clickButton('rankUp', RAM.id); +await clickKind('rankUp', RAM.id); await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 1, { timeout: 40000 }); -await clickButton('rankUp', RAM.id); +await clickKind('rankUp', RAM.id); await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 2, { timeout: 40000 }); v = await view(); -check(v.ranks[RAM.id] === 2, `two RANK UP clicks ranked ${RAM.label} to ${v.ranks[RAM.id]}/5`); -check(v.points === pts0 - 2, `the bank paid one point per click (${pts0} → ${v.points})`); -// BOTH skills take points from the same bank — and the PASSIVE is live the moment -// its first rank lands (the hull pool grows; nothing else changed it). -const hullBefore = await page.evaluate(() => window.game.scene.getScene('GameScene').combat?.maxHull ?? 0); -await clickButton('rankUp', PLATE.id); +check(v.ranks[RAM.id] === 2, `two ADD A SKILL POINT clicks ranked ${RAM.label} to ${v.ranks[RAM.id]}/5`); +check(v.points === pts0 - 2 && v.pending[RAM.id] === 2, `the bank paid and the session is holding both points (pending ${JSON.stringify(v.pending)})`); + +// Re-clicking the SELECTED tile spends too (one control, two hands). +await clickTile(RAM.id); await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 3, { timeout: 40000 }); -await page.waitForTimeout(250); v = await view(); +check(v.ranks[RAM.id] === 3, `re-clicking the selected tile spent a third point (${v.ranks[RAM.id]}/5)`); + +// RESET hands back ONLY what this session ranked. +await clickKind('reset', RAM.id); +await page.waitForTimeout(200); +v = await view(); +check((v.ranks[RAM.id] ?? 0) === 0 && v.points === pts0, `RESET gave the session's ${RAM.label} points straight back (rank ${v.ranks[RAM.id] ?? 0}, bank ${v.points}/${pts0})`); + +// The PASSIVE is live the moment its first rank lands (the hull pool grows). +const hullBefore = await page.evaluate(() => window.game.scene.getScene('GameScene').combat?.maxHull ?? 0); +await selectTile(PLATE.id); +v = await view(); +check(v.selected === PLATE.id && !v.hits.some((h) => h.id === PLATE.id && (h.kind === 'assign' || h.kind === 'clearSeat')), `${PLATE.label} (PASSIVE) gets no seat action in the drawer`); +await clickKind('rankUp', PLATE.id); +await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 1, { timeout: 40000 }); +await page.waitForTimeout(250); const hullAfter = await page.evaluate(() => window.game.scene.getScene('GameScene').combat?.maxHull ?? 0); -check(v.ranks[PLATE.id] === 1, `${PLATE.label} ranked to 1/5 from the console (bank ${pts0 - 3})`); +v = await view(); +check(v.ranks[PLATE.id] === 1, `${PLATE.label} ranked to ${v.ranks[PLATE.id]}/5 from the drawer`); check(hullAfter > hullBefore, `the PASSIVE is live at rank 1 — the hull pool went ${hullBefore} → ${hullAfter}`); await page.screenshot({ path: `${SHOTS}skills-window-ranked.png`, timeout: 60000 }); -await page.screenshot({ path: `${SHOTS}skills-window.png`, timeout: 60000 }); +// And RESET takes the hull back down again — the seam runs both ways. +await clickKind('reset', PLATE.id); +await page.waitForTimeout(300); +const hullReset = await page.evaluate(() => window.game.scene.getScene('GameScene').combat?.maxHull ?? 0); +check(hullReset <= hullBefore, `RESET re-rolled the passives (${hullAfter} → ${hullReset})`); -// THE HAND-OFF: CLEAR SEAT frees the default binding, ASSIGN SEAT raises the -// hand (the console steps aside) and Digit 3 places it. -await clickButton('clearSeat', RAM.id); -await page.waitForFunction((id) => !window.game.scene.getScene('GameScene').skillState.seats.includes(id), RAM.id, { timeout: 40000 }); -const handed = await clickButton('assign', RAM.id); +// Closing COMMITS: rank one skill up, close the console, and the point is spent. +await selectTile(RAM.id); +await clickKind('rankUp', RAM.id); +await page.waitForFunction((p) => window.game.scene.getScene('GameScene').classState.skillPoints === p, pts0 - 1, { timeout: 40000 }); +const closedByEscape = await pressEscape(); +check(closedByEscape, 'ESC closes the console'); +v = await view(); +check(v.session === false && v.points === pts0 - 1 && v.ranks[RAM.id] === 1, `closing the console committed it (rank ${v.ranks[RAM.id]}, bank ${pts0 - 1}, session ${v.session})`); + +// Re-opening files a NEW session: the committed rank is not refundable. +await page.evaluate(() => window.game.scene.getScene('GameScene').openSkillWindow({ source: 'test' })); +await page.waitForFunction(() => window.game.scene.getScene('GameScene').skillWindow.isOpen, null, { timeout: 40000 }); +v = await view(); +check(v.session === true && Object.values(v.pending).every((n) => n === 0), 'a fresh session has nothing pending — the committed rank is outside RESET'); +await clickKind('reset', RAM.id); +await page.waitForTimeout(200); +v = await view(); +check(v.ranks[RAM.id] === 1 && v.points === pts0 - 1, 'RESET on a committed rank says no and moves nothing'); + +// --------------------------------------------------------------- THE HAND-OFF + +// CLEAR SEAT frees the default binding, ASSIGN SEAT raises the hand (the console +// steps aside — and does NOT commit) and Digit 3 places it. +await selectTile(RAM.id); +v = await view(); +if (v.seats.includes(RAM.id)) { + await clickKind('clearSeat', RAM.id); + await page.waitForFunction((id) => !window.game.scene.getScene('GameScene').skillState.seats.includes(id), RAM.id, { timeout: 40000 }); +} +const pendingBefore = (await view()).points; +await selectTile(RAM.id); +const handed = await clickKind('assign', RAM.id); await page.waitForFunction(() => window.game.scene.getScene('GameScene').combatBar?.awaitingSeat === true, null, { timeout: 40000 }); check(handed, 'ASSIGN SEAT put the skill in the hand (the bar is waiting for a seat)'); const closedForAssign = await view(); check(closedForAssign.open === false, 'the console stepped aside so the bar could be seen'); await page.keyboard.press('Digit3'); await page.waitForFunction((id) => window.game.scene.getScene('GameScene').skillState.seats[2] === id, RAM.id, { timeout: 40000 }); -v = await view(); -check(v.seats[2] === RAM.id && v.seats[0] === null, `${RAM.label} bound to seat 3 and released its default seat 1`); - -// Back in the console: the row now reads SEAT 3 with a CLEAR SEAT action. -await page.evaluate(() => window.game.scene.getScene('GameScene').openSkillWindow({ source: 'test' })); await page.waitForFunction(() => window.game.scene.getScene('GameScene').skillWindow.isOpen, null, { timeout: 40000 }); v = await view(); -const clear = v.hits.find((h) => h.kind === 'clearSeat' && h.id === RAM.id); -check(!!clear, 'the bound row offers CLEAR SEAT (and no ASSIGN while it holds a seat)'); -await clickButton('clearSeat', RAM.id); -await page.waitForFunction((id) => !window.game.scene.getScene('GameScene').skillState.seats.includes(id), RAM.id, { timeout: 40000 }); -check(true, `CLEAR SEAT cleared it (ranks keep standing: ${JSON.stringify((await view()).ranks)})`); +check(v.seats[2] === RAM.id && v.seats[0] === null, `${RAM.label} bound to seat 3 and released its default seat 1`); +check(v.points === pendingBefore && v.session === true, 'the hand-off did NOT commit the session (still undoable after the bar took its pick)'); -// A PASSIVE never gets the seat action at all. -const passiveRow = await page.evaluate((id) => { - const g = window.game.scene.getScene('GameScene'); - return (g.skillWindow.actionRects() ?? []).filter((h) => h.id === id).map((h) => h.kind); -}, PLATE.id); -check(passiveRow.length === 1 && passiveRow[0] === 'rankUp', `${PLATE.label} (PASSIVE) has no seat action — only RANK UP`); +// Back in the console: the tile that holds a seat offers CLEAR SEAT again. +await selectTile(RAM.id); +v = await view(); +check(!!v.hits.find((h) => h.kind === 'clearSeat' && h.id === RAM.id), 'the bound skill offers CLEAR SEAT (and no ASSIGN while it holds a seat)'); -// ESC closes the console and gives the world back (comms first: a comm owns the -// input, so the escape never reaches the window while one is up). -const clearComms = () => page.evaluate(() => { - const g = window.game.scene.getScene('GameScene'); - if (g.comms?.paused) g.comms._ack(); +// ------------------------------------------------------------- THE SCROLLER + +// The tree pane is a SCROLLER: content taller than the pane must scroll (wheel, +// drag, thumb), cull what cannot fit whole and move its thumb. Two Vanguard skills +// do not exercise that, so for the duration of these checks the console is shown a +// long tree — a view-only substitution (getSnapshot is swapped and given back; the +// ledger is never touched). +const injectLongTree = () => page.evaluate((count) => { + const w = window.game.scene.getScene('GameScene').skillWindow; + window.__realSnapshot = window.__realSnapshot ?? w.getSnapshot; + const defs = Array.from({ length: count }, (_, i) => ({ + id: `probe_${i}`, label: `PROBE ${i}`, frame: i % 8, maxPoints: 5, + type: { key: 'offense', label: 'OFFENSE', accentCss: '#ff8a3d' }, + energy: 40 + i, cooldown: 3, description: 'A probe skill, for the scroller.', + node: { col: i % 5, row: Math.floor(i / 5) }, requires: [], + })); + w.getSnapshot = () => ({ + hasClass: true, classId: 'vanguard', className: 'VANGUARD', points: 4, iconReady: true, + frame: 0, seatCount: 8, pending: 0, + rows: defs.map((def) => ({ id: def.id, def, rank: 1, max: 5, state: 'upgradable', seat: null, pending: 0, locked: null })), + }); + w._sig = ''; + w.refresh(); + const out = (w._tree?.nodes ?? []).map((n) => ({ id: n.id, visible: !!n.visible, y: Math.round(n.rect.y), bottom: Math.round(n.rect.y + n.size) })); + return { regions: w.regions(), scroll: w.scrollState(), tiles: out }; +}, 22); +const restoreSnapshot = () => page.evaluate(() => { + const w = window.game.scene.getScene('GameScene').skillWindow; + if (window.__realSnapshot) w.getSnapshot = window.__realSnapshot; + w._sig = ''; + w.refresh(); }); -await clearComms(); -await page.waitForTimeout(250); -await page.keyboard.press('Escape'); -await clearComms(); -await page.waitForFunction(() => !window.game.scene.getScene('GameScene').skillWindow.isOpen, null, { timeout: 40000 }); + +const long = await injectLongTree(); +check(long.scroll.maxScroll > 0, `a tree taller than the pane reports ${long.scroll.maxScroll}px of overflow (pane ${long.regions.tree.h}px tall)`); +const shownTop = long.tiles.filter((t) => t.visible); +check(shownTop.length > 0 && shownTop.every((t) => t.y >= long.regions.tree.y - 1 && t.bottom <= long.regions.tree.y + long.regions.tree.h + 1), `${shownTop.length} tiles fit the pane and every one of them is inside it`); +check(long.tiles.length - shownTop.length > 0, `${long.tiles.length - shownTop.length} tiles are off-pane and drawn away (this build has no clip mask)`); +await page.screenshot({ path: `${SHOTS}skills-window-long-tree.png`, timeout: 60000 }); + +// A REAL wheel over the pane scrolls it (the pointer is put inside the tree region). +const before = (await view()).scroll.scroll; +await page.mouse.move(long.regions.tree.x + long.regions.tree.w / 2, long.regions.tree.y + long.regions.tree.h / 2); +await page.mouse.wheel(0, 240); +await page.waitForTimeout(200); +const afterWheel = (await view()).scroll; +check(afterWheel.scroll > before, `a wheel notch over the pane scrolled it (${before} → ${afterWheel.scroll} of ${afterWheel.maxScroll})`); + +// The thumb and the scroll surface exist for an overflowing pane… +const thumb = await page.evaluate(() => { + const w = window.game.scene.getScene('GameScene').skillWindow; + return (w.actionRects() ?? []).filter((h) => h.kind === 'scroll').map((h) => ({ cx: Math.round(h.cx), cy: Math.round(h.cy) })); +}); +check(thumb.length === 1, `an overflowing pane grows a thumb the player can hit (${JSON.stringify(thumb)})`); +if (thumb.length === 1) { + await clickAt(thumb[0].cx, long.regions.tree.y + long.regions.tree.h - 6); // bottom of the track + const atBottom = await view(); + check(atBottom.scroll.scroll > afterWheel.scroll, `clicking the bottom of the track takes the pane there (${afterWheel.scroll} → ${atBottom.scroll.scroll})`); +} + +// …and dragging the grid itself drags the content (the press starts on bare pane, +// 120px down means 120px of content, and nothing gets selected on the way out). +const gapY = await page.evaluate((pane) => { + const w = window.game.scene.getScene('GameScene').skillWindow; + const shown = (w._tree?.nodes ?? []).filter((n) => n.visible).map((n) => n.rect.y); + const sizes = (w._tree?.nodes ?? []).map((n) => n.size); + const size = sizes[0] ?? 60; + for (const y of shown) { + const after = y + size + 4; // just below a row: pane, not tile + if (after > pane.y + 8 && after < pane.y + pane.h - 8) return Math.round(after); + } + return Math.round(pane.y + pane.h / 2); +}, long.regions.tree); +const beforeDrag = (await view()).scroll; +const treeCX = long.regions.tree.x + long.regions.tree.w / 2; +const selectedBefore = (await view()).selected; +// Page pixels are not game pixels (SCALE_FIT): the drag is stated in page pixels and +// checked in the game space the pane works in. +const zoom = await page.evaluate(() => { + const c = document.querySelector('canvas').getBoundingClientRect(); + return window.game.scale.height / c.height; +}); +const DRAG_PX = 120; +await page.mouse.move(treeCX, gapY); +await page.mouse.down(); +await page.mouse.move(treeCX, gapY + DRAG_PX, { steps: 8 }); +await page.mouse.up(); +await page.waitForTimeout(200); +const dragged = await view(); +check( + Math.abs(dragged.scroll.scroll - Math.max(0, beforeDrag.scroll - DRAG_PX * zoom)) <= 4, + `dragging the grid pulls the content with it (${beforeDrag.scroll} → ${dragged.scroll.scroll}, ${Math.round(DRAG_PX * zoom)} game px of drag)`, +); +check(dragged.selected === selectedBefore, 'a drag is a drag: nothing was selected on the way out'); + +// Selecting a tile that is off-pane brings it into view. +const reveal = await page.evaluate(() => { + const w = window.game.scene.getScene('GameScene').skillWindow; + const far = w._tree.nodes[w._tree.nodes.length - 1]; + const hidden = !far.visible; + w.select(far.id); + return { id: far.id, hidden, before: w.scrollState() }; +}); +await page.waitForTimeout(200); +const revealed = await view(); +const nowShown = revealed.tiles.find((t) => t.id === reveal.id); +check(reveal.hidden && nowShown?.visible === true, `selecting an off-pane tile scrolls it into view (${reveal.id}, scroll ${reveal.before.scroll} → ${revealed.scroll.scroll})`); +// -------------------------------------------------------------- THE JOINS +// PIXEL PROOF that the elbow line between two skills is actually ON screen in +// the data's spent colour. Graphics leaves no display-list trace a test can +// read, and this build has no clip mask, so the presented frame is copied into +// a 2D canvas (the raw WebGL backbuffer reads blank once presented — the same +// hook as encounter-hud-check) and the pixels along the joins are counted. +const injectChain = () => page.evaluate(() => { + const w = window.game.scene.getScene('GameScene').skillWindow; + window.__realSnapshot = window.__realSnapshot ?? w.getSnapshot; + const defs = [0, 1, 2].map((i) => ({ + id: `chain_${i}`, label: `CHAIN ${i + 1}`, frame: i, maxPoints: 5, + type: { key: 'offense', label: 'OFFENSE', accentCss: '#ff8a3d' }, + energy: 40, cooldown: 3, description: 'A skill chained below another.', + node: { col: 0, row: i }, requires: i === 0 ? [] : [{ skill: `chain_${i - 1}`, rank: 1 }], + })); + const ranks = [2, 1, 0]; + w.getSnapshot = () => ({ + hasClass: true, classId: 'vanguard', className: 'VANGUARD', points: 4, iconReady: true, + frame: 0, seatCount: 8, pending: 0, + rows: defs.map((def, i) => ({ id: def.id, def, rank: ranks[i], max: 5, state: 'upgradable', seat: null, pending: 0, locked: null })), + }); + w._sig = ''; + w.refresh(); + const T = w._tree; + return { + nodes: T.nodes.map((n) => ({ id: n.id, x: Math.round(n.rect.x), y: Math.round(n.rect.y), size: Math.round(n.size), visible: !!n.visible })), + edges: T.layout.edges.map((e) => `${e.from}\u2192${e.to}`), + }; +}); + +const chain = await injectChain(); +check( + chain.edges.length === 2 && chain.edges[0].startsWith('chain_0\u2192chain_1'), + `the layout joins the chain: ${chain.edges.join(', ')}`, +); + +const gold = await page.evaluate(() => { + const ren = window.game.renderer; + const cv = ren.canvas; + const off = document.createElement('canvas'); + off.width = cv.width; off.height = cv.height; + const octx = off.getContext('2d'); + const orig = ren.render; + return new Promise((done) => { + // A copy is taken after every present, so the frame read is a REAL frame. + ren.render = function (...a) { + const ret = orig.apply(this, a); + try { octx.drawImage(cv, 0, 0); } catch { /* one bad frame — ignored */ } + return ret; + }; + setTimeout(() => { + ren.render = orig; + const T = window.game.scene.getScene('GameScene').skillWindow._tree; + const sx = cv.width / window.game.scale.width, sy = cv.height / window.game.scale.height; + const count = (x, y, w2, h2, test) => { + const X = Math.max(0, Math.round(x * sx)), Y = Math.max(0, Math.round(y * sy)); + const W = Math.max(1, Math.round(w2 * sx)), H = Math.max(1, Math.round(h2 * sy)); + const d = octx.getImageData(X, Y, W, H).data; + let n = 0; + for (let i = 0; i < d.length; i += 4) if (test(d[i], d[i + 1], d[i + 2])) n++; + return n; + }; + const GOLD = (r, g, b) => r > 185 && g > 145 && b < 150 && r - b > 70; + let onJoin = 0, offJoin = 0; + for (const a of T.nodes) { + const child = T.nodes.find((c) => (c.row?.def?.requires ?? []).some((q) => q.skill === a.id)); + if (!child || !a.visible || !child.visible) continue; + // The gap BETWEEN the two tiles: the elbow has to cross it. Tile frames are + // stroked inside the tile, so nothing lit of its own lives in this band. + const x0 = Math.min(a.rect.x, child.rect.x) - 4; + const x1 = Math.max(a.rect.x + a.size, child.rect.x + child.size) + 4; + const y0 = a.rect.y + a.size, y1 = child.rect.y; + onJoin += count(x0, y0, x1 - x0, Math.max(1, y1 - y0), GOLD); + // The same-sized band one column to the LEFT: the control that proves the + // gold belongs to the join and not to whatever else glitters on screen. + offJoin += count(x0 - (x1 - x0) - 8, y0, x1 - x0, Math.max(1, y1 - y0), GOLD); + } + done({ onJoin, offJoin }); + }, 700); + }); +}); +check( + gold.onJoin >= 8 && gold.offJoin === 0, + `a completed join is PAINTED across the gap in the spent colour (${gold.onJoin} gold px between the tiles, ${gold.offJoin} in the same band beside it)`, +); +await page.screenshot({ path: `${SHOTS}skills-window-chain.png`, timeout: 60000 }); + +await restoreSnapshot(); +v = await view(); +check(v.tiles.length === 2 && v.scroll.maxScroll === 0, `the real tree comes back whole (${v.tiles.length} tiles, no overflow)`); + +// ---------------------------------------------------------------- ESC CLOSE + +const escaped = await pressEscape(); const after = await view(); +check(escaped, 'ESC closes the console from the drawer too'); // What still owns the screen? (A comm or the level marquee can legitimately be up // after a combat wave — the console must not be one of them.) const busy = await page.evaluate(() => { @@ -213,7 +530,8 @@ const busy = await page.evaluate(() => { check(after.open === false && busy.by.skill === false, `ESC closes the console and it stops owning the screen (still up: ${JSON.stringify(busy)})`); check(errors.length === 0, `no console errors${errors.length ? ': ' + errors.slice(0, 3).join(' | ') : ''}`); -fs.writeFileSync(new URL('./shots/skills-window.json', import.meta.url), JSON.stringify({ ram: RAM, plate: PLATE, final: after }, null, 2)); +await page.screenshot({ path: `${SHOTS}skills-window-closed.png`, timeout: 60000 }); +fs.writeFileSync(new URL('./shots/skills-window.json', import.meta.url), JSON.stringify({ ram: RAM, plate: PLATE, regions: reg, final: after }, null, 2)); await browser.close(); console.log(fails ? `\nskills-window-browser: ${fails} FAILURE(S)` : '\nskills-window-browser: all checks passed'); process.exit(fails ? 1 : 0); diff --git a/dev/skills.test.mjs b/dev/skills.test.mjs index 697109b..ef30efa 100644 --- a/dev/skills.test.mjs +++ b/dev/skills.test.mjs @@ -175,9 +175,38 @@ check( // ---- the console's copy -------------------------------------------- const win = skills.window ?? {}; -for (const k of ['title', 'meta', 'treeTitle', 'pointsLine', 'rankLine', 'lockedNote', 'emptyNote']) { +for (const k of ['title', 'meta', 'treeTitle', 'pointsLine', 'rankLine', 'lockedNote', 'emptyNote', + 'detailPrompt', 'detailPromptSub', 'addPointLabel', 'addPointNoPoints', 'resetLabel', + 'assignLabel', 'unbindLabel', 'sessionNote', 'commitToast', 'pointsChip', 'pendingNote']) { check(`window: ${k} is authored`, typeof win[k] === 'string' && win[k].length > 0); } +// The session's ANSWERS (the door says why it did nothing, the ledger says what it kept). +check('window: the reset toasts are authored with their template keys', + /\{skill\}/.test(win.resetToast ?? '') && /\{child\}/.test(win.cascadeToast ?? '') && /\{n\}/.test(win.commitToast ?? '')); +check('window: every RESET refusal is authored', ['nothing', 'noSession', 'blocked'].every((k) => typeof win.resetRefusals?.[k] === 'string' && win.resetRefusals[k].length > 0)); +// The restyled console reads its GEOMETRY from the data too: three regions, square +// tiles, the join colours, a scroller. Every value here is a knob the console honours +// verbatim (windowSpec clamps it into range, nothing more). +{ + const t = win.tree ?? {}; + const chex = /^#[0-9a-f]{6}$/; + check('window.tree: cols/regions are sane numbers', Number.isInteger(t.cols) && t.cols >= 3 && t.regions.splitFraction > 0.5 && t.regions.splitFraction < 0.9 && t.regions.plateFraction > 0.1 && t.regions.plateFraction < 0.5); + check('window.tree: tiles are SQUARE with a data-bounded size', Number.isInteger(t.tile.minPx) && t.tile.maxPx > t.tile.minPx && Number.isInteger(t.gap.colPx) && Number.isInteger(t.gap.rowPx)); + check('window.tree: the join carries one colour per state', ['spentColor', 'openColor', 'lockedColor'].every((k) => chex.test(t.edge?.[k] ?? '')) && t.edge.notchPx < t.gap.colPx); + check('window.tree: every tile frame state has a colour', ['idleEdge', 'availableEdge', 'spentEdge', 'maxedEdge', 'lockedEdge', 'selectedEdge', 'faceColor'].every((k) => chex.test(t.frame?.[k] ?? ''))); + check('window.tree: the rank badge ("n/m") is authored', chex.test(t.badge?.color ?? '') && chex.test(t.badge?.spentColor ?? '')); + check('window.tree: empty cells are plated as sockets', t.socket?.enabled === true && chex.test(t.socket.edgeColor ?? '') && t.socket.tickPx >= 0); + check('window.tree: the scroller is authored', t.scrollbar.wPx >= 4 && Number.isInteger(t.scrollbar.wheelPxPerNotch) && chex.test(t.scrollbar.thumbColor ?? '')); + // The grid must be square in the data's own units: column and row gaps are both px. + check('window.tree: hintLine explains the scroll', typeof t.hintLine === 'string' && /scroll/i.test(t.hintLine)); +} +// Skills carry their authored cell, or the console falls back to graph depth — so a +// tree whose cells ARE authored must say so in every entry or none. +{ + const cells = (skills.classes.vanguard.skills ?? []).map((s) => s.node ?? null); + check('data: an authored grid is a grid (every skill has {col,row}, or nobody does)', + cells.every(Boolean) === true && cells.every((n) => Number.isInteger(n.col) && Number.isInteger(n.row))); +} check('window: every state stamp is authored', ['locked', 'available', 'upgradable', 'noPoints', 'maxed'].every((k) => typeof win.states?.[k] === 'string' && win.states[k].length > 0)); // ---- the "JSON owns the numbers" sweep ----------------------------- @@ -457,5 +486,134 @@ function vanguardRun(points = 5) { check('resetRunState clears the skill ledger', regNew.get('playerSkills') === null); } +// ===================================================================== +// 6. THE LAYOUT — js/skills/SkillModel.js → treeLayout() is the grid the +// console draws: authored cells win, unplaced skills fall to graph depth, +// collisions slide right instead of stacking, and the joins come from +// `requires` (both forms). Pure function, so the geometry is testable +// without a canvas. +// ===================================================================== +{ + const laid = model.classTreeLayout('vanguard'); + const cell = Object.fromEntries(laid.nodes.map((n) => [n.id, `${n.col},${n.row}`])); + check('layout: every skill of the tree gets exactly one node', laid.nodes.length === model.skillList('vanguard').length); + check(`layout: authored cells are honoured (${JSON.stringify(cell)})`, + cell.ramming_charge === '1,0' && cell.reinforced_plating === '3,0'); + check(`layout: bounds/cols/rows follow the placed cells (cols ${laid.cols}, rows ${laid.rows})`, + laid.cols >= 4 && laid.rows === 1 && laid.bounds.minCol <= 1 && laid.bounds.maxCol >= 3); + + // A tree with NO authored cells is laid out by the graph: a chain walks down one + // row per dependency depth, which is what "tree" means when nobody plans it. + const raw = config.get('skills.classes.vanguard.skills', []); + raw.push( + { id: 'lay_root', label: 'Lay Root', type: 'Offense' }, + { id: 'lay_mid', label: 'Lay Mid', type: 'Offense', requires: ['lay_root'] }, + { id: 'lay_leaf', label: 'Lay Leaf', type: 'Offense', requires: [{ skill: 'lay_mid', points: 2 }] }, + { id: 'lay_junk', label: 'Lay Junk', type: 'Offense', node: 'not-a-cell' }, + ); + model.invalidateSkillCache(); + const auto = model.classTreeLayout('vanguard'); + const at = Object.fromEntries(auto.nodes.map((n) => [n.id, n])); + check('layout: an unplaced chain walks DOWN by dependency depth', + at.lay_root.row === 0 && at.lay_mid.row === 1 && at.lay_leaf.row === 2); + check(`layout: the join carries the required rank (${JSON.stringify(auto.edges.map((e) => `${e.from}>${e.to}:${e.points}`))})`, + auto.edges.some((e) => e.from === 'lay_mid' && e.to === 'lay_leaf' && e.points === 2)); + check('layout: junk in `node` is no placement (the skill joins the auto-layout, never a tile piled at 0,0)', + at.lay_junk.authored === false && Number.isInteger(at.lay_junk.col) && at.lay_junk.col >= 0); + + // Two skills authored into ONE cell: the second slides right rather than stacking. + raw[2].node = { col: raw[1].node?.col ?? 3, row: raw[1].node?.row ?? 0 }; + model.invalidateSkillCache(); + const clash = model.classTreeLayout('vanguard'); + const keys = clash.nodes.map((n) => `${n.col}:${n.row}`); + check('layout: an authored collision slides right (never two tiles in one cell)', new Set(keys).size === keys.length); + + // Edges only ever point at a skill of the same tree. + raw.length = 2; + model.invalidateSkillCache(); + const clean = model.classTreeLayout('vanguard'); + check('layout: no edge survives to a skill that is not in the tree', + clean.edges.every((e) => clean.nodes.some((n) => n.id === e.from) && clean.nodes.some((n) => n.id === e.to))); +} + +// ===================================================================== +// 7. THE SESSION — points spent while the console is OPEN are held until it +// closes. Reset gives the session back; closing commits everything. The +// refund can never strand a point that was ALREADY committed, so the +// cascade refuses rather than de-powering the ship under the player. +// ===================================================================== +{ + const raw = config.get('skills.classes.vanguard.skills', []); + raw.push({ id: 'ses_child', label: 'Session Child', type: 'Offense', requires: [{ skill: 'ramming_charge', points: 1 }] }); + model.invalidateSkillCache(); + + const { cls, skills } = vanguardRun(6); + check('session: nothing is pending before the console opens', skills.stats().inSession === false && skills.pendingTotal() === 0); + + skills.beginSession(); + check('session: opening files a session and baselines the ledger', skills.stats().inSession === true && skills.baseRank('ramming_charge') === 0); + skills.beginSession(); + check('session: re-opening does not move the baseline (the session is one open stretch)', skills.baseRank('ramming_charge') === 0); + + skills.rankUp('ramming_charge'); + skills.rankUp('ramming_charge'); + check(`session: spending is live AND counted against the session (${skills.pendingOf('ramming_charge')}/${skills.rank('ramming_charge')})`, + skills.rank('ramming_charge') === 2 && skills.pendingOf('ramming_charge') === 2 && skills.pendingTotal() === 2); + check('session: the bank paid as it always does', cls.skillPoints === 4); + + const row = skills.rows().find((r) => r.id === 'ramming_charge'); + check(`session: the rows carry the session read for the badge/chip (pending ${row?.pending}, resettable ${row?.resettable})`, + row?.pending === 2 && row?.resettable === true); + + const back = skills.resetSession('ramming_charge'); + check(`session: RESET gives the session's points straight back (${JSON.stringify(back)})`, + back.ok === true && skills.rank('ramming_charge') === 0 && cls.skillPoints === 6 && skills.pendingTotal() === 0); + check('session: RESET leaves the session open (undo, not an exit)', skills.stats().inSession === true); + + skills.rankUp('ramming_charge'); + skills.rankUp('ses_child'); // needs ramming_charge ≥ 1 — unlocked by the spend above + const done = skills.commitSession(); + check(`session: closing commits what is held (${JSON.stringify(done)})`, + done.committed === 2 && skills.stats().inSession === false && skills.pendingTotal() === 0); + check('session: a committed rank is still spent (the bank stayed paid)', cls.skillPoints === 4 && skills.rank('ramming_charge') === 1); + + const tooLate = skills.resetSession('ramming_charge'); + check(`session: RESET after the close says no — committed points are not its business (${tooLate.reason})`, + tooLate.ok === false && tooLate.reason === 'noSession' && skills.rank('ramming_charge') === 1 && cls.skillPoints === 4); + + // A closed session has nothing to unwind once re-opened either. + skills.beginSession(); + const nothing = skills.resetSession('ramming_charge'); + check(`session: with nothing pending RESET declines (${nothing.reason})`, + nothing.ok === false && nothing.reason === 'nothing' && cls.skillPoints === 4); + + // THE CASCADE GUARD. Here the child's point is COMMITTED (its session base is 1) + // while the parent's rank only exists because of this session: unwinding the parent + // would strand a point already paid for — so nothing moves. + skills.sessionBase.set('ramming_charge', 0); // parent: committed 0, rank 1 → pending + const strand = skills.resetSession('ramming_charge'); + check(`session: a reset that would strand a committed point refuses instead (${JSON.stringify(strand)})`, + strand.ok === false && strand.reason === 'blocked' && strand.blocked?.skill === 'ses_child' + && skills.rank('ses_child') === 1 && skills.rank('ramming_charge') === 1 && cls.skillPoints === 4); + skills.sessionBase.set('ramming_charge', 1); // restore the honest baseline + skills.commitSession(); + + // A branch bought ENTIRELY inside this session refunds all the way down. + const { cls: cls2, skills: s2 } = vanguardRun(8); + s2.beginSession(); + s2.rankUp('ramming_charge'); + s2.rankUp('ses_child'); + const branch = s2.resetSession('ramming_charge'); + check(`session: a branch bought entirely in this session refunds down the tree (${JSON.stringify(branch)})`, + branch.ok === true && s2.rank('ses_child') === 0 && s2.rank('ramming_charge') === 0 && cls2.skillPoints === 8); + + // Restore wipes an open session: a load can never refund points across a reload. + const { skills: s3 } = vanguardRun(3); + s3.beginSession(); + s3.rankUp('ramming_charge'); + s3.restore({ ranks: { ramming_charge: 2 }, seats: {} }); + check('session: restore() drops the open session (no refund across a load)', s3.stats().inSession === false && s3.pendingTotal() === 0); +} + console.log(failures ? `\n${failures} FAILURE(S)` : '\nskills: all checks passed'); process.exit(failures ? 1 : 0); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 39c3380..e6ce797 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -1631,14 +1631,35 @@ them. Three files, strictly separated: documentation, never rows. - **`js/skills/SkillModel.js`** — the pure READER: normalizes the JSON into skill records (`id`, `label`, `type`, `energy`, `cooldown`, `maxPoints`, - `requires[]`, effect numbers, `frame`, copy), drops junk rows, supplies fallbacks + `requires[]`, effect numbers, `frame`, copy, `layout` — the authored + `node: {col,row}` cell), drops junk rows, supplies fallbacks so a missing/broken file degrades to "no skills" instead of a broken scene, and exposes `treeClassIds()` (who owns a tree), `skillList(classId)`, `windowSpec()`, `barSpec()` and the icon key/frame lookup. No Phaser, no state. +- **`SkillModel.treeLayout(skills)`** is the grid the console draws — pure geometry, so + the whole layout is testable without a canvas. An authored cell wins; an un-placed skill + falls to its dependency DEPTH (a chain walks DOWN — and the cycle guard adds the id as it + recurses, so `depthOf` must be called with an EMPTY trail: a self-trail trips the guard on + every node and silently flattens the tree into one row); two skills authored into one cell + slide right instead of stacking; junk in `node` is NO placement (the skill joins the + auto-layout rather than piling at 0,0). The joins come from `requires` in both forms and + never point outside the tree. - **`js/skills/SkillState.js`** — the run's LEDGER: `ranks`, `seats[]`, `points` (mirroring ClassState's bank), `rankUp`, `bindSeat`, `clearSeat`, `applyDefaultSeats`, `rows()` (what the console paints, including why a row is - unspendable), `hullBonus()`, `effectTotal()`. Plain data + `toJSON`/restore with + unspendable), `hullBonus()`, `effectTotal()`. +- **THE SESSION lives in the ledger, not the view** — it is the console's undo button and + it has to survive the view being rebuilt every frame. `beginSession()` files a snapshot of + the ranks (idempotent: one open stretch, however often the door is knocked on); + `pendingOf`/`pendingTotal` read what was spent SINCE that snapshot; `commitSession()` drops + the snapshot; `resetSession(id)` pays the pending points back through + `ClassState.awardSkillPoints`. A reset CASCADES down a branch this session bought, and + REFUSES (`reason: 'blocked'`) rather than reaching below the baseline and stranding a point + that was already committed — closing the console is what makes points permanent. Two rules + fell out of writing it: a rank-0 skill has no prerequisite to break (an early version + refused a legitimate whole-branch refund), and `restore()` wipes an open session so a + save/load can never refund across a reload. `rows()` carries `pending`/`resettable` so no + view re-implements when RESET lights. Plain data + `toJSON`/restore with validation: a foreign id, a rank above `maxPoints` or a seat out of range is dropped, never trusted. @@ -1678,19 +1699,42 @@ console all agree; the registry stamps `playerSkills` after every change. clearing `skillCooldowns` makes the door read whatever the clock says: that test flaked in the parallel run answering `'cooldown'`. It now waits for `!combat.dash` AND an expired recharge, THEN takes the energy away — one real 6 s wait per run, which is why that file - holds the only entry in `dev/run-all.mjs → BUDGETS` (a longer timeout with a stated reason; - never add one to mask a hang). + holds an entry in `dev/run-all.mjs → BUDGETS` (a longer timeout with a stated reason; + never add one to mask a hang — and note that four Chromium+swiftshader browsers at once + is the real reason browser tests time out in a full `run-all`: re-run the stragglers with + `--jobs 1` before believing one). - **Passives are re-derived, never compounded.** `refreshSkillPassives()` re-rolls `maxHull = baseMaxHull × (1 + hullBonus)` from the base every time and grows the CURRENT hull by the delta only — a rank-up is not a free repair, and refresh, level-up, combat restart or save/load all land on the same number. **The consoles.** `js/ui/SkillWindow.js` is a full-screen cabinet at depth 80 in -the ShipWindow/ResearchWindow idiom: it owns input while up (added to -`GameScene._screenIsBusy()`), ESC and the ✕ close it, the world parks through the -existing `_frozen` contract, and it holds no state of its own — RANK UP, ASSIGN SEAT -and CLEAR SEAT call `rankUpSkill`, `beginSkillAssign` and `clearSkillSeat`, the same -doors the keyboard uses. The assign flow is a HAND-OFF: press ASSIGN SEAT, the +the ShipWindow/ResearchWindow idiom, cut into three regions from +`data/skills.json → window.tree`: the **class plate** upper-left (crest, feed tag, +the bank, the session note), the **tree** upper-right — square icon tiles on the +authored grid, elbow joins, `n/m` rank badges on the tile corner, dim sockets for the +cells the tree has not filled yet — and a **detail drawer** across the bottom third with +the copy and its three actions lower-right (RESET · ASSIGN SEAT/CLEAR SEAT · ADD A SKILL +POINT). It owns input while up (added to `GameScene._screenIsBusy()`), ESC and the ✕ close +it, the world parks through the existing `_frozen` contract, and it holds no state of its +own beyond selection and scroll — RANK UP, RESET, ASSIGN SEAT and CLEAR SEAT call +`rankUpSkill`, `resetSkillPending`, `beginSkillAssign` and `clearSkillSeat`, the same +doors the keyboard uses, and closing calls `commitSkillSession()` (the seat hand-off does +NOT: the session outlives the console while a seat is being picked). Four things this +window learned the hard way: +- **One interactive surface, tiles by geometry.** A drag must keep scrolling while the + cursor is over a tile, and Phaser's bubbling gives a child no way to hand a held + `pointerdown` back to its parent. So the pane carries ONE interactive rectangle and the + window hit-tests tiles itself (`_tileAt`) on `pointerup`; hover only sets a flag that the + per-frame `_beatTree()` paints. Hover/selection NEVER force a rebuild from a pointer + handler — that destroys the object under the cursor mid-event. +- **One container IS the scroll.** Tiles, sockets and joins are drawn in local coordinates + inside a single inner container at `(pane.x, pane.y − scroll)`; scrolling is one y-write. +- **No clip mask in this Phaser build.** A tile is drawn only when it fits the pane WHOLE + (the BuyList rule) and each join segment is trimmed by hand (Liang–Barsky + `clipSegment`) — partial spill looks worse than pop-in at the veil. +- **Rebuild signature excludes `_scroll`.** Scroll must never re-render; the signature is + rows + selection + pending. The assign flow is a HAND-OFF: press ASSIGN SEAT, the console steps aside, the bar breathes (a pulse at `bar.assignPulseHz`), and a seat click or digit binds; ESC puts the skill back. The ship console's SKILL TREE tab is the door — it stays an OFFLINE socket until the filed class owns a tree @@ -1708,6 +1752,13 @@ the door — it stays an OFFLINE socket until the filed class owns a tree checked at the PIXEL level and the console's own copy/FX blocks asserted live — they print `window.video.noSignalTag` and paint `window.fx.sweep/glitch` from their data timings; screenshots in `dev/shots/skills-*.png`, including the tight `skills-seat.png`). + `dev/skills-window-browser.test.mjs` is the console's own pass: region geometry read off + `window.regions()` (tree upper-right two-thirds, drawer bottom third), tiles on the cells + the data authored, the session's begin/spend/reset/commit lifecycle through real clicks, + RESET paying a PASSIVE back and the hull pool shrinking with it, and — because a tree of + two skills cannot exercise a scroller — a longer tree substituted at the snapshot seam to + prove overflow, culling, wheel, thumb and drag, plus an elbow join proven by counting GOLD + PIXELS in the gap between two ranked tiles against the same band beside it (0) for control. ## Roadmap (working list, intentionally rough) diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index e7d2e27..d43b610 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -1691,6 +1691,19 @@ export class GameScene extends Phaser.Scene { this.clearSkillSeat(index); this.skillWindow?.refresh(); }, + // THE SESSION DOOR: give back what THIS OPENING of the console ranked, and + // only what can be given back without stranding a committed point — the + // ledger works the cascade and says when it cannot (SkillState.resetPlan). + onReset: (id) => { + this.resetSkillPending(id); + this.skillWindow?.refresh(); + }, + // CLOSING IS COMMITTING. The one close that is not is the seat hand-off, which + // steps the console aside for a moment and takes it straight back — committing + // there would take the undo out of the player's hand mid-transfer. + onClose: ({ reason } = {}) => { + if (reason !== 'assign') this.commitSkillSession(); + }, }); // ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ---- @@ -8119,6 +8132,81 @@ export class GameScene extends Phaser.Scene { return res; } + /** + * The console's RESET: hand back the points THIS SESSION ranked into a skill, + * taking anything those ranks were holding up along with them (a child bought + * under the dropped ranks falls back to its OWN committed rank — the ledger + * refuses rather than reaching under the player's earlier sessions). + * The bank pays out through ClassState, passives re-roll NOW (a hull rank that + * went away shrinks the armor this frame), and the save is stamped. + * @param {string} id skill id (data/skills.json) + */ + resetSkillPending(id) { + const spec = skillWindowSpec(); + const refusals = spec.resetRefusals ?? {}; + const res = this.skillState?.resetSession?.(id) ?? { ok: false, reason: 'noLedger', id: null, refunded: 0, cascade: [] }; + const labelOf = (key) => String(this.skillState?.defOf?.(key)?.label ?? key ?? '').toUpperCase(); + const label = labelOf(res.id ?? id); + if (!res.ok) { + this.playSfx?.(spec.sfx.denied); + const child = res.blocked ? String(res.blocked.label ?? res.blocked.skill ?? '').toUpperCase() : ''; + const line = + refusals[res.reason] ?? `${label} — ${String(res.reason ?? 'NO').toUpperCase()}`; + this.consoleToast(fillSkillTemplate(line, { skill: label, child, rank: res.rank ?? 0 }), { + glyph: '\u2715', + glyphColor: toCss(themeColor('amber', 0xffc94d)), + }); + return res; + } + this.registry.set('playerSkills', this.skillState); + // THE PASSIVE SEAM, run backwards: unspent ranks stop counting now. + this.combat?.refreshSkillPassives?.(); + this.playSfx?.(spec.sfx.refund ?? spec.sfx.denied); + this.consoleToast( + fillSkillTemplate(spec.resetToast, { + skill: label, + n: res.refunded ?? 0, + points: (res.refunded ?? 0) === 1 ? 'POINT' : 'POINTS', + }), + { glyph: '\u25c7', glyphColor: toCss(themeColor('cyan', 0x00e5ff)) }, + ); + // A cascade takes more than the player asked for, so it is said out loud. + if ((res.cascade ?? []).length > 0) { + this.consoleToast( + fillSkillTemplate(spec.cascadeToast, { + child: res.cascade.map((c) => labelOf(c.id)).join(' / '), + skill: label, + n: res.refunded ?? 0, + points: (res.refunded ?? 0) === 1 ? 'POINT' : 'POINTS', + }), + { glyph: '\u25c7', glyphColor: toCss(themeColor('amber', 0xffc94d)) }, + ); + } + return res; + } + + /** + * SIGN THE SESSION OFF — the console's way out (✕, ESC, the scrim, another + * console taking the screen). Whatever was still pending stops being undoable: + * the points stay spent and the next opening files a fresh snapshot. Nothing + * pending is not an event, so the toast only fires when there was something to + * commit (data/skills.json → window.commitToast). + */ + commitSkillSession() { + const res = this.skillState?.commitSession?.() ?? { committed: 0 }; + if (!(res.committed > 0)) return res; + this.registry.set('playerSkills', this.skillState); + const spec = skillWindowSpec(); + this.consoleToast( + fillSkillTemplate(spec.commitToast, { + n: res.committed, + points: res.committed === 1 ? 'POINT' : 'POINTS', + }), + { glyph: '\u25c7', glyphColor: toCss(themeColor('cyan', 0x00e5ff)) }, + ); + return res; + } + /** * KEYS 1-8 (wired in create()). Digit → seat, with the gates the mouse already * respects: a full-screen console owns the keys (its own ESC/typing must not be @@ -8237,9 +8325,9 @@ export class GameScene extends Phaser.Scene { /** * RIGHT-press a seat: CLEAR it, making room for another skill. Only the BINDING - * is lost — the ranks stay spent (data/skills.json → window.refundNote: points - * spent are spent until a respec exists). Saved with the run like every other - * part of the ledger. + * is lost — the ranks stay spent (un-ranking is the console's RESET, and it reaches + * only what THIS SESSION ranked; data/skills.json → window.sessionNote). Saved + * with the run like every other part of the ledger. * @param {number} index 0-7 */ clearSkillSeat(index) { @@ -8410,6 +8498,11 @@ export class GameScene extends Phaser.Scene { if (w?.isOpen) w.close(); } this.comms?.closeAll?.(); + // THE SESSION OPENS HERE. The ranks are snapshotted (SkillState.beginSession): + // that snapshot is what RESET unwinds back to and what closing commits. It is + // idempotent, so the seat hand-off's close→reopen keeps the ORIGINAL snapshot — + // a point spent before the hand-off stays refundable after it. + this.skillState?.beginSession?.(); this.skillWindow?.open({ source: o.source ?? null }); return true; } @@ -8432,6 +8525,10 @@ export class GameScene extends Phaser.Scene { iconReady: !!def && this.textures.exists(CLASS_ICON_KEY), points: cls.skillPoints, spent: st.stats().spent, + // What RESET can reach: the session's own tally (the console prints it, the + // ledger does the arithmetic). + pending: st.stats().pending, + inSession: st.stats().inSession, seatCount: st.seatCount, rows: st.rows(), }; diff --git a/js/skills/SkillModel.js b/js/skills/SkillModel.js index 19e7909..d1a5cb2 100644 --- a/js/skills/SkillModel.js +++ b/js/skills/SkillModel.js @@ -23,10 +23,20 @@ * { id, classId, label, typeId, type: typeRecord, frame, * energy: number, cooldown: number, maxPoints: int, * requires: [{ skill, points }], seatDefault: int|null, + * layout: { col, row }|null, * description, shortDescription, effect: { kind, ...numbers }, * passive: boolean, castable: boolean } * A TYPE RECORD (typeDef): * { id, label, accent (int), accentCss, passive } + * + * THE TREE'S SHAPE (treeLayout): the console draws the roster as a GRID of icon + * tiles joined by their unlock edges. Where a skill sits is its own authored + * `node: { col, row }` in the data; a tree with no authored nodes is laid out + * from the graph instead — roots along the top row, every child one row under + * its deepest parent, columns handed out left to right (a cycle, junk coords or + * two skills claiming one cell are all repaired rather than propagated). It is a + * PURE function of the skill records, so dev/skills.test.mjs proves it in bare + * Node and the console never invents placement of its own. */ import { config } from '../config/Config.js'; @@ -111,6 +121,20 @@ export function seatCount() { return Math.max(1, whole(n, 8)); } +/** + * A skill's authored cell on the console's tree grid (data/skills.json → a + * skill's `node`). Junk (a letter, a negative, a bare number) is NO placement, + * which hands the skill to the auto-layout — never a tile piled at 0,0. + */ +function normalizeNode(v) { + if (!v || typeof v !== 'object') return null; + const col = Number(v.col); + const row = Number(v.row); + if (!Number.isFinite(col) || !Number.isFinite(row)) return null; + if (col < 0 || row < 0) return null; + return { col: Math.floor(col), row: Math.floor(row) }; +} + /** A default seat binding inside the bar (out-of-range drops — or clamps). */ function normalizeSeat(v, count = seatCount()) { if (v === null || v === undefined || v === '' || v === false) return null; @@ -152,6 +176,7 @@ function normalizeSkill(row, classId, byId) { maxPoints: Math.max(1, whole(row?.maxPoints, 1)), requires, seatDefault: type.passive ? null : normalizeSeat(row?.seat), + layout: normalizeNode(row?.node), description: str(row?.description), shortDescription: str(row?.shortDescription), effect: { kind: str(effect.kind, 'none'), ...effect }, @@ -225,6 +250,115 @@ export function treeClassIds() { return [...trees().values()].filter((t) => t.skills.length > 0).map((t) => t.classId); } +/** + * THE TREE GRID (pure — takes skill RECORDS, reads no config). + * + * Returns `{ nodes, edges, cols, rows }`: + * nodes [{ id, def, col, row, authored }] in the roster's own order + * edges [{ from, to, points }] one line per unsatisfied-draft edge + * cols / rows the grid's extent (1-based counts — what the console sizes its + * scrollable content from) + * + * PLACEMENT: an authored `layout` wins; everything else is placed by the graph. + * A skill's depth is 1 + the deepest of its parents (a root is row 0), computed + * with a cycle guard, and columns are handed out left→right in display order so a + * hand-authored tree and an auto-laid one read the same way. Two skills claiming + * one cell push the later one right; nothing overlaps. + * + * EDGES: every `requires` pair becomes ONE line (a `{skill, points}` edge and a + * bare edge to the same parent share a line — the stricter `points` wins). A + * dangling parent id drops the line (the roster's own guard already dropped it, + * but a caller handing us a partial list must not see a line into nowhere). + * + * The pane's own column count (window.tree.cols) is a LOOK knob and belongs to the + * console; this function only says where each skill sits. + * + * @param {Array} skills skill records from skillList() + */ +export function treeLayout(skills = []) { + const list = (Array.isArray(skills) ? skills : []).filter((s) => s && typeof s.id === 'string' && s.id); + const byId = new Map(list.map((d) => [d.id, d])); + + // ---- depth per skill (a root is row 0), cycle-guarded ----------------- + const depthMemo = new Map(); + const depthOf = (id, trail) => { + if (depthMemo.has(id)) return depthMemo.get(id); + if (trail.has(id)) return 0; // a cycle in the data: break it here, not on screen + const def = byId.get(id); + const parents = (def?.requires ?? []).map((r) => r.skill).filter((p) => byId.has(p)); + let d = 0; + if (parents.length) { + const next = new Set(trail); + next.add(id); + d = 1 + Math.max(...parents.map((p) => depthOf(p, next))); + } + depthMemo.set(id, d); + return d; + }; + + // ---- place them ------------------------------------------------------- + const taken = new Set(); + const key = (c, r) => `${c}:${r}`; + const nodes = []; + for (const def of list) { + // A normalized record carries `layout`; a raw data row still carries `node`. + const want = def.layout ?? normalizeNode(def.node); + let col = Number.isFinite(Number(want?.col)) ? Math.floor(Number(want.col)) : null; + // The trail starts EMPTY: depthOf adds the id itself before recursing, so a + // self-trail here would trip the cycle guard on every node and flatten the tree. + let row = Number.isFinite(Number(want?.row)) ? Math.floor(Number(want.row)) : depthOf(def.id, new Set()); + if (col === null) { + for (let c = 0; ; c++) { + if (!taken.has(key(c, row))) { col = c; break; } + } + } else { + // An authored cell already spoken for slides right rather than stacking. + let c = col; + while (taken.has(key(c, row))) c += 1; + col = c; + } + taken.add(key(col, row)); + nodes.push({ id: def.id, def, col, row, authored: !!want }); + } + + // ---- the lines between them ------------------------------------------- + const seen = new Set(); + const edges = []; + for (const node of nodes) { + for (const req of node.def.requires ?? []) { + if (!byId.has(req.skill)) continue; + const k = `${req.skill}→${node.id}`; + const points = Math.max(1, whole(req.points, 1)); + if (seen.has(k)) { + const prev = edges.find((e) => `${e.from}→${e.to}` === k); + if (prev) prev.points = Math.max(prev.points, points); + continue; + } + seen.add(k); + edges.push({ from: req.skill, to: node.id, points }); + } + } + + const cols = nodes.reduce((m, n) => Math.max(m, n.col + 1), 0); + const rows = nodes.reduce((m, n) => Math.max(m, n.row + 1), 0); + // The used bounds (the console centers the tree on these and sizes its scroll + // content from them) — an empty roster still yields a 1×1 grid so no caller has + // to divide by zero. + const minCol = nodes.reduce((m, n) => Math.min(m, n.col), 0); + const maxCol = nodes.reduce((m, n) => Math.max(m, n.col), -1); + const minRow = nodes.reduce((m, n) => Math.min(m, n.row), 0); + const maxRow = nodes.reduce((m, n) => Math.max(m, n.row), -1); + return { nodes, edges, cols: Math.max(cols, 1), rows: Math.max(rows, 1), bounds: { minCol, maxCol, minRow, maxRow } }; +} + +/** + * A class tree laid out for the console (the roster read + treeLayout applied). + * @returns {{nodes: Array, edges: Array, cols: number, rows: number, bounds: object}} + */ +export function classTreeLayout(classId) { + return treeLayout(skillList(classId)); +} + /** Every effect number a cast/apply needs, read off the record with inert defaults. */ export function effectOf(def, kind = null) { const fx = def?.effect ?? {}; @@ -272,11 +406,26 @@ export function windowSpec() { ), lockedNote: str(win.lockedNote, 'REQUIRES {parent}'), spendLabel: str(win.spendLabel, '+ RANK UP'), + // THE DETAIL DRAWER (the console's bottom third) + the session rule: what a + // RESET gives back is ONLY what was ranked while the console was open. + detailPrompt: str(win.detailPrompt, 'SELECT A SKILL'), + detailPromptSub: str(win.detailPromptSub), + addPointLabel: str(win.addPointLabel, '+ ADD A SKILL POINT'), + addPointNoPoints: str(win.addPointNoPoints), + resetLabel: str(win.resetLabel, 'RESET'), + pointsChip: str(win.pointsChip, '{n} PTS'), + pendingNote: str(win.pendingNote, '+{n} THIS SESSION'), + resetToast: str(win.resetToast), + cascadeToast: str(win.cascadeToast), + resetRefusals: Object.fromEntries( + Object.entries(win.resetRefusals ?? {}).filter(([k]) => !k.startsWith('_')), + ), + sessionNote: str(win.sessionNote), + commitToast: str(win.commitToast), assignLabel: str(win.assignLabel, 'ASSIGN SEAT'), assignHint: str(win.assignHint), assignedToast: str(win.assignedToast), unbindLabel: str(win.unbindLabel, 'CLEAR SEAT'), - refundNote: str(win.refundNote), emptyNote: str(win.emptyNote), noClassNote: str(win.noClassNote), closeLabel: str(win.closeLabel, '◂ CLOSE'), @@ -291,6 +440,90 @@ export function windowSpec() { glitch: win.fx?.glitch ?? {}, sweep: win.fx?.sweep ?? {}, }, + /** THE TREE PANE (data/skills.json → window.tree): grid + tile + line + badge + * + scrollbar looks, every one of them the data's. Clamped so a stray number + * cannot blow a tile out of the pane; a missing block falls back to the shipped + * console's own numbers (the ONLY place those literals live). */ + tree: (() => { + const t = win.tree ?? {}; + const e = t.edge ?? {}; + const f = t.frame ?? {}; + const b = t.badge ?? {}; + const v = t.veil ?? {}; + const s = t.scrollbar ?? {}; + const g = t.gap ?? {}; + const r = t.regions ?? {}; + const px = (val, fb) => Math.max(1, Math.round(num(val, fb))); + const frac = (val, fb) => Math.min(0.9, Math.max(0.1, num(val, fb))); + return { + cols: Math.max(1, whole(t.cols, 5)), + // The body cut-up: tree pane across the top `splitFraction`, class plate + // down the left at `plateFraction` of the body width, drawer underneath. + regions: { + splitFraction: frac(r.splitFraction, 0.66), + plateFraction: frac(r.plateFraction, 0.27), + gapPx: px(r.gapPx, 14), + }, + tile: { + minPx: px(t.tile?.minPx, 46), + maxPx: Math.max(px(t.tile?.minPx, 46), px(t.tile?.maxPx, 92)), + }, + gap: { colPx: px(g.colPx, 34), rowPx: px(g.rowPx, 44), padPx: px(g.padPx, 18) }, + edge: { + spentColor: str(e.spentColor, '#ffd84d'), + spentAlpha: Math.min(1, Math.max(0, num(e.spentAlpha, 0.95))), + openColor: str(e.openColor, '#7d92c4'), + openAlpha: Math.min(1, Math.max(0, num(e.openAlpha, 0.5))), + lockedColor: str(e.lockedColor, '#3d4c74'), + lockedAlpha: Math.min(1, Math.max(0, num(e.lockedAlpha, 0.42))), + widthPx: px(e.widthPx, 4), + notchPx: px(e.notchPx, 8), + }, + frame: { + idleEdge: str(f.idleEdge, '#5f7099'), + availableEdge: str(f.availableEdge, '#cfe3f7'), + spentEdge: str(f.spentEdge, '#ffd84d'), + maxedEdge: str(f.maxedEdge, '#7ce8a4'), + lockedEdge: str(f.lockedEdge, '#2c3a5c'), + selectedEdge: str(f.selectedEdge, '#00e5ff'), + faceColor: str(f.faceColor, '#0b1424'), + faceAlpha: Math.min(1, Math.max(0, num(f.faceAlpha, 0.92))), + bevelPx: px(f.bevelPx, 3), + selectedPulseHz: Math.max(0.1, num(f.selectedPulseHz, 1.8)), + }, + badge: { + color: str(b.color, '#eaf6ff'), + spentColor: str(b.spentColor, '#ffd84d'), + bgColor: str(b.bgColor, '#04060d'), + bgAlpha: Math.min(1, Math.max(0, num(b.bgAlpha, 0.88))), + edgeColor: str(b.edgeColor, '#3d4c74'), + fontSizePx: px(b.fontSizePx, 12), + }, + // The empty authored cells (the reference sheet's dim unused nodes). + socket: { + enabled: t.socket?.enabled !== false, + faceColor: str(t.socket?.faceColor, '#080f1c'), + faceAlpha: Math.min(1, Math.max(0, num(t.socket?.faceAlpha, 0.5))), + edgeColor: str(t.socket?.edgeColor ?? f.idleEdge, '#3d4c74'), + edgeAlpha: Math.min(1, Math.max(0, num(t.socket?.edgeAlpha, 0.35))), + tickPx: px(t.socket?.tickPx, 7), + }, + veil: { + lockedColor: str(v.lockedColor, '#050a13'), + lockedAlpha: Math.min(1, Math.max(0, num(v.lockedAlpha, 0.62))), + pendingGlow: str(v.pendingGlow, '#ffd84d'), + pendingAlpha: Math.min(1, Math.max(0, num(v.pendingAlpha, 0.5))), + }, + scrollbar: { + wPx: px(s.wPx, 8), + trackColor: str(s.trackColor, '#0b1424'), + thumbColor: str(s.thumbColor, '#3f5a86'), + thumbHotColor: str(s.thumbHotColor, '#00e5ff'), + wheelPxPerNotch: px(s.wheelPxPerNotch, 64), + }, + hintLine: str(t.hintLine), + }; + })(), }; } diff --git a/js/skills/SkillState.js b/js/skills/SkillState.js index ded4e10..ebfae2d 100644 --- a/js/skills/SkillState.js +++ b/js/skills/SkillState.js @@ -15,8 +15,19 @@ * THE BANK IS NOT DUPLICATED HERE. Points live in js/player/ClassState.js * (the class lock-in banks them); this ledger spends from it and never * mints its own, so "bank + spent" can never disagree with the class layer. - * No refund either — a rank is forever until respec lands (data/skills.json → - * window.refundNote is the voice). + * A REFUND runs the other way through that same door (`awardSkillPoints`), and it + * is scoped by the SESSION below — a committed rank is otherwise forever. + * + * THE CONSOLE SESSION (the Skill Tree console's RESET rule): opening the console + * files a snapshot of the ranks (`beginSession`); every point spent after that is + * PENDING, and RESET hands a skill's pending points back to the bank — only + * those, never a rank that was already committed. Closing the console commits what + * is still pending (`commitSession`), which is why closing it is the player's + * "sign here". A cascade goes with it: giving a parent its points back would strand + * a child bought under it, so that child (whose own points must be pending too) + * resets in the same press — or the whole reset is REFUSED and the child is + * named. The session is transient and deliberately NOT saved: a load mid-window + * simply leaves nothing pending to unwind. * * UNLOCKS ARE EDGES IN THE DATA: `requires` names parent skills, optionally * with the points they need (`{ skill, points }`; a bare id means "one @@ -47,6 +58,10 @@ export class SkillState { this.seats = new Array(Math.max(1, seatCount())).fill(null); /** The data's default bindings have been seeded (never re-seeded later). */ this.defaultsApplied = false; + /** @type {Map|null} the ranks on file when the Skill Tree + * console opened — null when no session is running (everything on file is + * committed). Transient by design: it never rides the save. */ + this.sessionBase = null; } /** The class on file ('' when the run has none) — the tree's owner. */ @@ -152,6 +167,158 @@ export class SkillState { return { ok: true, reason: null, id: def.id, rank: next, max: def.maxPoints, points: this.points, seat }; } + // ------------------------------------------------------ the console session + + /** Is a Skill Tree console session open (i.e. is anything resettable)? */ + get inSession() { + return this.sessionBase instanceof Map; + } + + /** + * Open a session: file the ranks as they stand, so every point spent from now + * until `commitSession` is PENDING and RESET-able. Idempotent — re-opening (the + * console steps aside for a seat hand-off and comes straight back) keeps the + * ORIGINAL snapshot rather than silently committing the pending points. + */ + beginSession() { + if (!this.sessionBase) this.sessionBase = new Map(this.ranks); + return this; + } + + /** + * Close a session: everything pending becomes committed. The console calls this + * on its way out (✕, ESC, or the ship console taking the screen back), which is + * the rule "closing the window commits the points". + * @returns {{ok: boolean, committed: number}} how many points were signed off + */ + commitSession() { + const committed = this.pendingTotal(); + this.sessionBase = null; + return { ok: true, committed }; + } + + /** The rank a skill had when the session opened (its current rank with no session). */ + baseRank(id) { + const key = String(id ?? '').toLowerCase(); + if (!this.sessionBase) return this.rank(key); + return Math.max(0, Math.floor(Number(this.sessionBase.get(key)) || 0)); + } + + /** Points THIS SKILL gained during the open session (0 = nothing to reset). */ + pendingOf(id) { + return Math.max(0, this.rank(id) - this.baseRank(id)); + } + + /** Every pending point on the ledger (the console's "N points uncommitted"). */ + pendingTotal() { + if (!this.sessionBase) return 0; + let n = 0; + for (const [id, rank] of this.ranks) n += Math.max(0, rank - (this.sessionBase.get(id) ?? 0)); + return n; + } + + /** + * THE RESET PLAN — what handing this skill's session points back would do, + * computed WITHOUT touching the ledger so a refused reset never half-happens. + * + * A child bought under the target goes with it (its unlock would fall away), and + * so does that child's own child, transitively. Cascading to a child that still + * holds COMMITTED points is refused rather than quietly unspent from under the + * player — a session reset can only unwind what this session created. + * + * @returns {{ok: boolean, reason: (string|null), id: string|null, points: number, + * cascade: Array<{id: string, label: string, from: number, to: number}>, + * target: number, rank: number, blocked: object|null}} + */ + resetPlan(id) { + const def = this.defOf(id); + const noSession = { ok: false, reason: 'noSession', id: def?.id ?? null, points: 0, cascade: [], target: 0, rank: this.rank(id), blocked: null }; + if (!this.inSession) return noSession; + const key = def?.id ?? String(id ?? '').toLowerCase(); + const target = this.baseRank(key); + if (this.rank(key) <= target) { + return { ok: false, reason: 'nothing', id: key, points: 0, cascade: [], target, rank: this.rank(key), blocked: null }; + } + const refuse = (blocker) => ({ + ok: false, reason: 'blocked', id: key, points: 0, cascade: [], target, rank: this.rank(key), blocked: blocker, + }); + // Simulate on a copy: drop the target to its session-base, then repeatedly + // drop anything that can no longer be unlocked — every drop goes only as far + // as that skill's OWN committed rank. + const next = new Map(this.ranks); + const write = (map, id, to) => (to <= 0 ? map.delete(id) : map.set(id, to)); + const dropped = [{ id: key, label: def?.label ?? key, from: this.rank(key), to: target }]; + const touched = new Set([key]); + write(next, key, target); + for (let guard = 0; guard < this.ranks.size + 2; guard++) { + let broke = null; + for (const [sid, rank] of next) { + if (rank <= 0 || touched.has(sid)) continue; + const sdef = this.defOf(sid); + if (!sdef || this.rankSatisfiedBy(sdef, next)) continue; + broke = { id: sid, def: sdef }; + break; + } + if (!broke) break; // the plan stands on its own + const floor = this.baseRank(broke.id); + const cur = next.get(broke.id) ?? 0; + const trial = new Map(next); + write(trial, broke.id, floor); + const sdef = this.defOf(broke.id); + // A drop to floor 0 removes the skill — and a rank-0 skill has no prerequisite + // to break, so only a drop that LEAVES it ranked has to still satisfy its own `requires`. + if (floor >= cur || (floor > 0 && !this.rankSatisfiedBy(sdef, trial))) { + // Fixing this would mean reaching BELOW what the player already committed + // in an earlier session — not this session's call, so nothing moves. + return refuse({ skill: broke.id, label: sdef?.label ?? broke.id }); + } + dropped.push({ id: broke.id, label: sdef?.label ?? broke.id, from: cur, to: floor }); + touched.add(broke.id); + next.clear(); + for (const [id, rank] of trial) next.set(id, rank); + } + const points = dropped.reduce((n, d) => n + Math.max(0, d.from - d.to), 0); + return { ok: true, reason: null, id: key, points, cascade: dropped.slice(1), target, rank: this.rank(key), blocked: null }; + } + + /** Would `requires` all be satisfied by the ranks in `map` (id → rank)? */ + rankSatisfiedBy(def, map) { + return (def.requires ?? []).every((r) => Math.floor(Number(map.get(r.skill)) || 0) >= (r.points || 1)); + } + + /** + * RESET: hand this skill's SESSION points back to the bank (see `resetPlan` + * for the cascade + the refusals). The bank is js/player/ClassState.js again — + * `awardSkillPoints` is the same door a quest reward would use. + * @returns {{ok: boolean, reason: (string|null), id: string|null, refunded: number, + * cascade: Array, blocked: object|null, points: number}} + */ + resetSession(id) { + const plan = this.resetPlan(id); + if (!plan.ok) return { ...plan, refunded: 0, points: this.points }; + // Apply the plan exactly as it was proved out above. + const writes = [{ id: plan.id, to: plan.target }, ...plan.cascade.map((c) => ({ id: c.id, to: c.to }))]; + for (const w of writes) { + if (w.to <= 0) this.ranks.delete(w.id); + else this.ranks.set(w.id, Math.min(w.to, this.rank(w.id))); + } + // The bank pays itself back (js/player/ClassState.js → awardSkillPoints). No + // class ledger means nothing could have been ranked anyway — the ranks above + // are already down and the bank simply says nothing. + this.classState?.awardSkillPoints?.(plan.points); + return { + ok: true, + reason: null, + id: plan.id, + refunded: plan.points, + cascade: plan.cascade, + blocked: null, + points: this.points, + rank: this.rank(plan.id), + max: this.defOf(plan.id)?.maxPoints ?? 0, + }; + } + // --------------------------------------------------------------- seats /** How many seats the bar has (data/combat.json → bar.slot.count). */ @@ -299,12 +466,18 @@ export class SkillState { else if (rank === 0) state = 'available'; return { def, + // The plain key the consoles read rows off (the def stays alongside it). + id: def.id, rank, max: def.maxPoints, state, locked: this.lockedBy(def), seat: this.seatOf(def.id), spendable: state === 'available' || state === 'upgradable', + // The session half of the story (the console's RESET button lights off + // exactly this, so no view re-implements the rule). + pending: this.pendingOf(def.id), + resettable: this.pendingOf(def.id) > 0, }; }); } @@ -317,6 +490,8 @@ export class SkillState { spent: [...this.ranks.values()].reduce((a, b) => a + b, 0), ranks: Object.fromEntries(this.ranks), seats: [...this.seats], + pending: this.pendingTotal(), + inSession: this.inSession, }; } @@ -340,6 +515,9 @@ export class SkillState { this.ranks = new Map(); this.seats = new Array(Math.max(1, seatCount())).fill(null); this.defaultsApplied = false; + // A loaded ledger has no session to unwind (the snapshot is transient), so the + // console simply has nothing pending until it is opened again. + this.sessionBase = null; if (!state || typeof state !== 'object') return this; const rawRanks = state.ranks && typeof state.ranks === 'object' ? state.ranks : {}; for (const [id, v] of Object.entries(rawRanks)) { diff --git a/js/ui/SkillWindow.js b/js/ui/SkillWindow.js index b24dcad..cb9bc24 100644 --- a/js/ui/SkillWindow.js +++ b/js/ui/SkillWindow.js @@ -1,30 +1,57 @@ -// The SKILL TREE console (data/skills.json, js/skills/SkillModel.js + SkillState.js). +// THE SKILL TREE console (data/skills.json, js/skills/SkillModel.js + SkillState.js). // -// ┌──────────────────────────────────────────────────────────────┐ -// │ VANGUARD // SKILL TREE ✕ │ -// ├──────────────────┬───────────────────────────────────────────┤ -// │ the class plate │ ▣ RAMMING CHARGE OFFENSE 3/5 │ -// │ (insignia + │ 60 ENERGY · 6s RECHARGE · SEAT 1 │ -// │ the banked │ Charge the nearest enemy … │ -// │ points line) │ [ RANK UP — 1 PT ] [ ASSIGN SEAT ] │ -// │ │ ───────────────────────────────────── │ -// │ │ ▣ REINFORCED PLATING PASSIVE 5/5 │ -// │ │ ALWAYS ON · NO SEAT │ -// └──────────────────┴───────────────────────────────────────────┘ +// ┌──────────────────────────────────────────────────────────────────┐ +// │ VANGUARD // SKILL TREE 3 PTS ✕│ +// ├───────────────┬──────────────────────────────────────────────────┤ +// │ the class │ ▢ ▢ │ +// │ plate │ ║ ║ ← unlock lines, gold on the │ +// │ (insignia + │ ┌──┐ ┌──┐ │ +// │ the banked │ │◈▯▯│1/5 │██│5/5 ↑ icon tiles, rank badge in│ +// │ points + │ └──┘ └──┘ the tile's lower-right │ +// │ the session │ ═══════╡ │ +// │ note) │ ▢ (wheel or drag scrolls a long tree) │ +// ├───────────────┴──────────────────────────────────────────────────┤ +// │ [ ██ ] RAMMING CHARGE OFFENSE · SEAT 1 │ +// │ large 60 ENERGY · 6s RECHARGE │ +// │ icon Charge the nearest enemy at 3x speed over 1 second … │ +// │ +2 pts this session [ASSIGN SEAT][RESET][+ POINT]│ +// └──────────────────────────────────────────────────────────────────┘ +// +// THREE REGIONS, one cabinet. The class plate occupies the upper LEFT, the TREE the +// upper right two-thirds and the DETAIL DRAWER the bottom third (data/skills.json → +// window.tree.regions). The tree is a GRID of icon tiles — where each skill sits is +// its own `node: {col,row}` in the data, or the graph lays it out (SkillModel → +// treeLayout) — joined by their unlock edges, bright gold along the path the player +// actually took and each wearing its n/m rank plate. Content taller than the pane +// SCROLLS (wheel, drag, thumb) so a long install's tree is never cut off; this +// Phaser build has no clip mask, so a tile leaves when it cannot fit whole (the +// BuyList.js rule) and the lines are clipped by hand instead of spilling. +// +// THE SELECTED SKILL is the drawer's subject: its icon big on the left, its copy on +// the right, and its three actions in the drawer's lower-right corner — ADD A SKILL +// POINT (one point from the bank), RESET (only what THIS SESSION ranked) and the +// seat hand-off. An action with nothing to do draws gray but still answers the +// press, because a wall you can hear is better than a silent button. +// +// THE SESSION: opening this console files a snapshot of the ranks +// (SkillState.beginSession); RESET unwinds only the points spent since that +// snapshot, and closing the console commits whatever is left +// (SkillState.commitSession, fired from close()). A seat hand-off closes the window +// and immediately re-opens it, so THAT close does not commit — see GameScene. // // THE CABINET IDIOM: a full-screen console (depth 80) that owns all input while // it is up, exactly like ShipWindow / ResearchWindow / ClassWindow — the world // parks behind it (GameScene._frozen), ESC and the ✕ close it, and every string -// and number it prints comes from the data or the ledger. The window is a VIEW: -// it holds no state of its own beyond which row is hovered; spending a point, -// binding a seat and clearing one are the scene's (onRankUp / onAssign / -// onClearSeat), so the save registry, the combat passives and toasts all move the -// way they do when a KEY does the same thing. +// and number it prints comes from the data or the ledger. The window is a VIEW: it +// holds no rule of its own beyond which tile is selected and how far the pane has +// scrolled; spending, refunding, binding a seat and clearing one are the scene's +// (onRankUp / onReset / onAssign / onClearSeat), so the save registry, the combat +// passives and toasts all move the way they do when a KEY does the same thing. import Phaser from '../vendor/phaser.js'; import { toCss } from '../utils/Color.js'; import { fontStack, themeColor } from '../utils/Theme.js'; import { playSfxOn } from '../utils/Sfx.js'; -import { SKILL_ICON_KEY, fillTemplate, windowSpec } from '../skills/SkillModel.js'; +import { SKILL_ICON_KEY, fillTemplate, windowSpec, treeLayout } from '../skills/SkillModel.js'; const HEADER = fontStack('header'); const BODY = fontStack('body'); @@ -58,12 +85,44 @@ const STATE_TAG = { maxed: 'MAXED', }; +/** The Liang–Barsky clip: a segment against a rect, in the same space. The tree's + * unlock lines run inside the scrolled pane and this build cannot mask, so every + * line is trimmed to what is actually on screen (null = nothing of it shows). */ +function clipSegment(x1, y1, x2, y2, r) { + let t0 = 0; + let t1 = 1; + const dx = x2 - x1; + const dy = y2 - y1; + const steps = [ + [-dx, x1 - r.x], + [dx, r.r - x1], + [-dy, y1 - r.y], + [dy, r.b - y1], + ]; + for (const [p, q] of steps) { + if (p === 0) { + if (q < 0) return null; // parallel and outside + continue; + } + const t = q / p; + if (p < 0) { + if (t > t1) return null; + if (t > t0) t0 = t; + } else { + if (t < t0) return null; + if (t < t1) t1 = t; + } + } + return { x1: x1 + t0 * dx, y1: y1 + t0 * dy, x2: x1 + t1 * dx, y2: y1 + t1 * dy }; +} + export class SkillWindow extends Phaser.GameObjects.Container { constructor(scene, o = {}) { super(scene, 0, 0); this.scene = scene; this.getSnapshot = typeof o.getSnapshot === 'function' ? o.getSnapshot : () => null; this.onRankUp = typeof o.onRankUp === 'function' ? o.onRankUp : null; + this.onReset = typeof o.onReset === 'function' ? o.onReset : null; this.onAssign = typeof o.onAssign === 'function' ? o.onAssign : null; this.onClearSeat = typeof o.onClearSeat === 'function' ? o.onClearSeat : null; this.onClose = typeof o.onClose === 'function' ? o.onClose : null; @@ -72,6 +131,11 @@ export class SkillWindow extends Phaser.GameObjects.Container { this.openState = 'closed'; this._sig = ''; this._source = null; + /** The drawer's subject + the pane's scroll: the only two things this view owns. */ + this.selected = null; + this._scroll = 0; + this._tree = null; // the laid-out pane (nodes, edges, metrics) — rebuilt with the body + this._drag = null; // { y, scroll } while the pane is being dragged scene.add.existing(this); this.setDepth(DEPTH).setScrollFactor(0); this.setVisible(false); @@ -93,17 +157,21 @@ export class SkillWindow extends Phaser.GameObjects.Container { this.openState = 'open'; this.setVisible(true); this._sig = ''; + this._scroll = Math.max(0, Number(this._scroll) || 0); this._rebuild(); + this._armInput(); this.sfx(this.spec.sfx?.open); } - close() { + /** Closing is COMMITTING: the scene signs the session off here (see GameScene). */ + close(o = {}) { if (!this.isOpen) return; this.openState = 'closed'; this.setVisible(false); + this._disarmInput(); this._destroyBody(); this.sfx(this.spec.sfx?.close); - this.onClose?.({ source: this._source }); + this.onClose?.({ source: this._source, reason: o.reason ?? null }); } /** The action map in page-independent GAME coordinates (tests + debugging). */ @@ -111,11 +179,52 @@ export class SkillWindow extends Phaser.GameObjects.Container { return (this._hits ?? []).map((h) => ({ kind: h.kind, id: h.id, cx: h.x + h.w / 2, cy: h.y + h.h / 2 })); } + /** The console's regions in GAME coordinates (a browser test's yardstick). */ + regions() { + const r = this._regions; + if (!r) return null; + const copy = (o) => ({ x: Math.round(o.x), y: Math.round(o.y), w: Math.round(o.w), h: Math.round(o.h) }); + return { panel: copy(r.panel), plate: copy(r.plate), tree: copy(r.tree), drawer: copy(r.drawer) }; + } + + /** Where the tree pane has scrolled (tests + the next install's "jump to skill"). */ + scrollState() { + return { scroll: Math.round(this._scroll), maxScroll: Math.round(this._tree?.maxScroll ?? 0) }; + } + + /** Make a skill the drawer's subject. Lazy: the cabinet repaints on the scene's + * next poll beat rather than tearing itself down underneath the pointer that has + * just pressed the tile. */ + select(id, o = {}) { + const key = String(id ?? '').toLowerCase(); + if (!key || this.selected === key) return this; + this.selected = key; + this._wantReveal = o.reveal !== false; + this._sig = ''; // force the drawer repaint on the next beat + return this; + } + + /** Scroll the pane until the selected tile is whole inside it. */ + scrollToSelection() { + const t = this._tree; + if (!t || !this.selected) return this; + const node = t.nodes.find((n) => n.id === this.selected); + if (!node) return this; + const want = Math.min(Math.max(0, node.ly - t.pad), Math.max(0, node.ly + node.size + t.pad - t.viewH)); + if (Math.abs(want - this._scroll) > 0.5) { + this._scroll = Math.min(t.maxScroll, Math.max(0, want)); + this._applyScroll(); + } + return this; + } + /** Repaint on demand (a point was spent, a seat bound) and on the poll beat. */ refresh() { if (!this.isOpen) return; this._rebuild(); - this._beatFx(this.scene.time?.now ?? 0); + const now = this.scene.time?.now ?? 0; + this._beatFx(now); + this._beatTree(now); } /** THE DRESSING (data/skills.json → window.fx): a scan band crawling the feed and @@ -175,11 +284,95 @@ export class SkillWindow extends Phaser.GameObjects.Container { return { ...(this._fxPainted ?? { sweeps: 0, glitches: 0 }), feedTag: String((this.spec.video ?? {}).noSignalTag ?? '').toUpperCase() }; } + // ------------------------------------------------------- THE PANE'S INPUT + + /** + * While the console owns the screen, the scene's pointer answers to the tree pane: + * a press starts a drag, moving it scrolls the grid (content under the fingers, + * including when the fingers are on a tile), leaving it alone and releasing inside + * the pane selects the tile there. A release is looked up by GEOMETRY rather than + * by an interactive object per tile — one skin over the pane keeps drag and click + * from arguing over the same press, which is exactly what a grid you can throw + * around needs. + */ + _armInput() { + if (this._inputArmed || !this.scene?.input) return; + this._onMove = (p) => this._pressBeat(p); + this._onUp = (p) => this._releasePointer(p); + this.scene.input.on('pointermove', this._onMove); + this.scene.input.on('pointerup', this._onUp); + this._inputArmed = true; + } + + _disarmInput() { + if (!this._inputArmed) return; + const i = this.scene?.input; + i?.off('pointermove', this._onMove); + i?.off('pointerup', this._onUp); + i?.setDefaultCursor?.(''); + this._drag = null; + this._hover = null; + this._inputArmed = false; + } + + /** The tile whose plate is under a game point (null when the pane shows air). */ + _tileAt(x, y) { + const t = this._tree; + if (!t) return null; + for (const n of t.nodes) { + if (!n.visible) continue; + const top = t.pane.y + n.ly - this._scroll; + if (x >= n.absX && x <= n.absX + n.size && y >= top && y <= top + n.size) return n; + } + return null; + } + + _pressBeat(p) { + const t = this._tree; + if (!t || !p) return; + const d = this._drag; + if (d && p.isDown) { + const dy = p.y - d.y0; + if (Math.abs(dy) > 4) d.moved = true; + if (d.moved) this.scrollTo(d.scroll - dy); // content follows the fingers + return; + } + // Hover: dressing only (_beatTree draws the edge), plus a hand for the cursor. + const hit = this._tileAt(p.x, p.y); + const id = hit?.id ?? null; + if (id !== this._hover) { + this._hover = id; + this.scene.input?.setDefaultCursor?.(id ? 'pointer' : ''); + } + } + + _releasePointer(p) { + const d = this._drag; + this._drag = null; + if (!d || d.moved || !p) return; // it was a drag, not a press + const hit = this._tileAt(p.x, p.y); + if (hit) this._tapTile(hit.id); + } + + /** A tile press: select it, and re-pressing the selected tile spends a point (the + * way the bar's seats work — one control, two hands). */ + _tapTile(id) { + if (this.selected === id) { + this.onRankUp?.(id); + return; + } + this.select(id); + } + // ------------------------------------------------------------- furniture _destroyBody() { for (const go of this._body ?? []) go.destroy(); this._body = []; + this._tree = null; + this._inner = null; + this._edgeG = null; + this._ringG = null; } txt(x, y, str, o = {}) { @@ -208,9 +401,9 @@ export class SkillWindow extends Phaser.GameObjects.Container { return g; } - /** A console button. `inert` draws it dead but keeps the press (the scene's + /** A console button. `inert` draws it dead gray but keeps the press (the scene's * door answers with the refusal copy — the same honest wall as the bar). */ - button(x, y, w, h, label, accent, onPress, { inert = false, tag = '', kind = null, rowId = null } = {}) { + button(x, y, w, h, label, accent, onPress, { inert = false, tag = '', kind = null, rowId = null, align = 0.5 } = {}) { const g = this.scene.add.graphics().setDepth(DEPTH); const paint = (hot) => { g.clear(); @@ -218,13 +411,17 @@ export class SkillWindow extends Phaser.GameObjects.Container { g.lineStyle(1, inert ? C.faint : accent, inert ? 0.7 : hot ? 1 : 0.85); g.strokeRoundedRect(x, y, w, h, 3); g.fillRect(x, y, w, h); - if (!inert) g.fillRect(x, y + h - 2, w, 2); + if (!inert) { + // The lit base-line: a button that can act reads lit even without the cursor. + g.fillStyle(inert ? C.faint : accent, hot ? 1 : 0.75); + g.fillRect(x + 1, y + h - 2, w - 2, 2); + } }; paint(false); - this.txt(x + w / 2, y + h / 2, label, { + this.txt(align === 0.5 ? x + w / 2 : x + 10, y + h / 2, label, { size: 11, color: inert ? C.faint : accent, - origin: 0.5, + origin: align === 0.5 ? 0.5 : 0, ls: 2, header: true, }); @@ -234,7 +431,7 @@ export class SkillWindow extends Phaser.GameObjects.Container { const hit = this.scene.add .rectangle(x, y, w, h, 0x000000, 0.001) .setOrigin(0, 0) - .setDepth(DEPTH + 1) + .setDepth(DEPTH + 3) .setInteractive({ useHandCursor: true }); hit.on('pointerover', () => paint(true)); hit.on('pointerout', () => paint(false)); @@ -253,66 +450,98 @@ export class SkillWindow extends Phaser.GameObjects.Container { // ------------------------------------------------------------- the console + /** The body cut-up: plate upper-left, tree upper-right, drawer across the bottom. */ + _geometry() { + const W = this.scene.scale.width; + const H = this.scene.scale.height; + const pad = Math.max(16, Math.round(Math.min(W, H) * 0.04)); + const boxW = Math.min(1320, W - pad * 2); + const boxH = Math.min(860, H - pad * 2); + const bx = Math.round((W - boxW) / 2); + const by = Math.round((H - boxH) / 2); + const barH = 40; + const inner = { x: bx + 16, y: by + barH + 16, w: boxW - 32, h: boxH - barH - 32 }; + const reg = this.spec.tree?.regions ?? {}; + const gap = Math.max(8, Math.round(Number(reg.gapPx) || 14)); + const topH = Math.round(inner.h * (Number(reg.splitFraction) || 0.66)); + const plateW = Math.round(inner.w * (Number(reg.plateFraction) || 0.27)); + return { + W, H, pad, bx, by, boxW, boxH, barH, + panel: { x: bx, y: by, w: boxW, h: boxH }, + plate: { x: inner.x, y: inner.y, w: plateW, h: topH }, + tree: { x: inner.x + plateW + gap, y: inner.y, w: inner.w - plateW - gap, h: topH }, + drawer: { x: inner.x, y: inner.y + topH + gap, w: inner.w, h: inner.h - topH - gap }, + }; + } + _rebuild() { const snap = this.getSnapshot() ?? {}; const rows = Array.isArray(snap.rows) ? snap.rows : []; // The crest arrives with its media, so the icon's readiness is part of what - // this paint is about (otherwise the plate stays a NO SIGNAL box forever). - const sig = JSON.stringify([snap.hasClass, snap.classId, snap.points, snap.iconReady === true, rows.map((r) => [r.id ?? r.def?.id, r.rank, r.state, r.seat])]); + // this paint is about (otherwise the plate stays a NO SIGNAL box forever). The + // selected skill is in here too — the drawer is painted for it (the scroll is + // NOT: scrolling must never rebuild the cabinet). + const sig = JSON.stringify([ + snap.hasClass, snap.classId, snap.points, snap.iconReady === true, this.selected, + rows.map((r) => [r.id ?? r.def?.id, r.rank, r.state, r.seat, r.pending]), + ]); if (sig === this._sig) return; this._sig = sig; + const scrollWas = this._scroll; this._destroyBody(); this._hits = []; // the action map is rebuilt with the body (never wiped by a // no-change frame — a browser test clicks on these rects) - const W = this.scene.scale.width; - const H = this.scene.scale.height; - const pad = Math.max(16, Math.round(Math.min(W, H) * 0.05)); - const boxW = Math.min(1080, W - pad * 2); - const boxH = Math.min(620, H - pad * 2); - const bx = (W - boxW) / 2; - const by = (H - boxH) / 2; + const G = this._geometry(); + this._regions = G; // The scrim owns the screen: a press anywhere outside the panel closes it. - const scrim = this.scene.add.rectangle(0, 0, W, H, C.bg, 0.86).setOrigin(0, 0).setDepth(DEPTH).setInteractive(); + const scrim = this.scene.add.rectangle(0, 0, G.W, G.H, C.bg, 0.86).setOrigin(0, 0).setDepth(DEPTH).setInteractive(); scrim.on('pointerdown', () => this.close()); this.add(scrim); this._body = [scrim]; const g = this.gfx(); g.fillStyle(C.panel, 0.98); - g.fillRoundedRect(bx, by, boxW, boxH, 4); + g.fillRoundedRect(G.bx, G.by, G.boxW, G.boxH, 4); g.lineStyle(1, C.neon, 0.55); - g.strokeRoundedRect(bx, by, boxW, boxH, 4); + g.strokeRoundedRect(G.bx, G.by, G.boxW, G.boxH, 4); // The title bar. - const barH = 40; g.fillStyle(C.bg, 0.9); - g.fillRect(bx + 1, by + 1, boxW - 2, barH); + g.fillRect(G.bx + 1, G.by + 1, G.boxW - 2, G.barH); g.lineStyle(1, C.faint, 0.9); - g.lineBetween(bx + 1, by + barH, bx + boxW - 1, by + barH); - this.txt(bx + 18, by + barH / 2, String(snap.hasClass ? `${snap.className} // ${this.spec.title}` : this.spec.noClassNote).toUpperCase(), { + g.lineBetween(G.bx + 1, G.by + G.barH, G.bx + G.boxW - 1, G.by + G.barH); + this.txt(G.bx + 18, G.by + G.barH / 2, String(snap.hasClass ? `${snap.className} // ${this.spec.title}` : this.spec.noClassNote).toUpperCase(), { size: 15, header: true, color: C.ink, ls: 3, }); + const points = Math.max(0, Math.round(Number(snap.points) || 0)); + this.txt(G.bx + G.boxW - 52, G.by + G.barH / 2, fillTemplate(this.spec.pointsChip ?? '{n} PTS', { n: points }), { + size: 13, + header: true, + color: points > 0 ? C.amber : C.faint, + origin: 1, + ls: 2, + }); const closeW = 34; const closeG = this.gfx(); const paintClose = (hot) => { closeG.clear(); if (hot) { closeG.fillStyle(C.red, 0.18); - closeG.fillRect(bx + boxW - closeW, by + 1, closeW - 1, barH - 1); + closeG.fillRect(G.bx + G.boxW - closeW, G.by + 1, closeW - 1, G.barH - 1); } closeG.lineStyle(1.5, hot ? C.red : C.dim, 1); - closeG.lineBetween(bx + boxW - 24, by + barH / 2 - 7, bx + boxW - 10, by + barH / 2 + 7); - closeG.lineBetween(bx + boxW - 10, by + barH / 2 - 7, bx + boxW - 24, by + barH / 2 + 7); + closeG.lineBetween(G.bx + G.boxW - 24, G.by + G.barH / 2 - 7, G.bx + G.boxW - 10, G.by + G.barH / 2 + 7); + closeG.lineBetween(G.bx + G.boxW - 10, G.by + G.barH / 2 - 7, G.bx + G.boxW - 24, G.by + G.barH / 2 + 7); }; paintClose(false); const closeHit = this.scene.add - .rectangle(bx + boxW - closeW, by, closeW, barH, 0x000000, 0.001) + .rectangle(G.bx + G.boxW - closeW, G.by, closeW, G.barH, 0x000000, 0.001) .setOrigin(0, 0) - .setDepth(DEPTH + 2) + .setDepth(DEPTH + 3) .setInteractive({ useHandCursor: true }); closeHit.on('pointerover', () => paintClose(true)); closeHit.on('pointerout', () => paintClose(false)); @@ -322,37 +551,50 @@ export class SkillWindow extends Phaser.GameObjects.Container { }); this.add(closeHit); this._body.push(closeHit); + this._hits.push({ kind: 'close', id: null, x: G.bx + G.boxW - closeW, y: G.by, w: closeW, h: G.barH }); - // THE LEFT PLATE — the class's own column: insignia + the banked points. - const gap = 20; - const leftW = Math.round(boxW * 0.26); - const lx = bx + 18; - const ly = by + barH + 18; - const plateH = boxH - barH - 36; + this._buildPlate(snap, G.plate, points); + this._buildTree(rows, snap, G.tree); + // The selection must be somewhere it can be seen: a skill that left the roster + // (class changed) drops the drawer back to its prompt. + if (this.selected && !rows.some((r) => String(r.def?.id ?? '') === this.selected)) this.selected = null; + this._scroll = Math.min(Math.max(0, scrollWas), this._tree?.maxScroll ?? 0); + this._buildDrawer(rows, snap, G.drawer); + if (this._wantReveal) { + this._wantReveal = false; + this.scrollToSelection(); + } + this._applyScroll(); + } + + // ------------------------------------------------------- THE CLASS PLATE + + /** The class's own column: insignia, the looping feed's tag, the bank and the + * session rule (data/skills.json → window.video / window.sessionNote). */ + _buildPlate(snap, plate, points) { const pg = this.gfx(); pg.fillStyle(C.bg, 0.75); - pg.fillRect(lx, ly, leftW, plateH); + pg.fillRect(plate.x, plate.y, plate.w, plate.h); pg.lineStyle(1, C.faint, 0.9); - pg.strokeRect(lx, ly, leftW, plateH); - const accent = snap.accentCss ?? toCss(C.amber); + pg.strokeRect(plate.x, plate.y, plate.w, plate.h); + const plateKey = snap.iconKey ?? null; + let iconSize = 0; if (snap.iconReady && plateKey && this.scene.textures.exists(plateKey) && Number.isFinite(Number(snap.frame))) { - const iconSize = Math.min(150, leftW - 32); + iconSize = Math.min(150, plate.w - 32); const ic = this.scene.add - .image(lx + leftW / 2, ly + 20 + iconSize / 2, plateKey, Number(snap.frame)) + .image(plate.x + plate.w / 2, plate.y + 20 + iconSize / 2, plateKey, Number(snap.frame)) .setDisplaySize(iconSize, iconSize) .setDepth(DEPTH); this.add(ic); this._body.push(ic); - this._iconPlateSize = iconSize; // the feed sits under the crest } else { - this.txt(lx + leftW / 2, ly + plateH * 0.32, fillTemplate(this.spec.feedLine ?? 'NO SIGNAL', { class: snap.className ?? '' }), { size: 11, color: C.faint, origin: 0.5, ls: 3 }); + this.txt(plate.x + plate.w / 2, plate.y + plate.h * 0.32, fillTemplate(this.spec.feedLine ?? 'NO SIGNAL', { class: snap.className ?? '' }), { size: 11, color: C.faint, origin: 0.5, ls: 3, wrap: plate.w - 24 }); } // THE FEED (data/skills.json → window.video). A clip is authored separately; // while the cache has none the plate keeps its NO SIGNAL tag and the console // works exactly the same (the ship/class consoles' rule). - const iconS = Number.isFinite(this._iconPlateSize) ? this._iconPlateSize : 0; const feed = this.spec.video ?? {}; // A clip authored for the plate would be loaded under its basename // (assets/videos/skills.mp4 → 'skills'). No entry in the cache, no video. @@ -360,120 +602,535 @@ export class SkillWindow extends Phaser.GameObjects.Container { const vkey = want && this.scene.video?.exists?.(want) ? want : null; if (vkey) { const [aw, ah] = Array.isArray(feed.aspect) && feed.aspect.length === 2 ? feed.aspect : [2, 3]; - const vh = Math.min(plateH - 150, (leftW - 36) * (ah / aw)); - const vid = this.scene.video.add(lx + leftW / 2, ly + 24 + (iconS + vh) / 2, { key: vkey }); - vid.setDisplaySize(vh * (aw / ah), vh).loop().setMute(true).play(); - this.add(vid); - this._body.push(vid); + const vh = Math.min(plate.h - 150 - iconSize, (plate.w - 36) * (ah / aw)); + if (vh > 24) { + const vid = this.scene.video.add(plate.x + plate.w / 2, plate.y + 24 + iconSize + vh / 2, { key: vkey }); + vid.setDisplaySize(vh * (aw / ah), vh).loop().setMute(true).play(); + this.add(vid); + this._body.push(vid); + } } - this.txt(lx + leftW / 2, ly + plateH - 104, String((vkey ? feed.tag : feed.noSignalTag) ?? '').toUpperCase(), { + this.txt(plate.x + plate.w / 2, plate.y + plate.h - 106, String((vkey ? feed.tag : feed.noSignalTag) ?? '').toUpperCase(), { size: 10, color: vkey ? C.green : C.faint, origin: 0.5, ls: 3, }); // The dressing lives inside this rect (see _beatFx). - this._plate = { x: lx, y: ly, w: leftW, h: plateH }; - const points = Math.max(0, Math.round(Number(snap.points) || 0)); - this.txt(lx + 14, ly + plateH - 74, fillTemplate(points === 1 ? this.spec.pointsLineOne : this.spec.pointsLine, { n: points, points }), { + this._plate = { x: plate.x, y: plate.y, w: plate.w, h: plate.h }; + + this.txt(plate.x + 14, plate.y + plate.h - 76, fillTemplate(points === 1 ? this.spec.pointsLineOne : this.spec.pointsLine, { n: points, points }), { size: 12, color: points > 0 ? C.amber : C.dim, - wrap: leftW - 28, + wrap: plate.w - 28, line: 16, }); - this.txt(lx + 14, ly + plateH - 40, fillTemplate(this.spec.refundNote, { maxSeat: snap.seatCount ?? 8 }), { - size: 10, - color: C.faint, - wrap: leftW - 28, - line: 14, + // The SESSION rule — what RESET can reach, and what closing this window seals. + const pending = Math.max(0, Math.round(Number(snap.pending) || 0)); + this.txt(plate.x + 14, plate.y + plate.h - 44, fillTemplate(this.spec.sessionNote ?? '', { maxSeat: snap.seatCount ?? 8, n: pending }), { + size: 9, + color: pending > 0 ? C.amber : C.faint, + wrap: plate.w - 28, + line: 13, }); + } - // THE TREE — one row per skill of the class, in data order. - const rx = lx + leftW + gap; - const rw = bx + boxW - 18 - rx; - let y = ly; - this.txt(rx, y + 6, this.spec.treeTitle ?? 'SKILL TREE', { size: 12, header: true, color: C.ink, ls: 3 }); - y += 26; - if (!snap.hasClass || rows.length === 0) { - this.txt(rx, y + 10, snap.hasClass ? this.spec.emptyNote : this.spec.noClassNote, { size: 12, color: C.dim, wrap: rw }); - return; + // ------------------------------------------------------------ THE TREE + + /** + * The upper-right pane: a scrollable GRID of icon tiles joined by their unlock + * edges. Everything the pane owns (the tiles' container, the lines, the scroll + * surface, the thumb) is built here; `_applyScroll()` slides it and culls what + * cannot fit whole. + */ + _buildTree(rows, snap, pane) { + const T = this.spec.tree ?? {}; + const frame = T.frame ?? {}; + const edgeC = T.edge ?? {}; + const badge = T.badge ?? {}; + const sb = T.scrollbar ?? {}; + const gap = T.gap ?? {}; + const byId = new Map(rows.map((r) => [String(r.def?.id ?? ''), r])); + + // --- the grid (data placement, or the graph's: SkillModel → treeLayout) --- + const layout = treeLayout(rows.map((r) => r.def).filter(Boolean)); + const cols = Math.max(1, Number(layout.cols) || 1); + const rowCount = Math.max(1, Number(layout.rows) || 1); + const bounds = layout.bounds ?? { minCol: 0, maxCol: cols - 1, minRow: 0, maxRow: rowCount - 1 }; + const usedCols = Math.max(1, bounds.maxCol - bounds.minCol + 1); + + const pad = Math.max(8, Math.round(Number(gap.padPx) || 18)); + const gapX = Math.max(6, Math.round(Number(gap.colPx) || 34)); + const gapY = Math.max(6, Math.round(Number(gap.rowPx) || 44)); + const sbW = Math.max(0, Math.round(Number(sb.wPx) || 8)) + 6; + const innerW = pane.w - pad * 2; + + // Tiles: as big as the data allows, shrunk only so `cols` of them fit across. + const sizeMin = Math.max(16, Math.round(Number(T.tile?.minPx) || 46)); + const sizeMax = Math.max(sizeMin, Math.round(Number(T.tile?.maxPx) || 92)); + const size = Math.max(sizeMin, Math.min(sizeMax, Math.floor(innerW / usedCols) - gapX)); + const stepX = size + gapX; + const stepY = size + gapY; + + // The scroll window vs the content: centered when it fits, scrolled when not. + const viewH = pane.h - 2; + const contentH = (rowCount - 1) * stepY + size; + const maxScroll = Math.max(0, contentH + pad * 2 - viewH); + const topPad = pad + Math.max(0, (viewH - (contentH + pad * 2)) / 2); + const usedW = (usedCols - 1) * stepX + size; + // The grid's left edge INSIDE the pane (centered when it does not fill it; the + // scrollbar column is kept clear on the right). + const offX = pad + sbW / 2 + Math.max(0, (innerW - sbW - usedW) / 2); + + // --- the pane's skin: a sunken well with a soft band at each edge so a tile + // leaving the whole-fit line reads as an exit, not a pop --- + const skin = this.gfx(); + skin.fillStyle(hexToIntSafe(frame.faceColor, C.bg), Math.min(0.98, (Number(frame.faceAlpha) || 0.92) * 0.42)); + skin.fillRect(pane.x, pane.y, pane.w, pane.h); + skin.lineStyle(1, C.faint, 0.9); + skin.strokeRect(pane.x, pane.y, pane.w, pane.h); + + // --- everything that scrolls lives in one container; scrolling moves the + // container and nothing else (one write per frame, ever) --- + const inner = this.scene.add.container(pane.x, pane.y).setDepth(DEPTH); + this.add(inner); + this._body.push(inner); + this._inner = inner; + + // The unlock lines. Drawn in the container's own space and trimmed to what is + // on screen (no clip mask in this build — see clipSegment). + const edgeG = this.scene.add.graphics().setDepth(DEPTH - 2); + inner.add(edgeG); + this._edgeG = edgeG; + + const nodes = []; + for (const n of layout.nodes) { + const row = byId.get(n.id) ?? null; + const def = row?.def ?? n.def ?? {}; + // Everything below is laid out in the SCROLL CONTAINER's own space: lx across + // the pane, ly down the (unscrolled) content. A tile's world position is + // (pane.x + lx, pane.y + ly - scroll). + const lx = Math.round(offX + (n.col - bounds.minCol) * stepX); + const ly = Math.round(topPad + (n.row - bounds.minRow) * stepY); + const absX = Math.round(pane.x + lx); + const accent = def.type?.accentCss ?? toCss(C.amber); + const nc = this.scene.add.container(lx, ly).setDepth(DEPTH - 1); + inner.add(nc); + + // The tile's plate + frame: bevelled square, edge colour by STATE (the + // screenshot's idiom — gold on the path, pale for what money can buy, dead + // iron for what cannot be touched yet). + const tg = this.scene.add.graphics(); + nc.add(tg); + const locked = row?.state === 'locked'; + const maxed = row?.state === 'maxed'; + const onPath = (row?.rank ?? 0) > 0; + const edgeHex = locked + ? frame.lockedEdge + : maxed + ? frame.maxedEdge + : onPath + ? frame.spentEdge + : row?.state === 'available' || row?.state === 'upgradable' + ? frame.availableEdge + : frame.idleEdge; + const bevel = Math.max(1, Math.round(Number(frame.bevelPx) || 3)); + tg.fillStyle(hexToIntSafe(frame.faceColor, C.bg), Number.isFinite(Number(frame.faceAlpha)) ? Number(frame.faceAlpha) : 0.92); + tg.fillRect(0, 0, size, size); + tg.lineStyle(bevel, hexToIntSafe(edgeHex, C.faint), locked ? 0.75 : 1); + tg.strokeRect(bevel / 2, bevel / 2, size - bevel, size - bevel); + // The type's own hairline along the bottom (the tree reads at a glance). + tg.fillStyle(hexToIntSafe(accent, C.amber), locked ? 0.35 : 0.9); + tg.fillRect(bevel, size - bevel - 1, size - bevel * 2, 2); + + // The insignia (frame off assets/images/skills.png — the SAME sheet the bar + // wears). A locked skill still shows its silhouette, just buried. + if (this.scene.textures.exists(SKILL_ICON_KEY)) { + const icon = this.scene.add + .image(size / 2, size / 2, SKILL_ICON_KEY, def.frame ?? 0) + .setDisplaySize(size - bevel * 3, size - bevel * 3); + if (locked) { + icon.setTint(0x54608a); + icon.setAlpha(0.5); + } else if (!onPath) { + icon.setAlpha(0.9); + } + nc.add(icon); + } + // The n/m plate in the tile's lower-right corner (the screenshot's badge). + const showBadge = Number(def.maxPoints ?? row?.max ?? 1) > 1; + if (showBadge) { + const bw = Math.round(size * 0.52); + const bh = Math.max(12, Math.round((Number(badge.fontSizePx) || 12) + 6)); + const bg = this.scene.add.graphics(); + bg.fillStyle(hexToIntSafe(badge.bgColor, C.bg), Number.isFinite(Number(badge.bgAlpha)) ? Number(badge.bgAlpha) : 0.88); + bg.fillRect(size - bw, size - bh, bw, bh); + bg.lineStyle(1, hexToIntSafe(edgeHex, badge.edgeColor), 0.9); + bg.strokeRect(size - bw, size - bh, bw, bh); + nc.add(bg); + const bt = this.scene.add + .text(size - bw / 2, size - bh / 2, `${row?.rank ?? 0}/${def.maxPoints ?? row?.max ?? 1}`, { + fontFamily: HEADER, + fontSize: `${Number(badge.fontSizePx) || 12}px`, + color: toCss((row?.rank ?? 0) > 0 ? hexToIntSafe(badge.spentColor, C.amber) : hexToIntSafe(badge.color, C.ink)), + letterSpacing: 1, + }) + .setOrigin(0.5); + nc.add(bt); + } + // A tile with points this session but no commit yet wears a gold corner (the + // ledger's `pending`, never the view's arithmetic). + if ((row?.pending ?? 0) > 0) { + const veil = this.scene.add.graphics(); + veil.fillStyle(hexToIntSafe(T.veil?.pendingGlow, C.amber), Math.min(0.85, Number(T.veil?.pendingAlpha) || 0.5)); + veil.fillRect(0, 0, size, 3); + nc.add(veil); + } + + // The tile has NO interactive object of its own: one surface covers the pane + // (so a drag started on a tile still drags the tree) and finds its tile by + // geometry in _tileAt(). What lives in the action map is the tile's rect, kept + // glued to the tile by _applyScroll — that is what a browser test clicks. + const rect = { kind: 'tile', id: n.id, x: absX, y: pane.y + ly, w: size, h: size }; + this._hits.push(rect); + nodes.push({ id: n.id, def, row, lx, ly, absX, size, container: nc, rect, lcol: n.col, lrow: n.row }); } - const rowH = Math.min(132, Math.max(96, (by + boxH - 18 - y) / Math.max(1, rows.length))); - for (const row of rows) { - this._drawRow(row, rx, y, rw, rowH); - y += rowH + 10; + + // The authored grid does not stop where the skills do: every cell inside the + // tree's bounds that holds nothing is plated as a dim SOCKET, so a two-skill tree + // still reads as a tree — the reference sheet's empty nodes. + const sock = T.socket ?? {}; + if (sock.enabled) { + const sockets = this.scene.add.graphics().setDepth(DEPTH - 3); + inner.add(sockets); + const tick = Math.max(3, Math.round(Number(sock.tickPx) || 6)); + const edge = hexToIntSafe(sock.edgeColor ?? frame.idleEdge, C.faint); + for (let r = bounds.minRow; r <= bounds.maxRow; r++) { + for (let c = bounds.minCol; c <= bounds.maxCol; c++) { + if (nodes.some((n) => n.lcol === c && n.lrow === r)) continue; + const sx = Math.round(offX + (c - bounds.minCol) * stepX); + const sy = Math.round(topPad + (r - bounds.minRow) * stepY); + sockets.fillStyle(hexToIntSafe(sock.faceColor ?? frame.faceColor, C.bg), Number(sock.faceAlpha) || 0.5); + sockets.fillRect(sx, sy, size, size); + sockets.lineStyle(1, edge, Math.min(1, Number(sock.edgeAlpha) || 0.35)); + sockets.strokeRect(sx + 0.5, sy + 0.5, size - 1, size - 1); + // Corner ticks: an empty socket is a PLACE, not a hole. + sockets.lineStyle(2, edge, Math.min(1, (Number(sock.edgeAlpha) || 0.35) * 1.6)); + for (const [kx, ky, dx, dy] of [ + [sx, sy, 1, 1], [sx + size, sy, -1, 1], [sx, sy + size, 1, -1], [sx + size, sy + size, -1, -1], + ]) { + sockets.lineBetween(kx, ky, kx + dx * tick, ky); + sockets.lineBetween(kx, ky, kx, ky + dy * tick); + } + } + } + } + + this._tree = { + pane, nodes, layout, byId, pad, viewH, maxScroll, size, stepY, edgeC, frame, sb, T, + }; + + // --- the scroll surface: one interactive skin over the whole pane (wheel, + // drag, and every tile press) — then the thumb on top of it ------------- + const surf = this.scene.add.graphics().setDepth(DEPTH + 4); + const area = new Phaser.Geom.Rectangle(pane.x, pane.y, pane.w - sbW, pane.h); + surf.setInteractive({ hitArea: area, hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(area, px, py), draggable: false }); + surf.on('pointerdown', (p) => { + this._drag = { y0: p?.y ?? 0, scroll: this._scroll, moved: false }; + }); + surf.on('wheel', (_p, _dx, dyy) => { + const per = Math.max(8, Math.round(Number(sb.wheelPxPerNotch) || 64)); + if (dyy) this.scrollBy(dyy > 0 ? per : -per); + }); + this.add(surf); + this._body.push(surf); + + // The thumb: the pane's own overflow read back to the player. + const sbx = pane.x + pane.w - sbW + 2; + const track = this.scene.add.graphics().setDepth(DEPTH + 2); + track.fillStyle(hexToIntSafe(sb.trackColor, C.bg), 0.9); + if (maxScroll > 0) { + track.fillRoundedRect(sbx, pane.y + 3, Math.max(3, sbW - 6), pane.h - 6, 2); + track.lineStyle(1, C.faint, 0.8); + track.strokeRoundedRect(sbx, pane.y + 3, Math.max(3, sbW - 6), pane.h - 6, 2); + } + this.add(track); + this._body.push(track); + if (maxScroll > 0) { + const tHit = this.scene.add + .rectangle(sbx - 4, pane.y, sbW + 8, pane.h, 0x000000, 0.001) + .setOrigin(0, 0) + .setDepth(DEPTH + 5) + .setInteractive({ useHandCursor: true }); + const jump = (p) => { + const localY = (p?.y ?? 0) - pane.y; + const frac = Math.min(1, Math.max(0, localY / pane.h)); + this.scrollTo(frac * maxScroll); + }; + tHit.on('pointerdown', (p, l, e) => { e?.stopPropagation?.(); jump(p); this._dragThumb = true; }); + tHit.on('pointermove', (p) => { if (this._dragThumb) jump(p); }); + tHit.on('pointerup', () => { this._dragThumb = false; }); + tHit.on('pointerout', () => { this._dragThumb = false; }); + this.add(tHit); + this._body.push(tHit); + this._hits.push({ kind: 'scroll', id: null, x: sbx, y: pane.y, w: sbW, h: pane.h }); + } + this._thumbG = track; // redrawn by _applyScroll + this._thumbGeom = { x: sbx, w: Math.max(3, sbW - 6) }; + + // The selection ring + hover edge ride ABOVE the tiles (one graphics, per-frame). + const ring = this.scene.add.graphics().setDepth(DEPTH + 1); + inner.add(ring); + this._ringG = ring; + + // The overflow hint, only when there is something to scroll. + if (maxScroll > 0 && T.hintLine) { + this.txt(pane.x + 8, pane.y + pane.h - 12, String(T.hintLine).toUpperCase(), { size: 9, color: C.faint, ls: 2 }); } } - /** One skill's row: icon, label, TYPE, rank n/max, cost line, effect copy, actions. */ - _drawRow(row, x, y, w, h) { - const def = row.def ?? {}; - const spec = this.spec; - const accent = def.type?.accentCss ?? toCss(C.amber); - const locked = row.state === 'locked'; - const g = this.gfx(); - g.fillStyle(C.bg, 0.6); - g.fillRect(x, y, w, h - 10); - g.lineStyle(1, C.faint, locked ? 0.7 : 1); - g.strokeRect(x, y, w, h - 10); - // The type's own edge along the row's left (the tree reads at a glance). - g.fillStyle(Phaser.Display.Color.HexStringToColor(accent).color, 0.95); - g.fillRect(x, y, 3, h - 10); + scrollBy(dy) { + const max = this._tree?.maxScroll ?? 0; + if (max <= 0) return this; + this.scrollTo(this._scroll + dy); + return this; + } - // The icon (frame off assets/images/skills.png — the SAME sheet the bar wears). - const iconS = 40; - if (!locked && this.scene.textures.exists(SKILL_ICON_KEY)) { - const ic = this.scene.add.image(x + 12 + iconS / 2, y + 14 + iconS / 2, SKILL_ICON_KEY, def.frame ?? 0).setDisplaySize(iconS, iconS).setDepth(DEPTH); + scrollTo(y) { + const max = this._tree?.maxScroll ?? 0; + const next = Math.min(max, Math.max(0, Number(y) || 0)); + if (Math.abs(next - this._scroll) < 0.5) return this; + this._scroll = next; + this._applyScroll(); + return this; + } + + /** Slide the pane: move the container, cull what cannot fit WHOLE (no clip mask + * in this Phaser build — BuyList.js), re-trim the lines, redraw the thumb. */ + _applyScroll() { + const t = this._tree; + if (!t) return; + // One write moves the whole grid; the tiles' input rects live outside the + // container and are glued to their tiles by hand. + if (this._inner) this._inner.y = t.pane.y - this._scroll; + for (const n of t.nodes) { + const top = t.pane.y + n.ly - this._scroll; + const shown = top >= t.pane.y - 0.5 && top + n.size <= t.pane.y + t.viewH + 0.5; + n.container.setVisible(shown); + n.rect.y = top; // the action map tracks the tile (a test clicks these) + n.visible = shown; + } + this._paintEdges(); + this._paintThumb(); + } + + /** The unlock lines: gold where the path runs (parent satisfied AND the child + * ranked), mid-tone for an open door, faint for a locked one. Elbowed exactly + * like the reference sheet: out of the parent's bottom edge, across, into the + * child's top edge — each segment trimmed to the pane. */ + _paintEdges() { + const t = this._tree; + const g = this._edgeG; + if (!t || !g) return; + const e = t.edgeC ?? {}; + g.clear(); + const w = Math.max(1, Number(e.widthPx) || 4); + const notch = Math.max(4, Number(e.notchPx) || 8); + const at = (id) => t.nodes.find((n) => n.id === id); + // The visible band, in the container's own space: the pane is a window on the + // content, and at scroll `s` it shows content-y from s to s + viewH. + const win = { x: -1, r: t.pane.w + 1, y: this._scroll + 1, b: this._scroll + t.viewH - 1 }; + for (const edge of t.layout.edges ?? []) { + const a = at(edge.from); + const b = at(edge.to); + if (!a || !b) continue; + const fromRow = t.byId.get(edge.from); + const toRow = t.byId.get(edge.to); + const satisfied = (fromRow?.rank ?? 0) >= Math.max(1, edge.points || 1); + const spent = satisfied && (toRow?.rank ?? 0) > 0; + const hex = spent ? e.spentColor : satisfied ? e.openColor : e.lockedColor; + const alpha = spent ? e.spentAlpha : satisfied ? e.openAlpha : e.lockedAlpha; + g.lineStyle(w, hexToIntSafe(hex, C.faint), Number.isFinite(Number(alpha)) ? Number(alpha) : 0.6); + const x1 = a.lx + a.size / 2; + const y1 = a.ly + a.size; + const x2 = b.lx + b.size / 2; + const y2 = b.ly; + const midY = y1 + Math.max(notch, (y2 - y1) / 2); + const segs = Math.abs(x2 - x1) < 1 + ? [[x1, y1, x2, y2]] + : [[x1, y1, x1, midY], [x1, midY, x2, midY], [x2, midY, x2, y2]]; + for (const [sx1, sy1, sx2, sy2] of segs) { + const cut = clipSegment(sx1, sy1, sx2, sy2, win); + if (cut) g.lineBetween(cut.x1, cut.y1, cut.x2, cut.y2); + } + // The stub into the child's edge reads as an arrowhead on the chosen path. + if (spent && clipSegment(x2, y2 - notch, x2, y2, win)) { + g.fillStyle(hexToIntSafe(e.spentColor, C.amber), 1); + g.fillRect(x2 - w, y2 - 3, w * 2, 3); + } + } + } + + _paintThumb() { + const t = this._tree; + const g = this._thumbG; + if (!t || !g || !this._thumbGeom) return; + // Repaint the track (a thumb is drawn OVER it, so the track is redrawn first). + const sb = t.sb ?? {}; + g.clear(); + if ((t.maxScroll ?? 0) <= 0) return; + const { x, w } = this._thumbGeom; + g.fillStyle(hexToIntSafe(sb.trackColor, C.bg), 0.9); + g.fillRoundedRect(x, t.pane.y + 3, w, t.pane.h - 6, 2); + g.lineStyle(1, C.faint, 0.8); + g.strokeRoundedRect(x, t.pane.y + 3, w, t.pane.h - 6, 2); + const frac = Math.min(1, t.viewH / (t.viewH + t.maxScroll)); + const h = Math.max(18, Math.round((t.pane.h - 8) * frac)); + const span = Math.max(1, t.pane.h - 8 - h); + const ty = t.pane.y + 4 + span * (t.maxScroll > 0 ? this._scroll / t.maxScroll : 0); + g.fillStyle(hexToIntSafe(sb.thumbColor, C.dim), 0.95); + g.fillRoundedRect(x, ty, w, h, 2); + } + + /** The selected tile's ring (and the hover edge) — redrawn every frame off the + * data's own pulse rate, which is the only per-frame painting this pane does. */ + _beatTree(now) { + const t = this._tree; + const g = this._ringG; + if (!t || !g) return; + const f = t.frame ?? {}; + g.clear(); + const size = t.size; + const pulse = 0.55 + 0.45 * Math.sin(((Number(now) || 0) / 1000) * Math.PI * 2 * (Number(f.selectedPulseHz) || 1.8)); + const hot = f.selectedEdge ?? '#00e5ff'; + for (const n of t.nodes) { + if (!n.visible) continue; + // The ring graphics is a child of the scroll container, so it shares the + // tiles' own space — no scroll arithmetic needed. + const x = n.lx; + const y = n.ly; + if (n.id === this.selected) { + g.lineStyle(2, hexToIntSafe(hot, C.neon), 0.5 + 0.5 * pulse); + g.strokeRect(x - 5, y - 5, size + 10, size + 10); + g.fillStyle(hexToIntSafe(hot, C.neon), 0.28 + 0.32 * pulse); + for (const [cx, cy] of [[x - 5, y - 5], [x + size + 5, y - 5], [x - 5, y + size + 5], [x + size + 5, y + size + 5]]) { + g.fillRect(cx - 2, cy - 2, 4, 4); + } + } else if (n.id === this._hover) { + g.lineStyle(1, hexToIntSafe(hot, C.neon), 0.35 + 0.25 * pulse); + g.strokeRect(x - 3, y - 3, size + 6, size + 6); + } + } + } + + // ---------------------------------------------------------- THE DRAWER + + /** The bottom third: the selected skill, big icon left / copy right, its three + * actions in the lower-right corner. */ + _buildDrawer(rows, snap, d) { + const dg = this.gfx(); + dg.fillStyle(C.bg, 0.72); + dg.fillRect(d.x, d.y, d.w, d.h); + dg.lineStyle(1, C.faint, 0.95); + dg.strokeRect(d.x, d.y, d.w, d.h); + dg.fillStyle(C.neon, 0.5); + dg.fillRect(d.x + 1, d.y + 1, d.w - 2, 1); + + const row = rows.find((r) => String(r.def?.id ?? '') === String(this.selected ?? '')) ?? null; + if (!snap.hasClass || !row) { + const prompt = snap.hasClass ? this.spec.detailPrompt : this.spec.noClassNote; + this.txt(d.x + d.w / 2, d.y + d.h / 2 - 10, String(prompt ?? '').toUpperCase(), { size: 14, header: true, color: C.dim, origin: 0.5, ls: 3, wrap: d.w - 60 }); + const sub = snap.hasClass ? this.spec.detailPromptSub : ''; + if (sub) this.txt(d.x + d.w / 2, d.y + d.h / 2 + 14, String(sub).toUpperCase(), { size: 10, color: C.faint, origin: 0.5, ls: 3 }); + return; + } + + const def = row.def ?? {}; + const accent = def.type?.accentCss ?? toCss(C.amber); + // Graphics wants an int, canvas text wants CSS: the type's accent is both. + const accentInt = hexToIntSafe(accent, C.amber); + const locked = row.state === 'locked'; + const frame = this.spec.tree?.frame ?? {}; + const points = Math.max(0, Math.round(Number(snap.points) || 0)); + + // THE BIG ICON — the tile's own dressing, scaled up. + const iconS = Math.max(64, Math.min(d.h - 40, 132)); + const ix = d.x + 20; + const iy = d.y + 18; + const ig = this.gfx(); + ig.fillStyle(hexToIntSafe(frame.faceColor, C.bg), Number.isFinite(Number(frame.faceAlpha)) ? Number(frame.faceAlpha) : 0.92); + ig.fillRect(ix, iy, iconS, iconS); + ig.lineStyle(3, hexToIntSafe(locked ? frame.lockedEdge : (row.rank ?? 0) > 0 ? frame.spentEdge : accent, C.faint), 1); + ig.strokeRect(ix + 1.5, iy + 1.5, iconS - 3, iconS - 3); + if (this.scene.textures.exists(SKILL_ICON_KEY)) { + const ic = this.scene.add.image(ix + iconS / 2, iy + iconS / 2, SKILL_ICON_KEY, def.frame ?? 0).setDisplaySize(iconS - 10, iconS - 10).setDepth(DEPTH); + if (locked) { + ic.setTint(0x54608a); + ic.setAlpha(0.55); + } this.add(ic); this._body.push(ic); - } else { - const lock = this.gfx(); - lock.lineStyle(1, C.faint, 0.9); - lock.strokeRect(x + 12, y + 14, iconS, iconS); - lock.lineBetween(x + 12 + iconS / 2 - 6, y + 14 + iconS / 2, x + 12 + iconS / 2 + 6, y + 14 + iconS / 2); } + // The rank under the big icon (the drawer's own copy of n/max, bigger than the + // tile's badge because this is where it is read). + const rankTxt = fillTemplate(this.spec.rankLine ?? '{rank}/{max}', { rank: row.rank ?? 0, max: def.maxPoints ?? row.max ?? 1 }); + this.txt(ix + iconS / 2, iy + iconS + 14, rankTxt, { size: 16, header: true, color: (row.rank ?? 0) > 0 ? accent : C.dim, origin: 0.5, ls: 2 }); - const tx = x + 12 + iconS + 16; - const rightW = 190; - const textW = w - (tx - x) - rightW - 16; - this.txt(tx, y + 18, String(def.label ?? '').toUpperCase(), { size: 13, header: true, color: locked ? C.faint : C.ink, ls: 2 }); - this.txt(x + w - 14, y + 18, `${row.rank}/${row.max}`, { size: 15, header: true, color: row.rank > 0 ? accent : C.dim, origin: 1 }); - // The TYPE chip on its own line under the label (no width measuring needed). - this.txt(tx, y + 34, `${def.type?.label ?? ''}`.toUpperCase(), { size: 9, color: locked ? C.faint : accent, ls: 2 }); - - const costLine = def.passive - ? spec.passiveCostLine - : fillTemplate(spec.costLine, { energy: def.energy ?? 0, cooldown: def.cooldown ?? 0 }); - const seatLine = def.passive ? '' : ` \u00b7 ${String(spec.seatLabel ?? 'SEAT')} ${row.seat ?? '\u2014'}`; - this.txt(tx, y + 50, `${costLine}${seatLine}`.toUpperCase(), { size: 10, color: C.dim, ls: 1 }); - + // THE COPY — name, chips, cost, effect text. + const tx = ix + iconS + 22; + const btnGap = 8; + // Buttons sized off the drawer, so a small window still gets three whole ones. + const btnH = Math.max(22, Math.min(30, Math.round(d.h * 0.12))); + const fit = (want) => Math.max(76, Math.min(want, Math.round(d.w * 0.24))); + const addW = fit(196); + const resetW = fit(92); + const seatW = fit(130); + const rightW = addW + resetW + btnGap * 2 + 20; // keep the copy clear of the buttons + const textW = Math.max(140, d.x + d.w - rightW - tx - 8); + const ty = iy + 2; + this.txt(tx, ty, String(def.label ?? '').toUpperCase(), { size: 17, header: true, color: locked ? C.faint : C.ink, ls: 2 }); const stateTag = STATE_TAG[row.state] ?? ''; - this.txt(x + w - 14, y + 36, row.passive || def.passive ? (row.rank > 0 ? spec.states?.passiveOn ?? 'ACTIVE' : stateTag) : stateTag, { - size: 9, - color: row.state === 'maxed' ? C.green : row.state === 'noPoints' || row.state === 'locked' ? C.faint : C.amber, - origin: 1, + const seatBit = def.passive ? '' : ` \u00b7 ${String(this.spec.seatLabel ?? 'SEAT')} ${row.seat ?? '\u2014'}`; + this.txt(tx, ty + 20, `${def.type?.label ?? ''} \u00b7 ${row.rank > 0 && def.passive ? (this.spec.states?.passiveOn ?? 'ACTIVE') : stateTag}${seatBit}`.toUpperCase(), { + size: 10, + color: locked ? C.faint : accent, ls: 2, }); - - this.txt(tx, y + 68, def.description ?? def.shortDescription ?? '', { size: 10, color: locked ? C.faint : C.dim, wrap: textW, line: 13 }); - + const costLine = def.passive + ? this.spec.passiveCostLine + : fillTemplate(this.spec.costLine ?? '', { energy: def.energy ?? 0, cooldown: def.cooldown ?? 0 }); + this.txt(tx, ty + 38, String(costLine ?? '').toUpperCase(), { size: 10, color: C.dim, ls: 1 }); + // The pending tally — what RESET would hand back (the ledger's number, the data's + // words). + if ((row.pending ?? 0) > 0) { + this.txt(tx + textW + 8, ty + 2, fillTemplate(this.spec.pendingNote ?? '+{n}', { n: row.pending }).toUpperCase(), { size: 10, header: true, color: C.amber, origin: 0, ls: 2 }); + } + this.txt(tx, ty + 58, def.description || def.shortDescription || '', { size: 12, color: locked ? C.faint : C.dim, wrap: textW, line: 17 }); if (locked && row.locked) { const parent = String(row.locked.label ?? row.locked.id ?? '').toUpperCase(); - this.txt(tx, y + h - 26, fillTemplate(spec.lockedNote ?? 'REQUIRES {parent}', { parent, points: row.locked.points ?? 1 }), { size: 9, color: C.red, ls: 1 }); + this.txt(tx, d.y + d.h - 20, fillTemplate(this.spec.lockedNote ?? 'REQUIRES {parent}', { parent, points: row.locked.points ?? 1 }), { size: 10, color: C.red, ls: 1 }); } - // The actions: RANK UP (the point) + the seat hand-off (active skills only). - const btnW = 84; - const btnH = 20; - const byy = y + h - 36; - this.button(x + w - 14 - btnW, byy, btnW, btnH, fillTemplate(spec.spendLabel ?? 'RANK UP', { points: 1 }), accent, () => this.onRankUp?.(def.id), { - inert: !(row.state === 'available' || row.state === 'upgradable'), + // THE ACTIONS — lower-right corner. Gray means "nothing for it to do", never + // "the press went nowhere": the scene's door says why (see GameScene). + const byy = d.y + d.h - btnH - 16; + const addX = d.x + d.w - 20 - addW; + const resetX = addX - btnGap - resetW; + const seatX = resetX - btnGap - seatW; + const canAdd = row.state === 'available' || row.state === 'upgradable'; + this.button(addX, byy, addW, btnH, String(points > 0 ? this.spec.addPointLabel : this.spec.addPointNoPoints || 'NO POINTS LEFT').toUpperCase(), accentInt, () => this.onRankUp?.(def.id), { + inert: !canAdd, kind: 'rankUp', rowId: def.id, }); + this.button(resetX, byy, resetW, btnH, String(this.spec.resetLabel ?? 'RESET').toUpperCase(), C.ink, () => this.onReset?.(def.id), { + inert: !(row.pending > 0), + kind: 'reset', + rowId: def.id, + }); if (!def.passive) { - const label = row.seat ? spec.unbindLabel ?? 'CLEAR SEAT' : spec.assignLabel ?? 'ASSIGN SEAT'; - this.button(x + w - 14 - btnW * 2 - 8, byy, btnW, btnH, String(label).toUpperCase(), row.seat ? C.ink : C.neon, () => (row.seat ? this.onClearSeat?.(row.seat - 1) : this.onAssign?.(def.id)), { + const label = (row.seat ? this.spec.unbindLabel ?? 'CLEAR SEAT' : this.spec.assignLabel ?? 'ASSIGN SEAT'); + this.button(seatX, byy, seatW, btnH, String(label).toUpperCase(), row.seat ? C.ink : C.neon, () => (row.seat ? this.onClearSeat?.(row.seat - 1) : this.onAssign?.(def.id)), { inert: locked, kind: row.seat ? 'clearSeat' : 'assign', rowId: def.id,