Files
ark-stronghold-protocol/test/e2e/coop.e2e.mjs
T

1377 lines
77 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 (`node server/index.js` on a free port, real
// match engine) and headless Chrome clients (puppeteer-core + the system Chrome) driving the real UI with real
// clicks, keys and canvas drags — never net.request shortcuts.
//
// node test/e2e/coop.e2e.mjs # everything (facing, co-op, solo, result)
// SP_E2E=1 node --test test/e2e/coop.e2e.mjs # same through the test runner (skipped in `npm test` otherwise)
// SP_E2E_ONLY=facing|coop|solo|result SP_E2E_ROUNDS=5 CHROME_PATH=…
//
// Co-op (host 1920×1080, guest 1280×720): title → 玩法说明 → lobby → create a 同盟模拟 room (险境) → add an AI → the
// guest joins through the ?room= invite link → ready → start → briefing (both ready) → band draft (the guest skips
// once) → rounds 1..N: merge reward if offered, enemy drawer, emote (bubble seen by the teammate), level up (two taps),
// buy an item, refresh (R), buy operators (two taps: 确认购买), drag hand → board (legal tiles lit) + choose a direction on
// the deploy wheel (g.move carries `dir`), board → board (+ wheel), board → hand, re-orient in place (drop on the own
// tile + wheel), sell by tapping a unit → underframe 出售 +N, equip an item by dragging it onto an operator, detail
// panel (right-click), bond popup, freeze and unfreeze, watch a teammate's field during prep (前往查看), settings,
// in-game 玩法说明, ready (click / Space) → 机变 picks →
// combat rendered by the Pixi engine with units on screen. Combat is client-side (DESIGN §14): each browser simulates
// its own battle (battle/runner.js) and reports it; no view switcher during normal combat; once a player's own battle is
// over (⌛ 作战结束,等待队友完成作战 + teammates' progress) a teammate row → 前往查看 shows a local replica of a
// teammate's running battle and 返回战场 goes back; one forced disconnect mid-combat (the guest's socket drops and
// reconnects): the server takes the field over and the guest keeps watching it as a display replica; LP changes are
// settled from the reported results.
// Every step checks the DOM against the store (funds, round, LP, deploy counter, shop cards, team rows).
// Facing (research 09 §1.2 / §5 / §6.5): a solo 独立模拟 on the real server exercising the official prep interactions
// one by one with screenshots test/e2e/out/facing-*.png — two-tap buy (确认购买) and upgrade (确认升级), place + choose a
// direction on the wheel (swipe UP, live striped range, "拖回中心区域取消"), cancel by ✕ and by releasing in the centre
// (the piece returns), re-orient in place, tap → underframe (撤退 / 出售 +N) + range, 撤退, tap-sell, item → 销毁 only,
// long-press-free right-click detail, no drag-to-sell zone, touch (pointerType touch) placement through the wheel.
// Solo: 独立模拟 (绝境) through round 3 (incl. the solo 机变 of round 3). Result: a solo run on a fast-timer server
// (same engine, scaled timers) to RESULT, then 返回同盟.
// Every page must finish with zero console errors, page errors, failed requests or HTTP errors.
// Screenshots: test/e2e/out/{coop,solo,result}-*.png (…-720.png = the 1280×720 client).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from './client.mjs';
const ENABLED = (process.env.SP_E2E === '1' || !process.env.NODE_TEST_CONTEXT) && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const ONLY = process.env.SP_E2E_ONLY || '';
const ROUNDS = Math.max(4, Number(process.env.SP_E2E_ROUNDS) || 4);
const COMBAT = ['COMBAT', 'UNITE', 'FINAL_ASSAULT', 'HIDDEN_CORE'];
let puppeteer = null;
async function pptr() {
if (!puppeteer) puppeteer = (await import('puppeteer-core')).default;
return puppeteer;
}
// ---- UI actions --------------------------------------------------------------------------------------------------
/** DOM ⇄ store consistency of the in-match HUD. */
async function checkHud(c, where) {
const r = await c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public; const priv = s.match.private;
const txt = (sel) => document.querySelector(sel)?.textContent?.trim() ?? null;
const shopChess = (priv?.shop?.slots || []).filter((x) => x && x.kind !== 'item').length;
return {
phase: pub?.phase, round: pub?.round, lastRound: pub?.lastRound, players: (pub?.players || []).length,
lp: priv?.lp, teamLp: pub?.teamLp, funds: priv?.funds, cap: priv?.deployCap, count: priv?.deployCount,
boardChess: (priv?.board || []).filter((p) => p.kind === 'chess').length, hand: (priv?.hand || []).length, temp: (priv?.temp || []).length,
domRound: txt('.roundbox__num'), domLp: txt('.gtop .lp b'), domFunds: txt('.funds__num'), domRemain: txt('.shopbar__remain b'),
teamRows: document.querySelectorAll('.team__row').length, cards: document.querySelectorAll('.shopbar__cards > *').length,
shopChess, shopShown: !!document.querySelector('.shopbar'),
};
});
const tag = `${c.label} ${where}`;
if (!r.phase) return r;
assert.ok(r.funds == null || r.funds >= 0, `${tag}: funds never negative`);
assert.ok(r.hand === 10 && r.temp === 5, `${tag}: hand/temp sizes (${r.hand}/${r.temp})`);
assert.ok(r.count == null || r.count <= r.cap, `${tag}: deploy count ≤ cap`);
if (r.count != null) assert.equal(r.boardChess, r.count, `${tag}: deployCount = chess on board`);
if (r.round > 0 && r.round <= r.lastRound) assert.equal(r.domRound, String(r.round), `${tag}: top bar round`);
assert.equal(r.teamRows, r.players, `${tag}: one team row per player`);
if (r.domLp != null && ['COMBAT', 'UNITE'].includes(r.phase)) {
// live LP (DESIGN §17.5): while the own battle's enemies enter the blue gate the top bar already shows lp − the
// pending loss (≤ 10 a round), settled at SETTLE
const shown = Number(r.domLp);
assert.ok(shown <= r.lp && shown >= Math.max(0, r.lp - 10), `${tag}: top bar LP ${r.domLp} within the round's pending loss of ${r.lp}`);
} else if (r.domLp != null && !['FINAL_ASSAULT', 'HIDDEN_CORE'].includes(r.phase)) assert.equal(r.domLp, String(r.lp), `${tag}: top bar LP`);
if (r.shopShown) {
assert.equal(r.domFunds, String(r.funds), `${tag}: funds card`);
assert.equal(r.domRemain, String(Math.max(0, r.cap - r.count)), `${tag}: 剩余可放置角色`);
assert.equal(r.cards, r.shopChess, `${tag}: one card per chess slot`);
}
return r;
}
async function takeReward(c, did) {
if (!(await c.exists('.shopbar__rwcards .scard:not(.scard--sold)'))) return false;
const before = await c.st();
if (!did.rewardShot) { await sleep(400); await c.shot('reward'); did.rewardShot = true; }
// two taps like the shop: select (确认选择) → confirm
await c.click('.shopbar__rwcards .scard:not(.scard--sold)');
await c.page.waitForSelector('.shopbar__rwcards .scard.is-armed', { timeout: 3000 });
await c.click('.shopbar__rwcards .scard.is-armed');
await c.waitFor((s) => !s.reward || s.phase !== 'PREP', 'reward taken', 8000);
c.note(`merge reward taken (hand ${before.hand} → ${(await c.st()).hand})`);
return true;
}
const BUYABLE = '.shopbar__cards .scard:not(.scard--sold):not(.is-disabled)';
/**
* Two-tap purchase (research 09 §5): the first tap arms the card (确认购买, detail open, nothing bought), the second
* tap on the same card buys it.
*/
async function buyOne(c, sel = BUYABLE, { shot = null } = {}) {
const before = await c.st();
if (before.hand >= 10) return false;
// prefer copies that complete (or progress) a promotion: merges exercise the reward overlay
const pick = sel === BUYABLE ? ((await c.exists(`${BUYABLE}.is-merge`)) ? `${BUYABLE}.is-merge`
: (await c.exists(`${BUYABLE} .scard__pips`)) ? `${BUYABLE}:has(.scard__pips)` : sel) : sel;
const ok = await c.click(pick, null, { timeout: 1200, optional: true });
if (!ok) return false;
await c.page.waitForSelector('.scard.is-armed .scard__confirm', { timeout: 3000 });
await sleep(250);
const mid = await c.st();
assert.equal(mid.funds, before.funds, `${c.label}: the first tap only selects (确认购买)`);
if (shot) await c.shot(shot);
await c.click('.scard.is-armed');
const after = await c.waitFor((s) => s.funds !== before.funds || s.phase !== 'PREP', 'purchase', 8000);
c.note(`bought${sel.includes('item') ? ' item' : ''} in two taps (funds ${before.funds} → ${after.funds})`);
return after.funds < before.funds;
}
/**
* Enemy preview (research 09 §2 / §6.2 item 3): the amber 🔍▶▶ pans the camera to the pen and 🔍◀◀ returns; the
* enemy list is the 2nd tab (敌方情报) of the 本局信息 dialog behind the left 🔍.
*/
async function openEnemies(c) {
await c.click('.enemybtn');
const pen = await c.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 5000 }).then(() => true, () => false);
if (pen) {
await sleep(500);
await c.shot('pen');
await c.click('.gtop__iconbtn');
await c.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera !== 'pen', { timeout: 4000 });
}
await c.click('.gtop__iconbtn[aria-label="本局信息"]');
await c.page.waitForSelector('.edrawer', { timeout: 5000 });
await c.click('.edrawer .tabs__tab:nth-child(2)');
await sleep(300);
const rows = await c.page.$$eval('.edrawer .erow', (els) => els.length);
await c.shot('enemies');
await c.click('.edrawer__close');
await c.page.waitForFunction(() => !document.querySelector('.edrawer'), { timeout: 4000 });
c.note(`enemy pen ${pen ? 'shown' : 'unavailable (not prep)'}, list: ${rows} rows`);
return rows;
}
async function openInfo(c) {
await c.click('.gtop__iconbtn[aria-label="本局信息"]');
await c.page.waitForSelector('.edrawer', { timeout: 5000 });
await sleep(300);
await c.shot('info');
await c.click('.edrawer__close');
return true;
}
async function sendEmote(c, peer) {
await c.click('.ewheel__btn');
await c.page.waitForSelector('.ewheel__panel', { timeout: 4000 });
const items = await c.page.$$eval('.ewheel__item', (els) => els.length);
assert.equal(items, 6, 'the emote wheel shows one official theme page (3×2)');
const arts = await c.page.$$eval('.ewheel__item img', (els) => els.filter((e) => e.complete && e.naturalWidth > 0).length);
await sleep(300);
await c.shot('emote-wheel');
await c.click('.ewheel__item', null, { nth: 2 });
await peer.page.waitForSelector('.team__bubble', { timeout: 6000 });
await sleep(250);
await peer.shot('emote-bubble');
const bubbleArt = await peer.page.$$eval('.team__bubble img', (els) => els.filter((e) => e.complete && e.naturalWidth > 0).length);
c.note(`emote sent (wheel art ${arts}/6, bubble art ${bubbleArt})`);
return true;
}
/** Two-tap upgrade: the first tap arms 确认升级 (level unchanged), the second upgrades. */
async function levelUp(c, { shot = null } = {}) {
const s = await c.st();
if (s.level >= 6 || s.funds < s.upgradePrice) return false;
await c.click('.lvcard');
await c.page.waitForSelector('.lvcard.is-armed', { timeout: 3000 });
assert.equal((await c.page.$eval('.lvcard__label', (e) => e.textContent)).trim(), '确认升级', `${c.label}: first tap arms 确认升级`);
await sleep(200);
assert.equal((await c.st()).level, s.level, `${c.label}: nothing upgraded yet`);
if (shot) await c.shot(shot);
await c.click('.lvcard.is-armed');
const after = await c.waitFor((x) => x.level > s.level || x.phase !== 'PREP', 'level up', 6000);
c.note(`level ${s.level} → ${after.level} (two taps)`);
return after.level > s.level;
}
async function refreshKey(c) {
const s = await c.st();
if (s.funds < s.refreshPrice) return false;
const shop = () => c.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 c.page.mouse.click(c.w / 2, c.h * 0.3); // focus the page (not a text field)
await c.page.keyboard.press('KeyR');
// a free refresh (机变 补给 …) changes the free counter, a paid one the funds; the slots are rerolled either way
const t0 = Date.now();
let after = null; let now = null;
while (Date.now() - t0 < 6000) {
after = await c.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, `${c.label}: R refreshed the shop`);
if (s.refreshPrice > 0) assert.equal(after.funds, s.funds - s.refreshPrice, `${c.label}: refresh cost`);
c.note(`refresh (R) ${s.refreshPrice ? `funds ${s.funds} → ${after.funds}` : `free (${before.free} → ${now.free})`}, slots ${before.ids === now.ids ? 'same ids' : 'rerolled'}`);
return true;
}
async function freezeToggle(c) {
const s = await c.st();
await c.click('.toolbtn--ice');
await c.waitFor((x) => x.frozen !== s.frozen || x.phase !== 'PREP', 'freeze', 6000);
assert.ok(await c.exists('.shopbar.is-frozen'), 'the frozen shop is tinted');
await sleep(250);
await c.shot('frozen');
await c.page.keyboard.press('KeyF');
await c.waitFor((x) => x.frozen === s.frozen || x.phase !== 'PREP', 'unfreeze (F)', 6000);
c.note('freeze (click) + unfreeze (F)');
return true;
}
/**
* Every interactive prep tile (the 10 bench pads, the 5 temp pads, the own board) is reachable: the DOM HUD must not
* swallow the pointer over it. Probes the centre and 4 inner points of each tile's top face (elementFromPoint honours
* pointer-events). Review regression: the shop bar's box (and its one-line tool row) covered bench slots 5–9 at 16:9.
*/
async function checkTilesClear(c, where) {
const bad = await c.page.evaluate(() => {
const raw = globalThis.__SP_VIEW__?.raw;
const cam = raw?.debug?.cam;
const canvas = raw?.debug?.app?.view;
if (!cam || !canvas) return null;
const cr = canvas.getBoundingClientRect();
const out = [];
const probe = (row, col, label) => {
const z = raw.debug.tiles.heightAt(row, col);
for (const [dx, dy] of [[0, 0], [-0.3, -0.3], [0.3, -0.3], [-0.3, 0.3], [0.3, 0.3]]) {
const p = cam.project(col + dx, row + dy, z);
const x = cr.left + p.x, y = cr.top + p.y;
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) continue;
const el = document.elementFromPoint(x, y);
if (el && el.tagName !== 'CANVAS') { out.push(`${label}@${dx},${dy}:${String(el.className?.baseVal ?? el.className).split(' ')[0]}`); return; }
}
};
for (let i = 0; i < 10; i++) probe(7, i, `hand${i}`);
for (let i = 0; i < 5; i++) probe(8, 4 + i, `temp${i}`);
for (let r = 9; r <= 12; r++) for (let col = 0; col <= 10; col++) probe(r, col, `board${r},${col}`);
return out;
});
if (bad == null) return false;
assert.deepEqual(bad, [], `${c.label} ${where}: the HUD leaves every bench / temp / board tile reachable`);
return true;
}
const DIR_CYCLE = ['RIGHT', 'UP', 'LEFT', 'DOWN'];
/**
* The stored facing (m.private board `dir`) must follow the chosen direction (the wheel's g.move carries it as `dir`
* and `to.dir`), and the piece's model must show it (Back model for UP, mirrored for LEFT).
*/
async function checkStoredDir(c, uid, dir) {
const t0 = Date.now();
let p = await c.boardPiece(uid);
while (p && p.dir !== dir && Date.now() - t0 < 1500) { await sleep(120); p = await c.boardPiece(uid); }
assert.ok(p, `${c.label}: piece ${uid} on the board`);
assert.equal(p.dir, dir, `${c.label}: the board keeps the chosen facing`);
const shown = await c.page.evaluate((uid) => globalThis.__SP_VIEW__?.raw?.debug?.views?.get('p:' + uid)?.dir ?? null, uid);
if (shown != null) assert.equal(shown, dir, `${c.label}: the model shows the stored facing`);
return true;
}
/**
* After a board drop the direction wheel opens: swipe `dir`, then check the g.move {…, dir} that was sent (and the
* stored facing once the server echoes it).
*/
async function chooseDirection(c, uid, tile, dir, { midShot = null } = {}) {
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe(dir, { midShot });
const t0 = Date.now();
let sent = null;
while (Date.now() - t0 < 4000 && !sent) {
sent = (await c.requests('g.move')).reverse().find((r) => r[1].uid === uid && r[1].to?.area === 'board' && r[1].to.row === tile.row && r[1].to.col === tile.col) || null;
if (!sent) await sleep(100);
}
assert.ok(sent, `${c.label}: g.move sent after the swipe`);
assert.equal(sent[1].dir, dir, `${c.label}: g.move carries dir ${dir}`);
assert.equal(await c.exists('.fwheel'), false, `${c.label}: the wheel closed`);
return sent;
}
/** Hand → board drags + the direction wheel (the first one screenshots the lit legal tiles). */
async function deployFromHand(c, maxMoves, did) {
let moved = 0;
await c.hookRequests();
for (let i = 0; i < maxMoves; i++) {
const s = await c.st();
if (!(await c.isEditable()) || s.deployCount >= s.deployCap) break;
const hand = (await c.handPieces()).filter((p) => p.kind !== 'item');
if (!hand.length) break;
const p = hand[0];
const tile = await c.freeTileFor(p.uid);
if (!tile) break;
const from = await c.piecePoint(p.uid);
const to = await c.tilePoint(tile.row, tile.col);
if (!from || !to) break;
await c.drag(from, to, { midShot: did.dragShot ? null : 'drag-legal' });
did.dragShot = true;
if ((await c.st()).phase !== 'PREP') break;
const dir = DIR_CYCLE[(did.dirN = (did.dirN || 0) + 1) % DIR_CYCLE.length];
await chooseDirection(c, p.uid, tile, dir);
await c.waitFor((x) => x.board !== s.board || x.phase !== 'PREP', 'deploy', 6000).catch(() => null);
const onBoard = (await c.boardPieces()).find((b) => b.uid === p.uid);
if ((await c.st()).phase !== 'PREP') break;
assert.ok(onBoard, `${c.label}: dragged piece ${p.id} onto the board`);
assert.deepEqual([onBoard.row, onBoard.col], [tile.row, tile.col], `${c.label}: landed on the target tile`);
await checkStoredDir(c, p.uid, dir);
moved += 1;
}
if (moved) c.note(`deployed ${moved} (hand → board, direction wheel)`);
return moved;
}
/** Board chess pieces, front-most first (nothing is drawn in front of them, so they are the easiest to grab). */
async function frontUnits(c, pred = () => true) {
return (await c.boardPieces()).filter((b) => b.kind === 'chess' && pred(b)).sort((a, b) => a.row - b.row || b.col - a.col);
}
/**
* Drag piece `uid` to `to()` (re-evaluated per attempt) until `ok()` holds; retries with other grab points of the
* piece's sprite (a unit standing in front can cover the default one) and logs the view's drop events on a miss.
*/
async function dragPiece(c, uid, to, ok, what, { midShot = null } = {}) {
await c.drops();
for (const at of [0.72, 0.45, 0.9]) {
const from = await c.piecePoint(uid, at);
const dest = await to();
if (!from || !dest) break;
await c.drag(from, dest, { midShot: at === 0.72 ? midShot : null });
const t0 = Date.now();
while (Date.now() - t0 < 1500) { if (await ok()) return true; await sleep(120); }
if ((await c.st()).phase !== 'PREP') return false;
c.note(`${what}: retry (grab at ${at}) drops ${await c.drops()} toasts ${JSON.stringify(await c.toasts())}`);
}
await c.shot(`debug-${what.replace(/[^a-z0-9]+/gi, '-')}`);
return false;
}
/** Board → another free board tile (through the wheel). */
async function moveOnBoard(c) {
const unit = (await frontUnits(c))[0];
if (!unit) return false;
const tile = await c.freeTileFor(unit.uid, { prefer: 'back' });
if (!tile) return false;
await c.hookRequests();
const landed = await dragPiece(c, unit.uid, () => c.tilePoint(tile.row, tile.col), async () => {
if (await c.exists('.fwheel__dia')) await chooseDirection(c, unit.uid, tile, 'RIGHT');
return (await c.boardPieces()).some((b) => b.uid === unit.uid && b.row === tile.row && b.col === tile.col);
}, 'board-move');
assert.ok(landed, `${c.label}: board → board move landed (${unit.row},${unit.col}) → (${tile.row},${tile.col})`);
c.note(`moved ${unit.id} (${unit.row},${unit.col}) → (${tile.row},${tile.col}) facing RIGHT`);
return true;
}
/** Re-orient in place: drag a board unit off and back onto its own tile → the wheel → a new direction. */
async function reorientInPlace(c, dir = 'LEFT', { midShot = null } = {}) {
const unit = (await frontUnits(c))[0];
if (!unit) return false;
await c.hookRequests();
const from = await c.piecePoint(unit.uid);
const home = await c.tilePoint(unit.row, unit.col);
if (!from || !home) return false;
// off the tile and back: a plain drag whose release lands on the unit's own tile
const m = c.page.mouse;
await m.move(from.x, from.y);
await m.down();
for (let i = 1; i <= 8; i++) { await m.move(from.x + i * 9, from.y - i * 4); await sleep(14); }
for (let i = 1; i <= 10; i++) { await m.move(from.x + 72 + ((home.x - from.x - 72) * i) / 10, from.y - 32 + ((home.y - from.y + 32) * i) / 10); await sleep(14); }
await sleep(150);
await m.up();
await chooseDirection(c, unit.uid, { row: unit.row, col: unit.col }, dir, { midShot });
await sleep(500);
const after = await c.boardPiece(unit.uid);
assert.deepEqual([after.row, after.col], [unit.row, unit.col], `${c.label}: re-oriented in place (tile unchanged)`);
await checkStoredDir(c, unit.uid, dir);
c.note(`re-oriented ${unit.id} in place → ${dir}`);
return true;
}
/** Board → an empty hand slot, then back onto the board. */
async function benchRoundTrip(c) {
const unit = (await frontUnits(c))[0];
if (!unit) return false;
const hand = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private.hand.map((p) => !!p));
// the rightmost free bench slot the HUD does not cover (at 1280×720 the shop tools can hide the last pads)
let idx = -1;
for (let i = hand.length - 1; i >= 0 && idx < 0; i--) {
if (hand[i]) continue;
const pt = await c.tilePoint(7, i);
const free = pt && await c.page.evaluate((x, y) => document.elementFromPoint(x, y)?.tagName === 'CANVAS', pt.x, pt.y);
if (free) idx = i;
}
if (idx < 0) return false;
const toHand = await dragPiece(c, unit.uid, () => c.tilePoint(7, idx),
async () => (await c.handPieces()).some((p) => p.uid === unit.uid && p.idx === idx), 'board-to-hand');
assert.ok(toHand, `${c.label}: board → hand slot ${idx}`);
const tile = await c.freeTileFor(unit.uid);
await c.hookRequests();
const back = await dragPiece(c, unit.uid, () => c.tilePoint(tile.row, tile.col), async () => {
if (await c.exists('.fwheel__dia')) await chooseDirection(c, unit.uid, tile, 'DOWN');
return (await c.boardPieces()).some((b) => b.uid === unit.uid);
}, 'hand-to-board');
assert.ok(back, `${c.label}: hand → board again`);
c.note(`bench round trip (${unit.id} via hand slot ${idx})`);
return true;
}
/** Tap a board unit → its underframe (撤退 / 出售 +N) and range; the screenshot shows it; 出售 sells it. */
async function tapUnit(c, uid) {
for (const at of [0.72, 0.5, 0.88]) {
const pt = await c.piecePoint(uid, at);
if (!pt) return false;
await c.page.mouse.click(pt.x, pt.y);
if (await c.page.waitForSelector('.uframe', { timeout: 1500 }).then(() => true, () => false)) return true;
}
return false;
}
async function sellByTap(c, { shot = 'underframe' } = {}) {
const unit = (await frontUnits(c, (b) => !b.golden && !b.items))[0];
if (!unit) return false;
const before = await c.st();
// dropping on the shop bar no longer sells (no drag-to-sell zone): the unit stays on the board
const bar = await c.page.$eval('.shopbar__row', (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width * 0.45, y: r.top + r.height / 2 }; });
const from = await c.piecePoint(unit.uid);
await c.drag(from, bar);
await sleep(500);
assert.ok((await c.boardPieces()).some((b) => b.uid === unit.uid), `${c.label}: dropping on the shop bar does not sell`);
assert.equal(await c.exists('.sellzone'), false, 'no sell zone');
assert.ok(await tapUnit(c, unit.uid), `${c.label}: tapping the unit opens its underframe`);
assert.ok(await c.exists('.uframe__btn--retreat'), 'underframe: 撤退');
assert.ok(await c.exists('.uframe__btn--sell'), 'underframe: 出售');
const price = await c.page.$eval('.uframe__btn--sell .hex__value', (e) => e.textContent.trim());
assert.match(price, /^\+\d+$/, `出售 shows +N (${price})`);
await sleep(300);
if (shot) await c.shot(shot);
await c.click('.uframe__btn--sell');
const after = await c.waitFor((s) => s.board < before.board || s.phase !== 'PREP', 'sold', 6000);
assert.ok(after.funds > before.funds, `${c.label}: selling paid funds`);
assert.equal(await c.exists('.uframe'), false, 'underframe closed');
c.note(`sold ${unit.id} with the underframe 出售 ${price} (funds ${before.funds} → ${after.funds})`);
return true;
}
/** Drag a hand item onto a board operator → equipped. */
async function equipItem(c) {
const item = (await c.handPieces('item'))[0];
const target = (await frontUnits(c, (b) => b.items < 2))[0];
if (!item || !target) return false;
const rec = await c.page.evaluate((id) => { const r = globalThis.__SP__.data.lookup('items', id); return { type: r?.itemType, kind: r?.kind, name: r?.name }; }, item.id);
if (rec.type === 'MAGIC') return false;
const consumed = String(rec.kind || '').startsWith('consume_on_equip');
await dragPiece(c, item.uid, () => c.piecePoint(target.uid), async () => (await c.exists('.modal')) || !(await c.handPieces('item')).some((p) => p.uid === item.uid), 'equip');
// a full operator: the equip-replace dialog picks the item to destroy (ui/equipReplace.js)
await c.confirmReplace(0);
await sleep(400);
const after = (await c.boardPieces()).find((b) => b.uid === target.uid);
const gone = !(await c.handPieces('item')).some((p) => p.uid === item.uid);
const ok = gone && after && (consumed || after.items > target.items);
assert.ok(ok, `${c.label}: item ${item.id} (${rec.kind}) equipped onto ${target.id}`);
c.note(`equipped ${rec.name} (${rec.kind}) → ${target.id}`);
return true;
}
/** Right-click a board unit → detail panel (name matches), sell button present; Esc closes. */
async function inspectUnit(c) {
// the front-most unit: nothing is drawn in front of it
const unit = (await c.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 c.page.evaluate((id) => globalThis.__SP__.data.lookup('chess', id)?.name || '', unit.id);
const pt = await c.piecePoint(unit.uid);
await c.page.mouse.click(pt.x, pt.y, { button: 'right' });
await c.page.waitForSelector('.dpanel', { timeout: 5000 });
const shown = await c.page.$eval('.dpanel .dhead__name', (e) => e.textContent);
assert.equal(shown, name, `${c.label}: detail panel shows the right-clicked unit`);
assert.equal(await c.exists('.uframe'), false, `${c.label}: right-click opens the detail only (no underframe)`);
await sleep(350);
await c.shot('detail-chess');
await c.page.keyboard.press('Escape');
await c.page.waitForFunction(() => !document.querySelector('.dpanel'), { timeout: 4000 });
c.note(`inspected ${name}`);
return true;
}
async function inspectShopCard(c) {
const ok = await c.click('.shopbar__cards .scard:not(.scard--sold)', null, { button: 'right', optional: true, timeout: 1500, any: true });
if (!ok) return false;
await c.page.waitForSelector('.dpanel', { timeout: 5000 });
await sleep(300);
await c.shot('detail-shop');
await c.page.keyboard.press('Escape');
await c.page.waitForFunction(() => !document.querySelector('.dpanel'), { timeout: 4000 });
return true;
}
async function openBond(c) {
if (!(await c.exists('.bslot .bond'))) return false;
await c.click('.bslot .bond');
await c.page.waitForSelector('.bpop', { timeout: 5000 });
const name = await c.page.$eval('.bpop .bpop__name', (e) => e.textContent);
assert.ok(name && name.length > 0, 'bond popup has a name');
await sleep(300);
await c.shot('bond');
await c.click('.bpop__close');
c.note(`bond popup ${name}`);
return true;
}
/** Click a teammate's team row during prep → 前往查看 → read-only view of their board, then back. */
async function watchTeammatePrep(c) {
const target = await c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
return (s.match.public?.players || []).find((p) => p.playerId !== s.me.playerId && p.alive && p.boardCount > 0)?.name || null;
});
if (!target) return false;
await c.click('.team__row:not(.is-self) .team__btn', null, { nth: 0 });
// research 09 §3.1: the avatar expands a 前往查看 button (client-side combat flow)
if (await c.click('.team__ob', '前往查看', { optional: true, timeout: 3000 })) c.note('prep: 前往查看');
await c.page.waitForSelector('.gm__watching', { timeout: 5000 });
await c.waitFor((s) => (s.field || '').startsWith('n:') && s.field !== `n:${s.me}` || s.phase !== 'PREP', 'teammate board', 6000);
await sleep(1200);
const st = await c.viewStats();
await c.shot('watch-prep');
// research 09 §2.2 "Teammates": their preview pen comes with their board (Match.prepFieldMeta nextEnemies)
const theirs = await c.page.evaluate(() => globalThis.__SP__.store.get().match.field?.nextEnemies ?? null);
if ((await c.st()).phase === 'PREP' && Array.isArray(theirs)) {
await c.click('.enemybtn');
if (await c.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 4000 }).then(() => true, () => false)) {
await sleep(500);
const shown = await c.page.evaluate(() => {
const v = globalThis.__SP_VIEW__;
return v?.kind === 'engine' ? [...v.raw.debug.penViews.values()].map((x) => x.info.enemyKey) : [...document.querySelectorAll('.ff-pen__enemy')].map((el) => el.dataset.enemy);
});
const total = theirs.reduce((n, e) => n + (e.count || 1), 0);
if ((await c.st()).phase === 'PREP') {
assert.ok(shown.length > 0 && shown.length <= Math.min(50, total) + theirs.filter((e) => e.elite).length, `${c.label}: the teammate's pen (${shown.length} of ${total})`);
assert.deepEqual([...new Set(shown)].sort(), [...new Set(theirs.map((e) => e.enemyKey))].sort(), `${c.label}: THEIR enemies in the pen`);
await c.shot('watch-prep-pen');
c.note(`scouting: the teammate's pen shows ${shown.length} figures`);
}
await c.click('.gtop__iconbtn');
await c.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera !== 'pen', { timeout: 4000 }).catch(() => null);
}
}
await c.click('.gm__watching button');
await c.page.waitForFunction(() => !document.querySelector('.gm__watching'), { timeout: 5000 });
await sleep(600);
c.note(`watched a teammate's board during prep (${st?.units ?? '?'} units)`);
return true;
}
async function openSettings(c) {
await c.click('.gm__gear');
await c.page.waitForSelector('.modal', { timeout: 4000 });
await sleep(300);
await c.shot('settings');
await c.page.keyboard.press('Escape');
await c.page.waitForFunction(() => !document.querySelector('.modal'), { timeout: 4000 });
return true;
}
/** 玩法说明 viewer: open from `openSel`, page forward (button + arrow key), screenshot, close. */
async function openGuide(c, openSel, shotName) {
await c.click(openSel);
await c.page.waitForSelector('.guide', { timeout: 5000 });
await c.page.waitForFunction(() => { const i = document.querySelector('.guide__page img'); return i && i.complete && i.naturalWidth > 0; }, { timeout: 15000 });
const r = await c.page.$eval('.guide__page', (el) => { const b = el.getBoundingClientRect(); return b.width / b.height; });
assert.ok(Math.abs(r - 16 / 9) < 0.02, `guide pages display at 16:9 (${r.toFixed(3)})`);
const pages = await c.page.$$eval('.guide__dot', (els) => els.length);
assert.equal(pages, 19, 'the guide has the 19 official tutorial pages');
await sleep(300);
await c.shot(shotName);
await c.click('.guide__next');
await c.page.keyboard.press('ArrowRight');
await sleep(200);
const at = await c.page.$eval('.guide__count', (e) => e.textContent);
assert.ok(/^3\s*\/\s*19/.test(at.trim()), `guide paged to 3/19 (${at})`);
await c.page.keyboard.press('Escape');
await c.page.waitForFunction(() => !document.querySelector('.guide'), { timeout: 4000 });
return true;
}
async function ready(c, { key = false } = {}) {
if (!(await c.isEditable())) return;
const s = await c.st();
if (!s.canReady) { c.note(`temp not empty (${s.temp}) — ready blocked`); return; }
// Space asks about leftover funds like the button does (剩余资金, DESIGN §23.11); the button path confirms inside c.click
if (key) { await c.page.mouse.click(c.w / 2, c.h * 0.3); await c.page.keyboard.press('Space'); await c.confirmFundsLeft(); } else await c.click('.readybtn');
await c.waitFor((x) => x.ready || x.phase !== 'PREP', 'ready', 8000);
}
async function waitUnits(c, ms = 15000) {
const t1 = Date.now();
let st = null;
while (Date.now() - t1 < ms) {
st = await c.viewStats();
if (st && st.mode === 'battle' && st.units > 0) break;
await sleep(250);
}
return st;
}
/** The local battle runner's state (client-side combat). */
const runnerState = (c) => c.page.evaluate(() => globalThis.__SP_RUNNER__?.state() ?? null);
/**
* Facing end to end (DESIGN §3 / §14): every board piece's stored `dir` (m.private, set by the wheel's g.move) reaches
* the BattleSpec the server sent (b.start), the unit of the client's local simulation, whose range tiles equal the
* UI's rotated preview (ui/facing.js rangeTiles — an independent implementation), and the rendered battle view (model
* + ground wedge). Returns the number of checked units facing something other than RIGHT.
*/
async function checkFacingChain(c) {
await c.page.waitForFunction(() => { const s = globalThis.__SP_RUNNER__?.state(); return !!s && !s.loading && s.kind === 'normal' && s.own; }, { timeout: 15000 }).catch(() => {});
await sleep(1200); // deploy animations: every board unit has a battle view
const r = await c.page.evaluate(async () => {
const priv = globalThis.__SP__.store.get().match.private;
const R = globalThis.__SP_RUNNER__;
const e = R && priv && [...R._entries.values()].find((x) => x.kind === 'normal' && x.fieldId === 'n:' + priv.playerId);
if (!e) return { err: 'no local battle of the own field' };
const { rangeTiles } = await import('/js/ui/facing.js');
const sp = (e.spec.players || []).find((p) => p && p.playerId === priv.playerId);
const views = globalThis.__SP_VIEW__?.raw?.debug?.views;
const rows = [];
for (const p of priv.board || []) {
if (p.kind !== 'chess' && p.kind !== 'token') continue;
const want = p.dir || 'RIGHT';
const su = (sp?.units || []).find((u) => u.uid === p.uid);
const bu = e.battle.units.find((u) => u.uid === p.uid && u.side === 'ally');
let range = null;
if (bu && Array.isArray(bu.baseRangeKeys) && Number.isInteger(bu.tileR)) {
const ui = rangeTiles(bu.rangeGrid || [[0, 0]], bu.tileR, bu.tileC, want, { extend: bu.s.baseRangeExtend || 0 }).map(([r, cc]) => r * 21 + cc).sort((a, b) => a - b);
range = JSON.stringify(ui) === JSON.stringify([...bu.baseRangeKeys].sort((a, b) => a - b));
}
let shown = null;
if (views) for (const v of views.values()) if (v.info && v.info.uid === p.uid && v.info.side === 'ally' && !v.destroyed) { shown = [v.dir, !!v.hasDir]; break; }
rows.push({ uid: p.uid, kind: p.kind, want, spec: su ? su.dir : null, sim: bu ? bu.dir : null, range, shown });
}
return { rows };
});
assert.equal(r.err, undefined, `${c.label}: ${r.err}`);
const chess = r.rows.filter((x) => x.kind === 'chess');
assert.ok(chess.length > 0, `${c.label}: board operators in the battle`);
for (const x of r.rows) {
const tag = `${c.label} unit ${x.uid} (${x.want})`;
if (x.kind === 'chess' || x.spec != null) assert.equal(x.spec, x.want, `${tag}: the BattleSpec carries the stored dir`);
if (x.kind === 'chess') assert.equal(x.sim, x.want, `${tag}: the local sim unit faces it`);
if (x.range != null) assert.equal(x.range, true, `${tag}: sim range tiles = the rotated preview`);
if (x.shown) assert.deepEqual(x.shown, [x.want, true], `${tag}: the battle view shows it (model + wedge)`);
}
assert.ok(chess.some((x) => x.shown), `${c.label}: battle views found for the board operators`);
const turned = r.rows.filter((x) => x.want !== 'RIGHT' && x.sim === x.want).length;
c.note(`facing chain: ${r.rows.map((x) => `${x.uid}:${x.want}${x.shown ? '' : '(no view)'}`).join(' ')} — ${turned} not RIGHT`);
return turned;
}
/**
* One forced disconnect mid-combat: the client's socket drops while its own battle is authoritative and running; the
* server takes the field over (re-simulating it) and the reconnected client keeps it on screen as a display replica.
*/
async function forcedDisconnect(c, srv) {
const before = await runnerState(c);
if (!before || !before.authoritative || before.done || before.kind !== 'normal') return false;
const me = (await c.st()).me;
await c.page.evaluate(() => globalThis.__SP__.net.reconnectNow());
await c.page.waitForFunction((id) => { const s = globalThis.__SP_RUNNER__?.state(); return !!s && s.battleId === id && !s.authoritative; }, { timeout: 20000 }, before.battleId);
const t0 = Date.now();
while (Date.now() - t0 < 10000 && !srv.logs.some((l) => l.includes(`server takeover from ${me} (disconnect)`))) await sleep(200);
assert.ok(srv.logs.some((l) => l.includes(`server takeover from ${me} (disconnect)`)), 'the server took the field over');
const st = await waitUnits(c, 8000);
assert.ok(st && st.units > 0, 'the taken-over battle stays on screen');
await c.shot('combat-takeover');
c.note(`forced disconnect: ${before.battleId} taken over by the server, shown as a replica (${st.units} views)`);
return true;
}
/**
* research 09 §3.1: while the own normal battle runs a teammate cannot be observed; once it is over the waiting pill
* (with the teammates' progress) shows, a teammate row → 前往查看 runs a local replica of a still running teammate
* battle, and 返回战场 goes back to the own field.
*/
async function observeAfterFinish(c) {
const s0 = await runnerState(c);
if (!s0 || !s0.own) return false;
if (!s0.done) {
// not yet: tapping a teammate never offers 前往查看 while the own battle runs (checked once)
if (!observeAfterFinish.refusedChecked) {
observeAfterFinish.refusedChecked = true;
await c.click('.team__row:not(.is-self) .team__btn', null, { nth: 0, optional: true, timeout: 2000 });
await sleep(300);
assert.equal(await c.exists('.team__ob'), false, 'no 前往查看 while the own battle runs');
c.note('own battle running: a teammate row offers no 前往查看');
}
return false;
}
const other = await c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public;
if (pub?.phase !== 'COMBAT') return null;
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) return false;
const pill = await c.page.waitForSelector('.chud__wait', { timeout: 5000 }).then(() => true, () => false);
if (!pill && (await c.st()).phase !== 'COMBAT') return false; // the round moved on meanwhile
assert.ok(pill, 'waiting pill: 作战结束,等待队友完成作战');
assert.ok(await c.exists('.chud__progress'), "teammates' progress under the pill");
await c.shot('combat-waiting');
await c.click('.team__row:not(.is-self) .team__btn', null, { nth: other.nth });
if (!(await c.click('.team__ob', '前往查看', { optional: true, timeout: 3000 }))) return false;
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) return false; // the phase moved on
const st = await waitUnits(c, 8000);
assert.ok(st && st.units > 0, 'the observed battle renders units');
assert.ok(await c.exists('.chud__observe'), 'observing pill');
await sleep(800);
await c.shot('combat-observe');
if ((await c.st()).phase === 'COMBAT') {
await c.click('.team__back, .chud__back', '返回战场', { optional: true, timeout: 3000 });
await c.page.waitForFunction(() => { const s = globalThis.__SP_RUNNER__?.state(); return !s || (s.own && !s.watch); }, { timeout: 10000 }).catch(() => {});
// user playtest #2 item 6: back on the own battlefield the enemy pen stays hidden (figures, 2D rows, 3D area)
await sleep(1300);
const pen = await c.page.evaluate(() => {
const d = globalThis.__SP_VIEW__?.raw?.debug;
if (!d || globalThis.__SP__.store.get().match.public?.phase !== 'COMBAT') return null;
return {
visible: [...d.penViews.values()].filter((x) => x.root && x.root.visible !== false).length, band: d.tiles.band,
area: d.board3d ? Math.max(...d.board3d.area.map((a) => a.r1)) : null,
};
});
if (pen) {
assert.equal(pen.visible, 0, '返回战场: no enemy pen figure');
// the 3D area ends at the field's separator row 13 (its devices blow into the field: act2 m01's blowers, user
// playtest #5 item 6); the pen starts at row 14
assert.ok(pen.band[1] <= 13 && (pen.area == null || pen.area <= 13), `返回战场: no pen rows / area (${JSON.stringify(pen)})`);
}
}
c.note(`observed ${other.fieldId} after the own battle (${st.units} views) and went back`);
return true;
}
/** Drag hand piece `uid` onto `tile` and stop there (the wheel opens); returns the tile's point. */
async function dropOnTile(c, uid, tile) {
const from = await c.piecePoint(uid);
const to = await c.tilePoint(tile.row, tile.col);
await c.drag(from, to);
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
return to;
}
/** The same with touch input (pointerType 'touch': CDP touch events). */
async function touchDrag(c, from, to, steps = 12) {
const t = c.page.touchscreen;
await t.touchStart(from.x, from.y);
for (let i = 1; i <= steps; i++) { await t.touchMove(from.x + ((to.x - from.x) * i) / steps, from.y + ((to.y - from.y) * i) / steps); await sleep(16); }
await sleep(120);
await t.touchEnd();
}
/**
* The official prep interactions one by one (research 09 §1.2 / §5 / §6.5) on a solo run. Screenshots:
* facing-{buy-confirm, wheel, wheel-up, placed, cancel, reorient, underframe, retreat, sell, item-destroy,
* detail-longpress, touch-wheel, upgrade-confirm}.png.
*/
async function facingTour(c, did) {
await c.hookRequests();
if (!did.clear) did.clear = await checkTilesClear(c, 'facing tour');
const s0 = await c.st();
// two-tap buy
if (!did.buy) did.buy = await buyOne(c, BUYABLE, { shot: 'buy-confirm' });
for (let k = 0; k < 2; k++) if (!(await buyOne(c))) break;
// an item for the 销毁 underframe (when the shop has one and funds allow)
if (!did.item && !(await c.handPieces('item')).length) did.itemBought = await buyOne(c, '.shopbar__item .scard:not(.scard--sold):not(.is-disabled)').catch(() => false);
const units = () => c.handPieces().then((h) => h.filter((p) => p.kind === 'chess'));
let hand = await units();
assert.ok(hand.length >= 1, 'bought operators');
// place + choose a direction (UP: striped rotated range + 拖回中心区域取消 while held) — the unit with the widest range
// grid shows it best (a defender's grid is its own tile only)
if (!did.place) {
const sizes = await c.page.evaluate((ids) => ids.map((id) => globalThis.__SP__.data.lookup('chess', id)?.rangeGrid?.length || 0), hand.map((x) => x.id));
const p = hand.map((x, i) => ({ ...x, n: sizes[i] })).sort((a, b) => b.n - a.n)[0];
const tile = await c.freeTileFor(p.uid);
await dropOnTile(c, p.uid, tile);
await sleep(350);
await c.shot('wheel');
assert.ok(await c.exists('.fwheel__cancel'), '✕ 点击取消 tag');
const w = await c.wheel();
const m = c.page.mouse;
await m.move(w.x, w.y); await m.down();
for (let i = 1; i <= 10; i++) { await m.move(w.x + 1, w.y - (w.half * 0.72 * i) / 10); await sleep(16); }
await sleep(250);
assert.ok(await c.exists('.fwheel__chev.is-on'), 'the UP chevron lights');
assert.ok(await c.exists('.fwheel__tip'), 'tooltip 拖回中心区域取消 outside the centre');
if (p.n > 1) assert.ok(await c.exists('.fwheel__stripes polygon'), 'striped range tiles');
// with the 3D board the stripes lie between the board and the Pixi canvas: under the units, like the original
if (p.n > 1 && (await c.viewStats())?.board3d?.on) assert.ok(await c.exists('.fwheel__stripes--under polygon'), 'stripes under the units (3D board)');
await c.shot('wheel-up');
await m.up();
const sent = (await c.requests('g.move')).pop();
assert.deepEqual(sent[1], { uid: p.uid, to: { area: 'board', row: tile.row, col: tile.col, dir: 'UP' }, dir: 'UP' }, 'g.move {uid, to {…, dir}, dir: UP}');
await c.waitFor((x) => x.board > s0.board, 'placed', 6000);
await checkStoredDir(c, p.uid, 'UP');
await sleep(700);
await c.shot('placed');
did.place = true;
c.note(`placed ${p.id} facing UP on (${tile.row},${tile.col})`);
}
// cancel: ✕ and releasing in the centre — the piece stays in the bench, nothing is sent
hand = await units();
if (hand.length && !did.cancel) {
const p = hand[0];
const tile = await c.freeTileFor(p.uid);
const n0 = (await c.requests('g.move')).length;
await dropOnTile(c, p.uid, tile);
await sleep(250);
await c.click('.fwheel__cancel');
await sleep(500);
assert.equal(await c.exists('.fwheel'), false, '✕ closes the wheel');
assert.ok((await c.handPieces()).some((x) => x.uid === p.uid), '✕: the piece is back on the bench');
await c.shot('cancel');
await dropOnTile(c, p.uid, tile);
await c.swipe(null);
await sleep(400);
assert.equal(await c.exists('.fwheel'), false, 'release in the centre closes the wheel');
assert.ok((await c.handPieces()).some((x) => x.uid === p.uid), 'centre release: the piece is back on the bench');
assert.equal((await c.requests('g.move')).length, n0, 'nothing sent on cancel');
// keyboard alternative: ← then Enter
const b0 = (await c.st()).board;
await dropOnTile(c, p.uid, tile);
await c.page.keyboard.press('ArrowLeft');
await sleep(150);
await c.page.keyboard.press('Enter');
await c.waitFor((x) => x.board > b0 || x.phase !== 'PREP', 'keyboard placement', 6000);
assert.equal((await c.requests('g.move')).pop()[1].dir, 'LEFT', 'arrow keys choose the direction');
did.cancel = true;
}
// re-orient in place (drag onto the own tile → the wheel)
if (!did.reorient) did.reorient = await reorientInPlace(c, 'DOWN', { midShot: 'reorient' });
// tap → underframe + range; 撤退 back to the bench
if (!did.retreat) {
const unit = (await frontUnits(c))[0];
assert.ok(await tapUnit(c, unit.uid), 'tap opens the underframe');
assert.ok(await c.exists('.dpanel'), 'and the detail card');
await sleep(350);
await c.shot('underframe');
await c.click('.uframe__btn--retreat');
const t0 = Date.now();
while (Date.now() - t0 < 5000 && (await c.boardPieces()).some((b) => b.uid === unit.uid)) await sleep(120);
assert.ok((await c.handPieces()).some((x) => x.uid === unit.uid), '撤退: back on the bench');
await sleep(300);
await c.shot('retreat');
// and back onto the board, facing RIGHT
const tile = await c.freeTileFor(unit.uid);
const b1 = (await c.st()).board;
await dropOnTile(c, unit.uid, tile);
await c.swipe('RIGHT');
await c.waitFor((x) => x.phase !== 'PREP' || x.board > b1, 'redeploy', 6000);
did.retreat = true;
}
// tap-sell (no drag-to-sell)
if (!did.sell) did.sell = await sellByTap(c, { shot: 'sell' });
// items: tap → 销毁 only
const it = (await c.handPieces('item'))[0];
if (it && !did.item) {
const pt = await c.piecePoint(it.uid);
await c.page.mouse.click(pt.x, pt.y);
await c.page.waitForSelector('.uframe', { timeout: 4000 });
assert.ok(await c.exists('.uframe__btn--destroy'), 'items: 销毁');
assert.equal(await c.exists('.uframe__btn--sell'), false, 'items cannot be sold');
assert.equal(await c.exists('.uframe__btn--retreat'), false);
await sleep(300);
await c.shot('item-destroy');
await c.click('.uframe__btn--destroy');
await c.page.waitForSelector('.modal', { timeout: 4000 });
await c.click('.modal__actions .btn', '销毁');
const t0 = Date.now();
while (Date.now() - t0 < 5000 && (await c.handPieces('item')).some((x) => x.uid === it.uid)) await sleep(120);
assert.ok(!(await c.handPieces('item')).some((x) => x.uid === it.uid), 'item destroyed');
did.item = true;
c.note('item tapped → 销毁');
}
// right-click = detail only (the touch long-press equivalent)
const u = (await frontUnits(c))[0];
if (u && !did.detail) {
const pt = await c.piecePoint(u.uid);
await c.page.mouse.click(pt.x, pt.y, { button: 'right' });
await c.page.waitForSelector('.dpanel', { timeout: 4000 });
assert.equal(await c.exists('.uframe'), false, 'detail only');
// a new placement closes that card: drag another board unit (or the same one) onto its own tile → wheel, no card
const other = (await frontUnits(c)).find((b) => b.uid !== u.uid) || u;
const n0 = (await c.requests('g.move')).length;
const from = await c.piecePoint(other.uid);
const home = await c.tilePoint(other.row, other.col);
const m = c.page.mouse;
await m.move(from.x, from.y); await m.down();
for (let i = 1; i <= 8; i++) { await m.move(from.x + i * 9, from.y - i * 4); await sleep(14); }
for (let i = 1; i <= 10; i++) { await m.move(from.x + 72 + ((home.x - from.x - 72) * i) / 10, from.y - 32 + ((home.y - from.y + 32) * i) / 10); await sleep(14); }
await sleep(150); await m.up();
if (await c.page.waitForSelector('.fwheel__dia', { timeout: 3000 }).then(() => true, () => false)) {
assert.equal(await c.exists('.dpanel'), false, 'a drag closes the right-click detail card');
await c.page.keyboard.press('Escape');
await sleep(300);
assert.equal(await c.exists('.fwheel'), false, 'Esc cancels the wheel');
assert.equal((await c.requests('g.move')).length, n0, 'nothing sent on Esc');
} else await c.page.keyboard.press('Escape');
did.detail = true;
}
// touch: drag a bench unit with a finger, swipe the wheel with a finger (pointerType 'touch')
hand = await units();
const st = await c.st();
if (hand.length && !did.touch && st.deployCount < st.deployCap) {
const p = hand[0];
const tile = await c.freeTileFor(p.uid);
const from = await c.piecePoint(p.uid);
const to = await c.tilePoint(tile.row, tile.col);
await touchDrag(c, from, to);
const open = await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 }).then(() => true, () => false);
if (open) {
const w = await c.wheel();
await c.page.touchscreen.touchStart(w.x, w.y);
for (let i = 1; i <= 10; i++) { await c.page.touchscreen.touchMove(w.x - (w.half * 0.7 * i) / 10, w.y + 1); await sleep(16); }
await sleep(200);
await c.shot('touch-wheel');
await c.page.touchscreen.touchEnd();
await c.waitFor((x) => x.board > st.board || x.phase !== 'PREP', 'touch placement', 6000);
assert.equal((await c.requests('g.move')).pop()[1].dir, 'LEFT', 'touch swipe → LEFT');
did.touch = true;
c.note('touch drag + touch swipe placed a unit');
} else c.note('touch drag did not open the wheel (no touch input in this Chrome)');
}
}
// ---- tests ---------------------------------------------------------------------------------------------------------
describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrome + public/assets (run: node test/e2e/coop.e2e.mjs)' }, () => {
test('facing: the direction wheel and the official prep interactions (solo)', { skip: !!ONLY && ONLY !== 'facing', timeout: 15 * 60 * 1000 }, async () => {
const srv = await startRealServer();
const c = new Client(await pptr(), srv.base, 'facing', { prefix: 'facing' });
const did = { dragShot: true, rewardShot: true };
try {
await c.open();
await c.enter('推进之王');
await c.click('.mode-card', '独立模拟');
await c.click('.diff-card', '标准模拟');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor((s) => !!s.room, 'solo room');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await c.click('.dband', null, { nth: 2 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
for (let round = 1; round <= 3; round++) {
const s0 = await c.waitFor((s) => (s.phase === 'PREP' && s.round === round && !s.ready) || (s.phase === 'SP_DRAFT' && s.round === round), `prep ${round}`, 90000);
if (s0.phase === 'SP_DRAFT') {
// two taps (user playtest #4 item 2): select, then confirm on the selected card
await c.click('.spcard.is-pickable');
await c.click('.spcard.is-armed');
await c.waitFor((s) => s.phase === 'PREP' && !s.ready, `prep ${round} after 机变`, 30000);
}
await sleep(1800);
await takeReward(c, did);
await facingTour(c, did);
if (!did.upgrade) did.upgrade = await levelUp(c, { shot: 'upgrade-confirm' });
await deployFromHand(c, 3, did);
await ready(c);
await c.waitFor((s) => s.phase !== 'PREP', `combat ${round}`, 30000);
if ((await c.st()).phase === 'COMBAT') did.chain = (did.chain || 0) + await checkFacingChain(c);
if (['buy', 'place', 'cancel', 'reorient', 'retreat', 'sell', 'detail', 'upgrade', 'item', 'touch'].every((k) => did[k])) break;
}
c.note(`facing tour: ${JSON.stringify(did)}`);
for (const k of ['buy', 'place', 'cancel', 'reorient', 'retreat', 'sell', 'detail', 'upgrade', 'chain']) assert.ok(did[k], `did ${k}`);
if (!did.item) c.note('no item was affordable in 3 rounds: 销毁 underframe not exercised');
if (!did.touch) c.note('touch placement not exercised');
assert.deepEqual(problemsOf([c]), [], 'no console errors, page errors, failed requests or HTTP errors');
} finally {
console.log(c.log.join('\n'));
if (c.problems.length) console.log(`facing problems:\n ${c.problems.slice(0, 30).join('\n ')}`);
console.log(`screenshots: ${c.shots.join(' ')}`);
await c.close();
await srv.stop();
}
});
test(`co-op: 2 humans + 1 AI, lobby → briefing → band draft → ${ROUNDS} rounds`, { skip: !!ONLY && ONLY !== 'coop', timeout: 40 * 60 * 1000 }, async () => {
const srv = await startRealServer();
const P = await pptr();
const host = new Client(P, srv.base, 'host', { prefix: 'coop' });
const guest = new Client(P, srv.base, 'guest', { w: 1280, h: 720, prefix: 'coop' });
const both = [host, guest];
const did = { watched: false, watchedPrep: false, sold: false, reoriented: false, leveled: false, refreshed: false, froze: false, itemBought: false, equipped: false,
inspected: false, shopDetail: false, bond: false, enemies: -1, info: false, emote: false, moved: false, bench: false, settings: false, guideGame: false,
reward: 0, sp: 0, unite: false, combatRounds: new Set(), takeover: false, lpChanged: false, settledBy: new Set(), facingTurned: 0 };
const lps = new Map();
try {
// ---- title, 玩法说明, lobby, room --------------------------------------------------------------------------
await host.open();
await sleep(900);
await host.shot('title');
await openGuide(host, '.title-guide', 'guide-title');
await host.enter('凯尔希');
await sleep(400);
await host.shot('lobby');
await openGuide(host, '.lobby-guide', 'guide-lobby');
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.shot('title');
await guest.enter('阿米娅');
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined via the invite link');
await guest.click('.room-bar__right button', '准备就绪');
await sleep(500);
await host.shot('room');
await guest.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(1200);
for (const c of both) await c.shot('briefing');
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 !== 'INFO_CHECK', 'band draft', 40000);
let guestSkipped = false;
const picked = new Set();
const shotDraft = new Set();
const t0 = Date.now();
while (Date.now() - t0 < 180000) {
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') continue;
if (!shotDraft.has(c.label)) { shotDraft.add(c.label); await sleep(700); await c.shot(s.draft?.turn === s.me ? 'draft-myturn' : 'draft-waiting'); }
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
if (c === guest && !guestSkipped) {
await c.click('.draft-detail__btns .btn', '跳过');
guestSkipped = true;
c.note('band draft: skipped once');
continue;
}
if ((s.draft?.picks || 0) > 0) {
// research 09 §5: a strategy a teammate (human or AI) picked is marked 队友已选 and cannot be chosen
await c.page.waitForSelector('.dband.is-taken .dband__taken', { timeout: 4000 });
if (!shotDraft.has('taken')) {
shotDraft.add('taken');
// the strategy grid scrolls and the AI's weighted pick can sit below the fold: scroll it into view
// like a player would (a 15 s "nothing clickable" flake whenever the taken band was out of view)
await c.page.evaluate(() => document.querySelector('.dband.is-taken')?.scrollIntoView({ block: 'center' }));
await sleep(150);
await c.click('.dband.is-taken', null, { any: true });
await sleep(250);
assert.equal(await c.exists('.draft-detail__btns .btn--primary:not([disabled])'), false, '队友已选: confirm disabled');
await c.shot('draft-taken');
}
}
// the nth free strategy, skipping 老鲤 (【得闲饮茶】 holds the funds of rounds 1–2 back until round 3: a human on
// it has nothing to deploy in the first battles, and the round-1 facing chain needs board operators)
const bandName = await c.page.evaluate((nth) => {
const free = [...document.querySelectorAll('.dband:not(.is-taken)')].filter((el) => el.querySelector('.dband__name')?.textContent.trim() !== '老鲤');
const el = free[nth] || free[0];
el?.scrollIntoView({ block: 'nearest' });
return el?.querySelector('.dband__name')?.textContent.trim() || null;
}, c === host ? 3 : 7);
assert.ok(bandName, `${c.label}: a free strategy to pick`);
await sleep(150);
await c.click('.dband:not(.is-taken)', bandName);
await sleep(250);
if (!shotDraft.has(`${c.label}-sel`)) { shotDraft.add(`${c.label}-sel`); await c.shot('draft-selected'); }
await c.click('.draft-detail__btns .btn--primary', '确认选择');
picked.add(c.label);
c.note('band picked');
}
await sleep(250);
}
assert.equal(picked.size, 2, `both humans picked a band (${[...picked]})`);
assert.ok(guestSkipped, 'the guest used the skip');
// ---- rounds -----------------------------------------------------------------------------------------------------
const seen = new Set();
let lastCombat = '';
const deadline = Date.now() + 35 * 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 === 'BATTLE_CHECK' && !seen.has('bc-shot')) { seen.add('bc-shot'); await host.shot('battle-check'); }
if (hs.phase === 'PREP') {
for (const c of both) {
if (!(await c.isEditable())) continue;
const s = await c.st();
const peer = c === host ? guest : host;
if (!seen.has(`prep-${c.label}-${s.round}`)) {
seen.add(`prep-${c.label}-${s.round}`);
await sleep(1800); // past the 休整期 banner
await c.shot(`prep-r${s.round}`);
}
await checkHud(c, `prep r${s.round} start`);
if (!seen.has(`clear-${c.label}`) && await checkTilesClear(c, `prep r${s.round}`)) seen.add(`clear-${c.label}`);
if (await takeReward(c, did)) did.reward += 1;
if (c === host && did.enemies < 0) did.enemies = await openEnemies(c);
if (c === host && !did.info) did.info = await openInfo(c);
if (c === guest && !did.emote) did.emote = await sendEmote(c, peer);
if (c === guest && s.round >= 2 && !did.leveled) did.leveled = await levelUp(c);
if (c === host && s.round >= 2 && !did.itemBought) did.itemBought = await buyOne(c, '.shopbar__item .scard:not(.scard--sold):not(.is-disabled)');
if (c === host && s.round >= 3 && !did.refreshed) did.refreshed = await refreshKey(c);
if (!did.shopDetail && c === host) did.shopDetail = await inspectShopCard(c);
for (let k = 0; k < 3; k++) if (!(await buyOne(c))) break;
if (await takeReward(c, did)) did.reward += 1;
await deployFromHand(c, 4, did);
if (c === host && s.round >= 2 && !did.moved) did.moved = await moveOnBoard(c);
if (c === guest && s.round >= 2 && !did.bench) did.bench = await benchRoundTrip(c);
if (c === host && s.round >= 2 && !did.sold) did.sold = await sellByTap(c);
if (c === guest && s.round >= 2 && !did.reoriented) did.reoriented = await reorientInPlace(c, 'UP', { midShot: 'reorient' });
if (!did.equipped) did.equipped = await equipItem(c);
if (c === host && s.round >= 2 && !did.inspected) did.inspected = await inspectUnit(c);
if (c === host && !did.bond) did.bond = await openBond(c);
if (c === guest && !did.froze) did.froze = await freezeToggle(c);
if (c === host && s.round >= 2 && !did.watchedPrep) did.watchedPrep = await watchTeammatePrep(c);
if (c === guest && s.round >= 2 && !did.settings) did.settings = await openSettings(c);
if (c === host && s.round >= 3 && !did.guideGame) did.guideGame = await openGuide(c, '.gm__guide', 'guide-ingame');
await deployFromHand(c, 2, did);
await checkHud(c, `prep r${s.round} end`);
await ready(c, { key: c === guest });
}
} else if (hs.phase === 'SP_DRAFT') {
for (const c of both) {
const s = await c.st();
if (s.phase !== 'SP_DRAFT') continue;
if (!seen.has(`sp-${c.label}-${s.round}`)) { seen.add(`sp-${c.label}-${s.round}`); await sleep(2000); await c.shot(`sp-r${s.round}`); }
if (s.sp?.turn === s.me && (await c.exists('.spcard.is-pickable'))) {
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', '机变 pick', 8000);
did.sp += 1;
c.note(`机变 picked (round ${s.round})`);
}
}
} else if (COMBAT.includes(hs.phase) && lastCombat !== `${hs.round}:${hs.phase}`) {
lastCombat = `${hs.round}:${hs.phase}`;
assert.equal(await host.viewKind(), 'engine', 'the Pixi render engine is mounted (not the DOM fallback)');
const st = await waitUnits(host);
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.equal(st.spine?.failed ?? 0, 0, 'no Spine load failures');
const gst = await waitUnits(guest, 8000);
assert.ok(gst && gst.units > 0, `guest round ${hs.round}: battle units on screen`);
await sleep(1600);
const tag = hs.phase === 'UNITE' ? `unite-r${hs.round}` : `combat-r${hs.round}`;
for (const c of both) await c.shot(tag);
await checkHud(host, tag);
if (hs.phase === 'UNITE') did.unite = true;
else did.combatRounds.add(hs.round);
host.note(`round ${hs.round} ${hs.phase}: ${st.units} views, fps ${st.fps}, spine ${JSON.stringify(st.spine)}`);
if (hs.phase === 'COMBAT') {
// client-side combat: the own battle runs in each browser; no view switcher during normal combat
assert.equal(await host.exists('.chud .vswitch__arrow'), false, 'no ‹ › switcher during normal combat');
for (const c of both) {
await c.page.waitForFunction(() => { const s = globalThis.__SP_RUNNER__?.state(); return !!s && !s.loading && s.kind === 'normal'; }, { timeout: 15000 }).catch(() => {});
const rs = await runnerState(c);
assert.ok(rs && rs.kind === 'normal' && rs.own, `${c.label} runs its own battle locally (${JSON.stringify(rs)})`);
did.facingTurned += await checkFacingChain(c);
}
if (!did.takeover && hs.round >= 2) did.takeover = await forcedDisconnect(guest, srv);
// wait for either human's own battle to end while a teammate still fights, then observe it
const t1 = Date.now();
while (!did.watched && Date.now() - t1 < 120000) {
const cs = await host.st();
if (cs.phase !== 'COMBAT' || cs.round !== hs.round) break;
for (const c of both) if (!did.watched) did.watched = await observeAfterFinish(c);
await sleep(400);
}
} else if (hs.phase === 'UNITE') {
const rs = await runnerState(host);
assert.ok(rs && rs.kind === 'unite', 'every human simulates the 联防 field locally');
}
if (hs.round >= ROUNDS && hs.phase === 'COMBAT') { await sleep(2000); break; }
} else if (hs.phase === 'SETTLE' && !seen.has(`settle-${hs.round}`)) {
seen.add(`settle-${hs.round}`);
await host.shot(`settle-r${hs.round}`);
}
// results are settled from the browsers' reports: kills / leaks reach m.private.stats, LP follows the leaks
for (const c of both) {
const s = await c.st();
if (s.lp == null) continue;
const prev = lps.get(c.label);
if (prev && prev.lp !== s.lp) { did.lpChanged = true; c.note(`LP ${prev.lp} → ${s.lp} (round ${s.round})`); }
if ((s.kills || 0) + (s.leaks || 0) > 0) did.settledBy.add(c.label); // kills / leaks come from the reported results
lps.set(c.label, { lp: s.lp, kills: s.kills, leaks: s.leaks });
}
await sleep(300);
}
const end = await host.st();
assert.ok(end.round >= ROUNDS || end.result, `reached round ${ROUNDS} (at ${end.round} ${end.phase})`);
host.note(`did: ${JSON.stringify({ ...did, combatRounds: [...did.combatRounds] })}; phases: ${[...seen].filter((x) => !x.includes('-')).join(',')}`);
const rstats = await host.page.evaluate(() => globalThis.__SP_RUNNER__?.stats());
host.note(`runner: ${JSON.stringify(rstats)}; lp/kills/leaks: ${JSON.stringify([...lps])}; LP changed: ${did.lpChanged}`);
assert.ok(did.combatRounds.size >= ROUNDS, `combat rendered in ${ROUNDS} rounds (${[...did.combatRounds]})`);
assert.ok(did.takeover, 'a forced disconnect mid-combat was taken over by the server');
assert.deepEqual([...did.settledBy].sort(), ['guest', 'host'], 'both browsers\' reported results were settled (kills / leaks reached m.private.stats)');
for (const [label, v] of lps) assert.ok(v.lp > 0 && v.lp <= 60, `${label} LP settled (${v.lp})`);
assert.ok(!srv.logs.some((l) => /rejected client result/.test(l)), 'no client result was rejected');
assert.equal(rstats.errors, 0, 'the local simulation never failed');
for (const k of ['watched', 'watchedPrep', 'sold', 'reoriented', 'refreshed', 'froze', 'leveled', 'inspected', 'shopDetail', 'bond', 'emote', 'moved', 'bench', 'settings', 'guideGame', 'info']) {
assert.ok(did[k], `did ${k}`);
}
assert.ok(did.enemies >= 0, 'enemy drawer opened');
assert.ok(did.facingTurned > 0, 'an UP / LEFT / DOWN unit went through the whole facing chain');
assert.ok(did.sp >= 1, '机变 picked in round 3');
assert.deepEqual(problemsOf(both), [], 'no console errors, page errors, failed requests or HTTP errors');
} 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, 30).join('\n ')}`);
console.log(`screenshots: ${[...host.shots, ...guest.shots].join(' ')}`);
for (const c of both) await c.close();
await srv.stop();
}
});
test('solo: 独立模拟 (绝境) through round 3 incl. the solo 机变', { skip: !!ONLY && ONLY !== 'solo', timeout: 20 * 60 * 1000 }, async () => {
const srv = await startRealServer();
const solo = new Client(await pptr(), srv.base, 'solo', { prefix: 'solo' });
const did = { dragShot: true, rewardShot: false };
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 sleep(900);
await solo.shot('briefing');
await solo.click('.brief__foot .btn--primary', '准备就绪');
await solo.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await sleep(700);
await solo.shot('draft');
await solo.click('.dband', null, { nth: 1 });
await solo.click('.draft-detail__btns .btn--primary', '确认选择');
for (let round = 1; round <= 3; round++) {
const s0 = await solo.waitFor((s) => (s.phase === 'PREP' && s.round === round && !s.ready) || (s.phase === 'SP_DRAFT' && s.round === round), `prep ${round}`, 90000);
if (s0.phase === 'SP_DRAFT') {
await sleep(2000);
await solo.shot(`sp-r${round}`);
await solo.click('.spcard.is-pickable');
await solo.click('.spcard.is-armed');
await solo.waitFor((s) => s.phase === 'PREP' && !s.ready, `prep ${round} after 机变`, 30000);
solo.note(`solo 机变 picked (round ${round})`);
}
await sleep(1700);
await checkHud(solo, `solo prep r${round}`);
await takeReward(solo, did);
if (round === 2) await levelUp(solo);
for (let k = 0; k < 3; k++) if (!(await buyOne(solo))) break;
await deployFromHand(solo, 4, did);
await equipItem(solo).catch(() => false);
await solo.shot(`prep-r${round}`);
await ready(solo, { key: round === 2 });
await solo.waitFor((s) => s.phase === 'COMBAT', `combat ${round}`, 30000);
const st = await waitUnits(solo);
assert.ok(st?.units > 0, 'solo battle renders units');
await sleep(1500);
await solo.shot(`combat-r${round}`);
}
// leave for good: exit → 放弃模拟 → lobby
await solo.click('.gtop__exit');
await sleep(300);
await solo.shot('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 });
assert.deepEqual(problemsOf([solo]), [], 'no console errors, page errors, failed requests or HTTP errors');
} finally {
console.log(solo.log.join('\n'));
if (solo.problems.length) console.log(`solo problems:\n ${solo.problems.slice(0, 30).join('\n ')}`);
console.log(`screenshots: ${solo.shots.join(' ')}`);
await solo.close();
await srv.stop();
}
});
test('result: a fast-timer solo run to the RESULT screen and back to the room', { skip: !!ONLY && ONLY !== 'result', timeout: 20 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 0.3, combatSpeed: 24 } });
const c = new Client(await pptr(), srv.base, 'result', { prefix: 'result' });
try {
await c.open();
await c.enter('煌');
await c.click('.mode-card', '独立模拟');
await c.click('.diff-card', '标准模拟');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor((s) => !!s.room, 'solo room');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await c.click('.dband', null, { nth: 0 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
const t0 = Date.now();
let bossShot = false;
let bossPrep = null; // the Final Assault prep: { camera, deployed } (research 09 §1.2, own half of the boss field)
while (Date.now() - t0 < 15 * 60 * 1000) {
const s = await c.st();
if (s.result || s.phase === 'RESULT') break;
if (s.phase === 'PREP' && !s.ready && s.alive) {
// one cheap operator on the board each round keeps some fights going
await buyOne(c).catch(() => false);
if (s.round === s.bossRound && !bossPrep) {
// the boss round's prep: the pieces stand on the own (left, solo) half of the boss field; a hand unit still
// drags onto a board tile and gets its direction (drops come back in board coordinates)
await sleep(900);
const cam = (await c.st()).camera;
const pf = await c.page.evaluate(() => globalThis.__SP_VIEW__?.raw?.prepField?.() ?? null);
let deployed = 0;
let err = null;
try { deployed = await deployFromHand(c, 1, { dragShot: true }); } catch (e) { err = e; }
await c.shot('boss-prep');
bossPrep = { cam, pf, deployed, err: err ? String(err.message || err) : null };
c.note(`boss-round prep: camera ${cam}, ${JSON.stringify(pf)}, deployed ${deployed}${err ? ` (${bossPrep.err})` : ''}`);
} else await deployFromHand(c, 2, { dragShot: true }).catch(() => 0);
await ready(c);
} else if (s.phase === 'SP_DRAFT' && (await c.exists('.spcard.is-pickable'))) {
await c.click('.spcard.is-pickable', null, { optional: true, timeout: 2000 });
await c.click('.spcard.is-armed', null, { optional: true, timeout: 2000 });
} else if ((s.phase === 'FINAL_ASSAULT' || s.phase === 'HIDDEN_CORE') && !bossShot) {
await waitUnits(c, 8000);
await sleep(1200);
await c.shot('boss');
bossShot = true;
}
await sleep(300);
}
await c.waitFor((s) => s.result, 'm.result', 30000);
if (bossPrep) {
assert.equal(bossPrep.cam, 'bossPrep', 'the boss-round prep shows the own half of the boss field');
assert.deepEqual(bossPrep.pf, { kind: 'bossPrep', side: 'L', mirror: false }, 'solo: the left half');
assert.equal(bossPrep.err, null, 'a deploy in the boss-round prep lands on its board tile with its direction');
} else c.note('the run ended before the boss round');
await c.page.waitForSelector('.screen.result', { timeout: 10000 });
await sleep(1500);
await c.shot('screen');
const players = await c.page.$$eval('.rcard', (els) => els.length);
assert.equal(players, 1, 'one player card');
await c.click('.result__foot button');
await c.page.waitForSelector('.room-screen, .lobby-screen', { timeout: 15000 });
await sleep(500);
await c.shot('back');
assert.deepEqual(problemsOf([c]), [], 'no console errors, page errors, failed requests or HTTP errors');
} finally {
console.log(c.log.join('\n'));
if (c.problems.length) console.log(`result problems:\n ${c.problems.slice(0, 30).join('\n ')}`);
console.log(`screenshots: ${c.shots.join(' ')}`);
await c.close();
await srv.stop();
}
});
});