fix: sequence battle feedback and autosaves
This commit is contained in:
@@ -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)}`
|
||||
|
||||
Reference in New Issue
Block a user