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.
249 lines
16 KiB
JavaScript
249 lines
16 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__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();
|
||
}
|
||
});
|
||
});
|