// server/sim/content/index.js — installs all content into a Battle (DESIGN §7). // // installContent(battle, { mode }) — applies the per-battle loadout data view (simdata withUnitLoadouts; DESIGN §16), // then mode 'full' (default): hand-authored kits (kits/tier1..6) with generic // fallback + every domain module's install(battle); 'generic': generic kits only, no domain modules; // 'none': no kits (units never cast skills) and no modules. Every module install runs inside try/catch: // a faulty content module is logged and skipped, never crashing the battle. // setupUnitKit(battle, unit, mode) — resolves a unit's Kit: kits[baseChessId]?.(bb, chess, def) ?? generic. // Battle opts.kits ({ [baseChessId]: kitFn }) take precedence (tests / sandboxes). // Operator loadouts (DESIGN §16): the def is resolved for the unit's selected skill / module (simdata getChess), so // the kit receives `chess.skill` = the SELECTED skill record and `bb` = its blackboard (talents / trait / module of // the selected module). Kit contract (backward compatible): `{ skill?, skills?: { [skillId]: SkillSpec }, talents, // trait, install? }` — the skill spec is `skills[selectedSkillId]` when the kit authors it, else `skill` only when // the selected skill is the chess's default one, else the GENERIC spec of the selected skill (its generic install, // e.g. counter damage, is chained after the kit's). Talents / trait / install of the kit always apply. // syncUnitLoadout (called first, before the battle starts): a unit whose def lacks the loadout of its own input entry // (id-only data lookups in a multi-player field; an entry without loadout fields = the default) is given its own // def; summon pieces follow their owner (an inline `def` of a token entry is kept). Summons spawned AFTER the start // are resolved by Battle (getToken(id, owner.defId, owner.def.loadout)), not here. // registerAllMeta(registry) — calls each domain module's registerMeta(registry) (prep side, server boot). import { genericKit } from './generic.js'; import { withUnitLoadouts } from '../simdata.js'; // Content files are loaded with guarded dynamic imports: a module that fails to load (syntax error, throwing // top-level code, missing file) is logged and replaced by an empty module instead of breaking the server. async function safeImport(path) { try { return await import(path); } catch (e) { console.error(`[content] failed to load ${path}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); return {}; } } const TIERS = await Promise.all([1, 2, 3, 4, 5, 6].map((t) => safeImport(`./kits/tier${t}.js`))); const DOMAIN_NAMES = ['tokens', 'devices', 'enemies', 'bosses', 'bonds', 'garrisons', 'items', 'bands', 'choices']; const DOMAINS = await Promise.all(DOMAIN_NAMES.map((n) => safeImport(`./${n}.js`))); const tokens = DOMAINS[0]; /** Merged kit registry: baseChessId → (bb, chess, def) => Kit */ export const KITS = Object.freeze(Object.assign({}, ...TIERS.map((m) => (m && m.default && typeof m.default === 'object' ? m.default : {})))); /** Domain modules in install order: tokens, devices, enemies, bosses, bonds, garrisons, items, bands, choices. */ export const MODULES = Object.freeze(DOMAIN_NAMES.map((n, i) => [n, DOMAINS[i]])); /** Resolve the Kit of an ally unit. Never throws (falls back to the generic kit). */ export function setupUnitKit(battle, unit, mode = 'full') { try { syncUnitLoadout(battle, unit); } catch (e) { battle._handlerError?.('loadout', unit, e); } if (mode === 'none') return {}; const def = unit.def || {}; const raw = def.raw ?? def; const bb = def.skill?.bb ?? {}; if (unit.kind === 'token') { const tk = mode === 'full' ? (tokens.kits?.[def.id] ?? tokens.default?.[def.id]) : null; if (typeof tk === 'function') { try { const k = tk(bb, raw, def); if (k) return k; } catch (e) { battle._handlerError(`tokenKit:${def.id}`, unit, e); } } return def.skill ? genericKit(bb, raw, def) : {}; } if (unit.kind !== 'op') return {}; const injected = battle.opts && battle.opts.kits; if (mode === 'full' || injected) { // DESIGN §5.6's example keys kits by the suffix-less id (`chess_char_1_01`), data/SIM.md by baseId (`…_a`): accept both const bare = String(def.baseId ?? def.id ?? '').replace(/_[ab]$/, ''); const pick = (reg) => reg?.[def.baseId] ?? reg?.[def.id] ?? reg?.[bare]; const f = pick(injected) ?? (mode === 'full' ? pick(KITS) : undefined); if (typeof f === 'function') { try { const k = f(bb, raw, def); if (k) return selectSkillSpec(k, bb, raw, def); } catch (e) { battle._handlerError(`kit:${def.baseId}`, unit, e); } } } return genericKit(bb, raw, def); } /** A unit's own PlayerBattleInput entry (operators: kind ≠ 'token'; tokens: kind 'token'), or null. */ function inputEntry(unit) { const list = unit && unit.player && unit.player.input && Array.isArray(unit.player.input.units) ? unit.player.input.units : null; if (!list || unit.uid == null) return null; const tok = unit.kind === 'token'; return list.find((v) => v && v.uid === unit.uid && (v.kind === 'token') === tok) ?? null; } /** * The loadout `{ skillIndex, moduleId }` a unit's PlayerBattleInput entry gives; an entry without loadout fields means * the DEFAULT loadout (`{}`), never "whatever the data view maps this chess id to" (another player's choice in a * multi-player field). Null when the unit has no input entry. */ function inputLoadout(unit) { const x = inputEntry(unit); if (!x) return null; if (x.skillIndex == null && x.moduleId == null) return {}; return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null }; } /** Put a def on a not-yet-deployed ally (the fields Battle._makeAlly takes from the def). */ function swapDef(unit, def) { const st = def.stats; unit.def = def; unit.defId = def.id; unit.name = def.name; unit.rangeGrid = def.rangeGrid; Object.assign(unit.base, { maxHp: st.maxHp, atk: st.atk, def: st.def, res: st.res, aspd: st.aspd, bat: st.bat, blockCnt: st.blockCnt, spRecovery: st.spRecovery, tauntLevel: st.tauntLevel, massLevel: st.massLevel, hpRecoveryPerSec: st.hpRecoveryPerSec, cost: st.cost, respawnTime: st.respawnTime, }); unit.markDirty?.(); unit.hp = unit.s ? unit.s.maxHp : st.maxHp; } /** * Loadout exactness (DESIGN §16) before the battle starts: an operator whose def does not carry the loadout of its own * PlayerBattleInput entry (the per-battle data view resolves id-only lookups per chess id — two players of one field * may give the same chess different loadouts) gets the def of its own loadout — an entry without loadout fields means * the default — and a summon piece follows its owner's (unless its entry carries an inline `def`). A no-op when the def * already matches (Battle passing the unit's loadout to getChess / getToken). */ export function syncUnitLoadout(battle, unit) { if (!battle || battle.started || !unit || unit.deployed || !unit.def || !battle.data) return; if (unit.kind === 'op') { const lo = inputLoadout(unit); if (!lo || typeof battle.data.getChess !== 'function') return; // an entry without loadout fields only needs a look when the def carries a non-default loadout if (!Object.keys(lo).length && !(unit.def.loadout && unit.def.loadout.isDefault === false)) return; const want = battle.data.getChess(unit.def.id, lo); if (want && want !== unit.def && want.id === unit.def.id) swapDef(unit, want); } else if (unit.kind === 'token' && unit.ownerUnit && unit.ownerUnit.kind === 'op' && typeof battle.data.getToken === 'function') { // an explicit inline def of the input entry (Battle `_tokenDef(id, owner, inp.def)`) is never replaced if (inputEntry(unit)?.def) return; const owner = unit.ownerUnit; syncUnitLoadout(battle, owner); if (!owner.def || !owner.def.loadout) return; const want = battle.data.getToken(unit.def.id, owner.def.id, owner.def.loadout); if (want && want !== unit.def && want.id === unit.def.id) swapDef(unit, want); } } /** Id of the selected skill of an operator def (null without a skill). */ const selectedSkillId = (def) => def?.skill?.id ?? def?.raw?.skill?.skillId ?? null; /** True when the def's selected skill is the chess's default skill (no loadout info ⇒ default). */ const skillIsDefault = (def) => !def?.loadout || def.loadout.skillIsDefault !== false; /** * The kit with the skill spec of the SELECTED skill (see header): `kit.skills[id]` → `kit.skill` (default skill only) * → the generic spec. Returns the kit itself when nothing changes; `kit.skillSource` tells which one was used * ('skills' | 'kit' | 'generic'). * @param {object} kit hand-authored kit */ export function selectSkillSpec(kit, bb, raw, def) { const id = selectedSkillId(def); const map = kit && kit.skills && typeof kit.skills === 'object' ? kit.skills : null; if (id && map && Object.prototype.hasOwnProperty.call(map, id)) return { ...kit, skill: map[id] ?? null, skillSource: 'skills' }; if (skillIsDefault(def)) return kit; const g = genericKit(bb, raw, def); const own = typeof kit.install === 'function' ? kit.install : null; const gen = typeof g.install === 'function' ? g.install : null; const out = { ...kit, skill: g.skill ?? null, skillSource: 'generic' }; if (own && gen) out.install = (battle, unit) => { own(battle, unit); gen(battle, unit); }; else if (gen) out.install = gen; return out; } /** * Whether the kit of `def` has a hand-authored spec for its selected skill (tools/kit-coverage.mjs, tests): `skills` * entry, or the kit's `skill` for the default skill when the kit is not the generic fallback. Never throws. * @returns {'skills'|'kit'|'generic'|'none'} where the spec comes from ('none' = no kit function at all) */ export function skillSpecSource(def, kits = KITS) { const bare = String(def?.baseId ?? def?.id ?? '').replace(/_[ab]$/, ''); const f = kits?.[def?.baseId] ?? kits?.[def?.id] ?? kits?.[bare]; if (typeof f !== 'function') return 'none'; let k = null; try { k = f(def.skill?.bb ?? {}, def.raw ?? def, def); } catch { return 'generic'; } if (!k || k.generic) return 'generic'; const id = selectedSkillId(def); if (id && k.skills && Object.prototype.hasOwnProperty.call(k.skills, id)) return 'skills'; return skillIsDefault(def) && k.skill !== undefined ? 'kit' : 'generic'; } /** * Install kits for every ally unit and (mode 'full') every domain module. First, a Battle constructed without the spec * path (server-run fields, bot rehearsals, tests) gets the same per-battle loadout data view createBattleFromSpec uses * (simdata withUnitLoadouts over its players' inputs), so summons spawned during the battle (`getToken(id, owner)`) * and every other id-only lookup resolve the owner's selected skill / module there too (DESIGN §16). */ export function installContent(battle, { mode = 'full', extra = null } = {}) { try { const view = withUnitLoadouts(battle.data, (battle.players || []).map((p) => p && p.input)); if (view && view !== battle.data) battle.data = view; } catch (e) { battle._handlerError?.('loadoutView', null, e); } for (const u of battle.allyUnits) { if (u.kit) continue; try { battle._setupUnit(u); } catch (e) { battle._handlerError('setupUnit', u, e); } } if (mode === 'full') { for (const [name, mod] of MODULES) { if (typeof mod.install !== 'function') continue; try { mod.install(battle); } catch (e) { battle._handlerError(`content:${name}`, null, e); } } } for (const m of extra || []) { try { (typeof m === 'function' ? m : m.install)?.(battle); } catch (e) { battle._handlerError('content:extra', null, e); } } } /** Prep-side registration of every domain module (server boot). */ export function registerAllMeta(registry) { for (const [name, mod] of MODULES) { if (typeof mod.registerMeta !== 'function') continue; try { mod.registerMeta(registry); } catch (e) { console.error(`[content] ${name}.registerMeta failed:`, e); } } }