新增:导出 / 导入干员调配 (#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

+35
View File
@@ -0,0 +1,35 @@
// Clipboard helper shared by the room screen (invite code / link) and 干员调配 (导出 / 导入 payloads).
//
// Lives here rather than in a screen: `screens/room.js` imports `screens/loadout.js` (the 干员调配 button), so loadout
// importing the helper back from room would be a cycle.
/**
* Copy text to the clipboard (async API with a textarea fallback for insecure contexts).
* @param {string} text
* @returns {Promise<boolean>}
*/
export async function copyText(text) {
try {
if (globalThis.navigator?.clipboard && globalThis.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* fall through */ }
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
// 16 px: iOS zooms into smaller focused fields; `readonly` keeps the keyboard away
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;font-size:16px';
document.body.appendChild(ta);
ta.select();
// iOS Safari ignores select() on a textarea: an explicit range is what it copies (LAN play over http has no
// navigator.clipboard, so this path is the one iPhones / iPads take)
try { ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ }
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch {
return false;
}
}
+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 ---------------------------------------------------------------------------------------------
/**
+17
View File
@@ -40,6 +40,23 @@ export function setEntries(entries) {
loadoutStore.set({ entries: next });
}
/**
* Apply a parsed entry map (an imported preset). Sanitised against the loaded data first, then persisted and synced
* like any ordinary edit — so a preset from another build never sends the server an entry it would refuse. An import
* that keeps nothing (every chess unknown, or every choice already the default) changes NOTHING: wiping the current
* loadout over it would be a loss the player never asked for.
* @param {Record<string, any>} entries `parseImport(...).entries`
* @param {(id: string) => any} lookup chess lookup
* @returns {{ applied: number, dropped: number }} entries kept / entries that were not imported
*/
export function applyLoadoutEntries(entries, lookup) {
const asked = Object.keys(entries || {}).length;
const clean = sanitizeEntries(entries, lookup);
const applied = Object.keys(clean).length;
if (applied) setEntries(clean);
return { applied, dropped: Math.max(0, asked - applied) };
}
/** Open the 干員调配 screen. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel] */
export function openLoadout(from = 'lobby', sel = null) {
data.load('chess');