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:
1 parent
96d9441ff1
commit
7a0937e3dd
10 files changed
+441
-61
No files matched your search
+117
-15
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user