Files
ark-stronghold-protocol/test/ui/real.e2e.test.js
T

704 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Real-server browser E2E (DESIGN §11 "Browser E2E"): the real server (server/index.js on a free port) and headless
// Chrome clients driving the real UI with real clicks, keys and canvas drags — no net.request shortcuts.
//
// SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # co-op (2 humans + AI) to round 4+, then a solo run
// SP_REAL_E2E=1 SP_REAL_ROUNDS=6 node --test test/ui/real.e2e.test.js
//
// Co-op: host creates a 同盟模拟 room (险境), adds an AI teammate; the guest joins through the ?room= invite link;
// both ready, the host starts. Briefing ready → band draft (guest skips once, then picks a strategy nobody took —
// 队友已选) → every prep round: take a merge reward if offered (two taps), buy from the shop (two taps: 确认购买), drag
// hand pieces onto legal board tiles and choose a direction on the deploy wheel (g.move carries `dir`), sell a unit by
// tapping it → underframe 出售 +N, level up (two taps), refresh, freeze/unfreeze, equip bought items by dragging them
// onto a unit, ready (mouse / keyboard) → 机变 picks → combat rendered by the Pixi engine with real Spine units (client-
// side combat: no view switcher); once a human's own battle is over, a teammate row → 前往查看 shows that teammate's
// running battle and 返回战场 goes back (research 09 §3.1) → … until combat of round SP_REAL_ROUNDS (default 4).
// Solo: 独立模拟 (标准), free band pick, two rounds with shopping and deployment, then 放弃模拟 back to the lobby.
// Every page must finish with zero console errors, page errors, failed requests or HTTP errors.
// Screenshots: test/e2e/out/real-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } 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');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_REAL_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const ROUNDS = Math.max(2, Number(process.env.SP_REAL_ROUNDS) || 4);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const CHROME_ARGS = ['--no-sandbox', '--no-first-run', '--autoplay-policy=no-user-gesture-required', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'];
/**
* One player: its own Chrome process with a single (always visible) tab. Two tabs of one browser would leave one
* hidden — no animation frames, so the Pixi view and puppeteer's visibility checks stall.
*/
class Client {
constructor(puppeteer, base, label, { w = 1920, h = 1080 } = {}) {
Object.assign(this, { puppeteer, base, label, w, h, problems: [], log: [], shots: 0, hashed: { spine: 0, media: 0, other: 0 } });
}
async open(query = '') {
this.browser = await this.puppeteer.launch({ executablePath: CHROME, headless: true, args: CHROME_ARGS, protocolTimeout: 60000 });
const [first] = await this.browser.pages();
const page = first || await this.browser.newPage();
this.page = page;
await page.setViewport({ width: this.w, height: this.h });
page.on('console', (m) => { if (m.type() === 'error') this.problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => this.problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => this.problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`));
page.on('response', (r) => {
if (r.status() >= 400) this.problems.push(`http ${r.status()}: ${r.url()}`);
// the served manifest's content-hashed URLs (docs/ASSETS.md): Spine files, audio through /media/~<hash>/, the rest
const p = new URL(r.url()).pathname;
if (r.status() === 200 && /^\/(?:assets|media)\/~[0-9a-f]{16}\//.test(p)) {
this.hashed[/\.(?:skel|atlas)$|\/spine\//.test(p) ? 'spine' : p.startsWith('/media/') ? 'media' : 'other']++;
}
});
page.on('dialog', (d) => d.dismiss().catch(() => {}));
await page.goto(`${this.base}/${query}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
}
note(msg) { this.log.push(`[${this.label}] ${msg}`); }
/** Compact snapshot of the client store. */
st() {
return this.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public;
const priv = s.match.private;
const me = pub?.players?.find((p) => p.playerId === s.me.playerId) || null;
return {
me: s.me.playerId, room: s.room ? { code: s.room.code, inMatch: !!s.room.inMatch, mode: s.room.mode } : null,
phase: pub?.phase ?? null, round: pub?.round ?? 0, status: me?.status ?? null, alive: priv ? priv.alive !== false : true,
ready: !!priv?.ready, funds: priv?.funds ?? null, level: priv?.shop?.level ?? null, frozen: !!priv?.shop?.frozen,
upgradePrice: priv?.shop?.upgradePrice ?? null, refreshPrice: priv?.shop?.refreshPrice ?? null,
deployCap: priv?.deployCap ?? 8, deployCount: priv?.deployCount ?? 0, reward: !!priv?.shop?.rewardOffer,
hand: (priv?.hand || []).filter(Boolean).length, temp: (priv?.temp || []).filter(Boolean).length,
board: (priv?.board || []).length, result: !!s.match.result, field: s.match.field?.fieldId ?? null,
sp: pub?.sp ? { turn: pub.sp.turn, picked: Object.keys(pub.sp.picks || {}).length } : null,
draft: pub?.draft ? { turn: pub.draft.turn, picks: Object.keys(pub.draft.picks || {}).length } : null,
};
});
}
async waitFor(pred, what, timeout = 60000) {
const t0 = Date.now();
let last = null;
while (Date.now() - t0 < timeout) {
last = await this.st();
if (pred(last)) return last;
await sleep(250);
}
throw new Error(`${this.label}: timed out waiting for ${what} (last: ${JSON.stringify(last)})`);
}
async close() { await this.browser?.close().catch(() => {}); }
/** Real mouse click on the `nth` visible element matching `sel` whose text includes `text`. */
async click(sel, text = null, { timeout = 15000, optional = false, nth = 0 } = {}) {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
const pt = await this.page.evaluate((sel, text, nth) => {
let n = 0;
for (const el of document.querySelectorAll(sel)) {
if (text && !(el.textContent || '').includes(text)) continue;
if (el.disabled || el.getAttribute('aria-disabled') === 'true') continue;
const r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) continue;
const x = r.left + r.width / 2, y = r.top + r.height / 2;
const top = document.elementFromPoint(x, y);
if (top && !el.contains(top) && !top.contains(el)) continue; // covered
if (n++ < nth) continue;
return { x, y };
}
return null;
}, sel, text, nth);
if (pt) { await this.page.mouse.click(pt.x, pt.y); return true; }
await sleep(200);
}
if (optional) return false;
throw new Error(`${this.label}: nothing clickable for ${sel}${text ? ` "${text}"` : ''}`);
}
async exists(sel) { return !!(await this.page.$(sel)); }
async shot(name) {
await this.page.screenshot({ path: path.join(OUT, `real-${name}.png`) });
this.shots += 1;
}
/** Title → lobby with a callsign. */
async enter(name) {
await this.page.waitForSelector('.title-login input', { timeout: 20000 });
await this.click('.title-login input'); // fresh profile per client: the field starts empty
await this.page.keyboard.type(name);
await this.click('.title-login button', '开始');
}
// ---- field view helpers (render engine debug hooks) ------------------------------------------------------------
viewKind() { return this.page.evaluate(() => globalThis.__SP_VIEW__?.kind ?? null); }
viewStats() { return this.page.evaluate(() => { try { return globalThis.__SP_VIEW__?.raw?.stats?.() ?? null; } catch { return null; } }); }
/** Client point to grab a prep piece. */
piecePoint(uid) {
return this.page.evaluate((uid) => {
const r = globalThis.__SP_VIEW__?.pieceScreenRect(uid);
return r && r.width > 0 ? { x: r.left + r.width / 2, y: r.top + r.height * 0.72 } : null;
}, uid);
}
/** Client point of a board tile centre (top face). */
tilePoint(row, col) {
return this.page.evaluate((row, col) => {
const raw = globalThis.__SP_VIEW__?.raw;
const cam = raw?.debug?.cam;
if (!cam) return null;
const z = raw.debug.tiles.heightAt(row, col);
const p = cam.project(col, row, z);
const cr = raw.debug.app.view.getBoundingClientRect();
return { x: cr.left + p.x, y: cr.top + p.y };
}, row, col);
}
async drag(from, to, steps = 14) {
const m = this.page.mouse;
await m.move(from.x, from.y);
await m.down();
for (let i = 1; i <= steps; i++) { await m.move(from.x + ((to.x - from.x) * i) / steps, from.y + ((to.y - from.y) * i) / steps); await sleep(14); }
await sleep(120);
await m.up();
}
/** An empty legal tile for a hand piece (mirrors the deploy rules: melee on melee tiles, ranged anywhere). */
freeTileFor(uid) {
return this.page.evaluate((uid) => {
const { store, data } = globalThis.__SP__;
const s = store.get();
const priv = s.match.private;
const st = data.lookup('stages', s.match.public.stageId);
const piece = [...priv.hand, ...priv.temp].find((p) => p && p.uid === uid);
if (!piece || !st?.deployTiles?.normal) return null;
const rec = piece.kind === 'token' ? data.lookup('tokens', piece.id) : data.lookup('chess', piece.id);
const melee = rec?.position === 'MELEE';
const dt = st.deployTiles.normal;
const tiles = melee ? dt.melee : [...dt.rangedOnly, ...dt.melee];
const used = new Set(priv.board.map((p) => `${p.row},${p.col}`));
const free = tiles.filter(([r, c]) => !used.has(`${r},${c}`));
if (!free.length) return null;
free.sort((a, b) => (b[1] - a[1]) || (a[0] - b[0])); // towards the gates first
return { row: free[0][0], col: free[0][1] };
}, uid);
}
handPieces(kind = null) {
return this.page.evaluate((kind) => {
const priv = globalThis.__SP__.store.get().match.private;
return (priv?.hand || []).filter((p) => p && (!kind || p.kind === kind)).map((p) => ({ uid: p.uid, id: p.id, kind: p.kind, golden: !!p.golden }));
}, kind);
}
boardPieces() {
return this.page.evaluate(() => (globalThis.__SP__.store.get().match.private?.board || []).map((p) => ({
uid: p.uid, id: p.id, kind: p.kind, golden: !!p.golden, row: p.row, col: p.col, items: (p.items || []).length,
})));
}
async isEditable() {
const s = await this.st();
return s.phase === 'PREP' && !s.ready && s.alive;
}
// ---- prep actions ---------------------------------------------------------------------------------------------------
async takeReward() {
if (!(await this.exists('.shopbar__rwcards .scard:not(.scard--sold)'))) return false;
const before = await this.st();
await this.click('.shopbar__rwcards .scard:not(.scard--sold)');
await this.page.waitForSelector('.shopbar__rwcards .scard.is-armed', { timeout: 3000 });
await this.click('.shopbar__rwcards .scard.is-armed');
await this.waitFor((s) => !s.reward || s.phase !== 'PREP', 'reward taken', 8000);
this.note(`reward taken (hand ${before.hand} → ${(await this.st()).hand})`);
return true;
}
/** Two taps (research 09 §5): the first selects (确认购买), the second buys. */
async buyOne(sel = '.shopbar__cards .scard:not(.scard--sold):not(.is-disabled)') {
const before = await this.st();
const ok = await this.click(sel, null, { timeout: 1500, optional: true });
if (!ok) return false;
await this.page.waitForSelector('.scard.is-armed', { timeout: 3000 });
assert.equal((await this.st()).funds, before.funds, `${this.label}: the first tap only selects`);
await this.click('.scard.is-armed');
const after = await this.waitFor((s) => s.funds !== before.funds || s.phase !== 'PREP', 'purchase', 8000);
this.note(`bought${sel.includes('item') ? ' item' : ''} (funds ${before.funds} → ${after.funds})`);
return after.funds < before.funds;
}
buyItem() { return this.buyOne('.shopbar__item .scard:not(.scard--sold):not(.is-disabled)'); }
/** Right-click a board unit on the canvas → detail panel with its name. */
async inspectUnit() {
// the front-most unit (nothing is drawn in front of it)
const unit = (await this.boardPieces()).filter((b) => b.kind === 'chess').sort((a, b) => a.row - b.row || b.col - a.col)[0];
if (!unit) return false;
const name = await this.page.evaluate((id) => globalThis.__SP__.data.lookup('chess', id)?.name || '', unit.id);
const pt = await this.piecePoint(unit.uid);
await this.page.mouse.click(pt.x, pt.y, { button: 'right' });
await this.page.waitForSelector('.dpanel', { timeout: 5000 });
const shown = await this.page.$eval('.dpanel .dhead__name', (e) => e.textContent);
assert.equal(shown, name, `${this.label}: detail panel shows the right-clicked unit`);
await sleep(300);
await this.shot(`detail-${this.label}`);
await this.page.keyboard.press('Escape');
await this.page.waitForFunction(() => !document.querySelector('.dpanel'), { timeout: 4000 });
this.note(`inspected ${name}`);
return true;
}
/** Click the first bond disc → popup with the bond name; close it. */
async openBond() {
if (!(await this.exists('.bslot .bond'))) return false;
await this.click('.bslot .bond');
await this.page.waitForSelector('.bpop', { timeout: 5000 });
const name = await this.page.$eval('.bpop .bpop__name', (e) => e.textContent);
assert.ok(name && name.length > 0, 'bond popup has a name');
await sleep(250);
await this.shot(`bond-${this.label}`);
await this.click('.bpop__close');
this.note(`bond popup ${name}`);
return true;
}
/** Enemy preview: 🔍▶▶ pans to the pen (🔍◀◀ back); the list is the 敌方情报 tab of the 本局信息 dialog (left 🔍). */
async openEnemies() {
await this.click('.enemybtn');
const pen = await this.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 5000 }).then(() => true, () => false);
if (pen) {
await sleep(500);
await this.shot(`pen-${this.label}`);
await this.click('.gtop__iconbtn');
await this.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera !== 'pen', { timeout: 4000 });
}
await this.click('.gtop__iconbtn[aria-label="本局信息"]');
await this.page.waitForSelector('.edrawer', { timeout: 5000 });
await this.click('.edrawer .tabs__tab:nth-child(2)');
await sleep(250);
const rows = await this.page.$$eval('.edrawer .erow', (els) => els.length);
await this.shot(`enemies-${this.label}`);
await this.click('.edrawer__close');
this.note(`enemy pen ${pen ? 'shown' : 'unavailable'}, list: ${rows} rows`);
return rows;
}
async emote() {
await this.click('.ewheel__btn');
await this.click('.ewheel__item', null, { nth: 2 });
}
async deployFromHand(maxMoves = 3) {
let moved = 0;
for (let i = 0; i < maxMoves; i++) {
const s = await this.st();
if (!(await this.isEditable()) || s.deployCount >= s.deployCap) break;
const hand = (await this.handPieces()).filter((p) => p.kind !== 'item');
if (!hand.length) break;
const p = hand[0];
const tile = await this.freeTileFor(p.uid);
if (!tile) break;
const from = await this.piecePoint(p.uid);
const to = await this.tilePoint(tile.row, tile.col);
if (!from || !to) break;
await this.drag(from, to);
// the direction wheel (research 09 §1.2): swipe from its centre towards a side, release
await this.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
const w = await this.page.$eval('.fwheel__dia', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, half: r.width / 2 }; });
const up = i % 2 === 1;
await this.drag({ x: w.x, y: w.y }, { x: w.x + (up ? 1 : w.half * 0.7), y: w.y - (up ? w.half * 0.7 : 0) }, 10);
await this.waitFor((x) => x.board !== s.board || x.phase !== 'PREP', 'deploy', 6000).catch(() => null);
const onBoard = (await this.boardPieces()).find((b) => b.uid === p.uid);
assert.ok(onBoard, `${this.label}: dragged piece ${p.id} onto the board`);
assert.deepEqual([onBoard.row, onBoard.col], [tile.row, tile.col], `${this.label}: landed on the target tile`);
moved += 1;
}
if (moved) this.note(`deployed ${moved}`);
return moved;
}
/** Tap a normal board unit → its underframe → 出售 +N (research 09 §5: no drag-to-sell). */
async sellByTap() {
const unit = (await this.boardPieces()).filter((b) => b.kind === 'chess' && !b.golden && !b.items).sort((a, b) => a.row - b.row || b.col - a.col)[0];
if (!unit) return false;
const before = await this.st();
let open = false;
for (const at of [0.72, 0.5, 0.88]) {
const pt = await this.page.evaluate((uid, at) => { const r = globalThis.__SP_VIEW__?.pieceScreenRect(uid); return r && { x: r.left + r.width / 2, y: r.top + r.height * at }; }, unit.uid, at);
if (!pt) break;
await this.page.mouse.click(pt.x, pt.y);
if (await this.page.waitForSelector('.uframe__btn--sell', { timeout: 1500 }).then(() => true, () => false)) { open = true; break; }
}
if (!open) return false;
await this.click('.uframe__btn--sell');
const after = await this.waitFor((s) => s.board < before.board || s.phase !== 'PREP', 'sell', 6000);
assert.ok(after.funds > before.funds, `${this.label}: selling paid funds`);
this.note(`sold ${unit.id} with 出售 (funds ${before.funds} → ${after.funds})`);
return true;
}
/** Two taps: 确认升级 then the upgrade. */
async levelUp() {
const s = await this.st();
if (s.level >= 6 || s.funds < s.upgradePrice) return false;
await this.click('.lvcard');
await this.page.waitForSelector('.lvcard.is-armed', { timeout: 3000 });
await this.click('.lvcard.is-armed');
const after = await this.waitFor((x) => x.level > s.level || x.phase !== 'PREP', 'level up', 6000);
this.note(`level ${s.level} → ${after.level}`);
return after.level > s.level;
}
/** R shortcut → the shop is rerolled for the refresh price (or a free refresh from a 机变 card is used up). */
async refresh() {
const s = await this.st();
if (s.funds < s.refreshPrice) return false;
const shop = () => this.page.evaluate(() => { const sh = globalThis.__SP__.store.get().match.private.shop; return { ids: JSON.stringify(sh.slots.map((x) => x && x.id)), free: sh.freeRefreshes }; });
const before = await shop();
await this.page.mouse.click(this.w / 2, this.h * 0.3); // focus the page (not a HUD button)
await this.page.keyboard.press('KeyR');
const t0 = Date.now();
let after = null; let now = null;
while (Date.now() - t0 < 6000) {
after = await this.st(); now = await shop();
if (after.phase !== 'PREP' || after.funds !== s.funds || now.free !== before.free || now.ids !== before.ids) break;
await sleep(150);
}
assert.ok(after.funds !== s.funds || now.free !== before.free || now.ids !== before.ids, `${this.label}: R refreshed the shop`);
if (s.refreshPrice > 0) assert.equal(after.funds, s.funds - s.refreshPrice, `${this.label}: refresh cost`);
this.note(`refresh (${s.refreshPrice ? `funds ${s.funds} → ${after.funds}` : `free ${before.free} → ${now.free}`}, slots ${before.ids === now.ids ? 'same ids' : 'rerolled'})`);
return true;
}
/** 冻结 → 解冻 with the mouse. */
async freezeToggle() {
const s = await this.st();
await this.click('.toolbtn--ice');
await this.waitFor((x) => x.frozen !== s.frozen || x.phase !== 'PREP', 'freeze', 6000);
assert.ok(await this.exists('.shopbar.is-frozen'), 'the frozen shop is tinted');
await this.click('.toolbtn--ice', '解冻');
await this.waitFor((x) => x.frozen === s.frozen || x.phase !== 'PREP', 'unfreeze', 6000);
this.note('freeze toggled twice');
return true;
}
/** Drag a hand item onto a board operator → equipped. */
async equipItem() {
const item = (await this.handPieces('item'))[0];
const target = (await this.boardPieces()).find((b) => b.kind === 'chess' && b.items < 2);
if (!item || !target) return false;
const from = await this.piecePoint(item.uid);
const to = await this.piecePoint(target.uid);
if (!from || !to) return false;
await this.drag(from, to);
await sleep(400);
// a full operator: the equip-replace dialog (ui/equipReplace.js) — replace the older item
if (await this.exists('.eqr .eqr__opt')) {
await this.click('.eqr__opt');
await this.click('.eqr__ok');
await sleep(400);
}
const after = (await this.boardPieces()).find((b) => b.uid === target.uid);
const itemGone = !(await this.handPieces('item')).some((p) => p.uid === item.uid);
this.note(`equip ${item.id} → ${target.id}: ${itemGone && after && after.items > target.items ? 'ok' : 'not applied'}`);
return itemGone;
}
async ready() {
if (!(await this.isEditable())) return;
await this.click('.readybtn');
await this.confirmFundsLeft();
await this.waitFor((s) => s.ready || s.phase !== 'PREP', 'ready', 8000);
}
/** 准备 with funds left asks first (剩余资金, DESIGN §23.11): confirm it, as a player who means to start the fight does. */
async confirmFundsLeft() {
await sleep(250);
const asked = await this.page.evaluate(() => {
const t = document.querySelector('.modal__title');
return !!(t && t.textContent.includes('剩余资金'));
});
if (asked) await this.click('.modal__actions button', '准备就绪', { timeout: 4000 });
}
}
async function assertClean(clients) {
for (const c of clients) {
assert.deepEqual(c.problems, [], `${c.label}: console / page / network errors`);
}
}
describe('real server + real browsers', { skip: !ENABLED && 'set SP_REAL_E2E=1 (needs Chrome and downloaded assets)' }, () => {
let srv;
let puppeteer;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
// the content-hash index first, so the pages load the served manifests' /assets/~<hash>/… URLs like a player's
await srv.assetIndex?.whenReady();
base = `http://127.0.0.1:${srv.port}`;
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await srv?.close();
});
test(`co-op: 2 humans + AI from the lobby through combat of round ${ROUNDS}`, { timeout: 30 * 60 * 1000 }, async () => {
const host = new Client(puppeteer, base, 'host');
const guest = new Client(puppeteer, base, 'guest', { w: 1280, h: 720 });
const both = [host, guest];
try {
// ---- lobby & room --------------------------------------------------------------------------------------------
await host.open();
await host.enter('凯尔希');
await host.click('.mode-card', '同盟模拟');
await host.click('.diff-card', '险境模拟');
await host.click('.create-box button', '创建同盟');
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
await host.click('button', '添加 AI 队友');
await guest.open(`?room=${room.code}`);
await guest.enter('阿米娅');
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined via invite link');
await guest.click('.room-bar__right button', '准备就绪');
await host.shot('room');
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
// ---- briefing ---------------------------------------------------------------------------------------------------
for (const c of both) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
await sleep(800);
await host.shot('briefing');
await guest.shot('briefing-720');
for (const c of both) await c.click('.brief__foot .btn--primary', '准备就绪');
// ---- band draft -------------------------------------------------------------------------------------------------
for (const c of both) await c.waitFor((s) => s.phase === 'BAND_DRAFT' || s.phase === 'BATTLE_CHECK' || s.phase === 'PREP', 'band draft', 40000);
let guestSkipped = false;
let shotDraft = false;
const picked = new Set();
const t0 = Date.now();
while (Date.now() - t0 < 150000) {
const [hs, gs] = [await host.st(), await guest.st()];
if (hs.phase !== 'BAND_DRAFT' && gs.phase !== 'BAND_DRAFT') break;
for (const [c, s] of [[host, hs], [guest, gs]]) {
if (s.phase !== 'BAND_DRAFT' || picked.has(c.label) || s.draft?.turn !== s.me) continue;
if (!shotDraft) { await sleep(600); await c.shot('draft'); shotDraft = true; }
if (c === guest && !guestSkipped) {
guestSkipped = true;
// one skip — only while someone is left to pass to: the last player still to pick sees 跳过 disabled (PR #546)
const canSkip = await c.page.evaluate(() => [...document.querySelectorAll('.draft-detail__btns .btn')]
.some((b) => b.textContent.includes('跳过') && !b.disabled));
if (canSkip) {
await c.click('.draft-detail__btns .btn', '跳过');
c.note('skipped once');
continue;
}
c.note('跳过 disabled (the last player still to pick)');
}
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 3 : 7 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
picked.add(c.label);
c.note('band picked');
}
await sleep(300);
}
assert.ok(picked.size === 2, `both humans picked a band (${[...picked]})`);
// ---- rounds -----------------------------------------------------------------------------------------------------
const seen = new Set();
const did = { watched: false, sold: false, leveled: false, refreshed: false, froze: false, item: false, equipped: false, inspected: false, bond: false, enemies: -1, emote: false };
let spPicked = 0;
let lastCombatRound = 0;
const deadline = Date.now() + 25 * 60 * 1000;
while (Date.now() < deadline) {
const hs = await host.st();
if (hs.result || hs.phase === 'RESULT') break;
seen.add(hs.phase);
if (hs.phase === 'PREP') {
for (const c of both) {
if (!(await c.isEditable())) continue;
const s = await c.st();
if (!seen.has(`prep-shot-${c.label}-${s.round}`)) {
seen.add(`prep-shot-${c.label}-${s.round}`);
await sleep(1700); // past the 休整期 banner
await c.shot(`prep-r${s.round}-${c.label}`);
}
await c.takeReward();
if (c === host && did.enemies < 0) did.enemies = await c.openEnemies();
if (c === guest && !did.emote) {
await c.emote();
await host.page.waitForSelector('.team__bubble', { timeout: 5000 });
await host.shot('emote-bubble');
did.emote = true;
c.note('emote seen by the host');
}
if (c === guest && s.round >= 2 && !did.leveled) did.leveled = await c.levelUp();
if (c === host && s.round >= 2 && !did.item) did.item = await c.buyItem();
if (c === host && s.round >= 3 && !did.refreshed) did.refreshed = await c.refresh();
for (let k = 0; k < 3; k++) if (!(await c.buyOne())) break;
await c.deployFromHand(4);
if (c === host && s.round >= 2 && !did.sold) did.sold = await c.sellByTap();
if (!did.equipped) did.equipped = await c.equipItem();
if (c === host && s.round >= 2 && !did.inspected) did.inspected = await c.inspectUnit();
if (c === host && s.round >= 2 && !did.bond) did.bond = await c.openBond();
if (c === guest && !did.froze) did.froze = await c.freezeToggle();
await c.deployFromHand(2);
const s2 = await c.st();
if (s2.temp > 0) c.note(`temp not empty (${s2.temp}) — ready blocked`);
if (c === guest) { await c.page.keyboard.press('Space'); await c.confirmFundsLeft(); await c.waitFor((x) => x.ready || x.phase !== 'PREP', 'ready (Space)', 8000); } else await c.ready();
}
} else if (hs.phase === 'SP_DRAFT') {
for (const c of both) {
const s = await c.st();
if (s.phase === 'SP_DRAFT' && s.sp?.turn === s.me && (await c.exists('.spcard.is-pickable'))) {
if (!seen.has(`sp-shot-${s.round}`)) { seen.add(`sp-shot-${s.round}`); await sleep(500); await c.shot(`sp-r${s.round}-${c.label}`); }
// two taps (user playtest #4 item 2): select, then tap the selected card again
await c.click('.spcard.is-pickable');
await c.click('.spcard.is-armed');
await c.waitFor((x) => x.sp?.turn !== x.me || x.phase !== 'SP_DRAFT', 'sp pick', 8000);
spPicked += 1;
c.note(`机变 picked (round ${s.round})`);
}
}
} else if (['COMBAT', 'UNITE', 'FINAL_ASSAULT', 'HIDDEN_CORE'].includes(hs.phase) && lastCombatRound !== hs.round * 10 + (hs.phase === 'UNITE' ? 1 : 0)) {
lastCombatRound = hs.round * 10 + (hs.phase === 'UNITE' ? 1 : 0);
assert.equal(await host.viewKind(), 'engine', 'the Pixi render engine is mounted (not the DOM fallback)');
// units rendered by the engine, Spine models loaded
const t1 = Date.now();
let st = null;
while (Date.now() - t1 < 15000) {
st = await host.viewStats();
if (st && st.mode === 'battle' && st.units > 0 && (st.spine?.ready ?? 0) > 0) break;
await sleep(300);
}
assert.ok(st && st.mode === 'battle' && st.units > 0, `round ${hs.round} ${hs.phase}: battle units on screen (${JSON.stringify(st && { mode: st.mode, units: st.units })})`);
assert.ok((st.spine?.ready ?? 0) > 0, `round ${hs.round}: Spine models loaded (${JSON.stringify(st.spine)})`);
assert.equal(st.spine?.failed ?? 0, 0, 'no Spine load failures');
await sleep(1500);
await host.shot(`combat-r${hs.round}${hs.phase === 'UNITE' ? '-unite' : ''}`);
await guest.shot(`combat-r${hs.round}${hs.phase === 'UNITE' ? '-unite' : ''}-720`);
host.note(`round ${hs.round} ${hs.phase}: ${st.units} views, fps ${st.fps}, spine ${JSON.stringify(st.spine)}`);
// research 09 §3.1: no view switcher in normal combat; after the own battle ends, a teammate row → 前往查看
if (hs.phase === 'COMBAT') assert.equal(await host.exists('.chud .vswitch__arrow'), false, 'no ‹ › switcher during normal combat');
const tObs = Date.now();
while (!did.watched && hs.phase === 'COMBAT' && Date.now() - tObs < 90000) {
const cs = await host.st();
if (cs.phase !== 'COMBAT' || cs.round !== hs.round) break;
for (const c of both) {
if (did.watched) break;
const rs = await c.page.evaluate(() => globalThis.__SP_RUNNER__?.state() ?? null);
if (!rs || !rs.own || !rs.done) continue;
const other = await c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public;
const f = (pub?.fields || []).find((x) => x.kind === 'normal' && x.live && !x.players.includes(s.me.playerId));
if (!f) return null;
const rows = (pub.players || []).slice().sort((a, b) => a.seat - b.seat).filter((p) => p.playerId !== s.me.playerId);
return { fieldId: f.fieldId, nth: rows.findIndex((p) => p.playerId === f.players[0]) };
});
if (!other || other.nth < 0) continue;
await c.click('.team__row:not(.is-self) .team__btn', null, { nth: other.nth });
if (!(await c.click('.team__ob', '前往查看', { optional: true, timeout: 3000 }))) continue;
const ok = await c.page.waitForFunction((fid) => { const s = globalThis.__SP_RUNNER__?.state(); return !!s && s.fieldId === fid && s.watch; }, { timeout: 15000 }, other.fieldId).then(() => true, () => false);
if (!ok) continue;
await sleep(1200);
const other2 = await c.viewStats();
assert.ok(other2 && other2.units > 0, 'the observed field renders units');
await c.shot(`combat-observe-r${hs.round}`);
await c.click('.team__back, .chud__back', '返回战场', { optional: true, timeout: 3000 });
did.watched = true;
c.note(`前往查看 ${other.fieldId} (${other2.units} views) and 返回战场`);
}
await sleep(400);
}
if (hs.round >= ROUNDS && hs.phase !== 'UNITE') break;
}
await sleep(400);
}
const end = await host.st();
assert.ok(end.round >= ROUNDS || end.result, `reached round ${ROUNDS} (at ${end.round} ${end.phase})`);
assert.ok(seen.has('PREP') && seen.has('COMBAT'), 'saw prep and combat');
host.note(`did: ${JSON.stringify(did)}, 机变 picks: ${spPicked}, phases: ${[...seen].filter((x) => !x.includes('-')).join(',')}`);
assert.ok(did.watched, 'observed a teammate through 前往查看');
assert.ok(did.sold, 'sold a unit with the underframe 出售');
assert.ok(did.refreshed, 'refreshed the shop (R)');
assert.ok(did.froze, 'froze + unfroze the shop');
assert.ok(did.leveled, 'levelled the dispatch center up');
assert.ok(did.inspected && did.bond, 'detail panel + bond popup');
assert.ok(did.emote, 'emote bubble reached the teammate');
assert.ok(did.enemies >= 0, 'enemy drawer opened');
if (srv.assetIndex) {
host.note(`content-hashed responses: ${JSON.stringify(host.hashed)}`);
assert.ok(host.hashed.spine > 0 && host.hashed.media > 0 && host.hashed.other > 0, `Spine, audio and art through hashed URLs: ${JSON.stringify(host.hashed)}`);
}
await assertClean(both);
} finally {
console.log([...host.log, ...guest.log].join('\n'));
for (const c of both) if (c.problems.length) console.log(`${c.label} problems:\n ${c.problems.slice(0, 20).join('\n ')}`);
for (const c of both) await c.close();
}
});
test('solo: 独立模拟 through two rounds, then 放弃模拟 back to the lobby', { timeout: 10 * 60 * 1000 }, async () => {
const solo = new Client(puppeteer, base, 'solo');
try {
await solo.open();
await solo.enter('杜宾');
await solo.click('.mode-card', '独立模拟');
await solo.click('.diff-card', '标准模拟');
await solo.click('.create-box button', '开始独立模拟');
await solo.waitFor((s) => !!s.room, 'solo room');
if (!(await solo.st()).phase) await solo.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
await solo.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
await solo.click('.brief__foot .btn--primary', '准备就绪');
await solo.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await sleep(600);
await solo.shot('solo-draft');
await solo.click('.dband', null, { nth: 1 });
await solo.click('.draft-detail__btns .btn--primary', '确认选择');
for (let round = 1; round <= 2; round++) {
await solo.waitFor((s) => s.phase === 'PREP' && s.round === round && !s.ready, `prep ${round}`, 60000);
await sleep(700);
await solo.takeReward();
for (let k = 0; k < 3; k++) if (!(await solo.buyOne())) break;
await solo.deployFromHand(4);
await solo.shot(`solo-prep-r${round}`);
await solo.ready();
await solo.waitFor((s) => s.phase === 'COMBAT', `combat ${round}`, 30000);
const t1 = Date.now();
let st = null;
while (Date.now() - t1 < 15000) { st = await solo.viewStats(); if (st?.mode === 'battle' && st.units > 0) break; await sleep(300); }
assert.ok(st?.units > 0, 'solo battle renders units');
await sleep(1500);
await solo.shot(`solo-combat-r${round}`);
}
// leave for good: exit → 放弃模拟 → lobby
await solo.click('.gtop__exit');
await solo.click('.modal__actions .btn', '放弃模拟');
await solo.waitFor((s) => !s.room && !s.phase, 'back in the lobby', 20000);
await solo.page.waitForSelector('.lobby-screen', { timeout: 10000 });
await assertClean([solo]);
} finally {
console.log(solo.log.join('\n'));
if (solo.problems.length) console.log(`solo problems:\n ${solo.problems.slice(0, 20).join('\n ')}`);
await solo.close();
}
});
});