Files
ark-stronghold-protocol/test/ui/loadout-battle.e2e.test.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

175 lines
12 KiB
JavaScript

// Real-server browser E2E of user playtest #2 item 1 end to end (DESIGN §16): the loadout chosen in the 干员调配
// overlay reaches the server (room.loadout → seat → Match → m.private.loadout), the battle spec (units[].skillIndex /
// moduleId) and the browser's own local battle (client-side combat, DESIGN §14), which fights with the chosen skill and
// module — checked by the skill's signature effect in the local sim, not only by ids.
//
// SP_E2E=1 node --test test/ui/loadout-battle.e2e.test.js
//
// The chess: elite 野鬃 (chess_char_1_19_b). Default = S2 夹枪冲锋 (25/40 SP) + module 长枪替补套装 (ATK +40, ASPD +3).
// Chosen here: S1 骑枪刺击 (ON_DEPLOY: "部署后攻击速度+100" for 25 s) + 不装备 — so right after the unit deploys the
// local battle must show the S1 buff (wildmn:s1, ASPD +100, skill active) and the no-module stats (ATK 524, ASPD 100).
// The server is test/e2e/fastServer.mjs with its starter-kit hook (SP_START_CHESS: the elite in the hand at round 1);
// everything else is the real match engine and the real UI driven by real mouse input.
// Screenshots: test/e2e/out/loadout-battle-*.png.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const BASE = 'chess_char_1_19_a';
const ELITE = 'chess_char_1_19_b';
/** Item 11: the phase's server deadline and the countdown widgets actually shown. */
const untimed = (c) => c.page.evaluate(() => ({
deadline: !!globalThis.__SP__.store.get().match.public?.deadline,
shown: [...document.querySelectorAll('.countdown')].filter((el) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1 && getComputedStyle(el).display !== 'none'; }).length,
}));
describe('user playtest #2 item 1 — loadout chosen in the UI fights in the local battle (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
test('干员调配 S1 + 不装备 for 野鬃 → m.private.loadout → b.start spec → the local sim runs S1 with the no-module stats', { timeout: 6 * 60 * 1000 }, async () => {
const chess = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const rec = (Array.isArray(chess) ? chess : Object.values(chess.chess || chess)).find((x) => x && x.chessId === ELITE);
const s1 = rec.skills.find((s) => s.index === 0);
assert.equal(s1.skillId, 'skchr_wildmn_1');
assert.equal(s1.spType, 'ON_DEPLOY');
assert.notEqual(rec.skills.find((s) => s.isDefault).index, 0, 'S1 is not the default skill');
assert.ok(rec.modules.some((m) => m.isDefault && m.uniEquipId !== 'uniequip_001'), 'the elite has a default module');
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, chess: [ELITE] } });
const P = (await import('puppeteer-core')).default;
const c = new Client(P, srv.base, 'loadout', { prefix: 'loadout-battle' });
try {
await c.open();
await c.enter('调配');
// 1) the 干员调配 overlay: search 野鬃 → S1 + 不装备 (real clicks), synced with the server
await c.click('.lobby-screen [data-testid="loadout-open"]');
await c.page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await c.click('.lo-search input');
await c.page.keyboard.type('野鬃');
await c.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await c.click('.lo-card');
await c.click('.lo-detail .lo-skill[data-skill="0"]');
await c.click('.lo-detail .lo-mod[data-module="none"]');
await c.page.waitForFunction(() => document.querySelector('.lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-mod.is-on[data-module="none"]'), { timeout: 3000 });
await c.page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
await c.shot('overlay');
await c.page.keyboard.press('Escape');
await c.page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
// 2) a solo 标准 match: the briefing's m.private carries the loadout
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);
const lo = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.loadout ?? null);
assert.deepEqual(lo, { [BASE]: { skill: 0, module: 'none' } }, 'the match received the loadout');
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await sleep(500);
assert.deepEqual(await untimed(c), { deadline: false, shown: 0 }, 'item 11: the solo strategy draft is untimed');
await c.click('.dband', null, { nth: 1 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.hand > 0, 'prep with the starter kit', 60000);
await sleep(1800); // camera flight + pieces
assert.deepEqual(await untimed(c), { deadline: false, shown: 0 }, 'item 11: solo prep is untimed');
// 3) place the elite (drag → direction wheel)
await c.hookRequests();
const [piece] = (await c.handPieces('chess')).filter((p) => p.id === ELITE);
assert.ok(piece, 'the elite 野鬃 is in the hand');
const tile = await c.freeTileFor(piece.uid);
assert.ok(tile, 'a legal tile');
const from = await c.piecePoint(piece.uid);
const to = await c.tilePoint(tile.row, tile.col);
await c.drag(from, to);
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('RIGHT');
await c.waitFor((s) => s.board > 0, 'placed', 8000);
await sleep(600);
// the detail card of the placed elite shows the chosen skill (已调配) and 未装备模组
let detail = null;
for (const at of [0.72, 0.4, 0.88]) {
const p = await c.piecePoint(piece.uid, at);
if (!p) continue;
await c.page.mouse.click(p.x, p.y, { button: 'right' }); // right-click: the detail card only
detail = await c.page.waitForSelector('.dpanel .dskill__name', { timeout: 2500 }).then(() => c.page.evaluate(() => ({
skill: document.querySelector('.dpanel .dskill__name')?.textContent || '',
tag: !!document.querySelector('.dpanel .dskill__name .dtag-loadout'),
module: document.querySelector('.dpanel .dmodule')?.textContent || '',
none: !!document.querySelector('.dpanel .dmodule.is-none'),
// the record (base) value: with live stats (user playtest #4 item 7: the start-of-battle preview) it is the
// cell's title "基础 N", else the value itself
stats: Object.fromEntries([...document.querySelectorAll('.dpanel .dstat')].map((el) => [el.querySelector('.dstat__k')?.textContent,
(el.getAttribute('title') || '').replace(/^基础 /, '') || el.querySelector('.dstat__v')?.textContent])),
trait: document.querySelector('.dpanel .dtrait')?.textContent || '',
})), () => null);
if (detail) break;
}
assert.ok(detail, 'the detail card opens for the placed elite');
assert.ok(detail.skill.includes(s1.name) && detail.tag, `detail card: ${s1.name} 已调配 (${JSON.stringify(detail)})`);
assert.ok(detail.none && detail.module.includes('未装备模组'), `detail card: 未装备模组 (${JSON.stringify(detail)})`);
// …and the stats / 特性 the unit fights with: no module (ATK 524, interval 1.00 s, "获得1点") — not the default module's
assert.equal(detail.stats['攻击'], String(rec.statsBase.atk), `detail card ATK without the module (${JSON.stringify(detail.stats)})`);
assert.equal(detail.stats['攻击间隔'], '1.00s', 'detail card interval: ASPD 100');
assert.match(detail.trait, /获得1点部署费用/, `detail card 特性 without the module (${detail.trait})`);
await c.shot('detail');
await c.page.keyboard.press('Escape');
await c.page.waitForFunction(() => !document.querySelector('.dpanel'), { timeout: 4000 });
// 4) ready → the local battle
await c.click('.readybtn');
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
const got = await c.page.waitForFunction((uid) => {
const r = globalThis.__SP_RUNNER__;
const e = r && [...r._entries.values()].find((x) => x.own && x.battle);
if (!e) return false;
const u = e.battle.allyUnits.find((x) => x.uid === uid);
if (!u || !u.findBuff?.('wildmn:s1')) return false;
const b = u.findBuff('wildmn:s1');
const entry = (e.spec.players || []).flatMap((p) => p.units || []).find((x) => x.uid === uid) || null;
return {
authoritative: e.authoritative, t: e.battle.time,
spec: entry && { chessId: entry.chessId, skillIndex: entry.skillIndex, moduleId: entry.moduleId },
skill: u.skill?.id ?? null, source: u.kit?.skillSource ?? null, active: !!u.skill?.active,
loadout: u.def?.loadout ?? null, atk: u.def?.stats?.atk, aspd: u.def?.stats?.aspd,
buff: { aspd: b.mods?.aspd ?? null },
};
}, { timeout: 30000, polling: 100 }, piece.uid).then((h) => h.jsonValue())
.catch(async (err) => {
const dbg = await c.page.evaluate((uid) => {
const r = globalThis.__SP_RUNNER__;
const e = r && [...r._entries.values()].find((x) => x.own && x.battle);
const u = e?.battle.allyUnits.find((x) => x.uid === uid);
return { entries: r ? r._entries.size : -1, unit: u ? { skill: u.skill?.id, active: u.skill?.active, buffs: u.buffs.map((b) => b.key) } : null };
}, piece.uid);
throw new Error(`${err.message} — ${JSON.stringify(dbg)}`);
});
assert.equal(got.authoritative, true, 'the own normal battle is simulated by this browser');
assert.deepEqual(got.spec, { chessId: ELITE, skillIndex: 0, moduleId: 'none' }, 'b.start spec carries the loadout');
assert.equal(got.skill, 'skchr_wildmn_1', 'the local unit carries S1');
assert.equal(got.source, 'skills', 'with its hand-authored spec');
assert.deepEqual({ skillIndex: got.loadout?.skillIndex, moduleId: got.loadout?.moduleId }, { skillIndex: 0, moduleId: 'none' });
assert.equal(got.atk, rec.statsBase.atk, 'no module: base ATK (the default module adds +40)');
assert.equal(got.aspd, rec.statsBase.aspd, 'no module: base ASPD');
assert.notEqual(rec.stats.atk, rec.statsBase.atk, 'control: the default module would change ATK');
assert.equal(got.buff.aspd, s1.bb.attack_speed, 'S1 signature: ASPD +100 on deploy');
assert.ok(got.active, 'S1 is active right after deploying');
assert.ok(got.t < 10, `S1 fires on deploy (t = ${got.t}), long before S2 could charge (25/40 SP)`);
await sleep(800);
await c.shot('combat');
// the battle ends and the round settles normally (the result upload is accepted)
await c.waitFor((s) => s.phase === 'PREP' && s.round >= 2 || !!s.result, 'next prep', 180000);
assert.deepEqual(problemsOf([c]), []);
} finally {
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
await c.close();
await srv.stop();
}
});
});