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:
commit
c5e8d13636
29 files changed
+1059
-278
No files matched your search
+192
-2
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user