Files
ark-stronghold-protocol/public/js/ui/loadoutModel.js
T
sganggs 02353e13f8 feedback5 CB5: a module's added trait line (official additionalDescription, PRTS 特性追加) shows after the class trait, not instead of it — the 干员调配 module card (特性 + 特性追加 rows), its 局内数值, the detail card and the 自选 module row (community report of 2026-10-06, item 16.2: 114 of the 164 modules the screen offers showed the extra line alone)
Text only: data/chess.json keeps both lines (traitOverride.desc = the class trait or the module's rewrite,
moduleDesc = the added line) and the battle unit keeps the class trait with the module on — the same trait
text, targeting and attack shape as 不装备 for every added-line module (圣约送葬人 REA-Y also rewrites it).
New helpers ui/loadoutModel.js traitLines / fullTraitText; traitText (ui/detailPanel.js) composes the two.
Golden unchanged (no sim change). Tests: test/ui/feedback5-module-trait.test.js (new), loadout-stats updated.
2026-10-07 01:33:21 +08:00

389 lines
19 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';
import { t, N_ } from '../../../shared/i18n.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: N_('先锋'), WARRIOR: N_('近卫'), TANK: N_('重装'), SNIPER: N_('狙击'), CASTER: N_('术师'), MEDIC: N_('医疗'), SUPPORT: N_('辅助'), SPECIAL: N_('特种') });
export const SP_TYPE = Object.freeze({ INCREASE_WITH_TIME: N_('自动回复'), INCREASE_WHEN_ATTACK: N_('攻击回复'), INCREASE_WHEN_TAKEN_DAMAGE: N_('受击回复'), ON_DEPLOY: N_('被动'), 8: N_('被动') });
/** Module attribute keys (ModuleRecord.attr / battle_equip attributeBlackboard) → label + unit. */
export const ATTR_LABEL = Object.freeze({
maxHp: [N_('生命上限'), ''], max_hp: [N_('生命上限'), ''], atk: [N_('攻击力'), ''], def: [N_('防御力'), ''], res: [N_('法术抗性'), ''],
magic_resistance: [N_('法术抗性'), ''], aspd: [N_('攻击速度'), ''], attack_speed: [N_('攻击速度'), ''], cost: [N_('部署费用'), ''],
blockCnt: [N_('阻挡数'), ''], block_cnt: [N_('阻挡数'), ''], respawnTime: [N_('再部署时间'), N_('秒')], respawn_time: [N_('再部署时间'), N_('秒')],
baseAttackTime: [N_('攻击间隔'), N_('秒')], base_attack_time: [N_('攻击间隔'), N_('秒')], moveSpeed: [N_('移动速度'), ''], hpRecoveryPerSec: [N_('每秒回复'), ''],
});
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const isInt = (v) => Number.isInteger(v);
/** id keys a parsed payload must never inject into an entry map: `{ "__proto__": … }` would rewrite the prototype. */
const UNSAFE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
// ---- 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 (UNSAFE_IDS.has(id) || !/^[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 || {} });
// ---- export / import ----------------------------------------------------------------------------------------------
/**
* `kind` of an exported loadout envelope: what a downloaded file / a copied payload carries. `entries` is exactly
* `room.loadout.entries`, i.e. what `setEntries` + the sync already accept.
*/
export const LOADOUT_EXPORT_KIND = 'stronghold.loadout';
/** A picked file / pasted payload longer than this is refused before parsing (a real payload is a few KB). */
export const LOADOUT_IMPORT_MAX_BYTES = 256 * 1024;
/**
* Portable payload of a loadout, as downloaded / copied by 导出.
* @param {Record<string, any>} entries `room.loadout.entries`
* @param {{ now?: number }} [o]
*/
export function exportPayload(entries, { now = Date.now() } = {}) {
const clean = {};
for (const [id, e] of Object.entries(entries || {})) if (isObj(e)) clean[id] = { ...e };
return {
kind: LOADOUT_EXPORT_KIND,
v: LOADOUT_VERSION,
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
count: Object.keys(clean).length,
entries: clean,
};
}
/** Pretty JSON of `exportPayload` — one preset per file / clipboard payload. */
export function serializeExport(entries, opts) {
return JSON.stringify(exportPayload(entries, opts), null, 2);
}
/**
* Parse an imported loadout. Tolerant by design: the envelope, the stored `{ v, entries }` form and a bare
* `{ [chessId]: { skill, module } }` map all work, as does the serialised text of any of them. Parsing is STRUCTURAL
* only — the caller still runs `sanitizeEntries` against the loaded data, because a preset from another season may name
* chess / skills / modules this build does not have. `__proto__` / `constructor` keys are skipped (see parseStored).
* @param {any} input payload object or serialised text
* @returns {{ ok: true, entries: Record<string, any> } | { ok: false, error: string }}
*/
export function parseImport(input) {
let raw = input;
if (typeof raw === 'string') {
if (raw.length > LOADOUT_IMPORT_MAX_BYTES) return { ok: false, error: t('内容过长,无法导入') };
const text = raw.trim();
if (!text) return { ok: false, error: t('没有可导入的内容') };
try { raw = JSON.parse(text); } catch { return { ok: false, error: t('无法识别的内容') }; }
}
if (!isObj(raw)) return { ok: false, error: t('无法识别的格式') };
const v = isInt(raw.v) ? raw.v : null;
// a newer envelope may reshuffle fields — refuse instead of silently reading it as something else
if (v != null && v > LOADOUT_VERSION) return { ok: false, error: t('这份调配来自更新的版本(v{v}),请先更新游戏', { v }) };
const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: t('这不是干员调配的数据') };
const entries = parseStored(raw);
if (!Object.keys(entries).length) return { ok: false, error: t('里面没有有效的调配条目') };
return { ok: true, 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, t(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) || '?';
}
/**
* The two lines of a trait record (data `trait` / `traitBase`, ModuleRecord `traitOverride`, DATA.md §2): `base` = the
* 特性 the unit fights with — the class trait, or the module's own wording where the module rewrites it (official
* `overrideDescripton`) — and `added` = the module's extra line (official `additionalDescription`), or null. The extra
* line comes after the class trait, never instead of it: PRTS flags it 「特性追加」 on every such module, and the sim keeps
* the class trait with the module equipped (community report of 2026-10-06, item 16.2: until 0.2.0 the 干员调配 module
* card, its 局内数值 and the detail card showed the extra line alone on 114 of the 164 modules the screen offers).
* @param {any} trait
* @returns {{ base: string, added: string|null }}
*/
export function traitLines(trait) {
if (!isObj(trait)) return { base: '', added: null };
const base = String(trait.descRaw || trait.desc || '');
const added = trait.moduleDescRaw || trait.moduleDesc || null;
return { base, added: added ? String(added) : null };
}
/** The whole 特性 text of a trait record: its base line, then the module's extra line (`\n` between; RichText breaks it). */
export function fullTraitText(trait) {
const { base, added } = traitLines(trait);
return base && added ? `${base}\n${added}` : base || added || '';
}
/**
* 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: t(label), text: `${v > 0 ? '+' : ''}${n}${t(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 = t('弹药');
else if (Number(rec.duration) > 0) duration = t('{n}秒', { n: Number(rec.duration) });
return {
sp: t(SP_TYPE[rec.spType]) || (passive ? t('被动') : t('技力')),
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,
};
}