Files
ark-stronghold-protocol/test/ui/loadout-battle.e2e.test.js
T
sganggs 267a826b58 feedback7 R22-E2EFIX: loadout-battle / teammate-loadout e2e choose 野鬃's S1 + 不装备 in the row's quick choices and expect the default 练度 on the detail card; golden: unchanged
The 0.2.2 干员调配 is one list: a row carries the skills and the elite's modules, and the detail (now opening with
潜能与练度 and 特质) has its module cards below the fold at 1920 × 1080, where the Client's real-mouse click (no
scrolling) found nothing clickable for `.lo-detail .lo-mod[data-module="none"]`. Both suites now tap the row's
`.lo-q--skill[data-skill="0"]` / `.lo-q--mod[data-module="none"]` and wait for the row (aria-pressed) and the detail
(is-on) to agree before the sync; what they assert is unchanged (m.private.loadout → b.start spec → the local sim
runs S1 without the module; the host's card of the guest's unit reads S1 已调配 / 未装备模组).
loadout-battle's detail card check then met 0.2.2's other change: the card's base ATK carries the player's 练度 — the
default 精英2 Lv.60, ×1.1 (576 for the record's 524, effects.json aceffect_char_4); the unit's def keeps 524.

- Tests (SP_E2E=1): loadout-battle 2/2, teammate-loadout 1/1.
2026-10-08 23:44:02 +08:00

249 lines
16 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 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__pick');
// the row's quick choices (0.2.2: the roster is one list, a row per operator with its skills and the elite's
// modules; the detail's own module cards sit below the fold) — the row and the detail agree on S1 + 不装备
await c.click('.lo-card .lo-q--skill[data-skill="0"]');
await c.click('.lo-card .lo-q--mod[data-module="none"]');
await c.page.waitForFunction(() => document.querySelector('.lo-card .lo-q--skill[data-skill="0"]')?.getAttribute('aria-pressed') === 'true'
&& document.querySelector('.lo-card .lo-q--mod[data-module="none"]')?.getAttribute('aria-pressed') === 'true'
&& document.querySelector('.lo-detail .lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-detail .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;
// 0.2.2: the card's base value carries the player's 练度, here the default 精英2 Lv.60 (effects.json aceffect_char_4,
// ×1.1 ATK — a multiplier of its own, the unit's def keeps the record's 524)
const t4 = JSON.parse(readFileSync(path.join(ROOT, 'data/effects.json'), 'utf8')).aceffect_char_4.buffs.find((b) => b.key === 'char_attribute_mul').bb;
assert.equal(detail.stats['攻击'], String(Math.round(rec.statsBase.atk * t4.atk)), `detail card ATK without the module, at the default 练度 (${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();
}
});
});