{ "_comment": "THE SKILL LAYER — what a class can actually DO with the skill points its lock-in banks (data/classes.json → bonuses.skillPoints, the ledger js/player/ClassState.js). THE SHAPE: code owns behavior, this file owns every number, string, colour and icon frame (js/skills/SkillModel.js reads it and normalizes; js/skills/SkillState.js spends it; js/ui/SkillWindow.js draws it; js/combat/CombatSystem.js executes the effects). Keys starting with `_` are documentation, not data. THE RULE: adding skill #3 is an edit to this file plus, at most, one new effect `kind` handler in js/skills/SkillEffects.js — never a re-plumb of the tree, the bar or the ledger.", "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.", "texture": "assets/images/skills.png", "frameWidth": 128, "frameHeight": 128 }, "types": { "_comment": "THE TYPE TAXONOMY. A skill's `type` names an entry here; the type carries the accent the tree/badge wears, the label it prints, and `passive` — the ONLY thing that decides whether a skill is CASTABLE (an active skill can hold a combat-bar seat and spend energy; a passive is always-on at rank ≥1 and can never take a seat). Types are first-class so later skills can reference them ('+10% to all Offense skills'): reference by the SAME key. Add a type = add an entry here.", "Offense": { "label": "OFFENSE", "accent": "#ff3a4d", "passive": false }, "Defense": { "label": "DEFENSE", "accent": "#5fd4ff", "passive": false }, "Utility": { "label": "UTILITY", "accent": "#7ce8a4", "passive": false }, "Mobility": { "label": "MOBILITY", "accent": "#ffc94d", "passive": false }, "Passive": { "label": "PASSIVE", "accent": "#c084fc", "passive": true } }, "seats": { "_comment": "THE COMBAT BAR'S SEATS. `count` is the fallback when data/combat.json → bar.slot.count is unreadable (the bar owns the real number — eight seats wearing keys 1-8). A skill's `seat` below is its DEFAULT binding (1-based); the player reassigns by clicking a skill in the tree and then a seat, and the run's bindings live in the ledger (js/skills/SkillState.js → record.playerSkills.seats), so this table is only ever the first-run suggestion. seatOutOfRangePolicy: 'ignore' drops a default that points past the last seat, 'clamp' pins it to the last seat.", "count": 8, "seatOutOfRangePolicy": "ignore" }, "bar": { "_comment": "How a SEATED skill is DRAWN on the combat bar (js/ui/CombatBar.js): the icon size, the state veils and the recharge sweep. Behaviour lives in code — js/combat/CombatSystem.js → skillSeats() decides WHICH state a seat is in (ready / recharging / not affordable / inert / empty) and this block says what each one LOOKS like. Sizes: the icon is a FRACTION of the seat (the bar scales its seats down on narrow viewports, so absolute pixels would break at 1024px wide); the text size is px inside a ~44px seat. Colours stay CSS strings — the bar converts them at draw time (js/skills/SkillEffects.js → hexToInt).", "iconScale": 0.76, "rankTextPx": 9, "secondsTextPx": 13, "assignPulseHz": 1.6, "states": { "_comment": "Draw order: the icon, then a veil over it (an unranked skill is buried, a too-expensive one is dimmed blue), then the recharge SWEEP — a dark pie that unwinds clockwise as the skill recharges — with the SECONDS LEFT printed over it. `readyEdge` is the outline a live-and-charged seat wears; `assignEdge` outlines every seat while the tree is holding a skill up waiting to be placed.", "readyEdge": "#7ce8ff", "inertVeil": "#050a13", "inertVeilAlpha": 0.74, "noEnergyVeil": "#0d1526", "noEnergyVeilAlpha": 0.58, "cooldownVeil": "#03080f", "cooldownVeilAlpha": 0.66, "sweepColor": "#9fd8ff", "sweepAlpha": 0.16, "secondsColor": "#eaf6ff", "rankColor": "#cfe3f7", "assignEdge": "#ffd84d" }, "copy": { "_comment": "The bar's own toasts (the seat pick/clear path, js/scenes/GameScene.js). The SUCCESS line is the window's `assignedToast` — one copy string, two doors into the same binding.", "assignBlocked": "{skill} CANNOT TAKE A SEAT — ONLY ACTIVE SKILLS SIT IN THE BAR", "pickFromTree": "PICK A SKILL IN THE SKILL TREE FIRST", "clearEmpty": "SEAT {seat} IS ALREADY EMPTY", "clearedToast": "SEAT {seat} CLEARED — THE SKILL KEEPS ITS RANKS", "hintUnbind": "RIGHT-CLICK A SEAT TO CLEAR IT" } }, "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.", "skills": [ { "id": "ramming_charge", "label": "Ramming Charge", "_comment": "THE HEAD-ON OPENING. Type Offense (the type other skills will reference), 60 energy off the right orb, a 6 second recharge, five points to spend. Rank 0 = inert (no seat lights); rank ≥1 = castable. `seat: 1` is the default binding on a fresh run — keys 1-8 press the seat, not the skill.", "type": "Offense", "frame": 0, "energy": 60, "cooldown": 6, "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": { "_comment": "kind = the handler in js/skills/SkillEffects.js. dashBlast: lock the flight controls, drive the hull at speedMul × the ship's own max speed along the line to the aim point, and detonate on ARRIVAL (the moment it reaches the point, or when durationMs runs out — whichever lands first). The aim point is the nearest hostile's position AT CAST plus overshootPx PAST it, so the ship ends up through the enemy rather than inside it. blastRadiusPx = how far the arrival shock reaches from the ship (every hostile inside takes the hit once). damageMul is a multiple of the cannon's own damage (data/combat.json → player.weapon.damage) at rank 1; damageMulPerRank adds to it for every skill point AFTER the first — 4x at 1/5, 6x at 2/5 … 12x at 5/5.", "kind": "dashBlast", "speedMul": 3, "durationMs": 1000, "overshootPx": 90, "blastRadiusPx": 170, "damageMul": 4, "damageMulPerRank": 2, "fx": { "_comment": "The charge's own dressing (js/combat/Fx.js reads these; every key has a fallback): trailColor = the comet head's shed embers AND the arrival's ember ring, ringColor = the DAMAGE-RADIUS ring + the shock ripples, ringLifeMs = how long that radius ring lingers, sfxEvent = a COMBAT_SFX EVENT (js/combat/CombatSfx.js) played with the thunder boom ('kill') under the impact — 'hull' is the heavy armor-crack voice. The comet head itself, the lingering ~1 s ember trail and the fireball are Fx's fixed craft; these colors skin them.", "trailColor": "#ff3a4d", "trailAlpha": 0.55, "ringColor": "#ffd84d", "ringLifeMs": 480, "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.", "label": "Reinforced Plating", "type": "Passive", "frame": 1, "energy": 0, "cooldown": 0, "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": { "_comment": "hullPool: the armor's base hull (the ship's own stats.hullIntegrity, or data/combat.json → playerShip.hull on the range) is multiplied by 1 + hullBonusPerRank × rank — 5% at 1/5 … 25% at 5/5. THE MULTIPLIER IS RE-ROLLED FROM BASE EVERY TIME (never compounded on last frame's max), so rank-ups, level-up armor bumps (data/progression.json → reward), combat restarts and save/load can never stack it twice. The hull orb shows the bigger number.", "kind": "hullPool", "hullBonusPerRank": 0.05 } } ] }, "wraith": { "_comment": "THE WRAITH TREE — install 2 holds the class's two tricks: Boleadoras (the SNARE — the trap the targeting computer exists to lead) and Blink (the ESCAPE — the reason the trickery is survivable). Both are ROOTS: no `requires`, so a Wraith that just locked in can spend into either immediately. Both spend the right orb (60 / 70 off a 100) and sit in seats 1 / 2 on a fresh run, the same as the Vanguard's defaults.", "skills": [ { "id": "boleadoras", "label": "Boleadoras", "_comment": "THE SNARE. Type Utility (the type other skills reference), 60 energy off the right orb, a 6 second recharge, five points to spend. Rank 0 = inert; rank ≥1 = castable. `seat: 1` is the default binding on a fresh run. THE MECHANIC lives in js/combat/CombatSystem.js → launchBoleadoras / _stepBoleadoras / _snagBola: one set per nearest hostile (targetCount of them), each hunting its assigned craft on the ADVANCED TARGETING's intercept solve (js/combat/Aiming.js, capped by perks.targetingComputer.maxLead when the ledger carries the Wraith's computer). The STUN the impact drops (disableBaseMs + disablePerRankMs × rank, stamped on each caught craft's `stunUntil`) is what the pack AI's gate reads — a strung craft flies nowhere and fires nothing.", "type": "Utility", "frame": 8, "energy": 60, "cooldown": 6, "maxPoints": 5, "requires": [], "seat": 1, "node": { "col": 1, "row": 0 }, "description": "Hurl a spinning set of digital Boleadoras (two orbs on a string) at each of the nearest 3 enemies. On impact the enemies caught are disabled — unable to fly or shoot — for a period of 0.5 seconds, with an additional 0.5 seconds of disable time per skill point.", "shortDescription": "String up to 3 enemies: no flight, no fire, 0.5s +0.5s per point.", "effect": { "_comment": "kind = the handler in js/skills/SkillEffects.js. targetCount = the number of NEAREST hostiles that each get their own set; speed = the sets' flight (px/s); captureRadiusPx = on arrival, every live hostile whose hull touches this radius is strung (the set's own target plus any it sweeps in). disableBaseMs + disablePerRankMs × rank = the stun a strung craft wears, in MS (the combat loop's clock): 0.5s base + 0.5s per point — 1.0s at 1/5 … 3.0s at 5/5. fx skins the sets (ballColor = the orbs, stringColor = the line between them, ballSize/stringRadius in px); sfxEvent is a COMBAT_SFX EVENT (js/combat/CombatSfx.js) played when a set lands.", "kind": "boleadoras", "targetCount": 3, "speed": 700, "captureRadiusPx": 56, "disableBaseMs": 500, "disablePerRankMs": 500, "fx": { "ballColor": "#b489ff", "stringColor": "#9fd8ff", "ballSize": 14, "stringRadius": 16, "sfxEvent": "bola_snag" } } }, { "id": "blink", "label": "Blink", "_comment": "THE ESCAPE. Type Utility, 70 energy off the right orb, a 15 second recharge, five points to spend. Rank 0 = inert; rank ≥1 = castable. `seat: 2` is the default binding on a fresh run. THE MECHANIC lives in js/combat/CombatSystem.js → blink / _blinkDestination / _stepDecoys: on activation the hull is SWAPPED for a decoy copy of the player's own ship (decoyFrame of the player's own sheet) left at the old position, and the Wraith stands at a random point of the visible screen. While it stands (decoyBaseMs + decoyPerRankMs × rank), every pirate tracks and lances the decoy instead (pickEnemyTarget's first rule) and enemy bolts that reach it fizzle on the shell (collide()'s decoy pass).", "type": "Utility", "frame": 9, "energy": 70, "cooldown": 15, "maxPoints": 5, "requires": [], "seat": 2, "node": { "col": 3, "row": 0 }, "description": "Leave a decoy copy of your ship at your old position — it draws all enemy aggro while it stands — and teleport to a random location on the visible screen. The decoy lasts 0.4 seconds, with an additional 0.4 seconds per skill point.", "shortDescription": "Decoy behind, hop across the screen. 0.4s +0.4s per point.", "effect": { "_comment": "kind = the handler in js/skills/SkillEffects.js. decoyFrame = the decoy's frame of the PLAYER'S OWN sheet (assets/images/ships-player.png — frame 1, the frame right of the starter ship). decoyBaseMs + decoyPerRankMs × rank = the decoy's stand, in MS (the combat loop's clock): 0.4s base + 0.4s per point — 0.8s at 1/5 … 2.4s at 5/5. insetFraction = how far the destination stays off the visible screen's edges (a fraction of the shorter side); minDistancePx = the hop's minimum distance from the old position (a blink that lands in place is not a blink); fallbackRadiusPx = the hop's radius when the scene has no camera (the headless range/tests). fx.decoyColor tints the shell, fx.color the teleport beats; sfxEvent is a COMBAT_SFX EVENT (js/combat/CombatSfx.js).", "kind": "blink", "decoyFrame": 1, "decoyBaseMs": 400, "decoyPerRankMs": 400, "insetFraction": 0.16, "minDistancePx": 180, "fallbackRadiusPx": 340, "fx": { "decoyColor": "#b489ff", "color": "#a764ff", "sfxEvent": "blink" } } } ] } }, "window": { "_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}", "video": { "_comment": "The left feed — a muted 2:3 loop, authored separately. A missing clip leaves the NO SIGNAL plate up and the console still works (the ship/class consoles' rule).", "file": "assets/videos/skills.mp4", "aspect": [2, 3], "tag": "TRAINING SIM // OPEN CHANNEL", "noSignalTag": "FEED OFFLINE" }, "treeTitle": "AVAILABLE DOCTRINE", "pointsLine": "{n} SKILL POINTS AVAILABLE", "pointsLineOne": "{n} SKILL POINT AVAILABLE", "rankLine": "{rank}/{max}", "costLine": "{energy} ENERGY · {cooldown}s RECHARGE", "passiveCostLine": "PASSIVE · NO ENERGY · ALWAYS ON", "typeLabel": "TYPE", "seatLabel": "SEAT", "states": { "_comment": "The state stamp a skill row wears, and the copy a refused SPEND toasts. `available` is a root/parent satisfied but rank 0 (the first point), `upgradable` takes the next point, `locked` names the parent via lockedNote, `maxed` is 5/5, `noPoints` is spendable-but-broke.", "locked": "LOCKED", "available": "RANK UP", "upgradable": "RANK UP", "noPoints": "NO POINTS", "maxed": "MASTERED", "passiveOn": "ACTIVE" }, "_commentStates": "One line per reason a seat press can be REFUSED (js/combat/CombatSystem.js → cast() returns the reason; js/scenes/GameScene.js → castSkillSeat says it). The seat is always named, because on the bar a number IS the skill — keys bind seats, not skills. {skill}, {seat} and {left} (seconds left) fill in.", "castRefusals": { "empty": "SEAT {seat}: NOTHING ASSIGNED", "inert": "{skill} IS NOT RANKED YET", "energy": "NOT ENOUGH ENERGY FOR {skill}", "cooldown": "{skill} RECHARGING — {left}s", "passive": "{skill} IS ALWAYS ON", "down": "HULL OFFLINE", "dashing": "ALREADY CHARGING", "noLedger": "NO SKILLS ON FILE", "disabled": "SKILL SYSTEM OFFLINE", "unhandled": "{skill} IS NOT WIRED" }, "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", "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", "sfx": { "_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" }, "fx": { "_comment": "Window dressing, the research console's recipe: occasional RGB-split slice bursts over the plate and a scan band crawling the feed.", "glitch": { "enabled": true, "intervalMs": [6000, 13000], "durationMs": [220, 420] }, "sweep": { "enabled": true, "everyMs": [4200, 8600], "durationMs": 1500 } } }, "_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], 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", "frame": 2, "energy": 40, "cooldown": 8, "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" } } }