- simdata: DataSource keeps `backups` (rawBackups follows the fallback chain); getStandIn = shared/standIn.js standInRecord normalised (the chess's ids / bonds / 特质 / tier, the stand-in's body, def.standInFor); a PRESET / DIY chess or a source without backups fields itself; the per-battle view maps id-only lookups. - spec.js keeps `standIn` only when exactly true (never on a token); Battle passes it to getChess; syncUnitLoadout carries it; UnitInfo `standInFor`. - content/index.js kitOf: a stand-in's kit is KITS[def.charId] only; no default skill (a stand-in kit writes `skills`); without a kit the generic kit plus genericTalents. kits/index.js STANDIN_KIT_FILES / STANDIN_KITS, merged after the tier groups (old keys keep their order; golden unchanged). - server/data.js and tools/setup.mjs expect backups.json. - test/content/standin.test.js: the plumbing and the eight 预备干员 on all 28 chess records they replace.
255 lines
14 KiB
JavaScript
255 lines
14 KiB
JavaScript
// 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/index.js: one file per kit in kits/ops/) 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).
|
|
// 补位 stand-ins (isStandInDef: the def of simdata getStandIn — the chess's ids, another character's body): the kit is
|
|
// kits[def.charId] (the stand-in's own, kits/ops/standin-<codename>.js), never the chess id's — that is the replaced
|
|
// operator's kit; without one, the generic kit plus the talents it can apply exactly (generic.js genericTalents).
|
|
// A stand-in has no default skill: its kit authors every skill it supports in `skills` (selectSkillSpec).
|
|
// 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, genericTalents } 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 KIT_REGISTRY = await safeImport('./kits/index.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, then the 补位 stand-in kits: stand-in charId → builder
|
|
* (kits/index.js; each kit file is loaded guarded too)
|
|
*/
|
|
export const KITS = KIT_REGISTRY.KITS ?? Object.freeze({});
|
|
|
|
/** 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]]));
|
|
|
|
/**
|
|
* Whether `def` is a 补位 stand-in: a chess fielded with another character's body (simdata getStandIn — `standInFor` =
|
|
* the replaced operator's charId, `charId` = the stand-in's, which differ).
|
|
*/
|
|
export function isStandInDef(def) {
|
|
return !!(def && def.standInFor && def.charId && def.charId !== def.standInFor);
|
|
}
|
|
|
|
/**
|
|
* The kit builder of an operator def in `reg` ({ [key]: (bb, chess, def) => Kit }), or undefined. A stand-in's kit is
|
|
* keyed by its charId (`char_609_acguad`) and by nothing else: the chess id it keeps names the replaced operator's kit.
|
|
* Any other def: baseId (`…_a`, data/SIM.md), the exact id, or the suffix-less id of DESIGN §5.6's example.
|
|
*/
|
|
export function kitOf(def, reg) {
|
|
if (!def || !reg) return undefined;
|
|
if (isStandInDef(def)) return Object.prototype.hasOwnProperty.call(reg, def.charId) ? reg[def.charId] : undefined;
|
|
const bare = String(def.baseId ?? def.id ?? '').replace(/_[ab]$/, '');
|
|
return reg[def.baseId] ?? reg[def.id] ?? reg[bare];
|
|
}
|
|
|
|
/** The generic kit of an operator def; a stand-in's also carries the talents generic.js can apply exactly. */
|
|
function fallbackKit(bb, raw, def) {
|
|
const k = genericKit(bb, raw, def);
|
|
if (!isStandInDef(def)) return k;
|
|
const talents = genericTalents(def);
|
|
return talents.length ? { ...k, talents: [...(k.talents ?? []), ...talents] } : k;
|
|
}
|
|
|
|
/** 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) {
|
|
const f = kitOf(def, injected) ?? (mode === 'full' ? kitOf(def, 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:${isStandInDef(def) ? def.charId : def.baseId}`, unit, e);
|
|
}
|
|
}
|
|
}
|
|
return fallbackKit(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). `standIn: true` rides along (补位: the stand-in def whatever the skill / module fields say).
|
|
* Null when the unit has no input entry.
|
|
*/
|
|
function inputLoadout(unit) {
|
|
const x = inputEntry(unit);
|
|
if (!x) return null;
|
|
const standIn = x.standIn === true ? { standIn: true } : null;
|
|
if (x.skillIndex == null && x.moduleId == null) return standIn ?? {};
|
|
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null, ...standIn };
|
|
}
|
|
|
|
/** 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). Never for a stand-in:
|
|
* the skill it fields is the one its chess names (S2 on one chess, S3 on another), so its kit authors each in `skills`.
|
|
*/
|
|
const skillIsDefault = (def) => !isStandInDef(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;
|
|
* never for a stand-in) → 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 f = kitOf(def, kits);
|
|
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); }
|
|
}
|
|
}
|