feat: deepen audiovisual story immersion
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, sep } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const audioRoot = join('src', 'assets', 'audio');
|
||||
@@ -21,6 +22,20 @@ const requiredTacticalCueKeys = [
|
||||
];
|
||||
const requiredNarrativeCueKeys = ['story-page-turn'];
|
||||
const requiredSemanticFeedbackCueKeys = ['recovery-cue', 'reward-reveal', 'burn-tick'];
|
||||
const movementPoolSourceRecords = {
|
||||
'movement-foot-earth': movementSourceRecord('footstep', 'earth', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'),
|
||||
'movement-foot-stone': movementSourceRecord('footstep', 'stone', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'),
|
||||
'movement-foot-wood': movementSourceRecord('footstep', 'wood', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'),
|
||||
'movement-foot-wet': movementSourceRecord('footstep', 'wet', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'),
|
||||
'movement-hoof-earth': movementSourceRecord('hoof', 'earth', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/'),
|
||||
'movement-hoof-stone': movementSourceRecord('hoof', 'stone', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/'),
|
||||
'movement-hoof-wood': movementSourceRecord('hoof', 'wood', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/'),
|
||||
'movement-hoof-wet': movementSourceRecord('hoof', 'wet', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/')
|
||||
};
|
||||
const compensatedPoolKeys = ['melee-swing', 'melee-impact', 'arrow-impact'];
|
||||
const ffmpegCommand = process.env.FFMPEG_PATH?.trim() || 'ffmpeg';
|
||||
const ffprobeCommand = process.env.FFPROBE_PATH?.trim() || 'ffprobe';
|
||||
let audioInspectionToolStatus;
|
||||
const requiredBattleSceneAudioMethods = [
|
||||
'playSoundscape',
|
||||
'playAllyTurnCue',
|
||||
@@ -91,7 +106,13 @@ const server = await createServer({
|
||||
|
||||
try {
|
||||
const audioAssets = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
const { musicTracks, ambienceTracks, effectTracks } = audioAssets;
|
||||
const {
|
||||
musicTracks,
|
||||
ambienceTracks,
|
||||
effectTracks,
|
||||
effectTrackGainCompensation = {},
|
||||
movementEffectPoolsBySurface = {}
|
||||
} = audioAssets;
|
||||
const effectPools = audioAssets.effectPools ?? {};
|
||||
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
|
||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||
@@ -107,8 +128,16 @@ try {
|
||||
validateBattlefieldSourceDocumentation(errors);
|
||||
validateNarrativeSourceDocumentation(errors);
|
||||
validateSemanticFeedbackSourceDocumentation(errors);
|
||||
validateMovementSourceDocumentation(errors);
|
||||
validateBattleSceneAudioIntegration(errors);
|
||||
validateEffectPools(errors, effectPools, effectTracks);
|
||||
validateMovementEffectPools(
|
||||
errors,
|
||||
effectPools,
|
||||
effectTracks,
|
||||
movementEffectPoolsBySurface
|
||||
);
|
||||
validateEffectPoolMixBalance(errors, effectPools, effectTracks, effectTrackGainCompensation);
|
||||
validateEffectPoolRegistration(errors, effectPools);
|
||||
validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks);
|
||||
validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks);
|
||||
@@ -125,6 +154,7 @@ try {
|
||||
console.log(
|
||||
`Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(ambienceTracks).length} ambience tracks, ` +
|
||||
`${Object.keys(effectTracks).length} effect tracks, ${Object.keys(effectPools).length} effect pools, ` +
|
||||
`${Object.keys(movementPoolSourceRecords).length} terrain movement pools, balanced randomized effects, ` +
|
||||
`${collectStoryBgmReferences(scenarioModule, battleScenarios).length} story BGM references, and literal audio calls.`
|
||||
);
|
||||
}
|
||||
@@ -156,6 +186,115 @@ function validateEffectPools(errors, effectPools, effectTracks) {
|
||||
});
|
||||
}
|
||||
|
||||
function validateMovementEffectPools(errors, effectPools, effectTracks, movementEffectPoolsBySurface) {
|
||||
const expectedSurfaceEntries = {
|
||||
earth: { foot: 'movement-foot-earth', mounted: 'movement-hoof-earth' },
|
||||
stone: { foot: 'movement-foot-stone', mounted: 'movement-hoof-stone' },
|
||||
wood: { foot: 'movement-foot-wood', mounted: 'movement-hoof-wood' },
|
||||
wet: { foot: 'movement-foot-wet', mounted: 'movement-hoof-wet' }
|
||||
};
|
||||
|
||||
if (JSON.stringify(movementEffectPoolsBySurface) !== JSON.stringify(expectedSurfaceEntries)) {
|
||||
errors.push('movementEffectPoolsBySurface must map earth/stone/wood/wet to their foot and mounted pools');
|
||||
}
|
||||
|
||||
if (!audioInspectionToolsAvailable(errors)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(movementPoolSourceRecords).forEach(([poolKey, record]) => {
|
||||
const trackKeys = effectPools[poolKey];
|
||||
if (JSON.stringify(trackKeys) !== JSON.stringify(record.trackKeys)) {
|
||||
errors.push(`${poolKey} must contain exactly ${record.trackKeys.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const meanVolumes = [];
|
||||
trackKeys.forEach((trackKey) => {
|
||||
const url = effectTracks[trackKey];
|
||||
const path = url ? assetUrlToPath(url) : undefined;
|
||||
if (!path) {
|
||||
errors.push(`${poolKey} references movement track "${trackKey}" without a readable project path`);
|
||||
return;
|
||||
}
|
||||
|
||||
const probe = probeAudio(path, errors, trackKey);
|
||||
const volume = measureAudioVolume(path, errors, trackKey);
|
||||
if (!probe || !volume) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (probe.duration < 0.3 || probe.duration > 0.52) {
|
||||
errors.push(`${trackKey} must remain a 0.30-0.52 second one-shot; received ${probe.duration.toFixed(3)} seconds`);
|
||||
}
|
||||
if (probe.sampleRate !== 48_000 || probe.channels !== 2) {
|
||||
errors.push(`${trackKey} must be 48 kHz stereo; received ${probe.sampleRate} Hz/${probe.channels} channels`);
|
||||
}
|
||||
if (!probe.artist.includes(record.creator)) {
|
||||
errors.push(`${trackKey} metadata must retain original creator "${record.creator}"`);
|
||||
}
|
||||
if (!probe.comment.includes(record.parentFile) || !probe.comment.includes(record.source)) {
|
||||
errors.push(`${trackKey} metadata must retain parent file and Pixabay source lineage`);
|
||||
}
|
||||
if (volume.meanDb < -28 || volume.meanDb > -20) {
|
||||
errors.push(`${trackKey} mean level ${volume.meanDb.toFixed(1)} dB is outside the quiet movement mix`);
|
||||
}
|
||||
if (volume.peakDb > -2 || volume.peakDb < -15) {
|
||||
errors.push(`${trackKey} peak ${volume.peakDb.toFixed(1)} dB is outside the -15..-2 dB safety window`);
|
||||
}
|
||||
meanVolumes.push(volume.meanDb);
|
||||
});
|
||||
|
||||
if (meanVolumes.length === record.trackKeys.length) {
|
||||
const spread = Math.max(...meanVolumes) - Math.min(...meanVolumes);
|
||||
if (spread > 5) {
|
||||
errors.push(`${poolKey} one-shot mean-level spread ${spread.toFixed(1)} dB exceeds 5 dB`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateEffectPoolMixBalance(errors, effectPools, effectTracks, gainCompensation) {
|
||||
Object.entries(gainCompensation).forEach(([trackKey, gain]) => {
|
||||
if (!(trackKey in effectTracks)) {
|
||||
errors.push(`effect gain compensation references unknown track "${trackKey}"`);
|
||||
}
|
||||
if (!Number.isFinite(gain) || gain < 0.5 || gain > 2) {
|
||||
errors.push(`effect gain compensation for "${trackKey}" must stay within 0.5..2`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!audioInspectionToolsAvailable(errors)) {
|
||||
return;
|
||||
}
|
||||
|
||||
compensatedPoolKeys.forEach((poolKey) => {
|
||||
const adjustedLoudness = [];
|
||||
(effectPools[poolKey] ?? []).forEach((trackKey) => {
|
||||
const gain = gainCompensation[trackKey];
|
||||
if (!Number.isFinite(gain)) {
|
||||
errors.push(`${poolKey}/${trackKey} needs explicit playback gain compensation`);
|
||||
return;
|
||||
}
|
||||
const path = assetUrlToPath(effectTracks[trackKey] ?? '');
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
const loudness = measureIntegratedLoudness(path, errors, trackKey);
|
||||
if (loudness !== undefined) {
|
||||
adjustedLoudness.push(loudness + 20 * Math.log10(gain));
|
||||
}
|
||||
});
|
||||
|
||||
if (adjustedLoudness.length === (effectPools[poolKey]?.length ?? 0)) {
|
||||
const spread = Math.max(...adjustedLoudness) - Math.min(...adjustedLoudness);
|
||||
if (spread > 0.8) {
|
||||
errors.push(`${poolKey} compensated integrated-loudness spread ${spread.toFixed(2)} LU exceeds 0.8 LU`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateRequiredTrackKeys(errors, label, tracks, requiredKeys) {
|
||||
requiredKeys.forEach((key) => {
|
||||
if (!(key in tracks)) {
|
||||
@@ -230,6 +369,38 @@ function validateSemanticFeedbackSourceDocumentation(errors) {
|
||||
});
|
||||
}
|
||||
|
||||
function validateMovementSourceDocumentation(errors) {
|
||||
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
|
||||
const sectionHeading = '## Derived Terrain Movement One-shots';
|
||||
const sectionStart = docs.indexOf(sectionHeading);
|
||||
if (sectionStart < 0) {
|
||||
errors.push('docs/audio-sources.md must include the Derived Terrain Movement One-shots section');
|
||||
return;
|
||||
}
|
||||
const nextSectionStart = docs.indexOf('\n## ', sectionStart + sectionHeading.length);
|
||||
const movementDocs = docs.slice(sectionStart, nextSectionStart < 0 ? docs.length : nextSectionStart);
|
||||
[
|
||||
'2026-07-23',
|
||||
'48 kHz stereo MP3',
|
||||
'8 ms attack',
|
||||
'55 ms release',
|
||||
'terrain-specific EQ',
|
||||
'per-track gain compensation'
|
||||
].forEach((detail) => {
|
||||
if (!movementDocs.includes(detail)) {
|
||||
errors.push(`docs/audio-sources.md is missing movement processing detail "${detail}"`);
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(movementPoolSourceRecords).forEach((record) => {
|
||||
[record.projectPattern, record.parentProjectFile, record.creator, record.source].forEach((detail) => {
|
||||
if (!movementDocs.includes(detail)) {
|
||||
errors.push(`docs/audio-sources.md is missing movement source detail "${detail}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function validateBattleSceneAudioIntegration(errors) {
|
||||
const path = join('src', 'game', 'scenes', 'BattleScene.ts');
|
||||
const source = readFileSync(path, 'utf8');
|
||||
@@ -450,6 +621,104 @@ function firstArgumentEnd(source, startIndex) {
|
||||
return source.length;
|
||||
}
|
||||
|
||||
function movementSourceRecord(prefix, surface, parentFile, creator, source) {
|
||||
return {
|
||||
trackKeys: [`${prefix}-${surface}-1`, `${prefix}-${surface}-2`],
|
||||
projectPattern: `src/assets/audio/sfx/${prefix}-${surface}-{1,2}.mp3`,
|
||||
parentFile,
|
||||
parentProjectFile: `src/assets/audio/sfx/${parentFile}`,
|
||||
creator,
|
||||
source
|
||||
};
|
||||
}
|
||||
|
||||
function audioInspectionToolsAvailable(errors) {
|
||||
if (!audioInspectionToolStatus) {
|
||||
const ffmpeg = spawnSync(ffmpegCommand, ['-version'], { encoding: 'utf8', windowsHide: true });
|
||||
const ffprobe = spawnSync(ffprobeCommand, ['-version'], { encoding: 'utf8', windowsHide: true });
|
||||
audioInspectionToolStatus = {
|
||||
available: ffmpeg.status === 0 && ffprobe.status === 0,
|
||||
reported: false,
|
||||
detail: [ffmpeg.error?.message, ffprobe.error?.message].filter(Boolean).join('; ')
|
||||
};
|
||||
}
|
||||
|
||||
if (!audioInspectionToolStatus.available && !audioInspectionToolStatus.reported) {
|
||||
errors.push(
|
||||
`ffmpeg and ffprobe are required for movement and pool loudness verification` +
|
||||
(audioInspectionToolStatus.detail ? `: ${audioInspectionToolStatus.detail}` : '')
|
||||
);
|
||||
audioInspectionToolStatus.reported = true;
|
||||
}
|
||||
return audioInspectionToolStatus.available;
|
||||
}
|
||||
|
||||
function probeAudio(path, errors, context) {
|
||||
const result = spawnSync(
|
||||
ffprobeCommand,
|
||||
[
|
||||
'-v', 'error',
|
||||
'-select_streams', 'a:0',
|
||||
'-show_entries', 'format=duration:stream=sample_rate,channels:format_tags=artist,comment',
|
||||
'-of', 'json',
|
||||
path
|
||||
],
|
||||
{ encoding: 'utf8', maxBuffer: 1024 * 1024, windowsHide: true }
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
errors.push(`${context}: ffprobe failed: ${(result.stderr || result.error?.message || '').trim()}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
const stream = parsed.streams?.[0] ?? {};
|
||||
const tags = parsed.format?.tags ?? {};
|
||||
return {
|
||||
duration: Number(parsed.format?.duration),
|
||||
sampleRate: Number(stream.sample_rate),
|
||||
channels: Number(stream.channels),
|
||||
artist: String(tags.artist ?? ''),
|
||||
comment: String(tags.comment ?? '')
|
||||
};
|
||||
} catch (error) {
|
||||
errors.push(`${context}: unreadable ffprobe JSON: ${error.message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function measureAudioVolume(path, errors, context) {
|
||||
const result = spawnSync(
|
||||
ffmpegCommand,
|
||||
['-hide_banner', '-nostats', '-i', path, '-af', 'volumedetect', '-f', 'null', '-'],
|
||||
{ encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, windowsHide: true }
|
||||
);
|
||||
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
const meanMatch = [...output.matchAll(/mean_volume:\s*(-?\d+(?:\.\d+)?)\s*dB/g)].at(-1);
|
||||
const peakMatch = [...output.matchAll(/max_volume:\s*(-?\d+(?:\.\d+)?)\s*dB/g)].at(-1);
|
||||
if (result.status !== 0 || !meanMatch || !peakMatch) {
|
||||
errors.push(`${context}: ffmpeg could not measure mean/peak volume`);
|
||||
return undefined;
|
||||
}
|
||||
return { meanDb: Number(meanMatch[1]), peakDb: Number(peakMatch[1]) };
|
||||
}
|
||||
|
||||
function measureIntegratedLoudness(path, errors, context) {
|
||||
const result = spawnSync(
|
||||
ffmpegCommand,
|
||||
['-hide_banner', '-nostats', '-i', path, '-filter_complex', 'ebur128=peak=true', '-f', 'null', '-'],
|
||||
{ encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, windowsHide: true }
|
||||
);
|
||||
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
const matches = [...output.matchAll(/\bI:\s*(-?\d+(?:\.\d+)?)\s*LUFS/g)];
|
||||
const loudness = Number(matches.at(-1)?.[1]);
|
||||
if (result.status !== 0 || !Number.isFinite(loudness) || loudness <= -70) {
|
||||
errors.push(`${context}: ffmpeg could not measure integrated loudness`);
|
||||
return undefined;
|
||||
}
|
||||
return loudness;
|
||||
}
|
||||
|
||||
function assetUrlToPath(url) {
|
||||
const marker = 'src/assets/audio/';
|
||||
const index = url.indexOf(marker);
|
||||
|
||||
@@ -63,6 +63,13 @@ assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/);
|
||||
const victoryReward = privateMethodBody(campSource, 'showVictoryRewardAcknowledgement');
|
||||
assert.equal(countMatches(victoryReward, /soundDirector\.playRewardRevealCue\(\)/g), 1, 'victory reward should reveal once');
|
||||
|
||||
const progressChapterDetail = privateMethodBody(campSource, 'renderProgressChapterDetail');
|
||||
assert.match(progressChapterDetail, /const battleTitleWidth = 116/);
|
||||
assert.match(progressChapterDetail, /const objectiveX = battleTitleX \+ battleTitleWidth \+ 10/);
|
||||
assert.match(progressChapterDetail, /compactText\(battle\.title, 10\)[\s\S]*fixedWidth: battleTitleWidth/);
|
||||
assert.match(progressChapterDetail, /compactText\(battle\.victoryConditionLabel, 15\)[\s\S]*fixedWidth: objectiveWidth/);
|
||||
assert.match(progressChapterDetail, /setData\('timelineField', 'battle-title'\)[\s\S]*setData\('timelineField', 'victory-condition'\)/);
|
||||
|
||||
const deploymentConfirmation = privateMethodBody(battleSource, 'confirmPreBattleDeployment');
|
||||
assert.match(deploymentConfirmation, /showOpeningBattleEvent\(\)/);
|
||||
assert.doesNotMatch(deploymentConfirmation, /soundDirector\.playSelect\(\)/, 'opening alert must not overlap a selection cue');
|
||||
@@ -72,6 +79,18 @@ assert.match(resolveDamageTarget, /triggerBattleEvent\('first-engagement'[\s\S]*
|
||||
const triggerBattleEvent = privateMethodBody(battleSource, 'triggerBattleEvent');
|
||||
assert.match(triggerBattleEvent, /options\.playCue !== false/);
|
||||
|
||||
const movementSound = privateMethodBody(battleSource, 'playMovementSound');
|
||||
assert.match(movementSound, /const minInterval = isMounted \? 125 : 180/);
|
||||
assert.match(movementSound, /Math\.floor\(duration \/ minInterval\) \+ 1/);
|
||||
assert.match(movementSound, /pulseCount > 1 \? duration \/ \(pulseCount - 1\) : 0/);
|
||||
assert.match(movementSound, /playMovementStep\(isMounted, index, \{\s*terrain,/s);
|
||||
assert.doesNotMatch(movementSound, /stopAfterMs/, 'movement one-shots should clean up on ended instead of truncating each step');
|
||||
assert.equal(
|
||||
countMatches(battleSource, /playMovementSound\(unit, movementDuration, movementDistance, battleMap\.terrain\[y\]\?\.\[x\]\)/g),
|
||||
2,
|
||||
'both synchronous and asynchronous movement paths must pass destination terrain to movement audio'
|
||||
);
|
||||
|
||||
const mapCombat = privateMethodBody(battleSource, 'playCombatMapPresentation');
|
||||
assert.match(mapCombat, /contactDelayMs = Math\.max\(70, Math\.round\(duration \* 0\.42\)\)/);
|
||||
assert.match(mapCombat, /await this\.waitSceneDuration\(contactDelayMs\);\s*this\.playCombatContactSound\(result\)/s);
|
||||
|
||||
75
scripts/verify-battle-event-queue.mjs
Normal file
75
scripts/verify-battle-event-queue.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
|
||||
const saveSource = readFileSync('src/game/state/battleSaveState.ts', 'utf8');
|
||||
|
||||
const triggerEvent = privateMethodBody(battleSource, 'triggerBattleEvent');
|
||||
assert.match(triggerEvent, /this\.isBattleEventKnown\(key\)/, 'event detection must reject completed, active, and queued duplicates');
|
||||
assert.match(triggerEvent, /battleEventPriorityRank/, 'event insertion must compare explicit priorities');
|
||||
assert.match(triggerEvent, /this\.battleEventQueue\.splice\(insertionIndex, 0, event\)/, 'higher-priority events must enter ahead of lower priorities');
|
||||
assert.match(triggerEvent, /this\.battleEventQueue\.push\(event\)/, 'equal priorities must retain FIFO order');
|
||||
assert.match(triggerEvent, /if \(!this\.deferBattleEventPresentation\)/, 'batched event detection must defer presentation until priority sorting finishes');
|
||||
assert.doesNotMatch(triggerEvent, /triggeredBattleEvents\.add/, 'queued events must not complete before presentation');
|
||||
|
||||
const showNextEvent = privateMethodBody(battleSource, 'showNextBattleEvent');
|
||||
assert.match(showNextEvent, /this\.battleEventQueue\.shift\(\)/, 'presentation must consume the queue head');
|
||||
assert.match(showNextEvent, /this\.activeBattleEvent = event/, 'presentation must retain the active event until dismissal');
|
||||
assert.match(showNextEvent, /queuedEvent\.presented = true/, 'lower-priority notices in the same batch must be recorded without opening more modals');
|
||||
assert.match(showNextEvent, /title: `\$\{event\.title\} · 외 \$\{groupedEvents\.length\}건`/, 'the visible notice must disclose how many grouped updates were logged');
|
||||
assert.match(showNextEvent, /this\.showBattleEventBanner\(/, 'the queue head must be rendered');
|
||||
|
||||
const completeEvent = privateMethodBody(battleSource, 'completeActiveBattleEvent');
|
||||
assert.match(completeEvent, /this\.triggeredBattleEvents\.add\(completedEvent\.key\)/, 'an event may complete only after its banner closes');
|
||||
assert.doesNotMatch(completeEvent, /showNextBattleEvent/, 'closing one banner must not chain every queued notice into a modal burst');
|
||||
assert.match(completeEvent, /this\.battleEventQueue\.splice\(0\)/, 'closing the grouped banner must drain the same-action notice batch');
|
||||
assert.match(completeEvent, /this\.triggeredBattleEvents\.add\(groupedEvent\.key\)/, 'grouped notices recorded in the battle log must complete with the visible banner');
|
||||
|
||||
const checkEvents = privateMethodBody(battleSource, 'checkBattleEvents');
|
||||
assert.match(checkEvents, /this\.deferBattleEventPresentation = true/, 'one action must collect every newly satisfied event before choosing what to show');
|
||||
assert.match(checkEvents, /this\.collectBattleEvents\(\)/, 'one action must collect event candidates as a batch');
|
||||
assert.match(checkEvents, /this\.deferBattleEventPresentation = false/, 'event presentation deferral must always be released');
|
||||
assert.match(checkEvents, /this\.showNextBattleEvent\(\)/, 'one action may present the highest-priority queued notice after collection');
|
||||
|
||||
const clearEvents = privateMethodBody(battleSource, 'clearBattleEvents');
|
||||
assert.match(clearEvents, /this\.battleEventQueue = \[\]/, 'battle shutdown must discard pending events');
|
||||
assert.match(clearEvents, /this\.activeBattleEvent = undefined/, 'battle shutdown must discard the active event');
|
||||
|
||||
const createSave = privateMethodBody(battleSource, 'createBattleSaveState');
|
||||
const applySave = privateMethodBody(battleSource, 'applyBattleSaveState');
|
||||
assert.match(createSave, /pendingBattleEvents: this\.pendingBattleEventsForSave\(\)/, 'battle saves must retain active and queued events');
|
||||
assert.match(applySave, /state\.pendingBattleEvents \?\? \[\]/, 'battle loads must restore pending events');
|
||||
assert.match(applySave, /showNextBattleEvent\(\)/, 'battle loads must resume presentation');
|
||||
assert.match(saveSource, /pendingBattleEvents\?: BattleSavePendingEvent\[\]/, 'pending event saves must remain an optional version-1 field');
|
||||
|
||||
const pendingOutcome = privateMethodBody(battleSource, 'pendingBattleOutcome');
|
||||
assert.match(pendingOutcome, /requiredVictoryObjectiveStates\(\)/, 'victory must consult required narrative objectives');
|
||||
assert.match(pendingOutcome, /victory-gate-pending/, 'an unmet narrative gate must explain why battle continues');
|
||||
|
||||
const triggerTacticalEvent = privateMethodBody(battleSource, 'triggerTacticalEvent');
|
||||
const tacticalReaction = privateMethodBody(battleSource, 'tacticalEventReaction');
|
||||
assert.match(triggerTacticalEvent, /reaction \? \[reaction\.line\] : \[\]/, 'a valid deployed officer reaction must be appended to tactical event lines');
|
||||
assert.match(tacticalReaction, /unit\.faction === 'ally' && unit\.hp > 0/, 'reaction candidates must be deployed, allied, and alive');
|
||||
assert.match(battleSource, /tacticalEventReactions: this\.tacticalEventReactionHistory\.map/, 'debug state must expose the selected tactical speakers');
|
||||
assert.match(battleSource, /const maxBodyLines = 3/, 'the tactical reaction line must remain visible in the event banner');
|
||||
const reactionTable = sourceBlock(battleSource, 'const tacticalEventReactionCandidates', '\n};');
|
||||
const reactionUnitIds = new Set(Array.from(reactionTable.matchAll(/unitId: '([^']+)'/g), (match) => match[1]));
|
||||
assert.equal(reactionUnitIds.size, 8, 'the focused tactical reaction table should use exactly eight representative officers');
|
||||
|
||||
console.log('Verified prioritized FIFO battle-event presentation, one-modal grouped pacing, completion timing, save resume, cleanup, narrative victory gates, and deployed-officer reactions.');
|
||||
|
||||
function privateMethodBody(source, name) {
|
||||
const marker = new RegExp(`^ private (?:async )?${name}\\b`, 'm').exec(source);
|
||||
assert(marker, `missing private method: ${name}`);
|
||||
const start = marker.index;
|
||||
const next = source.indexOf('\n private ', start + marker[0].length);
|
||||
return source.slice(start, next > start ? next : source.length);
|
||||
}
|
||||
|
||||
function sourceBlock(source, startMarker, endMarker) {
|
||||
const start = source.indexOf(startMarker);
|
||||
assert(start >= 0, `missing source marker: ${startMarker}`);
|
||||
const end = source.indexOf(endMarker, start);
|
||||
assert(end > start, `missing source end marker after: ${startMarker}`);
|
||||
return source.slice(start, end + endMarker.length);
|
||||
}
|
||||
@@ -71,6 +71,34 @@ try {
|
||||
isValidBattleSaveState(legacyStateWithoutSortieOrder, options),
|
||||
'Expected legacy battle saves without a sortie order to remain valid.'
|
||||
);
|
||||
const validPendingBattleEventState = {
|
||||
...validState,
|
||||
pendingBattleEvents: [
|
||||
{
|
||||
key: 'leader-wavering',
|
||||
title: '두령 동요',
|
||||
lines: ['두령의 기세가 꺾였습니다.', '전열을 몰아붙이십시오.'],
|
||||
priority: 'high',
|
||||
playCue: true,
|
||||
presented: true
|
||||
},
|
||||
{
|
||||
key: 'objective-village-approach',
|
||||
title: '마을 접근',
|
||||
lines: ['마을 주변의 방어 병력을 확인했습니다.'],
|
||||
priority: 'low',
|
||||
playCue: true
|
||||
}
|
||||
]
|
||||
};
|
||||
const parsedPendingBattleEvents = parseBattleSaveState(JSON.stringify(validPendingBattleEventState), options)?.pendingBattleEvents;
|
||||
assert(
|
||||
isValidBattleSaveState(validPendingBattleEventState, options) &&
|
||||
parsedPendingBattleEvents?.length === 2 &&
|
||||
parsedPendingBattleEvents[0]?.presented === true &&
|
||||
parsedPendingBattleEvents !== validPendingBattleEventState.pendingBattleEvents,
|
||||
'Expected active and queued battle events to round-trip without changing save version 1.'
|
||||
);
|
||||
const validRecommendationState = {
|
||||
...validState,
|
||||
sortieRecommendation: {
|
||||
@@ -444,7 +472,21 @@ try {
|
||||
['invalid triggered events', { triggeredBattleEvents: [1] }],
|
||||
['unknown triggered event', { triggeredBattleEvents: ['phantom-event'] }],
|
||||
['duplicate triggered event', { triggeredBattleEvents: ['opening', 'opening'] }],
|
||||
['too long triggered event', { triggeredBattleEvents: ['x'.repeat(97)] }]
|
||||
['too long triggered event', { triggeredBattleEvents: ['x'.repeat(97)] }],
|
||||
['invalid pending events shape', { pendingBattleEvents: [1] }],
|
||||
['unknown pending event', { pendingBattleEvents: [{ key: 'phantom-event', title: 'Unknown', lines: ['Unknown'], priority: 'normal', playCue: true }] }],
|
||||
['duplicate pending event', { pendingBattleEvents: [
|
||||
{ key: 'leader-wavering', title: 'First', lines: ['First'], priority: 'high', playCue: true },
|
||||
{ key: 'leader-wavering', title: 'Second', lines: ['Second'], priority: 'normal', playCue: false }
|
||||
] }],
|
||||
['completed event cannot remain pending', { pendingBattleEvents: [{ key: 'opening', title: 'Opening', lines: ['Opening'], priority: 'critical', playCue: true }] }],
|
||||
['invalid pending event priority', { pendingBattleEvents: [{ key: 'leader-wavering', title: 'Leader', lines: ['Leader'], priority: 'urgent', playCue: true }] }],
|
||||
['invalid pending event cue', { pendingBattleEvents: [{ key: 'leader-wavering', title: 'Leader', lines: ['Leader'], priority: 'high', playCue: 'yes' }] }],
|
||||
['empty pending event lines', { pendingBattleEvents: [{ key: 'leader-wavering', title: 'Leader', lines: [], priority: 'high', playCue: true }] }],
|
||||
['presented pending event must be first', { pendingBattleEvents: [
|
||||
{ key: 'leader-wavering', title: 'Leader', lines: ['Leader'], priority: 'high', playCue: true },
|
||||
{ key: 'objective-village-approach', title: 'Village', lines: ['Village'], priority: 'low', playCue: true, presented: true }
|
||||
] }]
|
||||
];
|
||||
|
||||
rejectedCases.forEach(([label, patch]) => {
|
||||
|
||||
@@ -35,6 +35,9 @@ try {
|
||||
...(campaignRecruitUnits ?? []).map((unit) => unit.id)
|
||||
]);
|
||||
const errors = [];
|
||||
const expectedRequiredVictoryObjectives = new Map([
|
||||
['seventeenth-battle-wolong-visit-road', new Set(['scholar-rendezvous', 'longzhong-cottage'])]
|
||||
]);
|
||||
|
||||
scenarioEntries.forEach(([scenarioId, scenario]) => {
|
||||
const context = scenarioId;
|
||||
@@ -49,6 +52,7 @@ try {
|
||||
validateMap(errors, scenario, terrainKeys, context);
|
||||
validateUnits(errors, scenario, classKeys, itemKeys, equipmentSlots, context);
|
||||
validateObjectives(errors, scenario, terrainKeys, context);
|
||||
validateRequiredVictoryObjectives(errors, scenario, expectedRequiredVictoryObjectives.get(scenarioId), context);
|
||||
validateTacticalGuide(errors, scenario, tacticalGuideEventKeys, context);
|
||||
validateSortie(errors, scenario, classKeys, formationRoles, terrainRules, context);
|
||||
validateRewards(errors, scenario, scenarioIds, itemNames, knownRewardUnitIds, context);
|
||||
@@ -79,8 +83,12 @@ try {
|
||||
total + scenario.objectives.filter((objective) => objective.kind === 'secure-terrain').length,
|
||||
0
|
||||
);
|
||||
const requiredVictoryObjectiveCount = scenarioEntries.reduce(
|
||||
(total, [, scenario]) => total + scenario.objectives.filter((objective) => objective.requiredForVictory).length,
|
||||
0
|
||||
);
|
||||
console.log(
|
||||
`Verified ${scenarioEntries.length} battle scenarios, ${unitCount} unit placements, maps, opening briefs, objectives (${secureTerrainObjectiveCount} secure-terrain centers, ${secureTerrainOverrideCount} explicit overrides), sortie data, ${tacticalGuideCount} tactical guides, equipment, and ${rewardCount} reward labels.`
|
||||
`Verified ${scenarioEntries.length} battle scenarios, ${unitCount} unit placements, maps, opening briefs, objectives (${secureTerrainObjectiveCount} secure-terrain centers, ${requiredVictoryObjectiveCount} required victory gates, ${secureTerrainOverrideCount} explicit overrides), sortie data, ${tacticalGuideCount} tactical guides, equipment, and ${rewardCount} reward labels.`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -206,6 +214,12 @@ function validateObjectives(errors, scenario, terrainKeys, context) {
|
||||
if (!Number.isFinite(objective.rewardGold) || objective.rewardGold < 0) {
|
||||
errors.push(`${context}/${objective.id}: invalid rewardGold ${objective.rewardGold}`);
|
||||
}
|
||||
if (objective.requiredForVictory !== undefined && typeof objective.requiredForVictory !== 'boolean') {
|
||||
errors.push(`${context}/${objective.id}: requiredForVictory must be a boolean`);
|
||||
}
|
||||
if (objective.requiredForVictory && objective.kind !== 'secure-terrain') {
|
||||
errors.push(`${context}/${objective.id}: only secure-terrain objectives may gate victory`);
|
||||
}
|
||||
if (objective.unitId && !unitIds.has(objective.unitId)) {
|
||||
errors.push(`${context}/${objective.id}: references missing unit "${objective.unitId}"`);
|
||||
}
|
||||
@@ -229,6 +243,30 @@ function validateObjectives(errors, scenario, terrainKeys, context) {
|
||||
});
|
||||
}
|
||||
|
||||
function validateRequiredVictoryObjectives(errors, scenario, expectedObjectiveIds, context) {
|
||||
const requiredObjectiveIds = scenario.objectives
|
||||
.filter((objective) => objective.requiredForVictory)
|
||||
.map((objective) => objective.id);
|
||||
if (!expectedObjectiveIds) {
|
||||
if (requiredObjectiveIds.length > 0) {
|
||||
errors.push(`${context}: unexpected required victory objectives ${requiredObjectiveIds.join(', ')}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const actualIds = new Set(requiredObjectiveIds);
|
||||
expectedObjectiveIds.forEach((objectiveId) => {
|
||||
if (!actualIds.has(objectiveId)) {
|
||||
errors.push(`${context}: required victory objective "${objectiveId}" is missing`);
|
||||
}
|
||||
});
|
||||
actualIds.forEach((objectiveId) => {
|
||||
if (!expectedObjectiveIds.has(objectiveId)) {
|
||||
errors.push(`${context}: unexpected required victory objective "${objectiveId}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateSecureTerrainOverrides(errors, battleScenarios, overridesByBattle, terrainKeys) {
|
||||
if (!overridesByBattle || typeof overridesByBattle !== 'object' || Array.isArray(overridesByBattle)) {
|
||||
errors.push('secureTerrainCenterOverrides must be an object keyed by battle id');
|
||||
|
||||
@@ -65,6 +65,7 @@ try {
|
||||
campaignBattlePresentationProfiles,
|
||||
campaignPresentationStatePolicies,
|
||||
campaignPresentationTransitionPolicy,
|
||||
campaignBattleAmbienceFallbackVolume,
|
||||
campaignPresentationArcForOrdinal,
|
||||
campaignPresentationProfileFor,
|
||||
campaignPresentationStateRuleFor,
|
||||
@@ -76,6 +77,7 @@ try {
|
||||
const arcEntries = Object.entries(campaignArcPresentationProfiles);
|
||||
const arcIds = Object.keys(expectedArcRanges);
|
||||
const battleMusicKeys = new Set();
|
||||
let battleAmbienceFallbackCount = 0;
|
||||
|
||||
assertEqual(battleIds.length, 66, 'campaign battle count');
|
||||
assertEqual(new Set(battleIds).size, 66, 'canonical campaign route uniqueness');
|
||||
@@ -193,11 +195,28 @@ try {
|
||||
|
||||
const battlePlan = resolveCampaignPresentationSoundscape({ battleId, stage: 'battle' });
|
||||
const environment = battleEnvironmentProfiles[battleId];
|
||||
const expectedAmbienceKey = environment?.soundscape.ambienceKey ?? profile?.arc.audio.defaultAmbienceKey;
|
||||
const expectedAmbienceVolume = environment?.soundscape.ambienceVolume ??
|
||||
campaignBattleAmbienceFallbackVolume(profile?.arc.audio.defaultAmbienceVolume);
|
||||
assertEqual(battlePlan?.musicKey, profile?.arc.audio.battleMusicKey, `${battleId}: battle music plan`);
|
||||
assertEqual(battlePlan?.ambienceKey, environment?.soundscape.ambienceKey, `${battleId}: battle ambience plan`);
|
||||
assertEqual(battlePlan?.ambienceVolume, environment?.soundscape.ambienceVolume, `${battleId}: battle ambience volume`);
|
||||
assertEqual(battlePlan?.ambienceKey, expectedAmbienceKey, `${battleId}: battle ambience plan`);
|
||||
assertEqual(battlePlan?.ambienceVolume, expectedAmbienceVolume, `${battleId}: battle ambience volume`);
|
||||
assert(Boolean(battlePlan?.ambienceKey), `${battleId}: every battle must retain a quiet ambience bed`);
|
||||
if (!environment) {
|
||||
battleAmbienceFallbackCount += 1;
|
||||
assert(
|
||||
battlePlan.ambienceVolume >= campaignPresentationTransitionPolicy.battleAmbienceFallbackMinimum &&
|
||||
battlePlan.ambienceVolume <= campaignPresentationTransitionPolicy.battleAmbienceFallbackMaximum,
|
||||
`${battleId}: fallback ambience must stay inside the quiet battle mix`
|
||||
);
|
||||
}
|
||||
assert(battlePlan?.transition === campaignPresentationTransitionPolicy, `${battleId}: transition policy identity`);
|
||||
});
|
||||
assertEqual(
|
||||
battleAmbienceFallbackCount,
|
||||
battleIds.length - Object.keys(battleEnvironmentProfiles).length,
|
||||
'battle ambience fallback count'
|
||||
);
|
||||
|
||||
const preservedStoryPlan = resolveCampaignPresentationSoundscape({
|
||||
battleId: battleIds[0],
|
||||
@@ -250,7 +269,14 @@ try {
|
||||
assertEqual(campaignPresentationTransitionPolicy.sameTrackBehavior, 'keep-current-loop', 'same-track behavior');
|
||||
assertEqual(campaignPresentationTransitionPolicy.musicCrossfadeMs, 900, 'music crossfade duration');
|
||||
assertEqual(campaignPresentationTransitionPolicy.ambienceCrossfadeMs, 1200, 'ambience crossfade duration');
|
||||
assertEqual(campaignPresentationTransitionPolicy.battleAmbienceBehavior, 'specific-profile-only', 'battle ambience behavior');
|
||||
assertEqual(
|
||||
campaignPresentationTransitionPolicy.battleAmbienceBehavior,
|
||||
'specific-profile-then-arc-fallback',
|
||||
'battle ambience behavior'
|
||||
);
|
||||
assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackScale, 0.58, 'battle ambience fallback scale');
|
||||
assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackMinimum, 0.05, 'battle ambience fallback minimum');
|
||||
assertEqual(campaignPresentationTransitionPolicy.battleAmbienceFallbackMaximum, 0.065, 'battle ambience fallback maximum');
|
||||
assert(Object.isFrozen(campaignPresentationTransitionPolicy), 'transition policy must be frozen');
|
||||
|
||||
const soundDirectorSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8');
|
||||
@@ -308,7 +334,8 @@ try {
|
||||
|
||||
console.log(
|
||||
`Verified ${arcEntries.length} campaign presentation arcs across ${battleIds.length} battles, ` +
|
||||
`${battleMusicKeys.size} battle music groups, all state policies, palettes, motifs, and crossfade constraints.`
|
||||
`${battleMusicKeys.size} battle music groups, ${battleAmbienceFallbackCount} quiet ambience fallbacks, ` +
|
||||
'all state policies, palettes, motifs, and crossfade constraints.'
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
|
||||
@@ -1164,16 +1164,23 @@ try {
|
||||
revisedSettlement.inventory['Iron Armor'] === 1,
|
||||
`Expected revised battle report settlement to replace previous rewards instead of stacking: ${JSON.stringify(revisedSettlement)}`
|
||||
);
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] });
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] });
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-left');
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-right');
|
||||
const repeatedVisit = getCampaignState();
|
||||
assert(
|
||||
repeatedVisit.gold === 150 &&
|
||||
repeatedVisit.inventory.Bean === 5 &&
|
||||
repeatedVisit.completedCampVisits.includes('repeat-visit') &&
|
||||
repeatedVisit.firstBattleReport?.completedCampVisits.includes('repeat-visit'),
|
||||
repeatedVisit.firstBattleReport?.completedCampVisits.includes('repeat-visit') &&
|
||||
repeatedVisit.campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left' &&
|
||||
JSON.parse(storage.get(campaignStorageKey)).campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left',
|
||||
`Expected repeated camp visit rewards to be applied once: ${JSON.stringify(repeatedVisit)}`
|
||||
);
|
||||
repeatedVisit.campVisitChoiceIds['repeat-visit'] = 'mutated-outside-state';
|
||||
assert(
|
||||
getCampaignState().campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left',
|
||||
'Expected returned camp visit choice histories to be deep clones.'
|
||||
);
|
||||
const visitDesynced = getCampaignState();
|
||||
visitDesynced.completedCampVisits = [];
|
||||
visitDesynced.firstBattleReport.completedCampVisits = ['repeat-visit'];
|
||||
@@ -1197,9 +1204,9 @@ try {
|
||||
`Expected malformed reward labels to be ignored while valid rewards apply: ${JSON.stringify(dirtyRewardVisit)}`
|
||||
);
|
||||
const repeatBondId = firstScenario.bonds[0].id;
|
||||
applyCampBondExp('repeat-dialogue', repeatBondId, 10);
|
||||
applyCampBondExp('repeat-dialogue', repeatBondId, 10, 'repeat-dialogue-listen');
|
||||
const repeatedDialogueOnce = getCampaignState();
|
||||
applyCampBondExp('repeat-dialogue', repeatBondId, 10);
|
||||
applyCampBondExp('repeat-dialogue', repeatBondId, 10, 'repeat-dialogue-boast');
|
||||
const repeatedDialogueTwice = getCampaignState();
|
||||
const onceBond = repeatedDialogueOnce.bonds.find((bond) => bond.id === repeatBondId);
|
||||
const twiceBond = repeatedDialogueTwice.bonds.find((bond) => bond.id === repeatBondId);
|
||||
@@ -1207,9 +1214,16 @@ try {
|
||||
onceBond?.exp === twiceBond?.exp &&
|
||||
onceBond?.battleExp === twiceBond?.battleExp &&
|
||||
repeatedDialogueTwice.completedCampDialogues.includes('repeat-dialogue') &&
|
||||
repeatedDialogueTwice.firstBattleReport?.completedCampDialogues.includes('repeat-dialogue'),
|
||||
repeatedDialogueTwice.firstBattleReport?.completedCampDialogues.includes('repeat-dialogue') &&
|
||||
repeatedDialogueTwice.campDialogueChoiceIds['repeat-dialogue'] === 'repeat-dialogue-listen' &&
|
||||
JSON.parse(storage.get(campaignStorageKey)).campDialogueChoiceIds['repeat-dialogue'] === 'repeat-dialogue-listen',
|
||||
`Expected repeated camp dialogue rewards to be applied once: ${JSON.stringify(repeatedDialogueTwice)}`
|
||||
);
|
||||
repeatedDialogueTwice.campDialogueChoiceIds['repeat-dialogue'] = 'mutated-outside-state';
|
||||
assert(
|
||||
getCampaignState().campDialogueChoiceIds['repeat-dialogue'] === 'repeat-dialogue-listen',
|
||||
'Expected returned camp dialogue choice histories to be deep clones.'
|
||||
);
|
||||
const dialogueDesynced = getCampaignState();
|
||||
dialogueDesynced.completedCampDialogues = [];
|
||||
dialogueDesynced.firstBattleReport.completedCampDialogues = ['repeat-dialogue'];
|
||||
@@ -1224,6 +1238,41 @@ try {
|
||||
dialogueResynced.firstBattleReport?.completedCampDialogues.includes('repeat-dialogue'),
|
||||
`Expected report-only camp dialogue completion to resync without duplicate bond exp: ${JSON.stringify(dialogueResynced)}`
|
||||
);
|
||||
dialogueResynced.completedCampDialogues.push('legacy-dialogue-without-choice');
|
||||
dialogueResynced.completedCampVisits.push('legacy-visit-without-choice');
|
||||
setCampaignState(dialogueResynced);
|
||||
assert(dialogueResynced.firstBattleReport, 'Expected a first battle report before replay reconciliation checks.');
|
||||
setFirstBattleReport({
|
||||
...dialogueResynced.firstBattleReport,
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: []
|
||||
});
|
||||
const replayReconciled = getCampaignState();
|
||||
const replayBondBeforeRepeat = replayReconciled.bonds.find((bond) => bond.id === repeatBondId);
|
||||
assert(
|
||||
replayReconciled.completedCampDialogues.includes('repeat-dialogue') &&
|
||||
replayReconciled.firstBattleReport?.completedCampDialogues.includes('repeat-dialogue') &&
|
||||
replayReconciled.completedCampVisits.includes('repeat-visit') &&
|
||||
replayReconciled.firstBattleReport?.completedCampVisits.includes('repeat-visit') &&
|
||||
replayReconciled.completedCampDialogues.includes('legacy-dialogue-without-choice') &&
|
||||
replayReconciled.firstBattleReport?.completedCampDialogues.includes('legacy-dialogue-without-choice') &&
|
||||
replayReconciled.completedCampVisits.includes('legacy-visit-without-choice') &&
|
||||
replayReconciled.firstBattleReport?.completedCampVisits.includes('legacy-visit-without-choice'),
|
||||
`Expected choice histories and legacy completion flags to survive first-battle recalculation: ${JSON.stringify(replayReconciled)}`
|
||||
);
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-right');
|
||||
applyCampBondExp('repeat-dialogue', repeatBondId, 10, 'repeat-dialogue-boast');
|
||||
const replayRepeatBlocked = getCampaignState();
|
||||
const replayBondAfterRepeat = replayRepeatBlocked.bonds.find((bond) => bond.id === repeatBondId);
|
||||
assert(
|
||||
replayRepeatBlocked.gold === replayReconciled.gold &&
|
||||
JSON.stringify(replayRepeatBlocked.inventory) === JSON.stringify(replayReconciled.inventory) &&
|
||||
replayBondAfterRepeat?.exp === replayBondBeforeRepeat?.exp &&
|
||||
replayBondAfterRepeat?.battleExp === replayBondBeforeRepeat?.battleExp &&
|
||||
replayRepeatBlocked.campDialogueChoiceIds['repeat-dialogue'] === 'repeat-dialogue-listen' &&
|
||||
replayRepeatBlocked.campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left',
|
||||
`Expected first-battle recalculation to preserve first choices and block duplicate camp rewards: ${JSON.stringify(replayRepeatBlocked)}`
|
||||
);
|
||||
const maxedBondState = getCampaignState();
|
||||
const maxedCampaignBond = maxedBondState.bonds.find((bond) => bond.id === repeatBondId);
|
||||
const maxedReportBond = maxedBondState.firstBattleReport?.bonds.find((bond) => bond.id === repeatBondId);
|
||||
@@ -1624,6 +1673,10 @@ try {
|
||||
);
|
||||
assert(
|
||||
Array.isArray(legacy.selectedSortieUnitIds) &&
|
||||
typeof legacy.campDialogueChoiceIds === 'object' &&
|
||||
Object.keys(legacy.campDialogueChoiceIds).length === 0 &&
|
||||
typeof legacy.campVisitChoiceIds === 'object' &&
|
||||
Object.keys(legacy.campVisitChoiceIds).length === 0 &&
|
||||
typeof legacy.sortieFormationAssignments === 'object' &&
|
||||
typeof legacy.sortieItemAssignments === 'object' &&
|
||||
typeof legacy.sortieFormationPresets === 'object' &&
|
||||
@@ -1657,6 +1710,14 @@ try {
|
||||
],
|
||||
completedCampDialogues: ['dialogue-a', 17, 'dialogue-a', '', 'x'.repeat(97)],
|
||||
completedCampVisits: 'visit-a',
|
||||
campDialogueChoiceIds: {
|
||||
'dialogue-a': 'choice-a',
|
||||
'dialogue-choice-only': 'choice-only',
|
||||
'bad-choice-type': 17,
|
||||
['x'.repeat(97)]: 'choice-too-long-key',
|
||||
'dialogue-long-choice': 'x'.repeat(97)
|
||||
},
|
||||
campVisitChoiceIds: ['visit-a', 'choice-a'],
|
||||
inventory: {
|
||||
bean: '3',
|
||||
' bean ': '4',
|
||||
@@ -1699,10 +1760,16 @@ try {
|
||||
`Expected malformed bond entries to be filtered while valid bonds normalize: ${JSON.stringify(malformed)}`
|
||||
);
|
||||
assert(
|
||||
malformed.completedCampDialogues.length === 1 &&
|
||||
malformed.completedCampDialogues.length === 2 &&
|
||||
malformed.completedCampDialogues[0] === 'dialogue-a' &&
|
||||
malformed.completedCampVisits.length === 0,
|
||||
`Expected malformed completion lists to keep unique strings only: ${JSON.stringify(malformed)}`
|
||||
malformed.completedCampDialogues[1] === 'dialogue-choice-only' &&
|
||||
malformed.completedCampVisits.length === 0 &&
|
||||
JSON.stringify(malformed.campDialogueChoiceIds) === JSON.stringify({
|
||||
'dialogue-a': 'choice-a',
|
||||
'dialogue-choice-only': 'choice-only'
|
||||
}) &&
|
||||
Object.keys(malformed.campVisitChoiceIds).length === 0,
|
||||
`Expected malformed completion lists to keep unique strings and choice-only completions: ${JSON.stringify(malformed)}`
|
||||
);
|
||||
assert(
|
||||
malformed.inventory.bean === 7 &&
|
||||
@@ -1741,16 +1808,28 @@ try {
|
||||
updatedAt: '2026-07-03T12:30:00.000Z',
|
||||
step: 'third-camp',
|
||||
activeSaveSlot: 1,
|
||||
completedCampDialogues: Array.from({ length: 140 }, (_, index) => `dialogue-${index}`),
|
||||
completedCampVisits: ['visit-a', 'x'.repeat(97), 'visit-a']
|
||||
completedCampDialogues: Array.from({ length: 300 }, (_, index) => `dialogue-${index}`),
|
||||
completedCampVisits: ['visit-a', 'x'.repeat(97), 'visit-a'],
|
||||
campDialogueChoiceIds: Object.fromEntries(
|
||||
Array.from({ length: 300 }, (_, index) => [`dialogue-${index}`, `choice-${index}`])
|
||||
),
|
||||
campVisitChoiceIds: {
|
||||
'visit-a': 'choice-a',
|
||||
'visit-b': 23,
|
||||
'visit-c': 'x'.repeat(97)
|
||||
}
|
||||
})
|
||||
);
|
||||
const cappedStringLists = loadCampaignState();
|
||||
assert(
|
||||
cappedStringLists.completedCampDialogues.length === 128 &&
|
||||
cappedStringLists.completedCampDialogues.length === 256 &&
|
||||
cappedStringLists.completedCampDialogues[0] === 'dialogue-0' &&
|
||||
cappedStringLists.completedCampDialogues[127] === 'dialogue-127' &&
|
||||
cappedStringLists.completedCampVisits.length === 1,
|
||||
cappedStringLists.completedCampDialogues[255] === 'dialogue-255' &&
|
||||
cappedStringLists.completedCampVisits.length === 1 &&
|
||||
Object.keys(cappedStringLists.campDialogueChoiceIds).length === 256 &&
|
||||
cappedStringLists.campDialogueChoiceIds['dialogue-255'] === 'choice-255' &&
|
||||
cappedStringLists.campDialogueChoiceIds['dialogue-256'] === undefined &&
|
||||
JSON.stringify(cappedStringLists.campVisitChoiceIds) === JSON.stringify({ 'visit-a': 'choice-a' }),
|
||||
`Expected campaign string lists to filter long entries and cap excessive saves: ${JSON.stringify(cappedStringLists)}`
|
||||
);
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ try {
|
||||
|
||||
await verifyCampModalAndNavigation(page);
|
||||
await verifyBattlePointerFlow(page);
|
||||
await verifyWolongNarrativeVictoryGate(page);
|
||||
await verifyCampTimelineRowLayout(page);
|
||||
|
||||
if (pageErrors.length > 0) {
|
||||
throw new Error(`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`);
|
||||
@@ -42,7 +44,9 @@ try {
|
||||
console.log(
|
||||
`Verified pointer-based interaction UX at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}: ` +
|
||||
'camp and battle save modals block click-through, slow camp navigation is single-flight and commits after loading, ' +
|
||||
'battle event overlays block edge-scroll and hover feedback, ' +
|
||||
'battle event overlays block edge-scroll and hover feedback, prioritized same-action notices collapse into one disclosed modal and battle log, ' +
|
||||
'tactical reactions exclude undeployed or defeated officers, the Wolong narrative objectives gate victory, ' +
|
||||
'long camp timeline titles and victory conditions stay in separate fixed-width columns, ' +
|
||||
'movement commands stay anchored to the destination, the final ally prompt waits for the command, ' +
|
||||
'the persistent turn-end action reopens it, and the right-click menu follows the pointer.'
|
||||
);
|
||||
@@ -290,6 +294,7 @@ async function verifyBattlePointerFlow(page) {
|
||||
}, expectedBattleId, { timeout: 90000 });
|
||||
await startDeploymentIfNeeded(page, expectedBattleId);
|
||||
await verifyBattleEventOverlayInputBlock(page);
|
||||
await verifyBattleEventQueueBehavior(page);
|
||||
|
||||
const lastAllySetup = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
@@ -614,6 +619,230 @@ async function verifyBattleEventOverlayInputBlock(page) {
|
||||
await page.mouse.move(safeScreenPoint.x, safeScreenPoint.y);
|
||||
}
|
||||
|
||||
async function verifyBattleEventQueueBehavior(page) {
|
||||
const initial = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
if (!scene) {
|
||||
return null;
|
||||
}
|
||||
scene.hideBattleEventBanner();
|
||||
scene.clearBattleEvents();
|
||||
['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d', 'qa-event-interrupted'].forEach((key) => scene.triggeredBattleEvents.delete(key));
|
||||
scene.deferBattleEventPresentation = true;
|
||||
scene.triggerBattleEvent('qa-event-a', '먼저 감지된 사건', ['같은 전투 흐름에서 먼저 표시됩니다.'], { playCue: false });
|
||||
scene.triggerBattleEvent('qa-event-b', '낮은 우선순위', ['마지막에 표시되어야 합니다.'], { playCue: false, priority: 'low' });
|
||||
scene.triggerBattleEvent('qa-event-c', '긴급 사건 하나', ['가장 높은 우선순위로 표시되어야 합니다.'], { playCue: false, priority: 'critical' });
|
||||
scene.triggerBattleEvent('qa-event-d', '긴급 사건 둘', ['같은 우선순위의 발생 순서를 지켜야 합니다.'], { playCue: false, priority: 'critical' });
|
||||
scene.deferBattleEventPresentation = false;
|
||||
scene.showNextBattleEvent();
|
||||
return {
|
||||
active: scene.activeBattleEvent?.key ?? null,
|
||||
queued: scene.battleEventQueue.map((event) => event.key),
|
||||
completed: ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d'].filter((key) => scene.triggeredBattleEvents.has(key)),
|
||||
objectCount: scene.battleEventObjects.length,
|
||||
bannerText: scene.battleEventObjects
|
||||
.map((object) => typeof object.text === 'string' ? object.text : '')
|
||||
.filter(Boolean)
|
||||
};
|
||||
});
|
||||
assert(
|
||||
initial?.active === 'qa-event-c' &&
|
||||
JSON.stringify(initial.queued) === JSON.stringify(['qa-event-d', 'qa-event-a', 'qa-event-b']) &&
|
||||
initial.completed.length === 0 &&
|
||||
initial.objectCount > 0 &&
|
||||
initial.bannerText.some((text) => text.includes('외 3건')),
|
||||
`Battle event grouping did not preserve priority/FIFO or disclose grouped notices: ${JSON.stringify(initial)}`
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
scene?.hideBattleEventBanner();
|
||||
});
|
||||
await page.waitForFunction(() => (
|
||||
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.activeBattleEvent === undefined
|
||||
));
|
||||
const afterFirstDismissal = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
return scene
|
||||
? {
|
||||
active: scene.activeBattleEvent?.key ?? null,
|
||||
queued: scene.battleEventQueue.map((event) => event.key),
|
||||
completed: ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d'].filter((key) => scene.triggeredBattleEvents.has(key)),
|
||||
objectCount: scene.battleEventObjects.length
|
||||
}
|
||||
: null;
|
||||
});
|
||||
assert(
|
||||
afterFirstDismissal?.active === null &&
|
||||
afterFirstDismissal.queued.length === 0 &&
|
||||
afterFirstDismissal.completed.length === 4 &&
|
||||
afterFirstDismissal.objectCount === 0,
|
||||
`Battle event dismissal did not complete the grouped one-modal batch: ${JSON.stringify(afterFirstDismissal)}`
|
||||
);
|
||||
|
||||
const cleanup = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
scene?.triggerBattleEvent('qa-event-interrupted', '중단될 사건', ['정리 시 완료 처리되면 안 됩니다.'], { playCue: false });
|
||||
scene?.clearBattleEvents();
|
||||
return scene
|
||||
? {
|
||||
active: scene.activeBattleEvent?.key ?? null,
|
||||
queued: scene.battleEventQueue.map((event) => event.key),
|
||||
objectCount: scene.battleEventObjects.length,
|
||||
interruptedCompleted: scene.triggeredBattleEvents.has('qa-event-interrupted')
|
||||
}
|
||||
: null;
|
||||
});
|
||||
assert(
|
||||
cleanup?.active === null &&
|
||||
cleanup.queued.length === 0 &&
|
||||
cleanup.objectCount === 0 &&
|
||||
cleanup.interruptedCompleted === false,
|
||||
`Battle event cleanup retained or falsely completed interrupted events: ${JSON.stringify(cleanup)}`
|
||||
);
|
||||
|
||||
const reactionProbe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const units = scene?.debugBattleUnits?.() ?? [];
|
||||
const liuBei = units.find((unit) => unit.id === 'liu-bei');
|
||||
if (!scene || !liuBei) {
|
||||
return null;
|
||||
}
|
||||
const livingReaction = scene.tacticalEventReaction('ally-danger');
|
||||
const previousHp = liuBei.hp;
|
||||
liuBei.hp = 0;
|
||||
const defeatedReaction = scene.tacticalEventReaction('ally-danger');
|
||||
liuBei.hp = previousHp;
|
||||
return {
|
||||
deployedIds: units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id),
|
||||
livingSpeaker: livingReaction?.unitId ?? null,
|
||||
defeatedSpeaker: defeatedReaction?.unitId ?? null
|
||||
};
|
||||
});
|
||||
assert(
|
||||
reactionProbe?.livingSpeaker === 'liu-bei' &&
|
||||
reactionProbe.defeatedSpeaker === null &&
|
||||
!reactionProbe.deployedIds.includes('zhao-yun'),
|
||||
`Tactical event reaction used an undeployed or defeated officer: ${JSON.stringify(reactionProbe)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyWolongNarrativeVictoryGate(page) {
|
||||
const battleId = 'seventeenth-battle-wolong-visit-road';
|
||||
await page.evaluate((nextBattleId) => window.__HEROS_DEBUG__?.goToBattle(nextBattleId), battleId);
|
||||
await page.waitForFunction((expectedId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return battle?.battleId === expectedId && ['deployment', 'idle'].includes(battle.phase);
|
||||
}, battleId, { timeout: 90000 });
|
||||
await startDeploymentIfNeeded(page, battleId);
|
||||
|
||||
const probe = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const units = scene?.debugBattleUnits?.() ?? [];
|
||||
const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
|
||||
const enemies = units.filter((unit) => unit.faction === 'enemy');
|
||||
const visitor = allies.find((unit) => unit.id === 'liu-bei') ?? allies[0];
|
||||
if (!scene || !visitor || enemies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
scene.clearBattleEvents();
|
||||
enemies.forEach((enemy) => {
|
||||
enemy.hp = 0;
|
||||
});
|
||||
const blockedOutcome = scene.pendingBattleOutcome() ?? null;
|
||||
const before = scene.requiredVictoryObjectiveStates().map((objective) => ({
|
||||
id: objective.id,
|
||||
category: objective.category,
|
||||
achieved: objective.achieved
|
||||
}));
|
||||
const gateEventKnown = scene.isBattleEventKnown('victory-gate-pending');
|
||||
visitor.x = 29;
|
||||
visitor.y = 16;
|
||||
const after = scene.requiredVictoryObjectiveStates().map((objective) => ({
|
||||
id: objective.id,
|
||||
category: objective.category,
|
||||
achieved: objective.achieved
|
||||
}));
|
||||
const completedOutcome = scene.pendingBattleOutcome() ?? null;
|
||||
return { blockedOutcome, completedOutcome, before, after, gateEventKnown };
|
||||
});
|
||||
assert(
|
||||
probe?.blockedOutcome === null &&
|
||||
probe.completedOutcome === 'victory' &&
|
||||
probe.gateEventKnown === true &&
|
||||
JSON.stringify(probe.before.map((objective) => objective.id)) === JSON.stringify(['scholar-rendezvous', 'longzhong-cottage']) &&
|
||||
probe.before.every((objective) => objective.category === 'required' && objective.achieved === false) &&
|
||||
probe.after.every((objective) => objective.category === 'required' && objective.achieved === true),
|
||||
`Wolong battle ignored or misreported its narrative victory gate: ${JSON.stringify(probe)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyCampTimelineRowLayout(page) {
|
||||
await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp());
|
||||
await page.waitForFunction(() => {
|
||||
const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
||||
return scenes.includes('CampScene') && window.__HEROS_GAME__?.scene.getScene('CampScene')?.scene?.isActive();
|
||||
}, undefined, { timeout: 90000 });
|
||||
|
||||
const rows = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
if (!scene) {
|
||||
return null;
|
||||
}
|
||||
scene.hideSortiePrep(false);
|
||||
scene.clearContent();
|
||||
scene.renderProgressChapterDetail(
|
||||
{
|
||||
id: 'qa-long-timeline-fields',
|
||||
title: '긴 연표 필드 검증',
|
||||
period: '1920×1080',
|
||||
description: '긴 전투명과 승리 조건이 서로 침범하지 않는지 확인합니다.',
|
||||
battleIds: [
|
||||
'seventeenth-battle-wolong-visit-road',
|
||||
'fifty-fifth-battle-northern-qishan-road',
|
||||
'fifty-seventh-battle-jieting-crisis',
|
||||
'fifty-eighth-battle-qishan-retreat'
|
||||
],
|
||||
nextHints: []
|
||||
},
|
||||
0,
|
||||
804,
|
||||
262,
|
||||
390,
|
||||
310,
|
||||
new Set()
|
||||
);
|
||||
const fields = scene.children.list
|
||||
.filter((object) => object.type === 'Text' && object.active && object.getData('timelineField'))
|
||||
.map((object) => {
|
||||
const bounds = object.getBounds();
|
||||
return {
|
||||
field: object.getData('timelineField'),
|
||||
text: object.text,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
right: bounds.right,
|
||||
width: bounds.width
|
||||
};
|
||||
});
|
||||
const titles = fields.filter((field) => field.field === 'battle-title').sort((left, right) => left.y - right.y);
|
||||
const objectives = fields.filter((field) => field.field === 'victory-condition').sort((left, right) => left.y - right.y);
|
||||
return titles.map((title, index) => ({ title, objective: objectives[index] ?? null }));
|
||||
});
|
||||
assert(
|
||||
rows?.length === 4 &&
|
||||
rows.every(({ title, objective }) => (
|
||||
objective &&
|
||||
title.right < objective.x &&
|
||||
title.width > 0 &&
|
||||
objective.width > 0 &&
|
||||
objective.text.endsWith('…')
|
||||
)),
|
||||
`Camp timeline detail fields overlap or fail to truncate at 1920x1080: ${JSON.stringify(rows)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function startDeploymentIfNeeded(page, battleId) {
|
||||
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
if (state?.phase === 'idle') {
|
||||
|
||||
@@ -64,7 +64,8 @@ const server = await createServer({
|
||||
});
|
||||
|
||||
try {
|
||||
const { SoundDirector } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts');
|
||||
const { SoundDirector, movementSurfaceForTerrain } = await server.ssrLoadModule('/src/game/audio/SoundDirector.ts');
|
||||
const { effectTrackGainCompensation } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
const audioPreferencesModule = await server.ssrLoadModule('/src/game/settings/audioPreferences.ts');
|
||||
verifyAudioPreferences(audioPreferencesModule);
|
||||
const clock = new FakeClock();
|
||||
@@ -107,7 +108,23 @@ try {
|
||||
'burn-tick': '/audio/burn-tick.ogg',
|
||||
'reward-reveal': '/audio/reward-reveal.ogg',
|
||||
'strategy-cast': '/audio/strategy-cast.ogg',
|
||||
'ui-select': '/audio/ui-select.ogg'
|
||||
'ui-select': '/audio/ui-select.ogg',
|
||||
'footstep-earth-1': '/audio/footstep-earth-1.ogg',
|
||||
'footstep-earth-2': '/audio/footstep-earth-2.ogg',
|
||||
'footstep-stone-1': '/audio/footstep-stone-1.ogg',
|
||||
'footstep-stone-2': '/audio/footstep-stone-2.ogg',
|
||||
'footstep-wood-1': '/audio/footstep-wood-1.ogg',
|
||||
'footstep-wood-2': '/audio/footstep-wood-2.ogg',
|
||||
'footstep-wet-1': '/audio/footstep-wet-1.ogg',
|
||||
'footstep-wet-2': '/audio/footstep-wet-2.ogg',
|
||||
'hoof-earth-1': '/audio/hoof-earth-1.ogg',
|
||||
'hoof-earth-2': '/audio/hoof-earth-2.ogg',
|
||||
'hoof-stone-1': '/audio/hoof-stone-1.ogg',
|
||||
'hoof-stone-2': '/audio/hoof-stone-2.ogg',
|
||||
'hoof-wood-1': '/audio/hoof-wood-1.ogg',
|
||||
'hoof-wood-2': '/audio/hoof-wood-2.ogg',
|
||||
'hoof-wet-1': '/audio/hoof-wet-1.ogg',
|
||||
'hoof-wet-2': '/audio/hoof-wet-2.ogg'
|
||||
});
|
||||
director.registerEffectPools({
|
||||
impact: ['sword-slash', 'combat-impact', 'sword-slash'],
|
||||
@@ -115,6 +132,14 @@ try {
|
||||
'melee-impact': ['combat-impact', 'armor-impact', 'shield-block'],
|
||||
'arrow-impact': ['arrow-body-impact', 'arrow-wood-impact'],
|
||||
'arrow-miss': ['arrow-swish'],
|
||||
'movement-foot-earth': ['footstep-earth-1', 'footstep-earth-2'],
|
||||
'movement-foot-stone': ['footstep-stone-1', 'footstep-stone-2'],
|
||||
'movement-foot-wood': ['footstep-wood-1', 'footstep-wood-2'],
|
||||
'movement-foot-wet': ['footstep-wet-1', 'footstep-wet-2'],
|
||||
'movement-hoof-earth': ['hoof-earth-1', 'hoof-earth-2'],
|
||||
'movement-hoof-stone': ['hoof-stone-1', 'hoof-stone-2'],
|
||||
'movement-hoof-wood': ['hoof-wood-1', 'hoof-wood-2'],
|
||||
'movement-hoof-wet': ['hoof-wet-1', 'hoof-wet-2'],
|
||||
fallback: ['missing-track']
|
||||
});
|
||||
|
||||
@@ -171,9 +196,25 @@ try {
|
||||
);
|
||||
const secondImpact = audioHarness.instances.at(-1);
|
||||
assert.notEqual(firstImpact.originalSrc, secondImpact.originalSrc, 'effect pools should avoid immediate track repetition');
|
||||
assertClose(firstImpact.volume, 0.2, 'effect category multiplier on first variation');
|
||||
assertClose(secondImpact.volume, 0.2, 'effect category multiplier on second variation');
|
||||
debug = director.getDebugState();
|
||||
const [firstImpactMix, secondImpactMix] = debug.recentEffects.slice(-2);
|
||||
assertClose(
|
||||
firstImpact.volume,
|
||||
firstImpactMix.compensatedBaseVolume * 0.5,
|
||||
'effect category multiplier on first gain-balanced variation'
|
||||
);
|
||||
assertClose(
|
||||
secondImpact.volume,
|
||||
secondImpactMix.compensatedBaseVolume * 0.5,
|
||||
'effect category multiplier on second gain-balanced variation'
|
||||
);
|
||||
[firstImpactMix, secondImpactMix].forEach((mix) => {
|
||||
assert.equal(
|
||||
mix.trackGain,
|
||||
effectTrackGainCompensation[mix.trackKey],
|
||||
`${mix.trackKey} should expose its measured pool compensation`
|
||||
);
|
||||
});
|
||||
assert.deepEqual(
|
||||
debug.effectPools.impact,
|
||||
['sword-slash', 'combat-impact'],
|
||||
@@ -188,10 +229,11 @@ try {
|
||||
assert.equal(debug.activeEffectCount, 2, 'active pooled effects should be tracked');
|
||||
|
||||
director.setCategoryMultiplier('effects', 0.25);
|
||||
assertClose(firstImpact.volume, 0.1, 'active effect should follow category multiplier changes');
|
||||
assertClose(secondImpact.volume, 0.1, 'all active effects should follow category multiplier changes');
|
||||
assertClose(firstImpact.volume, firstImpactMix.compensatedBaseVolume * 0.25, 'active effect should follow category multiplier changes');
|
||||
assertClose(secondImpact.volume, secondImpactMix.compensatedBaseVolume * 0.25, 'all active effects should follow category multiplier changes');
|
||||
director.setCategoryMultipliers({ music: 1, effects: 1, ambience: 1 });
|
||||
assertClose(firstImpact.volume, 0.4, 'active effect should restore its base volume');
|
||||
assertClose(firstImpact.volume, firstImpactMix.compensatedBaseVolume, 'active effect should restore its compensated volume');
|
||||
assertClose(secondImpact.volume, secondImpactMix.compensatedBaseVolume, 'all active effects should restore their compensated volume');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/music-a.ogg').volume, 0.22, 'restored music category multiplier');
|
||||
assertClose(audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume, 0.08, 'restored ambience category multiplier');
|
||||
clock.advance(120);
|
||||
@@ -200,6 +242,42 @@ try {
|
||||
assertRetired(firstImpact, 'first pooled impact cleanup');
|
||||
assertRetired(secondImpact, 'second pooled impact cleanup');
|
||||
|
||||
assert.deepEqual(
|
||||
['plain', 'road', 'forest', 'bridge', 'river', 'marsh', 'unknown'].map((terrain) =>
|
||||
movementSurfaceForTerrain(terrain)
|
||||
),
|
||||
['earth', 'stone', 'wood', 'wood', 'wet', 'wet', 'earth'],
|
||||
'battle terrain names should resolve to the four movement sound surfaces'
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
director.playMovementStep(false, 0, { terrain: 'road', volume: 0.2, rate: 0.94, stopAfterMs: 120 }),
|
||||
true,
|
||||
'foot movement should accept a terrain without breaking the legacy argument order'
|
||||
);
|
||||
const firstStoneStep = audioHarness.instances.at(-1);
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.lastMovementStep.key, 'movement-foot-stone', 'road movement should use the stone foot pool');
|
||||
assert.equal(debug.lastMovementStep.surface, 'stone', 'movement debug state should expose the resolved surface');
|
||||
assert.equal(debug.lastMovementStep.terrain, 'road', 'movement debug state should retain the caller terrain');
|
||||
assert(['footstep-stone-1', 'footstep-stone-2'].includes(debug.lastMovementStep.trackKey));
|
||||
|
||||
director.playMovementStep(false, 1, { terrain: 'road', volume: 0.2, rate: 1.04, stopAfterMs: 120 });
|
||||
const secondStoneStep = audioHarness.instances.at(-1);
|
||||
assert.notEqual(firstStoneStep.originalSrc, secondStoneStep.originalSrc, 'terrain movement pools should avoid immediate repetition');
|
||||
|
||||
director.playMovementStep(true, 2, { terrain: 'river', volume: 0.24, stopAfterMs: 140 });
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.lastMovementStep.key, 'movement-hoof-wet', 'mounted river movement should use the wet hoof pool');
|
||||
assert.equal(debug.lastMovementStep.surface, 'wet', 'mounted movement should expose the wet surface');
|
||||
assert(['hoof-wet-1', 'hoof-wet-2'].includes(debug.lastMovementStep.trackKey));
|
||||
|
||||
director.playMeleeRush(false, 'bridge');
|
||||
debug = director.getDebugState();
|
||||
assert.equal(debug.lastEffect.key, 'movement-foot-wood', 'melee rush should share the terrain movement pools');
|
||||
clock.advance(600);
|
||||
assert.equal(director.getDebugState().activeEffectCount, 0, 'terrain movement one-shots should retire without leaks');
|
||||
|
||||
director.duckMusic({ multiplier: 0.4, attackMs: 64, holdMs: 64, releaseMs: 96 });
|
||||
clock.advance(64);
|
||||
debug = director.getDebugState();
|
||||
@@ -550,7 +628,8 @@ try {
|
||||
|
||||
console.log(
|
||||
`Verified dual SoundDirector channels across ${debug.music.elementRevision} music and ` +
|
||||
`${debug.ambience.elementRevision} ambience element revisions, no-repeat effect pools, ` +
|
||||
`${debug.ambience.elementRevision} ambience element revisions, gain-balanced no-repeat effect pools, ` +
|
||||
'terrain-aware foot and hoof movement, ' +
|
||||
'coalesced tactical, narrative, and feedback cues, semantic combat effects, category multipliers, and deterministic music ducking.'
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -12,6 +12,7 @@ const checks = [
|
||||
'scripts/verify-visual-motion-settings.mjs',
|
||||
'scripts/verify-battle-forecast.mjs',
|
||||
'scripts/verify-battle-save-normalization.mjs',
|
||||
'scripts/verify-battle-event-queue.mjs',
|
||||
'scripts/verify-battle-save-resume-routing.mjs',
|
||||
'scripts/verify-battle-usables.mjs',
|
||||
'scripts/verify-sortie-synergy.mjs',
|
||||
|
||||
Reference in New Issue
Block a user