238 lines
14 KiB
JavaScript
238 lines
14 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
|
|
const campSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8');
|
|
const soundSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8');
|
|
const storySource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8');
|
|
|
|
const applyStoryPage = privateMethodBody(storySource, 'applyPage');
|
|
assert.match(
|
|
applyStoryPage,
|
|
/chapterText\?\.setText\(page\.chapter\)\.setVisible\(!page\.cutscene\)/,
|
|
'cutscenes must hide the generic chapter label so it cannot overlap the result or reward strip'
|
|
);
|
|
assert.match(storySource, /chapterVisible: this\.chapterText\?\.visible \?\? false/);
|
|
|
|
const tickStatuses = privateMethodBody(battleSource, 'tickBattleStatuses');
|
|
assert.match(tickStatuses, /let burnTicked = false/);
|
|
assert.match(tickStatuses, /showMapResultPopup\s*\(/);
|
|
assert.match(tickStatuses, /`\$\{status\.label\} -\$\{damage\}`/);
|
|
assert.match(tickStatuses, /flashDamage\s*\(unit, previousHp\)/);
|
|
assert.match(tickStatuses, /const visibleOnMap = this\.isTileVisible\(unit\.x, unit\.y\)/);
|
|
assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.showMapResultPopup/s);
|
|
assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.flashDamage/s);
|
|
assert.match(tickStatuses, /if \(playCue && burnTicked\)\s*\{\s*soundDirector\.playBurnTickCue\(\)/s);
|
|
assert.match(tickStatuses, /offscreenBurnSources\.push/);
|
|
assert.match(tickStatuses, /showOffscreenBurnCaption/);
|
|
assert.equal(countMatches(tickStatuses, /soundDirector\.playBurnTickCue\(\)/g), 1, 'a direct status tick must play one cue per faction batch');
|
|
|
|
const endAllyTurn = privateMethodBody(battleSource, 'endAllyTurn');
|
|
const beginNextAllyTurn = privateMethodBody(battleSource, 'beginNextAllyTurn');
|
|
const turnStartCue = privateMethodBody(battleSource, 'playTurnStartFeedbackCue');
|
|
[endAllyTurn, beginNextAllyTurn].forEach((body) => {
|
|
assert.match(body, /tickBattleStatuses\('(ally|enemy)', false\)/);
|
|
assert.match(body, /applyTerrainRecovery\('(ally|enemy)', false\)/);
|
|
assert.match(body, /playTurnStartFeedbackCue/);
|
|
assert.match(body, /statusOutcomeDelayMs = statusResult\.burnTicked \? 720 : 0/);
|
|
assert.match(body, /resolveBattleOutcomeIfNeeded\(statusOutcomeDelayMs\)/);
|
|
assert.match(body, /statusResult\.burnTicked[\s\S]*playBurnTickCue\(\)/, 'lethal burn must retain its cue before an early return');
|
|
});
|
|
const cuePriorityMarkers = [
|
|
'if (operationCuePlayed)',
|
|
'if (statusResult.burnTicked)',
|
|
'if (recoveryResult.applied)',
|
|
"if (faction === 'ally')"
|
|
];
|
|
cuePriorityMarkers.forEach((marker) => assert.match(turnStartCue, new RegExp(escapeRegExp(marker))));
|
|
assert.deepEqual(
|
|
cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)),
|
|
cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)).toSorted((left, right) => left - right),
|
|
'turn-start cues must prioritize operation, burn, recovery, then the generic faction cue'
|
|
);
|
|
assert.doesNotMatch(turnStartCue, /delayedCall/, 'turn-start feedback must select one cue instead of layering delayed cues');
|
|
|
|
const supportMap = privateMethodBody(battleSource, 'playSupportMapPresentation');
|
|
const supportCutIn = privateMethodBody(battleSource, 'playSupportCutIn');
|
|
const terrainRecovery = privateMethodBody(battleSource, 'applyTerrainRecovery');
|
|
const tacticalInitiative = privateMethodBody(battleSource, 'activateTacticalInitiativeCommand');
|
|
const campSupply = privateMethodBody(campSource, 'useSupply');
|
|
[supportMap, supportCutIn].forEach((body) => {
|
|
assert.match(body, /result\.usable\.effect === 'heal'/);
|
|
assert.match(body, /soundDirector\.playRecoveryCue\(\)/);
|
|
});
|
|
assert.match(terrainRecovery, /soundDirector\.playRecoveryCue\(\)/);
|
|
assert.match(terrainRecovery, /if \(playCue\)/);
|
|
assert.match(terrainRecovery, /offscreenRecoverySources\.push/);
|
|
assert.match(terrainRecovery, /showOffscreenRecoveryCaption/);
|
|
assert.match(tacticalInitiative, /role === 'support'/);
|
|
assert.match(tacticalInitiative, /soundDirector\.playRecoveryCue\(\)/);
|
|
assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/);
|
|
|
|
const victoryReward = privateMethodBody(campSource, 'showVictoryRewardAcknowledgement');
|
|
assert.equal(countMatches(victoryReward, /soundDirector\.playRewardRevealCue\(\)/g), 1, 'victory reward should reveal once');
|
|
|
|
const 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');
|
|
|
|
const combatAssetLoading = privateMethodBody(battleSource, 'ensureScenarioCombatAssets');
|
|
assert.match(
|
|
combatAssetLoading,
|
|
/if \(this\.phase === 'deployment'\) \{\s*this\.renderSortieOrderHud\(\);\s*this\.renderDeploymentPanel\(\);/s,
|
|
'deployment text and its status HUD must be rebuilt once delayed combat assets settle'
|
|
);
|
|
const deploymentPanel = privateMethodBody(battleSource, 'renderDeploymentPanel');
|
|
assert.match(deploymentPanel, /const combatAssetsReady =/);
|
|
assert.match(deploymentPanel, /'준비 중 · 누르면 자동 시작'/);
|
|
assert.match(deploymentPanel, /combatAssetsReady\s*\? '전투 시작'/s);
|
|
|
|
const resolveDamageTarget = privateMethodBody(battleSource, 'tryResolveDamageTarget');
|
|
assert.match(resolveDamageTarget, /triggerBattleEvent\('first-engagement'[\s\S]*\{ playCue: false \}\)/);
|
|
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: movementAudioTerrainForPulse\(terrainPath, index, pulseCount\),/s
|
|
);
|
|
assert.doesNotMatch(movementSound, /stopAfterMs/, 'movement one-shots should clean up on ended instead of truncating each step');
|
|
assert.equal(
|
|
countMatches(battleSource, /this\.movementAudioTerrainPath\(unit, fromX, fromY, x, y\)/g),
|
|
2,
|
|
'both synchronous and asynchronous movement paths must pass a sampled terrain route to movement audio'
|
|
);
|
|
assert.match(battleSource, /particles\?\.kind === 'rain'/, 'rain battles should route movement through the wet pool');
|
|
const movementPlayback = privateMethodBody(soundSource, 'playMovementEffect');
|
|
assert.match(movementPlayback, /activeVoices\.length >= this\.movementVoiceCap[\s\S]*return false/);
|
|
assert.doesNotMatch(
|
|
movementPlayback,
|
|
/retireEffectElement/,
|
|
'movement voice limiting should skip an excess pulse instead of hard-cutting a playing sample'
|
|
);
|
|
const meleeRushPlayback = publicMethodBody(soundSource, 'playMeleeRush');
|
|
assert.match(meleeRushPlayback, /return this\.playEffect\(/, 'melee rush should bypass the saturated movement-step voice group');
|
|
assert.doesNotMatch(meleeRushPlayback, /playMovementEffect/, 'melee rush must not be dropped by the movement-step ceiling');
|
|
|
|
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);
|
|
assert(
|
|
mapCombat.indexOf('playMeleeSwing') < mapCombat.indexOf('await this.waitSceneDuration(contactDelayMs)') &&
|
|
mapCombat.indexOf('await this.waitSceneDuration(contactDelayMs)') < mapCombat.indexOf('playCombatContactSound(result)'),
|
|
'map combat must play launch motion before delayed contact audio'
|
|
);
|
|
assert.match(mapCombat, /await Promise\.all\(\[this\.waitSceneDuration\(impactDuration \+ 20\), actorFramePromise\]\)/);
|
|
const contactSound = privateMethodBody(battleSource, 'playCombatContactSound');
|
|
assert.match(contactSound, /soundDirector\.playStrategyImpact/);
|
|
assert.match(contactSound, /soundDirector\.playArrowImpact\(result\.hit, result\.critical\)/);
|
|
assert.match(contactSound, /soundDirector\.playCombatImpact\(result\.critical\)/);
|
|
assert.match(privateMethodBody(battleSource, 'showCombatImpact'), /playCombatContactSound\(result\)/);
|
|
|
|
const mapResultPopup = privateMethodBody(battleSource, 'showMapResultPopup');
|
|
assert.match(mapResultPopup, /!view \|\| !this\.isTileVisible\(unit\.x, unit\.y\)/, 'offscreen units must not create edge-clamped popups');
|
|
|
|
const statusBadges = privateMethodBody(battleSource, 'syncUnitStatusBadges');
|
|
assert.match(statusBadges, /\.slice\(0, 2\)/, 'map status badges must remain capped at two per unit');
|
|
assert.match(statusBadges, /createBattleUiIcon/);
|
|
assert.match(statusBadges, /statusEffectIcon/);
|
|
assert.match(statusBadges, /statusEffectTone/);
|
|
assert.match(statusBadges, /setMask\(this\.mapMask\)/);
|
|
assert.match(privateMethodBody(battleSource, 'applyBattleStatus'), /syncUnitStatusBadges/);
|
|
assert.match(tickStatuses, /syncUnitStatusBadges/);
|
|
assert.match(tacticalInitiative, /battleStatuses\.delete\(supportTarget\.id\)[\s\S]*syncUnitStatusBadges\(supportTarget\)/);
|
|
assert.match(privateMethodBody(battleSource, 'applyDefeatedStyle'), /syncUnitStatusBadges/);
|
|
assert.match(privateMethodBody(battleSource, 'moveUnitViewAsync'), /unitStatusBadgePosition/);
|
|
assert.match(privateMethodBody(battleSource, 'moveUnitView'), /unitStatusBadgePosition/);
|
|
assert.match(privateMethodBody(battleSource, 'unitStatusBadgePosition'), /0\.34 \+ index \* spacing/, 'paired badges should fan away from the unit instead of folding inward');
|
|
assert.match(battleSource, /statusBadgeSide: unit\.faction === 'ally' \? 1 : -1/);
|
|
assert.match(battleSource, /statusBadges: \(this\.unitViews\.get\(unit\.id\)\?\.statusBadges \?\? \[\]\)\.map/);
|
|
|
|
const facingIndicator = privateMethodBody(battleSource, 'showFacingIndicator');
|
|
assert.match(facingIndicator, /add\.ellipse/);
|
|
assert.match(facingIndicator, /directionSymbol/);
|
|
assert.match(facingIndicator, /strokeThickness: this\.battleUiLength\(4\)/);
|
|
assert.match(facingIndicator, /if \(!isVisualMotionReduced\(\)\)/);
|
|
assert.match(privateMethodBody(battleSource, 'startUnitIdleMotion'), /if \(isVisualMotionReduced\(\)\)\s*\{\s*return;/s);
|
|
assert.match(privateMethodBody(battleSource, 'moveUnitViewAsync'), /facingIndicatorUnitId === unit\.id[\s\S]*clearFacingIndicator\(\)/);
|
|
assert.match(privateMethodBody(battleSource, 'moveUnitView'), /facingIndicatorUnitId === unit\.id[\s\S]*clearFacingIndicator\(\)/);
|
|
assert.match(privateMethodBody(battleSource, 'faceUnitToward'), /syncUnitMotion[\s\S]*refreshFacingIndicator\(unit\.id\)/);
|
|
|
|
const actedStyle = privateMethodBody(battleSource, 'applyUnitLegibilityStyle');
|
|
assert.match(actedStyle, /const acted = this\.actedUnitIds\.has\(unit\.id\)/);
|
|
assert.match(actedStyle, /view\.sprite\.setAlpha\(acted \? 0\.62 : 1\)/);
|
|
assert.match(actedStyle, /view\.actedMarker\?\.setVisible\(visible && acted\)/);
|
|
assert.match(privateMethodBody(battleSource, 'createUnitActedMarker'), /'success'/);
|
|
assert.match(privateMethodBody(battleSource, 'applyDefeatedStyle'), /view\.actedMarker\?\.setAlpha\(0\)\.setVisible\(false\)/);
|
|
|
|
const drawMiniMap = privateMethodBody(battleSource, 'drawMiniMap');
|
|
assert.match(drawMiniMap, /objective\.kind === 'secure-terrain'/);
|
|
assert.match(drawMiniMap, /marker\.setAngle\(45\)/);
|
|
assert.match(drawMiniMap, /miniMapObjectiveMarkers\.push\(marker\)/);
|
|
|
|
const soundCaption = privateMethodBody(battleSource, 'showBattleSoundCaption');
|
|
assert.match(soundCaption, /battle-sound-caption/);
|
|
assert.match(soundCaption, /kind === 'status' \? 'burn' : kind === 'recovery' \? 'heal' : 'success'/);
|
|
assert.match(soundCaption, /this\.layout\.gridY \+ this\.layout\.gridHeight/);
|
|
assert.match(privateMethodBody(battleSource, 'offscreenTileDirection'), /cameraTileX/);
|
|
assert.match(privateMethodBody(battleSource, 'battleSoundCaptionDirectionLabel'), /'north-east': '↗'/);
|
|
assert.match(privateMethodBody(battleSource, 'showObjectiveMapFeedback'), /showBattleSoundCaption\('objective'/);
|
|
assert.doesNotMatch(soundCaption, /battleOutcome/, 'a lethal offscreen cue must remain captioned during the delayed result reveal');
|
|
|
|
for (const [method, group, key] of [
|
|
['playRecoveryCue', 'recovery', 'recovery-cue'],
|
|
['playBurnTickCue', 'status', 'burn-tick'],
|
|
['playRewardRevealCue', 'reward', 'reward-reveal']
|
|
]) {
|
|
const body = publicMethodBody(soundSource, method);
|
|
assert.match(body, new RegExp(`playSemanticCue\\('${group}', '${key}'`));
|
|
}
|
|
const strategyImpact = publicMethodBody(soundSource, 'playStrategyImpact');
|
|
assert.match(strategyImpact, /kind === 'fire'/);
|
|
assert.match(strategyImpact, /kind === 'roar'/);
|
|
assert.match(strategyImpact, /playEffect\('burn-tick'/);
|
|
assert.match(strategyImpact, /playEffect\('combat-impact'/);
|
|
assert.match(strategyImpact, /playEffect\('strategy-cast'/);
|
|
assert.match(soundSource, /recentEffects: this\.recentEffects\.map/);
|
|
assert.match(soundSource, /recovery:\s*1900/, 'recovery cue cooldown should cover its playback window');
|
|
|
|
console.log('Verified synchronized combat audio, offscreen captions, tactical focus markers, mini-map objectives, status badges, and semantic cues.');
|
|
|
|
function privateMethodBody(source, name) {
|
|
return methodBody(source, new RegExp(`^ private (?:async )?${name}\\b`, 'm'), `private ${name}`);
|
|
}
|
|
|
|
function publicMethodBody(source, name) {
|
|
return methodBody(source, new RegExp(`^ (?:async )?${name}\\b`, 'm'), name);
|
|
}
|
|
|
|
function methodBody(source, pattern, label) {
|
|
const match = pattern.exec(source);
|
|
assert(match, `missing method marker: ${label}`);
|
|
const start = match.index;
|
|
const next = source.indexOf('\n private ', start + match[0].length);
|
|
const nextPublic = source.indexOf('\n play', start + match[0].length);
|
|
const candidates = [next, nextPublic].filter((index) => index > start);
|
|
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
|
|
return source.slice(start, end);
|
|
}
|
|
|
|
function countMatches(source, pattern) {
|
|
return Array.from(source.matchAll(pattern)).length;
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|