Files
ark-stronghold-protocol/server/sim/content/index.js
T
sganggs ec0a2fc669 feedback7 R22-LOADOUT: the battle takes each operator's potential and 练度 from its spec entry; golden: unchanged
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.
2026-10-08 20:38:08 +08:00

277 lines
15 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.
// 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); }
}
}