fix: sequence battle feedback and autosaves
This commit is contained in:
@@ -100,11 +100,136 @@ assert.match(deploymentPanel, /combatAssetsReady\s*\? '전투 시작'/s);
|
||||
|
||||
const resolveDamageTarget = privateMethodBody(battleSource, 'tryResolveDamageTarget');
|
||||
assert.match(resolveDamageTarget, /triggerFirstEngagementEvent\(attacker, target\)/);
|
||||
assert.match(
|
||||
resolveDamageTarget,
|
||||
/await this\.showCombatExchangeMapResults\(result, true\);/,
|
||||
'the final attack path must await the full readable map-result window before completing the unit action'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
resolveDamageTarget,
|
||||
/await this\.delay\(620\)/,
|
||||
'attack completion must follow the popup lifetime instead of a shorter fixed delay'
|
||||
);
|
||||
assert(
|
||||
resolveDamageTarget.indexOf('await this.showCombatExchangeMapResults(result, true)') <
|
||||
resolveDamageTarget.indexOf('this.finishUnitAction(attacker'),
|
||||
'the attack result must finish displaying before finishUnitAction can open the turn-end prompt'
|
||||
);
|
||||
const resolveSupportTarget = privateMethodBody(battleSource, 'tryResolveSupportTarget');
|
||||
assert.match(
|
||||
resolveSupportTarget,
|
||||
/const popupDuration = this\.showSupportMapResult\(result\);\s*await this\.waitSceneDuration\(popupDuration\);/s,
|
||||
'the final support path must await the duration returned by its map-result popup'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
resolveSupportTarget,
|
||||
/await this\.delay\(620\)/,
|
||||
'support completion must follow the popup lifetime instead of a shorter fixed delay'
|
||||
);
|
||||
assert(
|
||||
resolveSupportTarget.indexOf('await this.waitSceneDuration(popupDuration)') <
|
||||
resolveSupportTarget.indexOf('this.finishUnitAction(user'),
|
||||
'the support result must finish displaying before finishUnitAction can open the turn-end prompt'
|
||||
);
|
||||
const showSupportMapResult = privateMethodBody(battleSource, 'showSupportMapResult');
|
||||
assert.equal(
|
||||
countMatches(showSupportMapResult, /return this\.showMapResultPopup\(/g),
|
||||
2,
|
||||
'both healing and buff support results must return their actual popup lifetime'
|
||||
);
|
||||
const settleEnemyIntentCounterplay = privateMethodBody(
|
||||
battleSource,
|
||||
'settleEnemyIntentCounterplay'
|
||||
);
|
||||
assert.match(
|
||||
settleEnemyIntentCounterplay,
|
||||
/const popupDuration = this\.showMapResultPopup\([\s\S]*return \{[\s\S]*message:[\s\S]*popupDuration/s,
|
||||
'intent counterplay must expose the lifetime of its follow-up result'
|
||||
);
|
||||
const showMapResultPopup = privateMethodBody(
|
||||
battleSource,
|
||||
'showMapResultPopup'
|
||||
);
|
||||
assert.match(
|
||||
showMapResultPopup,
|
||||
/extendBattleFeedbackReadableWindow\([\s\S]*duration \+ delay \+ battleFeedbackSettlePaddingMs/s,
|
||||
'map results must extend the shared action-feedback readability gate'
|
||||
);
|
||||
const showObjectiveMapFeedback = privateMethodBody(
|
||||
battleSource,
|
||||
'showObjectiveMapFeedback'
|
||||
);
|
||||
assert.equal(
|
||||
countMatches(
|
||||
showObjectiveMapFeedback,
|
||||
/extendBattleFeedbackReadableWindow\(/g
|
||||
),
|
||||
2,
|
||||
'visible and offscreen objective feedback must both extend the action-feedback readability gate'
|
||||
);
|
||||
const finishUnitAction = privateMethodBody(
|
||||
battleSource,
|
||||
'finishUnitAction'
|
||||
);
|
||||
assert.match(
|
||||
finishUnitAction,
|
||||
/collectBattleEventsWithoutPresentation\(\);[\s\S]*await this\.waitForBattleFeedbackReadability\(\);[\s\S]*this\.showNextBattleEvent\(\);[\s\S]*await this\.waitForBattleEventPresentation\(\);/s,
|
||||
'post-action map feedback must finish before queued battle events, and both must finish before turn completion'
|
||||
);
|
||||
assert(
|
||||
finishUnitAction.indexOf(
|
||||
'await this.waitForBattleEventPresentation()'
|
||||
) <
|
||||
finishUnitAction.indexOf(
|
||||
'this.showTurnEndPrompt(message)'
|
||||
),
|
||||
'the turn-end prompt must follow all transient and modal action feedback'
|
||||
);
|
||||
const outcomeResolutionIndex = finishUnitAction.indexOf(
|
||||
'this.resolveBattleOutcomeIfNeeded()'
|
||||
);
|
||||
const postOutcomeEventWaitIndex = finishUnitAction.indexOf(
|
||||
'await this.waitForBattleEventPresentation()',
|
||||
outcomeResolutionIndex
|
||||
);
|
||||
assert(
|
||||
outcomeResolutionIndex >= 0 &&
|
||||
postOutcomeEventWaitIndex > outcomeResolutionIndex &&
|
||||
postOutcomeEventWaitIndex <
|
||||
finishUnitAction.indexOf('this.showTurnEndPrompt(message)'),
|
||||
'a victory-gate event created during outcome resolution must also clear before the turn-end prompt'
|
||||
);
|
||||
const firstEngagementEvent = privateMethodBody(battleSource, 'triggerFirstEngagementEvent');
|
||||
assert.match(firstEngagementEvent, /volunteerPromiseLineForBattle\(/);
|
||||
assert.match(firstEngagementEvent, /triggerBattleEvent\(firstBattleVolunteerPromiseEventKey[\s\S]*\{ playCue: false \}\)/);
|
||||
assert.match(
|
||||
firstEngagementEvent,
|
||||
/presentationWasDeferred[\s\S]*deferBattleEventPresentation = true[\s\S]*finally[\s\S]*deferBattleEventPresentation =\s*presentationWasDeferred/s,
|
||||
'the first-engagement notice must queue behind attack results instead of covering them'
|
||||
);
|
||||
const triggerBattleEvent = privateMethodBody(battleSource, 'triggerBattleEvent');
|
||||
assert.match(triggerBattleEvent, /options\.playCue !== false/);
|
||||
const sceneDurationWait = privateMethodBody(battleSource, 'waitSceneDuration');
|
||||
assert.match(
|
||||
sceneDurationWait,
|
||||
/Phaser\.Scenes\.Events\.SHUTDOWN[\s\S]*resolve\(\)/s,
|
||||
'action-result waits must resolve when BattleScene shuts down'
|
||||
);
|
||||
const scaledBattleDelay = privateMethodBody(battleSource, 'delay');
|
||||
assert.match(
|
||||
scaledBattleDelay,
|
||||
/return this\.waitSceneDuration\(\s*this\.scaledBattleDuration\(ms\)\s*\)/s,
|
||||
'scaled combat delays must share the scene-shutdown-safe wait path'
|
||||
);
|
||||
const firstBattleTutorialSchedule = privateMethodBody(
|
||||
battleSource,
|
||||
'scheduleFirstBattleTutorial'
|
||||
);
|
||||
assert.match(
|
||||
firstBattleTutorialSchedule,
|
||||
/!this\.activeBattleEvent[\s\S]*this\.battleEventQueue\.length === 0[\s\S]*this\.battleEventObjects\.length === 0/s,
|
||||
'a restored pending battle event must display before the first-battle tutorial resumes'
|
||||
);
|
||||
|
||||
const movementSound = privateMethodBody(battleSource, 'playMovementSound');
|
||||
assert.match(movementSound, /const minInterval = isMounted \? 125 : 180/);
|
||||
|
||||
@@ -47,6 +47,11 @@ const battleBaseKey = `heros-web:battle:${battleId}`;
|
||||
const battleSlotKey = `${battleBaseKey}:slot-1`;
|
||||
const legacyBattleKey = 'heros-web:first-battle-state';
|
||||
const battleStorageKeys = [battleSlotKey, battleBaseKey, legacyBattleKey];
|
||||
const otherBattleBaseKey =
|
||||
'heros-web:battle:second-battle-yellow-turban-pursuit';
|
||||
const otherBattleSlotKey = `${otherBattleBaseKey}:slot-1`;
|
||||
const otherBattleCheckpoint =
|
||||
'{"sentinel":"preserve-other-battle-checkpoint"}';
|
||||
const baseUrl =
|
||||
process.env.VERIFY_BATTLE_SAVE_GENERATION_URL ??
|
||||
`http://127.0.0.1:${renderer === 'canvas' ? 41825 : 41826}/heros_web/`;
|
||||
@@ -222,7 +227,10 @@ try {
|
||||
staleMarkerHp,
|
||||
validMarkerHp,
|
||||
valid,
|
||||
removalStats
|
||||
removalStats,
|
||||
restoreRemovalInterceptor() {
|
||||
storagePrototype.removeItem = originalRemoveItem;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -491,6 +499,606 @@ try {
|
||||
assert.equal(restored.slotGeneration, campaignGeneration);
|
||||
assert.equal(restored.campaignStep, campaignStep);
|
||||
assert.equal(restored.slotStep, campaignStep);
|
||||
|
||||
const tutorialPendingEventProbe = await page.evaluate(() => {
|
||||
const scene =
|
||||
window.__HEROS_DEBUG__?.scene('BattleScene');
|
||||
if (
|
||||
!scene ||
|
||||
typeof scene.scheduleFirstBattleTutorial !==
|
||||
'function'
|
||||
) {
|
||||
throw new Error(
|
||||
'The first-battle tutorial resume probe is unavailable.'
|
||||
);
|
||||
}
|
||||
const originalStart =
|
||||
scene.startFirstBattleTutorial;
|
||||
const originalDelayedCall =
|
||||
scene.time.delayedCall;
|
||||
const originalState = {
|
||||
phase: scene.phase,
|
||||
activeFaction: scene.activeFaction,
|
||||
turnNumber: scene.turnNumber,
|
||||
firstBattleTutorialStep:
|
||||
scene.firstBattleTutorialStep,
|
||||
activeBattleEvent: scene.activeBattleEvent,
|
||||
battleEventQueue: scene.battleEventQueue,
|
||||
battleEventObjects: scene.battleEventObjects
|
||||
};
|
||||
let tutorialStartCalls = 0;
|
||||
let retryCalls = 0;
|
||||
try {
|
||||
scene.phase = 'idle';
|
||||
scene.activeFaction = 'ally';
|
||||
scene.turnNumber = 1;
|
||||
scene.firstBattleTutorialStep = undefined;
|
||||
scene.activeBattleEvent = undefined;
|
||||
scene.battleEventObjects = [];
|
||||
scene.battleEventQueue = [{
|
||||
key: 'qa-restored-pending-event',
|
||||
title: 'Restored pending event',
|
||||
lines: ['This event must display before tutorial input.'],
|
||||
priority: 'normal',
|
||||
playCue: false
|
||||
}];
|
||||
scene.startFirstBattleTutorial = () => {
|
||||
tutorialStartCalls += 1;
|
||||
return true;
|
||||
};
|
||||
scene.time.delayedCall = () => {
|
||||
retryCalls += 1;
|
||||
return { remove() {} };
|
||||
};
|
||||
scene.scheduleFirstBattleTutorial();
|
||||
return {
|
||||
tutorialStartCalls,
|
||||
retryCalls,
|
||||
pendingEventCount:
|
||||
scene.battleEventQueue.length
|
||||
};
|
||||
} finally {
|
||||
scene.startFirstBattleTutorial = originalStart;
|
||||
scene.time.delayedCall = originalDelayedCall;
|
||||
scene.phase = originalState.phase;
|
||||
scene.activeFaction = originalState.activeFaction;
|
||||
scene.turnNumber = originalState.turnNumber;
|
||||
scene.firstBattleTutorialStep =
|
||||
originalState.firstBattleTutorialStep;
|
||||
scene.activeBattleEvent =
|
||||
originalState.activeBattleEvent;
|
||||
scene.battleEventQueue =
|
||||
originalState.battleEventQueue;
|
||||
scene.battleEventObjects =
|
||||
originalState.battleEventObjects;
|
||||
}
|
||||
});
|
||||
assert.deepEqual(
|
||||
tutorialPendingEventProbe,
|
||||
{
|
||||
tutorialStartCalls: 0,
|
||||
retryCalls: 1,
|
||||
pendingEventCount: 1
|
||||
},
|
||||
`${renderer}: a restored pending battle event was not allowed to finish before the first-battle tutorial resumed.`
|
||||
);
|
||||
|
||||
const autosaveContract = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
|
||||
const battle = window.__HEROS_DEBUG__?.battle?.();
|
||||
const fixtureState =
|
||||
window.__HEROS_BATTLE_GENERATION_FIXTURE__;
|
||||
fixtureState?.restoreRemovalInterceptor?.();
|
||||
return {
|
||||
sceneMethod:
|
||||
typeof scene?.persistBattleAutosave === 'function',
|
||||
debugState:
|
||||
battle?.autosave ?? null
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
autosaveContract.sceneMethod,
|
||||
true,
|
||||
`${renderer}: BattleScene.persistBattleAutosave(reason) is unavailable.`
|
||||
);
|
||||
assertAutosaveDebugState(
|
||||
autosaveContract.debugState,
|
||||
`${renderer}: initial autosave debug state`
|
||||
);
|
||||
|
||||
const actionBaseline = await readAutosaveCheckpoint(
|
||||
page,
|
||||
battleSlotKey
|
||||
);
|
||||
const actionTrigger = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
|
||||
const unit = scene?.debugUnitById?.('liu-bei');
|
||||
if (
|
||||
!scene ||
|
||||
!unit ||
|
||||
typeof scene.completeUnitAction !== 'function'
|
||||
) {
|
||||
throw new Error(
|
||||
'The ally-action autosave trigger is unavailable.'
|
||||
);
|
||||
}
|
||||
scene.phase = 'command';
|
||||
scene.activeFaction = 'ally';
|
||||
scene.selectedUnit = unit;
|
||||
scene.pendingMove = undefined;
|
||||
scene.targetingAction = undefined;
|
||||
scene.selectedUsable = undefined;
|
||||
scene.completeUnitAction(unit, 'wait');
|
||||
return {
|
||||
phase:
|
||||
window.__HEROS_DEBUG__?.battle?.()?.phase ?? null,
|
||||
actedUnitIds: [
|
||||
...(window.__HEROS_DEBUG__?.battle?.()?.actedUnitIds ?? [])
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
assert(
|
||||
actionTrigger.actedUnitIds.includes('liu-bei'),
|
||||
`${renderer}: the ally action did not complete.`
|
||||
);
|
||||
|
||||
await page.waitForFunction(
|
||||
({
|
||||
requestedBattleSlotKey,
|
||||
previousSavedAt,
|
||||
previousWriteCount
|
||||
}) => {
|
||||
const persisted = JSON.parse(
|
||||
window.localStorage.getItem(requestedBattleSlotKey) ??
|
||||
'null'
|
||||
);
|
||||
const autosave =
|
||||
window.__HEROS_DEBUG__?.battle?.()?.autosave;
|
||||
return (
|
||||
persisted?.savedAt &&
|
||||
persisted.savedAt !== previousSavedAt &&
|
||||
persisted.actedUnitIds?.includes('liu-bei') &&
|
||||
autosave?.writeCount === previousWriteCount + 1 &&
|
||||
autosave?.lastResult === 'saved' &&
|
||||
autosave?.lastReason === 'ally-action'
|
||||
);
|
||||
},
|
||||
{
|
||||
requestedBattleSlotKey: battleSlotKey,
|
||||
previousSavedAt: actionBaseline.state.savedAt,
|
||||
previousWriteCount:
|
||||
actionBaseline.autosave.writeCount
|
||||
}
|
||||
);
|
||||
|
||||
const actionAutosave = await readAutosaveCheckpoint(
|
||||
page,
|
||||
battleSlotKey
|
||||
);
|
||||
assertAutosaveWrite(
|
||||
actionBaseline,
|
||||
actionAutosave,
|
||||
{
|
||||
reason: 'ally-action',
|
||||
expectedTurnNumber: fixture.validTurnNumber,
|
||||
expectedActedUnitId: 'liu-bei'
|
||||
},
|
||||
`${renderer}: ally action autosave`
|
||||
);
|
||||
|
||||
const unsafeAutosaves = [];
|
||||
for (const [
|
||||
unsafeIndex,
|
||||
unsafePhase
|
||||
] of ['command', 'animating'].entries()) {
|
||||
const beforeUnsafe = await readAutosaveCheckpoint(
|
||||
page,
|
||||
battleSlotKey
|
||||
);
|
||||
const unsafeTurnNumber = 41 + unsafeIndex;
|
||||
const eventProbe = await page.evaluate(
|
||||
({
|
||||
requestedPhase,
|
||||
requestedTurnNumber
|
||||
}) => {
|
||||
const scene =
|
||||
window.__HEROS_DEBUG__?.scene('BattleScene');
|
||||
const unit = scene?.debugUnitById?.('liu-bei');
|
||||
if (
|
||||
!scene ||
|
||||
!unit ||
|
||||
typeof scene.persistBattleAutosave !== 'function'
|
||||
) {
|
||||
throw new Error(
|
||||
'The unsafe pagehide autosave probe is unavailable.'
|
||||
);
|
||||
}
|
||||
scene.turnNumber = requestedTurnNumber;
|
||||
scene.activeFaction = 'ally';
|
||||
scene.phase = requestedPhase;
|
||||
scene.selectedUnit =
|
||||
requestedPhase === 'command' ? unit : undefined;
|
||||
scene.pendingMove = undefined;
|
||||
scene.targetingAction = undefined;
|
||||
scene.selectedUsable = undefined;
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
const battle =
|
||||
window.__HEROS_DEBUG__?.battle?.();
|
||||
return {
|
||||
phase: battle?.phase ?? null,
|
||||
turnNumber: battle?.turnNumber ?? null,
|
||||
autosave: battle?.autosave ?? null
|
||||
};
|
||||
},
|
||||
{
|
||||
requestedPhase: unsafePhase,
|
||||
requestedTurnNumber: unsafeTurnNumber
|
||||
}
|
||||
);
|
||||
const afterUnsafe = await readAutosaveCheckpoint(
|
||||
page,
|
||||
battleSlotKey
|
||||
);
|
||||
|
||||
assert.equal(eventProbe.phase, unsafePhase);
|
||||
assert.equal(
|
||||
eventProbe.turnNumber,
|
||||
unsafeTurnNumber
|
||||
);
|
||||
assertAutosaveDebugState(
|
||||
eventProbe.autosave,
|
||||
`${renderer}: ${unsafePhase} pagehide debug state`
|
||||
);
|
||||
assert.equal(
|
||||
afterUnsafe.raw,
|
||||
beforeUnsafe.raw,
|
||||
`${renderer}: ${unsafePhase} pagehide overwrote the last safe checkpoint.`
|
||||
);
|
||||
assert.equal(
|
||||
afterUnsafe.autosave.writeCount,
|
||||
beforeUnsafe.autosave.writeCount,
|
||||
`${renderer}: ${unsafePhase} pagehide incremented the autosave write count.`
|
||||
);
|
||||
assert.equal(
|
||||
afterUnsafe.autosave.skipCount,
|
||||
beforeUnsafe.autosave.skipCount + 1,
|
||||
`${renderer}: ${unsafePhase} pagehide did not record one safe-state skip.`
|
||||
);
|
||||
assert.equal(
|
||||
afterUnsafe.autosave.failureCount,
|
||||
beforeUnsafe.autosave.failureCount,
|
||||
`${renderer}: ${unsafePhase} pagehide was reported as a failure instead of a skip.`
|
||||
);
|
||||
assert.equal(
|
||||
afterUnsafe.autosave.lastResult,
|
||||
'skipped'
|
||||
);
|
||||
assert.equal(
|
||||
afterUnsafe.autosave.lastReason,
|
||||
'pagehide'
|
||||
);
|
||||
assert.equal(
|
||||
afterUnsafe.autosave.lastSavedAt,
|
||||
beforeUnsafe.state.savedAt
|
||||
);
|
||||
unsafeAutosaves.push({
|
||||
phase: unsafePhase,
|
||||
turnNumber: unsafeTurnNumber,
|
||||
writeCount: afterUnsafe.autosave.writeCount,
|
||||
skipCount: afterUnsafe.autosave.skipCount,
|
||||
checkpointSavedAt: afterUnsafe.state.savedAt
|
||||
});
|
||||
}
|
||||
|
||||
const readinessBaseline = await readAutosaveCheckpoint(
|
||||
page,
|
||||
battleSlotKey
|
||||
);
|
||||
const readinessTurnNumber = 42;
|
||||
const readinessProbe = await page.evaluate(
|
||||
(requestedTurnNumber) => {
|
||||
const scene =
|
||||
window.__HEROS_DEBUG__?.scene('BattleScene');
|
||||
if (!scene) {
|
||||
throw new Error(
|
||||
'The autosave readiness probe is unavailable.'
|
||||
);
|
||||
}
|
||||
scene.turnNumber = requestedTurnNumber;
|
||||
scene.activeFaction = 'ally';
|
||||
scene.phase = 'idle';
|
||||
scene.selectedUnit = undefined;
|
||||
scene.pendingMove = undefined;
|
||||
scene.targetingAction = undefined;
|
||||
scene.selectedUsable = undefined;
|
||||
scene.battleAutosaveReady = false;
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
const blocked =
|
||||
window.__HEROS_DEBUG__?.battle?.()?.autosave ??
|
||||
null;
|
||||
scene.battleAutosaveReady = true;
|
||||
return blocked;
|
||||
},
|
||||
readinessTurnNumber
|
||||
);
|
||||
const readinessAfter = await readAutosaveCheckpoint(
|
||||
page,
|
||||
battleSlotKey
|
||||
);
|
||||
assertAutosaveDebugState(
|
||||
readinessProbe,
|
||||
`${renderer}: pre-initialization pagehide debug state`
|
||||
);
|
||||
assert.equal(
|
||||
readinessAfter.raw,
|
||||
readinessBaseline.raw,
|
||||
`${renderer}: a pagehide before battle initialization replaced the last safe checkpoint.`
|
||||
);
|
||||
assert.equal(
|
||||
readinessAfter.autosave.writeCount,
|
||||
readinessBaseline.autosave.writeCount
|
||||
);
|
||||
assert.equal(
|
||||
readinessAfter.autosave.skipCount,
|
||||
readinessBaseline.autosave.skipCount + 1
|
||||
);
|
||||
assert.equal(
|
||||
readinessAfter.autosave.lastResult,
|
||||
'skipped'
|
||||
);
|
||||
assert.equal(
|
||||
readinessAfter.autosave.lastReason,
|
||||
'pagehide'
|
||||
);
|
||||
|
||||
const idleBaseline = await readAutosaveCheckpoint(
|
||||
page,
|
||||
battleSlotKey
|
||||
);
|
||||
const idleTurnNumber = 43;
|
||||
const idleMarkerHp = Math.max(
|
||||
1,
|
||||
fixture.validMarkerHp - 1
|
||||
);
|
||||
await page.evaluate(
|
||||
({
|
||||
requestedTurnNumber,
|
||||
requestedMarkerHp
|
||||
}) => {
|
||||
const scene =
|
||||
window.__HEROS_DEBUG__?.scene('BattleScene');
|
||||
const unit = scene?.debugUnitById?.('liu-bei');
|
||||
if (
|
||||
!scene ||
|
||||
!unit ||
|
||||
typeof scene.persistBattleAutosave !== 'function'
|
||||
) {
|
||||
throw new Error(
|
||||
'The idle pagehide autosave probe is unavailable.'
|
||||
);
|
||||
}
|
||||
scene.turnNumber = requestedTurnNumber;
|
||||
scene.activeFaction = 'ally';
|
||||
scene.phase = 'idle';
|
||||
scene.selectedUnit = undefined;
|
||||
scene.pendingMove = undefined;
|
||||
scene.targetingAction = undefined;
|
||||
scene.selectedUsable = undefined;
|
||||
unit.hp = requestedMarkerHp;
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
},
|
||||
{
|
||||
requestedTurnNumber: idleTurnNumber,
|
||||
requestedMarkerHp: idleMarkerHp
|
||||
}
|
||||
);
|
||||
|
||||
await page.waitForFunction(
|
||||
({
|
||||
requestedBattleSlotKey,
|
||||
previousSavedAt,
|
||||
previousWriteCount,
|
||||
expectedTurnNumber,
|
||||
expectedMarkerHp
|
||||
}) => {
|
||||
const persisted = JSON.parse(
|
||||
window.localStorage.getItem(requestedBattleSlotKey) ??
|
||||
'null'
|
||||
);
|
||||
const autosave =
|
||||
window.__HEROS_DEBUG__?.battle?.()?.autosave;
|
||||
return (
|
||||
persisted?.savedAt &&
|
||||
persisted.savedAt !== previousSavedAt &&
|
||||
persisted.turnNumber === expectedTurnNumber &&
|
||||
persisted.units?.find(
|
||||
(unit) => unit.id === 'liu-bei'
|
||||
)?.hp === expectedMarkerHp &&
|
||||
autosave?.writeCount === previousWriteCount + 1 &&
|
||||
autosave?.lastResult === 'saved' &&
|
||||
autosave?.lastReason === 'pagehide'
|
||||
);
|
||||
},
|
||||
{
|
||||
requestedBattleSlotKey: battleSlotKey,
|
||||
previousSavedAt: idleBaseline.state.savedAt,
|
||||
previousWriteCount:
|
||||
idleBaseline.autosave.writeCount,
|
||||
expectedTurnNumber: idleTurnNumber,
|
||||
expectedMarkerHp: idleMarkerHp
|
||||
}
|
||||
);
|
||||
|
||||
const idleAutosave = await readAutosaveCheckpoint(
|
||||
page,
|
||||
battleSlotKey
|
||||
);
|
||||
assertAutosaveWrite(
|
||||
idleBaseline,
|
||||
idleAutosave,
|
||||
{
|
||||
reason: 'pagehide',
|
||||
expectedTurnNumber: idleTurnNumber,
|
||||
expectedActedUnitId: 'liu-bei',
|
||||
expectedMarkerHp: idleMarkerHp
|
||||
},
|
||||
`${renderer}: ally idle pagehide autosave`
|
||||
);
|
||||
assert.equal(
|
||||
idleAutosave.autosave.skipCount,
|
||||
idleBaseline.autosave.skipCount,
|
||||
`${renderer}: a safe idle pagehide was counted as skipped.`
|
||||
);
|
||||
|
||||
const rollbackBaseline = await readAutosaveCheckpoint(
|
||||
page,
|
||||
battleSlotKey
|
||||
);
|
||||
const rollbackBaseRaw = await page.evaluate(
|
||||
(requestedBattleBaseKey) =>
|
||||
window.localStorage.getItem(requestedBattleBaseKey),
|
||||
battleBaseKey
|
||||
);
|
||||
const rollbackProbe = await page.evaluate(
|
||||
({
|
||||
requestedBattleSlotKey,
|
||||
requestedBattleBaseKey,
|
||||
requestedOtherBattleSlotKey,
|
||||
requestedOtherBattleBaseKey,
|
||||
requestedOtherBattleCheckpoint
|
||||
}) => {
|
||||
const scene =
|
||||
window.__HEROS_DEBUG__?.scene('BattleScene');
|
||||
if (
|
||||
!scene ||
|
||||
typeof scene.persistBattleAutosave !== 'function'
|
||||
) {
|
||||
throw new Error(
|
||||
'The autosave rollback probe is unavailable.'
|
||||
);
|
||||
}
|
||||
scene.battleAutosaveReady = true;
|
||||
scene.activeFaction = 'ally';
|
||||
scene.phase = 'idle';
|
||||
scene.selectedUnit = undefined;
|
||||
scene.pendingMove = undefined;
|
||||
scene.targetingAction = undefined;
|
||||
scene.selectedUsable = undefined;
|
||||
scene.turnNumber = 44;
|
||||
window.localStorage.setItem(
|
||||
requestedOtherBattleSlotKey,
|
||||
requestedOtherBattleCheckpoint
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
requestedOtherBattleBaseKey,
|
||||
requestedOtherBattleCheckpoint
|
||||
);
|
||||
|
||||
const storagePrototype =
|
||||
Object.getPrototypeOf(window.localStorage);
|
||||
const originalSetItem = storagePrototype.setItem;
|
||||
let injected = false;
|
||||
storagePrototype.setItem = function setItem(
|
||||
key,
|
||||
value
|
||||
) {
|
||||
if (
|
||||
this === window.localStorage &&
|
||||
String(key) === requestedBattleSlotKey &&
|
||||
!injected
|
||||
) {
|
||||
injected = true;
|
||||
throw new DOMException(
|
||||
'Injected autosave write refusal',
|
||||
'QuotaExceededError'
|
||||
);
|
||||
}
|
||||
return originalSetItem.call(this, key, value);
|
||||
};
|
||||
|
||||
let saved;
|
||||
try {
|
||||
saved = scene.persistBattleAutosave('debug');
|
||||
} finally {
|
||||
storagePrototype.setItem = originalSetItem;
|
||||
}
|
||||
return {
|
||||
saved,
|
||||
injected,
|
||||
slotRaw: window.localStorage.getItem(
|
||||
requestedBattleSlotKey
|
||||
),
|
||||
baseRaw: window.localStorage.getItem(
|
||||
requestedBattleBaseKey
|
||||
),
|
||||
otherBattleSlotRaw: window.localStorage.getItem(
|
||||
requestedOtherBattleSlotKey
|
||||
),
|
||||
otherBattleBaseRaw: window.localStorage.getItem(
|
||||
requestedOtherBattleBaseKey
|
||||
),
|
||||
autosave:
|
||||
window.__HEROS_DEBUG__?.battle?.()?.autosave ??
|
||||
null
|
||||
};
|
||||
},
|
||||
{
|
||||
requestedBattleSlotKey: battleSlotKey,
|
||||
requestedBattleBaseKey: battleBaseKey,
|
||||
requestedOtherBattleSlotKey: otherBattleSlotKey,
|
||||
requestedOtherBattleBaseKey: otherBattleBaseKey,
|
||||
requestedOtherBattleCheckpoint:
|
||||
otherBattleCheckpoint
|
||||
}
|
||||
);
|
||||
assert.equal(rollbackProbe.injected, true);
|
||||
assert.equal(rollbackProbe.saved, false);
|
||||
assert.equal(
|
||||
rollbackProbe.slotRaw,
|
||||
rollbackBaseline.raw,
|
||||
`${renderer}: a failed autosave did not restore the canonical slot checkpoint.`
|
||||
);
|
||||
assert.equal(
|
||||
rollbackProbe.baseRaw,
|
||||
rollbackBaseRaw,
|
||||
`${renderer}: a failed autosave did not restore the slot-one compatibility checkpoint.`
|
||||
);
|
||||
assert.equal(
|
||||
rollbackProbe.otherBattleSlotRaw,
|
||||
otherBattleCheckpoint,
|
||||
`${renderer}: a failed autosave discarded another battle's canonical checkpoint in the same campaign slot.`
|
||||
);
|
||||
assert.equal(
|
||||
rollbackProbe.otherBattleBaseRaw,
|
||||
otherBattleCheckpoint,
|
||||
`${renderer}: a failed autosave discarded another battle's compatibility checkpoint in the same campaign slot.`
|
||||
);
|
||||
assert.equal(
|
||||
rollbackProbe.autosave.writeCount,
|
||||
rollbackBaseline.autosave.writeCount
|
||||
);
|
||||
assert.equal(
|
||||
rollbackProbe.autosave.skipCount,
|
||||
rollbackBaseline.autosave.skipCount
|
||||
);
|
||||
assert.equal(
|
||||
rollbackProbe.autosave.failureCount,
|
||||
rollbackBaseline.autosave.failureCount + 1
|
||||
);
|
||||
assert.equal(
|
||||
rollbackProbe.autosave.lastResult,
|
||||
'failed'
|
||||
);
|
||||
assert.equal(
|
||||
rollbackProbe.autosave.lastReason,
|
||||
'debug'
|
||||
);
|
||||
assert.equal(
|
||||
rollbackProbe.autosave.lastSavedAt,
|
||||
rollbackBaseline.state.savedAt
|
||||
);
|
||||
assert.equal(pageErrors.length, 0, pageErrors.join('\n'));
|
||||
assert.equal(consoleErrors.length, 0, consoleErrors.join('\n'));
|
||||
|
||||
@@ -513,7 +1121,56 @@ try {
|
||||
campaignGeneration,
|
||||
saveGeneration: beforeControl.acceptedGeneration,
|
||||
restoredTurnNumber: restored.turnNumber,
|
||||
restoredMarkerHp: restored.markerHp
|
||||
restoredMarkerHp: restored.markerHp,
|
||||
pendingEventBeforeTutorial:
|
||||
tutorialPendingEventProbe
|
||||
},
|
||||
autosave: {
|
||||
allyAction: {
|
||||
phaseAfterAction:
|
||||
actionTrigger.phase,
|
||||
turnNumber:
|
||||
actionAutosave.state.turnNumber,
|
||||
actedUnitIds:
|
||||
actionAutosave.state.actedUnitIds,
|
||||
savedAt:
|
||||
actionAutosave.state.savedAt,
|
||||
debug:
|
||||
actionAutosave.autosave
|
||||
},
|
||||
unsafePagehide:
|
||||
unsafeAutosaves,
|
||||
initializationGuard: {
|
||||
turnNumber: readinessTurnNumber,
|
||||
debug: readinessAfter.autosave
|
||||
},
|
||||
idlePagehide: {
|
||||
turnNumber:
|
||||
idleAutosave.state.turnNumber,
|
||||
markerHp:
|
||||
idleAutosave.state.units.find(
|
||||
(unit) => unit.id === 'liu-bei'
|
||||
)?.hp ?? null,
|
||||
savedAt:
|
||||
idleAutosave.state.savedAt,
|
||||
debug:
|
||||
idleAutosave.autosave
|
||||
},
|
||||
failedWriteRollback: {
|
||||
slotPreserved:
|
||||
rollbackProbe.slotRaw ===
|
||||
rollbackBaseline.raw,
|
||||
basePreserved:
|
||||
rollbackProbe.baseRaw ===
|
||||
rollbackBaseRaw,
|
||||
otherBattleSlotPreserved:
|
||||
rollbackProbe.otherBattleSlotRaw ===
|
||||
otherBattleCheckpoint,
|
||||
otherBattleBasePreserved:
|
||||
rollbackProbe.otherBattleBaseRaw ===
|
||||
otherBattleCheckpoint,
|
||||
debug: rollbackProbe.autosave
|
||||
}
|
||||
},
|
||||
pageErrors: pageErrors.length,
|
||||
consoleErrors: consoleErrors.length
|
||||
@@ -576,6 +1233,159 @@ async function waitForBattleReady(page) {
|
||||
);
|
||||
}
|
||||
|
||||
async function readAutosaveCheckpoint(
|
||||
page,
|
||||
requestedBattleSlotKey
|
||||
) {
|
||||
const checkpoint = await page.evaluate(
|
||||
(storageKey) => {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
const battle =
|
||||
window.__HEROS_DEBUG__?.battle?.();
|
||||
return {
|
||||
raw,
|
||||
state: JSON.parse(raw ?? 'null'),
|
||||
autosave: battle?.autosave ?? null
|
||||
};
|
||||
},
|
||||
requestedBattleSlotKey
|
||||
);
|
||||
assert(
|
||||
checkpoint.raw,
|
||||
`Missing battle autosave checkpoint at ${requestedBattleSlotKey}.`
|
||||
);
|
||||
assert(
|
||||
checkpoint.state,
|
||||
`Invalid battle autosave checkpoint at ${requestedBattleSlotKey}.`
|
||||
);
|
||||
assertAutosaveDebugState(
|
||||
checkpoint.autosave,
|
||||
`Autosave debug state for ${requestedBattleSlotKey}`
|
||||
);
|
||||
return checkpoint;
|
||||
}
|
||||
|
||||
function assertAutosaveDebugState(
|
||||
autosave,
|
||||
label
|
||||
) {
|
||||
assert(
|
||||
autosave && typeof autosave === 'object',
|
||||
`${label} is unavailable.`
|
||||
);
|
||||
for (const field of [
|
||||
'writeCount',
|
||||
'skipCount',
|
||||
'failureCount'
|
||||
]) {
|
||||
assert(
|
||||
Number.isInteger(autosave[field]) &&
|
||||
autosave[field] >= 0,
|
||||
`${label}.${field} must be a non-negative integer.`
|
||||
);
|
||||
}
|
||||
assert(
|
||||
[
|
||||
'saved',
|
||||
'skipped',
|
||||
'failed',
|
||||
null
|
||||
].includes(autosave.lastResult),
|
||||
`${label}.lastResult is invalid.`
|
||||
);
|
||||
assert(
|
||||
autosave.lastReason === null ||
|
||||
typeof autosave.lastReason === 'string',
|
||||
`${label}.lastReason is invalid.`
|
||||
);
|
||||
assert(
|
||||
autosave.lastSlot === null ||
|
||||
autosave.lastSlot === 1,
|
||||
`${label}.lastSlot is invalid.`
|
||||
);
|
||||
assert(
|
||||
autosave.lastSavedAt === null ||
|
||||
(
|
||||
typeof autosave.lastSavedAt === 'string' &&
|
||||
Number.isFinite(
|
||||
Date.parse(autosave.lastSavedAt)
|
||||
)
|
||||
),
|
||||
`${label}.lastSavedAt is invalid.`
|
||||
);
|
||||
}
|
||||
|
||||
function assertAutosaveWrite(
|
||||
before,
|
||||
after,
|
||||
{
|
||||
reason,
|
||||
expectedTurnNumber,
|
||||
expectedActedUnitId,
|
||||
expectedMarkerHp
|
||||
},
|
||||
label
|
||||
) {
|
||||
assert.notEqual(
|
||||
after.raw,
|
||||
before.raw,
|
||||
`${label} did not update the active battle slot.`
|
||||
);
|
||||
assert.notEqual(
|
||||
after.state.savedAt,
|
||||
before.state.savedAt,
|
||||
`${label} did not refresh savedAt.`
|
||||
);
|
||||
assert.equal(
|
||||
after.state.turnNumber,
|
||||
expectedTurnNumber,
|
||||
`${label} saved the wrong turn.`
|
||||
);
|
||||
assert(
|
||||
after.state.actedUnitIds.includes(
|
||||
expectedActedUnitId
|
||||
),
|
||||
`${label} omitted the completed ally action.`
|
||||
);
|
||||
if (expectedMarkerHp !== undefined) {
|
||||
assert.equal(
|
||||
after.state.units.find(
|
||||
(unit) => unit.id === 'liu-bei'
|
||||
)?.hp,
|
||||
expectedMarkerHp,
|
||||
`${label} did not persist the live ally state.`
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
after.autosave.writeCount,
|
||||
before.autosave.writeCount + 1,
|
||||
`${label} did not record exactly one write.`
|
||||
);
|
||||
assert.equal(
|
||||
after.autosave.skipCount,
|
||||
before.autosave.skipCount,
|
||||
`${label} unexpectedly recorded a skip.`
|
||||
);
|
||||
assert.equal(
|
||||
after.autosave.failureCount,
|
||||
before.autosave.failureCount,
|
||||
`${label} unexpectedly recorded a failure.`
|
||||
);
|
||||
assert.equal(
|
||||
after.autosave.lastResult,
|
||||
'saved'
|
||||
);
|
||||
assert.equal(
|
||||
after.autosave.lastReason,
|
||||
reason
|
||||
);
|
||||
assert.equal(after.autosave.lastSlot, 1);
|
||||
assert.equal(
|
||||
after.autosave.lastSavedAt,
|
||||
after.state.savedAt
|
||||
);
|
||||
}
|
||||
|
||||
async function assertDesktopRuntime(page, expectedRenderer) {
|
||||
const runtime = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('canvas');
|
||||
|
||||
@@ -364,6 +364,21 @@ try {
|
||||
),
|
||||
'BattleScene initial and manual loads must filter candidates through the loaded campaign generation.'
|
||||
);
|
||||
assert(
|
||||
/isSafeBattleAutosaveCheckpoint\(\)[\s\S]*?this\.battleAutosaveReady[\s\S]*?this\.activeFaction === 'ally'[\s\S]*?this\.phase === 'idle'/.test(
|
||||
battleSceneSource
|
||||
) &&
|
||||
/this\.applyBattleSaveState\(state\);[\s\S]*?this\.battleAutosaveReady = true;[\s\S]*?this\.scheduleFirstBattleTutorial\(\);/.test(
|
||||
battleSceneSource
|
||||
),
|
||||
'Autosave must stay disabled until a new or restored battle is complete, and first-battle tutorial scheduling must resume with the checkpoint.'
|
||||
);
|
||||
assert(
|
||||
/persistBattleAutosave\(reason:[\s\S]*?Object\.keys\(battleScenarios\)\.flatMap\([\s\S]*?campaignBattleResumeStorageKeys\([\s\S]*?previousStorageValues[\s\S]*?clearCampaignBattleSavesForSlot\(normalizedSlot\)[\s\S]*?catch \{[\s\S]*?restoreBattleAutosaveStorage\([\s\S]*?previousStorageValues/.test(
|
||||
battleSceneSource
|
||||
),
|
||||
'A failed autosave write must restore every previous battle checkpoint removed from the destination campaign slot.'
|
||||
);
|
||||
assert(
|
||||
/stats: \{ \.\.\.unit\.stats \}/.test(battleSceneSource) &&
|
||||
/unit\.stats = \{ \.\.\.savedUnit\.stats \}/.test(battleSceneSource) &&
|
||||
|
||||
@@ -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