diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index fcedcc4..b00e92d 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -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`와 구버전 변환표를 더한다. 이후에는 전투·이야기·탐색의 +모든 중단 지점이 “중단해도 읽고 행동한 만큼만 이어지는” 같은 원칙을 공유한다. ## 장기 개선 순서 diff --git a/scripts/verify-audiovisual-feedback.mjs b/scripts/verify-audiovisual-feedback.mjs index 84a43a1..2608f17 100644 --- a/scripts/verify-audiovisual-feedback.mjs +++ b/scripts/verify-audiovisual-feedback.mjs @@ -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/); diff --git a/scripts/verify-battle-save-generation-browser.mjs b/scripts/verify-battle-save-generation-browser.mjs index 6f2c62c..eb2d66d 100644 --- a/scripts/verify-battle-save-generation-browser.mjs +++ b/scripts/verify-battle-save-generation-browser.mjs @@ -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'); diff --git a/scripts/verify-battle-save-resume-routing.mjs b/scripts/verify-battle-save-resume-routing.mjs index 8f318c2..5a026a3 100644 --- a/scripts/verify-battle-save-resume-routing.mjs +++ b/scripts/verify-battle-save-resume-routing.mjs @@ -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) && diff --git a/scripts/verify-interaction-ux.mjs b/scripts/verify-interaction-ux.mjs index 855c5ec..d11f87a 100644 --- a/scripts/verify-interaction-ux.mjs +++ b/scripts/verify-interaction-ux.mjs @@ -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)}` diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 73fceec..2604b54 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -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; @@ -2370,6 +2382,10 @@ type IntentCounterplayEvent = { type IntentCounterplayActionOutcome = { defeatedEnemyIds?: string[]; }; +type IntentCounterplaySettlement = { + message: string; + popupDuration: number; +}; type TacticalInitiativeRole = BattleSaveTacticalCommandRole; type TacticalCommandSource = NonNullable; @@ -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((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((resolve) => { - this.time.delayedCall(this.scaledBattleDuration(ms), () => resolve()); - }); + return this.waitSceneDuration( + this.scaledBattleDuration(ms) + ); } private waitSceneDuration(ms: number) { return new Promise((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); }