feat: strengthen semantic battle feedback
This commit is contained in:
@@ -20,6 +20,7 @@ const requiredTacticalCueKeys = [
|
||||
'objective-failure'
|
||||
];
|
||||
const requiredNarrativeCueKeys = ['story-page-turn'];
|
||||
const requiredSemanticFeedbackCueKeys = ['recovery-cue', 'reward-reveal', 'burn-tick'];
|
||||
const requiredBattleSceneAudioMethods = [
|
||||
'playSoundscape',
|
||||
'playAllyTurnCue',
|
||||
@@ -64,6 +65,23 @@ const narrativeSourceRecord = {
|
||||
'high-pass and low-pass filtered'
|
||||
]
|
||||
};
|
||||
const semanticFeedbackSourceRecords = [
|
||||
{
|
||||
projectFile: 'src/assets/audio/sfx/recovery-cue.mp3',
|
||||
source: 'https://pixabay.com/sound-effects/film-special-effects-holy-healing-spell-533279/',
|
||||
requiredDocumentation: ['Holy Healing Spell', 'Coghezzi', 'approximately 1.9 seconds']
|
||||
},
|
||||
{
|
||||
projectFile: 'src/assets/audio/sfx/reward-reveal.mp3',
|
||||
source: 'https://pixabay.com/sound-effects/film-special-effects-reward-chime-144757/',
|
||||
requiredDocumentation: ['Reward Chime', 'Universfield', '1.4 seconds']
|
||||
},
|
||||
{
|
||||
projectFile: 'src/assets/audio/sfx/burn-tick.mp3',
|
||||
source: 'https://pixabay.com/sound-effects/film-special-effects-electronic-element-burn-spark-1-248606/',
|
||||
requiredDocumentation: ['Electronic element burn spark 1', 'FxProSound', '0.7 seconds']
|
||||
}
|
||||
];
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
@@ -85,8 +103,10 @@ try {
|
||||
validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys);
|
||||
validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys);
|
||||
validateRequiredTrackKeys(errors, 'narrative cue', effectTracks, requiredNarrativeCueKeys);
|
||||
validateRequiredTrackKeys(errors, 'semantic feedback cue', effectTracks, requiredSemanticFeedbackCueKeys);
|
||||
validateBattlefieldSourceDocumentation(errors);
|
||||
validateNarrativeSourceDocumentation(errors);
|
||||
validateSemanticFeedbackSourceDocumentation(errors);
|
||||
validateBattleSceneAudioIntegration(errors);
|
||||
validateEffectPools(errors, effectPools, effectTracks);
|
||||
validateEffectPoolRegistration(errors, effectPools);
|
||||
@@ -177,6 +197,39 @@ function validateNarrativeSourceDocumentation(errors) {
|
||||
});
|
||||
}
|
||||
|
||||
function validateSemanticFeedbackSourceDocumentation(errors) {
|
||||
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
|
||||
const sectionHeading = '## Semantic Feedback Cues';
|
||||
const sectionStart = docs.indexOf(sectionHeading);
|
||||
if (sectionStart < 0) {
|
||||
errors.push('docs/audio-sources.md must include the Semantic Feedback Cues section');
|
||||
return;
|
||||
}
|
||||
const nextSectionStart = docs.indexOf('\n## ', sectionStart + sectionHeading.length);
|
||||
const semanticDocs = docs.slice(sectionStart, nextSectionStart < 0 ? docs.length : nextSectionStart);
|
||||
if (!semanticDocs.includes('2026-07-22')) {
|
||||
errors.push('docs/audio-sources.md must record the 2026-07-22 semantic feedback download date');
|
||||
}
|
||||
['48 kHz stereo MP3', 'silence-trimmed', 'fade-in/out', 'high-pass and low-pass filtered'].forEach((detail) => {
|
||||
if (!semanticDocs.includes(detail)) {
|
||||
errors.push(`docs/audio-sources.md is missing semantic feedback processing detail "${detail}"`);
|
||||
}
|
||||
});
|
||||
semanticFeedbackSourceRecords.forEach(({ projectFile, source, requiredDocumentation }) => {
|
||||
if (!semanticDocs.includes(projectFile)) {
|
||||
errors.push(`docs/audio-sources.md is missing semantic feedback file "${projectFile}"`);
|
||||
}
|
||||
if (!semanticDocs.includes(source)) {
|
||||
errors.push(`docs/audio-sources.md is missing Pixabay source "${source}"`);
|
||||
}
|
||||
requiredDocumentation.forEach((detail) => {
|
||||
if (!semanticDocs.includes(detail)) {
|
||||
errors.push(`docs/audio-sources.md is missing semantic feedback detail "${detail}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function validateBattleSceneAudioIntegration(errors) {
|
||||
const path = join('src', 'game', 'scenes', 'BattleScene.ts');
|
||||
const source = readFileSync(path, 'utf8');
|
||||
|
||||
328
scripts/verify-audiovisual-feedback-browser.mjs
Normal file
328
scripts/verify-audiovisual-feedback-browser.mjs
Normal file
@@ -0,0 +1,328 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
desktopBrowserContextOptions,
|
||||
desktopBrowserDeviceScaleFactor,
|
||||
desktopBrowserViewport
|
||||
} from './desktop-browser-viewport.mjs';
|
||||
|
||||
const explicitUrl = process.env.AV_FEEDBACK_QA_URL;
|
||||
const targetUrl = new URL(explicitUrl ?? 'http://127.0.0.1:41741/heros_web/');
|
||||
targetUrl.searchParams.set('debug', '1');
|
||||
targetUrl.searchParams.set('renderer', 'canvas');
|
||||
targetUrl.searchParams.set('debugBattleSetup', 'status-ui');
|
||||
|
||||
let serverProcess;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
if (!explicitUrl) {
|
||||
serverProcess = spawn(
|
||||
process.execPath,
|
||||
['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '41741', '--strictPort'],
|
||||
{ cwd: process.cwd(), stdio: 'ignore', windowsHide: true }
|
||||
);
|
||||
await waitForUrl(targetUrl, 20000);
|
||||
}
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage(desktopBrowserContextOptions);
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(targetUrl.href, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForFunction(() => Boolean(window.__HEROS_DEBUG__), undefined, { timeout: 30000 });
|
||||
await page.evaluate(async () => {
|
||||
await window.__HEROS_DEBUG__.goToBattle('first-battle-zhuo-commandery');
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return battle?.scene === 'BattleScene' && battle.units?.some((unit) => unit.statusBadges?.length > 0);
|
||||
}, undefined, { timeout: 90000 });
|
||||
|
||||
const initial = await page.evaluate(() => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const battle = debug.battle();
|
||||
const ally = battle.units.find((unit) => unit.statusBadges?.some((badge) => badge.kind === 'burn'));
|
||||
const enemy = battle.units.find((unit) => unit.statusBadges?.some((badge) => badge.kind === 'confusion'));
|
||||
const allyView = ally ? scene.unitViews.get(ally.id) : undefined;
|
||||
const enemyView = enemy ? scene.unitViews.get(enemy.id) : undefined;
|
||||
return {
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
dpr: window.devicePixelRatio,
|
||||
scale: window.visualViewport?.scale ?? 1
|
||||
},
|
||||
canvas: (() => {
|
||||
const bounds = document.querySelector('canvas')?.getBoundingClientRect();
|
||||
return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null;
|
||||
})(),
|
||||
ally: ally && allyView
|
||||
? {
|
||||
id: ally.id,
|
||||
hp: ally.hp,
|
||||
spriteX: allyView.sprite.x,
|
||||
badge: ally.statusBadges.find((badge) => badge.kind === 'burn'),
|
||||
roleSealBounds: objectBounds(allyView.roleSeal),
|
||||
badgeBounds: objectBounds(allyView.statusBadges[0]?.container)
|
||||
}
|
||||
: null,
|
||||
enemy: enemy && enemyView
|
||||
? {
|
||||
id: enemy.id,
|
||||
spriteX: enemyView.sprite.x,
|
||||
badge: enemy.statusBadges.find((badge) => badge.kind === 'confusion'),
|
||||
badgeBounds: objectBounds(enemyView.statusBadges[0]?.container)
|
||||
}
|
||||
: null
|
||||
};
|
||||
|
||||
function objectBounds(object) {
|
||||
if (!object?.active) {
|
||||
return null;
|
||||
}
|
||||
const bounds = object.getBounds();
|
||||
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(initial.viewport, {
|
||||
width: desktopBrowserViewport.width,
|
||||
height: desktopBrowserViewport.height,
|
||||
dpr: desktopBrowserDeviceScaleFactor,
|
||||
scale: 1
|
||||
});
|
||||
assert.deepEqual(initial.canvas, { x: 0, y: 0, width: 1920, height: 1080 });
|
||||
assert(initial.ally?.badge?.visible, `Expected a visible allied burn badge: ${JSON.stringify(initial.ally)}`);
|
||||
assert(initial.enemy?.badge?.visible, `Expected a visible enemy confusion badge: ${JSON.stringify(initial.enemy)}`);
|
||||
assert(initial.ally.badge.x > initial.ally.spriteX, 'allied status badges should occupy the side opposite the role seal');
|
||||
assert(initial.enemy.badge.x < initial.enemy.spriteX, 'enemy status badges should occupy the side opposite the intent marker');
|
||||
if (initial.ally.roleSealBounds) {
|
||||
assert(!boundsIntersect(initial.ally.roleSealBounds, initial.ally.badgeBounds), 'allied status badge must not overlap the role seal');
|
||||
}
|
||||
await page.screenshot({ path: 'dist/qa-av-status-badges-1920x1080.png', fullPage: true });
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
battle?.phase === 'deployment' &&
|
||||
['ready', 'degraded'].includes(battle.combatAssets?.status) &&
|
||||
Boolean(battle.battleHud?.deploymentPanel?.startButtonBounds)
|
||||
);
|
||||
}, undefined, { timeout: 90000 });
|
||||
const startPoint = await page.evaluate(() => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const bounds = debug.battle().battleHud.deploymentPanel.startButtonBounds;
|
||||
const canvasBounds = document.querySelector('canvas').getBoundingClientRect();
|
||||
return {
|
||||
x: canvasBounds.left + (bounds.x + bounds.width / 2) * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + (bounds.y + bounds.height / 2) * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
});
|
||||
await page.mouse.click(4, 4);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.started === true, undefined, { timeout: 5000 });
|
||||
await page.mouse.click(startPoint.x, startPoint.y);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.phase === 'idle', undefined, { timeout: 90000 });
|
||||
|
||||
const openingAudio = await page.evaluate(() => window.__HEROS_DEBUG__.battle().audio);
|
||||
assert.equal(openingAudio.started, true, 'the deployment click should unlock audio');
|
||||
assert.equal(openingAudio.semanticCues.lastPlayed?.group, 'operation');
|
||||
assert.equal(openingAudio.semanticCues.lastPlayed?.key, 'operation-alert');
|
||||
assert.equal(openingAudio.lastEffect?.trackKey, 'operation-alert', 'the opening alert must not be overwritten by ui-select');
|
||||
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 5000 });
|
||||
|
||||
const pairedBadges = await page.evaluate((unitId) => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const unit = scene.debugUnitById(unitId);
|
||||
scene.applyBattleStatus(unit, 'confusion', 2, 1);
|
||||
const view = scene.unitViews.get(unitId);
|
||||
const state = debug.battle().units.find((candidate) => candidate.id === unitId);
|
||||
return {
|
||||
spriteX: view.sprite.x,
|
||||
badges: state.statusBadges,
|
||||
roleSealBounds: objectBounds(view.roleSeal),
|
||||
badgeBounds: view.statusBadges.map((badge) => objectBounds(badge.container))
|
||||
};
|
||||
|
||||
function objectBounds(object) {
|
||||
if (!object?.active) {
|
||||
return null;
|
||||
}
|
||||
const bounds = object.getBounds();
|
||||
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
||||
}
|
||||
}, initial.ally.id);
|
||||
assert.deepEqual(pairedBadges.badges.map((badge) => badge.kind), ['burn', 'confusion']);
|
||||
assert(pairedBadges.badges.every((badge) => badge.visible && badge.x > pairedBadges.spriteX));
|
||||
assert(
|
||||
pairedBadges.badges[1].x > pairedBadges.badges[0].x,
|
||||
'the second allied badge should fan outward instead of folding back over the unit'
|
||||
);
|
||||
pairedBadges.badgeBounds.forEach((bounds) => {
|
||||
assert(!boundsIntersect(pairedBadges.roleSealBounds, bounds), 'paired allied status badges must not overlap the role seal');
|
||||
});
|
||||
|
||||
const tickResult = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_DEBUG__.scene('BattleScene');
|
||||
scene.hideBattleEventBanner();
|
||||
return scene.tickBattleStatuses('ally');
|
||||
});
|
||||
assert.match(tickResult.message, /화상/);
|
||||
await page.waitForFunction(() => {
|
||||
const audio = window.__HEROS_DEBUG__?.audio();
|
||||
return audio?.lastEffect?.trackKey === 'burn-tick' && audio.activeEffectCount > 0;
|
||||
}, undefined, { timeout: 3000 });
|
||||
|
||||
const afterTick = await page.evaluate((unitId) => {
|
||||
const battle = window.__HEROS_DEBUG__.battle();
|
||||
return {
|
||||
feedback: battle.mapResultFeedback,
|
||||
audio: battle.audio,
|
||||
unit: battle.units.find((candidate) => candidate.id === unitId)
|
||||
};
|
||||
}, initial.ally.id);
|
||||
assert(afterTick.feedback.activeCount > 0 && afterTick.feedback.activeCount <= afterTick.feedback.maxActive);
|
||||
assert.deepEqual(afterTick.feedback.last.lines, ['화상 -3', `HP ${initial.ally.hp}→${initial.ally.hp - 3}`]);
|
||||
assert.equal(afterTick.audio.semanticCues.lastPlayed?.group, 'status');
|
||||
assert.equal(afterTick.audio.semanticCues.lastPlayed?.key, 'burn-tick');
|
||||
assert(afterTick.audio.activeEffectCount > 0, 'the browser must keep the decoded burn cue active after playback starts');
|
||||
assert.equal(afterTick.unit.hp, initial.ally.hp - 3);
|
||||
assert.equal(afterTick.unit.statusBadges.find((badge) => badge.kind === 'burn')?.turns, 1);
|
||||
assert.equal(afterTick.unit.statusBadges.find((badge) => badge.kind === 'confusion')?.turns, 1);
|
||||
await page.screenshot({ path: 'dist/qa-av-status-tick-1920x1080.png', fullPage: true });
|
||||
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.mapResultFeedback?.activeCount === 0, undefined, {
|
||||
timeout: 3000
|
||||
});
|
||||
const offscreenTick = await page.evaluate((unitId) => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const unit = scene.debugUnitById(unitId);
|
||||
const camera = scene.getDebugState().camera;
|
||||
const candidates = [
|
||||
{ x: camera.x + camera.visibleColumns, y: unit.y },
|
||||
{ x: camera.x - 1, y: unit.y },
|
||||
{ x: unit.x, y: camera.y + camera.visibleRows },
|
||||
{ x: unit.x, y: camera.y - 1 }
|
||||
];
|
||||
const destination = candidates.find(
|
||||
(tile) =>
|
||||
tile.x >= 0 &&
|
||||
tile.y >= 0 &&
|
||||
tile.x < camera.mapWidth &&
|
||||
tile.y < camera.mapHeight &&
|
||||
!scene.isTileVisible(tile.x, tile.y)
|
||||
);
|
||||
if (!destination) {
|
||||
throw new Error(`No offscreen tile is available for camera ${JSON.stringify(camera)}`);
|
||||
}
|
||||
unit.x = destination.x;
|
||||
unit.y = destination.y;
|
||||
scene.positionUnitView(unit);
|
||||
const result = scene.tickBattleStatuses('ally');
|
||||
const battle = debug.battle();
|
||||
return {
|
||||
message: result.message,
|
||||
tileVisible: scene.isTileVisible(unit.x, unit.y),
|
||||
feedback: battle.mapResultFeedback,
|
||||
unit: battle.units.find((candidate) => candidate.id === unitId)
|
||||
};
|
||||
}, initial.ally.id);
|
||||
assert.match(offscreenTick.message, /화상/);
|
||||
assert.equal(offscreenTick.tileVisible, false);
|
||||
assert.equal(offscreenTick.feedback.activeCount, 0, 'offscreen burn damage must not clamp a popup to the map edge');
|
||||
assert.equal(offscreenTick.unit.hp, initial.ally.hp - 6);
|
||||
assert.equal(offscreenTick.unit.statusBadges.length, 0, 'expired status badges must be removed after the offscreen tick');
|
||||
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 3000 });
|
||||
const prioritizedBurn = await page.evaluate(() => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const recoveryPlayedAt = debug.audio().semanticCues.playedAt.recovery ?? null;
|
||||
const selected = scene.playTurnStartFeedbackCue('ally', true, true);
|
||||
return { selected, recoveryPlayedAt, audio: debug.audio() };
|
||||
});
|
||||
assert.equal(prioritizedBurn.selected, 'status');
|
||||
assert.equal(prioritizedBurn.audio.semanticCues.lastPlayed.group, 'status');
|
||||
assert.equal(prioritizedBurn.audio.lastEffect.trackKey, 'burn-tick');
|
||||
assert.equal(prioritizedBurn.audio.activeEffectCount, 1, 'burn must be the only cue selected when burn and recovery coincide');
|
||||
assert.equal(
|
||||
prioritizedBurn.audio.semanticCues.playedAt.recovery ?? null,
|
||||
prioritizedBurn.recoveryPlayedAt,
|
||||
'lower-priority recovery must not be layered under burn'
|
||||
);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.audio()?.activeEffectCount === 0, undefined, { timeout: 3000 });
|
||||
const operationPriority = await page.evaluate(() => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const before = debug.audio().semanticCues.lastPlayed;
|
||||
const selected = scene.playTurnStartFeedbackCue('ally', true, true, true);
|
||||
return { before, selected, after: debug.audio().semanticCues.lastPlayed, activeEffectCount: debug.audio().activeEffectCount };
|
||||
});
|
||||
assert.equal(operationPriority.selected, 'operation');
|
||||
assert.deepEqual(operationPriority.after, operationPriority.before, 'an existing operation cue must suppress all additional turn-start cues');
|
||||
assert.equal(operationPriority.activeEffectCount, 0);
|
||||
|
||||
const recoveryCue = await page.evaluate(() => {
|
||||
const debug = window.__HEROS_DEBUG__;
|
||||
const scene = debug.scene('BattleScene');
|
||||
const selected = scene.playTurnStartFeedbackCue('ally', false, true);
|
||||
return { selected, audio: debug.audio() };
|
||||
});
|
||||
assert.equal(recoveryCue.selected, 'recovery');
|
||||
assert.equal(recoveryCue.audio.semanticCues.lastPlayed.group, 'recovery');
|
||||
assert.equal(recoveryCue.audio.lastEffect.trackKey, 'recovery-cue');
|
||||
assert.equal(recoveryCue.audio.activeEffectCount, 1, 'the standalone recovery cue must decode and play in the browser');
|
||||
|
||||
assert.deepEqual(pageErrors, [], `Unexpected page errors: ${pageErrors.join('\n')}`);
|
||||
assert.deepEqual(consoleErrors, [], `Unexpected browser console errors: ${consoleErrors.join('\n')}`);
|
||||
console.log(
|
||||
'Verified 1920x1080 DPR1 paired status badges, decoded and prioritized cues, offscreen popup suppression, expiry, and opening isolation.'
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
serverProcess?.kill();
|
||||
}
|
||||
|
||||
function boundsIntersect(left, right) {
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
left.x + left.width <= right.x ||
|
||||
right.x + right.width <= left.x ||
|
||||
left.y + left.height <= right.y ||
|
||||
right.y + right.height <= left.y
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForUrl(url, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (serverProcess?.exitCode !== null) {
|
||||
throw new Error(`Preview server exited early with code ${serverProcess.exitCode}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Preview startup is still in progress.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${url.href}`);
|
||||
}
|
||||
117
scripts/verify-audiovisual-feedback.mjs
Normal file
117
scripts/verify-audiovisual-feedback.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
|
||||
const campSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8');
|
||||
const soundSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8');
|
||||
|
||||
const tickStatuses = privateMethodBody(battleSource, 'tickBattleStatuses');
|
||||
assert.match(tickStatuses, /let burnTicked = false/);
|
||||
assert.match(tickStatuses, /showMapResultPopup\s*\(/);
|
||||
assert.match(tickStatuses, /`\$\{status\.label\} -\$\{damage\}`/);
|
||||
assert.match(tickStatuses, /flashDamage\s*\(unit, previousHp\)/);
|
||||
assert.match(tickStatuses, /const visibleOnMap = this\.isTileVisible\(unit\.x, unit\.y\)/);
|
||||
assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.showMapResultPopup/s);
|
||||
assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.flashDamage/s);
|
||||
assert.match(tickStatuses, /if \(playCue && burnTicked\)\s*\{\s*soundDirector\.playBurnTickCue\(\)/s);
|
||||
assert.equal(countMatches(tickStatuses, /soundDirector\.playBurnTickCue\(\)/g), 1, 'a direct status tick must play one cue per faction batch');
|
||||
|
||||
const endAllyTurn = privateMethodBody(battleSource, 'endAllyTurn');
|
||||
const beginNextAllyTurn = privateMethodBody(battleSource, 'beginNextAllyTurn');
|
||||
const turnStartCue = privateMethodBody(battleSource, 'playTurnStartFeedbackCue');
|
||||
[endAllyTurn, beginNextAllyTurn].forEach((body) => {
|
||||
assert.match(body, /tickBattleStatuses\('(ally|enemy)', false\)/);
|
||||
assert.match(body, /applyTerrainRecovery\('(ally|enemy)', false\)/);
|
||||
assert.match(body, /playTurnStartFeedbackCue/);
|
||||
assert.match(body, /statusOutcomeDelayMs = statusResult\.burnTicked \? 720 : 0/);
|
||||
assert.match(body, /resolveBattleOutcomeIfNeeded\(statusOutcomeDelayMs\)/);
|
||||
assert.match(body, /statusResult\.burnTicked[\s\S]*playBurnTickCue\(\)/, 'lethal burn must retain its cue before an early return');
|
||||
});
|
||||
const cuePriorityMarkers = ['if (operationCuePlayed)', 'if (burnTicked)', 'if (recoveryApplied)', "if (faction === 'ally')"];
|
||||
cuePriorityMarkers.forEach((marker) => assert.match(turnStartCue, new RegExp(escapeRegExp(marker))));
|
||||
assert.deepEqual(
|
||||
cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)),
|
||||
cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)).toSorted((left, right) => left - right),
|
||||
'turn-start cues must prioritize operation, burn, recovery, then the generic faction cue'
|
||||
);
|
||||
assert.doesNotMatch(turnStartCue, /delayedCall/, 'turn-start feedback must select one cue instead of layering delayed cues');
|
||||
|
||||
const supportMap = privateMethodBody(battleSource, 'playSupportMapPresentation');
|
||||
const supportCutIn = privateMethodBody(battleSource, 'playSupportCutIn');
|
||||
const terrainRecovery = privateMethodBody(battleSource, 'applyTerrainRecovery');
|
||||
const tacticalInitiative = privateMethodBody(battleSource, 'activateTacticalInitiativeCommand');
|
||||
const campSupply = privateMethodBody(campSource, 'useSupply');
|
||||
[supportMap, supportCutIn].forEach((body) => {
|
||||
assert.match(body, /result\.usable\.effect === 'heal'/);
|
||||
assert.match(body, /soundDirector\.playRecoveryCue\(\)/);
|
||||
});
|
||||
assert.match(terrainRecovery, /soundDirector\.playRecoveryCue\(\)/);
|
||||
assert.match(terrainRecovery, /if \(playCue\)/);
|
||||
assert.match(tacticalInitiative, /role === 'support'/);
|
||||
assert.match(tacticalInitiative, /soundDirector\.playRecoveryCue\(\)/);
|
||||
assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/);
|
||||
|
||||
const victoryReward = privateMethodBody(campSource, 'showVictoryRewardAcknowledgement');
|
||||
assert.equal(countMatches(victoryReward, /soundDirector\.playRewardRevealCue\(\)/g), 1, 'victory reward should reveal once');
|
||||
|
||||
const deploymentConfirmation = privateMethodBody(battleSource, 'confirmPreBattleDeployment');
|
||||
assert.match(deploymentConfirmation, /showOpeningBattleEvent\(\)/);
|
||||
assert.doesNotMatch(deploymentConfirmation, /soundDirector\.playSelect\(\)/, 'opening alert must not overlap a selection cue');
|
||||
|
||||
const mapResultPopup = privateMethodBody(battleSource, 'showMapResultPopup');
|
||||
assert.match(mapResultPopup, /!view \|\| !this\.isTileVisible\(unit\.x, unit\.y\)/, 'offscreen units must not create edge-clamped popups');
|
||||
|
||||
const statusBadges = privateMethodBody(battleSource, 'syncUnitStatusBadges');
|
||||
assert.match(statusBadges, /\.slice\(0, 2\)/, 'map status badges must remain capped at two per unit');
|
||||
assert.match(statusBadges, /createBattleUiIcon/);
|
||||
assert.match(statusBadges, /statusEffectIcon/);
|
||||
assert.match(statusBadges, /statusEffectTone/);
|
||||
assert.match(statusBadges, /setMask\(this\.mapMask\)/);
|
||||
assert.match(privateMethodBody(battleSource, 'applyBattleStatus'), /syncUnitStatusBadges/);
|
||||
assert.match(tickStatuses, /syncUnitStatusBadges/);
|
||||
assert.match(tacticalInitiative, /battleStatuses\.delete\(supportTarget\.id\)[\s\S]*syncUnitStatusBadges\(supportTarget\)/);
|
||||
assert.match(privateMethodBody(battleSource, 'applyDefeatedStyle'), /syncUnitStatusBadges/);
|
||||
assert.match(privateMethodBody(battleSource, 'moveUnitViewAsync'), /unitStatusBadgePosition/);
|
||||
assert.match(privateMethodBody(battleSource, 'moveUnitView'), /unitStatusBadgePosition/);
|
||||
assert.match(privateMethodBody(battleSource, 'unitStatusBadgePosition'), /0\.34 \+ index \* spacing/, 'paired badges should fan away from the unit instead of folding inward');
|
||||
assert.match(battleSource, /statusBadgeSide: unit\.faction === 'ally' \? 1 : -1/);
|
||||
assert.match(battleSource, /statusBadges: \(this\.unitViews\.get\(unit\.id\)\?\.statusBadges \?\? \[\]\)\.map/);
|
||||
|
||||
for (const [method, group, key] of [
|
||||
['playRecoveryCue', 'recovery', 'recovery-cue'],
|
||||
['playBurnTickCue', 'status', 'burn-tick'],
|
||||
['playRewardRevealCue', 'reward', 'reward-reveal']
|
||||
]) {
|
||||
const body = publicMethodBody(soundSource, method);
|
||||
assert.match(body, new RegExp(`playSemanticCue\\('${group}', '${key}'`));
|
||||
}
|
||||
assert.match(soundSource, /recovery:\s*1900/, 'recovery cue cooldown should cover its playback window');
|
||||
|
||||
console.log('Verified semantic sound cues, map status feedback, capped status badges, and non-overlapping battle opening audio.');
|
||||
|
||||
function privateMethodBody(source, name) {
|
||||
return methodBody(source, new RegExp(`^ private (?:async )?${name}\\b`, 'm'), `private ${name}`);
|
||||
}
|
||||
|
||||
function publicMethodBody(source, name) {
|
||||
return methodBody(source, new RegExp(`^ (?:async )?${name}\\b`, 'm'), name);
|
||||
}
|
||||
|
||||
function methodBody(source, pattern, label) {
|
||||
const match = pattern.exec(source);
|
||||
assert(match, `missing method marker: ${label}`);
|
||||
const start = match.index;
|
||||
const next = source.indexOf('\n private ', start + match[0].length);
|
||||
const nextPublic = source.indexOf('\n play', start + match[0].length);
|
||||
const candidates = [next, nextPublic].filter((index) => index > start);
|
||||
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function countMatches(source, pattern) {
|
||||
return Array.from(source.matchAll(pattern)).length;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -103,6 +103,9 @@ try {
|
||||
'objective-success': '/audio/objective-success.ogg',
|
||||
'objective-failure': '/audio/objective-failure.ogg',
|
||||
'story-page-turn': '/audio/story-page-turn.ogg',
|
||||
'recovery-cue': '/audio/recovery-cue.ogg',
|
||||
'burn-tick': '/audio/burn-tick.ogg',
|
||||
'reward-reveal': '/audio/reward-reveal.ogg',
|
||||
'ui-select': '/audio/ui-select.ogg'
|
||||
});
|
||||
director.registerEffectPools({
|
||||
@@ -388,7 +391,7 @@ try {
|
||||
debug = director.getDebugState();
|
||||
assert.deepEqual(
|
||||
debug.semanticCues.cooldownMs,
|
||||
{ turn: 900, operation: 650, objective: 1200, narrative: 220 },
|
||||
{ turn: 900, operation: 650, objective: 1200, narrative: 220, recovery: 1900, status: 280, reward: 1100 },
|
||||
'semantic cue cooldowns should be visible in debug state'
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -447,6 +450,41 @@ try {
|
||||
clock.advance(1100);
|
||||
assert.equal(director.getDebugState().activeEffectCount, 0, 'story advance cues should retire after playback');
|
||||
|
||||
const feedbackCueStart = audioHarness.instances.length;
|
||||
const musicDuckRevisionBeforeFeedback = director.getDebugState().musicDuck.revision;
|
||||
assert.equal(director.playRecoveryCue(), true, 'the first recovery cue should play');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/recovery-cue.ogg').volume, 0.26, 'recovery cue volume');
|
||||
assert.equal(director.playRecoveryCue(), false, 'rapid recovery cues should be coalesced');
|
||||
assert.equal(director.playBurnTickCue(), true, 'the first batched burn cue should play');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/burn-tick.ogg').volume, 0.24, 'burn cue volume');
|
||||
assert.equal(director.playBurnTickCue(), false, 'rapid burn cues should be coalesced');
|
||||
assert.equal(director.playRewardRevealCue(), true, 'the first reward reveal cue should play');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/reward-reveal.ogg').volume, 0.26, 'reward reveal cue volume');
|
||||
assert.equal(director.playRewardRevealCue(), false, 'rapid reward reveal cues should be coalesced');
|
||||
debug = director.getDebugState();
|
||||
assert.equal(
|
||||
debug.musicDuck.revision,
|
||||
musicDuckRevisionBeforeFeedback + 1,
|
||||
'only the reward reveal cue should apply a short music duck'
|
||||
);
|
||||
assert.deepEqual(
|
||||
debug.semanticCues.lastSuppressed,
|
||||
{ group: 'reward', key: 'reward-reveal', at: clock.now, remainingMs: 1100 },
|
||||
'debug state should expose a coalesced reward reveal cue'
|
||||
);
|
||||
assert.equal(audioHarness.instances.length, feedbackCueStart + 3, 'coalesced feedback cues must not construct Audio');
|
||||
clock.advance(280);
|
||||
assert.equal(director.playRecoveryCue(), false, 'recovery cues should remain coalesced for the clip playback window');
|
||||
assert.equal(director.playBurnTickCue(), true, 'burn cues should replay after their cooldown');
|
||||
assert.equal(director.playRewardRevealCue(), false, 'reward cues should retain their longer arrival cooldown');
|
||||
clock.advance(820);
|
||||
assert.equal(director.playRewardRevealCue(), true, 'reward cues should replay after the arrival cooldown');
|
||||
assert.equal(director.playRecoveryCue(), false, 'recovery cues should still be coalesced before the clip finishes');
|
||||
clock.advance(800);
|
||||
assert.equal(director.playRecoveryCue(), true, 'recovery cues should replay after the full clip playback window');
|
||||
clock.advance(2000);
|
||||
assert.equal(director.getDebugState().activeEffectCount, 0, 'semantic feedback cues should retire after playback');
|
||||
|
||||
const semanticEffectStart = audioHarness.instances.length;
|
||||
director.playMeleeSwing(true, false);
|
||||
assert.equal(director.getDebugState().lastEffect?.key, 'melee-swing', 'melee API should use the swing pool');
|
||||
@@ -485,7 +523,7 @@ try {
|
||||
console.log(
|
||||
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
|
||||
`${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` +
|
||||
'coalesced tactical and narrative cues, semantic combat effects, category multipliers, and deterministic music ducking.'
|
||||
'coalesced tactical, narrative, and feedback cues, semantic combat effects, category multipliers, and deterministic music ducking.'
|
||||
);
|
||||
} finally {
|
||||
restoreGlobals();
|
||||
|
||||
@@ -33,6 +33,7 @@ const checks = [
|
||||
'scripts/verify-battle-map-asset-data.mjs',
|
||||
'scripts/verify-battle-environment-profiles.mjs',
|
||||
'scripts/verify-audio-asset-data.mjs',
|
||||
'scripts/verify-audiovisual-feedback.mjs',
|
||||
'scripts/verify-sound-director.mjs'
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user