Redesign skill tree console as grid with session-based reset
- Replace per-skill rows with a scrollable tile grid (authored `node` cells or auto-layout by dependency depth), elbow join lines, rank badges and empty sockets - Add detail drawer at bottom third showing the selected skill's copy plus ADD A SKILL POINT, RESET and ASSIGN/CLEAR SEAT actions - Introduce a console session in SkillState: points spent while open are PENDING and resettable; closing commits them; reset cascades down a branch bought this session or refuses when it would strand a committed point - Wire GameScene onReset/onClose doors, toast copy and passive re-roll on refund - Move all tree geometry/palette knobs into data/skills.json (window.tree) with clamped fallbacks in SkillModel.windowSpec() - Add pure treeLayout()/classTreeLayout() to SkillModel for canvas-free layout tests - Expand dev/skills-window-browser.test.mjs to verify regions, tile placement, session lifecycle, scroller (wheel/drag/thumb), and pixel-prove the gold join line - Update README, PROJECT_NOTES and screenshot fixtures
32
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`.
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.1 MiB After Width: | Height: | Size: 5.7 MiB |
|
After Width: | Height: | Size: 133 KiB |
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 403 KiB After Width: | Height: | Size: 513 KiB |
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 484 KiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 565 KiB |
|
Before Width: | Height: | Size: 486 KiB After Width: | Height: | Size: 456 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<object>} 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),
|
||||
};
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string,number>|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<object>, 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)) {
|
||||
|
|
|
|||