feedback5: merge feedback4 (beb0296) — PR #73 (operator battle voices) ported to the split layout (the voice setting into ui/gameLogic/settings.js, the gameLogic.js shim unchanged; onResult and DetailPanel voice stay in screens/game.js, which needed its store import back; the sim's 'engage' in server/sim/ai.js performAttack, still the one battle/combat.js and devices.js call; plan.mjs / fetch-assets.mjs merged with --add-only, extraOperators and moduleTypes; the 干员语音 slider through t() with English); golden: roster / bonds / fields / standins / diy differ only in events.engage, matches unchanged

Ported by hand:
- public/js/ui/gameLogic.js is a re-export shim on feedback5: #73's DEFAULT_SETTINGS / sanitizeSettings `voice`
  hunk went into public/js/ui/gameLogic/settings.js.
- public/js/screens/game.js: MatchScreen still lives there, so #73's onResult (battleRunner 'result' → resultSpeaker /
  resultVoiceSlot) and `voice=${combat}` on the DetailPanel auto-merged in place. feedback5's game.js no longer
  imported `store` (the split moved its other users), so onResult's store.get() would have thrown into its
  try/catch and the 结算 line would never have played: `store` is imported again. `localAsset` stays dropped.
- public/js/ui/settings.js (i18n phase 1): the slider is `label=${t('干员语音')}`, public/i18n/en.json
  "干员语音": "Operator Voices" (next to Music / Sound Effects).
- tools/assets/plan.mjs: buildPlan takes #73's charword / voiceLang / voiceSlots and feedback5's extraOperators /
  moduleTypes; the template has `modules` and audio { bgm, bossBgm, voice, sfx }. `chars` now includes the 自选 picks,
  so the voice loop's comment says so (the next `npm run assets` plans their lines too).
- tools/fetch-assets.mjs: parseArgs keeps --add-only beside --voice-lang / --voice-all.
- data/assets.json: feedback5's manifest plus #73's audio.voice (120 operators, after bossBgm, the plan order);
  stats files +1680 and bytes +42 041 118 as measured on feedback4, voiceChars 120; the hash is recomputed with
  the generator's contentHash. No other entry changed.
- docs/ASSETS.md: the stats line lists modules and voiceChars. DESIGN §21.30 stays where feedback4 put it; SIM.md's
  'engage' line and docs-consistency's §21.30 test auto-merged (game.js / detailPanel.js still hold the pinned code).
- Auto-merged and checked: audio.js, battle/runner.js emit('result'), components.js mic icon, detailPanel.js voice
  prop, shared/protocol.js EV.ENGAGE, server/sim/ai.js, tools/assets/audio.mjs / cache.mjs, #73's tests.

Where #73 met feedback5 work:
- 补位: a chess fielded as its stand-in shows the stand-in in the detail card, and audio.js keys battle lines by
  UnitInfo spine (the stand-in's model). The 选中干员 line now takes detail.standIn.charId first, so the stand-in
  speaks, not the operator it replaces (test/ui/standin-ui.test.js pins it). A 自选 record is already the pick's.
- The sim's 'engage': stand-in and 自选 units attack through the same performAttack, so it fires for them as well.

Golden regenerated: every moved scenario differs only in events.engage (the new event's count; no gameplay change),
proven by a field-path diff against feedback5:
- roster: all 49; bonds: all 46; fields: all 22; standins: all 10 (the stand-ins engage).
- diy: 101 of 109. Unmoved: diy-010/011/012/092/093 (闪灵, 夜莺, 凯尔希 squads: they only heal, and a heal never
  engages) and diy-077 (薇薇安娜), diy-080/081 (赫德雷), whose picks make no attack in those battles (already so on
  feedback5).
- matches: unchanged.
This commit is contained in:
sganggs committed 2026-10-06 04:31:38 +08:00
commit c5e8d13636
29 files changed
+1059 -278

No files matched your search

+192 -2
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 } 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 } from '../../public/js/audio.js';
import { mediaUrl } from '../../public/js/media.js';
import { PHASE } from '../../shared/constants.js';
@@ -170,6 +170,196 @@ describe('SfxLimiter', () => {
});
});
// ---- operator battle voice (manifest audio.voice; official priorities, audio_data.json battleVoice) ---------
describe('operator battle voice', () => {
test('resultVoiceSlot: 完美作战 / 高难 / 漏怪 / 全灭', () => {
assert.equal(resultVoiceSlot({ perfect: true }), 'resultThree');
assert.equal(resultVoiceSlot({ perfect: true, hard: true }), 'resultFour', '绝境 / 终极 报 完成高难行动');
assert.equal(resultVoiceSlot({ perfect: false, leaked: 2, killed: 10, total: 12 }), 'resultTwo');
assert.equal(resultVoiceSlot({ perfect: false, leaked: 0, killed: 0, total: 12 }), 'resultLose');
assert.equal(resultVoiceSlot({ perfect: false, leaked: 0, killed: 5, total: 5 }), 'resultThree', 'no leak ⇒ the 完美 line');
assert.equal(resultVoiceSlot(), 'resultThree');
});
test('resultSpeaker: the operators of THAT battle (survivors first), never the field on screen', () => {
// `mine.unitsEnd` of the finished own battle: operators (char_*) and the board's summon pieces (token_*, no voice)
const mine = { unitsEnd: [
{ defId: 'char_fallen', alive: false },
{ defId: 'token_1001', alive: true },
{ defId: 'char_a', alive: true },
{ defId: 'char_b', alive: true },
] };
assert.equal(resultSpeaker(mine, () => 0), 'char_a');
assert.equal(resultSpeaker(mine, () => 0.999), 'char_b', 'a drawn operator, still only the ones standing');
assert.equal(resultSpeaker(mine, () => 0.4), 'char_a');
const drawn = new Set([0, 0.2, 0.4, 0.6, 0.8, 1].map((r) => resultSpeaker(mine, () => r)));
assert.deepEqual([...drawn].sort(), ['char_a', 'char_b'], 'a fallen operator or a summon never speaks');
// the whole squad fell: only then does a fallen operator report the result
const wiped = { unitsEnd: [{ defId: 'char_fallen', alive: false }, { defId: 'char_also', alive: false }] };
assert.equal(resultSpeaker(wiped, () => 0), 'char_fallen');
assert.equal(resultSpeaker(wiped, () => 0.999), 'char_also');
// no operator on that field at all — summons only, an empty list, or a payload without `unitsEnd` (an older
// server): no line. There is deliberately NO fallback to the operators currently tracked for the field on screen —
// that fallback is what made a watched team-mate's operator say the viewer's line (review on #73).
assert.equal(resultSpeaker({ unitsEnd: [{ defId: 'token_1', alive: true }] }, () => 0), null);
assert.equal(resultSpeaker({ unitsEnd: [] }), null);
assert.equal(resultSpeaker({}), null);
assert.equal(resultSpeaker(null), null);
assert.equal(resultSpeaker(undefined), null);
assert.equal(resultSpeaker({ unitsEnd: [{ alive: true }] }, () => 0), null, 'a unit without a defId');
assert.equal(resultSpeaker({ unitsEnd: [{ defId: 'enemy_1007_slime', alive: true }] }, () => 0), null, 'an enemy');
});
test('VoiceGate: one line at a time, a global gap, per-unit cooldowns, higher priority takes over', () => {
assert.ok(VOICE_PRIORITY.start > VOICE_PRIORITY.skill1 && VOICE_PRIORITY.skill1 > VOICE_PRIORITY.place, 'the official order');
assert.ok(VOICE_PRIORITY.resultThree > VOICE_PRIORITY.skill1 && VOICE_PRIORITY.resultThree < VOICE_PRIORITY.faceEnemy);
assert.equal(VOICE_COOLDOWN_MS.skill1, 10000, 'official SKILL_ACTIVE 10 s');
const g = new VoiceGate({ gapMs: 1000 });
assert.equal(g.request('start', 1, 0), 'play');
g.start('start', 1, 0);
assert.equal(g.request('skill1', 2, 500), 'drop', 'one voice at a time (a lower priority waits)');
assert.equal(g.request('start', 3, 500), 'drop', 'the same priority does not interrupt');
assert.equal(g.request('faceEnemy', 3, 500), 'drop', '90 < 100 + margin');
g.release();
assert.equal(g.request('place', 1, 900), 'drop', 'the global gap is not over');
assert.equal(g.request('place', 1, 1000), 'play');
g.release();
const g2 = new VoiceGate({ gapMs: 0 });
assert.equal(g2.request('skill1', 'u1', 0), 'play');
g2.start('skill1', 'u1', 0);
g2.release();
assert.equal(g2.request('skill1', 'u1', 5000), 'drop', 'the unit cooldown');
assert.equal(g2.request('skill1', 'u2', 5000), 'play', 'another unit is unaffected');
g2.release();
assert.equal(g2.request('skill1', 'u1', 10000), 'play', 'cooldown over');
g2.release();
g2.reset();
assert.equal(g2.request('skill1', 'u1', 10001), 'play', 'a new battle inherits no cooldown');
});
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;
const urls = [];
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
try {
const vm = { audio: { sfx: { ui: {}, battle: {}, units: {} }, voice: {
char_a: { start: '/v/a_start.mp3', place: ['/v/a_p1.mp3', '/v/a_p2.mp3'], skill3: '/v/a_s3.mp3', faceEnemy: '/v/a_face.mp3' },
char_b: { place: '/v/b_p1.mp3', skill1: '/v/b_s1.mp3' },
} } };
const a = new AudioManager({ win: fw.win, getManifest: () => vm });
a.voiceGate = new VoiceGate({ gapMs: 0 }); // the gap itself is covered above
a.install();
assert.equal(a.voice('char_a', 'place'), false, 'locked: nothing is requested');
assert.equal(urls.length, 0);
fw.fire('pointerdown');
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voice('char_a', 'start', { unitKey: 1 }), true);
await new Promise((r) => setTimeout(r, 10));
assert.ok(asked(urls, '/v/a_start.mp3'));
assert.equal(a.voice('char_b', 'place', { unitKey: 2 }), false, '部署 cannot interrupt 行动出发');
assert.equal(a.voice('char_a', 'faceEnemy', { unitKey: 3 }), false, '接敌 cannot either');
a._stopVoice();
assert.equal(a.voice('char_a', 'place', { unitKey: 1 }), true, 'the channel is free again');
await new Promise((r) => setTimeout(r, 10));
assert.ok(asked(urls, '/v/a_p1.mp3') || asked(urls, '/v/a_p2.mp3'), 'a drawn 部署 line');
assert.equal(a.voice('char_a', 'nope', { unitKey: 1 }), false, 'unknown slot');
assert.equal(a.voice('char_zz', 'place', { unitKey: 1 }), false, 'unknown operator');
a._stopVoice(); a.voiceGate.reset();
// the battle events drive the lines: 行动出发 (the first deploy), 部署 (the rest), 行动开始, 作战中N
a.setFieldUnits([{ id: 11, side: 'ally', spine: 'char_a', kind: 'op', skillIndex: 2 }, { id: 12, side: 'ally', spine: 'char_b', kind: 'op' }]);
a.handleBattleEvents([['deploy', 11]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_start.mp3', 'the first operator deployed says 行动出发');
a.handleBattleEvents([['deploy', 12]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_start.mp3', 'a 部署 line never interrupts it (lower priority)');
a._stopVoice(); a.voiceGate.reset();
a.handleBattleEvents([['engage', 11], ['skill', 12, 1]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_face.mp3', '首次接敌 ⇒ 行动开始 (a 作战中 line never interrupts it)');
a._stopVoice(); a.voiceGate.reset();
a.handleBattleEvents([['skill', 11, 1]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_s3.mp3', '作战中N follows the unit skillIndex (2 ⇒ 作战中3)');
a._stopVoice(); a.voiceGate.reset();
a.handleBattleEvents([['skill', 12, 1]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/b_s1.mp3', 'a unit without skillIndex falls back to 作战中1');
a._stopVoice(); a.voiceGate.reset();
a.setFieldUnits([{ id: 21, side: 'ally', spine: 'char_a', kind: 'op' }]);
a.handleBattleEvents([['deploy', 21]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_start.mp3', 'a new battle starts with 行动出发 again');
} finally {
globalThis.fetch = origFetch;
}
});
test('voice: a stale callback never frees the channel the newest line holds (review on #73)', async () => {
const fw = fakeWindow();
const origFetch = globalThis.fetch;
// holds named URLs open: the first request for one waits on the promise in `held`, later ones (the manager caches a
// decoded URL) do not. `fail` answers 404, i.e. `_buffer` resolves null.
const held = new Map();
const fail = new Set();
globalThis.fetch = async (u) => {
if (fail.has(u)) return { ok: false, status: 404 };
const h = held.get(u);
if (h) { held.delete(u); await h; } // the manager caches per URL, so only the first call waits
return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) };
};
const tick = () => new Promise((r) => setTimeout(r, 10));
try {
const vm = { audio: { sfx: { ui: {}, battle: {}, units: {} }, voice: {
char_a: { place: '/v/a_p1.mp3', start: '/v/a_start.mp3', faceEnemy: '/v/a_face.mp3', select: '/v/a_sel.mp3' },
} } };
const a = new AudioManager({ win: fw.win, getManifest: () => vm });
a.voiceGate = new VoiceGate({ gapMs: 0 }); // the gap itself is covered above
a.install();
fw.fire('pointerdown');
await tick();
// 部署 (20) starts with its LOAD HELD OPEN, so 行动出发 (100) takes the channel over while it is still loading
let openA;
held.set('/v/a_p1.mp3', new Promise((r) => { openA = r; }));
assert.equal(a.voice('char_a', 'place', { unitKey: 1 }), true);
assert.equal(a.voice('char_a', 'start', { unitKey: 2 }), true, 'a higher priority line takes the channel over');
await tick();
assert.equal(a.voiceNode?.url, '/v/a_start.mp3', 'the replacement is on air');
openA(); // the preempted line's load finally finishes
await tick();
assert.equal(a.voiceNode?.url, '/v/a_start.mp3', 'a stale callback never starts its own line');
assert.equal(a.voice('char_a', 'place', { unitKey: 3 }), false, 'and never freed the channel it lost');
// the safety timer / onended of a line that was STOPPED must not free the line that replaced it either
const ringing = a.voiceNode; // 行动出发 on air
a._stopVoice(); // the stop path releases the gate itself and moves the token on
assert.equal(a.voice('char_a', 'place', { unitKey: 4 }), true, 'the channel is free again');
await tick();
const now = a.voiceNode;
assert.equal(now?.url, '/v/a_p1.mp3');
ringing.src.onended?.(); // the stopped line's own end lands late (or its safety timer fires)
ringing.src.onended?.(); // …and a second time, like timer + onended both firing
await tick();
assert.equal(a.voiceNode, now, 'the line on air is untouched');
assert.equal(a.voice('char_a', 'place', { unitKey: 5 }), false, 'and its channel is still held');
// a load that FAILS after the takeover (404 ⇒ null buffer): same rule
fail.add('/v/a_sel.mp3');
a._stopVoice();
assert.equal(a.voice('char_a', 'select', { unitKey: 6 }), true);
assert.equal(a.voice('char_a', 'place', { unitKey: 7 }), true, 'a higher priority line takes over while it loads');
await tick();
assert.equal(a.voiceNode?.url, '/v/a_p1.mp3');
await tick();
assert.equal(a.voice('char_a', 'place', { unitKey: 8 }), false, 'the failed load kept out of the newest line\'s way');
} finally {
globalThis.fetch = origFetch;
}
});
});
// ---- fake Web Audio -------------------------------------------------------------------------------------
function fakeWindow() {
@@ -209,7 +399,7 @@ describe('AudioManager', () => {
assert.equal(a.unit('char_x', 'attack', 1), false);
a.handleBattleEvents([['atk', 1, 2, 'arrow'], 'junk', null]);
a.setVolumes({ bgm: 5, sfx: -1, muted: true });
assert.deepEqual(a.volumes, { bgm: 1, sfx: 0, muted: true });
assert.deepEqual(a.volumes, { bgm: 1, sfx: 0, voice: 0.8, muted: true });
assert.equal(a.unlocked, false);
});
test('unlocks on the first gesture, then plays BGM and SFX from the manifest', async () => {
+3 -2
View File
@@ -515,8 +515,9 @@ describe('keyboard & settings', () => {
});
test('sanitizeSettings', () => {
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
{ bgm: 1, sfx: 0, muted: false, damageNumbers: false, quality: 'high' });
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' });
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
assert.equal(sanitizeSettings({ bgm: 0.333 }).bgm, 0.33);
assert.equal(sanitizeSettings({ quality: 'low' }).quality, 'low');
});
+4
View File
@@ -296,6 +296,10 @@ describe('match UI of a not-owned chess', () => {
const ph = [...walk(plain.find((b) => b.key === 'head'))];
assert.equal(ph.find((v) => hasClass(v, 'dhead__name')).props.children, '银灰');
assert.equal(ph.find((v) => hasClass(v, 'dhead__for')), undefined);
// PR #73's 选中干员 voice (DetailPanel `voice`, in battle): the stand-in speaks, like its battle lines (audio.js keys
// them by UnitInfo spine, the stand-in's model) — never the operator it replaces
assert.match(readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8'),
/const selectChar = voice && detail\?\.type === 'chess' \? detail\.standIn\?\.charId \|\| detail\.chess\?\.charId \|\| null : null;/);
});
test('own prep pieces with stand-ins get a small 「替补」 tag (hand, temp, board); others none', () => {