A PlayerBattleInput operator entry may carry `potential` (潜能 1–6) and `cultivate` (练度 0–3, the 自持有 bonus) —
the player's 干员调配 settings (the owner's decision of 2026-10-08). Specs without them field what 0.2.1 fielded, so
no golden scenario moves here; the match states both next.
- shared/loadoutRecord.js: a resolved loadout carries `potential`; loadoutRecord composes the record at it first
(shared/potential.js atPotential), then the skill / module as before.
- server/sim/simdata.js: getChess(id, { …, potential }), getDiy(slot, pick, potential) (an owned 自选 pick's form at it;
a prototype has none — `diyProto` on its record, shared/diy.js), getToken / getDiyToken compose the owner variant at
the owner's potential (talents; a 自选 summon's deploy limit / holding / count); the per-battle loadout view keeps it
per unit; DataSource.cultivateMul reads the effects.json CHAR_MAP record's char_attribute_mul.
- battle/players.js: `potential` composes the def (never a stand-in's); `cultivate` sets unit.cultMul — ×ATK / ×DEF /
×max HP in the unit's Πmul (units.js), a multiplier of its own, never summed with the 直接乘算 percentages (PRTS
盟约记录 「…与自持有干员的属性加成独立」); a stand-in, a prototype 自选 pick and a summon take none [ASSUMED for summons].
- spec.js keeps both only when well-formed (never on a stand-in or a summon); content/index.js passes the potential to
the loadout sync; the kits that read a summon variant raw (content/tokens.js ownVariant, summoner.js tokenStat,
多萝西, 麦克尼斯特) compose it at the owner's potential; UnitInfo carries `potential` (below 6) / `cultivate`;
unitStatsEntry counts the 练度 in the unit's own numbers (no green delta for it on the card).
- Docs: SIM.md §2 (Πmul), §9 (UnitInfo), §12.
- Tests: test/sim/potential-battle.test.js (new, 6): a unit at each potential (刺玫 413 / 17 / 70 s and 8 % at 潜能1;
the elite on 不装备), the four 练度 tiers and their product with a +20 % ATK bonus, HP at deployment and the card's
numbers, stand-in / prototype take neither and an owned 自选 pick both, Mon3tr / 望's 棋子 / “小自在” at the owner's
potential, the spec round trip (kept, dropped, the JSON spec gives the same result digest), two players with the same
chess at different settings on one field; test/sim/loadout.test.js expects the resolved loadout's new fields.
277 lines
15 KiB
JavaScript
277 lines
15 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).
|
||
// 自选 pieces (isDiyDef: the def of simdata getDiy — a DIY slot's ids, the picked operator's body) take their kit the
|
||
// same way: kits[def.charId] (an owned 6★'s kits/ops/op-<codename>.js, a prototype's standin-<codename>.js), else the
|
||
// generic kit plus genericTalents; no default skill either (the pick chooses any of the three).
|
||
// 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';
|
||
import { isPotential } from '../../../shared/potential.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);
|
||
}
|
||
|
||
/**
|
||
* Whether `def` is a 自选 piece: a DIY slot fielded with a picked operator's body (simdata getDiy — `diyFor` = the slot's
|
||
* base id, `charId` = the operator's).
|
||
*/
|
||
export function isDiyDef(def) {
|
||
return !!(def && def.diyFor && def.charId);
|
||
}
|
||
|
||
/** A def whose body is another character than its chess id names (补位 stand-in, 自选 piece): its kit is keyed by charId. */
|
||
const isOtherBody = (def) => isStandInDef(def) || isDiyDef(def);
|
||
|
||
/**
|
||
* The kit builder of an operator def in `reg` ({ [key]: (bb, chess, def) => Kit }), or undefined. A stand-in's and a
|
||
* 自选 piece's kit is keyed by its charId (`char_609_acguad`, `char_112_siege`) and by nothing else: the chess id it keeps
|
||
* names the replaced operator's kit (a DIY slot has none). 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 (isOtherBody(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 / 自选 piece's also carries the talents generic.js can apply exactly. */
|
||
function fallbackKit(bb, raw, def) {
|
||
const k = genericKit(bb, raw, def);
|
||
if (!isOtherBody(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:${isOtherBody(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), as
|
||
* do `diy` (自选: the pick of a DIY slot's piece) and `potential` (0.2.2: 1–6, never a stand-in's). Null when the unit
|
||
* has no input entry.
|
||
*/
|
||
function inputLoadout(unit) {
|
||
const x = inputEntry(unit);
|
||
if (!x) return null;
|
||
const extra = {
|
||
...(x.standIn === true ? { standIn: true } : null),
|
||
...(x.diy && typeof x.diy === 'object' ? { diy: x.diy } : null),
|
||
...(x.standIn !== true && isPotential(x.potential) ? { potential: x.potential } : null),
|
||
};
|
||
if (x.skillIndex == null && x.moduleId == null) return extra;
|
||
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null, ...extra };
|
||
}
|
||
|
||
/** 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`;
|
||
* nor for a 自选 piece (the pick chooses any of its skills).
|
||
*/
|
||
const skillIsDefault = (def) => !isOtherBody(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); }
|
||
}
|
||
}
|