From 7a65577cc17985040e39607981be5c146bdd449f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E9=97=8C?= Date: Sun, 11 Oct 2026 12:54:00 +0800 Subject: [PATCH] fix: allow exporting DIY-only operator settings --- docs/design/match.md | 3 + docs/history/0.2.4.md | 3 + public/js/screens/loadout.js | 2 +- test/ui/loadout-ops-export.e2e.test.js | 168 +++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 test/ui/loadout-ops-export.e2e.test.js diff --git a/docs/design/match.md b/docs/design/match.md index b5a5729d..7e59d6fd 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -113,6 +113,9 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno **UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): the roster is one list, a row per operator with aligned columns under a head row — portrait / name / bonds (opens the detail), the skills and the elite's modules to tap (不装备 first; the quick choices of PR #301 by @farridge, laid out as one list instead of its three columns of cards, the owner's call of 2026-10-08), the 潜能 / 练度 selects (`screens/loadout.js RosterRow`, `screens/cultivation.js`); the detail beside it (below 1000 px wide it slides over the list, a quick choice keeps the list) opens with the 潜能与练度 section (潜能 1–6, the four official 练度 tiers with their effect texts); the detail then shows the operator's 特质 — the in-match `GarrisonBlock` / RichText of the form its 普通 / 精锐 skill toggle shows, triggers such as 「<进入休整期时>」 kept (0.2.2, PR #301 by @farridge); 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, ops }` (`ops`: the 潜能 / 练度 settings, 0.2.2 — an import without them leaves the current settings alone) — 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`). +Export remains available for non-default `ops` alone, including operators outside the normal roster. The roster's +adjusted count and reset scope stay limited to its own operators; the DIY export still carries picks only. + **干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources. **自选编队 (0.2.0):** the overlay's third tab fills the four DIY slots (5阶 ×2, 6阶 ×2) with an owned 6★ outside the chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (its locked skill / module); only operators with a kit are offered (`welcome.diyKitted`). Per-browser `sp.pref.diy` = `{ v, picks }`, synced as `room.diy { picks }` by the same sync engine; 导出 / 导入 as `{ kind: 'stronghold.diy', v, exportedAt, count, picks }` (`ui/diyModel.js`, `screens/diy.js`). In the match a slotted piece is the operator for its player (name, art, class, bonds from its factions, no 特质, the pick's skill and module; the slot's tier, price and merge), sold only in that player's shop from the slot's 调度中心 level with its own stock; the shop / reward / detail cards and the own pieces carry a 「自选」 badge (`ui/gameLogic/diy.js`); the 0.2.0 自选编队 design subsection has the rules and sources. diff --git a/docs/history/0.2.4.md b/docs/history/0.2.4.md index 9f5c8917..6a8cdaa8 100644 --- a/docs/history/0.2.4.md +++ b/docs/history/0.2.4.md @@ -74,6 +74,9 @@ are unchanged. No unannounced future-season behavior is inferred. ### 29.6 UI and recovery +- Loadout export stays available when only a DIY-only operator's potential/cultivation differs from the defaults. + It uses the existing `stronghold.loadout.ops`; the roster's adjusted count/reset scope and `stronghold.diy` format + remain unchanged. Real-server browser regressions cover settings-only, normal/mixed round trips and reset boundaries. - DIY selection reuses the roster's `LoadoutStats` component, showing the chosen ordinary/elite stats, attack range, trait, talents, potential and cultivation. Skill descriptions/SP labels follow the same preview level. The list uses aligned roster-style rows; list and details scroll independently, leaving Confirm reachable. diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js index be44e06c..657c6fde 100644 --- a/public/js/screens/loadout.js +++ b/public/js/screens/loadout.js @@ -675,7 +675,7 @@ function LoadoutScreen({ st }) { ${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label=${t('调配截止')} class="lo-deadline" />` : null} ${syncText ? html`${t(syncText)}` : null} ${t('已调整')} ${nChanged}/${roster.length} - <${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')} + <${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged && !Object.keys(st.ops || {}).length} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')} <${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title=${t('导入调配(粘贴或选择文件)')}>${t('导入')} <${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>${t('全部恢复默认')} `} diff --git a/test/ui/loadout-ops-export.e2e.test.js b/test/ui/loadout-ops-export.e2e.test.js new file mode 100644 index 00000000..b0c35993 --- /dev/null +++ b/test/ui/loadout-ops-export.e2e.test.js @@ -0,0 +1,168 @@ +// The existing stronghold.loadout export carries every operator's potential/cultivation, including DIY-only ones. +// Real server + headless Chrome, normal UI input only; no storage/store/network mutation. No art is required. +// SP_E2E=1 node --test test/ui/loadout-ops-export.e2e.test.js +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { Client, hasChrome, startRealServer } from '../e2e/client.mjs'; + +const KALTS = 'char_003_kalts'; +const INSIDE = 'chess_char_1_01_a'; +const SLOT = 'chess_char_6_diy1_a'; +const slot = `.diy-slot[data-slot="${SLOT}"]`; +const picker = '[data-testid="diy-picker"]'; +const io = '[data-testid="loadout-io-text"]'; + +describe('loadout export includes DIY-only cultivation (real server, headless Chrome)', { + skip: !(process.env.SP_E2E === '1' && hasChrome()) && 'set SP_E2E=1 (and have Chrome)', +}, () => { + let srv, P; + before(async () => { srv = await startRealServer(); P = (await import('puppeteer-core')).default; }); + after(async () => { await srv?.stop(); }); + + async function open(label) { + const c = new Client(P, srv.base, label, { prefix: 'loadout-ops-export', w: 1280, h: 800 }); + try { + await c.open(); + await c.enter('调配回归'); + await c.click('.lobby-screen [data-testid="loadout-open"]'); + await c.page.waitForSelector('.lo-card'); + return c; + } catch (e) { await c.close(); throw e; } + } + const tab = (c, name) => c.click(`.lo-tab[data-tab="${name}"]`); + const disabled = (c, id) => c.page.$eval(`[data-testid="${id}"]`, e => e.disabled); + const saved = (c) => c.page.evaluate(() => { + const p = JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}'); + return { entries: p.entries || {}, ops: p.ops || {} }; + }); + async function chooseKalts(c) { + await tab(c, 'diy'); + await c.click(`${slot} :is(.diy-slot__fill, [data-testid="diy-change"])`); + await c.page.waitForSelector(picker); + await c.click(`${picker} input[type="search"]`); + await c.page.keyboard.type('凯尔希'); + await c.click(`${picker} .diy-opt[data-char="${KALTS}"]`); + await c.click('[data-testid="diy-confirm"]'); + await c.page.waitForSelector(picker, { hidden: true }); + } + async function setKalts(c, patch) { + await tab(c, 'diy'); + for (const [key, value] of Object.entries(patch)) await c.page.select(`${slot} select[data-cult="${key}"]`, String(value)); + } + async function changeNormal(c) { + await tab(c, 'loadout'); + await c.click(`.lo-card[data-chess="${INSIDE}"] .lo-q--skill[data-skill="0"]`); + await c.page.select(`.lo-card[data-chess="${INSIDE}"] select[data-cult="potential"]`, '2'); + } + async function exportText(c, id = 'loadout-export') { + assert.equal(await disabled(c, id), false, `${id} must allow the saved settings to be exported`); + await c.click(`[data-testid="${id}"]`); + await c.page.waitForSelector(io); + const text = await c.page.$eval(io, e => e.value); + await c.page.keyboard.press('Escape'); + await c.page.waitForSelector(io, { hidden: true }); + return text; + } + async function importText(c, text, id = 'loadout-import') { + await c.click(`[data-testid="${id}"]`); + await c.page.waitForSelector(io); + await c.click(io); + await c.page.keyboard.type(text); + await c.click('[data-testid="loadout-io-apply"]'); + await c.page.waitForSelector(io, { hidden: true }); + } + + for (const [name, normal, patch] of [ + ['DIY potential only', false, { potential: 1 }], + ['DIY cultivation only', false, { cultivate: 0 }], + ['DIY potential and cultivation', false, { potential: 1, cultivate: 0 }], + ['normal only', true, null], + ['normal and DIY', true, { potential: 1, cultivate: 0 }], + ]) { + test(`${name}: export then import in a fresh browser preserves entries and ops`, { timeout: 90000 }, async () => { + let c, target, diyText; + try { + c = await open(name); + if (patch) { + await chooseKalts(c); + await setKalts(c, patch); + await c.shot(`${name.replaceAll(' ', '-')}-settings`); + diyText = await exportText(c, 'diy-export'); + const diy = JSON.parse(diyText); + assert.equal(diy.kind, 'stronghold.diy'); + assert.equal('ops' in diy, false, 'the DIY roster format stays unchanged'); + assert.equal(diy.picks[SLOT].charId, KALTS); + } + if (normal) await changeNormal(c); + await tab(c, 'loadout'); + const expected = await saved(c); + if (patch) assert.deepEqual(expected.ops[KALTS], patch); + if (!normal) { + assert.deepEqual(expected.entries, {}); + assert.match(await c.page.$eval('.lo-top__right', e => e.textContent), /已调整\s*0/); + assert.equal(await c.page.$eval('.lo-top__right button:last-child', e => e.disabled), true, + 'normal reset still covers only its roster'); + } + console.log(JSON.stringify({ scenario: name, saved: expected, exportDisabled: await disabled(c, 'loadout-export') })); + await c.shot(`${name.replaceAll(' ', '-')}-before-export`); + const text = await exportText(c); + const payload = JSON.parse(text); + assert.equal(payload.kind, 'stronghold.loadout'); + assert.equal(payload.v, 1); + assert.equal(payload.count, normal ? 1 : 0, 'count retains its existing entry semantics'); + assert.deepEqual({ entries: payload.entries, ops: payload.ops }, expected); + await c.shot(`${name.replaceAll(' ', '-')}-exported`); + target = await open(`${name} import`); + if (diyText) { + await tab(target, 'diy'); + await importText(target, diyText, 'diy-import'); + await tab(target, 'loadout'); + } + await importText(target, text); + assert.deepEqual(await saved(target), expected, 'a fresh browser keeps the complete exported loadout'); + await target.page.reload({ waitUntil: 'domcontentloaded' }); + await target.page.waitForSelector('.lobby-screen'); + assert.deepEqual(await saved(target), expected, 'the imported loadout persists across reload'); + if (patch) { + assert.deepEqual(await target.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.diy')).picks), JSON.parse(diyText).picks); + await target.click('.lobby-screen [data-testid="loadout-open"]'); + await tab(target, 'diy'); + await target.page.waitForSelector(`${slot} select[data-cult]`); + assert.deepEqual(await target.page.$$eval(`${slot} select[data-cult]`, els => Object.fromEntries(els.map(e => [e.dataset.cult, Number(e.value)]))), + { potential: patch.potential ?? 6, cultivate: patch.cultivate ?? 3 }, 'the restored pick displays its restored settings'); + await target.shot(`${name.replaceAll(' ', '-')}-imported`); + } + assert.deepEqual([...c.problems, ...target.problems].filter(p => p.startsWith('pageerror:')), []); + console.log(JSON.stringify({ scenario: name, payload, imported: await saved(target), resourceProblems: c.problems.length + target.problems.length })); + } finally { await target?.close(); await c?.close(); } + }); + } + + test('defaults, clearing a pick and normal reset preserve the existing scopes', { timeout: 90000 }, async () => { + let c; + try { + c = await open('reset'); + assert.equal(await disabled(c, 'loadout-export'), true); + await chooseKalts(c); + await tab(c, 'loadout'); + assert.equal(await disabled(c, 'loadout-export'), true, 'a roster choice alone is not loadout settings'); + await setKalts(c, { potential: 1, cultivate: 0 }); + await changeNormal(c); + await c.click('.lo-top__right button', '全部恢复默认'); + await c.click('.modal button', '恢复默认'); + await c.page.waitForSelector('.modal', { hidden: true }); + assert.deepEqual(await saved(c), { entries: {}, ops: { [KALTS]: { potential: 1, cultivate: 0 } } }); + assert.equal(await disabled(c, 'loadout-export'), false, 'normal reset retains exportable DIY settings'); + await tab(c, 'diy'); + await c.click(`${slot} [data-testid="diy-clear"]`); + await tab(c, 'loadout'); + assert.equal(await disabled(c, 'loadout-export'), false, 'clearing a pick retains its per-operator settings'); + await chooseKalts(c); + await setKalts(c, { potential: 6, cultivate: 3 }); + await tab(c, 'loadout'); + assert.deepEqual(await saved(c), { entries: {}, ops: {} }); + assert.equal(await disabled(c, 'loadout-export'), true, 'back to defaults: nothing to export'); + assert.deepEqual(c.problems.filter(p => p.startsWith('pageerror:')), []); + } finally { await c?.close(); } + }); +});