Add Inferno and Nanobots skills to the Vanguard tree

- Inferno (Offense, seat 2): flame arcs home on the nearest 2 hostiles
  (3 from rank 4) and ignite a burn that ticks on the combat loop's clock;
  scales in duration and per-tick damage with rank.
- Nanobots (Passive, no seat): mends 1% of max hull every 2s per point;
  a full hull banks nothing.
- Wire flameArc + hullRegen effect handlers in SkillEffects.js and the
  arc/burn/step logic in CombatSystem.js.
- Add firstPassiveOf() / hullRegen() to the skill ledger so the combat
  loop can read the mend's interval and color off the ranked def.
- Update icon sheet, dev shots, and the browser/combat/data test suites for
  the two new skills.
This commit is contained in:
Brian Fertig 2026-09-27 22:06:03 -06:00
parent 8a85ec9fff
commit a5f69f3221
24 changed files with 950 additions and 158 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

View File

@ -3,7 +3,7 @@
"enabled": true,
"_timeUnit": "cooldown is SECONDS (recharge time after a cast); duration/durationMs inside an effect are MILLISECONDS so they match the combat loop's clock.",
"icons": {
"_comment": "The skill-insignia sheet — assets/images/skills.png is 1024×512 = eight 128×128 columns × four rows = thirty-two frames, numbered row-major (frame 0 = top-left, frame 1 = the cell right of it). Frame 0 = Ramming Charge (the ram's head), frame 1 = Reinforced Plating (the armour plate), frame 8 = Boleadoras (the two snaring orbs), frame 9 = Blink (the watching eye); frames 2..7 and 10..31 are unassigned seams waiting for the next installs. The tree, the tooltip and the combat bar's seats all read a skill's `frame` off this ONE sheet.",
"_comment": "The skill-insignia sheet — assets/images/skills.png is 1024×512 = eight 128×128 columns × four rows = thirty-two frames, numbered row-major (frame 0 = top-left, frame 1 = the cell right of it). Frame 0 = Ramming Charge (the ram's head), frame 1 = Reinforced Plating (the armour plate), frame 2 = Inferno (the arcs of flame), frame 3 = Nanobots (the mending swarm), frame 8 = Boleadoras (the two snaring orbs), frame 9 = Blink (the watching eye); frames 4..7 and 10..31 are unassigned seams waiting for the next installs. The tree, the tooltip and the combat bar's seats all read a skill's `frame` off this ONE sheet.",
"texture": "assets/images/skills.png",
"frameWidth": 128,
"frameHeight": 128
@ -74,7 +74,7 @@
"classes": {
"_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.",
"_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. Install 2 grows a child under each: Inferno (the fire that stays on the target — 3 points in Ramming Charge before it unlocks) and Nanobots (the hull that mends itself — 3 points in Reinforced Plating before it unlocks).",
"skills": [
{
"id": "ramming_charge",
@ -112,6 +112,48 @@
}
}
},
{
"id": "inferno",
"_comment": "THE FIRE THAT STAYS ON THE TARGET. Type Offense (the type the other Vanguard skills wear), 50 energy off the right orb, a 7 second recharge, five points to spend. `requires` is the tree's spends-through-unlock edge: 3 points must sit in Ramming Charge before a point may go here. `seat: 2` is the default binding on a fresh run — the Vanguard's second seat, under the ram. THE MECHANIC lives in js/combat/CombatSystem.js → launchFlames / _stepFlames / _igniteFlames / _stepBurns: one wide arc of flame per nearest hostile (effect.targetBase of them — effect.extraTargets more from effect.extraTargetAtRank on), each a fast wave that homes on its assigned craft and, on arrival, ignites everything its splash touches. The burn each caught craft wears is STAMPED on it (burnDurationMs of it, ticking burnTickDamage every effect.burnTickMs starting at t=0) and stepped in the combat loop — the damage outlives the arc, and a stronger cast overwrites a weaker burn on the same hull.",
"label": "Inferno",
"type": "Offense",
"frame": 2,
"energy": 50,
"cooldown": 7,
"maxPoints": 5,
"requires": [
{
"skill": "ramming_charge",
"points": 3
}
],
"seat": 2,
"node": {
"col": 1,
"row": 1
},
"description": "Send wide arcs of flame at the nearest 2 enemies. When an arc impacts it lights the enemy's ship on fire for 0.5 seconds, dealing 1x regular damage every 0.5 seconds (a hit at 0 seconds and another at 0.5 seconds to begin with). Each additional skill point adds 0.5 seconds to the burn and 0.1 extra damage to every tick, and the 4th point sets one additional enemy alight (3 total).",
"shortDescription": "Flame the nearest 2 — 0.5s of burns, 1x per tick, growing per point.",
"effect": {
"_comment": "kind = the handler in js/skills/SkillEffects.js. targetBase = the NEAREST hostiles that each get their own arc (2); from extraTargetAtRank (the 4th point) extraTargets more are targeted (3 total). speed = the arcs' flight (px/s); splashRadiusPx = on impact, every live hostile whose hull touches this radius is ignited (the arc's own target plus any it sweeps in). THE BURN, per caught craft, in MS (the combat loop's clock): it lasts burnBaseMs + burnPerRankMs × (rank − 1) — 500 at 1/5 … 2500 at 5/5 — and ticks every burnTickMs starting at t=0 (a 0.5s burn deals TWO ticks: 0s and 0.5s), each for (burnDamageMul + burnDamagePerRank × (rank − 1)) × the cannon's own damage — 1x at 1/5 … 1.4x at 5/5. fx skins the arcs (flameColor = the wave and the ignition's read, emberColor = the embers the wave sheds); sfxEvent is a COMBAT_SFX EVENT (js/combat/CombatSfx.js) played when an arc lands.",
"kind": "flameArc",
"targetBase": 2,
"extraTargetAtRank": 4,
"extraTargets": 1,
"speed": 950,
"splashRadiusPx": 64,
"burnBaseMs": 500,
"burnPerRankMs": 500,
"burnTickMs": 500,
"burnDamageMul": 1,
"burnDamagePerRank": 0.1,
"fx": {
"flameColor": "#ff9d3a",
"emberColor": "#ffd84d",
"sfxEvent": "hull"
}
}
},
{
"id": "reinforced_plating",
"_comment": "THE HULL THAT WILL NOT QUIT. Type Passive — no cast, no energy, no recharge, and it can NEVER hold a bar seat: it works from rank 1 onward. `seat` is null to say so out loud in the data.",
@ -134,6 +176,38 @@
"kind": "hullPool",
"hullBonusPerRank": 0.05
}
},
{
"id": "nanobots",
"_comment": "THE HULL THAT MENDS ITSELF. Type Passive — no cast, no energy, no recharge, and it can NEVER hold a bar seat: it works from rank 1 onward. `requires` is the tree's spends-through-unlock edge: 3 points must sit in Reinforced Plating before a point may go here. THE MECHANIC lives in js/combat/CombatSystem.js → hullRegen() + the update loop's armor pass: on the combat clock, every effect.intervalMs (and only while the hull is up and not already full) the hull regrows the ledger's share of its MAX — regenPercentPerRank × rank of it: 1% at 1/5 … 5% at 5/5 — so a 200-hull ship gains 2 hull every 2 seconds at 1/5 and 10 at 5/5. A full hull banks nothing: the mend clock stays spent, never queued.",
"label": "Nanobots",
"type": "Passive",
"frame": 3,
"energy": 0,
"cooldown": 0,
"maxPoints": 5,
"requires": [
{
"skill": "reinforced_plating",
"points": 3
}
],
"seat": null,
"node": {
"col": 3,
"row": 1
},
"description": "Mend your ship's hull: repair 1% of maximum hull every 2 seconds, with an additional 1% of maximum hull per additional skill point.",
"shortDescription": "Repair 1% max hull every 2s. +1% per point.",
"effect": {
"_comment": "hullRegen: the combat loop owns the tick (effect.intervalMs between mends — the data's number, read off the ledger, never restated in code). regenPercentPerRank is a RATIO of the hull's MAX (0.01 = 1%), summed across the skill's ranked points by the ledger (js/skills/SkillState.js → effectTotal) exactly like Reinforced Plating's hullBonusPerRank — rank 5 mends 5% of max per tick. fx.mendColor is the color each mend's beat wears (js/combat/Fx.js).",
"kind": "hullRegen",
"regenPercentPerRank": 0.01,
"intervalMs": 2000,
"fx": {
"mendColor": "#7ce8a4"
}
}
}
]
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 579 KiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 362 KiB

View File

@ -86,21 +86,21 @@
{
"index": 1,
"number": 2,
"id": null,
"label": null,
"frame": null,
"id": "inferno",
"label": "Inferno",
"frame": 2,
"icon": "assets/images/skills.png",
"iconFrameWidth": 128,
"typeLabel": null,
"accentCss": null,
"rank": 0,
"maxPoints": 0,
"energy": 0,
"cooldown": 0,
"typeLabel": "OFFENSE",
"accentCss": "#ff3a4d",
"rank": 5,
"maxPoints": 5,
"energy": 50,
"cooldown": 7,
"cooldownLeft": 0,
"cooldownFrac": 1,
"affordable": false,
"ready": false,
"affordable": true,
"ready": true,
"inert": false
},
{
@ -232,9 +232,9 @@
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"state": "ready",
"icon": true,
"frame": "2",
"secs": ""
},
{
@ -282,12 +282,12 @@
"dashing": false,
"down": false,
"ship": {
"x": -537.9620367147268,
"y": -698.4573644458305
"x": 4043.2519114045685,
"y": 5097.60248944705
},
"ledgerSeats": [
"ramming_charge",
null,
"inferno",
null,
null,
null,
@ -297,12 +297,14 @@
],
"ranks": {
"ramming_charge": 5,
"reinforced_plating": 5
"inferno": 5,
"reinforced_plating": 5,
"nanobots": 5
},
"sheetLoaded": true,
"recordSeats": [
"ramming_charge",
null,
"inferno",
null,
null,
null,
@ -329,8 +331,8 @@
"maxPoints": 5,
"energy": 60,
"cooldown": 6,
"cooldownLeft": 5.906679999999998,
"cooldownFrac": 0.015553333333333641,
"cooldownLeft": 5.900040000000001,
"cooldownFrac": 0.016659999999999897,
"affordable": false,
"ready": false,
"inert": false
@ -338,17 +340,17 @@
{
"index": 1,
"number": 2,
"id": null,
"label": null,
"frame": null,
"id": "inferno",
"label": "Inferno",
"frame": 2,
"icon": "assets/images/skills.png",
"iconFrameWidth": 128,
"typeLabel": null,
"accentCss": null,
"rank": 0,
"maxPoints": 0,
"energy": 0,
"cooldown": 0,
"typeLabel": "OFFENSE",
"accentCss": "#ff3a4d",
"rank": 5,
"maxPoints": 5,
"energy": 50,
"cooldown": 7,
"cooldownLeft": 0,
"cooldownFrac": 1,
"affordable": false,
@ -484,9 +486,9 @@
"secs": "6"
},
{
"state": "empty",
"icon": false,
"frame": null,
"state": "noEnergy",
"icon": true,
"frame": "2",
"secs": ""
},
{
@ -526,7 +528,7 @@
"secs": ""
}
],
"energy": 40.559920000000005,
"energy": 40.59976,
"maxEnergy": 100,
"hull": 6000,
"maxHull": 6000,
@ -534,12 +536,12 @@
"dashing": true,
"down": false,
"ship": {
"x": -667.245123811374,
"y": -602.0867092723174
"x": 4164.61770021181,
"y": 5331.986652361176
},
"ledgerSeats": [
"ramming_charge",
null,
"inferno",
null,
null,
null,
@ -549,12 +551,14 @@
],
"ranks": {
"ramming_charge": 5,
"reinforced_plating": 5
"inferno": 5,
"reinforced_plating": 5,
"nanobots": 5
},
"sheetLoaded": true,
"recordSeats": [
"ramming_charge",
null,
"inferno",
null,
null,
null,
@ -590,21 +594,21 @@
{
"index": 1,
"number": 2,
"id": null,
"label": null,
"frame": null,
"id": "inferno",
"label": "Inferno",
"frame": 2,
"icon": "assets/images/skills.png",
"iconFrameWidth": 128,
"typeLabel": null,
"accentCss": null,
"rank": 0,
"maxPoints": 0,
"energy": 0,
"cooldown": 0,
"typeLabel": "OFFENSE",
"accentCss": "#ff3a4d",
"rank": 5,
"maxPoints": 5,
"energy": 50,
"cooldown": 7,
"cooldownLeft": 0,
"cooldownFrac": 1,
"affordable": false,
"ready": false,
"affordable": true,
"ready": true,
"inert": false
},
{
@ -736,9 +740,9 @@
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"state": "ready",
"icon": true,
"frame": "2",
"secs": ""
},
{
@ -778,20 +782,20 @@
"secs": ""
}
],
"energy": 59.13998,
"energy": 59.14994,
"maxEnergy": 100,
"hull": 5990,
"hull": 6000,
"maxHull": 6000,
"level": 0,
"dashing": false,
"down": false,
"ship": {
"x": -864.2761937160317,
"y": -466.8622759066493
"x": 4417.934083476811,
"y": 5821.196577394506
},
"ledgerSeats": [
"ramming_charge",
null,
"inferno",
null,
null,
null,
@ -801,12 +805,14 @@
],
"ranks": {
"ramming_charge": 5,
"reinforced_plating": 5
"inferno": 5,
"reinforced_plating": 5,
"nanobots": 5
},
"sheetLoaded": true,
"recordSeats": [
"ramming_charge",
null,
"inferno",
null,
null,
null,
@ -842,21 +848,21 @@
{
"index": 1,
"number": 2,
"id": null,
"label": null,
"frame": null,
"id": "inferno",
"label": "Inferno",
"frame": 2,
"icon": "assets/images/skills.png",
"iconFrameWidth": 128,
"typeLabel": null,
"accentCss": null,
"rank": 0,
"maxPoints": 0,
"energy": 0,
"cooldown": 0,
"typeLabel": "OFFENSE",
"accentCss": "#ff3a4d",
"rank": 5,
"maxPoints": 5,
"energy": 50,
"cooldown": 7,
"cooldownLeft": 0,
"cooldownFrac": 1,
"affordable": false,
"ready": false,
"affordable": true,
"ready": true,
"inert": false
},
{
@ -988,9 +994,9 @@
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"state": "ready",
"icon": true,
"frame": "2",
"secs": ""
},
{
@ -1030,20 +1036,20 @@
"secs": ""
}
],
"energy": 62.07956000000003,
"energy": 62.14874000000002,
"maxEnergy": 100,
"hull": 129,
"maxHull": 129,
"level": 1,
"hull": 6000,
"maxHull": 6000,
"level": 0,
"dashing": false,
"down": false,
"ship": {
"x": -864.2761937160317,
"y": -466.8622759066493
"x": 4417.934083476811,
"y": 5821.196577394506
},
"ledgerSeats": [
null,
null,
"inferno",
null,
"ramming_charge",
null,
@ -1053,12 +1059,14 @@
],
"ranks": {
"ramming_charge": 5,
"reinforced_plating": 5
"inferno": 5,
"reinforced_plating": 5,
"nanobots": 5
},
"sheetLoaded": true,
"recordSeats": [
null,
null,
"inferno",
null,
"ramming_charge",
null,
@ -1094,21 +1102,21 @@
{
"index": 1,
"number": 2,
"id": null,
"label": null,
"frame": null,
"id": "inferno",
"label": "Inferno",
"frame": 2,
"icon": "assets/images/skills.png",
"iconFrameWidth": 128,
"typeLabel": null,
"accentCss": null,
"rank": 0,
"maxPoints": 0,
"energy": 0,
"cooldown": 0,
"typeLabel": "OFFENSE",
"accentCss": "#ff3a4d",
"rank": 5,
"maxPoints": 5,
"energy": 50,
"cooldown": 7,
"cooldownLeft": 0,
"cooldownFrac": 1,
"affordable": false,
"ready": false,
"affordable": true,
"ready": true,
"inert": false
},
{
@ -1240,9 +1248,9 @@
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"state": "ready",
"icon": true,
"frame": "2",
"secs": ""
},
{
@ -1282,20 +1290,20 @@
"secs": ""
}
],
"energy": 63.05942000000004,
"energy": 63.348260000000025,
"maxEnergy": 100,
"hull": 129,
"maxHull": 129,
"level": 1,
"hull": 5990,
"maxHull": 6000,
"level": 0,
"dashing": false,
"down": false,
"ship": {
"x": -864.2761937160317,
"y": -466.8622759066493
"x": 4417.934083476811,
"y": 5821.196577394506
},
"ledgerSeats": [
null,
null,
"inferno",
null,
null,
null,
@ -1305,12 +1313,14 @@
],
"ranks": {
"ramming_charge": 5,
"reinforced_plating": 5
"inferno": 5,
"reinforced_plating": 5,
"nanobots": 5
},
"sheetLoaded": true,
"recordSeats": [
null,
null,
"inferno",
null,
null,
null,
@ -1346,21 +1356,21 @@
{
"index": 1,
"number": 2,
"id": null,
"label": null,
"frame": null,
"id": "inferno",
"label": "Inferno",
"frame": 2,
"icon": "assets/images/skills.png",
"iconFrameWidth": 128,
"typeLabel": null,
"accentCss": null,
"rank": 0,
"maxPoints": 0,
"energy": 0,
"cooldown": 0,
"typeLabel": "OFFENSE",
"accentCss": "#ff3a4d",
"rank": 5,
"maxPoints": 5,
"energy": 50,
"cooldown": 7,
"cooldownLeft": 0,
"cooldownFrac": 1,
"affordable": false,
"ready": false,
"affordable": true,
"ready": true,
"inert": false
},
{
@ -1492,9 +1502,9 @@
"secs": ""
},
{
"state": "empty",
"icon": false,
"frame": null,
"state": "ready",
"icon": true,
"frame": "2",
"secs": ""
},
{
@ -1534,20 +1544,20 @@
"secs": ""
}
],
"energy": 63.899300000000046,
"energy": 64.39784000000003,
"maxEnergy": 100,
"hull": 129,
"maxHull": 129,
"level": 1,
"hull": 5990,
"maxHull": 6000,
"level": 0,
"dashing": false,
"down": false,
"ship": {
"x": -864.2761937160317,
"y": -466.8622759066493
"x": 4417.934083476811,
"y": 5821.196577394506
},
"ledgerSeats": [
"ramming_charge",
null,
"inferno",
null,
null,
null,
@ -1557,12 +1567,14 @@
],
"ranks": {
"ramming_charge": 5,
"reinforced_plating": 5
"inferno": 5,
"reinforced_plating": 5,
"nanobots": 5
},
"sheetLoaded": true,
"recordSeats": [
"ramming_charge",
null,
"inferno",
null,
null,
null,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 205 KiB

View File

@ -127,7 +127,7 @@
"frame": 0,
"iconKey": "class_icons",
"iconReady": true,
"points": 9,
"points": 19,
"spent": 1,
"pending": 0,
"inSession": false,
@ -188,6 +188,71 @@
"pending": 0,
"resettable": false
},
{
"def": {
"id": "inferno",
"classId": "vanguard",
"label": "Inferno",
"typeId": "Offense",
"type": {
"id": "Offense",
"label": "OFFENSE",
"accent": 16726605,
"accentCss": "#ff3a4d",
"passive": false
},
"frame": 2,
"energy": 50,
"cooldown": 7,
"maxPoints": 5,
"requires": [
{
"skill": "ramming_charge",
"points": 3
}
],
"seatDefault": 2,
"layout": {
"col": 1,
"row": 1
},
"description": "Send wide arcs of flame at the nearest 2 enemies. When an arc impacts it lights the enemy's ship on fire for 0.5 seconds, dealing 1x regular damage every 0.5 seconds (a hit at 0 seconds and another at 0.5 seconds to begin with). Each additional skill point adds 0.5 seconds to the burn and 0.1 extra damage to every tick, and the 4th point sets one additional enemy alight (3 total).",
"shortDescription": "Flame the nearest 2 — 0.5s of burns, 1x per tick, growing per point.",
"effect": {
"kind": "flameArc",
"targetBase": 2,
"extraTargetAtRank": 4,
"extraTargets": 1,
"speed": 950,
"splashRadiusPx": 64,
"burnBaseMs": 500,
"burnPerRankMs": 500,
"burnTickMs": 500,
"burnDamageMul": 1,
"burnDamagePerRank": 0.1,
"fx": {
"flameColor": "#ff9d3a",
"emberColor": "#ffd84d",
"sfxEvent": "hull"
}
},
"passive": false,
"castable": true
},
"id": "inferno",
"rank": 0,
"max": 5,
"state": "locked",
"locked": {
"skill": "ramming_charge",
"points": 3,
"label": "Ramming Charge"
},
"seat": 2,
"spendable": false,
"pending": 0,
"resettable": false
},
{
"def": {
"id": "reinforced_plating",
@ -229,6 +294,61 @@
"spendable": true,
"pending": 0,
"resettable": false
},
{
"def": {
"id": "nanobots",
"classId": "vanguard",
"label": "Nanobots",
"typeId": "Passive",
"type": {
"id": "Passive",
"label": "PASSIVE",
"accent": 12616956,
"accentCss": "#c084fc",
"passive": true
},
"frame": 3,
"energy": 0,
"cooldown": 0,
"maxPoints": 5,
"requires": [
{
"skill": "reinforced_plating",
"points": 3
}
],
"seatDefault": null,
"layout": {
"col": 3,
"row": 1
},
"description": "Mend your ship's hull: repair 1% of maximum hull every 2 seconds, with an additional 1% of maximum hull per additional skill point.",
"shortDescription": "Repair 1% max hull every 2s. +1% per point.",
"effect": {
"kind": "hullRegen",
"regenPercentPerRank": 0.01,
"intervalMs": 2000,
"fx": {
"mendColor": "#7ce8a4"
}
},
"passive": true,
"castable": false
},
"id": "nanobots",
"rank": 0,
"max": 5,
"state": "locked",
"locked": {
"skill": "reinforced_plating",
"points": 3,
"label": "Reinforced Plating"
},
"seat": null,
"spendable": false,
"pending": 0,
"resettable": false
}
]
},
@ -243,20 +363,32 @@
"kind": "tile",
"id": "ramming_charge",
"cx": 745,
"cy": 279
"cy": 211
},
{
"kind": "tile",
"id": "inferno",
"cx": 745,
"cy": 347
},
{
"kind": "tile",
"id": "reinforced_plating",
"cx": 997,
"cy": 279
"cy": 211
},
{
"kind": "tile",
"id": "nanobots",
"cx": 997,
"cy": 347
}
],
"tiles": [],
"texts": [],
"seats": [
null,
null,
"inferno",
"ramming_charge",
null,
null,
@ -270,7 +402,7 @@
"pending": {
"ramming_charge": 0
},
"points": 9,
"points": 19,
"session": false
}
}

View File

@ -259,8 +259,8 @@ fs.writeFileSync(new URL('./shots/skills-seat.png', import.meta.url), seatBase);
await page.screenshot({ path: `${SHOTS}skills-bar-ready.png`, timeout: 60000 });
check(ready.art[0].state === 'ready', `a live, charged, affordable seat reads ready (got ${ready.art[0].state})`);
check(
ready.seats.slice(1).every((s) => s.id === null) && ready.art.slice(1).every((x) => x.state === 'empty' && x.icon === false),
'the other seats keep their reserved EMPTY look',
ready.seats[1].id === 'inferno' && ready.seats.slice(2).every((s) => s.id === null) && ready.art.slice(2).every((x) => x.state === 'empty' && x.icon === false),
'seat 2 carries the Inferno (its default binding) and the rest keep their reserved EMPTY look',
);
await page.screenshot({ path: `${SHOTS}skills-bar-ready.png`, timeout: 60000 });

View File

@ -593,6 +593,133 @@ function dummy(combat, x, y) {
check(`blink: the 5/5 stand is the data's (${hop5.decoyMs}ms)`, hop5.decoyMs === effects.decoyMs(HEFF, HOP.maxPoints));
}
// ================================================================== the Inferno
// THE VANGUARD'S FIRE (data/skills.json → 'inferno', effect.kind 'flameArc'):
// one wide arc of flame per nearest hostile (2, or 3 from the 4th point on),
// each homing on its craft and, on arrival, igniting whatever its splash
// touches — the burn then ticks on the combat loop's own clock: the rank's
// damage every effect.burnTickMs for the rank's duration, starting at t=0
// (a 0.5s burn at rank 1 deals exactly two ticks: 0s and 0.5s).
const INF = model.skillDef('inferno', 'vanguard');
const INFEFF = INF.effect;
const tickDmg = (rank) => (INFEFF.burnDamageMul || 0) * BASE_DAMAGE + (INFEFF.burnDamagePerRank || 0) * (rank - 1) * BASE_DAMAGE;
const burnFor = (rank) => (INFEFF.burnBaseMs || 0) + (INFEFF.burnPerRankMs || 0) * (rank - 1);
const stillDummy = (combat, x, y) => {
const e = dummy(combat, x, y);
e.speed = 0; // a dummy that holds its station (no orbit drift under the burn)
return e;
};
const waitLand = (combat) => {
for (let t = 1016; t <= 9000; t += 16) {
combat.update(t, 16);
if (combat.flames.length === 0) return t;
}
return null;
};
const infernoSeat = (f) => f.combat.skillSeats().findIndex((s) => s.id === INF.id);
{
const f = makeFight({ ranks: { ramming_charge: 3, inferno: 1 } });
const d = stillDummy(f.combat, 620, 360);
const seat = infernoSeat(f);
check(`inferno: the cast spends ${INF.energy} energy and starts the ${INF.cooldown}s recharge`,
f.combat.cast(seat).ok === true && f.combat.energy === MAX_ENERGY - INF.energy &&
Math.abs(f.combat.skillCooldowns.get(INF.id) - INF.cooldown) < 1e-9);
check('inferno: one arc per nearest hostile (one here)', f.combat.flames.length === 1);
const tLand = waitLand(f.combat);
check('inferno: the arc reached its craft', tLand !== null);
check('inferno: the impact ticks at once (the t=0 rule)', tLand !== null && approx(d.maxHull - d.hull, tickDmg(1)));
f.combat.update(tLand + 250, 16);
check('inferno: no tick mid-interval', approx(d.maxHull - d.hull, tickDmg(1)));
f.combat.update(tLand + 500, 16);
check('inferno: the second tick lands at 0.5s (two ticks in a 0.5s burn)', approx(d.maxHull - d.hull, tickDmg(1) * 2));
f.combat.update(tLand + 501, 16);
f.combat.update(tLand + 1500, 16);
check('inferno: the burn holds 0.5s — no third tick after', approx(d.maxHull - d.hull, tickDmg(1) * 2) && d.burnUntil === 0);
}
{
const f = makeFight({ ranks: { ramming_charge: 3, inferno: 5 } });
const d = stillDummy(f.combat, 620, 360);
check('inferno: the 5/5 cast still arcs its one nearest hostile', f.combat.cast(infernoSeat(f)).ok === true && f.combat.flames.length === 1);
const tLand = waitLand(f.combat);
f.combat.update(tLand + burnFor(5), 16);
check('inferno: 5/5 — six ticks of 1.4x across the 2.5s burn (0s … 2.5s)',
tLand !== null && approx(d.maxHull - d.hull, tickDmg(5) * 6));
f.combat.update(tLand + burnFor(5) + 1, 16);
f.combat.update(tLand + burnFor(5) + 1500, 16);
check('inferno: 5/5 — no seventh tick after the burn ends', approx(d.maxHull - d.hull, tickDmg(5) * 6) && d.burnUntil === 0);
}
{
const f = makeFight({ ranks: { ramming_charge: 3, inferno: 1 } });
const d1 = stillDummy(f.combat, 560, 260);
const d2 = stillDummy(f.combat, 640, 360);
const d3 = stillDummy(f.combat, 720, 460);
check('inferno: the cast arcs the nearest 2 (not the third)', f.combat.cast(infernoSeat(f)).ok === true && f.combat.flames.length === 2);
const tLand = waitLand(f.combat);
check('inferno: both nearest craft are alight', tLand !== null && d1.burnUntil > 0 && d2.burnUntil > 0);
check('inferno: the third enemy is untouched', d3.burnUntil === undefined && d3.hull === d3.maxHull);
}
{
const f = makeFight({ ranks: { ramming_charge: 3, inferno: 4 } });
const d1 = stillDummy(f.combat, 560, 260);
const d2 = stillDummy(f.combat, 640, 360);
const d3 = stillDummy(f.combat, 720, 460);
check('inferno: the 4th point arcs the third nearest', f.combat.cast(infernoSeat(f)).ok === true && f.combat.flames.length === 3);
const tLand = waitLand(f.combat);
check('inferno: all three are alight', tLand !== null && d1.burnUntil > 0 && d2.burnUntil > 0 && d3.burnUntil > 0);
}
{
const f = makeFight({ ranks: { ramming_charge: 3, inferno: 1 } });
check('inferno: a cast with no hostiles arcs nothing', f.combat.cast(infernoSeat(f)).ok === true && f.combat.flames.length === 0);
}
// ================================================================== the Nanobots
// THE HULL THAT MENDS ITSELF (data/skills.json → 'nanobots', effect.kind
// 'hullRegen'): a passive — no cast, no seat — on the combat clock, every
// effect.intervalMs (2s) the hull regrows the ledger's share of its MAX
// (1% per point, 5% at 5/5), a full hull banking nothing.
{
const f = makeFight({ ranks: { ramming_charge: 1, reinforced_plating: 3, nanobots: 1 } });
const maxHull = f.combat.maxHull;
const perTick = 0.01 * maxHull;
f.combat._nanobotAccumMs = 0;
f.combat.hull = maxHull - 100;
tick(f.combat, 1984); // just short of the first mend
check('nanobots: no mend before the 2s clock turns', f.combat.hull === maxHull - 100);
tick(f.combat, 16); // the 2000 ms mark
check('nanobots: 1/5 mends 1% of max hull per tick', approx(f.combat.hull, maxHull - 100 + perTick));
tick(f.combat, 1983); // 16 + 1983 = 1999 ms: still one frame short of the second mend
check('nanobots: no second mend early', approx(f.combat.hull, maxHull - 100 + perTick));
tick(f.combat, 1);
check('nanobots: the second tick mends again', approx(f.combat.hull, maxHull - 100 + perTick * 2));
}
{
const f = makeFight({ ranks: { ramming_charge: 1, reinforced_plating: 3, nanobots: 5 } });
const maxHull = f.combat.maxHull;
f.combat._nanobotAccumMs = 0;
f.combat.hull = maxHull; // a full hull banks nothing
tick(f.combat, 5000);
check('nanobots: a full hull does not overfill or bank mends', f.combat.hull === maxHull);
f.combat.hull = maxHull - 50;
tick(f.combat, 2000);
check('nanobots: 5/5 mends 5% of max hull per tick (no banked extra)', approx(f.combat.hull, maxHull - 50 + 0.05 * maxHull));
}
{
const f = makeFight({ ranks: { ramming_charge: 1, reinforced_plating: 3 } });
f.combat._nanobotAccumMs = 0;
f.combat.hull = f.combat.maxHull - 50;
tick(f.combat, 5000);
check('nanobots: nothing mends the hull without the passive', f.combat.hull === f.combat.maxHull - 50);
}
// ================================================================= the bar's row
{
const f = makeFight({ ranks: { ramming_charge: RAM.maxPoints } });
@ -610,8 +737,10 @@ function dummy(combat, x, y) {
check('bar: a thin orb shows it (affordable false, ready false)', f.combat.skillSeats()[0].affordable === false && f.combat.skillSeats()[0].ready === false);
const fresh = makeFight();
const freshSeats = fresh.combat.skillSeats();
check('bar: an unranked skill draws INERT (bound but not live)', freshSeats[0].inert === true && freshSeats[0].ready === false && freshSeats[1].id === null);
check('bar: the Vanguard ships two seats — the ram and the fire (both inert at rank 0)',
freshSeats[0].id === RAM.id && freshSeats[0].inert === true && freshSeats[0].ready === false &&
freshSeats[1].id === 'inferno' && freshSeats[1].inert === true && freshSeats[1].ready === false &&
freshSeats[2].id === null);
// The stats() row the bar polls every frame.
const st = f.combat.stats();
check('bar: stats() carries the seats, the hull bonus and the grown pool for the orbs', Array.isArray(st.seats) && st.seats.length === 8 && st.hullBonus === 0 && st.maxHull === BASE_HULL);

View File

@ -164,7 +164,7 @@ 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 carries the class's ${v.snap.rows.length} skills (${v.snap.rows.map((r) => r.def.label).join(', ')})`);
check(v.snap.rows.length === 4 && 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
@ -179,7 +179,7 @@ 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(' ')})`);
check(v.tiles.length === 4 && v.tiles.every((t) => t.visible), `all four 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)),
@ -514,7 +514,7 @@ 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)`);
check(v.tiles.length === 4 && v.scroll.maxScroll === 0, `the real tree comes back whole (${v.tiles.length} tiles, no overflow)`);
// ---------------------------------------------------------------- ESC CLOSE

View File

@ -9,7 +9,7 @@
* 1. THE DATA CONTRACT — data/skills.json is a real config file (listed in
* data/manifest.json), its type taxonomy / seats table / per-class trees
* are well formed, the icon sheet exists on disk with the frames the tree
* references, and the two authored Vanguard skills carry EXACTLY the
* references, and the four authored Vanguard skills carry EXACTLY the
* spec'd numbers; those numbers live in the JSON only — no js/ file
* restates them (the "code owns behavior, JSON owns parameters" rule).
* 2. THE MODEL — js/skills/SkillModel.js normalizes whatever the data says
@ -100,8 +100,8 @@ check('classes: every tree key is a real class id', Object.keys(trees).every((id
const vanguard = trees.vanguard?.skills ?? [];
const byId = Object.fromEntries(vanguard.map((s) => [s.id, s]));
check('vanguard: the tree holds the two authored skills', vanguard.length === 2);
check('vanguard: ids are ramming_charge + reinforced_plating', JSON.stringify(Object.keys(byId)) === JSON.stringify(['ramming_charge', 'reinforced_plating']));
check('vanguard: the tree holds the four authored skills', vanguard.length === 4);
check('vanguard: ids are ramming_charge + inferno + reinforced_plating + nanobots', JSON.stringify(Object.keys(byId)) === JSON.stringify(['ramming_charge', 'inferno', 'reinforced_plating', 'nanobots']));
check(
'every skill in every tree is well formed (id/label/type/frame/maxPoints/description/effect)',
Object.values(trees).every((t) =>
@ -137,9 +137,11 @@ check(
}),
);
// ---- the two authored skills, spec for spec ------------------------
// ---- the four authored skills, spec for spec ------------------------
const ram = byId.ramming_charge ?? {};
const inferno = byId.inferno ?? {};
const plate = byId.reinforced_plating ?? {};
const nano = byId.nanobots ?? {};
check('Ramming Charge: label "Ramming Charge"', ram.label === 'Ramming Charge');
check('Ramming Charge: type Offense', ram.type === 'Offense');
@ -173,6 +175,38 @@ check(
/5%/.test(plate.description ?? '') && /additional 5% increase per additional skill point/.test(plate.description ?? ''),
);
check('Inferno: label "Inferno"', inferno.label === 'Inferno');
check('Inferno: type Offense', inferno.type === 'Offense');
check('Inferno: 50 energy', inferno.energy === 50);
check('Inferno: 7 second recharge', inferno.cooldown === 7);
check('Inferno: 5 assignable points', inferno.maxPoints === 5);
check('Inferno: icon frame 2', inferno.frame === 2);
check('Inferno: the bar seat (default seat 2)', inferno.seat === 2);
check('Inferno: needs 3 points in Ramming Charge', JSON.stringify(inferno.requires) === JSON.stringify([{ skill: 'ramming_charge', points: 3 }]));
check('Inferno: effect is a flameArc', inferno.effect?.kind === 'flameArc');
check('Inferno: arcs the nearest 2 (the 4th point adds the third)', inferno.effect?.targetBase === 2 && inferno.effect?.extraTargetAtRank === 4 && inferno.effect?.extraTargets === 1);
check('Inferno: a 0.5s burn ticks at 1x weapon damage', inferno.effect?.burnBaseMs === 500 && inferno.effect?.burnTickMs === 500 && inferno.effect?.burnDamageMul === 1);
check('Inferno: the 4th burn second per point, +0.1x tick per point', inferno.effect?.burnPerRankMs === 500 && inferno.effect?.burnDamagePerRank === 0.1);
check(
'Inferno: the effect copy names the targets, the burn and its growth',
/nearest 2/.test(inferno.description ?? '') && /4th point/.test(inferno.description ?? '') && /0\.5 seconds/.test(inferno.description ?? '') && /1x regular damage/.test(inferno.description ?? '') && /0\.1 extra damage/.test(inferno.description ?? ''),
);
check('Nanobots: label "Nanobots"', nano.label === 'Nanobots');
check('Nanobots: type Passive', nano.type === 'Passive');
check('Nanobots: 0 energy', nano.energy === 0);
check('Nanobots: 0 recharge', nano.cooldown === 0);
check('Nanobots: 5 assignable points', nano.maxPoints === 5);
check('Nanobots: icon frame 3', nano.frame === 3);
check('Nanobots: no bar seat — it is passive', nano.seat === null);
check('Nanobots: needs 3 points in Reinforced Plating', JSON.stringify(nano.requires) === JSON.stringify([{ skill: 'reinforced_plating', points: 3 }]));
check('Nanobots: effect is a hullRegen', nano.effect?.kind === 'hullRegen');
check('Nanobots: 1% of max hull per point, mended every 2s', nano.effect?.regenPercentPerRank === 0.01 && nano.effect?.intervalMs === 2000);
check(
'Nanobots: the effect copy names both the share and the cadence',
/1% of maximum hull/.test(nano.description ?? '') && /every 2 seconds/.test(nano.description ?? '') && /additional 1% of maximum hull per additional skill point/.test(nano.description ?? ''),
);
// ---- the console's copy --------------------------------------------
const win = skills.window ?? {};
for (const k of ['title', 'meta', 'treeTitle', 'pointsLine', 'rankLine', 'lockedNote', 'emptyNote',
@ -221,6 +255,16 @@ const BALANCE = [
['durationMs', 1000],
['blastRadiusPx', 170],
['overshootPx', 90],
// Inferno + Nanobots (data/skills.json → inferno.effect / nanobots.effect).
['targetBase', 2],
['extraTargetAtRank', 4],
['burnBaseMs', 500],
['burnPerRankMs', 500],
['burnTickMs', 500],
['burnDamagePerRank', 0.1],
['splashRadiusPx', 64],
['regenPercentPerRank', 0.01],
['intervalMs', 2000],
];
const offenders = [];
for (const p of jsSources()) {
@ -281,7 +325,7 @@ function vanguardRun(points = 5) {
check('model: seatCount() follows the combat bar (8)', model.seatCount() === config.get('combat.bar.slot.count'));
const list = model.skillList('vanguard');
check('model: the Vanguard tree lists both skills in data order', list.map((s) => s.id).join() === 'ramming_charge,reinforced_plating');
check('model: the Vanguard tree lists all four skills in data order', list.map((s) => s.id).join() === 'ramming_charge,inferno,reinforced_plating,nanobots');
check('model: an unknown class has no tree', model.skillList('nobody').length === 0 && model.hasTree('nobody') === false);
// The WRAITH tree (data/skills.json → classes.wraith): Boleadoras (the
@ -306,11 +350,23 @@ function vanguardRun(points = 5) {
check('model: a passive is never castable and never holds a seat', plate.castable === false && plate.passive === true && plate.seatDefault === null);
check('model: effect numbers ride the record untouched', plate.effect.kind === 'hullPool' && plate.effect.hullBonusPerRank === 0.05 && ram.effect.damageMul === 4 && ram.effect.damageMulPerRank === 2);
const inferno = model.skillDef('inferno');
check('model: Inferno carries the spec numbers (50 energy, 7s, 5 points, the flame insignia)', inferno.energy === 50 && inferno.cooldown === 7 && inferno.maxPoints === 5 && inferno.frame === 2);
check('model: Inferno is a castable Offense on a default seat', inferno.type.id === 'Offense' && inferno.castable === true && inferno.passive === false && inferno.seatDefault === 2);
check('model: Inferno requires 3 points in Ramming Charge', JSON.stringify(inferno.requires) === JSON.stringify([{ skill: 'ramming_charge', points: 3 }]));
check('model: the fire numbers ride the record untouched', inferno.effect.kind === 'flameArc' && inferno.effect.targetBase === 2 && inferno.effect.burnBaseMs === 500 && inferno.effect.burnTickMs === 500 && inferno.effect.burnDamageMul === 1);
const nano = model.skillDef('nanobots');
check('model: Nanobots carries the spec numbers (0 energy, 0s, 5 points, the microswarm insignia)', nano.energy === 0 && nano.cooldown === 0 && nano.maxPoints === 5 && nano.frame === 3);
check('model: Nanobots is a passive with no seat', nano.castable === false && nano.passive === true && nano.seatDefault === null);
check('model: Nanobots requires 3 points in Reinforced Plating', JSON.stringify(nano.requires) === JSON.stringify([{ skill: 'reinforced_plating', points: 3 }]));
check('model: the mending numbers ride the record untouched', nano.effect.kind === 'hullRegen' && nano.effect.regenPercentPerRank === 0.01 && nano.effect.intervalMs === 2000);
// Junk handling: a skill without a usable id is dropped, never propagated.
const raw = config.get('skills.classes.vanguard.skills', []);
raw.push({ label: 'Half Authored' });
model.invalidateSkillCache();
check('model: a skill with no id is dropped (never a dead row)', model.skillList('vanguard').length === 2 && model.skillDef('undefined') === null);
check('model: a skill with no id is dropped (never a dead row)', model.skillList('vanguard').length === 4 && model.skillDef('undefined') === null);
raw.push({ id: 'edge_test', label: 'Edge Test', type: 'Offense', requires: ['ramming_charge', { skill: 'nope', points: 3 }, { skill: 'reinforced_plating', points: 2 }] });
model.invalidateSkillCache();
const edge = model.skillDef('edge_test');
@ -318,7 +374,7 @@ function vanguardRun(points = 5) {
'model: requires accept ids and {skill, points}; a dangling edge drops, the rest stand',
JSON.stringify(edge.requires) === JSON.stringify([{ skill: 'ramming_charge', points: 1 }, { skill: 'reinforced_plating', points: 2 }]),
);
raw.length = 2;
raw.length = 4;
model.invalidateSkillCache();
check('model: windowSpec() fills every copy knob with a fallback', (() => {
@ -383,6 +439,7 @@ function vanguardRun(points = 5) {
// ---- seats --------------------------------------------------------
const s2 = vanguardRun(5);
check('seats: the data default binds Ramming Charge to seat 1', s2.skills.seatOf('ramming_charge') === 1 && s2.skills.skillAtSeat(0) === 'ramming_charge');
check('seats: the data default binds Inferno to seat 2 (a rank-0 active may sit inert)', s2.skills.seatOf('inferno') === 2 && s2.skills.skillAtSeat(1) === 'inferno');
check('seats: a passive can never be seated', s2.skills.bindSeat('reinforced_plating', 2).reason === 'passive' && s2.skills.seatOf('reinforced_plating') === null);
// A RANK-0 active skill may still hold a seat (that is how the data's default
// layout arrives) — it draws inert and combat refuses the press with a reason.
@ -407,6 +464,28 @@ function vanguardRun(points = 5) {
bar.skills.rankUp('ramming_charge');
check('seats: a freshly ranked active skill takes the data seat when it is free', bar.skills.seatOf('ramming_charge') === 1);
// ---- the new pair's edges + passive read --------------------------
const gated2 = vanguardRun(99);
check('edges: Inferno is locked until 3 points sit in Ramming Charge', gated2.skills.canRank('inferno').reason === 'locked' && gated2.skills.lockedBy(model.skillDef('inferno'))?.skill === 'ramming_charge');
gated2.skills.rankUp('ramming_charge');
gated2.skills.rankUp('ramming_charge');
check('edges: two points in Ramming Charge is still short of Inferno', gated2.skills.canRank('inferno').reason === 'locked');
gated2.skills.rankUp('ramming_charge');
check('edges: the third point opens Inferno', gated2.skills.canRank('inferno').ok === true && gated2.skills.canRank('nanobots').reason === 'locked');
for (let i = 0; i < 3; i++) gated2.skills.rankUp('reinforced_plating');
check('edges: 3 points in Reinforced Plating opens Nanobots', gated2.skills.canRank('nanobots').ok === true);
// Passive scaling: +1% hull per point, read as a modifier, not a cast.
const mend = vanguardRun(99);
check('ledger: rank 0 Nanobots grant nothing', mend.skills.hullRegen() === 0);
mend.skills.rankUp('reinforced_plating');
mend.skills.rankUp('reinforced_plating');
mend.skills.rankUp('reinforced_plating');
mend.skills.rankUp('nanobots');
check('ledger: 1/5 Nanobots mends 1% of max hull per tick', Math.abs(mend.skills.hullRegen() - 0.01) < 1e-9);
for (let i = 0; i < 4; i++) mend.skills.rankUp('nanobots');
check('ledger: 5/5 Nanobots mend 5% of max hull per tick', Math.abs(mend.skills.hullRegen() - 0.05) < 1e-9);
// ---- unlock edges (spent-through, not mere existence) --------------
const gated = vanguardRun(99);
const rawSkills = config.get('skills.classes.vanguard.skills', []);
@ -417,15 +496,16 @@ function vanguardRun(points = 5) {
check('edges: two points is still short of a 3-point requirement', gated.skills.canRank('follow_on').reason === 'locked');
gated.skills.rankUp('ramming_charge');
check('edges: the third point opens the branch', gated.skills.canRank('follow_on').ok === true);
rawSkills.length = 2;
rawSkills.length = 4;
model.invalidateSkillCache();
// ---- the console's rows -------------------------------------------
const view = vanguardRun(1);
view.skills.rankUp('ramming_charge'); // spend the single point there
const rows = view.skills.rows();
check('rows: one row per skill, in data order', rows.map((r) => r.def.id).join() === 'ramming_charge,reinforced_plating');
check('rows: a maxed-out bank reads noPoints on the spendable root', rows[1].state === 'noPoints' && rows[1].spendable === false);
check('rows: one row per skill, in data order', rows.map((r) => r.def.id).join() === 'ramming_charge,inferno,reinforced_plating,nanobots');
check('rows: a maxed-out bank reads noPoints on the spendable root', rows[2].state === 'noPoints' && rows[2].spendable === false);
check('rows: the 3-point branches read locked while their parents sit short', rows[1].state === 'locked' && rows[3].state === 'locked' && rows[1].spendable === false);
check('rows: a rank-0 root with points banked reads available', vanguardRun(9).skills.rows()[0].state === 'available' && vanguardRun(9).skills.rows()[0].spendable === true);
const mid = vanguardRun(9);
mid.skills.rankUp('ramming_charge');
@ -515,9 +595,9 @@ function vanguardRun(points = 5) {
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');
cell.ramming_charge === '1,0' && cell.inferno === '1,1' && cell.reinforced_plating === '3,0' && cell.nanobots === '3,1');
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);
laid.cols >= 4 && laid.rows === 2 && laid.bounds.minCol <= 1 && laid.bounds.maxCol >= 3 && laid.bounds.maxRow >= 1);
// 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.
@ -539,14 +619,14 @@ function vanguardRun(points = 5) {
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 };
raw[5].node = { col: raw[0].node.col, row: raw[0].node.row }; // lay_mid on top of Ramming Charge
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;
raw.length = 4;
model.invalidateSkillCache();
const clean = model.classTreeLayout('vanguard');
check('layout: no edge survives to a skill that is not in the tree',

View File

@ -8,7 +8,7 @@ import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { applyDamage, inView, pickTarget } from './Combat.js';
import { firePattern, stepMissiles, missilesHitPlayer } from './Weapons.js';
import { barrels as barrelLine, intercept } from './Aiming.js';
import { SKILL_EFFECTS, baseWeaponDamage, blastDamage, dashPoint, decoyMs, hexToInt, radiusHits, stunMs } from '../skills/SkillEffects.js';
import { SKILL_EFFECTS, baseWeaponDamage, blastDamage, burnDurationMs, burnTickDamage, dashPoint, decoyMs, flameTargetCount, hexToInt, radiusHits, stunMs } from '../skills/SkillEffects.js';
import { iconSheet, seatCount, skillsEnabled } from '../skills/SkillModel.js';
import { perkDef } from '../player/ClassModel.js';
import { XpManager } from './XpManager.js';
@ -221,6 +221,17 @@ export class CombatSystem {
// decoyPerRankMs × rank). Stepped in update()'s step 0b, gone in destroy().
this.decoys = [];
// THE VANGUARD'S FIRE (data/skills.json → effect.kind 'flameArc'): the
// flame arcs in flight — one wide wave per nearest hostile, each hunting
// its craft and igniting whatever it touches on arrival (the burn they
// stamp on the caught craft is stepped by _stepBurns, below). Stepped in
// update()'s step 0b, gone in destroy().
this.flames = [];
// THE NANOBOTS' clock (data/skills.json → effect.kind 'hullRegen'): ms
// accumulated toward the next hull mend (the data's intervalMs between
// mends, read live off the ledger in hullRegen()).
this._nanobotAccumMs = 0;
this.fx = new Fx(scene);
// The whiteness-purge roster: the purge (Fx.purgeCraftTints, every
// ~250 ms) must cover the WHOLE pack — a craft that somehow ends up
@ -719,6 +730,8 @@ export class CombatSystem {
// stand (their shimmer + their expiry), both on the combat clock.
this._stepBoleadoras(dt, time);
this._stepDecoys(time);
this._stepFlames(dt, time);
this._stepBurns(time);
// 1) The pack: orbit AI + lances (a dead one is its reform clock).
// The ON-SCREEN RULE: the AI may WANT to fire, but the lance only
@ -866,6 +879,27 @@ export class CombatSystem {
this.shields = Math.min(this.maxShields, this.shields + rate * dt);
}
}
// THE NANOBOTS (data/skills.json → 'hullRegen'): the hull's own mending
// clock — every effect.intervalMs (the data's number, read off the
// ledger) the hull regrows the ledger's share of its MAX (1% per point).
// A full hull banks nothing (the clock stays spent, never queued), and
// a mend tops the hull back toward max and nowhere past it — it is a
// repair, not a level-up.
const regen = this.hullRegen();
if (regen.fraction > 0 && this.hull < this.maxHull - 1e-9) {
this._nanobotAccumMs += dt * 1000;
for (let mends = 0; this._nanobotAccumMs >= regen.intervalMs && mends < 4; mends += 1) {
this._nanobotAccumMs -= regen.intervalMs;
this.hull = Math.min(this.maxHull, this.hull + regen.fraction * this.maxHull);
const col = hexToInt(regen.color, 0x7ce8a4);
if (this.ship) {
this.fx.flash(this.ship.x, this.ship.y, col, 26, 150);
this.fx.sparks(this.ship.x, this.ship.y, col, 5, 150, 300);
}
}
} else {
this._nanobotAccumMs = 0; // a full (or unranked) hull banks no mends
}
if (this.energy < this.maxEnergy) {
this.energy = Math.min(this.maxEnergy, this.energy + this.energyRegen * dt);
}
@ -2164,6 +2198,195 @@ export class CombatSystem {
this.decoys = keep;
}
/**
* THE NANOBOTS (data/skills.json → an effect of kind 'hullRegen'): the
* mending a ranked Nanobots ledger grants — the fraction of MAX hull each
* tick regrows (0.01 per point, summed by the ledger — 0.05 at 5/5), the
* tick's clock in MS (effect.intervalMs, the data's own number off the
* ranked skill), and the color each mend's beat wears (fx.mendColor).
* Inert (all zero) when nothing is ranked — the combat loop then mends
* nothing.
* @returns {{fraction: number, intervalMs: number, color: (string|null)}}
*/
hullRegen() {
const st = this.skillState;
if (!st) return { fraction: 0, intervalMs: 0, color: null };
const fraction = Number(st.effectTotal('hullRegen', 'regenPercentPerRank'));
if (!Number.isFinite(fraction) || fraction <= 0) return { fraction: 0, intervalMs: 0, color: null };
const def = st.firstPassiveOf('hullRegen');
const interval = Number(def?.effect?.intervalMs);
return {
fraction,
intervalMs: Number.isFinite(interval) && interval > 0 ? interval : 1000,
color: def?.effect?.fx?.mendColor ?? null,
};
}
/**
* THE VANGUARD'S INFERNO (data/skills.json → effect.kind 'flameArc'):
* one wide arc of flame per nearest hostile (effect.targetBase of them —
* effect.extraTargets more from effect.extraTargetAtRank on), each a fast
* wave that homes on its assigned craft and, on arrival, ignites every
* live hostile its splash (effect.splashRadiusPx) touches. The burn each
* caught craft wears (this cast's data numbers, stamped on the craft) is
* stepped by the combat loop — the damage outlives the arc.
* The handler's seam — call cast(), not this.
* @param {object} def the model's def (classId filled)
* @param {number} rank points spent
* @returns {{count: number, speed: number, burnMs: number, tickMs: number, damage: number}|null} the cast's plan (null when there is no ship)
*/
launchFlames(def, rank) {
const ship = this.ship;
if (!ship || def?.effect?.kind !== 'flameArc') return null;
const eff = def.effect;
const speed = Math.max(1, Number(eff.speed) || 0);
const count = flameTargetCount(eff, rank);
const nearest = this.enemies
.filter((e) => !e.dead && !e._removed)
.sort((a, b) => Math.hypot(a.x - ship.x, a.y - ship.y) - Math.hypot(b.x - ship.x, b.y - ship.y))
.slice(0, count);
const burn = burnDurationMs(eff, rank);
const tick = Math.max(1, Number(eff.burnTickMs) || 0);
const damage = burnTickDamage(eff, rank);
for (const t of nearest) {
this.flames.push({
x: ship.x,
y: ship.y,
target: t,
toX: t.x,
toY: t.y,
speed,
splash: Math.max(0, Number(eff.splashRadiusPx) || 0),
burn,
tick,
damage,
fx: eff.fx ?? {},
});
}
if (nearest.length > 0) {
const col = hexToInt(eff.fx?.flameColor, 0xff9d3a);
this.fx.flash(ship.x, ship.y, col, 34, 160);
this.fx.sparks(ship.x, ship.y, col, 8, 200, 380);
}
return { count: nearest.length, speed, burnMs: burn, tickMs: tick, damage };
}
/**
* The flame arcs' frame step (update()'s step 0b): each arc chases its
* craft (re-solving the position every frame — the craft may have flown)
* and flies at the data's speed; a craft that dies mid-flight leaves the
* arc on its last sight, which the arc still reaches and ignites at.
* On landing the splash ignites (and its read plays).
*/
_stepFlames(dt, time) {
if (this.flames.length === 0) return;
const keep = [];
for (const f of this.flames) {
const t = f.target;
if (t && !t.dead && !t._removed) {
f.toX = t.x;
f.toY = t.y;
}
const dx = f.toX - f.x;
const dy = f.toY - f.y;
const dist = Math.hypot(dx, dy);
const step = f.speed * dt;
if (dist > 0.001 && step < dist) {
f.x += (dx / dist) * step;
f.y += (dy / dist) * step;
// THE WAVE's read: a hot head shedding embers behind it (the data's
// colors, with fallbacks) — Fx's ember is the fixed craft.
this.fx.ember(f.x, f.y, hexToInt(f.fx.flameColor, 0xff9d3a), 16, 260, 30, 0.7);
keep.push(f);
} else {
this._igniteFlames(f, time);
}
}
this.flames = keep;
}
/**
* THE IGNITION (a flame arc lands, at the combat clock's `time`): every
* live hostile whose hull touches the splash radius is set alight — its
* burn clock starts/extends to this cast's burn for the rank from now,
* the first tick fires at once (the t=0 rule), and the damage and tick it
* wears are THIS cast's data numbers whenever they are the stronger wear
* (a rank 5 burn overwrites a rank 1's, never the other way round).
*/
_igniteFlames(f, time) {
const col = hexToInt(f.fx.flameColor, 0xff9d3a);
const ember = hexToInt(f.fx.emberColor, 0xffd84d);
const caught = radiusHits(this.enemies, f.x, f.y, f.splash);
for (const e of caught) {
if (e.dead || e._removed) continue;
e.burnUntil = Math.max(e.burnUntil || 0, time + f.burn);
e.burnNextAt = Math.min(e.burnNextAt ?? time, time); // a fresh impact ticks at once
if (f.damage >= (e.burnDamage || 0)) {
e.burnDamage = f.damage; // the stronger burn wears
e.burnTickMs = f.tick;
}
e.burnColor = col;
e._burnFxAt = 0; // the embers restart on a fresh impact
}
if (caught.length > 0) {
this.fx.flash(f.x, f.y, col, 44, 200);
this.fx.sparks(f.x, f.y, ember, 10, 220, 420);
this.fx.ring(f.x, f.y, col, 90, 380);
combatSfx(this.scene, String(f.fx.sfxEvent || 'hull'));
} else {
this.fx.flash(f.x, f.y, col, 26, 140); // a whiffed arc still pops where it landed
this.fx.ember(f.x, f.y, ember, 14, 300, 40, 0.8);
}
}
/**
* THE BURNS' frame step (update()'s step 0b): every alight craft ticks
* its burnDamage every burnTickMs while its burn clock holds — the t=0
* tick already fired on ignition — shedding embers off its hull each
* beat, and the expiry's edge settles with a small beat (the fire goes
* out). A burn that kills its craft hands the kill to the usual seam.
* (The tick loop catches up any intervals the clock overshot in one frame,
* capped — a dropped frame never loses a burn's damage.)
*/
_stepBurns(time) {
for (const e of this.enemies) {
if (!e.burnUntil) continue;
if (e.dead || e._removed) {
e.burnUntil = 0; // the craft is gone — its burn goes with it
continue;
}
const col = e.burnColor ?? 0xff9d3a;
// THE BURN'S OWN READ — embers shed off the hull while it holds.
if (time >= (e._burnFxAt ?? 0)) {
e._burnFxAt = time + 140;
this.fx.ember(e.x, e.y, col, 14, 420, 34, 0.7);
}
if (e.burnDamage > 0 && time >= (e.burnNextAt ?? 0)) {
let ticks = 0;
while (e.burnNextAt <= time && ticks < 8) {
e.burnNextAt += e.burnTickMs || 500;
const killed = e.damage(e.burnDamage);
this.fx.sparks(e.x, e.y, col, 5, 180, 360);
this.fx.damage(e.x, e.y, e.burnDamage, 'hull');
if (killed) {
this.killEnemy(e, this._now());
break;
}
ticks += 1;
}
}
if (time >= e.burnUntil) {
// THE FIRE GOES OUT — the hull clears of embers with the beat.
this.fx.flash(e.x, e.y, col, 30, 160);
this.fx.sparks(e.x, e.y, col, 6, 160, 320);
e.burnUntil = 0;
e.burnNextAt = null;
e.burnDamage = 0;
e.burnTickMs = 0;
}
}
}
/** The per-skill recharges (update()'s step 5b). */
_tickSkillCooldowns(dt) {
if (this.skillCooldowns.size === 0) return;
@ -2201,6 +2424,8 @@ export class CombatSystem {
this.bolas = [];
for (const d of this.decoys) if (d.sprite && !d.sprite.destroyed) d.sprite.destroy(); // and its decoys
this.decoys = [];
this.flames = []; // the arcs die with the fight (no ignition in flight)
this._nanobotAccumMs = 0;
this.waveRun = null;
if (this._levelUnsub) {
this._levelUnsub();

View File

@ -117,6 +117,85 @@ export function decoyMs(effect = {}, rank = 1) {
return Math.max(0, (Number(effect.decoyBaseMs) || 0) + (Number(effect.decoyPerRankMs) || 0) * r);
}
/**
* THE NUMBER OF ARCS A `flameArc` CAST SENDS AT A GIVEN RANK (data/skills.json
* → effect.targetBase / extraTargetAtRank / extraTargets — INFERNO): the
* nearest `targetBase` hostiles, plus `extraTargets` more from
* `extraTargetAtRank` onward (the 4th point: 2 → 3). The rank is clamped to
* ≥1 (an inert skill arcs no one), and a missing table degrades to zero
* arcs (inert), never to a made-up count.
*
* @param {object} effect a flameArc effect (def.effect off the model)
* @param {number} rank points spent (≥1)
* @returns {number} the number of hostiles to arc
*/
export function flameTargetCount(effect = {}, rank = 1) {
const r = Math.max(1, Math.floor(Number(rank) || 1));
let n = Math.max(0, Math.floor(Number(effect.targetBase) || 0));
const at = Number(effect.extraTargetAtRank);
if (Number.isFinite(at) && r >= at) n += Math.max(0, Math.floor(Number(effect.extraTargets) || 0));
return n;
}
/**
* THE BURN A `flameArc` IMPACT SETS AT A GIVEN RANK, in MS of the combat
* loop's clock (data/skills.json → effect.burnBaseMs / burnPerRankMs):
* the 0.5 s base plus 0.5 s for every point after the first — 500 at 1/5 …
* 2500 at 5/5. Zero or less (inert) burns for nothing.
*
* @param {object} effect a flameArc effect
* @param {number} rank points spent (≥1)
* @returns {number} the burn duration in MS
*/
export function burnDurationMs(effect = {}, rank = 1) {
const r = Math.max(1, Math.floor(Number(rank) || 1));
return Math.max(0, (Number(effect.burnBaseMs) || 0) + (Number(effect.burnPerRankMs) || 0) * (r - 1));
}
/**
* THE DAMAGE MULTIPLE ONE BURN TICK DEALS AT A GIVEN RANK (data/skills.json
* → effect.burnDamageMul / burnDamagePerRank): the 1x base plus 0.1 per point
* after the first — 1 at 1/5 … 1.4 at 5/5. (The tick's actual damage is this
* times the weapon's own damage — see burnTickDamage.)
*
* @param {object} effect a flameArc effect
* @param {number} rank points spent (≥1)
* @returns {number} the damage multiple (0 when inert)
*/
export function burnTickMultiple(effect = {}, rank = 1) {
const r = Math.max(1, Math.floor(Number(rank) || 1));
return (Number(effect.burnDamageMul) || 0) + (Number(effect.burnDamagePerRank) || 0) * (r - 1);
}
/**
* THE ACTUAL DAMAGE ONE BURN TICK DEALS AT A GIVEN RANK: burnTickMultiple ×
* the weapon's own damage (the same `base` blastDamage multiplies on — the
* burn's "1x regular damage" is the weapon's own number).
*
* @param {object} effect a flameArc effect
* @param {number} rank points spent (≥1)
* @param {number} base the weapon's own damage
* @returns {number} the per-tick damage (0 when inert)
*/
export function burnTickDamage(effect = {}, rank = 1, base = baseWeaponDamage()) {
return Math.max(0, base) * burnTickMultiple(effect, rank);
}
/**
* THE HULL A `hullRegen` PASSIVE REGROWS PER TICK (data/skills.json →
* effect.regenPercentPerRank — NANOBOTS): the hull's MAX × 1% per point —
* 1% at 1/5 … 5% at 5/5 — as a flat hull number. A ratio of the max (like
* hullPool's bonus), so the mend scales with the ship's armor.
*
* @param {number} maxHull the hull's max (after the pool's roll)
* @param {object} effect a hullRegen effect
* @param {number} rank points spent
* @returns {number} the mend per tick (0 when inert)
*/
export function hullRegenAmount(maxHull = 0, effect = {}, rank = 1) {
return Math.max(0, Number(maxHull) || 0) * (Number(effect.regenPercentPerRank) || 0) * Math.max(0, Math.floor(Number(rank) || 0));
}
/**
* '#ff3a4d' / '0xff3a4d' / 16711501 → the int Graphics + Phaser text want.
* The data writes colors as strings (every other sheet does); the FX layer
@ -173,6 +252,40 @@ function blink(combat, def, rank) {
return hop ? { blink: hop } : { failed: 'no ship' };
}
/**
* flameArc — INFERNO (data/skills.json → 'inferno'): the cast hands off to the
* combat system, whose loop owns the arcs' flight and the burns' ticks
* (js/combat/CombatSystem.js → launchFlames / _stepFlames / _igniteFlames /
* _stepBurns) — the same hand-off as dashBlast and boleadoras: the handler
* steps no frames itself, it only reports the plan the scene reads.
*
* @param {object} combat CombatSystem
* @param {object} def the model's def (classId filled)
* @param {number} rank points spent
* @returns {{flames: {count: number, speed: number, burnMs: number, tickMs: number, damage: number}}|{failed: string}}
*/
function flameArc(combat, def, rank) {
const plan = combat.launchFlames(def, rank);
return plan ? { flames: plan } : { failed: 'no ship' };
}
/**
* hullRegen — NANOBOTS (data/skills.json → 'nanobots'): a PASSIVE — nothing
* to fire, no cast, no seat: the combat loop's armor pass mends the hull on
* the data's own clock (js/combat/CombatSystem.js → hullRegen + update),
* reading the ledger every frame, so a rank-up takes effect from the next
* tick with no further plumbing. The handler only reports what the ledger
* grants, for cast()/console parity.
*
* @param {object} combat CombatSystem
* @param {object} def the model's def (classId filled)
* @param {number} rank points spent
* @returns {{hullRegen: number}} the fraction of max hull regrown per tick
*/
function hullRegen(combat, def, rank) {
return { hullRegen: (Number(def.effect?.regenPercentPerRank) || 0) * Math.max(1, Math.floor(Number(rank) || 1)) };
}
/** kind (data/skills.json → effect.kind) → handler. A kind with no entry here
* is refused by cast() with reason 'unhandled' — a typo never silently fires. */
export const SKILL_EFFECTS = {
@ -180,6 +293,8 @@ export const SKILL_EFFECTS = {
hullPool,
boleadoras,
blink,
flameArc,
hullRegen,
};
/** Is a kind wired up (the console can grey out an unbuilt effect)? */

View File

@ -447,6 +447,31 @@ export class SkillState {
return this.effectTotal('hullPool', 'hullBonusPerRank');
}
/**
* The first RANKED passive of an effect kind (ledger id → its record) —
* for the effect values a SUM cannot state: a mending clock is ONE interval
* between ticks (effect.intervalMs), not one interval per skill, and the
* mend's color is the skill's own dressing. Null when nothing is ranked.
* @param {string} kind the effect kind (e.g. 'hullRegen')
* @returns {object|null} the model's def, or null
*/
firstPassiveOf(kind) {
for (const [id, points] of this.ranks) {
if (points <= 0) continue;
const def = this.defOf(id);
if (!def || !def.passive || def.classId !== this.classId) continue;
if (def.effect?.kind !== kind) continue;
return def;
}
return null;
}
/** The hull-mending share Nanobots grants (0.05 at 5/5 with the shipped
* 0.01 per point) — the fraction of MAX hull regrown per tick. */
hullRegen() {
return this.effectTotal('hullRegen', 'regenPercentPerRank');
}
// ------------------------------------------------------------- the view
/**