fix: sequence battle feedback and autosaves
This commit is contained in:
@@ -469,10 +469,13 @@ RPG로 다듬는다.
|
||||
[Xbox XAG 114](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/114)를
|
||||
턴 전환과 전투 저장의 무결성 기준으로 함께 적용했다.
|
||||
- 마지막 아군의 공격이나 지원 명령이 행동 수를 모두 소모했더라도 피해·회복·
|
||||
공명·상태 변화의 시각·음향 결과와 결과 문구가 완전히 끝나기 전에는 턴 종료
|
||||
확인창을 열지 않는다. 결과를 읽을 수 있는 안정 상태에 도달한 뒤에만 확인창을
|
||||
표시해 마지막 행동의 의미를 모달이 가리거나 입력 초점이 갑자기 바뀌지 않게
|
||||
한다.
|
||||
공명·상태 변화뿐 아니라 이어서 판정되는 적 의도 파훼와 목표 달성 결과까지
|
||||
완전히 끝나기 전에는 턴 종료 확인창을 열지 않는다. 전술 사건은 짧은 결과
|
||||
문구가 끝난 뒤 순서대로 보여 주고, 그 사건도 닫힌 뒤에만 확인창을 표시해
|
||||
마지막 행동의 의미를 모달이 가리거나 입력 초점이 갑자기 바뀌지 않게 한다.
|
||||
첫 교전 알림도 공격 연출 앞에 겹치지 않고 결과 뒤에 나오며, 적 전멸 뒤
|
||||
필수 목표가 남았다는 경고처럼 승패 판정 중 새로 생기는 사건까지 닫힌 뒤에만
|
||||
턴 종료 확인으로 넘어간다.
|
||||
- 완료된 아군 행동은 결과 정산까지 끝난 안전 경계에서 전투 자동저장에 반영한다.
|
||||
명령이나 연출이 진행 중이지 않은 유휴 상태와 안전한 `pagehide`에서도 같은
|
||||
체크포인트를 갱신해, 브라우저를 닫거나 숨긴 뒤 이어해도 이미 끝낸 행동을
|
||||
@@ -480,15 +483,24 @@ RPG로 다듬는다.
|
||||
- 명령 선택·공격 및 지원 연출·적군 턴처럼 상태가 반쯤 적용될 수 있는 구간에서는
|
||||
새 자동저장을 만들지 않는다. 이때 숨김이나 종료 신호가 들어와도 마지막으로
|
||||
확정된 안전 체크포인트를 그대로 보존하고, 불완전한 명령·피해·행동 소비가
|
||||
정상 저장을 덮어쓰지 못하게 한다.
|
||||
- 마지막 공격·지원 결과와 턴 종료 확인의 순서, 아군 행동별 자동저장, 안전한
|
||||
유휴·`pagehide` 저장, 불안전한 명령·연출·적군 턴의 체크포인트 불변성을
|
||||
Canvas와 WebGL 모두 1920x1080 CSS viewport, 100% 확대, DPR 1에서 자동
|
||||
회귀 검증한다.
|
||||
정상 저장을 덮어쓰지 못하게 한다. 전장 자산·배치·재개 상태가 모두 준비되기
|
||||
전에도 자동저장을 잠가 초기 1턴 상태가 실제 재개 지점을 덮지 않게 한다.
|
||||
- 새 저장 쓰기가 용량이나 권한 문제로 실패하면 현재 전투뿐 아니라 정리 대상이
|
||||
된 같은 슬롯의 다른 전투 체크포인트와 호환 저장까지 모두 되살린다. 첫 전투
|
||||
저장을 불러왔을 때는 대기 중이던 전술 사건을 먼저 보여 준 뒤 아직 끝내지 않은
|
||||
자동 튜토리얼을 다시 예약해, 오프닝 도중 중단한 신규 사용자가 사건이나 조작
|
||||
안내를 영구히 건너뛰지 않는다. 결과를 기다리는 도중 장면이 종료되어도 대기
|
||||
작업을 함께 끝내 재시작 뒤에 멈춘 비동기 처리가 남지 않게 한다.
|
||||
- 마지막 공격·지원·의도 파훼·목표 달성 결과와 턴 종료 확인의 순서, 아군
|
||||
행동별 자동저장, 초기화 잠금, 안전한 유휴·`pagehide` 저장, 불안전한
|
||||
명령·연출·적군 턴의 체크포인트 불변성, 저장 실패 시 다른 전투까지 포함한
|
||||
전체 롤백, 복귀 사건과 튜토리얼 순서를 Canvas와 WebGL 모두 1920x1080
|
||||
CSS viewport, 100% 확대, DPR 1에서 자동 회귀 검증한다.
|
||||
|
||||
다음 묶음은 탐색 대사에도 문구나 순서가 바뀌어도 같은 장면을 찾는 안정적인
|
||||
`beatId`와 구버전 변환표를 더한다. 이후에는 전투·이야기·탐색의 모든
|
||||
중단 지점이 “중단해도 읽고 행동한 만큼만 이어지는” 같은 원칙을 공유한다.
|
||||
다음 묶음은 수동 저장과 자동 체크포인트가 같은 슬롯에서 어떤 역할을 맡는지
|
||||
플레이어에게 명확히 구분하고, 탐색 대사에도 문구나 순서가 바뀌어도 같은 장면을
|
||||
찾는 안정적인 `beatId`와 구버전 변환표를 더한다. 이후에는 전투·이야기·탐색의
|
||||
모든 중단 지점이 “중단해도 읽고 행동한 만큼만 이어지는” 같은 원칙을 공유한다.
|
||||
|
||||
## 장기 개선 순서
|
||||
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isVisualMotionReduced } from '../settings/visualMotion';
|
||||
import { battleMapAssets } from '../data/battleMapAssets';
|
||||
import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario';
|
||||
import {
|
||||
battleScenarios,
|
||||
defaultBattleScenario,
|
||||
getBattleScenario,
|
||||
type BattleCampaignRewardDefinition,
|
||||
@@ -233,6 +234,7 @@ import {
|
||||
type BattleSaveUnitStats
|
||||
} from '../state/battleSaveState';
|
||||
import {
|
||||
campaignBattleResumeStorageKeys,
|
||||
clearCampaignBattleSavesForSlot,
|
||||
clearBattleSaveStorageForBattle,
|
||||
readBattleSaveStorageCandidate
|
||||
@@ -257,6 +259,7 @@ const battleFhdUiScale = 1.5;
|
||||
const battleEnvironmentTintDepth = 3.04;
|
||||
const battleEnvironmentHazeDepth = 3.12;
|
||||
const battleEnvironmentParticleDepth = 3.3;
|
||||
const battleFeedbackSettlePaddingMs = 24;
|
||||
const firstBattleTutorialId: CampaignTutorialId = 'first-battle-basic-controls';
|
||||
|
||||
type FirstBattlePreparationState = {
|
||||
@@ -1532,6 +1535,15 @@ type UnitActionPose = 'attack' | 'strategy' | 'item' | 'hurt' | 'celebrate';
|
||||
const levelUpCelebrationStepOffsets = [0, -7, -2, -9, 0, -6, -3, -8];
|
||||
|
||||
type BattlePhase = 'deployment' | 'idle' | 'moving' | 'command' | 'targeting' | 'animating' | 'resolved';
|
||||
type BattleAutosaveReason =
|
||||
| 'battle-start'
|
||||
| 'deployment-confirmed'
|
||||
| 'ally-action'
|
||||
| 'ally-turn-start'
|
||||
| 'pagehide'
|
||||
| 'hidden'
|
||||
| 'debug';
|
||||
type BattleAutosaveResult = 'saved' | 'skipped' | 'failed';
|
||||
type ScenarioCombatAssetStatus = 'idle' | 'loading' | 'ready' | 'degraded';
|
||||
type BattleCommand = 'attack' | UsableCommand | 'wait';
|
||||
type DamageCommand = Exclude<BattleCommand, 'wait'>;
|
||||
@@ -2370,6 +2382,10 @@ type IntentCounterplayEvent = {
|
||||
type IntentCounterplayActionOutcome = {
|
||||
defeatedEnemyIds?: string[];
|
||||
};
|
||||
type IntentCounterplaySettlement = {
|
||||
message: string;
|
||||
popupDuration: number;
|
||||
};
|
||||
|
||||
type TacticalInitiativeRole = BattleSaveTacticalCommandRole;
|
||||
type TacticalCommandSource = NonNullable<BattleSaveTacticalCommandState['source']>;
|
||||
@@ -3997,6 +4013,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
private battleEventQueue: BattleSavePendingEvent[] = [];
|
||||
private activeBattleEvent?: BattleSavePendingEvent;
|
||||
private battleEventDismissTween?: Phaser.Tweens.Tween;
|
||||
private battleEventCompletionWaiters: Array<() => void> = [];
|
||||
private battleEventPresentationCount = 0;
|
||||
private deferBattleEventPresentation = false;
|
||||
private tacticalEventReactionHistory: TacticalEventReactionSnapshot[] = [];
|
||||
@@ -4016,6 +4033,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
private turnPromptRiskRows: TurnEndRiskRowView[] = [];
|
||||
private mapResultPopupObjects: Phaser.GameObjects.Text[] = [];
|
||||
private mapResultPopupPeakCount = 0;
|
||||
private battleFeedbackReadableUntil = 0;
|
||||
private mapResultPopupLast?: {
|
||||
unitId: string;
|
||||
lines: string[];
|
||||
@@ -4164,6 +4182,22 @@ export class BattleScene extends Phaser.Scene {
|
||||
private launchCamaraderieMemoryBondId?: string;
|
||||
private launchCamaraderieMemoryBonus = 0;
|
||||
private storyHandoff?: CampaignStoryHandoffReference;
|
||||
private battleAutosaveWriteCount = 0;
|
||||
private battleAutosaveSkipCount = 0;
|
||||
private battleAutosaveFailureCount = 0;
|
||||
private battleAutosaveLastResult?: BattleAutosaveResult;
|
||||
private battleAutosaveLastReason?: BattleAutosaveReason;
|
||||
private battleAutosaveLastSlot?: number;
|
||||
private battleAutosaveLastSavedAt?: string;
|
||||
private battleAutosaveReady = false;
|
||||
private readonly handleBattlePageHide = () => {
|
||||
this.persistBattleAutosave('pagehide');
|
||||
};
|
||||
private readonly handleBattleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this.persistBattleAutosave('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super('BattleScene');
|
||||
@@ -4237,6 +4271,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.turnPromptRiskSummaryText = undefined;
|
||||
this.turnPromptRiskFootnoteText = undefined;
|
||||
this.turnPromptRiskRows = [];
|
||||
this.battleEventCompletionWaiters = [];
|
||||
this.battleFeedbackReadableUntil = 0;
|
||||
this.turnText = undefined;
|
||||
this.battleTitleText = undefined;
|
||||
this.objectiveTrackerText = undefined;
|
||||
@@ -4354,6 +4390,14 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.resetSortieCooperationStats();
|
||||
this.launchCamaraderieMemoryBondId = undefined;
|
||||
this.launchCamaraderieMemoryBonus = 0;
|
||||
this.battleAutosaveWriteCount = 0;
|
||||
this.battleAutosaveSkipCount = 0;
|
||||
this.battleAutosaveFailureCount = 0;
|
||||
this.battleAutosaveLastResult = undefined;
|
||||
this.battleAutosaveLastReason = undefined;
|
||||
this.battleAutosaveLastSlot = undefined;
|
||||
this.battleAutosaveLastSavedAt = undefined;
|
||||
this.battleAutosaveReady = false;
|
||||
}
|
||||
|
||||
create() {
|
||||
@@ -4384,6 +4428,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.mapResultPopupObjects.forEach((object) => object.active && object.destroy());
|
||||
this.mapResultPopupObjects = [];
|
||||
this.hideBattleSoundCaption();
|
||||
this.battleAutosaveReady = false;
|
||||
this.uninstallBattleAutosaveHandlers();
|
||||
});
|
||||
this.resultFormationReviewVisible = false;
|
||||
this.resultFormationReviewFeedback = '';
|
||||
@@ -4478,6 +4524,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
});
|
||||
this.input.mouse?.disableContextMenu();
|
||||
this.installBattleInputHandlers();
|
||||
this.installBattleAutosaveHandlers();
|
||||
this.installDebugHotkeys();
|
||||
|
||||
this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0);
|
||||
@@ -4634,6 +4681,27 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.uninstallDebugHotkeys();
|
||||
}
|
||||
|
||||
private installBattleAutosaveHandlers() {
|
||||
this.uninstallBattleAutosaveHandlers();
|
||||
window.addEventListener('pagehide', this.handleBattlePageHide);
|
||||
document.addEventListener('visibilitychange', this.handleBattleVisibilityChange);
|
||||
this.events.once(
|
||||
Phaser.Scenes.Events.SHUTDOWN,
|
||||
this.uninstallBattleAutosaveHandlers,
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
private uninstallBattleAutosaveHandlers() {
|
||||
this.events.off(
|
||||
Phaser.Scenes.Events.SHUTDOWN,
|
||||
this.uninstallBattleAutosaveHandlers,
|
||||
this
|
||||
);
|
||||
window.removeEventListener('pagehide', this.handleBattlePageHide);
|
||||
document.removeEventListener('visibilitychange', this.handleBattleVisibilityChange);
|
||||
}
|
||||
|
||||
private battleInputEventHandled(
|
||||
channel: string,
|
||||
event: KeyboardEvent,
|
||||
@@ -4975,6 +5043,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.time.delayedCall(180, () => {
|
||||
this.showOpeningBattleEvent();
|
||||
this.scheduleFirstBattleTutorial();
|
||||
this.battleAutosaveReady = true;
|
||||
this.persistBattleAutosave('battle-start');
|
||||
});
|
||||
this.renderRosterPanel('ally', '행동할 장수를 선택하세요.');
|
||||
this.refreshEnemyIntentForecast();
|
||||
@@ -11114,6 +11184,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.refreshEnemyIntentForecast();
|
||||
this.showOpeningBattleEvent();
|
||||
this.scheduleFirstBattleTutorial();
|
||||
this.battleAutosaveReady = true;
|
||||
this.persistBattleAutosave('deployment-confirmed');
|
||||
}
|
||||
|
||||
private deploymentSignature() {
|
||||
@@ -11949,8 +12021,18 @@ export class BattleScene extends Phaser.Scene {
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
soundDirector.playGrowthTick();
|
||||
this.showMapResultPopup(actor, [`의도 파훼 +${events.length}`, detail], '#ffdf7b', '#2b1606', 18, 36);
|
||||
return `의도 파훼 +${events.length} · ${actor.name} · ${compactDetail}`;
|
||||
const popupDuration = this.showMapResultPopup(
|
||||
actor,
|
||||
[`의도 파훼 +${events.length}`, detail],
|
||||
'#ffdf7b',
|
||||
'#2b1606',
|
||||
18,
|
||||
36
|
||||
);
|
||||
return {
|
||||
message: `의도 파훼 +${events.length} · ${actor.name} · ${compactDetail}`,
|
||||
popupDuration
|
||||
} satisfies IntentCounterplaySettlement;
|
||||
}
|
||||
|
||||
private recordIntentGuardCounterplay(
|
||||
@@ -13066,7 +13148,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
return;
|
||||
}
|
||||
|
||||
this.finishUnitAction(unit, this.commandResultMessage(unit, command));
|
||||
void this.finishUnitAction(unit, this.commandResultMessage(unit, command));
|
||||
}
|
||||
|
||||
private actionTargetingLabel(action: DamageCommand, usable?: BattleUsable) {
|
||||
@@ -14263,10 +14345,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
await this.delay(180);
|
||||
await this.presentCombatResult(result.counter);
|
||||
}
|
||||
await this.showCombatExchangeMapResults(result);
|
||||
// Keep the result anchored on its unit long enough to read before auto-focusing the next ally.
|
||||
await this.delay(620);
|
||||
this.finishUnitAction(attacker, this.formatCombatResult(result), {
|
||||
await this.showCombatExchangeMapResults(result, true);
|
||||
await this.finishUnitAction(attacker, this.formatCombatResult(result), {
|
||||
defeatedEnemyIds: result.defeated ? [result.defender.id] : []
|
||||
});
|
||||
}
|
||||
@@ -14292,10 +14372,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
const result = this.resolveSupportAction(user, target, usable);
|
||||
this.clearMarkers();
|
||||
await this.presentSupportResult(result);
|
||||
this.showSupportMapResult(result);
|
||||
// Keep the support result visible before the camera moves to the next actionable ally.
|
||||
await this.delay(620);
|
||||
this.finishUnitAction(user, this.formatSupportResult(result));
|
||||
const popupDuration = this.showSupportMapResult(result);
|
||||
await this.waitSceneDuration(popupDuration);
|
||||
await this.finishUnitAction(user, this.formatSupportResult(result));
|
||||
}
|
||||
|
||||
private renderAttackPreview(preview: CombatPreview, locked = false) {
|
||||
@@ -14359,11 +14438,19 @@ export class BattleScene extends Phaser.Scene {
|
||||
battleScenario.id,
|
||||
this.prologueVolunteerReassured
|
||||
);
|
||||
this.triggerBattleEvent(firstBattleVolunteerPromiseEventKey, '첫 교전', [
|
||||
const presentationWasDeferred =
|
||||
this.deferBattleEventPresentation;
|
||||
this.deferBattleEventPresentation = true;
|
||||
try {
|
||||
this.triggerBattleEvent(firstBattleVolunteerPromiseEventKey, '첫 교전', [
|
||||
`${attacker.name} · ${target.name} 공격을 시작합니다.`,
|
||||
'공격 후 경험치와 장비 경험치가 전투창에서 정산됩니다.',
|
||||
...(volunteerPromiseLine ? [volunteerPromiseLine] : [])
|
||||
], { playCue: false });
|
||||
], { playCue: false });
|
||||
} finally {
|
||||
this.deferBattleEventPresentation =
|
||||
presentationWasDeferred;
|
||||
}
|
||||
}
|
||||
|
||||
private battleBuffShortText(buff: BattleBuffState) {
|
||||
@@ -15336,11 +15423,12 @@ export class BattleScene extends Phaser.Scene {
|
||||
return x + width + 6;
|
||||
}
|
||||
|
||||
private finishUnitAction(unit: UnitData, message: string, outcome: IntentCounterplayActionOutcome = {}) {
|
||||
const counterplayMessage = this.settleEnemyIntentCounterplay(unit, this.pendingMove, outcome);
|
||||
private async finishUnitAction(unit: UnitData, message: string, outcome: IntentCounterplayActionOutcome = {}) {
|
||||
const counterplaySettlement = this.settleEnemyIntentCounterplay(unit, this.pendingMove, outcome);
|
||||
const counterplayMessage = counterplaySettlement?.message;
|
||||
const resolvedMessage = counterplayMessage ? `${counterplayMessage}\n${message}` : message;
|
||||
this.actedUnitIds.add(unit.id);
|
||||
this.phase = 'idle';
|
||||
this.phase = 'animating';
|
||||
this.selectedUnit = undefined;
|
||||
this.pendingMove = undefined;
|
||||
this.targetingAction = undefined;
|
||||
@@ -15352,14 +15440,27 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.renderTacticalInitiativeChip();
|
||||
soundDirector.playSelect();
|
||||
this.pushBattleLog(resolvedMessage);
|
||||
this.checkBattleEvents();
|
||||
this.collectBattleEventsWithoutPresentation();
|
||||
this.updateObjectiveTracker();
|
||||
|
||||
if (this.resolveBattleOutcomeIfNeeded(counterplayMessage ? 820 : 0)) {
|
||||
await this.waitForBattleFeedbackReadability();
|
||||
this.showNextBattleEvent();
|
||||
await this.waitForBattleEventPresentation();
|
||||
if (!this.scene.isActive() || this.battleOutcome) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.phase = 'idle';
|
||||
if (this.resolveBattleOutcomeIfNeeded()) {
|
||||
return;
|
||||
}
|
||||
await this.waitForBattleEventPresentation();
|
||||
if (!this.scene.isActive() || this.battleOutcome) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshEnemyIntentForecast();
|
||||
this.persistBattleAutosave('ally-action');
|
||||
|
||||
const remaining = this.remainingAllyCount();
|
||||
const turnHint =
|
||||
@@ -19906,6 +20007,8 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.phase === 'idle' &&
|
||||
this.activeFaction === 'ally' &&
|
||||
this.turnNumber === 1 &&
|
||||
!this.activeBattleEvent &&
|
||||
this.battleEventQueue.length === 0 &&
|
||||
this.battleEventObjects.length === 0 &&
|
||||
this.mapMenuObjects.length === 0 &&
|
||||
this.saveSlotPanelObjects.length === 0 &&
|
||||
@@ -21555,6 +21658,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
this.triggeredBattleEvents.add(groupedEvent.key);
|
||||
});
|
||||
this.resolveBattleEventCompletionWaiters();
|
||||
}
|
||||
|
||||
private clearBattleEvents() {
|
||||
@@ -21565,16 +21669,37 @@ export class BattleScene extends Phaser.Scene {
|
||||
this.activeBattleEvent = undefined;
|
||||
this.battleEventQueue = [];
|
||||
this.deferBattleEventPresentation = false;
|
||||
this.resolveBattleEventCompletionWaiters();
|
||||
}
|
||||
|
||||
private waitForBattleEventPresentation() {
|
||||
if (!this.activeBattleEvent && this.battleEventObjects.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.battleEventCompletionWaiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private resolveBattleEventCompletionWaiters() {
|
||||
if (this.activeBattleEvent || this.battleEventObjects.length > 0) {
|
||||
return;
|
||||
}
|
||||
this.battleEventCompletionWaiters.splice(0).forEach((resolve) => resolve());
|
||||
}
|
||||
|
||||
private checkBattleEvents() {
|
||||
this.collectBattleEventsWithoutPresentation();
|
||||
this.showNextBattleEvent();
|
||||
}
|
||||
|
||||
private collectBattleEventsWithoutPresentation() {
|
||||
this.deferBattleEventPresentation = true;
|
||||
try {
|
||||
this.collectBattleEvents();
|
||||
} finally {
|
||||
this.deferBattleEventPresentation = false;
|
||||
}
|
||||
this.showNextBattleEvent();
|
||||
}
|
||||
|
||||
private collectBattleEvents() {
|
||||
@@ -22966,6 +23091,111 @@ export class BattleScene extends Phaser.Scene {
|
||||
return eventIds;
|
||||
}
|
||||
|
||||
private isSafeBattleAutosaveCheckpoint() {
|
||||
return (
|
||||
this.scene.isActive() &&
|
||||
this.battleAutosaveReady &&
|
||||
this.activeFaction === 'ally' &&
|
||||
this.phase === 'idle' &&
|
||||
!this.battleOutcome &&
|
||||
!this.resultNavigationPending &&
|
||||
!this.selectedUnit &&
|
||||
!this.pendingMove &&
|
||||
!this.targetingAction &&
|
||||
!this.selectedUsable
|
||||
);
|
||||
}
|
||||
|
||||
private persistBattleAutosave(reason: BattleAutosaveReason = 'debug') {
|
||||
this.battleAutosaveLastReason = reason;
|
||||
if (!this.isSafeBattleAutosaveCheckpoint()) {
|
||||
this.battleAutosaveSkipCount += 1;
|
||||
this.battleAutosaveLastResult = 'skipped';
|
||||
return false;
|
||||
}
|
||||
|
||||
let previousStorageValues: Array<{
|
||||
key: string;
|
||||
value: string | null;
|
||||
}> = [];
|
||||
let storageMutationStarted = false;
|
||||
try {
|
||||
const campaign = getCampaignState();
|
||||
const normalizedSlot = normalizeBattleSaveSlot(
|
||||
campaign.activeSaveSlot,
|
||||
campaignSaveSlotCount
|
||||
);
|
||||
const state = this.createBattleSaveState();
|
||||
const serializedState = JSON.stringify(state);
|
||||
const slotKey =
|
||||
this.battleSaveStorageKeyForSlot(normalizedSlot);
|
||||
const storageKeys = [
|
||||
...new Set(
|
||||
Object.keys(battleScenarios).flatMap((battleId) =>
|
||||
campaignBattleResumeStorageKeys(
|
||||
battleId,
|
||||
normalizedSlot,
|
||||
campaignSaveSlotCount
|
||||
)
|
||||
)
|
||||
)
|
||||
];
|
||||
previousStorageValues = storageKeys.map((key) => ({
|
||||
key,
|
||||
value: window.localStorage.getItem(key)
|
||||
}));
|
||||
storageMutationStarted = true;
|
||||
clearCampaignBattleSavesForSlot(normalizedSlot);
|
||||
window.localStorage.setItem(
|
||||
slotKey,
|
||||
serializedState
|
||||
);
|
||||
if (normalizedSlot === 1) {
|
||||
window.localStorage.setItem(
|
||||
battleSaveStorageKey,
|
||||
serializedState
|
||||
);
|
||||
window.localStorage.removeItem(
|
||||
legacyBattleSaveStorageKey
|
||||
);
|
||||
}
|
||||
saveCampaignState(campaign, normalizedSlot);
|
||||
this.battleAutosaveWriteCount += 1;
|
||||
this.battleAutosaveLastResult = 'saved';
|
||||
this.battleAutosaveLastSlot = normalizedSlot;
|
||||
this.battleAutosaveLastSavedAt = state.savedAt;
|
||||
return true;
|
||||
} catch {
|
||||
if (storageMutationStarted) {
|
||||
this.restoreBattleAutosaveStorage(
|
||||
previousStorageValues
|
||||
);
|
||||
}
|
||||
this.battleAutosaveFailureCount += 1;
|
||||
this.battleAutosaveLastResult = 'failed';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private restoreBattleAutosaveStorage(
|
||||
values: ReadonlyArray<{
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>
|
||||
) {
|
||||
values.forEach(({ key, value }) => {
|
||||
try {
|
||||
if (value === null) {
|
||||
window.localStorage.removeItem(key);
|
||||
} else {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
} catch {
|
||||
// Best effort: write-denied storage normally also preserves the old value.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private saveBattleState(slot = 1) {
|
||||
try {
|
||||
const state = this.createBattleSaveState();
|
||||
@@ -22991,6 +23221,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
try {
|
||||
this.battleAutosaveReady = false;
|
||||
const campaign = loadCampaignState(slot);
|
||||
this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(campaign.selectedSortieUnitIds);
|
||||
this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(campaign.sortieFormationAssignments);
|
||||
@@ -23011,7 +23242,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
);
|
||||
this.launchSortieRecommendation = this.normalizeLaunchSortieRecommendation(state.sortieRecommendation);
|
||||
this.applyBattleSaveState(state);
|
||||
this.battleAutosaveReady = true;
|
||||
this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`);
|
||||
this.scheduleFirstBattleTutorial();
|
||||
if (this.activeFaction === 'enemy' && !this.battleOutcome) {
|
||||
this.time.delayedCall(250, () => {
|
||||
if (this.activeFaction === 'enemy' && !this.battleOutcome) {
|
||||
@@ -27440,17 +27673,67 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private delay(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
this.time.delayedCall(this.scaledBattleDuration(ms), () => resolve());
|
||||
});
|
||||
return this.waitSceneDuration(
|
||||
this.scaledBattleDuration(ms)
|
||||
);
|
||||
}
|
||||
|
||||
private waitSceneDuration(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
this.time.delayedCall(ms, () => resolve());
|
||||
if (!this.scene.isActive()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const complete = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
this.events.off(
|
||||
Phaser.Scenes.Events.SHUTDOWN,
|
||||
complete
|
||||
);
|
||||
resolve();
|
||||
};
|
||||
this.events.once(
|
||||
Phaser.Scenes.Events.SHUTDOWN,
|
||||
complete
|
||||
);
|
||||
this.time.delayedCall(ms, complete);
|
||||
});
|
||||
}
|
||||
|
||||
private extendBattleFeedbackReadableWindow(duration: number) {
|
||||
if (duration <= 0) {
|
||||
return;
|
||||
}
|
||||
this.battleFeedbackReadableUntil = Math.max(
|
||||
this.battleFeedbackReadableUntil,
|
||||
this.time.now + duration
|
||||
);
|
||||
}
|
||||
|
||||
private async waitForBattleFeedbackReadability() {
|
||||
while (this.scene.isActive()) {
|
||||
const remaining = this.battleFeedbackReadableUntil - this.time.now;
|
||||
const activeFeedbackObjects =
|
||||
this.mapResultPopupObjects.some(
|
||||
(object) => object.active
|
||||
) ||
|
||||
Boolean(
|
||||
this.battleSoundCaption?.active &&
|
||||
this.battleSoundCaption.visible
|
||||
);
|
||||
if (remaining <= 0 && !activeFeedbackObjects) {
|
||||
return;
|
||||
}
|
||||
await this.waitSceneDuration(
|
||||
remaining > 0 ? remaining : 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private moveUnitViewAsync(
|
||||
unit: UnitData,
|
||||
x: number,
|
||||
@@ -27588,6 +27871,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
.join('\n');
|
||||
this.renderRosterPanel('ally', turnMessage);
|
||||
this.refreshEnemyIntentForecast();
|
||||
this.persistBattleAutosave('ally-turn-start');
|
||||
|
||||
const firstUnit = this.firstActionableAlly();
|
||||
if (firstUnit) {
|
||||
@@ -29506,7 +29790,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
private showSupportMapResult(result: SupportResult) {
|
||||
if (result.usable.effect === 'heal') {
|
||||
this.showMapResultPopup(
|
||||
return this.showMapResultPopup(
|
||||
result.target,
|
||||
[
|
||||
`회복 +${result.healAmount}`,
|
||||
@@ -29524,10 +29808,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
? 42
|
||||
: 28
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showMapResultPopup(
|
||||
return this.showMapResultPopup(
|
||||
result.target,
|
||||
[
|
||||
`강화 ${result.buff?.turns ?? result.usable.duration ?? 1}턴`,
|
||||
@@ -29597,6 +29880,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
|
||||
const duration = this.scaledBattleDuration(680, 260);
|
||||
const delay = this.scaledBattleDuration(80, 30);
|
||||
this.extendBattleFeedbackReadableWindow(
|
||||
duration + delay + battleFeedbackSettlePaddingMs
|
||||
);
|
||||
const bounds = popup.getBounds();
|
||||
this.mapResultPopupLast = {
|
||||
unitId: unit.id,
|
||||
@@ -29762,6 +30048,9 @@ export class BattleScene extends Phaser.Scene {
|
||||
}
|
||||
if (!this.isTileVisible(anchor.x, anchor.y)) {
|
||||
this.showBattleSoundCaption('objective', lines.filter(Boolean).join(' · '), [anchor]);
|
||||
this.extendBattleFeedbackReadableWindow(
|
||||
2020 + battleFeedbackSettlePaddingMs
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29785,6 +30074,15 @@ export class BattleScene extends Phaser.Scene {
|
||||
if (this.mapMask) {
|
||||
popup.setMask(this.mapMask);
|
||||
}
|
||||
this.mapResultPopupObjects =
|
||||
this.mapResultPopupObjects.filter(
|
||||
(object) => object.active
|
||||
);
|
||||
this.mapResultPopupObjects.push(popup);
|
||||
this.mapResultPopupPeakCount = Math.max(
|
||||
this.mapResultPopupPeakCount,
|
||||
this.mapResultPopupObjects.length
|
||||
);
|
||||
this.tweens.add({
|
||||
targets: popup,
|
||||
y: popup.y - 26,
|
||||
@@ -29792,8 +30090,20 @@ export class BattleScene extends Phaser.Scene {
|
||||
duration: 880,
|
||||
delay: 80,
|
||||
ease: 'Sine.easeOut',
|
||||
onComplete: () => popup.destroy()
|
||||
onComplete: () => {
|
||||
this.mapResultPopupObjects =
|
||||
this.mapResultPopupObjects.filter(
|
||||
(object) =>
|
||||
object !== popup && object.active
|
||||
);
|
||||
if (popup.active) {
|
||||
popup.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.extendBattleFeedbackReadableWindow(
|
||||
960 + battleFeedbackSettlePaddingMs
|
||||
);
|
||||
}
|
||||
|
||||
private objectiveFeedbackAnchor(objective: BattleObjectiveDefinition, state: BattleObjectiveState) {
|
||||
@@ -34230,6 +34540,17 @@ export class BattleScene extends Phaser.Scene {
|
||||
turnNumber: this.turnNumber,
|
||||
activeFaction: this.activeFaction,
|
||||
phase: this.phase,
|
||||
autosave: {
|
||||
ready: this.battleAutosaveReady,
|
||||
safeCheckpoint: this.isSafeBattleAutosaveCheckpoint(),
|
||||
writeCount: this.battleAutosaveWriteCount,
|
||||
skipCount: this.battleAutosaveSkipCount,
|
||||
failureCount: this.battleAutosaveFailureCount,
|
||||
lastResult: this.battleAutosaveLastResult ?? null,
|
||||
lastReason: this.battleAutosaveLastReason ?? null,
|
||||
lastSlot: this.battleAutosaveLastSlot ?? null,
|
||||
lastSavedAt: this.battleAutosaveLastSavedAt ?? null
|
||||
},
|
||||
deploymentKeyboard: this.deploymentKeyboardDebugState(),
|
||||
combatAssets: {
|
||||
status: this.scenarioCombatAssetStatus,
|
||||
@@ -34668,6 +34989,7 @@ export class BattleScene extends Phaser.Scene {
|
||||
return false;
|
||||
}
|
||||
this.triggerFirstEngagementEvent(attacker, target);
|
||||
this.showNextBattleEvent();
|
||||
return this.isBattleEventKnown(firstBattleVolunteerPromiseEventKey);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user