0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)

Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
This commit is contained in:
sganggs committed 2026-10-09 22:55:24 +08:00
1 parent 314564c139
commit f3658a0dd4
104 files changed
+3674 -741

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
// Browser check of 设置 →「文字大小」 (opt-in, needs Chrome, ~30 s):
// SP_E2E=1 CHROME_PATH=… node --test test/ui/text-scale.e2e.test.js
//
// A 844×390 landscape phone: 1rem is clamped at 40 px, so the .18rem body text is 7.2 CSS px. The setting steps the text
// root `--t` (css/theme.css) instead of the layout root: the readable text grows, while `documentElement`'s font-size,
// the modal's rem-sized box and — through the dev mock's 休整期 screen — the field host, the canvas and the shop bar stay
// exactly where they were. Static counterpart: test/ui/text-scale.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const PHONE = { width: 844, height: 390 };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** The settings the mock harness reads: `sp.pref.settings` with one text step (the argument is passed into the page —
* evaluateOnNewDocument cannot see a closure). */
const seedSettings = (page, textSize) => page.evaluateOnNewDocument((ts) => {
try { localStorage.setItem('sp.pref.settings', JSON.stringify({ textSize: ts })); } catch { /* ignore */ }
}, textSize);
describe('文字大小 on a phone (headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
/** A page watching for console / page errors, with `sp.pref.settings` seeded before the first script runs. */
async function open(url, textSize, { seed = true } = {}) {
const page = await browser.newPage();
await page.setViewport(PHONE);
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
if (seed && textSize) await seedSettings(page, textSize);
await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
return { page, problems };
}
/** What the settings modal shows at this step: the roots, a readable label, its Latin micro label, the box. */
const modalMetrics = (page) => page.evaluate(() => {
const px = (sel, prop = 'fontSize') => {
const el = document.querySelector(sel);
return el ? parseFloat(getComputedStyle(el)[prop]) : null;
};
const box = document.querySelector('.modal__box')?.getBoundingClientRect();
const body = document.querySelector('.modal__body');
const on = document.querySelector('[data-testid="text-size"] button.is-on');
// nothing may stick out of the modal's box (the label wraps and the four steps fit its row)
let outRight = 0;
for (const el of document.querySelectorAll('.modal__box *')) {
const r = el.getBoundingClientRect();
if (box && r.width > 0) outRight = Math.max(outRight, r.right - box.right);
}
return {
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
label: px('.set-row__label'),
micro: px('.set-row__label .micro'),
modalWidth: box ? Math.round(box.width) : null,
bodyOverflowX: body ? body.scrollWidth - body.clientWidth : null,
outRight: Math.round(outRight),
step: on?.textContent ?? null,
hint: document.querySelector('.set-textsize-note')?.textContent ?? null,
};
});
test('the steps grow the readable text; the layout root and the modal box do not move', async () => {
const { page, problems } = await open(`${base}/`, null, { seed: false });
await page.waitForSelector('input', { timeout: 15000 });
await page.type('input', '文字大小');
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /开始/.test(b.textContent))?.click());
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
await page.click('.lobby-screen [data-testid="settings-btn"]');
await page.waitForSelector('.modal .set-list', { timeout: 5000 });
await sleep(200);
const small = await modalMetrics(page);
assert.equal(small.step, '小', 'the default is the design\'s own sizes');
assert.equal(small.root, 40, 'a landscape phone clamps the layout root at 40 px');
assert.ok(Math.abs(small.label - 0.18 * 40) < 0.1, `the body text starts at the reported 7.2 px (${small.label})`);
assert.ok(small.hint && /棋盘/.test(small.hint), 'the row says the layout is not touched');
const step = async (name) => {
await page.evaluate((n) => {
const btn = [...document.querySelectorAll('[data-testid="text-size"] button')].find((b) => b.textContent.trim() === n);
btn?.click();
}, name);
await sleep(200);
return modalMetrics(page);
};
const md = await step('中');
const lg = await step('大');
const xl = await step('特大');
assert.equal(md.step, '中');
assert.equal(xl.step, '特大', 'the pick sticks');
assert.ok(md.label > small.label * 1.5 && lg.label > md.label && xl.label > lg.label,
`the readable text grows step by step (${[small.label, md.label, lg.label, xl.label].join(' → ')})`);
assert.ok(Math.abs(xl.label - 0.18 * 88) < 0.2, `特大 reaches the 88 px text root (${xl.label})`);
assert.ok(xl.micro > small.micro * 1.9, `the 4.4 px micro labels follow (${small.micro} → ${xl.micro})`);
for (const m of [md, lg, xl]) {
assert.equal(m.root, 40, 'the layout root never moves: the field camera and the DOM board read 1rem');
assert.equal(m.modalWidth, small.modalWidth, 'the modal\'s box is sized in rem and does not change');
assert.ok(m.bodyOverflowX <= 1, `the rows do not overflow the modal body (${m.bodyOverflowX} px)`);
assert.ok(m.outRight <= 1, `nothing sticks out of the modal's box (${m.outRight} px): the label wraps and the four steps fit`);
}
// saved with the settings and restored on the next visit
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.settings')).textSize), 'xl');
await page.reload({ waitUntil: 'networkidle0' });
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
await page.click('.lobby-screen [data-testid="settings-btn"]');
await page.waitForSelector('.modal .set-list', { timeout: 5000 });
const again = await modalMetrics(page);
assert.equal(again.step, '特大', 'the choice survives a reload');
assert.equal(again.root, 40);
assert.deepEqual(problems, []);
await page.close();
});
test('the in-match field, canvas and shop bar do not move; the settings dialog in a match grows', async () => {
const measure = async (textSize) => {
const { page, problems } = await open(`${base}/dev/game-mock.html?shot=1&phase=PREP&variant=settings`, textSize);
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
await sleep(900);
const m = await page.evaluate(() => {
const box = (sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)];
};
return {
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
field: box('.gm__field'),
canvas: box('.gm__field canvas'),
shopbar: box('.gm__hud .shopbar'),
label: (() => { const el = document.querySelector('.set-row__label'); return el ? parseFloat(getComputedStyle(el).fontSize) : null; })(),
};
});
return { ...m, problems, page };
};
const small = await measure('sm');
const big = await measure('xl');
assert.ok(small.field && small.shopbar, 'the prep screen with its HUD is on screen');
assert.equal(small.root, 40);
assert.equal(big.root, 40, 'the text step never reaches the root');
assert.deepEqual(big.field, small.field, 'the field host (host.clientWidth / clientHeight) is unchanged');
assert.deepEqual(big.canvas, small.canvas, 'and so is the canvas the renderer resizes to it');
assert.deepEqual(big.shopbar, small.shopbar, 'the shop bar is a fixed HUD box: still the design\'s size');
assert.ok(big.label > small.label * 1.9, `while the dialog's readable text grows (${small.label} → ${big.label})`);
assert.deepEqual(small.problems, []);
assert.deepEqual(big.problems, []);
await small.page.close();
await big.page.close();
});
});