fix: sequence battle feedback and autosaves

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

View File

@@ -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`와 구버전 변환표를 더한다. 이후에는 전투·이야기·탐색의
모든 중단 지점이 “중단해도 읽고 행동한 만큼만 이어지는” 같은 원칙을 공유한다.
## 장기 개선 순서

View File

@@ -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/);

View File

@@ -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');

View File

@@ -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) &&

View File

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

View File

@@ -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);
}