0.2.6-dev: merge claude/r37-ui — PR #416 ported (each round start replays the own board's Start clips and landing sounds in deployment order, as the maintainer confirmed from official play; landings on buy / a new board tile [ASSUMED]; one sound per landing, a landing pool that hands the oldest voice to the newest) and PR #552 ported as the optional 「弹药数量」 setting, default off; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-13 06:14:01 +08:00
commit 349debb320
26 files changed
+1351 -39

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
// test/render/deploy-replay.test.js — PR #416 by @Cromemadnd, ported in 0.2.6 (DESIGN §31.8): a prep piece that lands —
// the round start's replay of the own board, a purchase, a move onto or across the board — plays its Start clip again
// with its landing sound (render/units.js UnitView.land → ctx.audio.deployCue), once per landing. Headless fake PIXI
// (test/render/fakepixi.js). The schedule itself (render/app.js replayDeploys, screens/game.js) runs in the browser check
// test/ui/deploy-replay.e2e.test.js; the order and the due rule are test/ui/deploy-replay.test.js.
// Run: node --test test/render/deploy-replay.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
let fake, UnitView, START_CLIP_FALLBACK;
before(async () => {
fake = installFakePixi();
({ UnitView, START_CLIP_FALLBACK } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const cam = presetCamera('prep', { width: 1280, height: 720 });
const ANIMS = ['Idle', 'Start', 'Die', 'Attack'];
/**
* A prep (or battle) view of an operator whose model loads when `ready()` is called (`instant`: at once). `start` = the
* Start clip's length in the manifest entry (null: the model has no Start clip). Every deployCue is recorded.
*/
function rig({ prep = true, side = 'ally', kind = 'op', start = 1, instant = true, info = {} } = {}) {
let resolve = null;
const anims = { idle: 'Idle', deploy: start == null ? 'Idle' : 'Start', die: 'Die', attack: { begin: null, loop: 'Attack', end: null } };
const animations = { Idle: 1, Die: 1, Attack: 1, ...(start == null ? {} : { Start: start }) };
const entry = { skel: '/op.skel', atlas: '/op.atlas', textures: ['/op.png'], anims, animations };
const data = { animations: ANIMS.filter((n) => start != null || n !== 'Start').map((name) => ({ name })) };
const assets = {
spineEntry: () => entry, hasBack: () => false,
spine: { acquire: () => (instant ? Promise.resolve(data) : new Promise((r) => { resolve = () => r(data); })), release() {} },
};
const cues = [];
const audio = { deployCue: (u, key) => { cues.push({ ...u, key }); return true; } };
const ctx = fakeViewCtx(fake.P, { assets, cam: () => cam, audio });
const v = new UnitView(ctx, { id: 'p:7', uid: 7, side, kind, defId: 'chess_char_x', spine: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1, dir: 'RIGHT', ...info }, { prep });
const tick = async () => { await new Promise((r) => setImmediate(r)); await new Promise((r) => setImmediate(r)); };
return { v, cues, advance: (dt) => v.update(dt, cam, dt), ready: async () => { resolve?.(); await tick(); }, tick };
}
/** Run `fn` with performance.now() reading `t` ms (the landing window is real time). */
function at(t, fn) {
const perf = globalThis.performance;
const now = perf.now;
perf.now = () => t;
try { return fn(); } finally { perf.now = now; }
}
describe('UnitView.land — the Start clip again and the landing sound, once (PR #416)', () => {
test('an idle prep piece lands: Start plays and its landing sound is asked for once, with the piece as the key', async () => {
const h = rig();
await h.tick();
assert.ok(h.v.actor, 'the model is up');
h.advance(0.5);
assert.equal(h.v.actor.mode, 'base');
assert.equal(h.v.land(), true);
assert.equal(h.v.actor.mode, 'deploy', 'the Start clip plays');
assert.equal(h.v.fadeIn, 0, 'and the piece fades in with it, like a deploy');
assert.deepEqual(h.cues, [{ def: 'char_x', defId: 'chess_char_x', kind: 'op', key: 'p:7' }], 'the model id picks the sound, the kind its class');
h.v.destroy();
});
test('a second trigger of the same landing changes nothing and sounds nothing; after the clip a new landing plays again', async () => {
const h = rig();
await h.tick();
assert.equal(h.v.land(), true); // e.g. bought …
h.advance(0.4);
assert.equal(h.v.land(), false, '… then moved onto the board while its Start still plays');
assert.equal(h.v.actor.mode, 'deploy');
assert.ok(Math.abs(h.v.actor.deployElapsed() - 0.4) < 1e-9, 'the clip is not restarted');
assert.equal(h.cues.length, 1, 'one landing, one sound');
// the round start's replay reaching a piece put down a moment before: the same
assert.equal(h.v.deploying(), true);
h.advance(0.7); // the 1 s clip is over
assert.equal(h.v.actor.mode, 'base');
at(globalThis.performance.now() + 5000, () => {
assert.equal(h.v.deploying(), false);
assert.equal(h.v.land(), true, 'a later landing (another move) plays again');
});
assert.equal(h.cues.length, 2);
h.v.destroy();
});
test('a landing asked before the model has loaded sounds at once and continues by the time gone (0.2.3); no second sound at load', async () => {
const h = rig({ instant: false });
assert.equal(h.v.actor, null);
assert.equal(h.v.land(), true);
assert.equal(h.cues.length, 1, 'the sound belongs to the landing, not to the model');
h.advance(0.3);
assert.equal(h.v.land(), false, 'still landing (owed to the model)');
await h.ready();
assert.equal(h.v.actor.mode, 'deploy');
assert.ok(Math.abs(h.v.actor.deployElapsed() - 0.3) < 1e-9, 'Start continues where the time says');
assert.equal(h.cues.length, 1);
h.v.destroy();
});
test('a model without a Start clip and a view without a model still land once per clip length', async () => {
const h = rig({ start: null });
await h.tick();
const t0 = 1000;
at(t0, () => assert.equal(h.v.land(), true));
assert.equal(h.v.actor.mode, 'base', 'nothing to play');
at(t0 + 500, () => assert.equal(h.v.land(), false, 'the landing still owns the clip length'));
at(t0 + START_CLIP_FALLBACK * 1000 + 1, () => assert.equal(h.v.land(), true));
assert.equal(h.cues.length, 2);
h.v.destroy();
// no manifest entry at all: the fallback length
const ctx = fakeViewCtx(fake.P, { assets: null, cam: () => cam, audio: { deployCue: () => true } });
const bare = new UnitView(ctx, { id: 'p:8', uid: 8, side: 'ally', kind: 'op', defId: 'chess_char_y', x: 5, y: 10, maxHp: 1 }, { prep: true });
assert.equal(bare._startDur(), START_CLIP_FALLBACK);
at(t0, () => assert.equal(bare.land(), true));
at(t0 + 200, () => assert.equal(bare.land(), false));
bare.destroy();
});
test('never pulls a unit out of a battle, an attack, a death or a drag into the Start clip', async () => {
// a battle unit (the sim's own 'deploy' events go through onDeploy, which stays silent here)
const b = rig({ prep: false });
await b.tick();
b.advance(0.5);
assert.equal(b.v.land(), false);
assert.equal(b.v.actor.mode, 'base');
b.v.onDeploy();
assert.equal(b.v.actor.mode, 'deploy', 'a battle deploy still plays Start');
assert.equal(b.cues.length, 0, 'and asks for no sound: audio.handleBattleEvents sounds battle deploys');
b.v.destroy();
// attacking, dying, dead, lifted, an enemy
const a = rig();
await a.tick();
a.advance(0.5);
a.v.onAttack(null, 1, 'none');
assert.equal(a.v.actor.mode, 'attack');
assert.equal(a.v.land(), false, 'attacking');
a.v.die();
assert.equal(a.v.land(), false, 'dying');
a.v.destroy();
assert.equal(a.v.land(), false, 'destroyed');
const l = rig();
await l.tick();
l.v.lift = 0.3;
assert.equal(l.v.land(), false, 'lifted by a drag');
l.v.lift = 0;
assert.equal(l.v.land(), true);
l.v.destroy();
const e = rig({ side: 'enemy', kind: 'enemy' });
await e.tick();
assert.equal(e.v.land(), false, 'an enemy (the pen) never lands');
e.v.destroy();
assert.equal(a.cues.length + e.cues.length, 0);
// a hidden tab: nothing would show it, and the clip and the suspended audio would play it stale on return
const hid = rig();
await hid.tick();
globalThis.document.hidden = true;
try { assert.equal(hid.v.land(), false, 'hidden'); } finally { delete globalThis.document.hidden; }
assert.equal(hid.v.actor.mode, 'base');
assert.equal(hid.cues.length, 0);
assert.equal(hid.v.land(), true, 'shown again');
hid.v.destroy();
});
test('a summon piece lands with its own class; a landing sound that throws never stops the clip', async () => {
const t = rig({ kind: 'token', info: { defId: 'token_10000_silent_healrb', spine: 'token_10000_silent_healrb' } });
await t.tick();
assert.equal(t.v.land(), true);
assert.deepEqual(t.cues[0], { def: 'token_10000_silent_healrb', defId: 'token_10000_silent_healrb', kind: 'token', key: 'p:7' });
t.v.destroy();
const h = rig();
await h.tick();
h.v.ctx.audio = { deployCue: () => { throw new Error('no audio'); } };
assert.equal(h.v.land(), true);
assert.equal(h.v.actor.mode, 'deploy');
h.v.ctx.audio = null;
h.v.destroy();
});
});
test('render/app.js wiring: landings in setPrep, the replay schedule, its timers cleared when prep mode ends and on destroy', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
// a purchase (an operator new on the bench), a merge's elite, a piece put on a board tile it was not on
assert.match(src, /if \(e\.piece\.kind === 'chess'\) landPiece\(v, 'buy'\);/);
assert.match(src, /landPiece\(v, 'merge'\);/);
assert.match(src, /if \(was && e\.area === 'board' && \(was\.area !== 'board' \|\| was\.row !== e\.row \|\| was\.col !== e\.col\)[^\n]*landPiece\(v, 'board'\);/);
assert.match(src, /const prevAt = new Map\(prepPieces\.map\(\(p\) => \[p\.uid, p\]\)\);/, 'compared with the last state of this prep, not a rebuild');
// the replay: prep mode only, the same view, the tab shown, one turn after the other; cancelled by enterBattle,
// enterPrepMode and destroy
assert.match(src, /function replayDeploys\(uids, gap = DEPLOY_REPLAY_MS\) \{\s*if \(destroyed \|\| mode !== 'prep'/);
assert.match(src, /if \(destroyed \|\| mode !== 'prep'\) return;\s*const \{ uid, v \} = queue\[i\+\+\];\s*if \(!globalThis\.document\?\.hidden && views\.get\('p:' \+ uid\) === v\) landPiece\(v, 'round'\);\s*if \(i < queue\.length\) replayTimer = setTimeout\(turn, step\);/);
const body = (name) => src.slice(src.indexOf(name), src.indexOf(name) + 900);
assert.match(body('function enterBattle(meta)'), /cancelReplay\(\);/);
assert.match(body('function enterPrepMode()'), /cancelReplay\(\);/);
assert.match(body(' destroy() {\n if (destroyed) return;'), /cancelReplay\(\);/);
assert.match(src, /audio: opts\.audio \|\| null,/, 'the views reach the audio manager');
});
+87
View File
@@ -3,6 +3,7 @@
// [[id, left, max]] and `neg` [[id, fill]] beside the unit tuples and samples them from the older snapshot; render/units.js
// draws the ammo skill's segmented yellow bar in place of the SP bar, 伺夜's 狼影 as a row of diamonds and the 我执 pool
// as the red bar over the drained HP bar; an ammo skill has no standing aura (headless fake PIXI, test/render/fakepixi.js).
// 设置「弹药数量」 (PR #552 by @IceCodeNew, off by default) adds the magazine's count under the ammo bar.
// The server side is test/sim/hud-readouts.test.js and test/content/op_hsgma2.test.js.
// Run: node --test test/render/hud-readouts.test.js
@@ -166,6 +167,92 @@ describe('UnitView: the ammo skill\'s segmented bar', () => {
});
});
// 设置「弹药数量」 (PR #552 by @IceCodeNew, DESIGN §31.9): the official HUD has no number under the ammo bar, so the count is an
// option, off by default (the maintainer's decision of 2026-10-13); the settings side is test/ui/ammo-count.test.js
describe('UnitView: the optional ammo count under the bar (设置「弹药数量」)', () => {
const counting = (on = true) => view({}, { settings: { damageNumbers: true, quality: 'high', ammoCount: on } });
const coinY = (v) => v.coinHud.root.position.y;
test('off (the default): nothing is drawn under the magazine, and the rows below stay where they were', () => {
const v = view();
const w = counting(false);
try {
for (const u of [v, w]) { u.sync(sample({ flags: UF.SKILL, ammo: [13, 14], coins: [3, 10] })); frames(u); }
assert.equal(v.ammoText, null, 'never built without the setting');
assert.equal(w.ammoText, null, 'nor with it off');
assert.equal(v.hud.children.some((c) => c instanceof fake.P.Text && /\d+\/14/.test(c.text)), false);
const plain = view();
plain.sync(sample({ coins: [3, 10] }));
frames(plain);
assert.equal(coinY(v), coinY(w));
assert.ok(coinY(v) > coinY(plain), 'the magazine row (thicker than the SP bar) is all that sits above the purse');
plain.destroy();
} finally { v.destroy(); w.destroy(); }
});
test('on: the exact count through spending, refills and capacity changes, also where the cells are too narrow for cuts', () => {
const v = counting();
try {
for (const [left, max] of [[13, 14], [12, 14], [137, 240], [136, 240], [260, 260], [0, 260]]) {
v.sync(sample({ flags: UF.SKILL, ammo: [left, max] }));
frames(v);
assert.equal(v.ammoText?.text, `${left}/${max}`);
assert.equal(v.ammoText.visible, true);
if (max >= 240) assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'a dense magazine draws no cuts, the count still reads');
}
const bw = barWidth(v);
assert.ok(Math.abs(v.ammoText.position.x - (v.spFill.position.x + bw / 2)) < 1e-9, 'centred under the bar');
assert.ok(v.ammoText.position.y > v.spFill.position.y, 'below the magazine');
assert.equal(v.ammoText.anchor.x, 0.5);
assert.equal(v.ammoText.style.fill, COLORS.ammo, 'in the bar\'s yellow');
} finally { v.destroy(); }
});
test('on: hidden when the skill ends, without the snapshot row, in the prep view, after death and for enemies; the rows below make room', () => {
const v = counting();
try {
v.sync(sample({ flags: UF.SKILL, ammo: [5, 14], coins: [3, 10] }));
frames(v);
const withCount = coinY(v);
v.sync(sample({ sp: 6, spMax: 24, coins: [3, 10] })); // the magazine is spent: the SP bar again
frames(v);
assert.equal(v.ammoText.visible, false, 'no stale number after the skill');
assert.ok(withCount > coinY(v), 'the purse row moved down by the count\'s height while it showed');
v.sync(sample({ flags: UF.SKILL, ammo: [5, 14] }));
v.prep = true;
frames(v);
assert.equal(v.ammoText.visible, false, 'the prep view draws no bars');
v.prep = false;
frames(v);
assert.equal(v.ammoText.visible, true);
v.die();
frames(v);
assert.equal(v.ammoText.visible, false, 'a dead unit has no bars');
} finally { v.destroy(); }
const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' }, { settings: { damageNumbers: true, quality: 'high', ammoCount: true } });
try {
e.sync(sample({ hp: 500, flags: UF.SKILL, ammo: [3, 4] }));
frames(e);
assert.equal(e.ammoText, null, 'enemies have no magazine, so no count');
} finally { e.destroy(); }
});
test('turning the setting off takes the count away at once (the field view shares its settings object with the units)', () => {
const v = counting();
try {
v.sync(sample({ flags: UF.SKILL, ammo: [9, 14] }));
frames(v);
assert.equal(v.ammoText.visible, true);
v.ctx.settings.ammoCount = false;
frames(v);
assert.equal(v.ammoText.visible, false);
v.ctx.settings.ammoCount = true;
frames(v);
assert.equal(v.ammoText.visible, true);
} finally { v.destroy(); }
});
});
describe('UnitView: 伺夜\'s 狼影 pips', () => {
const pack = (extra = {}) => view({ kind: 'token', defId: 'token_10028_vigil_wolf', ...extra });
+2 -1
View File
@@ -83,6 +83,7 @@ test('render/app.js plays the promotion cue instead of the deploy flash for a me
assert.match(src, /import \{ promotionsOf \} from '\.\/promote\.js';/);
assert.match(src, /const before = prepPieces\.length \? prepPieces : promoBase;[\s\S]{0,80}promotionsOf\(before, list, \(id\) => data\.chess\(id\)\)/, 'compared with the previous state');
assert.match(src, /if \(prepPieces\.length\) promoBase = prepPieces;\s*prepPieces = \[\];/, 'a battle keeps the last prep state: a merge between two preps is cued too (QA 6b)');
assert.match(src, /if \(promoFrom\.has\(e\.uid\)\) \{[\s\S]{0,400}fx\.promote\(v,[\s\S]{0,400}\} else if \(e\.area === 'board' && prevBoard\.size && !prevBoard\.has\(e\.uid\)\) \{ v\.onDeploy\?\.\(\); fx\.deploy\(v\); \}/);
// (the elite and a new board piece land — the Start clip and its landing sound, PR #416 — and only the latter flashes)
assert.match(src, /if \(promoFrom\.has\(e\.uid\)\) \{[\s\S]{0,400}fx\.promote\(v,[\s\S]{0,400}\} else if \(e\.area === 'board' && prevBoard\.size && !prevBoard\.has\(e\.uid\)\) \{ landPiece\(v, 'board'\); fx\.deploy\(v\); \}/);
assert.match(src, /promotions,/, 'the cue log is exposed on view.debug (E2E)');
});
+58
View File
@@ -0,0 +1,58 @@
// test/ui/ammo-count.test.js — 设置「弹药数量」 (PR #552 by @IceCodeNew, ported in 0.2.6, DESIGN §31.9): the remaining ammo
// count under an ammo skill's bar is not part of the official HUD, so it is an option, off by default (the maintainer's
// decision of 2026-10-13, like 「官方伤害数字」). Settings normalisation, the dialog's toggle and its hint in every language
// pack, the field view's live setting, the diagnostics line. What the renderer draws: test/render/hud-readouts.test.js.
// Run: node --test test/ui/ammo-count.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { sanitizeSettings, DEFAULT_SETTINGS } from '../../public/js/ui/gameLogic.js';
import { buildDiagnostics } from '../../public/js/diag.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const LABEL = '弹药数量';
const HINT = '弹药类技能开启时,在弹药条下方显示剩余弹药 / 总弹药数(如 13/14)。官方界面没有这个数字。';
test('ammoCount is stored with the other settings: off by default and for a profile saved before it, a boolean kept, junk off', () => {
assert.equal(DEFAULT_SETTINGS.ammoCount, false);
assert.equal(sanitizeSettings(null).ammoCount, false);
assert.equal(sanitizeSettings({ bgm: 0.4, officialDamageNumbers: true }).ammoCount, false, 'a profile saved before 0.2.6');
assert.equal(sanitizeSettings({ ammoCount: true }).ammoCount, true);
assert.equal(sanitizeSettings({ ammoCount: false }).ammoCount, false);
for (const bad of ['true', 1, 0, null, {}, []]) assert.equal(sanitizeSettings({ ammoCount: bad }).ammoCount, false, JSON.stringify(bad));
// independent of the damage-number switches
assert.deepEqual(sanitizeSettings({ ammoCount: true, damageNumbers: false, officialDamageNumbers: true }), { ...sanitizeSettings({ damageNumbers: false, officialDamageNumbers: true }), ammoCount: true });
});
test('the settings dialog has the toggle and a hint that the official interface has no such number, in every language pack', () => {
const src = read('public/js/ui/settings.js');
assert.match(src, /<\$\{Toggle\} label=\$\{t\('弹药数量'\)\} micro="AMMO COUNT" value=\$\{s\.ammoCount\} onChange=\$\{\(v\) => updateSettings\(\{ ammoCount: v \}\)\} \/>/);
assert.ok(src.includes(`t('${HINT}')`), 'the hint under it');
assert.ok(src.indexOf("t('官方伤害数字')") < src.indexOf(`t('${LABEL}')`), 'beside the damage-number options');
for (const lang of ['en', 'ja', 'ko', 'zh-TW']) {
const pack = JSON.parse(read(`public/i18n/${lang}.json`));
for (const id of [LABEL, HINT]) assert.ok(typeof pack[id] === 'string' && pack[id].length > 0, `${lang}: ${id}`);
assert.match(pack[HINT], /13\/14/, `${lang}: the example count`);
}
});
test('the field view takes the setting at creation and live (setSettings); the units read it from their shared settings', () => {
const app = read('public/js/render/app.js');
assert.match(app, /const settings = \{ damageNumbers: true, officialDamageNumbers: false, ammoCount: false, quality: 'high', \.\.\.\(opts\.settings \|\| \{\}\) \};/);
assert.match(app, /if \(typeof s\.ammoCount === 'boolean'\) settings\.ammoCount = s\.ammoCount;/);
const units = read('public/js/render/units.js');
assert.match(units, /this\._updateAmmoCount\(showSp && !!ammo && this\.ctx\.settings\?\.ammoCount === true, x0 \+ bw \/ 2, rowsTop, s\)/);
// the simplified board draws no ammo bar (its SP bar is the tuple's SP), so it has nothing to count under
assert.doesNotMatch(read('public/js/ui/fallbackField.js'), /ammo/);
});
test('the diagnostics name the option when it is on', () => {
const state = { me: {}, room: null, match: { public: null, battle: null }, connection: { status: 'online' } };
const line = (settings) => buildDiagnostics({ state, env: {}, settings }).split('\n').find((l) => l.startsWith('settings:'));
assert.equal(line({ quality: 'high', damageNumbers: true, ammoCount: true }), 'settings: quality high | damage numbers | ammo count');
assert.equal(line({ quality: 'high', damageNumbers: true, ammoCount: false }), 'settings: quality high | damage numbers');
});
+163
View File
@@ -0,0 +1,163 @@
// Browser check of the round start's deploy replay (PR #416 by @Cromemadnd, DESIGN §31.8) on the mock harness
// (public/dev/game-mock.html) in headless Chrome (puppeteer-core + the system Chrome); opt-in, ~15 s:
// SP_E2E=1 node --test test/ui/deploy-replay.e2e.test.js
//
// 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) 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.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** The deployment order, written out independently of ui/gameLogic deployOrder: operators, then summons; col asc, row desc. */
const expectedOrder = (board) => board.filter((p) => p && (p.kind === 'chess' || p.kind === 'token'))
.sort((a, b) => (a.kind === 'chess' ? 0 : 1) - (b.kind === 'chess' ? 0 : 1) || a.col - b.col || b.row - a.row)
.map((p) => p.uid);
describe('the round start replays the own board\'s Start clips in deployment order', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
await srv.assetIndex?.whenReady();
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('SETTLE → 回合开始: one landing per piece, in order and 50 ms apart; once per round; purchases and moves land once', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 900 });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
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), 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);
S.battle = null;
S.pub.phase = ph; S.pub.round = r; S.pub.deadline = Date.now() + (ph === 'ROUND_START' ? 2000 : 60000);
S.priv.ready = false;
}), phase, round);
// ---- 回合开始 of round 7: the board replays in deployment order
const board = await page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv.board)));
const order = expectedOrder(board);
assert.ok(order.length >= 5, `the mock board has pieces (${order.length})`);
const t0 = await page.evaluate(() => performance.now());
await toPhase('ROUND_START', 7);
await page.waitForFunction((n) => globalThis.__SP_VIEW__.raw.debug.landings.filter((l) => l.why === 'round').length >= n, { timeout: 5000 }, order.length);
// the last piece's Start is playing right now (on its model, or owed to one still loading)
const lastPlaying = await page.evaluate((uid) => globalThis.__SP_VIEW__.raw.debug.views.get(`p:${uid}`)?.deploying(), order.at(-1));
const round7 = await landings('round');
assert.deepEqual(round7.map((l) => l.uid), order, 'every operator once, in deployment order (by column from the left, top to bottom)');
assert.equal(await page.evaluate(() => globalThis.__SP_VIEW__.raw.mode), 'prep');
assert.ok(round7[0].at >= t0, 'scheduled by this round start');
for (let i = 1; i < round7.length; i++) {
const gap = round7[i].at - round7[i - 1].at;
assert.ok(gap >= 35 && gap < 400, `one after another, ~50 ms apart (${gap.toFixed(1)} ms between #${i - 1} and #${i})`);
}
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(() => {}); });
await sleep(600);
await toPhase('PREP', 7);
await sleep(800);
assert.equal((await landings('round')).length, order.length, 'round 7 replayed once');
assert.equal(await page.evaluate(() => globalThis.__cues.length), order.length, 'and sounded once per piece');
// ---- a purchase lands the bought operator; a move onto the board during its Start sounds nothing more
const slot = await page.evaluate(() => globalThis.__MOCK__.S().priv.shop.slots.findIndex((s) => s && s.kind === 'chess' && !s.sold));
assert.ok(slot >= 0, 'the shop offers an operator');
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 99; }));
await page.evaluate(async (i) => { const { net } = await import('/js/net.js'); await net.request('g.buy', { slot: i }); }, slot);
await page.waitForFunction(() => globalThis.__SP_VIEW__.raw.debug.landings.some((l) => l.why === 'buy'), { timeout: 4000 });
const bought = (await landings('buy')).at(-1).uid;
const inHand = await page.evaluate((uid) => globalThis.__MOCK__.S().priv.hand.some((p) => p && p.uid === uid), bought);
assert.ok(inHand, 'the landing is the bought piece on the bench');
const free = await page.evaluate(() => {
const S = globalThis.__MOCK__.S();
const used = new Set(S.priv.board.map((p) => `${p.row},${p.col}`));
const dt = S.stage.deployTiles.normal;
return [...dt.melee, ...dt.rangedOnly].filter(([r, c]) => !used.has(`${r},${c}`)).slice(0, 2);
});
assert.equal(free.length, 2, 'two free deploy tiles');
const move = (uid, [row, col], dir = 'RIGHT') => page.evaluate(async (u, r, c, d) => {
const { net } = await import('/js/net.js');
await net.request('g.move', { uid: u, to: { area: 'board', row: r, col: c }, dir: d });
}, uid, row, col, dir);
const cuesOf = (uid) => page.evaluate((k) => globalThis.__cues.filter((c) => c.key === k).length, `p:${uid}`);
await move(bought, free[0]);
await sleep(300);
assert.equal(await page.evaluate((uid) => globalThis.__MOCK__.S().priv.board.some((p) => p.uid === uid), bought), true, 'it is on the board');
assert.equal(await cuesOf(bought), 1, 'bought and moved within one Start clip: one landing sound');
// a later move to another tile lands again; turning in place does not
await sleep(1400);
await move(bought, free[1]);
await page.waitForFunction((uid) => globalThis.__SP_VIEW__.raw.debug.landings.some((l) => l.why === 'board' && l.uid === uid), { timeout: 4000 }, bought);
assert.equal(await cuesOf(bought), 2, 'a move to another tile is a new landing');
await sleep(1400);
await move(bought, free[1], 'UP');
await sleep(600);
assert.equal(await cuesOf(bought), 2, 'turning in place is no landing');
assert.equal((await landings('round')).length, order.length, 'the 休整期 never replays the round start');
// ---- the next round replays again (with the bought operator in its place in the order)
await toPhase('SETTLE', 7);
await sleep(300);
const board8 = await page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv.board)));
await toPhase('ROUND_START', 8);
const order8 = expectedOrder(board8);
await page.waitForFunction((n) => globalThis.__SP_VIEW__.raw.debug.landings.filter((l) => l.why === 'round').length >= n, { timeout: 5000 }, order.length + order8.length);
const round8 = (await landings('round')).slice(order.length).map((l) => l.uid);
assert.deepEqual(round8, order8, 'round 8 replays its board in deployment order');
await page.close();
assert.deepEqual(problems, []);
});
});
+296
View File
@@ -0,0 +1,296 @@
// 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) 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';
import assert from 'node:assert/strict';
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, 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)), '../..');
const manifest = JSON.parse(readFileSync(path.join(ROOT, 'data', 'assets.json'), 'utf8'));
const chess = (uid, row, col, extra = {}) => ({ uid, kind: 'chess', id: `chess_${uid}`, row, col, ...extra });
describe('deployOrder — the order the battle deploys the board (Battle.start)', () => {
test('operators by column from the left, top to bottom within a column, then the summons the same way; no items', () => {
// board rows 9 (bottom) … 12 (top), cols 2 … 10; given in reading order, as m.private sends it (server board.js boardOrder)
const board = [
chess(1, 12, 5), chess(2, 12, 2), { uid: 3, kind: 'token', id: 'token_a', row: 12, col: 3 }, chess(4, 11, 2),
{ uid: 5, kind: 'item', id: 'chess_item_6_03_m', row: 10, col: 4 }, chess(6, 10, 5), chess(7, 9, 3),
{ uid: 8, kind: 'token', id: 'token_b', row: 9, col: 2 },
];
assert.deepEqual(deployOrder(board), [2, 4, 7, 1, 6, 8, 3]);
// the same rule the client's merge target uses (gameLogic mergeTarget: col asc, then row desc)
assert.deepEqual(deployOrder([chess(1, 9, 4), chess(2, 12, 4), chess(3, 10, 3)]), [3, 2, 1]);
});
test('junk and missing fields are left out; an empty or absent board is no order', () => {
assert.deepEqual(deployOrder(null), []);
assert.deepEqual(deployOrder([]), []);
assert.deepEqual(deployOrder([null, 'x', { uid: 1, kind: 'chess', row: 10 }, { kind: 'chess', row: 10, col: 3 }, chess(9, 10, 3)]), [9]);
});
});
describe('roundStartReplay — once per round, in 回合开始, on the own board', () => {
const board = [chess(11, 12, 6), chess(12, 9, 2)];
const base = { phase: PHASE.ROUND_START, round: 7, replayed: 6, ownBoard: true, board };
test('due in 回合开始 of a round not replayed yet: its pieces in deployment order', () => {
assert.deepEqual(roundStartReplay(base), { round: 7, uids: [12, 11] });
});
test('not again for the same round (a reconnect, a re-render); not in another phase', () => {
assert.equal(roundStartReplay({ ...base, replayed: 7 }), null);
for (const phase of [PHASE.PREP, PHASE.SP_DRAFT, PHASE.SETTLE, PHASE.COMBAT, PHASE.BATTLE_CHECK, null]) {
assert.equal(roundStartReplay({ ...base, phase }), null, String(phase));
}
assert.equal(roundStartReplay({ ...base, round: null }), null);
});
test('only the viewer\'s own board: a scouted teammate, a spectator seat or an eliminated player gets none', () => {
assert.equal(roundStartReplay({ ...base, ownBoard: false }), null);
});
test('an empty board (or a private state not here yet) schedules nothing — and marks nothing, so a board that arrives later in 回合开始 still plays', () => {
assert.equal(roundStartReplay({ ...base, board: [] }), null);
assert.equal(roundStartReplay({ ...base, board: undefined }), null);
assert.deepEqual(roundStartReplay({ ...base, replayed: null }), { round: 7, uids: [12, 11] });
});
test('screens/game.js asks after the wiring effect and marks the round only once the view took the sequence', () => {
const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
const wiring = src.indexOf('}, [view, showPrep, priv, editable, field, combat, mode, watchingOther, watching, holdSeq]);');
const replay = src.indexOf('const plan = roundStartReplay(');
assert.ok(wiring > 0 && replay > wiring, 'the replay effect runs after setPrep in the same commit');
assert.match(src, /roundStartReplay\(\{ phase, round: pub\?\.round, replayed: replayedRef\.current, ownBoard: showPrep && alive && !spectator, board: priv\?\.board \}\)/);
assert.match(src, /if \(plan && view && view\.replayDeploys\?\.\(plan\.uids\) > 0\) replayedRef\.current = plan\.round;/);
assert.doesNotMatch(src, /deployPiece|deploySequence/, 'no landing at the wheel\'s confirm: the state that shows the move lands the piece once');
const host = readFileSync(path.join(ROOT, 'public/js/ui/fieldHost.js'), 'utf8');
assert.match(host, /'stripesUnder', 'replayDeploys'\]/, 'an optional view method (the simplified board has none)');
});
});
// ---- 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; },
};
}
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 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('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);
});
});
+1 -1
View File
@@ -722,7 +722,7 @@ describe('keyboard & settings', () => {
test('sanitizeSettings', () => {
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, officialDamageNumbers: false, ammoCount: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub