- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
107 lines
5.1 KiB
JavaScript
107 lines
5.1 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { AudioManager } from '../../public/js/audio.js';
|
|
import { audioEarly, keepEarly } from '../../public/js/screens/game/early.js';
|
|
|
|
function harness() {
|
|
const sources = [];
|
|
const a = new AudioManager({ getManifest: () => ({ audio: { sfx: { ui: { bondUp: '/bond.mp3' } } } }) });
|
|
a.ctx = {
|
|
createGain: () => ({ gain: {}, connect() {}, disconnect() {} }),
|
|
createBufferSource: () => {
|
|
const s = { started: false, stopped: false, connect() {}, disconnect() {},
|
|
start() { this.started = true; }, stop() { this.stopped = true; this.onended?.(); } };
|
|
sources.push(s); return s;
|
|
},
|
|
};
|
|
a.sfxGain = {};
|
|
a._buffer = async () => ({ duration: 1 });
|
|
return { a, sources };
|
|
}
|
|
const flush = () => new Promise(resolve => queueMicrotask(resolve));
|
|
|
|
test('a real layer gain plays once regardless of amount; invalid gains are silent', async () => {
|
|
const { a, sources } = harness();
|
|
for (const n of [0, -1, NaN, Infinity, '1']) a.layerGain('sargonShip', n);
|
|
a.layerGain(null, 1); a.layerGain('', 1);
|
|
await flush(); assert.equal(sources.length, 0);
|
|
a.layerGain('sargonShip', 5); await flush();
|
|
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
|
|
a.stopBondCue();
|
|
});
|
|
|
|
test('each new layer cue immediately replaces the playing cue, with no cooldown', async () => {
|
|
const { a, sources } = harness();
|
|
a.layerGain('sargonShip', 1); await flush();
|
|
a.handleBattleEvents([['layer', 'p_1', 'sargonShip', 2]]); await flush();
|
|
assert.equal(sources.length, 2); assert.equal(sources[0].stopped, true);
|
|
assert.equal(sources[1].started, true); assert.equal(sources[1].stopped, false);
|
|
sources[0].onended(); assert.equal(a.bondCueNode.src, sources[1], 'late cleanup must not clear the replacement');
|
|
a.stopBondCue(); assert.equal(sources[1].stopped, true);
|
|
});
|
|
|
|
test('only the newest request can start after asynchronous decoding', async () => {
|
|
const { a, sources } = harness(), resolves = [];
|
|
a._buffer = () => new Promise(resolve => resolves.push(resolve));
|
|
a.layerGain('kjeragShip', 1); a.layerGain('defend', 2);
|
|
resolves[1]({ duration: 1 }); await flush();
|
|
resolves[0]({ duration: 1 }); await flush();
|
|
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
|
|
a.stopBondCue();
|
|
});
|
|
|
|
test('field entry, stopping and muting cancel a cue that is still decoding', async () => {
|
|
for (const cancel of [a => a.setFieldUnits([]), a => a.stopBondCue(), a => a.setVolumes({ muted: true }), a => a.setVolumes({ sfx: 0 })]) {
|
|
const { a, sources } = harness(); let resolve;
|
|
a._buffer = () => new Promise(r => { resolve = r; });
|
|
a.layerGain('sargonShip', 1); cancel(a); resolve({ duration: 1 }); await flush();
|
|
assert.equal(sources.length, 0);
|
|
}
|
|
});
|
|
|
|
test('locked/muted audio and missing assets never queue old layer sounds', async () => {
|
|
const { a, sources } = harness(), ctx = a.ctx;
|
|
a.ctx = null; a.layerGain('sargonShip', 1); a.ctx = ctx;
|
|
a.setVolumes({ muted: true }); a.layerGain('sargonShip', 1); a.setVolumes({ muted: false });
|
|
a._buffer = async () => null; a.layerGain('sargonShip', 1); await flush();
|
|
a._buffer = async () => { throw new Error('missing'); }; a.layerGain('sargonShip', 1); await flush();
|
|
assert.equal(sources.length, 0); assert.equal(a.bondCueNode, null);
|
|
});
|
|
|
|
test('returning to prep cancels old battle audio but preserves a new prep gain received before the phase view', async () => {
|
|
const { a, sources } = harness(); let resolve;
|
|
a._buffer = () => new Promise(r => { resolve = r; });
|
|
a.handleBattleEvents([['layer', 'p_0', 'sargonShip', 1]]);
|
|
a.stopBondCue('battle'); resolve({ duration: 1 }); await flush();
|
|
assert.equal(sources.length, 0);
|
|
a.layerGain('sargonShip', 1);
|
|
a.stopBondCue('battle'); resolve({ duration: 1 }); await flush();
|
|
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
|
|
a.stopBondCue();
|
|
});
|
|
|
|
test('battle layer tuples use the same exclusive channel; late field replay has no layer cue', async () => {
|
|
const { a, sources } = harness();
|
|
const events = [['layer', 'p_0', 'sargonShip', 1], ['layer', 'p_1', 'kjeragShip', 2]];
|
|
a.handleBattleEvents(events); await flush(); assert.equal(sources.length, 1);
|
|
a.stopBondCue();
|
|
assert.equal(events.some(keepEarly), false);
|
|
a.handleBattleEvents(audioEarly(events)); await flush(); assert.equal(sources.length, 1);
|
|
});
|
|
|
|
test('a prep update that adds layers and activates a bond plays one cue, not a cut-off restart', async () => {
|
|
const { a, sources } = harness();
|
|
a.layerGain('sargonShip', 2);
|
|
await flush();
|
|
// screens/game.js: the active-bond count rose in the same update — it keeps the layer cue
|
|
if (!a.bondCueRecent('prep')) a.sfx('bondUp', { volume: 0.7, scope: 'prep' });
|
|
await flush();
|
|
assert.equal(sources.length, 1, 'one source');
|
|
assert.equal(sources[0].stopped, false, 'the layer cue is not cut off');
|
|
// an activation with no layer cue of its own still sounds
|
|
const b = harness();
|
|
if (!b.a.bondCueRecent('prep')) b.a.sfx('bondUp', { volume: 0.7, scope: 'prep' });
|
|
await flush();
|
|
assert.equal(b.sources.length, 1);
|
|
});
|