feedback7 R22-LOADOUT: the browser stores, syncs and shows each operator's 潜能 / 练度 (cards at the player's settings); golden: unchanged

- ui/loadoutModel.js: `ops` beside the entries (sp.pref.loadout { v, entries, ops }; an older stored loadout has none =
  潜能 6, 精英2 Lv.60): parseStoredOps, opsOf / setOps / resetOps (defaults never stored), sanitizeOps (only what the
  server keeps: the roster and the 自选 owned pool); 导出 carries `ops`, 导入 replaces the settings when the payload has
  them (an older export leaves them alone; settings alone import); the 已调整 count and 仅看已调整 count them.
- ui/loadoutSync.js: the store's `ops` (setOpsMap persists both); the loadout sync watches entries and ops and sends
  room.loadout { entries, ops } (the ops sanitised against chess.json + backups.json; nothing set: no download);
  openLoadout loads effects.json (the 练度 multipliers).
- Cards: gameLogic chessLoadout(chess, loadout, getChess, { ops | cultivation, effects }) composes the record at the
  operator's potential and multiplies its stats by the 练度 tier (the player's m.private.ops — shop / hand / board cards
  and the detail panel; a teammate's unit its UnitInfo potential / cultivate: unitCultivation; a teammate's bond-popup
  card the defaults, never the viewer's settings; a stand-in neither); a 自选 card is composed at its pick's potential
  (ownDiyPick from m.private.ops, unitPick from UnitInfo).
- Tests: test/ui/potential-ui.test.js (new, 7): storage, helpers, 导出 / 导入, the 已调整 count, the sync (sanitised ops,
  settings-only edits, no download when none), the cards (刺玫 413 / 17 at 潜能1, ×1.05 at 精英1, a teammate's unit, a
  stand-in), 自选 cards at 潜能1 (凯尔希 392 / 20) and the 练度 on top; test/ui/loadout.test.js expects the envelope's
  `ops` and room.loadout's `ops: {}`.
This commit is contained in:
sganggs committed 2026-10-08 21:01:09 +08:00
1 parent 96d9441ff1
commit 7a0937e3dd
10 files changed
+441 -61

No files matched your search

+117 -15
View File
@@ -8,8 +8,13 @@
// (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.
// 潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08): `ops` = `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` —
// per operator, so the normal, elite and 自选 forms share them; only values that differ from the defaults (潜能 6,
// 精英2 Lv.60) are kept; stored beside the entries (`sp.pref.loadout` = { v: 1, entries, ops }; an older stored loadout
// has none = the defaults) and sent with `room.loadout { entries, ops }` (shared/protocol.js checkLoadoutOps).
import { loadoutOptions, checkLoadout, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
import { loadoutOptions, checkLoadout, checkLoadoutOps, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
import { isPotential, isCultivate, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from '../../../shared/potential.js';
import { t, N_ } from '../../../shared/i18n.js';
export { MODULE_NONE };
@@ -56,8 +61,95 @@ export function parseStored(raw) {
return out;
}
/** Serialised form for localStorage. */
export const toStored = (entries) => ({ v: LOADOUT_VERSION, entries: entries || {} });
/**
* Parse the stored per-operator 潜能 / 练度 (0.2.2; any junk → {}): structurally valid entries only, defaults dropped.
* An older stored loadout (no `ops`) gives {} — every operator at the defaults.
* @param {any} raw `{ v, entries, ops }`
* @returns {Record<string, { potential?: number, cultivate?: number }>}
*/
export function parseStoredOps(raw) {
const src = isObj(raw) && isObj(raw.ops) ? raw.ops : null;
const out = {};
if (!src) return out;
for (const [id, e] of Object.entries(src)) {
if (Object.keys(out).length >= LOADOUT_LIMITS.ops) break;
if (UNSAFE_IDS.has(id) || !/^[A-Za-z0-9_\-.:]{1,64}$/.test(id) || !isObj(e)) continue;
const x = {};
if (isPotential(e.potential) && e.potential !== POTENTIAL_DEFAULT) x.potential = e.potential;
if (isCultivate(e.cultivate) && e.cultivate !== CULTIVATE_DEFAULT) x.cultivate = e.cultivate;
if (Object.keys(x).length) out[id] = x;
}
return out;
}
/** Serialised form for localStorage (`ops`: the per-operator 潜能 / 练度, 0.2.2). */
export const toStored = (entries, ops = {}) => ({ v: LOADOUT_VERSION, entries: entries || {}, ops: ops || {} });
// ---- 潜能 / 练度 (0.2.2) ------------------------------------------------------------------------------------------------
/**
* The effective 潜能 / 练度 of operator `charId` under the stored settings (the defaults for a missing entry or field).
* @param {Record<string, any>|null|undefined} ops @param {string|null|undefined} charId
* @returns {{ potential: number, cultivate: number, changed: boolean }}
*/
export function opsOf(ops, charId) {
const e = charId && isObj(ops) && Object.hasOwn(ops, charId) && isObj(ops[charId]) ? ops[charId] : null;
const potential = e && isPotential(e.potential) ? e.potential : POTENTIAL_DEFAULT;
const cultivate = e && isCultivate(e.cultivate) ? e.cultivate : CULTIVATE_DEFAULT;
return { potential, cultivate, changed: potential !== POTENTIAL_DEFAULT || cultivate !== CULTIVATE_DEFAULT };
}
/**
* Set (part of) one operator's 潜能 / 练度; an entry equal to the defaults is removed. Returns a new map.
* @param {Record<string, any>} ops @param {string} charId @param {{ potential?: number, cultivate?: number }} patch
*/
export function setOps(ops, charId, patch) {
if (typeof charId !== 'string' || !charId) return ops;
const cur = opsOf(ops, charId);
const potential = patch && isPotential(patch.potential) ? patch.potential : cur.potential;
const cultivate = patch && isCultivate(patch.cultivate) ? patch.cultivate : cur.cultivate;
const out = { ...(ops || {}) };
delete out[charId];
const e = {};
if (potential !== POTENTIAL_DEFAULT) e.potential = potential;
if (cultivate !== CULTIVATE_DEFAULT) e.cultivate = cultivate;
if (Object.keys(e).length) out[charId] = e;
return out;
}
/** Remove one operator's 潜能 / 练度 (恢复默认). */
export function resetOps(ops, charId) {
if (!ops || !Object.hasOwn(ops, charId)) return ops;
const out = { ...ops };
delete out[charId];
return out;
}
/**
* The settings to send (`room.loadout.ops`): every stored entry the server would keep for the loaded data (an operator
* of the 干员调配 roster or the 自选 owned pool, `isOperator` — shared/protocol.js cultivationCharIds), the rest dropped one
* by one; defaults dropped.
* @param {Record<string, any>} ops @param {(charId: string) => boolean} isOperator
* @returns {Record<string, { potential?: number, cultivate?: number }>}
*/
export function sanitizeOps(ops, isOperator) {
const out = {};
for (const [id, e] of Object.entries(ops || {})) {
if (Object.keys(out).length >= LOADOUT_LIMITS.ops) break;
const one = {};
if (isPotential(e?.potential)) one.potential = e.potential;
if (isCultivate(e?.cultivate)) one.cultivate = e.cultivate;
if (!Object.keys(one).length) continue;
const res = checkLoadoutOps({ [id]: one }, isOperator);
if (res.ok && res.ops[id]) {
const x = {};
if (res.ops[id].potential !== POTENTIAL_DEFAULT) x.potential = res.ops[id].potential;
if (res.ops[id].cultivate !== CULTIVATE_DEFAULT) x.cultivate = res.ops[id].cultivate;
out[id] = x;
}
}
return out;
}
// ---- export / import ----------------------------------------------------------------------------------------------
@@ -71,19 +163,23 @@ export const LOADOUT_EXPORT_KIND = 'stronghold.loadout';
export const LOADOUT_IMPORT_MAX_BYTES = 256 * 1024;
/**
* Portable payload of a loadout, as downloaded / copied by 导出.
* Portable payload of a loadout, as downloaded / copied by 导出 — with the per-operator 潜能 / 练度 (`ops`, 0.2.2; an
* older build reading it ignores the field).
* @param {Record<string, any>} entries `room.loadout.entries`
* @param {{ now?: number }} [o]
* @param {{ now?: number, ops?: Record<string, any> }} [o]
*/
export function exportPayload(entries, { now = Date.now() } = {}) {
export function exportPayload(entries, { now = Date.now(), ops = {} } = {}) {
const clean = {};
for (const [id, e] of Object.entries(entries || {})) if (isObj(e)) clean[id] = { ...e };
const cleanOps = {};
for (const [id, e] of Object.entries(ops || {})) if (isObj(e)) cleanOps[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,
ops: cleanOps,
};
}
@@ -97,8 +193,10 @@ export function serializeExport(entries, opts) {
* `{ [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).
* A payload with `ops` (0.2.2) brings the per-operator 潜能 / 练度 too (`ops` in the result — absent when the payload has
* none: an older export leaves the current settings alone); a payload of settings only imports.
* @param {any} input payload object or serialised text
* @returns {{ ok: true, entries: Record<string, any> } | { ok: false, error: string }}
* @returns {{ ok: true, entries: Record<string, any>, ops?: Record<string, any> } | { ok: false, error: string }}
*/
export function parseImport(input) {
let raw = input;
@@ -115,8 +213,9 @@ export function parseImport(input) {
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 };
const ops = isObj(raw.ops) ? parseStoredOps(raw) : null;
if (!Object.keys(entries).length && !(ops && Object.keys(ops).length)) return { ok: false, error: t('里面没有有效的调配条目') };
return ops ? { ok: true, entries, ops } : { ok: true, entries };
}
// ---- options & choices ---------------------------------------------------------------------------------------------
@@ -281,8 +380,9 @@ export function rosterOf(list) {
* @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)
* @param {Record<string, any>|null} [ops] the per-operator 潜能 / 练度 (changedOnly counts them too)
*/
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null) {
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null, ops = null) {
const q = String(f.query || '').trim().toLowerCase();
return roster.filter((c) => {
if (f.tier && c.tier !== f.tier) return false;
@@ -290,7 +390,7 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null
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 (!effectiveChoice(entries, c, golden).changed && !opsOf(ops, c.charId).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)]
@@ -302,12 +402,14 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null
}
/** 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) {
* retired / hidden chess is never sent nor applied) — with `ops` (0.2.2), also those whose operator's 潜能 / 练度 differ. */
export function changedCount(entries, getChess, ops = null, roster = null) {
const ids = new Set(Object.keys(entries || {}));
if (ops && Object.keys(ops).length && Array.isArray(roster)) for (const c of roster) if (opsOf(ops, c.charId).changed) ids.add(c.chessId);
let n = 0;
for (const id of Object.keys(entries || {})) {
for (const id of ids) {
const { base, golden } = recordsOf(id, getChess);
if (isLoadoutSlot(base) && base.chessId === id && effectiveChoice(entries, base, golden).changed) n++;
if (isLoadoutSlot(base) && base.chessId === id && (effectiveChoice(entries, base, golden).changed || opsOf(ops, base.charId).changed)) n++;
}
return n;
}