0.2.6-dev: port PR #416 by Cromemadnd — at every round start the own board's pieces replay their Start clip and landing sound one after another in deployment order (the maintainer's confirmation from official play, 2026-10-13), and a purchase or a move onto / across the board plays them too ([ASSUMED], the PR author's account); one sound per landing, idle prep pieces only, the round marked once the view took the sequence, own board only, timers cleared when prep mode ends and on destroy; DESIGN §31.8; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-13 04:36:56 +08:00
1 parent 281cf01ff0
commit 231da0efbc
16 files changed
+791 -22

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
// 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); 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) and stop the mock battle's stream
await page.evaluate(async () => {
const { audio } = await import('/js/audio.js');
const cues = globalThis.__cues = [];
const real = audio.deployCue.bind(audio);
audio.deployCue = (info, key) => { cues.push({ key, at: performance.now() }); return real(info, key); };
});
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');
// ---- 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, []);
});
});
+130
View File
@@ -0,0 +1,130 @@
// 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.
// 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, deploySfxUrl } from '../../public/js/audio.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)');
});
});
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 };
};
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');
});
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('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]);
});
});