0.2.6-dev: merge PR #546 by Xinglan233 — the strategy draft enables 跳过 only while another unpicked player remains (finished players stay in the shown order but are not skip targets)
This commit is contained in:
commit
77865e6bc2
6 files changed
+254
-2
No files matched your search
+2
-1
@@ -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`.
|
||||
|
||||
|
||||
@@ -56,6 +56,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
|
||||
|
||||
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user