feedback7: merge fb7-voices — the 选中干员 line on every tap (prep / shop / hand, official FOCUS_CHAR: priority 10, cooldown 0, no gap restart), the Japanese voice tree (audio.voiceJp, 2674 files, setting 语音语言 中文 / 日本語, CN fallback), the full zip ships both

- Conflict: public/js/ui/settings.js imports (store from #321 + VOICE_LANGS) — both kept. test/ui/playtest6-elite.test.js's
  source pin of the onDetail line allows the new tap counter (bcee737f).
- Grok review: MERGE AFTER FIXES — the three findings fixed on the branch (bcee737f). Golden unchanged; broad node --test
  5334 (the one failure was that source pin, fixed), i18n strict clean, lint 0 errors, typecheck.
This commit is contained in:
sganggs committed 2026-10-08 20:35:26 +08:00
commit fbcbfdffbc
26 files changed
+584 -104

No files matched your search

+13 -5
View File
@@ -22,7 +22,7 @@
// the 突变细胞 bench rule (§21.1: the carrier destroyed, its new operator gained into the 整备区 — official footage, PR #2),
// the closing additions §21.26–§21.28 (GitHub issues #1 / #5 / #8), the owner's deliberate trigger deviation for six
// 重装 skills (§21.29, GitHub issue #4 / PR #12) and the operator battle voice the user asked for the same day (§21.30,
// battle only — the 休整期 is silent).
// battle only — since 0.2.2 a tap on an operator says 选中干员 in every phase).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
@@ -868,7 +868,7 @@ test('the deliberate trigger deviation (DESIGN §21.29): six 重装 skills DEFAU
assert.match(doc('CHANGELOG.md'), /深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能改为攻击范围内有敌人时就释放/);
});
test('干员战斗语音 (DESIGN §21.30): the manifest data, the official priorities, and the 休整期 stays silent', async () => {
test('干员战斗语音 (DESIGN §21.30): the manifest data, the official priorities, and only a tap\'s 选中干员 speaks outside battle', async () => {
const { VOICE_PRIORITY, VOICE_COOLDOWN_MS, resultVoiceSlot } = await import('../public/js/audio.js');
const manifest = JSON.parse(readFileSync(join(ROOT, 'data/assets.json'), 'utf8'));
const voice = manifest.audio?.voice ?? {};
@@ -902,22 +902,30 @@ test('干员战斗语音 (DESIGN §21.30): the manifest data, the official prior
assert.equal(VOICE_PRIORITY.select, 10);
assert.equal(VOICE_COOLDOWN_MS.skill1, 10000);
assert.equal(VOICE_COOLDOWN_MS.faceEnemy, 3000);
assert.equal(VOICE_COOLDOWN_MS.select, 1500);
assert.equal(VOICE_COOLDOWN_MS.select, 0, 'official FOCUS_CHAR cooldown 0 (1.5 s until 0.2.2)');
assert.equal(resultVoiceSlot({ perfect: true }), 'resultThree');
// the docs
assert.match(DESIGN, /### 21\.30 /);
assert.match(DESIGN, /\*\*Where each line plays — battle only\.\*\*/);
assert.match(doc('docs/ASSETS.md'), /\| 干员战斗语音 \|/);
assert.match(doc('docs/ASSETS.md'), /voiceChars/);
// 0.2.2: the Japanese dub beside the Chinese one (audio.voiceJp, settings 语音语言, the full zip's switch)
assert.equal(Object.keys(manifest.audio?.voiceJp ?? {}).length, charIds.length, 'every voiced operator has its JP tree');
assert.equal(manifest.stats.voiceJpChars, charIds.length);
assert.match(doc('docs/ASSETS.md'), /voiceJp: \{ \[charId\]: \{ …the slots of `voice` \} \}/);
assert.match(doc('docs/DEPLOY.md'), /`FULL_ZIP_JP_VOICE` 改成 `false` 时/);
assert.match(PLAYING, /「语音语言」选 \*\*中文 \/ 日本語\*\*(默认中文,和界面语言无关/);
assert.match(PLAYING, /会说一句官方的「选中干员」语音,休整期也一样/);
assert.match(SIM, /\['engage', id\]/);
// the code: every slot the client asks for comes from a running battle's own stream — the three prep-only lines
// (干员报到 / 编入队伍 / 任命队长) are never requested, and 选中干员 sits behind the panel's combat flag
// (干员报到 / 编入队伍 / 任命队长) are never requested — except 选中干员, the detail panel's line on every tap of an
// operator, in every phase (0.2.2: the owner's request of 2026-10-08)
const panel = doc('public/js/ui/detailPanel.js');
const game = doc('public/js/screens/game.js');
for (const [name, src] of [['audio.js', doc('public/js/audio.js')], ['game.js', game], ['detailPanel.js', panel]]) {
assert.ok(!/voice\([^)]*'(gacha|squad|squadFirst)'/.test(src), `${name}: no prep slot is played`);
}
assert.match(panel, /const selectKey = voice && detail\?\.type === 'chess'/);
assert.match(game, /voice=\$\{combat\}/);
assert.match(game, /voice=\$\{true\}/);
assert.match(game, /audio\.voice\(charId, resultVoiceSlot\(/);
});
+176
View File
@@ -0,0 +1,176 @@
// test/feedback7-voices.test.js — the Japanese voice dub beside the Chinese one (0.2.2; the owner's request of 2026-10-08
// 「全套的日配语音」): tools/assets/plan.mjs plans `audio.voiceJp` — the same slots and file names from the
// ArknightsAssets2 `voice/` folder (the JP dub), under audio/voice/jp/ — the committed data/assets.json lists it for every
// operator the Chinese tree has (checked against public/assets when present), the settings carry 语音语言 (中文 by
// default, the four language packs translate the row), and tools/package.mjs FULL_ZIP_JP_VOICE decides whether the full
// zip ships the JP files (default: yes) — held back, they are neither missing nor deleted by an update.
// The client's choice of line and its fallback: test/ui/audio.test.js (voiceLine).
// Run: node --test test/feedback7-voices.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildPlan, VOICE_JP_LANG } from '../tools/assets/plan.mjs';
import { indexAudio, VOICE_DIRS } from '../tools/assets/audio.mjs';
import { RAW } from '../tools/assets/sources.mjs';
import { artPlan, FULL_ZIP_JP_VOICE, JP_VOICE_DIR } from '../tools/package.mjs';
import { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from '../public/js/ui/gameLogic.js';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
/** A charword_table.json with two operators: one the game fields (planned), one it does not (a 预备干员: no voice). */
const CHARWORD = {
charWords: {
a: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_SELECT', voiceId: 'CN_021', voiceIndex: 21, voiceAsset: 'char_263_skadi/CN_021' },
b: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_SELECT', voiceId: 'CN_022', voiceIndex: 22, voiceAsset: 'char_263_skadi/CN_022' },
c: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_START', voiceId: 'CN_019', voiceIndex: 19, voiceAsset: 'char_263_skadi/CN_019' },
d: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'GACHA', voiceId: 'CN_011', voiceIndex: 11, voiceAsset: 'char_263_skadi/CN_011' },
e: { charId: 'char_602_cdfend', wordKey: 'char_602_cdfend', placeType: 'BATTLE_SELECT', voiceId: 'CN_021', voiceIndex: 21, voiceAsset: 'char_602_cdfend/CN_021' },
},
};
const OPS = { char_263_skadi: { name: '斯卡蒂', skills: [] } };
const plan = (extra = {}) => buildPlan({ assets07: {}, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {},
charword: CHARWORD, extraOperators: OPS, ...extra }).template;
test('plan: audio.voiceJp is the JP dub (ArknightsAssets2 voice/) of the very slots and file names of audio.voice, under audio/voice/jp/', () => {
assert.equal(VOICE_JP_LANG, 'jp');
assert.equal(VOICE_DIRS.jp, 'voice', 'the dump folder of the JP dub');
const t = plan();
const cn = t.audio.voice.char_263_skadi;
const jp = t.audio.voiceJp.char_263_skadi;
assert.deepEqual(Object.keys(jp).sort(), ['select', 'start'], 'the battle slots only, like the Chinese tree (干员报到 not planned)');
assert.deepEqual(Object.keys(jp).sort(), Object.keys(cn).sort());
assert.equal(jp.select.length, 2, 'one leaf per line: 选中干员1 / 2');
assert.deepEqual(jp.select.map((l) => l.alts[0].rel), ['audio/voice/jp/char_263_skadi/cn_021.mp3', 'audio/voice/jp/char_263_skadi/cn_022.mp3']);
assert.deepEqual(jp.select[0].alts[0].urls, [`${RAW.aa2voice}voice/char_263_skadi/cn_021.mp3`]);
assert.deepEqual(cn.select.map((l) => l.alts[0].rel), ['audio/voice/cn/char_263_skadi/cn_021.mp3', 'audio/voice/cn/char_263_skadi/cn_022.mp3']);
assert.deepEqual(cn.select[0].alts[0].urls, [`${RAW.aa2voice}voice_cn/char_263_skadi/cn_021.mp3`]);
assert.equal(jp.start.alts[0].rel, 'audio/voice/jp/char_263_skadi/cn_019.mp3');
assert.equal(t.audio.voiceJp.char_602_cdfend, undefined, 'an operator the game does not field (a stand-in) gets neither tree');
assert.equal(t.audio.voice.char_602_cdfend, undefined);
assert.deepEqual(Object.keys(t.audio), ['bgm', 'bossBgm', 'voice', 'voiceJp', 'sfx'], 'voiceJp right after voice');
// --voice-lang puts another dub in audio.voice; voiceJp stays the JP one; voiceJp: false plans no JP tree
const en = plan({ voiceLang: 'en' });
assert.equal(en.audio.voice.char_263_skadi.start.alts[0].rel, 'audio/voice/en/char_263_skadi/cn_019.mp3');
assert.equal(en.audio.voiceJp.char_263_skadi.start.alts[0].rel, 'audio/voice/jp/char_263_skadi/cn_019.mp3');
assert.equal(plan({ voiceJp: false }).audio.voiceJp, undefined);
// --voice-all widens both trees alike
assert.ok(plan({ voiceSlots: null }).audio.voiceJp.char_263_skadi.gacha);
});
test('data/assets.json: voiceJp gives every voiced operator the JP twin of each Chinese line (191 operators, 2674 files); stand-ins, 盟约·辅助干员 and summons have neither', () => {
const m = readJson('data/assets.json');
const cn = m.audio.voice;
const jp = m.audio.voiceJp;
assert.ok(jp && typeof jp === 'object', 'the manifest carries the JP tree');
assert.deepEqual(Object.keys(jp), Object.keys(cn), 'the same operators');
assert.equal(m.stats.voiceJpChars, Object.keys(jp).length);
assert.equal(m.stats.voiceChars, Object.keys(cn).length);
assert.equal(Object.keys(jp).length, 191);
// the JP tree is the Chinese one with the folder swapped: same slots, same lines in the same order, same file names
assert.deepEqual(JSON.parse(JSON.stringify(cn).replaceAll('/assets/audio/voice/cn/', '/assets/audio/voice/jp/')), jp);
const lines = [];
const walk = (x) => { if (typeof x === 'string') lines.push(x); else if (Array.isArray(x)) x.forEach(walk); else if (x && typeof x === 'object') Object.values(x).forEach(walk); };
walk(jp);
assert.equal(lines.length, 2674);
for (const u of lines) assert.match(u, /^\/assets\/audio\/voice\/jp\/char_[^/]+\/cn_\d+\.mp3$/);
// no voice: the 17 stand-ins of data/backups.json (预备干员 / 原型干员), the mode's 盟约·辅助干员, every summon
const backups = readJson('data/backups.json');
const standIns = Object.entries(backups.units).filter(([, u]) => Array.isArray(u.standsIn) ? u.standsIn.length : u.standsIn).map(([id]) => id);
assert.equal(standIns.length, 17);
for (const id of [...standIns, 'char_616_pithst', ...Object.keys(readJson('data/tokens.json'))]) {
assert.equal(jp[id], undefined, `${id}: no JP line`);
assert.equal(cn[id], undefined, `${id}: no line`);
}
// the files themselves, where this checkout has downloaded the art
const dir = path.join(ROOT, 'public', 'assets', 'audio', 'voice', 'jp');
if (fs.existsSync(dir)) for (const u of lines) assert.ok(fs.statSync(path.join(ROOT, 'public', u)).size > 0, u);
});
test('settings 语音语言: 中文 by default, 日本語 kept, nothing else; the row is translated in every language pack', () => {
assert.deepEqual([...VOICE_LANGS], ['cn', 'jp']);
assert.equal(DEFAULT_SETTINGS.voiceLang, 'cn', 'not tied to the interface language: 中文 until the player picks 日本語');
assert.equal(sanitizeSettings({}).voiceLang, 'cn');
assert.equal(sanitizeSettings({ voiceLang: 'jp' }).voiceLang, 'jp');
assert.equal(sanitizeSettings({ voiceLang: 'en' }).voiceLang, 'cn', 'no English / Korean dub');
const ui = fs.readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
assert.match(ui, /updateSettings\(\{ voiceLang: id \}\)/);
assert.match(ui, /const VOICE_LANG_NAMES = \{ cn: '中文', jp: '日本語' \};/, 'each dub named in its own language');
for (const code of ['en', 'ja', 'ko', 'zh-TW']) {
const pack = readJson(`public/i18n/${code}.json`);
assert.ok(typeof pack['语音语言'] === 'string' && pack['语音语言'] && pack['语音语言'] !== '语音语言', `${code}: 语音语言`);
}
});
test('package: the full zip ships the JP dub by default; FULL_ZIP_JP_VOICE off holds it back (not missing, not an orphan, never removed by an update); the lite zip has no art', () => {
assert.equal(FULL_ZIP_JP_VOICE, true, '0.2.2 ships both dubs in the full zip');
assert.equal(JP_VOICE_DIR, 'public/assets/audio/voice/jp/');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-voicejp-'));
try {
const cnRel = 'public/assets/audio/voice/cn/char_263_skadi/cn_021.mp3';
const jpRel = 'public/assets/audio/voice/jp/char_263_skadi/cn_021.mp3';
const jpRel2 = 'public/assets/audio/voice/jp/char_263_skadi/cn_022.mp3';
for (const rel of [cnRel, jpRel]) { fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); fs.writeFileSync(path.join(dir, rel), 'ID3'); }
fs.mkdirSync(path.join(dir, 'data'));
const url = (rel) => `/${rel.slice('public/'.length)}`;
fs.writeFileSync(path.join(dir, 'data', 'assets.json'), JSON.stringify({ audio: {
voice: { char_263_skadi: { select: url(cnRel) } }, voiceJp: { char_263_skadi: { select: [url(jpRel), url(jpRel2)] } } } }));
const on = artPlan(dir);
assert.ok(on.files.includes(cnRel) && on.files.includes(jpRel), 'both dubs ship');
assert.deepEqual(on.missing, [jpRel2], 'a listed JP file not on disk is missing, like any art');
assert.deepEqual(on.held, []);
const off = artPlan(dir, { jpVoice: false });
assert.ok(off.files.includes(cnRel), 'the Chinese dub ships');
assert.ok(!off.files.includes(jpRel), 'the JP files stay out of the full zip');
assert.deepEqual(off.held, [jpRel, jpRel2], 'held back on purpose (setup downloads them; an update never deletes them)');
assert.deepEqual(off.missing, [], 'not missing');
assert.deepEqual(off.orphans, [], 'not orphans either');
assert.deepEqual(artPlan(dir, { lite: true }).files, [], 'the lite zip carries no art');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
// the update zip keeps a held file out of `removed` (tools/package.mjs buildUpdate)
const src = fs.readFileSync(path.join(ROOT, 'tools/package.mjs'), 'utf8');
assert.match(src, /removable: \(rel\) => !removalProblem\(rel\) && !heldBack\.has\(rel\)/);
});
test('选中干员 on every card tap (review of fb7-voices): two shop / reward cards of one operator are two taps, a re-render is none', async () => {
// shop / reward cards hand only the chess id to the detail target (no piece, no unit): game.js numbers every card it opens
// (`tap`), resolveDetail keeps it, and the panel's 选中 key (selectVoiceKey) includes it — the second 斯卡蒂 card spoke
// nothing before, nor replaced the first card's line (the pool deals duplicates)
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
let body;
try { body = readJson(`data/${name}`); } catch { return { ok: false, status: 404, json: async () => ({}) }; }
return { ok: true, status: 200, json: async () => body };
};
try {
const { resolveDetail, selectVoiceKey } = await import('../public/js/ui/detailPanel.js');
const { data } = await import('../public/js/data.js');
await data.loadAll('chess', 'backups');
const id = Object.values(readJson('data/chess.json')).find((c) => c.charId === 'char_263_skadi' && !c.isGolden).chessId;
const card = (tap) => resolveDetail({ kind: 'chess', id, hint: null, tap }, new Map(), { priv: null });
const a = card(1), b = card(2);
assert.equal(a.chess.charId, 'char_263_skadi');
assert.deepEqual([a.tap, b.tap], [1, 2], 'the tap rides along');
assert.notEqual(selectVoiceKey(a), selectVoiceKey(b), 'the second card of the same operator is a new opening');
assert.equal(selectVoiceKey(card(1)), selectVoiceKey(a), 'the same tap resolved again (a re-render) says nothing more');
assert.equal(resolveDetail({ kind: 'chess', id }, new Map(), { priv: null }).tap, undefined, 'no tap, no field');
// pieces and battle units keep their own identity; a non-operator card has no key
assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, piece: { uid: 4 } }), 'x:4:');
assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, unitId: 9 }), 'x:9:');
assert.equal(selectVoiceKey({ type: 'item' }), null);
assert.equal(selectVoiceKey(null), null);
// the panel keys its 选中 effect on it, and the game screen numbers every operator card it opens
assert.match(fs.readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8'), /const selectKey = voice && detail\?\.type === 'chess' \? selectVoiceKey\(detail\) : null;/);
const game = fs.readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(game, /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null, tap: \+\+cardTap\.current \}\)\}/);
} finally {
globalThis.fetch = origFetch;
}
});
+123 -1
View File
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx, installAudio, audio, combatTrackFor, COMBAT_TRACK_SWITCH_ROUND, VoiceGate, resultSpeaker, resultVoiceSlot, VOICE_PRIORITY, VOICE_COOLDOWN_MS } from '../../public/js/audio.js';
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx, installAudio, audio, combatTrackFor, COMBAT_TRACK_SWITCH_ROUND, VoiceGate, resultSpeaker, resultVoiceSlot, VOICE_PRIORITY, VOICE_COOLDOWN_MS, voiceLine } from '../../public/js/audio.js';
import { mediaUrl } from '../../public/js/media.js';
import { PHASE } from '../../shared/constants.js';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
@@ -252,6 +252,128 @@ describe('operator battle voice', () => {
assert.equal(g2.request('skill1', 'u1', 10001), 'play', 'a new battle inherits no cooldown');
});
test('选中干员 on every tap (0.2.2; official FOCUS_CHAR: priority 10, cooldown 0): the prep speaks too, an idle channel always answers, a newer tap replaces it, a higher line is never interrupted', async () => {
// the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」: the game screen lets the detail panel speak
// in every phase — a tap on a piece in the field / hand, a shop card — not only while a battle runs
const game = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(game, /<\$\{DetailPanel\}[^`]*?voice=\$\{true\}/, 'the detail panel speaks outside battle too');
assert.doesNotMatch(game, /voice=\$\{combat\}/);
assert.equal(VOICE_PRIORITY.select, 10, 'official FOCUS_CHAR priority');
assert.equal(VOICE_COOLDOWN_MS.select, 0, 'official FOCUS_CHAR cooldown 0');
const g = new VoiceGate(); // the real 1.2 s global gap
assert.equal(g.request('select', null, 0), 'play');
g.start('select', null, 0); g.release(); // tap A, its line ended
assert.equal(g.request('select', null, 300), 'play', 'an idle channel: the global gap never drops a tap');
g.start('select', 'u1', 300); g.release();
assert.equal(g.request('select', 'u1', 400), 'play', 'no cooldown either, keyed or not');
g.start('select', null, 400);
assert.equal(g.request('select', null, 600), 'preempt', 'a newer tap replaces the 选中 line on air (overlapIfSamePriority)');
g.start('select', null, 600);
assert.equal(g.request('place', 'u2', 700), 'preempt', '部署 (20) still takes the channel from 选中 (10)');
g.start('place', 'u2', 700);
assert.equal(g.request('select', null, 800), 'drop', 'a tap never interrupts a higher-priority line');
g.reset();
g.start('skill1', 'u3', 0);
assert.equal(g.request('select', null, 100), 'drop', '… nor a 作战中 line');
g.reset();
// a tap starts no gap of its own (review of fb7-voices): a 部署 at 0 holds the battle lines until 1200; a tap at 500
// answers, its short line ends at 944, and a 作战中 the battle asks for once at 1300 still plays (it was dropped: the
// tap had moved the gap's start to 500)
g.start('place', 'u4', 0); g.release();
assert.equal(g.request('select', null, 500), 'play');
g.start('select', null, 500); g.release();
assert.equal(g.request('skill1', 'u5', 1100), 'drop', 'inside the 部署 gap a battle line still waits');
assert.equal(g.request('skill1', 'u5', 1300), 'play', 'the gap ends where the 部署 put it: the tap did not restart it');
g.reset();
g.start('select', null, 0); g.release();
assert.equal(g.request('place', 'u6', 100), 'play', 'a battle line right after a tap: no gap behind a 选中 line');
g.reset();
// the manager: two taps 0.3 s apart in the prep (no battle, the real gate) both speak, the second replacing the first
const fw = fakeWindow();
const origFetch = globalThis.fetch;
globalThis.fetch = async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
try {
const vm = { audio: { sfx: { ui: {}, battle: {}, units: {} }, voice: { char_a: { select: '/v/a_sel.mp3' }, char_b: { select: '/v/b_sel.mp3' } } } };
const a = new AudioManager({ win: fw.win, getManifest: () => vm });
a.install();
fw.fire('pointerdown');
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voice('char_a', 'select'), true, 'tap A');
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_sel.mp3');
assert.equal(a.voice('char_b', 'select'), true, 'tap B while A still speaks');
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/b_sel.mp3', 'B replaced A');
a._stopVoice(); // B ended
assert.equal(a.voice('char_a', 'select'), true, 'tap A again right after: no gap');
} finally {
globalThis.fetch = origFetch;
}
});
test('语音语言 (0.2.2): 日本語 plays audio.voiceJp — the same slots and file names — and falls back to the Chinese line per slot and per line', async () => {
const audioM = {
voice: { char_a: { select: ['/a/voice/cn/char_a/cn_021.mp3', '/a/voice/cn/char_a/cn_022.mp3'], place: '/a/voice/cn/char_a/cn_023.mp3', start: '/a/voice/cn/char_a/cn_019.mp3' } },
voiceJp: { char_a: { select: ['/a/voice/jp/char_a/cn_021.mp3', '/a/voice/jp/char_a/cn_022.mp3'], place: '/a/voice/jp/char_a/cn_023.mp3' } },
};
assert.deepEqual(voiceLine(audioM, 'char_a', 'place'), { url: '/a/voice/cn/char_a/cn_023.mp3', fallback: null }, '中文 by default');
assert.deepEqual(voiceLine(audioM, 'char_a', 'place', 'cn'), { url: '/a/voice/cn/char_a/cn_023.mp3', fallback: null });
assert.deepEqual(voiceLine(audioM, 'char_a', 'place', 'jp'), { url: '/a/voice/jp/char_a/cn_023.mp3', fallback: '/a/voice/cn/char_a/cn_023.mp3' });
// a drawn line keeps its Chinese twin as the fallback (选中干员2 ⇒ 选中干员2)
assert.deepEqual(voiceLine(audioM, 'char_a', 'select', 'jp', () => 0.99), { url: '/a/voice/jp/char_a/cn_022.mp3', fallback: '/a/voice/cn/char_a/cn_022.mp3' });
assert.deepEqual(voiceLine(audioM, 'char_a', 'select', 'jp', () => 0), { url: '/a/voice/jp/char_a/cn_021.mp3', fallback: '/a/voice/cn/char_a/cn_021.mp3' });
assert.deepEqual(voiceLine(audioM, 'char_a', 'start', 'jp'), { url: '/a/voice/cn/char_a/cn_019.mp3', fallback: null }, 'a slot the JP tree lacks: the Chinese line');
assert.equal(voiceLine(audioM, 'char_zz', 'select', 'jp'), null, 'an operator no dub voices (stand-ins, 盟约·辅助干员, summons) stays silent');
assert.equal(voiceLine({ voice: {} }, 'char_a', 'select', 'jp'), null);
assert.equal(voiceLine(null, 'char_a', 'select', 'jp'), null);
assert.deepEqual(voiceLine({ voice: audioM.voice }, 'char_a', 'place', 'jp'), { url: '/a/voice/cn/char_a/cn_023.mp3', fallback: null }, 'a manifest without voiceJp');
// the real manifest: every operator with a Chinese line has its Japanese twin
const real = voiceLine(manifest.audio, 'char_263_skadi', 'select', 'jp', () => 0);
assert.match(real.url, /^\/assets\/audio\/voice\/jp\/char_263_skadi\/cn_021\.mp3$/);
assert.equal(real.fallback, '/assets/audio/voice/cn/char_263_skadi/cn_021.mp3');
// the manager: the setting picks the tree; a JP file the host lacks (404) plays the Chinese one, holding the channel
const fw = fakeWindow();
const origFetch = globalThis.fetch;
const urls = [];
const missing = new Set(['/a/voice/jp/char_a/cn_023.mp3', mediaUrl('/a/voice/jp/char_a/cn_023.mp3')]);
globalThis.fetch = async (u) => { urls.push(u); return missing.has(u) ? { ok: false, status: 404 } : { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
const tick = () => new Promise((r) => setTimeout(r, 10));
try {
const a = new AudioManager({ win: fw.win, getManifest: () => ({ audio: { sfx: { ui: {}, battle: {}, units: {} }, ...audioM } }) });
a.voiceGate = new VoiceGate({ gapMs: 0 }); // the gap itself is covered above
a.install();
fw.fire('pointerdown');
await tick();
assert.equal(a.voiceLang, 'cn');
a.setVoiceLang('jp');
assert.equal(a.voiceLang, 'jp');
assert.equal(a.voice('char_a', 'select'), true);
await tick();
assert.match(a.voiceNode?.url ?? '', /^\/a\/voice\/jp\/char_a\/cn_02[12]\.mp3$/, '日本語: the JP line');
a._stopVoice();
assert.equal(a.voice('char_a', 'place', { unitKey: 1 }), true);
await tick(); await tick();
assert.ok(asked(urls, '/a/voice/jp/char_a/cn_023.mp3'), 'the JP file was asked for first');
assert.equal(a.voiceNode?.url, '/a/voice/cn/char_a/cn_023.mp3', 'the host lacks it: the Chinese line of the same name plays');
assert.equal(a.voice('char_a', 'place', { unitKey: 2 }), false, 'the fallback holds the channel like any line');
a._stopVoice();
a.setVoiceLang('cn');
assert.equal(a.voice('char_a', 'select'), true);
await tick();
assert.match(a.voiceNode?.url ?? '', /^\/a\/voice\/cn\/char_a\/cn_02[12]\.mp3$/, '中文 again');
a._stopVoice();
a.setVoiceLang('kr');
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, /t\('语音语言'\)/);
} finally {
globalThis.fetch = origFetch;
}
});
test('AudioManager.voice: manifest slots (a drawn array), the gate, and the battle events that drive them', async () => {
const fw = fakeWindow();
const origFetch = globalThis.fetch;
+6 -1
View File
@@ -694,9 +694,14 @@ 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, muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', muted: false, damageNumbers: false, quality: 'high', 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
assert.equal(DEFAULT_SETTINGS.voiceLang, 'cn');
assert.equal(sanitizeSettings({ bgm: 0.5 }).voiceLang, 'cn');
assert.equal(sanitizeSettings({ voiceLang: 'jp' }).voiceLang, 'jp');
for (const bad of ['en', 'kr', 'JP', 'ja', 1, null]) assert.equal(sanitizeSettings({ voiceLang: bad }).voiceLang, 'cn', String(bad));
assert.equal(sanitizeSettings({ bgm: 0.333 }).bgm, 0.33);
assert.equal(sanitizeSettings({ quality: 'low' }).quality, 'low');
});
+1 -1
View File
@@ -120,7 +120,7 @@ describe('shop bar: the merge tag and the armed card', () => {
assert.equal(mergeHint(board, golden(A)), null, 'an elite card never merges');
// the first tap: ShopBar tapCard → onDetail(id, 'chess', mergeHint) → game.js setDetail({ …, hint }) → resolveDetail
assert.match(read('public/js/ui/shopBar.js'), /setArmed\(key\); onDetail\(slot\.id, detailKind, detailKind === 'chess' \? mergeHint\(priv, slot\.id\) : null\)/);
assert.match(read('public/js/screens/game.js'), /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null \}\)\}/);
assert.match(read('public/js/screens/game.js'), /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null(?:, tap: \+\+cardTap\.current)? \}\)\}/);
const r = resolveDetail({ kind: 'chess', id: A, hint: mergeHint(board, A) }, new Map());
assert.equal(r.hint, '精锐干员将出现在作战区原位置');
const blocks = ChessDetail({ chess: r.chess, piece: null, editable: false, bonds: [], loadout: null, hint: r.hint });