From 502f183e43a0064e289900a65713ac44884a66b5 Mon Sep 17 00:00:00 2001
From: shanzhaikabi <31912061+shanzhaikabi@users.noreply.github.com>
Date: Sun, 4 Oct 2026 14:20:02 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=AF=BC=E5=87=BA?=
=?UTF-8?q?=20/=20=E5=AF=BC=E5=85=A5=E5=B9=B2=E5=91=98=E8=B0=83=E9=85=8D?=
=?UTF-8?q?=20(#14)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/DESIGN.md | 2 +
public/css/screens/loadout.css | 21 +++++++
public/js/screens/loadout.js | 104 +++++++++++++++++++++++++++++++--
public/js/screens/room.js | 30 ++--------
public/js/ui/clipboard.js | 35 +++++++++++
public/js/ui/loadoutModel.js | 64 +++++++++++++++++++-
public/js/ui/loadoutSync.js | 17 ++++++
test/ui/loadout.e2e.test.js | 81 +++++++++++++++++++++++++
test/ui/loadout.test.js | 74 ++++++++++++++++++++++-
9 files changed, 395 insertions(+), 33 deletions(-)
create mode 100644 public/js/ui/clipboard.js
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index dc98e29a..6b6e8113 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -597,6 +597,8 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
+**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries }` — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
+
---
## 17. User playtest #3 (v2.2): models, picking, FX, rules, HUD
diff --git a/public/css/screens/loadout.css b/public/css/screens/loadout.css
index bc163e6d..dfd7f234 100644
--- a/public/css/screens/loadout.css
+++ b/public/css/screens/loadout.css
@@ -377,3 +377,24 @@
.sp-coarse .lo-chip, .sp-coarse .lo-toggle, .sp-coarse .lo-select { min-height: 30px; }
.sp-coarse .lo-seg button { min-height: 28px; }
@media (prefers-reduced-motion: reduce) { .lo, .lo-card { animation: none; transition: none; } }
+
+/* 导出 / 导入 (干员调配 presets): the dialog is the shared Modal, rendered NEXT TO `.lo` (a sibling) rather than inside
+ it — so components.css `.modal { position: fixed }` and its z-index above the overlay apply as they do everywhere
+ else, and `.lo > *` needs no exception for it. Below: the payload textarea + the hidden input behind 选择文件. */
+.lo-io__hint { margin: 0 0 max(.1rem, 8px); font-size: max(.18rem, 12px); line-height: 1.55; color: var(--text-md); }
+.lo-io__hint b { color: var(--mint-400); font-size: max(.22rem, 13px); margin: 0 .02rem; }
+.lo-io__hint strong { color: var(--gold); }
+.lo-io__text {
+ box-sizing: border-box; width: 100%; height: max(2.4rem, 160px); min-height: 120px; resize: vertical;
+ padding: max(.1rem, 8px) max(.12rem, 10px);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ /* 16 px: iOS zooms into a focused field below that (same reason as ui/clipboard.js) */
+ font-size: max(.2rem, 16px); line-height: 1.45;
+ color: var(--text-hi); background: rgba(4, 10, 8, .55);
+ border: 1px solid var(--line-2); border-radius: 2px; outline: none;
+ /* wrap instead of a horizontal scrollbar: phones are narrow and the payload is meant to be read/copied whole */
+ white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto;
+}
+.lo-io__text:focus { border-color: var(--mint-700); }
+.lo-io__text::placeholder { color: var(--text-lo); }
+.lo-io__file { display: none; }
diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js
index dafc36be..72f99e35 100644
--- a/public/js/screens/loadout.js
+++ b/public/js/screens/loadout.js
@@ -15,7 +15,7 @@
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field.
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
-import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline } from '../ui/components.js';
+import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
import { chessStatsBlock, traitText, chessTalents } from '../ui/detailPanel.js';
@@ -25,15 +25,54 @@ import { useStore } from '../store.js';
import { PHASE } from '../../../shared/constants.js';
import {
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
- changedCount, skillLabel, moduleBadge, attrRows, skillTags,
+ changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
} from '../ui/loadoutModel.js';
-import { loadoutStore, openLoadout, closeLoadout, setEntries } from '../ui/loadoutSync.js';
+import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries } from '../ui/loadoutSync.js';
+import { copyText } from '../ui/clipboard.js';
+import { toast } from '../ui/toasts.js';
export { openLoadout, closeLoadout };
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
+// ---- export / import (干员调配 presets) -----------------------------------------------------------------------------
+//
+// The payload is the versioned envelope of ui/loadoutModel.js (exportPayload / parseImport): a downloaded file and a
+// pasted string are the SAME object, so 导出 and 导入 both funnel through applyLoadoutEntries
+// (sanitise → persist → room.loadout). The dialog is a shared Modal rendered next to the overlay, not inside it.
+
+/** Save `text` as a download. Silent no-op when the browser refuses downloads — 复制 stays available. */
+function downloadText(filename, text) {
+ try {
+ const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ } catch { /* ignore */ }
+}
+
+/** Read a picked file as text (`File.text()`, with a FileReader fallback for older Safari). */
+function readFileText(file) {
+ if (typeof file?.text === 'function') return file.text();
+ return new Promise((resolve, reject) => {
+ const fr = new FileReader();
+ fr.onload = () => resolve(String(fr.result ?? ''));
+ fr.onerror = () => reject(fr.error || new Error('read failed'));
+ fr.readAsText(file);
+ });
+}
+
+/** `stronghold-loadout-20261003-1245.json` */
+function exportFilename(now = new Date()) {
+ const p = (n) => String(n).padStart(2, '0');
+ return `stronghold-loadout-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
+}
+
/** Square profession glyph (manifest prof.large: black glyph on white, drawn as a white glyph by loadout.css). */
function profGlyphUrl(m, prof) {
const k = String(prof || '').toLowerCase();
@@ -358,7 +397,9 @@ function LoadoutScreen({ st }) {
const nChanged = changedCount(st.entries, getChess);
const locked = (inMatch && phase && phase !== PHASE.INFO_CHECK && phase !== PHASE.LOBBY) || st.sync === 'locked';
const gridRef = useRef(null);
+ const fileRef = useRef(null); // hidden of the 导入 dialog
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
+ const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
@@ -369,6 +410,38 @@ function LoadoutScreen({ st }) {
if (ok) setEntries({});
};
+ // 导出 / 导入 the loadout as the versioned payload (a downloaded file, the clipboard, or the textarea)
+ const ioText = io?.text ?? '';
+ const openExport = () => setIo({ mode: 'export', text: serializeExport(loadoutStore.get().entries) });
+ const openImport = () => setIo({ mode: 'import', text: '' });
+ const ioCopy = async () => {
+ const ok = await copyText(ioText);
+ toast(ok ? '已复制到剪贴板' : '复制失败,请在文本框中手动全选复制', ok ? 'success' : 'warn');
+ };
+ const ioDownload = () => downloadText(exportFilename(), ioText);
+ const ioPick = () => fileRef.current?.click();
+ const ioFile = async (e) => {
+ const f = e.currentTarget.files && e.currentTarget.files[0];
+ e.currentTarget.value = ''; // picking the same file twice must fire again
+ if (!f) return;
+ // refuse a huge pick before reading it into memory (a real payload is a few KB)
+ if (f.size > LOADOUT_IMPORT_MAX_BYTES) { toast('文件过大,请选择「导出」下载的调配文件', 'error'); return; }
+ try { setIo({ mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); }
+ };
+ const ioApply = () => {
+ // an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout
+ if (!ready) { toast('干员数据仍在载入,请稍候再导入', 'warn'); return; }
+ const res = parseImport(ioText);
+ if (!res.ok) { toast(`导入失败:${res.error}`, 'error'); return; }
+ const { applied, dropped } = applyLoadoutEntries(res.entries, getChess);
+ // nothing survived sanitising (unknown chess, or every choice already the default): keep the current loadout
+ if (!applied) { toast('导入失败:这份数据在当前版本没有可用的调配,未做任何改动', 'error'); return; }
+ setIo(null);
+ toast(dropped
+ ? `已导入 ${applied} 名干员(另有 ${dropped} 项未导入)`
+ : `已导入 ${applied} 名干员的调配`, dropped ? 'warn' : 'success');
+ };
+
// Esc closes; ←/→ browse the filtered roster (not while typing in the search field)
useEffect(() => {
const onKey = (e) => {
@@ -401,7 +474,8 @@ function LoadoutScreen({ st }) {
const [syncText, syncCls] = SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整';
- return html`
+ return html`<${Fragment}>
+
@@ -415,6 +489,8 @@ function LoadoutScreen({ st }) {
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label="调配截止" class="lo-deadline" />` : null}
${syncText ? html`${syncText}` : null}
已调整 ${nChanged}/${roster.length}
+ <${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title="导出当前调配(可复制或下载)">导出/>
+ <${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title="导入调配(粘贴或选择文件)">导入/>
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认/>
@@ -432,7 +508,25 @@ function LoadoutScreen({ st }) {
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked} />
`}
-
`;
+
+ ${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)}
+ title=${io.mode === 'export' ? '导出干员调配' : '导入干员调配'} micro="OPERATOR LOADOUT"
+ actions=${io.mode === 'export'
+ ? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>关闭/>
+ <${Button} variant="secondary" icon="copy" data-testid="loadout-io-copy" onClick=${ioCopy}>复制/>
+ <${Button} variant="primary" data-testid="loadout-io-download" onClick=${ioDownload}>下载文件/>`
+ : html`<${Button} variant="ghost" onClick=${() => setIo(null)}>取消/>
+ <${Button} variant="secondary" data-testid="loadout-io-pick" onClick=${ioPick}>选择文件/>
+ <${Button} variant="primary" icon="check" data-testid="loadout-io-apply" disabled=${!ioText.trim() || !ready} onClick=${ioApply}>导入/>`}>
+ ${io.mode === 'export'
+ ? html`共 ${nChanged} 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。`
+ : html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会覆盖当前的 ${nChanged} 名干员调配。` : null}`}
+
+
+ />` : null}
+/>`;
}
/**
diff --git a/public/js/screens/room.js b/public/js/screens/room.js
index 97a108bf..60c3de91 100644
--- a/public/js/screens/room.js
+++ b/public/js/screens/room.js
@@ -13,6 +13,7 @@ import {
html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo,
} from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js';
+import { copyText } from '../ui/clipboard.js';
import { GuideButton } from '../ui/guide.js';
import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
@@ -68,35 +69,12 @@ export function inviteLink(code) {
}
/**
- * Copy text to the clipboard (async API with a textarea fallback for insecure contexts).
+ * Copy text to the clipboard (async API with a textarea fallback for insecure contexts). Moved to ui/clipboard.js so
+ * 干员调配 can use it without importing this screen (which imports loadout.js): re-exported here for existing callers.
* @param {string} text
* @returns {Promise}
*/
-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;
- }
-}
+export { copyText };
function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot }) {
const coop = room.mode !== 'solo';
diff --git a/public/js/ui/clipboard.js b/public/js/ui/clipboard.js
new file mode 100644
index 00000000..2da33657
--- /dev/null
+++ b/public/js/ui/clipboard.js
@@ -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}
+ */
+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;
+ }
+}
diff --git a/public/js/ui/loadoutModel.js b/public/js/ui/loadoutModel.js
index 5dd207aa..269752ad 100644
--- a/public/js/ui/loadoutModel.js
+++ b/public/js/ui/loadoutModel.js
@@ -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} 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 } | { 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 ---------------------------------------------------------------------------------------------
/**
diff --git a/public/js/ui/loadoutSync.js b/public/js/ui/loadoutSync.js
index 24ed5bfc..6f2c738d 100644
--- a/public/js/ui/loadoutSync.js
+++ b/public/js/ui/loadoutSync.js
@@ -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} 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');
diff --git a/test/ui/loadout.e2e.test.js b/test/ui/loadout.e2e.test.js
index b2c63ffc..5068d3e9 100644
--- a/test/ui/loadout.e2e.test.js
+++ b/test/ui/loadout.e2e.test.js
@@ -134,6 +134,72 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await ctx.close();
});
+ test('desktop: 导出 hands out a versioned payload; 导入 restores it and refuses junk', async () => {
+ const { ctx, page, problems } = await open();
+ await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
+ await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
+ // one edit, so there is something to export
+ await page.type('.lo-search input', '隐现');
+ await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
+ await page.click('.lo-card');
+ await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
+ await page.click('.lo-detail .lo-skill[data-skill="0"]');
+ const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
+ assert.deepEqual(stored.entries, { [INSIDE]: { skill: 0 } });
+
+ // 导出: a centred dialog — its own full-viewport overlay, rendered NEXT TO the .lo screen rather than inside it
+ await clickSel(page, '[data-testid="loadout-export"]');
+ await page.waitForSelector('.modal [data-testid="loadout-io-text"]', { visible: true, timeout: 5000 });
+ // the box slides in (`modal-in`, 250 ms): measure the settled geometry, not a frame of the entry animation
+ await page.waitForFunction(() => document.querySelector('.modal__box')?.getAnimations().every((a) => a.playState === 'finished'), { timeout: 5000 });
+ const box = await page.evaluate(() => {
+ const m = document.querySelector('.modal');
+ const b = document.querySelector('.modal__box').getBoundingClientRect();
+ return { position: getComputedStyle(m).position, y: b.y, h: b.height, vh: innerHeight, inside: !!m.closest('.lo') };
+ });
+ assert.equal(box.position, 'fixed', 'the modal keeps components.css `position: fixed`');
+ assert.equal(box.inside, false, 'a sibling of .lo, so `.lo > *` needs no exception for it');
+ assert.ok(Math.abs(box.y + box.h / 2 - box.vh / 2) < 2, `centred vertically (${JSON.stringify(box)})`);
+ const payload = JSON.parse(await page.$eval('[data-testid="loadout-io-text"]', (t) => t.value));
+ assert.equal(payload.kind, 'stronghold.loadout');
+ assert.equal(payload.v, 1);
+ assert.deepEqual(payload.entries, stored.entries, 'what is exported is what is stored');
+ await page.screenshot({ path: path.join(OUT, 'loadout-export.png') });
+ await page.evaluate(() => [...document.querySelectorAll('.modal__actions .btn')].find((b) => b.textContent.trim() === '关闭').click());
+ await page.waitForFunction(() => !document.querySelector('.modal'), { timeout: 3000 });
+
+ // wipe it, then 导入 the payload back
+ await page.evaluate(() => localStorage.setItem('sp.pref.loadout', JSON.stringify({ v: 1, entries: {} })));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && !!document.querySelector('.lobby-screen'), { timeout: 30000 });
+ await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
+ await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
+ await clickSel(page, '[data-testid="loadout-import"]');
+ await page.waitForSelector('.modal [data-testid="loadout-io-text"]', { visible: true, timeout: 5000 });
+ await page.$eval('[data-testid="loadout-io-text"]', (t, v) => { t.value = v; t.dispatchEvent(new Event('input', { bubbles: true })); }, JSON.stringify(payload));
+ await page.click('[data-testid="loadout-io-apply"]');
+ await page.waitForFunction(() => !document.querySelector('.modal'), { timeout: 5000 });
+ await sleep(300);
+ assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')).entries), stored.entries, 'round trip');
+
+ // a payload of another tool is refused and leaves the loadout alone
+ await clickSel(page, '[data-testid="loadout-import"]');
+ await page.waitForSelector('.modal [data-testid="loadout-io-text"]', { visible: true, timeout: 5000 });
+ await page.$eval('[data-testid="loadout-io-text"]', (t) => { t.value = '{"kind":"other.tool","entries":{"a":{"skill":1}}}'; t.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.click('[data-testid="loadout-io-apply"]');
+ await page.waitForFunction(() => /导入失败/.test(document.body.textContent || ''), { timeout: 5000 });
+ assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')).entries), stored.entries, 'a refused import changes nothing');
+
+ // a payload this build cannot use at all (every chess unknown) must leave the loadout alone too (review fix)
+ await page.$eval('[data-testid="loadout-io-text"]', (t) => { t.value = '{"v":1,"entries":{"chess_not_here":{"skill":0}}}'; t.dispatchEvent(new Event('input', { bubbles: true })); });
+ await page.click('[data-testid="loadout-io-apply"]');
+ await page.waitForFunction(() => /没有可用的调配/.test(document.body.textContent || ''), { timeout: 5000 });
+ assert.ok(await page.$('.modal'), 'the dialog stays open for the player to fix the payload');
+ assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')).entries), stored.entries, 'an import that keeps nothing changes nothing');
+ assert.deepEqual(problems, []);
+ await ctx.close();
+ });
+
test('solo briefing: an edit made right before 准备就绪 still applies to this match (closing the overlay sends it)', async () => {
const { ctx, page, problems } = await open();
await page.evaluate(() => globalThis.__SP__.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' }));
@@ -176,6 +242,21 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
// the briefing (and its countdown) is hidden under the overlay: the overlay carries the time left
await page.waitForSelector('.lo-top .lo-deadline', { visible: true, timeout: 3000 });
assert.match(await page.$eval('.lo-top .lo-deadline', (el) => el.getAttribute('aria-label')), /剩余\d+秒/);
+ // review: the two new 导出 / 导入 buttons must fit next to the countdown on a narrow phone in landscape (667×375).
+ // Only the size changes (toggling isMobile / hasTouch would make puppeteer reload the page).
+ await page.setViewport({ width: 667, height: 375 });
+ const bar = await page.evaluate(() => {
+ const top = document.querySelector('.lo-top');
+ const right = document.querySelector('.lo-top__right').getBoundingClientRect();
+ const center = document.querySelector('.lo-top__center').getBoundingClientRect();
+ const buttons = [...document.querySelectorAll('.lo-top__right .btn')].map((b) => Math.round(b.getBoundingClientRect().right));
+ return { overflow: top.scrollWidth - top.clientWidth, overlap: right.left < center.right, offscreen: right.right > innerWidth, buttons };
+ });
+ assert.ok(bar.overflow <= 0, `the top bar does not scroll sideways (${JSON.stringify(bar)})`);
+ assert.equal(bar.overlap, false, `the right column does not run into the title (${JSON.stringify(bar)})`);
+ assert.equal(bar.offscreen, false, `every button ends inside the viewport (${JSON.stringify(bar)})`);
+ await page.screenshot({ path: path.join(OUT, 'loadout-narrow-toolbar.png') });
+ await page.setViewport({ width: 1920, height: 1080 });
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card');
diff --git a/test/ui/loadout.test.js b/test/ui/loadout.test.js
index 62914b3d..a5f1e76b 100644
--- a/test/ui/loadout.test.js
+++ b/test/ui/loadout.test.js
@@ -13,8 +13,9 @@ import { PHASE } from '../../shared/constants.js';
import {
parseStored, toStored, chessOptions, effectiveChoice, setChoice, resetChoice, sanitizeEntries, rosterOf, filterRoster,
changedCount, moduleBadge, attrRows, skillTags, skillLabel, selectedSkill, selectedModule, recordsOf,
+ exportPayload, serializeExport, parseImport, LOADOUT_EXPORT_KIND, LOADOUT_VERSION, LOADOUT_IMPORT_MAX_BYTES,
} from '../../public/js/ui/loadoutModel.js';
-import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS } from '../../public/js/ui/loadoutSync.js';
+import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS, applyLoadoutEntries, setEntries, loadoutStore } from '../../public/js/ui/loadoutSync.js';
import { createStore } from '../../public/js/store.js';
import { shouldAutoClose } from '../../public/js/screens/loadout.js';
@@ -39,6 +40,77 @@ test('parseStored: tolerant of junk, keeps structurally valid entries; toStored
assert.deepEqual(parseStored(JSON.parse(JSON.stringify(toStored(e)))), e);
});
+test('exportPayload / serializeExport: versioned envelope, entries copied; parseImport round trip', () => {
+ const entries = { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } };
+ const p = exportPayload(entries, { now: Date.UTC(2026, 9, 3, 4, 5, 6) });
+ assert.deepEqual(Object.keys(p).sort(), ['count', 'entries', 'exportedAt', 'kind', 'v'], 'exactly the envelope, no unused field');
+ assert.equal(p.kind, LOADOUT_EXPORT_KIND);
+ assert.equal(p.v, LOADOUT_VERSION);
+ assert.equal(p.count, 2);
+ assert.equal(p.exportedAt, '2026-10-03T04:05:06.000Z');
+ assert.deepEqual(p.entries, entries);
+ assert.notEqual(p.entries, entries, 'a copy — later edits must not mutate an already-built payload');
+ assert.notEqual(p.entries[INSIDE], entries[INSIDE]);
+ assert.equal(exportPayload(null).count, 0);
+
+ const back = parseImport(serializeExport(entries, { now: 0 }));
+ assert.equal(back.ok, true);
+ assert.deepEqual(back.entries, entries);
+});
+
+test('parseImport: accepts the envelope, the stored form, a bare map and text; refuses junk, newer data and hostile keys', () => {
+ const entries = { [INSIDE]: { skill: 0 } };
+ assert.deepEqual(parseImport(exportPayload(entries)).entries, entries, 'envelope');
+ assert.deepEqual(parseImport(toStored(entries)).entries, entries, 'stored { v, entries }');
+ assert.deepEqual(parseImport(entries).entries, entries, 'bare map (hand-written / older build)');
+ assert.deepEqual(parseImport(JSON.stringify(exportPayload(entries))).entries, entries, 'serialised text');
+ assert.deepEqual(parseImport(`\n ${JSON.stringify(entries)} \n`).entries, entries, 'padded text');
+
+ for (const junk of ['', ' ', 'not a payload', '{', 42, null, undefined, [], true]) {
+ assert.equal(parseImport(junk).ok, false, `${JSON.stringify(junk)} is refused`);
+ }
+ const newer = parseImport({ v: LOADOUT_VERSION + 1, entries });
+ assert.equal(newer.ok, false, 'a NEWER payload is refused, never mis-read');
+ assert.match(newer.error, /请先更新游戏/, 'the player is told to update the game');
+ assert.equal(parseImport({ kind: 'some.other.tool', entries }).ok, false, "another tool's payload");
+ assert.equal(parseImport({ v: LOADOUT_VERSION, entries: {} }).ok, false, 'nothing to import');
+ assert.equal(parseImport({ v: LOADOUT_VERSION, entries: { 'bad id': { skill: 0 } } }).ok, false, 'no structurally valid entry');
+ assert.equal(parseImport('x'.repeat(LOADOUT_IMPORT_MAX_BYTES + 1)).ok, false, 'an oversized payload is refused before parsing');
+
+ // hostile keys: JSON.parse keeps `__proto__` as an own key, and assigning it would rewrite an object's prototype
+ const hostile = JSON.parse(`{"entries":{"__proto__":{"skill":0},"constructor":{"skill":0},"prototype":{"skill":0},"${INSIDE}":{"skill":0}}}`);
+ assert.deepEqual(Object.keys(hostile.entries), ['__proto__', 'constructor', 'prototype', INSIDE], 'fixture: parsed as own keys');
+ const safe = parseImport(hostile);
+ assert.deepEqual(safe.entries, { [INSIDE]: { skill: 0 } }, 'only the real chess survives');
+ assert.equal(Object.getPrototypeOf(safe.entries), Object.prototype, 'the entry map keeps a clean prototype');
+ assert.equal({}.skill, undefined, 'Object.prototype was not polluted');
+});
+
+test('applyLoadoutEntries: sanitises against the loaded data and reports what was dropped', () => {
+ const before = loadoutStore.get().entries;
+ try {
+ const res = applyLoadoutEntries({ [INSIDE]: { skill: 0 }, chess_nope_999: { skill: 0 } }, get);
+ assert.deepEqual(res, { applied: 1, dropped: 1 }, 'the unknown chess is dropped, the real one applied');
+ assert.deepEqual(loadoutStore.get().entries, { [INSIDE]: { skill: 0 } }, 'the store got the sanitised entries');
+ } finally {
+ setEntries(before);
+ }
+});
+
+test('applyLoadoutEntries: an import that keeps nothing changes nothing (review: it used to wipe the loadout)', () => {
+ setEntries({ [INSIDE]: { skill: 0 } });
+ const before = loadoutStore.get().entries;
+ try {
+ assert.deepEqual(applyLoadoutEntries({ chess_nope_999: { skill: 0 } }, get), { applied: 0, dropped: 1 }, 'every chess unknown');
+ assert.equal(loadoutStore.get().entries, before, 'the current loadout is untouched (not even replaced by an equal one)');
+ const def = IB.skills.find((s) => s.isDefault).index;
+ assert.deepEqual(applyLoadoutEntries({ [INSIDE]: { skill: def } }, get), { applied: 0, dropped: 1 }, 'every choice already the default');
+ assert.equal(loadoutStore.get().entries, before, 'still untouched');
+ } finally {
+ setEntries(before);
+ }
+});
+
test('options: skill records at Lv4 (normal) and Lv7 (elite); modules + 不装备 with defaults flagged', () => {
const o = chessOptions(IB, IG);
assert.deepEqual(o.skillOptions.map((s) => s.index), [0, 1]);