orbit/js/skills/SkillModel.js

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