fix: 禁用最后一个选策略的玩家的跳过按钮

This commit is contained in:
星闌 committed 2026-10-12 19:38:19 +08:00
1 parent cf7a940027
commit c919794724
6 files changed
+254 -2

No files matched your search

+2 -1
View File
@@ -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`.
+3
View File
@@ -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
+8
View File
@@ -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.
+1 -1
View File
@@ -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) {
+200
View File
@@ -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); }
});
});
+40
View File
@@ -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 });