feat: deepen audiovisual story immersion

This commit is contained in:
2026-07-23 10:57:07 +09:00
parent a9e401aedf
commit 8cf0886d7d
38 changed files with 2037 additions and 144 deletions

View File

@@ -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);

View File

@@ -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);

View 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);
}

View File

@@ -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]) => {

View File

@@ -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');

View File

@@ -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();

View File

@@ -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)}`
);

View File

@@ -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') {

View File

@@ -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 {

View File

@@ -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',