Files
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

349 lines
21 KiB
JavaScript
Raw Permalink 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.
// Browser E2E of the 统计数据 page (public/js/screens/stats.js, ui/stats.js; PR #323 by @2321Robin, reworked for 0.2.2)
// against the real server, headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1 node --test test/ui/stats.e2e.test.js
//
// 1. A seeded history (records built from three REAL m.result payloads, test/fixtures/stats-results.json) on desktop
// 1920×1080 and a phone in landscape 844×390: the four tabs, the counting rule on screen (the match left in the first
// round is listed as 中途退出 / 不计入统计 and left out of the totals), the row click that re-views a settlement and
// its 返回统计, Esc, the title screen's entry; zero console errors. Screenshots: test/e2e/out/stats-*.png
// 2. The data actions: 导出 hands out a file the page itself imports again (nothing new), a corrupt file and a newer
// one are refused with a message and change nothing, 清空 asks first (the confirm dialog sits above the overlay and
// Esc closes only it), unreadable storage is set aside with a notice, a newer build's data is never overwritten.
// 3. The real flows: a solo match given up in the briefing is history only; one given up after round 1 counts (1 round
// cleared, the server's own rule); a match played to its settlement is recorded once — also after a reload (the
// lobby replays m.result).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Client, sleep, startRealServer, hasChrome, OUT } from '../e2e/client.mjs';
import { buildRecord, buildQuitRecord, createLiveMatch, observeMatch, STATS_VERSION } from '../../public/js/ui/stats.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const ENABLED = process.env.SP_E2E === '1' && hasChrome();
const FIX = JSON.parse(readFileSync(path.join(ROOT, 'test/fixtures/stats-results.json'), 'utf8'));
const BANDS = Object.keys(JSON.parse(readFileSync(path.join(ROOT, 'data/bands.json'), 'utf8')));
const NOW = Date.parse('2026-10-08T21:30:00');
const DAY = 86400000;
/** n settled records from the real payloads (cycling solo / 4-player co-op, difficulties, outcomes, bands) + two quit records. */
function seed(n = 24) {
const recs = [];
const names = ['solo-normal', 'coop4-hard', 'coop4-abyss'];
for (let i = 0; i < n; i++) {
const res = JSON.parse(JSON.stringify(FIX[names[i % 3]]));
const solo = i % 3 === 0;
const diff = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS'][i % 4];
const win = i % 5 < 2;
Object.assign(res, { seed: 1000 + i, difficulty: diff, modeId: `mode_${solo ? 'single' : 'multi'}_${diff.toLowerCase()}`, victory: win, reason: win ? 'victory' : 'defeat', roundsPassed: win ? 14 : 4 + (i % 8), durationMs: (8 + (i % 13)) * 60000 });
const me = res.players.find((p) => p.playerId === 'p_0');
Object.assign(me, { bandId: BANDS[i % 7], victory: win, alive: win, roundsPassed: res.roundsPassed, title: i % 3 ? { id: `comment_${1 + (i % 6)}` } : null });
recs.unshift(buildRecord(res, { myId: 'p_0', roomMode: solo ? 'solo' : 'coop', now: NOW - i * 0.6 * DAY }));
}
const state = (phase, round) => ({
me: { playerId: 'p_0' }, room: { code: 'ABCD', mode: 'solo' },
match: { public: { phase, round, lastRound: 14, modeId: 'mode_single_hard', difficulty: 'HARD', players: [{ playerId: 'p_0', seat: 0, name: 'Doctor', alive: true, lp: 30, bandId: BANDS[2] }] }, result: null },
});
const quit = (phase, round, t) => { const live = createLiveMatch(); observeMatch(live, state('INFO_CHECK', 0), t - 600000); return buildQuitRecord(state(phase, round), { live, now: t }); };
recs.push(quit('PREP', 1, NOW - 2 * 3600e3), quit('PREP', 7, NOW - 1.3 * DAY));
return { v: STATS_VERSION, records: recs.sort((a, b) => b.t - a.t) };
}
describe('统计数据 page (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let puppeteer;
const clients = [];
before(async () => {
puppeteer = (await import('puppeteer-core')).default;
srv = await startRealServer();
mkdirSync(OUT, { recursive: true });
});
after(async () => { for (const c of clients) await c.close(); await srv?.stop(); });
/** A fresh browser on `base` with `sp.pref.stats` (a string) stored before the page loads. */
async function open({ w = 1920, h = 1080, stored = null, entered = true, base = srv.base, lang = null } = {}) {
const c = new Client(puppeteer, base, 'stats', { prefix: 'stats', w, h });
clients.push(c);
c.browser = await puppeteer.launch({
executablePath: process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', headless: true,
args: ['--no-sandbox', '--mute-audio', '--force-device-scale-factor=1'],
});
const [first] = await c.browser.pages();
c.page = first || await c.browser.newPage();
await c.page.setViewport({ width: w, height: h, hasTouch: w < 1000, isMobile: w < 1000 });
c.page.on('console', (m) => { if (m.type() === 'error') c.problems.push(`console: ${m.text()}`); });
c.page.on('pageerror', (e) => c.problems.push(`pageerror: ${e.message}`));
await c.page.evaluateOnNewDocument((stored, entered, lang) => {
localStorage.setItem('sp.name', 'Doctor');
if (entered) sessionStorage.setItem('sp.entered', '1');
if (lang) localStorage.setItem('sp.pref.lang', JSON.stringify(lang));
if (stored != null && !sessionStorage.getItem('sp.e2e.seeded')) { localStorage.setItem('sp.pref.stats', stored); sessionStorage.setItem('sp.e2e.seeded', '1'); }
}, stored, entered, lang);
await c.page.goto(`${base}/`, { waitUntil: 'domcontentloaded' });
await c.page.waitForFunction((needOnline) => (!needOnline || globalThis.__SP__?.store.get().connection.status === 'online') && !!document.querySelector('.screen'), { timeout: 30000 }, entered);
return c;
}
const texts = (c, sel) => c.page.$$eval(sel, (els) => els.map((e) => e.innerText.replace(/\s+/g, ' ').trim()));
const stored = (c) => c.page.evaluate(() => { const r = localStorage.getItem('sp.pref.stats'); return r ? JSON.parse(r) : null; });
const openStats = async (c, from = '.lobby-screen .stats-entry') => {
await c.click(from, from.includes('stats-entry') ? null : '统计');
await c.page.waitForSelector('.st .st-tab', { visible: true, timeout: 15000 });
};
const toast = (c) => c.page.waitForFunction(() => document.querySelector('.toast')?.textContent || false, { timeout: 8000 }).then((h) => h.jsonValue());
const noProblems = (c) => assert.deepEqual(c.problems, [], 'no console errors / failed requests');
test('desktop: tabs, the counting rule on screen, a settlement re-viewed, Esc, the title entry', async () => {
const env = seed();
const c = await open({ stored: JSON.stringify(env) });
await c.page.waitForSelector('.lobby-screen');
await openStats(c);
// 总览: 24 settled + the quit after 6 rounds count, the first-round one does not
const cards = await texts(c, '.st-card__num');
assert.equal(cards[0], '25', 'games');
assert.match(await c.page.$eval('.st-rule', (e) => e.textContent), /另有 1 局未计入统计/);
assert.equal(await c.page.$$eval('.st-title', (els) => els.length), 6, 'the six 评语');
await c.page.screenshot({ path: path.join(OUT, 'stats-overview-desktop.png') });
// 策略 / 战斗累计
await c.click('.st-tab[data-tab="bands"]');
assert.ok((await c.page.$$eval('.st-table tbody tr', (r) => r.length)) >= 5, 'strategy rows');
await c.page.screenshot({ path: path.join(OUT, 'stats-bands-desktop.png') });
await c.click('.st-tab[data-tab="totals"]');
assert.ok((await c.page.$$eval('.st-sum', (r) => r.length)) >= 8, 'combat totals');
// 对局记录: every record, the left-early ones marked, the first-round one dimmed, only settled rows open
await c.click('.st-tab[data-tab="history"]');
const rows = await texts(c, '.st-table--history tbody tr');
assert.equal(rows.length, 26);
assert.equal(rows.filter((r) => r.includes('中途退出')).length, 2);
assert.equal(rows.filter((r) => r.includes('不计入统计')).length, 1);
assert.equal(await c.page.$$eval('.st-table--history tr.is-excluded', (r) => r.length), 1);
assert.equal(await c.page.$$eval('.st-table--history tr.is-clickable', (r) => r.length), 24);
await c.page.screenshot({ path: path.join(OUT, 'stats-history-desktop.png') });
// a row re-views the settlement (the real result screen), 返回统计 comes back to the list, Esc closes
await c.click('.st-table--history tr.is-clickable');
await c.page.waitForSelector('.st--replay .result', { visible: true, timeout: 20000 });
await sleep(900);
await c.page.screenshot({ path: path.join(OUT, 'stats-replay-desktop.png') });
assert.ok(await c.page.$('.st--replay .rcard.is-self'), 'the player\'s own card is marked 你 (the record\'s own id)');
await c.page.keyboard.press('Escape');
await c.page.waitForSelector('.st .st-tab', { timeout: 5000 });
assert.ok(!(await c.exists('.st--replay')), 'Esc in the re-view goes back to the list');
await c.page.keyboard.press('Escape');
await c.page.waitForFunction(() => !document.querySelector('.st'), { timeout: 5000 });
assert.ok(await c.exists('.lobby-screen'));
noProblems(c);
await c.close();
// the title screen's entry (not entered yet: no connection needed), in English
const t = await open({ stored: JSON.stringify(env), entered: false, lang: 'en' });
await t.click('.title-corner .btn', 'Stats');
await t.page.waitForSelector('.st .st-tab', { visible: true, timeout: 15000 });
assert.match(await t.page.$eval('.st-top', (e) => e.textContent), /Overview/);
await t.page.screenshot({ path: path.join(OUT, 'stats-overview-title-en.png') });
noProblems(t);
await t.close();
});
test('phone in landscape (844×390): the page and the re-view fit; no console errors', async () => {
const c = await open({ w: 844, h: 390, stored: JSON.stringify(seed()) });
await c.page.waitForSelector('.lobby-screen');
await openStats(c);
const fits = () => c.page.evaluate(() => {
const st = document.querySelector('.st').getBoundingClientRect();
const top = document.querySelector('.st-top').getBoundingClientRect();
const right = document.querySelector('.st-top__right').getBoundingClientRect();
const tabs = document.querySelector('.st-tabs').getBoundingClientRect();
return { w: st.width, topH: top.height, rightEdge: right.right, tabsRight: tabs.right, rightLeft: right.left, scrollW: document.documentElement.scrollWidth };
});
const f = await fits();
assert.ok(f.rightEdge <= f.w + 1 && f.tabsRight <= f.rightLeft + 1, `the top bar fits: ${JSON.stringify(f)}`);
assert.ok(f.scrollW <= 845, 'no horizontal page scroll');
await c.page.screenshot({ path: path.join(OUT, 'stats-overview-phone.png') });
await c.click('.st-tab[data-tab="history"]');
await c.page.screenshot({ path: path.join(OUT, 'stats-history-phone.png') });
await c.click('.st-table--history tr.is-clickable');
await c.page.waitForSelector('.st--replay .result', { visible: true, timeout: 20000 });
await sleep(900);
await c.page.screenshot({ path: path.join(OUT, 'stats-replay-phone.png') });
await c.click('.st--replay .btn--primary', '返回统计');
await c.page.waitForSelector('.st .st-tab', { timeout: 5000 });
noProblems(c);
});
test('the data actions: export → import (nothing new), a corrupt file, a newer file, 清空 with its confirm dialog', async () => {
const c = await open({ stored: JSON.stringify(seed(6)) });
await c.page.waitForSelector('.lobby-screen');
await openStats(c);
// 导出: capture the blob the page hands to the browser
await c.page.evaluate(() => {
HTMLAnchorElement.prototype.click = function click() { globalThis.__dl = { name: this.download, href: this.href }; };
});
await c.click('[data-testid="stats-export"]');
await c.page.waitForFunction(() => !!globalThis.__dl, { timeout: 5000 });
const exported = await c.page.evaluate(async () => { const r = await fetch(globalThis.__dl.href); return { name: globalThis.__dl.name, text: await r.text() }; });
assert.match(exported.name, /^stronghold-stats-\d{8}-\d{4}\.json$/);
const payload = JSON.parse(exported.text);
assert.equal(payload.kind, 'local-stats');
assert.equal(payload.records.length, 8);
const dir = mkdtempSync(path.join(tmpdir(), 'sp-stats-'));
const upload = async (name, text) => {
const file = path.join(dir, name);
writeFileSync(file, text);
const input = await c.page.$('.st input[type="file"]');
await input.uploadFile(file);
};
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
// 导入 the same file: nothing new
await upload('same.json', exported.text);
assert.match(await toast(c), /没有新记录/);
assert.equal((await stored(c)).records.length, 8);
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
// a corrupt file and a newer one change nothing
await upload('bad.json', '{"records": [1, 2');
assert.match(await toast(c), /不是有效的统计导出文件/);
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
await upload('newer.json', JSON.stringify({ kind: 'local-stats', v: 99, records: [] }));
assert.match(await toast(c), /更新版本/);
assert.equal((await stored(c)).records.length, 8);
await c.page.evaluate(() => document.querySelectorAll('.toast').forEach((e) => e.remove()));
// 导入 a file with one new record: merged, newest first
const extra = { ...payload.records[0], id: 'r1.cafe0001', t: NOW + DAY };
await upload('extra.json', JSON.stringify({ ...payload, records: [extra] }));
assert.match(await toast(c), /已导入 1 条记录/);
assert.equal((await stored(c)).records[0].id, 'r1.cafe0001');
// 清空: the confirm dialog is above the overlay, Esc closes only it, the second time confirms
await c.click('[data-testid="stats-clear"]');
await c.page.waitForSelector('.modal', { visible: true });
assert.match(await c.page.$eval('.modal', (e) => e.textContent), /将删除本机保存的 9 条对局记录/);
await sleep(250); // the dialog's key handler is attached by its effect, a moment after it paints
await c.page.keyboard.press('Escape');
await c.page.waitForFunction(() => !document.querySelector('.modal'), { timeout: 3000 });
assert.ok(await c.exists('.st'), 'Esc closed the dialog only');
assert.equal((await stored(c)).records.length, 9, 'nothing deleted');
await c.click('[data-testid="stats-clear"]');
await c.page.waitForSelector('.modal', { visible: true });
await c.click('.modal__actions button.btn--danger');
await c.page.waitForFunction(() => /还没有记录/.test(document.querySelector('.st-body')?.textContent || ''), { timeout: 5000 });
assert.deepEqual((await stored(c)).records, []);
noProblems(c);
});
test('corrupt and newer storage: a notice, no crash; the newer build\'s data is never overwritten', async () => {
const bad = '{"v":1,"records":[{"players":[';
const c = await open({ stored: bad });
await c.page.waitForSelector('.lobby-screen');
await openStats(c);
assert.match(await c.page.$eval('.st-note.is-warn', (e) => e.textContent), /无法读取,已另存为备份/);
assert.equal(await c.page.evaluate(() => localStorage.getItem('sp.pref.stats.damaged')), bad, 'kept as a backup');
noProblems(c);
await c.close();
const newer = JSON.stringify({ v: STATS_VERSION + 3, records: [{ players: [{ playerId: 'x' }], future: true }] });
const n = await open({ stored: newer });
await n.page.waitForSelector('.lobby-screen');
await openStats(n);
assert.match(await n.page.$eval('.st-body', (e) => e.textContent), /更新版本的统计数据/);
assert.ok(await n.page.$eval('[data-testid="stats-clear"]', (b) => b.disabled), 'no 清空 over data this build cannot read');
assert.equal(await n.page.evaluate(() => localStorage.getItem('sp.pref.stats')), newer);
noProblems(n);
});
});
describe('统计数据: the real flows (a fast real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let puppeteer;
const servers = [];
before(async () => { puppeteer = (await import('puppeteer-core')).default; });
after(async () => { for (const s of servers) await s.stop(); });
const stored = (c) => c.page.evaluate(() => { const r = localStorage.getItem('sp.pref.stats'); return r ? JSON.parse(r) : null; });
async function toSoloMatch(c) {
await c.click('.mode-card', '独立模拟');
await sleep(300);
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);
}
async function toPrep(c) {
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await c.click('.dband', null, { nth: 1 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor((s) => s.phase === 'PREP' && s.round === 1, 'prep round 1', 60000);
await sleep(1500);
}
async function giveUp(c) {
await c.click('.stephead .btn--danger, .gtop__exit', null, { timeout: 8000 });
await c.click('.modal__actions button', '放弃模拟');
await c.waitFor((s) => !s.room && !s.phase, 'back in the lobby', 15000);
}
test('given up in the briefing: history only; given up after round 1: it counts', async () => {
const srv = await startRealServer({ fast: { timerScale: 0.25, combatSpeed: 8 } });
servers.push(srv);
const c = new Client(puppeteer, srv.base, 'stats', { prefix: 'stats', w: 1600, h: 900 });
try {
await c.open();
await c.enter('Doc');
// entered and left right away
await toSoloMatch(c);
await giveUp(c);
let env = await stored(c);
assert.equal(env.records.length, 1);
assert.equal(env.records[0].end, 'quit');
assert.equal(env.records[0].players[0].roundsPassed, 0);
// through round 1, then given up in round 2
await toSoloMatch(c);
await toPrep(c);
await c.click('.readybtn');
await c.waitFor((s) => s.round >= 2 && ['PREP', 'ROUND_START', 'SP_DRAFT'].includes(s.phase), 'round 2 (round 1 cleared)', 120000);
await giveUp(c);
env = await stored(c);
assert.equal(env.records.length, 2);
assert.equal(env.records[0].end, 'quit');
assert.equal(env.records[0].players[0].roundsPassed, 1, 'one round cleared — the server\'s own count for a quit in round 2');
assert.ok(env.records[0].players[0].bandId, 'its strategy');
// the page: one game counted, one left out, both in the history
await c.click('.lobby-screen .stats-entry');
await c.page.waitForSelector('.st .st-card', { visible: true });
assert.equal((await c.page.$$eval('.st-card__num', (els) => els.map((e) => e.textContent)))[0], '1');
assert.match(await c.page.$eval('.st-rule', (e) => e.textContent), /另有 1 局未计入统计/);
await c.click('.st-tab[data-tab="history"]');
const rows = await c.page.$$eval('.st-table--history tbody tr', (trs) => trs.map((tr) => tr.innerText.replace(/\s+/g, ' ').trim()));
assert.equal(rows.length, 2);
assert.ok(rows[0].includes('中途退出') && !rows[0].includes('不计入统计'), rows[0]);
assert.ok(rows[1].includes('中途退出') && rows[1].includes('不计入统计'), rows[1]);
await c.shot('history-real');
assert.deepEqual(c.problems, []);
} finally { await c.close(); }
});
test('played to its settlement: one record, and a reload (the lobby replays m.result) adds no second one', async () => {
const srv = await startRealServer({ fast: { timerScale: 0.25, combatSpeed: 8, finishAfter: 1 } });
servers.push(srv);
const c = new Client(puppeteer, srv.base, 'stats', { prefix: 'stats', w: 1600, h: 900 });
try {
await c.open();
await c.enter('Doc');
await toSoloMatch(c);
await toPrep(c);
await c.click('.readybtn');
await c.waitFor((s) => s.result, 'the settlement', 120000);
await sleep(500);
let env = await stored(c);
assert.equal(env.records.length, 1);
assert.equal(env.records[0].end, 'settled');
assert.equal(env.records[0].roomMode, 'solo');
assert.equal(env.records[0].selfId, (await c.st()).me);
await c.page.reload({ waitUntil: 'domcontentloaded' });
await c.page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
await c.waitFor((s) => s.result, 'the replayed settlement after a reload', 30000);
await sleep(500);
env = await stored(c);
assert.equal(env.records.length, 1, 'the replay is the same record');
assert.deepEqual(c.problems, []);
} finally { await c.close(); }
});
});