/** * SKILL MODEL — the pure reads over the skill roster (data/skills.json). * * No Phaser, no scene: the same contract as js/player/ClassModel.js and * js/research/ResearchModel.js, so dev/skills.test.mjs exercises exactly * this in bare Node and every screen (the Skill Tree console, the combat * bar's seats, js/combat/CombatSystem.js's cast pipeline) reads ONE * definition of a skill. * * THE SPLIT: data/skills.json owns every label, accent, icon frame, energy * cost, recharge, max rank, unlock edge and mechanics number; this module * owns the SHAPE — normalizing whatever the data says into records the UI * and the combat code can rely on. A malformed entry is dropped, never * propagated, and every read carries a fallback so a missing key degrades * instead of throwing. * * FALLBACK DISCIPLINE: a default here is deliberately INERT (0 / false / * ''), never a copy of a shipped number — the day someone re-tunes Ramming * Charge in the JSON there must be exactly one place the old number lived. * dev/skills.test.mjs sweeps js/ for restated balance to keep that honest. * * A SKILL RECORD (skillDef / skillList): * { 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'; /** The skill-insignia texture key (one sheet, cached game-wide). */ export const SKILL_ICON_KEY = 'skill_icons'; const str = (v, fb = '') => (typeof v === 'string' && v.trim() ? v : fb); /** A number that survives junk; `fb` when the data says nothing usable. */ const num = (v, fb = 0) => { const n = Number(v); return Number.isFinite(n) ? n : fb; }; /** A whole number ≥ 0 (ranks, seats and frames never go negative). */ const whole = (v, fb = 0) => Math.max(0, Math.floor(num(v, fb))); /** A CSS/int color → the int Graphics + Phaser text both want. */ function toInt(v, fb) { if (typeof v === 'number' && Number.isFinite(v)) return v >>> 0; const s = String(v ?? '').trim().replace('#', ''); if (!/^[0-9a-fA-F]{6}$/.test(s)) return fb; return parseInt(s, 16) >>> 0; } /** `_`-prefixed keys are documentation, not data (the project's rule). */ const real = (o) => Object.fromEntries(Object.entries(o ?? {}).filter(([k]) => !String(k).startsWith('_'))); /** Is the skill layer on at all (data/skills.json → enabled)? */ export function skillsEnabled() { return config.get('skills.enabled', true) !== false; } /** The insignia sheet's config (texture + frame size). */ export function iconSheet() { const cfg = config.section('skills.icons', {}); // The path is the data's — a missing key means NO sheet (the UI draws its // procedural stand-in), not a path baked into code. return { texture: str(cfg.texture), frameWidth: Math.max(1, whole(cfg.frameWidth, 0)), frameHeight: Math.max(1, whole(cfg.frameHeight, 0)), }; } /** * One type record (data/skills.json → types[id]). A type is what later * skills reference ("+10% to all Offense skills"), so the id is the API and * `passive` is the ONE flag that decides castable vs always-on. An unknown * type id still yields a usable record (inert accent, castable) — a typo in * the data costs nothing but its colour. */ export function typeDef(id) { const key = str(id); const table = real(config.section('skills.types', {})); // Case-insensitive, and the CANONICAL key wins (the data spells its types // exactly once — later skills reference that spelling). const hit = Object.entries(table).find(([k]) => k.toLowerCase() === String(id ?? '').trim().toLowerCase()); if (!hit) return { id: '', label: '', accent: 0, accentCss: '', passive: false }; const [canon, row] = hit; const css = typeof row?.accent === 'string' && row.accent.trim() ? row.accent.trim() : ''; return { id: canon, label: str(row?.label, canon).toUpperCase(), accent: toInt(row?.accent, 0), accentCss: css, passive: row?.passive === true, }; } /** Every authored type, normalized (the console's legend / filters). */ export function typeList() { return Object.keys(real(config.section('skills.types', {}))).map((k) => typeDef(k)); } /** * How many bar seats exist. The combat bar owns the real number * (data/combat.json → bar.slot.count); skills.seats.count is the fallback. */ export function seatCount() { const n = num(config.get('skills.seats.count', 0), 0) || num(config.get('combat.bar.slot.count', 0), 0); 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; const n = whole(v, 0); if (n < 1) return null; if (n <= count) return n; const policy = str(config.get('skills.seats.seatOutOfRangePolicy', 'ignore')).toLowerCase(); return policy === 'clamp' ? count : null; } /** A skill's own record (no rank knowledge — that is SkillState's job). */ function normalizeSkill(row, classId, byId) { const id = str(row?.id).toLowerCase(); if (!id || !/^[a-z0-9_]+$/.test(id)) return null; // an unusable id never enters the tree const type = typeDef(row?.type); const raw = row?.effect && typeof row.effect === 'object' ? row.effect : {}; const { _comment, ...effect } = raw; const requires = (Array.isArray(row?.requires) ? row.requires : []) .map((r) => { if (typeof r === 'string') return { skill: r.toLowerCase(), points: 1 }; if (r && typeof r === 'object') { const skill = str(r.skill).toLowerCase(); // `points: 0` still means "put a point in it" — a rank of 0 is inert. const points = Math.max(1, whole(r.points, 1)); return skill ? { skill, points } : null; } return null; }) .filter((r) => r && byId.has(r.skill)); // a dangling edge cannot lock the tree forever return { id, classId, label: str(row?.label, id), typeId: type.id, type, frame: whole(row?.frame, 0), energy: Math.max(0, num(row?.energy, 0)), cooldown: Math.max(0, num(row?.cooldown, 0)), 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 }, passive: type.passive, castable: !type.passive, }; } /** The normalized trees, built once per config (data order preserved). */ let cache = null; function trees() { if (cache) return cache; const out = new Map(); const table = real(config.section('skills.classes', {})); for (const [classId, branch] of Object.entries(table)) { const key = String(classId).toLowerCase(); // Pass one: the id set, so `requires` can drop edges to unknown skills. const ids = new Set((Array.isArray(branch?.skills) ? branch.skills : []).map((s) => str(s?.id).toLowerCase())); const list = []; for (const row of Array.isArray(branch?.skills) ? branch.skills : []) { const rec = normalizeSkill(row, key, ids); if (rec) list.push(rec); } out.set(key, { classId: key, skills: list, byId: new Map(list.map((s) => [s.id, s])) }); } cache = out; return out; } /** A run's class changed nothing structural — but a hot reload of data does. */ export function invalidateSkillCache() { cache = null; } /** * One skill record by id. Pass `classId` to scope the lookup to that class's * tree (the same id in two class trees resolves to the caller's class). * @returns {object|null} null when no such skill exists */ export function skillDef(id, classId = null) { const key = str(id).toLowerCase(); if (!key) return null; if (classId) return trees().get(String(classId).toLowerCase())?.byId.get(key) ?? null; for (const branch of trees().values()) { const hit = branch.byId.get(key); if (hit) return hit; } return null; } /** The tree's skill records, in data (display) order. */ export function skillList(classId) { if (!skillsEnabled() || !classId) return []; return trees().get(String(classId).toLowerCase())?.skills ?? []; } /** Does this class have an authored tree at all (the console's empty plate)? */ export function hasTree(classId) { return skillList(classId).length > 0; } /** * Every class id that owns a tree, in data order (data/skills.json → classes). * The dev door uses it to pick a tree when the URL names none; UI uses it to ask * "does this class have skills at all" without hardcoding vanguard/wraith. */ export function treeClassIds() { if (!skillsEnabled()) return []; // Only classes that actually own a row: the Map carries `_`-prefixed // documentation branches (they have no skills, and are not a class). return [...trees().values()].filter((t) => t.skills.length > 0).map((t) => t.classId); } /** * THE TREE GRID (pure — takes skill RECORDS, reads no config). * * Returns `{ nodes, edges, cols, rows }`: * nodes [{ id, def, col, row, authored }] in the roster's own order * edges [{ from, to, points }] one line per unsatisfied-draft edge * cols / rows the grid's extent (1-based counts — what the console sizes its * scrollable content from) * * PLACEMENT: an authored `layout` wins; everything else is placed by the graph. * A skill's depth is 1 + the deepest of its parents (a root is row 0), computed * with a cycle guard, and columns are handed out left→right in display order so a * hand-authored tree and an auto-laid one read the same way. Two skills claiming * one cell push the later one right; nothing overlaps. * * EDGES: every `requires` pair becomes ONE line (a `{skill, points}` edge and a * bare edge to the same parent share a line — the stricter `points` wins). A * dangling parent id drops the line (the roster's own guard already dropped it, * but a caller handing us a partial list must not see a line into nowhere). * * The pane's own column count (window.tree.cols) is a LOOK knob and belongs to the * console; this function only says where each skill sits. * * @param {Array} skills skill records from skillList() */ export function treeLayout(skills = []) { const list = (Array.isArray(skills) ? skills : []).filter((s) => s && typeof s.id === 'string' && s.id); const byId = new Map(list.map((d) => [d.id, d])); // ---- depth per skill (a root is row 0), cycle-guarded ----------------- const depthMemo = new Map(); const depthOf = (id, trail) => { if (depthMemo.has(id)) return depthMemo.get(id); if (trail.has(id)) return 0; // a cycle in the data: break it here, not on screen const def = byId.get(id); const parents = (def?.requires ?? []).map((r) => r.skill).filter((p) => byId.has(p)); let d = 0; if (parents.length) { const next = new Set(trail); next.add(id); d = 1 + Math.max(...parents.map((p) => depthOf(p, next))); } depthMemo.set(id, d); return d; }; // ---- place them ------------------------------------------------------- const taken = new Set(); const key = (c, r) => `${c}:${r}`; const nodes = []; for (const def of list) { // A normalized record carries `layout`; a raw data row still carries `node`. const want = def.layout ?? normalizeNode(def.node); let col = Number.isFinite(Number(want?.col)) ? Math.floor(Number(want.col)) : null; // The trail starts EMPTY: depthOf adds the id itself before recursing, so a // self-trail here would trip the cycle guard on every node and flatten the tree. let row = Number.isFinite(Number(want?.row)) ? Math.floor(Number(want.row)) : depthOf(def.id, new Set()); if (col === null) { for (let c = 0; ; c++) { if (!taken.has(key(c, row))) { col = c; break; } } } else { // An authored cell already spoken for slides right rather than stacking. let c = col; while (taken.has(key(c, row))) c += 1; col = c; } taken.add(key(col, row)); nodes.push({ id: def.id, def, col, row, authored: !!want }); } // ---- the lines between them ------------------------------------------- const seen = new Set(); const edges = []; for (const node of nodes) { for (const req of node.def.requires ?? []) { if (!byId.has(req.skill)) continue; const k = `${req.skill}→${node.id}`; const points = Math.max(1, whole(req.points, 1)); if (seen.has(k)) { const prev = edges.find((e) => `${e.from}→${e.to}` === k); if (prev) prev.points = Math.max(prev.points, points); continue; } seen.add(k); edges.push({ from: req.skill, to: node.id, points }); } } const cols = nodes.reduce((m, n) => Math.max(m, n.col + 1), 0); const rows = nodes.reduce((m, n) => Math.max(m, n.row + 1), 0); // The used bounds (the console centers the tree on these and sizes its scroll // content from them) — an empty roster still yields a 1×1 grid so no caller has // to divide by zero. const minCol = nodes.reduce((m, n) => Math.min(m, n.col), 0); const maxCol = nodes.reduce((m, n) => Math.max(m, n.col), -1); const minRow = nodes.reduce((m, n) => Math.min(m, n.row), 0); const maxRow = nodes.reduce((m, n) => Math.max(m, n.row), -1); return { nodes, edges, cols: Math.max(cols, 1), rows: Math.max(rows, 1), bounds: { minCol, maxCol, minRow, maxRow } }; } /** * A class tree laid out for the console (the roster read + treeLayout applied). * @returns {{nodes: Array, edges: Array, cols: number, rows: number, bounds: object}} */ export function classTreeLayout(classId) { return treeLayout(skillList(classId)); } /** Every effect number a cast/apply needs, read off the record with inert defaults. */ export function effectOf(def, kind = null) { const fx = def?.effect ?? {}; if (kind && fx.kind !== kind) return null; return fx; } /** * The console's copy + knobs (data/skills.json → window). Every string has a * fallback so an unfinished data file still renders a legible window. */ export function windowSpec() { const win = config.section('skills.window', {}); const states = win.states ?? {}; return { title: str(win.title, 'SKILL TREE'), meta: str(win.meta), feedLine: str(win.feedLine), video: { file: str(win.video?.file), aspect: Array.isArray(win.video?.aspect) ? win.video.aspect : [2, 3], tag: str(win.video?.tag).toUpperCase(), noSignalTag: str(win.video?.noSignalTag, 'FEED OFFLINE').toUpperCase(), }, treeTitle: str(win.treeTitle, 'AVAILABLE DOCTRINE'), pointsLine: str(win.pointsLine), pointsLineOne: str(win.pointsLineOne), rankLine: str(win.rankLine, '{rank}/{max}'), costLine: str(win.costLine), passiveCostLine: str(win.passiveCostLine), typeLabel: str(win.typeLabel, 'TYPE'), seatLabel: str(win.seatLabel, 'SEAT'), states: { locked: str(states.locked, 'LOCKED'), available: str(states.available, 'RANK UP'), upgradable: str(states.upgradable, states.available ?? 'RANK UP'), noPoints: str(states.noPoints, 'NO POINTS'), maxed: str(states.maxed, 'MASTERED'), passiveOn: str(states.passiveOn, 'ACTIVE'), }, // Why a seat press was refused (CombatSystem.cast()'s reason → the line the // scene toasts). An unknown reason falls back in the caller. castRefusals: Object.fromEntries( Object.entries(win.castRefusals ?? {}).filter(([k]) => !k.startsWith('_')), ), 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'), emptyNote: str(win.emptyNote), noClassNote: str(win.noClassNote), closeLabel: str(win.closeLabel, '◂ CLOSE'), sfx: { spend: str(win.sfx?.spend, 'discovery'), denied: str(win.sfx?.denied, 'ui_click'), assign: str(win.sfx?.assign, 'ui_click'), open: str(win.sfx?.open, 'ui_window'), close: str(win.sfx?.close, 'ui_close'), }, fx: { 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), }; })(), }; } /** * The combat bar's SEAT face (data/skills.json → bar). Which STATE a seat is in * is the fight's answer (js/combat/CombatSystem.js → skillSeats(): ready / * recharging / not affordable / inert / empty); this block is only what each one * LOOKS like — so the bar never hardcodes a colour or an icon size either. * Clamped, because a stray `2` in the data would blow a seat's icon out of the * bar (the icon scale is a FRACTION of the seat) and an alpha over 1 is noise. */ export function barSpec() { const b = config.section('skills.bar', {}); const s = b.states ?? {}; const alpha = (v, fb) => Math.max(0, Math.min(1, num(v, fb))); return { // A seated icon is drawn at this fraction of the seat's box, so the bar can // scale its seats down on a narrow viewport and the icons follow. iconScale: Math.max(0.1, Math.min(1, num(b.iconScale, 0.76))), rankTextPx: Math.max(6, whole(b.rankTextPx, 9)), secondsTextPx: Math.max(7, whole(b.secondsTextPx, 13)), // How fast an awaiting-seat outline breathes while the tree holds a skill up. assignPulseHz: Math.max(0.1, num(b.assignPulseHz, 1.6)), states: { readyEdge: str(s.readyEdge, '#7ce8ff'), inertVeil: str(s.inertVeil, '#050a13'), inertVeilAlpha: alpha(s.inertVeilAlpha, 0.74), noEnergyVeil: str(s.noEnergyVeil, '#0d1526'), noEnergyVeilAlpha: alpha(s.noEnergyVeilAlpha, 0.58), cooldownVeil: str(s.cooldownVeil, '#03080f'), cooldownVeilAlpha: alpha(s.cooldownVeilAlpha, 0.66), sweepColor: str(s.sweepColor, '#9fd8ff'), sweepAlpha: alpha(s.sweepAlpha, 0.16), secondsColor: str(s.secondsColor, '#eaf6ff'), rankColor: str(s.rankColor, '#cfe3f7'), assignEdge: str(s.assignEdge, '#ffd84d'), }, copy: { assignBlocked: String(b.copy?.assignBlocked ?? '').toUpperCase(), pickFromTree: String(b.copy?.pickFromTree ?? '').toUpperCase(), clearEmpty: String(b.copy?.clearEmpty ?? '').toUpperCase(), clearedToast: String(b.copy?.clearedToast ?? '').toUpperCase(), hintUnbind: String(b.copy?.hintUnbind ?? '').toUpperCase(), }, }; } /** * The template fill the console's lines use (`{class}`, `{rank}`, `{max}`, * `{n}`, `{energy}`, `{cooldown}`, `{seat}`, `{parent}`, …). An unknown * placeholder is left alone, so a typo in the data never prints "undefined". */ export function fillTemplate(text, map = {}) { return String(text ?? '').replace(/\{(\w+)\}/g, (m, k) => (map[k] === undefined || map[k] === null ? m : String(map[k])), ); }