新增:导出 / 导入干员调配 (#14)

This commit is contained in:
shanzhaikabi authored and GitHub committed 2026-10-04 14:20:02 +08:00
1 parent e90e7a30fc
commit 502f183e43
9 files changed
+395 -33

No files matched your search

+63 -1
View File
@@ -30,6 +30,8 @@ export const ATTR_LABEL = Object.freeze({
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 -----------------------------------------------------------------------------------------------------
@@ -44,7 +46,7 @@ export function parseStored(raw) {
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;
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;
@@ -56,6 +58,66 @@ export function parseStored(raw) {
/** 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: '内容过长,无法导入' };
const text = raw.trim();
if (!text) return { ok: false, error: '没有可导入的内容' };
try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; }
}
if (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
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: `这份调配来自更新的版本(v${v}),请先更新游戏` };
const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: '这不是干员调配的数据' };
const entries = parseStored(raw);
if (!Object.keys(entries).length) return { ok: false, error: '里面没有有效的调配条目' };
return { ok: true, entries };
}
// ---- options & choices ---------------------------------------------------------------------------------------------
/**