diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index f66cdc6..77d848e 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -401,10 +401,67 @@ RPG로 다듬는다. 위치·방향·대사 줄·선택창·상점·군령 카드 복원과 보상 1회성, 정상 퇴장 시 체크포인트 제거를 자동 검증한다. -다음 묶음은 이야기 화면의 페이지 단위 재개와 남은 직접 상태 변경 경로를 -같은 저장 원칙으로 정리한다. 탐색 대사에는 문구나 순서가 바뀌어도 같은 -장면을 찾는 안정적인 `beatId`와 구버전 변환표를 더해, 긴 캠페인에서 -“중단해도 읽은 만큼만 이어지는” 경험을 전 구간으로 확장한다. +### 완료한 18차 묶음: 마지막으로 읽은 대사에서 이어지는 이야기 (2026-07-28) + +- 이야기 배열의 순번 대신 장면과 대사의 안정적인 `sequenceId`·`beatId`를 + 저장한다. 대사 추가나 순서 변경 뒤에도 같은 대사를 다시 찾고, 기존의 + 순번 전용 저장과 과거 ID는 호환 변환표를 거쳐 새 형식으로 옮긴다. +- 이야기 체크포인트를 `reading`과 `handoff`로 나눈다. 읽는 중에는 정확한 + 페이지를 복원하고, 마지막 대사를 확인한 뒤에는 같은 마지막 페이지를 + 재생하지 않고 목적 장면으로 바로 이어 간다. +- 페이지를 넘길 때 다음 페이지를 먼저 저장하고, 브라우저 숨김·종료 때 + 화면에 보이는 대사를 다시 확정한다. 이미 목적 장면으로 넘긴 뒤 들어온 + 늦은 저장 요청이나 이전 revision의 요청은 완료 상태를 되살리지 못한다. +- 새 캠페인 시작, 마을·의용군 막사 완료, 전투 승리 정산, 군영 출진은 + 캠페인 단계 변경과 다음 이야기 첫 대사를 한 번의 저장으로 확정한다. + 저장이나 장면 로딩이 실패하면 이전 캠페인 전체를 되돌려 반쪽 진행을 + 남기지 않는다. +- 타이틀 이어하기는 이야기 체크포인트를 낡은 진행 중 전투 저장보다 먼저 본다. + 일반·특수 출진과 승전 후일담을 안정 ID로 다시 구성하며, 전투 보고에서 + `편성 보완`을 골랐다면 후일담 뒤 군영의 해당 화면까지 의도를 보존한다. +- 새 출전을 확정할 때 전투 저장 세대를 함께 올리고, 전투 저장에도 같은 + 세대를 기록한다. 브라우저가 낡은 파일 삭제를 거부하더라도 이전 세대의 + 전투는 타이틀 이어하기뿐 아니라 전투 안의 수동 불러오기에서도 후보가 + 되지 않으며, 새 전투에서 만든 같은 세대의 저장만 다시 열린다. +- 새 캠페인으로 1번 슬롯을 덮을 때에는 기존 슬롯보다 높은 전투 저장 세대를 + 먼저 정식 저장한 뒤 낡은 전투 파일을 정리한다. 새 캠페인 저장 자체가 + 실패하면 기존 캠페인과 진행 중 전투를 모두 그대로 보존하고, 파일 삭제가 + 거부되더라도 논리 세대가 이전 플레이의 부활을 막는다. +- 마지막 대사에서 목적 장면 시작을 요청한 것만으로 handoff를 지우지 않는다. + 출발 장면은 이를 유지하고, 목적 장면이 자신의 캠페인 단계나 후일담 정산을 + 먼저 확정해 그 단계만으로 복구할 수 있게 된 뒤 정확한 sequence와 revision을 + 소비한다. 따라서 로딩 도중 종료되어도 이어하기 목적지를 잃지 않는다. +- 승전 후일담에서 군영으로 넘어갈 때에는 전투 정산 표식과 이야기 handoff가 + 모두 정확히 일치해야 둘을 한 번에 소비한다. 전투 결과 저장이 일시적으로 + 실패하면 승패 모두 결과창에 `저장 다시 시도`를 제공하고, 저장 전에는 + 재도전·타이틀·편성 보완 이동을 잠가 결과를 버리는 이탈을 막는다. 저장에 + 성공한 뒤에만 정상 버튼을 되살리고 후일담이나 재도전으로 진행한다. +- 예전 저장의 `first-victory-story` 단계도 마지막 대사 handoff와 단계 전환을 + 한 번에 확정해 `first-camp`로 옮긴다. 보상을 다시 지급하지 않으면서 같은 + 첫 승리 이야기가 계속하기 때마다 반복되는 구버전 저장 루프를 없앤다. +- 승리의 `다음 출전 보완`과 패배의 `재도전 보완`은 출발 전투·목표 전투· + 목표 캠페인 단계·보완 종류를 `sortiePreparationCheckpoint`에 함께 남긴다. + 타이틀 이어하기는 낡은 전투 저장보다 이 명시적 목적지를 먼저 복원하고, + 군영은 장면 전달값이 없어도 편성 보완 화면을 다시 연다. 실제 출전을 + 한 번의 저장으로 확정할 때만 이를 소비하며, 전투 결과·단계가 어긋난 + 구버전이나 변조 상태는 로드 시 자동 폐기한다. +- 승리 결과 저장에 이미 동일한 기본 후일담 첫 대사가 들어 있으면 계속하기가 + 같은 내용을 다시 쓰지 않는다. 결과 저장 뒤 저장 권한이나 용량이 나빠져도 + 이미 안전하게 확정된 후일담으로 이동하는 데 불필요한 두 번째 쓰기가 + 장애가 되지 않는다. +- 469개 이야기 대사의 ID 전역 중복을 정적 검사하고, 저장 정규화·구버전 + 변환·저장 실패 불변성·handoff 경합을 함께 검증한다. Canvas·WebGL 각각 + 1920x1080 CSS viewport, 100% 확대, DPR 1에서는 정확한 페이지·화자·문구· + 배경·초상 복원과 목적 장면 소유권 이전 전후 handoff 수명을 자동 확인한다. +- 스토리 체크포인트·전투 저장 세대·결과 저장 재시도 브라우저 검사는 개발 + 서버가 아니라 현재 `dist`의 해시 자산을 소유한 미리보기 서버에서 수행한다. + 다른 프로세스가 같은 포트에서 응답하면 재사용하지 않고 실패시키며, 응답 + 제한시간과 종료 대기를 둔다. 운영 배포 시작 전에는 추적 파일뿐 아니라 + 미추적 파일도 없는지 확인해 로컬에만 존재하는 소스로 배포되는 일을 막는다. + +다음 묶음은 탐색 대사에도 문구나 순서가 바뀌어도 같은 장면을 찾는 안정적인 +`beatId`와 구버전 변환표를 더한다. 이후에는 전투·이야기·탐색의 모든 +중단 지점이 “중단해도 읽고 행동한 만큼만 이어지는” 같은 원칙을 공유한다. ## 장기 개선 순서 diff --git a/package.json b/package.json index f2cb6be..f29c4ef 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,11 @@ "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs", "verify:campaign-exploration-checkpoints": "node scripts/verify-campaign-exploration-checkpoint-normalization.mjs", + "verify:campaign-story-checkpoints": "node scripts/verify-campaign-story-checkpoint-normalization.mjs", + "verify:sortie-preparation-checkpoints": "node scripts/verify-sortie-preparation-checkpoint.mjs", + "verify:story-checkpoint:browser": "node scripts/verify-story-checkpoint-browser.mjs", + "verify:battle-result-persistence": "node scripts/verify-battle-result-persistence-retry.mjs", + "verify:battle-result-persistence:browser": "node scripts/verify-battle-result-persistence-retry-browser.mjs", "verify:campaign-settlement-idempotency": "node scripts/verify-campaign-settlement-idempotency.mjs", "verify:camp-visit-resume": "node scripts/verify-camp-visit-resume-state.mjs", "verify:camp-visit-resume:browser": "node scripts/verify-camp-visit-resume-browser.mjs", @@ -33,6 +38,7 @@ "verify:campaign-objectives:browser": "node scripts/verify-campaign-objective-journal-browser.mjs", "verify:growth-consistency": "node scripts/verify-growth-consistency.mjs", "verify:battle-save": "node scripts/verify-battle-save-normalization.mjs", + "verify:battle-save-generation:browser": "node scripts/verify-battle-save-generation-browser.mjs", "verify:battle-usables": "node scripts/verify-battle-usables.mjs", "verify:combat-presentation": "node scripts/verify-combat-presentation-settings.mjs", "verify:enemy-intent": "node scripts/verify-enemy-intent-rollout.mjs", @@ -97,7 +103,7 @@ "verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:turn-end-risk:browser && pnpm run verify:campaign-objectives:browser && pnpm run verify:camp-visit-resume:browser && pnpm run verify:exploration-checkpoints:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:turn-end-risk:browser && pnpm run verify:campaign-objectives:browser && pnpm run verify:camp-visit-resume:browser && pnpm run verify:exploration-checkpoints:browser && pnpm run verify:story-checkpoint:browser && pnpm run verify:battle-save-generation:browser && pnpm run verify:battle-result-persistence:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/ship-release.mjs b/scripts/ship-release.mjs index 4034ce9..1c226cc 100644 --- a/scripts/ship-release.mjs +++ b/scripts/ship-release.mjs @@ -46,17 +46,17 @@ function makeQaVersion() { } function assertNoTrackedChanges() { - const result = spawnSync('git', ['status', '--porcelain', '--untracked-files=no'], { + const result = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: process.cwd(), encoding: 'utf8' }); if (result.status !== 0) { - throw new Error(`Could not inspect tracked git status before shipping:\n${result.stderr.trim()}`); + throw new Error(`Could not inspect git status before shipping:\n${result.stderr.trim()}`); } const dirty = result.stdout.trim(); if (dirty) { - throw new Error(`Refusing to ship release with uncommitted tracked changes:\n${dirty}`); + throw new Error(`Refusing to ship release with uncommitted or untracked files:\n${dirty}`); } } diff --git a/scripts/verification-preview-server.mjs b/scripts/verification-preview-server.mjs new file mode 100644 index 0000000..3f1ba8c --- /dev/null +++ b/scripts/verification-preview-server.mjs @@ -0,0 +1,321 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +const localHostnames = new Set([ + 'localhost', + '127.0.0.1', + '0.0.0.0', + '::1' +]); +const defaultStartupTimeoutMs = 45_000; +const defaultFetchTimeoutMs = 1_500; +const ownedServerStabilityMs = 300; +const gracefulShutdownTimeoutMs = 4_000; +const forcedShutdownTimeoutMs = 4_000; +const maxCapturedOutputLength = 16_000; + +/** + * Starts an owned Vite preview for local URLs. A pre-existing process on the + * requested port is deliberately never reused: --strictPort makes ownership + * unambiguous and the child must remain alive after the built page responds. + * + * Explicit non-local URLs are useful for opt-in production verification and + * are only probed; they never create a local process. + */ +export async function startVerificationPreview({ + targetUrl, + explicitUrl = false, + label = 'browser verification', + cwd = process.cwd(), + startupTimeoutMs = defaultStartupTimeoutMs, + fetchTimeoutMs = defaultFetchTimeoutMs +}) { + const parsed = new URL(targetUrl); + if (!localHostnames.has(parsed.hostname)) { + if (!explicitUrl) { + throw new Error( + `${label}: refusing to start a local preview for non-local URL ${targetUrl}` + ); + } + await assertExternalBuildReachable( + targetUrl, + label, + fetchTimeoutMs + ); + return undefined; + } + + if (!parsed.port) { + throw new Error( + `${label}: local verification URLs must use an explicit port (${targetUrl}).` + ); + } + + const distHtmlPath = path.resolve(cwd, 'dist', 'index.html'); + const distHtml = await readFile(distHtmlPath, 'utf8').catch( + (error) => { + throw new Error( + `${label}: ${distHtmlPath} is unavailable. Build the release candidate before running browser verification.`, + { cause: error } + ); + } + ); + const assetSignature = builtAssetSignature( + distHtml, + `${label}: local dist` + ); + const viteCliPath = path.resolve( + cwd, + 'node_modules', + 'vite', + 'bin', + 'vite.js' + ); + const child = spawn( + process.execPath, + [ + viteCliPath, + 'preview', + '--host', + '127.0.0.1', + '--port', + parsed.port, + '--strictPort' + ], + { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ); + const output = { + stdout: '', + stderr: '', + spawnError: undefined + }; + child.stdout?.on('data', (chunk) => { + output.stdout = appendCapturedOutput( + output.stdout, + chunk + ); + }); + child.stderr?.on('data', (chunk) => { + output.stderr = appendCapturedOutput( + output.stderr, + chunk + ); + }); + child.on('error', (error) => { + output.spawnError = error; + }); + + const deadline = Date.now() + startupTimeoutMs; + while (Date.now() < deadline) { + if (processHasExited(child)) { + throw previewStartupError( + label, + targetUrl, + child, + output + ); + } + if ( + await builtPageResponds( + targetUrl, + assetSignature, + fetchTimeoutMs + ) + ) { + // A process already occupying the port can answer the first probe while + // Vite is still reporting EADDRINUSE. Requiring our strict-port child to + // survive a short stabilization window closes that race. + await delay(ownedServerStabilityMs); + if (processHasExited(child)) { + throw previewStartupError( + label, + targetUrl, + child, + output + ); + } + return child; + } + await delay(200); + } + + await stopVerificationPreview(child); + throw new Error( + `${label}: owned Vite preview did not serve the current dist at ${targetUrl}.${formatCapturedOutput(output)}` + ); +} + +export async function stopVerificationPreview(child) { + if (!child || processHasExited(child)) { + return; + } + + child.kill('SIGTERM'); + if ( + await waitForProcessExit( + child, + gracefulShutdownTimeoutMs + ) + ) { + return; + } + + child.kill('SIGKILL'); + if ( + await waitForProcessExit( + child, + forcedShutdownTimeoutMs + ) + ) { + return; + } + + throw new Error( + `Owned Vite preview process ${child.pid ?? 'unknown'} did not exit after forced shutdown.` + ); +} + +async function assertExternalBuildReachable( + url, + label, + fetchTimeoutMs +) { + let response; + try { + response = await fetch(url, { + signal: AbortSignal.timeout(fetchTimeoutMs) + }); + } catch (error) { + throw new Error( + `${label}: no external server responded at ${url} within ${fetchTimeoutMs}ms.`, + { cause: error } + ); + } + if (!response.ok) { + throw new Error( + `${label}: external server returned HTTP ${response.status} at ${url}.` + ); + } + const html = await response.text(); + builtAssetSignature(html, `${label}: external page`); +} + +async function builtPageResponds( + url, + assetSignature, + fetchTimeoutMs +) { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(fetchTimeoutMs) + }); + if (!response.ok) { + return false; + } + const html = await response.text(); + return ( + !html.includes('/src/') && + html.includes(assetSignature) + ); + } catch { + return false; + } +} + +function builtAssetSignature(html, label) { + if (html.includes('/src/')) { + throw new Error( + `${label} points at source modules instead of a built release.` + ); + } + const match = html.match( + /]*\bsrc=["']([^"']*\/assets\/[^"']+\.js)["'][^>]*>/i + ); + if (!match) { + throw new Error( + `${label} does not contain a production JavaScript asset.` + ); + } + return match[1]; +} + +function previewStartupError( + label, + targetUrl, + child, + output +) { + const cause = output.spawnError + ? `\nSpawn error: ${output.spawnError.message}` + : ''; + return new Error( + `${label}: owned Vite preview exited before becoming ready at ${targetUrl} ` + + `(exit ${child.exitCode ?? child.signalCode ?? 'unknown'}).` + + cause + + formatCapturedOutput(output) + ); +} + +function formatCapturedOutput(output) { + const sections = []; + if (output.stdout.trim()) { + sections.push(`stdout:\n${output.stdout.trim()}`); + } + if (output.stderr.trim()) { + sections.push(`stderr:\n${output.stderr.trim()}`); + } + return sections.length > 0 + ? `\n${sections.join('\n')}` + : ''; +} + +function appendCapturedOutput(current, chunk) { + const appended = current + chunk.toString(); + return appended.length <= maxCapturedOutputLength + ? appended + : appended.slice(-maxCapturedOutputLength); +} + +function processHasExited(child) { + return ( + child.exitCode !== null || + child.signalCode !== null + ); +} + +function waitForProcessExit(child, timeoutMs) { + if (processHasExited(child)) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + let settled = false; + const finish = (exited) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + child.off('exit', onExit); + resolve(exited); + }; + const onExit = () => finish(true); + const timeout = setTimeout( + () => finish(processHasExited(child)), + timeoutMs + ); + child.once('exit', onExit); + if (processHasExited(child)) { + finish(true); + } + }); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/scripts/verify-battle-result-persistence-retry-browser.mjs b/scripts/verify-battle-result-persistence-retry-browser.mjs new file mode 100644 index 0000000..be70078 --- /dev/null +++ b/scripts/verify-battle-result-persistence-retry-browser.mjs @@ -0,0 +1,1530 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; +import { + startVerificationPreview, + stopVerificationPreview +} from './verification-preview-server.mjs'; + +const rendererNames = ['canvas', 'webgl']; +const renderer = + process.env.VERIFY_BATTLE_RESULT_PERSISTENCE_RENDERER; + +if (!renderer) { + for (const requestedRenderer of rendererNames) { + const result = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url)], + { + cwd: process.cwd(), + env: { + ...process.env, + VERIFY_BATTLE_RESULT_PERSISTENCE_RENDERER: + requestedRenderer + }, + stdio: 'inherit' + } + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } + process.exit(0); +} + +assert( + rendererNames.includes(renderer), + `Unsupported battle result persistence renderer "${renderer}".` +); + +const battleId = 'first-battle-zhuo-commandery'; +const canonicalCampaignKey = + 'heros-web:campaign-state:slot-1'; +const currentCampaignKey = 'heros-web:campaign-state'; +const baseUrl = + process.env.VERIFY_BATTLE_RESULT_PERSISTENCE_URL ?? + `http://127.0.0.1:${ + renderer === 'canvas' ? 41823 : 41824 + }/heros_web/`; +const targetUrl = withDebugOptions(baseUrl, renderer); +let serverProcess; +let browser; + +try { + serverProcess = await startVerificationPreview({ + targetUrl, + explicitUrl: Boolean( + process.env + .VERIFY_BATTLE_RESULT_PERSISTENCE_URL + ), + label: + `${renderer} battle result persistence verification` + }); + browser = await chromium.launch({ + headless: + process.env + .VERIFY_BATTLE_RESULT_PERSISTENCE_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); + + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => + pageErrors.push(error.stack ?? error.message) + ); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + await page.goto(targetUrl, { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForTitle(page); + await assertDesktopRuntime(page, renderer); + + await openBattle(page, battleId); + await waitForBattleReady(page, battleId); + const victoryRecommendation = + await installRecommendationFixture( + page, + battleId + ); + assert( + victoryRecommendation.selectedUnitIds.length > 0, + `${renderer}: victory fixture has no selected units` + ); + + const beforeFailure = await readCampaignStorage(page); + assertCampaignMirrorsEqual( + beforeFailure, + `${renderer}: battle-entry baseline` + ); + assert.equal(beforeFailure.current.step, 'first-battle'); + assert.equal( + beforeFailure.current.firstBattleReport, + undefined + ); + assert.equal( + beforeFailure.current.pendingAftermathBattleId, + undefined + ); + assert.equal( + beforeFailure.current.storyCheckpoint, + undefined + ); + + await installOneShotCanonicalFailure(page); + await page.evaluate(() => { + window.__HEROS_DEBUG__?.forceBattleOutcome('victory'); + }); + const failedResult = await waitForResultPersistence( + page, + 'victory', + 'failed' + ); + + assert.equal(failedResult.battleOutcome, 'victory'); + assert.equal(failedResult.phase, 'resolved'); + assert.equal(failedResult.resultVisible, true); + assert.equal( + failedResult.resultSettlement.reportPersistence, + 'failed' + ); + assert.equal( + failedResult.resultSettlement.cta.state, + 'persistence-retry' + ); + assert.equal( + failedResult.resultSettlement.cta.label, + '저장 다시 시도' + ); + assert.equal( + failedResult.resultSettlement.cta.interactive, + true + ); + assertFiniteBounds( + failedResult.resultSettlement.cta.bounds + ); + assertBoundsInsideViewport( + failedResult.resultSettlement.cta.bounds + ); + + const failureStats = await readFailureStats(page); + assert.deepEqual( + failureStats, + { + canonicalAttempts: 1, + injectedFailures: 1 + }, + `${renderer}: the canonical campaign slot must fail exactly once` + ); + + const afterFailure = await readCampaignStorage(page); + assert.deepEqual( + afterFailure.current, + beforeFailure.current, + `${renderer}: the compatibility campaign mirror changed after the canonical write failed` + ); + assert.deepEqual( + afterFailure.slot, + beforeFailure.slot, + `${renderer}: the canonical campaign slot changed after its injected failure` + ); + assert.deepEqual( + campaignOutcomeProjection(afterFailure.current), + campaignOutcomeProjection(beforeFailure.current), + `${renderer}: failed persistence leaked report, pending aftermath, or story checkpoint state` + ); + + await triggerResultContinueButton(page); + const recoveredResult = await waitForResultPersistence( + page, + 'victory', + 'complete' + ); + assert.equal(recoveredResult.resultVisible, true); + assert.equal( + recoveredResult.resultSettlement.reportPersistence, + 'complete' + ); + assert.notEqual( + recoveredResult.resultSettlement.cta.state, + 'persistence-retry' + ); + assert.notEqual( + recoveredResult.resultSettlement.cta.label, + '저장 다시 시도' + ); + + const recoveredFailureStats = await readFailureStats(page); + assert.deepEqual( + recoveredFailureStats, + { + canonicalAttempts: 2, + injectedFailures: 1 + }, + `${renderer}: retry must recover after the one injected failure` + ); + + const afterRecovery = await readCampaignStorage(page); + assertCampaignMirrorsEqual( + afterRecovery, + `${renderer}: recovered battle result` + ); + const finalState = afterRecovery.current; + assert.equal(finalState.step, 'first-camp'); + assert.equal( + finalState.firstBattleReport?.battleId, + battleId + ); + assert.equal( + finalState.firstBattleReport?.outcome, + 'victory' + ); + assert.equal(finalState.pendingAftermathBattleId, battleId); + assert.equal( + finalState.storyCheckpoint?.phase, + 'reading' + ); + assert.equal( + finalState.storyCheckpoint?.campaignStep, + 'first-camp' + ); + assert.equal( + typeof finalState.storyCheckpoint?.sequenceId, + 'string' + ); + assert.equal( + typeof finalState.storyCheckpoint?.beatId, + 'string' + ); + assert.equal( + finalState.storyCheckpointRevision, + beforeFailure.current.storyCheckpointRevision + 1, + `${renderer}: the failed attempt must not consume an extra story revision` + ); + assert.deepEqual( + campaignOutcomeProjection(afterRecovery.current), + campaignOutcomeProjection(afterRecovery.slot), + `${renderer}: report, pending aftermath, and story checkpoint diverged between current and slot` + ); + + assertSingleSettlement( + beforeFailure.current, + finalState, + battleId, + renderer + ); + + await completeVisibleResultSettlement(page); + const victoryImprovement = + await openResultImprovementProbe( + page, + 'victory' + ); + assert.equal( + victoryImprovement.available, + true, + `${renderer}: victory recommendation fixture did not expose its next-sortie improvement action` + ); + assert.equal( + victoryImprovement.interactive, + true, + `${renderer}: durable victory result did not enable its improvement action` + ); + assertFiniteBounds(victoryImprovement.bounds); + assertBoundsInsideViewport( + victoryImprovement.bounds + ); + await triggerResultImprovementButton(page); + await waitForActiveScene(page, 'StoryScene'); + const victoryIntentBeforeStory = + await readCampaignStorage(page); + assertCampaignMirrorsEqual( + victoryIntentBeforeStory, + `${renderer}: victory improvement destination` + ); + assertPreparationCheckpoint( + victoryIntentBeforeStory.current, + { + mode: 'next-sortie', + sourceBattleId: battleId, + targetBattleId: + 'second-battle-yellow-turban-pursuit', + targetCampaignStep: 'second-battle' + }, + `${renderer}: victory improvement destination` + ); + assert.equal( + victoryIntentBeforeStory.current + .storyCheckpoint + ?.continuationIntent, + 'sortie-improvement', + `${renderer}: victory improvement and aftermath continuation were not committed together` + ); + await completeActiveStoryToCamp(page); + await waitForCamp(page); + + await reloadToTitle(page, renderer); + await triggerTitleContinue(page); + const restoredVictoryPreparation = + await waitForCampImprovement( + page, + { + mode: 'next-sortie', + targetBattleId: + 'second-battle-yellow-turban-pursuit' + } + ); + assert.equal( + restoredVictoryPreparation + .retrySortieBattleId, + null + ); + assert.equal( + restoredVictoryPreparation + .sortieGuidedImprovement + ?.targetBattleId, + 'second-battle-yellow-turban-pursuit', + `${renderer}: victory reload did not restore the guided next-sortie overlay` + ); + const victoryIntentAfterReload = + await readCampaignStorage(page); + assertPreparationCheckpoint( + victoryIntentAfterReload.current, + { + mode: 'next-sortie', + sourceBattleId: battleId, + targetBattleId: + 'second-battle-yellow-turban-pursuit', + targetCampaignStep: 'second-battle' + }, + `${renderer}: victory improvement reload` + ); + + await resetToFreshBattle(page, renderer, battleId); + const defeatRecommendation = + await installRecommendationFixture( + page, + battleId + ); + assert( + defeatRecommendation.selectedUnitIds.length > 0, + `${renderer}: defeat fixture has no selected units` + ); + const beforeDefeatFailure = + await readCampaignStorage(page); + assertCampaignMirrorsEqual( + beforeDefeatFailure, + `${renderer}: defeat battle-entry baseline` + ); + assert.equal( + beforeDefeatFailure.current.step, + 'first-battle' + ); + assert.equal( + beforeDefeatFailure.current.firstBattleReport, + undefined + ); + + await installOneShotCanonicalFailure(page); + await page.evaluate(() => { + window.__HEROS_DEBUG__?.forceBattleOutcome('defeat'); + }); + const failedDefeatResult = + await waitForResultPersistence( + page, + 'defeat', + 'failed' + ); + assert.equal(failedDefeatResult.phase, 'resolved'); + assert.equal(failedDefeatResult.resultVisible, true); + assert.equal( + failedDefeatResult.resultSettlement.cta.state, + 'persistence-retry' + ); + assert.equal( + failedDefeatResult.resultSettlement.cta.label, + '저장 다시 시도' + ); + assert.equal( + failedDefeatResult.resultSettlement.cta.interactive, + true + ); + + const failedDefeatActions = + resultActionMap(failedDefeatResult); + assert.equal( + failedDefeatActions.retry?.interactive, + false, + `${renderer}: normal retry must be disabled until the defeat report is durable` + ); + assert.equal( + failedDefeatActions.title?.interactive, + false, + `${renderer}: title navigation must be disabled until the defeat report is durable` + ); + assertFiniteBounds(failedDefeatActions.retry?.bounds); + assertFiniteBounds(failedDefeatActions.title?.bounds); + assertBoundsInsideViewport( + failedDefeatActions.retry.bounds + ); + assertBoundsInsideViewport( + failedDefeatActions.title.bounds + ); + + const beforeBlockedDefeatNavigation = + await readDefeatFailureProbe(page); + await clickBounds( + page, + failedDefeatActions.retry.bounds + ); + await page.waitForTimeout(160); + await clickBounds( + page, + failedDefeatActions.title.bounds + ); + await page.waitForTimeout(160); + const afterBlockedResultActions = + await readDefeatFailureProbe(page); + assert.deepEqual( + afterBlockedResultActions, + beforeBlockedDefeatNavigation, + `${renderer}: disabled defeat retry/title buttons escaped the failed result` + ); + + const improvementProbe = + await openResultImprovementProbe( + page, + 'defeat' + ); + assert.equal( + improvementProbe.available, + true, + `${renderer}: defeat recommendation fixture did not expose its improvement action` + ); + assert.equal( + improvementProbe.interactive, + false, + `${renderer}: formation-improvement navigation must be disabled until the report is durable` + ); + assertFiniteBounds(improvementProbe.bounds); + assertBoundsInsideViewport(improvementProbe.bounds); + await clickBounds(page, improvementProbe.bounds); + await page.waitForTimeout(180); + const afterBlockedImprovement = + await readDefeatFailureProbe(page); + assert.deepEqual( + afterBlockedImprovement, + beforeBlockedDefeatNavigation, + `${renderer}: disabled formation improvement escaped the failed defeat result` + ); + + const afterDefeatFailure = + await readCampaignStorage(page); + assert.deepEqual( + afterDefeatFailure.current, + beforeDefeatFailure.current, + `${renderer}: current save changed after defeat persistence failed` + ); + assert.deepEqual( + afterDefeatFailure.slot, + beforeDefeatFailure.slot, + `${renderer}: canonical slot changed after defeat persistence failed` + ); + assert.deepEqual( + await readFailureStats(page), + { + canonicalAttempts: 1, + injectedFailures: 1 + }, + `${renderer}: blocked defeat navigation must not attempt another campaign write` + ); + + await closeResultImprovementProbe(page); + await triggerResultContinueButton(page); + const recoveredDefeatResult = + await waitForResultPersistence( + page, + 'defeat', + 'complete' + ); + const recoveredDefeatActions = + resultActionMap(recoveredDefeatResult); + assert.equal( + recoveredDefeatActions.retry?.label, + '다시 하기' + ); + assert.equal( + recoveredDefeatActions.retry?.interactive, + true, + `${renderer}: normal retry did not recover after the defeat report became durable` + ); + assert.equal( + recoveredDefeatActions.title?.interactive, + true, + `${renderer}: title navigation did not recover after the defeat report became durable` + ); + assertFiniteBounds(recoveredDefeatActions.retry?.bounds); + assertBoundsInsideViewport( + recoveredDefeatActions.retry.bounds + ); + + const afterDefeatRecovery = + await readCampaignStorage(page); + assertCampaignMirrorsEqual( + afterDefeatRecovery, + `${renderer}: recovered defeat result` + ); + assert.equal( + afterDefeatRecovery.current.step, + 'first-battle' + ); + assert.equal( + afterDefeatRecovery.current.firstBattleReport?.outcome, + 'defeat' + ); + assert.equal( + afterDefeatRecovery.current.pendingAftermathBattleId, + undefined + ); + assert.equal( + afterDefeatRecovery.current.storyCheckpoint, + undefined + ); + assert.equal( + afterDefeatRecovery.current.storyCheckpointRevision, + beforeDefeatFailure.current.storyCheckpointRevision, + `${renderer}: defeat persistence retry must not consume a story revision` + ); + assert.equal( + afterDefeatRecovery.current.gold, + beforeDefeatFailure.current.gold, + `${renderer}: defeat recovery must not grant victory gold` + ); + assert.deepEqual( + await readFailureStats(page), + { + canonicalAttempts: 2, + injectedFailures: 1 + }, + `${renderer}: defeat persistence retry must recover after exactly one injected failure` + ); + + const recoveredDefeatImprovement = + await openResultImprovementProbe( + page, + 'defeat' + ); + assert.equal( + recoveredDefeatImprovement.available, + true + ); + assert.equal( + recoveredDefeatImprovement.interactive, + true, + `${renderer}: recovered defeat did not enable retry improvement` + ); + const durableBattleSaveKeys = + await readBattleSaveKeys(page); + assert( + durableBattleSaveKeys.length > 0, + `${renderer}: defeat regression fixture has no older battle save to outrank` + ); + await triggerResultImprovementButton(page); + const defeatCamp = + await waitForCampImprovement( + page, + { + mode: 'retry', + targetBattleId: battleId + } + ); + assert.equal( + defeatCamp.retrySortieBattleId, + battleId + ); + const defeatIntentAtCamp = + await readCampaignStorage(page); + assertPreparationCheckpoint( + defeatIntentAtCamp.current, + { + mode: 'retry', + sourceBattleId: battleId, + targetBattleId: battleId, + targetCampaignStep: 'first-battle' + }, + `${renderer}: defeat improvement camp arrival` + ); + + await reloadToTitle(page, renderer); + await triggerTitleContinue(page); + const restoredDefeatPreparation = + await waitForCampImprovement( + page, + { + mode: 'retry', + targetBattleId: battleId + } + ); + assert.equal( + restoredDefeatPreparation + .retrySortieBattleId, + battleId + ); + assert( + !restoredDefeatPreparation.activeScenes.includes( + 'BattleScene' + ), + `${renderer}: an older battle save outranked the explicit retry-preparation destination` + ); + const defeatGenerationBeforeLaunch = + ( + await readCampaignStorage(page) + ).current.battleResumeGeneration; + await launchPreparedBattle(page); + await waitForBattleReady(page, battleId); + const restartedBattle = await page.evaluate( + () => window.__HEROS_DEBUG__?.battle?.() + ); + assert.equal(restartedBattle.battleOutcome, null); + assert.equal(restartedBattle.resultVisible, false); + const defeatAfterLaunch = + await readCampaignStorage(page); + assert.equal( + defeatAfterLaunch.current + .sortiePreparationCheckpoint, + undefined, + `${renderer}: retry launch did not consume the durable preparation checkpoint` + ); + assert.equal( + defeatAfterLaunch.current + .battleResumeGeneration, + defeatGenerationBeforeLaunch + 1, + `${renderer}: retry launch did not atomically advance its battle generation` + ); + + assert.equal(pageErrors.length, 0, pageErrors.join('\n')); + const unexpectedConsoleErrors = consoleErrors.filter( + (message) => + !message.includes( + 'Failed to persist the battle result.' + ) && + !message.includes('Injected canonical campaign slot failure') + ); + assert.equal( + unexpectedConsoleErrors.length, + 0, + unexpectedConsoleErrors.join('\n') + ); + + console.log( + JSON.stringify( + { + renderer, + viewport: desktopBrowserViewport, + deviceScaleFactor: + desktopBrowserDeviceScaleFactor, + cssZoom: 1, + canonicalFailure: failureStats, + recoveredAttempts: recoveredFailureStats, + failedCta: + failedResult.resultSettlement.cta.label, + reportPersistence: + recoveredResult.resultSettlement + .reportPersistence, + campaignStep: finalState.step, + pendingAftermathBattleId: + finalState.pendingAftermathBattleId, + storyCheckpoint: + finalState.storyCheckpoint, + rewardGold: + finalState.firstBattleReport.rewardGold, + finalGold: finalState.gold, + defeat: { + failedCta: + failedDefeatResult.resultSettlement.cta + .label, + blockedActions: { + retry: + failedDefeatActions.retry.interactive, + title: + failedDefeatActions.title.interactive, + improvement: + improvementProbe.interactive + }, + recoveredRetry: + recoveredDefeatActions.retry.interactive, + restoredDestination: + restoredDefeatPreparation + .retrySortieBattleId, + reportOutcome: + afterDefeatRecovery.current + .firstBattleReport.outcome, + finalGold: + afterDefeatRecovery.current.gold + }, + pageErrors: pageErrors.length, + consoleErrors: unexpectedConsoleErrors.length + }, + null, + 2 + ) + ); +} finally { + await browser?.close(); + await stopVerificationPreview(serverProcess); +} + +function withDebugOptions(url, requestedRenderer) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('renderer', requestedRenderer); + parsed.searchParams.set('motion', 'reduced'); + return parsed.toString(); +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + Boolean( + document.querySelector('canvas') && + window.__HEROS_GAME__ && + window.__HEROS_DEBUG__ + ), + undefined, + { timeout: 90000 } + ); +} + +async function waitForTitle(page) { + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'TitleScene' + ), + undefined, + { timeout: 90000 } + ); +} + +async function openBattle(page, expectedBattleId) { + const started = await page.evaluate(async (requestedBattleId) => { + if (!window.__HEROS_DEBUG__) { + return false; + } + await window.__HEROS_DEBUG__.goToBattle( + requestedBattleId + ); + return true; + }, expectedBattleId); + assert.equal(started, true); +} + +async function waitForBattleReady(page, expectedBattleId) { + await page.waitForFunction( + (requestedBattleId) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'BattleScene' + ) && + battle?.scene === 'BattleScene' && + battle?.battleId === requestedBattleId && + battle?.battleOutcome === null && + ['deployment', 'idle'].includes(battle?.phase) && + battle?.mapBackgroundReady === true && + battle?.resultVisible === false + ); + }, + expectedBattleId, + { timeout: 90000 } + ); +} + +async function installOneShotCanonicalFailure(page) { + await page.evaluate((canonicalKey) => { + const storagePrototype = Object.getPrototypeOf( + window.localStorage + ); + const originalSetItem = + storagePrototype.setItem; + const stats = { + canonicalAttempts: 0, + injectedFailures: 0 + }; + Object.defineProperty( + window, + '__HEROS_CANONICAL_FAILURE_STATS__', + { + configurable: true, + value: stats + } + ); + storagePrototype.setItem = function setItem( + key, + value + ) { + if ( + this === window.localStorage && + String(key) === canonicalKey + ) { + stats.canonicalAttempts += 1; + if (stats.injectedFailures === 0) { + stats.injectedFailures += 1; + throw new DOMException( + 'Injected canonical campaign slot failure', + 'QuotaExceededError' + ); + } + } + return originalSetItem.call(this, key, value); + }; + }, canonicalCampaignKey); +} + +async function readFailureStats(page) { + return page.evaluate(() => { + const stats = + window.__HEROS_CANONICAL_FAILURE_STATS__; + return stats + ? { + canonicalAttempts: stats.canonicalAttempts, + injectedFailures: stats.injectedFailures + } + : null; + }); +} + +async function waitForResultPersistence( + page, + outcome, + status +) { + await page.waitForFunction( + ({ expectedOutcome, expectedStatus }) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + battle?.battleOutcome === expectedOutcome && + battle?.phase === 'resolved' && + battle?.resultVisible === true && + battle?.resultSettlement?.reportPersistence === + expectedStatus && + ( + expectedStatus !== 'failed' || + battle?.resultSettlement?.cta?.interactive === + true + ) + ); + }, + { + expectedOutcome: outcome, + expectedStatus: status + }, + { timeout: 90000 } + ); + return page.evaluate( + () => window.__HEROS_DEBUG__?.battle?.() + ); +} + +async function readCampaignStorage(page) { + return page.evaluate( + ({ currentKey, slotKey }) => { + const read = (key) => { + const raw = window.localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + }; + return { + current: read(currentKey), + slot: read(slotKey) + }; + }, + { + currentKey: currentCampaignKey, + slotKey: canonicalCampaignKey + } + ); +} + +function assertCampaignMirrorsEqual(save, label) { + assert(save.current, `${label}: current save is missing.`); + assert(save.slot, `${label}: slot save is missing.`); + assert.deepEqual( + save.current, + save.slot, + `${label}: current and canonical slot differ` + ); +} + +function campaignOutcomeProjection(state) { + return { + firstBattleReport: state?.firstBattleReport ?? null, + pendingAftermathBattleId: + state?.pendingAftermathBattleId ?? null, + storyCheckpoint: state?.storyCheckpoint ?? null, + storyCheckpointRevision: + state?.storyCheckpointRevision ?? null + }; +} + +function resultActionMap(battle) { + return Object.fromEntries( + (battle?.resultActions ?? []).map((action) => [ + action.kind, + action + ]) + ); +} + +function assertSingleSettlement( + baseline, + finalState, + expectedBattleId, + rendererLabel +) { + const report = finalState.firstBattleReport; + assert(report); + const orderRewardGold = report.sortieOrder?.rewardGranted + ? { + elite: 180, + mobile: 160, + siege: 200 + }[report.sortieOrder.orderId] + : 0; + assert.equal( + finalState.gold, + baseline.gold + report.rewardGold + orderRewardGold, + `${rendererLabel}: gold reflects more than one victory settlement` + ); + assert.deepEqual( + Object.keys(finalState.battleHistory), + [expectedBattleId], + `${rendererLabel}: the retry created duplicate battle-history state` + ); + assert.equal( + finalState.battleHistory[expectedBattleId]?.outcome, + 'victory' + ); + assert.equal( + new Set(finalState.claimedSortieOrderRewardIds).size, + finalState.claimedSortieOrderRewardIds.length, + `${rendererLabel}: sortie-order reward claims contain duplicates` + ); + assert.equal( + new Set(finalState.roster.map((unit) => unit.id)).size, + finalState.roster.length, + `${rendererLabel}: the retry duplicated roster rewards` + ); +} + +async function triggerResultContinueButton(page) { + const triggered = await page.evaluate(() => { + const scene = + window.__HEROS_DEBUG__?.scene( + 'BattleScene' + ); + const button = + scene?.resultContinueButton?.background; + if ( + !button?.active || + !button.input?.enabled + ) { + return false; + } + button.emit('pointerdown'); + return true; + }); + assert.equal( + triggered, + true, + `${renderer}: the visible persistence retry CTA could not be activated.` + ); +} + +async function clickBounds(page, bounds) { + assertFiniteBounds(bounds); + await page.mouse.click( + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2 + ); +} + +async function resetToFreshBattle( + page, + expectedRenderer, + expectedBattleId +) { + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await waitForTitle(page); + await assertDesktopRuntime(page, expectedRenderer); + await openBattle(page, expectedBattleId); + await waitForBattleReady(page, expectedBattleId); +} + +async function installRecommendationFixture( + page, + expectedBattleId +) { + return page.evaluate((requestedBattleId) => { + const scene = + window.__HEROS_DEBUG__?.scene('BattleScene'); + const battle = + window.__HEROS_DEBUG__?.battle?.(); + if (!scene || battle?.battleId !== requestedBattleId) { + throw new Error( + 'BattleScene is unavailable for the defeat recommendation fixture.' + ); + } + const selectedUnitIds = battle.units + .filter((unit) => unit.faction === 'ally') + .map((unit) => unit.id); + const fallbackRoles = [ + 'front', + 'flank', + 'support', + 'reserve' + ]; + const formationAssignments = Object.fromEntries( + selectedUnitIds.map((unitId, index) => [ + unitId, + battle.sortieFormationAssignments?.[unitId] ?? + fallbackRoles[index] ?? + 'reserve' + ]) + ); + scene.launchSortieUnitIds = [...selectedUnitIds]; + scene.launchSortieFormationAssignments = { + ...formationAssignments + }; + scene.launchSortieOrderId = 'elite'; + scene.launchSortieRecommendation = { + version: 1, + battleId: requestedBattleId, + planId: 'order', + label: '저장 실패 회귀 추천안', + summary: + '패배 결과의 편성 보완 이동 잠금을 검증합니다.', + sortieOrderId: 'elite', + selectedUnitIds: [...selectedUnitIds], + formationAssignments: { + ...formationAssignments + }, + unitReasons: Object.fromEntries( + selectedUnitIds.map((unitId) => [ + unitId, + '패배 결과 저장 경계조건 검증' + ]) + ) + }; + scene.saveBattleState?.(1); + return { + selectedUnitIds, + formationAssignments + }; + }, expectedBattleId); +} + +async function readDefeatFailureProbe(page) { + return page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + battleOutcome: + window.__HEROS_DEBUG__?.battle?.()?.battleOutcome ?? + null, + reportPersistence: + window.__HEROS_DEBUG__?.battle?.() + ?.resultSettlement?.reportPersistence ?? null, + campaignCurrent: + window.localStorage.getItem( + 'heros-web:campaign-state' + ), + campaignSlot: + window.localStorage.getItem( + 'heros-web:campaign-state:slot-1' + ), + failureStats: { + canonicalAttempts: + window.__HEROS_CANONICAL_FAILURE_STATS__ + ?.canonicalAttempts ?? null, + injectedFailures: + window.__HEROS_CANONICAL_FAILURE_STATS__ + ?.injectedFailures ?? null + } + })); +} + +async function openResultImprovementProbe( + page, + outcome +) { + return page.evaluate((requestedOutcome) => { + const scene = + window.__HEROS_DEBUG__?.scene('BattleScene'); + scene?.showResultFormationReview?.( + requestedOutcome + ); + const debug = + window.__HEROS_DEBUG__?.battle?.(); + const action = + debug?.sortieEvaluation?.improvementAction; + const button = + scene?.resultFormationReviewView + ?.improvementButton; + const bounds = button?.getBounds?.(); + return { + available: action?.available === true, + interactive: Boolean(button?.input?.enabled), + bounds: bounds + ? { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + } + : null + }; + }, outcome); +} + +async function closeResultImprovementProbe(page) { + await page.evaluate(() => { + window.__HEROS_DEBUG__ + ?.scene('BattleScene') + ?.hideResultFormationReview?.(); + }); +} + +async function triggerResultImprovementButton(page) { + const triggered = await page.evaluate(() => { + const scene = + window.__HEROS_DEBUG__ + ?.scene('BattleScene'); + const button = + scene?.resultFormationReviewView + ?.improvementButton; + if ( + !button?.active || + !button.input?.enabled + ) { + return false; + } + button.emit('pointerdown'); + return true; + }); + assert.equal( + triggered, + true, + `${renderer}: enabled result improvement action could not be activated` + ); +} + +async function completeVisibleResultSettlement(page) { + await page.evaluate(() => { + window.__HEROS_DEBUG__ + ?.scene('BattleScene') + ?.completeResultSettlement?.(true); + }); +} + +async function waitForActiveScene(page, sceneKey) { + await page.waitForFunction( + (requestedSceneKey) => + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes(requestedSceneKey), + sceneKey, + { timeout: 90000 } + ); +} + +async function completeActiveStoryToCamp(page) { + for (let attempt = 0; attempt < 80; attempt += 1) { + const state = await page.evaluate(() => { + const activeScenes = + window.__HEROS_DEBUG__ + ?.activeScenes?.() ?? []; + if (activeScenes.includes('CampScene')) { + return 'camp'; + } + const story = + window.__HEROS_DEBUG__ + ?.scene('StoryScene'); + if ( + activeScenes.includes('StoryScene') && + story && + !story.transitioning + ) { + story.advance?.(); + } + return activeScenes.includes('StoryScene') + ? 'story' + : 'other'; + }); + if (state === 'camp') { + return; + } + await page.waitForTimeout(250); + } + throw new Error( + `${renderer}: aftermath story did not reach CampScene` + ); +} + +async function waitForCamp(page) { + await page.waitForFunction( + () => { + const activeScenes = + window.__HEROS_DEBUG__ + ?.activeScenes?.() ?? []; + const camp = + window.__HEROS_DEBUG__?.camp?.(); + return ( + activeScenes.includes('CampScene') && + camp?.scene === 'CampScene' + ); + }, + undefined, + { timeout: 90000 } + ); +} + +async function reloadToTitle(page, expectedRenderer) { + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await waitForTitle(page); + await assertDesktopRuntime( + page, + expectedRenderer + ); +} + +async function triggerTitleContinue(page) { + const triggered = await page.evaluate(() => { + const title = + window.__HEROS_DEBUG__ + ?.scene('TitleScene'); + if (!title) { + return false; + } + title.requestContinueGame?.(); + return true; + }); + assert.equal( + triggered, + true, + `${renderer}: title Continue could not be activated` + ); +} + +async function waitForCampImprovement( + page, + { mode, targetBattleId } +) { + await page.waitForFunction( + ({ expectedMode, expectedTargetBattleId }) => { + const activeScenes = + window.__HEROS_DEBUG__ + ?.activeScenes?.() ?? []; + const camp = + window.__HEROS_DEBUG__?.camp?.(); + const checkpoint = + camp?.campaign + ?.sortiePreparationCheckpoint; + return ( + activeScenes.includes('CampScene') && + !activeScenes.includes('BattleScene') && + camp?.scene === 'CampScene' && + camp?.sortieVisible === true && + checkpoint?.intent === + 'sortie-improvement' && + checkpoint?.mode === expectedMode && + checkpoint?.targetBattleId === + expectedTargetBattleId + ); + }, + { + expectedMode: mode, + expectedTargetBattleId: targetBattleId + }, + { timeout: 90000 } + ); + return page.evaluate(() => ({ + ...window.__HEROS_DEBUG__?.camp?.(), + activeScenes: + window.__HEROS_DEBUG__ + ?.activeScenes?.() ?? [] + })); +} + +async function readBattleSaveKeys(page) { + return page.evaluate(() => + Array.from( + { length: window.localStorage.length }, + (_, index) => + window.localStorage.key(index) + ) + .filter( + (key) => + typeof key === 'string' && + key.startsWith('heros-web:battle') + ) + .sort() + ); +} + +async function launchPreparedBattle(page) { + const launched = await page.evaluate(() => { + const camp = + window.__HEROS_DEBUG__ + ?.scene('CampScene'); + const checkpoint = + window.__HEROS_DEBUG__ + ?.camp?.()?.campaign + ?.sortiePreparationCheckpoint; + if (!camp || !checkpoint) { + return false; + } + camp.startCampNavigationWithCampaignStep?.( + checkpoint.targetCampaignStep, + 'BattleScene', + { + battleId: + checkpoint.targetBattleId, + selectedSortieUnitIds: [ + ...(camp.selectedSortieUnitIds ?? []) + ], + sortieFormationAssignments: { + ...(camp.sortieFormationAssignments ?? {}) + }, + sortieItemAssignments: + JSON.parse( + JSON.stringify( + camp.sortieItemAssignments ?? {} + ) + ), + sortieOrderId: + camp.currentSortieOrderId?.(), + sortieResonanceBondId: + camp.currentSortieResonanceBondId?.(), + sortieRecommendation: + camp.currentSortieRecommendationSelection?.() + } + ); + return true; + }); + assert.equal( + launched, + true, + `${renderer}: prepared battle launch could not start` + ); +} + +function assertPreparationCheckpoint( + state, + expected, + label +) { + const checkpoint = + state?.sortiePreparationCheckpoint; + assert( + checkpoint, + `${label}: missing sortie-preparation checkpoint` + ); + assert.deepEqual( + { + intent: checkpoint.intent, + mode: checkpoint.mode, + sourceBattleId: + checkpoint.sourceBattleId, + targetBattleId: + checkpoint.targetBattleId, + targetCampaignStep: + checkpoint.targetCampaignStep + }, + { + intent: 'sortie-improvement', + ...expected + }, + `${label}: restored the wrong preparation destination` + ); +} + +async function assertDesktopRuntime(page, expectedRenderer) { + const runtime = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + return { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + visualViewportScale: + window.visualViewport?.scale ?? 1, + rootCssZoom: + getComputedStyle(document.documentElement).zoom, + rendererType: window.__HEROS_GAME__?.renderer.type, + canvas: canvas + ? { + width: canvas.width, + height: canvas.height, + clientWidth: canvas.clientWidth, + clientHeight: canvas.clientHeight, + bounds: bounds + ? { + width: bounds.width, + height: bounds.height + } + : null + } + : null + }; + }); + assert.equal( + runtime.innerWidth, + desktopBrowserViewport.width + ); + assert.equal( + runtime.innerHeight, + desktopBrowserViewport.height + ); + assert.equal( + runtime.devicePixelRatio, + desktopBrowserDeviceScaleFactor + ); + assert.equal(runtime.visualViewportScale, 1); + assert( + runtime.rootCssZoom === '1' || + runtime.rootCssZoom === 'normal', + `Unexpected CSS zoom ${runtime.rootCssZoom}.` + ); + assert.equal( + runtime.rendererType, + expectedRenderer === 'canvas' ? 1 : 2 + ); + assert.equal( + runtime.canvas?.width, + desktopBrowserViewport.width + ); + assert.equal( + runtime.canvas?.height, + desktopBrowserViewport.height + ); + assert.equal( + runtime.canvas?.clientWidth, + desktopBrowserViewport.width + ); + assert.equal( + runtime.canvas?.clientHeight, + desktopBrowserViewport.height + ); + assert.equal( + runtime.canvas?.bounds?.width, + desktopBrowserViewport.width + ); + assert.equal( + runtime.canvas?.bounds?.height, + desktopBrowserViewport.height + ); +} + +function assertFiniteBounds(bounds) { + assert(bounds, 'Interactive bounds are required.'); + for (const value of [ + bounds.x, + bounds.y, + bounds.width, + bounds.height + ]) { + assert(Number.isFinite(value)); + } + assert(bounds.width > 0); + assert(bounds.height > 0); +} + +function assertBoundsInsideViewport(bounds) { + assert( + bounds.x >= 0 && + bounds.y >= 0 && + bounds.x + bounds.width <= + desktopBrowserViewport.width && + bounds.y + bounds.height <= + desktopBrowserViewport.height, + `Bounds are outside the FHD viewport: ${JSON.stringify( + bounds + )}` + ); +} diff --git a/scripts/verify-battle-result-persistence-retry.mjs b/scripts/verify-battle-result-persistence-retry.mjs new file mode 100644 index 0000000..260e5e0 --- /dev/null +++ b/scripts/verify-battle-result-persistence-retry.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const [battleSource, campaignSource] = await Promise.all([ + readFile( + new URL( + '../src/game/scenes/BattleScene.ts', + import.meta.url + ), + 'utf8' + ), + readFile( + new URL( + '../src/game/state/campaignState.ts', + import.meta.url + ), + 'utf8' + ) +]); + +const publishStart = battleSource.indexOf( + 'private publishFirstBattleReport(' +); +const publishEnd = battleSource.indexOf( + '\n private resultSubtitle(', + publishStart +); +assert( + publishStart >= 0 && publishEnd > publishStart, + 'BattleScene must retain a bounded result-persistence method.' +); +const publishSource = battleSource.slice( + publishStart, + publishEnd +); +assert( + publishSource.includes( + 'persistedReport = setFirstBattleReport(' + ), + 'BattleScene must persist its complete battle report through the atomic campaign writer.' +); +assert( + publishSource.includes( + "this.resultReportPersistenceStatus =\n 'failed';" + ) && + publishSource.includes('return false;'), + 'A failed campaign write must leave the result screen in an explicit retry state.' +); +assert( + publishSource.includes( + "this.resultReportPersistenceStatus =\n 'complete';" + ) && + publishSource.includes('return true;'), + 'A successful campaign write must mark the result report complete.' +); + +const continueStart = battleSource.indexOf( + 'private handleResultContinue()' +); +const continueEnd = battleSource.indexOf( + '\n private hideBattleResult(', + continueStart +); +assert( + continueStart >= 0 && continueEnd > continueStart, + 'BattleScene must retain a bounded result continuation handler.' +); +const continueSource = battleSource.slice( + continueStart, + continueEnd +); +assert( + continueSource.includes( + "this.resultReportPersistenceStatus ===\n 'failed'" + ) && + continueSource.includes( + '!this.publishFirstBattleReport(outcome)' + ) && + continueSource.includes( + 'this.showBattleResult(outcome);' + ), + 'The result CTA must retry persistence in place and redraw the result only after recovery.' +); +assert( + battleSource.includes("return '저장 다시 시도';"), + 'The failed result CTA must clearly say “저장 다시 시도”.' +); +assert( + battleSource.includes( + "outcome === 'victory' ||\n this.resultReportPersistenceStatus === 'failed'" + ), + 'Defeat results with an unsaved report must also expose the persistence retry CTA.' +); +assert( + battleSource.includes( + 'const retryButton = this.addResultButton(' + ) && + battleSource.includes( + 'const titleButton = this.addResultButton(' + ) && + battleSource.includes( + 'this.setResultButtonEnabled(\n retryButton,\n false' + ) && + battleSource.includes( + 'this.setResultButtonEnabled(\n titleButton,\n false' + ), + 'Normal retry and title navigation must be visibly disabled while result persistence has failed.' +); +assert( + battleSource.includes( + 'this.resultNavigationPending ||\n this.blockResultNavigationWhilePersistenceFailed()' + ), + 'All result navigation must retain a persistence-failure guard even if a disabled button is invoked indirectly.' +); +assert( + battleSource.includes( + "const improvementEnabled =\n this.resultReportPersistenceStatus !==\n 'failed';" + ) && + battleSource.includes( + "improvementEnabled,\n false,\n () => this.openResultSortieImprovement(outcome)" + ), + 'Result-screen formation improvement must remain disabled until the battle report is durable.' +); +assert( + battleSource.includes( + 'reportPersistence:\n this.resultReportPersistenceStatus' + ), + 'The result persistence state must remain observable to the browser regression.' +); + +const persistenceStart = campaignSource.indexOf( + 'function persistCampaignState(' +); +const persistenceEnd = campaignSource.indexOf( + '\nfunction tryStorage()', + persistenceStart +); +assert( + persistenceStart >= 0 && + persistenceEnd > persistenceStart, + 'Campaign persistence must retain a bounded writer.' +); +const persistenceSource = campaignSource.slice( + persistenceStart, + persistenceEnd +); +const canonicalWrite = persistenceSource.indexOf( + 'campaignSaveSlotKey(state.activeSaveSlot)' +); +const compatibilityWrite = persistenceSource.indexOf( + 'storage.setItem(campaignStorageKey, serialized)' +); +assert( + canonicalWrite >= 0 && + compatibilityWrite > canonicalWrite, + 'The canonical campaign slot must be written before the compatibility mirror.' +); + +console.log( + 'Verified battle-result persistence failure/retry UI, debug observability, and canonical-slot-first atomic write contract.' +); diff --git a/scripts/verify-battle-save-generation-browser.mjs b/scripts/verify-battle-save-generation-browser.mjs new file mode 100644 index 0000000..6f2c62c --- /dev/null +++ b/scripts/verify-battle-save-generation-browser.mjs @@ -0,0 +1,622 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; +import { + startVerificationPreview, + stopVerificationPreview +} from './verification-preview-server.mjs'; + +const renderers = ['canvas', 'webgl']; +const renderer = process.env.VERIFY_BATTLE_SAVE_GENERATION_RENDERER; + +if (!renderer) { + for (const requestedRenderer of renderers) { + const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], { + cwd: process.cwd(), + env: { + ...process.env, + VERIFY_BATTLE_SAVE_GENERATION_RENDERER: requestedRenderer + }, + stdio: 'inherit' + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } + process.exit(0); +} + +assert( + renderers.includes(renderer), + `Unsupported battle-save generation renderer "${renderer}".` +); + +const battleId = 'first-battle-zhuo-commandery'; +const campaignStep = 'first-battle'; +const campaignGeneration = 4; +const staleGeneration = 3; +const campaignKey = 'heros-web:campaign-state'; +const campaignSlotKey = `${campaignKey}:slot-1`; +const battleBaseKey = `heros-web:battle:${battleId}`; +const battleSlotKey = `${battleBaseKey}:slot-1`; +const legacyBattleKey = 'heros-web:first-battle-state'; +const battleStorageKeys = [battleSlotKey, battleBaseKey, legacyBattleKey]; +const baseUrl = + process.env.VERIFY_BATTLE_SAVE_GENERATION_URL ?? + `http://127.0.0.1:${renderer === 'canvas' ? 41825 : 41826}/heros_web/`; +const targetUrl = withDebugOptions(baseUrl, renderer); + +let browser; +let serverProcess; + +try { + serverProcess = await startVerificationPreview({ + targetUrl, + explicitUrl: Boolean( + process.env.VERIFY_BATTLE_SAVE_GENERATION_URL + ), + label: + `${renderer} battle save generation verification` + }); + browser = await chromium.launch({ + headless: process.env.VERIFY_BATTLE_SAVE_GENERATION_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); + + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + await page.goto(targetUrl, { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForTitle(page); + await assertDesktopRuntime(page, renderer); + + await page.evaluate(async (requestedBattleId) => { + await window.__HEROS_DEBUG__?.goToBattle(requestedBattleId); + }, battleId); + await waitForBattleReady(page); + + const fixture = await page.evaluate( + ({ + requestedBattleId, + requestedCampaignStep, + requestedCampaignGeneration, + requestedStaleGeneration, + requestedCampaignKey, + requestedCampaignSlotKey, + requestedBattleBaseKey, + requestedBattleSlotKey, + requestedLegacyBattleKey, + requestedBattleStorageKeys + }) => { + const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); + if ( + !scene || + typeof scene.createBattleSaveState !== 'function' || + typeof scene.saveBattleState !== 'function' + ) { + throw new Error('BattleScene save internals are unavailable.'); + } + + // Persist a normalized campaign fixture and a fully valid battle-save + // shape through the production writer before applying generation markers. + scene.saveBattleState(1); + const campaignRaw = + window.localStorage.getItem(requestedCampaignSlotKey) ?? + window.localStorage.getItem(requestedCampaignKey); + const battleRaw = + window.localStorage.getItem(requestedBattleSlotKey) ?? + window.localStorage.getItem(requestedBattleBaseKey); + if (!campaignRaw || !battleRaw) { + throw new Error('The production save writer did not create the browser fixture.'); + } + + const campaign = JSON.parse(campaignRaw); + campaign.step = requestedCampaignStep; + campaign.activeSaveSlot = 1; + campaign.battleResumeGeneration = requestedCampaignGeneration; + campaign.storyCheckpoint = undefined; + campaign.explorationCheckpoint = undefined; + campaign.updatedAt = new Date().toISOString(); + const serializedCampaign = JSON.stringify(campaign); + window.localStorage.setItem(requestedCampaignSlotKey, serializedCampaign); + window.localStorage.setItem(requestedCampaignKey, serializedCampaign); + + const baseline = JSON.parse(battleRaw); + const markerIndex = baseline.units.findIndex((unit) => unit.id === 'liu-bei'); + if (markerIndex < 0) { + throw new Error('The first-battle Liu Bei save fixture is unavailable.'); + } + const baselineMarkerHp = baseline.units[markerIndex].hp; + const markerMaxHp = baseline.units[markerIndex].maxHp; + const staleMarkerHp = Math.max(1, Math.min(markerMaxHp, baselineMarkerHp - 7)); + const validMarkerHp = Math.max(1, Math.min(markerMaxHp, baselineMarkerHp - 3)); + if ( + staleMarkerHp === baselineMarkerHp || + validMarkerHp === baselineMarkerHp || + staleMarkerHp === validMarkerHp + ) { + throw new Error( + `Battle marker HP values are not distinguishable: ${JSON.stringify({ + baselineMarkerHp, + staleMarkerHp, + validMarkerHp + })}` + ); + } + + const stale = structuredClone(baseline); + stale.battleId = requestedBattleId; + stale.campaignStep = requestedCampaignStep; + stale.battleResumeGeneration = requestedStaleGeneration; + stale.turnNumber = 8; + stale.activeFaction = 'ally'; + stale.savedAt = new Date(Date.now() - 60_000).toISOString(); + stale.units[markerIndex].hp = staleMarkerHp; + + const valid = structuredClone(baseline); + valid.battleId = requestedBattleId; + valid.campaignStep = requestedCampaignStep; + valid.battleResumeGeneration = requestedCampaignGeneration; + valid.turnNumber = 6; + valid.activeFaction = 'ally'; + valid.savedAt = new Date().toISOString(); + valid.units[markerIndex].hp = validMarkerHp; + + window.localStorage.removeItem(requestedBattleBaseKey); + window.localStorage.removeItem(requestedLegacyBattleKey); + window.localStorage.setItem(requestedBattleSlotKey, JSON.stringify(stale)); + + const storagePrototype = Object.getPrototypeOf(window.localStorage); + const originalRemoveItem = storagePrototype.removeItem; + const blockedKeys = new Set(requestedBattleStorageKeys); + const removalStats = { + attempts: 0, + blocked: 0, + keys: [] + }; + storagePrototype.removeItem = function removeItem(key) { + const normalizedKey = String(key); + if (this === window.localStorage && blockedKeys.has(normalizedKey)) { + removalStats.attempts += 1; + removalStats.blocked += 1; + removalStats.keys.push(normalizedKey); + throw new DOMException( + 'Injected battle-save removal refusal', + 'InvalidStateError' + ); + } + return originalRemoveItem.call(this, key); + }; + + Object.defineProperty(window, '__HEROS_BATTLE_GENERATION_FIXTURE__', { + configurable: true, + value: { + baselineMarkerHp, + staleMarkerHp, + validMarkerHp, + valid, + removalStats + } + }); + + scene.scene.restart({ + battleId: requestedBattleId, + resumeBattleSaveSlot: 1 + }); + + return { + baselineMarkerHp, + staleMarkerHp, + validMarkerHp, + staleTurnNumber: stale.turnNumber, + validTurnNumber: valid.turnNumber + }; + }, + { + requestedBattleId: battleId, + requestedCampaignStep: campaignStep, + requestedCampaignGeneration: campaignGeneration, + requestedStaleGeneration: staleGeneration, + requestedCampaignKey: campaignKey, + requestedCampaignSlotKey: campaignSlotKey, + requestedBattleBaseKey: battleBaseKey, + requestedBattleSlotKey: battleSlotKey, + requestedLegacyBattleKey: legacyBattleKey, + requestedBattleStorageKeys: battleStorageKeys + } + ); + + await page.waitForFunction( + ({ expectedBattleId, expectedBaselineHp }) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + const fixtureState = window.__HEROS_BATTLE_GENERATION_FIXTURE__; + return ( + battle?.battleId === expectedBattleId && + battle?.mapBackgroundReady === true && + ['deployment', 'idle'].includes(battle?.phase) && + battle?.turnNumber === 1 && + battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp === + expectedBaselineHp && + fixtureState?.removalStats?.blocked >= 1 + ); + }, + { + expectedBattleId: battleId, + expectedBaselineHp: fixture.baselineMarkerHp + }, + { timeout: 90000 } + ); + + const staleProbe = await page.evaluate( + ({ + requestedBattleSlotKey, + requestedStaleGeneration + }) => { + const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); + if (!scene) { + throw new Error('BattleScene is unavailable after stale resume rejection.'); + } + const rejected = + scene.readBattleSaveState?.(1, true) === undefined; + const loadDisabled = + scene.isMapMenuActionDisabled?.('load') === true; + scene.showSaveSlotPanel?.('load'); + const panelBeforeSelection = + window.__HEROS_DEBUG__?.battle?.(); + scene.activateSaveSlotPanelSlot?.(1); + const panelAfterSelection = + window.__HEROS_DEBUG__?.battle?.(); + scene.hideSaveSlotPanel?.(); + + const persisted = JSON.parse( + window.localStorage.getItem(requestedBattleSlotKey) ?? 'null' + ); + const fixtureState = + window.__HEROS_BATTLE_GENERATION_FIXTURE__; + return { + rejected, + loadDisabled, + panelMode: + panelBeforeSelection?.saveSlotPanelMode ?? null, + panelStayedOpen: + panelAfterSelection?.saveSlotPanelVisible === true, + promptMode: + panelAfterSelection?.turnPromptMode ?? null, + persistedGeneration: + persisted?.battleResumeGeneration ?? null, + removalStats: { + attempts: + fixtureState?.removalStats?.attempts ?? 0, + blocked: + fixtureState?.removalStats?.blocked ?? 0, + keys: [ + ...(fixtureState?.removalStats?.keys ?? []) + ] + } + }; + }, + { + requestedBattleSlotKey: battleSlotKey, + requestedStaleGeneration: staleGeneration + } + ); + + assert.equal( + staleProbe.rejected, + true, + `${renderer}: BattleScene accepted a stale generation.` + ); + assert.equal( + staleProbe.loadDisabled, + true, + `${renderer}: manual Load remained enabled for a stale save.` + ); + assert.equal(staleProbe.panelMode, 'load'); + assert.equal( + staleProbe.panelStayedOpen, + true, + `${renderer}: selecting a stale Load row should be a no-op.` + ); + assert.equal( + staleProbe.promptMode, + null, + `${renderer}: a stale save reached the Load confirmation prompt.` + ); + assert.equal( + staleProbe.persistedGeneration, + staleGeneration, + `${renderer}: the removal-refusal fixture was not preserved.` + ); + assert( + staleProbe.removalStats.blocked >= 1, + `${renderer}: no stale-save cleanup refusal was exercised.` + ); + assert( + staleProbe.removalStats.keys.every((key) => + battleStorageKeys.includes(key) + ), + `${renderer}: removal refusal escaped the intended battle keys.` + ); + + const beforeControl = await page.evaluate( + ({ + requestedBattleSlotKey, + requestedCampaignGeneration + }) => { + const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); + const fixtureState = + window.__HEROS_BATTLE_GENERATION_FIXTURE__; + if (!scene || !fixtureState?.valid) { + throw new Error('The current-generation control fixture is unavailable.'); + } + + window.localStorage.setItem( + requestedBattleSlotKey, + JSON.stringify(fixtureState.valid) + ); + const accepted = scene.readBattleSaveState?.(1, true); + const loadDisabled = + scene.isMapMenuActionDisabled?.('load') === true; + scene.showSaveSlotPanel?.('load'); + const panel = + window.__HEROS_DEBUG__?.battle?.(); + scene.activateSaveSlotPanelSlot?.(1); + const confirmation = + window.__HEROS_DEBUG__?.battle?.(); + + return { + acceptedGeneration: + accepted?.battleResumeGeneration ?? null, + acceptedTurnNumber: + accepted?.turnNumber ?? null, + loadDisabled, + panelMode: panel?.saveSlotPanelMode ?? null, + confirmationMode: + confirmation?.turnPromptMode ?? null, + confirmationVisible: + confirmation?.turnPromptVisible === true, + expectedGeneration: requestedCampaignGeneration + }; + }, + { + requestedBattleSlotKey: battleSlotKey, + requestedCampaignGeneration: campaignGeneration + } + ); + + assert.equal( + beforeControl.acceptedGeneration, + campaignGeneration, + `${renderer}: the current-generation control was not accepted.` + ); + assert.equal( + beforeControl.acceptedTurnNumber, + fixture.validTurnNumber + ); + assert.equal(beforeControl.loadDisabled, false); + assert.equal(beforeControl.panelMode, 'load'); + assert.equal(beforeControl.confirmationMode, 'load-confirm'); + assert.equal(beforeControl.confirmationVisible, true); + + await page.evaluate(() => { + const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); + scene?.activateTurnPromptPrimaryAction?.(); + }); + await page.waitForFunction( + ({ + expectedBattleId, + expectedTurnNumber, + expectedMarkerHp + }) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + battle?.battleId === expectedBattleId && + battle?.turnNumber === expectedTurnNumber && + battle?.activeFaction === 'ally' && + battle?.phase === 'idle' && + battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp === + expectedMarkerHp + ); + }, + { + expectedBattleId: battleId, + expectedTurnNumber: fixture.validTurnNumber, + expectedMarkerHp: fixture.validMarkerHp + }, + { timeout: 90000 } + ); + + const restored = await page.evaluate( + ({ + requestedCampaignKey, + requestedCampaignSlotKey + }) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + const current = JSON.parse( + window.localStorage.getItem(requestedCampaignKey) ?? 'null' + ); + const slot = JSON.parse( + window.localStorage.getItem(requestedCampaignSlotKey) ?? 'null' + ); + return { + turnNumber: battle?.turnNumber ?? null, + activeFaction: battle?.activeFaction ?? null, + markerHp: + battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ?? null, + campaignGeneration: + current?.battleResumeGeneration ?? null, + slotGeneration: + slot?.battleResumeGeneration ?? null, + campaignStep: current?.step ?? null, + slotStep: slot?.step ?? null + }; + }, + { + requestedCampaignKey: campaignKey, + requestedCampaignSlotKey: campaignSlotKey + } + ); + + assert.equal(restored.turnNumber, fixture.validTurnNumber); + assert.equal(restored.activeFaction, 'ally'); + assert.equal(restored.markerHp, fixture.validMarkerHp); + assert.equal(restored.campaignGeneration, campaignGeneration); + assert.equal(restored.slotGeneration, campaignGeneration); + assert.equal(restored.campaignStep, campaignStep); + assert.equal(restored.slotStep, campaignStep); + assert.equal(pageErrors.length, 0, pageErrors.join('\n')); + assert.equal(consoleErrors.length, 0, consoleErrors.join('\n')); + + console.log( + JSON.stringify( + { + renderer, + viewport: desktopBrowserViewport, + deviceScaleFactor: desktopBrowserDeviceScaleFactor, + cssZoom: 1, + stale: { + campaignGeneration, + saveGeneration: staleGeneration, + requestedTurnNumber: fixture.staleTurnNumber, + liveTurnNumber: 1, + loadDisabled: staleProbe.loadDisabled, + removalRefusal: staleProbe.removalStats + }, + control: { + campaignGeneration, + saveGeneration: beforeControl.acceptedGeneration, + restoredTurnNumber: restored.turnNumber, + restoredMarkerHp: restored.markerHp + }, + pageErrors: pageErrors.length, + consoleErrors: consoleErrors.length + }, + null, + 2 + ) + ); +} finally { + await browser?.close(); + await stopVerificationPreview(serverProcess); +} + +function withDebugOptions(url, requestedRenderer) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('renderer', requestedRenderer); + parsed.searchParams.set('motion', 'reduced'); + return parsed.toString(); +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + Boolean( + document.querySelector('canvas') && + window.__HEROS_GAME__ && + window.__HEROS_DEBUG__ + ), + undefined, + { timeout: 90000 } + ); +} + +async function waitForTitle(page) { + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'), + undefined, + { timeout: 90000 } + ); +} + +async function waitForBattleReady(page) { + await page.waitForFunction( + (expectedBattleId) => { + const battle = window.__HEROS_DEBUG__?.battle?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes('BattleScene') && + battle?.scene === 'BattleScene' && + battle?.battleId === expectedBattleId && + battle?.battleOutcome === null && + ['deployment', 'idle'].includes(battle?.phase) && + battle?.mapBackgroundReady === true && + battle?.resultVisible === false + ); + }, + battleId, + { timeout: 90000 } + ); +} + +async function assertDesktopRuntime(page, expectedRenderer) { + const runtime = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + return { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + visualViewportScale: window.visualViewport?.scale ?? 1, + rootCssZoom: getComputedStyle(document.documentElement).zoom, + rendererType: window.__HEROS_GAME__?.renderer.type, + canvas: canvas + ? { + width: canvas.width, + height: canvas.height, + clientWidth: canvas.clientWidth, + clientHeight: canvas.clientHeight, + bounds: bounds + ? { + width: bounds.width, + height: bounds.height + } + : null + } + : null + }; + }); + + assert.equal(runtime.innerWidth, desktopBrowserViewport.width); + assert.equal(runtime.innerHeight, desktopBrowserViewport.height); + assert.equal(runtime.devicePixelRatio, desktopBrowserDeviceScaleFactor); + assert.equal(runtime.visualViewportScale, 1); + assert( + runtime.rootCssZoom === '1' || runtime.rootCssZoom === 'normal', + `Unexpected CSS zoom ${runtime.rootCssZoom}.` + ); + assert.equal(runtime.rendererType, expectedRenderer === 'canvas' ? 1 : 2); + assert.equal(runtime.canvas?.width, desktopBrowserViewport.width); + assert.equal(runtime.canvas?.height, desktopBrowserViewport.height); + assert.equal(runtime.canvas?.clientWidth, desktopBrowserViewport.width); + assert.equal(runtime.canvas?.clientHeight, desktopBrowserViewport.height); + assert.equal(runtime.canvas?.bounds?.width, desktopBrowserViewport.width); + assert.equal(runtime.canvas?.bounds?.height, desktopBrowserViewport.height); +} diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index d925ecd..72c5012 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -90,6 +90,38 @@ try { isValidBattleSaveState(legacyStateWithoutSortieOrder, options), 'Expected legacy battle saves without a sortie order to remain valid.' ); + const generatedBattleSave = { + ...validState, + battleResumeGeneration: 7 + }; + assert( + isValidBattleSaveState( + generatedBattleSave, + options + ) && + parseBattleSaveState( + JSON.stringify(generatedBattleSave), + options + )?.battleResumeGeneration === 7, + 'Expected a non-negative battle-resume generation to round-trip.' + ); + assert( + !isValidBattleSaveState( + { + ...validState, + battleResumeGeneration: -1 + }, + options + ) && + !isValidBattleSaveState( + { + ...validState, + battleResumeGeneration: 1.5 + }, + options + ), + 'Expected invalid battle-resume generations to be rejected.' + ); const validPendingBattleEventState = { ...validState, pendingBattleEvents: [ diff --git a/scripts/verify-battle-save-resume-routing.mjs b/scripts/verify-battle-save-resume-routing.mjs index 4965f52..8f318c2 100644 --- a/scripts/verify-battle-save-resume-routing.mjs +++ b/scripts/verify-battle-save-resume-routing.mjs @@ -22,7 +22,12 @@ try { readBattleSaveStorageCandidate, readCampaignBattleResume } = await server.ssrLoadModule('/src/game/state/battleSaveStorage.ts'); - const { resetCampaignState, startNewCampaign } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); + const { + createInitialCampaignState, + resetCampaignState, + setCampaignState, + startNewCampaign + } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const firstBattleId = 'first-battle-zhuo-commandery'; const campaign = { step: 'first-battle', activeSaveSlot: 2 }; @@ -39,6 +44,79 @@ try { `Expected a matching slotted battle save to become a direct-resume candidate: ${JSON.stringify(directResume)}` ); + const generationCampaign = { + ...campaign, + battleResumeGeneration: 4 + }; + storage.setItem( + slotKey, + JSON.stringify( + createBattleSave({ + battleResumeGeneration: 3 + }) + ) + ); + assert( + readCampaignBattleResume( + generationCampaign, + 2, + storage + ) === undefined && + storage.getItem(slotKey) === null, + 'A superseded battle-save generation must be rejected and cleaned.' + ); + storage.setItem( + slotKey, + JSON.stringify( + createBattleSave({ + battleResumeGeneration: 4, + turnNumber: 6 + }) + ) + ); + assert( + readCampaignBattleResume( + generationCampaign, + 2, + storage + )?.turnNumber === 6, + 'A battle save from the current generation must remain resumable.' + ); + + const stickyStorage = createStorage({ + refuseRemoval: true + }); + stickyStorage.setItem( + slotKey, + JSON.stringify( + createBattleSave({ + battleResumeGeneration: 3 + }) + ) + ); + assert( + readCampaignBattleResume( + generationCampaign, + 2, + stickyStorage + ) === undefined && + stickyStorage.getItem(slotKey) !== null, + 'A generation mismatch must remain logically blocked when storage refuses physical deletion.' + ); + + storage.setItem( + slotKey, + JSON.stringify(createBattleSave()) + ); + assert( + readCampaignBattleResume( + generationCampaign, + 2, + storage + ) === undefined, + 'A legacy generation-zero battle save must not cross a later battle launch generation.' + ); + storage.setItem(slotKey, '{broken-json'); assert(readCampaignBattleResume(campaign, 2, storage) === undefined, 'Broken JSON must not become a resume candidate.'); assert(storage.getItem(slotKey) === null, 'Broken slotted battle saves must be removed after validation.'); @@ -50,6 +128,50 @@ try { const slotOneCampaign = { step: 'first-battle', activeSaveSlot: 1 }; const slotOneKey = battleSaveStorageSlotKey(firstBattleId, 1); const baseKey = battleSaveStorageBaseKey(firstBattleId); + const generationFilteredStorage = createStorage({ + refuseRemoval: true + }); + generationFilteredStorage.setItem( + slotOneKey, + JSON.stringify( + createBattleSave({ + battleResumeGeneration: 3, + turnNumber: 8 + }) + ) + ); + generationFilteredStorage.setItem( + baseKey, + JSON.stringify( + createBattleSave({ + battleResumeGeneration: 4, + turnNumber: 5 + }) + ) + ); + const generationFilteredCandidate = + readBattleSaveStorageCandidate( + firstBattleId, + 'first-battle', + 1, + generationFilteredStorage, + 3, + {}, + { + ...slotOneCampaign, + battleResumeGeneration: 4 + } + ); + assert( + generationFilteredCandidate + ?.storageKey === baseKey && + generationFilteredCandidate.state + .turnNumber === 5 && + generationFilteredStorage.getItem( + slotOneKey + ) !== null, + `A direct BattleScene-style read must reject a stale generation and continue to a current fallback even when physical deletion is refused: ${JSON.stringify(generationFilteredCandidate)}` + ); storage.setItem(slotOneKey, 'corrupted-slot-copy'); storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 3 }))); const fallbackResume = readCampaignBattleResume(slotOneCampaign, 1, storage); @@ -150,11 +272,21 @@ try { campaignStep: 'second-battle' }))); browserStorage.setItem(slotOneKey, JSON.stringify(createBattleSave())); - startNewCampaign(); + const priorSlotOneCampaign = + createInitialCampaignState(); + priorSlotOneCampaign.step = 'first-battle'; + priorSlotOneCampaign.activeSaveSlot = 1; + priorSlotOneCampaign.battleResumeGeneration = 7; + setCampaignState(priorSlotOneCampaign); + const newCampaign = startNewCampaign(); assert( browserStorage.getItem(slotOneKey) === null && browserStorage.getItem(secondBattleKey) !== null, 'Starting a new campaign must clear slot 1 battle data without deleting resumable saves from preserved campaign slots.' ); + assert( + newCampaign.battleResumeGeneration === 8, + `A replacement campaign must advance beyond the overwritten slot generation before best-effort battle cleanup: ${newCampaign.battleResumeGeneration}` + ); assert( clearCampaignBattleSavesForSlot(3, browserStorage) === 1 && browserStorage.getItem(secondBattleKey) === null, 'Slot-scoped cleanup must remove only battle saves belonging to the requested campaign slot.' @@ -220,6 +352,18 @@ try { /saveBattleState\(slot = 1\)[\s\S]*?const state = this\.createBattleSaveState\(\)[\s\S]*?clearCampaignBattleSavesForSlot\(normalizedSlot\)[\s\S]*?localStorage\.setItem\(this\.battleSaveStorageKeyForSlot\(normalizedSlot\)/.test(battleSceneSource), 'Battle slot overwrites must clear all destination battle resumes before writing the current battle snapshot.' ); + assert( + /battleResumeGeneration:\s*campaign\.battleResumeGeneration/.test( + battleSceneSource + ), + 'Every new battle snapshot must carry its campaign battle-resume generation.' + ); + assert( + /readBattleSaveState\(slot: number, validateCurrentComposition = false\)[\s\S]*?readBattleSaveStorageCandidate\([\s\S]*?savedCampaign\s*\)/.test( + battleSceneSource + ), + 'BattleScene initial and manual loads must filter candidates through the loaded campaign generation.' + ); assert( /stats: \{ \.\.\.unit\.stats \}/.test(battleSceneSource) && /unit\.stats = \{ \.\.\.savedUnit\.stats \}/.test(battleSceneSource) && @@ -227,12 +371,12 @@ try { 'Battle saves must persist new attributes and reconstruct battle-earned attributes for legacy saves.' ); - console.log('Verified direct battle-save resume routing, BattleScene restore hooks, stale cleanup, fallback order, generation cleanup, and non-battle isolation.'); + console.log('Verified direct battle-save resume routing, durable launch-generation matching, BattleScene generation filtering, deletion-failure blocking, monotonic new-campaign replacement, restore hooks, stale cleanup, fallback order, generation cleanup, and non-battle isolation.'); } finally { await server.close(); } -function createStorage() { +function createStorage(options = {}) { const values = new Map(); return { getItem(key) { @@ -242,7 +386,9 @@ function createStorage() { values.set(key, String(value)); }, removeItem(key) { - values.delete(key); + if (!options.refuseRemoval) { + values.delete(key); + } }, clear() { values.clear(); diff --git a/scripts/verify-camp-reward-data.mjs b/scripts/verify-camp-reward-data.mjs index 687d9f9..a8e7e33 100644 --- a/scripts/verify-camp-reward-data.mjs +++ b/scripts/verify-camp-reward-data.mjs @@ -206,12 +206,14 @@ function validateCampInteractionUxGuards() { checkedGuardCount += 1; expectCampUx( - steppedNavigationMethod.includes('const previousStep = getCampaignState().step') && - steppedNavigationMethod.includes('markCampaignStep(step)') && - steppedNavigationMethod.includes('getCampaignState().step === step') && - steppedNavigationMethod.includes('markCampaignStep(previousStep)') && + steppedNavigationMethod.includes('const previousCampaign = getCampaignState()') && + steppedNavigationMethod.includes('beginCampaignStory(') && + steppedNavigationMethod.includes('const startsBattle =') && + steppedNavigationMethod.includes('markCampaignStep(step, {') && + steppedNavigationMethod.includes('startBattle: startsBattle') && + steppedNavigationMethod.includes('saveCampaignState(previousCampaign)') && navigationMethod.includes('recoverState?.()'), - 'campaign-step navigation must restore the previous step when lazy scene loading fails' + 'campaign-step navigation must atomically begin stories and restore the complete previous campaign when lazy scene loading fails' ); checkedGuardCount += 1; diff --git a/scripts/verify-campaign-flow-data.mjs b/scripts/verify-campaign-flow-data.mjs index 593e969..e2c5df5 100644 --- a/scripts/verify-campaign-flow-data.mjs +++ b/scripts/verify-campaign-flow-data.mjs @@ -153,8 +153,8 @@ assert( 'Defeat results must never expose the victory-story continuation route' ); assert( - /if \(outcome === 'victory'\) \{\s*this\.resultContinueButton = this\.addResultButton/.test(battleSceneSource), - 'The victory-story result CTA must only be created for victories' + /if \(\s*outcome === 'victory' \|\|\s*this\.resultReportPersistenceStatus ===\s*'failed'\s*\) \{\s*this\.resultContinueButton = this\.addResultButton/.test(battleSceneSource), + 'The result CTA must be available for victories and for either outcome when report persistence needs an explicit retry' ); assert( /if \(outcome === 'victory'\) \{[\s\S]*?pages: battleScenario\.victoryPages,[\s\S]*?return;\s*\}\s*this\.beginResultNavigation\(\(\) => startLazyScene\(this, 'CampScene', campSceneData\)\);/.test(battleSceneSource), @@ -178,8 +178,19 @@ assert( 'Continue must restore a pending aftermath before routing camp or ending campaign steps' ); assert( - /if \(data\?\.completedAftermathBattleId\) \{\s*completeCampaignAftermath\(data\.completedAftermathBattleId\);\s*\}/.test(campSceneSource), - 'CampScene must complete the pending aftermath before loading its campaign snapshot' + campSceneSource.includes( + 'completeCampaignStoryHandoffAftermath(' + ) && + campSceneSource.includes( + 'completeCampaignAftermath(' + ) && + campSceneSource.indexOf( + 'completeCampaignStoryHandoffAftermath(' + ) < + campSceneSource.indexOf( + 'this.campaign = getCampaignState()' + ), + 'CampScene must atomically consume an exact story aftermath handoff, preserve its legacy completion path, and do both before loading its campaign snapshot' ); if (failures.length > 0) { diff --git a/scripts/verify-campaign-settlement-idempotency.mjs b/scripts/verify-campaign-settlement-idempotency.mjs index bdc99cb..c520cfe 100644 --- a/scripts/verify-campaign-settlement-idempotency.mjs +++ b/scripts/verify-campaign-settlement-idempotency.mjs @@ -101,7 +101,71 @@ try { 'Replaying the exact same victory report must not duplicate gold or items.' ); - campaign.completeCampaignAftermath(scenario.id); + const aftermathReading = + campaign.setCampaignStoryCheckpoint({ + version: 1, + sequenceId: + 'settlement-aftermath-fixture', + beatId: 'settlement-aftermath-fixture', + pageIndex: 0 + }); + const aftermathReference = { + sequenceId: + aftermathReading.storyCheckpoint.sequenceId, + revision: + aftermathReading.storyCheckpoint.revision + }; + const aftermathHandoff = + campaign.markCampaignStoryHandoff( + aftermathReference.sequenceId, + aftermathReference.revision + ); + const beforeMismatchedAftermath = + JSON.stringify(aftermathHandoff); + const staleAftermath = + campaign.completeCampaignStoryHandoffAftermath( + scenario.id, + { + ...aftermathReference, + revision: + aftermathReference.revision + 1 + } + ); + assert.equal( + JSON.stringify(staleAftermath), + beforeMismatchedAftermath, + 'A stale handoff reference must consume neither the pending aftermath nor its story checkpoint.' + ); + const wrongBattleAftermath = + campaign.completeCampaignStoryHandoffAftermath( + 'second-battle-yellow-turban-pursuit', + aftermathReference + ); + assert.equal( + JSON.stringify(wrongBattleAftermath), + beforeMismatchedAftermath, + 'A mismatched battle must consume neither half of an aftermath handoff.' + ); + const completedAftermath = + campaign.completeCampaignStoryHandoffAftermath( + scenario.id, + aftermathReference + ); + assert.equal( + completedAftermath.pendingAftermathBattleId, + undefined, + 'An exact aftermath handoff must retire the pending settlement.' + ); + assert.equal( + completedAftermath.storyCheckpoint, + undefined, + 'An exact aftermath handoff must retire its story checkpoint atomically.' + ); + assert.equal( + completedAftermath.storyCheckpointRevision, + aftermathReference.revision + 1, + 'An exact aftermath handoff must invalidate its consumed revision.' + ); campaign.setActiveCampVisitId( 'first-pursuit-scout-tent' ); diff --git a/scripts/verify-campaign-story-checkpoint-normalization.mjs b/scripts/verify-campaign-story-checkpoint-normalization.mjs new file mode 100644 index 0000000..5df223e --- /dev/null +++ b/scripts/verify-campaign-story-checkpoint-normalization.mjs @@ -0,0 +1,1117 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createServer } from 'vite'; + +const storage = new Map(); +let failCanonicalSlotWrites = false; + +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + if ( + failCanonicalSlotWrites && + String(key).includes(':slot-') + ) { + throw new Error( + 'Simulated canonical campaign save-slot failure.' + ); + } + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + }, + clear() { + storage.clear(); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const campaign = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const story = await server.ssrLoadModule( + '/src/game/state/campaignStoryCheckpoint.ts' + ); + const campaignFlow = await server.ssrLoadModule( + '/src/game/data/campaignFlow.ts' + ); + const scenario = await server.ssrLoadModule( + '/src/game/data/scenario.ts' + ); + + const pages = [ + { + id: 'sequence-new', + legacyBeatIds: ['sequence-old'], + chapter: '회귀 검증', + background: 'story-chaos', + text: '첫 장면' + }, + { + id: 'beat-inserted', + chapter: '회귀 검증', + background: 'story-chaos', + text: '나중에 삽입된 장면' + }, + { + id: 'beat-final', + legacyBeatIds: ['beat-old'], + chapter: '회귀 검증', + background: 'story-chaos', + text: '이어 읽어야 할 장면' + } + ]; + + verifyPhaseContract(); + verifyStableBeatResolution(); + verifyLegacyCheckpointMigration(); + verifyUnknownBeatIsRejected(); + verifyStoryRouteResolution(); + verifyStoryExplorationMutualExclusion(); + verifyBeginCampaignStoryAtomicCommit(); + verifyTutorialStoryAtomicCommit(); + verifyHandoffRejectsLateReadingWrites(); + verifyExactHandoffConsumption(); + verifyLegacyFirstVictoryStoryTransition(); + verifyExactClearRejectsStaleRevision(); + verifyInvalidDirectWriteIsCompleteNoop(); + verifyCanonicalStorageFailureIsAtomic(); + verifyDestinationOwnedHandoffContract(); + + console.log( + 'Verified campaign story checkpoints: stable-beat and legacy migration, reading/handoff phases, story/exploration exclusion, atomic story entry commits, stale-battle retirement, exact destination-owned handoff consumption, legacy victory-step advancement, stale-revision races, invalid-write no-ops, and canonical storage-failure invariance.' + ); + + function verifyPhaseContract() { + assert.deepEqual( + story.campaignStoryCheckpointPhases, + ['reading', 'handoff'], + 'Story checkpoint phases must remain the explicit reading/handoff pair.' + ); + } + + function verifyStableBeatResolution() { + const checkpoint = normalizedCheckpoint({ + revision: 3, + sequenceId: 'sequence-new', + beatId: 'beat-final', + pageIndex: 0 + }); + const resolution = story.resolveCampaignStoryCheckpoint( + checkpoint, + pages, + 'prologue' + ); + + assert.deepEqual( + resolution, + { + pageIndex: 2, + sequenceId: 'sequence-new', + beatId: 'beat-final', + migrated: true + }, + 'A stable beat ID must win over a conflicting legacy page index.' + ); + } + + function verifyLegacyCheckpointMigration() { + const rawState = initialState('prologue'); + rawState.updatedAt = '2026-07-28T10:00:00.000Z'; + rawState.storyCheckpointRevision = 7; + rawState.storyCheckpoint = { + version: 0, + sequenceId: 'sequence-new', + legacyPageIndex: 2, + savedAt: '2026-07-28T10:01:00.000Z' + }; + + const loaded = loadRawState(rawState); + assert.deepEqual( + loaded.storyCheckpoint, + { + version: 1, + revision: 7, + sequenceId: 'sequence-new', + phase: 'reading', + pageIndex: 2, + campaignStep: 'prologue', + continuationIntent: 'default', + savedAt: '2026-07-28T10:01:00.000Z' + }, + 'A legacy pageIndex-only checkpoint must normalize without losing its position.' + ); + assert.deepEqual( + story.resolveCampaignStoryCheckpoint( + loaded.storyCheckpoint, + pages, + loaded.step + ), + { + pageIndex: 2, + sequenceId: 'sequence-new', + beatId: 'beat-final', + migrated: true + }, + 'A legacy pageIndex-only checkpoint must migrate to the canonical beat ID.' + ); + + const renamedCheckpoint = normalizedCheckpoint({ + revision: 8, + sequenceId: 'sequence-old', + beatId: 'beat-old', + pageIndex: 0 + }); + assert.deepEqual( + story.resolveCampaignStoryCheckpoint( + renamedCheckpoint, + pages, + 'prologue' + ), + { + pageIndex: 2, + sequenceId: 'sequence-new', + beatId: 'beat-final', + migrated: true + }, + 'Legacy sequence and beat aliases must migrate to canonical IDs.' + ); + } + + function verifyUnknownBeatIsRejected() { + const checkpoint = normalizedCheckpoint({ + revision: 9, + sequenceId: 'sequence-new', + beatId: 'beat-removed', + pageIndex: 2 + }); + assert.equal( + story.resolveCampaignStoryCheckpoint( + checkpoint, + pages, + 'prologue' + ), + undefined, + 'An unknown stable beat must not fall back to an unrelated page index.' + ); + } + + function verifyStoryRouteResolution() { + const cases = [ + { + label: 'ordinary battle intro', + pages: scenario.secondBattleIntroPages, + step: 'second-battle', + nextBattleId: + 'second-battle-yellow-turban-pursuit' + }, + { + label: 'Hanzhong king council', + pages: scenario.hanzhongKingCouncilPages, + step: 'hanzhong-king-camp' + }, + { + label: 'Shu Han foundation', + pages: scenario.shuHanFoundationPages, + step: 'shu-han-foundation-camp' + }, + { + label: 'Baidi entrustment', + pages: scenario.baidiEntrustmentPages, + step: 'baidi-entrustment-camp' + }, + { + label: 'northern campaign preparation', + pages: scenario.northernCampaignPrepPages, + step: 'northern-campaign-prep-camp' + } + ]; + + for (const routeCase of cases) { + const sequenceId = + story.storySequenceIdForPages( + routeCase.pages + ); + const resolved = + campaignFlow.getSortieFlowForStorySequence( + sequenceId, + routeCase.step + ); + assert( + resolved, + `${routeCase.label} must resolve from its stable story sequence.` + ); + assert.equal( + resolved.campaignStep, + routeCase.step, + `${routeCase.label} resolved to the wrong campaign step.` + ); + assert.equal( + resolved.nextBattleId, + routeCase.nextBattleId, + `${routeCase.label} resolved to the wrong destination battle.` + ); + assert.equal( + story.storySequenceIdForPages(resolved.pages), + sequenceId, + `${routeCase.label} resolved to a different story sequence.` + ); + } + } + + function verifyStoryExplorationMutualExclusion() { + resetTo(initialState('prologue-town')); + const exploring = + campaign.setCampaignExplorationCheckpoint( + villageCheckpoint() + ); + assert(exploring.explorationCheckpoint); + assert.equal(exploring.storyCheckpoint, undefined); + + const reading = campaign.setCampaignStoryCheckpoint( + storyWrite('village-story', 'village-story', 0) + ); + assert(reading.storyCheckpoint); + assert.equal( + reading.explorationCheckpoint, + undefined, + 'Starting a story must retire the exploration checkpoint.' + ); + assertPersistedState( + reading, + 'story replacing exploration' + ); + + const storyRevision = reading.storyCheckpointRevision; + const exploringAgain = + campaign.setCampaignExplorationCheckpoint( + villageCheckpoint() + ); + assert(exploringAgain.explorationCheckpoint); + assert.equal( + exploringAgain.storyCheckpoint, + undefined, + 'Starting exploration must retire the story checkpoint.' + ); + assert.equal( + exploringAgain.storyCheckpointRevision, + storyRevision + 1, + 'Retiring a story checkpoint must invalidate its revision.' + ); + assertPersistedState( + exploringAgain, + 'exploration replacing story' + ); + } + + function verifyBeginCampaignStoryAtomicCommit() { + resetTo(initialState('prologue-town')); + const exploring = + campaign.setCampaignExplorationCheckpoint( + villageCheckpoint() + ); + const previousRevision = + exploring.storyCheckpointRevision; + const previousBattleGeneration = + exploring.battleResumeGeneration; + const staleBattleSaveKey = + `heros-web:battle:first-battle-zhuo-commandery:slot-${exploring.activeSaveSlot}`; + storage.set( + staleBattleSaveKey, + '{"fixture":"superseded battle"}' + ); + + const begun = campaign.beginCampaignStory( + 'first-battle', + storyWrite( + 'first-battle-opening', + 'first-battle-opening', + 0 + ), + { startBattle: true } + ); + + assert.equal(begun.step, 'first-battle'); + assert.equal(begun.explorationCheckpoint, undefined); + assert.equal(begun.activeCityStayId, undefined); + assert.equal(begun.activeCampVisitId, undefined); + assert.equal( + begun.storyCheckpointRevision, + previousRevision + 1 + ); + assert.equal( + begun.battleResumeGeneration, + previousBattleGeneration + 1, + 'A new battle story must advance the durable battle-resume generation.' + ); + assert.deepEqual( + storySnapshot(begun), + { + sequenceId: 'first-battle-opening', + beatId: 'first-battle-opening', + pageIndex: 0, + campaignStep: 'first-battle', + phase: 'reading' + }, + 'beginCampaignStory must save the new step and first beat together.' + ); + assert.equal( + begun.updatedAt, + begun.storyCheckpoint.savedAt + ); + assertPersistedState( + begun, + 'beginCampaignStory atomic commit' + ); + assert.equal( + storage.has(staleBattleSaveKey), + false, + 'A committed story departure must retire the superseded battle resume.' + ); + } + + function verifyTutorialStoryAtomicCommit() { + resetTo(initialState('prologue-town')); + const exploring = + campaign.setCampaignExplorationCheckpoint( + villageCheckpoint() + ); + const previousRevision = + exploring.storyCheckpointRevision; + const tutorialId = + campaign.prologueVillageCampaignTutorialIds.complete; + + const completed = campaign.completeCampaignTutorial( + tutorialId, + { + clearExplorationCheckpointScene: + 'prologue-village', + storyCheckpoint: storyWrite( + 'volunteers-rise', + 'volunteers-rise', + 0 + ) + } + ); + + assert( + completed.completedTutorialIds.includes(tutorialId), + 'The tutorial completion must be part of the committed state.' + ); + assert.equal(completed.explorationCheckpoint, undefined); + assert.equal( + completed.storyCheckpointRevision, + previousRevision + 1 + ); + assert.deepEqual( + storySnapshot(completed), + { + sequenceId: 'volunteers-rise', + beatId: 'volunteers-rise', + pageIndex: 0, + campaignStep: 'prologue-town', + phase: 'reading' + }, + 'completeCampaignTutorial must save completion and the first story beat together.' + ); + assert.equal( + completed.updatedAt, + completed.storyCheckpoint.savedAt + ); + assertPersistedState( + completed, + 'tutorial and story atomic commit' + ); + } + + function verifyHandoffRejectsLateReadingWrites() { + resetTo(initialState('prologue')); + const reading = campaign.setCampaignStoryCheckpoint( + storyWrite('handoff-story', 'handoff-story', 0) + ); + const revision = reading.storyCheckpoint.revision; + const handedOff = campaign.markCampaignStoryHandoff( + 'handoff-story', + revision + ); + assert.equal( + handedOff.storyCheckpoint.phase, + 'handoff' + ); + assertPersistedState(handedOff, 'story handoff'); + + assertCompleteNoop( + () => + campaign.setCampaignStoryCheckpoint( + storyWrite( + 'handoff-story', + 'late-reading', + 1, + revision + ) + ), + 'A late revision-bound reading write after handoff' + ); + assertCompleteNoop( + () => + campaign.setCampaignStoryCheckpoint( + storyWrite( + 'handoff-story', + 'pagehide-reading', + 2 + ) + ), + 'A late pagehide reading write without a revision after handoff' + ); + } + + function verifyExactHandoffConsumption() { + resetTo(initialState('prologue')); + const reading = campaign.setCampaignStoryCheckpoint( + storyWrite( + 'destination-owned-story', + 'destination-owned-story', + 0 + ) + ); + const reference = { + sequenceId: + reading.storyCheckpoint.sequenceId, + revision: reading.storyCheckpoint.revision + }; + + assertCompleteNoop( + () => + campaign.consumeCampaignStoryHandoff( + reference + ), + 'A destination trying to consume a reading checkpoint' + ); + + const handedOff = + campaign.markCampaignStoryHandoff( + reference.sequenceId, + reference.revision + ); + assert.equal( + handedOff.storyCheckpoint.phase, + 'handoff' + ); + + assertCompleteNoop( + () => + campaign.consumeCampaignStoryHandoff({ + ...reference, + revision: reference.revision + 1 + }), + 'A stale destination handoff reference' + ); + assertCompleteNoop( + () => + campaign.consumeCampaignStoryHandoff({ + ...reference, + sequenceId: 'different-story' + }), + 'A mismatched destination handoff reference' + ); + + const consumed = + campaign.consumeCampaignStoryHandoff( + reference + ); + assert.equal(consumed.storyCheckpoint, undefined); + assert.equal( + consumed.storyCheckpointRevision, + reference.revision + 1, + 'An exact destination handoff consume must invalidate its revision.' + ); + assertPersistedState( + consumed, + 'exact destination handoff consumption' + ); + + assertCompleteNoop( + () => + campaign.consumeCampaignStoryHandoff( + reference + ), + 'A repeated destination handoff consume' + ); + } + + function verifyLegacyFirstVictoryStoryTransition() { + const legacyState = initialState( + 'first-victory-story' + ); + legacyState.gold = 321; + resetTo(legacyState); + const reading = + campaign.setCampaignStoryCheckpoint( + storyWrite( + 'legacy-first-victory', + 'legacy-first-victory', + 0 + ) + ); + const reference = { + sequenceId: + reading.storyCheckpoint.sequenceId, + revision: + reading.storyCheckpoint.revision + }; + const handedOff = + campaign.markCampaignStoryHandoff( + reference.sequenceId, + reference.revision + ); + assert.equal( + handedOff.storyCheckpoint.phase, + 'handoff' + ); + + assertCompleteNoop( + () => + campaign.completeLegacyFirstVictoryStoryHandoff( + { + ...reference, + revision: + reference.revision + 1 + } + ), + 'A stale legacy first-victory handoff reference' + ); + + const completed = + campaign.completeLegacyFirstVictoryStoryHandoff( + reference + ); + assert.equal( + completed.step, + 'first-camp', + 'Completing the supported legacy victory story must advance to the current first-camp flow.' + ); + assert.equal( + completed.latestBattleId, + 'first-battle-zhuo-commandery' + ); + assert.equal( + completed.storyCheckpoint, + undefined + ); + assert.equal( + completed.storyCheckpointRevision, + reference.revision + 1 + ); + assert.equal( + completed.gold, + 321, + 'Legacy step migration must not settle rewards again.' + ); + assertPersistedState( + completed, + 'legacy first-victory handoff transition' + ); + } + + function verifyExactClearRejectsStaleRevision() { + resetTo(initialState('prologue')); + const reading = campaign.setCampaignStoryCheckpoint( + storyWrite('clear-story', 'clear-story', 0) + ); + const revision = reading.storyCheckpoint.revision; + + const cleared = campaign.clearCampaignStoryCheckpoint( + 'clear-story', + revision + ); + assert.equal(cleared.storyCheckpoint, undefined); + assert.equal( + cleared.storyCheckpointRevision, + revision + 1, + 'An exact clear must invalidate the cleared checkpoint revision.' + ); + assertPersistedState(cleared, 'exact story clear'); + + assertCompleteNoop( + () => + campaign.setCampaignStoryCheckpoint( + storyWrite( + 'clear-story', + 'stale-reading', + 1, + revision + ) + ), + 'A stale write after an exact story clear' + ); + } + + function verifyInvalidDirectWriteIsCompleteNoop() { + resetTo(initialState('prologue')); + assertCompleteNoop( + () => + campaign.setCampaignStoryCheckpoint({ + version: 1, + sequenceId: 'invalid story id!', + beatId: 'invalid-beat', + pageIndex: 0 + }), + 'An invalid direct story write' + ); + } + + function verifyCanonicalStorageFailureIsAtomic() { + resetTo(initialState('prologue-town')); + const staleBattleSaveKey = + 'heros-web:battle:first-battle-zhuo-commandery:slot-2'; + storage.set( + staleBattleSaveKey, + '{"fixture":"must survive failed commit"}' + ); + assertCanonicalWriteFailure( + () => + campaign.beginCampaignStory( + 'first-battle', + storyWrite( + 'failed-begin', + 'failed-begin', + 0 + ), + { startBattle: true } + ), + 'beginCampaignStory' + ); + assert.equal( + storage.get(staleBattleSaveKey), + '{"fixture":"must survive failed commit"}', + 'A failed story commit must not retire the prior battle resume.' + ); + assertCanonicalWriteFailure( + () => + campaign.completeCampaignTutorial( + campaign.prologueVillageCampaignTutorialIds + .complete, + { + storyCheckpoint: storyWrite( + 'failed-tutorial', + 'failed-tutorial', + 0 + ) + } + ), + 'completeCampaignTutorial' + ); + + const priorCampaign = + initialState('first-battle', 1); + priorCampaign.battleResumeGeneration = 9; + resetTo(priorCampaign); + const priorBattleSaveKey = + 'heros-web:battle:first-battle-zhuo-commandery:slot-1'; + storage.set( + priorBattleSaveKey, + '{"fixture":"must survive failed new campaign"}' + ); + assertCanonicalWriteFailure( + () => + campaign.startNewCampaign({ + storyCheckpoint: storyWrite( + 'failed-new-campaign', + 'failed-new-campaign', + 0 + ) + }), + 'startNewCampaign' + ); + assert.equal( + storage.get(priorBattleSaveKey), + '{"fixture":"must survive failed new campaign"}', + 'A failed new-campaign commit must preserve the overwritten slot battle resume.' + ); + + resetTo( + initialState('first-victory-story') + ); + const legacyReading = + campaign.setCampaignStoryCheckpoint( + storyWrite( + 'failed-legacy-handoff', + 'failed-legacy-handoff', + 0 + ) + ); + const legacyReference = { + sequenceId: + legacyReading.storyCheckpoint + .sequenceId, + revision: + legacyReading.storyCheckpoint + .revision + }; + campaign.markCampaignStoryHandoff( + legacyReference.sequenceId, + legacyReference.revision + ); + assertCanonicalWriteFailure( + () => + campaign.completeLegacyFirstVictoryStoryHandoff( + legacyReference + ), + 'completeLegacyFirstVictoryStoryHandoff' + ); + } + + function verifyDestinationOwnedHandoffContract() { + const titleSource = sourceFile( + 'src/game/scenes/TitleScene.ts' + ); + const titleContinue = sourceSection( + titleSource, + 'private async continueGameAsync(', + 'private async continueStoryCheckpoint(' + ); + assertOrder( + titleContinue, + 'continueStoryCheckpoint(campaign)', + 'readCampaignBattleResume(campaign', + 'Title Continue must prefer a durable story checkpoint over a superseded battle snapshot.' + ); + assert( + titleSource.includes( + 'battleResume = campaign.storyCheckpoint' + ), + 'Title save summaries must describe the story checkpoint instead of a superseded battle snapshot.' + ); + assert( + /create\(data\?: TitleSceneData\)[\s\S]*?this\.consumeTitleStoryHandoff\([\s\S]*?private consumeTitleStoryHandoff\([\s\S]*?completeLegacyFirstVictoryStoryHandoff\(/.test( + titleSource + ) && + /candidate\.destinationScene === 'TitleScene'[\s\S]*?this\.consumeTitleStoryHandoff\(/.test( + titleSource + ), + 'Both normal and restored Story-to-Title handoffs must atomically advance the supported legacy first-victory step.' + ); + + const villageCreate = sourceSection( + sourceFile( + 'src/game/scenes/PrologueVillageScene.ts' + ), + 'create(data?: PrologueVillageSceneData)', + 'getDebugState()' + ); + assertOrder( + villageCreate, + 'this.prepareCampaignProgress()', + 'consumeCampaignStoryHandoff(data?.storyHandoff)', + 'The village must establish a recoverable campaign step before consuming its story handoff.' + ); + + const militiaCreate = sourceSection( + sourceFile( + 'src/game/scenes/PrologueMilitiaCampScene.ts' + ), + 'create(data?: PrologueMilitiaCampSceneData)', + 'getDebugState()' + ); + assertOrder( + militiaCreate, + 'this.prepareCampaignProgress()', + 'consumeCampaignStoryHandoff(data?.storyHandoff)', + 'The militia camp must establish a recoverable campaign step before consuming its story handoff.' + ); + + const campSource = sourceFile( + 'src/game/scenes/CampScene.ts' + ); + const campInit = sourceSection( + campSource, + 'init(data?: CampSceneData)', + 'create()' + ); + assert( + campInit.includes( + 'completeCampaignStoryHandoffAftermath(' + ) && + campInit.includes( + 'completeCampaignAftermath(' + ) && + campInit.includes( + 'consumeCampaignStoryHandoff(' + ), + 'Camp must atomically consume exact aftermath handoffs while retaining legacy independent completion and handoff paths.' + ); + const campInitOffset = campSource.indexOf( + 'init(data?: CampSceneData)' + ); + assert( + campSource.indexOf( + 'consumeCampaignStoryHandoff(', + campInitOffset + ) < + campSource.indexOf( + 'this.campaign = getCampaignState()' + ), + 'Camp must consume the handoff before caching campaign state so a stale snapshot cannot resurrect it.' + ); + + const battleSource = sourceFile( + 'src/game/scenes/BattleScene.ts' + ); + const battleInit = sourceSection( + battleSource, + 'init(data?: BattleSceneData)', + 'create()' + ); + assert( + battleInit.includes( + 'this.storyHandoff = data?.storyHandoff' + ) && + !battleInit.includes( + 'consumeCampaignStoryHandoff(' + ), + 'Battle preload must retain, not consume, the durable handoff.' + ); + const battleCreate = sourceSection( + battleSource, + 'create()', + 'getDebugState()' + ); + assertOrder( + battleCreate, + "markCampaignStep('first-battle',", + 'consumeCampaignStoryHandoff(this.storyHandoff)', + 'Battle must establish its recoverable campaign step before consuming the handoff.' + ); + } + + function normalizedCheckpoint({ + revision, + sequenceId, + beatId, + pageIndex + }) { + const checkpoint = + story.normalizeCampaignStoryCheckpoint( + { + version: 1, + revision, + sequenceId, + phase: 'reading', + beatId, + pageIndex, + campaignStep: 'prologue', + continuationIntent: 'default', + savedAt: '2026-07-28T10:30:00.000Z' + }, + 'prologue', + revision + ); + assert( + checkpoint, + `Expected a valid checkpoint for ${sequenceId}/${beatId}.` + ); + return checkpoint; + } + + function initialState(step, slot = 2) { + const state = campaign.createInitialCampaignState(); + state.updatedAt = '2026-07-28T10:00:00.000Z'; + state.step = step; + state.activeSaveSlot = slot; + return state; + } + + function villageCheckpoint() { + return { + version: 1, + scene: 'prologue-village', + contextId: 'prologue-village', + player: { + x: 420.25, + y: 512.5, + direction: 'south' + } + }; + } + + function storyWrite( + sequenceId, + beatId, + pageIndex, + revision + ) { + return { + version: 1, + ...(revision === undefined ? {} : { revision }), + sequenceId, + beatId, + pageIndex + }; + } + + function storySnapshot(state) { + const checkpoint = state.storyCheckpoint; + assert(checkpoint, 'Expected a story checkpoint.'); + return { + sequenceId: checkpoint.sequenceId, + beatId: checkpoint.beatId, + pageIndex: checkpoint.pageIndex, + campaignStep: checkpoint.campaignStep, + phase: checkpoint.phase + }; + } + + function resetTo(state) { + failCanonicalSlotWrites = false; + storage.clear(); + return campaign.setCampaignState(state); + } + + function loadRawState(state) { + failCanonicalSlotWrites = false; + storage.clear(); + const serialized = JSON.stringify(state); + storage.set(campaign.campaignStorageKey, serialized); + storage.set( + slotKey(state.activeSaveSlot ?? 1), + serialized + ); + return campaign.loadCampaignState(); + } + + function assertPersistedState(expected, label) { + assert.deepEqual( + campaign.getCampaignState(), + expected, + `${label}: in-memory state must match the returned state.` + ); + assert.deepEqual( + JSON.parse(storage.get(campaign.campaignStorageKey)), + expected, + `${label}: base-save state must match the returned state.` + ); + assert.deepEqual( + JSON.parse( + storage.get(slotKey(expected.activeSaveSlot)) + ), + expected, + `${label}: canonical slot-save state must match the returned state.` + ); + } + + function assertCompleteNoop(action, label) { + const before = persistentSnapshot(); + const returned = action(); + + assert.deepEqual( + returned, + before.state, + `${label} must return the unchanged state.` + ); + assert.deepEqual( + campaign.getCampaignState(), + before.state, + `${label} must leave memory and updatedAt unchanged.` + ); + assert.equal( + storage.get(campaign.campaignStorageKey), + before.base, + `${label} must leave base-save bytes unchanged.` + ); + assert.equal( + storage.get(slotKey(before.state.activeSaveSlot)), + before.slot, + `${label} must leave slot-save bytes unchanged.` + ); + } + + function assertCanonicalWriteFailure(action, label) { + const before = persistentSnapshot(); + failCanonicalSlotWrites = true; + try { + assert.throws( + action, + /Simulated canonical campaign save-slot failure/, + `${label} must surface a canonical storage failure.` + ); + } finally { + failCanonicalSlotWrites = false; + } + + assert.deepEqual( + campaign.getCampaignState(), + before.state, + `${label} storage failure must leave memory unchanged.` + ); + assert.equal( + storage.get(campaign.campaignStorageKey), + before.base, + `${label} storage failure must leave base-save bytes unchanged.` + ); + assert.equal( + storage.get(slotKey(before.state.activeSaveSlot)), + before.slot, + `${label} storage failure must leave slot-save bytes unchanged.` + ); + } + + function persistentSnapshot() { + const state = campaign.getCampaignState(); + const base = storage.get(campaign.campaignStorageKey); + const slot = storage.get(slotKey(state.activeSaveSlot)); + assert(base, 'Expected the base campaign save fixture.'); + assert(slot, 'Expected the slot campaign save fixture.'); + return { state, base, slot }; + } + + function slotKey(slot) { + return `${campaign.campaignStorageKey}:slot-${slot}`; + } + + function sourceFile(path) { + return readFileSync( + new URL(`../${path}`, import.meta.url), + 'utf8' + ); + } + + function sourceSection(source, startMarker, endMarker) { + const start = source.indexOf(startMarker); + assert( + start >= 0, + `Missing source marker "${startMarker}".` + ); + const end = source.indexOf( + endMarker, + start + startMarker.length + ); + assert( + end > start, + `Missing source marker "${endMarker}" after "${startMarker}".` + ); + return source.slice(start, end); + } + + function assertOrder(source, first, second, message) { + const firstIndex = source.indexOf(first); + const secondIndex = source.indexOf(second); + assert( + firstIndex >= 0 && + secondIndex > firstIndex, + message + ); + } +} finally { + await server.close(); +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 92c119e..e412192 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -206,18 +206,43 @@ try { await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true }); await assertCanvasPainted(page, 'new game story'); await assertFreshStoryAssetStreaming(page, requestedResourceUrls, freshStoryRequestStartIndex); + const interruptedPrologue = await page.evaluate( + () => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() + ); + const interruptedPrologueSave = await readCampaignSave(page); + assert( + Number.isInteger(interruptedPrologue?.pageIndex) && + interruptedPrologue.pageIndex > 0 && + Boolean(interruptedPrologue.currentPageId), + `Expected the prologue interruption fixture to be beyond its first page: ${JSON.stringify(interruptedPrologue)}` + ); + assertStoryCheckpointMatchesPage( + interruptedPrologue, + interruptedPrologueSave, + 'interrupted prologue story' + ); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await clickLegacyUi(page, 962, 310); await waitForStoryReady(page); const resumedPrologue = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); + const resumedPrologueSave = await readCampaignSave(page); assert( - resumedPrologue?.pageIndex === 0 && + resumedPrologue?.pageIndex === interruptedPrologue.pageIndex && + resumedPrologue?.currentPageId === interruptedPrologue.currentPageId && resumedPrologue?.presentation?.battleId === 'first-battle-zhuo-commandery' && resumedPrologue.presentation.arcId === 'yellow-turban' && resumedPrologue.presentation.stage === 'story' && resumedPrologue.presentation.washVisible === true, - `Expected a resumed prologue to retain the founding campaign presentation: ${JSON.stringify(resumedPrologue)}` + `Expected a resumed prologue to retain the exact interrupted page and founding campaign presentation: ${JSON.stringify({ + interrupted: interruptedPrologue, + resumed: resumedPrologue + })}` + ); + assertStoryCheckpointMatchesPage( + resumedPrologue, + resumedPrologueSave, + 'resumed prologue story' ); const firstBattleTransition = await advanceUntilBattle(page, 'first-battle-zhuo-commandery', { startDeployment: false, @@ -2651,6 +2676,20 @@ try { undefined, { timeout: 30000 } ); + const interruptedFirstAftermath = await page.evaluate( + () => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() + ); + const interruptedFirstAftermathSave = await readCampaignSave(page); + assert( + interruptedFirstAftermath?.pageIndex === 1 && + Boolean(interruptedFirstAftermath.currentPageId), + `Expected the first aftermath interruption fixture on its second page: ${JSON.stringify(interruptedFirstAftermath)}` + ); + assertStoryCheckpointMatchesPage( + interruptedFirstAftermath, + interruptedFirstAftermathSave, + 'interrupted first aftermath' + ); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await clickLegacyUi(page, 962, 310); @@ -2658,13 +2697,23 @@ try { const resumedFirstAftermath = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); const resumedFirstAftermathSave = await readCampaignSave(page); assert( - resumedFirstAftermath?.pageIndex === 0 && + resumedFirstAftermath?.pageIndex === interruptedFirstAftermath.pageIndex && + resumedFirstAftermath?.currentPageId === interruptedFirstAftermath.currentPageId && resumedFirstAftermath?.totalPages === 5 && resumedFirstAftermath?.presentation?.battleId === 'first-battle-zhuo-commandery' && resumedFirstAftermath.presentation.stage === 'aftermath' && resumedFirstAftermathSave.current?.pendingAftermathBattleId === 'first-battle-zhuo-commandery' && resumedFirstAftermathSave.slot1?.pendingAftermathBattleId === 'first-battle-zhuo-commandery', - `Expected continue after an interrupted aftermath to restore that aftermath before camp: ${JSON.stringify({ story: resumedFirstAftermath, save: resumedFirstAftermathSave })}` + `Expected continue after an interrupted aftermath to restore the exact interrupted page before camp: ${JSON.stringify({ + interrupted: interruptedFirstAftermath, + resumed: resumedFirstAftermath, + save: resumedFirstAftermathSave + })}` + ); + assertStoryCheckpointMatchesPage( + resumedFirstAftermath, + resumedFirstAftermathSave, + 'resumed first aftermath' ); assert( sameJsonValue( @@ -2704,19 +2753,23 @@ try { 'first-victory-zhang-fei', 'first-victory-next' ]; + const firstAftermathRewardDisplays = [ + directFirstAftermath.rewardDisplay, + ...firstCampTransition.rewardDisplays + ]; assert( sameJsonValue( - firstCampTransition.rewardDisplays.map((display) => display.pageId), + firstAftermathRewardDisplays.map((display) => display.pageId), firstVictoryStoryPageIds ) && - firstCampTransition.rewardDisplays.every((display) => ( + firstAftermathRewardDisplays.every((display) => ( display.source === 'campaign' && display.battleId === 'first-battle-zhuo-commandery' && display.rewardGold === null && Array.isArray(display.items) && display.items.length === 0 )), - `Expected all five first-victory aftermath pages to retain campaign context without duplicating exact rewards: ${JSON.stringify(firstCampTransition.rewardDisplays)}` + `Expected all five first-victory aftermath pages to retain campaign context without duplicating exact rewards: ${JSON.stringify(firstAftermathRewardDisplays)}` ); const firstNarrativeMemoriesByPage = Object.fromEntries( firstCampTransition.narrativeMemories.map((memory) => [memory.pageId, memory]) @@ -6131,6 +6184,15 @@ try { await waitForTitle(page); } + const midCampaignSelectedUnitIds = [ + 'zhuge-liang', + 'jiang-wei', + 'wang-ping', + 'zhao-yun', + 'huang-quan', + 'ma-liang', + 'ma-dai' + ]; await seedCampaignSave(page, { battleId: 'fifty-eighth-battle-qishan-retreat', battleTitle: '기산 후퇴로', @@ -6138,12 +6200,36 @@ try { gold: 7400, turnNumber: 13, defeatedEnemies: 16, - selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'huang-quan', 'ma-liang', 'ma-dai'] + selectedSortieUnitIds: midCampaignSelectedUnitIds }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await clickLegacyUi(page, 962, 310); await waitForCamp(page); + const midCampaignSelectionFixture = await page.evaluate((requestedUnitIds) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if ( + !scene || + typeof scene.normalizedSortieUnitIds !== 'function' || + typeof scene.persistSortieSelection !== 'function' + ) { + return { ready: false, selectedUnitIds: [] }; + } + scene.selectedSortieUnitIds = scene.normalizedSortieUnitIds(requestedUnitIds); + scene.persistSortieSelection(); + return { + ready: true, + selectedUnitIds: [...scene.selectedSortieUnitIds] + }; + }, midCampaignSelectedUnitIds); + assert( + midCampaignSelectionFixture.ready === true && + sameJsonValue( + [...midCampaignSelectionFixture.selectedUnitIds].sort(), + [...midCampaignSelectedUnitIds].sort() + ), + `Expected the mid-campaign fixture to select all seven officers after camp recruitment: ${JSON.stringify(midCampaignSelectionFixture)}` + ); await page.screenshot({ path: `${screenshotDir}/rc-mid-campaign-continue.png`, fullPage: true }); await assertCanvasPainted(page, 'mid campaign camp'); @@ -6597,8 +6683,12 @@ try { JSON.stringify(swapPreview?.comparison?.projected?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && swapPreview?.metrics?.length === 4 && swapPreview.metrics.some((metric) => metric.delta !== 0) && - swapPreview?.comparison?.lostRoles?.includes('flank') && - swapPreview?.comparison?.activeBondDelta === 0 && + swapPreview?.comparison?.current?.coveredRoleCount === 3 && + swapPreview?.comparison?.projected?.coveredRoleCount === 3 && + swapPreview?.comparison?.current?.formationRoleCounts?.flank === 2 && + swapPreview?.comparison?.projected?.formationRoleCounts?.flank === 1 && + swapPreview?.comparison?.lostRoles?.length === 0 && + swapPreview?.comparison?.activeBondDelta === -1 && swapPreview?.comparison?.gainedBonds?.length > 0 && swapPreview?.comparison?.lostBonds?.length > 0 && swapPreview?.strategyCoverage?.current?.count === 4 && @@ -6617,7 +6707,7 @@ try { midFormationSwapPreviewProbe.panel.headline?.includes('IN 관우') && midFormationSwapPreviewProbe.panel.impact?.includes('전법 4/4→4/4') && midFormationSwapPreviewProbe.panel.impact?.includes('추격') && - midFormationSwapPreviewProbe.panel.impact?.includes('공백 돌파') && + midFormationSwapPreviewProbe.panel.impact?.includes('역할 유지') && midFormationSwapPreviewProbe.panel.detail?.includes('전법 유지') && midFormationSwapPreviewProbe.panel.detail?.includes('추격 해제') && midFormationSwapPreviewProbe.panel.detail?.includes('8%') && @@ -7753,6 +7843,61 @@ try { campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave) ) ); + const midImprovementStoryCheckpointConsumed = ['current', 'slot1'].every( + (slot) => { + const before = updatedMobileResultSave[slot]; + const after = pendingMidImprovementSave[slot]; + const checkpoint = before?.storyCheckpoint; + return ( + checkpoint?.phase === 'reading' && + checkpoint.pageIndex === 0 && + checkpoint.campaignStep === before?.step && + checkpoint.revision === + before?.storyCheckpointRevision && + checkpoint.continuationIntent === 'default' && + typeof checkpoint.sequenceId === 'string' && + checkpoint.sequenceId.length > 0 && + after?.storyCheckpoint === undefined && + after?.storyCheckpointRevision === + expectedNextStoryCheckpointRevision( + before?.storyCheckpointRevision + ) + ); + } + ); + const expectedMidImprovementTargetStep = + typeof midImprovementTargetBattleId === 'string' + ? midImprovementTargetBattleId.replace( + /-battle-.+$/, + '-battle' + ) + : undefined; + const midImprovementPreparationCheckpointPersisted = [ + 'current', + 'slot1' + ].every((slot) => { + const before = updatedMobileResultSave[slot]; + const after = pendingMidImprovementSave[slot]; + const checkpoint = + after?.sortiePreparationCheckpoint; + return ( + before?.sortiePreparationCheckpoint === + undefined && + checkpoint?.version === 1 && + checkpoint.intent === + 'sortie-improvement' && + checkpoint.mode === 'next-sortie' && + checkpoint.sourceBattleId === + midCampNextBattleId && + checkpoint.targetBattleId === + midImprovementTargetBattleId && + checkpoint.targetCampaignStep === + expectedMidImprovementTargetStep && + Number.isFinite( + Date.parse(checkpoint.savedAt) + ) + ); + }); const midImprovementAftermathCompleted = updatedMobileResultSave.current?.pendingAftermathBattleId === midCampNextBattleId && updatedMobileResultSave.slot1?.pendingAftermathBattleId === midCampNextBattleId && @@ -7802,19 +7947,63 @@ try { boundsWithinFhdViewport(midImprovementLedger.toggleButtonBounds) && midImprovementLedger.toggleInteractive === true && midImprovementLedger.cards.length === 0 && - midImprovementLedger.actions.length === 0 && - midImprovementCampEntrySaveUnchanged && - midImprovementAftermathCompleted && - midImprovementRewardDestinationPending, + midImprovementLedger.actions.length === 0 && + midImprovementCampEntrySaveUnchanged && + midImprovementStoryCheckpointConsumed && + midImprovementPreparationCheckpointPersisted && + midImprovementAftermathCompleted && + midImprovementRewardDestinationPending, `Expected next-sortie improvement arrival to remain nonblocking with passive settlement and an unopened on-demand ledger: ${JSON.stringify({ openSortiePrepOnCreate: pendingMidImprovementReward?.openSortiePrepOnCreate, sortieVisible: pendingMidImprovementReward?.sortieVisible, arrival: midImprovementArrival, ledger: midImprovementLedger, - expectedBattleId: midCampNextBattleId, - saveUnchanged: midImprovementCampEntrySaveUnchanged, - aftermathCompleted: midImprovementAftermathCompleted, - rewardDestinationPending: midImprovementRewardDestinationPending, + expectedBattleId: midCampNextBattleId, + saveUnchanged: midImprovementCampEntrySaveUnchanged, + storyCheckpointConsumed: + midImprovementStoryCheckpointConsumed, + storyCheckpointBefore: { + current: + updatedMobileResultSave.current + ?.storyCheckpoint, + slot1: + updatedMobileResultSave.slot1 + ?.storyCheckpoint + }, + storyCheckpointAfter: { + current: + pendingMidImprovementSave.current + ?.storyCheckpoint, + slot1: + pendingMidImprovementSave.slot1 + ?.storyCheckpoint + }, + storyCheckpointRevisions: { + beforeCurrent: + updatedMobileResultSave.current + ?.storyCheckpointRevision, + beforeSlot1: + updatedMobileResultSave.slot1 + ?.storyCheckpointRevision, + afterCurrent: + pendingMidImprovementSave.current + ?.storyCheckpointRevision, + afterSlot1: + pendingMidImprovementSave.slot1 + ?.storyCheckpointRevision + }, + preparationCheckpointPersisted: + midImprovementPreparationCheckpointPersisted, + preparationCheckpoint: { + current: + pendingMidImprovementSave.current + ?.sortiePreparationCheckpoint, + slot1: + pendingMidImprovementSave.slot1 + ?.sortiePreparationCheckpoint + }, + aftermathCompleted: midImprovementAftermathCompleted, + rewardDestinationPending: midImprovementRewardDestinationPending, rewardAcknowledgements: midImprovementRewardAcknowledgements, noticeDismissals: midImprovementNoticeDismissals })}` @@ -11375,6 +11564,39 @@ async function readCampaignSave(page) { }); } +function assertStoryCheckpointMatchesPage(story, saves, label) { + const runtimeCheckpoint = + story?.storyCheckpoint?.persisted; + const currentCheckpoint = saves?.current?.storyCheckpoint; + const slotCheckpoint = saves?.slot1?.storyCheckpoint; + assert( + story?.storyCheckpoint?.enabled === true && + Boolean(story.currentPageId) && + runtimeCheckpoint?.sequenceId === + story.storyCheckpoint.sequenceId && + runtimeCheckpoint?.beatId === story.currentPageId && + runtimeCheckpoint?.pageIndex === story.pageIndex && + runtimeCheckpoint?.phase === 'reading' && + currentCheckpoint?.sequenceId === + runtimeCheckpoint.sequenceId && + currentCheckpoint?.beatId === story.currentPageId && + currentCheckpoint?.pageIndex === story.pageIndex && + currentCheckpoint?.phase === 'reading' && + slotCheckpoint?.sequenceId === + runtimeCheckpoint.sequenceId && + slotCheckpoint?.beatId === story.currentPageId && + slotCheckpoint?.pageIndex === story.pageIndex && + slotCheckpoint?.phase === 'reading' && + sameJsonValue(currentCheckpoint, slotCheckpoint), + `Expected ${label} to persist its exact page checkpoint in runtime, current, and slot saves: ${JSON.stringify({ + story, + runtimeCheckpoint, + currentCheckpoint, + slotCheckpoint + })}` + ); +} + async function readBattleResultEvaluationControlPoint(page, control, presetId) { const probe = await page.evaluate(({ control, presetId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -12558,6 +12780,16 @@ function sameJsonValue(value, expected) { return stableJson(value) === stableJson(expected); } +function expectedNextStoryCheckpointRevision(revision) { + const normalized = + Number.isSafeInteger(revision) && revision >= 0 + ? revision + : 0; + return normalized >= Number.MAX_SAFE_INTEGER + ? 0 + : normalized + 1; +} + function assertBattleResultDebriefText(resultTexts, rewards, context) { const texts = Array.isArray(resultTexts) ? resultTexts : []; const recruits = rewards?.recruits ?? []; @@ -12604,6 +12836,10 @@ function campaignSaveWithoutCampRecruitmentEffects(save) { acknowledgedVictoryRewardBattleIds: _acknowledgedVictoryRewardBattleIds, acknowledgedVictoryRewardCategories: _acknowledgedVictoryRewardCategories, pendingAftermathBattleId: _pendingAftermathBattleId, + storyCheckpointRevision: _storyCheckpointRevision, + storyCheckpoint: _storyCheckpoint, + sortiePreparationCheckpoint: + _sortiePreparationCheckpoint, firstBattleReport, ...stableFields } = state; diff --git a/scripts/verify-sortie-preparation-checkpoint.mjs b/scripts/verify-sortie-preparation-checkpoint.mjs new file mode 100644 index 0000000..5131836 --- /dev/null +++ b/scripts/verify-sortie-preparation-checkpoint.mjs @@ -0,0 +1,658 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createServer } from 'vite'; + +const storage = new Map(); +let failCanonicalSlotWrites = false; +let storageReadCount = 0; + +globalThis.window = { + localStorage: { + getItem(key) { + storageReadCount += 1; + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + if ( + failCanonicalSlotWrites && + String(key).includes(':slot-') + ) { + throw new Error( + 'Simulated canonical campaign save-slot failure.' + ); + } + storage.set(String(key), String(value)); + }, + removeItem(key) { + storage.delete(String(key)); + }, + clear() { + storage.clear(); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const campaign = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const battleSaves = await server.ssrLoadModule( + '/src/game/state/battleSaveStorage.ts' + ); + + verifyDefeatPreparationDurability(); + verifyVictoryPreparationAndStoryAtomicity(); + verifyCanonicalFailureAtomicity(); + verifyStaleCheckpointNormalization(); + verifySceneRoutingContracts(); + + console.log( + 'Verified durable sortie-improvement checkpoints for defeat retry and victory next-sortie flows: atomic story intent, title-over-battle-save priority, camp restoration, launch-time consumption, canonical-write failure invariance, stale-state normalization, and redundant default-story write avoidance.' + ); + + function verifyDefeatPreparationDurability() { + resetToBattle(); + campaign.setFirstBattleReport( + reportFixture('defeat') + ); + const before = + campaign.getCampaignState(); + const prepared = + campaign.beginCampaignSortiePreparation({ + mode: 'retry', + sourceBattleId: + 'first-battle-zhuo-commandery', + targetBattleId: + 'first-battle-zhuo-commandery' + }); + + assert.deepEqual( + preparationProjection(prepared), + { + intent: 'sortie-improvement', + mode: 'retry', + sourceBattleId: + 'first-battle-zhuo-commandery', + targetBattleId: + 'first-battle-zhuo-commandery', + targetCampaignStep: 'first-battle' + } + ); + assert.equal( + prepared.storyCheckpoint, + undefined, + 'Defeat preparation must not invent an aftermath story.' + ); + assertPersistedState( + prepared, + 'defeat retry preparation' + ); + + const readCountBeforeResume = + storageReadCount; + assert.equal( + battleSaves.readCampaignBattleResume( + prepared, + prepared.activeSaveSlot, + { + getItem() { + throw new Error( + 'A battle save must not be inspected while explicit preparation is pending.' + ); + }, + setItem() {}, + removeItem() {} + } + ), + undefined, + 'Explicit sortie preparation must outrank a prior battle resume.' + ); + assert.equal( + storageReadCount, + readCountBeforeResume, + 'The isolated resume probe unexpectedly touched browser storage.' + ); + + const reloaded = + campaign.loadCampaignState( + prepared.activeSaveSlot + ); + assert.deepEqual( + preparationProjection(reloaded), + preparationProjection(prepared), + 'Reload must preserve the exact retry preparation destination.' + ); + + const launched = campaign.markCampaignStep( + 'first-battle', + { startBattle: true } + ); + assert.equal( + launched.sortiePreparationCheckpoint, + undefined, + 'A committed retry launch must consume the preparation checkpoint.' + ); + assert.equal( + launched.battleResumeGeneration, + before.battleResumeGeneration + 1, + 'Retry launch must advance the battle-resume generation atomically.' + ); + assertPersistedState( + launched, + 'retry launch consumption' + ); + } + + function verifyVictoryPreparationAndStoryAtomicity() { + resetToBattle(); + campaign.setFirstBattleReport( + reportFixture('victory'), + { + storyCheckpoint: + storyWrite('default') + } + ); + const settled = + campaign.getCampaignState(); + const storyRevision = + settled.storyCheckpoint.revision; + const prepared = + campaign.beginCampaignSortiePreparation( + { + mode: 'next-sortie', + sourceBattleId: + 'first-battle-zhuo-commandery', + targetBattleId: + 'second-battle-yellow-turban-pursuit' + }, + { + storyCheckpoint: + storyWrite( + 'sortie-improvement' + ) + } + ); + + assert.deepEqual( + preparationProjection(prepared), + { + intent: 'sortie-improvement', + mode: 'next-sortie', + sourceBattleId: + 'first-battle-zhuo-commandery', + targetBattleId: + 'second-battle-yellow-turban-pursuit', + targetCampaignStep: 'second-battle' + } + ); + assert.equal( + prepared.storyCheckpoint.revision, + storyRevision, + 'Changing only the aftermath continuation intent must retain its exact story revision.' + ); + assert.equal( + prepared.storyCheckpoint + .continuationIntent, + 'sortie-improvement' + ); + assertPersistedState( + prepared, + 'victory improvement and aftermath story' + ); + + const handoff = + campaign.markCampaignStoryHandoff( + prepared.storyCheckpoint.sequenceId, + prepared.storyCheckpoint.revision + ); + const arrived = + campaign.completeCampaignStoryHandoffAftermath( + 'first-battle-zhuo-commandery', + { + sequenceId: + handoff.storyCheckpoint.sequenceId, + revision: + handoff.storyCheckpoint.revision + } + ); + assert.equal( + arrived.storyCheckpoint, + undefined + ); + assert( + arrived.sortiePreparationCheckpoint, + 'Camp arrival must retain the explicit improvement overlay checkpoint.' + ); + assertPersistedState( + arrived, + 'victory camp arrival' + ); + + const launched = + campaign.beginCampaignStory( + 'second-battle', + { + version: 1, + sequenceId: + 'second-battle-intro-fixture', + beatId: + 'second-battle-intro-fixture', + pageIndex: 0, + continuationIntent: 'default' + }, + { startBattle: true } + ); + assert.equal( + launched.sortiePreparationCheckpoint, + undefined, + 'Beginning the next sortie story must consume the preparation checkpoint in the same campaign write.' + ); + assert.equal( + launched.step, + 'second-battle' + ); + assertPersistedState( + launched, + 'next-sortie story launch consumption' + ); + } + + function verifyCanonicalFailureAtomicity() { + resetToBattle(); + campaign.setFirstBattleReport( + reportFixture('defeat') + ); + assertCanonicalWriteFailure( + () => + campaign.beginCampaignSortiePreparation({ + mode: 'retry', + sourceBattleId: + 'first-battle-zhuo-commandery', + targetBattleId: + 'first-battle-zhuo-commandery' + }), + 'defeat sortie preparation' + ); + + resetToBattle(); + campaign.setFirstBattleReport( + reportFixture('victory'), + { + storyCheckpoint: + storyWrite('default') + } + ); + assertCanonicalWriteFailure( + () => + campaign.beginCampaignSortiePreparation( + { + mode: 'next-sortie', + sourceBattleId: + 'first-battle-zhuo-commandery', + targetBattleId: + 'second-battle-yellow-turban-pursuit' + }, + { + storyCheckpoint: + storyWrite( + 'sortie-improvement' + ) + } + ), + 'victory sortie preparation and story intent' + ); + } + + function verifyStaleCheckpointNormalization() { + resetToBattle(); + campaign.setFirstBattleReport( + reportFixture('defeat') + ); + const valid = + campaign.beginCampaignSortiePreparation({ + mode: 'retry', + sourceBattleId: + 'first-battle-zhuo-commandery', + targetBattleId: + 'first-battle-zhuo-commandery' + }); + + for (const [label, mutate] of [ + [ + 'advanced campaign step', + (state) => { + state.step = 'second-battle'; + } + ], + [ + 'mismatched target campaign step', + (state) => { + state.sortiePreparationCheckpoint + .targetCampaignStep = + 'second-battle'; + } + ], + [ + 'forged non-next battle and matching step', + (state) => { + state.sortiePreparationCheckpoint + .targetBattleId = + 'third-battle-guangzong-road'; + state.sortiePreparationCheckpoint + .targetCampaignStep = + 'third-battle'; + } + ], + [ + 'wrong preparation mode', + (state) => { + state.sortiePreparationCheckpoint.mode = + 'next-sortie'; + } + ], + [ + 'invalid timestamp', + (state) => { + state.sortiePreparationCheckpoint.savedAt = + 'not-a-time'; + } + ] + ]) { + const stale = structuredClone(valid); + mutate(stale); + const loaded = loadRawState(stale); + assert.equal( + loaded.sortiePreparationCheckpoint, + undefined, + `${label} must retire a stale sortie-preparation checkpoint.` + ); + } + } + + function verifySceneRoutingContracts() { + const titleSource = sourceFile( + 'src/game/scenes/TitleScene.ts' + ); + const continueSource = sourceSection( + titleSource, + 'private async continueGameAsync(', + 'private async continueStoryCheckpoint(' + ); + assertOrder( + continueSource, + 'await this.continueStoryCheckpoint(campaign)', + 'campaign.sortiePreparationCheckpoint', + 'Title must finish or resume aftermath story before restoring preparation.' + ); + assertOrder( + continueSource, + 'campaign.sortiePreparationCheckpoint', + 'readCampaignBattleResume(', + 'Explicit preparation must outrank an older battle save.' + ); + + const campSource = sourceFile( + 'src/game/scenes/CampScene.ts' + ); + const campInit = sourceSection( + campSource, + 'init(data?: CampSceneData)', + '\n create()' + ); + assert( + campInit.includes( + '.sortiePreparationCheckpoint' + ) && + campInit.includes( + 'durablePreparation.targetBattleId' + ) && + campInit.includes( + "durablePreparation.mode === 'retry'" + ), + 'Camp init must rebuild the exact improvement overlay and retry target from campaign state.' + ); + + const battleSource = sourceFile( + 'src/game/scenes/BattleScene.ts' + ); + const improvementSource = sourceSection( + battleSource, + 'private openResultSortieImprovement(', + 'private publishFirstBattleReport(' + ); + assertOrder( + improvementSource, + 'this.commitResultSortiePreparation(', + 'startLazyScene(this,', + 'Result navigation must not begin until its durable destination is committed.' + ); + const storyCommitSource = sourceSection( + battleSource, + 'private commitResultStoryCheckpoint(', + 'private handleResultContinue()' + ); + assert( + storyCommitSource.includes( + "current.continuationIntent ===\n continuationIntent" + ) && + storyCommitSource.includes( + 'return true;' + ), + 'An already exact default aftermath checkpoint must avoid a redundant storage write.' + ); + } + + function resetToBattle() { + failCanonicalSlotWrites = false; + storage.clear(); + const state = + campaign.createInitialCampaignState(); + state.step = 'first-battle'; + state.activeSaveSlot = 1; + return campaign.setCampaignState(state); + } + + function reportFixture(outcome) { + return { + battleId: + 'first-battle-zhuo-commandery', + battleTitle: '탁현 방어전', + outcome, + turnNumber: 3, + rewardGold: + outcome === 'victory' ? 100 : 0, + defeatedEnemies: + outcome === 'victory' ? 1 : 0, + totalEnemies: 1, + objectives: [], + units: [], + bonds: [], + itemRewards: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: + '2026-07-29T01:00:00.000Z' + }; + } + + function storyWrite(continuationIntent) { + return { + version: 1, + sequenceId: + 'first-aftermath-fixture', + beatId: + 'first-aftermath-fixture', + pageIndex: 0, + continuationIntent + }; + } + + function preparationProjection(state) { + const checkpoint = + state.sortiePreparationCheckpoint; + assert( + checkpoint, + 'Expected a sortie-preparation checkpoint.' + ); + return { + intent: checkpoint.intent, + mode: checkpoint.mode, + sourceBattleId: + checkpoint.sourceBattleId, + targetBattleId: + checkpoint.targetBattleId, + targetCampaignStep: + checkpoint.targetCampaignStep + }; + } + + function loadRawState(state) { + failCanonicalSlotWrites = false; + storage.clear(); + const serialized = JSON.stringify(state); + storage.set( + campaign.campaignStorageKey, + serialized + ); + storage.set( + slotKey(state.activeSaveSlot), + serialized + ); + return campaign.loadCampaignState( + state.activeSaveSlot + ); + } + + function assertPersistedState(expected, label) { + assert.deepEqual( + campaign.getCampaignState(), + expected, + `${label}: memory diverged from the returned campaign.` + ); + assert.deepEqual( + JSON.parse( + storage.get( + campaign.campaignStorageKey + ) + ), + expected, + `${label}: compatibility mirror diverged.` + ); + assert.deepEqual( + JSON.parse( + storage.get( + slotKey(expected.activeSaveSlot) + ) + ), + expected, + `${label}: canonical slot diverged.` + ); + } + + function assertCanonicalWriteFailure( + action, + label + ) { + const before = + persistentSnapshot(); + failCanonicalSlotWrites = true; + try { + assert.throws( + action, + /Simulated canonical campaign save-slot failure/, + `${label} must surface its canonical write failure.` + ); + } finally { + failCanonicalSlotWrites = false; + } + assert.deepEqual( + campaign.getCampaignState(), + before.state, + `${label} changed in-memory campaign state after failure.` + ); + assert.equal( + storage.get( + campaign.campaignStorageKey + ), + before.base, + `${label} changed the compatibility mirror after failure.` + ); + assert.equal( + storage.get( + slotKey(before.state.activeSaveSlot) + ), + before.slot, + `${label} changed the canonical slot after failure.` + ); + } + + function persistentSnapshot() { + const state = + campaign.getCampaignState(); + return { + state, + base: storage.get( + campaign.campaignStorageKey + ), + slot: storage.get( + slotKey(state.activeSaveSlot) + ) + }; + } + + function slotKey(slot) { + return `${campaign.campaignStorageKey}:slot-${slot}`; + } + + function sourceFile(path) { + return readFileSync( + new URL(`../${path}`, import.meta.url), + 'utf8' + ); + } + + function sourceSection( + source, + startMarker, + endMarker + ) { + const start = source.indexOf(startMarker); + assert( + start >= 0, + `Missing source marker "${startMarker}".` + ); + const end = source.indexOf( + endMarker, + start + startMarker.length + ); + assert( + end > start, + `Missing source marker "${endMarker}".` + ); + return source.slice(start, end); + } + + function assertOrder( + source, + first, + second, + message + ) { + const firstIndex = source.indexOf(first); + const secondIndex = + source.indexOf(second); + assert( + firstIndex >= 0 && + secondIndex > firstIndex, + message + ); + } +} finally { + await server.close(); +} diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 34a158a..3be454d 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -6,6 +6,8 @@ const checks = [ 'scripts/verify-campaign-flow-data.mjs', 'scripts/verify-campaign-save-normalization.mjs', 'scripts/verify-campaign-exploration-checkpoint-normalization.mjs', + 'scripts/verify-campaign-story-checkpoint-normalization.mjs', + 'scripts/verify-sortie-preparation-checkpoint.mjs', 'scripts/verify-campaign-settlement-idempotency.mjs', 'scripts/verify-camp-visit-resume-state.mjs', 'scripts/verify-campaign-completion.mjs', @@ -18,6 +20,7 @@ const checks = [ 'scripts/verify-battle-save-normalization.mjs', 'scripts/verify-battle-event-queue.mjs', 'scripts/verify-battle-save-resume-routing.mjs', + 'scripts/verify-battle-result-persistence-retry.mjs', 'scripts/verify-battle-usables.mjs', 'scripts/verify-sortie-synergy.mjs', 'scripts/verify-sortie-recommendations.mjs', diff --git a/scripts/verify-story-asset-data.mjs b/scripts/verify-story-asset-data.mjs index b6ca033..e7ea310 100644 --- a/scripts/verify-story-asset-data.mjs +++ b/scripts/verify-story-asset-data.mjs @@ -40,6 +40,7 @@ try { const errors = []; const storySources = collectStorySources(scenarioModule, battleScenarios); const uniquePageIds = new Set(); + const firstSourceByPageId = new Map(); const usedBackgroundKeys = new Set(); const selectedBackgroundKeys = new Set(); const selectedBackgroundUrlsByBase = new Map(); @@ -93,6 +94,17 @@ try { pages.forEach((page, index) => { pageCount += 1; + const firstSource = firstSourceByPageId.get(page.id); + if (firstSource) { + errors.push( + `story page id global uniqueness: duplicate "${page.id}" at ${source}[${index}], first used at ${firstSource}` + ); + } else { + firstSourceByPageId.set( + page.id, + `${source}[${index}]` + ); + } uniquePageIds.add(page.id); validateStoryPage({ errors, @@ -127,6 +139,11 @@ try { if (pageCount !== expectedStoryPageCount) { errors.push(`story page selection coverage: expected ${expectedStoryPageCount} pages, received ${pageCount}`); } + if (uniquePageIds.size !== pageCount) { + errors.push( + `story page id global uniqueness: expected ${pageCount} unique ids, received ${uniquePageIds.size}` + ); + } if (portraitSelectionMetrics.pages !== pageCount) { errors.push( `story portrait selection coverage: checked ${portraitSelectionMetrics.pages}/${pageCount} pages` diff --git a/scripts/verify-story-checkpoint-browser.mjs b/scripts/verify-story-checkpoint-browser.mjs new file mode 100644 index 0000000..2e3ee0d --- /dev/null +++ b/scripts/verify-story-checkpoint-browser.mjs @@ -0,0 +1,1794 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; +import { + startVerificationPreview, + stopVerificationPreview +} from './verification-preview-server.mjs'; + +const rendererNames = ['canvas', 'webgl']; +const renderer = + process.env.VERIFY_STORY_CHECKPOINT_RENDERER; +const legacyFirstVictoryStoryFixture = { + battleId: 'first-battle-zhuo-commandery', + battleTitle: '탁현 방어전', + rewardGold: 300, + alliedUnits: [ + { + id: 'liu-bei', + name: '유비', + faction: 'ally', + className: '군웅', + classKey: 'lord', + level: 3, + exp: 18, + hp: 32, + maxHp: 32, + attack: 9, + move: 4, + stats: { + might: 76, + intelligence: 78, + leadership: 84, + agility: 70, + luck: 88 + }, + equipment: { + weapon: { + itemId: 'twin-oath-blades', + level: 1, + exp: 18 + }, + armor: { + itemId: 'oath-robe', + level: 1, + exp: 12 + }, + accessory: { + itemId: 'peach-charm', + level: 1, + exp: 0 + } + }, + x: 1, + y: 15 + }, + { + id: 'guan-yu', + name: '관우', + faction: 'ally', + className: '의용 보병', + classKey: 'infantry', + level: 3, + exp: 26, + hp: 30, + maxHp: 30, + attack: 10, + move: 3, + stats: { + might: 96, + intelligence: 74, + leadership: 91, + agility: 72, + luck: 68 + }, + equipment: { + weapon: { + itemId: 'green-dragon-glaive', + level: 1, + exp: 26 + }, + armor: { + itemId: 'reinforced-lamellar', + level: 1, + exp: 10 + }, + accessory: { + itemId: 'war-manual', + level: 1, + exp: 0 + } + }, + x: 2, + y: 15 + }, + { + id: 'zhang-fei', + name: '장비', + faction: 'ally', + className: '창병', + classKey: 'spearman', + level: 3, + exp: 20, + hp: 30, + maxHp: 30, + attack: 10, + move: 3, + stats: { + might: 95, + intelligence: 42, + leadership: 83, + agility: 67, + luck: 61 + }, + equipment: { + weapon: { + itemId: 'serpent-spear', + level: 1, + exp: 20 + }, + armor: { + itemId: 'lamellar-armor', + level: 1, + exp: 8 + }, + accessory: { + itemId: 'bravery-token', + level: 1, + exp: 0 + } + }, + x: 1, + y: 16 + } + ], + bonds: [ + { + id: 'liu-bei__guan-yu', + unitIds: ['liu-bei', 'guan-yu'], + title: '도원결의', + level: 72, + exp: 20, + battleExp: 0, + description: + '같은 적을 노릴 때 공격 피해 보너스와 연계 공격 확률이 오른다.' + }, + { + id: 'liu-bei__zhang-fei', + unitIds: ['liu-bei', 'zhang-fei'], + title: '의형제', + level: 68, + exp: 12, + battleExp: 0, + description: + '공격 목표가 겹치면 공명 경험치가 빠르게 쌓인다.' + }, + { + id: 'guan-yu__zhang-fei', + unitIds: ['guan-yu', 'zhang-fei'], + title: '맹장 공명', + level: 64, + exp: 8, + battleExp: 0, + description: + '둘이 같은 전선을 압박하면 연계 공격의 기초 확률이 오른다.' + } + ], + pageIds: [ + 'first-victory-village', + 'first-victory-liu-bei', + 'first-victory-guan-yu', + 'first-victory-zhang-fei', + 'first-victory-next' + ] +}; + +if (!renderer) { + for (const requestedRenderer of rendererNames) { + const result = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url)], + { + cwd: process.cwd(), + env: { + ...process.env, + VERIFY_STORY_CHECKPOINT_RENDERER: + requestedRenderer + }, + stdio: 'inherit' + } + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } + process.exit(0); +} + +assert( + rendererNames.includes(renderer), + `Unsupported story checkpoint renderer "${renderer}".` +); + +const baseUrl = + process.env.VERIFY_STORY_CHECKPOINT_URL ?? + `http://127.0.0.1:${ + renderer === 'canvas' ? 41819 : 41820 + }/heros_web/`; +const targetUrl = withDebugOptions(baseUrl, renderer); +let serverProcess; +let browser; + +try { + serverProcess = await startVerificationPreview({ + targetUrl, + explicitUrl: Boolean( + process.env.VERIFY_STORY_CHECKPOINT_URL + ), + label: `${renderer} story checkpoint verification` + }); + browser = await chromium.launch({ + headless: + process.env.VERIFY_STORY_CHECKPOINT_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); + + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => + pageErrors.push(error.stack ?? error.message) + ); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + await page.goto(targetUrl, { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForTitle(page, false); + await assertDesktopRuntime(page, renderer); + + const started = await page.evaluate(() => { + const title = + window.__HEROS_DEBUG__?.scene('TitleScene'); + if ( + !title || + typeof title.startGame !== 'function' + ) { + return false; + } + title.startGame(); + return true; + }); + assert.equal( + started, + true, + `${renderer}: new campaign could not be started.` + ); + + const openingPage = await waitForStoryPage(page, 0); + assert.equal( + openingPage.totalPages, + 2, + `${renderer}: expected the short two-page opening story.` + ); + await assertStoryCheckpointMatchesPage( + page, + openingPage, + `${renderer}: opening page` + ); + + const interrupted = await advanceStoryPage(page, 1); + const interruptedIdentity = + storyPageIdentity(interrupted); + await assertStoryCheckpointMatchesPage( + page, + interrupted, + `${renderer}: interrupted page` + ); + + await reloadToTitle(page); + await clickContinue(page); + const resumed = await waitForStoryPage(page, 1); + assert.deepEqual( + storyPageIdentity(resumed), + interruptedIdentity, + `${renderer}: reload and Continue did not restore the exact story page.` + ); + await assertStoryCheckpointMatchesPage( + page, + resumed, + `${renderer}: exact resumed page` + ); + + const legacyFixture = await injectLegacyIndexCheckpoint( + page, + 1 + ); + assert.equal(legacyFixture.pageIndex, 1); + assert.equal(legacyFixture.beatId, null); + await reloadToTitle(page); + await clickContinue(page); + const legacyResumed = await waitForStoryPage(page, 1); + assert.deepEqual( + storyPageIdentity(legacyResumed), + interruptedIdentity, + `${renderer}: legacy index-only checkpoint did not resume the exact page.` + ); + const migratedCheckpoint = + await assertStoryCheckpointMatchesPage( + page, + legacyResumed, + `${renderer}: migrated legacy page` + ); + assert.equal( + migratedCheckpoint.version, + 1, + `${renderer}: legacy checkpoint version was not migrated.` + ); + assert.equal( + migratedCheckpoint.beatId, + legacyResumed.currentPageId, + `${renderer}: legacy checkpoint did not gain a stable beat ID.` + ); + + const tamperResult = + await tamperCheckpointAndDispatchPageHide(page); + assert.equal( + tamperResult.runtimePageIndex, + 1, + `${renderer}: storage tampering changed the live story page.` + ); + assert.equal( + tamperResult.beforeDispatch.current.pageIndex, + 0, + `${renderer}: current-save tamper fixture was not installed.` + ); + assert.equal( + tamperResult.beforeDispatch.slot.pageIndex, + 0, + `${renderer}: slot-save tamper fixture was not installed.` + ); + assert.equal( + tamperResult.afterDispatch.current.pageIndex, + 1, + `${renderer}: pagehide did not restore the current save to the live page.` + ); + assert.equal( + tamperResult.afterDispatch.slot.pageIndex, + 1, + `${renderer}: pagehide did not restore the slot save to the live page.` + ); + assert.equal( + tamperResult.afterDispatch.current.beatId, + legacyResumed.currentPageId + ); + assert.equal( + tamperResult.afterDispatch.slot.beatId, + legacyResumed.currentPageId + ); + assert.equal( + tamperResult.afterDispatch.current.phase, + 'reading' + ); + assert.equal( + tamperResult.afterDispatch.slot.phase, + 'reading' + ); + assert.deepEqual( + tamperResult.afterDispatch.current, + tamperResult.afterDispatch.slot, + `${renderer}: pagehide did not keep current and slot checkpoints identical.` + ); + + assert.equal( + legacyResumed.isLastPage, + true, + `${renderer}: the checkpoint completion fixture is not on its final page.` + ); + await installSceneStartGate(page, 'StoryScene'); + await page.keyboard.press('Space'); + await waitForSceneStartGate(page); + const pendingFinalHandoff = + await readCheckpointPhases(page); + assertDurableHandoffPending( + pendingFinalHandoff, + migratedCheckpoint, + `${renderer}: final-page handoff cleared before destination ownership` + ); + await releaseSceneStartGate(page); + await waitForVillageReady(page); + const consumed = await readCheckpointPhases(page); + assertNoStoryCheckpoint( + consumed, + `${renderer}: completed story checkpoint was not consumed` + ); + + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + await page.waitForTimeout(100); + const afterLatePageHide = + await readCheckpointPhases(page); + assertNoStoryCheckpoint( + afterLatePageHide, + `${renderer}: a late pagehide resurrected the completed story checkpoint` + ); + + await reloadToTitle(page); + await injectInterruptedStoryHandoff( + page, + migratedCheckpoint + ); + const injectedHandoff = + await readCheckpointPhases(page); + assertDurableHandoffPending( + injectedHandoff, + injectedHandoff.current, + `${renderer}: interrupted handoff fixture was not persisted` + ); + await reloadToTitle(page); + await installSceneStartGate(page, 'TitleScene'); + await clickContinue(page); + await waitForSceneStartGate(page); + const pendingResumedHandoff = + await readCheckpointPhases(page); + assertDurableHandoffPending( + pendingResumedHandoff, + pendingResumedHandoff.current, + `${renderer}: resumed handoff cleared before destination ownership` + ); + await releaseSceneStartGate(page); + await waitForVillageReady(page); + const resumedHandoff = + await readCheckpointPhases(page); + assertNoStoryCheckpoint( + resumedHandoff, + `${renderer}: a resumed final-page handoff was replayed or left pending` + ); + + await reloadToTitle(page); + const coexistenceFixture = + await injectStoryAndMatchingBattleResume(page); + await reloadToTitle(page); + await clickContinue(page); + const storyPreferredOverBattle = + await waitForStoryPage( + page, + coexistenceFixture.pageIndex + ); + assert.equal( + storyPreferredOverBattle.currentPageId, + coexistenceFixture.beatId, + `${renderer}: a matching-generation battle snapshot displaced the newer story checkpoint.` + ); + assert.equal( + ( + await page.evaluate( + () => + window.__HEROS_DEBUG__ + ?.activeScenes?.() ?? [] + ) + ).includes('BattleScene'), + false, + `${renderer}: Continue opened BattleScene instead of the durable story checkpoint.` + ); + + await reloadToTitle(page); + const legacyVictoryReading = + await seedLegacyFirstVictoryCheckpoint( + page, + { + phase: 'reading', + pageIndex: 2 + } + ); + await reloadToTitle(page); + await clickContinue(page); + let legacyVictoryStory = await waitForStoryPage( + page, + legacyVictoryReading.pageIndex + ); + assert.equal( + legacyVictoryStory.currentPageId, + legacyVictoryReading.beatId, + `${renderer}: legacy first-victory reading checkpoint did not restore its exact beat.` + ); + assert.equal( + legacyVictoryStory.totalPages, + legacyVictoryReading.totalPages, + `${renderer}: legacy first-victory story page count changed during resume.` + ); + for ( + let pageIndex = + legacyVictoryReading.pageIndex + 1; + pageIndex < legacyVictoryReading.totalPages; + pageIndex += 1 + ) { + legacyVictoryStory = await advanceStoryPage( + page, + pageIndex + ); + } + assert.equal( + legacyVictoryStory.isLastPage, + true, + `${renderer}: legacy first-victory story did not reach its final beat.` + ); + + await installSceneStartGate(page, 'StoryScene'); + await page.keyboard.press('Space'); + await waitForSceneStartGate(page); + const pendingLegacyVictoryHandoff = + await readCampaignMirrors(page); + assertLegacyFirstVictoryCheckpoint( + pendingLegacyVictoryHandoff, + { + ...legacyVictoryReading, + phase: 'handoff', + pageIndex: + legacyVictoryReading.totalPages - 1, + beatId: legacyVictoryStory.currentPageId + }, + `${renderer}: completed legacy first-victory story` + ); + await releaseSceneStartGate(page); + await waitForTitle(page, true); + const completedLegacyVictory = + await readCampaignMirrors(page); + assertLegacyFirstVictoryTransition( + completedLegacyVictory, + legacyVictoryReading, + `${renderer}: Title-owned legacy first-victory handoff` + ); + assert.equal( + ( + await page.evaluate( + () => + window.__HEROS_DEBUG__ + ?.activeScenes?.() ?? [] + ) + ).includes('StoryScene'), + false, + `${renderer}: completed legacy first-victory story remained active after Title ownership.` + ); + + await clickContinue(page); + await waitForCampStep(page, 'first-camp'); + assert.equal( + ( + await page.evaluate( + () => + window.__HEROS_DEBUG__ + ?.activeScenes?.() ?? [] + ) + ).includes('StoryScene'), + false, + `${renderer}: Continue replayed the completed legacy first-victory story instead of opening Camp.` + ); + + await reloadToTitle(page); + const legacyVictoryHandoff = + await seedLegacyFirstVictoryCheckpoint( + page, + { + phase: 'handoff', + pageIndex: 4 + } + ); + await reloadToTitle(page); + const reloadedLegacyVictoryHandoff = + await readCampaignMirrors(page); + assertLegacyFirstVictoryCheckpoint( + reloadedLegacyVictoryHandoff, + legacyVictoryHandoff, + `${renderer}: directly reloaded legacy first-victory handoff` + ); + await clickContinue(page); + await waitForCampStep(page, 'first-camp'); + const resumedLegacyVictoryHandoff = + await readCampaignMirrors(page); + assertLegacyFirstVictoryTransition( + resumedLegacyVictoryHandoff, + legacyVictoryHandoff, + `${renderer}: directly resumed legacy first-victory handoff` + ); + assert.equal( + ( + await page.evaluate( + () => + window.__HEROS_DEBUG__ + ?.activeScenes?.() ?? [] + ) + ).includes('StoryScene'), + false, + `${renderer}: directly resumed legacy handoff replayed StoryScene.` + ); + + await assertDesktopRuntime(page, renderer); + assert.deepEqual( + pageErrors, + [], + `${renderer}: page errors: ${pageErrors.join('\n')}` + ); + assert.deepEqual( + consoleErrors, + [], + `${renderer}: console errors: ${consoleErrors.join( + '\n' + )}` + ); + + console.log( + JSON.stringify( + { + renderer, + viewport: { + ...desktopBrowserViewport, + deviceScaleFactor: + desktopBrowserDeviceScaleFactor, + browserZoom: '100%' + }, + storyCheckpoint: { + exactPageResume: { + pageIndex: resumed.pageIndex, + currentPageId: resumed.currentPageId, + speaker: resumed.currentSpeaker, + background: resumed.background, + portrait: resumed.portraitTextureKey + }, + runtimeCurrentSlotPhase: 'reading', + legacyIndexMigratedToBeatId: + migratedCheckpoint.beatId, + pageHideRestoredExactPage: true, + finalHandoffStayedUntilDestinationOwnership: + true, + finalHandoffConsumed: true, + resumedHandoffStayedUntilDestinationOwnership: + true, + interruptedFinalHandoffResumedToDestination: + true, + storyCheckpointPreferredOverMatchingBattleResume: + true, + latePageHideResurrectionPrevented: true, + legacyFirstVictoryReadingResumedAtBeat: + legacyVictoryReading.beatId, + legacyFirstVictoryTitleHandoffAdvancedToCamp: + true, + legacyFirstVictoryDidNotReplay: true, + legacyFirstVictoryReloadedHandoffAdvancedToCamp: + true + }, + pageErrors: pageErrors.length, + consoleErrors: consoleErrors.length + }, + null, + 2 + ) + ); +} finally { + await browser?.close(); + await stopVerificationPreview(serverProcess); +} + +function withDebugOptions(url, requestedRenderer) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('renderer', requestedRenderer); + return parsed.toString(); +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined, + undefined, + { timeout: 90000 } + ); +} + +async function waitForTitle(page, continueEnabled = true) { + await page.waitForFunction( + (requireContinue) => { + const debug = window.__HEROS_DEBUG__; + const title = debug?.title?.(); + const continueItem = + title?.focus?.items?.find( + ({ id }) => id === 'continue' + ); + return ( + debug?.activeScenes?.().includes('TitleScene') && + title?.scene === 'TitleScene' && + title?.navigating === false && + title?.focus?.scope === 'main' && + (!requireContinue || + (continueItem?.enabled === true && + Boolean(continueItem.bounds))) + ); + }, + continueEnabled, + { timeout: 90000 } + ); + await afterTwoFrames(page); +} + +async function reloadToTitle(page) { + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await waitForTitle(page, true); + await assertDesktopRuntime(page, renderer); +} + +async function clickContinue(page) { + const item = await page.evaluate( + () => + window.__HEROS_DEBUG__ + ?.title?.() + ?.focus?.items?.find( + ({ id }) => id === 'continue' + ) ?? null + ); + assert.equal( + item?.enabled, + true, + `${renderer}: Continue is unavailable.` + ); + assertFiniteBounds(item.bounds); + assertBoundsInsideViewport(item.bounds); + await page.mouse.click( + item.bounds.x + item.bounds.width / 2, + item.bounds.y + item.bounds.height / 2 + ); +} + +async function installSceneStartGate(page, sourceSceneKey) { + const installed = await page.evaluate((sceneKey) => { + const scene = + window.__HEROS_DEBUG__?.scene(sceneKey); + const plugin = scene?.scene; + if (!plugin || typeof plugin.start !== 'function') { + return false; + } + + const originalStart = plugin.start; + const gate = { + pending: null, + release() { + if (!gate.pending) { + throw new Error( + 'The gated scene start has not been requested.' + ); + } + const pending = gate.pending; + plugin.start = originalStart; + delete window.__HEROS_STORY_START_GATE__; + originalStart.call( + plugin, + pending.key, + pending.data + ); + } + }; + window.__HEROS_STORY_START_GATE__ = gate; + plugin.start = (key, data) => { + gate.pending = { key, data }; + return plugin; + }; + return true; + }, sourceSceneKey); + assert.equal( + installed, + true, + `${renderer}: could not install the destination-ownership gate on ${sourceSceneKey}.` + ); +} + +async function waitForSceneStartGate(page) { + await page.waitForFunction( + () => + Boolean( + window.__HEROS_STORY_START_GATE__?.pending + ), + undefined, + { timeout: 90000 } + ); + await afterTwoFrames(page); +} + +async function releaseSceneStartGate(page) { + await page.evaluate(() => { + const gate = + window.__HEROS_STORY_START_GATE__; + if (!gate) { + throw new Error( + 'The destination-ownership gate is unavailable.' + ); + } + gate.release(); + }); +} + +async function waitForStoryPage(page, expectedPageIndex) { + try { + await page.waitForFunction( + (pageIndex) => { + const debug = window.__HEROS_DEBUG__; + const scene = debug?.scene('StoryScene'); + const story = scene?.getDebugState?.(); + const checkpoint = + story?.storyCheckpoint?.persisted; + return ( + debug?.activeScenes?.().includes('StoryScene') && + story?.ready === true && + scene?.transitioning === false && + story.pageIndex === pageIndex && + Boolean(story.currentPageId) && + story.backgroundReady === true && + story.activeTexture === story.background && + checkpoint?.phase === 'reading' && + checkpoint?.pageIndex === pageIndex && + checkpoint?.beatId === story.currentPageId + ); + }, + expectedPageIndex, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + story: + window.__HEROS_DEBUG__ + ?.scene('StoryScene') + ?.getDebugState?.() ?? null + })); + throw new Error( + `${renderer}: story page ${expectedPageIndex} did not become ready: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); + return readStory(page); +} + +async function advanceStoryPage(page, expectedPageIndex) { + const before = await readStory(page); + assert.equal( + before?.pageIndex, + expectedPageIndex - 1, + `${renderer}: story was not on the preceding page.` + ); + await page.keyboard.press('Space'); + return waitForStoryPage(page, expectedPageIndex); +} + +async function readStory(page) { + return page.evaluate( + () => + window.__HEROS_DEBUG__ + ?.scene('StoryScene') + ?.getDebugState?.() ?? null + ); +} + +function storyPageIdentity(story) { + assert(story, 'Story debug state is required.'); + return { + pageIndex: story.pageIndex, + currentPageId: story.currentPageId, + currentSpeaker: story.currentSpeaker, + currentText: story.currentText, + requestedBackground: story.requestedBackground, + background: story.background, + activeTexture: story.activeTexture, + portraitKey: story.portraitKey, + portraitTextureKey: story.portraitTextureKey + }; +} + +async function readCheckpointPhases(page) { + return page.evaluate(() => { + const read = (key) => { + const raw = window.localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + }; + const story = + window.__HEROS_DEBUG__ + ?.scene('StoryScene') + ?.getDebugState?.() ?? null; + return { + story, + runtime: + story?.storyCheckpoint?.persisted ?? null, + current: + read('heros-web:campaign-state') + ?.storyCheckpoint ?? null, + slot: + read('heros-web:campaign-state:slot-1') + ?.storyCheckpoint ?? null + }; + }); +} + +async function assertStoryCheckpointMatchesPage( + page, + story, + label +) { + const checkpoints = + await readCheckpointPhases(page); + const runtime = checkpoints.runtime; + const current = checkpoints.current; + const slot = checkpoints.slot; + assert.equal( + story?.storyCheckpoint?.enabled, + true, + label + ); + for (const [source, checkpoint] of [ + ['runtime', runtime], + ['current', current], + ['slot', slot] + ]) { + assert.equal( + checkpoint?.version, + 1, + `${label}: ${source} version` + ); + assert.equal( + checkpoint?.sequenceId, + story.storyCheckpoint.sequenceId, + `${label}: ${source} sequence` + ); + assert.equal( + checkpoint?.beatId, + story.currentPageId, + `${label}: ${source} beat` + ); + assert.equal( + checkpoint?.pageIndex, + story.pageIndex, + `${label}: ${source} page` + ); + assert.equal( + checkpoint?.phase, + 'reading', + `${label}: ${source} phase` + ); + } + assert.deepEqual( + current, + slot, + `${label}: current and slot checkpoints differ.` + ); + return runtime; +} + +async function injectLegacyIndexCheckpoint( + page, + pageIndex +) { + return page.evaluate((legacyPageIndex) => { + const keys = [ + 'heros-web:campaign-state', + 'heros-web:campaign-state:slot-1' + ]; + let fixture; + for (const key of keys) { + const raw = window.localStorage.getItem(key); + if (!raw) { + throw new Error(`Missing campaign save ${key}`); + } + const save = JSON.parse(raw); + if (!save.storyCheckpoint) { + throw new Error( + `Missing story checkpoint in ${key}` + ); + } + const legacy = { + ...save.storyCheckpoint, + version: 0, + pageIndex: legacyPageIndex + }; + delete legacy.beatId; + save.storyCheckpoint = legacy; + window.localStorage.setItem( + key, + JSON.stringify(save) + ); + fixture ??= legacy; + } + return { + pageIndex: fixture.pageIndex, + beatId: fixture.beatId ?? null + }; + }, pageIndex); +} + +async function tamperCheckpointAndDispatchPageHide(page) { + return page.evaluate(() => { + const story = + window.__HEROS_DEBUG__ + ?.scene('StoryScene') + ?.getDebugState?.(); + if ( + !story || + story.pageIndex !== 1 || + !story.storyCheckpoint?.sequenceId + ) { + throw new Error( + 'The live second story page is unavailable.' + ); + } + const keys = [ + 'heros-web:campaign-state', + 'heros-web:campaign-state:slot-1' + ]; + const readCheckpoints = () => { + const values = keys.map((key) => { + const raw = window.localStorage.getItem(key); + return raw + ? JSON.parse(raw).storyCheckpoint ?? null + : null; + }); + return { + current: values[0], + slot: values[1] + }; + }; + for (const key of keys) { + const raw = window.localStorage.getItem(key); + if (!raw) { + throw new Error(`Missing campaign save ${key}`); + } + const save = JSON.parse(raw); + save.storyCheckpoint = { + ...save.storyCheckpoint, + beatId: story.storyCheckpoint.sequenceId, + pageIndex: 0, + phase: 'reading' + }; + window.localStorage.setItem( + key, + JSON.stringify(save) + ); + } + const beforeDispatch = readCheckpoints(); + window.dispatchEvent(new Event('pagehide')); + return { + runtimePageIndex: story.pageIndex, + beforeDispatch, + afterDispatch: readCheckpoints() + }; + }); +} + +async function injectInterruptedStoryHandoff( + page, + checkpoint +) { + await page.evaluate((sourceCheckpoint) => { + const keys = [ + 'heros-web:campaign-state', + 'heros-web:campaign-state:slot-1' + ]; + const savedAt = new Date( + Date.now() + 1000 + ).toISOString(); + for (const key of keys) { + const raw = window.localStorage.getItem(key); + if (!raw) { + throw new Error(`Missing campaign save ${key}`); + } + const save = JSON.parse(raw); + const revision = Math.max( + Number(save.storyCheckpointRevision) || 0, + Number(sourceCheckpoint.revision) || 0 + ) + 1; + save.step = 'prologue'; + save.completedTutorialIds = []; + delete save.explorationCheckpoint; + delete save.activeCityStayId; + delete save.activeCampVisitId; + save.storyCheckpointRevision = revision; + save.storyCheckpoint = { + ...sourceCheckpoint, + revision, + campaignStep: 'prologue', + phase: 'handoff', + savedAt + }; + save.updatedAt = savedAt; + window.localStorage.setItem( + key, + JSON.stringify(save) + ); + } + }, checkpoint); +} + +async function injectStoryAndMatchingBattleResume( + page +) { + return page.evaluate(() => { + const campaignKeys = [ + 'heros-web:campaign-state', + 'heros-web:campaign-state:slot-1' + ]; + const currentRaw = + window.localStorage.getItem(campaignKeys[0]); + if (!currentRaw) { + throw new Error( + 'Missing campaign save for the coexistence fixture.' + ); + } + const current = JSON.parse(currentRaw); + const revision = + Math.max( + Number( + current.storyCheckpointRevision + ) || 0, + Number( + current.storyCheckpoint?.revision + ) || 0 + ) + 1; + const generation = + (Number(current.battleResumeGeneration) || + 0) + 1; + const savedAt = new Date( + Date.now() + 2000 + ).toISOString(); + const sequenceId = + 'second-pursuit-rumor'; + const beatId = 'second-guan-yu-vow'; + const pageIndex = 2; + + for (const key of campaignKeys) { + const raw = + window.localStorage.getItem(key); + if (!raw) { + throw new Error( + `Missing campaign save ${key}` + ); + } + const save = JSON.parse(raw); + save.step = 'second-battle'; + save.activeSaveSlot = 1; + save.battleResumeGeneration = + generation; + save.storyCheckpointRevision = + revision; + save.storyCheckpoint = { + version: 1, + revision, + sequenceId, + phase: 'reading', + beatId, + pageIndex, + campaignStep: 'second-battle', + continuationIntent: 'default', + savedAt + }; + delete save.explorationCheckpoint; + delete save.activeCityStayId; + delete save.activeCampVisitId; + delete save.pendingAftermathBattleId; + save.updatedAt = savedAt; + window.localStorage.setItem( + key, + JSON.stringify(save) + ); + } + + window.localStorage.setItem( + 'heros-web:battle:second-battle-yellow-turban-pursuit:slot-1', + JSON.stringify({ + version: 1, + battleId: + 'second-battle-yellow-turban-pursuit', + campaignStep: 'second-battle', + battleResumeGeneration: generation, + savedAt: new Date( + Date.now() + 3000 + ).toISOString(), + turnNumber: 2, + activeFaction: 'ally', + rosterTab: 'ally', + actedUnitIds: [], + attackIntents: [], + battleLog: ['coexistence fixture'], + units: [ + { + id: 'liu-bei', + level: 1, + exp: 0, + hp: 30, + maxHp: 30, + attack: 10, + move: 4, + x: 1, + y: 1, + direction: 'south', + equipment: { + weapon: { + itemId: 'training-sword', + level: 1, + exp: 0 + }, + armor: { + itemId: 'cloth-armor', + level: 1, + exp: 0 + }, + accessory: { + itemId: 'grain-pouch', + level: 1, + exp: 0 + } + } + } + ], + bonds: [] + }) + ); + + return { + sequenceId, + beatId, + pageIndex, + generation + }; + }); +} + +async function seedLegacyFirstVictoryCheckpoint( + page, + { + phase, + pageIndex + } +) { + const prepared = await page.evaluate( + ({ + requestedPhase, + requestedPageIndex, + fixture + }) => { + if ( + !['reading', 'handoff'].includes( + requestedPhase + ) || + !Number.isInteger(requestedPageIndex) || + requestedPageIndex < 0 || + requestedPageIndex >= fixture.pageIds.length + ) { + throw new Error( + 'Invalid legacy first-victory checkpoint fixture.' + ); + } + + const campaignKeys = [ + 'heros-web:campaign-state', + 'heros-web:campaign-state:slot-1' + ]; + const currentRaw = + window.localStorage.getItem(campaignKeys[0]) ?? + window.localStorage.getItem(campaignKeys[1]); + if (!currentRaw) { + throw new Error( + 'Missing campaign save for the legacy first-victory fixture.' + ); + } + const campaign = JSON.parse(currentRaw); + const roster = + Array.isArray(campaign.roster) && + campaign.roster.length > 0 + ? structuredClone(campaign.roster) + : structuredClone(fixture.alliedUnits); + const bonds = + Array.isArray(campaign.bonds) && + campaign.bonds.length > 0 + ? structuredClone(campaign.bonds).map( + (bond) => ({ + ...bond, + battleExp: + Number.isFinite(bond.battleExp) + ? bond.battleExp + : 0 + }) + ) + : structuredClone(fixture.bonds); + const revision = + Math.max( + Number( + campaign.storyCheckpointRevision + ) || 0, + Number( + campaign.storyCheckpoint?.revision + ) || 0 + ) + 1; + const savedAt = new Date( + Date.now() + 4000 + ).toISOString(); + const checkpoint = { + version: 1, + revision, + sequenceId: fixture.pageIds[0], + phase: requestedPhase, + beatId: + fixture.pageIds[requestedPageIndex], + pageIndex: requestedPageIndex, + campaignStep: 'first-victory-story', + continuationIntent: 'default', + savedAt + }; + + campaign.step = 'first-victory-story'; + campaign.activeSaveSlot = 1; + campaign.roster = roster; + campaign.bonds = bonds; + campaign.storyCheckpointRevision = revision; + campaign.storyCheckpoint = checkpoint; + campaign.firstBattleReport = { + battleId: fixture.battleId, + battleTitle: fixture.battleTitle, + outcome: 'victory', + turnNumber: 6, + rewardGold: fixture.rewardGold, + defeatedEnemies: 1, + totalEnemies: 1, + objectives: [], + units: roster, + bonds, + itemRewards: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: + '2026-07-29T00:00:00.000Z' + }; + delete campaign.explorationCheckpoint; + delete campaign.activeCityStayId; + delete campaign.activeCampVisitId; + delete campaign.pendingAftermathBattleId; + campaign.updatedAt = savedAt; + + return { + campaignKeys, + serializedCampaign: + JSON.stringify(campaign), + expected: { + phase: requestedPhase, + sequenceId: checkpoint.sequenceId, + revision: checkpoint.revision, + pageIndex: checkpoint.pageIndex, + beatId: checkpoint.beatId, + totalPages: fixture.pageIds.length, + storyCheckpointRevision: revision + } + }; + }, + { + requestedPhase: phase, + requestedPageIndex: pageIndex, + fixture: legacyFirstVictoryStoryFixture + } + ); + + // Install the fixture in the next document, after the outgoing StoryScene + // has received pagehide. This prevents its legitimate final save from + // overwriting a storage-only migration fixture and keeps the release build + // free from source-module imports or test-only product hooks. + const marker = + `heros-story-fixture:${renderer}:${phase}:${pageIndex}:` + + `${Date.now()}:${Math.random()}`; + await page.addInitScript( + ({ + fixtureMarker, + campaignKeys, + serializedCampaign + }) => { + if ( + window.sessionStorage.getItem( + fixtureMarker + ) === 'installed' + ) { + return; + } + window.sessionStorage.setItem( + fixtureMarker, + 'installed' + ); + for (const key of campaignKeys) { + window.localStorage.setItem( + key, + serializedCampaign + ); + } + }, + { + fixtureMarker: marker, + campaignKeys: prepared.campaignKeys, + serializedCampaign: + prepared.serializedCampaign + } + ); + + return prepared.expected; +} + +async function readCampaignMirrors(page) { + return page.evaluate(() => { + const read = (key) => { + const raw = window.localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + }; + return { + current: read('heros-web:campaign-state'), + slot: read( + 'heros-web:campaign-state:slot-1' + ) + }; + }); +} + +function assertLegacyFirstVictoryCheckpoint( + mirrors, + expected, + label +) { + assert.deepEqual( + mirrors.current, + mirrors.slot, + `${label}: current and canonical slot diverged.` + ); + for (const [source, campaign] of [ + ['current', mirrors.current], + ['slot', mirrors.slot] + ]) { + assert.equal( + campaign?.step, + 'first-victory-story', + `${label}: ${source} campaign step` + ); + assert.equal( + campaign?.storyCheckpoint?.sequenceId, + expected.sequenceId, + `${label}: ${source} sequence` + ); + assert.equal( + campaign?.storyCheckpoint?.revision, + expected.revision, + `${label}: ${source} revision` + ); + assert.equal( + campaign?.storyCheckpoint?.phase, + expected.phase, + `${label}: ${source} phase` + ); + assert.equal( + campaign?.storyCheckpoint?.pageIndex, + expected.pageIndex, + `${label}: ${source} page` + ); + assert.equal( + campaign?.storyCheckpoint?.beatId, + expected.beatId, + `${label}: ${source} beat` + ); + } +} + +function assertLegacyFirstVictoryTransition( + mirrors, + sourceCheckpoint, + label +) { + assert.deepEqual( + mirrors.current, + mirrors.slot, + `${label}: current and canonical slot diverged.` + ); + for (const [source, campaign] of [ + ['current', mirrors.current], + ['slot', mirrors.slot] + ]) { + assert.equal( + campaign?.step, + 'first-camp', + `${label}: ${source} did not advance to first-camp` + ); + assert.equal( + campaign?.latestBattleId, + 'first-battle-zhuo-commandery', + `${label}: ${source} latest battle` + ); + assert.equal( + campaign?.storyCheckpoint, + undefined, + `${label}: ${source} retained the consumed checkpoint` + ); + assert.equal( + campaign?.storyCheckpointRevision, + sourceCheckpoint.revision + 1, + `${label}: ${source} did not invalidate the consumed revision` + ); + assert.equal( + campaign?.firstBattleReport?.outcome, + 'victory', + `${label}: ${source} lost the settled victory report` + ); + assert.equal( + campaign?.pendingAftermathBattleId, + undefined, + `${label}: ${source} retained a pending aftermath` + ); + } +} + +async function waitForCampStep(page, expectedStep) { + try { + await page.waitForFunction( + (step) => { + const debug = window.__HEROS_DEBUG__; + const activeScenes = + debug?.activeScenes?.() ?? []; + const camp = debug?.camp?.(); + return ( + activeScenes.includes('CampScene') && + !activeScenes.includes('StoryScene') && + camp?.scene === 'CampScene' && + camp?.campaign?.step === step && + Boolean( + camp?.campaignObjectiveJournal?.snapshot + ) + ); + }, + expectedStep, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + camp: + window.__HEROS_DEBUG__?.camp?.() ?? null, + title: + window.__HEROS_DEBUG__?.title?.() ?? null, + story: + window.__HEROS_DEBUG__ + ?.scene('StoryScene') + ?.getDebugState?.() ?? null + })); + throw new Error( + `${renderer}: CampScene did not reach ${expectedStep}: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); + await assertDesktopRuntime(page, renderer); +} + +function assertNoStoryCheckpoint(checkpoints, label) { + assert.equal(checkpoints.runtime, null, label); + assert.equal(checkpoints.current, null, label); + assert.equal(checkpoints.slot, null, label); +} + +function assertDurableHandoffPending( + checkpoints, + expected, + label +) { + for (const [source, checkpoint] of [ + ['current', checkpoints.current], + ['slot', checkpoints.slot] + ]) { + assert.equal( + checkpoint?.sequenceId, + expected?.sequenceId, + `${label}: ${source} sequence` + ); + assert.equal( + checkpoint?.revision, + expected?.revision, + `${label}: ${source} revision` + ); + assert.equal( + checkpoint?.phase, + 'handoff', + `${label}: ${source} phase` + ); + } + assert.deepEqual( + checkpoints.current, + checkpoints.slot, + `${label}: current and slot handoffs differ.` + ); +} + +async function waitForVillageReady(page) { + try { + await page.waitForFunction( + () => { + const debug = window.__HEROS_DEBUG__; + const village = debug?.village?.(); + return ( + debug + ?.activeScenes?.() + .includes('PrologueVillageScene') && + !debug + ?.activeScenes?.() + .includes('StoryScene') && + village?.scene === 'PrologueVillageScene' && + village?.ready === true && + village?.requiredTexturesReady === true + ); + }, + undefined, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + village: + window.__HEROS_DEBUG__?.village?.() ?? null, + story: + window.__HEROS_DEBUG__ + ?.scene('StoryScene') + ?.getDebugState?.() ?? null + })); + throw new Error( + `${renderer}: story did not hand off to the village: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); +} + +async function assertDesktopRuntime( + page, + expectedRenderer +) { + const runtime = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + return { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + devicePixelRatio: window.devicePixelRatio, + visualViewportScale: + window.visualViewport?.scale ?? 1, + rootCssZoom: + getComputedStyle(document.documentElement).zoom || + '1', + rendererType: + window.__HEROS_GAME__?.renderer?.type ?? null, + canvas: canvas + ? { + width: canvas.width, + height: canvas.height, + clientWidth: canvas.clientWidth, + clientHeight: canvas.clientHeight, + bounds: bounds + ? { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + } + : null + } + : null + }; + }); + assert.equal( + runtime.innerWidth, + desktopBrowserViewport.width + ); + assert.equal( + runtime.innerHeight, + desktopBrowserViewport.height + ); + assert.equal( + runtime.devicePixelRatio, + desktopBrowserDeviceScaleFactor + ); + assert.equal(runtime.visualViewportScale, 1); + assert( + runtime.rootCssZoom === '1' || + runtime.rootCssZoom === 'normal', + `Unexpected CSS zoom ${runtime.rootCssZoom}.` + ); + assert.equal( + runtime.rendererType, + expectedRenderer === 'canvas' ? 1 : 2 + ); + assert.equal( + runtime.canvas?.width, + desktopBrowserViewport.width + ); + assert.equal( + runtime.canvas?.height, + desktopBrowserViewport.height + ); + assert.equal( + runtime.canvas?.clientWidth, + desktopBrowserViewport.width + ); + assert.equal( + runtime.canvas?.clientHeight, + desktopBrowserViewport.height + ); + assert.equal( + runtime.canvas?.bounds?.width, + desktopBrowserViewport.width + ); + assert.equal( + runtime.canvas?.bounds?.height, + desktopBrowserViewport.height + ); +} + +function assertFiniteBounds(bounds) { + assert(bounds, 'Interactive bounds are required.'); + for (const value of [ + bounds.x, + bounds.y, + bounds.width, + bounds.height + ]) { + assert(Number.isFinite(value)); + } + assert(bounds.width > 0); + assert(bounds.height > 0); +} + +function assertBoundsInsideViewport(bounds) { + assert( + bounds.x >= 0 && + bounds.y >= 0 && + bounds.x + bounds.width <= + desktopBrowserViewport.width && + bounds.y + bounds.height <= + desktopBrowserViewport.height, + `Bounds are outside the FHD viewport: ${JSON.stringify( + bounds + )}` + ); +} + +async function afterTwoFrames(page) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(resolve) + ) + ) + ); +} diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 25548ac..5256739 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -945,3 +945,22 @@ export function getSortieFlow(latestBattleId?: string, campaignStep?: CampaignSt } return sortieFlows[latestBattleId ?? defaultBattleScenario.id] ?? sortieFlows[defaultBattleScenario.id]; } + +export function getSortieFlowForStorySequence( + sequenceId: string, + campaignStep?: CampaignStep +) { + return Object.values(sortieFlows).find( + (flow) => + flow.pages.some( + (page, index) => + index === 0 && + ( + page.id === sequenceId || + page.legacyBeatIds?.includes(sequenceId) + ) + ) && + (!campaignStep || + flow.campaignStep === campaignStep) + ); +} diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index d558b19..003b20c 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -6,6 +6,13 @@ export type PortraitKey = string; export type StoryPage = { id: string; + /** + * Previous stable IDs for this narrative beat. + * + * Story checkpoints always prefer `id`. Keep renamed IDs here so an older + * save can migrate to the current beat without falling back to page order. + */ + legacyBeatIds?: readonly string[]; chapter: string; background: string; speaker?: string; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 7517582..73fceec 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -181,10 +181,12 @@ import { type SortieOrderProgressEvaluationSnapshot } from '../data/sortieOrders'; import { + beginCampaignSortiePreparation, campaignSortiePresetIds, campaignSaveSlotCount, cloneCampaignSortieRecommendationSnapshot, completeCampaignTutorial, + consumeCampaignStoryHandoff, getCampaignState, hasCompletedCampaignTutorial, listCampaignSaveSlots, @@ -195,6 +197,7 @@ import { prologueMilitiaCampCampaignTutorialIds, readCampaignSaveState, saveCampaignState, + setCampaignStoryCheckpoint, setFirstBattleReport, type CampaignRewardSnapshot, type CampaignState, @@ -205,8 +208,17 @@ import { type CampaignSortiePerformanceSnapshot, type CampaignSortiePresetId, type CampaignSortieRecommendationSnapshot, - type CampaignStep + type CampaignStoryHandoffReference, + type CampaignStep, + type FirstBattleReport } from '../state/campaignState'; +import { + campaignStoryCheckpointVersion, + storyBeatIdForPage, + storySequenceIdForPages, + type CampaignStoryContinuationIntent, + type CampaignStoryCheckpointWrite +} from '../state/campaignStoryCheckpoint'; import { normalizeBattleSaveSlot, type BattleSaveCityEquipmentContributionState, @@ -1765,6 +1777,7 @@ type DeploymentSlot = { type BattleSceneData = { battleId?: string; resumeBattleSaveSlot?: number; + storyHandoff?: CampaignStoryHandoffReference; selectedSortieUnitIds?: string[]; sortieFormationAssignments?: SortieFormationAssignments; sortieItemAssignments?: CampaignSortieItemAssignments; @@ -2009,6 +2022,10 @@ type ResultButtonView = { type ResultButtonTone = 'primary' | 'secondary' | 'utility' | 'ghost'; type ResultButtonKind = 'continue' | 'retry' | 'title' | 'detail'; type ResultSettlementStatus = 'hidden' | 'pending' | 'animating' | 'complete'; +type ResultReportPersistenceStatus = + | 'idle' + | 'complete' + | 'failed'; type ThreatTile = { x: number; y: number; @@ -4028,6 +4045,8 @@ export class BattleScene extends Phaser.Scene { private resultSettlementProgress = 0; private resultSettlementTweens: Phaser.Tweens.Tween[] = []; private resultSettlementTransientObjects: Phaser.GameObjects.GameObject[] = []; + private resultReportPersistenceStatus: + ResultReportPersistenceStatus = 'idle'; private resultContinueButton?: ResultButtonView; private resultContinueLockedUntil = 0; private resultNavigationPending = false; @@ -4144,6 +4163,7 @@ export class BattleScene extends Phaser.Scene { private cooperationStatsByBond = new Map(); private launchCamaraderieMemoryBondId?: string; private launchCamaraderieMemoryBonus = 0; + private storyHandoff?: CampaignStoryHandoffReference; constructor() { super('BattleScene'); @@ -4151,6 +4171,8 @@ export class BattleScene extends Phaser.Scene { init(data?: BattleSceneData) { configureBattleScenario(getBattleScenario(data?.battleId)); + this.storyHandoff = data?.storyHandoff; + this.resultReportPersistenceStatus = 'idle'; const requestedResumeSlot = data?.resumeBattleSaveSlot; this.resumeBattleSaveSlot = typeof requestedResumeSlot === 'number' && Number.isInteger(requestedResumeSlot) ? normalizeBattleSaveSlot(requestedResumeSlot, campaignSaveSlotCount) @@ -4376,11 +4398,17 @@ export class BattleScene extends Phaser.Scene { campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'prologue-town' || - campaign.step === 'prologue-camp' || - campaign.step === 'first-battle' + campaign.step === 'prologue-camp' ) { - markCampaignStep('first-battle'); + markCampaignStep('first-battle', { + startBattle: true + }); } + // The handoff remains durable through preload and early create. Once the + // campaign step itself can recover directly to this battle, this scene owns + // and consumes the exact sequence/revision reference. + consumeCampaignStoryHandoff(this.storyHandoff); + this.storyHandoff = undefined; const { width, height } = this.scale; this.layout = this.createLayout(width, height); const startUnit = battleUnits.find((unit) => unit.id === 'liu-bei') ?? battleUnits[0]; @@ -15531,7 +15559,17 @@ export class BattleScene extends Phaser.Scene { title.setOrigin(0.5, 0); title.setDepth(depth + 2); - const subtitleText = this.resultSubtitle(outcome); + const subtitleText = [ + this.resultSubtitle(outcome), + this.resultReportPersistenceStatus === + 'failed' + ? '저장 실패 · 아래의 ‘저장 다시 시도’를 눌러 안전하게 계속하세요.' + : undefined + ] + .filter( + (line): line is string => Boolean(line) + ) + .join('\n'); const subtitle = this.trackResultObject(this.add.text(left + panelWidth / 2, top + 72, subtitleText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '17px', @@ -15683,7 +15721,10 @@ export class BattleScene extends Phaser.Scene { ); } - if (outcome === 'victory') { + if ( + outcome === 'victory' || + this.resultReportPersistenceStatus === 'failed' + ) { this.resultContinueButton = this.addResultButton( this.resultContinueCtaLabel(), left + panelWidth - 422, @@ -15695,7 +15736,7 @@ export class BattleScene extends Phaser.Scene { 'continue' ); } - this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { + const retryButton = this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { this.beginResultNavigation(() => { this.scene.restart({ battleId: battleScenario.id, @@ -15709,12 +15750,25 @@ export class BattleScene extends Phaser.Scene { : undefined }); }); - }, depth + 3, outcome === 'defeat' ? 'primary' : 'secondary', 'retry'); - this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { + }, depth + 3, outcome === 'defeat' && this.resultReportPersistenceStatus !== 'failed' ? 'primary' : 'secondary', 'retry'); + const titleButton = this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { this.beginResultNavigation(() => { this.scene.start('TitleScene'); }); }, depth + 3, 'ghost', 'title'); + if ( + this.resultReportPersistenceStatus === + 'failed' + ) { + this.setResultButtonEnabled( + retryButton, + false + ); + this.setResultButtonEnabled( + titleButton, + false + ); + } void this.playResultSettlementAnimation(animationToken); } @@ -15727,6 +15781,12 @@ export class BattleScene extends Phaser.Scene { } private resultContinueCtaLabel() { + if ( + this.resultReportPersistenceStatus === + 'failed' + ) { + return '저장 다시 시도'; + } return this.resultSettlementStatus === 'complete' ? this.resultContinueRoute()?.label ?? '군영으로' : '정산 완료하기'; @@ -15741,23 +15801,56 @@ export class BattleScene extends Phaser.Scene { if (!button.background.active) { return; } - if (enabled) { - button.background.setInteractive({ useHandCursor: true }); - } else { - button.background.disableInteractive(); - } - button.background.setAlpha(enabled ? 1 : 0.72); - button.label.setAlpha(enabled ? 1 : 0.72); + this.setResultButtonEnabled( + button, + enabled + ); }); } + private setResultButtonEnabled( + button: ResultButtonView, + enabled: boolean + ) { + if (enabled) { + button.background.setInteractive({ + useHandCursor: true + }); + } else { + button.background.disableInteractive(); + } + button.background.setAlpha( + enabled ? 1 : 0.5 + ); + button.label.setAlpha( + enabled ? 1 : 0.5 + ); + } + + private blockResultNavigationWhilePersistenceFailed() { + if ( + this.resultReportPersistenceStatus !== + 'failed' + ) { + return false; + } + this.renderSituationPanel( + '전투 결과 저장이 완료되지 않았습니다.\n‘저장 다시 시도’를 눌러 결과를 안전하게 보관해 주세요.' + ); + this.refreshResultContinueCta(); + return true; + } + private recoverResultNavigation() { this.resultNavigationPending = false; this.setResultNavigationButtonsEnabled(true); } private beginResultNavigation(navigate: () => void | Promise) { - if (this.resultNavigationPending) { + if ( + this.resultNavigationPending || + this.blockResultNavigationWhilePersistenceFailed() + ) { return false; } @@ -15776,11 +15869,115 @@ export class BattleScene extends Phaser.Scene { return true; } + private resultStoryCheckpoint( + continuationIntent: CampaignStoryContinuationIntent + ): Omit { + const pages = battleScenario.victoryPages; + const firstPage = pages[0]; + const sequenceId = + storySequenceIdForPages(pages); + if (!firstPage || !sequenceId) { + throw new Error( + 'The battle aftermath has no stable opening beat.' + ); + } + return { + version: campaignStoryCheckpointVersion, + sequenceId, + beatId: storyBeatIdForPage(firstPage), + pageIndex: 0, + continuationIntent + }; + } + + private commitResultStoryCheckpoint( + continuationIntent: CampaignStoryContinuationIntent + ) { + try { + const checkpoint = + this.resultStoryCheckpoint( + continuationIntent + ); + const currentCampaign = + getCampaignState(); + const current = + currentCampaign.storyCheckpoint; + if ( + current && + current.sequenceId === + checkpoint.sequenceId && + current.beatId === checkpoint.beatId && + current.pageIndex === 0 && + current.phase === 'reading' && + current.campaignStep === + currentCampaign.step && + current.continuationIntent === + continuationIntent + ) { + return true; + } + const campaign = setCampaignStoryCheckpoint({ + ...checkpoint, + ...(current?.sequenceId === + checkpoint.sequenceId + ? { revision: current.revision } + : {}) + }); + const saved = campaign.storyCheckpoint; + if ( + !saved || + saved.sequenceId !== checkpoint.sequenceId || + saved.beatId !== checkpoint.beatId || + saved.pageIndex !== 0 || + saved.phase !== 'reading' || + saved.continuationIntent !== + continuationIntent + ) { + throw new Error( + 'The battle aftermath checkpoint write was rejected.' + ); + } + return true; + } catch (error) { + console.error( + 'Failed to save the battle aftermath checkpoint.', + error + ); + this.renderSituationPanel( + '승리 이야기를 저장하지 못했습니다.\n저장 공간과 브라우저 권한을 확인한 뒤 다시 시도해 주세요.' + ); + return false; + } + } + private handleResultContinue() { if (this.resultNavigationPending) { return; } + if ( + this.resultReportPersistenceStatus === + 'failed' + ) { + soundDirector.playSelect(); + const outcome = this.battleOutcome; + if ( + !outcome || + !this.publishFirstBattleReport(outcome) + ) { + this.renderSituationPanel( + '전투 결과를 저장하지 못했습니다.\n저장 공간과 브라우저 권한을 확인한 뒤 다시 시도해 주세요.' + ); + this.refreshResultContinueCta(); + return; + } + this.renderSituationPanel( + '전투 결과를 안전하게 저장했습니다.\n결과를 확인한 뒤 승리 이야기로 이어갈 수 있습니다.' + ); + this.showBattleResult(outcome); + return; + } + if (this.resultSettlementStatus !== 'complete') { soundDirector.playSelect(); this.completeResultSettlement(true); @@ -15809,13 +16006,17 @@ export class BattleScene extends Phaser.Scene { if (!this.resultContinueRoute()) { return; } + if (!this.commitResultStoryCheckpoint('default')) { + return; + } this.beginResultNavigation(() => startLazyScene(this, 'StoryScene', { pages: battleScenario.victoryPages, nextScene: battleScenario.nextCampScene, nextSceneData: { completedAftermathBattleId: battleScenario.id }, presentationBattleId: battleScenario.id, - presentationStage: 'aftermath' + presentationStage: 'aftermath', + continuationIntent: 'default' })); } @@ -16094,7 +16295,10 @@ export class BattleScene extends Phaser.Scene { } private openResultSortieImprovement(outcome: BattleOutcome) { - if (this.resultNavigationPending) { + if ( + this.resultNavigationPending || + this.blockResultNavigationWhilePersistenceFailed() + ) { return; } const targetBattleId = this.resultSortieImprovementTargetBattleId(outcome); @@ -16111,6 +16315,14 @@ export class BattleScene extends Phaser.Scene { } : {}) }; + if ( + !this.commitResultSortiePreparation( + outcome, + targetBattleId + ) + ) { + return; + } this.hideResultFormationReview(); if (outcome === 'victory') { this.beginResultNavigation(() => startLazyScene(this, 'StoryScene', { @@ -16121,13 +16333,82 @@ export class BattleScene extends Phaser.Scene { completedAftermathBattleId: battleScenario.id }, presentationBattleId: battleScenario.id, - presentationStage: 'aftermath' + presentationStage: 'aftermath', + continuationIntent: + 'sortie-improvement' })); return; } this.beginResultNavigation(() => startLazyScene(this, 'CampScene', campSceneData)); } + private commitResultSortiePreparation( + outcome: BattleOutcome, + targetBattleId: BattleScenarioId + ) { + try { + const campaign = + beginCampaignSortiePreparation( + { + mode: + outcome === 'defeat' + ? 'retry' + : 'next-sortie', + sourceBattleId: + battleScenario.id, + targetBattleId + }, + outcome === 'victory' + ? { + storyCheckpoint: + this.resultStoryCheckpoint( + 'sortie-improvement' + ) + } + : undefined + ); + const saved = + campaign.sortiePreparationCheckpoint; + if ( + !saved || + saved.intent !== + 'sortie-improvement' || + saved.mode !== + (outcome === 'defeat' + ? 'retry' + : 'next-sortie') || + saved.sourceBattleId !== + battleScenario.id || + saved.targetBattleId !== + targetBattleId + ) { + throw new Error( + 'The sortie-improvement checkpoint write was rejected.' + ); + } + if ( + outcome === 'victory' && + campaign.storyCheckpoint + ?.continuationIntent !== + 'sortie-improvement' + ) { + throw new Error( + 'The sortie-improvement aftermath checkpoint was not committed.' + ); + } + return true; + } catch (error) { + console.error( + 'Failed to save the sortie-improvement destination.', + error + ); + this.renderSituationPanel( + '출전 보완 위치를 저장하지 못했습니다.\n저장 공간과 브라우저 권한을 확인한 뒤 다시 시도해 주세요.' + ); + return false; + } + } + private publishFirstBattleReport(outcome: BattleOutcome) { const objectives = this.resultObjectives(outcome); const mvp = this.resultMvp(); @@ -16149,7 +16430,10 @@ export class BattleScene extends Phaser.Scene { ? this.resultSortieOrderEvaluation(outcome, objectives, sortiePerformance, formationReview) : undefined; - const persistedReport = setFirstBattleReport({ + this.resultSortieOrder = sortieOrder; + let persistedReport: FirstBattleReport; + try { + persistedReport = setFirstBattleReport({ battleId: battleScenario.id, battleTitle: battleScenario.title, outcome, @@ -16187,11 +16471,28 @@ export class BattleScene extends Phaser.Scene { completedCampDialogues: [], completedCampVisits: [], createdAt: new Date().toISOString() - }); + }, outcome === 'victory' + ? { + storyCheckpoint: + this.resultStoryCheckpoint('default') + } + : undefined); + } catch (error) { + this.resultReportPersistenceStatus = + 'failed'; + console.error( + 'Failed to persist the battle result. The player may retry from the result screen.', + error + ); + return false; + } if (outcome === 'victory') { clearBattleSaveStorageForBattle(battleScenario.id, getCampaignState().activeSaveSlot); } this.resultSortieOrder = persistedReport.sortieOrder ?? sortieOrder; + this.resultReportPersistenceStatus = + 'complete'; + return true; } private resultSubtitle(outcome: BattleOutcome) { @@ -16808,14 +17109,21 @@ export class BattleScene extends Phaser.Scene { review.gradeColor, depth + 1 ); + const improvementEnabled = + this.resultReportPersistenceStatus !== + 'failed'; const improvementButton = recommendationOutcome && improvementTargetBattleId ? this.addResultFormationReviewButton( - outcome === 'defeat' ? '재도전 보완' : '다음 출전 보완', + improvementEnabled + ? outcome === 'defeat' + ? '재도전 보완' + : '다음 출전 보완' + : '저장 후 보완', x + 456, analysisY + 18, 146, 36, - true, + improvementEnabled, false, () => this.openResultSortieImprovement(outcome), depth + 2, @@ -22618,7 +22926,8 @@ export class BattleScene extends Phaser.Scene { ]) ), validTriggeredBattleEventIds: this.validTriggeredBattleEventIds() - } + }, + savedCampaign ); return candidate?.state; } @@ -22718,10 +23027,13 @@ export class BattleScene extends Phaser.Scene { private createBattleSaveState(): BattleSaveState { const deployedUnitIds = new Set(battleUnits.map((unit) => unit.id)); + const campaign = getCampaignState(); return { version: 1, battleId: battleScenario.id, - campaignStep: getCampaignState().step, + campaignStep: campaign.step, + battleResumeGeneration: + campaign.battleResumeGeneration, sortieOrderId: this.launchSortieOrderId, sortieResonanceBondId: this.launchSortieResonanceBondId, sortieRecommendation: this.launchSortieRecommendation @@ -32423,11 +32735,16 @@ export class BattleScene extends Phaser.Scene { const route = this.battleOutcome === 'victory' ? this.resultContinueRoute() : undefined; const ctaState = !button ? 'hidden' - : this.resultSettlementStatus === 'complete' - ? 'navigation' - : 'settlement'; + : this.resultReportPersistenceStatus === + 'failed' + ? 'persistence-retry' + : this.resultSettlementStatus === 'complete' + ? 'navigation' + : 'settlement'; return { status: this.resultSettlementStatus, + reportPersistence: + this.resultReportPersistenceStatus, skipped: this.resultSettlementSkipped, progress: this.resultSettlementProgress, total: this.resultGaugeAnimations.length, @@ -34054,10 +34371,15 @@ export class BattleScene extends Phaser.Scene { targetBattleId: resultImprovementTargetBattleId ?? null, retry: this.battleOutcome === 'defeat', planId: resultRecommendationOutcome.planId, - weakUnitIds: resultRecommendationOutcome.unitContributions - .filter((entry) => entry.status !== 'hit') - .map((entry) => entry.unitId), - buttonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewView?.improvementButton) + weakUnitIds: resultRecommendationOutcome.unitContributions + .filter((entry) => entry.status !== 'hit') + .map((entry) => entry.unitId), + buttonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewView?.improvementButton), + interactive: Boolean( + this.resultFormationReviewView + ?.improvementButton?.input + ?.enabled + ) } : null, toggleButtonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewButton?.background), diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index b4bf242..0a6ed47 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -217,17 +217,21 @@ import { zhugeRecruitBonds, zhugeRecruitUnits, type BattleBond, + type StoryPage, type UnitData } from '../data/scenario'; import { acknowledgeCampaignVictoryReward, applyCampVisitReward, applyCampBondExp, + beginCampaignStory, campaignInventoryCap, campaignVictoryRewardPresentation, campaignSortiePresetIds, campaignReserveTrainingFocusDefinitions, completeCampaignAftermath, + completeCampaignStoryHandoffAftermath, + consumeCampaignStoryHandoff, campaignCampVisitIds, defaultCampaignReserveTrainingFocusId, dismissCampaignVictoryRewardNotice, @@ -259,10 +263,17 @@ import { type CampaignSortiePerformanceSnapshot, type CampaignSortiePresetId, type CampaignSortieRecommendationSnapshot, + type CampaignStoryHandoffReference, type CampaignVictoryRewardCategoryId, type CampaignVictoryRewardReport, type FirstBattleReport } from '../state/campaignState'; +import { + campaignStoryCheckpointVersion, + storyBeatIdForPage, + storySequenceIdForPages, + type CampaignStoryCheckpointWrite +} from '../state/campaignStoryCheckpoint'; import { resolveCampaignObjectiveJournal, type CampaignObjectiveJournalEntry @@ -984,6 +995,7 @@ type CampaignTimelineChapter = { }; type CampSceneData = { + storyHandoff?: CampaignStoryHandoffReference; openSortiePrep?: boolean; openSortieImprovement?: boolean; openEquipmentCityStayId?: CityStayId; @@ -11598,15 +11610,48 @@ export class CampScene extends Phaser.Scene { this.thirdCampPreparationKeyboardIndex = 0; this.thirdCampIncompatibleSortieConfirmSignature = undefined; this.firstBattleCamaraderieSortieConfirmSignature = undefined; - if (data?.completedAftermathBattleId) { - completeCampaignAftermath(data.completedAftermathBattleId); + if ( + data?.completedAftermathBattleId && + data.storyHandoff + ) { + completeCampaignStoryHandoffAftermath( + data.completedAftermathBattleId, + data.storyHandoff + ); + } else { + if (data?.completedAftermathBattleId) { + completeCampaignAftermath( + data.completedAftermathBattleId + ); + } + consumeCampaignStoryHandoff( + data?.storyHandoff + ); } - this.openSortiePrepOnCreate = Boolean(data?.openSortiePrep); - this.openSortieImprovementOnCreate = Boolean(data?.openSortieImprovement); + const durablePreparation = + getCampaignState() + .sortiePreparationCheckpoint; + this.openSortiePrepOnCreate = Boolean( + data?.openSortiePrep || + durablePreparation + ); + this.openSortieImprovementOnCreate = Boolean( + data?.openSortieImprovement || + durablePreparation?.intent === + 'sortie-improvement' + ); this.openEquipmentCityStayId = data?.openEquipmentCityStayId; this.openEquipmentOfferId = data?.openEquipmentOfferId; - this.retrySortieBattleId = data?.retryBattleId; - this.skipIntroStoryForSortie = Boolean(data?.skipIntroStory); + this.retrySortieBattleId = + durablePreparation + ? durablePreparation.mode === 'retry' + ? durablePreparation.targetBattleId + : undefined + : data?.retryBattleId; + this.skipIntroStoryForSortie = + durablePreparation + ? durablePreparation.mode === 'retry' + : Boolean(data?.skipIntroStory); } create() { @@ -21508,12 +21553,17 @@ export class CampScene extends Phaser.Scene { return; } if (flow.pages.length > 0) { - this.startCampNavigation('StoryScene', { - pages: flow.pages, - nextScene: 'CampScene', - presentationBattleId: flow.afterBattleId, - presentationStage: 'story' - }); + this.startCampNavigationWithCampaignStep( + this.campaign?.step ?? 'new', + 'StoryScene', + { + pages: flow.pages, + nextScene: 'CampScene', + presentationBattleId: + flow.afterBattleId, + presentationStage: 'story' + } + ); return; } this.showCampNotice(flow.unavailableNotice ?? '현재 군영 정비를 먼저 마치세요. 대화, 보급, 편성을 점검할 수 있습니다.'); @@ -21566,6 +21616,42 @@ export class CampScene extends Phaser.Scene { }); } + private storyCheckpointForNavigation( + data?: Record + ): Omit< + CampaignStoryCheckpointWrite, + 'revision' + > | undefined { + if (!Array.isArray(data?.pages)) { + return undefined; + } + const pages = data.pages as StoryPage[]; + const firstPage = pages[0]; + const sequenceId = storySequenceIdForPages(pages); + if (!firstPage || !sequenceId) { + return undefined; + } + const nextSceneData = + data.nextSceneData && + typeof data.nextSceneData === 'object' && + !Array.isArray(data.nextSceneData) + ? data.nextSceneData as Record + : undefined; + const continuationIntent = + data.continuationIntent === + 'sortie-improvement' || + nextSceneData?.openSortieImprovement === true + ? 'sortie-improvement' + : 'default'; + return { + version: campaignStoryCheckpointVersion, + sequenceId, + beatId: storyBeatIdForPage(firstPage), + pageIndex: 0, + continuationIntent + }; + } + private startCampNavigationWithCampaignStep( step: CampaignStep, key: LazySceneKey, @@ -21575,19 +21661,48 @@ export class CampScene extends Phaser.Scene { return; } - const previousStep = getCampaignState().step; + const previousCampaign = getCampaignState(); + const storyCheckpoint = + key === 'StoryScene' + ? this.storyCheckpointForNavigation(data) + : undefined; + const startsBattle = + key === 'BattleScene' || + (key === 'StoryScene' && + data?.nextScene === 'BattleScene'); this.startCampNavigation( key, data, () => { - markCampaignStep(step); - this.campaign = getCampaignState(); + if (storyCheckpoint) { + this.campaign = beginCampaignStory( + step, + storyCheckpoint, + { startBattle: startsBattle } + ); + if ( + this.campaign.storyCheckpoint + ?.sequenceId !== + storyCheckpoint.sequenceId || + this.campaign.storyCheckpoint.beatId !== + storyCheckpoint.beatId || + this.campaign.storyCheckpoint.phase !== + 'reading' + ) { + throw new Error( + 'The story checkpoint could not be committed.' + ); + } + } else { + markCampaignStep(step, { + startBattle: startsBattle + }); + this.campaign = getCampaignState(); + } }, () => { - if (getCampaignState().step === step) { - markCampaignStep(previousStep); - } - this.campaign = getCampaignState(); + this.campaign = + saveCampaignState(previousCampaign); } ); } @@ -28614,6 +28729,10 @@ export class CampScene extends Phaser.Scene { } : null, openSortiePrepOnCreate: this.openSortiePrepOnCreate, + openSortieImprovementOnCreate: + this.openSortieImprovementOnCreate, + retrySortieBattleId: + this.retrySortieBattleId ?? null, skipIntroStoryForSortie: this.skipIntroStoryForSortie, availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id), completedAvailableDialogues: this.completedAvailableDialogues().map((dialogue) => dialogue.id), @@ -28636,6 +28755,10 @@ export class CampScene extends Phaser.Scene { sortieFormationAssignments: this.campaign.sortieFormationAssignments, sortieItemAssignments: this.campaign.sortieItemAssignments, sortieResonanceSelection: this.campaign.sortieResonanceSelection ?? null, + sortiePreparationCheckpoint: + this.campaign + .sortiePreparationCheckpoint ?? + null, activeSaveSlot: this.campaign.activeSaveSlot, acknowledgedVictoryRewardCategories: Object.fromEntries( Object.entries( diff --git a/src/game/scenes/EndingScene.ts b/src/game/scenes/EndingScene.ts index 2697937..0cd6a79 100644 --- a/src/game/scenes/EndingScene.ts +++ b/src/game/scenes/EndingScene.ts @@ -2,13 +2,21 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { getBattleScenario } from '../data/battles'; import { storyBackgroundAssets } from '../data/storyAssets'; -import { getCampaignState } from '../state/campaignState'; +import { + consumeCampaignStoryHandoff, + getCampaignState, + type CampaignStoryHandoffReference +} from '../state/campaignState'; import { palette } from '../ui/palette'; const fhdUiScale = 1.5; const ui = (value: number) => value * fhdUiScale; const uiPx = (value: number) => `${Math.round(ui(value))}px`; +type EndingSceneData = { + storyHandoff?: CampaignStoryHandoffReference; +}; + export class EndingScene extends Phaser.Scene { private readonly backgroundKey = 'story-wuzhang-jiangwei-inheritance'; private ready = false; @@ -18,7 +26,8 @@ export class EndingScene extends Phaser.Scene { super('EndingScene'); } - create() { + create(data?: EndingSceneData) { + consumeCampaignStoryHandoff(data?.storyHandoff); this.ready = false; this.navigating = false; this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.returnToTitle(event)); diff --git a/src/game/scenes/PrologueMilitiaCampScene.ts b/src/game/scenes/PrologueMilitiaCampScene.ts index 1ab24a3..98b5c45 100644 --- a/src/game/scenes/PrologueMilitiaCampScene.ts +++ b/src/game/scenes/PrologueMilitiaCampScene.ts @@ -34,6 +34,7 @@ import { } from '../data/explorationCharacterAssets'; import { completeCampaignTutorial, + consumeCampaignStoryHandoff, getCampaignState, hasCompletedCampaignTutorial, markCampaignStep, @@ -43,8 +44,14 @@ import { setCampaignSortieOrderSelection, type CampaignExplorationCheckpoint, type CampaignExplorationDialogueKey, + type CampaignStoryHandoffReference, type CampaignTutorialId } from '../state/campaignState'; +import { + campaignStoryCheckpointVersion, + storyBeatIdForPage, + storySequenceIdForPages +} from '../state/campaignStoryCheckpoint'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; @@ -129,6 +136,10 @@ const characterTextureKeys = Object.freeze( ])) ); +type PrologueMilitiaCampSceneData = { + storyHandoff?: CampaignStoryHandoffReference; +}; + type CampNpcView = { definition: PrologueMilitiaCampNpcDefinition; textureKey: ExplorationCharacterTextureKey; @@ -285,10 +296,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { }); } - create() { + create(data?: PrologueMilitiaCampSceneData) { this.resetRuntimeState(); this.visualMotionReduced = isVisualMotionReduced(); this.prepareCampaignProgress(); + consumeCampaignStoryHandoff(data?.storyHandoff); window.addEventListener( 'pagehide', this.persistCheckpointOnPageHide @@ -2971,16 +2983,46 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { void (async () => { try { - completeCampaignTutorial( + const pages = prologueDeparturePages(); + const firstPage = pages[0]; + const sequenceId = + storySequenceIdForPages(pages); + if (!firstPage || !sequenceId) { + throw new Error( + 'The departure story has no stable opening beat.' + ); + } + const campaign = completeCampaignTutorial( prologueMilitiaCampCampaignTutorialIds.complete, { clearExplorationCheckpointScene: - 'prologue-militia-camp' + 'prologue-militia-camp', + storyCheckpoint: { + version: + campaignStoryCheckpointVersion, + sequenceId, + beatId: + storyBeatIdForPage(firstPage), + pageIndex: 0, + continuationIntent: 'default' + } } ); + if ( + !campaign.completedTutorialIds.includes( + prologueMilitiaCampCampaignTutorialIds.complete + ) || + campaign.storyCheckpoint?.sequenceId !== + sequenceId || + campaign.storyCheckpoint.phase !== 'reading' + ) { + throw new Error( + 'The departure story handoff could not be saved.' + ); + } this.refreshObjectiveHud(); await startGameScene(this, 'StoryScene', { - pages: prologueDeparturePages(), + pages, nextScene: 'BattleScene', nextSceneData: { battleId: 'first-battle-zhuo-commandery' diff --git a/src/game/scenes/PrologueVillageScene.ts b/src/game/scenes/PrologueVillageScene.ts index d4feeea..0b98317 100644 --- a/src/game/scenes/PrologueVillageScene.ts +++ b/src/game/scenes/PrologueVillageScene.ts @@ -29,6 +29,7 @@ import { import { musicVolumeMatchedToCampTrack } from '../data/campSoundscapes'; import { completeCampaignTutorial, + consumeCampaignStoryHandoff, getCampaignState, hasCompletedCampaignTutorial, markCampaignStep, @@ -37,8 +38,14 @@ import { setCampaignExplorationCheckpoint, type CampaignExplorationCheckpoint, type CampaignExplorationDialogueKey, + type CampaignStoryHandoffReference, type CampaignTutorialId } from '../state/campaignState'; +import { + campaignStoryCheckpointVersion, + storyBeatIdForPage, + storySequenceIdForPages +} from '../state/campaignStoryCheckpoint'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; @@ -125,6 +132,10 @@ const characterTextureKeys = Object.freeze( ])) ); +type PrologueVillageSceneData = { + storyHandoff?: CampaignStoryHandoffReference; +}; + type VillageNpcView = { definition: PrologueVillageNpcDefinition; textureKey: ExplorationCharacterTextureKey; @@ -292,10 +303,11 @@ export class PrologueVillageScene extends Phaser.Scene { }); } - create() { + create(data?: PrologueVillageSceneData) { this.resetRuntimeState(); this.visualMotionReduced = isVisualMotionReduced(); this.prepareCampaignProgress(); + consumeCampaignStoryHandoff(data?.storyHandoff); window.addEventListener( 'pagehide', this.persistCheckpointOnPageHide @@ -2577,16 +2589,46 @@ export class PrologueVillageScene extends Phaser.Scene { void (async () => { try { - completeCampaignTutorial( + const pages = prologueBrotherhoodPages(); + const firstPage = pages[0]; + const sequenceId = + storySequenceIdForPages(pages); + if (!firstPage || !sequenceId) { + throw new Error( + 'The brotherhood story has no stable opening beat.' + ); + } + const campaign = completeCampaignTutorial( prologueVillageCampaignTutorialIds.complete, { clearExplorationCheckpointScene: - 'prologue-village' + 'prologue-village', + storyCheckpoint: { + version: + campaignStoryCheckpointVersion, + sequenceId, + beatId: + storyBeatIdForPage(firstPage), + pageIndex: 0, + continuationIntent: 'default' + } } ); + if ( + !campaign.completedTutorialIds.includes( + prologueVillageCampaignTutorialIds.complete + ) || + campaign.storyCheckpoint?.sequenceId !== + sequenceId || + campaign.storyCheckpoint.phase !== 'reading' + ) { + throw new Error( + 'The brotherhood story handoff could not be saved.' + ); + } this.refreshObjectiveHud(); await startGameScene(this, 'StoryScene', { - pages: prologueBrotherhoodPages(), + pages, nextScene: 'PrologueMilitiaCampScene', presentationBattleId: 'first-battle-zhuo-commandery', diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index aaa4e69..0a19e33 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -56,7 +56,22 @@ import { } from '../data/storyCutscenes'; import { prologueOpeningPages } from '../data/prologueVillage'; import { type PortraitKey, type StoryPage } from '../data/scenario'; -import { getCampaignState, getFirstBattleReport } from '../state/campaignState'; +import { + clearCampaignStoryCheckpoint, + getCampaignState, + getFirstBattleReport, + markCampaignStoryHandoff, + setCampaignStoryCheckpoint, + type CampaignStoryHandoffReference +} from '../state/campaignState'; +import { + campaignStoryCheckpointVersion, + resolveCampaignStoryCheckpoint, + storyBeatIdForPage, + storyCheckpointSequenceMatchesPages, + storySequenceIdForPages, + type CampaignStoryContinuationIntent +} from '../state/campaignStoryCheckpoint'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; @@ -73,6 +88,8 @@ type StorySceneData = { nextSceneData?: Record; presentationBattleId?: string; presentationStage?: Extract; + continuationIntent?: CampaignStoryContinuationIntent; + checkpointEnabled?: boolean; }; type StoryPageAssetRequest = { @@ -205,6 +222,12 @@ export class StoryScene extends Phaser.Scene { private nextSceneData?: Record; private presentationBattleId?: string; private presentationStage: Extract = 'story'; + private continuationIntent: CampaignStoryContinuationIntent = + 'default'; + private checkpointEnabled = true; + private storySequenceId?: string; + private storyCheckpointRevision?: number; + private storyNavigationCommitted = false; private presentationWash?: Phaser.GameObjects.Rectangle; private transitioning = false; private background?: Phaser.GameObjects.Image; @@ -273,8 +296,59 @@ export class StoryScene extends Phaser.Scene { this.xuzhouStayNarrativeMemory = xuzhouStayNarrative.memory; this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages); + this.checkpointEnabled = + data?.checkpointEnabled !== false; + this.continuationIntent = + data?.continuationIntent ?? + (data?.nextSceneData?.openSortieImprovement === true + ? 'sortie-improvement' + : 'default'); + this.storySequenceId = + storySequenceIdForPages(this.pages); + const checkpointResolution = + this.checkpointEnabled && + this.storySequenceId && + campaign.storyCheckpoint + ? resolveCampaignStoryCheckpoint( + campaign.storyCheckpoint, + this.pages, + campaign.step + ) + : undefined; + if ( + this.checkpointEnabled && + campaign.storyCheckpoint?.phase === 'reading' && + this.storySequenceId && + storyCheckpointSequenceMatchesPages( + campaign.storyCheckpoint.sequenceId, + this.pages + ) && + !checkpointResolution + ) { + try { + clearCampaignStoryCheckpoint( + campaign.storyCheckpoint.sequenceId, + campaign.storyCheckpoint.revision + ); + } catch (error) { + console.error( + 'Failed to discard an unknown story beat.', + error + ); + } + } + if (checkpointResolution && campaign.storyCheckpoint) { + this.continuationIntent = + campaign.storyCheckpoint.continuationIntent; + } this.presentationWash = undefined; - this.pageIndex = 0; + this.pageIndex = + checkpointResolution?.pageIndex ?? 0; + this.storyCheckpointRevision = + checkpointResolution + ? campaign.storyCheckpoint?.revision + : undefined; + this.storyNavigationCommitted = false; this.transitioning = false; this.readyStoryPageIndexes.clear(); this.storyPageAssetQueue = []; @@ -320,6 +394,16 @@ export class StoryScene extends Phaser.Scene { pageIndex: this.pageIndex, totalPages: this.pages.length, currentPageId: page?.id, + storyCheckpoint: { + enabled: this.checkpointEnabled, + sequenceId: this.storySequenceId ?? null, + revision: this.storyCheckpointRevision ?? null, + continuationIntent: this.continuationIntent, + navigationCommitted: + this.storyNavigationCommitted, + persisted: + getCampaignState().storyCheckpoint ?? null + }, currentSpeaker: page?.speaker ?? null, currentText: this.bodyText?.text ?? page?.text ?? '', requestedBackground: page?.background, @@ -426,6 +510,14 @@ export class StoryScene extends Phaser.Scene { const generation = this.storyAssetLoadGeneration; this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + window.removeEventListener( + 'pagehide', + this.handleStoryPageHide + ); + document.removeEventListener( + 'visibilitychange', + this.handleStoryVisibilityChange + ); this.storyAssetLoadGeneration += 1; this.storyPageAssetQueue = []; this.activeStoryPageAssetRequest = undefined; @@ -441,20 +533,40 @@ export class StoryScene extends Phaser.Scene { }); this.warmFirstBattleSceneModule(); - this.ensureStoryPageAssets(0, () => { + this.ensureStoryPageAssets(this.pageIndex, () => { if (generation !== this.storyAssetLoadGeneration) { return; } this.loadingText?.destroy(); this.loadingText = undefined; - this.background = this.add.image(width / 2, height / 2, this.resolveBackgroundKey(this.pageBackgroundKey(this.pages[0]))); + this.background = this.add.image( + width / 2, + height / 2, + this.resolveBackgroundKey( + this.pageBackgroundKey( + this.pages[this.pageIndex] + ) + ) + ); this.background.setDepth(0); this.presentationWash = this.add.rectangle(0, 0, width, height, 0x000000, 0); this.presentationWash.setOrigin(0); this.presentationWash.setDepth(0.12); this.drawSceneShade(width, height); this.drawDialogPanel(width, height); - this.showPage(0, true); + this.showPage(this.pageIndex, true); + this.persistStoryCheckpoint( + this.pageIndex, + true + ); + window.addEventListener( + 'pagehide', + this.handleStoryPageHide + ); + document.addEventListener( + 'visibilitychange', + this.handleStoryVisibilityChange + ); this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay( this, { @@ -2114,6 +2226,197 @@ export class StoryScene extends Phaser.Scene { return this.textures.exists(textureKey) ? textureKey : 'story-fallback'; } + private readonly handleStoryPageHide = () => { + this.persistStoryCheckpoint( + this.pageIndex, + false + ); + }; + + private readonly handleStoryVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + this.persistStoryCheckpoint( + this.pageIndex, + false + ); + } + }; + + private persistStoryCheckpoint( + pageIndex: number, + showFailure: boolean + ) { + if ( + !this.checkpointEnabled || + this.storyNavigationCommitted + ) { + return true; + } + const page = this.pages[pageIndex]; + const sequenceId = this.storySequenceId; + if (!page || !sequenceId) { + return false; + } + + try { + const campaign = setCampaignStoryCheckpoint({ + version: campaignStoryCheckpointVersion, + ...(this.storyCheckpointRevision !== undefined + ? { + revision: + this.storyCheckpointRevision + } + : {}), + sequenceId, + beatId: storyBeatIdForPage(page), + pageIndex, + phase: 'reading', + continuationIntent: + this.continuationIntent + }); + const saved = campaign.storyCheckpoint; + if ( + !saved || + saved.sequenceId !== sequenceId || + saved.beatId !== storyBeatIdForPage(page) || + saved.pageIndex !== pageIndex || + saved.phase !== 'reading' + ) { + throw new Error( + 'The story checkpoint write was rejected.' + ); + } + this.storyCheckpointRevision = saved.revision; + return true; + } catch (error) { + console.error( + 'Failed to save the story checkpoint.', + error + ); + if (showFailure) { + this.showStoryCheckpointFailure(); + } + return false; + } + } + + private showStoryCheckpointFailure() { + this.progressText?.setText( + '진행을 저장하지 못했습니다 · 저장 공간을 확인하고 다시 시도하세요' + ); + this.progressBackground?.setStrokeStyle( + ui(1), + palette.red, + 0.9 + ); + } + + private storyNextSceneData() { + if ( + this.continuationIntent === + 'sortie-improvement' && + this.nextScene === 'CampScene' + ) { + return { + ...this.nextSceneData, + openSortiePrep: true, + openSortieImprovement: true + }; + } + return this.nextSceneData; + } + + private async finishStory() { + const sequenceId = this.storySequenceId; + const nextSceneData = this.storyNextSceneData(); + let storyHandoff: + | CampaignStoryHandoffReference + | undefined; + if (this.checkpointEnabled) { + if ( + !sequenceId || + ( + this.storyCheckpointRevision === undefined && + !this.persistStoryCheckpoint( + this.pageIndex, + true + ) + ) + ) { + this.transitioning = false; + return; + } + const revision = + this.storyCheckpointRevision; + if (revision === undefined) { + this.transitioning = false; + this.showStoryCheckpointFailure(); + return; + } + try { + const campaign = markCampaignStoryHandoff( + sequenceId, + revision + ); + if ( + campaign.storyCheckpoint?.sequenceId !== + sequenceId || + campaign.storyCheckpoint.revision !== + revision || + campaign.storyCheckpoint.phase !== 'handoff' + ) { + throw new Error( + 'The story handoff checkpoint was rejected.' + ); + } + storyHandoff = { + sequenceId, + revision + }; + } catch (error) { + this.transitioning = false; + console.error( + 'Failed to prepare the story handoff.', + error + ); + this.showStoryCheckpointFailure(); + return; + } + } + + this.storyNavigationCommitted = true; + try { + await startGameScene( + this, + this.nextScene, + storyHandoff + ? { + ...(nextSceneData ?? {}), + storyHandoff + } + : nextSceneData + ); + } catch (error) { + console.error( + `Failed to start ${this.nextScene}`, + error + ); + this.transitioning = false; + this.showStoryNavigationFailure(); + } + } + + private showStoryNavigationFailure() { + this.progressText?.setText( + '다음 장면을 불러오지 못했습니다 · 눌러서 다시 시도' + ); + this.progressBackground?.setStrokeStyle( + ui(1), + palette.red, + 0.9 + ); + } + private advance() { if (this.transitioning || this.campaignObjectiveJournal?.isOpen()) { return; @@ -2121,10 +2424,7 @@ export class StoryScene extends Phaser.Scene { if (this.pageIndex >= this.pages.length - 1) { this.transitioning = true; - void startGameScene(this, this.nextScene, this.nextSceneData).catch((error) => { - console.error(`Failed to start ${this.nextScene}`, error); - this.transitioning = false; - }); + void this.finishStory(); return; } @@ -2136,6 +2436,15 @@ export class StoryScene extends Phaser.Scene { if (!this.scene.isActive()) { return; } + if ( + !this.persistStoryCheckpoint( + targetPageIndex, + true + ) + ) { + this.transitioning = false; + return; + } this.pageIndex = targetPageIndex; soundDirector.playStoryAdvanceCue(); this.showPage(targetPageIndex); diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 10a64ac..adf0161 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -25,6 +25,7 @@ import { prologueDeparturePages, prologueOpeningPages } from '../data/prologueVillage'; +import type { StoryPage } from '../data/scenario'; import { shouldReviewThirdCampPreparationBeforeRetry, thirdCampPreparationTargetBattleId @@ -32,6 +33,10 @@ import { import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage'; import { + clearCampaignStoryCheckpoint, + completeLegacyFirstVictoryStoryHandoff, + consumeCampaignStoryHandoff, + getCampaignState, hasCampaignSave, listCampaignSaveSlots, loadCampaignState, @@ -40,11 +45,23 @@ import { summarizeCampaignProgress, startNewCampaign, type CampaignCampVisitId, - type CampaignSaveSlotSummary + type CampaignState, + type CampaignSaveSlotSummary, + type CampaignStoryHandoffReference } from '../state/campaignState'; +import { + campaignStoryCheckpointVersion, + storyBeatIdForPage, + storySequenceIdForPages, + storyCheckpointSequenceMatchesPages +} from '../state/campaignStoryCheckpoint'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { palette } from '../ui/palette'; -import { startLazyScene, type LazySceneKey } from './lazyScenes'; +import { + isLazySceneKey, + startLazyScene, + type LazySceneKey +} from './lazyScenes'; const fhdUiScale = 1.5; const ui = (value: number) => value * fhdUiScale; @@ -52,6 +69,10 @@ const uiPx = (value: number) => `${Math.round(ui(value))}px`; type TitleFocusScope = 'main' | 'settings' | 'save-slots' | 'new-game-confirm'; +type TitleSceneData = { + storyHandoff?: CampaignStoryHandoffReference; +}; + type TitleFocusItem = { id: string; enabled: boolean; @@ -81,7 +102,10 @@ export class TitleScene extends Phaser.Scene { super('TitleScene'); } - create() { + create(data?: TitleSceneData) { + this.consumeTitleStoryHandoff( + data?.storyHandoff + ); this.settingsPanel = undefined; this.newGameConfirmPanel = undefined; this.saveSlotPanel = undefined; @@ -202,7 +226,8 @@ export class TitleScene extends Phaser.Scene { await this.navigateTo('StoryScene', { pages: [selectedPage], - nextScene: 'TitleScene' + nextScene: 'TitleScene', + checkpointEnabled: false }); } @@ -452,7 +477,9 @@ export class TitleScene extends Phaser.Scene { private campaignSaveDetail( campaign: ReturnType, isEndingComplete: boolean, - battleResume = readCampaignBattleResume(campaign) + battleResume = campaign.storyCheckpoint + ? undefined + : readCampaignBattleResume(campaign) ) { if (battleResume) { const progress = summarizeCampaignProgress(campaign); @@ -477,7 +504,9 @@ export class TitleScene extends Phaser.Scene { private campaignSaveMeta( campaign: ReturnType, - battleResume = readCampaignBattleResume(campaign) + battleResume = campaign.storyCheckpoint + ? undefined + : readCampaignBattleResume(campaign) ) { if (battleResume) { return this.campaignBattleResumeMeta(battleResume); @@ -915,7 +944,11 @@ export class TitleScene extends Phaser.Scene { const campaign = enabled ? loadCampaignState(slot.slot) : undefined; const isEndingComplete = campaign?.step === 'ending-complete'; - const battleResume = campaign ? readCampaignBattleResume(campaign, slot.slot) : undefined; + const battleResume = campaign?.storyCheckpoint + ? undefined + : campaign + ? readCampaignBattleResume(campaign, slot.slot) + : undefined; const title = this.add.text(-ui(122), -ui(12), `슬롯 ${slot.slot}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(16), @@ -1329,9 +1362,31 @@ export class TitleScene extends Phaser.Scene { soundDirector.start(); soundDirector.resume(); soundDirector.playSelect(); - startNewCampaign(); + const pages = prologueOpeningPages(); + const firstPage = pages[0]; + const sequenceId = + storySequenceIdForPages(pages); + if (!firstPage || !sequenceId) { + return; + } + const campaign = startNewCampaign({ + storyCheckpoint: { + version: campaignStoryCheckpointVersion, + sequenceId, + beatId: storyBeatIdForPage(firstPage), + pageIndex: 0, + continuationIntent: 'default' + } + }); + if ( + campaign.storyCheckpoint?.sequenceId !== + sequenceId || + campaign.storyCheckpoint.phase !== 'reading' + ) { + return; + } void this.navigateTo('StoryScene', { - pages: prologueOpeningPages(), + pages, nextScene: 'PrologueVillageScene', presentationBattleId: 'first-battle-zhuo-commandery', presentationStage: 'story' @@ -1350,6 +1405,25 @@ export class TitleScene extends Phaser.Scene { private async continueGameAsync(slot?: number) { const campaign = loadCampaignState(slot); + if (await this.continueStoryCheckpoint(campaign)) { + return; + } + const sortiePreparation = + campaign.sortiePreparationCheckpoint; + if (sortiePreparation) { + await this.navigateTo('CampScene', { + openSortiePrep: true, + openSortieImprovement: true, + ...(sortiePreparation.mode === 'retry' + ? { + retryBattleId: + sortiePreparation.targetBattleId, + skipIntroStory: true + } + : {}) + }); + return; + } const battleResume = readCampaignBattleResume(campaign, slot ?? campaign.activeSaveSlot); if (battleResume) { await this.navigateTo('BattleScene', { @@ -1501,17 +1575,309 @@ export class TitleScene extends Phaser.Scene { }); } - private async navigateTo(sceneKey: LazySceneKey, data?: Record) { + private async continueStoryCheckpoint( + campaign: CampaignState + ) { + const checkpoint = campaign.storyCheckpoint; + if (!checkpoint) { + return false; + } + + const candidates: Array<{ + pages: StoryPage[]; + data: Record; + destinationScene?: string; + destinationData?: Record; + }> = []; + const addCandidate = ( + pages: StoryPage[], + data: Record + ) => { + if ( + storyCheckpointSequenceMatchesPages( + checkpoint.sequenceId, + pages + ) + ) { + const destinationScene = + typeof data.nextScene === 'string' + ? data.nextScene + : undefined; + const destinationData = + data.nextSceneData && + typeof data.nextSceneData === 'object' && + !Array.isArray(data.nextSceneData) + ? data.nextSceneData as Record< + string, + unknown + > + : undefined; + candidates.push({ + pages, + data, + destinationScene, + destinationData + }); + } + }; + + if (campaign.pendingAftermathBattleId) { + const { battleScenarios } = await import( + '../data/battles' + ); + const aftermath = + battleScenarios[ + campaign.pendingAftermathBattleId + ]; + addCandidate(aftermath.victoryPages, { + pages: aftermath.victoryPages, + nextScene: aftermath.nextCampScene, + nextSceneData: { + completedAftermathBattleId: aftermath.id, + ...(checkpoint.continuationIntent === + 'sortie-improvement' + ? { + openSortiePrep: true, + openSortieImprovement: true + } + : {}) + }, + presentationBattleId: aftermath.id, + presentationStage: 'aftermath', + continuationIntent: + checkpoint.continuationIntent + }); + } + + if (campaign.step === 'prologue') { + const pages = prologueOpeningPages(); + addCandidate(pages, { + pages, + nextScene: 'PrologueVillageScene', + presentationBattleId: + 'first-battle-zhuo-commandery', + presentationStage: 'story' + }); + } + if ( + campaign.step === 'prologue-town' && + campaign.completedTutorialIds.includes( + prologueVillageCampaignTutorialIds.complete + ) + ) { + const pages = prologueBrotherhoodPages(); + addCandidate(pages, { + pages, + nextScene: 'PrologueMilitiaCampScene', + presentationBattleId: + 'first-battle-zhuo-commandery', + presentationStage: 'story' + }); + } + if ( + campaign.step === 'prologue-camp' && + campaign.completedTutorialIds.includes( + prologueMilitiaCampCampaignTutorialIds.complete + ) + ) { + const pages = prologueDeparturePages(); + addCandidate(pages, { + pages, + nextScene: 'BattleScene', + nextSceneData: { + battleId: + 'first-battle-zhuo-commandery' + }, + presentationBattleId: + 'first-battle-zhuo-commandery', + presentationStage: 'story' + }); + } + if (campaign.step === 'first-victory-story') { + const { firstBattleVictoryPages } = + await import('../data/scenario'); + addCandidate(firstBattleVictoryPages, { + pages: firstBattleVictoryPages, + nextScene: 'TitleScene', + presentationBattleId: + 'first-battle-zhuo-commandery', + presentationStage: 'aftermath' + }); + } + + const { getSortieFlowForStorySequence } = + await import('../data/campaignFlow'); + const flow = getSortieFlowForStorySequence( + checkpoint.sequenceId, + campaign.step + ); + if ( + flow && + flow.campaignStep === campaign.step + ) { + if (flow.nextBattleId) { + const battleId = flow.nextBattleId; + addCandidate(flow.pages, { + pages: flow.pages, + nextScene: 'BattleScene', + nextSceneData: { + battleId, + selectedSortieUnitIds: [ + ...campaign.selectedSortieUnitIds + ], + sortieFormationAssignments: { + ...campaign.sortieFormationAssignments + }, + sortieItemAssignments: + JSON.parse( + JSON.stringify( + campaign.sortieItemAssignments + ) + ), + sortieOrderId: + campaign.sortieOrderSelection + ?.battleId === battleId + ? campaign.sortieOrderSelection.orderId + : undefined, + sortieResonanceBondId: + campaign.sortieResonanceSelection + ?.battleId === battleId + ? campaign.sortieResonanceSelection.bondId + : undefined, + sortieRecommendation: + campaign.sortieRecommendationSelection + ?.battleId === battleId + ? campaign.sortieRecommendationSelection + : undefined + }, + presentationBattleId: battleId, + presentationStage: 'story', + continuationIntent: + checkpoint.continuationIntent + }); + } else { + addCandidate(flow.pages, { + pages: flow.pages, + nextScene: + flow.campaignStep === 'ending-complete' + ? 'EndingScene' + : 'CampScene', + presentationBattleId: + flow.afterBattleId, + presentationStage: + flow.campaignStep === 'ending-complete' + ? 'aftermath' + : 'story', + continuationIntent: + checkpoint.continuationIntent + }); + } + } + + const candidate = candidates[0]; + if (!candidate) { + try { + clearCampaignStoryCheckpoint( + checkpoint.sequenceId, + checkpoint.revision + ); + } catch (error) { + console.error( + 'Failed to discard an invalid story checkpoint.', + error + ); + } + return false; + } + + if (checkpoint.phase === 'handoff') { + if (candidate.destinationScene === 'TitleScene') { + try { + const advancedLegacyVictory = + this.consumeTitleStoryHandoff( + checkpoint + ); + if (advancedLegacyVictory) { + await this.navigateTo( + 'CampScene' + ); + } + } catch (error) { + console.error( + 'Failed to consume the completed title story handoff.', + error + ); + } + return true; + } + if ( + !candidate.destinationScene || + !isLazySceneKey(candidate.destinationScene) + ) { + return true; + } + const storyHandoff: + CampaignStoryHandoffReference = { + sequenceId: checkpoint.sequenceId, + revision: checkpoint.revision + }; + await this.navigateTo( + candidate.destinationScene, + { + ...(candidate.destinationData ?? {}), + storyHandoff + } + ); + return true; + } + + await this.navigateTo('StoryScene', candidate.data); + return true; + } + + private consumeTitleStoryHandoff( + reference?: CampaignStoryHandoffReference + ) { + if (!reference) { + return false; + } + if ( + getCampaignState().step === + 'first-victory-story' + ) { + const transitioned = + completeLegacyFirstVictoryStoryHandoff( + reference + ); + if ( + transitioned.step === + 'first-camp' + ) { + return true; + } + } + consumeCampaignStoryHandoff( + reference + ); + return false; + } + + private async navigateTo( + sceneKey: LazySceneKey, + data?: Record + ) { if (this.navigating) { - return; + return false; } this.navigating = true; try { await startLazyScene(this, sceneKey, data); + return true; } catch (error) { console.error(`Failed to start ${sceneKey}`, error); this.navigating = false; + return false; } } } diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index 9c9717b..7d50351 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -140,6 +140,7 @@ export type BattleSaveState = { version: 1; battleId: string; campaignStep?: CampaignStep; + battleResumeGeneration?: number; sortieOrderId?: SortieOrderId; sortieResonanceBondId?: string; sortieRecommendation?: CampaignSortieRecommendationSnapshot; @@ -284,6 +285,19 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida return false; } + if ( + state.battleResumeGeneration !== + undefined && + (typeof state.battleResumeGeneration !== + 'number' || + !Number.isSafeInteger( + state.battleResumeGeneration + ) || + state.battleResumeGeneration < 0) + ) { + return false; + } + if (state.sortieOrderId !== undefined && !isSortieOrderId(state.sortieOrderId)) { return false; } diff --git a/src/game/state/battleSaveStorage.ts b/src/game/state/battleSaveStorage.ts index aa92a72..aa4bb50 100644 --- a/src/game/state/battleSaveStorage.ts +++ b/src/game/state/battleSaveStorage.ts @@ -34,6 +34,10 @@ export { export type CampaignBattleResumeSource = { step: CampaignStep; activeSaveSlot: number; + battleResumeGeneration?: number; + sortiePreparationCheckpoint?: { + intent: 'sortie-improvement'; + }; }; export type CampaignBattleResume = { @@ -57,7 +61,8 @@ export function readBattleSaveStorageCandidate( slot: number, storage = browserStorage(), slotCount = 3, - validationOptions: BattleSaveValidationOptions = {} + validationOptions: BattleSaveValidationOptions = {}, + campaign?: CampaignBattleResumeSource ): BattleSaveStorageCandidate | undefined { if (!storage) { return undefined; @@ -72,7 +77,20 @@ export function readBattleSaveStorageCandidate( ...validationOptions, expectedBattleId: battleId }); - if (state && (state.campaignStep === undefined || state.campaignStep === campaignStep)) { + if ( + state && + ( + state.campaignStep === undefined || + state.campaignStep === campaignStep + ) && + ( + !campaign || + battleSaveGenerationMatchesCampaign( + state, + campaign + ) + ) + ) { return { storageKey, state }; } safeRemoveItem(storage, storageKey); @@ -86,6 +104,12 @@ export function readCampaignBattleResume( storage = browserStorage(), slotCount = 3 ): CampaignBattleResume | undefined { + if ( + campaign.sortiePreparationCheckpoint + ?.intent === 'sortie-improvement' + ) { + return undefined; + } const battleId = battleIdForCampaignStep(campaign.step); if (!battleId || !storage) { return undefined; @@ -98,20 +122,43 @@ export function readCampaignBattleResume( normalizedSlot, storage, slotCount, - { allowTacticalCommand: true, allowLegacyTacticalCommand: true } + { + allowTacticalCommand: true, + allowLegacyTacticalCommand: true + }, + campaign ); - if (candidate) { - return { - slot: normalizedSlot, - battleId, - campaignStep: campaign.step, - storageKey: candidate.storageKey, - savedAt: candidate.state.savedAt, - turnNumber: candidate.state.turnNumber, - activeFaction: candidate.state.activeFaction - }; + if (!candidate) { + return undefined; } - return undefined; + return { + slot: normalizedSlot, + battleId, + campaignStep: campaign.step, + storageKey: candidate.storageKey, + savedAt: candidate.state.savedAt, + turnNumber: candidate.state.turnNumber, + activeFaction: + candidate.state.activeFaction + }; +} + +export function battleSaveGenerationMatchesCampaign( + state: BattleSaveState, + campaign: CampaignBattleResumeSource +) { + const campaignGeneration = + Number.isSafeInteger( + campaign.battleResumeGeneration + ) && + (campaign.battleResumeGeneration ?? 0) >= 0 + ? campaign.battleResumeGeneration ?? 0 + : 0; + const savedGeneration = + state.battleResumeGeneration; + return savedGeneration === undefined + ? campaignGeneration === 0 + : savedGeneration === campaignGeneration; } export function clearCampaignBattleResume( diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 9296421..e97a635 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -25,6 +25,7 @@ import { firstPursuitScoutSourceBattleId, firstPursuitScoutVisitId } from '../data/firstPursuitScoutMemory'; +import { getSortieFlow } from '../data/campaignFlow'; import { secondBattleReliefSourceBattleId, secondBattleReliefVisitId @@ -66,8 +67,23 @@ import { type SortieOrderResultSnapshot } from '../data/sortieOrders'; import { battleIdForCampaignStep, isCampCampaignStep } from './campaignRouting'; +import { + campaignStoryCheckpointVersion, + normalizeCampaignStoryCheckpoint, + type CampaignStoryCheckpoint, + type CampaignStoryCheckpointPhase, + type CampaignStoryCheckpointWrite, + type CampaignStoryHandoffReference +} from './campaignStoryCheckpoint'; import { clearAllCampaignBattleSaves, clearCampaignBattleSavesForSlot } from './battleSaveKeys'; +export type { + CampaignStoryCheckpoint, + CampaignStoryCheckpointPhase, + CampaignStoryCheckpointWrite, + CampaignStoryHandoffReference +} from './campaignStoryCheckpoint'; + export type BattleObjectiveSnapshot = { id: string; label: string; @@ -303,6 +319,47 @@ export type CampaignExplorationCommitOptions = { 'savedAt' >; clearExplorationCheckpointScene?: CampaignExplorationCheckpoint['scene']; + storyCheckpoint?: Omit< + CampaignStoryCheckpointWrite, + 'revision' + >; +}; + +export type CampaignStoryCommitOptions = { + storyCheckpoint?: Omit< + CampaignStoryCheckpointWrite, + 'revision' + >; +}; + +export type CampaignStepCommitOptions = { + startBattle?: boolean; +}; + +export type CampaignSortiePreparationMode = + | 'retry' + | 'next-sortie'; + +export type CampaignSortiePreparationCheckpoint = { + version: 1; + intent: 'sortie-improvement'; + mode: CampaignSortiePreparationMode; + sourceBattleId: BattleScenarioId; + targetBattleId: BattleScenarioId; + targetCampaignStep: CampaignStep; + savedAt: string; +}; + +export type CampaignSortiePreparationWrite = Pick< + CampaignSortiePreparationCheckpoint, + 'mode' | 'sourceBattleId' | 'targetBattleId' +>; + +export type CampaignSortiePreparationCommitOptions = { + storyCheckpoint?: Omit< + CampaignStoryCheckpointWrite, + 'revision' + >; }; export type ThirdCampPreparationSelectionSnapshot = { @@ -795,7 +852,11 @@ export type CampaignState = { pendingAftermathBattleId?: BattleScenarioId; activeCityStayId?: CityStayId; activeCampVisitId?: CampaignCampVisitId; + battleResumeGeneration: number; + storyCheckpointRevision: number; + storyCheckpoint?: CampaignStoryCheckpoint; explorationCheckpoint?: CampaignExplorationCheckpoint; + sortiePreparationCheckpoint?: CampaignSortiePreparationCheckpoint; firstBattleReport?: FirstBattleReport; }; @@ -952,6 +1013,42 @@ const campaignCampVisitProgressLabels: Record< }; export function summarizeCampaignProgress(state: CampaignState) { + if (state.storyCheckpoint) { + const pendingStory = state.pendingAftermathBattleId + ? state.battleHistory[state.pendingAftermathBattleId] + : undefined; + const storyBattleId = battleIdForCampaignStep( + state.step + ); + const storyTitle = + pendingStory?.battleTitle ?? + (storyBattleId + ? scenarioTitle(storyBattleId) + : undefined) ?? + campaignStepProgressLabels[state.step]?.title ?? + '진행 중인 이야기'; + return { + title: storyTitle, + meta: + state.storyCheckpoint.phase === 'handoff' + ? '이야기 완료 · 다음 장면 이어가기' + : `이야기 ${state.storyCheckpoint.pageIndex + 1}쪽 이어보기` + }; + } + + if (state.sortiePreparationCheckpoint) { + return { + title: + scenarioTitle( + state.sortiePreparationCheckpoint.targetBattleId + ) ?? '출전 준비', + meta: + state.sortiePreparationCheckpoint.mode === 'retry' + ? '재도전 보완' + : '다음 출전 보완' + }; + } + if (state.activeCampVisitId) { return campaignCampVisitProgressLabels[state.activeCampVisitId]; } @@ -1060,15 +1157,60 @@ export function createInitialCampaignState(): CampaignState { acknowledgedVictoryRewardBattleIds: [], acknowledgedVictoryRewardCategories: {}, dismissedVictoryRewardNoticeBattleIds: [], - battleHistory: {} + battleHistory: {}, + battleResumeGeneration: 0, + storyCheckpointRevision: 0 }; } -export function startNewCampaign() { +export function startNewCampaign( + commitOptions: CampaignStoryCommitOptions = {} +) { const state = createInitialCampaignState(); - clearCampaignBattleSavesForSlot(state.activeSaveSlot); + const previousSlotState = + readStoredCampaignState( + state.activeSaveSlot + ); + state.battleResumeGeneration = + nextBattleResumeGeneration( + previousSlotState + ?.battleResumeGeneration ?? 0 + ); state.step = 'prologue'; - return setCampaignState(state); + if (commitOptions.storyCheckpoint) { + const revision = nextStoryCheckpointRevision( + state.storyCheckpointRevision + ); + const savedAt = new Date().toISOString(); + const checkpoint = normalizeCampaignStoryCheckpoint( + { + ...commitOptions.storyCheckpoint, + version: campaignStoryCheckpointVersion, + phase: 'reading', + revision, + campaignStep: state.step, + savedAt + }, + state.step, + revision, + 'write' + ); + if (!checkpoint) { + throw new Error( + 'The new campaign story checkpoint is invalid.' + ); + } + state.storyCheckpointRevision = revision; + state.storyCheckpoint = checkpoint; + state.updatedAt = savedAt; + } + const committed = setCampaignState(state); + // The canonical campaign generation is the durable barrier. Physical + // cleanup happens only after that write succeeds and remains best-effort. + clearCampaignBattleSavesForSlot( + committed.activeSaveSlot + ); + return committed; } export function getCampaignState() { @@ -1143,9 +1285,23 @@ export function listCampaignSaveSlots(): CampaignSaveSlotSummary[] { }); } -export function markCampaignStep(step: CampaignStep) { +export function markCampaignStep( + step: CampaignStep, + commitOptions: CampaignStepCommitOptions = {} +) { const state = cloneCampaignState(ensureCampaignState()); - state.step = step; + state.step = normalizeCampaignStep(step); + const startsBattle = Boolean( + commitOptions.startBattle + ); + if ( + startsBattle && + !battleIdForCampaignStep(state.step) + ) { + throw new Error( + `Cannot begin a battle generation at campaign step "${state.step}".` + ); + } if (!normalizeActiveCityStayId(state.activeCityStayId, state.latestBattleId, step)) { delete state.activeCityStayId; } @@ -1168,9 +1324,45 @@ export function markCampaignStep(step: CampaignStep) { } else { delete state.explorationCheckpoint; } + const storyCheckpoint = normalizeCampaignStoryCheckpoint( + state.storyCheckpoint, + state.step, + state.storyCheckpointRevision + ); + if (storyCheckpoint) { + state.storyCheckpoint = storyCheckpoint; + delete state.explorationCheckpoint; + } else { + invalidateCampaignStoryCheckpoint(state); + } + const sortiePreparationCheckpoint = + normalizeCampaignSortiePreparationCheckpoint( + state.sortiePreparationCheckpoint, + state + ); + if (sortiePreparationCheckpoint) { + state.sortiePreparationCheckpoint = + sortiePreparationCheckpoint; + } else { + delete state.sortiePreparationCheckpoint; + } + if (startsBattle) { + invalidateCampaignSortiePreparationCheckpoint( + state + ); + state.battleResumeGeneration = + nextBattleResumeGeneration( + state.battleResumeGeneration + ); + } state.updatedAt = new Date().toISOString(); persistCampaignState(state); campaignState = state; + if (startsBattle) { + clearCampaignBattleSavesForSlot( + state.activeSaveSlot + ); + } return cloneCampaignState(state); } @@ -1180,6 +1372,7 @@ export function setActiveCityStayId(cityStayId?: CityStayId) { if (normalizedCityStayId) { state.activeCityStayId = normalizedCityStayId; delete state.activeCampVisitId; + invalidateCampaignStoryCheckpoint(state); if ( state.explorationCheckpoint?.scene !== 'city-stay' || state.explorationCheckpoint.contextId !== @@ -1213,6 +1406,7 @@ export function setActiveCampVisitId(campVisitId?: CampaignCampVisitId) { if (normalizedCampVisitId) { state.activeCampVisitId = normalizedCampVisitId; delete state.activeCityStayId; + invalidateCampaignStoryCheckpoint(state); if ( state.explorationCheckpoint?.scene !== 'camp-visit-exploration' || @@ -1251,6 +1445,7 @@ export function setCampaignExplorationCheckpoint( return cloneCampaignState(state); } state.explorationCheckpoint = normalized; + invalidateCampaignStoryCheckpoint(state); state.updatedAt = normalized.savedAt; persistCampaignState(state); campaignState = state; @@ -1274,6 +1469,365 @@ export function clearCampaignExplorationCheckpoint( return cloneCampaignState(state); } +export function beginCampaignSortiePreparation( + write: CampaignSortiePreparationWrite, + commitOptions: CampaignSortiePreparationCommitOptions = {} +) { + const previousState = cloneCampaignState( + ensureCampaignState() + ); + const state = cloneCampaignState(previousState); + const targetCampaignStep = + campaignBattleSteps[write.targetBattleId] + ?.retry; + if (!targetCampaignStep) { + throw new Error( + `The sortie-improvement target "${write.targetBattleId}" has no campaign battle step.` + ); + } + + const savedAt = new Date().toISOString(); + const checkpoint = + normalizeCampaignSortiePreparationCheckpoint( + { + version: 1, + intent: 'sortie-improvement', + ...write, + targetCampaignStep, + savedAt + }, + state, + 'write' + ); + if (!checkpoint) { + throw new Error( + 'The sortie-improvement checkpoint is invalid for the current campaign state.' + ); + } + + if (commitOptions.storyCheckpoint) { + const existing = state.storyCheckpoint; + if ( + existing?.phase === 'handoff' && + existing.sequenceId === + commitOptions.storyCheckpoint.sequenceId + ) { + throw new Error( + 'A completed story handoff cannot be reopened for sortie improvement.' + ); + } + const revision = + existing?.sequenceId === + commitOptions.storyCheckpoint.sequenceId && + existing.campaignStep === state.step + ? existing.revision + : nextStoryCheckpointRevision( + state.storyCheckpointRevision + ); + const storyCheckpoint = + normalizeCampaignStoryCheckpoint( + { + ...commitOptions.storyCheckpoint, + version: + campaignStoryCheckpointVersion, + phase: 'reading', + revision, + campaignStep: state.step, + savedAt + }, + state.step, + revision, + 'write' + ); + if (!storyCheckpoint) { + throw new Error( + 'The sortie-improvement story checkpoint is invalid.' + ); + } + if (revision !== state.storyCheckpointRevision) { + state.storyCheckpointRevision = revision; + } + state.storyCheckpoint = storyCheckpoint; + } + + state.sortiePreparationCheckpoint = checkpoint; + delete state.activeCityStayId; + delete state.activeCampVisitId; + delete state.explorationCheckpoint; + state.updatedAt = savedAt; + persistCampaignState(state); + campaignState = state; + return cloneCampaignState(state); +} + +export function setCampaignStoryCheckpoint( + checkpoint: CampaignStoryCheckpointWrite +) { + const previousState = cloneCampaignState( + ensureCampaignState() + ); + const state = cloneCampaignState(previousState); + const existing = state.storyCheckpoint; + if ( + existing?.phase === 'handoff' && + existing.sequenceId === checkpoint.sequenceId && + (checkpoint.revision === undefined || + checkpoint.revision === existing.revision) + ) { + // A late pagehide/visibility write must never resurrect a story whose + // final beat has already handed control to the destination scene. + return cloneCampaignState(previousState); + } + let revision: number; + + if (checkpoint.revision !== undefined) { + if ( + checkpoint.revision !== + state.storyCheckpointRevision + ) { + return cloneCampaignState(previousState); + } + revision = checkpoint.revision; + } else if ( + existing?.sequenceId === checkpoint.sequenceId && + existing.campaignStep === state.step + ) { + revision = existing.revision; + } else { + revision = nextStoryCheckpointRevision( + state.storyCheckpointRevision + ); + state.storyCheckpointRevision = revision; + } + + const savedAt = new Date().toISOString(); + const normalized = normalizeCampaignStoryCheckpoint( + { + ...checkpoint, + version: campaignStoryCheckpointVersion, + phase: 'reading', + revision, + campaignStep: state.step, + savedAt + }, + state.step, + revision, + 'write' + ); + if (!normalized) { + return cloneCampaignState(previousState); + } + + state.storyCheckpoint = normalized; + delete state.explorationCheckpoint; + state.updatedAt = savedAt; + persistCampaignState(state); + campaignState = state; + return cloneCampaignState(state); +} + +export function markCampaignStoryHandoff( + sequenceId: string, + revision: number +) { + const previousState = cloneCampaignState( + ensureCampaignState() + ); + const checkpoint = previousState.storyCheckpoint; + if ( + !checkpoint || + checkpoint.sequenceId !== sequenceId || + checkpoint.revision !== revision + ) { + return cloneCampaignState(previousState); + } + if (checkpoint.phase === 'handoff') { + return cloneCampaignState(previousState); + } + + const state = cloneCampaignState(previousState); + const savedAt = new Date().toISOString(); + state.storyCheckpoint = { + ...checkpoint, + phase: 'handoff', + savedAt + }; + state.updatedAt = savedAt; + persistCampaignState(state); + campaignState = state; + return cloneCampaignState(state); +} + +export function clearCampaignStoryCheckpoint( + sequenceId?: string, + revision?: number +) { + const state = cloneCampaignState(ensureCampaignState()); + const checkpoint = state.storyCheckpoint; + if ( + !checkpoint || + (sequenceId && checkpoint.sequenceId !== sequenceId) || + (revision !== undefined && + checkpoint.revision !== revision) + ) { + return cloneCampaignState(state); + } + + invalidateCampaignStoryCheckpoint(state); + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + campaignState = state; + return cloneCampaignState(state); +} + +export function consumeCampaignStoryHandoff( + reference?: CampaignStoryHandoffReference +) { + const state = cloneCampaignState(ensureCampaignState()); + const checkpoint = state.storyCheckpoint; + if ( + !reference || + typeof reference.sequenceId !== 'string' || + !Number.isInteger(reference.revision) || + !checkpoint || + checkpoint.phase !== 'handoff' || + checkpoint.sequenceId !== reference.sequenceId || + checkpoint.revision !== reference.revision + ) { + return cloneCampaignState(state); + } + + invalidateCampaignStoryCheckpoint(state); + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + campaignState = state; + return cloneCampaignState(state); +} + +export function completeLegacyFirstVictoryStoryHandoff( + reference?: CampaignStoryHandoffReference +) { + const state = cloneCampaignState( + ensureCampaignState() + ); + const checkpoint = state.storyCheckpoint; + if ( + state.step !== 'first-victory-story' || + !reference || + typeof reference.sequenceId !== 'string' || + !Number.isInteger(reference.revision) || + !checkpoint || + checkpoint.phase !== 'handoff' || + checkpoint.sequenceId !== + reference.sequenceId || + checkpoint.revision !== + reference.revision + ) { + return cloneCampaignState(state); + } + + state.step = 'first-camp'; + state.latestBattleId = + 'first-battle-zhuo-commandery'; + if ( + state.pendingAftermathBattleId === + 'first-battle-zhuo-commandery' + ) { + delete state.pendingAftermathBattleId; + } + delete state.activeCityStayId; + delete state.activeCampVisitId; + delete state.explorationCheckpoint; + invalidateCampaignStoryCheckpoint(state); + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + campaignState = state; + return cloneCampaignState(state); +} + +export function beginCampaignStory( + step: CampaignStep, + checkpoint: Omit< + CampaignStoryCheckpointWrite, + 'revision' + >, + commitOptions: CampaignStepCommitOptions = {} +) { + const previousState = cloneCampaignState( + ensureCampaignState() + ); + const state = cloneCampaignState(previousState); + state.step = normalizeCampaignStep(step); + const startsBattle = Boolean( + commitOptions.startBattle + ); + if ( + startsBattle && + !battleIdForCampaignStep(state.step) + ) { + throw new Error( + `Cannot begin a battle generation at campaign step "${state.step}".` + ); + } + const revision = nextStoryCheckpointRevision( + state.storyCheckpointRevision + ); + const savedAt = new Date().toISOString(); + const normalized = normalizeCampaignStoryCheckpoint( + { + ...checkpoint, + version: campaignStoryCheckpointVersion, + phase: 'reading', + revision, + campaignStep: state.step, + savedAt + }, + state.step, + revision, + 'write' + ); + if (!normalized) { + return cloneCampaignState(previousState); + } + + state.storyCheckpointRevision = revision; + state.storyCheckpoint = normalized; + delete state.explorationCheckpoint; + delete state.activeCityStayId; + delete state.activeCampVisitId; + const sortiePreparationCheckpoint = + normalizeCampaignSortiePreparationCheckpoint( + state.sortiePreparationCheckpoint, + state + ); + if (sortiePreparationCheckpoint) { + state.sortiePreparationCheckpoint = + sortiePreparationCheckpoint; + } else { + delete state.sortiePreparationCheckpoint; + } + if (startsBattle) { + invalidateCampaignSortiePreparationCheckpoint( + state + ); + state.battleResumeGeneration = + nextBattleResumeGeneration( + state.battleResumeGeneration + ); + } + state.updatedAt = savedAt; + persistCampaignState(state); + campaignState = state; + if (startsBattle) { + // Physical removal is best-effort. The durable generation above prevents + // an older snapshot from becoming resumable even if storage refuses it. + clearCampaignBattleSavesForSlot( + state.activeSaveSlot + ); + } + return cloneCampaignState(state); +} + export function hasCompletedCampaignTutorial(id: CampaignTutorialId, state = ensureCampaignState()) { return state.completedTutorialIds.includes(id); } @@ -1282,7 +1836,11 @@ export function completeCampaignTutorial( id: CampaignTutorialId, commitOptions: CampaignExplorationCommitOptions = {} ) { - const state = cloneCampaignState(ensureCampaignState()); + const previousState = cloneCampaignState( + ensureCampaignState() + ); + const state = cloneCampaignState(previousState); + const savedAt = new Date().toISOString(); let changed = false; if (!state.completedTutorialIds.includes(id)) { state.completedTutorialIds.push(id); @@ -1301,8 +1859,33 @@ export function completeCampaignTutorial( state, commitOptions.explorationCheckpoint ) || changed; + if (commitOptions.storyCheckpoint) { + const revision = nextStoryCheckpointRevision( + state.storyCheckpointRevision + ); + const checkpoint = normalizeCampaignStoryCheckpoint( + { + ...commitOptions.storyCheckpoint, + version: campaignStoryCheckpointVersion, + phase: 'reading', + revision, + campaignStep: state.step, + savedAt + }, + state.step, + revision, + 'write' + ); + if (!checkpoint) { + return cloneCampaignState(previousState); + } + state.storyCheckpointRevision = revision; + state.storyCheckpoint = checkpoint; + delete state.explorationCheckpoint; + changed = true; + } if (changed) { - state.updatedAt = new Date().toISOString(); + state.updatedAt = savedAt; persistCampaignState(state); campaignState = state; } @@ -1412,7 +1995,10 @@ export function normalizeCampaignReserveTrainingAssignments( }, {}); } -export function setFirstBattleReport(report: FirstBattleReport) { +export function setFirstBattleReport( + report: FirstBattleReport, + commitOptions: CampaignStoryCommitOptions = {} +) { const state = cloneCampaignState(ensureCampaignState()); let reportClone = cloneReport(report); const battleId = reportClone.battleId; @@ -1595,6 +2181,10 @@ export function setFirstBattleReport(report: FirstBattleReport) { delete state.activeCityStayId; delete state.activeCampVisitId; delete state.explorationCheckpoint; + invalidateCampaignStoryCheckpoint(state); + invalidateCampaignSortiePreparationCheckpoint( + state + ); delete state.pendingAftermathBattleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); @@ -1687,9 +2277,42 @@ export function setFirstBattleReport(report: FirstBattleReport) { delete state.activeCityStayId; delete state.activeCampVisitId; delete state.explorationCheckpoint; + if (commitOptions.storyCheckpoint) { + const revision = nextStoryCheckpointRevision( + state.storyCheckpointRevision + ); + const savedAt = new Date().toISOString(); + const checkpoint = + normalizeCampaignStoryCheckpoint( + { + ...commitOptions.storyCheckpoint, + version: + campaignStoryCheckpointVersion, + phase: 'reading', + revision, + campaignStep: state.step, + savedAt + }, + state.step, + revision, + 'write' + ); + if (!checkpoint) { + throw new Error( + 'The battle aftermath story checkpoint is invalid.' + ); + } + state.storyCheckpointRevision = revision; + state.storyCheckpoint = checkpoint; + } else { + invalidateCampaignStoryCheckpoint(state); + } state.pendingAftermathBattleId = battleId as BattleScenarioId; } + invalidateCampaignSortiePreparationCheckpoint( + state + ); state.updatedAt = new Date().toISOString(); persistCampaignState(state); @@ -1709,6 +2332,33 @@ export function completeCampaignAftermath(battleId: BattleScenarioId) { } delete state.pendingAftermathBattleId; + invalidateCampaignStoryCheckpoint(state); + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + campaignState = state; + return cloneCampaignState(state); +} + +export function completeCampaignStoryHandoffAftermath( + battleId: BattleScenarioId, + reference: CampaignStoryHandoffReference +) { + const state = cloneCampaignState( + ensureCampaignState() + ); + const checkpoint = state.storyCheckpoint; + if ( + state.pendingAftermathBattleId !== battleId || + !checkpoint || + checkpoint.phase !== 'handoff' || + checkpoint.sequenceId !== reference.sequenceId || + checkpoint.revision !== reference.revision + ) { + return cloneCampaignState(state); + } + + delete state.pendingAftermathBattleId; + invalidateCampaignStoryCheckpoint(state); state.updatedAt = new Date().toISOString(); persistCampaignState(state); campaignState = state; @@ -2183,6 +2833,7 @@ function applyCampaignExplorationCheckpoint( return false; } state.explorationCheckpoint = normalized; + invalidateCampaignStoryCheckpoint(state); return true; } @@ -2381,6 +3032,14 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.updatedAt = normalizeCampaignTimestamp(normalized.updatedAt) ?? new Date().toISOString(); normalized.step = normalizeCampaignStep(normalized.step); normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot); + normalized.storyCheckpointRevision = + normalizeNonNegativeInteger( + normalized.storyCheckpointRevision + ); + normalized.battleResumeGeneration = + normalizeNonNegativeInteger( + normalized.battleResumeGeneration + ); normalized.gold = normalizeNonNegativeInteger(normalized.gold); normalized.roster = arrayOrEmpty(normalized.roster) .map(normalizeUnitDataSnapshot) @@ -2564,14 +3223,62 @@ function normalizeCampaignState(state: Partial): CampaignState { } else { delete normalized.pendingAftermathBattleId; } + const sortiePreparationCheckpoint = + normalizeCampaignSortiePreparationCheckpoint( + normalized.sortiePreparationCheckpoint, + normalized + ); + if (sortiePreparationCheckpoint) { + normalized.sortiePreparationCheckpoint = + sortiePreparationCheckpoint; + delete normalized.activeCityStayId; + delete normalized.activeCampVisitId; + delete normalized.explorationCheckpoint; + } else { + delete normalized.sortiePreparationCheckpoint; + } + const storyCheckpoint = + normalizeCampaignStoryCheckpoint( + normalized.storyCheckpoint, + normalized.step, + normalized.storyCheckpointRevision + ); const explorationCheckpoint = normalizeCampaignExplorationCheckpoint( normalized.explorationCheckpoint, normalized ); - if (explorationCheckpoint) { + if (storyCheckpoint && explorationCheckpoint) { + const storySavedAt = + Date.parse(storyCheckpoint.savedAt) || 0; + const explorationSavedAt = + Date.parse(explorationCheckpoint.savedAt) || 0; + if (storySavedAt >= explorationSavedAt) { + normalized.storyCheckpoint = storyCheckpoint; + delete normalized.explorationCheckpoint; + } else { + delete normalized.storyCheckpoint; + normalized.storyCheckpointRevision = + nextStoryCheckpointRevision( + normalized.storyCheckpointRevision + ); + normalized.explorationCheckpoint = + explorationCheckpoint; + } + } else if (storyCheckpoint) { + normalized.storyCheckpoint = storyCheckpoint; + delete normalized.explorationCheckpoint; + } else if (explorationCheckpoint) { + delete normalized.storyCheckpoint; normalized.explorationCheckpoint = explorationCheckpoint; } else { + if (normalized.storyCheckpoint) { + normalized.storyCheckpointRevision = + nextStoryCheckpointRevision( + normalized.storyCheckpointRevision + ); + } + delete normalized.storyCheckpoint; delete normalized.explorationCheckpoint; } if (normalized.firstBattleReport && sortieUnitFilter) { @@ -4583,6 +5290,147 @@ function cloneCampaignState(state: CampaignState): CampaignState { return JSON.parse(JSON.stringify(state)) as CampaignState; } +function invalidateCampaignStoryCheckpoint( + state: CampaignState +) { + if (!state.storyCheckpoint) { + return false; + } + delete state.storyCheckpoint; + state.storyCheckpointRevision = + nextStoryCheckpointRevision( + state.storyCheckpointRevision + ); + return true; +} + +function nextStoryCheckpointRevision(revision: number) { + const normalized = Number.isSafeInteger(revision) && + revision >= 0 + ? revision + : 0; + return normalized >= Number.MAX_SAFE_INTEGER + ? 0 + : normalized + 1; +} + +function nextBattleResumeGeneration( + generation: number +) { + const normalized = + Number.isSafeInteger(generation) && + generation >= 0 + ? generation + : 0; + return normalized >= Number.MAX_SAFE_INTEGER + ? 0 + : normalized + 1; +} + +function invalidateCampaignSortiePreparationCheckpoint( + state: CampaignState +) { + if (!state.sortiePreparationCheckpoint) { + return false; + } + delete state.sortiePreparationCheckpoint; + return true; +} + +function normalizeCampaignSortiePreparationCheckpoint( + value: unknown, + campaign: Pick< + CampaignState, + | 'step' + | 'latestBattleId' + | 'firstBattleReport' + | 'battleHistory' + >, + mode: 'stored' | 'write' = 'stored' +): CampaignSortiePreparationCheckpoint | undefined { + if ( + !isPlainObject(value) || + value.version !== 1 || + value.intent !== 'sortie-improvement' || + (value.mode !== 'retry' && + value.mode !== 'next-sortie') || + typeof value.sourceBattleId !== 'string' || + !(value.sourceBattleId in battleScenarios) || + typeof value.targetBattleId !== 'string' || + !(value.targetBattleId in battleScenarios) || + typeof value.targetCampaignStep !== 'string' + ) { + return undefined; + } + + const sourceBattleId = + value.sourceBattleId as BattleScenarioId; + const targetBattleId = + value.targetBattleId as BattleScenarioId; + const modeValue = + value.mode as CampaignSortiePreparationMode; + const sourceRule = + campaignBattleSteps[sourceBattleId]; + const targetRule = + campaignBattleSteps[targetBattleId]; + const expectedNextBattleId = + sourceRule + ? getSortieFlow( + sourceBattleId, + sourceRule.victory + ).nextBattleId + : undefined; + const targetCampaignStep = + value.targetCampaignStep as CampaignStep; + const sourceReport = + campaign.firstBattleReport?.battleId === + sourceBattleId + ? campaign.firstBattleReport + : undefined; + const sourceSettlement = + campaign.battleHistory[sourceBattleId]; + const retryContextMatches = + modeValue === 'retry' && + targetBattleId === sourceBattleId && + campaign.step === sourceRule?.retry && + sourceReport?.outcome === 'defeat'; + const nextSortieContextMatches = + modeValue === 'next-sortie' && + targetBattleId === expectedNextBattleId && + campaign.step === sourceRule?.victory && + campaign.latestBattleId === sourceBattleId && + ( + sourceSettlement?.outcome === 'victory' || + sourceReport?.outcome === 'victory' + ); + const savedAt = + normalizeCampaignTimestamp(value.savedAt) ?? + (mode === 'write' + ? new Date().toISOString() + : undefined); + + if ( + !sourceRule || + !targetRule || + targetCampaignStep !== targetRule.retry || + (!retryContextMatches && + !nextSortieContextMatches) || + !savedAt + ) { + return undefined; + } + + return { + version: 1, + intent: 'sortie-improvement', + mode: modeValue, + sourceBattleId, + targetBattleId, + targetCampaignStep, + savedAt + }; +} + function normalizeCampaignExplorationCheckpoint( value: unknown, campaign: Pick< diff --git a/src/game/state/campaignStoryCheckpoint.ts b/src/game/state/campaignStoryCheckpoint.ts new file mode 100644 index 0000000..33eef08 --- /dev/null +++ b/src/game/state/campaignStoryCheckpoint.ts @@ -0,0 +1,281 @@ +import type { StoryPage } from '../data/scenario'; + +export const campaignStoryCheckpointVersion = 1 as const; +export const campaignStoryContinuationIntents = [ + 'default', + 'sortie-improvement' +] as const; +export type CampaignStoryContinuationIntent = + (typeof campaignStoryContinuationIntents)[number]; +export const campaignStoryCheckpointPhases = [ + 'reading', + 'handoff' +] as const; +export type CampaignStoryCheckpointPhase = + (typeof campaignStoryCheckpointPhases)[number]; + +export type CampaignStoryCheckpoint = { + version: typeof campaignStoryCheckpointVersion; + revision: number; + sequenceId: string; + /** + * `reading` resumes the exact beat. `handoff` means the final beat was + * accepted and the destination scene still needs to take ownership. + */ + phase: CampaignStoryCheckpointPhase; + /** + * Stable narrative beat ID. This is optional only while an older + * index-only checkpoint is being migrated. + */ + beatId?: string; + /** + * Compatibility location for index-only saves and a useful diagnostic. + * `beatId` is authoritative whenever both fields exist. + */ + pageIndex: number; + campaignStep: string; + continuationIntent: CampaignStoryContinuationIntent; + savedAt: string; +}; + +export type CampaignStoryCheckpointWrite = { + version: typeof campaignStoryCheckpointVersion; + revision?: number; + sequenceId: string; + beatId: string; + pageIndex: number; + phase?: CampaignStoryCheckpointPhase; + continuationIntent?: CampaignStoryContinuationIntent; +}; + +export type CampaignStoryHandoffReference = Pick< + CampaignStoryCheckpoint, + 'sequenceId' | 'revision' +>; + +export type CampaignStoryCheckpointResolution = { + pageIndex: number; + sequenceId: string; + beatId: string; + migrated: boolean; +}; + +type CampaignStoryCheckpointNormalizationMode = 'stored' | 'write'; + +const maxStoryCheckpointIdLength = 160; +const maxStoryCheckpointPageIndex = 999; +const storyCheckpointIdPattern = + /^[a-z0-9][a-z0-9:_-]*$/i; +const campaignStoryContinuationIntentSet = + new Set(campaignStoryContinuationIntents); +const campaignStoryCheckpointPhaseSet = + new Set(campaignStoryCheckpointPhases); + +export function storySequenceIdForPages( + pages: readonly StoryPage[] +) { + return pages[0]?.id; +} + +export function storyBeatIdForPage(page: StoryPage) { + return page.id; +} + +export function storyCheckpointSequenceMatchesPages( + sequenceId: string, + pages: readonly StoryPage[] +) { + const firstPage = pages[0]; + return Boolean( + firstPage && + (firstPage.id === sequenceId || + firstPage.legacyBeatIds?.includes(sequenceId)) + ); +} + +export function resolveCampaignStoryCheckpoint( + checkpoint: CampaignStoryCheckpoint, + pages: readonly StoryPage[], + campaignStep: string +): CampaignStoryCheckpointResolution | undefined { + if ( + checkpoint.phase !== 'reading' || + checkpoint.campaignStep !== campaignStep || + !storyCheckpointSequenceMatchesPages( + checkpoint.sequenceId, + pages + ) + ) { + return undefined; + } + + const canonicalSequenceId = + storySequenceIdForPages(pages); + if (!canonicalSequenceId) { + return undefined; + } + + if (checkpoint.beatId) { + const pageIndex = pages.findIndex( + (page) => + page.id === checkpoint.beatId || + page.legacyBeatIds?.includes( + checkpoint.beatId as string + ) + ); + if (pageIndex < 0) { + // Never fall back to an unrelated index when a stable beat was stored. + return undefined; + } + const beatId = storyBeatIdForPage(pages[pageIndex]); + return { + pageIndex, + sequenceId: canonicalSequenceId, + beatId, + migrated: + checkpoint.sequenceId !== canonicalSequenceId || + checkpoint.beatId !== beatId || + checkpoint.pageIndex !== pageIndex + }; + } + + if ( + checkpoint.pageIndex < 0 || + checkpoint.pageIndex >= pages.length + ) { + return undefined; + } + const page = pages[checkpoint.pageIndex]; + return { + pageIndex: checkpoint.pageIndex, + sequenceId: canonicalSequenceId, + beatId: storyBeatIdForPage(page), + migrated: true + }; +} + +export function normalizeCampaignStoryCheckpoint( + value: unknown, + campaignStep: string, + revision: number, + mode: CampaignStoryCheckpointNormalizationMode = 'stored' +): CampaignStoryCheckpoint | undefined { + if (!isPlainObject(value)) { + return undefined; + } + + const storedVersion = Number(value.version); + if ( + storedVersion !== 0 && + storedVersion !== campaignStoryCheckpointVersion + ) { + return undefined; + } + + const sequenceId = normalizeCheckpointId(value.sequenceId); + const beatId = normalizeCheckpointId(value.beatId); + const legacyShape = + storedVersion === 0 || !beatId; + const pageIndex = normalizePageIndex( + value.pageIndex ?? value.legacyPageIndex + ); + const storedStep = + normalizeCheckpointId(value.campaignStep) ?? + (legacyShape ? campaignStep : undefined); + const storedRevision = + normalizeRevision(value.revision) ?? + (legacyShape ? revision : undefined); + const continuationIntent = + typeof value.continuationIntent === 'string' && + campaignStoryContinuationIntentSet.has( + value.continuationIntent + ) + ? value.continuationIntent as CampaignStoryContinuationIntent + : 'default'; + const phase = + typeof value.phase === 'string' && + campaignStoryCheckpointPhaseSet.has(value.phase) + ? value.phase as CampaignStoryCheckpointPhase + : legacyShape || value.phase === undefined + ? 'reading' + : undefined; + + if ( + !sequenceId || + pageIndex === undefined || + !phase || + !storedStep || + storedStep !== campaignStep || + storedRevision !== revision || + (mode === 'write' && !beatId) + ) { + return undefined; + } + + return { + version: campaignStoryCheckpointVersion, + revision: storedRevision, + sequenceId, + phase, + ...(beatId ? { beatId } : {}), + pageIndex, + campaignStep: storedStep, + continuationIntent, + savedAt: + normalizeTimestamp(value.savedAt) ?? + new Date().toISOString() + }; +} + +function normalizeCheckpointId(value: unknown) { + if (typeof value !== 'string') { + return undefined; + } + const normalized = value.trim(); + return ( + normalized.length > 0 && + normalized.length <= maxStoryCheckpointIdLength && + storyCheckpointIdPattern.test(normalized) + ) + ? normalized + : undefined; +} + +function normalizePageIndex(value: unknown) { + const parsed = Number(value); + if ( + !Number.isSafeInteger(parsed) || + parsed < 0 || + parsed > maxStoryCheckpointPageIndex + ) { + return undefined; + } + return parsed; +} + +function normalizeRevision(value: unknown) { + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 + ? parsed + : undefined; +} + +function normalizeTimestamp(value: unknown) { + if (typeof value !== 'string') { + return undefined; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) + ? new Date(timestamp).toISOString() + : undefined; +} + +function isPlainObject( + value: unknown +): value is Record { + return Boolean( + value && + typeof value === 'object' && + !Array.isArray(value) + ); +}