feat: strengthen semantic battle feedback
This commit is contained in:
117
scripts/verify-audiovisual-feedback.mjs
Normal file
117
scripts/verify-audiovisual-feedback.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8');
|
||||
const campSource = readFileSync('src/game/scenes/CampScene.ts', 'utf8');
|
||||
const soundSource = readFileSync('src/game/audio/SoundDirector.ts', 'utf8');
|
||||
|
||||
const tickStatuses = privateMethodBody(battleSource, 'tickBattleStatuses');
|
||||
assert.match(tickStatuses, /let burnTicked = false/);
|
||||
assert.match(tickStatuses, /showMapResultPopup\s*\(/);
|
||||
assert.match(tickStatuses, /`\$\{status\.label\} -\$\{damage\}`/);
|
||||
assert.match(tickStatuses, /flashDamage\s*\(unit, previousHp\)/);
|
||||
assert.match(tickStatuses, /const visibleOnMap = this\.isTileVisible\(unit\.x, unit\.y\)/);
|
||||
assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.showMapResultPopup/s);
|
||||
assert.match(tickStatuses, /if \(visibleOnMap\)\s*\{\s*this\.flashDamage/s);
|
||||
assert.match(tickStatuses, /if \(playCue && burnTicked\)\s*\{\s*soundDirector\.playBurnTickCue\(\)/s);
|
||||
assert.equal(countMatches(tickStatuses, /soundDirector\.playBurnTickCue\(\)/g), 1, 'a direct status tick must play one cue per faction batch');
|
||||
|
||||
const endAllyTurn = privateMethodBody(battleSource, 'endAllyTurn');
|
||||
const beginNextAllyTurn = privateMethodBody(battleSource, 'beginNextAllyTurn');
|
||||
const turnStartCue = privateMethodBody(battleSource, 'playTurnStartFeedbackCue');
|
||||
[endAllyTurn, beginNextAllyTurn].forEach((body) => {
|
||||
assert.match(body, /tickBattleStatuses\('(ally|enemy)', false\)/);
|
||||
assert.match(body, /applyTerrainRecovery\('(ally|enemy)', false\)/);
|
||||
assert.match(body, /playTurnStartFeedbackCue/);
|
||||
assert.match(body, /statusOutcomeDelayMs = statusResult\.burnTicked \? 720 : 0/);
|
||||
assert.match(body, /resolveBattleOutcomeIfNeeded\(statusOutcomeDelayMs\)/);
|
||||
assert.match(body, /statusResult\.burnTicked[\s\S]*playBurnTickCue\(\)/, 'lethal burn must retain its cue before an early return');
|
||||
});
|
||||
const cuePriorityMarkers = ['if (operationCuePlayed)', 'if (burnTicked)', 'if (recoveryApplied)', "if (faction === 'ally')"];
|
||||
cuePriorityMarkers.forEach((marker) => assert.match(turnStartCue, new RegExp(escapeRegExp(marker))));
|
||||
assert.deepEqual(
|
||||
cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)),
|
||||
cuePriorityMarkers.map((marker) => turnStartCue.indexOf(marker)).toSorted((left, right) => left - right),
|
||||
'turn-start cues must prioritize operation, burn, recovery, then the generic faction cue'
|
||||
);
|
||||
assert.doesNotMatch(turnStartCue, /delayedCall/, 'turn-start feedback must select one cue instead of layering delayed cues');
|
||||
|
||||
const supportMap = privateMethodBody(battleSource, 'playSupportMapPresentation');
|
||||
const supportCutIn = privateMethodBody(battleSource, 'playSupportCutIn');
|
||||
const terrainRecovery = privateMethodBody(battleSource, 'applyTerrainRecovery');
|
||||
const tacticalInitiative = privateMethodBody(battleSource, 'activateTacticalInitiativeCommand');
|
||||
const campSupply = privateMethodBody(campSource, 'useSupply');
|
||||
[supportMap, supportCutIn].forEach((body) => {
|
||||
assert.match(body, /result\.usable\.effect === 'heal'/);
|
||||
assert.match(body, /soundDirector\.playRecoveryCue\(\)/);
|
||||
});
|
||||
assert.match(terrainRecovery, /soundDirector\.playRecoveryCue\(\)/);
|
||||
assert.match(terrainRecovery, /if \(playCue\)/);
|
||||
assert.match(tacticalInitiative, /role === 'support'/);
|
||||
assert.match(tacticalInitiative, /soundDirector\.playRecoveryCue\(\)/);
|
||||
assert.match(campSupply, /soundDirector\.playRecoveryCue\(\)/);
|
||||
|
||||
const victoryReward = privateMethodBody(campSource, 'showVictoryRewardAcknowledgement');
|
||||
assert.equal(countMatches(victoryReward, /soundDirector\.playRewardRevealCue\(\)/g), 1, 'victory reward should reveal once');
|
||||
|
||||
const deploymentConfirmation = privateMethodBody(battleSource, 'confirmPreBattleDeployment');
|
||||
assert.match(deploymentConfirmation, /showOpeningBattleEvent\(\)/);
|
||||
assert.doesNotMatch(deploymentConfirmation, /soundDirector\.playSelect\(\)/, 'opening alert must not overlap a selection cue');
|
||||
|
||||
const mapResultPopup = privateMethodBody(battleSource, 'showMapResultPopup');
|
||||
assert.match(mapResultPopup, /!view \|\| !this\.isTileVisible\(unit\.x, unit\.y\)/, 'offscreen units must not create edge-clamped popups');
|
||||
|
||||
const statusBadges = privateMethodBody(battleSource, 'syncUnitStatusBadges');
|
||||
assert.match(statusBadges, /\.slice\(0, 2\)/, 'map status badges must remain capped at two per unit');
|
||||
assert.match(statusBadges, /createBattleUiIcon/);
|
||||
assert.match(statusBadges, /statusEffectIcon/);
|
||||
assert.match(statusBadges, /statusEffectTone/);
|
||||
assert.match(statusBadges, /setMask\(this\.mapMask\)/);
|
||||
assert.match(privateMethodBody(battleSource, 'applyBattleStatus'), /syncUnitStatusBadges/);
|
||||
assert.match(tickStatuses, /syncUnitStatusBadges/);
|
||||
assert.match(tacticalInitiative, /battleStatuses\.delete\(supportTarget\.id\)[\s\S]*syncUnitStatusBadges\(supportTarget\)/);
|
||||
assert.match(privateMethodBody(battleSource, 'applyDefeatedStyle'), /syncUnitStatusBadges/);
|
||||
assert.match(privateMethodBody(battleSource, 'moveUnitViewAsync'), /unitStatusBadgePosition/);
|
||||
assert.match(privateMethodBody(battleSource, 'moveUnitView'), /unitStatusBadgePosition/);
|
||||
assert.match(privateMethodBody(battleSource, 'unitStatusBadgePosition'), /0\.34 \+ index \* spacing/, 'paired badges should fan away from the unit instead of folding inward');
|
||||
assert.match(battleSource, /statusBadgeSide: unit\.faction === 'ally' \? 1 : -1/);
|
||||
assert.match(battleSource, /statusBadges: \(this\.unitViews\.get\(unit\.id\)\?\.statusBadges \?\? \[\]\)\.map/);
|
||||
|
||||
for (const [method, group, key] of [
|
||||
['playRecoveryCue', 'recovery', 'recovery-cue'],
|
||||
['playBurnTickCue', 'status', 'burn-tick'],
|
||||
['playRewardRevealCue', 'reward', 'reward-reveal']
|
||||
]) {
|
||||
const body = publicMethodBody(soundSource, method);
|
||||
assert.match(body, new RegExp(`playSemanticCue\\('${group}', '${key}'`));
|
||||
}
|
||||
assert.match(soundSource, /recovery:\s*1900/, 'recovery cue cooldown should cover its playback window');
|
||||
|
||||
console.log('Verified semantic sound cues, map status feedback, capped status badges, and non-overlapping battle opening audio.');
|
||||
|
||||
function privateMethodBody(source, name) {
|
||||
return methodBody(source, new RegExp(`^ private (?:async )?${name}\\b`, 'm'), `private ${name}`);
|
||||
}
|
||||
|
||||
function publicMethodBody(source, name) {
|
||||
return methodBody(source, new RegExp(`^ (?:async )?${name}\\b`, 'm'), name);
|
||||
}
|
||||
|
||||
function methodBody(source, pattern, label) {
|
||||
const match = pattern.exec(source);
|
||||
assert(match, `missing method marker: ${label}`);
|
||||
const start = match.index;
|
||||
const next = source.indexOf('\n private ', start + match[0].length);
|
||||
const nextPublic = source.indexOf('\n play', start + match[0].length);
|
||||
const candidates = [next, nextPublic].filter((index) => index > start);
|
||||
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function countMatches(source, pattern) {
|
||||
return Array.from(source.matchAll(pattern)).length;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
Reference in New Issue
Block a user