From c91979472420191c3134d76f4e7b65500a2ad09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E9=97=8C?= Date: Sun, 11 Oct 2026 21:31:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A6=81=E7=94=A8=E6=9C=80=E5=90=8E?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E9=80=89=E7=AD=96=E7=95=A5=E7=9A=84=E7=8E=A9?= =?UTF-8?q?=E5=AE=B6=E7=9A=84=E8=B7=B3=E8=BF=87=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/META.md | 3 +- docs/design/client.md | 3 + docs/history/0.2.5.md | 8 ++ public/js/screens/bandDraft.js | 2 +- test/ui/band-draft-skip.e2e.test.js | 200 ++++++++++++++++++++++++++++ test/ui/bandDraft.test.js | 40 ++++++ 6 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 test/ui/band-draft-skip.e2e.test.js diff --git a/docs/META.md b/docs/META.md index d105feaa..0f37c3d0 100644 --- a/docs/META.md +++ b/docs/META.md @@ -103,7 +103,8 @@ published as `m.public.deadline` (= `draft.turnDeadline`; `draft.turnSeconds` it at once. A turn that runs out takes the strategy the player highlights in the draft screen (`g.bandFocus {bandId?}`, `Match.timeoutBand`) while it is allowed and no teammate holds it, else `bandDraft.timeoutBandId` 华法琳, else the first free strategy by sortId (`defaultBand`; a departing seat gets `defaultBand` too). One skip per player (`g.bandSkip`: the -player moves to the end of the order; refused when nobody is left to pass to); a strategy a teammate already took is +player moves to the end of the order; refused when nobody is left to pass to). The draft screen disables skip when +no other unpicked player remains; finished players stay in the published order but are not skip targets. A strategy a teammate already took is refused (队友已选); band must list the mode type in `modeTypeList`. A single human (co-op with AI teammates only): untimed. Solo: free pick, no timer, no skip. Starting LP = `bands[id].totalHp`. diff --git a/docs/design/client.md b/docs/design/client.md index b0b94b44..96e76d3c 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -54,6 +54,9 @@ Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立 A bond the mode never activates (标准: official `inactiveBondIdList`) reads 本局禁用 on shop / reward cards (struck through), detail chips and the popup — no 在场 count, no 当前效果 (§21.7); a strategy built around one carries a 本局禁用 tag and note in the strategy draft and stays selectable (bands.json `bondIds`, §21.26). +The strategy draft enables skip only on the viewer's turn, with quota left and another unpicked player to pass to. +The order list retains finished players for display; they do not keep the last player's skip button enabled. + Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — over a legal tile the dragged unit stands on it, as in the official deploy drag, held under the pointer elsewhere (§27.62); a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range — the range follows the open detail card (`screens/game/range.js inspectRange`, GitHub PR #281): an own board piece in prep, and in battle or on a scouted board any ally operator / summon on the field with its current range (the live stats' `range` and `dir`, the snapshot's tile), never an enemy or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen hide it; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small). The owner's `prepMapChars` (外勤医疗's 预备干员-医疗, or another displayed map-character variant) are read-only diff --git a/docs/history/0.2.5.md b/docs/history/0.2.5.md index 5f6c07ab..b1df3fae 100644 --- a/docs/history/0.2.5.md +++ b/docs/history/0.2.5.md @@ -704,3 +704,11 @@ default skill is S2, which is the skill this correction changes. | 耶拉 was on the pre-change lineup; that seat is then eliminated and the stored lineup is empty | `solo-ABYSS-1`: defeat at round 13 → eliminated at round 12, team LP 0 → null. The pre-change lineup had 耶拉 `3_20_a` and 至简 `3_13_a`. First divergence round 8: LP 27 → 18, killed 19 → 10, leaks 0 → 9. `coop4-ABYSS-7`: still a defeat at round 13, team LP 0, boss pool 4821366 → 3417504. ai_1 is eliminated at round 13; the pre-change lineup had 至简 `3_13_a` and 耶拉 `3_20_a`. First divergence round 8: ai_1 LP 19 → 20, killed 2 → 3, leaks 7 → 6. ai_0's lineup also changes and has no funnel chess id before or after | | 耶拉 plus other funnel operators, lineup unchanged, same victory and the same team LP 130 | `coop4-FUNNY-6`: ai_0 耶拉 `3_20_a`, ai_1 荒芜拉普兰德 `6_18_a`, ai_3 洛洛 `2_10_a`. First divergence round 11, ai_0's kjeragShip layers 26 → 23 | | No 耶拉 on the final lineup. The diverging round's pieces and skill choice are not stored | `coop2-NORMAL-1-coverage-standins`: still a defeat, team LP 0, boss pool 25040 unchanged. ai_0's final lineup includes 荒芜拉普兰德 `6_18_a`. First divergence round 7, the other seat's miraShip layer 5 → 6. `coop2-NORMAL-14-standins`: victory, team LP 31 → 40 (ai_0 LP 13 → 22). Neither final lineup contains a funnel chess id. First divergence round 10 bond layers (raidShip 26 → 28, steadShip 16 → 18); that round's board hash is unchanged | + +### 30.31 Strategy skip eligibility + +The strategy-selection page only enables skipping while another unpicked player remains. The published order +keeps finished players for display, so its total length cannot establish that a skip has a recipient. The client +counts order entries absent from `draft.picks`, alongside the existing turn and quota checks. The server's +`skipBand` already rejects the final player with `BAD_TARGET / nobody to pass to`; its rule remains unchanged. +Regression covers normal two-, three- and four-human browser flows, the final turn, and exhausted quota. diff --git a/public/js/screens/bandDraft.js b/public/js/screens/bandDraft.js index 46d9e791..62874c78 100644 --- a/public/js/screens/bandDraft.js +++ b/public/js/screens/bandDraft.js @@ -185,7 +185,7 @@ export function BandDraftScreen() { const myPick = draft.picks.get(myId) || priv?.bandId || null; const myTurn = !myPick && (solo || draft.turnPid === myId); const skipsLeft = draft.skipsLeft.has(myId) ? draft.skipsLeft.get(myId) : (skipped ? 0 : 1); - const canSkip = !solo && myTurn && skipsLeft > 0 && draft.order.length > 1; + const canSkip = !solo && myTurn && skipsLeft > 0 && draft.order.filter((pid) => !draft.picks.has(pid)).length > 1; const taken = solo ? new Map() : teammateBands(draft.picks, myId); const pickers = new Map(); // bandId → players for (const [pid, bid] of draft.picks) { diff --git a/test/ui/band-draft-skip.e2e.test.js b/test/ui/band-draft-skip.e2e.test.js new file mode 100644 index 00000000..4c79593b --- /dev/null +++ b/test/ui/band-draft-skip.e2e.test.js @@ -0,0 +1,200 @@ +// Real server and normal UI operations only: create/join, ready, skip and confirm. +// No mocked draft, injected game state, direct network requests or fixed seed. +// SP_E2E=1 node --test test/ui/band-draft-skip.e2e.test.js +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const OUT = path.join(ROOT, 'test/e2e/out/band-draft-skip'); +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME); +const SKIP = '.draft-detail__btns .btn:not(.btn--primary)'; + +describe('strategy skip eligibility (real server, normal browser operations)', { + skip: !ENABLED && 'set SP_E2E=1 and have Chrome to run', +}, () => { + let srv; + let browser; + let base; + const observations = []; + + before(async () => { + mkdirSync(OUT, { recursive: true }); + const { startServer } = await import('../../server/index.js'); + const puppeteer = (await import('puppeteer-core')).default; + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + base = `http://127.0.0.1:${srv.port}`; + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, + args: ['--no-sandbox', '--no-first-run', '--mute-audio', '--disable-background-timer-throttling'] }); + }); + after(async () => { + try { + writeFileSync(path.join(OUT, 'observations.json'), JSON.stringify(observations, null, 2)); + } finally { + const proc = browser?.process(); + try { await browser?.close(); } finally { + for (const stream of [proc?.stdout, proc?.stderr]) stream?.destroy(); + await srv?.close(); + } + } + }); + + async function state(page) { + return page.evaluate(() => { + const s = globalThis.__SP__.store.get(); + return { me: s.me.playerId, code: s.room?.code, phase: s.match.public?.phase, + draft: s.match.public?.draft, bandId: s.match.private?.bandId }; + }); + } + async function click(page, selector, text = '') { + await page.waitForFunction((sel, txt) => [...document.querySelectorAll(sel)] + .some((el) => !el.disabled && el.textContent.includes(txt)), { timeout: 10000 }, selector, text); + const buttons = await page.$$(selector); + for (const button of buttons) { + const match = await button.evaluate((el, txt) => !el.disabled && el.textContent.includes(txt), text); + if (match) { await button.click(); return; } + } + assert.fail(`no enabled ${selector} ${text}`); + } + async function player(name, suffix = '') { + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); + const errors = []; + const frames = []; + page.on('pageerror', (e) => errors.push(e.stack || e.message)); + const cdp = await page.createCDPSession(); + await cdp.send('Network.enable'); + // Observe only: retain skip requests and replies, never handshake tokens or session data. + cdp.on('Network.webSocketFrameSent', ({ response }) => { + try { const m = JSON.parse(response.payloadData); if (m.t === 'g.bandSkip') frames.push({ direction: 'sent', message: m }); } catch { /* non-JSON */ } + }); + cdp.on('Network.webSocketFrameReceived', ({ response }) => { + try { const m = JSON.parse(response.payloadData); if (m.t === 'error') frames.push({ direction: 'received', message: m }); } catch { /* non-JSON */ } + }); + await page.goto(base + '/' + suffix, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.title-login input'); + await page.type('.title-login input', name); + await click(page, '.title-login button', '开始'); + await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online'); + return { context, page, errors, frames }; + } + async function draft(humans, mode = 'coop') { + const clients = [await player('轮选测试1')]; + const host = clients[0].page; + await click(host, '.mode-card', mode === 'solo' ? '独立模拟' : '同盟模拟'); + await click(host, '.create-box button', mode === 'solo' ? '开始独立模拟' : '创建同盟'); + await host.waitForFunction(() => !!globalThis.__SP__.store.get().room?.code); + const code = (await state(host)).code; + for (let i = 1; i < humans; i++) { + const c = await player(`轮选测试${i + 1}`, `?room=${code}`); + clients.push(c); + await c.page.waitForFunction((code) => globalThis.__SP__.store.get().room?.code === code, {}, code); + await click(c.page, '.room-bar__right button', '准备就绪'); + } + if ((await state(host)).phase !== 'INFO_CHECK') await click(host, '.room-bar__right button', '开始模拟'); + for (const c of clients) { + await c.page.waitForSelector('.brief__foot .btn--primary'); + await click(c.page, '.brief__foot .btn--primary', '准备就绪'); + } + await host.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'BAND_DRAFT'); + return clients; + } + async function current(clients) { + const s = await state(clients[0].page); + for (const c of clients) if ((await state(c.page)).me === s.draft.turn) { + await c.page.waitForFunction(() => { + const s = globalThis.__SP__.store.get(); + return s.match.public?.draft?.turn === s.me.playerId; + }); + return c; + } + assert.fail('current human not found'); + } + async function capture(c, name) { + await c.page.waitForSelector(SKIP); + const s = await state(c.page); + const disabled = await c.page.$eval(SKIP, (el) => el.disabled); + const toasts = await c.page.$$eval('.toast', (els) => els.map((el) => el.textContent)); + const file = `${name}.png`; + await c.page.screenshot({ path: path.join(OUT, file), fullPage: true }); + const result = { name, ...s, disabled, toasts, file, frames: c.frames.slice() }; + observations.push(result); + return result; + } + async function confirm(c) { + await click(c.page, '.dband:not(.is-taken)'); + await click(c.page, '.draft-detail__btns .btn--primary', '确认选择'); + await c.page.waitForFunction(() => !!globalThis.__SP__.store.get().match.private?.bandId); + } + async function close(clients) { + const runtimeErrors = clients.flatMap((c) => c.errors); + try { assert.deepEqual(runtimeErrors, [], 'no page errors during normal operations'); } finally { + for (const c of clients) await c.context.close(); + observations.push({ name: 'browser-cleanup', runtimeErrors, + errorsAfterClose: clients.flatMap((c) => c.errors) }); + } + } + + for (const humans of [2, 3, 4]) test(`${humans} humans: initial/middle turns enabled, last disabled with quota left`, { timeout: 120000 }, async () => { + const clients = await draft(humans); + try { + for (let i = 0; i < humans; i++) { + const c = await current(clients); + const s = await capture(c, `${humans}p-position-${i + 1}`); + assert.equal(s.draft.skipsLeft[s.me], 1, 'no player has skipped'); + assert.equal(s.disabled, i === humans - 1, 'skip only while another player remains'); + if (i === humans - 1) { + const before = c.frames.length; + // A real click on the disabled button must send nothing and leave the turn unchanged. + await c.page.click(SKIP); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal((await state(c.page)).draft.turn, s.me); + assert.equal(c.frames.length, before); + } + await confirm(c); + } + await clients[0].page.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase !== 'BAND_DRAFT'); + } finally { await close(clients); } + }); + + test('spent quota stays disabled even with another unpicked player; waiting/selected players disabled', { timeout: 120000 }, async () => { + const clients = await draft(3); + try { + const first = await current(clients); + const firstId = (await state(first.page)).me; + const waiting = clients.find((c) => c !== first); + assert.equal((await capture(waiting, '3p-waiting')).disabled, true); + await click(first.page, SKIP, '跳过'); + await first.page.waitForFunction((id) => globalThis.__SP__.store.get().match.public.draft.turn !== id, {}, firstId); + const second = await current(clients); + await click(second.page, SKIP, '跳过'); + const secondId = (await state(second.page)).me; + await second.page.waitForFunction((id) => globalThis.__SP__.store.get().match.public.draft.turn !== id, {}, secondId); + await confirm(await current(clients)); + await first.page.waitForFunction((id) => globalThis.__SP__.store.get().match.public.draft.turn === id, {}, firstId); + const spent = await capture(first, '3p-spent-quota-with-successor'); + assert.equal(spent.draft.skipsLeft[firstId], 0); + assert.equal(spent.draft.order.filter((id) => !spent.draft.picks[id]).length, 2); + assert.equal(spent.disabled, true); + const before = first.frames.length; + await first.page.click(SKIP); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(first.frames.length, before); + await confirm(first); + assert.equal((await capture(first, '3p-already-selected')).disabled, true); + await confirm(await current(clients)); + } finally { await close(clients); } + }); + + test('solo draft has no skip button', { timeout: 60000 }, async () => { + const clients = await draft(1, 'solo'); + try { + assert.equal(await clients[0].page.$(SKIP), null); + await confirm(clients[0]); + } finally { await close(clients); } + }); +}); diff --git a/test/ui/bandDraft.test.js b/test/ui/bandDraft.test.js index 70bf508c..c0282172 100644 --- a/test/ui/bandDraft.test.js +++ b/test/ui/bandDraft.test.js @@ -22,6 +22,46 @@ function draftOf(o) { return h; } +describe('server: skip requires a successor and remaining quota', () => { + for (const humans of [2, 3, 4]) test(`${humans} humans: the final unpicked player cannot pass to a finished player`, () => { + const h = draftOf({ humans, seed: 5 }); + const m = h.m; + try { + const order = m.draft.order.slice(); + for (let i = 0; i < order.length - 1; i++) { + const pid = order[i]; + assert.equal(m.draftTurn(), pid); + assert.deepEqual(m.handle(pid, { t: 'g.band', bandId: m.defaultBand(pid) }), { ok: true }); + } + const last = order.at(-1); + assert.equal(m.draftTurn(), last); + assert.equal(m.publicView().draft.order.length, humans, 'the published order retains finished players'); + assert.equal(m.draft.skipsLeft[last], 1); + assert.deepEqual(m.handle(last, { t: 'g.bandSkip' }), { error: ERR.BAD_TARGET, detail: 'nobody to pass to' }); + assert.equal(m.draftTurn(), last); + assert.equal(m.draft.skipsLeft[last], 1, 'a refused skip consumes no quota'); + assert.deepEqual(m.handle(last, { t: 'g.band', bandId: m.defaultBand(last) }), { ok: true }); + } finally { m.dispose(); } + }); + + test('passing works before the final position, but a spent quota cannot be used again', () => { + const h = draftOf({ humans: 3, seed: 5 }); + const m = h.m; + try { + const [first, second, third] = m.draft.order; + assert.deepEqual(m.handle(first, { t: 'g.bandSkip' }), { ok: true }); + assert.equal(m.draftTurn(), second); + assert.deepEqual(m.handle(second, { t: 'g.bandSkip' }), { ok: true }); + assert.equal(m.draftTurn(), third); + assert.deepEqual(m.handle(third, { t: 'g.band', bandId: m.defaultBand(third) }), { ok: true }); + assert.equal(m.draftTurn(), first); + assert.equal(m.draft.order.length - m.draft.idx, 2); + assert.deepEqual(m.handle(first, { t: 'g.bandSkip' }), { error: ERR.ALREADY, detail: 'no skip left' }); + assert.equal(m.draftTurn(), first); + } finally { m.dispose(); } + }); +}); + describe('server: no duplicate strategies in the co-op draft', () => { test('a band picked by a teammate is refused with 队友已选; a free one is accepted', () => { const h = draftOf({ humans: 3, seed: 5 });