宴 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.
242 lines
15 KiB
JavaScript
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();
|
|
}
|
|
});
|
|
});
|