/** * CLASS MODEL — the pure reads over the class roster (data/classes.json). * * No Phaser, no scene: the same contract as js/ship/ShipScreenModel.js and * js/combat/Weapons.js, so dev/classes.test.mjs exercises exactly this in * bare Node and every screen (the select console, the ship console's class * plate, the combat system's firing code) reads ONE definition of a class. * * The split: data/classes.json owns the labels, the accents, the insignia * frame indices, the overview copy and the perk numbers; 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; every read has a fallback). * * THE DOOR LEVEL is NOT restated here: `classAt` lives in * data/shipScreen.json → level.classAt (the ship console's journey plate * and the class trigger read the same number — one source). * * A CLASS RECORD (classDef / classList): * { id, label, accent, frame, role, tagline, summary, overview: string[], * skillPoints: int, perks: [perkRecord] } * A PERK RECORD (perkDef): * { id, label, note, ...the perk's own numbers } */ import { config } from '../config/Config.js'; /** The class-insignia texture key (one sheet, cached game-wide). */ export const CLASS_ICON_KEY = 'class_icons'; const str = (v, fb = '') => (typeof v === 'string' && v.trim() ? 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; } /** Is the class layer on at all (data/classes.json → enabled)? */ export function classesEnabled() { return config.get('classes.enabled', true) !== false; } /** The combat level a class is earned at — data/shipScreen.json → level.classAt. */ export function classAt() { const n = Number(config.get('shipScreen.level.classAt', 5)); return Number.isFinite(n) ? Math.max(1, Math.round(n)) : 5; } /** The insignia sheet's config (texture + frame size). */ export function iconSheet() { const cfg = config.section('classes.icons', {}); return { texture: str(cfg.texture, 'assets/images/classes.png'), frameWidth: Math.max(1, Math.round(Number(cfg.frameWidth) || 128)), frameHeight: Math.max(1, Math.round(Number(cfg.frameHeight) || 128)), }; } /** * One perk record (data/classes.json → perks[id]). The definition carries * its own mechanics numbers (dualCannon.barrels / barrelOffset, * targetingComputer.maxLead) so combat reads them here, never hardcoded. * @param {string} id the perk id a class lists in bonuses.perks * @returns {object|null} null for an unknown id */ export function perkDef(id) { const key = str(id); if (!key) return null; const table = config.section('classes.perks', {}); const def = table[key]; if (!def || typeof def !== 'object') return null; return { id: key, label: str(def.label, key).toUpperCase(), note: str(def.note), ...def }; } /** The perk records a class's initial bonus grants (unknown ids drop). */ export function perksFor(id) { const def = classDef(id); if (!def) return []; return def.perks; } /** * One class record (data/classes.json → classes[id]). * @param {string} id a class id ('vanguard', 'wraith') * @returns {object|null} null when the roster has no such class */ export function classDef(id) { const key = String(id ?? '').trim().toLowerCase(); if (!key) return null; return classList().find((c) => c.id === key) ?? null; } /** * EVERY class offered, in data order (the tab order). An entry without a * usable id is skipped — a half-authored class never shows as a dead tab. * @returns {Array} the normalized roster ([] when the layer is off) */ export function classList() { if (!classesEnabled()) return []; const rows = config.get('classes.classes', []); if (!Array.isArray(rows)) return []; const out = []; for (const row of rows) { const id = str(row?.id).toLowerCase(); if (!id) continue; const bonuses = row.bonuses && typeof row.bonuses === 'object' ? row.bonuses : {}; const perkIds = Array.isArray(bonuses.perks) ? bonuses.perks : []; const perks = perkIds.map((p) => perkDef(p)).filter(Boolean); const overview = Array.isArray(row.overview) ? row.overview.map((p) => str(p)).filter(Boolean) : []; out.push({ id, label: str(row.label, id).toUpperCase(), accent: toInt(row.accent, 0x00e5ff), accentCss: typeof row.accent === 'string' && row.accent.trim() ? row.accent.trim() : '#00e5ff', frame: Math.max(0, Math.floor(Number(row.frame) || 0)), role: str(row.role).toUpperCase(), tagline: str(row.tagline).toUpperCase(), summary: str(row.summary), overview, skillPoints: Math.max(0, Math.floor(Number(bonuses.skillPoints) || 0)), perks, }); } return out; } /** The perk LABELS a class grants, joined for the toast / confirm line. */ export function perkNames(id, sep = ' + ') { return perksFor(id) .map((p) => p.label) .join(sep); } /** The insignia frame index of a class (null when the class is unknown). */ export function iconFrame(id) { const def = classDef(id); return def ? def.frame : null; } /** * The select console's briefing (data/classes.json → window.intro + * window.speech). `text` is Trestle's copy verbatim from the data. */ export function introSpec() { const win = config.section('classes.window', {}); const intro = win.intro ?? {}; const speech = win.speech ?? {}; return { speaker: str(intro.speaker, 'TRESTLE'), role: str(intro.role).toUpperCase(), text: str(intro.text), hint: str(intro.hint).toUpperCase(), caret: intro.caret !== false, audio: str(speech.file), musicDuck: Number.isFinite(Number(speech.musicDuck)) ? Number(speech.musicDuck) : 0.25, backLabel: str(intro.backLabel, '◂ BRIEFING').toUpperCase(), }; } /** The window's own copy + knobs (data/classes.json → window). */ export function windowSpec() { const win = config.section('classes.window', {}); return { title: str(win.title, 'CLASS SELECT'), meta: str(win.meta), // the little stamp under the left feed (the console's own shelf label) feedLine: str(win.feedLine, 'CLASS DOCTRINE // LV {classAt}'), video: { file: str(win.video?.file, 'classes.mp4'), 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(), }, tabs: { iconSize: Math.max(0, Number(win.tabs?.iconSize) || 26), height: Math.max(30, Number(win.tabs?.height) || 46), }, plate: win.classPlate ?? {}, pick: win.pick ?? {}, toasts: { alreadyChosen: str(win.alreadyChosenToast), success: str(win.successToast), defer: str(win.deferToast), }, sfx: { lock: str(win.lockSfx, 'ui_window'), success: str(win.lockSuccessSfx, 'discovery'), }, }; } /** * The template fill the confirm / toast lines use — `{class}`, `{perks}`, * `{skillPoints}` (an unknown placeholder is left alone, so a typo in the * data never prints "undefined"). */ export function fillTemplate(str_, map = {}) { return String(str_ ?? '').replace(/\{(\w+)\}/g, (m, k) => (map[k] === undefined || map[k] === null ? m : String(map[k])), ); } /** * The confirm dialog's copy for one class (data/classes.json → window.pick). * @param {string} id the class about to be locked in */ export function confirmSpec(id) { const def = classDef(id); const pick = windowSpec().pick; const map = { class: def?.label ?? String(id ?? '').toUpperCase(), perks: perkNames(id), skillPoints: def?.skillPoints ?? 0, frame: def?.frame ?? 0, }; return { title: fillTemplate(pick.title, map), line1: fillTemplate(pick.line1, map), line2: fillTemplate(pick.line2, map), confirmLabel: str(pick.confirmLabel, 'CONFIRM'), cancelLabel: str(pick.cancelLabel, 'CANCEL'), width: Number(pick.width) || 520, height: Number(pick.height) || 196, accent: def?.accentCss ?? '#00e5ff', }; } /** The lock-in toast's copy for one class. */ export function successMessage(id) { const spec = windowSpec(); return fillTemplate(spec.toasts.success, { class: classDef(id)?.label ?? String(id ?? '').toUpperCase(), perks: perkNames(id), skillPoints: classDef(id)?.skillPoints ?? 0, }); }