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``; + + ${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]);