351 lines
14 KiB
JavaScript
351 lines
14 KiB
JavaScript
/**
|
|
* 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,
|
|
* description, shortDescription, effect: { kind, ...numbers },
|
|
* passive: boolean, castable: boolean }
|
|
* A TYPE RECORD (typeDef):
|
|
* { id, label, accent (int), accentCss, passive }
|
|
*/
|
|
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 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),
|
|
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);
|
|
}
|
|
|
|
/** 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'),
|
|
assignLabel: str(win.assignLabel, 'ASSIGN SEAT'),
|
|
assignHint: str(win.assignHint),
|
|
assignedToast: str(win.assignedToast),
|
|
unbindLabel: str(win.unbindLabel, 'CLEAR SEAT'),
|
|
refundNote: str(win.refundNote),
|
|
emptyNote: str(win.emptyNote),
|
|
noClassNote: str(win.noClassNote),
|
|
closeLabel: str(win.closeLabel, '◂ CLOSE'),
|
|
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 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])),
|
|
);
|
|
}
|