From 0ceb6641ecd0b0e125a03acc99b921701bf5fb72 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 13 Oct 2026 05:46:29 +0800 Subject: [PATCH] =?UTF-8?q?0.2.6-dev:=20PR=20#416=20by=20Cromemadnd=20?= =?UTF-8?q?=E2=80=94=20every=20landing=20of=20a=20round=20start=20is=20hea?= =?UTF-8?q?rd:=20prep=20landing=20sounds=20take=20a=20voice=20of=20their?= =?UTF-8?q?=20own=20pool=20(LAND=5FVOICES=203,=20the=20oldest=20fading=20o?= =?UTF-8?q?ut=20over=2030=20ms)=20instead=20of=20being=20dropped=20by=20th?= =?UTF-8?q?e=20battle=20limiter,=20which=20let=20only=20the=20first=20two?= =?UTF-8?q?=20of=20a=20chain=20sound=20since=20192=20of=20210=20operators?= =?UTF-8?q?=20share=20the=201.41=20s=20b=5Fchar=5Fset=20(Grok's=20review);?= =?UTF-8?q?=20per-piece=20cooldown,=20SFX=20volume=20and=20mute=20kept,=20?= =?UTF-8?q?the=20battle=20sounds'=20limiting=20unchanged,=20the=20generic?= =?UTF-8?q?=20files=20fetched=20ahead=20when=20a=20match=20view=20comes=20?= =?UTF-8?q?up;=20DESIGN=20=C2=A731.8;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/client.md | 7 +- docs/history/0.2.6.md | 28 +++- public/js/audio.js | 115 ++++++++++++- public/js/render/app.js | 3 + public/js/render/units.js | 5 +- test/ui/deploy-replay.e2e.test.js | 27 +++- test/ui/deploy-replay.test.js | 258 ++++++++++++++++++++++++------ 7 files changed, 379 insertions(+), 64 deletions(-) diff --git a/docs/design/client.md b/docs/design/client.md index 1fbe7a65..45f9f0c8 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -69,7 +69,7 @@ its board-space position and facing, including on a mirrored boss half. They can from this preview. The inspection affordance does not make the preview a combat unit; the battle sim still creates the selected variant at battle start (§30.22). -Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), a prep piece's landing sound (its battle deploy sound, once per landing, §31.8), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. While the music is silent — muted, or the BGM volume at 0 — no BGM is downloaded: the track the screen wants is remembered, a different one still playing stops, and the wanted track is fetched once the music is audible again (`AudioManager.bgmSilent`; about 14 MB a match for a player who turned the music off). +Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), a prep piece's landing sound (its battle deploy sound, once per landing, in landing voices of its own that take over the oldest, §31.8), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. While the music is silent — muted, or the BGM volume at 0 — no BGM is downloaded: the track the screen wants is remembered, a different one still playing stops, and the wanted track is fetched once the music is audible again (`AudioManager.bgmSilent`; about 14 MB a match for a player who turned the music off). --- @@ -199,7 +199,10 @@ simplified board uses the same mark with a fixed slot. The title footer links th PR #416 by @Cromemadnd. A prep piece that lands plays its Start clip again with its landing sound, once per landing (`render/units.js UnitView.land`): the sound its battle deployment makes (`audio.js deployCue` → `deploySfxUrl`, its own -ON_UNIT_BORN file else b_char_set / b_char_tokenset) through the SFX volume, mute and the battle limiter. A piece whose +ON_UNIT_BORN file else b_char_set / b_char_tokenset) through the SFX volume and mute, in landing voices of its own +(`LAND_VOICES` 3 at once [ASSUMED], a 160 ms cooldown per piece, not the battle limiter): a landing over the cap takes the +oldest one's voice (a 30 ms fade) instead of being dropped, so every landing of a round start is heard though most share +the 1.41 s b_char_set; the field view fetches the generic files ahead (`warmLandings`). A piece whose Start still plays (on its model, owed to a model still loading, or cued less than a clip ago) keeps it and sounds nothing more. Only an idle prep piece lands, never a battle unit or one attacking, dying, dragged or in another clip, and nothing lands in a hidden tab. Battle deploys keep the silent `onDeploy`; audio.handleBattleEvents sounds them. diff --git a/docs/history/0.2.6.md b/docs/history/0.2.6.md index 2c4d4070..12c977d9 100644 --- a/docs/history/0.2.6.md +++ b/docs/history/0.2.6.md @@ -137,8 +137,16 @@ has loaded) was fixed in 0.2.3 (§28.22: the late model continues Start at the e - `UnitView.land()` (render/units.js) plays the piece's Start clip again through `onDeploy` (its fade-in and the late-model continuation) and asks `audio.deployCue` for the landing sound: the piece's battle deploy sound (`deploySfxUrl`: its own - ON_UNIT_BORN file at its bank gain, else b_char_set / b_char_tokenset at 0.5) through the SFX channel's volume and mute - and the battle limiter (≤ 2 copies of one sound, a 160 ms cooldown per piece). One landing makes one sound: a piece + ON_UNIT_BORN file at its bank gain, else b_char_set / b_char_tokenset at 0.5) through the SFX channel's volume and mute, + in landing voices of their own (`AudioManager._playLanding`: `LAND_VOICES` 3 at once, a 160 ms cooldown per piece; the + battle limiter is not used). A landing over the cap takes the oldest landing's voice, whose copy fades out over 30 ms, + instead of being dropped: 192 of the 210 operators land with the same 1.41 s b_char_set and the round start lands one + every 50 ms, so the battle rule (≤ 2 copies of a file, the rest dropped) left all but the first two landings of a chain + silent (Grok's review of this port). With 3 voices every copy rings 150 ms before it is taken over — b_char_set's + attack, its loudest part, lies in its first 100 ms, and b_char_tokenset has 95 % of its energy in 150 ms — while the + summed level stays one copy above the battle's two. The field view asks for the two generic files when a match screen + comes up (`warmLandings`; kept until the first gesture or until the SFX are audible), so a round start's first landings + never wait for a download. One landing makes one sound: a piece whose Start still plays keeps it and sounds nothing more, whether the clip runs on its model, is owed to a model still loading, or was cued less than a clip length ago. That covers a purchase followed by a move, and the round start's replay reaching a piece just put down. Only an idle prep piece lands. A battle unit never does: the sim's `deploy` events keep @@ -167,16 +175,20 @@ has loaded) was fixed in 0.2.3 (§28.22: the late model continues Start at the e which fired on every confirm, a turn in place included. The deploy flash (`fx.deploy`) is unchanged. **[ASSUMED]** The purchase and board triggers (the PR author's account). The 50 ms gap (`DEPLOY_REPLAY_MS`, the PR's -value; no source gives the official one). Summon pieces taking part in the replay after the operators, as in the battle's +value; no source gives the official one). The 3 landing voices taken over oldest first (the official born bank allows any +number of copies; three keep every landing's attack within a bounded level). Summon pieces taking part in the replay after the operators, as in the battle's deployment. The clip length a landing counts as playing when the model's own is unknown (`START_CLIP_FALLBACK` 1 s; the operators' Start clips run 0.77–1.2 s). A page reloaded during 回合开始 replays once in the new page. **Tests.** `test/render/deploy-replay.test.js` (the clip, one sound, a second trigger, a late model, the refusals, a hidden -tab; the app.js wiring), `test/ui/deploy-replay.test.js` (`deployOrder`, `roundStartReplay`, the screen wiring; `deployCue`: -the files, the limiter keys, volume and mute), `test/render/playtest6-promote.test.js` (the elite's cue), and the browser -check `test/ui/deploy-replay.e2e.test.js` (`SP_E2E=1`, mock harness: SETTLE → 回合开始 replays the board in deployment order -about 50 ms apart with one sound each, once per round and again the next round; in the 休整期 a purchase lands, a move -within its Start sounds nothing more, a later move lands, a turn in place does not). Golden: unchanged (client only). +tab; the app.js wiring), `test/ui/deploy-replay.test.js` (`deployOrder`, `roundStartReplay`, the screen wiring; `deployCue` +on the real AudioManager, SfxLimiter and buffer cache with a fake Web Audio context: 8 b_char_set landings 50 ms apart start +8 times, at most 3 at once, the oldest taken over each time; a 36-piece board within the cap; the per-piece cooldown; a +landing taken over while it downloads never starts; the warm-up; volume and mute), `test/render/playtest6-promote.test.js` +(the elite's cue), and the browser check `test/ui/deploy-replay.e2e.test.js` (`SP_E2E=1`, mock harness: SETTLE → 回合开始 +replays the board in deployment order about 50 ms apart with one sound each that really starts in the page's audio, at +most 3 landing voices at once, once per round and again the next round; in the 休整期 a purchase lands, a move within its +Start sounds nothing more, a later move lands, a turn in place does not). Golden: unchanged (client only). ### 31.9 Optional ammo count (PR #552) diff --git a/public/js/audio.js b/public/js/audio.js index 5d26598a..902aa2be 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -64,7 +64,8 @@ // 香槟炸弹) plays its impact sound instead of a death sound. // - A prep piece that plays its Start clip again (render/units.js land(), PR #416: the round start's replay of the own // board, a purchase, a move onto or across the board) makes its battle deploy sound once (`deployCue`): the same file -// and gain, the battle limiter and the SFX channel's volume and mute. +// and gain through the SFX channel's volume and mute, in its own voices (LAND_VOICES) instead of the battle limiter — +// a full pool hands the oldest landing's voice to the newest, so every piece of the chain is heard. // - Buffers are fetched once and cached (LRU); failed fetch/decode ⇒ silent (logged once as a warning). // // `bgmKeyFor(route, pub)` picks the track for the current screen/phase (main.js calls `audio.install()`, @@ -104,6 +105,19 @@ const IMPACT_TYPES = new Set(['phys', 'arts', 'true']); const IMPACT_WINDOW_MS = 2500; /** Casts held for a unit that is not tracked yet (`pendingSkill`): a hold per unit of a field, never more than this. */ const PENDING_SKILL_MAX = 64; +/** + * Prep landing sounds (deployCue, PR #416) at once: their own pool, apart from the battle limiter. A landing over the cap + * takes the oldest landing's voice (it fades out in LAND_STEAL_S) instead of being dropped — 192 of the 210 operators + * land with the same 1.41 s b_char_set and the round start lands one every 50 ms, so the battle rule (≤ 2 copies of a + * file, the rest dropped) left all but the first two silent. 3: at the replay's 50 ms rhythm every copy still rings for + * 150 ms before it is taken over — b_char_set's attack, its loudest part, lies in its first 100 ms, and b_char_tokenset + * (0.5 s) has 95 % of its energy in 150 ms — while three copies sum at most one more than the battle's two; a fourth would + * add ~2.5 dB of level to a dense chain and no further attack. [ASSUMED] (no source gives the official cap: its born bank + * allows any number of copies). + */ +export const LAND_VOICES = 3; +/** Seconds a landing sound taken over by a newer one fades out (a hard stop clicks). */ +const LAND_STEAL_S = 0.03; /** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */ const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i; @@ -433,6 +447,18 @@ export class SfxLimiter { const n = this.activeByUrl.get(url) || 0; if (n <= 1) this.activeByUrl.delete(url); else this.activeByUrl.set(url, n - 1); } + + /** + * Whether `unitKey` is still inside its cooldown at `now` (ms) — read only; a caller that takes a voice by stealing + * (AudioManager._playLanding) asks first, so a refused key never costs another sound its voice. + * @param {number} now ms + * @param {string|number|null} unitKey + */ + cooling(now, unitKey) { + if (unitKey == null) return false; + const t = this.lastByUnit.get(unitKey); + return t != null && now - t < this.unitCooldownMs; + } } /** @@ -531,6 +557,12 @@ export class AudioManager { this.bufBytes = new Map(); // url → decoded PCM bytes (the byte budget of the LRU, see _buffer) this.warned = new Set(); this.limiter = new SfxLimiter(); + // prep landings (deployCue): their own voices — a full pool hands the oldest one's voice to the newest (_playLanding); + // no per-file gap (one landing per piece, the cap bounds the overlap), the per-piece cooldown of the battle sounds + this.landLimiter = new SfxLimiter({ maxVoices: LAND_VOICES, maxPerUrl: LAND_VOICES, urlGapMs: 0 }); + this.landVoices = []; // the landing sounds holding a voice, oldest first: { url, src, gain, over, release } + this.landStats = { started: 0, stolen: 0 }; // landing sounds started / taken over since the page opened (dev / tests) + this.landWarm = false; // the field view asked for the landing sounds ahead while they could not be fetched (warmLandings) this.voiceGate = new VoiceGate(); this.voiceNode = null; // { src, gain, url, token } of the line on air this.voiceToken = 0; @@ -609,6 +641,7 @@ export class AudioManager { } catch { /* ignore */ } this._applyVolumes(); this._primeOutput(); + if (this.landWarm) this.warmLandings(); if (this.ctx.state === 'running') this._dropUnlock(); else { const p = this.ctx.resume?.(); @@ -682,6 +715,7 @@ export class AudioManager { if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue(); // the music became audible again: download and start the track the screen wants now (none was fetched meanwhile) if (wasSilent && !this.bgmSilent && this.ctx && this.wantBgm) { const k = this.wantBgm; this.wantBgm = null; this.playBgm(k); } + if (this.landWarm && this.ctx && !this.volumes.muted && this.volumes.sfx > 0) this.warmLandings(); } /** Is the music silent — muted, or the BGM volume at 0? Then no BGM is downloaded (playBgm). */ @@ -1134,7 +1168,8 @@ export class AudioManager { * The landing sound of a prep piece that plays its Start clip again (render/units.js land(), PR #416: the round start's * replay of the own board, a purchase, a move onto or across the board): the sound its battle deployment makes * (deploySfxUrl — its own ON_UNIT_BORN sound with the bank's mix, else b_char_set / b_char_tokenset) at the same gains, - * through the SFX channel (volume, mute) and the battle limiter (≤ 2 copies of one sound, a cooldown per piece). + * through the SFX channel (volume, mute) and the landing voices (_playLanding: at most LAND_VOICES at once, the oldest + * taken over by the newest, a cooldown per piece) — every landing is heard, one after another. * @param {{ def?: string|null, defId?: string|null, kind?: string|null }} info the piece: its model id (the sfx.units * key), its chess / token id and its kind ('op' | 'token' — the official sound class, unitSoundClass) * @param {string|number} key the piece (its cooldown key) @@ -1148,11 +1183,85 @@ export class AudioManager { if (!url) return false; const own = url === m?.audio?.sfx?.units?.[u.def]?.born; const mix = own ? m.audio.sfx.units[u.def].mix?.born : null; - if (unitSoundPlays(mix, this.random())) this._playUnitUrl(url, `land:${key}`, own ? unitGain(0.8, mix) : 0.5); + if (unitSoundPlays(mix, this.random())) this._playLanding(url, `land:${key}`, own ? unitGain(0.8, mix) : 0.5); return true; } catch { return false; } } + /** + * Play a landing sound in the landing voices: never dropped for want of a voice — over LAND_VOICES (any file) the oldest + * landing, playing or still loading, gives its voice up (_stealLanding) and the new one starts; only the per-piece + * cooldown refuses one (the same piece twice within UNIT_COOLDOWN_MS: one landing never sounds twice), checked before + * anything is taken over. The battle limiter is not touched. Muted or at SFX volume 0, nothing happens. + * @param {string} url + * @param {string} unitKey the piece's cooldown key (`land:`) + * @param {number} volume gain of this sound under the SFX channel + */ + _playLanding(url, unitKey, volume) { + if (!this.ctx || typeof url !== 'string' || !url || this.volumes.muted || this.volumes.sfx <= 0) return; + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const pool = this.landLimiter; + if (pool.cooling(now, unitKey)) return; + while (!pool.tryAcquire(now, unitKey, url)) { + if (!this.landVoices.length) return; // (a full pool always holds voices; never loop on a refusal it cannot lift) + this._stealLanding(this.landVoices[0]); + } + const v = { url, src: null, gain: null, over: false, release: null }; + v.release = () => { + if (v.over) return; + v.over = true; + pool.release(url); + const i = this.landVoices.indexOf(v); + if (i >= 0) this.landVoices.splice(i, 1); + }; + this.landVoices.push(v); + this._buffer(url).then((buf) => { + if (v.over || !buf || !this.ctx) { v.release(); return; } // taken over while it loaded: it never starts + try { + const s = this.ctx.createBufferSource(); + s.buffer = buf; + const g = this.ctx.createGain(); + g.gain.value = Math.max(0, Math.min(1.5, volume)); + s.connect(g); g.connect(this.sfxGain); + v.src = s; v.gain = g; + let done = false; + const end = () => { if (done) return; done = true; v.release(); try { g.disconnect(); } catch { /* ignore */ } }; + s.onended = end; + setTimeout(end, (buf.duration || 0) * 1000 + 250); // safety if onended never fires + s.start(); + this.landStats.started += 1; + } catch { v.release(); } + }, v.release); + } + + /** A landing sound gives its voice to a newer one: the voice is free at once, the sound fades out over LAND_STEAL_S. */ + _stealLanding(v) { + v.release(); + this.landStats.stolen += 1; + if (!v.src || !v.gain || !this.ctx) return; + try { + const t = this.ctx.currentTime; + const g = v.gain.gain; + g.cancelScheduledValues(t); + g.setValueAtTime(g.value, t); + g.linearRampToValueAtTime(0, t + LAND_STEAL_S); + v.src.stop(t + LAND_STEAL_S + 0.005); + } catch { /* ignore */ } + } + + /** + * Fetch the generic landing sounds ahead (b_char_set / b_char_tokenset, ~30 KB together), so the round start's first + * landings never wait for a download (a wait would bunch the chain's first sounds together). The field view asks when a + * match screen comes up (render/app.js createFieldView); before the first gesture, or while the SFX are silent, the wish + * is kept (`landWarm`) and _unlock / setVolumes fetch then. An own deploy sound loads with the first landing that uses it. + */ + warmLandings() { + if (!this.ctx || this.volumes.muted || this.volumes.sfx <= 0) { this.landWarm = true; return; } + this.landWarm = false; + const b = this.getManifest()?.audio?.sfx?.battle; + for (const url of [b?.deploy, b?.tokenDeploy]) if (typeof url === 'string') this._buffer(url); + } + /** * React to `b.ev` tuples (DESIGN §8.2). * @param {any[]} ev diff --git a/public/js/render/app.js b/public/js/render/app.js index 4587e339..d405b45f 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -336,6 +336,9 @@ export async function createFieldView(host, options = {}) { loadLevel: () => loadLevel, surfaceLayer: (row) => tiles.surfaceLayer(row), }; + // the prep landings' generic sounds ahead of the first round start (audio.js warmLandings: a download would bunch the + // replay's first landings together); a wish kept until the first gesture / audible SFX + try { ctx.audio?.warmLandings?.(); } catch { /* the sound is optional */ } const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim }); setupCleanup.push(() => tiles.destroy()); const fx = new FxSystem({ diff --git a/public/js/render/units.js b/public/js/render/units.js index 697ca022..b398d5da 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -41,8 +41,9 @@ // One landing, one sound: a piece whose Start still plays (on its model, owed to a model still loading, or cued less than // a clip ago) keeps it and sounds nothing more, and only an idle prep piece is ever pulled into it — never a battle unit // or one in an attack, skill, death or form clip. The sound is `ctx.audio.deployCue` (public/js/audio.js: the unit's own -// battle deploy sound, through the SFX volume, mute and the battle limiter); `onDeploy` itself — the sim's 'deploy' -// events — stays silent: audio.handleBattleEvents sounds those. +// battle deploy sound through the SFX volume and mute, in landing voices of its own that hand the oldest one's voice to +// the newest instead of dropping it); `onDeploy` itself — the sim's 'deploy' events — stays silent: +// audio.handleBattleEvents sounds those. // Enemy modes (the `form` of a sim fx — shared/protocol.js fxForm — → `setForm(form, fx)`): 掠海漂移体 dropping to 爬行模式 // (user playtest #5 item 1) plays its skeleton's 'Change' clip once, then the crawl set (*_02); 暴鸰 flies on without its // bomb (*_2) after the drop (feedback D4); 转译基底's forms, the 逐火 embers, 再生's puppet, the leaders' 重生 and 守墓石像 diff --git a/test/ui/deploy-replay.e2e.test.js b/test/ui/deploy-replay.e2e.test.js index 82322baa..c6a73370 100644 --- a/test/ui/deploy-replay.e2e.test.js +++ b/test/ui/deploy-replay.e2e.test.js @@ -4,8 +4,9 @@ // // From a settled battle to 回合开始 of the next round, the real game screen (screens/game.js) and Pixi field view // (render/app.js) replay the own board's pieces one after another in deployment order — operators by column from the -// left, top to bottom within a column (sim Battle.start) — each with one landing sound (audio.deployCue, spied); the -// same round never again (a re-pushed state, the 休整期 that follows), the next round once more. In the 休整期 a purchase +// left, top to bottom within a column (sim Battle.start) — each with one landing sound (audio.deployCue, spied) that +// really starts in the page's unlocked audio (audio.landStats), never more than LAND_VOICES at once; the same round never +// again (a re-pushed state, the 休整期 that follows), the next round once more. In the 休整期 a purchase // lands the bought operator; a move onto the board while that Start still plays sounds nothing more; a later move to // another tile lands again; turning in place does not. No console / page errors. @@ -47,13 +48,23 @@ describe('the round start replays the own board\'s Start clips in deployment ord page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); await page.goto(`${base}/dev/game-mock.html?shot=1&render=engine&phase=SETTLE`, { waitUntil: 'networkidle0', timeout: 60000 }); await page.waitForFunction(() => globalThis.__SP_VIEW__?.kind === 'engine' && globalThis.__SP_VIEW__.raw.mode === 'battle', { timeout: 20000 }); - // spy on the landing sound (the view's ctx.audio is this very manager) and stop the mock battle's stream + // spy on the landing sound (the view's ctx.audio is this very manager), sample how many landing voices ring, and + // unlock the page's audio with a key press (a user gesture; Shift is no shortcut) so the landings really start await page.evaluate(async () => { const { audio } = await import('/js/audio.js'); + globalThis.__audio = audio; const cues = globalThis.__cues = []; const real = audio.deployCue.bind(audio); audio.deployCue = (info, key) => { cues.push({ key, at: performance.now() }); return real(info, key); }; + globalThis.__mostVoices = 0; + setInterval(() => { globalThis.__mostVoices = Math.max(globalThis.__mostVoices, audio.landVoices.length); }, 4); }); + await page.keyboard.press('Shift'); + await page.waitForFunction(() => globalThis.__audio.unlocked, { timeout: 5000 }); + // the field view asked for the generic landing sounds ahead (audio.warmLandings); the gesture fetched them + await page.waitForFunction(() => [...globalThis.__audio.buffers.keys()].some((u) => /b_char_set/.test(u)), { timeout: 5000 }); + await sleep(300); + const stats0 = await page.evaluate(() => ({ ...globalThis.__audio.landStats })); const landings = (why) => page.evaluate((w) => globalThis.__SP_VIEW__.raw.debug.landings.filter((l) => !w || l.why === w).map((l) => ({ ...l })), why); const toPhase = (phase, round) => page.evaluate((ph, r) => globalThis.__MOCK__.mutate((S) => { if (S.battle?.timer) clearInterval(S.battle.timer); @@ -82,6 +93,16 @@ describe('the round start replays the own board\'s Start clips in deployment ord assert.equal(lastPlaying, true, 'the piece is in its Start clip when it lands'); const cues7 = await page.evaluate(() => globalThis.__cues.map((c) => c.key)); assert.deepEqual(cues7, order.map((uid) => `p:${uid}`), 'one landing sound per piece, in the same order'); + // every landing's sound really started — most of them share b_char_set — and never more than LAND_VOICES at once + await sleep(200); + const voices = await page.evaluate(async () => { + const { LAND_VOICES } = await import('/js/audio.js'); + const st = globalThis.__audio.landStats; + return { started: st.started, stolen: st.stolen, most: globalThis.__mostVoices, cap: LAND_VOICES }; + }); + assert.equal(voices.started - stats0.started, order.length, `all ${order.length} landing sounds started (${JSON.stringify(voices)})`); + assert.ok(voices.most <= voices.cap, `at most ${voices.cap} landing voices at once (${voices.most})`); + assert.equal(voices.stolen - stats0.stolen, Math.max(0, order.length - voices.cap), 'the oldest gave its voice to each landing over the cap'); // ---- the same round again: a re-pushed state (a reconnect's resync, a re-render) and the 休整期 that follows replay nothing await page.evaluate(() => { globalThis.__MOCK__.pushPublic(); globalThis.__MOCK__.mutate(() => {}); }); diff --git a/test/ui/deploy-replay.test.js b/test/ui/deploy-replay.test.js index bab02d54..acbe9dbb 100644 --- a/test/ui/deploy-replay.test.js +++ b/test/ui/deploy-replay.test.js @@ -1,8 +1,10 @@ // test/ui/deploy-replay.test.js — PR #416 by @Cromemadnd, ported in 0.2.6 (DESIGN §31.8): the round start replays the own // board's Start clips in deployment order (ui/gameLogic deployOrder / roundStartReplay — the maintainer's confirmation from // official play, 2026-10-13), and a landing sounds like the piece's battle deployment through the SFX channel -// (public/js/audio.js deployCue). The view side is test/render/deploy-replay.test.js, the browser check -// test/ui/deploy-replay.e2e.test.js. +// (public/js/audio.js deployCue) in its own voices: at most LAND_VOICES at once, a new landing taking the oldest one's +// voice instead of being dropped (Grok's review of r37: the battle limiter left 6 of 8 b_char_set landings silent) — +// checked with the real AudioManager, SfxLimiter and buffer cache on a fake Web Audio context. The view side is +// test/render/deploy-replay.test.js, the browser check test/ui/deploy-replay.e2e.test.js. // Run: node --test test/ui/deploy-replay.test.js import { test, describe } from 'node:test'; @@ -11,7 +13,8 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { deployOrder, roundStartReplay } from '../../public/js/ui/gameLogic.js'; -import { AudioManager, deploySfxUrl } from '../../public/js/audio.js'; +import { AudioManager, SfxLimiter, LAND_VOICES, deploySfxUrl } from '../../public/js/audio.js'; +import { mediaUrl } from '../../public/js/media.js'; import { PHASE } from '../../shared/constants.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -77,54 +80,217 @@ describe('roundStartReplay — once per round, in 回合开始, on the own board }); }); -describe('AudioManager.deployCue — the landing sound', () => { - const OWN = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && typeof manifest.audio.sfx.units[k].born === 'string'); - const PLAIN = Object.keys(manifest.chars).find((k) => !manifest.audio.sfx.units[k]?.born); - const rig = (o = {}) => { - const a = new AudioManager({ win: null, getManifest: () => manifest, random: () => 0, ...o }); - a.ctx = {}; // unlocked - const played = []; - a._play = (url, opt) => { played.push({ url, ...opt }); }; - return { a, played }; +// ---- the landing voices: the real AudioManager (SfxLimiter, _buffer, the nodes) on a fake Web Audio context -------------- + +const SET = manifest.audio.sfx.battle.deploy; // b_char_set: 192 of the 210 operators land with it +const TOKEN_SET = manifest.audio.sfx.battle.tokenDeploy; +const OWN = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && typeof manifest.audio.sfx.units[k].born === 'string'); +const PLAIN = Object.keys(manifest.chars).filter((k) => !manifest.audio.sfx.units[k]?.born); +/** Seconds of the files as served (b_char_set 1.41 s, b_char_tokenset 0.50 s — afinfo); 1 s for any other. */ +const durationOf = (url) => (/b_char_set/.test(url) ? 1.41 : /b_char_tokenset/.test(url) ? 0.5 : 1); + +/** + * A fake Web Audio window: every buffer source records when it started and stopped on a clock the test moves (`at(ms)`: + * performance.now() and ctx.currentTime follow it, a source that reaches its end fires `onended`); fetch serves every + * sound with its real length. `hold(url)` keeps one download open until the returned function is called. + */ +function landingRig({ settings = { sfx: 0.8, muted: false } } = {}) { + const clock = { ms: 0 }; + const sources = []; + const fetched = []; + const held = new Map(); + class Param { + constructor() { this.value = 1; this.ramps = []; } + setValueAtTime(v) { this.value = v; } linearRampToValueAtTime(v, t) { this.ramps.push([v, t]); } setTargetAtTime(v) { this.value = v; } + cancelScheduledValues() {} + } + class Node { connect() {} disconnect() {} } + class Gain extends Node { constructor() { super(); this.gain = new Param(); } } + class Src extends Node { + constructor() { super(); this.playbackRate = new Param(); this.buffer = null; this.startedAt = null; this.stoppedAt = null; this.stopWhen = null; this.ended = false; sources.push(this); } + start() { this.startedAt = clock.ms; } + stop(when) { this.stoppedAt = clock.ms; this.stopWhen = when; } + get playing() { return this.startedAt != null && this.stoppedAt == null && !this.ended; } + } + class Ctx { + constructor() { this.state = 'running'; this.destination = new Node(); this.sampleRate = 44100; } + get currentTime() { return clock.ms / 1000; } + createGain() { return new Gain(); } + createBufferSource() { return new Src(); } + createBuffer() { return { duration: 0 }; } + decodeAudioData(ab, ok) { ok({ duration: ab.duration, length: 1, numberOfChannels: 1, url: ab.url }); } + resume() { return Promise.resolve(); } + suspend() { return Promise.resolve(); } + } + const listeners = new Map(); + const win = { AudioContext: Ctx, document: { hidden: false, addEventListener() {} }, addEventListener(t, fn) { listeners.set(t, fn); }, removeEventListener(t) { listeners.delete(t); } }; + const realFetch = globalThis.fetch; + const perf = globalThis.performance; + const realNow = perf.now; + globalThis.fetch = async (u) => { + fetched.push(u); + const wait = held.get(u); + if (wait) await wait; + return { ok: true, headers: { get: () => 'audio/mpeg' }, arrayBuffer: async () => ({ duration: durationOf(u), url: u }) }; }; + perf.now = () => clock.ms; + const a = new AudioManager({ win, getManifest: () => manifest, random: () => 0 }); + a.setVolumes(settings); + a.install(); + a.warmLandings(); // the field view's wish when the match screen comes up, before any gesture (render/app.js) + const flush = async () => { for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); }; + return { + a, sources, fetched, + unlock: async () => { listeners.get('pointerdown')?.(); await flush(); }, + /** Move the clock to `ms`: a playing source whose file ran out ends (onended), as Web Audio does. */ + at: async (ms) => { + clock.ms = ms; + for (const s of sources) if (s.playing && s.buffer && ms >= s.startedAt + s.buffer.duration * 1000) { s.ended = true; s.onended?.(); } + await flush(); + }, + flush, + hold: (url) => { let open; held.set(mediaUrl(url), new Promise((r) => { open = r; })); return () => open(); }, + playing: () => sources.filter((s) => s.playing && s.buffer?.duration > 0), + started: () => sources.filter((s) => s.startedAt != null && s.buffer?.duration > 0), + restore: () => { globalThis.fetch = realFetch; perf.now = realNow; }, + }; +} - test('the sound of the piece\'s battle deployment: its own ON_UNIT_BORN file, else b_char_set / b_char_tokenset, limited per piece', () => { - assert.ok(OWN && PLAIN, 'the manifest has operators with and without an own deploy sound'); - const { a, played } = rig(); - assert.equal(a.deployCue({ def: OWN, defId: 'chess_x', kind: 'op' }, 'p:3'), true); - assert.equal(a.deployCue({ def: PLAIN, defId: 'chess_y', kind: 'op' }, 'p:4'), true); - assert.equal(a.deployCue({ def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token' }, 'p:5'), true); - assert.deepEqual(played.map((p) => p.url), [ - manifest.audio.sfx.units[OWN].born, manifest.audio.sfx.battle.deploy, - deploySfxUrl(manifest, { def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token', side: 'ally' }), - ]); - assert.equal(played[2].url, manifest.audio.sfx.units.token_10000_silent_healrb?.born ?? manifest.audio.sfx.battle.tokenDeploy, 'a summon: the token sound'); - assert.deepEqual(played.map((p) => [p.limited, p.unitKey]), [[true, 'land:p:3'], [true, 'land:p:4'], [true, 'land:p:5']], 'the battle limiter: ≤ 2 copies of one sound, a cooldown per piece'); - assert.equal(played[1].volume, 0.5, 'the generic file at the battle deploy\'s gain'); - assert.ok(played[0].volume > 0 && played[0].volume <= 0.8, 'an own file at its bank gain'); +describe('AudioManager.deployCue — the landing sound in its own voices, taken over instead of dropped', () => { + test(`8 operators on b_char_set, 50 ms apart: 8 starts, never more than ${LAND_VOICES} at once, the oldest taken over each time`, async () => { + assert.equal(LAND_VOICES, 3); + // the battle rule this replaces: ≤ 2 copies of a file at once and the rest dropped — 2 of the 8 would have sounded + const battle = new SfxLimiter(); + assert.equal([...Array(8).keys()].filter((i) => battle.tryAcquire(i * 50, `land:p:${i}`, SET)).length, 2); + const r = landingRig(); + try { + await r.unlock(); + const most = []; + for (let i = 0; i < 8; i++) { + await r.at(i * 50); + assert.equal(r.a.deployCue({ def: PLAIN[i], defId: `chess_${i}`, kind: 'op' }, `p:${i}`), true); + await r.flush(); + most.push(r.playing().length); + const started = r.started(); + assert.equal(started.length, i + 1, `landing #${i} started at once`); + assert.equal(started[i].startedAt, i * 50); + assert.equal(started[i].buffer.url, mediaUrl(SET), 'b_char_set'); + if (i >= LAND_VOICES) { + const old = started[i - LAND_VOICES]; + assert.equal(old.stoppedAt, i * 50, `#${i} took the voice of #${i - LAND_VOICES}, the oldest still ringing`); + assert.ok(Math.abs(old.stopWhen - (i * 50) / 1000 - 0.035) < 1e-9, 'which fades out over 30 ms instead of clicking'); + assert.deepEqual(r.a.landVoices.length, LAND_VOICES); + } + } + assert.ok(Math.max(...most) <= LAND_VOICES, `at most ${LAND_VOICES} at once (${most.join(',')})`); + assert.deepEqual(most, [1, 2, 3, 3, 3, 3, 3, 3]); + assert.deepEqual(r.a.landStats, { started: 8, stolen: 5 }); + // the newest three ring out; the other battle sounds' limiter was never touched + await r.at(350 + 1500); + assert.equal(r.playing().length, 0); + assert.equal(r.a.landVoices.length, 0, 'every voice returned'); + assert.equal(r.a.landLimiter.active, 0); + assert.equal(r.a.limiter.active, 0); + assert.equal(r.a.limiter.lastByUrl.size, 0, 'the battle limiter knows nothing of landings'); + } finally { r.restore(); } }); - test('a piece without a sound, an enemy model, no manifest: nothing, never a throw', () => { - const { a, played } = rig({ getManifest: () => ({ audio: { sfx: { battle: {}, units: {} } } }) }); - assert.equal(a.deployCue({ def: 'char_x', kind: 'op' }, 1), false); - const none = rig({ getManifest: () => null }); - assert.equal(none.a.deployCue({ def: 'char_x', kind: 'op' }, 1), false); - assert.equal(none.a.deployCue(null, 1), false); - assert.equal(played.length + none.played.length, 0); + test('a full board of 36 pieces — b_char_set, own deploy sounds and summons — lands every one within the cap', async () => { + const r = landingRig(); + try { + await r.unlock(); + let most = 0; + for (let i = 0; i < 36; i++) { + await r.at(i * 50); + const info = i % 6 === 5 ? { def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token' } + : i % 9 === 4 ? { def: OWN, defId: `chess_${i}`, kind: 'op' } : { def: PLAIN[i], defId: `chess_${i}`, kind: 'op' }; + r.a.deployCue(info, `p:${i}`); + await r.flush(); + most = Math.max(most, r.playing().length, r.a.landVoices.length); + } + assert.equal(r.started().length, 36, 'every landing started'); + assert.ok(most <= LAND_VOICES, `never more than ${LAND_VOICES} at once (${most})`); + assert.equal(r.a.landStats.started, 36); + const files = new Set(r.started().map((s) => s.buffer.url)); + assert.ok(files.has(mediaUrl(SET)) && files.has(mediaUrl(manifest.audio.sfx.units[OWN].born)) && files.size >= 3, 'the generic, an own and a summon sound'); + await r.at(36 * 50 + 1500); + assert.equal(r.a.landVoices.length, 0); + assert.equal(r.a.landLimiter.active, 0); + } finally { r.restore(); } }); - test('the SFX volume and mute apply: muted or at 0 nothing is even fetched', () => { - const a = new AudioManager({ win: null, getManifest: () => manifest, random: () => 0 }); - a.ctx = {}; - const fetched = []; - a._buffer = (url) => { fetched.push(url); return Promise.resolve(null); }; - a.setVolumes({ sfx: 0.8, muted: true }); - a.deployCue({ def: PLAIN, kind: 'op' }, 'p:1'); - a.setVolumes({ sfx: 0, muted: false }); - a.deployCue({ def: PLAIN, kind: 'op' }, 'p:2'); - assert.deepEqual(fetched, []); - a.setVolumes({ sfx: 0.8, muted: false }); - a.deployCue({ def: PLAIN, kind: 'op' }, 'p:3'); - assert.deepEqual(fetched, [manifest.audio.sfx.battle.deploy]); + test('one landing never sounds twice: the same piece within the cooldown takes no voice and stops nothing', async () => { + const r = landingRig(); + try { + await r.unlock(); + for (let i = 0; i < LAND_VOICES; i++) { await r.at(i * 50); r.a.deployCue({ def: PLAIN[i], kind: 'op' }, `p:${i}`); await r.flush(); } + await r.at(LAND_VOICES * 50); + r.a.deployCue({ def: PLAIN[LAND_VOICES - 1], kind: 'op' }, `p:${LAND_VOICES - 1}`); // 50 ms after its own landing + await r.flush(); + assert.equal(r.started().length, LAND_VOICES, 'no second start'); + assert.equal(r.started().filter((s) => s.stoppedAt != null).length, 0, 'and nothing taken over for it'); + await r.at(LAND_VOICES * 50 + 200); + r.a.deployCue({ def: PLAIN[0], kind: 'op' }, 'p:0'); // a later landing of the same piece + await r.flush(); + assert.equal(r.started().length, LAND_VOICES + 1); + } finally { r.restore(); } + }); + + test('the sound of the piece\'s battle deployment at its gain: own ON_UNIT_BORN file, else b_char_set / b_char_tokenset', async () => { + const r = landingRig(); + try { + await r.unlock(); + assert.equal(r.a.deployCue({ def: OWN, defId: 'chess_x', kind: 'op' }, 'p:3'), true); + assert.equal(r.a.deployCue({ def: PLAIN[0], defId: 'chess_y', kind: 'op' }, 'p:4'), true); + assert.equal(r.a.deployCue({ def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token' }, 'p:5'), true); + await r.flush(); + const token = deploySfxUrl(manifest, { def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token', side: 'ally' }); + assert.deepEqual(r.a.landVoices.map((v) => v.url), [manifest.audio.sfx.units[OWN].born, SET, token]); + assert.equal(token, manifest.audio.sfx.units.token_10000_silent_healrb?.born ?? TOKEN_SET, 'a summon: the token sound'); + assert.equal(r.a.landVoices[1].gain.gain.value, 0.5, 'the generic file at the battle deploy\'s gain'); + assert.ok(r.a.landVoices[0].gain.gain.value > 0 && r.a.landVoices[0].gain.gain.value <= 0.8, 'an own file at its bank gain'); + } finally { r.restore(); } + }); + + test('a landing taken over while its file still downloads never starts; the generic files are fetched ahead', async () => { + const r = landingRig(); + try { + assert.equal(r.fetched.length, 0, 'the field view\'s wish waits for the first gesture'); + await r.unlock(); + assert.deepEqual(r.fetched, [mediaUrl(SET), mediaUrl(TOKEN_SET)], 'fetched at unlock: a round start never waits for them'); + const born = manifest.audio.sfx.units[OWN].born; + const open = r.hold(born); + r.a.deployCue({ def: OWN, kind: 'op' }, 'p:1'); // still downloading … + for (let i = 0; i < LAND_VOICES; i++) { await r.at(50 + i * 50); r.a.deployCue({ def: PLAIN[i], kind: 'op' }, `p:${10 + i}`); await r.flush(); } + assert.equal(r.a.landStats.stolen, 1, '… when the pool filled up it gave its voice'); + open(); + await r.flush(); + assert.equal(r.started().filter((s) => s.buffer.url === mediaUrl(born)).length, 0, 'and it never starts'); + assert.equal(r.started().length, LAND_VOICES); + } finally { r.restore(); } + }); + + test('the SFX volume and mute apply: muted or at 0 nothing is fetched, started or taken over; a manifest without the sound: nothing', async () => { + const r = landingRig({ settings: { sfx: 0.8, muted: true } }); + try { + await r.unlock(); + assert.equal(r.fetched.length, 0, 'no warm-up while muted'); + r.a.deployCue({ def: PLAIN[0], kind: 'op' }, 'p:1'); + r.a.setVolumes({ sfx: 0, muted: false }); + r.a.deployCue({ def: PLAIN[1], kind: 'op' }, 'p:2'); + await r.flush(); + assert.deepEqual([r.fetched.length, r.started().length, r.a.landVoices.length], [0, 0, 0]); + r.a.setVolumes({ sfx: 0.8, muted: false }); + await r.flush(); + assert.deepEqual(r.fetched, [mediaUrl(SET), mediaUrl(TOKEN_SET)], 'audible again: the kept wish fetches them now'); + r.a.deployCue({ def: PLAIN[2], kind: 'op' }, 'p:3'); + await r.flush(); + assert.equal(r.started().length, 1); + } finally { r.restore(); } + const bare = new AudioManager({ win: null, getManifest: () => ({ audio: { sfx: { battle: {}, units: {} } } }) }); + assert.equal(bare.deployCue({ def: 'char_x', kind: 'op' }, 1), false); + const none = new AudioManager({ win: null, getManifest: () => null }); + assert.equal(none.deployCue({ def: 'char_x', kind: 'op' }, 1), false); + assert.equal(none.deployCue(null, 1), false); }); });