diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f357981a..4edbbd60 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -6,6 +6,8 @@ 界面语言:默认中文;标题画面右上角或「设置」里可切换 **中文 / English / 日本語 / 한국어 / 繁體中文**(会记住选择,不用刷新)。日语、韩语、繁体中文的游戏文本取自官方日服 / 韩服 / 台服数据,界面文字暂未翻译——日语和韩语显示英文界面,繁体中文显示简体中文界面,欢迎社区补全(docs/I18N.md「Adding a language」)。英文的干员、技能、盟约、装备、敌人等游戏文本取自官方英文服数据,界面文字全部有英文;切到英文后仍显示中文的只有玩家名字(人机的名字也是)、多轮悬赏改写过的「接下来两场作战」说明,以及官方英文服还没有的游戏文本(部分自选干员和较新的模组);「玩法说明」的教程图是官方的中文图片,标题有英文(docs/I18N.md)。 +干员语音(官方台词,「设置」里的「干员语音」调音量):点选干员时——作战区或整备区里的干员、商店和晋升奖励里的干员卡片——会说一句官方的「选中干员」语音,休整期也一样;连续点选时新的一句接替正在说的那句,但不会打断正在播放的部署、技能等作战语音。部署(每场第一个部署的干员说「行动出发」)、技能、接敌和结算语音只在作战中播放。替补干员和原型干员(预备干员、Sharp、Mechanist 等)、盟约·辅助干员和召唤物没有官方语音,点选时不出声。 + ## 1. 一局的流程 ``` diff --git a/docs/history/0.1.1.md b/docs/history/0.1.1.md index c91bb32d..28811c89 100644 --- a/docs/history/0.1.1.md +++ b/docs/history/0.1.1.md @@ -350,6 +350,8 @@ Two public GitHub issues after 0.1.0, both client-only. Normative lines rewritte User: 「给每个卫戍干员都加语音」, then, after the first build, 「整备阶段不需要干员语音,战斗开始后才需要语音,也就是只需要作战中语音」. +**Changed in 0.2.2** (§27, the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」): 选中干员 is no longer battle-only — the detail panel opening on an operator the player taps says it in every phase, the prep, the shop and the hand included (`DetailPanel voice` is true on the game screen), with the official FOCUS_CHAR timing: no cooldown and no global gap on an idle channel, a newer tap replaces the 选中 line on air, a higher-priority line is never interrupted (`VOICE_TAP_SLOTS`). Every other line stays battle-only; the paragraphs below describe 0.1.1. + **Where the lines come from.** Every operator the mode can field already has official battle lines in the same voice dump the BGM and SFX come from — `voice_cn/{charId}/cn_nn.mp3` (JP/EN/KR: `voice/`, `voice_en/`, `voice_kr/`, the same file names, `tools/fetch-assets.mjs --voice-lang=jp|en|kr`). `charword_table.json`'s `placeType` says when each line is used, and `indexVoice()` turns it into the manifest's `audio.voice[charId]`: 行动出发 `start` (BATTLE_START), 行动开始 `faceEnemy` (BATTLE_FACE_ENEMY), 选中干员 `select`, 部署 `place`, 作战中1-4 `skill1..skill4` (BATTLE_SKILL_N), 编入队伍 `squad`, 任命队长 `squadFirst`, 结算 `resultFour/Three/Two/Lose` (完成高难行动 / 3星结束行动 / 非3星结束行动 / 行动失败) and 干员报到 `gacha`. A slot with several lines stays an array and the client draws one. Only the base word key is planned (`wordKey === charId`): the dump has no folder for a skin variant's word key (`char_x_ita`, `char_x_epoque#28`), so those lines 404. **Only the slots a battle can play are planned** (`audio.mjs VOICE_BATTLE_SLOTS`): 干员报到 / 编入队伍 / 任命队长 are lines of the official client's own 养成 / 编队 UI, nothing in this mode ever requests them (`test/docs-consistency.test.js` checks the client's `voice(...)` call sites), and downloading them cost 360 files (19.3 MB) on every run — `--voice-all` adds them back for the complete official set. Measured: 120 of the 121 in-mode charIds have a full set — the 17 预备干员 and the mode's own 盟约·辅助干员 have no battle voice at all, so they simply get no entry — 1680 lines of the 2040 upstream, in a manifest of 5694 files / 324.2 MB (files and `stats.voiceChars` in `data/assets.json`). **Where each line plays — battle only.** Every line comes from a running battle's own event stream or from its settlement, so no hook needs a phase check and the 休整期 stays silent: the shop does not say 干员报到 (a merge-completing buy neither says 任命队长), a retreat does not say 编入队伍 and dragging a piece onto the board in prep says nothing. In battle: 开战 — the first operator deployed says 行动出发 and the others 部署 (the sim's `deploy` events; `setFieldUnits` resets the flag per field — in this mode every piece enters at once, which is why the gate below exists, and a knocked-out operator's redeploy says 部署 again); 接敌 — the sim's new `['engage', id]` (a unit's first attack that hits an enemy, official ENCOUNTER_ENEMY); 技能 — `skillN` from the unit's `skillIndex` (作战中1-4); 选中 — the detail panel opening on an operator **while a battle runs** (`DetailPanel voice` = the game screen's `combat`); 结算 — `resultVoiceSlot()`: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动), 有漏怪 ⇒ 非3星结束行动, 一个没杀 ⇒ 行动失败, spoken by a drawn operator **of that battle's own field** (`resultSpeaker`, from the result's `perPlayer.unitsEnd`: the operators of that field, survivors first, a summon piece never), driven by the runner's new `emit('result', …)` — never the field on screen, so watching a teammate cannot put their operator's voice on the viewer's settlement line (review on #73). `unitsEnd` names each unit by its chess id (`chess_char_*`), so `onResult` passes the chess record's `charId` as `resultSpeaker`'s `charOf`; as merged, the speaker looked for `char_*` ids only and no battle ever said its settlement line (found while porting #73 to 0.2.0; `test/ui/audio.test.js` runs a real battle result through it). diff --git a/public/js/audio.js b/public/js/audio.js index f7a8784a..74f0bb08 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -18,11 +18,14 @@ // schedule), so every client of a match hears the same one, a fight never switches track halfway through and the // 联防 that follows a 作战 keeps its round's track. // - 干员战斗语音 (`audio.voice`, user request): an operator says the official line of the moment it is in — -// 行动出发 start, 行动开始 faceEnemy, 选中干员 select, 部署 place, 作战中1-4 skillN and the settlement's -// 结算 result* (all battle-only; the 休整期 is silent). Voices are seconds long and a battle deploys eight +// 行动出发 start, 行动开始 faceEnemy, 部署 place, 作战中1-4 skillN and the settlement's 结算 result* (battle-only), +// and 选中干员 select whenever the player taps an operator — in the prep, the shop and the hand too (the owner's +// request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」, which lifts 2026-10-03's 「整备阶段不需要干员语音」 for +// this line only). Voices are seconds long and a battle deploys eight // operators, then fires dozens of skills, so they run on their own channel (own gain, settings 干员语音) through // VoiceGate: one line at a time, a global gap, a per-unit per-slot cooldown, and a higher-priority line taking the -// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions`. +// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions` (a tap's 选中 line skips +// the gap: VOICE_TAP_SLOTS). // - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most // MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a // per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable. @@ -248,9 +251,9 @@ export function unitSoundPlays(mix, roll) { /** * Voice priorities — the official battle voice types (`audio_data.json battleVoice.voiceTypeOptions`) mapped onto the * manifest's slots: BATTLE_START 100, BATTLE_FACE_ENEMY 90, SKILL_ACTIVE 70, PASSIVE_IMP 60, PASSIVE_NOR 50, - * PLACE_CHAR 20, FOCUS_CHAR 10. The settlement lines are no battle voice of the official scheduler: they sit at 85, - * above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The four prep - * slots (部署 / 编入队伍 / 任命队长 / 干员报到) keep their levels although the 休整期 is silent (see the header). + * PLACE_CHAR 20, FOCUS_CHAR 10 (选中干员). The settlement lines are no battle voice of the official scheduler: they sit at + * 85, above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The three + * prep-only slots (编入队伍 / 任命队长 / 干员报到) keep their levels although nothing plays them (see the header). */ export const VOICE_PRIORITY = Object.freeze({ start: 100, faceEnemy: 90, @@ -259,14 +262,25 @@ export const VOICE_PRIORITY = Object.freeze({ gacha: 60, squadFirst: 45, squad: 30, place: 20, select: 10, }); -/** Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. */ +/** + * Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. 选中干员 + * has none — official FOCUS_CHAR `cooldown: 0` (it was 1.5 s until 0.2.2, when a tap in the prep made it audible). + */ export const VOICE_COOLDOWN_MS = Object.freeze({ start: 0, faceEnemy: 3000, skill1: 10000, skill2: 10000, skill3: 10000, skill4: 10000, resultFour: 0, resultThree: 0, resultTwo: 0, resultLose: 0, - gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 1500, + gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 0, }); +/** + * The slot a player's tap asks for: 选中干员 (official FOCUS_CHAR — priority 10, cooldown 0, `overlapIfSamePriority: + * true`). The battle lines are timed by the gate's global gap; a tap is the player's own request and answers at once: + * on an idle channel it always plays (no gap, no cooldown), a newer tap replaces the 选中 line still on air (the same + * priority, overlapIfSamePriority), and it still never interrupts a higher-priority line (部署, 作战中, 开战 …). + */ +export const VOICE_TAP_SLOTS = Object.freeze(['select']); + /** * The settlement slot of a finished 作战: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动 instead), a leaked enemy * ⇒ 非3星结束行动, nothing killed at all ⇒ 行动失败. @@ -365,7 +379,8 @@ export class SfxLimiter { /** * Voice gate: one line at a time, a global gap between two lines, a per-unit per-slot cooldown, and takeover by a - * clearly more important line (the caller fades the playing one out first). Pure — the clock is passed in. + * clearly more important line (the caller fades the playing one out first). A tap's 选中干员 skips the gap and the + * cooldown on an idle channel and replaces a 选中 line on air (VOICE_TAP_SLOTS). Pure — the clock is passed in. */ export class VoiceGate { /** @param {{ gapMs?: number, preemptMargin?: number, maxUnits?: number }} [o] */ @@ -396,10 +411,13 @@ export class VoiceGate { */ request(slot, unitKey, now) { const pri = VOICE_PRIORITY[slot] ?? 0; + const tap = VOICE_TAP_SLOTS.includes(slot); if (this.playing) { + if (tap && this.playing.slot === slot) return 'preempt'; // a newer tap replaces the 选中 line on air if (pri < this.playing.pri + this.preemptMargin) return 'drop'; return 'preempt'; // a clearly more important line takes the channel } + if (tap) return 'play'; // a tap on an idle channel always answers (FOCUS_CHAR cooldown 0, no gap) if (now - this.lastAt < this.gapMs) return 'drop'; if (unitKey != null && now < (this.unitUntil.get(`${unitKey}:${slot}`) || 0)) return 'drop'; return 'play'; @@ -824,8 +842,9 @@ export class AudioManager { /** * Play an operator's battle line (`audio.voice[charId][slot]`; a slot with several lines draws one at random). - * Only in battle: every caller is a running battle's own event stream or its settlement (user request — the 休整期 - * is silent). The line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins. + * Every caller is a running battle's own event stream or its settlement, except 选中干员: the detail panel opening on + * an operator the player tapped, in every phase (ui/detailPanel.js `voice`; the owner's request of 2026-10-08). The + * line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins. * @param {string} charId e.g. 'char_263_skadi' * @param {'start'|'faceEnemy'|'select'|'place'|'skill1'|'skill2'|'skill3'|'skill4'|'squad'|'squadFirst' * |'resultFour'|'resultThree'|'resultTwo'|'resultLose'|'gacha'} slot diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 52a79eb0..dcb63ff5 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -1391,7 +1391,7 @@ function MatchScreen() { onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor, diy) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null, diy: diy || null })} />` : null} ${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }} - bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat} + bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${true} onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null} ${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid} diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 215b725a..37ea26b9 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -798,8 +798,10 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( * = the defaults * live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4× * a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers - * voice: whether the panel may speak — 选中干员 (audio.voice 'select') plays only while a battle runs (user request: - * 整备期不播干员语音), so the game screen passes its combat flag + * voice: whether the panel may speak — 选中干员 (audio.voice 'select') when it opens on an operator the player tapped: + * a piece on the field or in the hand, a shop / reward card, a bond member. The game screen passes true in every phase + * (the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」 lifted 2026-10-03's 「整备阶段不需要干员语音」 + * for this line only; [ASSUMED] the official prep tap says 选中干员 like the battle's FOCUS_CHAR) */ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) { const getter = typeof live === 'function' ? live : null; diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index e604e53d..3a4bb798 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -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,7 +902,7 @@ 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 /); @@ -911,13 +911,14 @@ test('干员战斗语音 (DESIGN §21.30): the manifest data, the official prior assert.match(doc('docs/ASSETS.md'), /voiceChars/); 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\(/); }); diff --git a/test/ui/audio.test.js b/test/ui/audio.test.js index addb8c96..8d33b425 100644 --- a/test/ui/audio.test.js +++ b/test/ui/audio.test.js @@ -252,6 +252,54 @@ 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'); + assert.equal(g.request('place', 'u1', 300), 'drop', 'the battle lines keep the gap'); + 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(); + // 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('AudioManager.voice: manifest slots (a drawn array), the gate, and the battle events that drive them', async () => { const fw = fakeWindow(); const origFetch = globalThis.fetch; diff --git a/tools/assets/audio.mjs b/tools/assets/audio.mjs index 81469f68..b8e93efc 100644 --- a/tools/assets/audio.mjs +++ b/tools/assets/audio.mjs @@ -319,10 +319,10 @@ export const VOICE_SLOTS = Object.freeze({ }); /** - * The slots a running battle can actually request — the only ones `public/js/audio.js` ever asks for (the 休整期 is - * silent, so nothing else is played): 行动出发 start, 首次接敌 faceEnemy, 作战中1-4 skillN, 部署 place (the deploy - * events) and 选中干员 select (the detail panel, behind its combat flag), plus the settlement lines - * resultFour / resultThree / resultTwo / resultLose (public/js/screens/game.js onResult). + * The slots the client can actually request — the only ones `public/js/audio.js` ever asks for: 行动出发 start, 首次接敌 + * faceEnemy, 作战中1-4 skillN, 部署 place (a running battle's deploy events), the settlement lines resultFour / + * resultThree / resultTwo / resultLose (public/js/screens/game.js onResult) and 选中干员 select (the detail panel opening + * on an operator the player tapped, in every phase since 0.2.2). * `buildPlan` plans these by default; `--voice-all` widens it to every slot of VOICE_SLOTS. */ export const VOICE_BATTLE_SLOTS = Object.freeze(['start', 'faceEnemy', 'select', 'place', @@ -333,7 +333,7 @@ export const VOICE_BATTLE_SLOTS = Object.freeze(['start', 'faceEnemy', 'select', * 任命队长 squadFirst). `test/docs-consistency.test.js` proves the client never asks for one, so planning them only * makes every `npm run assets` download 360 files (19.3 MB, CN dub) that no player will ever hear — they are left out * unless `--voice-all` is passed. 部署 `place` and 选中干员 `select` deliberately stay in: the official client groups - * them with the prep lines, but a battle does play them (the deploy events; the detail panel behind its combat flag). + * them with the prep lines, but the client does play them (the deploy events; a tap on an operator). */ export const VOICE_PREP_SLOTS = Object.freeze(['gacha', 'squad', 'squadFirst']);