feedback7: watch-bonds' "after the own battle" test gets a real window — the guest's enemies spawn at twice their times (new fastServer hook SP_SLOW_SPAWNS, startRealServer fast.slowSpawns), the host fields its 萨尔贡 five without 菲莱 (the planner puts a zero-range guard on the road since PR #339 and she held the host's battle to 109 s of 110), and 返回战场 tolerates the battles ending; measured: at the scheduled times a leaker's walk outlasted the host by 5–11 s in 0.2.1 and 0.2.2 alike (the 0.2.2 full pass lost 返回战场 to 联防)

This commit is contained in:
sganggs committed 2026-10-09 00:52:08 +08:00
1 parent e5e3f05bb2
commit 9bacd9ad45
3 files changed
+40 -9

No files matched your search

+4 -2
View File
@@ -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). * `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[], * @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, * 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 * 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 * 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: '<humanIdx>:<factor>' — 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<void> }>} * @returns {Promise<{ base: string, port: number, logs: string[], stop: (o?: { hard?: boolean }) => Promise<void> }>}
*/ */
export async function startRealServer(opts = {}) { 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.stage) env.SP_STAGE = String(opts.fast.stage);
if (opts.fast.level) env.SP_START_LEVEL = String(opts.fast.level); 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.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 child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
const logs = []; const logs = [];
+18
View File
@@ -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_START_SHOP stocks its first slots) — test/ui/diy.e2e.test.js: a tier-5 自选 piece is sold from level 5.
// SP_FINISH_AFTER=<round>: the match ends (a defeat: m.result, the RESULT screen) once that round has settled, instead // SP_FINISH_AFTER=<round>: 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. // 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=<humanIdx>:<factor>: that human's (seat order) normal battles spawn their enemies at <factor> × 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). // 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'; 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 forcedStage = String(process.env.SP_STAGE || '').trim();
const startLevel = Math.max(0, Math.min(6, Number(process.env.SP_START_LEVEL) || 0)); 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 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 { class FastMatch extends Match {
constructor(opts) { constructor(opts) {
@@ -127,6 +132,19 @@ if (!process.env.NODE_TEST_CONTEXT) {
super.afterSettle(); 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) { scheduleBotPrep(ps, i = 0) {
if (!idleBots) { super.scheduleBotPrep(ps, i); return; } if (!idleBots) { super.scheduleBotPrep(ps, i); return; }
const round = this.round; const round = this.round;
+18 -7
View File
@@ -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 // 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 // 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. // 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 // After the own battle (2 humans + 1 idle AI from round 3, 1× combat, the guest's enemies spawning at twice their times):
// teammate still fighting → the strip shows that teammate's bonds with the observing pill "👁 name" + 返回战场; a // 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. // 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), // Unit counterparts: test/ui/watch-bonds.test.js (selection logic incl. 联防), test/match/watch-bonds.test.js (views),
// test/match/observe.test.js (resumedWatch). // 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 // 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 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']; 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. */ /** The strip as shown: whose (data-owner), the bonds (data-bond) with their counts, the tag's text. */
const stripOf = (c) => c.page.evaluate(() => { 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 () => { 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' // the host's five 萨尔贡 operators end their battle first; the guest (no board) leaks, and its enemies spawn at twice
// whole walk (combat at 1×: a window for the reload) — the host watches one of them, reloads, then goes back // 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, 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; const P = (await import('puppeteer-core')).default;
let host = null; let host = null;
let guest = 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)})`); 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 sleep(1500); // the phase banner of the fresh screen
await host.shot('reloaded'); await host.shot('reloaded');
// 返回战场 → the own bonds, untagged // 返回战场 → the own bonds, untagged. The watched battle may end meanwhile — 联防 or the settlement takes the
await host.click('.chud__observe .chud__back', '返回战场'); // 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 }); await host.page.waitForFunction(() => !document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner'), { timeout: 8000 });
st = await stripOf(host); st = await stripOf(host);
assert.deepEqual([st.owner, st.observe], [null, null], 'back: own bonds, no observing pill'); assert.deepEqual([st.owner, st.observe], [null, null], 'back: own bonds, no observing pill');