fix: sequence battle feedback and autosaves

This commit is contained in:
2026-07-29 03:49:21 +09:00
parent d4bd489b3d
commit 26ab244ad2
6 changed files with 2002 additions and 48 deletions

View File

@@ -1,9 +1,42 @@
import { spawn } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
const renderers = ['canvas', 'webgl'];
const renderer =
process.env.VERIFY_INTERACTION_UX_RENDERER;
if (!renderer) {
for (const requestedRenderer of renderers) {
const result = spawnSync(
process.execPath,
[fileURLToPath(import.meta.url)],
{
cwd: process.cwd(),
env: {
...process.env,
VERIFY_INTERACTION_UX_RENDERER:
requestedRenderer
},
stdio: 'inherit'
}
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
assert(
renderers.includes(renderer),
`Unsupported interaction UX renderer "${renderer}".`
);
const targetUrl = withDebugOptions(
process.env.VERIFY_INTERACTION_UX_URL ?? 'http://127.0.0.1:41783/'
process.env.VERIFY_INTERACTION_UX_URL ??
`http://127.0.0.1:${renderer === 'canvas' ? 41783 : 41784}/`
);
const expectedBattleId = 'second-battle-yellow-turban-pursuit';
@@ -12,7 +45,17 @@ let browser;
try {
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({ headless: process.env.VERIFY_INTERACTION_UX_HEADLESS !== '0' });
browser = await chromium.launch({
headless:
process.env.VERIFY_INTERACTION_UX_HEADLESS !== '0',
args:
renderer === 'webgl'
? [
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader'
]
: []
});
const context = await browser.newContext(desktopBrowserContextOptions);
const page = await context.newPage();
page.setDefaultTimeout(30000);
@@ -31,6 +74,7 @@ try {
await verifyBattlePointerFlow(page);
await verifyWolongNarrativeVictoryGate(page);
await verifyCampTimelineRowLayout(page);
await verifyBattleShutdownWaitCleanup(page);
if (pageErrors.length > 0) {
throw new Error(`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`);
@@ -42,16 +86,18 @@ try {
}
console.log(
`Verified pointer-based interaction UX at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}: ` +
`Verified ${renderer} 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, ' +
'delayed combat assets rebuild deployment controls before battle start, ' +
'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, ' +
'final attack and support result popups clear before first-engagement and victory-gate events, and all clear before the turn-end prompt appears, ' +
'the all-acted prompt keeps Enter-to-end, early manual turn end discloses unacted allies and overflow threats, ' +
'safe Enter/Esc cancellation requires explicit keyboard focus before abandoning actions, ' +
'the persistent turn-end action reopens it, and the right-click menu follows the pointer.'
'the persistent turn-end action reopens it, the right-click menu follows the pointer, ' +
'and pending result waits resolve when the battle scene shuts down.'
);
} finally {
await browser?.close();
@@ -63,7 +109,7 @@ try {
function withDebugOptions(url) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', 'canvas');
parsed.searchParams.set('renderer', renderer);
parsed.searchParams.set('debugCombatAssetWatchdogMs', '250');
return parsed.toString();
}
@@ -359,6 +405,9 @@ async function verifyBattlePointerFlow(page) {
!selectedBeforeMove.turnPromptVisible,
`Selecting the final ally must not show the turn-end prompt: ${JSON.stringify(selectedBeforeMove)}`
);
// WebGL needs one rendered frame after selection before the new tile hit
// areas are guaranteed to accept synthetic pointer input.
await page.waitForTimeout(120);
const movementTarget = await page.evaluate((unitId) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -432,6 +481,7 @@ async function verifyBattlePointerFlow(page) {
`The movement command menu drifted away from the clicked destination (${destinationMenuDistance.toFixed(1)}px): ${JSON.stringify(postMovement)}`
);
assert(postMovement.waitBounds, 'Expected the wait command button after movement.');
await page.waitForTimeout(120);
await clickSceneBounds(page, 'BattleScene', postMovement.waitBounds);
await page.waitForFunction((unitId) => {
@@ -457,6 +507,7 @@ async function verifyBattlePointerFlow(page) {
`The all-acted automatic prompt must retain Enter-to-end and Esc-to-review semantics: ${JSON.stringify(automaticTurnPrompt)}`
);
await page.waitForTimeout(120);
await clickTurnPromptSecondary(page);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false);
const persistentTurnEndBounds = await page.evaluate(() => {
@@ -468,6 +519,7 @@ async function verifyBattlePointerFlow(page) {
return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null;
});
assert(persistentTurnEndBounds, 'Expected a persistent turn-end action after choosing battlefield review.');
await page.waitForTimeout(120);
await clickSceneBounds(page, 'BattleScene', persistentTurnEndBounds);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true &&
@@ -538,6 +590,7 @@ async function verifyBattlePointerFlow(page) {
height: mapMenu.buttonHeight
});
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.saveSlotPanelMode === 'save');
await page.waitForTimeout(120);
const battleModalProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -756,7 +809,11 @@ async function verifyEarlyTurnEndSafety(page) {
const initialPrompt = await openManualTurnEndPrompt(page);
assertEarlyTurnPrompt(initialPrompt, setup, 'initial');
await page.screenshot({ path: 'dist/verification-interaction-ux-early-turn-end.png', fullPage: true });
await page.screenshot({
path:
`dist/verification-interaction-ux-early-turn-end-${renderer}.png`,
fullPage: true
});
await page.keyboard.press('Enter');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false);
@@ -861,6 +918,7 @@ async function verifyEarlyTurnEndSafety(page) {
confirmed.battle.turnPromptVisible === false,
`The explicitly selected dangerous action did not enter and cleanly restore the enemy turn: ${JSON.stringify(confirmed)}`
);
await verifyFinalActionResultTiming(page);
}
async function openManualTurnEndPrompt(page) {
@@ -935,6 +993,561 @@ function assertEarlyTurnPrompt(prompt, setup, stage) {
);
}
async function verifyFinalActionResultTiming(page) {
for (const actionKind of ['attack', 'support']) {
const result = await page.evaluate(async (requestedActionKind) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
if (!scene || allies.length === 0) {
return { error: 'missing active battle scene or allied units' };
}
const originalMethods = {
showBondMapEffect: scene.showBondMapEffect,
presentCombatResult: scene.presentCombatResult,
presentSupportResult: scene.presentSupportResult,
resolveCounterAttack: scene.resolveCounterAttack,
triggerFirstEngagementEvent: scene.triggerFirstEngagementEvent,
collectBattleEventsWithoutPresentation:
scene.collectBattleEventsWithoutPresentation,
showNextBattleEvent: scene.showNextBattleEvent,
resolveBattleOutcomeIfNeeded: scene.resolveBattleOutcomeIfNeeded
};
const originalBattleSpeed = scene.battleSpeed;
const originalUnitState = new Map(
units.map((unit) => [
unit.id,
{ x: unit.x, y: unit.y, hp: unit.hp, maxHp: unit.maxHp }
])
);
const sample = {
actionKind: requestedActionKind,
popupSeen: false,
promptSeen: false,
overlapSeen: false,
eventOverlaySeen: false,
resultEventOverlapSeen: false,
firstPopupFrame: null,
firstPromptFrame: null,
settledFrame: null,
frame: 0,
maxActivePopupCount: 0
};
let sampling = true;
let samplingError;
let settleSampling;
const samplingDone = new Promise((resolve) => {
settleSampling = resolve;
});
const sampleFrame = () => {
if (!sampling) {
settleSampling();
return;
}
sample.frame += 1;
const activePopupCount = (scene.mapResultPopupObjects ?? []).filter((object) => object.active).length;
const activeEventObjectCount = (
scene.battleEventObjects ?? []
).filter((object) => object.active).length;
const promptVisible = (scene.turnPromptObjects?.length ?? 0) > 0;
sample.maxActivePopupCount = Math.max(sample.maxActivePopupCount, activePopupCount);
if (activePopupCount > 0) {
sample.popupSeen = true;
sample.firstPopupFrame ??= sample.frame;
}
if (promptVisible) {
sample.promptSeen = true;
sample.firstPromptFrame ??= sample.frame;
}
if (activePopupCount > 0 && promptVisible) {
sample.overlapSeen = true;
}
if (activeEventObjectCount > 0) {
sample.eventOverlaySeen = true;
}
if (
activePopupCount > 0 &&
activeEventObjectCount > 0
) {
sample.resultEventOverlapSeen = true;
}
if (sample.popupSeen && promptVisible && activePopupCount === 0) {
sample.settledFrame = sample.frame;
sampling = false;
settleSampling();
return;
}
if (sample.frame >= 480) {
samplingError = `timed out after ${sample.frame} animation frames`;
sampling = false;
settleSampling();
return;
}
requestAnimationFrame(sampleFrame);
};
try {
scene.clearBattleEvents();
scene.triggeredBattleEvents.delete('first-engagement');
scene.hideTurnEndPrompt();
scene.hideSaveSlotPanel();
scene.hideMapMenu();
scene.hideCommandMenu();
scene.clearMarkers();
(scene.mapResultPopupObjects ?? []).forEach((object) => {
if (object.active) {
scene.tweens.killTweensOf(object);
object.destroy();
}
});
scene.mapResultPopupObjects = [];
scene.mapResultPopupLast = undefined;
scene.battleSpeed = 'normal';
scene.activeFaction = 'ally';
scene.battleOutcome = undefined;
scene.phase = 'targeting';
scene.pendingMove = undefined;
scene.targetingAction = requestedActionKind === 'attack' ? 'attack' : 'strategy';
scene.lockedTargetPreview = undefined;
scene.actedUnitIds.clear();
scene.showBondMapEffect = async () => {};
scene.presentCombatResult = async () => {};
scene.presentSupportResult = async () => {};
scene.resolveCounterAttack = () => undefined;
scene.collectBattleEventsWithoutPresentation = () => {};
scene.resolveBattleOutcomeIfNeeded = () => false;
let actingUnit;
let targetUnit;
let supportUsable;
if (requestedActionKind === 'attack') {
actingUnit = allies[0];
targetUnit = units.find((unit) => unit.faction === 'enemy' && unit.hp > 0);
if (!targetUnit) {
return { error: 'missing living enemy target' };
}
const adjacentTile = [
{ x: actingUnit.x + 1, y: actingUnit.y },
{ x: actingUnit.x - 1, y: actingUnit.y },
{ x: actingUnit.x, y: actingUnit.y + 1 },
{ x: actingUnit.x, y: actingUnit.y - 1 }
].find((tile) => scene.isInBounds(tile.x, tile.y) && !scene.isOccupied(tile.x, tile.y));
targetUnit.x = adjacentTile?.x ?? actingUnit.x;
targetUnit.y = adjacentTile?.y ?? actingUnit.y;
targetUnit.maxHp = Math.max(targetUnit.maxHp, 999);
targetUnit.hp = targetUnit.maxHp;
} else {
const supportChoice = allies
.map((unit) => ({
unit,
usable: scene.availableUsables(unit, 'strategy').find((usable) => usable.effect === 'focus')
}))
.find((choice) => choice.usable);
actingUnit = supportChoice?.unit;
targetUnit = actingUnit;
supportUsable = supportChoice?.usable;
if (!actingUnit || !targetUnit || !supportUsable) {
return { error: 'missing allied focus-support action' };
}
}
scene.selectedUnit = actingUnit;
scene.selectedUsable = supportUsable;
allies
.filter((unit) => unit.id !== actingUnit.id)
.forEach((unit) => scene.actedUnitIds.add(unit.id));
scene.centerCameraOnTile(targetUnit.x, targetUnit.y);
scene.restoreUnitView(targetUnit);
scene.renderSituationPanel();
requestAnimationFrame(sampleFrame);
if (requestedActionKind === 'attack') {
await scene.tryResolveDamageTarget(actingUnit, targetUnit, 'attack');
} else {
await scene.tryResolveSupportTarget(actingUnit, targetUnit, supportUsable);
}
await samplingDone;
return {
...sample,
samplingError: samplingError ?? null,
actionCompleted: scene.actedUnitIds.has(actingUnit.id),
promptVisibleAfterAction: scene.turnPromptObjects.length > 0,
promptModeAfterAction: scene.turnPromptMode ?? null,
popupActiveAfterAction: scene.mapResultPopupObjects.filter((object) => object.active).length
};
} finally {
sampling = false;
scene.showBondMapEffect = originalMethods.showBondMapEffect;
scene.presentCombatResult = originalMethods.presentCombatResult;
scene.presentSupportResult = originalMethods.presentSupportResult;
scene.resolveCounterAttack = originalMethods.resolveCounterAttack;
scene.triggerFirstEngagementEvent = originalMethods.triggerFirstEngagementEvent;
scene.collectBattleEventsWithoutPresentation =
originalMethods.collectBattleEventsWithoutPresentation;
scene.showNextBattleEvent =
originalMethods.showNextBattleEvent;
scene.resolveBattleOutcomeIfNeeded = originalMethods.resolveBattleOutcomeIfNeeded;
scene.battleSpeed = originalBattleSpeed;
scene.hideTurnEndPrompt();
(scene.mapResultPopupObjects ?? []).forEach((object) => {
if (object.active) {
scene.tweens.killTweensOf(object);
object.destroy();
}
});
scene.mapResultPopupObjects = [];
scene.clearBattleEvents();
scene.triggeredBattleEvents.delete('first-engagement');
units.forEach((unit) => {
const original = originalUnitState.get(unit.id);
if (!original) {
return;
}
unit.x = original.x;
unit.y = original.y;
unit.hp = original.hp;
unit.maxHp = original.maxHp;
scene.restoreUnitView(unit);
});
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.selectedUsable = undefined;
scene.targetingAction = undefined;
scene.lockedTargetPreview = undefined;
scene.actedUnitIds.clear();
}
}, actionKind);
assert(!result?.error, `Could not prepare the final ${actionKind} timing case: ${JSON.stringify(result)}`);
assert(
result.popupSeen &&
result.promptSeen &&
!result.overlapSeen &&
!result.resultEventOverlapSeen &&
(actionKind !== 'attack' || result.eventOverlaySeen) &&
!result.samplingError &&
result.actionCompleted &&
result.promptVisibleAfterAction &&
result.promptModeAfterAction === 'turn-end' &&
result.popupActiveAfterAction === 0,
`The final ${actionKind} result must clear before the turn-end prompt appears: ${JSON.stringify(result)}`
);
}
await verifyFinalFollowUpResultTiming(page);
await verifyOutcomeGateEventTiming(page);
}
async function verifyFinalFollowUpResultTiming(page) {
const result = await page.evaluate(async () => {
const scene =
window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const allies = units.filter(
(unit) => unit.faction === 'ally' && unit.hp > 0
);
const actor = allies[0];
if (!scene || !actor) {
return {
error:
'missing active battle scene or final allied actor'
};
}
const originalMethods = {
settleEnemyIntentCounterplay:
scene.settleEnemyIntentCounterplay,
collectBattleEventsWithoutPresentation:
scene.collectBattleEventsWithoutPresentation,
showNextBattleEvent: scene.showNextBattleEvent,
resolveBattleOutcomeIfNeeded:
scene.resolveBattleOutcomeIfNeeded,
persistBattleAutosave: scene.persistBattleAutosave,
showTurnEndPrompt: scene.showTurnEndPrompt
};
const originalState = {
phase: scene.phase,
activeFaction: scene.activeFaction,
selectedUnit: scene.selectedUnit,
pendingMove: scene.pendingMove,
targetingAction: scene.targetingAction,
selectedUsable: scene.selectedUsable,
actedUnitIds: [...scene.actedUnitIds],
battleLog: [...scene.battleLog],
feedbackReadableUntil:
scene.battleFeedbackReadableUntil
};
let popupDuration = 0;
let promptAt = null;
let activePopupsWhenPromptOpened = null;
try {
scene.hideBattleEventBanner();
scene.hideTurnEndPrompt();
scene.hideSaveSlotPanel();
scene.hideMapMenu();
scene.hideCommandMenu();
scene.clearMarkers();
(scene.mapResultPopupObjects ?? []).forEach(
(object) => {
if (object.active) {
scene.tweens.killTweensOf(object);
object.destroy();
}
}
);
scene.mapResultPopupObjects = [];
scene.battleFeedbackReadableUntil = scene.time.now;
scene.activeFaction = 'ally';
scene.phase = 'command';
scene.selectedUnit = actor;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.actedUnitIds.clear();
allies
.filter((unit) => unit.id !== actor.id)
.forEach((unit) =>
scene.actedUnitIds.add(unit.id)
);
scene.settleEnemyIntentCounterplay = (
settlementActor
) => {
popupDuration = scene.showMapResultPopup(
settlementActor,
['의도 파훼 +1', '공격선 차단'],
'#ffdf7b',
'#2b1606',
18,
36
);
return {
message: `의도 파훼 +1 · ${settlementActor.name} · 차1`,
popupDuration
};
};
scene.collectBattleEventsWithoutPresentation =
() => {};
scene.showNextBattleEvent = () => {};
scene.resolveBattleOutcomeIfNeeded = () => false;
scene.persistBattleAutosave = () => true;
scene.showTurnEndPrompt = function (
...args
) {
promptAt = performance.now();
activePopupsWhenPromptOpened = (
scene.mapResultPopupObjects ?? []
).filter((object) => object.active).length;
return originalMethods.showTurnEndPrompt.apply(
this,
args
);
};
const startedAt = performance.now();
await scene.finishUnitAction(
actor,
`${actor.name} 행동 완료`
);
const elapsed = performance.now() - startedAt;
return {
elapsed,
popupDuration,
promptAt,
activePopupsWhenPromptOpened,
promptVisible:
scene.turnPromptObjects.length > 0,
promptMode: scene.turnPromptMode ?? null,
actionCompleted:
scene.actedUnitIds.has(actor.id)
};
} finally {
scene.settleEnemyIntentCounterplay =
originalMethods.settleEnemyIntentCounterplay;
scene.collectBattleEventsWithoutPresentation =
originalMethods.collectBattleEventsWithoutPresentation;
scene.showNextBattleEvent =
originalMethods.showNextBattleEvent;
scene.resolveBattleOutcomeIfNeeded =
originalMethods.resolveBattleOutcomeIfNeeded;
scene.persistBattleAutosave =
originalMethods.persistBattleAutosave;
scene.showTurnEndPrompt =
originalMethods.showTurnEndPrompt;
scene.hideTurnEndPrompt();
(scene.mapResultPopupObjects ?? []).forEach(
(object) => {
if (object.active) {
scene.tweens.killTweensOf(object);
object.destroy();
}
}
);
scene.mapResultPopupObjects = [];
scene.phase = originalState.phase;
scene.activeFaction = originalState.activeFaction;
scene.selectedUnit = originalState.selectedUnit;
scene.pendingMove = originalState.pendingMove;
scene.targetingAction =
originalState.targetingAction;
scene.selectedUsable =
originalState.selectedUsable;
scene.actedUnitIds = new Set(
originalState.actedUnitIds
);
scene.battleLog = originalState.battleLog;
scene.battleFeedbackReadableUntil =
originalState.feedbackReadableUntil;
scene.resetActedStyles();
}
});
assert(
!result?.error &&
result.popupDuration > 0 &&
result.elapsed >= result.popupDuration &&
result.promptAt !== null &&
result.activePopupsWhenPromptOpened === 0 &&
result.promptVisible &&
result.promptMode === 'turn-end' &&
result.actionCompleted,
`The final follow-up result must settle before the turn-end prompt appears: ${JSON.stringify(result)}`
);
}
async function verifyOutcomeGateEventTiming(page) {
const result = await page.evaluate(async () => {
const scene =
window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const allies = units.filter(
(unit) => unit.faction === 'ally' && unit.hp > 0
);
const actor = allies[0];
if (!scene || !actor) {
return {
error:
'missing active battle scene or final allied actor'
};
}
const eventKey = 'qa-victory-gate-pending';
const originalMethods = {
settleEnemyIntentCounterplay:
scene.settleEnemyIntentCounterplay,
collectBattleEventsWithoutPresentation:
scene.collectBattleEventsWithoutPresentation,
resolveBattleOutcomeIfNeeded:
scene.resolveBattleOutcomeIfNeeded,
persistBattleAutosave: scene.persistBattleAutosave,
showTurnEndPrompt: scene.showTurnEndPrompt
};
const originalState = {
phase: scene.phase,
activeFaction: scene.activeFaction,
selectedUnit: scene.selectedUnit,
actedUnitIds: [...scene.actedUnitIds],
battleLog: [...scene.battleLog]
};
let promptAt = null;
let activeEventObjectsWhenPromptOpened = null;
let outcomeResolutionCalls = 0;
try {
scene.clearBattleEvents();
scene.hideTurnEndPrompt();
scene.activeFaction = 'ally';
scene.phase = 'command';
scene.selectedUnit = actor;
scene.actedUnitIds.clear();
allies
.filter((unit) => unit.id !== actor.id)
.forEach((unit) =>
scene.actedUnitIds.add(unit.id)
);
scene.triggeredBattleEvents.delete(eventKey);
scene.settleEnemyIntentCounterplay = () => undefined;
scene.collectBattleEventsWithoutPresentation = () => {};
scene.resolveBattleOutcomeIfNeeded = function () {
outcomeResolutionCalls += 1;
this.triggerBattleEvent(
eventKey,
'Required objective pending',
['Secure the required objective before ending combat.'],
{ playCue: false, priority: 'critical' }
);
return false;
};
scene.persistBattleAutosave = () => true;
scene.showTurnEndPrompt = function (...args) {
promptAt = performance.now();
activeEventObjectsWhenPromptOpened =
this.battleEventObjects.filter(
(object) => object.active
).length;
return originalMethods.showTurnEndPrompt.apply(
this,
args
);
};
const startedAt = performance.now();
await scene.finishUnitAction(
actor,
`${actor.name} action complete`
);
return {
elapsed: performance.now() - startedAt,
promptAt,
activeEventObjectsWhenPromptOpened,
outcomeResolutionCalls,
eventTriggered:
scene.triggeredBattleEvents.has(eventKey),
promptVisible:
scene.turnPromptObjects.length > 0,
promptMode: scene.turnPromptMode ?? null
};
} finally {
scene.settleEnemyIntentCounterplay =
originalMethods.settleEnemyIntentCounterplay;
scene.collectBattleEventsWithoutPresentation =
originalMethods.collectBattleEventsWithoutPresentation;
scene.resolveBattleOutcomeIfNeeded =
originalMethods.resolveBattleOutcomeIfNeeded;
scene.persistBattleAutosave =
originalMethods.persistBattleAutosave;
scene.showTurnEndPrompt =
originalMethods.showTurnEndPrompt;
scene.clearBattleEvents();
scene.hideTurnEndPrompt();
scene.triggeredBattleEvents.delete(eventKey);
scene.phase = originalState.phase;
scene.activeFaction = originalState.activeFaction;
scene.selectedUnit = originalState.selectedUnit;
scene.actedUnitIds = new Set(
originalState.actedUnitIds
);
scene.battleLog = originalState.battleLog;
scene.resetActedStyles();
}
});
assert(
!result?.error &&
result.outcomeResolutionCalls === 1 &&
result.eventTriggered &&
result.promptAt !== null &&
result.activeEventObjectsWhenPromptOpened === 0 &&
result.promptVisible &&
result.promptMode === 'turn-end',
`A victory-gate event created during outcome resolution must clear before the turn-end prompt appears: ${JSON.stringify(result)}`
);
}
async function verifyBattleEventOverlayInputBlock(page) {
const before = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -1221,6 +1834,57 @@ async function verifyCampTimelineRowLayout(page) {
);
}
async function verifyBattleShutdownWaitCleanup(page) {
await page.evaluate(
(battleId) =>
window.__HEROS_DEBUG__?.goToBattle(battleId),
expectedBattleId
);
await page.waitForFunction((battleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.battleId === battleId &&
['deployment', 'idle'].includes(battle.phase) &&
battle.mapBackgroundReady === true
);
}, expectedBattleId, { timeout: 90000 });
const result = await page.evaluate(async () => {
const scene =
window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return { error: 'missing active battle scene' };
}
scene.battleFeedbackReadableUntil =
scene.time.now + 5_000;
const waits = [
scene.delay(5_000),
scene.waitSceneDuration(5_000),
scene.waitForBattleFeedbackReadability()
];
scene.scene.stop();
const resolved = await Promise.race([
Promise.all(waits).then(() => true),
new Promise((resolve) =>
window.setTimeout(() => resolve(false), 1_500)
)
]);
return {
resolved,
battleSceneActive:
window.__HEROS_DEBUG__?.activeScenes?.()
?.includes('BattleScene') ?? false
};
});
assert(
!result?.error &&
result.resolved === true &&
result.battleSceneActive === false,
`Battle result waits remained pending after scene shutdown: ${JSON.stringify(result)}`
);
}
async function startDeploymentIfNeeded(page, battleId) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
if (state?.phase === 'idle') {
@@ -1292,6 +1956,9 @@ async function assertDesktopViewport(page) {
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
scale: window.visualViewport?.scale ?? 1,
rendererType:
window.__HEROS_GAME__?.renderer?.type ?? null,
canvas: (() => {
const canvas = document.querySelector('canvas');
return canvas ? { width: canvas.width, height: canvas.height } : null;
@@ -1301,6 +1968,9 @@ async function assertDesktopViewport(page) {
viewport.width === desktopBrowserViewport.width &&
viewport.height === desktopBrowserViewport.height &&
viewport.dpr === 1 &&
viewport.scale === 1 &&
viewport.rendererType ===
(renderer === 'webgl' ? 2 : 1) &&
viewport.canvas?.width === desktopBrowserViewport.width &&
viewport.canvas?.height === desktopBrowserViewport.height,
`Expected the required 1920x1080 CSS viewport at 100% zoom: ${JSON.stringify(viewport)}`