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

+143 -1
View File
@@ -367,7 +367,7 @@ describe('operator battle voice', () => {
assert.equal(a.voiceLang, 'cn', 'no other dub: anything but jp is 中文');
// the settings store hands the choice over (installAudio and ui/settings.js)
const settings = readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang\)/);
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang, s\.voiceOverrides\)/);
assert.match(settings, /t\('语音语言'\)/);
} finally {
globalThis.fetch = origFetch;
@@ -870,6 +870,148 @@ describe('impact sounds (user playtest #4 item 6)', () => {
});
});
// 司霆惊蛰's S3 attack had no sound: she is 解放者 (she only attacks while a skill runs) and the official client has no
// normal-mode bank of hers at all — every attack of hers is a skill mode's (`attacks`, tools/assets/audio.mjs
// pickModeAttacks). 赤刃明霄陈's S3 slashes are the other half of the same idea: the mode's IMPACT differs too (`hits`,
// pickModeHits) — without it her S3 still landed with p_imp_hljdswd_n, i.e. it sounded like her normal attack. Both
// tables play only while that skill is active (the ['skill', id, 1] / 0 events).
describe('the running skill\'s own attack / impact sound (sfx.units[id].attacks / hits)', () => {
const LEIZI2 = 'char_1043_leizi2';
const CHEN3 = 'char_1050_chen3';
const enemyId = () => Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].attack);
async function rig(units) {
const fw = fakeWindow();
const urls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
a.install();
fw.fire('pointerdown');
a.setFieldUnits(units);
const settle = () => new Promise((r) => setTimeout(r, 5));
return { a, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
}
const clear = (a) => { a.limiter.lastByUnit.clear(); a.limiter.lastByUrl.clear(); };
// …and forget the decoded buffers too: these assertions count REQUESTS, and a sound the manager already holds is
// played from the cache without a second fetch (a real battle hears it either way).
const fresh = (a) => { clear(a); a.buffers.clear(); };
/** One attack plus the impact that belongs to it (the dmg inside IMPACT_WINDOW_MS). */
const swingAndHit = (a) => a.handleBattleEvents([['atk', 1, 2, 'none'], ['dmg', 2, 120, 'phys']]);
test('司霆惊蛰 S3: her attack rings the S3 mode\'s file while the skill runs, and only then', async () => {
const u = manifest.audio.sfx.units[LEIZI2];
assert.equal(u.attack, undefined, '前提:她没有普通形态的攻击音(这正是那次「没有音效」)');
assert.ok(u.attacks['2'] && /_s\.mp3$/.test(u.attacks['2']), '前提:清单带 S3 形态的攻击音');
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: LEIZI2, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
]);
try {
// 技能没开(解放者本就不攻击,模拟里也不会发 atk;真发了也仍然是静的)
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), 0, '没有技能就没有她的攻击音');
// S3 开启 → ['skill', id, 1]
a.handleBattleEvents([['skill', 1, 1]]);
await settle();
const before = askedCount(urls, u.attacks['2']);
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), before + 1, 'S3 期间她的攻击响 S3 的形态音');
assert.ok(!asked(urls, u.skill), '技能发动音是另一条事件(这次没发),别混进攻击音');
// 重复的 atk 会被同键冷却挡(既有口径,这里确认它没被新分支绕过)
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), before + 1, '同一单位 160 ms 内只响一次');
// S3 结束 → ['skill', id, 0]
clear(a);
a.handleBattleEvents([['skill', 1, 0]]);
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), before + 1, '技能结束后又静音');
} finally { restore(); }
});
test('a unit with both banks: the mode file inside the skill, its normal attack outside it', async () => {
const id = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && manifest.audio.sfx.units[k].attack && manifest.audio.sfx.units[k].attacks?.['2']);
const u = manifest.audio.sfx.units[id];
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: id, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
]);
try {
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attack), 1, `${id}: 技能外是普通攻击音`);
assert.equal(askedCount(urls, u.attacks['2']), 0, `${id}: 技能外不响形态音`);
clear(a);
a.handleBattleEvents([['skill', 1, 1]]);
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), 1, `${id}: 技能期间响形态音`);
assert.equal(askedCount(urls, u.attack), 1, `${id}: 形态音取代、而不是叠加普通攻击音`);
} finally { restore(); }
});
test('a unit whose manifest has no attacks table for that slot keeps its normal bank (and no crash)', async () => {
const id = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && manifest.audio.sfx.units[k].attack && !manifest.audio.sfx.units[k].attacks?.['2']);
const u = manifest.audio.sfx.units[id];
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: id, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
]);
try {
a.handleBattleEvents([['skill', 1, 1]]);
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attack), 1, `${id}: 没有形态音就用普通攻击音`);
} finally { restore(); }
});
test('赤刃明霄陈 S3: 挥砍与斩击都用 S3 的 `_s` 对,技能外回到普通 `_n` 对', async () => {
const u = manifest.audio.sfx.units[CHEN3];
assert.ok(u.attacks['2'] && u.hits['2'] && /_s\.mp3$/.test(u.attacks['2']) && /_s\.mp3$/.test(u.hits['2']), '前提:清单带 S3 的挥砍与斩击');
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: CHEN3, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
]);
try {
fresh(a);
swingAndHit(a);
await settle();
assert.equal(askedCount(urls, u.attack), 1, '技能外:普通挥砍');
assert.equal(askedCount(urls, u.hit), 1, '技能外:普通命中');
fresh(a);
a.handleBattleEvents([['skill', 1, 1]]); // S3 开启
swingAndHit(a);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), 1, 'S3:挥砍换成 S3 的');
assert.equal(askedCount(urls, u.hits['2']), 1, 'S3:斩击(命中)也换成 S3 的');
assert.equal(askedCount(urls, u.attack), 1, 'S3:没有叠加普通挥砍');
assert.equal(askedCount(urls, u.hit), 1, 'S3:没有叠加普通命中');
fresh(a);
a.handleBattleEvents([['skill', 1, 0]]); // S3 结束
swingAndHit(a);
await settle();
assert.equal(askedCount(urls, u.attack), 2, '技能结束:普通挥砍回来了');
assert.equal(askedCount(urls, u.hit), 2, '技能结束:普通命中回来了');
assert.equal(askedCount(urls, u.hits['2']), 1, '技能结束:不再用 S3 的斩击');
} finally { restore(); }
});
test('a mode without a hits entry keeps the unit\'s normal impact while the skill runs', async () => {
const u = manifest.audio.sfx.units[LEIZI2];
assert.ok(u.attacks['2'] && !u.hits?.['2'], '前提:她的 S3 有挥砍、没有命中 bank(官方就没有)');
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: LEIZI2, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
]);
try {
a.handleBattleEvents([['skill', 1, 1]]);
swingAndHit(a);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), 1, 'S3 的挥砍');
assert.equal(askedCount(urls, u.hit), 1, '命中仍是普通的那条');
} finally { restore(); }
});
});
// =====================================================================================================================
// 漏怪 sound (user request "接下来加漏怪的音效", then "应该是原版明日方舟关卡中的怪进蓝门的音效"). The sim emits
// `['leak', id]` when an enemy reaches its goal (Battle.leak) — NOT a `die` — so until now an escape was completely
+146
View File
@@ -0,0 +1,146 @@
// Real browser / server checks for local identity recovery, per-operator voice, install offers and large text.
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);
describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
let server, browser, base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${server.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
});
after(async () => { await browser?.close(); await server?.close(); });
async function pageIn(ctx, phone = false) {
const page = await ctx.newPage();
await page.setViewport({ width: phone ? 844 : 1440, height: phone ? 390 : 900 });
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery Test'); sessionStorage.setItem('sp.entered', '1'); });
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && document.querySelector('.lobby-screen'));
return page;
}
test('closed second window resumes the same server player and match through the recovery UI', async () => {
const ctx = await browser.createBrowserContext();
try {
const a = await pageIn(ctx), b = await pageIn(ctx);
const before = await b.evaluate(async () => {
const { net, store } = globalThis.__SP__;
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
await net.request('room.start', {});
return { id: store.get().me.playerId, code: store.get().room.code };
});
await b.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await a.waitForSelector('[data-testid="resume-local-match"]');
const refused = await a.evaluate(async () => { const { identity } = await import('/js/net.js'); return identity.resume(identity.recoverable()[0].id); });
assert.equal(refused, false, 'live window is not displaced');
assert.equal(await b.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
await b.close();
await a.click('[data-testid="resume-local-match"]');
await a.waitForSelector('.modal .btn--block');
await Promise.all([a.waitForNavigation({ waitUntil: 'domcontentloaded' }), a.click('.modal .btn--block')]);
await a.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
const after = await a.evaluate(() => { const s = globalThis.__SP__.store.get(); return { id: s.me.playerId, code: s.room.code }; });
assert.deepEqual(after, before);
} finally { await ctx.close(); }
});
test('co-op opening reroll shows a unanimous vote, clears stale readiness and can be rejected', { timeout: 45000 }, async () => {
const ctx = await browser.createBrowserContext();
try {
const host = await pageIn(ctx), guest = await pageIn(ctx);
const code = await host.evaluate(async () => {
const { net, store } = globalThis.__SP__;
await net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' });
return store.get().room.code;
});
await guest.evaluate(async (code) => {
const { net } = globalThis.__SP__;
await net.request('room.join', { code }); await net.request('room.ready', { ready: true });
}, code);
await host.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await guest.evaluate(() => globalThis.__SP__.net.request('g.infoReady', { setupRevision: 0 }));
await host.waitForSelector('.brief-reroll button');
await host.bringToFront();
await host.click('.brief-reroll button');
await guest.waitForSelector('.brief-reroll .btn--primary');
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), 0);
await guest.bringToFront();
await guest.click('.brief-reroll .btn--primary');
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1);
const state = await guest.evaluate(() => globalThis.__SP__.store.get().match.public);
assert.equal(state.rerollVote, null);
assert.ok(state.players.filter((p) => !p.isBot).every((p) => !p.ready));
await host.waitForFunction(() => !!document.querySelector('.brief-reroll button:not(:disabled)'));
// Wait out the documented server anti-burst interval before a second request.
await new Promise((resolve) => setTimeout(resolve, 350));
await host.bringToFront();
await host.click('.brief-reroll button');
await guest.waitForSelector('.brief-reroll .btn--danger');
await guest.bringToFront();
await guest.click('.brief-reroll .btn--danger');
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote);
assert.equal(await host.evaluate(() => globalThis.__SP__.store.get().match.public.setupRevision), 1);
} finally { await ctx.close(); }
});
test('operator voice selection persists across reload and follows global after reset', async () => {
const ctx = await browser.createBrowserContext();
try {
const page = await pageIn(ctx, true);
const open = async () => { await page.evaluate(async () => (await import('/js/ui/loadoutSync.js')).openLoadout()); await page.waitForSelector('.lo-card__pick'); await page.click('.lo-card__pick'); await page.waitForSelector('[data-voice-char] select', { visible: true }); };
await open();
const id = await page.$eval('[data-voice-char]', (e) => e.dataset.voiceChar);
await page.select('[data-voice-char] select', 'jp');
assert.equal(await page.evaluate(async (id) => (await import('/js/audio.js')).audio.voiceOverrides[id], id), 'jp');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online');
await open();
assert.equal(await page.$eval('[data-voice-char] select', (e) => e.value), 'jp');
await page.select('[data-voice-char] select', '');
assert.equal(await page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.settings')).voiceOverrides[id], id), undefined);
} finally { await ctx.close(); }
});
test('install manifest is eligible and a dismissed offer is consumed through the actual button', async () => {
const ctx = browser.defaultBrowserContext();
let page;
try {
page = await pageIn(ctx);
const client = await page.createCDPSession();
const manifest = await client.send('Page.getAppManifest');
assert.deepEqual(manifest.errors, []);
const install = await client.send('Page.getInstallabilityErrors');
assert.deepEqual(install.installabilityErrors, []);
await page.evaluate(() => {
const e = new Event('beforeinstallprompt', { cancelable: true });
window.promptCalls = 0;
e.prompt = async () => { window.promptCalls++; return { outcome: 'dismissed' }; };
window.dispatchEvent(e);
});
await page.waitForSelector('[data-testid="pwa-install"]');
await page.click('[data-testid="pwa-install"]');
await page.waitForFunction(() => !document.querySelector('[data-testid="pwa-install"]'));
assert.equal(await page.evaluate(() => window.promptCalls), 1);
} finally { await page?.close(); }
});
test('extra-large settings stay within a landscape phone in every shipped interface language', async () => {
const ctx = await browser.createBrowserContext();
try {
const page = await pageIn(ctx, true);
for (const lang of ['zh-CN', 'en', 'ja', 'ko', 'zh-TW']) {
await page.evaluate(async (lang) => {
await (await import('/js/ui/lang.js')).switchLang(lang);
(await import('/js/ui/settings.js')).updateSettings({ textSize: 'xl' });
}, lang);
await page.click('[data-testid="settings-btn"]');
await page.waitForSelector('.modal .set-list');
const overflow = await page.$eval('.modal__body', (e) => e.scrollWidth - e.clientWidth);
const excess = await page.$eval('.modal__body', (e) => { const right = e.getBoundingClientRect().right; return [...e.querySelectorAll('*')].filter((x) => x.getBoundingClientRect().right > right + 1).map((x) => [x.className, x.textContent.slice(0, 50), Math.round(x.getBoundingClientRect().width)]); });
assert.ok(overflow <= 1, `${lang}: settings overflow ${overflow} ${JSON.stringify(excess)}`);
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.modal .set-list'));
}
} finally { await ctx.close(); }
});
});
+1 -1
View File
@@ -723,7 +723,7 @@ describe('keyboard & settings', () => {
test('sanitizeSettings', () => {
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub
+54
View File
@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createPwaInstall } from '../../public/js/pwa.js';
function rig(standalone = false) {
const win = new EventTarget(), display = new EventTarget();
display.matches = standalone;
win.matchMedia = () => display;
const pwa = createPwaInstall(win);
const offer = (outcome = 'dismissed', fail = false) => {
let calls = 0;
const e = new Event('beforeinstallprompt', { cancelable: true });
e.prompt = async () => { calls++; if (fail) throw Error('unavailable'); return { outcome }; };
win.dispatchEvent(e);
return { calls: () => calls, e };
};
return { pwa, win, display, offer };
}
test('install offer is consumed even on dismissal, duplicate clicks, or rejected prompt', async () => {
const { pwa, offer } = rig();
const states = []; const off = pwa.subscribe((x) => states.push(x));
assert.equal(pwa.available(), false);
const first = offer();
assert.equal(first.e.defaultPrevented, true);
assert.equal(pwa.available(), true);
const a = pwa.request(), b = pwa.request();
assert.equal(await a, false); assert.equal(await b, false); assert.equal(first.calls(), 1);
const broken = offer('accepted', true);
assert.equal(await pwa.request(), false); assert.equal(await pwa.request(), false); assert.equal(broken.calls(), 1);
const next = offer('accepted');
assert.equal(await pwa.request(), true); assert.equal(next.calls(), 1);
assert.deepEqual(states, [false, true, false, true, false, true, false]);
off(); pwa.dispose();
});
test('installed / standalone hides stale offers; disposing removes listeners', async () => {
const { pwa, win, display, offer } = rig();
offer(); display.matches = true; display.dispatchEvent(new Event('change'));
assert.equal(pwa.available(), false); assert.equal(await pwa.request(), false);
display.matches = false; win.dispatchEvent(new Event('appinstalled')); offer();
assert.equal(pwa.available(), false);
pwa.dispose();
assert.equal(offer().e.defaultPrevented, false);
const native = rig(true); native.offer(); assert.equal(native.pwa.available(), false); native.pwa.dispose();
});
test('manifest uses actual 192/512 icons shipped outside optional game assets', () => {
const m = JSON.parse(readFileSync(new URL('../../public/manifest.json', import.meta.url)));
assert.equal(m.start_url, '/'); assert.equal(m.scope, '/');
for (const icon of m.icons) {
assert.ok(icon.src.startsWith('/icons/'));
const bytes = readFileSync(new URL('../../public' + icon.src, import.meta.url));
const size = Number(icon.sizes.split('x')[0]);
assert.equal(bytes.readUInt32BE(16), size); assert.equal(bytes.readUInt32BE(20), size);
}
});
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { AudioManager } from '../../public/js/audio.js';
import { indexAudio, pickModeAttacks } from '../../tools/assets/audio.mjs';
import { buildPlan } from '../../tools/assets/plan.mjs';
const id = 'char_1043_leizi2';
function rig() {
const entry = { attack: '/normal_n.mp3', hit: '/normal_hit_n.mp3', attacks: { 2: '/mode_s.mp3' }, hits: { 2: '/impact_s.mp3' }, mix: { attack: { p: 0 }, hit: { vol: 0.1 } }, attackMix: { 2: { vol: 0.6 } } };
const a = new AudioManager({ getManifest: () => ({ audio: { sfx: { units: { [id]: entry } } } }), random: () => 0.5 });
a.ctx = {};
const played = [];
a._play = (url, opts) => played.push({ url, ...opts });
const units = [{ id: 1, spine: id, side: 'ally', kind: 'chess', skillIndex: 2 }, { id: 2, spine: 'enemy_1_test', side: 'enemy' }];
a.setFieldUnits(units);
return { a, entry, played, units };
}
test('mode uses its own chance and volume, never the normal bank mix', () => {
const { a, entry, played } = rig();
a.unit(id, 'attack', 1, 2, true);
a.unit(id, 'hit', 1, 2, true);
assert.deepEqual(played.map((p) => [p.url, p.volume]), [['/mode_s.mp3', 0.33], ['/impact_s.mp3', 0.55]]);
entry.attackMix[2].p = 0;
a.unit(id, 'attack', 1, 2, true);
assert.equal(played.length, 2, 'silent mode does not fall back to normal');
});
test('an in-flight impact remembers the mode at attack time after skill end', () => {
const { a, played } = rig();
a.handleBattleEvents([['skill', 1, 1], ['atk', 1, 2, 'none'], ['skill', 1, 0], ['dmg', 2, 10, 'phys']]);
assert.deepEqual(played.map((p) => p.url), ['/mode_s.mp3', '/impact_s.mp3']);
assert.equal(a.units.get(1).skillActive, false);
});
test('end before spawn cancels a queued start; field changes discard stale modes', () => {
const { a, played, units } = rig();
a.setFieldUnits([]);
a.handleBattleEvents([['skill', 1, 1], ['skill', 1, 0], ['spawn', units[0]], ['atk', 1, 2, 'none']]);
assert.equal(a.pendingSkill.size, 0);
assert.equal(a.units.get(1).skillActive, false);
assert.equal(played.length, 0);
a.setFieldUnits([{ ...units[0], skillActive: true }, units[1]]);
a.handleBattleEvents([['atk', 1, 2, 'none']]);
assert.equal(played.at(-1).url, '/mode_s.mp3', 'late join snapshot restores active mode');
a.setFieldUnits(units);
assert.equal(a.lastAttacker.size, 0);
assert.equal(a.units.get(1).skillActive, false);
});
test('mixed mode banks are not guessed; the selected official bank retains its mix in the plan', () => {
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.attack', ['/a_s.mp3', '/b_h.mp3']]])), {});
const audio = indexAudio({ soundFXBanks: [{ name: `battle.ON_ABILITY_START.${id}.attack.7`, sounds: [
{ asset: 'Audio/Sound_Beta_2/Player/p_atk/test_s', weight: 1, minVolume: 0.6, maxVolume: 0.6 },
{ asset: '', weight: 1 },
] }] });
const { template } = buildPlan({ assets07: {}, ops03: {}, extraOperators: { [id]: { skills: [{ index: 2, skillId: 'test' }] } }, enemies05: {}, maps05: {}, audio, modelsData: {} });
assert.deepEqual(template.audio.sfx.units[id].attackMix, { 2: { p: 0.5, vol: 0.6 } });
});
+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();
});
});
+240
View File
@@ -0,0 +1,240 @@
// 设置 →「文字大小」 (the owner's decision of 2026-10-09): the interface text root `--t` of css/theme.css, stepped by
// the settings store (`sp.pref.settings` textSize), applied to <html data-text> by ui/settings.js applyTextSize.
// A phone clamps the LAYOUT root `1rem` at 40 px (theme.css), which left the .18rem body text at 7.2 CSS px, while the
// browser's own font settings only inflate glyphs inside fixed boxes and page zoom is off (index.html's viewport,
// ui/device.js). Only font-size declarations read `--t`: the five modules that turn `1rem` into pixels — ui/fieldHost.js
// hudPadding / hudBands (the prep camera's HUD clearance), ui/gameLogic/panel.js panelSlots (the detail card's side),
// ui/underframe.js (the tile labels' budget) and ui/fallbackField.js (the DOM board) — must never move with it, and the
// field itself is sized from the host element's clientWidth (render/app.js).
//
// What this file guards: the default is the design's own sizes; a bad or missing value falls back to 小; the step lands
// on <html data-text>; the layout root's clamp is untouched; and every font-size that stayed on a plain `rem` is one of
// the deliberate exclusions (the in-match HUD's fixed boxes, the board-pinned DOM text, geometry-derived sizes, the
// ≥16 px iOS-zoom guards) — a new readable size that forgets `--t` fails here, the way `tools/i18n.mjs extract 0` fails
// on an unwrapped string. Browser counterpart: test/ui/text-scale.e2e.test.js (SP_E2E=1).
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { DEFAULT_SETTINGS, TEXT_SIZES, sanitizeSettings } from '../../public/js/ui/gameLogic.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const CSS = 'public/css';
/** Every stylesheet under public/css (index.html loads all of them, devices.css last), as { rel, src }. */
function stylesheets() {
const out = [];
const visit = (rel) => {
for (const e of readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
const r = `${rel}/${e.name}`;
if (e.isDirectory()) visit(r);
else if (r.endsWith('.css')) out.push({ rel: r, src: read(r).replace(/\/\*[\s\S]*?\*\//g, '') });
}
};
visit(CSS);
return out;
}
/** Rules as { selector, body } (flat; @media blocks are walked into, as test/client-static.test.js does). */
function cssRules(src) {
const out = [];
const re = /([^{}]+)\{([^{}]*)\}/g;
let m;
while ((m = re.exec(src))) out.push({ selector: m[1].trim(), body: m[2] });
return out;
}
/**
* The font sizes that deliberately stay on a plain `rem` (or a bare px): the in-match HUD chrome in fixed boxes, the
* DOM text pinned onto board tiles, the geometry-derived sizes and the ≥16 px iOS-zoom guards. Listed by selector, the
* same list the codemod used — a rule that stops being an exclusion (or a new one that should have used `--t`) shows up
* as a failure with its file and selector.
*/
const NOT_SCALED = [
// pinned onto board tiles by JS-computed pixels (ui/standInTags.js, ui/underframe.js)
/\.sitag/, /\.tempnote/, /\.uframe/, /\.ff-/,
// in-match HUD chrome whose box the design fixed (a later pass: shop cards, top bar, bond strip, corner buttons)
/\.gm__/, /\.roundbox/, /\.capsule/, /\.lp\b/, /\.lp--/, /\.misstag/, /\.bossbar/, /\.readywrap/,
/\.bstrip/, /\.bslot/, /\.effect/, /\.efftip/, /\.ebubble/, /\.ticker/, /\.dpbox/, /\.vswitch/,
/\.specpill/, /\.otwarn/, /\.team/, /\.enemybtn/, /\.readybtn/, /\.ewheel/, /\.fwheel/, /\.chud/, /\.toolbtn/,
/\.shopbar/, /\.scard/, /\.scr\b/,
// text inside a fixed-height bar (the HP bar of the detail card, the boss bar's fill)
/\.dhp\b/,
// the 机变 card: its grid fits 7 lines of the effect text (game-panels.css), so a bigger font would clamp text away
/\.spcard/,
// decorative wordmarks and boot/rotate overlays whose size is the composition
/\.title-cn/, /\.title-en/, /\.title-bg__target/, /\.brief-boss__q/, /\.boot__/, /\.rotate-hint/,
// the join code's letter-spaced field, the px-floored 本局信息 dialog, the 16 px diagnostics box (iOS zoom guard)
/\.field--code/, /\.minfo-dlg/, /\.set-diag__text/,
// the keyboard hints of the 快捷键 list (a shared rule with the HUD's .toolbtn kbd)
/\.set-hint kbd/,
];
const notScaled = (selector) => NOT_SCALED.some((re) => re.test(selector));
/**
* Whole stylesheets a later pass owns: the shop bar's fixed cards (their height feeds the prep camera's HUD band,
* ui/fieldHost.js HUD_REM), and the 干员调配 / 统计 screens, whose text is already px-floored (`max(.15rem, 11px)`) and
* therefore readable on a phone as it is. Scaling them is step 2/3 of the change, not a forgotten line.
*/
const NOT_SCALED_FILES = [`${CSS}/screens/game-shop.css`, `${CSS}/screens/loadout.css`, `${CSS}/screens/stats.css`];
/** A font-size value that is neither scaled nor an exclusion: a plain `rem`, or `max(<rem>, …)`. */
function plainRem(value) {
if (/var\(/.test(value)) return false; // --t, or a geometry-derived size
if (/(^|[^r])em\b|%/.test(value.replace(/rem\b/g, ''))) return false;
return /^\s*(max\(\s*)?\d*\.?\d+rem\b/.test(value);
}
describe('文字大小: the setting', () => {
test('the default is 小 (the design\'s own sizes) and a saved profile without it keeps it', () => {
assert.deepEqual([...TEXT_SIZES], ['sm', 'md', 'lg', 'xl'], 'the steps, in the settings order');
assert.equal(DEFAULT_SETTINGS.textSize, 'sm');
assert.equal(sanitizeSettings(null).textSize, 'sm');
assert.equal(sanitizeSettings({ bgm: 0.4 }).textSize, 'sm', 'a profile saved before the setting existed');
});
test('every step survives sanitising; anything else falls back to 小', () => {
for (const v of TEXT_SIZES) assert.equal(sanitizeSettings({ textSize: v }).textSize, v);
for (const bad of ['huge', 'SM', 'xl2', 'md ', 1, true, null, undefined, {}, []]) {
assert.equal(sanitizeSettings({ textSize: bad }).textSize, 'sm', String(bad));
}
assert.equal(sanitizeSettings({ textSize: 'xl', quality: 'low' }).quality, 'low', 'the other settings are untouched');
});
test('applyTextSize writes the step to <html data-text>, and 小 when it is not a step', async () => {
const { applyTextSize } = await import('../../public/js/ui/settings.js');
const el = { dataset: {} };
const prev = globalThis.document;
globalThis.document = { documentElement: el };
try {
for (const v of TEXT_SIZES) {
applyTextSize(v);
assert.equal(el.dataset.text, v);
}
for (const bad of ['', 'big', null, undefined, 42]) {
applyTextSize(bad);
assert.equal(el.dataset.text, 'sm', String(bad));
}
applyTextSize(undefined);
assert.equal(el.dataset.text, 'sm', 'the attribute is always written, never left stale');
} finally {
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
}
});
test('the store applies the step on every change, and at boot before the first render', async () => {
const { settingsStore, updateSettings } = await import('../../public/js/ui/settings.js');
const el = { dataset: {} };
const prev = globalThis.document;
globalThis.document = { documentElement: el };
try {
updateSettings({ textSize: 'lg' });
assert.equal(el.dataset.text, 'lg', 'picking a step re-renders the text immediately');
assert.equal(settingsStore.get().textSize, 'lg', 'and it is saved with the settings (sp.pref.settings)');
updateSettings({ textSize: 'sm' });
assert.equal(el.dataset.text, 'sm');
} finally {
updateSettings({ textSize: DEFAULT_SETTINGS.textSize });
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
}
});
test('the settings modal offers the four steps (data-testid text-size), below 画面质量', () => {
const src = read('public/js/ui/settings.js');
assert.match(src, /data-testid="text-size"/);
assert.match(src, /const TEXT_SCALES = \[\[?.*'sm'.*'md'.*'lg'.*'xl'/s, 'the steps with their labels');
assert.match(src, /applyTextSize\(settingsStore\.get\(\)\.textSize\)/, 'applied at module load (before main.js renders)');
assert.ok(src.indexOf("t('文字大小')") > src.indexOf("t('画面质量')"), 'the row is below the quality row');
assert.match(read('public/css/screens/game.css'), /\.set-seg\.set-textsize button \{ min-width: \.62rem; \}/,
'four steps fit the row on a phone (1rem = 40 px)');
});
});
describe('文字大小: the CSS text root', () => {
const sheets = stylesheets();
const theme = sheets.find((s) => s.rel === `${CSS}/theme.css`).src;
test('theme.css defines --t for 小 and every step, and nothing else defines it', () => {
assert.match(theme, /:root \{ --t: 1rem; \}/, '小 = the design\'s own sizes');
for (const [attr, value] of [['md', /--t: max\(1rem, 64px\)/], ['lg', /--t: max\(calc\(1rem \* 1\.15\), 76px\)/], ['xl', /--t: max\(calc\(1rem \* 1\.3\), 88px\)/]]) {
assert.match(theme, new RegExp(`:root\\[data-text="${attr}"\\] \\{ ${value.source}; \\}`), `the ${attr} step`);
}
for (const { rel, src } of sheets) {
if (rel === `${CSS}/theme.css`) continue;
assert.doesNotMatch(src, /--t\s*:/, `${rel}: --t is defined in theme.css only`);
}
});
test('the layout root 1rem is untouched — the five rem→px readers must not move', () => {
assert.match(theme, /html \{\n {2}font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100vh \/ 10\.8\)\), 240px\);/,
'the desktop clamp');
assert.match(theme, /font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100svh \/ 10\.8\)\), 240px\);/,
'the svh clamp (mobile toolbars)');
assert.doesNotMatch(theme, /font-size: clamp\([^)]*var\(--t\)/, 'the root scale never reads the text root');
// and the modules that convert it keep their own floor: a text step must not reach them
for (const f of ['public/js/ui/fieldHost.js', 'public/js/ui/gameLogic/panel.js', 'public/js/ui/underframe.js', 'public/js/ui/fallbackField.js']) {
const src = read(f);
assert.doesNotMatch(src, /--t\b|data-text/, `${f}: the layout must not read the text root`);
assert.match(src, /getComputedStyle\(document\.documentElement\)\.fontSize|rem\b/, `${f}: still converts 1rem`);
}
});
test('readable text reads --t (body, .micro, buttons, modals, panels, settings)', () => {
const want = [
[theme, /body \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s, 'theme.css body (the 7.2 px text)'],
[theme, /\.micro \{[^}]*font-size: calc\(\.11 \* var\(--t\)\)/s, 'theme.css .micro (92 render sites)'],
[theme, /:root\[data-text="md"\]/, 'the steps'],
];
for (const [src, re, what] of want) assert.match(src, re, what);
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
const samples = [
['components.css', /\.btn \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
['screens/game.css', /\.set-row__label \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
['screens/game-panels.css', /\.dtext \{ font-size: calc\(\.15 \* var\(--t\)\); line-height: 1\.65;/],
['screens/guide.css', /\.guide__tips li \{ font-size: calc\(\.18 \* var\(--t\)\)/],
['screens/lobby.css', /\.mode-card__desc/, ],
];
for (const [file, re] of samples) assert.match(byRel.get(`${CSS}/${file}`), re, file);
});
test('a newer reader would be caught: every plain rem font-size is a documented exclusion', () => {
const bad = [];
let scaled = 0;
for (const { rel, src } of sheets) {
if (NOT_SCALED_FILES.includes(rel)) continue;
for (const { selector, body } of cssRules(src)) {
for (const m of body.matchAll(/font-size:([^;}]*)/g)) {
const value = m[1];
if (/var\(--t\)/.test(value)) { scaled++; continue; }
if (!plainRem(value)) continue;
if (notScaled(selector)) continue;
bad.push(`${rel}: ${selector} { font-size:${value.trim()} }`);
}
}
}
assert.deepEqual(bad, [], `not on --t and not a documented exclusion (${scaled} declarations do read --t):`);
assert.ok(scaled >= 250, `${scaled} declarations read --t — a mass revert would fail here`);
});
test('the in-match HUD, the board-pinned text and the iOS zoom guards stay as they were', () => {
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
const plain = [
['screens/game-panels.css', /\.sitag \{[^}]*font-size: max\(\.12rem, 9px\)/, '.sitag'],
['screens/game-panels.css', /\.tempnote__title \{[^}]*font-size: \.15rem/, '.tempnote__title'],
['screens/game-panels.css', /\.uframe__label \{[^}]*font-size: \.14rem/, '.uframe__label'],
['screens/game-panels.css', /\.spcard__desc \{ flex: none; font-size: \.2rem/, '.spcard__desc (7 clamped lines)'],
['screens/game-panels.css', /\.dhp span \{[^}]*font-size: \.12rem/, '.dhp span (a fixed-height HP bar)'],
['screens/game-shop.css', /\.scard__name \{[^}]*font-size: \.19rem/, 'the shop card'],
['screens/game.css', /\.bslot \.bond__name \{[^}]*font-size: \.14rem/, 'the bond strip'],
['screens/game.css', /\.bossbar__txt \{[^}]*font-size: \.14rem/, 'the boss bar'],
['screens/game.css', /\.lp--md \{ font-size: \.24rem; \}/, 'the top bar LP'],
['screens/game.css', /\.sp-coarse \.set-diag__text \{ font-size: 16px; \}/, 'the 16 px iOS-zoom guard'],
['theme.css', /\.rotate-hint \{[\s\S]*?font-size: 16px/, 'the rotate hint'],
];
for (const [file, re, what] of plain) assert.match(byRel.get(`${CSS}/${file}`), re, what);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sanitizeSettings } from '../../public/js/ui/gameLogic/settings.js';
import { sanitizeVoiceOverrides, voiceLangFor } from '../../public/js/voicePrefs.js';
import { AudioManager, voiceLine } from '../../public/js/audio.js';
const a = 'char_263_skadi', b = 'char_103_angel';
test('old settings migrate to inherit, malformed or prototype entries are discarded', () => {
assert.deepEqual(sanitizeSettings({ voiceLang: 'jp' }).voiceOverrides, {});
assert.deepEqual(sanitizeVoiceOverrides(Object.assign(Object.create({ [b]: 'jp' }), { [a]: 'jp', bad: 'cn', char_1_no: 'en' })), { [a]: 'jp' });
for (const raw of [null, [], 1, 'jp']) assert.deepEqual(sanitizeVoiceOverrides(raw), {});
});
test('per-character override survives persistence; deletion restores the current global setting', () => {
const settings = sanitizeSettings(JSON.parse(JSON.stringify({ voiceLang: 'cn', voiceOverrides: { [a]: 'jp', [b]: 'cn' } })));
const manager = new AudioManager();
manager.setVoiceLang(settings.voiceLang, settings.voiceOverrides);
assert.equal(voiceLangFor(a, manager.voiceLang, manager.voiceOverrides), 'jp');
manager.setVoiceLang('jp');
assert.equal(voiceLangFor(b, manager.voiceLang, manager.voiceOverrides), 'cn');
delete settings.voiceOverrides[b];
manager.setVoiceLang('jp', settings.voiceOverrides);
assert.equal(voiceLangFor(b, manager.voiceLang, manager.voiceOverrides), 'jp');
assert.equal(voiceLangFor('unknown', 'cn', manager.voiceOverrides), 'cn');
});
test('the per-operator JP preference retains the existing per-slot Chinese fallback', () => {
const tree = { voice: { [a]: { select: '/cn.mp3' } }, voiceJp: { [a]: { skill1: '/jp.mp3' } } };
assert.equal(voiceLine(tree, a, 'select', voiceLangFor(a, 'cn', { [a]: 'jp' })).url, '/cn.mp3');
assert.equal(voiceLine(tree, a, 'skill1', voiceLangFor(a, 'cn', { [a]: 'jp' })).url, '/jp.mp3');
});