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

+2
View File
@@ -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
+21
View File
@@ -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; }
+99 -5
View File
@@ -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 <input type=file> 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`<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
return html`<${Fragment}>
<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
<div class="lo__bg" aria-hidden="true"></div>
<header class="lo-top">
<div class="lo-top__left">
@@ -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`<span class=${cx('lo-sync', syncCls)} role="status">${syncText}</span>` : null}
<span class="lo-count">已调整 <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
<${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}>全部恢复默认<//>
</div>
</header>
@@ -432,7 +508,25 @@ function LoadoutScreen({ st }) {
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked} />
</div>
</main>`}
</div>`;
</div>
${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}>导入<//>`}>
<p class="lo-io__hint">${io.mode === 'export'
? html`共 <b class="num">${nChanged}</b> 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。`
: html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会<strong>覆盖</strong>当前的 ${nChanged} 名干员调配。` : null}`}</p>
<textarea class="lo-io__text" data-testid="loadout-io-text" spellcheck=${false} readOnly=${io.mode === 'export'} value=${ioText}
placeholder=${io.mode === 'export' ? '' : '在此粘贴导出的调配内容…'}
onInput=${(e) => setIo({ mode: io.mode, text: e.currentTarget.value })}></textarea>
<input type="file" accept=".json,application/json,text/plain" class="lo-io__file" ref=${fileRef} onChange=${ioFile} />
<//>` : null}
<//>`;
}
/**
+4 -26
View File
@@ -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<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;
}
}
export { copyText };
function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot }) {
const coop = room.mode !== 'solo';
+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');
+81
View File
@@ -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');
+73 -1
View File
@@ -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]);