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
This commit is contained in:
Brian Fertig 2026-09-27 10:12:47 -06:00
parent 51fc62ec66
commit 420371a4de
25 changed files with 2198 additions and 311 deletions

View File

@ -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`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 MiB

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

BIN
assets/skilltree.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@ -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" }

View File

@ -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))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 KiB

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 KiB

After

Width:  |  Height:  |  Size: 456 KiB

View File

@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View File

@ -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
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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)

View File

@ -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(),
};

View File

@ -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),
};
})(),
};
}

View File

@ -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)) {

File diff suppressed because it is too large Load Diff