Files
ark-stronghold-protocol/test/ui/loadout-battle.e2e.test.js
T
Sukvii ffdb1293c4 Deploy-timed skills run the normal duration lifecycle: one charge on deployment, a duration bar, a real end, again on the next deployment (#109)
宴 S2, 斯卡蒂 S2, 野鬃 S1, 砾 S1 / S2, 伊内丝 S3, 缄默德克萨斯 S1–S3 and 耀骑士临光 S2 used a separate timed buff while the skill stayed active; equipment and bond effects that read the skill state now see it end. 伊内丝's first sentry placement starts no skill. Contributed by @Sukvii.
2026-10-05 22:36:29 +08:00

242 lines
15 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 S1 active with a draining duration bar, ASPD +100 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';
import { makeBattle } from '../helpers/battleHarness.js';
import { UF } from '../../shared/constants.js';
import { COLORS } from '../../public/js/render/style.js';
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?.skill?.active || u.skill.id !== 'skchr_wildmn_1') return false;
const tuple = e.battle.snapshot().units.find((t) => t[0] === u.id);
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,
liveAspd: u.s.aspd, timeLeft: u.skill.timeLeft, duration: u.skill.duration, ready: u.skill.ready,
bar: tuple && { value: tuple[5], max: tuple[6] },
};
}, { 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.liveAspd - got.aspd, s1.bb.attack_speed, 'S1 signature: ASPD +100 on deploy');
assert.equal(got.ready, false, 'deployment consumed its sole charge');
assert.equal(got.duration, s1.duration);
assert.equal(got.bar.max, s1.duration, 'zero-SP skill has a duration bar');
assert.ok(Math.abs(got.bar.value - got.timeLeft) <= 0.06);
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();
}
});
test('野鬃 S1 duration bar: full on deploy, half remaining, hidden after expiry (real renderer)', { timeout: 120000 }, async () => {
const h = makeBattle({
stageId: 'act2autochess_m01', autoFinish: false, timeLimit: 60,
units: [{ chessId: ELITE, skillIndex: 0, moduleId: 'none', row: 9, col: 5 }],
});
h.b.start();
const u = h.unit(ELITE);
assert.equal(u.skill.id, 'skchr_wildmn_1');
const meta = { ...h.b.fieldMeta(), stageId: 'act2autochess_m01' };
const frames = [];
for (const [name, elapsed, share] of [['full', 0, 1], ['half', u.skill.duration / 2, 0.5], ['ended', u.skill.duration / 2 + h.TICK, 0]]) {
h.run(elapsed);
const { t, ...rest } = h.snapshot();
frames.push({ name, share, snap: { ...rest, t: 'b.snap', gt: t } });
assert.equal(u.skill.ready, false, `${name}: no remaining deploy charge`);
assert.equal(u.skill.active, name !== 'ended');
}
const srv = await startRealServer();
const P = (await import('puppeteer-core')).default;
const c = new Client(P, srv.base, 'duration', { prefix: 'loadout-battle' });
try {
await c.open();
await c.page.goto(`${srv.base}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await c.page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
assert.equal(await c.page.evaluate(() => window.__demo.error ?? null), null);
for (const f of frames) {
await c.page.evaluate((meta, snap) => {
const v = window.__demo.view;
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
v.pushSnapshot(snap);
}, meta, f.snap);
await c.page.waitForFunction((id, spMax) => {
const x = window.__demo.view.debug.views.get(id);
return x?.spineReady && x.hud.visible && x.hud.alpha > 0.99 && x.hpFill.visible && x.spMax === spMax;
}, { timeout: 15000 }, u.id, f.snap.units.find((t) => t[0] === u.id)[6]);
const bar = await c.page.evaluate((id) => {
const x = window.__demo.view.debug.views.get(id);
return { visible: x.spFill.visible, bg: x.spBg.visible, share: x.spFill.width / (x.spBg.width - 2),
tint: x.spFill.tint, flags: x.flags, glow: x.spGlow.visible, sp: x.sp, max: x.spMax };
}, u.id);
assert.equal(bar.glow, false, `${f.name}: no ready highlight`);
assert.equal(!!(bar.flags & UF.SKILL), f.name !== 'ended');
assert.equal(bar.visible, f.name !== 'ended');
assert.equal(bar.bg, f.name !== 'ended');
if (bar.visible) {
assert.ok(Math.abs(bar.share - f.share) < 0.002, `${f.name}: drawn share ${bar.share}`);
assert.equal(bar.tint, COLORS.spActive);
} else assert.deepEqual([bar.sp, bar.max], [0, 0]);
await c.shot(`duration-${f.name}`);
}
assert.deepEqual(problemsOf([c]), []);
} finally {
await c.close();
await srv.stop();
}
});
});