diff --git a/test/e2e/client.mjs b/test/e2e/client.mjs index 1f7828ce..f8790577 100644 --- a/test/e2e/client.mjs +++ b/test/e2e/client.mjs @@ -61,10 +61,11 @@ export function freePort() { * `port`: e.g. the port of a server just stopped — a server restart the open clients reconnect to). * @param {{ port?: number, fast?: { timerScale?: number, combatSpeed?: number, startRound?: 'boss'|'hidden'|number, kit?: number, chess?: string[], * items?: string[], idleBots?: boolean, kits?: string[][], botChess?: string[], autoPlace?: boolean, eliminate?: number[], stage?: string, - * finishAfter?: number } }} [opts] + * finishAfter?: number, slowSpawns?: string } }} [opts] * kits: per-human starter kits (seat order); botChess: the AI seats' kit; autoPlace: the kits go onto the board at the * first prep; eliminate: humans (seat order) eliminated at the jump; stage: the stage of every match; finishAfter: the - * match ends (RESULT) once that round has settled — fastServer.mjs hooks + * match ends (RESULT) once that round has settled; slowSpawns: ':' — that human's normal battles + * spawn at factor × the scheduled times — fastServer.mjs hooks * @returns {Promise<{ base: string, port: number, logs: string[], stop: (o?: { hard?: boolean }) => Promise }>} */ export async function startRealServer(opts = {}) { @@ -90,6 +91,7 @@ export async function startRealServer(opts = {}) { if (opts.fast.stage) env.SP_STAGE = String(opts.fast.stage); if (opts.fast.level) env.SP_START_LEVEL = String(opts.fast.level); if (opts.fast.finishAfter) env.SP_FINISH_AFTER = String(opts.fast.finishAfter); + if (opts.fast.slowSpawns) env.SP_SLOW_SPAWNS = String(opts.fast.slowSpawns); } const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] }); const logs = []; diff --git a/test/e2e/fastServer.mjs b/test/e2e/fastServer.mjs index 768f3d28..ffcb3c3f 100644 --- a/test/e2e/fastServer.mjs +++ b/test/e2e/fastServer.mjs @@ -26,6 +26,9 @@ // SP_START_SHOP stocks its first slots) — test/ui/diy.e2e.test.js: a tier-5 自选 piece is sold from level 5. // SP_FINISH_AFTER=: the match ends (a defeat: m.result, the RESULT screen) once that round has settled, instead // of going on to the next round — test/ui/standin.e2e.test.js: the result lineup of the board just fought with. +// SP_SLOW_SPAWNS=:: that human's (seat order) normal battles spawn their enemies at × the +// scheduled times (and intervals) — a leaker's battle then runs to the round's time limit, long after a teammate's quick +// one (test/ui/watch-bonds.e2e.test.js, "after the own battle": a window for watching and a reload). // Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set). import { startServer } from '../../server/index.js'; @@ -52,6 +55,8 @@ if (!process.env.NODE_TEST_CONTEXT) { const forcedStage = String(process.env.SP_STAGE || '').trim(); const startLevel = Math.max(0, Math.min(6, Number(process.env.SP_START_LEVEL) || 0)); const finishAfter = Math.max(0, Number(process.env.SP_FINISH_AFTER) || 0); + const [slowIdx, slowFactor] = String(process.env.SP_SLOW_SPAWNS || '').split(':').map(Number); + const slowSpawns = Number.isInteger(slowIdx) && slowIdx >= 0 && slowFactor > 0 ? { idx: slowIdx, factor: slowFactor } : null; class FastMatch extends Match { constructor(opts) { @@ -127,6 +132,19 @@ if (!process.env.NODE_TEST_CONTEXT) { super.afterSettle(); } + _ccField(args) { + const f = super._ccField(args); + const ps = slowSpawns && f.kind === 'normal' ? this.order.filter((x) => !x.isBot)[slowSpawns.idx] : null; + if (ps && f.players.includes(ps.playerId)) { + for (const x of f.spec.spawns) { + if (!x || !Number.isFinite(Number(x.time))) continue; + x.time = Number(x.time) * slowSpawns.factor; + if (Number(x.interval) > 0) x.interval = Number(x.interval) * slowSpawns.factor; + } + } + return f; + } + scheduleBotPrep(ps, i = 0) { if (!idleBots) { super.scheduleBotPrep(ps, i); return; } const round = this.round; diff --git a/test/ui/watch-bonds.e2e.test.js b/test/ui/watch-bonds.e2e.test.js index 40ee4225..dfe94a8e 100644 --- a/test/ui/watch-bonds.e2e.test.js +++ b/test/ui/watch-bonds.e2e.test.js @@ -17,8 +17,8 @@ // the pair field → its first player's bonds (never its own), taps the second player's operator and its card's bond // chip → the SECOND player's popup (the strip stays on the first), taps each pair player → their half + bonds, taps // the lone field's player → that field and its player's bonds. -// After the own battle (2 humans + 1 idle AI from round 3, 1× combat): the host's battle ends first, the host watches a -// teammate still fighting → the strip shows that teammate's bonds with the observing pill "👁 name" + 返回战场; a +// After the own battle (2 humans + 1 idle AI from round 3, 1× combat, the guest's enemies spawning at twice their times): +// the host's battle ends first, the host watches a teammate still fighting → the strip shows that teammate's bonds with the observing pill "👁 name" + 返回战场; a // reload keeps all three (the resent field is adopted — battle/observe.js resumedWatch); 返回战场 → the own bonds. // Unit counterparts: test/ui/watch-bonds.test.js (selection logic incl. 联防), test/match/watch-bonds.test.js (views), // test/match/observe.test.js (resumedWatch). @@ -39,6 +39,10 @@ const GUEST_KIT = ['chess_char_1_03_a', 'chess_char_1_09_a']; // 惊蛰 (炎), // the AI seats five 拉特兰 ones + 古米 (信仰搅拌机, 莫斯提马, 能天使, 空弦, 送葬人; 坚守) — different bonds on each strip const UNITE_HOST_KIT = ['chess_char_4_23_a', 'chess_char_4_24_a', 'chess_char_3_06_a', 'chess_char_3_13_a', 'chess_char_1_12_a', 'chess_char_2_06_a']; const BOT_KIT = ['chess_char_4_01_a', 'chess_char_4_02_a', 'chess_char_3_01_a', 'chess_char_3_21_a', 'chess_char_2_01_a', 'chess_char_1_10_a']; +// "after the own battle": the same host without 菲莱 — the layout planner behind fastServer's autoPlace puts a zero-range +// guard on a road tile first (PR #339, §27.35), where she holds the enemies and the host's battle could run 109 s of a +// 110 s round-3 limit (measured; 53–68 s without her) +const AFTER_HOST_KIT = UNITE_HOST_KIT.filter((id) => id !== 'chess_char_3_06_a'); /** The strip as shown: whose (data-owner), the bonds (data-bond) with their counts, the tag's text. */ const stripOf = (c) => c.page.evaluate(() => { @@ -470,10 +474,12 @@ describe('DESIGN §20.15 — the bond strip shows the watched teammate\'s bonds }); test('after the own battle: the watched teammate\'s bonds, kept through a reload with the observing pill + 返回战场 (desktop)', { timeout: 12 * 60 * 1000 }, async () => { - // the host's six 萨尔贡 operators end their battle early; the guest (no board) and the idle AI leak for the enemies' - // whole walk (combat at 1×: a window for the reload) — the host watches one of them, reloads, then goes back + // the host's five 萨尔贡 operators end their battle first; the guest (no board) leaks, and its enemies spawn at twice + // their times (fastServer SP_SLOW_SPAWNS), so its battle runs to the round's time limit — a window of 40 s and more + // for watching it and a reload (at the scheduled times a leaker's walk outlasted the host's battle by 5–11 s in 0.2.1 + // and 0.2.2 alike, and the 0.2.2 full pass lost 返回战场 to 联防); the host watches the guest, reloads, then goes back const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 1, startRound: 3, idleBots: true, autoPlace: true, - kits: [UNITE_HOST_KIT, []] } }); + kits: [AFTER_HOST_KIT, []], slowSpawns: '1:2' } }); const P = (await import('puppeteer-core')).default; let host = null; let guest = null; @@ -539,8 +545,13 @@ describe('DESIGN §20.15 — the bond strip shows the watched teammate\'s bonds for (const id of await pubBonds()) assert.ok(st.bonds.includes(id), `after the reload: ${name}'s ${id} on the strip (${JSON.stringify(st.bonds)})`); await sleep(1500); // the phase banner of the fresh screen await host.shot('reloaded'); - // 返回战场 → the own bonds, untagged - await host.click('.chud__observe .chud__back', '返回战场'); + // 返回战场 → the own bonds, untagged. The watched battle may end meanwhile — 联防 or the settlement takes the + // screen and the pill with it (the 0.2.2 full pass) — then the next round tries again + if (!(await host.click('.chud__observe .chud__back', '返回战场', { optional: true, timeout: 8000 }))) { + const phase = (await host.st()).phase; + if (phase !== 'COMBAT') { console.log(`round ${round}: the battles ended before 返回战场 (${phase}), next round`); continue; } + throw new Error('host: nothing clickable for .chud__observe .chud__back "返回战场" while the battle runs'); + } await host.page.waitForFunction(() => !document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner'), { timeout: 8000 }); st = await stripOf(host); assert.deepEqual([st.owner, st.observe], [null, null], 'back: own bonds, no observing pill');