Files
ark-stronghold-protocol/public/js/ui/loadoutModel.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

303 lines
14 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.
// Operator loadout model (DESIGN §16) — pure logic of the 干员调配 screen (screens/loadout.js), shared with the sync
// (ui/loadoutSync.js) and usable by the in-match UI (shop cards / detail panel: `effectiveChoice`, `selectedSkill`).
//
// The per-browser loadout is `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`, persisted in
// localStorage (`sp.pref.loadout` = { v: 1, entries }) and sent with C2S `room.loadout { entries }`. Only choices that
// differ from the chess's defaults are kept. The legal choices come from data/chess.json (DESIGN §16): every chess has
// `skills[]` (SkillRecord at its own skill level — the normal chess Lv4, the elite Lv7) and elites `modules[]`
// (ModuleRecord: uniEquipId, name, typeName, attr, traitOverride, talentChanges, isDefault) — while the data lacks
// them only the default skill / module is offered. The option rules are shared with the server
// (shared/protocol.js loadoutOptions / checkLoadout), so a sanitised loadout is always accepted.
import { loadoutOptions, checkLoadout, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
export { MODULE_NONE };
/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
export const LOADOUT_PREF = 'loadout';
export const LOADOUT_VERSION = 1;
export const PROF_ORDER = ['PIONEER', 'WARRIOR', 'TANK', 'SNIPER', 'CASTER', 'MEDIC', 'SUPPORT', 'SPECIAL'];
export const PROF_NAME = Object.freeze({ PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种' });
export const SP_TYPE = Object.freeze({ INCREASE_WITH_TIME: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动', 8: '被动' });
/** Module attribute keys (ModuleRecord.attr / battle_equip attributeBlackboard) → label + unit. */
export const ATTR_LABEL = Object.freeze({
maxHp: ['生命上限', ''], max_hp: ['生命上限', ''], atk: ['攻击力', ''], def: ['防御力', ''], res: ['法术抗性', ''],
magic_resistance: ['法术抗性', ''], aspd: ['攻击速度', ''], attack_speed: ['攻击速度', ''], cost: ['部署费用', ''],
blockCnt: ['阻挡数', ''], block_cnt: ['阻挡数', ''], respawnTime: ['再部署时间', '秒'], respawn_time: ['再部署时间', '秒'],
baseAttackTime: ['攻击间隔', '秒'], base_attack_time: ['攻击间隔', '秒'], moveSpeed: ['移动速度', ''], hpRecoveryPerSec: ['每秒回复', ''],
});
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const isInt = (v) => Number.isInteger(v);
// ---- storage -----------------------------------------------------------------------------------------------------
/**
* Parse a stored loadout (any junk → {}): keeps structurally valid entries only (ids, skill ints, module ids).
* @param {any} raw `{ v, entries }` (or a bare entries map from an older build)
* @returns {Record<string, { skill?: number, module?: string }>}
*/
export function parseStored(raw) {
const src = isObj(raw) && isObj(raw.entries) ? raw.entries : isObj(raw) && raw.v == null ? raw : null;
const out = {};
if (!src) return out;
for (const [id, e] of Object.entries(src)) {
if (Object.keys(out).length >= LOADOUT_LIMITS.entries) break;
if (!/^[A-Za-z0-9_\-.:]{1,64}$/.test(id) || !isObj(e)) continue;
const x = {};
if (isInt(e.skill) && e.skill >= 0 && e.skill <= LOADOUT_LIMITS.skillIndex) x.skill = e.skill;
if (typeof e.module === 'string' && /^[A-Za-z0-9_\-.:]{1,64}$/.test(e.module)) x.module = e.module;
if (Object.keys(x).length) out[id] = x;
}
return out;
}
/** Serialised form for localStorage. */
export const toStored = (entries) => ({ v: LOADOUT_VERSION, entries: entries || {} });
// ---- options & choices ---------------------------------------------------------------------------------------------
/**
* The chess records of one loadout slot.
* @param {string} baseId normal chess id
* @param {(id: string) => any} getChess
* @returns {{ base: any, golden: any }}
*/
export function recordsOf(baseId, getChess) {
const base = getChess(baseId) || null;
const golden = base && base.goldenId ? getChess(base.goldenId) || null : null;
return { base, golden };
}
/** SkillRecord of a chess record by skill index (data `skills[]`, else the default `skill`). */
export function skillRecord(chess, index) {
if (!chess) return null;
if (Array.isArray(chess.skills)) {
const s = chess.skills.find((x) => x && x.index === index);
if (s) return s;
}
return chess.skill && chess.skill.index === index ? chess.skill : null;
}
/** ModuleRecord of an elite by uniEquipId (data `modules[]`, else a minimal record from the default `module`). */
export function moduleRecord(golden, id) {
if (!golden || !id || id === MODULE_NONE) return null;
if (Array.isArray(golden.modules)) {
const m = golden.modules.find((x) => x && x.uniEquipId === id);
if (m) return m;
}
const d = golden.module;
return d && d.id === id ? { uniEquipId: d.id, name: d.name, typeName: d.type, isDefault: true, attr: null, traitOverride: null, talentChanges: [] } : null;
}
/**
* Everything the screen shows for one chess: its skill options (normal Lv4 + elite Lv7 records) and module options.
* @param {any} base normal chess record
* @param {any} golden elite record (or null)
*/
export function chessOptions(base, golden) {
const opt = loadoutOptions(base, golden);
const skills = opt.skills.map((index) => ({
index,
normal: skillRecord(base, index),
elite: skillRecord(golden, index),
isDefault: index === opt.defaultSkill,
}));
const modules = opt.modules.map((id) => ({
id,
rec: moduleRecord(golden, id),
isDefault: id === opt.defaultModule,
}));
return { ...opt, skillOptions: skills, moduleOptions: modules };
}
/**
* The effective choice of a chess under a stored loadout (defaults for missing / unavailable choices).
* @returns {{ skill: number|null, module: string|null, changed: boolean }}
*/
export function effectiveChoice(entries, base, golden) {
const opt = loadoutOptions(base, golden);
const e = base && entries && Object.hasOwn(entries, base.chessId) ? entries[base.chessId] : null;
const skill = e && opt.skills.includes(e.skill) ? e.skill : opt.defaultSkill;
const module = golden ? (e && opt.modules.includes(e.module) ? e.module : opt.defaultModule) : null;
return { skill, module, changed: skill !== opt.defaultSkill || module !== opt.defaultModule };
}
/**
* Set (part of) one chess's choice; an entry equal to the defaults is removed. Returns a new entries map.
* @param {Record<string, any>} entries
* @param {any} base @param {any} golden
* @param {{ skill?: number, module?: string }} patch
*/
export function setChoice(entries, base, golden, patch) {
if (!base) return entries;
const opt = loadoutOptions(base, golden);
const cur = effectiveChoice(entries, base, golden);
const skill = patch && patch.skill !== undefined && opt.skills.includes(patch.skill) ? patch.skill : cur.skill;
const module = golden && patch && patch.module !== undefined && opt.modules.includes(patch.module) ? patch.module : cur.module;
const out = { ...(entries || {}) };
delete out[base.chessId];
const e = {};
if (skill !== opt.defaultSkill && skill != null) e.skill = skill;
if (golden && module !== opt.defaultModule && module != null) e.module = module;
if (Object.keys(e).length) out[base.chessId] = e;
return out;
}
/** Remove one chess's entry (恢复默认). */
export function resetChoice(entries, baseId) {
if (!entries || !Object.hasOwn(entries, baseId)) return entries;
const out = { ...entries };
delete out[baseId];
return out;
}
/**
* The entries to send (`room.loadout.entries`): every stored entry that is still legal for the loaded data, the
* rest dropped one by one (a stale browser loadout never gets the whole message refused). Defaults are dropped.
* @param {Record<string, any>} entries
* @param {(id: string) => any} getChess
* @returns {Record<string, { skill?: number, module?: string }>}
*/
export function sanitizeEntries(entries, getChess) {
const out = {};
for (const [id, e] of Object.entries(entries || {})) {
if (Object.keys(out).length >= LOADOUT_LIMITS.entries) break;
const one = {};
if (isInt(e?.skill)) one.skill = e.skill;
if (typeof e?.module === 'string') one.module = e.module;
if (!Object.keys(one).length) continue;
const res = checkLoadout({ [id]: one }, getChess);
if (!res.ok) {
// keep the part that is still legal (e.g. the skill when a module disappeared)
for (const k of ['skill', 'module']) {
if (one[k] === undefined) continue;
const r = checkLoadout({ [id]: { [k]: one[k] } }, getChess);
if (r.ok && r.loadout[id]) out[id] = { ...(out[id] || {}), [k]: one[k] };
}
continue;
}
if (res.loadout[id]) out[id] = one;
}
return out;
}
/** Selected SkillRecord of a board / shop chess under a loadout (the elite gets its Lv7 record). */
export function selectedSkill(loadout, chess, getChess) {
const r = resolveLoadout(loadout, chess, getChess);
return skillRecord(chess, r.skillIndex) || chess?.skill || null;
}
/** Selected ModuleRecord of an elite under a loadout (null: none / normal chess). */
export function selectedModule(loadout, chess, getChess) {
if (!chess || !chess.isGolden) return null;
const r = resolveLoadout(loadout, chess, getChess);
return moduleRecord(chess, r.moduleId);
}
// ---- roster & filters ------------------------------------------------------------------------------------------------
/**
* Visible normal chess (the loadout slots), in shop order: tier, then shopSortId.
* @param {any[]} list data.list('chess')
*/
/** Whether a chess record is a loadout slot (a visible normal chess — what the server's checkLoadout accepts). */
export const isLoadoutSlot = (c) => !!c && !c.isGolden && c.visible !== false && !c.isHidden && !c.isDiy && (!c.baseId || c.baseId === c.chessId);
export function rosterOf(list) {
return (Array.isArray(list) ? list : [])
.filter(isLoadoutSlot)
.sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0) || (a.shopSortId ?? 0) - (b.shopSortId ?? 0) || String(a.chessId).localeCompare(String(b.chessId)));
}
/**
* Apply the screen's filters.
* @param {any[]} roster rosterOf(...)
* @param {{ tier?: number|null, prof?: string|null, bond?: string|null, query?: string, changedOnly?: boolean }} f
* @param {Record<string, any>} entries stored loadout (for changedOnly)
* @param {(id: string) => any} getChess
* @param {(id: string) => any} [getBond] bond lookup (the search also matches bond names)
*/
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null) {
const q = String(f.query || '').trim().toLowerCase();
return roster.filter((c) => {
if (f.tier && c.tier !== f.tier) return false;
if (f.prof && c.profession !== f.prof) return false;
if (f.bond && !(Array.isArray(c.bonds) && c.bonds.includes(f.bond))) return false;
if (f.changedOnly) {
const golden = c.goldenId ? getChess(c.goldenId) : null;
if (!effectiveChoice(entries, c, golden).changed) return false;
}
if (q) {
const hay = [c.name, c.appellation, c.subProfessionName, PROF_NAME[c.profession], ...(c.bonds || []).map((b) => getBond(b)?.name)]
.filter(Boolean).join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
/** Number of chess whose choice differs from the defaults (only loadout slots of the loaded data count: an entry of a
* retired / hidden chess is never sent nor applied). */
export function changedCount(entries, getChess) {
let n = 0;
for (const id of Object.keys(entries || {})) {
const { base, golden } = recordsOf(id, getChess);
if (isLoadoutSlot(base) && base.chessId === id && effectiveChoice(entries, base, golden).changed) n++;
}
return n;
}
// ---- display helpers -----------------------------------------------------------------------------------------------------
/** "S2" style label of a skill index. */
export const skillLabel = (index) => (isInt(index) ? `S${index + 1}` : '—');
/** Short type badge of a module ("MAR-X" → "X", "ISW-α" → "α"); 'none' → "—". */
export function moduleBadge(rec, id = null) {
if (!rec) return id === MODULE_NONE || id == null ? '—' : '?';
const t = String(rec.typeName || rec.type || '');
const m = t.match(/-([^-\s]+)$/);
return m ? m[1] : t.slice(-1) || '?';
}
/**
* Module stat bonus as display rows (non-zero entries only).
* @param {Record<string, number> | null | undefined} attr
* @returns {Array<{ key: string, label: string, text: string, positive: boolean }>}
*/
export function attrRows(attr) {
const out = [];
if (!isObj(attr)) return out;
for (const [k, v] of Object.entries(attr)) {
if (typeof v !== 'number' || !Number.isFinite(v) || v === 0) continue;
const [label, unit] = ATTR_LABEL[k] || [k, ''];
const n = Math.abs(v) < 10 && !Number.isInteger(v) ? Number(v.toFixed(2)) : Math.round(v);
out.push({ key: k, label, text: `${v > 0 ? '+' : ''}${n}${unit}`, positive: k === 'cost' || k === 'respawnTime' || k === 'respawn_time' || k === 'baseAttackTime' || k === 'base_attack_time' ? v < 0 : v > 0 });
}
return out;
}
/**
* Tags of a SkillRecord: SP recovery, SP numbers, duration / ammo, charges.
* @returns {{ sp: string, spKind: 'time'|'atk'|'def'|'passive', init: number|null, cost: number|null, duration: string|null, charges: number|null, passive: boolean }}
*/
export function skillTags(rec) {
if (!rec) return { sp: '—', spKind: 'time', init: null, cost: null, duration: null, charges: null, passive: false };
const passive = rec.skillType === 'PASSIVE' || rec.spType === 'ON_DEPLOY' || rec.spType === 8;
const spKind = passive ? 'passive' : rec.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : rec.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time';
let duration = null;
if (rec.durationType === 'AMMO') duration = '弹药';
else if (Number(rec.duration) > 0) duration = `${Number(rec.duration)}秒`;
return {
sp: SP_TYPE[rec.spType] || (passive ? '被动' : '技力'),
spKind,
init: passive ? null : Number.isFinite(rec.initSp) ? rec.initSp : 0,
cost: passive ? null : Number.isFinite(rec.spCost) ? rec.spCost : 0,
duration,
charges: Number(rec.maxChargeTime) > 1 ? Number(rec.maxChargeTime) : null,
passive,
};
}