orbit/js/skills/SkillModel.js

584 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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