Files
ark-stronghold-protocol/shared/loadoutRecord.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

118 lines
6.7 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.
// shared/loadoutRecord.js — operator loadouts (DESIGN §16, DATA.md §2.2): a data/chess.json record as the selected
// skill / module make it. Pure ESM shared by the simulation (server/sim/simdata.js re-exports it: getChess(id, loadout)
// builds unit defs from it) and the client UI (the detail card shows the stats / 特性 / talents the unit fights with —
// user playtest #2 integration: an elite on 不装备 showed its default module's ATK and trait). One implementation, so
// the card and the battle never disagree. (Which choices a player may make: shared/protocol.js loadoutOptions.)
/**
* Resolve a loadout against a chess record.
* @param {object|null} rec data/chess.json record
* @param {{ skillIndex?: number, moduleId?: string, skill?: number, module?: string }|null} [loadout]
* @returns {{ skillIndex: number|null, moduleId: string|null, skillIsDefault: boolean, moduleIsDefault: boolean,
* isDefault: boolean }|null} null without a record; `moduleId` null for chess without module choices
*/
export function resolveRecordLoadout(rec, loadout = null) {
if (!rec || typeof rec !== 'object') return null;
const skills = Array.isArray(rec.skills) ? rec.skills : null;
const defSkill = rec.skill && Number.isInteger(rec.skill.index) ? rec.skill.index : (skills?.find((s) => s && s.isDefault)?.index ?? null);
const lo = loadout && typeof loadout === 'object' ? loadout : {};
const wantSkill = lo.skillIndex ?? lo.skill;
const skillIndex = skills && Number.isInteger(wantSkill) && skills.some((s) => s && s.index === wantSkill) ? wantSkill : defSkill;
const mods = Array.isArray(rec.modules) ? rec.modules : null;
const defMod = mods ? (mods.find((m) => m && m.isDefault)?.uniEquipId ?? 'none') : null;
const wantMod = lo.moduleId ?? lo.module;
const moduleId = mods && (wantMod === 'none' || (typeof wantMod === 'string' && mods.some((m) => m && m.uniEquipId === wantMod))) ? wantMod : defMod;
const skillIsDefault = skillIndex === defSkill;
const moduleIsDefault = moduleId === defMod;
return { skillIndex, moduleId, skillIsDefault, moduleIsDefault, isDefault: skillIsDefault && moduleIsDefault };
}
const clean6 = (v) => (typeof v !== 'number' || !Number.isFinite(v) || Number.isInteger(v) || Math.abs(v) >= 1e6 ? v : Math.round(v * 1e6) / 1e6);
/** Stats with a module: the no-module `statsBase` + the module's flat `attr` (same arithmetic as tools/build-data.mjs). */
export function composeStats(statsBase, attr) {
const s = { ...(statsBase || {}) };
for (const [f, v] of Object.entries(attr || {})) s[f] = clean6((s[f] || 0) + v);
return s;
}
/**
* Talents with a module: apply ModuleRecord.talentChanges to the no-module talents — the merge rule of
* tools/build-data.mjs mergeTalentChanges (override of an existing index: module values win, base keys the module does
* not restate are kept; otherwise appended; empty placeholders dropped).
*/
export function composeTalents(base, changes) {
const talents = (base || []).map((t) => ({ ...t }));
for (const ch of changes || []) {
const { talentIndex, ...rest } = ch;
const rec = { index: talentIndex, ...rest, fromModule: true };
const at = talentIndex >= 0 ? talents.findIndex((x) => x.index === talentIndex) : -1;
if (at >= 0) {
const old = talents[at];
talents[at] = {
...rec,
name: rec.name || old.name, desc: rec.desc ?? old.desc, descRaw: rec.descRaw ?? old.descRaw,
bb: { ...old.bb, ...rec.bb }, bbStr: { ...old.bbStr, ...rec.bbStr },
rangeGrid: rec.rangeGrid || old.rangeGrid, tokenKey: rec.tokenKey || old.tokenKey,
hidden: old.hidden && rec.hidden,
};
} else {
talents.push(rec);
}
}
return talents.filter((t) => t.name || t.desc || Object.keys(t.bb || {}).length || t.tokenKey);
}
/**
* The chess record as the selected loadout makes it (a new object; the input is never mutated): `skill` = the selected
* SkillRecord; golden chess with a non-default module choice: `stats` = statsBase + module attr, `trait` = the module's
* traitOverride or traitBase, `talents` = talentsBase + talentChanges, `module` = the chosen module (`active:false`,
* id null for 'none'). A talent that summons through a container token (凛御银灰) follows the selected skill's token.
* The default loadout returns `rec` itself.
* @param {object} rec data/chess.json record
* @param {object} lo resolveRecordLoadout(rec, …)
*/
export function loadoutRecord(rec, lo) {
if (!rec || !lo || lo.isDefault) return rec;
const out = { ...rec };
if (!lo.moduleIsDefault && Array.isArray(rec.modules)) {
const m = lo.moduleId === 'none' ? null : rec.modules.find((x) => x.uniEquipId === lo.moduleId) ?? null;
out.stats = composeStats(rec.statsBase ?? rec.stats, m ? m.attr : null);
out.trait = (m && m.traitOverride) || rec.traitBase || rec.trait;
out.talents = composeTalents(rec.talentsBase ?? rec.talents, m ? m.talentChanges : null);
out.module = m
? { id: m.uniEquipId, name: m.name ?? null, type: m.typeName ?? null, level: m.level ?? rec.module?.level ?? 0, active: true }
: { id: null, name: null, type: null, level: rec.module?.level ?? 0, active: false };
}
if (!lo.skillIsDefault && Array.isArray(rec.skills)) {
const s = rec.skills.find((x) => x.index === lo.skillIndex);
if (s) {
out.skill = s;
if (rec.assets) out.assets = { ...rec.assets, skillIcon: s.iconId ?? rec.assets.skillIcon };
const tok = s.overrideTokenKey;
if (tok && (rec.tokens || []).includes(tok) && (out.talents || []).some((t) => t && t.containerTokenKey)) {
out.talents = out.talents.map((t) => (t && t.containerTokenKey ? { ...t, tokenKey: tok } : t));
}
}
}
return out;
}
/**
* The attack range a (loadout-resolved) chess record fights with: an elite whose equipped module reads "攻击范围扩大"
* uses that module's own grid — its range-only talent change (talentIndex −1), e.g. SPC-X = the 3×3 caster range + the
* centre tile [0,3] — as the kits do (tier4 moduleRangeGrid, tier5 moduleRangeUp); anything else its `rangeGrid`.
* @param {object|null} rec loadoutRecord(…) output (or a data/chess.json record: its default module)
* @returns {number[][]|null}
*/
export function attackRangeGrid(rec) {
if (!rec || typeof rec !== 'object') return null;
const m = rec.module;
if (rec.isGolden && m && m.active && m.id && /攻击范围扩大/.test(String(rec.trait?.moduleDesc ?? ''))) {
const mod = (Array.isArray(rec.modules) ? rec.modules : []).find((x) => x && x.uniEquipId === m.id);
const g = (mod?.talentChanges || []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
if (g) return g;
}
return Array.isArray(rec.rangeGrid) ? rec.rangeGrid : null;
}