From 8a6c8c2cd75ac02479877c4d5854eb67a3bd2ac3 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 28 Jul 2026 17:39:20 +0900 Subject: [PATCH] feat: resume exploration exactly and harden campaign saves --- docs/game-design-improvement-goal.md | 39 +- package.json | 9 +- ...fy-camp-exploration-checkpoint-browser.mjs | 1499 +++++++++++++++++ scripts/verify-camp-visit-resume-state.mjs | 26 +- ...n-exploration-checkpoint-normalization.mjs | 636 +++++++ .../verify-campaign-save-normalization.mjs | 58 +- ...verify-campaign-settlement-idempotency.mjs | 584 +++++++ scripts/verify-city-equipment-preparation.mjs | 9 +- scripts/verify-city-purchase-idempotency.mjs | 275 +++ .../verify-city-stay-checkpoint-browser.mjs | 1252 ++++++++++++++ ...rologue-exploration-checkpoint-browser.mjs | 1196 +++++++++++++ scripts/verify-static-data.mjs | 3 + ...verify-victory-reward-acknowledgements.mjs | 2 + src/game/scenes/CampScene.ts | 89 +- src/game/scenes/CampVisitExplorationScene.ts | 627 ++++++- src/game/scenes/CityStayScene.ts | 584 ++++++- src/game/scenes/PrologueMilitiaCampScene.ts | 574 ++++++- src/game/scenes/PrologueVillageScene.ts | 448 ++++- src/game/scenes/TitleScene.ts | 27 + src/game/state/campaignState.ts | 1115 +++++++++++- src/game/state/cityStayActions.ts | 122 +- src/game/state/firstPursuitScoutActions.ts | 17 +- src/game/state/secondBattleReliefActions.ts | 36 +- src/game/state/thirdCampExplorationActions.ts | 144 +- 24 files changed, 8958 insertions(+), 413 deletions(-) create mode 100644 scripts/verify-camp-exploration-checkpoint-browser.mjs create mode 100644 scripts/verify-campaign-exploration-checkpoint-normalization.mjs create mode 100644 scripts/verify-campaign-settlement-idempotency.mjs create mode 100644 scripts/verify-city-purchase-idempotency.mjs create mode 100644 scripts/verify-city-stay-checkpoint-browser.mjs create mode 100644 scripts/verify-prologue-exploration-checkpoint-browser.mjs diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 4470bdf..f66cdc6 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -349,7 +349,7 @@ RPG로 다듬는다. Canvas·WebGL의 1920x1080 CSS viewport, 100% 확대, DPR 1에서 포인터·J·Esc 재열기, 카드 경계·비겹침·저장 불변을 자동 검증한다. -### 탐색 재개와 읽지 않은 도입 보존 (2026-07-28) +### 완료한 17차 묶음: 저장한 순간으로 돌아가는 탐색 (2026-07-28) - 불러오기는 허브가 아니라 저장한 지점으로 돌아와야 하고 대화 중 저장을 막거나 허브로 돌려보내는 구현은 실패 사례라는 @@ -371,9 +371,40 @@ RPG로 다듬는다. 마지막 대사를 읽은 순간에만 완독 처리한다. 대화 도중 종료하면 다음 재개에서 첫 줄부터 안전하게 다시 들려준다. -다음 묶음은 버전이 있는 `explorationCheckpoint`로 주인공 좌표·방향과 -대화 상대·대사 위치·선택창을 안정 ID로 보존하고, 선택·구매·공명 보상이 -재로드 경계에서도 정확히 한 번만 적용되는지 검증한다. +- 버전이 있는 `explorationCheckpoint`를 도입해 세 막간 탐색, 서주·신야·성도 + 체류, 프롤로그 마을·의용군 막사의 주인공 좌표와 방향을 정확히 보존한다. + 대화 상대와 대사 위치, 선택창, 상점, 아직 확정하지 않은 첫 군령 카드도 + 문자열 원문이나 실행 함수를 저장하지 않고 안정 ID로 다시 구성한다. +- 막사 세 곳·도시·프롤로그 두 장면마다 허용하는 대화·선택·상점 상태를 + 따로 검사한다. 손상되거나 구버전인 보조 화면만 버리고 유효한 위치는 + 살리며, 이미 다른 장소로 이동한 뒤 도착한 늦은 저장 요청은 기존 최신 + 체크포인트와 저장 시각을 전혀 바꾸지 않는다. +- 이동이 멈춘 때와 대화·선택·상점의 의미 있는 경계, 브라우저 숨김·종료 + 직전에 체크포인트를 갱신한다. 타이틀 이어하기는 전투 저장 다음으로 이 + 체크포인트를 우선해, 프롤로그 도입을 다시 거치거나 체류 허브로 밀려나는 + 일을 막는다. +- 캠프 선택과 공명 보상은 상태 변경·완료 표식·선택 직후 대화 체크포인트를 + 한 번의 저장으로 확정한다. 도시의 반복 구매에는 의도 ID와 완료 영수증을 + 붙여 같은 입력을 재시도해도 금전과 장비가 한 번만 바뀌게 했다. 이는 + 재시도를 안전하게 만드는 + [AWS의 멱등 API 지침](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/)과 + 변경 묶음이 전부 적용되거나 전부 취소되어야 한다는 + [W3C IndexedDB 트랜잭션 모델](https://www.w3.org/TR/IndexedDB/#transaction-concept)을 + 로컬 저장 흐름에 맞게 적용한 것이다. +- 전투 승전 정산을 같은 보고서로 다시 실행해도 이미 소비한 금전·장비를 + 되살리거나 이후 바꾼 장비·공명·체류 위치를 과거로 되감지 않는다. 이미 + 확정된 승리 뒤 늦게 들어온 패배 기록도 무시하며, 저장 실패 시 화면 속 + 상태만 앞서 바뀌는 현상도 막았다. 활성 슬롯을 정본으로 먼저 저장하고 + 구버전 호환 저장은 보조 사본으로 다뤄, 둘째 쓰기 실패가 유효한 진행을 + 무효화하지 않게 했다. +- Canvas·WebGL 각각 1920x1080 CSS viewport, 100% 확대, DPR 1에서 정확한 + 위치·방향·대사 줄·선택창·상점·군령 카드 복원과 보상 1회성, 정상 퇴장 + 시 체크포인트 제거를 자동 검증한다. + +다음 묶음은 이야기 화면의 페이지 단위 재개와 남은 직접 상태 변경 경로를 +같은 저장 원칙으로 정리한다. 탐색 대사에는 문구나 순서가 바뀌어도 같은 +장면을 찾는 안정적인 `beatId`와 구버전 변환표를 더해, 긴 캠페인에서 +“중단해도 읽은 만큼만 이어지는” 경험을 전 구간으로 확장한다. ## 장기 개선 순서 diff --git a/package.json b/package.json index 94029df..f2cb6be 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,11 @@ "verify:narrative-continuity": "node scripts/verify-narrative-continuity.mjs", "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-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", + "verify:camp-exploration-checkpoint:browser": "node scripts/verify-camp-exploration-checkpoint-browser.mjs", "verify:campaign-completion": "node scripts/verify-campaign-completion.mjs", "verify:campaign-presentation": "node scripts/verify-campaign-presentation-profiles.mjs", "verify:campaign-objectives": "node scripts/verify-campaign-objective-journal.mjs", @@ -39,7 +42,9 @@ "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", "verify:city-stays": "node scripts/verify-city-stay-data.mjs", "verify:city-equipment-preparation": "node scripts/verify-city-equipment-preparation.mjs", + "verify:city-purchase-idempotency": "node scripts/verify-city-purchase-idempotency.mjs", "verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs", + "verify:city-stay-checkpoint:browser": "node scripts/verify-city-stay-checkpoint-browser.mjs", "verify:xuzhou-equipment-link:browser": "node scripts/verify-xuzhou-equipment-link-browser.mjs", "verify:xuzhou-stay-narrative": "node scripts/verify-xuzhou-stay-narrative-memory.mjs", "verify:xuzhou-stay-payoff:browser": "node scripts/verify-xuzhou-stay-battle-payoff-browser.mjs", @@ -60,6 +65,8 @@ "verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs", "verify:prologue-dialogue-portraits": "node scripts/verify-prologue-dialogue-portrait-data.mjs", "verify:prologue-village:browser": "node scripts/verify-prologue-village-browser.mjs", + "verify:prologue-exploration-checkpoint:browser": "node scripts/verify-prologue-exploration-checkpoint-browser.mjs", + "verify:exploration-checkpoints:browser": "pnpm run verify:camp-exploration-checkpoint:browser && pnpm run verify:city-stay-checkpoint:browser && pnpm run verify:prologue-exploration-checkpoint:browser", "verify:camp-skins": "node scripts/verify-camp-skin-data.mjs", "verify:camp-soundscapes": "node scripts/verify-camp-soundscape-data.mjs", "verify:sound-director": "node scripts/verify-sound-director.mjs", @@ -90,7 +97,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: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: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/verify-camp-exploration-checkpoint-browser.mjs b/scripts/verify-camp-exploration-checkpoint-browser.mjs new file mode 100644 index 0000000..6ffdee8 --- /dev/null +++ b/scripts/verify-camp-exploration-checkpoint-browser.mjs @@ -0,0 +1,1499 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport, + desktopBrowserViewportBounds +} from './desktop-browser-viewport.mjs'; + +const renderer = + process.env.VERIFY_CAMP_EXPLORATION_CHECKPOINT_RENDERER; +const rendererNames = ['canvas', 'webgl']; +const fixture = Object.freeze({ + visitId: 'first-pursuit-scout-tent', + battleId: 'first-battle-zhuo-commandery', + campaignStep: 'first-camp', + actorId: 'jian-yong', + movementAnchorId: 'zhang-fei', + exitId: 'camp-exit', + choiceId: 'trace-river-ambush', + itemId: '상처약', + itemAmount: 1, + bondId: 'liu-bei__jian-yong', + bondExp: 10 +}); + +if (!renderer) { + for (const requestedRenderer of rendererNames) { + const result = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url)], + { + cwd: process.cwd(), + env: { + ...process.env, + VERIFY_CAMP_EXPLORATION_CHECKPOINT_RENDERER: + requestedRenderer + }, + stdio: 'inherit' + } + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } + process.exit(0); +} + +assert( + rendererNames.includes(renderer), + `Unsupported camp exploration checkpoint renderer "${renderer}".` +); + +const baseUrl = + process.env.VERIFY_CAMP_EXPLORATION_CHECKPOINT_URL ?? + 'http://127.0.0.1:41808/heros_web/'; +const targetUrl = withDebugOptions(baseUrl, renderer); +const localSeedUrl = withDebugOptions( + 'http://127.0.0.1:41808/heros_web/', + renderer +); + +let serverProcess; +let seedServerProcess; +let browser; + +try { + mkdirSync('dist', { recursive: true }); + serverProcess = await ensureLocalServer(targetUrl); + browser = await chromium.launch({ + headless: + process.env.VERIFY_CAMP_EXPLORATION_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.addInitScript(() => { + const marker = + 'heros-web:qa:camp-exploration-checkpoint-storage-cleared'; + if (window.sessionStorage.getItem(marker) === '1') { + return; + } + window.localStorage.clear(); + window.sessionStorage.setItem(marker, '1'); + }); + await page.goto(targetUrl, { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + + let seeded; + if (isLocalUrl(targetUrl)) { + seeded = await seedFirstCampVisit(page, fixture); + } else { + const bridge = await createSeedBridge( + browser, + localSeedUrl, + fixture + ); + seedServerProcess = bridge.serverProcess; + seeded = bridge.seeded; + await installCampaignSaves(page, bridge.campaignSaves); + } + assertSeededVisit(seeded); + + await reloadToTitle(page); + assertRuntimeBaseline( + await readRuntimeProbe(page, 'TitleScene'), + renderer, + 'seeded first-camp title' + ); + await continueToExploration(page); + assertRuntimeBaseline( + await readRuntimeProbe( + page, + 'CampVisitExplorationScene' + ), + renderer, + 'first-camp exploration' + ); + + const savedPlayer = await createPlayerCheckpoint(page); + const playerResume = await reloadAndContinueToExploration(page); + assertExactPlayer( + playerResume.player, + savedPlayer, + `${renderer}: position checkpoint resume` + ); + assertExactCheckpointPlayer( + playerResume.campaign.explorationCheckpoint, + savedPlayer, + `${renderer}: persisted position checkpoint` + ); + + const dialogueBeforeReload = + await openAndAdvanceActorDialogue(page); + const dialogueResume = + await reloadAndContinueToExploration(page); + assertExactPlayer( + dialogueResume.player, + dialogueBeforeReload.player, + `${renderer}: dialogue player resume` + ); + assertDialogueResume( + dialogueResume, + dialogueBeforeReload, + `${renderer}: actor dialogue resume` + ); + + const choiceBeforeReload = await advanceToChoicePanel(page); + const choiceResume = + await reloadAndContinueToExploration(page); + await waitForChoiceReady(page); + const readyChoiceResume = await readExploration(page); + assertChoiceResume( + readyChoiceResume, + choiceBeforeReload, + `${renderer}: choice panel resume` + ); + assertRuntimeBaseline( + await readRuntimeProbe( + page, + 'CampVisitExplorationScene' + ), + renderer, + 'resumed choice panel' + ); + await page.screenshot({ + path: + `dist/camp-exploration-checkpoint-choice-${renderer}.png` + }); + + const rewardBefore = rewardSnapshot( + await readCampaignStates(page) + ); + assertRewardNotYetApplied( + rewardBefore, + `${renderer}: pre-choice reward state` + ); + + const selectedChoice = readyChoiceResume.choice.choices.find( + ({ id }) => id === fixture.choiceId + ); + assert( + selectedChoice, + `${renderer}: choice ${fixture.choiceId} is unavailable.` + ); + const choiceBounds = assertFiniteBounds( + selectedChoice.bounds, + `${renderer}: ${fixture.choiceId} choice` + ); + assertBoundsContained( + choiceBounds, + desktopBrowserViewportBounds, + `${renderer}: ${fixture.choiceId} choice` + ); + await clickSceneBounds( + page, + 'CampVisitExplorationScene', + choiceBounds + ); + await waitForChoiceResponse(page, 0); + + const rewardAfterChoice = rewardSnapshot( + await readCampaignStates(page) + ); + assertRewardAppliedExactlyOnce( + rewardBefore, + rewardAfterChoice, + `${renderer}: first choice commit` + ); + + const advancedResponse = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.debugAdvanceDialogue?.() ?? false + ); + assert.equal( + advancedResponse, + true, + `${renderer}: could not advance the choice response.` + ); + await waitForChoiceResponse(page, 1); + const responseBeforeReload = await readExploration(page); + assert.equal( + responseBeforeReload.dialogue.sourceNpcId, + fixture.actorId + ); + assert.equal( + responseBeforeReload.campaign.explorationCheckpoint?.overlay + ?.dialogueKey, + 'first-choice-response' + ); + + const responseResume = + await reloadAndContinueToExploration(page); + assertChoiceResponseResume( + responseResume, + responseBeforeReload, + `${renderer}: choice response resume` + ); + const rewardAfterReload = rewardSnapshot( + await readCampaignStates(page) + ); + assert.deepEqual( + rewardAfterReload, + rewardAfterChoice, + `${renderer}: reloading the response duplicated or changed rewards.` + ); + assertRewardAppliedExactlyOnce( + rewardBefore, + rewardAfterReload, + `${renderer}: post-reload reward state` + ); + + await finishActiveDialogue(page); + await exitExplorationNormally(page); + await waitForCampReady(page, fixture.campaignStep); + assertRuntimeBaseline( + await readRuntimeProbe(page, 'CampScene'), + renderer, + 'normal checkpoint-clearing exit' + ); + assertCheckpointCleared( + await readCampaignStates(page), + `${renderer}: normal exit` + ); + + await reloadToTitle(page); + await clickContinue(page); + await waitForCampReady(page, fixture.campaignStep); + assertCheckpointCleared( + await readCampaignStates(page), + `${renderer}: continue after normal exit` + ); + assert( + !(await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('CampVisitExplorationScene') + )), + `${renderer}: a cleared checkpoint reopened exploration.` + ); + + const actionableConsoleErrors = consoleErrors.filter( + (message) => + !message.includes( + 'The AudioContext was not allowed to start' + ) + ); + assert.deepEqual( + pageErrors, + [], + `${renderer}: page errors: ${pageErrors.join('\n')}` + ); + assert.deepEqual( + actionableConsoleErrors, + [], + `${renderer}: console errors: ${actionableConsoleErrors.join( + '\n' + )}` + ); + + console.log( + JSON.stringify( + { + renderer, + viewport: { + ...desktopBrowserViewport, + deviceScaleFactor: + desktopBrowserDeviceScaleFactor, + browserZoom: '100%' + }, + checkpoint: { + exactPlayerResume: savedPlayer, + dialogue: { + sourceNpcId: + dialogueBeforeReload.dialogue.sourceNpcId, + lineIndex: + dialogueBeforeReload.dialogue.lineIndex + }, + choicePanelResumed: + choiceResume.choice.open === true, + response: { + sourceNpcId: + responseBeforeReload.dialogue.sourceNpcId, + lineIndex: + responseBeforeReload.dialogue.lineIndex + }, + rewardCommittedOnce: true, + normalExitCleared: true + }, + pageErrors: pageErrors.length, + consoleErrors: actionableConsoleErrors.length + }, + null, + 2 + ) + ); +} finally { + await browser?.close(); + await stopServerProcess(serverProcess); + await stopServerProcess(seedServerProcess); +} + +function withDebugOptions(url, requestedRenderer) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('renderer', requestedRenderer); + return parsed.toString(); +} + +function isLocalUrl(url) { + return ['localhost', '127.0.0.1', '0.0.0.0'].includes( + new URL(url).hostname + ); +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined, + undefined, + { timeout: 90000 } + ); +} + +async function seedFirstCampVisit(page, requested) { + return page.evaluate(async (seed) => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const { battleScenarios } = await import( + '/heros_web/src/game/data/battles.ts' + ); + const scenario = battleScenarios[seed.battleId]; + if (!scenario) { + throw new Error( + `Missing battle scenario ${seed.battleId}.` + ); + } + + campaignModule.resetCampaignState(); + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 6, + rewardGold: scenario.baseVictoryGold, + defeatedEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: [], + units: scenario.units, + bonds: (scenario.bonds ?? []).map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-28T00:00:00.000Z' + }); + campaignModule.completeCampaignAftermath(scenario.id); + campaignModule.setActiveCampVisitId(seed.visitId); + + const campaign = campaignModule.getCampaignState(); + const slot = campaignModule.readCampaignSaveState(1); + const bond = campaign.bonds.find( + ({ id }) => id === seed.bondId + ); + return { + step: campaign.step, + latestBattleId: campaign.latestBattleId ?? null, + activeCampVisitId: + campaign.activeCampVisitId ?? null, + slotActiveCampVisitId: + slot?.activeCampVisitId ?? null, + checkpoint: + campaign.explorationCheckpoint ?? null, + completedVisitCount: + campaign.completedCampVisits.filter( + (id) => id === seed.visitId + ).length, + inventoryItem: + campaign.inventory[seed.itemId] ?? 0, + bond: bond + ? { + level: bond.level, + exp: bond.exp, + battleExp: bond.battleExp + } + : null + }; + }, requested); +} + +function assertSeededVisit(seeded) { + assert.equal(seeded.step, fixture.campaignStep); + assert.equal(seeded.latestBattleId, fixture.battleId); + assert.equal( + seeded.activeCampVisitId, + fixture.visitId + ); + assert.equal( + seeded.slotActiveCampVisitId, + fixture.visitId + ); + assert.equal(seeded.checkpoint, null); + assert.equal(seeded.completedVisitCount, 0); + assert( + seeded.bond, + `${renderer}: seed is missing ${fixture.bondId}.` + ); +} + +async function createSeedBridge( + activeBrowser, + seedUrl, + requested +) { + const localServerProcess = await ensureLocalServer(seedUrl); + const context = await activeBrowser.newContext( + desktopBrowserContextOptions + ); + try { + const page = await context.newPage(); + page.setDefaultTimeout(30000); + await page.addInitScript(() => window.localStorage.clear()); + await page.goto(seedUrl, { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + const seeded = await seedFirstCampVisit(page, requested); + const campaignSaves = await readCampaignSaves(page); + assert( + Object.keys(campaignSaves).length >= 2, + 'Local seed bridge did not create current and slot saves.' + ); + return { + seeded, + campaignSaves, + serverProcess: localServerProcess + }; + } catch (error) { + await stopServerProcess(localServerProcess); + throw error; + } finally { + await context.close(); + } +} + +async function installCampaignSaves(page, campaignSaves) { + await page.evaluate((saves) => { + Object.keys(window.localStorage) + .filter((key) => + key.startsWith('heros-web:campaign-state') + ) + .forEach((key) => window.localStorage.removeItem(key)); + for (const [key, value] of Object.entries(saves)) { + window.localStorage.setItem(key, value); + } + }, campaignSaves); +} + +async function reloadToTitle(page) { + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await page.waitForFunction( + () => { + 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' && + continueItem?.enabled === true && + continueItem?.bounds + ); + }, + undefined, + { timeout: 90000 } + ); + await afterTwoFrames(page); +} + +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}: title continue button is unavailable.` + ); + const bounds = assertFiniteBounds( + item.bounds, + `${renderer}: title continue button` + ); + assertBoundsContained( + bounds, + desktopBrowserViewportBounds, + `${renderer}: title continue button` + ); + await clickSceneBounds(page, 'TitleScene', bounds); +} + +async function continueToExploration(page) { + await clickContinue(page); + return waitForExplorationReady(page); +} + +async function reloadAndContinueToExploration(page) { + await reloadToTitle(page); + return continueToExploration(page); +} + +async function waitForExplorationReady(page) { + try { + await page.waitForFunction( + (visitId) => { + const debug = window.__HEROS_DEBUG__; + const exploration = + debug?.campVisitExploration?.(); + return ( + debug + ?.activeScenes?.() + .includes('CampVisitExplorationScene') && + exploration?.ready === true && + exploration?.locationId === visitId && + exploration?.visit?.id === visitId && + exploration?.requiredTexturesReady === true + ); + }, + fixture.visitId, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + title: window.__HEROS_DEBUG__?.title?.() ?? null, + exploration: + window.__HEROS_DEBUG__?.campVisitExploration?.() ?? + null + })); + throw new Error( + `${renderer}: exploration did not become ready: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); + const exploration = await readExploration(page); + assert.equal( + exploration.campaignStep, + fixture.campaignStep + ); + return exploration; +} + +async function readExploration(page) { + return page.evaluate( + () => + window.__HEROS_DEBUG__?.campVisitExploration?.() ?? + null + ); +} + +async function createPlayerCheckpoint(page) { + await page.waitForTimeout(450); + const teleported = await page.evaluate((targetId) => + window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.debugTeleportTo?.(targetId) ?? false, + fixture.movementAnchorId + ); + assert.equal( + teleported, + true, + `${renderer}: could not create the movement checkpoint.` + ); + + await page.keyboard.down('ArrowRight'); + await page.waitForTimeout(260); + await page.keyboard.up('ArrowRight'); + try { + await page.waitForFunction( + () => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + const player = state?.player; + const checkpoint = + state?.campaign?.explorationCheckpoint; + return ( + player && + player.moving === false && + player.direction === 'east' && + checkpoint?.overlay === undefined && + Math.abs(checkpoint?.player?.x - player.x) <= 0.05 && + Math.abs(checkpoint?.player?.y - player.y) <= 0.05 && + checkpoint?.player?.direction === player.direction + ); + }, + undefined, + { timeout: 10000 } + ); + } catch (error) { + const diagnostic = await readExploration(page); + throw new Error( + `${renderer}: movement checkpoint did not settle: ` + + JSON.stringify({ + activeScenes: + await page.evaluate( + () => + window.__HEROS_DEBUG__?.activeScenes?.() ?? + [] + ), + player: diagnostic?.player ?? null, + checkpoint: + diagnostic?.campaign?.explorationCheckpoint ?? + null, + movement: diagnostic?.movement ?? null + }), + { cause: error } + ); + } + const exploration = await readExploration(page); + const player = exactPlayer( + exploration.campaign.explorationCheckpoint?.player + ); + assertExactCheckpointPlayer( + exploration.campaign.explorationCheckpoint, + player, + `${renderer}: newly saved player checkpoint` + ); + return player; +} + +async function openAndAdvanceActorDialogue(page) { + const interacted = await page.evaluate((actorId) => + window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.debugInteractWith?.(actorId) ?? false, + fixture.actorId + ); + assert.equal( + interacted, + true, + `${renderer}: could not interact with ${fixture.actorId}.` + ); + await waitForDialogue(page, { + sourceNpcId: fixture.actorId, + lineIndex: 0, + dialogueKey: 'visit-interaction' + }); + + const advanced = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.debugAdvanceDialogue?.() ?? false + ); + assert.equal( + advanced, + true, + `${renderer}: could not advance the actor dialogue.` + ); + await waitForDialogue(page, { + sourceNpcId: fixture.actorId, + lineIndex: 1, + dialogueKey: 'visit-interaction' + }); + return readExploration(page); +} + +async function waitForDialogue( + page, + { sourceNpcId, lineIndex, dialogueKey } +) { + await page.waitForFunction( + (expected) => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + const overlay = + state?.campaign?.explorationCheckpoint?.overlay; + return ( + state?.dialogue?.active === true && + state.dialogue.sourceNpcId === + expected.sourceNpcId && + state.dialogue.lineIndex === expected.lineIndex && + overlay?.type === 'dialogue' && + overlay.dialogueKey === expected.dialogueKey && + overlay.sourceNpcId === expected.sourceNpcId && + overlay.lineIndex === expected.lineIndex + ); + }, + { sourceNpcId, lineIndex, dialogueKey }, + { timeout: 10000 } + ); + await afterTwoFrames(page); +} + +function assertDialogueResume(actual, expected, label) { + assert.equal(actual.dialogue.active, true, label); + assert.equal( + actual.dialogue.sourceNpcId, + expected.dialogue.sourceNpcId, + `${label}: source changed.` + ); + assert.equal( + actual.dialogue.lineIndex, + expected.dialogue.lineIndex, + `${label}: line changed.` + ); + assert.equal( + actual.dialogue.totalLines, + expected.dialogue.totalLines, + `${label}: dialogue content changed.` + ); + const actualOverlay = + actual.campaign.explorationCheckpoint?.overlay; + const expectedOverlay = + expected.campaign.explorationCheckpoint?.overlay; + assert.deepEqual( + actualOverlay, + expectedOverlay, + `${label}: checkpoint overlay changed.` + ); +} + +async function advanceToChoicePanel(page) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const state = await readExploration(page); + if (state?.choice?.open) { + break; + } + assert.equal( + state?.dialogue?.active, + true, + `${renderer}: dialogue closed before the choice panel opened.` + ); + const advanced = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.debugAdvanceDialogue?.() ?? false + ); + assert.equal( + advanced, + true, + `${renderer}: failed to advance toward the choice panel.` + ); + await page.waitForTimeout(80); + } + await page.waitForFunction( + () => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + const overlay = + state?.campaign?.explorationCheckpoint?.overlay; + return ( + state?.dialogue?.active === false && + state?.choice?.open === true && + state.choice.mode === 'visit' && + state.choice.choices.length === 2 && + overlay?.type === 'choice' && + overlay.mode === 'visit' + ); + }, + undefined, + { timeout: 10000 } + ); + await afterTwoFrames(page); + return readExploration(page); +} + +async function waitForChoiceReady(page) { + await page.waitForFunction( + () => { + const choice = + window.__HEROS_DEBUG__ + ?.campVisitExploration?.() + ?.choice; + return ( + choice?.open === true && + choice?.ready === true && + choice.choices.length === 2 && + choice.choices.every( + ({ interactive, bounds }) => + interactive === true && + bounds && + Number.isFinite(bounds.x) && + Number.isFinite(bounds.y) + ) + ); + }, + undefined, + { timeout: 10000 } + ); + await afterTwoFrames(page); +} + +function assertChoiceResume(actual, expected, label) { + assert.equal(actual.choice.open, true, label); + assert.equal(actual.choice.mode, expected.choice.mode); + assert.equal( + actual.choice.sourceNpcId, + expected.choice.sourceNpcId + ); + assert.deepEqual( + actual.choice.choices.map(({ id }) => id), + expected.choice.choices.map(({ id }) => id), + `${label}: choice IDs changed.` + ); + assert.deepEqual( + actual.campaign.explorationCheckpoint?.overlay, + expected.campaign.explorationCheckpoint?.overlay, + `${label}: choice checkpoint changed.` + ); + for (const choice of actual.choice.choices) { + const bounds = assertFiniteBounds( + choice.bounds, + `${label}: ${choice.id}` + ); + assertBoundsContained( + bounds, + desktopBrowserViewportBounds, + `${label}: ${choice.id}` + ); + } +} + +async function waitForChoiceResponse(page, lineIndex) { + await waitForDialogue(page, { + sourceNpcId: fixture.actorId, + lineIndex, + dialogueKey: 'first-choice-response' + }); + const state = await readExploration(page); + assert.equal(state.choice.open, false); + assert.equal(state.visit.completed, true); + assert.equal(state.visit.choiceId, fixture.choiceId); +} + +function assertChoiceResponseResume(actual, expected, label) { + assertDialogueResume(actual, expected, label); + assert.equal(actual.visit.completed, true, label); + assert.equal( + actual.visit.choiceId, + fixture.choiceId, + `${label}: selected choice changed.` + ); + assert.equal( + actual.choice.open, + false, + `${label}: response reopened the choice panel.` + ); +} + +async function finishActiveDialogue(page) { + for (let attempt = 0; attempt < 8; attempt += 1) { + const state = await readExploration(page); + if (!state?.dialogue?.active) { + break; + } + const advanced = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.debugAdvanceDialogue?.() ?? false + ); + assert.equal( + advanced, + true, + `${renderer}: could not finish the response dialogue.` + ); + await page.waitForTimeout(80); + } + await page.waitForFunction( + () => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + return ( + state?.dialogue?.active === false && + state?.choice?.open === false && + state?.campaign?.explorationCheckpoint && + state.campaign.explorationCheckpoint.overlay === + undefined + ); + }, + undefined, + { timeout: 10000 } + ); +} + +async function exitExplorationNormally(page) { + const startedExit = await page.evaluate((exitId) => + window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.debugInteractWith?.(exitId) ?? false, + fixture.exitId + ); + assert.equal( + startedExit, + true, + `${renderer}: normal camp exit did not start.` + ); +} + +async function waitForCampReady(page, expectedStep) { + try { + await page.waitForFunction( + (step) => { + const debug = window.__HEROS_DEBUG__; + const camp = debug?.camp?.(); + return ( + debug?.activeScenes?.().includes('CampScene') && + camp?.scene === 'CampScene' && + camp?.campaignObjectiveJournal?.snapshot && + camp?.campaign?.step === step + ); + }, + expectedStep, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + camp: window.__HEROS_DEBUG__?.camp?.() ?? null, + exploration: + window.__HEROS_DEBUG__?.campVisitExploration?.() ?? + null + })); + throw new Error( + `${renderer}: CampScene did not become ready: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); +} + +function exactPlayer(player) { + assert( + player && + Number.isFinite(player.x) && + Number.isFinite(player.y) && + ['north', 'south', 'east', 'west'].includes( + player.direction + ), + `${renderer}: invalid player snapshot ${JSON.stringify( + player + )}.` + ); + return { + x: player.x, + y: player.y, + direction: player.direction + }; +} + +function assertExactPlayer(actual, expected, label) { + assert.deepEqual( + exactPlayer(actual), + exactPlayer(expected), + `${label}: exact x/y/direction changed.` + ); +} + +function assertExactCheckpointPlayer( + checkpoint, + expected, + label +) { + assert( + checkpoint, + `${label}: exploration checkpoint is missing.` + ); + assert.equal(checkpoint.version, 1, label); + assert.equal( + checkpoint.scene, + 'camp-visit-exploration', + label + ); + assert.equal(checkpoint.contextId, fixture.visitId, label); + assert.deepEqual( + checkpoint.player, + expected, + `${label}: checkpoint player differs.` + ); +} + +async function readCampaignSaves(page) { + return page.evaluate(() => + Object.fromEntries( + Object.keys(window.localStorage) + .filter((key) => + key.startsWith('heros-web:campaign-state') + ) + .sort() + .map((key) => [ + key, + window.localStorage.getItem(key) + ]) + ) + ); +} + +async function readCampaignStates(page) { + const saves = await readCampaignSaves(page); + return Object.fromEntries( + Object.entries(saves).map(([key, value]) => [ + key, + JSON.parse(value) + ]) + ); +} + +function rewardSnapshot(states) { + const entries = Object.entries(states); + assert( + entries.length >= 2, + `${renderer}: expected current and slot campaign saves.` + ); + return Object.fromEntries( + entries.map(([key, state]) => { + const bond = state.bonds.find( + ({ id }) => id === fixture.bondId + ); + const reportBond = state.firstBattleReport?.bonds.find( + ({ id }) => id === fixture.bondId + ); + assert( + bond && reportBond, + `${renderer}: ${key} is missing ${fixture.bondId}.` + ); + return [ + key, + { + completedVisitCount: + state.completedCampVisits.filter( + (id) => id === fixture.visitId + ).length, + reportCompletedVisitCount: + state.firstBattleReport.completedCampVisits.filter( + (id) => id === fixture.visitId + ).length, + choiceId: + state.campVisitChoiceIds[fixture.visitId] ?? + null, + inventory: { ...state.inventory }, + bond: { + level: bond.level, + exp: bond.exp, + battleExp: bond.battleExp + }, + reportBond: { + level: reportBond.level, + exp: reportBond.exp, + battleExp: reportBond.battleExp + } + } + ]; + }) + ); +} + +function assertRewardNotYetApplied(snapshot, label) { + for (const [key, state] of Object.entries(snapshot)) { + assert.equal( + state.completedVisitCount, + 0, + `${label}: ${key} already completed the visit.` + ); + assert.equal( + state.reportCompletedVisitCount, + 0, + `${label}: ${key} report already completed the visit.` + ); + assert.equal( + state.choiceId, + null, + `${label}: ${key} already remembers a choice.` + ); + } +} + +function assertRewardAppliedExactlyOnce( + before, + after, + label +) { + assert.deepEqual( + Object.keys(after), + Object.keys(before), + `${label}: campaign save keys changed.` + ); + for (const key of Object.keys(before)) { + const previous = before[key]; + const current = after[key]; + assert.equal( + current.completedVisitCount, + 1, + `${label}: ${key} visit completion count mismatch.` + ); + assert.equal( + current.reportCompletedVisitCount, + 1, + `${label}: ${key} report completion count mismatch.` + ); + assert.equal( + current.choiceId, + fixture.choiceId, + `${label}: ${key} choice mismatch.` + ); + assert.equal( + current.inventory[fixture.itemId] ?? 0, + (previous.inventory[fixture.itemId] ?? 0) + + fixture.itemAmount, + `${label}: ${key} inventory reward mismatch.` + ); + for (const [itemId, amount] of Object.entries( + current.inventory + )) { + if (itemId !== fixture.itemId) { + assert.equal( + amount, + previous.inventory[itemId], + `${label}: ${key} changed unrelated item ${itemId}.` + ); + } + } + assert.deepEqual( + current.bond, + { + level: previous.bond.level, + exp: previous.bond.exp + fixture.bondExp, + battleExp: + previous.bond.battleExp + fixture.bondExp + }, + `${label}: ${key} campaign bond reward mismatch.` + ); + assert.deepEqual( + current.reportBond, + current.bond, + `${label}: ${key} report bond diverged.` + ); + } +} + +function assertCheckpointCleared(states, label) { + const entries = Object.entries(states); + assert( + entries.length >= 2, + `${label}: expected current and slot saves.` + ); + for (const [key, state] of entries) { + assert.equal( + state.activeCampVisitId ?? null, + null, + `${label}: ${key} retained the active visit.` + ); + assert.equal( + state.explorationCheckpoint ?? null, + null, + `${label}: ${key} retained the exploration checkpoint.` + ); + } +} + +async function clickSceneBounds(page, sceneKey, bounds) { + const point = await page.evaluate( + ({ key, requestedBounds }) => { + const scene = window.__HEROS_DEBUG__?.scene(key); + const canvasBounds = + document.querySelector('canvas')?.getBoundingClientRect(); + if (!scene || !canvasBounds) { + return null; + } + return { + x: + canvasBounds.left + + (requestedBounds.x + + requestedBounds.width / 2) * + canvasBounds.width / + scene.scale.width, + y: + canvasBounds.top + + (requestedBounds.y + + requestedBounds.height / 2) * + canvasBounds.height / + scene.scale.height + }; + }, + { key: sceneKey, requestedBounds: bounds } + ); + assert( + point && + Number.isFinite(point.x) && + Number.isFinite(point.y), + `Unable to map ${sceneKey} bounds ${JSON.stringify( + bounds + )}.` + ); + await page.mouse.click(point.x, point.y); +} + +function assertFiniteBounds(bounds, label) { + assert( + bounds && + Number.isFinite(bounds.x) && + Number.isFinite(bounds.y) && + Number.isFinite(bounds.width) && + Number.isFinite(bounds.height) && + bounds.width > 0 && + bounds.height > 0, + `${label}: invalid bounds ${JSON.stringify(bounds)}.` + ); + return bounds; +} + +function assertBoundsContained(bounds, container, label) { + assert( + bounds.x >= container.x && + bounds.y >= container.y && + bounds.x + bounds.width <= + container.x + container.width && + bounds.y + bounds.height <= + container.y + container.height, + `${label}: ${JSON.stringify(bounds)} is outside ` + + JSON.stringify(container) + ); +} + +async function afterTwoFrames(page) { + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => + requestAnimationFrame(resolve) + ); + }) + ); +} + +async function readRuntimeProbe(page, sceneKey) { + return page.evaluate((key) => { + const game = window.__HEROS_GAME__; + const scene = game?.scene.getScene(key); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + return { + viewport: { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + zoom: window.visualViewport?.scale ?? 1 + }, + canvas: canvas + ? { + width: canvas.width, + height: canvas.height, + bounds: canvasBounds + ? { + x: canvasBounds.x, + y: canvasBounds.y, + width: canvasBounds.width, + height: canvasBounds.height + } + : null + } + : null, + renderer: { + requested: new URLSearchParams( + window.location.search + ).get('renderer'), + type: game?.renderer?.type ?? null, + name: game?.renderer?.constructor?.name ?? null + }, + scene: scene + ? { + width: scene.scale.width, + height: scene.scale.height + } + : null + }; + }, sceneKey); +} + +function assertRuntimeBaseline( + runtime, + requestedRenderer, + label +) { + assert.deepEqual( + runtime.viewport, + { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height, + dpr: desktopBrowserDeviceScaleFactor, + zoom: 1 + }, + `${label}: viewport, DPR, or browser zoom mismatch.` + ); + assert.deepEqual( + runtime.canvas, + { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height, + bounds: { + x: 0, + y: 0, + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + } + }, + `${label}: canvas did not fill the CSS viewport.` + ); + assert.deepEqual( + runtime.scene, + { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + }, + `${label}: Phaser scene size mismatch.` + ); + assert.equal( + runtime.renderer.requested, + requestedRenderer, + `${label}: renderer query mismatch.` + ); + assert.equal( + runtime.renderer.type, + requestedRenderer === 'canvas' ? 1 : 2, + `${label}: expected ${requestedRenderer}: ` + + JSON.stringify(runtime.renderer) + ); +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + + const parsed = new URL(url); + if ( + !['localhost', '127.0.0.1', '0.0.0.0'].includes( + parsed.hostname + ) + ) { + throw new Error(`No server responded at ${url}`); + } + + const stderr = []; + const child = spawn( + process.execPath, + [ + 'node_modules/vite/bin/vite.js', + '--host', + '127.0.0.1', + '--port', + parsed.port || '41808', + '--strictPort' + ], + { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ); + child.stderr.on('data', (chunk) => + stderr.push(chunk.toString()) + ); + child.stdout.on('data', () => {}); + + for (let attempt = 0; attempt < 120; attempt += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + + await stopServerProcess(child); + throw new Error( + `Vite server did not start at ${url}\n${stderr.join('')}` + ); +} + +async function canReach(url) { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(1000) + }); + return response.ok; + } catch { + return false; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function stopServerProcess(child) { + if (!child || child.exitCode !== null) { + return; + } + const exited = new Promise((resolve) => + child.once('exit', resolve) + ); + child.kill(); + await Promise.race([exited, delay(5000)]); + if (child.exitCode === null) { + child.kill('SIGKILL'); + await Promise.race([exited, delay(2000)]); + } +} diff --git a/scripts/verify-camp-visit-resume-state.mjs b/scripts/verify-camp-visit-resume-state.mjs index 0224ae0..d03b229 100644 --- a/scripts/verify-camp-visit-resume-state.mjs +++ b/scripts/verify-camp-visit-resume-state.mjs @@ -82,15 +82,15 @@ try { verifyValidSetterPersistence(fixture); verifyNormalizationFailures(fixture); verifyCampaignStepClearsVisit(fixture); - verifyBattleRecordUpdateClearsVisit(fixture, 'victory'); - verifyBattleRecordUpdateClearsVisit(fixture, 'defeat'); + verifySettledBattleReplayPreservesVisit(fixture, 'victory'); + verifySettledBattleReplayPreservesVisit(fixture, 'defeat'); } verifyCityMutualExclusion(); verifyLegacySaveWithoutCampVisitField(); console.log( - 'Verified camp-visit resume state for all three visits: setter validation, base/active-slot persistence, reload retention, resume summaries, invalid-state normalization, campaign-step and battle-record clearing, city mutual exclusion, and legacy-save compatibility.' + 'Verified camp-visit resume state for all three visits: setter validation, base/active-slot persistence, reload retention, resume summaries, invalid-state normalization, campaign-step clearing, settled battle replay preservation, city mutual exclusion, and legacy-save compatibility.' ); function verifyValidSetterPersistence(fixture) { @@ -271,23 +271,31 @@ try { ); } - function verifyBattleRecordUpdateClearsVisit(fixture, outcome) { + function verifySettledBattleReplayPreservesVisit( + fixture, + outcome + ) { storage.clear(); campaign.setCampaignState(validCampVisitState(fixture, 2)); campaign.setActiveCampVisitId(fixture.visitId); - campaign.setFirstBattleReport( + const replayedReport = campaign.setFirstBattleReport( battleReport(fixture.sourceBattleId, outcome) ); + assert.equal( + replayedReport.outcome, + 'victory', + `${fixture.visitId}: a settled ${outcome} replay must resolve to the existing victory.` + ); assert.equal( campaign.getCampaignState().activeCampVisitId, - undefined, - `${fixture.visitId}: a ${outcome} battle-record update must clear the active visit.` + fixture.visitId, + `${fixture.visitId}: a settled ${outcome} replay must preserve the active visit.` ); assertPersistedVisit( - undefined, + fixture.visitId, 2, - `${fixture.visitId}: ${outcome} battle-record persistence` + `${fixture.visitId}: settled ${outcome} replay persistence` ); } diff --git a/scripts/verify-campaign-exploration-checkpoint-normalization.mjs b/scripts/verify-campaign-exploration-checkpoint-normalization.mjs new file mode 100644 index 0000000..2133458 --- /dev/null +++ b/scripts/verify-campaign-exploration-checkpoint-normalization.mjs @@ -0,0 +1,636 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; + +const storage = new Map(); +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + 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 { battleScenarios } = await server.ssrLoadModule( + '/src/game/data/battles.ts' + ); + const firstPursuit = await server.ssrLoadModule( + '/src/game/data/firstPursuitScoutMemory.ts' + ); + const secondRelief = await server.ssrLoadModule( + '/src/game/data/secondBattleReliefExploration.ts' + ); + const thirdCamp = await server.ssrLoadModule( + '/src/game/data/thirdCampExploration.ts' + ); + + const campFixtures = [ + { + label: 'first camp', + visitId: firstPursuit.firstPursuitScoutVisitId, + sourceBattleId: firstPursuit.firstPursuitScoutSourceBattleId, + step: 'first-camp', + dialogueKeys: [ + 'visit-interaction', + 'camp-exit', + 'first-choice-response' + ], + choiceModes: ['visit'], + invalidOverlays: [ + { + type: 'dialogue', + dialogueKey: 'second-choice-response', + lineIndex: 1 + }, + { type: 'choice', mode: 'third-companion' }, + { type: 'shop', sourceNpcId: 'quartermaster' } + ] + }, + { + label: 'second camp', + visitId: secondRelief.secondBattleReliefVisitId, + sourceBattleId: secondRelief.secondBattleReliefSourceBattleId, + step: 'second-camp', + dialogueKeys: [ + 'visit-interaction', + 'camp-exit', + 'second-choice-response' + ], + choiceModes: ['visit'], + invalidOverlays: [ + { + type: 'dialogue', + dialogueKey: 'first-choice-response', + lineIndex: 1 + }, + { type: 'choice', mode: 'city-resonance' } + ] + }, + { + label: 'third camp', + visitId: thirdCamp.thirdCampExplorationVisitId, + sourceBattleId: thirdCamp.thirdCampExplorationSourceBattleId, + step: 'third-camp', + dialogueKeys: [ + 'third-intro', + 'visit-interaction', + 'camp-exit', + 'third-companion-response' + ], + choiceModes: ['third-companion'], + invalidOverlays: [ + { + type: 'dialogue', + dialogueKey: 'first-choice-response', + lineIndex: 1 + }, + { type: 'choice', mode: 'visit' } + ] + } + ]; + + const routes = [ + ...campFixtures.map((fixture) => ({ + label: fixture.label, + state: () => validCampState(fixture), + scene: 'camp-visit-exploration', + contextId: fixture.visitId, + dialogueKeys: fixture.dialogueKeys, + choiceOverlays: fixture.choiceModes.map((mode) => ({ + type: 'choice', + mode, + sourceNpcId: `${fixture.label}-npc` + })), + shopOverlays: [], + invalidOverlays: fixture.invalidOverlays + })), + { + label: 'city', + state: validCityState, + scene: 'city-stay', + contextId: 'xuzhou', + dialogueKeys: [ + 'city-interaction', + 'city-exit', + 'city-resonance-response' + ], + choiceOverlays: [ + { + type: 'choice', + mode: 'city-resonance', + sourceNpcId: 'city-companion' + } + ], + shopOverlays: [ + { + type: 'shop', + sourceNpcId: 'city-quartermaster' + } + ], + invalidOverlays: [ + { + type: 'dialogue', + dialogueKey: 'first-choice-response', + lineIndex: 1 + }, + { type: 'choice', mode: 'visit' } + ] + }, + { + label: 'prologue village', + state: validVillageState, + scene: 'prologue-village', + contextId: 'prologue-village', + dialogueKeys: [ + 'prologue-village-interaction', + 'prologue-village-oath' + ], + choiceOverlays: [], + shopOverlays: [], + invalidOverlays: [ + { type: 'choice', mode: 'city-resonance' }, + { + type: 'dialogue', + dialogueKey: 'prologue-camp-interaction', + lineIndex: 1 + } + ] + }, + { + label: 'prologue militia camp', + state: validMilitiaState, + scene: 'prologue-militia-camp', + contextId: 'prologue-militia-camp', + dialogueKeys: [ + 'prologue-camp-interaction', + 'prologue-camp-departure', + 'prologue-camp-command-response' + ], + choiceOverlays: [ + { + type: 'choice', + mode: 'prologue-command', + sourceNpcId: 'liu-bei' + }, + { + type: 'choice', + mode: 'prologue-command', + sourceNpcId: 'liu-bei', + choiceId: 'mobile' + } + ], + shopOverlays: [], + invalidOverlays: [ + { + type: 'choice', + mode: 'prologue-command', + choiceId: 'unknown-order' + }, + { type: 'shop', sourceNpcId: 'camp-quartermaster' }, + { + type: 'dialogue', + dialogueKey: 'prologue-village-oath', + lineIndex: 1 + } + ] + } + ]; + + verifyValidPolicyMatrix(); + verifyStoredInvalidOverlayPreservesPlayer(); + verifyInvalidSetterIsCompleteNoop(); + verifyStaleCampToCityRace(); + + console.log( + 'Verified exploration checkpoint normalization: complete per-context overlay policy matrix, stored invalid-overlay player preservation, invalid-setter state/timestamp/base+slot byte invariance, sortie-order choice validation, and stale camp-to-city write rejection.' + ); + + function verifyValidPolicyMatrix() { + for (const route of routes) { + const overlays = [ + ...route.dialogueKeys.map((dialogueKey) => ({ + type: 'dialogue', + dialogueKey, + sourceNpcId: `${route.label}-npc`, + lineIndex: 4 + })), + ...route.choiceOverlays, + ...route.shopOverlays + ]; + + for (const overlay of overlays) { + const checkpoint = checkpointFor(route, overlay); + const loaded = loadRawState( + withCheckpoint(route.state(), checkpoint) + ); + assert.deepEqual( + loaded.explorationCheckpoint, + checkpoint, + `${route.label}: valid ${overlay.type} overlay must survive stored-save normalization.` + ); + } + + const checkpointWithoutOverlay = checkpointFor(route); + const loadedWithoutOverlay = loadRawState( + withCheckpoint(route.state(), checkpointWithoutOverlay) + ); + assert.deepEqual( + loadedWithoutOverlay.explorationCheckpoint, + checkpointWithoutOverlay, + `${route.label}: an absent overlay must remain absent.` + ); + } + } + + function verifyStoredInvalidOverlayPreservesPlayer() { + for (const route of routes) { + for (const invalidOverlay of [ + ...route.invalidOverlays, + null, + { type: 'unknown-overlay' } + ]) { + const rawCheckpoint = checkpointFor( + route, + invalidOverlay + ); + const expectedCheckpoint = checkpointFor(route); + const loaded = loadRawState( + withCheckpoint(route.state(), rawCheckpoint) + ); + assert.deepEqual( + loaded.explorationCheckpoint, + expectedCheckpoint, + `${route.label}: an incompatible stored overlay must be removed without losing the player checkpoint.` + ); + } + } + } + + function verifyInvalidSetterIsCompleteNoop() { + const firstRoute = routes[0]; + assertSetterNoop( + firstRoute, + { + ...checkpointFor(firstRoute), + player: { + x: -1, + y: 512, + direction: 'south' + } + }, + 'invalid player coordinates' + ); + assertSetterNoop( + firstRoute, + { + ...checkpointFor(firstRoute), + player: { + x: 420, + y: 512, + direction: 'upward' + } + }, + 'invalid player direction' + ); + assertSetterNoop( + firstRoute, + checkpointFor(firstRoute, { + type: 'dialogue', + dialogueKey: 'city-interaction', + lineIndex: 0 + }), + 'context-incompatible overlay' + ); + assertSetterNoop( + firstRoute, + { + ...checkpointFor(firstRoute), + contextId: campFixtures[1].visitId + }, + 'stale context' + ); + + const militiaRoute = routes.find( + ({ scene }) => scene === 'prologue-militia-camp' + ); + assert(militiaRoute); + assertSetterNoop( + militiaRoute, + checkpointFor(militiaRoute, { + type: 'choice', + mode: 'prologue-command', + choiceId: 'unknown-order' + }), + 'invalid sortie order choice id' + ); + + const cityRoute = routes.find( + ({ scene }) => scene === 'city-stay' + ); + assert(cityRoute); + assertSetterNoop( + cityRoute, + checkpointFor(cityRoute, { + type: 'choice', + mode: 'city-resonance', + choiceId: 'mobile' + }), + 'unexpected choice id outside prologue command' + ); + } + + function assertSetterNoop(route, invalidCheckpoint, label) { + storage.clear(); + campaign.setCampaignState(route.state()); + campaign.setCampaignExplorationCheckpoint( + setterCheckpointFor( + checkpointFor(route, validOverlayFor(route)) + ) + ); + + const beforeState = campaign.getCampaignState(); + const beforeBase = storage.get(campaign.campaignStorageKey); + const beforeSlot = storage.get( + slotKey(beforeState.activeSaveSlot) + ); + assert(beforeBase, `${route.label}: base fixture is missing.`); + assert(beforeSlot, `${route.label}: slot fixture is missing.`); + + const returned = campaign.setCampaignExplorationCheckpoint( + setterCheckpointFor(invalidCheckpoint) + ); + const afterState = campaign.getCampaignState(); + + assert.deepEqual( + returned, + beforeState, + `${route.label} ${label}: setter return must preserve the complete state.` + ); + assert.deepEqual( + afterState, + beforeState, + `${route.label} ${label}: in-memory state and updatedAt must remain unchanged.` + ); + assert.equal( + storage.get(campaign.campaignStorageKey), + beforeBase, + `${route.label} ${label}: base-save bytes must remain unchanged.` + ); + assert.equal( + storage.get(slotKey(beforeState.activeSaveSlot)), + beforeSlot, + `${route.label} ${label}: slot-save bytes must remain unchanged.` + ); + } + + function verifyStaleCampToCityRace() { + const cityRoute = routes.find( + ({ scene }) => scene === 'city-stay' + ); + assert(cityRoute); + + storage.clear(); + campaign.setCampaignState(cityRoute.state()); + campaign.setCampaignExplorationCheckpoint( + setterCheckpointFor( + checkpointFor(cityRoute, validOverlayFor(cityRoute)) + ) + ); + const beforeState = campaign.getCampaignState(); + const beforeBase = storage.get(campaign.campaignStorageKey); + const beforeSlot = storage.get( + slotKey(beforeState.activeSaveSlot) + ); + + const staleCampCheckpoint = { + version: 1, + scene: 'camp-visit-exploration', + contextId: campFixtures[0].visitId, + player: { + x: 701, + y: 601, + direction: 'west' + }, + overlay: { + type: 'dialogue', + dialogueKey: 'visit-interaction', + sourceNpcId: 'stale-camp-npc', + lineIndex: 1 + } + }; + const returned = + campaign.setCampaignExplorationCheckpoint( + staleCampCheckpoint + ); + + assert.deepEqual( + returned, + beforeState, + 'A stale camp pagehide write after city activation must return the unchanged city state.' + ); + assert.deepEqual( + campaign.getCampaignState(), + beforeState, + 'A stale camp pagehide write must not erase or replace the city checkpoint.' + ); + assert.equal( + storage.get(campaign.campaignStorageKey), + beforeBase, + 'A stale camp pagehide write must not change base-save bytes.' + ); + assert.equal( + storage.get(slotKey(beforeState.activeSaveSlot)), + beforeSlot, + 'A stale camp pagehide write must not change slot-save bytes.' + ); + } + + function validOverlayFor(route) { + if (route.choiceOverlays.length > 0) { + return route.choiceOverlays.at(-1); + } + return { + type: 'dialogue', + dialogueKey: route.dialogueKeys[0], + sourceNpcId: `${route.label}-npc`, + lineIndex: 1 + }; + } + + function checkpointFor(route, overlay) { + return { + version: 1, + scene: route.scene, + contextId: route.contextId, + player: { + x: 420.25, + y: 512.5, + direction: 'south' + }, + ...(arguments.length >= 2 ? { overlay } : {}), + savedAt: '2026-07-28T10:30:00.000Z' + }; + } + + function setterCheckpointFor(checkpoint) { + const { savedAt: _savedAt, ...setterCheckpoint } = checkpoint; + return setterCheckpoint; + } + + function withCheckpoint(state, checkpoint) { + return { + ...state, + explorationCheckpoint: clone(checkpoint) + }; + } + + function loadRawState(state) { + storage.clear(); + const serialized = JSON.stringify(state); + storage.set(campaign.campaignStorageKey, serialized); + storage.set( + slotKey(state.activeSaveSlot ?? 1), + serialized + ); + return campaign.loadCampaignState(); + } + + function validCampState(fixture, slot = 2) { + const report = battleReport(fixture.sourceBattleId); + const state = campaign.createInitialCampaignState(); + state.updatedAt = '2026-07-28T10:00:00.000Z'; + state.step = fixture.step; + state.activeSaveSlot = slot; + state.gold = 420; + state.roster = clone(report.units); + state.bonds = clone(report.bonds); + state.latestBattleId = fixture.sourceBattleId; + state.firstBattleReport = report; + state.battleHistory = { + [fixture.sourceBattleId]: battleSettlement(report) + }; + state.activeCampVisitId = fixture.visitId; + delete state.pendingAftermathBattleId; + delete state.activeCityStayId; + return state; + } + + function validCityState(slot = 2) { + const battleId = 'seventh-battle-xuzhou-rescue'; + const report = battleReport(battleId); + const state = campaign.createInitialCampaignState(); + state.updatedAt = '2026-07-28T10:05:00.000Z'; + state.step = 'seventh-camp'; + state.activeSaveSlot = slot; + state.gold = 640; + state.roster = clone(report.units); + state.bonds = clone(report.bonds); + state.latestBattleId = battleId; + state.firstBattleReport = report; + state.battleHistory = { + [battleId]: battleSettlement(report) + }; + state.activeCityStayId = 'xuzhou'; + delete state.pendingAftermathBattleId; + delete state.activeCampVisitId; + return state; + } + + function validVillageState(slot = 2) { + const state = campaign.createInitialCampaignState(); + state.updatedAt = '2026-07-28T10:10:00.000Z'; + state.step = 'prologue-town'; + state.activeSaveSlot = slot; + return state; + } + + function validMilitiaState(slot = 2) { + const state = campaign.createInitialCampaignState(); + state.updatedAt = '2026-07-28T10:15:00.000Z'; + state.step = 'prologue-camp'; + state.activeSaveSlot = slot; + return state; + } + + function battleReport(battleId) { + const scenario = battleScenarios[battleId]; + assert(scenario, `Missing battle scenario fixture ${battleId}.`); + const alliedUnits = scenario.units + .filter((unit) => unit.faction === 'ally') + .map((unit) => clone(unit)); + return { + battleId, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 5, + rewardGold: 100, + defeatedEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: [], + units: alliedUnits, + bonds: scenario.bonds.map((bond) => ({ + ...clone(bond), + battleExp: 0 + })), + itemRewards: [], + campaignRewards: { + supplies: [], + equipment: [], + reputation: [], + recruits: [], + unlocks: [] + }, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-28T10:00:00.000Z' + }; + } + + function battleSettlement(report) { + return { + battleId: report.battleId, + battleTitle: report.battleTitle, + outcome: report.outcome, + turnNumber: report.turnNumber, + rewardGold: report.rewardGold, + itemRewards: [...report.itemRewards], + campaignRewards: clone(report.campaignRewards), + objectives: [], + units: [], + bonds: [], + completedAt: report.createdAt + }; + } + + function slotKey(slot) { + return `${campaign.campaignStorageKey}:slot-${slot}`; + } + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } +} finally { + await server.close(); +} diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 559ba08..e314caa 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -887,6 +887,10 @@ try { preJianYongSave.battleHistory[firstScenario.id].bonds = preJianYongSave.battleHistory[firstScenario.id].bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong'); preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits = preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits.filter((recruit) => recruit.unitId !== 'jian-yong'); storage.set(campaignStorageKey, JSON.stringify(preJianYongSave)); + storage.set( + `${campaignStorageKey}:slot-${preJianYongSave.activeSaveSlot}`, + JSON.stringify(preJianYongSave) + ); const repairedEarlyChoiceSave = loadCampaignState(); const persistedEarlyChoiceSave = saveCampaignState(repairedEarlyChoiceSave); assert( @@ -1152,6 +1156,10 @@ try { 'unknown-battle' ); storage.set(campaignStorageKey, JSON.stringify(corruptedFutureAcknowledgementSave)); + storage.set( + `${campaignStorageKey}:slot-${corruptedFutureAcknowledgementSave.activeSaveSlot}`, + JSON.stringify(corruptedFutureAcknowledgementSave) + ); const normalizedFutureAcknowledgementSave = loadCampaignState(); assert( normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.includes(firstScenario.id) && @@ -1167,19 +1175,19 @@ try { }); const revisedSettlement = getCampaignState(); assert( - revisedSettlement.gold === 140 && - revisedSettlement.inventory.Bean === 3 && - revisedSettlement.inventory.Wine === 1 && - revisedSettlement.inventory['Iron Sword'] === undefined && - revisedSettlement.inventory['Iron Armor'] === 1, - `Expected revised battle report settlement to replace previous rewards instead of stacking: ${JSON.stringify(revisedSettlement)}` + revisedSettlement.gold === 100 && + revisedSettlement.inventory.Bean === 2 && + revisedSettlement.inventory.Wine === undefined && + revisedSettlement.inventory['Iron Sword'] === 1 && + revisedSettlement.inventory['Iron Armor'] === undefined, + `Expected a revised settled report to update history without replacing or resurrecting the already-committed economy: ${JSON.stringify(revisedSettlement)}` ); applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-left'); applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-right'); const repeatedVisit = getCampaignState(); assert( - repeatedVisit.gold === 150 && - repeatedVisit.inventory.Bean === 5 && + repeatedVisit.gold === 110 && + repeatedVisit.inventory.Bean === 4 && repeatedVisit.completedCampVisits.includes('repeat-visit') && repeatedVisit.firstBattleReport?.completedCampVisits.includes('repeat-visit') && repeatedVisit.campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left' && @@ -1198,8 +1206,8 @@ try { applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }); const visitResynced = getCampaignState(); assert( - visitResynced.gold === 150 && - visitResynced.inventory.Bean === 5 && + visitResynced.gold === 110 && + visitResynced.inventory.Bean === 4 && visitResynced.completedCampVisits.includes('repeat-visit') && visitResynced.firstBattleReport?.completedCampVisits.includes('repeat-visit'), `Expected report-only camp visit completion to resync without duplicate rewards: ${JSON.stringify(visitResynced)}` @@ -1207,7 +1215,7 @@ try { applyCampVisitReward('dirty-reward-visit', { itemRewards: ['', ' ', 'Bean -2', 'Bean +0', 'Bean x2'] }); const dirtyRewardVisit = getCampaignState(); assert( - dirtyRewardVisit.inventory.Bean === 7 && + dirtyRewardVisit.inventory.Bean === 6 && dirtyRewardVisit.inventory[''] === undefined && dirtyRewardVisit.inventory['Bean -'] === undefined && dirtyRewardVisit.completedCampVisits.includes('dirty-reward-visit'), @@ -1495,6 +1503,10 @@ try { corruptedContextualCommandState.battleHistory[firstScenario.id].sortieOrder.command.role = 'support'; corruptedContextualCommandState.sortieOrderHistory[firstScenario.id].elite.command.usedTurn = orderBattleReport.turnNumber + 1; storage.set(campaignStorageKey, JSON.stringify(corruptedContextualCommandState)); + storage.set( + `${campaignStorageKey}:slot-1`, + JSON.stringify(corruptedContextualCommandState) + ); const normalizedContextualCommandState = loadCampaignState(); assert( normalizedContextualCommandState.firstBattleReport?.sortieOrder?.command === undefined && @@ -1506,6 +1518,10 @@ try { `Expected contextual normalization to remove ghost actors, role mismatches, and future command turns without dropping their order results: ${JSON.stringify(normalizedContextualCommandState)}` ); storage.set(campaignStorageKey, validContextualCommandStateRaw); + storage.set( + `${campaignStorageKey}:slot-1`, + validContextualCommandStateRaw + ); loadCampaignState(); orderBattleReport.sortieOrder.progress[0].achieved = false; @@ -1556,14 +1572,14 @@ try { setFirstBattleReport(revisedOrderReport); const revisedOrderSettlement = getCampaignState(); assert( - revisedOrderSettlement.gold === 320 && - revisedOrderSettlement.inventory.Bean === 3 && - revisedOrderSettlement.inventory.Wine === 1 && - revisedOrderSettlement.inventory['Iron Sword'] === undefined && - revisedOrderSettlement.inventory['Iron Armor'] === 1 && + revisedOrderSettlement.gold === 280 && + revisedOrderSettlement.inventory.Bean === 2 && + revisedOrderSettlement.inventory.Wine === undefined && + revisedOrderSettlement.inventory['Iron Sword'] === 1 && + revisedOrderSettlement.inventory['Iron Armor'] === undefined && revisedOrderSettlement.inventory['상처약'] === 1 && revisedOrderSettlement.claimedSortieOrderRewardIds.length === 1, - `Expected ordinary reward replacement to preserve the separately claimed sortie-order reward: ${JSON.stringify(revisedOrderSettlement)}` + `Expected revised ordinary reward metadata to preserve the already-committed economy and separately claimed sortie-order reward: ${JSON.stringify(revisedOrderSettlement)}` ); setFirstBattleReport({ @@ -1573,7 +1589,7 @@ try { }); const secondOrderSameBattle = getCampaignState(); assert( - secondOrderSameBattle.gold === 480 && + secondOrderSameBattle.gold === 440 && secondOrderSameBattle.inventory['상처약'] === 1 && secondOrderSameBattle.inventory['탁주'] === 1 && secondOrderSameBattle.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) && @@ -1597,7 +1613,7 @@ try { }); const failedEliteReplay = getCampaignState(); assert( - failedEliteReplay.gold === 480 && + failedEliteReplay.gold === 440 && failedEliteReplay.inventory['상처약'] === 1 && failedEliteReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) && failedEliteReplay.firstBattleReport?.sortieOrder?.achieved === false && @@ -1624,7 +1640,7 @@ try { }); const sameOrderDifferentBattle = getCampaignState(); assert( - sameOrderDifferentBattle.gold === 780 && + sameOrderDifferentBattle.gold === 740 && sameOrderDifferentBattle.inventory['상처약'] === 2 && sameOrderDifferentBattle.claimedSortieOrderRewardIds.includes(`${secondScenario.id}:elite`) && sameOrderDifferentBattle.claimedSortieOrderRewardIds.length === 3 && @@ -1654,7 +1670,7 @@ try { const spentRewardReplay = getCampaignState(); assert( spentRewardReplay.gold === 160 && - spentRewardReplay.inventory['탁주'] === 2 && + spentRewardReplay.inventory['탁주'] === 1 && spentRewardReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:mobile`) && spentRewardReplay.firstBattleReport?.sortieOrder?.rewardGranted === true, `Expected a first order reward to remain intact even when previously settled ordinary gold/items were already spent: ${JSON.stringify(spentRewardReplay)}` diff --git a/scripts/verify-campaign-settlement-idempotency.mjs b/scripts/verify-campaign-settlement-idempotency.mjs new file mode 100644 index 0000000..bdc99cb --- /dev/null +++ b/scripts/verify-campaign-settlement-idempotency.mjs @@ -0,0 +1,584 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; + +const storage = new Map(); +let rejectCanonicalSlotWrites = false; +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + if ( + rejectCanonicalSlotWrites && + key === 'heros-web:campaign-state:slot-1' + ) { + throw new Error( + 'Simulated canonical campaign save failure.' + ); + } + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + }, + clear() { + storage.clear(); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const campaign = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const { battleScenarios } = await server.ssrLoadModule( + '/src/game/data/battles.ts' + ); + + const scenario = + battleScenarios['first-battle-zhuo-commandery']; + const report = { + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 4, + rewardGold: 120, + defeatedEnemies: 2, + totalEnemies: 2, + objectives: [], + units: scenario.units.filter( + (unit) => unit.faction === 'ally' + ), + bonds: scenario.bonds.map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: ['Bean x2', 'Iron Sword 1'], + campaignRewards: { + supplies: ['Bean x2'], + equipment: ['Iron Sword 1'], + reputation: [], + recruits: [], + unlocks: [] + }, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-28T07:00:00.000Z' + }; + + campaign.resetCampaignState(); + campaign.setFirstBattleReport(report); + const firstSettlement = campaign.getCampaignState(); + const initialSettlementProgress = + battleHistoryProgressOf( + firstSettlement, + scenario.id + ); + assert.deepEqual( + economyOf(firstSettlement), + { + gold: 120, + inventory: { + Bean: 2, + 'Iron Sword': 1 + } + }, + 'The first victory settlement must apply its ordinary economy exactly once.' + ); + + campaign.setFirstBattleReport(report); + const exactReplay = campaign.getCampaignState(); + assert.deepEqual( + economyOf(exactReplay), + economyOf(firstSettlement), + 'Replaying the exact same victory report must not duplicate gold or items.' + ); + + campaign.completeCampaignAftermath(scenario.id); + campaign.setActiveCampVisitId( + 'first-pursuit-scout-tent' + ); + campaign.setCampaignExplorationCheckpoint({ + version: 1, + scene: 'camp-visit-exploration', + contextId: 'first-pursuit-scout-tent', + player: { + x: 824, + y: 716, + direction: 'east' + } + }); + + const consumed = campaign.getCampaignState(); + const progressedUnit = consumed.roster[0]; + const progressedBond = consumed.bonds[0]; + assert(progressedUnit, 'Expected a settled allied unit.'); + assert(progressedBond, 'Expected a settled campaign bond.'); + consumed.gold = 0; + consumed.inventory = {}; + progressedUnit.level = Math.min( + 99, + progressedUnit.level + 1 + ); + progressedUnit.exp = 37; + progressedUnit.equipment.weapon.level = Math.min( + 9, + progressedUnit.equipment.weapon.level + 1 + ); + progressedUnit.equipment.weapon.exp = 3; + progressedBond.level = Math.min( + 100, + progressedBond.level + 1 + ); + progressedBond.exp = 41; + progressedBond.battleExp += 11; + campaign.setCampaignState(consumed); + const consumedEconomy = economyOf( + campaign.getCampaignState() + ); + const progressedContext = progressAndContextOf( + campaign.getCampaignState(), + progressedUnit.id, + progressedBond.id + ); + + campaign.setFirstBattleReport(report); + const consumedExactReplay = campaign.getCampaignState(); + assert.deepEqual( + economyOf(consumedExactReplay), + consumedEconomy, + 'Replaying a settled victory must not resurrect its already-consumed gold or items.' + ); + assert.deepEqual( + progressAndContextOf( + consumedExactReplay, + progressedUnit.id, + progressedBond.id + ), + progressedContext, + 'Replaying a settled victory must preserve post-settlement unit, bond, navigation, and exploration progress.' + ); + const consumedReplayProgress = + reportAndSettlementProgressOf( + consumedExactReplay, + scenario.id + ); + assert.deepEqual( + consumedReplayProgress.firstBattleReport.units.find( + (unit) => unit.id === progressedUnit.id + ), + progressedContext.unit, + 'A settled victory replay must refresh firstBattleReport unit progress from the live roster.' + ); + assert.deepEqual( + consumedReplayProgress.firstBattleReport.bonds.find( + (bond) => bond.id === progressedBond.id + ), + progressedContext.bond, + 'A settled victory replay must refresh firstBattleReport bond progress from the live campaign bonds.' + ); + assert.deepEqual( + consumedReplayProgress.battleHistory, + initialSettlementProgress, + 'A settled victory replay must preserve the original battleHistory unit and bond snapshots.' + ); + + campaign.setFirstBattleReport({ + ...report, + rewardGold: 200, + itemRewards: ['Bean x5', 'Iron Armor 1'], + campaignRewards: { + supplies: ['Bean x5'], + equipment: ['Iron Armor 1'], + reputation: [], + recruits: [], + unlocks: [] + }, + createdAt: '2026-07-28T07:05:00.000Z' + }); + const revisedReplay = campaign.getCampaignState(); + assert.deepEqual( + economyOf(revisedReplay), + consumedEconomy, + 'Revising an already-settled victory report must update history without replacing or resurrecting consumed economy.' + ); + assert.deepEqual( + progressAndContextOf( + revisedReplay, + progressedUnit.id, + progressedBond.id + ), + progressedContext, + 'Revising a settled victory report must not rewind live unit, bond, navigation, or exploration state.' + ); + assert.deepEqual( + reportAndSettlementProgressOf( + revisedReplay, + scenario.id + ), + consumedReplayProgress, + 'Revising a settled victory report must preserve both firstBattleReport and battleHistory unit/bond progress.' + ); + + const beforeStaleDefeat = campaign.getCampaignState(); + const storedBeforeStaleDefeat = new Map(storage); + const staleDefeatResult = campaign.setFirstBattleReport({ + ...report, + outcome: 'defeat', + rewardGold: 0, + itemRewards: [], + createdAt: '2026-07-28T07:06:00.000Z' + }); + assert.equal( + staleDefeatResult.outcome, + 'victory', + 'A late defeat callback must resolve to the already-settled victory report.' + ); + assert.deepEqual( + campaign.getCampaignState(), + beforeStaleDefeat, + 'A late defeat callback must not rewind a settled victory step, active exploration context, roster, bonds, or economy.' + ); + assert.deepEqual( + [...storage.entries()], + [...storedBeforeStaleDefeat.entries()], + 'A rejected late defeat callback must not write a replacement settlement.' + ); + + const reportlessSettlement = + campaign.getCampaignState(); + delete reportlessSettlement.firstBattleReport; + campaign.setCampaignState(reportlessSettlement); + + assertFailedCampaignMutationIsAtomic( + campaign, + 'settled victory report recovery', + () => campaign.setFirstBattleReport(report) + ); + + const beforeReportlessDefeat = + campaign.getCampaignState(); + const storedBeforeReportlessDefeat = + new Map(storage); + const reportlessDefeatResult = + campaign.setFirstBattleReport({ + ...report, + outcome: 'defeat', + rewardGold: 0, + itemRewards: [], + createdAt: '2026-07-28T07:07:00.000Z' + }); + assert.equal( + reportlessDefeatResult.outcome, + 'victory', + 'A late defeat callback must still resolve to victory when the settled battle report is missing.' + ); + assert.equal( + reportlessDefeatResult.rewardGold, + beforeReportlessDefeat.battleHistory[ + scenario.id + ].rewardGold, + 'A reportless late defeat must recover its return value from the authoritative victory settlement.' + ); + assert.deepEqual( + campaign.getCampaignState(), + beforeReportlessDefeat, + 'A reportless late defeat must leave in-memory campaign state unchanged.' + ); + assert.deepEqual( + [...storage.entries()], + [...storedBeforeReportlessDefeat.entries()], + 'A reportless late defeat must leave canonical and mirror storage unchanged.' + ); + + const recoveredReport = + campaign.setFirstBattleReport(report); + const recoveredSettlement = + campaign.getCampaignState(); + assert.equal( + recoveredReport.outcome, + 'victory', + 'A settled victory replay must restore a missing firstBattleReport.' + ); + assert.equal( + recoveredSettlement.firstBattleReport?.outcome, + 'victory', + 'The recovered firstBattleReport must persist the settled victory outcome.' + ); + assert.equal( + recoveredSettlement.firstBattleReport?.rewardGold, + revisedReplay.battleHistory[ + scenario.id + ].rewardGold, + 'Recovered report rewards must come from the authoritative settlement rather than stale replay input.' + ); + assert.deepEqual( + economyOf(recoveredSettlement), + consumedEconomy, + 'Recovering a missing settled victory report must not resurrect consumed economy.' + ); + assert.deepEqual( + reportAndSettlementProgressOf( + recoveredSettlement, + scenario.id + ), + consumedReplayProgress, + 'Recovering a missing settled victory report must preserve firstBattleReport and battleHistory unit/bond progress.' + ); + + const beforeMissingBond = + campaign.getCampaignState(); + const storedBeforeMissingBond = new Map(storage); + const missingBondResult = + campaign.applyCampVisitReward( + 'verify-missing-bond-visit', + { + bondId: 'verify-missing-bond', + bondExp: 10, + gold: 90, + itemRewards: ['Bean x3'] + }, + 'verify-missing-bond-choice' + ); + const afterMissingBond = + campaign.getCampaignState(); + + assert.equal( + missingBondResult, + undefined, + 'A camp visit reward that requests an unavailable bond must fail.' + ); + assert.deepEqual( + completionAndEconomyOf(afterMissingBond), + completionAndEconomyOf(beforeMissingBond), + 'A missing requested bond must leave visit completion, choice history, gold, and inventory unchanged.' + ); + assert.deepEqual( + [...storage.entries()], + [...storedBeforeMissingBond.entries()], + 'A rejected missing-bond reward must not persist any partial state.' + ); + + const rollbackFixture = campaign.getCampaignState(); + const rollbackBond = rollbackFixture.bonds.find( + (bond) => + bond.unitIds.every((unitId) => + rollbackFixture.roster.some( + (unit) => unit.id === unitId + ) + ) + ); + assert( + rollbackBond, + 'Expected a roster-backed bond for persistence rollback verification.' + ); + rollbackBond.level = 30; + rollbackBond.exp = 0; + rollbackFixture.selectedSortieUnitIds = [ + ...rollbackBond.unitIds + ]; + campaign.setCampaignState(rollbackFixture); + + assertFailedCampaignMutationIsAtomic( + campaign, + 'sortie resonance selection', + () => + campaign.setCampaignSortieResonanceSelection( + scenario.id, + rollbackBond.id + ) + ); + assertFailedCampaignMutationIsAtomic( + campaign, + 'reserve training focus', + () => + campaign.setCampaignReserveTrainingFocus( + 'class-practice' + ) + ); + assertFailedCampaignMutationIsAtomic( + campaign, + 'reserve training assignment', + () => + campaign.setCampaignReserveTrainingAssignment( + rollbackFixture.roster[0].id, + 'bond-practice' + ) + ); + assertFailedCampaignMutationIsAtomic( + campaign, + 'victory reward notice dismissal', + () => + campaign.dismissCampaignVictoryRewardNotice( + scenario.id + ) + ); + const rollbackRecruit = structuredClone( + rollbackFixture.roster[0] + ); + rollbackRecruit.id = 'verify-rollback-recruit'; + rollbackRecruit.name = 'Rollback Recruit'; + assertFailedCampaignMutationIsAtomic( + campaign, + 'roster expansion', + () => + campaign.ensureCampaignRosterUnits([ + rollbackRecruit + ]) + ); + + console.log( + 'Campaign settlement idempotency verification passed: settled victory replays preserve report/history progress and active context, missing reports recover safely, stale defeats are ignored, missing-bond camp rewards are atomic, and canonical save failures leave memory and storage unchanged.' + ); +} finally { + await server.close(); +} + +function economyOf(state) { + return { + gold: state.gold, + inventory: { ...state.inventory } + }; +} + +function completionAndEconomyOf(state) { + return { + gold: state.gold, + inventory: { ...state.inventory }, + completedCampVisits: [ + ...state.completedCampVisits + ], + reportCompletedCampVisits: [ + ...(state.firstBattleReport + ?.completedCampVisits ?? []) + ], + campVisitChoiceIds: { + ...state.campVisitChoiceIds + } + }; +} + +function progressAndContextOf(state, unitId, bondId) { + const unit = state.roster.find( + (candidate) => candidate.id === unitId + ); + const bond = state.bonds.find( + (candidate) => candidate.id === bondId + ); + return { + step: state.step, + latestBattleId: state.latestBattleId ?? null, + pendingAftermathBattleId: + state.pendingAftermathBattleId ?? null, + activeCampVisitId: state.activeCampVisitId ?? null, + activeCityStayId: state.activeCityStayId ?? null, + explorationCheckpoint: + state.explorationCheckpoint ?? null, + unit: unit + ? { + id: unit.id, + level: unit.level, + exp: unit.exp, + equipment: structuredClone(unit.equipment) + } + : null, + bond: bond + ? { + id: bond.id, + level: bond.level, + exp: bond.exp, + battleExp: bond.battleExp + } + : null + }; +} + +function reportAndSettlementProgressOf( + state, + battleId +) { + return { + firstBattleReport: { + units: unitProgressOf( + state.firstBattleReport?.units ?? [] + ), + bonds: bondProgressOf( + state.firstBattleReport?.bonds ?? [] + ) + }, + battleHistory: battleHistoryProgressOf( + state, + battleId + ) + }; +} + +function battleHistoryProgressOf(state, battleId) { + const settlement = state.battleHistory[battleId]; + return { + units: unitProgressOf(settlement?.units ?? []), + bonds: bondProgressOf(settlement?.bonds ?? []) + }; +} + +function unitProgressOf(units) { + return units + .map((unit) => ({ + id: unit.id ?? unit.unitId, + level: unit.level, + exp: unit.exp, + equipment: structuredClone(unit.equipment) + })) + .sort((left, right) => + left.id.localeCompare(right.id) + ); +} + +function bondProgressOf(bonds) { + return bonds + .map((bond) => ({ + id: bond.id, + level: bond.level, + exp: bond.exp, + battleExp: bond.battleExp + })) + .sort((left, right) => + left.id.localeCompare(right.id) + ); +} + +function assertFailedCampaignMutationIsAtomic( + campaign, + label, + mutate +) { + const before = campaign.getCampaignState(); + const storedBefore = new Map(storage); + rejectCanonicalSlotWrites = true; + try { + assert.throws( + mutate, + /Simulated canonical campaign save failure/, + `${label} must surface the canonical save failure.` + ); + } finally { + rejectCanonicalSlotWrites = false; + } + assert.deepEqual( + campaign.getCampaignState(), + before, + `${label} must not leave ghost in-memory state after a failed canonical save.` + ); + assert.deepEqual( + [...storage.entries()], + [...storedBefore.entries()], + `${label} must not mutate canonical or mirror storage after a failed canonical save.` + ); +} diff --git a/scripts/verify-city-equipment-preparation.mjs b/scripts/verify-city-equipment-preparation.mjs index c48079a..2ada285 100644 --- a/scripts/verify-city-equipment-preparation.mjs +++ b/scripts/verify-city-equipment-preparation.mjs @@ -290,7 +290,8 @@ function verifyPurchasePersistenceAndRollback({ const first = actionModule.purchaseCityStayEquipment( 'xuzhou', - 'city-xuzhou-iron-spear' + 'city-xuzhou-iron-spear', + 'verify:preparation:purchase:1' ); assert.equal(first.ok, true); assert.equal(first.purchaseNumber, 1); @@ -315,7 +316,8 @@ function verifyPurchasePersistenceAndRollback({ const second = actionModule.purchaseCityStayEquipment( 'xuzhou', - 'city-xuzhou-iron-spear' + 'city-xuzhou-iron-spear', + 'verify:preparation:purchase:2' ); assert.equal(second.ok, true); assert.equal(second.purchaseNumber, 2); @@ -352,7 +354,8 @@ function verifyPurchasePersistenceAndRollback({ rejectStorageWrites = true; const failed = actionModule.purchaseCityStayEquipment( 'xuzhou', - 'city-xuzhou-iron-spear' + 'city-xuzhou-iron-spear', + 'verify:preparation:purchase:failed' ); rejectStorageWrites = false; assert.deepEqual(failed, { ok: false, reason: 'save-unavailable' }); diff --git a/scripts/verify-city-purchase-idempotency.mjs b/scripts/verify-city-purchase-idempotency.mjs new file mode 100644 index 0000000..75d7c07 --- /dev/null +++ b/scripts/verify-city-purchase-idempotency.mjs @@ -0,0 +1,275 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; + +const storage = new Map(); +let rejectBaseMirrorWrites = false; +let rejectSlotWrites = false; +globalThis.window = { + localStorage: { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + if ( + rejectSlotWrites && + key === 'heros-web:campaign-state:slot-1' + ) { + throw new Error('Simulated canonical slot failure.'); + } + if ( + rejectBaseMirrorWrites && + key === 'heros-web:campaign-state' + ) { + throw new Error('Simulated compatibility mirror failure.'); + } + storage.set(key, String(value)); + }, + removeItem(key) { + storage.delete(key); + } + } +}; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const campaignModule = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const actionModule = await server.ssrLoadModule( + '/src/game/state/cityStayActions.ts' + ); + const { itemInventoryLabel } = await server.ssrLoadModule( + '/src/game/data/battleItems.ts' + ); + + storage.clear(); + campaignModule.resetCampaignState(); + const initial = cityCampaign(campaignModule, 5000); + campaignModule.setCampaignState(initial); + + const label = itemInventoryLabel('iron-spear'); + const intentId = 'verify:xuzhou:iron-spear:1'; + const first = actionModule.purchaseCityStayEquipment( + 'xuzhou', + 'city-xuzhou-iron-spear', + intentId + ); + assert.equal(first.ok, true); + assert.equal(first.replayed, false); + assert.equal(first.purchaseNumber, 1); + assert.equal(first.campaign.gold, 4380); + assert.equal(first.campaign.inventory[label], 1); + assert.deepEqual( + first.campaign.cityEquipmentPurchaseReceipts[intentId], + { + version: 1, + intentId, + cityStayId: 'xuzhou', + offerId: 'city-xuzhou-iron-spear', + purchaseNumber: 1, + completedAt: + first.campaign.cityEquipmentPurchaseReceipts[intentId] + .completedAt + } + ); + + const replay = actionModule.purchaseCityStayEquipment( + 'xuzhou', + 'city-xuzhou-iron-spear', + intentId + ); + assert.equal(replay.ok, true); + assert.equal(replay.replayed, true); + assert.equal(replay.purchaseNumber, 1); + assert.equal(replay.campaign.gold, 4380); + assert.equal(replay.campaign.inventory[label], 1); + assert.equal( + replay.campaign.cityEquipmentPurchaseCounts[ + 'city-xuzhou-iron-spear' + ], + 1 + ); + + assert.deepEqual( + actionModule.purchaseCityStayEquipment( + 'xuzhou', + 'city-xuzhou-lamellar-armor', + intentId + ), + { ok: false, reason: 'intent-conflict' } + ); + + const reloaded = campaignModule.loadCampaignState(1); + assert.equal(reloaded.gold, 4380); + const replayAfterReload = + actionModule.purchaseCityStayEquipment( + 'xuzhou', + 'city-xuzhou-iron-spear', + intentId + ); + assert.equal(replayAfterReload.ok, true); + assert.equal(replayAfterReload.replayed, true); + assert.equal(replayAfterReload.campaign.gold, 4380); + assert.equal(replayAfterReload.campaign.inventory[label], 1); + + const fullInventory = structuredClone( + replayAfterReload.campaign + ); + fullInventory.inventory[label] = + campaignModule.campaignInventoryCap(label); + campaignModule.setCampaignState(fullInventory); + assert.deepEqual( + actionModule.purchaseCityStayEquipment( + 'xuzhou', + 'city-xuzhou-iron-spear', + 'verify:xuzhou:iron-spear:full' + ), + { ok: false, reason: 'inventory-full' } + ); + const afterFull = campaignModule.getCampaignState(); + assert.equal(afterFull.gold, 4380); + assert.equal( + afterFull.cityEquipmentPurchaseCounts[ + 'city-xuzhou-iron-spear' + ], + 1 + ); + + const bounded = structuredClone(afterFull); + bounded.cityEquipmentPurchaseCounts[ + 'city-xuzhou-iron-spear' + ] = 99; + bounded.cityEquipmentPurchaseReceipts = + Object.fromEntries( + Array.from({ length: 140 }, (_, index) => { + const receiptIntentId = + `verify:bounded:${String(index).padStart(3, '0')}`; + return [ + receiptIntentId, + { + version: 1, + intentId: receiptIntentId, + cityStayId: 'xuzhou', + offerId: 'city-xuzhou-iron-spear', + purchaseNumber: 1, + completedAt: new Date( + Date.UTC(2026, 0, 1, 0, 0, index) + ).toISOString() + } + ]; + }) + ); + const normalized = campaignModule.setCampaignState(bounded); + assert.equal( + Object.keys( + normalized.cityEquipmentPurchaseReceipts + ).length, + 128 + ); + assert( + normalized.cityEquipmentPurchaseReceipts[ + 'verify:bounded:139' + ] + ); + assert.equal( + normalized.cityEquipmentPurchaseReceipts[ + 'verify:bounded:000' + ], + undefined + ); + + const beforeMirrorFailure = + campaignModule.getCampaignState(); + await new Promise((resolve) => setTimeout(resolve, 2)); + rejectBaseMirrorWrites = true; + const slotCanonical = campaignModule.setCampaignState({ + ...beforeMirrorFailure, + gold: 4321 + }); + rejectBaseMirrorWrites = false; + assert.equal(slotCanonical.gold, 4321); + assert.equal( + JSON.parse( + storage.get( + `${campaignModule.campaignStorageKey}:slot-1` + ) + ).gold, + 4321 + ); + assert.notEqual( + JSON.parse( + storage.get(campaignModule.campaignStorageKey) + ).gold, + 4321 + ); + assert.equal( + campaignModule.loadCampaignState().gold, + 4321, + 'Default loading must prefer the newest canonical slot over a stale base mirror.' + ); + + const rollbackSeed = cityCampaign( + campaignModule, + 5000 + ); + campaignModule.setCampaignState(rollbackSeed); + const beforeSlotFailure = + campaignModule.getCampaignState(); + const storedBeforeSlotFailure = new Map(storage); + rejectSlotWrites = true; + const rejectedPurchase = + actionModule.purchaseCityStayEquipment( + 'xuzhou', + 'city-xuzhou-iron-spear', + 'verify:xuzhou:slot-failure' + ); + rejectSlotWrites = false; + assert.deepEqual(rejectedPurchase, { + ok: false, + reason: 'save-unavailable' + }); + assert.deepEqual( + campaignModule.getCampaignState(), + beforeSlotFailure, + 'A failed canonical write must not mutate in-memory gold, inventory, counts, or receipts.' + ); + assert.deepEqual( + [...storage.entries()], + [...storedBeforeSlotFailure.entries()], + 'A failed canonical write must leave both stored copies unchanged.' + ); + + console.log( + 'City purchase idempotency verification passed (single-charge replay, intent conflict rejection, persisted receipts, inventory cap protection, bounded receipt normalization, canonical slot ordering, newest-save loading, and failed-write rollback).' + ); +} finally { + await server.close(); +} + +function cityCampaign(campaignModule, gold) { + const state = campaignModule.createInitialCampaignState(); + state.step = 'seventh-camp'; + state.latestBattleId = 'seventh-battle-xuzhou-rescue'; + state.activeCityStayId = 'xuzhou'; + state.gold = gold; + state.battleHistory[ + 'seventh-battle-xuzhou-rescue' + ] = { + battleId: 'seventh-battle-xuzhou-rescue', + battleTitle: 'Xuzhou rescue', + outcome: 'victory', + turnNumber: 18, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: [], + bonds: [], + completedAt: new Date().toISOString() + }; + return state; +} diff --git a/scripts/verify-city-stay-checkpoint-browser.mjs b/scripts/verify-city-stay-checkpoint-browser.mjs new file mode 100644 index 0000000..4547474 --- /dev/null +++ b/scripts/verify-city-stay-checkpoint-browser.mjs @@ -0,0 +1,1252 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const fixture = Object.freeze({ + cityStayId: 'xuzhou', + afterBattleId: 'seventh-battle-xuzhou-rescue', + campaignStep: 'seventh-camp', + partnerId: 'mi-zhu', + bondId: 'liu-bei__mi-zhu', + dialogueId: 'city-xuzhou-liu-mi-supply-trust', + choiceId: 'city-xuzhou-share-storehouse-duty', + informationActorId: 'xuzhou-information-clerk', + marketActorId: 'xuzhou-market-smith', + companionActorId: 'xuzhou-companion-mi-zhu', + offerId: 'city-xuzhou-iron-spear', + seedGold: 5000 +}); +const rendererNames = ['canvas', 'webgl']; +const renderer = process.env.VERIFY_CITY_STAY_CHECKPOINT_RENDERER; + +if (!renderer) { + for (const requestedRenderer of rendererNames) { + const result = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url)], + { + cwd: process.cwd(), + env: { + ...process.env, + VERIFY_CITY_STAY_CHECKPOINT_RENDERER: + requestedRenderer + }, + stdio: 'inherit' + } + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } + process.exit(0); +} + +assert( + rendererNames.includes(renderer), + `Unsupported city checkpoint renderer "${renderer}".` +); + +const baseUrl = + process.env.VERIFY_CITY_STAY_CHECKPOINT_URL ?? + 'http://127.0.0.1:41810/heros_web/'; +const targetUrl = withDebugOptions(baseUrl, renderer); +let serverProcess; +let browser; + +try { + serverProcess = await ensureLocalServer(targetUrl); + browser = await chromium.launch({ + headless: + process.env.VERIFY_CITY_STAY_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 assertDesktopRuntime(page, renderer); + + const seeded = await seedCityStay(page, fixture); + assert.equal(seeded.step, fixture.campaignStep); + assert.equal(seeded.latestBattleId, fixture.afterBattleId); + assert.equal(seeded.activeCityStayId, fixture.cityStayId); + assert.equal(seeded.gold, fixture.seedGold); + assert.equal(seeded.checkpoint, null); + assert.equal(seeded.bondId, fixture.bondId); + + await page.evaluate( + async (cityStayId) => + window.__HEROS_DEBUG__?.goToCityStay(cityStayId), + fixture.cityStayId + ); + await waitForCityReady(page); + await assertDesktopRuntime(page, renderer); + + const savedPlayer = await createPlayerCheckpoint(page); + const playerResume = await reloadAndContinueToCity(page); + assertExactPlayer( + playerResume.player, + savedPlayer, + `${renderer}: city position resume` + ); + assertCheckpointPlayer( + playerResume.explorationCheckpoint, + savedPlayer, + `${renderer}: persisted city position` + ); + + const dialogueBeforeReload = + await openAndAdvanceInformationDialogue(page); + const dialogueResume = await reloadAndContinueToCity(page); + assertExactPlayer( + dialogueResume.player, + dialogueBeforeReload.player, + `${renderer}: city dialogue position resume` + ); + assertDialogue( + dialogueResume, + { + key: 'city-interaction', + source: fixture.informationActorId, + lineIndex: dialogueBeforeReload.dialogue.lineIndex + }, + `${renderer}: information dialogue resume` + ); + await page.waitForTimeout(450); + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === + false + ); + + const choiceBeforeReload = await openResonanceChoice(page); + const choiceResume = await reloadAndContinueToCity(page); + await waitForChoiceOpen(page); + const readyChoiceResume = await readCity(page); + assertExactPlayer( + readyChoiceResume.player, + choiceBeforeReload.player, + `${renderer}: city resonance choice position resume` + ); + assert.equal(readyChoiceResume.choice.open, true); + assert.deepEqual( + readyChoiceResume.explorationCheckpoint?.overlay, + { + type: 'choice', + mode: 'city-resonance', + sourceNpcId: fixture.companionActorId + }, + `${renderer}: city resonance choice checkpoint` + ); + assert.equal(choiceResume.cityStayId, fixture.cityStayId); + + const rewardBefore = await readCityActionSnapshot(page); + const selectedChoice = + readyChoiceResume.choice.choices.find( + ({ id }) => id === fixture.choiceId + ); + assert( + selectedChoice?.interactive, + `${renderer}: resonance choice is not interactive.` + ); + await clickBounds(page, selectedChoice.bounds); + await page.waitForFunction( + ({ key, source }) => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); + return ( + city?.dialogue?.active === true && + city.dialogue.dialogueKey === key && + city.dialogue.sourceTargetId === source + ); + }, + { + key: 'city-resonance-response', + source: fixture.companionActorId + } + ); + const rewardAfterChoice = await readCityActionSnapshot(page); + assert.equal( + rewardAfterChoice.completedDialogueCount, + rewardBefore.completedDialogueCount + 1, + `${renderer}: resonance completion must be recorded once.` + ); + assert.equal( + rewardAfterChoice.choiceId, + fixture.choiceId, + `${renderer}: resonance choice id was not saved.` + ); + assert.notDeepEqual( + rewardAfterChoice.campaignBond, + rewardBefore.campaignBond, + `${renderer}: resonance reward did not change the campaign bond.` + ); + assert.deepEqual( + rewardAfterChoice.campaignBond, + rewardAfterChoice.reportBond, + `${renderer}: campaign and report bond ledgers diverged.` + ); + + const responseBeforeReload = await readCity(page); + const responseResume = await reloadAndContinueToCity(page); + assertDialogue( + responseResume, + { + key: 'city-resonance-response', + source: fixture.companionActorId, + lineIndex: responseBeforeReload.dialogue.lineIndex + }, + `${renderer}: resonance response resume` + ); + assert.deepEqual( + await readCityActionSnapshot(page), + rewardAfterChoice, + `${renderer}: reloading the resonance response duplicated its reward.` + ); + await finishCityDialogue(page); + + const shopBeforeReload = await openShop(page); + const shopResume = await reloadAndContinueToCity(page); + await waitForShopOpen(page); + const readyShopResume = await readCity(page); + assertExactPlayer( + readyShopResume.player, + shopBeforeReload.player, + `${renderer}: city shop position resume` + ); + assert.equal(shopResume.cityStayId, fixture.cityStayId); + assert.equal(readyShopResume.shop.open, true); + assert.deepEqual( + readyShopResume.explorationCheckpoint?.overlay, + { + type: 'shop', + sourceNpcId: fixture.marketActorId + }, + `${renderer}: city shop checkpoint` + ); + + const purchase = await replayPurchaseIntent(page, fixture); + assert.equal(purchase.first.ok, true); + assert.equal(purchase.first.replayed, false); + assert.equal(purchase.second.ok, true); + assert.equal(purchase.second.replayed, true); + assert.equal( + purchase.after.gold, + purchase.before.gold - purchase.first.price, + `${renderer}: a replayed purchase charged gold more than once.` + ); + assert.equal( + purchase.after.inventoryTotal, + purchase.before.inventoryTotal + 1, + `${renderer}: a replayed purchase granted more than one item.` + ); + assert.equal( + purchase.after.purchaseCount, + purchase.before.purchaseCount + 1, + `${renderer}: a replayed purchase incremented its count more than once.` + ); + assert.equal(purchase.after.receiptCount, 1); + + await reloadAndContinueToCity(page); + await waitForShopOpen(page); + assert.deepEqual( + await readPurchaseSnapshot(page, fixture), + purchase.after, + `${renderer}: shop reload changed the committed purchase.` + ); + + await page.waitForTimeout(450); + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === + false + ); + const exitOpened = await page.evaluate((targetId) => + window.__HEROS_DEBUG__ + ?.scene('CityStayScene') + ?.debugInteractWith?.(targetId) ?? false, + 'camp-exit' + ); + assert.equal( + exitOpened, + true, + `${renderer}: could not open the city exit.` + ); + await page.waitForFunction( + () => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); + return ( + city?.dialogue?.active === true && + city.dialogue.dialogueKey === 'city-exit' + ); + } + ); + await finishCityDialogueToCamp(page); + + const exited = await readCampaignCheckpoint(page); + assert.equal(exited.activeCityStayId, null); + assert.equal( + exited.checkpoint, + null, + `${renderer}: normal city exit left a stale checkpoint.` + ); + + await reloadToTitle(page); + await clickContinue(page); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('CampScene'), + undefined, + { timeout: 90000 } + ); + assert( + !(await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('CityStayScene') + )), + `${renderer}: Continue reopened a normally exited city stay.` + ); + assert.equal( + (await readCampaignCheckpoint(page)).checkpoint, + null + ); + + const actionableConsoleErrors = consoleErrors.filter( + (message) => + !message.includes( + 'The AudioContext was not allowed to start' + ) + ); + assert.deepEqual( + pageErrors, + [], + `${renderer}: page errors: ${pageErrors.join('\n')}` + ); + assert.deepEqual( + actionableConsoleErrors, + [], + `${renderer}: console errors: ${actionableConsoleErrors.join( + '\n' + )}` + ); + + console.log( + JSON.stringify( + { + renderer, + viewport: { + ...desktopBrowserViewport, + deviceScaleFactor: + desktopBrowserDeviceScaleFactor, + browserZoom: '100%' + }, + cityStayId: fixture.cityStayId, + checkpoint: { + exactPlayerResume: savedPlayer, + dialogueLineResume: true, + resonanceChoiceResume: true, + resonanceRewardCommittedOnce: true, + responseReloadStable: true, + shopResume: true, + purchaseIntentReplayedSafely: true, + normalExitCleared: true + }, + pageErrors: pageErrors.length, + consoleErrors: actionableConsoleErrors.length + }, + null, + 2 + ) + ); +} finally { + await browser?.close(); + await stopServerProcess(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 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, + 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.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 + ); +} + +async function seedCityStay(page, seed) { + return page.evaluate(async (requested) => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const { battleScenarios } = await import( + '/heros_web/src/game/data/battles.ts' + ); + const scenario = battleScenarios[requested.afterBattleId]; + if (!scenario) { + throw new Error( + `Missing battle scenario ${requested.afterBattleId}.` + ); + } + const template = + scenario.units.find(({ id }) => id === 'liu-bei') ?? + scenario.units[0]; + if (!template) { + throw new Error('The city seed has no unit template.'); + } + + campaignModule.resetCampaignState(); + const state = campaignModule.createInitialCampaignState(); + const liuBei = { + ...structuredClone(template), + id: 'liu-bei', + name: 'Liu Bei' + }; + const partner = { + ...structuredClone(template), + id: requested.partnerId, + name: requested.partnerId + }; + const bond = { + id: requested.bondId, + unitIds: ['liu-bei', requested.partnerId], + title: `${requested.cityStayId}-verification`, + level: 1, + exp: 0, + battleExp: 0, + description: 'City checkpoint verification bond.' + }; + const report = { + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 6, + rewardGold: 0, + defeatedEnemies: scenario.units.filter( + ({ faction }) => faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + ({ faction }) => faction === 'enemy' + ).length, + itemRewards: [], + objectives: [], + units: [liuBei, partner], + bonds: [bond], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-28T00:00:00.000Z' + }; + Object.assign(state, { + step: requested.campaignStep, + gold: requested.seedGold, + roster: [liuBei, partner], + bonds: [bond], + latestBattleId: requested.afterBattleId, + firstBattleReport: report, + battleHistory: { + [requested.afterBattleId]: { + battleId: requested.afterBattleId, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: report.turnNumber, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: [], + bonds: [], + completedAt: report.createdAt + } + }, + activeCityStayId: requested.cityStayId, + acknowledgedVictoryRewardBattleIds: [ + requested.afterBattleId + ], + dismissedVictoryRewardNoticeBattleIds: [ + requested.afterBattleId + ] + }); + const saved = campaignModule.setCampaignState(state); + return { + step: saved.step, + latestBattleId: saved.latestBattleId ?? null, + activeCityStayId: + saved.activeCityStayId ?? null, + gold: saved.gold, + checkpoint: + saved.explorationCheckpoint ?? null, + bondId: + saved.bonds.find( + ({ id }) => id === requested.bondId + )?.id ?? null + }; + }, seed); +} + +async function waitForCityReady(page) { + try { + await page.waitForFunction( + (cityStayId) => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); + return ( + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('CityStayScene') && + city?.scene === 'CityStayScene' && + city?.ready === true && + city?.cityStayId === cityStayId && + city?.requiredTexturesReady === true + ); + }, + fixture.cityStayId, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + city: + window.__HEROS_DEBUG__?.cityStay?.() ?? null, + title: + window.__HEROS_DEBUG__?.title?.() ?? null + })); + throw new Error( + `${renderer}: city stay did not become ready: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); + return readCity(page); +} + +async function readCity(page) { + return page.evaluate( + () => window.__HEROS_DEBUG__?.cityStay?.() ?? null + ); +} + +async function createPlayerCheckpoint(page) { + await page.waitForTimeout(450); + const before = await readCity(page); + await page.keyboard.down('ArrowRight'); + await page.waitForTimeout(260); + await page.keyboard.up('ArrowRight'); + await page.waitForFunction( + () => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); + const player = city?.player; + const checkpoint = city?.explorationCheckpoint; + return ( + player?.moving === false && + player?.direction === 'east' && + checkpoint?.scene === 'city-stay' && + checkpoint?.contextId === 'xuzhou' && + checkpoint?.overlay === undefined && + Math.abs(checkpoint.player.x - player.x) <= 0.05 && + Math.abs(checkpoint.player.y - player.y) <= 0.05 && + checkpoint.player.direction === player.direction + ); + }, + undefined, + { timeout: 10000 } + ); + const city = await readCity(page); + assert( + city.player.x > before.player.x + 20, + `${renderer}: keyboard movement did not create a distinct checkpoint.` + ); + return exactPlayer(city.player); +} + +async function openAndAdvanceInformationDialogue(page) { + const interacted = await page.evaluate((actorId) => + window.__HEROS_DEBUG__ + ?.scene('CityStayScene') + ?.debugInteractWith?.(actorId) ?? false, + fixture.informationActorId + ); + assert.equal( + interacted, + true, + `${renderer}: could not open information dialogue.` + ); + await waitForDialogue(page, { + key: 'city-interaction', + source: fixture.informationActorId, + lineIndex: 0 + }); + await page.waitForTimeout(320); + await page.keyboard.press('e'); + await waitForDialogue(page, { + key: 'city-interaction', + source: fixture.informationActorId, + lineIndex: 1 + }); + const city = await readCity(page); + assertDialogue( + city, + { + key: 'city-interaction', + source: fixture.informationActorId, + lineIndex: 1 + }, + `${renderer}: saved information dialogue` + ); + return { + ...city, + player: exactPlayer(city.player) + }; +} + +async function openResonanceChoice(page) { + const interacted = await page.evaluate((actorId) => + window.__HEROS_DEBUG__ + ?.scene('CityStayScene') + ?.debugInteractWith?.(actorId) ?? false, + fixture.companionActorId + ); + assert.equal( + interacted, + true, + `${renderer}: could not open companion dialogue.` + ); + for (let attempt = 0; attempt < 12; attempt += 1) { + const city = await readCity(page); + if (city?.choice?.open) { + assert.deepEqual( + city.explorationCheckpoint?.overlay, + { + type: 'choice', + mode: 'city-resonance', + sourceNpcId: fixture.companionActorId + } + ); + return { + ...city, + player: exactPlayer(city.player) + }; + } + assert.equal( + city?.dialogue?.active, + true, + `${renderer}: companion dialogue closed before choice.` + ); + await page.waitForTimeout(170); + await page.keyboard.press('e'); + } + throw new Error( + `${renderer}: resonance choice did not open.` + ); +} + +async function openShop(page) { + const interacted = await page.evaluate((actorId) => + window.__HEROS_DEBUG__ + ?.scene('CityStayScene') + ?.debugInteractWith?.(actorId) ?? false, + fixture.marketActorId + ); + assert.equal( + interacted, + true, + `${renderer}: could not open the city shop.` + ); + await waitForShopOpen(page); + const city = await readCity(page); + assert.deepEqual(city.explorationCheckpoint?.overlay, { + type: 'shop', + sourceNpcId: fixture.marketActorId + }); + return { + ...city, + player: exactPlayer(city.player) + }; +} + +async function waitForDialogue( + page, + { key, source, lineIndex } +) { + await page.waitForFunction( + (expected) => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); + return ( + city?.dialogue?.active === true && + city.dialogue.dialogueKey === expected.key && + city.dialogue.sourceTargetId === expected.source && + city.dialogue.lineIndex === expected.lineIndex && + city.explorationCheckpoint?.overlay?.type === + 'dialogue' && + city.explorationCheckpoint.overlay.dialogueKey === + expected.key && + city.explorationCheckpoint.overlay.sourceNpcId === + expected.source && + city.explorationCheckpoint.overlay.lineIndex === + expected.lineIndex + ); + }, + { key, source, lineIndex } + ); +} + +async function waitForChoiceOpen(page) { + await page.waitForFunction( + () => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); + return ( + city?.choice?.open === true && + city.explorationCheckpoint?.overlay?.type === + 'choice' && + city.explorationCheckpoint.overlay.mode === + 'city-resonance' + ); + }, + undefined, + { timeout: 30000 } + ); +} + +async function waitForShopOpen(page) { + await page.waitForFunction( + () => { + const city = window.__HEROS_DEBUG__?.cityStay?.(); + return ( + city?.shop?.open === true && + city.explorationCheckpoint?.overlay?.type === + 'shop' + ); + }, + undefined, + { timeout: 30000 } + ); +} + +function assertDialogue( + city, + { key, source, lineIndex }, + label +) { + assert.equal(city?.dialogue?.active, true, label); + assert.equal(city.dialogue.dialogueKey, key, label); + assert.equal( + city.dialogue.sourceTargetId, + source, + label + ); + assert.equal(city.dialogue.lineIndex, lineIndex, label); + assert.deepEqual( + city.explorationCheckpoint?.overlay, + { + type: 'dialogue', + dialogueKey: key, + sourceNpcId: source, + lineIndex + }, + label + ); +} + +async function finishCityDialogue(page) { + for (let attempt = 0; attempt < 12; attempt += 1) { + const city = await readCity(page); + if (!city?.dialogue?.active) { + return; + } + await page.waitForTimeout(150); + await page.keyboard.press('e'); + } + throw new Error( + `${renderer}: city dialogue did not finish.` + ); +} + +async function finishCityDialogueToCamp(page) { + for (let attempt = 0; attempt < 12; attempt += 1) { + const activeScenes = await page.evaluate( + () => window.__HEROS_DEBUG__?.activeScenes?.() ?? [] + ); + if (activeScenes.includes('CampScene')) { + return; + } + const city = await readCity(page); + if (!city?.dialogue?.active) { + assert.equal( + city?.navigationPending, + true, + `${renderer}: city exit dialogue disappeared without starting its transition.` + ); + await page.waitForTimeout(180); + continue; + } + await page.waitForTimeout(170); + await page.keyboard.press('e'); + } + await page.waitForFunction( + () => + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('CampScene'), + undefined, + { timeout: 90000 } + ); +} + +async function readCityActionSnapshot(page) { + return page.evaluate(async ({ bondId, dialogueId }) => { + const { getCampaignState } = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const campaign = getCampaignState(); + const campaignBond = campaign.bonds.find( + ({ id }) => id === bondId + ); + const reportBond = campaign.firstBattleReport?.bonds.find( + ({ id }) => id === bondId + ); + const simplifyBond = (bond) => + bond + ? { + id: bond.id, + level: bond.level, + exp: bond.exp, + battleExp: bond.battleExp + } + : null; + return { + completedDialogueCount: + campaign.completedCampDialogues.filter( + (id) => id === dialogueId + ).length, + choiceId: + campaign.campDialogueChoiceIds[dialogueId] ?? null, + campaignBond: simplifyBond(campaignBond), + reportBond: simplifyBond(reportBond) + }; + }, { + bondId: fixture.bondId, + dialogueId: fixture.dialogueId + }); +} + +async function replayPurchaseIntent(page, seed) { + return page.evaluate(async (requested) => { + const actions = await import( + '/heros_web/src/game/state/cityStayActions.ts' + ); + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const intentId = + `qa-${requested.cityStayId}-${requested.offerId}-intent`; + const snapshot = (campaign) => ({ + gold: campaign.gold, + inventoryTotal: Object.values( + campaign.inventory + ).reduce((sum, amount) => sum + amount, 0), + purchaseCount: + campaign.cityEquipmentPurchaseCounts[ + requested.offerId + ] ?? 0, + receiptCount: Object.values( + campaign.cityEquipmentPurchaseReceipts + ).filter( + (receipt) => receipt.intentId === intentId + ).length + }); + const before = snapshot( + campaignModule.getCampaignState() + ); + const first = actions.purchaseCityStayEquipment( + requested.cityStayId, + requested.offerId, + intentId + ); + const second = actions.purchaseCityStayEquipment( + requested.cityStayId, + requested.offerId, + intentId + ); + const after = snapshot( + campaignModule.getCampaignState() + ); + return { + before, + first: first.ok + ? { + ok: true, + replayed: first.replayed, + price: first.offer.price, + purchaseNumber: first.purchaseNumber + } + : first, + second: second.ok + ? { + ok: true, + replayed: second.replayed, + price: second.offer.price, + purchaseNumber: second.purchaseNumber + } + : second, + after + }; + }, seed); +} + +async function readPurchaseSnapshot(page, seed) { + return page.evaluate( + async ({ cityStayId, offerId }) => { + const { getCampaignState } = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const intentId = + `qa-${cityStayId}-${offerId}-intent`; + const campaign = getCampaignState(); + return { + gold: campaign.gold, + inventoryTotal: Object.values( + campaign.inventory + ).reduce((sum, amount) => sum + amount, 0), + purchaseCount: + campaign.cityEquipmentPurchaseCounts[offerId] ?? + 0, + receiptCount: Object.values( + campaign.cityEquipmentPurchaseReceipts + ).filter( + (receipt) => receipt.intentId === intentId + ).length + }; + }, + { + cityStayId: seed.cityStayId, + offerId: seed.offerId + } + ); +} + +async function readCampaignCheckpoint(page) { + return page.evaluate(async () => { + const { getCampaignState } = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const campaign = getCampaignState(); + return { + step: campaign.step, + activeCityStayId: + campaign.activeCityStayId ?? null, + checkpoint: + campaign.explorationCheckpoint ?? null + }; + }); +} + +async function reloadAndContinueToCity(page) { + await reloadToTitle(page); + await clickContinue(page); + return waitForCityReady(page); +} + +async function reloadToTitle(page) { + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await page.waitForFunction( + () => { + 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' && + continueItem?.enabled === true && + continueItem?.bounds + ); + }, + undefined, + { timeout: 90000 } + ); + await afterTwoFrames(page); +} + +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.` + ); + await clickBounds(page, item.bounds); +} + +async function clickBounds(page, bounds) { + assertFiniteBounds(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 + )}` + ); + await page.mouse.click( + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2 + ); +} + +function exactPlayer(player) { + assert(player, 'Player debug state is required.'); + assert(Number.isFinite(player.x)); + assert(Number.isFinite(player.y)); + assert( + ['north', 'south', 'east', 'west'].includes( + player.direction + ) + ); + return { + x: player.x, + y: player.y, + direction: player.direction + }; +} + +function assertExactPlayer(actual, expected, label) { + assert(actual, label); + assert( + Math.abs(actual.x - expected.x) <= 0.05, + `${label}: x ${actual.x} !== ${expected.x}` + ); + assert( + Math.abs(actual.y - expected.y) <= 0.05, + `${label}: y ${actual.y} !== ${expected.y}` + ); + assert.equal(actual.direction, expected.direction, label); +} + +function assertCheckpointPlayer(checkpoint, expected, label) { + assert.equal(checkpoint?.version, 1, label); + assert.equal(checkpoint?.scene, 'city-stay', label); + assert.equal( + checkpoint?.contextId, + fixture.cityStayId, + label + ); + assertExactPlayer(checkpoint?.player, expected, label); +} + +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); +} + +async function afterTwoFrames(page) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(resolve) + ) + ) + ); +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + const parsed = new URL(url); + if ( + !['localhost', '127.0.0.1', '0.0.0.0'].includes( + parsed.hostname + ) + ) { + throw new Error(`No server responded at ${url}`); + } + const stderr = []; + const child = spawn( + process.execPath, + [ + 'node_modules/vite/bin/vite.js', + '--host', + '127.0.0.1', + '--port', + parsed.port || '41810', + '--strictPort' + ], + { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ); + child.stderr.on('data', (chunk) => + stderr.push(chunk.toString()) + ); + child.stdout.on('data', () => {}); + for (let attempt = 0; attempt < 160; attempt += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + child.kill(); + throw new Error( + `Vite server did not start at ${url}\n${stderr.join('')}` + ); +} + +async function canReach(url) { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(1000) + }); + return response.ok; + } catch { + return false; + } +} + +async function stopServerProcess(child) { + if (!child || child.killed) { + return; + } + child.kill(); + await delay(80); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/scripts/verify-prologue-exploration-checkpoint-browser.mjs b/scripts/verify-prologue-exploration-checkpoint-browser.mjs new file mode 100644 index 0000000..086d289 --- /dev/null +++ b/scripts/verify-prologue-exploration-checkpoint-browser.mjs @@ -0,0 +1,1196 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const rendererNames = ['canvas', 'webgl']; +const renderer = + process.env.VERIFY_PROLOGUE_CHECKPOINT_RENDERER; +const selectedOrderId = 'mobile'; + +if (!renderer) { + for (const requestedRenderer of rendererNames) { + const result = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url)], + { + cwd: process.cwd(), + env: { + ...process.env, + VERIFY_PROLOGUE_CHECKPOINT_RENDERER: + requestedRenderer + }, + stdio: 'inherit' + } + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } + process.exit(0); +} + +assert( + rendererNames.includes(renderer), + `Unsupported prologue checkpoint renderer "${renderer}".` +); + +const baseUrl = + process.env.VERIFY_PROLOGUE_CHECKPOINT_URL ?? + 'http://127.0.0.1:41811/heros_web/'; +const targetUrl = withDebugOptions(baseUrl, renderer); +let serverProcess; +let browser; + +try { + serverProcess = await ensureLocalServer(targetUrl); + browser = await chromium.launch({ + headless: + process.env.VERIFY_PROLOGUE_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 assertDesktopRuntime(page, renderer); + + const villageSeed = await seedVillage(page); + assert.equal(villageSeed.step, 'prologue-town'); + assert.equal(villageSeed.checkpoint, null); + assert.equal(villageSeed.entered, true); + + await page.evaluate( + async () => window.__HEROS_DEBUG__?.goToVillage() + ); + await waitForVillageReady(page); + await assertDesktopRuntime(page, renderer); + + const villagePlayer = await createMovementCheckpoint( + page, + 'village' + ); + const villagePositionResume = + await reloadAndContinueToVillage(page); + assertExactPlayer( + villagePositionResume.player, + villagePlayer, + `${renderer}: village position resume` + ); + assertCheckpoint( + villagePositionResume.explorationCheckpoint, + 'prologue-village', + 'prologue-village', + villagePlayer, + `${renderer}: village position checkpoint` + ); + + const villageDialogueBeforeReload = + await openVillageDialogueLine(page); + const villageDialogueResume = + await reloadAndContinueToVillage(page); + assertExactPlayer( + villageDialogueResume.player, + villageDialogueBeforeReload.player, + `${renderer}: village dialogue position resume` + ); + assertExplorationDialogue( + villageDialogueResume, + { + key: 'prologue-village-interaction', + source: 'zhang-fei', + lineIndex: + villageDialogueBeforeReload.dialogue.lineIndex + }, + `${renderer}: village dialogue line resume` + ); + + const completedVillage = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('PrologueVillageScene') + ?.debugCompleteVillage?.() ?? false + ); + assert.equal( + completedVillage, + true, + `${renderer}: could not complete the village transition.` + ); + await waitForStoryReady(page, 'PrologueMilitiaCampScene'); + const villageExitState = await readCampaignCheckpoint(page); + assert.equal(villageExitState.step, 'prologue-town'); + assert.equal(villageExitState.checkpoint, null); + assert.equal(villageExitState.villageComplete, true); + + await page.keyboard.press('Space'); + const campAfterVillage = await waitForMilitiaCampReady(page); + assertCheckpoint( + campAfterVillage.explorationCheckpoint, + 'prologue-militia-camp', + 'prologue-militia-camp', + campAfterVillage.player, + `${renderer}: village transition changed checkpoint context` + ); + assert.equal( + campAfterVillage.dialogue.dialogueKey, + 'prologue-camp-interaction' + ); + assert.equal( + campAfterVillage.dialogue.sourceNpcId, + 'camp-intro' + ); + await finishMilitiaDialogue(page); + + const campPlayer = await createMovementCheckpoint( + page, + 'militia-camp' + ); + const campPositionResume = + await reloadAndContinueToMilitiaCamp(page); + assertExactPlayer( + campPositionResume.player, + campPlayer, + `${renderer}: militia camp position resume` + ); + assertCheckpoint( + campPositionResume.explorationCheckpoint, + 'prologue-militia-camp', + 'prologue-militia-camp', + campPlayer, + `${renderer}: militia camp position checkpoint` + ); + + const campDialogueBeforeReload = + await openMilitiaDialogueLine(page); + const campDialogueResume = + await reloadAndContinueToMilitiaCamp(page); + assertExactPlayer( + campDialogueResume.player, + campDialogueBeforeReload.player, + `${renderer}: militia dialogue position resume` + ); + assertExplorationDialogue( + campDialogueResume, + { + key: 'prologue-camp-interaction', + source: 'guan-yu', + lineIndex: + campDialogueBeforeReload.dialogue.lineIndex + }, + `${renderer}: militia dialogue line resume` + ); + await finishMilitiaDialogue(page); + + const completedObjectives = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('PrologueMilitiaCampScene') + ?.debugCompleteRequiredObjectives?.() ?? [] + ); + assert.deepEqual( + [...completedObjectives].sort(), + [ + 'inspect-arms', + 'ready-vanguard', + 'review-scout-report' + ].sort() + ); + + try { + await page.waitForFunction( + () => { + const camp = + window.__HEROS_DEBUG__?.militiaCamp?.(); + return ( + camp?.ready === true && + camp?.navigationPending === false && + camp?.movement?.npcMovementPending === false && + camp?.dialogue?.active === false && + camp?.commandChoice?.open === false + ); + }, + undefined, + { timeout: 15000 } + ); + } catch (error) { + throw new Error( + `${renderer}: militia camp did not settle before command interaction: ` + + JSON.stringify(await readMilitiaCamp(page)), + { cause: error } + ); + } + const commanderInteracted = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('PrologueMilitiaCampScene') + ?.debugInteractWith?.('zou-jing') ?? false + ); + assert.equal( + commanderInteracted, + true, + `${renderer}: could not start the departure command dialogue.` + ); + await waitForMilitiaDialogue(page, { + key: 'prologue-camp-departure', + source: 'zou-jing', + lineIndex: 0 + }); + await page.waitForTimeout(320); + await page.keyboard.press('e'); + await waitForCommandChoice(page); + + const choiceOpened = await readMilitiaCamp(page); + const mobileCard = choiceOpened.commandChoice.cards.find( + ({ orderId }) => orderId === selectedOrderId + ); + assert( + mobileCard?.bounds, + `${renderer}: mobile command card is missing.` + ); + await clickBounds(page, mobileCard.bounds); + await page.waitForFunction( + (orderId) => { + const camp = + window.__HEROS_DEBUG__?.militiaCamp?.(); + return ( + camp?.commandChoice?.open === true && + camp.commandChoice.pendingId === orderId && + camp.commandChoice.confirmEnabled === true && + camp.explorationCheckpoint?.overlay?.type === + 'choice' && + camp.explorationCheckpoint.overlay.mode === + 'prologue-command' && + camp.explorationCheckpoint.overlay.choiceId === + orderId + ); + }, + selectedOrderId + ); + const pendingChoiceBeforeReload = + await readMilitiaCamp(page); + + const pendingChoiceResume = + await reloadAndContinueToMilitiaCamp(page); + await waitForCommandChoice(page, selectedOrderId); + const readyPendingChoiceResume = + await readMilitiaCamp(page); + assertExactPlayer( + readyPendingChoiceResume.player, + pendingChoiceBeforeReload.player, + `${renderer}: command choice position resume` + ); + assert.equal( + pendingChoiceResume.commandChoice.pendingId, + selectedOrderId + ); + assert.equal( + readyPendingChoiceResume.commandChoice.confirmEnabled, + true + ); + assert.equal( + readyPendingChoiceResume.commandChoice.cards.find( + ({ orderId }) => orderId === selectedOrderId + )?.pending, + true + ); + assert.deepEqual( + readyPendingChoiceResume.explorationCheckpoint?.overlay, + { + type: 'choice', + mode: 'prologue-command', + sourceNpcId: 'zou-jing', + choiceId: selectedOrderId + }, + `${renderer}: pending command selection checkpoint` + ); + + const commandChosen = await page.evaluate((orderId) => + window.__HEROS_DEBUG__ + ?.scene('PrologueMilitiaCampScene') + ?.debugChooseFirstCommand?.(orderId) ?? false, + selectedOrderId + ); + assert.equal( + commandChosen, + true, + `${renderer}: could not confirm the command choice.` + ); + await waitForMilitiaDialogue(page, { + key: 'prologue-camp-command-response', + source: `first-command-${selectedOrderId}`, + lineIndex: 0 + }); + await page.waitForTimeout(320); + await page.keyboard.press('e'); + await waitForMilitiaDialogue(page, { + key: 'prologue-camp-command-response', + source: `first-command-${selectedOrderId}`, + lineIndex: 1 + }); + const responseBeforeReload = await readMilitiaCamp(page); + + const responseResume = + await reloadAndContinueToMilitiaCamp(page); + assertExactPlayer( + responseResume.player, + responseBeforeReload.player, + `${renderer}: command response position resume` + ); + assertExplorationDialogue( + responseResume, + { + key: 'prologue-camp-command-response', + source: `first-command-${selectedOrderId}`, + lineIndex: 1 + }, + `${renderer}: command response line resume` + ); + assert.deepEqual( + (await readCampaignCheckpoint(page)) + .sortieOrderSelection, + { + battleId: 'first-battle-zhuo-commandery', + orderId: selectedOrderId + } + ); + + await page.waitForTimeout(320); + await page.keyboard.press('e'); + await waitForStoryReady(page, 'BattleScene'); + const campExitState = await readCampaignCheckpoint(page); + assert.equal(campExitState.step, 'prologue-camp'); + assert.equal(campExitState.checkpoint, null); + assert.equal(campExitState.campComplete, true); + assert.deepEqual(campExitState.sortieOrderSelection, { + battleId: 'first-battle-zhuo-commandery', + orderId: selectedOrderId + }); + + await reloadToTitle(page); + await clickContinue(page); + await waitForStoryReady(page, 'BattleScene'); + assert( + !(await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('PrologueMilitiaCampScene') + )), + `${renderer}: Continue reopened a completed militia camp.` + ); + assert.equal( + (await readCampaignCheckpoint(page)).checkpoint, + null + ); + + const actionableConsoleErrors = consoleErrors.filter( + (message) => + !message.includes( + 'The AudioContext was not allowed to start' + ) + ); + assert.deepEqual( + pageErrors, + [], + `${renderer}: page errors: ${pageErrors.join('\n')}` + ); + assert.deepEqual( + actionableConsoleErrors, + [], + `${renderer}: console errors: ${actionableConsoleErrors.join( + '\n' + )}` + ); + + console.log( + JSON.stringify( + { + renderer, + viewport: { + ...desktopBrowserViewport, + deviceScaleFactor: + desktopBrowserDeviceScaleFactor, + browserZoom: '100%' + }, + checkpoints: { + villageExactPlayerResume: villagePlayer, + villageDialogueLineResume: true, + villageExitCleared: true, + villageToCampContextChangedSafely: true, + militiaExactPlayerResume: campPlayer, + militiaDialogueLineResume: true, + commandChoiceResume: true, + pendingCommandSelectionResume: selectedOrderId, + commandResponseLineResume: true, + militiaExitCleared: true + }, + pageErrors: pageErrors.length, + consoleErrors: actionableConsoleErrors.length + }, + null, + 2 + ) + ); +} finally { + await browser?.close(); + await stopServerProcess(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 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, + 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.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 + ); +} + +async function seedVillage(page) { + return page.evaluate(async () => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + campaignModule.resetCampaignState(); + const state = + campaignModule.createInitialCampaignState(); + state.step = 'prologue-town'; + state.completedTutorialIds = [ + campaignModule.prologueVillageCampaignTutorialIds + .entered + ]; + const saved = campaignModule.setCampaignState(state); + return { + step: saved.step, + checkpoint: + saved.explorationCheckpoint ?? null, + entered: saved.completedTutorialIds.includes( + campaignModule.prologueVillageCampaignTutorialIds + .entered + ) + }; + }); +} + +async function waitForVillageReady(page) { + try { + await page.waitForFunction( + () => { + const village = + window.__HEROS_DEBUG__?.village?.(); + return ( + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('PrologueVillageScene') && + 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, + title: + window.__HEROS_DEBUG__?.title?.() ?? null + })); + throw new Error( + `${renderer}: village did not become ready: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); + return readVillage(page); +} + +async function waitForMilitiaCampReady(page) { + try { + await page.waitForFunction( + () => { + const camp = + window.__HEROS_DEBUG__?.militiaCamp?.(); + return ( + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('PrologueMilitiaCampScene') && + camp?.scene === + 'PrologueMilitiaCampScene' && + camp?.ready === true && + camp?.requiredTexturesReady === true + ); + }, + undefined, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + militiaCamp: + window.__HEROS_DEBUG__?.militiaCamp?.() ?? null, + story: + window.__HEROS_DEBUG__ + ?.scene('StoryScene') + ?.getDebugState?.() ?? null + })); + throw new Error( + `${renderer}: militia camp did not become ready: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); + return readMilitiaCamp(page); +} + +async function readVillage(page) { + return page.evaluate( + () => window.__HEROS_DEBUG__?.village?.() ?? null + ); +} + +async function readMilitiaCamp(page) { + return page.evaluate( + () => + window.__HEROS_DEBUG__?.militiaCamp?.() ?? null + ); +} + +async function createMovementCheckpoint(page, sceneKind) { + const reader = + sceneKind === 'village' ? readVillage : readMilitiaCamp; + await page.waitForTimeout(450); + const before = await reader(page); + const teleported = await page.evaluate( + ({ sceneKey, targetId }) => + window.__HEROS_DEBUG__ + ?.scene(sceneKey) + ?.debugTeleportTo?.(targetId) ?? false, + sceneKind === 'village' + ? { + sceneKey: 'PrologueVillageScene', + targetId: 'zhang-fei' + } + : { + sceneKey: 'PrologueMilitiaCampScene', + targetId: 'quartermaster' + } + ); + assert.equal( + teleported, + true, + `${renderer}: ${sceneKind} checkpoint teleport failed.` + ); + await page.waitForTimeout(100); + await page.keyboard.down('ArrowRight'); + await page.waitForTimeout(240); + await page.keyboard.up('ArrowRight'); + await page.waitForFunction( + (kind) => { + const state = + kind === 'village' + ? window.__HEROS_DEBUG__?.village?.() + : window.__HEROS_DEBUG__?.militiaCamp?.(); + const player = state?.player; + const checkpoint = state?.explorationCheckpoint; + return ( + player?.moving === false && + player?.direction === 'east' && + checkpoint?.overlay === undefined && + Math.abs(checkpoint?.player?.x - player.x) <= 0.05 && + Math.abs(checkpoint?.player?.y - player.y) <= 0.05 && + checkpoint?.player?.direction === player.direction + ); + }, + sceneKind, + { timeout: 10000 } + ); + const state = await reader(page); + assert( + Math.hypot( + state.player.x - before.player.x, + state.player.y - before.player.y + ) > 20, + `${renderer}: ${sceneKind} movement checkpoint was not distinct.` + ); + return exactPlayer(state.player); +} + +async function openVillageDialogueLine(page) { + const interacted = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('PrologueVillageScene') + ?.debugInteractWith?.('zhang-fei') ?? false + ); + assert.equal( + interacted, + true, + `${renderer}: could not interact with Zhang Fei.` + ); + await waitForVillageDialogue(page, { + key: 'prologue-village-interaction', + source: 'zhang-fei', + lineIndex: 0 + }); + await page.waitForTimeout(320); + await page.keyboard.press('e'); + await waitForVillageDialogue(page, { + key: 'prologue-village-interaction', + source: 'zhang-fei', + lineIndex: 1 + }); + const village = await readVillage(page); + return { + ...village, + player: exactPlayer(village.player) + }; +} + +async function openMilitiaDialogueLine(page) { + const interacted = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('PrologueMilitiaCampScene') + ?.debugInteractWith?.('guan-yu') ?? false + ); + assert.equal( + interacted, + true, + `${renderer}: could not interact with Guan Yu.` + ); + await waitForMilitiaDialogue(page, { + key: 'prologue-camp-interaction', + source: 'guan-yu', + lineIndex: 0 + }); + await page.waitForTimeout(320); + await page.keyboard.press('e'); + await waitForMilitiaDialogue(page, { + key: 'prologue-camp-interaction', + source: 'guan-yu', + lineIndex: 1 + }); + const camp = await readMilitiaCamp(page); + return { + ...camp, + player: exactPlayer(camp.player) + }; +} + +async function waitForVillageDialogue( + page, + expected +) { + await page.waitForFunction( + ({ key, source, lineIndex }) => { + const village = + window.__HEROS_DEBUG__?.village?.(); + return ( + village?.dialogue?.active === true && + village.dialogue.dialogueKey === key && + village.dialogue.sourceNpcId === source && + village.dialogue.lineIndex === lineIndex && + village.explorationCheckpoint?.overlay?.type === + 'dialogue' && + village.explorationCheckpoint.overlay + .dialogueKey === key && + village.explorationCheckpoint.overlay + .sourceNpcId === source && + village.explorationCheckpoint.overlay + .lineIndex === lineIndex + ); + }, + expected + ); +} + +async function waitForMilitiaDialogue( + page, + expected +) { + await page.waitForFunction( + ({ key, source, lineIndex }) => { + const camp = + window.__HEROS_DEBUG__?.militiaCamp?.(); + return ( + camp?.dialogue?.active === true && + camp.dialogue.dialogueKey === key && + camp.dialogue.sourceNpcId === source && + camp.dialogue.lineIndex === lineIndex && + camp.explorationCheckpoint?.overlay?.type === + 'dialogue' && + camp.explorationCheckpoint.overlay.dialogueKey === + key && + camp.explorationCheckpoint.overlay.sourceNpcId === + source && + camp.explorationCheckpoint.overlay.lineIndex === + lineIndex + ); + }, + expected, + { timeout: 15000 } + ); +} + +async function finishMilitiaDialogue(page) { + for (let attempt = 0; attempt < 12; attempt += 1) { + const camp = await readMilitiaCamp(page); + if (!camp?.dialogue?.active) { + return; + } + const advanced = await page.evaluate(() => { + const scene = + window.__HEROS_DEBUG__?.scene( + 'PrologueMilitiaCampScene' + ); + if (!scene || typeof scene.advanceDialogue !== 'function') { + return false; + } + scene.advanceDialogue(); + return true; + }); + assert.equal( + advanced, + true, + `${renderer}: militia dialogue advance helper is unavailable.` + ); + await page.waitForTimeout(100); + } + throw new Error( + `${renderer}: militia camp dialogue did not finish.` + ); +} + +async function waitForCommandChoice( + page, + pendingId = null +) { + await page.waitForFunction( + (expectedPendingId) => { + const camp = + window.__HEROS_DEBUG__?.militiaCamp?.(); + return ( + camp?.commandChoice?.open === true && + camp.commandChoice.ready === true && + camp.explorationCheckpoint?.overlay?.type === + 'choice' && + camp.explorationCheckpoint.overlay.mode === + 'prologue-command' && + (expectedPendingId === null || + (camp.commandChoice.pendingId === + expectedPendingId && + camp.explorationCheckpoint.overlay.choiceId === + expectedPendingId)) + ); + }, + pendingId, + { timeout: 30000 } + ); +} + +function assertExplorationDialogue( + state, + { key, source, lineIndex }, + label +) { + assert.equal(state?.dialogue?.active, true, label); + assert.equal(state.dialogue.dialogueKey, key, label); + assert.equal(state.dialogue.sourceNpcId, source, label); + assert.equal(state.dialogue.lineIndex, lineIndex, label); + assert.deepEqual( + state.explorationCheckpoint?.overlay, + { + type: 'dialogue', + dialogueKey: key, + sourceNpcId: source, + lineIndex + }, + label + ); +} + +async function waitForStoryReady(page, nextScene) { + try { + await page.waitForFunction( + (expectedNextScene) => { + const debug = window.__HEROS_DEBUG__; + const storyScene = debug?.scene('StoryScene'); + const story = storyScene?.getDebugState?.(); + return ( + debug?.activeScenes?.().includes('StoryScene') && + story?.ready === true && + storyScene?.transitioning === false && + story?.nextScene === expectedNextScene + ); + }, + nextScene, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + story: + window.__HEROS_DEBUG__ + ?.scene('StoryScene') + ?.getDebugState?.() ?? null, + village: + window.__HEROS_DEBUG__?.village?.() ?? null, + militiaCamp: + window.__HEROS_DEBUG__?.militiaCamp?.() ?? null + })); + throw new Error( + `${renderer}: StoryScene did not become ready: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); +} + +async function readCampaignCheckpoint(page) { + return page.evaluate(async () => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const campaign = campaignModule.getCampaignState(); + return { + step: campaign.step, + checkpoint: + campaign.explorationCheckpoint ?? null, + villageComplete: + campaign.completedTutorialIds.includes( + campaignModule.prologueVillageCampaignTutorialIds + .complete + ), + campComplete: + campaign.completedTutorialIds.includes( + campaignModule + .prologueMilitiaCampCampaignTutorialIds.complete + ), + sortieOrderSelection: + campaign.sortieOrderSelection ?? null + }; + }); +} + +async function reloadAndContinueToVillage(page) { + await reloadToTitle(page); + await clickContinue(page); + return waitForVillageReady(page); +} + +async function reloadAndContinueToMilitiaCamp(page) { + await reloadToTitle(page); + await clickContinue(page); + return waitForMilitiaCampReady(page); +} + +async function reloadToTitle(page) { + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await page.waitForFunction( + () => { + 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' && + continueItem?.enabled === true && + continueItem?.bounds + ); + }, + undefined, + { timeout: 90000 } + ); + await afterTwoFrames(page); +} + +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.` + ); + await clickBounds(page, item.bounds); +} + +async function clickBounds(page, bounds) { + assertFiniteBounds(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 + )}` + ); + await page.mouse.click( + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2 + ); +} + +function exactPlayer(player) { + assert(player, 'Player debug state is required.'); + assert(Number.isFinite(player.x)); + assert(Number.isFinite(player.y)); + assert( + ['north', 'south', 'east', 'west'].includes( + player.direction + ) + ); + return { + x: player.x, + y: player.y, + direction: player.direction + }; +} + +function assertExactPlayer(actual, expected, label) { + assert(actual, label); + assert( + Math.abs(actual.x - expected.x) <= 0.05, + `${label}: x ${actual.x} !== ${expected.x}` + ); + assert( + Math.abs(actual.y - expected.y) <= 0.05, + `${label}: y ${actual.y} !== ${expected.y}` + ); + assert.equal(actual.direction, expected.direction, label); +} + +function assertCheckpoint( + checkpoint, + scene, + contextId, + expectedPlayer, + label +) { + assert.equal(checkpoint?.version, 1, label); + assert.equal(checkpoint?.scene, scene, label); + assert.equal(checkpoint?.contextId, contextId, label); + assertExactPlayer( + checkpoint?.player, + exactPlayer(expectedPlayer), + label + ); +} + +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); +} + +async function afterTwoFrames(page) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(resolve) + ) + ) + ); +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + const parsed = new URL(url); + if ( + !['localhost', '127.0.0.1', '0.0.0.0'].includes( + parsed.hostname + ) + ) { + throw new Error(`No server responded at ${url}`); + } + const stderr = []; + const child = spawn( + process.execPath, + [ + 'node_modules/vite/bin/vite.js', + '--host', + '127.0.0.1', + '--port', + parsed.port || '41811', + '--strictPort' + ], + { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ); + child.stderr.on('data', (chunk) => + stderr.push(chunk.toString()) + ); + child.stdout.on('data', () => {}); + for (let attempt = 0; attempt < 160; attempt += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + child.kill(); + throw new Error( + `Vite server did not start at ${url}\n${stderr.join('')}` + ); +} + +async function canReach(url) { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(1000) + }); + return response.ok; + } catch { + return false; + } +} + +async function stopServerProcess(child) { + if (!child || child.killed) { + return; + } + child.kill(); + await delay(80); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 4e5463e..34a158a 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -5,6 +5,8 @@ const checks = [ 'scripts/verify-flow-segment-data.mjs', 'scripts/verify-campaign-flow-data.mjs', 'scripts/verify-campaign-save-normalization.mjs', + 'scripts/verify-campaign-exploration-checkpoint-normalization.mjs', + 'scripts/verify-campaign-settlement-idempotency.mjs', 'scripts/verify-camp-visit-resume-state.mjs', 'scripts/verify-campaign-completion.mjs', 'scripts/verify-campaign-presentation-profiles.mjs', @@ -25,6 +27,7 @@ const checks = [ 'scripts/verify-camp-reward-data.mjs', 'scripts/verify-city-stay-data.mjs', 'scripts/verify-city-equipment-preparation.mjs', + 'scripts/verify-city-purchase-idempotency.mjs', 'scripts/verify-xuzhou-stay-battle-payoff.mjs', 'scripts/verify-xuzhou-stay-narrative-memory.mjs', 'scripts/verify-prologue-village-data.mjs', diff --git a/scripts/verify-victory-reward-acknowledgements.mjs b/scripts/verify-victory-reward-acknowledgements.mjs index e6300a2..95be9ed 100644 --- a/scripts/verify-victory-reward-acknowledgements.mjs +++ b/scripts/verify-victory-reward-acknowledgements.mjs @@ -128,6 +128,7 @@ try { delete legacySave.acknowledgedVictoryRewardCategories; delete legacySave.dismissedVictoryRewardNoticeBattleIds; values.set(campaignStorageKey, JSON.stringify(legacySave)); + values.set(`${campaignStorageKey}:slot-${legacySave.activeSaveSlot}`, JSON.stringify(legacySave)); const migratedLegacySave = loadCampaignState(); assert( migratedLegacySave.acknowledgedVictoryRewardBattleIds.includes(scenario.id) && @@ -150,6 +151,7 @@ try { 'unknown-battle' ]; values.set(campaignStorageKey, JSON.stringify(partialSave)); + values.set(`${campaignStorageKey}:slot-${partialSave.activeSaveSlot}`, JSON.stringify(partialSave)); const normalizedPartialSave = loadCampaignState(); assert( JSON.stringify(normalizedPartialSave.acknowledgedVictoryRewardCategories) === JSON.stringify({ [scenario.id]: ['equipment'] }) && diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index d295b41..b4bf242 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -223,6 +223,7 @@ import { acknowledgeCampaignVictoryReward, applyCampVisitReward, applyCampBondExp, + campaignInventoryCap, campaignVictoryRewardPresentation, campaignSortiePresetIds, campaignReserveTrainingFocusDefinitions, @@ -24572,8 +24573,17 @@ export class CampScene extends Phaser.Scene { cityStay.equipmentOffers.forEach((offer, index) => { const item = getItem(offer.itemId); const rowY = y + 50 + index * 60; - const canBuy = campaign.gold >= offer.price; - const owned = campaign.inventory[itemInventoryLabel(item.id)] ?? 0; + const inventoryLabel = itemInventoryLabel(item.id); + const owned = campaign.inventory[inventoryLabel] ?? 0; + const purchaseCount = + campaign.cityEquipmentPurchaseCounts?.[offer.id] ?? 0; + const inventoryFull = + owned >= campaignInventoryCap(inventoryLabel); + const purchaseLimitReached = purchaseCount >= 99; + const canBuy = + campaign.gold >= offer.price && + !inventoryFull && + !purchaseLimitReached; const row = this.track(this.add.rectangle(x + 20, rowY, width - 40, 48, canBuy ? 0x151f2a : 0x111820, canBuy ? 0.94 : 0.76)); row.setOrigin(0); row.setStrokeStyle(1, canBuy ? palette.blue : 0x53606c, canBuy ? 0.52 : 0.3); @@ -24599,15 +24609,30 @@ export class CampScene extends Phaser.Scene { const buttonText = this.track(this.add.text( x + width - 66, rowY + 24, - canBuy ? '구매' : '부족', + canBuy + ? '구매' + : inventoryFull + ? '가득' + : purchaseLimitReached + ? '한도' + : '부족', this.textStyle(12, canBuy ? '#fff2b8' : '#7f8994', true) )); buttonText.setOrigin(0.5); if (canBuy) { + const purchaseIntentId = + this.createCityPurchaseIntentId(offer.id); button.setInteractive({ useHandCursor: true }); button.on('pointerover', () => button.setFillStyle(0x283947, 0.99)); button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.98)); - button.on('pointerdown', () => this.buyCityEquipment(offer)); + button.on( + 'pointerdown', + () => + this.buyCityEquipment( + offer, + purchaseIntentId + ) + ); } this.cityEquipmentOfferViews.push({ offerId: offer.id, itemId: offer.itemId, buyButton: button }); }); @@ -24712,6 +24737,12 @@ export class CampScene extends Phaser.Scene { this.showCampNotice('이미 완료된 공명 대화입니다.'); return; } + if (result.reason === 'save-unavailable') { + this.showCampNotice( + '공명 선택을 저장하지 못했습니다. 잠시 후 다시 시도해 주세요.' + ); + return; + } this.showCampNotice('두 동료의 공명 관계를 찾지 못했습니다. 합류 상태를 확인해 주세요.'); return; } @@ -24722,13 +24753,20 @@ export class CampScene extends Phaser.Scene { this.render(); } - private buyCityEquipment(offer: CityEquipmentOffer) { + private buyCityEquipment( + offer: CityEquipmentOffer, + purchaseIntentId: string + ) { const cityStay = this.currentCityStay(); if (!cityStay) { this.showCampNotice('현재 시장에서는 살 수 없는 장비입니다.'); return; } - const result = purchaseCityStayEquipment(cityStay.id, offer.id); + const result = purchaseCityStayEquipment( + cityStay.id, + offer.id, + purchaseIntentId + ); if (!result.ok) { if (result.reason === 'restricted-item') { this.showCampNotice('보물 장비는 일반 시장에서 거래할 수 없습니다.'); @@ -24738,19 +24776,54 @@ export class CampScene extends Phaser.Scene { this.showCampNotice('군자금이 부족합니다.'); return; } + if (result.reason === 'inventory-full') { + this.showCampNotice('이 장비는 더 보관할 수 없습니다.'); + return; + } + if (result.reason === 'purchase-limit-reached') { + this.showCampNotice('이 도시의 구매 한도에 도달했습니다.'); + return; + } + if ( + result.reason === 'intent-conflict' || + result.reason === 'invalid-purchase-intent' + ) { + this.showCampNotice( + '구매 요청을 확인할 수 없습니다. 다시 선택해 주세요.' + ); + return; + } this.showCampNotice('현재 시장에서는 살 수 없는 장비입니다.'); return; } this.campaign = result.campaign; this.report = this.campaign.firstBattleReport ?? this.report; - soundDirector.playSelect(); + if (!result.replayed) { + soundDirector.playSelect(); + } this.showCampNotice( - `${result.cityStay.city.name} 시장 · ${result.item.name} 구입 · 군자금 -${result.offer.price}` + result.replayed + ? `${result.cityStay.city.name} 시장 · ${result.item.name} 구매 기록을 확인했습니다.` + : `${result.cityStay.city.name} 시장 · ${result.item.name} 구입 · 군자금 -${result.offer.price}` ); this.render(); } + private createCityPurchaseIntentId(offerId: string) { + const randomPart = + globalThis.crypto?.randomUUID?.() ?? + `${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 12)}`; + return [ + 'camp-city-purchase', + this.currentCityStay()?.id ?? 'unknown', + offerId, + randomPart + ].join(':'); + } + private renderVisitPanel() { const x = 394; const y = 120; diff --git a/src/game/scenes/CampVisitExplorationScene.ts b/src/game/scenes/CampVisitExplorationScene.ts index ee18ec2..8e5cd45 100644 --- a/src/game/scenes/CampVisitExplorationScene.ts +++ b/src/game/scenes/CampVisitExplorationScene.ts @@ -51,8 +51,12 @@ import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; import { getCampaignState, + saveCampaignState, + setCampaignExplorationCheckpoint, setActiveCampVisitId, - type CampaignCampVisitId + type CampaignCampVisitId, + type CampaignExplorationCheckpoint, + type CampaignExplorationDialogueKey } from '../state/campaignState'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions'; @@ -151,8 +155,9 @@ type DialogueLine = { type DialogueState = { lines: DialogueLine[]; lineIndex: number; - onComplete?: () => void; + onComplete?: () => boolean | void; sourceNpcId?: ExplorationActorId | 'camp-exit'; + dialogueKey: CampaignExplorationDialogueKey; }; type ChoiceView = { @@ -360,6 +365,16 @@ export class CampVisitExplorationScene extends Phaser.Scene { private ownedPresentationTextureKeys = new Set(); private visualMotionReduced = false; private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay; + private checkpointLastPlayerSignature = ''; + private checkpointRestoring = false; + private readonly persistCheckpointOnPageHide = () => { + this.persistExplorationCheckpoint(true); + }; + private readonly persistCheckpointWhenHidden = () => { + if (document.visibilityState === 'hidden') { + this.persistExplorationCheckpoint(true); + } + }; constructor() { super('CampVisitExplorationScene'); @@ -404,16 +419,33 @@ export class CampVisitExplorationScene extends Phaser.Scene { create() { this.resetRuntimeState(); this.visualMotionReduced = isVisualMotionReduced(); + window.addEventListener( + 'pagehide', + this.persistCheckpointOnPageHide + ); + document.addEventListener( + 'visibilitychange', + this.persistCheckpointWhenHidden + ); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.persistExplorationCheckpoint(true); this.ready = false; this.navigationPending = false; + window.removeEventListener( + 'pagehide', + this.persistCheckpointOnPageHide + ); + document.removeEventListener( + 'visibilitychange', + this.persistCheckpointWhenHidden + ); this.clearNavigationTarget(); this.explorationInput?.destroy(); this.explorationInput = undefined; this.dialogueState = undefined; this.campaignObjectiveJournal?.destroy(); this.campaignObjectiveJournal = undefined; - this.closeChoicePanel(); + this.closeChoicePanel(false); releaseExplorationCharacterTextureKeys( this, this.currentCharacterTextureKeys() @@ -460,6 +492,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.createDialoguePanel(); this.ready = true; this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + const restoredCheckpoint = + this.restoreExplorationCheckpoint(); this.createCampaignObjectiveJournal(); this.refreshObjectiveHud(); this.refreshActorMarkers(); @@ -480,27 +514,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { ambienceVolume: 0.12 } ); - if (this.isThirdCampVisit() && this.thirdCampIntroPending) { - const definition = this.thirdCampDefinition(); - this.startDialogue( - definition ? [...definition.introLines] : [], - () => { - const completion = completeThirdCampExplorationIntro(); - if (!completion.ok) { - this.showCompletionToast( - '도입 기록을 저장하지 못했습니다. 다음 방문 때 다시 들려드릴게요.', - 'error', - 2400 - ); - return; - } - this.showCompletionToast( - '세 준비는 선택 활동입니다. 필요한 곳만 살피고 언제든 출진할 수 있습니다.', - 'guide', - 2100 - ); - } - ); + if (restoredCheckpoint.overlayRestored) { + this.lastNotice = '저장한 탐험 지점에서 이어왔습니다.'; + } else if (this.isThirdCampVisit() && this.thirdCampIntroPending) { + this.startThirdCampIntroDialogue(); } else { this.showCompletionToast( this.isThirdCampVisit() @@ -867,6 +884,14 @@ export class CampVisitExplorationScene extends Phaser.Scene { campaign.thirdCampPreparationSelection ? { ...campaign.thirdCampPreparationSelection } : null, + explorationCheckpoint: + campaign.explorationCheckpoint + ? JSON.parse( + JSON.stringify( + campaign.explorationCheckpoint + ) + ) + : null, inventory: { ...campaign.inventory } }, blockers: this.blockers.map((blocker) => @@ -1182,6 +1207,409 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.stepIndex = 0; this.lastStepAt = 0; this.lastNotice = ''; + this.checkpointLastPlayerSignature = ''; + this.checkpointRestoring = false; + } + + private persistExplorationCheckpoint(force = false) { + if ( + !this.ready || + this.checkpointRestoring || + !this.player + ) { + return; + } + const campaign = getCampaignState(); + if (campaign.activeCampVisitId !== this.visitId) { + return; + } + const overlay: CampaignExplorationCheckpoint['overlay'] = + this.dialogueState + ? { + type: 'dialogue', + dialogueKey: this.dialogueState.dialogueKey, + ...(this.dialogueState.sourceNpcId + ? { + sourceNpcId: + this.dialogueState.sourceNpcId + } + : {}), + lineIndex: this.dialogueState.lineIndex + } + : this.choicePanel + ? { + type: 'choice', + mode: this.choicePanelMode, + ...(this.choiceSourceNpcId + ? { sourceNpcId: this.choiceSourceNpcId } + : {}) + } + : undefined; + const checkpoint: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > = { + version: 1, + scene: 'camp-visit-exploration', + contextId: this.visitId as CampaignCampVisitId, + player: { + x: this.player.x, + y: this.player.y, + direction: this.playerDirection + }, + ...(overlay ? { overlay } : {}) + }; + const signature = JSON.stringify(checkpoint); + if (!force && signature === this.checkpointLastPlayerSignature) { + return; + } + try { + setCampaignExplorationCheckpoint(checkpoint); + this.checkpointLastPlayerSignature = signature; + } catch (error) { + console.error( + 'Failed to save the camp exploration checkpoint.', + error + ); + } + } + + private explorationDialogueCheckpoint( + dialogueKey: CampaignExplorationDialogueKey, + sourceNpcId?: string + ) { + if (!this.player) { + return undefined; + } + return { + version: 1, + scene: 'camp-visit-exploration', + contextId: this.visitId as CampaignCampVisitId, + player: { + x: this.player.x, + y: this.player.y, + direction: this.playerDirection + }, + overlay: { + type: 'dialogue', + dialogueKey, + ...(sourceNpcId ? { sourceNpcId } : {}), + lineIndex: 0 + } + } satisfies Omit< + CampaignExplorationCheckpoint, + 'savedAt' + >; + } + + private explorationPositionCheckpoint() { + if (!this.player) { + return undefined; + } + return { + version: 1, + scene: 'camp-visit-exploration', + contextId: this.visitId as CampaignCampVisitId, + player: { + x: this.player.x, + y: this.player.y, + direction: this.playerDirection + } + } satisfies Omit< + CampaignExplorationCheckpoint, + 'savedAt' + >; + } + + private restoreExplorationCheckpoint() { + const checkpoint = getCampaignState().explorationCheckpoint; + if ( + !checkpoint || + checkpoint.scene !== 'camp-visit-exploration' || + checkpoint.contextId !== this.visitId || + !this.player + ) { + this.persistExplorationCheckpoint(true); + return { + positionRestored: false, + overlayRestored: false + }; + } + + this.checkpointRestoring = true; + let positionRestored = false; + let overlayRestored = false; + try { + if ( + this.canPlayerOccupy( + checkpoint.player.x, + checkpoint.player.y + ) + ) { + this.player.setPosition( + checkpoint.player.x, + checkpoint.player.y + ); + this.playerDirection = checkpoint.player.direction; + this.syncPlayerView(); + this.setPlayerMoving(false); + positionRestored = true; + } + overlayRestored = checkpoint.overlay + ? this.restoreCheckpointOverlay(checkpoint.overlay) + : false; + } finally { + this.checkpointRestoring = false; + } + this.persistExplorationCheckpoint(true); + return { positionRestored, overlayRestored }; + } + + private restoreCheckpointOverlay( + overlay: CampaignExplorationCheckpoint['overlay'] + ) { + if (!overlay) { + return false; + } + if (overlay.type === 'shop') { + return false; + } + if (overlay.type === 'choice') { + if ( + overlay.mode !== 'visit' && + overlay.mode !== 'third-companion' + ) { + return false; + } + if ( + overlay.mode === 'visit' && + ( + this.visitComplete() || + ( + this.isSecondReliefVisit() && + !secondBattleReliefProgress().choiceReady + ) + ) + ) { + return false; + } + if ( + overlay.mode === 'third-companion' && + thirdCampExplorationProgress().completedActivityIds.includes( + 'companion' + ) + ) { + return false; + } + const sourceView = overlay.sourceNpcId + ? this.actorViews.get(overlay.sourceNpcId) + : undefined; + if (sourceView) { + this.faceCharactersForConversation(sourceView); + } + this.openChoicePanel( + overlay.mode, + overlay.sourceNpcId + ); + return Boolean(this.choicePanel); + } + + if (overlay.dialogueKey === 'third-intro') { + if ( + !this.isThirdCampVisit() || + !this.thirdCampIntroPending + ) { + return false; + } + this.startThirdCampIntroDialogue(overlay.lineIndex); + } else if ( + overlay.dialogueKey === 'visit-interaction' + ) { + if (!overlay.sourceNpcId) { + return false; + } + const target = this.interactionTargetById( + overlay.sourceNpcId + ); + if (!target || target.kind !== 'actor') { + return false; + } + this.interactWithTarget(target); + this.restoreDialogueLine( + overlay.dialogueKey, + overlay.lineIndex + ); + } else if (overlay.dialogueKey === 'camp-exit') { + this.interactWithExit(); + this.restoreDialogueLine( + overlay.dialogueKey, + overlay.lineIndex + ); + } else { + this.startChoiceResponseDialogue( + overlay.dialogueKey, + overlay.sourceNpcId, + overlay.lineIndex + ); + } + return Boolean( + this.dialogueState?.dialogueKey === + overlay.dialogueKey + ); + } + + private restoreDialogueLine( + dialogueKey: CampaignExplorationDialogueKey, + lineIndex: number + ) { + const dialogue = this.dialogueState; + if (!dialogue || dialogue.dialogueKey !== dialogueKey) { + return; + } + dialogue.lineIndex = Phaser.Math.Clamp( + Math.floor(lineIndex), + 0, + dialogue.lines.length - 1 + ); + this.renderDialogueLine(); + } + + private startThirdCampIntroDialogue(initialLineIndex = 0) { + const definition = this.thirdCampDefinition(); + this.startDialogue( + definition ? [...definition.introLines] : [], + () => { + const completion = completeThirdCampExplorationIntro( + this.explorationPositionCheckpoint() + ); + if (!completion.ok) { + this.showCompletionToast( + '도입 기록을 저장하지 못했습니다. 다음 방문 때 다시 들려드릴게요.', + 'error', + 2400 + ); + return false; + } + this.showCompletionToast( + '세 준비는 선택 활동입니다. 필요한 곳만 살피고 언제든 출진할 수 있습니다.', + 'guide', + 2100 + ); + return true; + }, + undefined, + 'third-intro', + initialLineIndex + ); + } + + private startChoiceResponseDialogue( + dialogueKey: CampaignExplorationDialogueKey, + sourceNpcId: string | undefined, + initialLineIndex: number + ) { + const campaign = getCampaignState(); + if (dialogueKey === 'first-choice-response') { + const choiceId = + campaign.campVisitChoiceIds[firstPursuitScoutVisitId]; + const choice = choiceId + ? getFirstPursuitScoutVisitChoice(choiceId) + : undefined; + if (!choice) { + return; + } + this.startDialogue( + [ + { + speaker: '간옹', + portraitKey: 'jian-yong-campaign', + text: choice.response.replace(/^간옹:\s*/, '') + }, + { + speaker: '정찰 기록', + text: `${choice.label} · 공명 +${choice.bondExp} · ${choice.itemRewards.join(' · ')}` + } + ], + () => + this.showCompletionToast( + '정찰 기록이 출진 준비와 다음 전투에 반영됩니다.' + ), + sourceNpcId ?? 'jian-yong', + dialogueKey, + initialLineIndex + ); + return; + } + if (dialogueKey === 'second-choice-response') { + const choiceId = + campaign.campVisitChoiceIds[secondBattleReliefVisitId]; + const choice = choiceId + ? getSecondBattleReliefChoice(choiceId) + : undefined; + if (!choice) { + return; + } + this.startDialogue( + [ + { + speaker: '간옹', + portraitKey: 'jian-yong-campaign', + text: choice.response + }, + { + speaker: '현장 수습 기록', + text: `${choice.label} · 공명 +${choice.bondExp} · ${choice.itemRewards.join(' · ')}` + } + ], + () => + this.showCompletionToast( + '수습 방침이 광종 구원로의 출진 준비에 반영됩니다.' + ), + sourceNpcId ?? 'jian-yong-records', + dialogueKey, + initialLineIndex + ); + return; + } + if (dialogueKey !== 'third-companion-response') { + return; + } + const dialogue = + resolveThirdCampCompanionDialogue(campaign); + const choiceId = dialogue + ? campaign.campDialogueChoiceIds[dialogue.id] + : undefined; + const choice = dialogue?.choices.find( + (candidate) => candidate.id === choiceId + ); + const activity = this.thirdCampDefinition()?.activities.find( + (candidate) => candidate.id === 'companion' + ); + if (!dialogue || !choice || !activity) { + return; + } + const rewardExp = dialogue.rewardExp + choice.rewardExp; + this.startDialogue( + [ + { + speaker: '출진 약속', + text: choice.response + }, + { + speaker: '공명 기록', + text: `${choice.label} · 공명 +${rewardExp} · 군영 장부에서 초반 협공 보너스로 선택 가능` + } + ], + () => + this.showCompletionToast( + activity.completionLine, + 'success', + 2100 + ), + sourceNpcId, + dialogueKey, + initialLineIndex + ); } private visitAvailable() { @@ -2899,6 +3327,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { if (!this.player) { return; } + const wasMoving = this.playerMoving; applyExplorationCharacterMotion( this.player, playerTextureKey, @@ -2907,6 +3336,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.visualMotionReduced ); this.playerMoving = moving; + if ( + wasMoving && + !moving && + !this.checkpointRestoring + ) { + this.persistExplorationCheckpoint(true); + } } private stopPlayerMovement() { @@ -2932,6 +3368,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.player.setPosition(x, y); this.syncPlayerView(); this.stopPlayerMovement(); + if (!this.checkpointRestoring) { + this.persistExplorationCheckpoint(true); + } } private safeApproachPosition(targetX: number, targetY: number) { @@ -3029,7 +3468,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { [...lines], () => { this.restoreActorDirection(view); - this.completeThirdCampActivity(activityId); + return this.completeThirdCampActivity(activityId); }, view.definition.id ); @@ -3039,7 +3478,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { activityId: ThirdCampExplorationActivityId ) { const before = thirdCampExplorationProgress(); - const result = recordThirdCampExplorationActivity(activityId); + const result = recordThirdCampExplorationActivity( + activityId, + this.explorationPositionCheckpoint() + ); if (!result.ok) { const message = result.reason === 'choice-required' @@ -3050,7 +3492,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { ? '현재 군영에서는 이 준비를 진행할 수 없습니다.' : '준비 기록을 저장하지 못했습니다.'; this.showCompletionToast(message, 'error'); - return; + return false; } const newlyCompleted = @@ -3073,6 +3515,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { 'success', newlyCompleted ? 2100 : 1750 ); + return true; } private interactWithReliefActor(view: ExplorationActorView) { @@ -3145,7 +3588,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { dialogueLines, () => { this.restoreActorDirection(view); - this.completeReliefObjective(objective.id); + return this.completeReliefObjective(objective.id); }, view.definition.id ); @@ -3176,7 +3619,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { private completeReliefObjective( objectiveId: SecondBattleReliefObjectiveId ) { - const result = recordSecondBattleReliefObjective(objectiveId); + const result = recordSecondBattleReliefObjective( + objectiveId, + this.explorationPositionCheckpoint() + ); if (!result.ok) { const message = result.reason === 'prerequisites-incomplete' @@ -3187,7 +3633,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.showCompletionToast(message, 'error'); this.refreshObjectiveHud(); this.refreshActorMarkers(); - return; + return false; } this.inputReadyAt = this.time.now + inputCarryoverGuardMs; @@ -3197,6 +3643,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.refreshActorMarkers(); this.refreshInteractionPrompt(); this.showCompletionToast(result.objective.completionLine, 'success', 1900); + return true; } private interactWithJianYong(view: ExplorationActorView) { @@ -3271,8 +3718,11 @@ export class CampVisitExplorationScene extends Phaser.Scene { private startDialogue( lines: DialogueLine[], - onComplete?: () => void, - sourceNpcId?: DialogueState['sourceNpcId'] + onComplete?: () => boolean | void, + sourceNpcId?: DialogueState['sourceNpcId'], + dialogueKey: CampaignExplorationDialogueKey = + 'visit-interaction', + initialLineIndex = 0 ) { if ( lines.length === 0 || @@ -3284,15 +3734,21 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.stopPlayerMovement(); this.dialogueState = { lines, - lineIndex: 0, + lineIndex: Phaser.Math.Clamp( + Math.floor(initialLineIndex), + 0, + lines.length - 1 + ), onComplete, - sourceNpcId + sourceNpcId, + dialogueKey }; this.dialoguePanel?.setVisible(true); this.promptBackground?.setVisible(false); this.promptText?.setVisible(false); soundDirector.playSelect(); this.renderDialogueLine(); + this.persistExplorationCheckpoint(true); } private renderDialogueLine() { @@ -3350,13 +3806,17 @@ export class CampVisitExplorationScene extends Phaser.Scene { dialogue.lineIndex += 1; soundDirector.playStoryAdvanceCue(); this.renderDialogueLine(); + this.persistExplorationCheckpoint(true); return; } const onComplete = dialogue.onComplete; this.dialogueState = undefined; this.dialoguePanel?.setVisible(false); this.stopPlayerMovement(); - onComplete?.(); + const checkpointCommitted = onComplete?.() === true; + if (!checkpointCommitted) { + this.persistExplorationCheckpoint(true); + } this.refreshInteractionPrompt(); } @@ -3371,6 +3831,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.restoreActorDirection(view); } } + this.persistExplorationCheckpoint(true); this.refreshInteractionPrompt(); } @@ -3520,6 +3981,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.choicePanelMode = mode; this.choiceSourceNpcId = sourceNpcId; this.choiceReadyAt = this.time.now + inputCarryoverGuardMs; + this.persistExplorationCheckpoint(true); } private chooseChoiceByIndex(index: number) { @@ -3571,7 +4033,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { ) { return; } - const result = completeFirstPursuitScoutVisit(choice.id); + const result = completeFirstPursuitScoutVisit( + choice.id, + this.explorationDialogueCheckpoint( + 'first-choice-response', + 'jian-yong' + ) + ); if (!result.ok) { this.closeChoicePanel(); const message = @@ -3587,7 +4055,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { } this.inputReadyAt = this.time.now + inputCarryoverGuardMs; - this.closeChoicePanel(); + this.closeChoicePanel(false); this.lastNotice = `정찰 완료 · ${result.choice.label}`; soundDirector.playEffect('bond-resonance', { volume: 0.28, @@ -3611,7 +4079,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.showCompletionToast( '정찰 기록이 출진 준비와 다음 전투에 반영됩니다.' ), - 'jian-yong' + 'jian-yong', + 'first-choice-response' ); } @@ -3622,7 +4091,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { ) { return; } - const result = completeSecondBattleReliefExploration(choice.id); + const result = completeSecondBattleReliefExploration( + choice.id, + this.explorationDialogueCheckpoint( + 'second-choice-response', + 'jian-yong-records' + ) + ); if (!result.ok) { this.closeChoicePanel(); const message = @@ -3640,7 +4115,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { } this.inputReadyAt = this.time.now + inputCarryoverGuardMs; - this.closeChoicePanel(); + this.closeChoicePanel(false); this.lastNotice = `수습 완료 · ${result.choice.label}`; soundDirector.playEffect('bond-resonance', { volume: 0.28, @@ -3664,7 +4139,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.showCompletionToast( '수습 방침이 광종 구원로의 출진 준비에 반영됩니다.' ), - 'jian-yong-records' + 'jian-yong-records', + 'second-choice-response' ); } @@ -3686,7 +4162,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { getCampaignState().completedCampDialogues.includes(dialogue.id) ); const sourceNpcId = this.choiceSourceNpcId; - const result = completeThirdCampCompanionActivity(choice.id); + const result = completeThirdCampCompanionActivity( + choice.id, + this.explorationDialogueCheckpoint( + 'third-companion-response', + sourceNpcId + ) + ); if (!result.ok) { this.closeChoicePanel(); const message = @@ -3704,7 +4186,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { } this.inputReadyAt = this.time.now + inputCarryoverGuardMs; - this.closeChoicePanel(); + this.closeChoicePanel(false); this.lastNotice = '동료 공명 · 보너스 후보 해금'; if (alreadyCompleted) { soundDirector.playSelect(); @@ -3736,11 +4218,12 @@ export class CampVisitExplorationScene extends Phaser.Scene { 'success', 2100 ), - sourceNpcId + sourceNpcId, + 'third-companion-response' ); } - private closeChoicePanel() { + private closeChoicePanel(persistCheckpoint = true) { this.choicePanel?.destroy(); this.choicePanel = undefined; this.choicePanelBounds = undefined; @@ -3748,6 +4231,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.choicePanelMode = 'visit'; this.choiceSourceNpcId = undefined; this.choiceReadyAt = Number.POSITIVE_INFINITY; + if (persistCheckpoint) { + this.persistExplorationCheckpoint(true); + } this.refreshInteractionPrompt(); } @@ -3799,6 +4285,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { } ], () => this.returnToCamp(), + 'camp-exit', 'camp-exit' ); return; @@ -3823,6 +4310,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { } ], () => this.returnToCamp(), + 'camp-exit', 'camp-exit' ); return; @@ -3840,6 +4328,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { } ], () => this.returnToCamp(), + 'camp-exit', 'camp-exit' ); return; @@ -3858,24 +4347,36 @@ export class CampVisitExplorationScene extends Phaser.Scene { if (!this.sys.isActive()) { return; } - const previousActiveCampVisitId = - getCampaignState().activeCampVisitId; - setActiveCampVisitId(); - void startGameScene(this, 'CampScene').catch((error) => { - console.error('Failed to return from camp exploration.', error); - setActiveCampVisitId( - previousActiveCampVisitId ?? - (this.visitId as CampaignCampVisitId) - ); - this.navigationPending = false; - this.transitionOverlay?.destroy(); - this.transitionOverlay = undefined; - this.showCompletionToast( - '군영 장부로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.', - 'error' - ); - this.refreshInteractionPrompt(); - }); + this.persistExplorationCheckpoint(true); + const previousCampaign = getCampaignState(); + void (async () => { + try { + setActiveCampVisitId(); + await startGameScene(this, 'CampScene'); + return; + } catch (error) { + console.error( + 'Failed to return from camp exploration.', + error + ); + try { + saveCampaignState(previousCampaign); + } catch (rollbackError) { + console.error( + 'Failed to restore the camp exploration save after navigation failure.', + rollbackError + ); + } + this.navigationPending = false; + this.transitionOverlay?.destroy(); + this.transitionOverlay = undefined; + this.showCompletionToast( + '군영 장부로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.', + 'error' + ); + this.refreshInteractionPrompt(); + } + })(); }); } diff --git a/src/game/scenes/CityStayScene.ts b/src/game/scenes/CityStayScene.ts index 72ce99f..2af1fef 100644 --- a/src/game/scenes/CityStayScene.ts +++ b/src/game/scenes/CityStayScene.ts @@ -49,8 +49,13 @@ import { selectCityStayEquipmentForCamp } from '../state/cityStayActions'; import { + campaignInventoryCap, getCampaignState, + saveCampaignState, + setCampaignExplorationCheckpoint, setActiveCityStayId, + type CampaignExplorationCheckpoint, + type CampaignExplorationDialogueKey, type CampaignState } from '../state/campaignState'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; @@ -137,8 +142,9 @@ type CityDialogueLine = { type DialogueState = { lines: CityDialogueLine[]; lineIndex: number; - onComplete?: () => void; + onComplete?: () => boolean | void; sourceTargetId?: string; + dialogueKey: CampaignExplorationDialogueKey; }; type ShopOfferView = { @@ -188,8 +194,10 @@ export class CityStayScene extends Phaser.Scene { private shopPanel?: Phaser.GameObjects.Container; private shopOfferViews: ShopOfferView[] = []; private shopNotice = ''; + private shopSourceNpcId?: string; private choicePanel?: Phaser.GameObjects.Container; private choiceViews: ChoiceView[] = []; + private choiceSourceNpcId?: string; private transitionOverlay?: Phaser.GameObjects.Container; private completionToast?: Phaser.GameObjects.Container; private explorationInput?: ExplorationInputController; @@ -212,6 +220,16 @@ export class CityStayScene extends Phaser.Scene { private visualMotionReduced = false; private ownedPresentationTextureKeys = new Set(); private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay; + private checkpointLastSignature = ''; + private checkpointRestoring = false; + private readonly persistCheckpointOnPageHide = () => { + this.persistExplorationCheckpoint(true); + }; + private readonly persistCheckpointWhenHidden = () => { + if (document.visibilityState === 'hidden') { + this.persistExplorationCheckpoint(true); + } + }; constructor() { super('CityStayScene'); @@ -258,6 +276,14 @@ export class CityStayScene extends Phaser.Scene { create() { this.resetRuntimeState(); this.visualMotionReduced = isVisualMotionReduced(); + window.addEventListener( + 'pagehide', + this.persistCheckpointOnPageHide + ); + document.addEventListener( + 'visibilitychange', + this.persistCheckpointWhenHidden + ); this.markCityStayActive(); this.drawCity(); this.drawHud(); @@ -270,6 +296,7 @@ export class CityStayScene extends Phaser.Scene { this.createDialoguePanel(); this.ready = true; this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.restoreExplorationCheckpoint(); this.createCampaignObjectiveJournal(); this.refreshProgressHud(); this.refreshActorMarkers(); @@ -283,14 +310,25 @@ export class CityStayScene extends Phaser.Scene { }); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.persistExplorationCheckpoint(true); this.ready = false; this.navigationPending = false; + window.removeEventListener( + 'pagehide', + this.persistCheckpointOnPageHide + ); + document.removeEventListener( + 'visibilitychange', + this.persistCheckpointWhenHidden + ); this.clearNavigationTarget(true); this.explorationInput?.destroy(); this.explorationInput = undefined; this.dialogueState = undefined; this.campaignObjectiveJournal?.destroy(); this.campaignObjectiveJournal = undefined; + this.closeChoicePanel(false); + this.closeShop(false); releaseExplorationCharacterTextureKeys( this, this.characterTextureKeys() @@ -358,6 +396,11 @@ export class CityStayScene extends Phaser.Scene { visualMotionReduced: this.visualMotionReduced, viewport: { width: this.scale.width, height: this.scale.height }, activeCityStayId: campaign.activeCityStayId ?? null, + explorationCheckpoint: campaign.explorationCheckpoint + ? JSON.parse( + JSON.stringify(campaign.explorationCheckpoint) + ) + : null, background: { textureKey: this.backgroundImage?.texture.key ?? @@ -469,6 +512,7 @@ export class CityStayScene extends Phaser.Scene { dialogue: this.dialogueState ? { active: true, + dialogueKey: this.dialogueState.dialogueKey, speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '', lineIndex: this.dialogueState.lineIndex, totalLines: this.dialogueState.lines.length, @@ -488,6 +532,7 @@ export class CityStayScene extends Phaser.Scene { } : { active: false, + dialogueKey: null, speaker: null, lineIndex: null, totalLines: 0, @@ -505,6 +550,15 @@ export class CityStayScene extends Phaser.Scene { offers: this.cityStay.equipmentOffers.map((offer) => { const item = getItem(offer.itemId); const view = this.shopOfferViews.find((candidate) => candidate.offerId === offer.id); + const inventoryLabel = itemInventoryLabel(item.id); + const owned = + campaign.inventory[inventoryLabel] ?? 0; + const inventoryCapacity = + campaignInventoryCap(inventoryLabel); + const purchaseCount = + campaign.cityEquipmentPurchaseCounts?.[ + offer.id + ] ?? 0; return { id: offer.id, itemId: item.id, @@ -513,9 +567,14 @@ export class CityStayScene extends Phaser.Scene { bonusText: this.itemBonusText(item), iconKind: item.slot, price: offer.price, - owned: campaign.inventory[itemInventoryLabel(item.id)] ?? 0, - purchaseCount: campaign.cityEquipmentPurchaseCounts?.[offer.id] ?? 0, - canBuy: campaign.gold >= offer.price, + owned, + inventoryCapacity, + inventoryFull: owned >= inventoryCapacity, + purchaseCount, + canBuy: + campaign.gold >= offer.price && + owned < inventoryCapacity && + purchaseCount < 99, interactive: Boolean(view?.interactive), buttonBounds: view ? this.boundsSnapshot(view.button.getBounds()) : null, equipCtaBounds: view?.equipButton @@ -601,8 +660,10 @@ export class CityStayScene extends Phaser.Scene { this.shopPanel = undefined; this.shopOfferViews = []; this.shopNotice = ''; + this.shopSourceNpcId = undefined; this.choicePanel = undefined; this.choiceViews = []; + this.choiceSourceNpcId = undefined; this.moveTarget = undefined; this.moveWaypoints = []; this.moveTargetId = undefined; @@ -620,12 +681,304 @@ export class CityStayScene extends Phaser.Scene { this.stepIndex = 0; this.lastStepAt = 0; this.lastNotice = ''; + this.checkpointLastSignature = ''; + this.checkpointRestoring = false; } private markCityStayActive() { this.campaign = setActiveCityStayId(this.cityStay.id); } + private persistExplorationCheckpoint(force = false) { + if ( + !this.ready || + this.checkpointRestoring || + !this.player + ) { + return; + } + const campaign = getCampaignState(); + if (campaign.activeCityStayId !== this.cityStay.id) { + return; + } + const shopSourceNpcId = + this.shopSourceNpcId ?? + this.profile.actors.find( + (actor) => actor.kind === 'market' + )?.id; + const overlay: CampaignExplorationCheckpoint['overlay'] = + this.dialogueState + ? { + type: 'dialogue', + dialogueKey: this.dialogueState.dialogueKey, + ...(this.dialogueState.sourceTargetId + ? { + sourceNpcId: + this.dialogueState.sourceTargetId + } + : {}), + lineIndex: this.dialogueState.lineIndex + } + : this.choicePanel + ? { + type: 'choice', + mode: 'city-resonance', + ...(this.choiceSourceNpcId + ? { sourceNpcId: this.choiceSourceNpcId } + : {}) + } + : this.shopPanel && shopSourceNpcId + ? { + type: 'shop', + sourceNpcId: shopSourceNpcId + } + : undefined; + const checkpoint: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > = { + version: 1, + scene: 'city-stay', + contextId: this.cityStay.id, + player: { + x: this.player.x, + y: this.player.y, + direction: this.playerDirection + }, + ...(overlay ? { overlay } : {}) + }; + const signature = JSON.stringify(checkpoint); + if (!force && signature === this.checkpointLastSignature) { + return; + } + try { + setCampaignExplorationCheckpoint(checkpoint); + this.checkpointLastSignature = signature; + } catch (error) { + console.error( + 'Failed to save the city stay checkpoint.', + error + ); + } + } + + private explorationDialogueCheckpoint( + dialogueKey: CampaignExplorationDialogueKey, + sourceNpcId?: string + ) { + if (!this.player) { + return undefined; + } + return { + version: 1, + scene: 'city-stay', + contextId: this.cityStay.id, + player: { + x: this.player.x, + y: this.player.y, + direction: this.playerDirection + }, + overlay: { + type: 'dialogue', + dialogueKey, + ...(sourceNpcId ? { sourceNpcId } : {}), + lineIndex: 0 + } + } satisfies Omit< + CampaignExplorationCheckpoint, + 'savedAt' + >; + } + + private explorationPositionCheckpoint() { + if (!this.player) { + return undefined; + } + return { + version: 1, + scene: 'city-stay', + contextId: this.cityStay.id, + player: { + x: this.player.x, + y: this.player.y, + direction: this.playerDirection + } + } satisfies Omit< + CampaignExplorationCheckpoint, + 'savedAt' + >; + } + + private restoreExplorationCheckpoint() { + const checkpoint = getCampaignState().explorationCheckpoint; + if ( + !checkpoint || + checkpoint.scene !== 'city-stay' || + checkpoint.contextId !== this.cityStay.id || + !this.player + ) { + this.persistExplorationCheckpoint(true); + return { + positionRestored: false, + overlayRestored: false + }; + } + + this.checkpointRestoring = true; + let positionRestored = false; + let overlayRestored = false; + try { + if ( + this.canPlayerOccupy( + checkpoint.player.x, + checkpoint.player.y + ) + ) { + this.player.setPosition( + checkpoint.player.x, + checkpoint.player.y + ); + this.playerDirection = checkpoint.player.direction; + this.syncPlayerView(); + this.setPlayerMoving(false); + positionRestored = true; + } + overlayRestored = checkpoint.overlay + ? this.restoreCheckpointOverlay(checkpoint.overlay) + : false; + } finally { + this.checkpointRestoring = false; + } + this.persistExplorationCheckpoint(true); + return { positionRestored, overlayRestored }; + } + + private restoreCheckpointOverlay( + overlay: CampaignExplorationCheckpoint['overlay'] + ) { + if (!overlay) { + return false; + } + if (overlay.type === 'shop') { + const view = this.actors.get(overlay.sourceNpcId); + if (!view || view.definition.kind !== 'market') { + return false; + } + this.faceCharactersForConversation(view); + this.openShop(view.definition.id); + return Boolean(this.shopPanel); + } + if (overlay.type === 'choice') { + if ( + overlay.mode !== 'city-resonance' || + this.dialogueComplete() + ) { + return false; + } + const sourceNpcId = + overlay.sourceNpcId ?? this.companionActor().id; + const view = this.actors.get(sourceNpcId); + if (view) { + this.faceCharactersForConversation(view); + } + this.openChoicePanel(sourceNpcId); + return Boolean(this.choicePanel); + } + + if (overlay.dialogueKey === 'city-interaction') { + if (!overlay.sourceNpcId) { + return false; + } + const target = this.interactionTargetByIdOrKind( + overlay.sourceNpcId + ); + if (!target || target.kind === 'exit') { + return false; + } + this.interactWithTarget(target); + this.restoreDialogueLine( + overlay.dialogueKey, + overlay.lineIndex + ); + } else if (overlay.dialogueKey === 'city-exit') { + this.interactWithExit(); + this.restoreDialogueLine( + overlay.dialogueKey, + overlay.lineIndex + ); + } else if ( + overlay.dialogueKey === 'city-resonance-response' + ) { + this.startResonanceResponseDialogue( + overlay.sourceNpcId, + overlay.lineIndex + ); + } else { + return false; + } + return Boolean( + this.dialogueState?.dialogueKey === + overlay.dialogueKey + ); + } + + private restoreDialogueLine( + dialogueKey: CampaignExplorationDialogueKey, + lineIndex: number + ) { + const dialogue = this.dialogueState; + if (!dialogue || dialogue.dialogueKey !== dialogueKey) { + return; + } + dialogue.lineIndex = Phaser.Math.Clamp( + Math.floor(lineIndex), + 0, + dialogue.lines.length - 1 + ); + this.renderDialogueLine(); + } + + private startResonanceResponseDialogue( + sourceNpcId?: string, + initialLineIndex = 0 + ) { + const campaign = getCampaignState(); + const dialogue = this.cityStay.dialogue; + const choiceId = + campaign.campDialogueChoiceIds[dialogue.id]; + const choice = dialogue.choices.find( + (candidate) => candidate.id === choiceId + ); + const companion = this.companionActor(); + if (!choice) { + return; + } + const companionView = this.actors.get( + sourceNpcId ?? companion.id + ); + if (companionView) { + this.faceCharactersForConversation(companionView); + } + this.startDialogue( + [ + { + speaker: companion.name, + portraitKey: this.actorPortraitKey(companion), + text: choice.response + }, + { + speaker: '공명', + text: `${this.cityDialogueUnitNames()} · 공명 +${dialogue.rewardExp + choice.rewardExp}` + } + ], + () => + this.showCompletionToast('동료 공명 대화 완료'), + sourceNpcId ?? companion.id, + 'city-resonance-response', + initialLineIndex + ); + } + private currentBackground() { return cityBackgrounds[this.cityStay.id]; } @@ -1910,6 +2263,7 @@ export class CityStayScene extends Phaser.Scene { if (!this.player) { return; } + const wasMoving = this.playerMoving; applyExplorationCharacterMotion( this.player, playerTextureKey, @@ -1918,6 +2272,13 @@ export class CityStayScene extends Phaser.Scene { this.visualMotionReduced ); this.playerMoving = moving; + if ( + wasMoving && + !moving && + !this.checkpointRestoring + ) { + this.persistExplorationCheckpoint(true); + } } private stopPlayerMovement() { @@ -1942,6 +2303,9 @@ export class CityStayScene extends Phaser.Scene { this.player.setPosition(x, y); this.syncPlayerView(); this.stopPlayerMovement(); + if (!this.checkpointRestoring) { + this.persistExplorationCheckpoint(true); + } } private safeApproachPosition(targetX: number, targetY: number) { @@ -1982,7 +2346,7 @@ export class CityStayScene extends Phaser.Scene { return; } if (target.kind === 'market') { - this.openShop(); + this.openShop(view.definition.id); return; } this.interactWithCompanion(view); @@ -2029,7 +2393,8 @@ export class CityStayScene extends Phaser.Scene { private completeInformation() { const result = collectCityStayInformation( this.cityStay.id, - this.cityStay.information.id + this.cityStay.information.id, + this.explorationPositionCheckpoint() ); if (!result.ok) { const message = result.reason === 'already-completed' @@ -2037,7 +2402,7 @@ export class CityStayScene extends Phaser.Scene { : '정보를 장부에 기록하지 못했습니다. 저장 상태를 확인해 주세요.'; this.showCompletionToast(message, false); this.refreshProgressHud(); - return; + return false; } this.campaign = result.campaign; this.lastNotice = `정보 확보 · ${result.information.title}`; @@ -2045,6 +2410,7 @@ export class CityStayScene extends Phaser.Scene { this.showCompletionToast(`정보 확보 · ${this.cityStay.information.title}`); this.refreshProgressHud(); this.refreshActorMarkers(); + return true; } private interactWithCompanion(view: CityActorView) { @@ -2085,7 +2451,9 @@ export class CityStayScene extends Phaser.Scene { }; } - private openChoicePanel() { + private openChoicePanel( + sourceNpcId = this.companionActor().id + ) { if (this.choicePanel || this.navigationPending) { return; } @@ -2167,6 +2535,8 @@ export class CityStayScene extends Phaser.Scene { }).setOrigin(1, 0.5); objects.push(cancel); this.choicePanel = this.add.container(0, 0, objects).setDepth(3200); + this.choiceSourceNpcId = sourceNpcId; + this.persistExplorationCheckpoint(true); } private chooseDialogueByIndex(index: number) { @@ -2191,7 +2561,16 @@ export class CityStayScene extends Phaser.Scene { ) { return; } - const result = chooseCityStayResonance(this.cityStay.id, choice.id); + const sourceNpcId = + this.choiceSourceNpcId ?? this.companionActor().id; + const result = chooseCityStayResonance( + this.cityStay.id, + choice.id, + this.explorationDialogueCheckpoint( + 'city-resonance-response', + sourceNpcId + ) + ); if (!result.ok) { this.closeChoicePanel(); const message = result.reason === 'already-completed' @@ -2205,40 +2584,32 @@ export class CityStayScene extends Phaser.Scene { } this.campaign = result.campaign; this.lastNotice = `공명 +${result.rewardExp} · ${result.choice.label}`; - this.closeChoicePanel(); + this.closeChoicePanel(false); soundDirector.playEffect('bond-resonance', { volume: 0.28, stopAfterMs: 1100 }); this.refreshProgressHud(); this.refreshActorMarkers(); - const companion = this.companionActor(); - const companionView = this.actors.get(companion.id); - if (companionView) { - this.faceCharactersForConversation(companionView); - } - this.startDialogue([ - { - speaker: companion.name, - portraitKey: this.actorPortraitKey( - companion - ), - text: choice.response - }, - { - speaker: '공명', - text: `${this.cityDialogueUnitNames()} · 공명 +${this.cityStay.dialogue.rewardExp + choice.rewardExp}` - } - ], () => this.showCompletionToast('동료 공명 대화 완료'), companion.id); + this.startResonanceResponseDialogue(sourceNpcId); this.autoDialogueInputReadyAt = this.time.now + inputCarryoverGuardMs; } - private closeChoicePanel() { + private closeChoicePanel(persistCheckpoint = true) { + this.restoreActorDirection(this.choiceSourceNpcId); this.choicePanel?.destroy(); this.choicePanel = undefined; this.choiceViews = []; + this.choiceSourceNpcId = undefined; + if (persistCheckpoint) { + this.persistExplorationCheckpoint(true); + } this.refreshInteractionPrompt(); } - private openShop() { + private openShop( + sourceNpcId = this.profile.actors.find( + (actor) => actor.kind === 'market' + )?.id + ) { if (this.shopPanel || this.navigationPending) { return; } @@ -2246,6 +2617,7 @@ export class CityStayScene extends Phaser.Scene { this.completionToast?.destroy(); this.completionToast = undefined; this.shopNotice = ''; + this.shopSourceNpcId = sourceNpcId; soundDirector.playEffect('cart-roll', { volume: 0.14, rate: 0.96, @@ -2253,6 +2625,7 @@ export class CityStayScene extends Phaser.Scene { }); this.renderShopPanel(); this.refreshInteractionPrompt(); + this.persistExplorationCheckpoint(true); } private renderShopPanel() { @@ -2309,9 +2682,17 @@ export class CityStayScene extends Phaser.Scene { this.cityStay.equipmentOffers.forEach((offer, index) => { const item = getItem(offer.itemId); - const owned = campaign.inventory[itemInventoryLabel(item.id)] ?? 0; + const inventoryLabel = itemInventoryLabel(item.id); + const owned = campaign.inventory[inventoryLabel] ?? 0; + const inventoryCapacity = + campaignInventoryCap(inventoryLabel); const purchaseCount = campaign.cityEquipmentPurchaseCounts?.[offer.id] ?? 0; - const canBuy = campaign.gold >= offer.price; + const inventoryFull = owned >= inventoryCapacity; + const purchaseLimitReached = purchaseCount >= 99; + const canBuy = + campaign.gold >= offer.price && + !inventoryFull && + !purchaseLimitReached; const rowX = 230; const rowY = 300 + index * 180; const purchasedHere = purchaseCount > 0; @@ -2389,7 +2770,15 @@ export class CityStayScene extends Phaser.Scene { const buttonLabel = this.add.text( purchaseButtonX, rowY + 73, - canBuy ? (purchasedHere ? '추가 구매' : '구매') : '군자금 부족', + canBuy + ? purchasedHere + ? '추가 구매' + : '구매' + : inventoryFull + ? '보관 한도' + : purchaseLimitReached + ? '구매 한도' + : '군자금 부족', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: canBuy ? (purchasedHere ? '18px' : '21px') : '16px', @@ -2398,10 +2787,15 @@ export class CityStayScene extends Phaser.Scene { } ).setOrigin(0.5); if (canBuy) { + const purchaseIntentId = + this.createPurchaseIntentId(offer.id); button.setInteractive({ useHandCursor: true }); button.on('pointerover', () => button.setFillStyle(0x654c25, 0.99)); button.on('pointerout', () => button.setFillStyle(0x4a371d, 0.99)); - button.on('pointerdown', () => this.buyOffer(offer)); + button.on( + 'pointerdown', + () => this.buyOffer(offer, purchaseIntentId) + ); } let equipButton: Phaser.GameObjects.Rectangle | undefined; let equipButtonLabel: Phaser.GameObjects.Text | undefined; @@ -2464,12 +2858,29 @@ export class CityStayScene extends Phaser.Scene { this.shopPanel = this.add.container(0, 0, objects).setDepth(3100); } - private buyOffer(offer: CityEquipmentOffer) { - const result = purchaseCityStayEquipment(this.cityStay.id, offer.id); + private buyOffer( + offer: CityEquipmentOffer, + purchaseIntentId: string + ) { + const result = purchaseCityStayEquipment( + this.cityStay.id, + offer.id, + purchaseIntentId + ); const message = result.ok - ? `${result.item.name} 구입 · 군자금 -${result.offer.price}` + ? result.replayed + ? `${result.item.name} 구매 기록을 확인했습니다.` + : `${result.item.name} 구입 · 군자금 -${result.offer.price}` : result.reason === 'insufficient-gold' ? '군자금이 부족합니다.' + : result.reason === 'inventory-full' + ? '이 장비는 더 보관할 수 없습니다.' + : result.reason === 'purchase-limit-reached' + ? '이 도시에서 허용된 구매 횟수에 도달했습니다.' + : result.reason === 'intent-conflict' + ? '이전 구매 요청과 충돌했습니다. 다시 선택해 주세요.' + : result.reason === 'invalid-purchase-intent' + ? '구매 요청을 확인할 수 없습니다. 다시 선택해 주세요.' : result.reason === 'restricted-item' ? '보물 장비는 일반 시장에서 거래할 수 없습니다.' : '현재 시장에서는 살 수 없는 장비입니다.'; @@ -2477,12 +2888,32 @@ export class CityStayScene extends Phaser.Scene { this.lastNotice = message; if (result.ok) { this.campaign = result.campaign; - soundDirector.playEffect('reward-reveal', { volume: 0.22, stopAfterMs: 720 }); + if (!result.replayed) { + soundDirector.playEffect('reward-reveal', { + volume: 0.22, + stopAfterMs: 720 + }); + } } else { soundDirector.playEffect('objective-failure', { volume: 0.16, stopAfterMs: 620 }); } this.renderShopPanel(); this.refreshProgressHud(); + this.persistExplorationCheckpoint(true); + } + + private createPurchaseIntentId(offerId: string) { + const randomPart = + globalThis.crypto?.randomUUID?.() ?? + `${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 12)}`; + return [ + 'city-purchase', + this.cityStay.id, + offerId, + randomPart + ].join(':'); } private renderShopEquipmentIcon( @@ -2524,11 +2955,16 @@ export class CityStayScene extends Phaser.Scene { return [graphics]; } - private closeShop() { + private closeShop(persistCheckpoint = true) { + this.restoreActorDirection(this.shopSourceNpcId); this.shopPanel?.destroy(); this.shopPanel = undefined; this.shopOfferViews = []; this.shopNotice = ''; + this.shopSourceNpcId = undefined; + if (persistCheckpoint) { + this.persistExplorationCheckpoint(true); + } this.refreshInteractionPrompt(); } @@ -2561,7 +2997,7 @@ export class CityStayScene extends Phaser.Scene { speaker: '길잡이', text: '군영으로 돌아갑니다.' } - ], () => this.returnToCamp(), 'camp-exit'); + ], () => this.returnToCamp(), 'camp-exit', 'city-exit'); return; } this.returnToCamp(); @@ -2601,19 +3037,38 @@ export class CityStayScene extends Phaser.Scene { } this.navigationPending = true; this.stopPlayerMovement(); - const previousActiveCityStayId = getCampaignState().activeCityStayId; - this.campaign = setActiveCityStayId(); + this.persistExplorationCheckpoint(true); + const previousCampaign = getCampaignState(); this.showTransitionOverlay(); - void startGameScene(this, 'CampScene', data).catch((error) => { - console.error('Failed to return from the city stay.', error); - this.campaign = setActiveCityStayId(previousActiveCityStayId ?? this.cityStay.id); - this.navigationPending = false; - this.transitionOverlay?.destroy(); - this.transitionOverlay = undefined; - this.showCompletionToast('군영으로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.', false); - this.refreshInteractionPrompt(); - }); + void (async () => { + try { + this.campaign = setActiveCityStayId(); + await startGameScene(this, 'CampScene', data); + return; + } catch (error) { + console.error( + 'Failed to return from the city stay.', + error + ); + try { + this.campaign = saveCampaignState(previousCampaign); + } catch (rollbackError) { + console.error( + 'Failed to restore the city stay save after navigation failure.', + rollbackError + ); + } + this.navigationPending = false; + this.transitionOverlay?.destroy(); + this.transitionOverlay = undefined; + this.showCompletionToast( + '군영으로 돌아가지 못했습니다. 잠시 후 다시 시도하세요.', + false + ); + this.refreshInteractionPrompt(); + } + })(); } private showTransitionOverlay() { @@ -2646,19 +3101,33 @@ export class CityStayScene extends Phaser.Scene { private startDialogue( lines: CityDialogueLine[], - onComplete?: () => void, - sourceTargetId?: string + onComplete?: () => boolean | void, + sourceTargetId?: string, + dialogueKey: CampaignExplorationDialogueKey = + 'city-interaction', + initialLineIndex = 0 ) { if (!lines.length || this.navigationPending || this.shopPanel || this.choicePanel) { return; } this.stopPlayerMovement(); - this.dialogueState = { lines, lineIndex: 0, onComplete, sourceTargetId }; + this.dialogueState = { + lines, + lineIndex: Phaser.Math.Clamp( + Math.floor(initialLineIndex), + 0, + lines.length - 1 + ), + onComplete, + sourceTargetId, + dialogueKey + }; this.dialoguePanel?.setVisible(true); this.promptBackground?.setVisible(false); this.promptText?.setVisible(false); soundDirector.playSelect(); this.renderDialogueLine(); + this.persistExplorationCheckpoint(true); } private renderDialogueLine() { @@ -2728,6 +3197,7 @@ export class CityStayScene extends Phaser.Scene { dialogue.lineIndex += 1; soundDirector.playStoryAdvanceCue(); this.renderDialogueLine(); + this.persistExplorationCheckpoint(true); return; } @@ -2737,7 +3207,10 @@ export class CityStayScene extends Phaser.Scene { this.dialoguePanel?.setVisible(false); this.stopPlayerMovement(); this.restoreActorDirection(sourceTargetId); - onComplete?.(); + const checkpointCommitted = onComplete?.() === true; + if (!checkpointCommitted) { + this.persistExplorationCheckpoint(true); + } this.refreshInteractionPrompt(); } @@ -2747,6 +3220,7 @@ export class CityStayScene extends Phaser.Scene { this.dialoguePanel?.setVisible(false); this.stopPlayerMovement(); this.restoreActorDirection(sourceTargetId); + this.persistExplorationCheckpoint(true); this.refreshInteractionPrompt(); } diff --git a/src/game/scenes/PrologueMilitiaCampScene.ts b/src/game/scenes/PrologueMilitiaCampScene.ts index e556569..1ab24a3 100644 --- a/src/game/scenes/PrologueMilitiaCampScene.ts +++ b/src/game/scenes/PrologueMilitiaCampScene.ts @@ -38,7 +38,11 @@ import { hasCompletedCampaignTutorial, markCampaignStep, prologueMilitiaCampCampaignTutorialIds, + saveCampaignState, + setCampaignExplorationCheckpoint, setCampaignSortieOrderSelection, + type CampaignExplorationCheckpoint, + type CampaignExplorationDialogueKey, type CampaignTutorialId } from '../state/campaignState'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; @@ -76,6 +80,18 @@ const campDialoguePortraitKeys = [ 'zouJing', 'zhuoYoungVolunteer' ] as const; +const commandPartyPositions = { + guanYu: { + x: 650, + y: 420, + direction: 'north' as const + }, + zhangFei: { + x: 890, + y: 420, + direction: 'north' as const + } +}; const playerTextureKey = explorationCharacterTextureKeyByUnitTextureKey['unit-liu-bei']; const campNpcTextureKeyById = { quartermaster: 'exploration-zhuo-quartermaster', @@ -126,8 +142,9 @@ type CampNpcView = { type DialogueState = { lines: PrologueVillageDialogueLine[]; lineIndex: number; - onComplete?: () => void; + onComplete?: () => boolean | void; sourceNpcId?: string; + dialogueKey: CampaignExplorationDialogueKey; }; type ObjectiveRowView = { @@ -231,6 +248,16 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { private visualMotionReduced = false; private ownedPresentationTextureKeys = new Set(); private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay; + private checkpointLastSignature = ''; + private checkpointRestoring = false; + private readonly persistCheckpointOnPageHide = () => { + this.persistExplorationCheckpoint(true); + }; + private readonly persistCheckpointWhenHidden = () => { + if (document.visibilityState === 'hidden') { + this.persistExplorationCheckpoint(true); + } + }; constructor() { super('PrologueMilitiaCampScene'); @@ -262,6 +289,14 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.resetRuntimeState(); this.visualMotionReduced = isVisualMotionReduced(); this.prepareCampaignProgress(); + window.addEventListener( + 'pagehide', + this.persistCheckpointOnPageHide + ); + document.addEventListener( + 'visibilitychange', + this.persistCheckpointWhenHidden + ); this.drawCamp(); this.drawHud(); this.createLoadingOverlay(); @@ -275,13 +310,22 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { }); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.persistExplorationCheckpoint(true); this.ready = false; this.navigationPending = false; + window.removeEventListener( + 'pagehide', + this.persistCheckpointOnPageHide + ); + document.removeEventListener( + 'visibilitychange', + this.persistCheckpointWhenHidden + ); this.clearNavigationTarget(true); this.dialogueState = undefined; this.campaignObjectiveJournal?.destroy(); this.campaignObjectiveJournal = undefined; - this.closeCommandChoice(); + this.closeCommandChoice(false); this.stopNpcMovement(); releaseExplorationCharacterTextureKeys(this, characterTextureKeys); this.releasePresentationTextures(); @@ -295,21 +339,18 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.loadingOverlay = undefined; this.ready = true; this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + const restoredCheckpoint = + this.restoreExplorationCheckpoint(); this.createCampaignObjectiveJournal(); this.refreshObjectiveHud(); this.refreshNpcMarkers(); this.refreshInteractionPrompt(); - if (this.firstVisit) { - this.startDialogue([ - { - speaker: '유비', - textureKey: 'unit-liu-bei', - text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 모두가 살아 돌아올 준비를 갖추자.' - } - ], () => { - completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.entered); - }); + if ( + this.firstVisit && + !restoredCheckpoint.overlayRestored + ) { + this.startCampIntroDialogue(); } } @@ -369,6 +410,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { visualMotionReduced: this.visualMotionReduced, viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, + explorationCheckpoint: + campaign.explorationCheckpoint ?? null, requiredTextures: characterTextureKeys.map((key) => ({ key, ready: this.textures.exists(key) @@ -503,6 +546,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '', lineIndex: this.dialogueState.lineIndex, totalLines: this.dialogueState.lines.length, + dialogueKey: this.dialogueState.dialogueKey, sourceNpcId: this.dialogueState.sourceNpcId ?? null, portraitTextureKey: this.dialoguePortrait?.visible ? this.dialoguePortrait.texture.key @@ -516,6 +560,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { speaker: null, lineIndex: -1, totalLines: 0, + dialogueKey: null, sourceNpcId: null, portraitTextureKey: null, portraitVisible: false, @@ -663,6 +708,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.interactionQueued = false; this.stepIndex = 0; this.lastStepAt = 0; + this.checkpointLastSignature = ''; + this.checkpointRestoring = false; } private prepareCampaignProgress() { @@ -675,6 +722,305 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { } } + private persistExplorationCheckpoint(force = false) { + if ( + !this.ready || + this.navigationPending || + this.checkpointRestoring || + !this.player + ) { + return; + } + const campaign = getCampaignState(); + if ( + campaign.step !== 'prologue-camp' || + campaign.completedTutorialIds.includes( + prologueMilitiaCampCampaignTutorialIds.complete + ) + ) { + return; + } + const overlay: CampaignExplorationCheckpoint['overlay'] = + this.dialogueState + ? { + type: 'dialogue', + dialogueKey: this.dialogueState.dialogueKey, + ...(this.dialogueState.sourceNpcId + ? { + sourceNpcId: + this.dialogueState.sourceNpcId + } + : {}), + lineIndex: this.dialogueState.lineIndex + } + : this.commandChoicePanel + ? { + type: 'choice', + mode: 'prologue-command', + sourceNpcId: 'zou-jing', + ...(this.pendingCommandOrderId + ? { + choiceId: + this.pendingCommandOrderId + } + : {}) + } + : undefined; + const checkpoint: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > = { + version: 1, + scene: 'prologue-militia-camp', + contextId: 'prologue-militia-camp', + player: { + x: this.player.x, + y: this.player.y, + direction: this.playerDirection + }, + ...(overlay ? { overlay } : {}) + }; + const signature = JSON.stringify(checkpoint); + if ( + !force && + signature === this.checkpointLastSignature + ) { + return; + } + try { + setCampaignExplorationCheckpoint(checkpoint); + this.checkpointLastSignature = signature; + } catch (error) { + console.error( + 'Failed to save the prologue militia camp checkpoint.', + error + ); + } + } + + private explorationPositionCheckpoint() { + if (!this.player) { + return undefined; + } + return { + version: 1, + scene: 'prologue-militia-camp', + contextId: 'prologue-militia-camp', + player: { + x: this.player.x, + y: this.player.y, + direction: this.playerDirection + } + } satisfies Omit< + CampaignExplorationCheckpoint, + 'savedAt' + >; + } + + private restoreExplorationCheckpoint() { + const checkpoint = + getCampaignState().explorationCheckpoint; + if ( + !checkpoint || + checkpoint.scene !== + 'prologue-militia-camp' || + checkpoint.contextId !== + 'prologue-militia-camp' || + !this.player + ) { + this.persistExplorationCheckpoint(true); + return { + positionRestored: false, + overlayRestored: false + }; + } + + this.checkpointRestoring = true; + let positionRestored = false; + let overlayRestored = false; + try { + if ( + this.canPlayerOccupy( + checkpoint.player.x, + checkpoint.player.y + ) + ) { + this.player.setPosition( + checkpoint.player.x, + checkpoint.player.y + ); + this.playerDirection = checkpoint.player.direction; + this.syncPlayerView(); + this.setPlayerMoving(false); + positionRestored = true; + } + overlayRestored = checkpoint.overlay + ? this.restoreCheckpointOverlay(checkpoint.overlay) + : false; + } finally { + this.checkpointRestoring = false; + } + this.persistExplorationCheckpoint(true); + return { positionRestored, overlayRestored }; + } + + private restoreCheckpointOverlay( + overlay: CampaignExplorationCheckpoint['overlay'] + ) { + if (!overlay) { + return false; + } + if (overlay.type === 'choice') { + if ( + overlay.mode !== 'prologue-command' || + !this.allRequiredObjectivesComplete() + ) { + return false; + } + this.restoreCommandPartyPositions(); + this.openFirstCommandChoice(); + const pendingChoice = + overlay.choiceId + ? prologueMilitiaCampCommandChoices.find( + (choice) => + choice.orderId === overlay.choiceId + ) + : undefined; + if (pendingChoice) { + this.previewFirstCommand( + pendingChoice.orderId, + true + ); + } + return Boolean(this.commandChoicePanel); + } + if (overlay.type !== 'dialogue') { + return false; + } + if ( + overlay.dialogueKey === + 'prologue-camp-command-response' + ) { + const selection = + getCampaignState().sortieOrderSelection; + const choice = + selection?.battleId === + 'first-battle-zhuo-commandery' + ? prologueMilitiaCampCommandChoices.find( + (entry) => + entry.orderId === selection.orderId + ) + : undefined; + if (!choice) { + return false; + } + this.restoreCommandPartyPositions(); + this.startCommandResponseDialogue( + choice, + overlay.lineIndex + ); + } else if ( + overlay.dialogueKey === + 'prologue-camp-departure' + ) { + if (!this.allRequiredObjectivesComplete()) { + return false; + } + this.restoreCommandPartyPositions(); + const commander = this.npcViews.get('zou-jing'); + if (!commander) { + return false; + } + this.startDepartureDialogue( + commander, + overlay.lineIndex + ); + } else if ( + overlay.dialogueKey === + 'prologue-camp-interaction' + ) { + if (overlay.sourceNpcId === 'camp-intro') { + if (!this.firstVisit) { + return false; + } + this.startCampIntroDialogue(overlay.lineIndex); + } else if (overlay.sourceNpcId) { + const view = this.npcViews.get( + overlay.sourceNpcId + ); + if ( + !view || + (view.definition.departure && + this.allRequiredObjectivesComplete()) + ) { + return false; + } + this.interactWithNpc(view); + this.restoreDialogueLine( + overlay.dialogueKey, + overlay.lineIndex + ); + } else { + return false; + } + } else { + return false; + } + return Boolean( + this.dialogueState?.dialogueKey === + overlay.dialogueKey + ); + } + + private restoreDialogueLine( + dialogueKey: CampaignExplorationDialogueKey, + lineIndex: number + ) { + const dialogue = this.dialogueState; + if (!dialogue || dialogue.dialogueKey !== dialogueKey) { + return; + } + dialogue.lineIndex = Phaser.Math.Clamp( + Math.floor(lineIndex), + 0, + dialogue.lines.length - 1 + ); + this.renderDialogueLine(); + } + + private startCampIntroDialogue(initialLineIndex = 0) { + this.startDialogue( + [ + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 모두가 살아 돌아올 준비를 갖추자.' + } + ], + () => { + try { + completeCampaignTutorial( + prologueMilitiaCampCampaignTutorialIds.entered, + { + explorationCheckpoint: + this.explorationPositionCheckpoint() + } + ); + return true; + } catch (error) { + console.error( + 'Failed to save the prologue militia camp introduction.', + error + ); + return false; + } + }, + 'camp-intro', + 'prologue-camp-interaction', + initialLineIndex + ); + } + private drawCamp() { this.cameras.main.setBackgroundColor('#1a241e'); if (this.textures.exists(campBackgroundKey)) { @@ -1984,6 +2330,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { if (!this.player) { return; } + const wasMoving = this.playerMoving; applyExplorationCharacterMotion( this.player, playerTextureKey, @@ -1992,6 +2339,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.visualMotionReduced ); this.playerMoving = moving; + if (wasMoving && !moving) { + this.persistExplorationCheckpoint(); + } } private stopPlayerMovement() { @@ -2015,6 +2365,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.player.setPosition(x, y); this.syncPlayerView(); this.stopPlayerMovement(); + this.persistExplorationCheckpoint(true); } private safeApproachPosition(targetX: number, targetY: number) { @@ -2048,11 +2399,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { return; } this.gatherCommandParty(() => { - this.startDialogue( - view.definition.dialogue, - () => this.openFirstCommandChoice(), - view.definition.id - ); + this.startDepartureDialogue(view); }); return; } @@ -2068,30 +2415,75 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { : view.definition.dialogue; this.startDialogue(dialogue, () => { if (view.definition.objectiveId && !completed) { - this.completeObjective(view.definition.objectiveId); + return this.completeObjective( + view.definition.objectiveId + ); } if (optionalTutorialId && !completed) { - completeCampaignTutorial(optionalTutorialId); - this.showCompletionToast('젊은 의병이 약속을 기억합니다.'); + try { + completeCampaignTutorial(optionalTutorialId, { + explorationCheckpoint: + this.explorationPositionCheckpoint() + }); + this.showCompletionToast( + '젊은 의병이 약속을 기억합니다.' + ); + return true; + } catch (error) { + console.error( + 'Failed to save the optional militia camp dialogue.', + error + ); + return false; + } } + return false; }, view.definition.id); } + private startDepartureDialogue( + view: CampNpcView, + initialLineIndex = 0 + ) { + this.faceCharactersForConversation(view); + this.startDialogue( + view.definition.dialogue, + () => this.openFirstCommandChoice(), + view.definition.id, + 'prologue-camp-departure', + initialLineIndex + ); + } + private startDialogue( lines: PrologueVillageDialogueLine[], - onComplete?: () => void, - sourceNpcId?: string + onComplete?: () => boolean | void, + sourceNpcId?: string, + dialogueKey: CampaignExplorationDialogueKey = + 'prologue-camp-interaction', + initialLineIndex = 0 ) { if (!lines.length || this.navigationPending) { return; } this.stopPlayerMovement(); - this.dialogueState = { lines, lineIndex: 0, onComplete, sourceNpcId }; + this.dialogueState = { + lines, + lineIndex: Phaser.Math.Clamp( + Math.floor(initialLineIndex), + 0, + lines.length - 1 + ), + onComplete, + sourceNpcId, + dialogueKey + }; this.dialoguePanel?.setVisible(true); this.promptBackground?.setVisible(false); this.promptText?.setVisible(false); soundDirector.playSelect(); this.renderDialogueLine(); + this.persistExplorationCheckpoint(true); } private renderDialogueLine() { @@ -2151,27 +2543,46 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { dialogue.lineIndex += 1; soundDirector.playStoryAdvanceCue(); this.renderDialogueLine(); + this.persistExplorationCheckpoint(true); return; } const onComplete = dialogue.onComplete; this.dialogueState = undefined; this.dialoguePanel?.setVisible(false); this.stopPlayerMovement(); - onComplete?.(); + const checkpointCommitted = onComplete?.() === true; + if (!checkpointCommitted) { + this.persistExplorationCheckpoint(true); + } this.refreshInteractionPrompt(); } private completeObjective(objectiveId: PrologueMilitiaCampRequiredObjectiveId) { if (this.isObjectiveComplete(objectiveId)) { - return; + return false; + } + try { + completeCampaignTutorial( + objectiveTutorialIds[objectiveId], + { + explorationCheckpoint: + this.explorationPositionCheckpoint() + } + ); + } catch (error) { + console.error( + 'Failed to save the prologue militia camp objective.', + error + ); + return false; } - completeCampaignTutorial(objectiveTutorialIds[objectiveId]); this.syncPreparationFeedback(objectiveId); soundDirector.playObjectiveAchieved(); this.refreshObjectiveHud(); this.refreshNpcMarkers(); const objective = prologueMilitiaCampObjectiveDefinitions.find((entry) => entry.id === objectiveId); this.showCompletionToast(`${objective?.shortLabel ?? '점검'} 완료`); + return true; } private openFirstCommandChoice() { @@ -2418,6 +2829,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.commandChoiceReadyAt = this.time.now + 260; this.refreshFirstCommandChoice(); soundDirector.playSelect(); + this.persistExplorationCheckpoint(true); } private chooseFirstCommandByIndex(index: number) { @@ -2444,6 +2856,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.pendingCommandOrderId = choice.orderId; this.refreshFirstCommandChoice(); soundDirector.playSelect(); + this.persistExplorationCheckpoint(true); return true; } @@ -2485,14 +2898,23 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { setCampaignSortieOrderSelection('first-battle-zhuo-commandery', choice.orderId); this.inputReadyAt = this.time.now + 260; - this.closeCommandChoice(); + this.closeCommandChoice(false); soundDirector.playObjectiveAchieved(); + this.startCommandResponseDialogue(choice); + return true; + } + + private startCommandResponseDialogue( + choice: PrologueMilitiaCampCommandChoice, + initialLineIndex = 0 + ) { this.startDialogue( choice.reaction, () => this.finishCamp(), - `first-command-${choice.orderId}` + `first-command-${choice.orderId}`, + 'prologue-camp-command-response', + initialLineIndex ); - return true; } private cancelFirstCommandChoice() { @@ -2510,7 +2932,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { return true; } - private closeCommandChoice() { + private closeCommandChoice(persistCheckpoint = true) { this.commandChoicePanel?.destroy(); this.commandChoicePanel = undefined; this.commandChoicePanelBounds = undefined; @@ -2519,6 +2941,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.commandChoiceConfirmText = undefined; this.pendingCommandOrderId = undefined; this.commandChoiceReadyAt = Number.POSITIVE_INFINITY; + if (persistCheckpoint) { + this.persistExplorationCheckpoint(true); + } } private finishCamp() { @@ -2537,26 +2962,54 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { return; } - completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.complete); - this.refreshObjectiveHud(); + this.persistExplorationCheckpoint(true); + const previousCampaign = getCampaignState(); this.navigationPending = true; this.stopPlayerMovement(); soundDirector.playObjectiveAchieved(); this.showTransitionOverlay(); - void startGameScene(this, 'StoryScene', { - pages: prologueDeparturePages(), - nextScene: 'BattleScene', - nextSceneData: { battleId: 'first-battle-zhuo-commandery' }, - presentationBattleId: 'first-battle-zhuo-commandery', - presentationStage: 'story' - }).catch((error) => { - console.error('Failed to continue from the prologue militia camp.', error); - this.navigationPending = false; - this.transitionOverlay?.destroy(); - this.transitionOverlay = undefined; - this.refreshInteractionPrompt(); - }); + void (async () => { + try { + completeCampaignTutorial( + prologueMilitiaCampCampaignTutorialIds.complete, + { + clearExplorationCheckpointScene: + 'prologue-militia-camp' + } + ); + this.refreshObjectiveHud(); + await startGameScene(this, 'StoryScene', { + pages: prologueDeparturePages(), + nextScene: 'BattleScene', + nextSceneData: { + battleId: 'first-battle-zhuo-commandery' + }, + presentationBattleId: + 'first-battle-zhuo-commandery', + presentationStage: 'story' + }); + return; + } catch (error) { + console.error( + 'Failed to continue from the prologue militia camp.', + error + ); + try { + saveCampaignState(previousCampaign); + } catch (rollbackError) { + console.error( + 'Failed to restore the prologue militia camp save after navigation failure.', + rollbackError + ); + } + this.navigationPending = false; + this.transitionOverlay?.destroy(); + this.transitionOverlay = undefined; + this.refreshObjectiveHud(); + this.refreshInteractionPrompt(); + } + })(); } private showTransitionOverlay() { @@ -2750,16 +3203,24 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { const movements = [ { npcId: 'guan-yu', - waypoints: [{ x: 650, y: 420 }], - direction: 'north' as const + waypoints: [ + { + x: commandPartyPositions.guanYu.x, + y: commandPartyPositions.guanYu.y + } + ], + direction: commandPartyPositions.guanYu.direction }, { npcId: 'zhang-fei', waypoints: [ { x: 890, y: 560 }, - { x: 890, y: 420 } + { + x: commandPartyPositions.zhangFei.x, + y: commandPartyPositions.zhangFei.y + } ], - direction: 'north' as const + direction: commandPartyPositions.zhangFei.direction } ].filter(({ npcId }) => this.npcViews.has(npcId)); if (movements.length === 0) { @@ -2889,6 +3350,21 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { ); } + private restoreCommandPartyPositions() { + this.setNpcPosition( + 'guan-yu', + commandPartyPositions.guanYu.x, + commandPartyPositions.guanYu.y, + commandPartyPositions.guanYu.direction + ); + this.setNpcPosition( + 'zhang-fei', + commandPartyPositions.zhangFei.x, + commandPartyPositions.zhangFei.y, + commandPartyPositions.zhangFei.direction + ); + } + private isObjectiveComplete(objectiveId: PrologueMilitiaCampRequiredObjectiveId) { return hasCompletedCampaignTutorial(objectiveTutorialIds[objectiveId]); } diff --git a/src/game/scenes/PrologueVillageScene.ts b/src/game/scenes/PrologueVillageScene.ts index 9a849d2..d4feeea 100644 --- a/src/game/scenes/PrologueVillageScene.ts +++ b/src/game/scenes/PrologueVillageScene.ts @@ -33,6 +33,10 @@ import { hasCompletedCampaignTutorial, markCampaignStep, prologueVillageCampaignTutorialIds, + saveCampaignState, + setCampaignExplorationCheckpoint, + type CampaignExplorationCheckpoint, + type CampaignExplorationDialogueKey, type CampaignTutorialId } from '../state/campaignState'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; @@ -133,8 +137,9 @@ type VillageNpcView = { type DialogueState = { lines: PrologueVillageDialogueLine[]; lineIndex: number; - onComplete?: () => void; + onComplete?: () => boolean | void; sourceNpcId?: string; + dialogueKey: CampaignExplorationDialogueKey; }; type InteractionTarget = @@ -250,6 +255,16 @@ export class PrologueVillageScene extends Phaser.Scene { private visualMotionReduced = false; private ownedPresentationTextureKeys = new Set(); private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay; + private checkpointLastSignature = ''; + private checkpointRestoring = false; + private readonly persistCheckpointOnPageHide = () => { + this.persistExplorationCheckpoint(true); + }; + private readonly persistCheckpointWhenHidden = () => { + if (document.visibilityState === 'hidden') { + this.persistExplorationCheckpoint(true); + } + }; constructor() { super('PrologueVillageScene'); @@ -281,6 +296,14 @@ export class PrologueVillageScene extends Phaser.Scene { this.resetRuntimeState(); this.visualMotionReduced = isVisualMotionReduced(); this.prepareCampaignProgress(); + window.addEventListener( + 'pagehide', + this.persistCheckpointOnPageHide + ); + document.addEventListener( + 'visibilitychange', + this.persistCheckpointWhenHidden + ); this.drawVillage(); this.drawHud(); this.createLoadingOverlay(); @@ -294,9 +317,18 @@ export class PrologueVillageScene extends Phaser.Scene { }); this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + this.persistExplorationCheckpoint(true); this.ready = false; this.navigationPending = false; this.oathGatherPending = false; + window.removeEventListener( + 'pagehide', + this.persistCheckpointOnPageHide + ); + document.removeEventListener( + 'visibilitychange', + this.persistCheckpointWhenHidden + ); this.clearNavigationTarget(true); this.queuedMovementIntent = undefined; this.dialogueState = undefined; @@ -315,21 +347,18 @@ export class PrologueVillageScene extends Phaser.Scene { this.loadingOverlay = undefined; this.ready = true; this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + const restoredCheckpoint = + this.restoreExplorationCheckpoint(); this.createCampaignObjectiveJournal(); this.refreshObjectiveHud(); this.refreshNpcMarkers(); this.refreshInteractionPrompt(); - if (this.firstVisit) { - this.startDialogue([ - { - speaker: '유비', - textureKey: 'unit-liu-bei', - text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.' - } - ], () => { - completeCampaignTutorial(prologueVillageCampaignTutorialIds.entered); - }); + if ( + this.firstVisit && + !restoredCheckpoint.overlayRestored + ) { + this.startVillageIntroDialogue(); } } @@ -391,6 +420,8 @@ export class PrologueVillageScene extends Phaser.Scene { visualMotionReduced: this.visualMotionReduced, viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, + explorationCheckpoint: + campaign.explorationCheckpoint ?? null, campaignObjectiveJournal: { snapshot: resolveCampaignObjectiveJournal(campaign), view: this.campaignObjectiveJournal?.getDebugState() ?? null @@ -505,6 +536,7 @@ export class PrologueVillageScene extends Phaser.Scene { speaker: this.dialogueState.lines[this.dialogueState.lineIndex]?.speaker ?? '', lineIndex: this.dialogueState.lineIndex, totalLines: this.dialogueState.lines.length, + dialogueKey: this.dialogueState.dialogueKey, sourceNpcId: this.dialogueState.sourceNpcId ?? null, portraitTextureKey: this.dialoguePortrait?.visible ? this.dialoguePortrait.texture.key @@ -518,6 +550,7 @@ export class PrologueVillageScene extends Phaser.Scene { speaker: null, lineIndex: null, totalLines: 0, + dialogueKey: null, sourceNpcId: null, portraitTextureKey: null, portraitVisible: false, @@ -617,6 +650,8 @@ export class PrologueVillageScene extends Phaser.Scene { this.oathGatherPending = false; this.stepIndex = 0; this.lastStepAt = 0; + this.checkpointLastSignature = ''; + this.checkpointRestoring = false; } private prepareCampaignProgress() { @@ -629,6 +664,244 @@ export class PrologueVillageScene extends Phaser.Scene { } } + private persistExplorationCheckpoint(force = false) { + if ( + !this.ready || + this.navigationPending || + this.checkpointRestoring || + !this.player + ) { + return; + } + const campaign = getCampaignState(); + if ( + campaign.step !== 'prologue-town' || + campaign.completedTutorialIds.includes( + prologueVillageCampaignTutorialIds.complete + ) + ) { + return; + } + const overlay: CampaignExplorationCheckpoint['overlay'] = + this.dialogueState + ? { + type: 'dialogue', + dialogueKey: this.dialogueState.dialogueKey, + ...(this.dialogueState.sourceNpcId + ? { + sourceNpcId: + this.dialogueState.sourceNpcId + } + : {}), + lineIndex: this.dialogueState.lineIndex + } + : undefined; + const checkpoint: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > = { + version: 1, + scene: 'prologue-village', + contextId: 'prologue-village', + player: { + x: this.player.x, + y: this.player.y, + direction: this.playerDirection + }, + ...(overlay ? { overlay } : {}) + }; + const signature = JSON.stringify(checkpoint); + if ( + !force && + signature === this.checkpointLastSignature + ) { + return; + } + try { + setCampaignExplorationCheckpoint(checkpoint); + this.checkpointLastSignature = signature; + } catch (error) { + console.error( + 'Failed to save the prologue village checkpoint.', + error + ); + } + } + + private explorationPositionCheckpoint() { + if (!this.player) { + return undefined; + } + return { + version: 1, + scene: 'prologue-village', + contextId: 'prologue-village', + player: { + x: this.player.x, + y: this.player.y, + direction: this.playerDirection + } + } satisfies Omit< + CampaignExplorationCheckpoint, + 'savedAt' + >; + } + + private restoreExplorationCheckpoint() { + const checkpoint = + getCampaignState().explorationCheckpoint; + if ( + !checkpoint || + checkpoint.scene !== 'prologue-village' || + checkpoint.contextId !== 'prologue-village' || + !this.player + ) { + this.persistExplorationCheckpoint(true); + return { + positionRestored: false, + overlayRestored: false + }; + } + + this.checkpointRestoring = true; + let positionRestored = false; + let overlayRestored = false; + try { + if ( + this.canPlayerOccupy( + checkpoint.player.x, + checkpoint.player.y + ) + ) { + this.player.setPosition( + checkpoint.player.x, + checkpoint.player.y + ); + this.playerDirection = checkpoint.player.direction; + this.syncPlayerView(); + this.setPlayerMoving(false); + positionRestored = true; + } + overlayRestored = checkpoint.overlay + ? this.restoreCheckpointOverlay(checkpoint.overlay) + : false; + } finally { + this.checkpointRestoring = false; + } + this.persistExplorationCheckpoint(true); + return { positionRestored, overlayRestored }; + } + + private restoreCheckpointOverlay( + overlay: CampaignExplorationCheckpoint['overlay'] + ) { + if (!overlay || overlay.type !== 'dialogue') { + return false; + } + if ( + overlay.dialogueKey === + 'prologue-village-oath' + ) { + if ( + !this.allRequiredObjectivesComplete() || + hasCompletedCampaignTutorial( + prologueVillageCampaignTutorialIds.complete + ) + ) { + return false; + } + this.startOathDialogue(overlay.lineIndex); + return Boolean( + this.dialogueState?.dialogueKey === + overlay.dialogueKey + ); + } + if ( + overlay.dialogueKey !== + 'prologue-village-interaction' + ) { + return false; + } + if (overlay.sourceNpcId === 'village-intro') { + if (!this.firstVisit) { + return false; + } + this.startVillageIntroDialogue(overlay.lineIndex); + } else if (overlay.sourceNpcId === 'make-oath') { + if (this.allRequiredObjectivesComplete()) { + return false; + } + this.startLockedOathDialogue(overlay.lineIndex); + } else if (overlay.sourceNpcId) { + const target = this.interactionTargetById( + overlay.sourceNpcId + ); + if (!target || target.kind !== 'npc') { + return false; + } + this.interactWithTarget(target); + this.restoreDialogueLine( + overlay.dialogueKey, + overlay.lineIndex + ); + } else { + return false; + } + return Boolean( + this.dialogueState?.dialogueKey === + overlay.dialogueKey + ); + } + + private restoreDialogueLine( + dialogueKey: CampaignExplorationDialogueKey, + lineIndex: number + ) { + const dialogue = this.dialogueState; + if (!dialogue || dialogue.dialogueKey !== dialogueKey) { + return; + } + dialogue.lineIndex = Phaser.Math.Clamp( + Math.floor(lineIndex), + 0, + dialogue.lines.length - 1 + ); + this.renderDialogueLine(); + } + + private startVillageIntroDialogue(initialLineIndex = 0) { + this.startDialogue( + [ + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.' + } + ], + () => { + try { + completeCampaignTutorial( + prologueVillageCampaignTutorialIds.entered, + { + explorationCheckpoint: + this.explorationPositionCheckpoint() + } + ); + return true; + } catch (error) { + console.error( + 'Failed to save the prologue village introduction.', + error + ); + return false; + } + }, + 'village-intro', + 'prologue-village-interaction', + initialLineIndex + ); + } + private drawVillage() { this.cameras.main.setBackgroundColor('#28372a'); if (this.textures.exists(villageBackgroundKey)) { @@ -1818,6 +2091,7 @@ export class PrologueVillageScene extends Phaser.Scene { if (!this.player) { return; } + const wasMoving = this.playerMoving; applyExplorationCharacterMotion( this.player, playerTextureKey, @@ -1826,6 +2100,9 @@ export class PrologueVillageScene extends Phaser.Scene { this.visualMotionReduced ); this.playerMoving = moving; + if (wasMoving && !moving) { + this.persistExplorationCheckpoint(); + } } private stopPlayerMovement() { @@ -1849,6 +2126,7 @@ export class PrologueVillageScene extends Phaser.Scene { this.player.setPosition(x, y); this.syncPlayerView(); this.stopPlayerMovement(); + this.persistExplorationCheckpoint(true); } private gatherOathParty(onComplete: () => void) { @@ -2068,31 +2346,48 @@ export class PrologueVillageScene extends Phaser.Scene { : view.definition.dialogue; this.startDialogue(dialogue, () => { if (view.definition.objectiveId && unlocked && !completed) { - this.completeObjective(view.definition.objectiveId); + return this.completeObjective( + view.definition.objectiveId + ); } + return false; }, view.definition.id); } private interactWithOath() { if (!this.allRequiredObjectivesComplete()) { - const remaining = prologueVillageObjectiveDefinitions - .filter((objective) => objective.id !== 'make-oath' && !this.isObjectiveComplete(objective.id)) - .map((objective) => objective.shortLabel) - .join(' · '); - this.startDialogue([ - { - speaker: '유비', - textureKey: 'unit-liu-bei', - text: `아직 준비가 끝나지 않았다. 먼저 ${remaining}을 마치자.` - } - ]); + this.startLockedOathDialogue(); return; } this.gatherOathParty(() => this.startOathDialogue()); } - private startOathDialogue() { + private startLockedOathDialogue(initialLineIndex = 0) { + const remaining = prologueVillageObjectiveDefinitions + .filter( + (objective) => + objective.id !== 'make-oath' && + !this.isObjectiveComplete(objective.id) + ) + .map((objective) => objective.shortLabel) + .join(' · '); + this.startDialogue( + [ + { + speaker: '유비', + textureKey: 'unit-liu-bei', + text: `아직 준비가 끝나지 않았다. 먼저 ${remaining}을 마치자.` + } + ], + undefined, + 'make-oath', + 'prologue-village-interaction', + initialLineIndex + ); + } + + private startOathDialogue(initialLineIndex = 0) { this.startDialogue([ { speaker: '장비', @@ -2114,24 +2409,42 @@ export class PrologueVillageScene extends Phaser.Scene { textureKey: 'unit-zhang-fei', text: '형님들의 뜻을 내 목숨처럼 지키겠소. 결의를 마치면 내 장원 밖 막사에서 의병들을 함께 살핍시다!' } - ], () => this.finishVillage(), 'make-oath'); + ], + () => this.finishVillage(), + 'make-oath', + 'prologue-village-oath', + initialLineIndex); } private startDialogue( lines: PrologueVillageDialogueLine[], - onComplete?: () => void, - sourceNpcId?: string + onComplete?: () => boolean | void, + sourceNpcId?: string, + dialogueKey: CampaignExplorationDialogueKey = + 'prologue-village-interaction', + initialLineIndex = 0 ) { if (!lines.length || this.navigationPending) { return; } this.stopPlayerMovement(); - this.dialogueState = { lines, lineIndex: 0, onComplete, sourceNpcId }; + this.dialogueState = { + lines, + lineIndex: Phaser.Math.Clamp( + Math.floor(initialLineIndex), + 0, + lines.length - 1 + ), + onComplete, + sourceNpcId, + dialogueKey + }; this.dialoguePanel?.setVisible(true); this.promptBackground?.setVisible(false); this.promptText?.setVisible(false); soundDirector.playSelect(); this.renderDialogueLine(); + this.persistExplorationCheckpoint(true); } private renderDialogueLine() { @@ -2191,6 +2504,7 @@ export class PrologueVillageScene extends Phaser.Scene { dialogue.lineIndex += 1; soundDirector.playStoryAdvanceCue(); this.renderDialogueLine(); + this.persistExplorationCheckpoint(true); return; } @@ -2198,15 +2512,32 @@ export class PrologueVillageScene extends Phaser.Scene { this.dialogueState = undefined; this.dialoguePanel?.setVisible(false); this.stopPlayerMovement(); - onComplete?.(); + const checkpointCommitted = onComplete?.() === true; + if (!checkpointCommitted) { + this.persistExplorationCheckpoint(true); + } this.refreshInteractionPrompt(); } private completeObjective(objectiveId: PrologueVillageRequiredObjectiveId) { if (this.isObjectiveComplete(objectiveId)) { - return; + return false; + } + try { + completeCampaignTutorial( + objectiveTutorialIds[objectiveId], + { + explorationCheckpoint: + this.explorationPositionCheckpoint() + } + ); + } catch (error) { + console.error( + 'Failed to save the prologue village objective.', + error + ); + return false; } - completeCampaignTutorial(objectiveTutorialIds[objectiveId]); if (objectiveId === 'meet-zhang-fei') { this.walkNpcTo('zhang-fei', narrativePositions.tavern.zhangFei); } else if (objectiveId === 'meet-guan-yu') { @@ -2225,6 +2556,7 @@ export class PrologueVillageScene extends Phaser.Scene { this.refreshNpcMarkers(); const objective = prologueVillageObjectiveDefinitions.find((entry) => entry.id === objectiveId); this.showCompletionToast(`${objective?.shortLabel ?? '준비'} 완료`); + return true; } private finishVillage() { @@ -2236,25 +2568,51 @@ export class PrologueVillageScene extends Phaser.Scene { return; } - completeCampaignTutorial(prologueVillageCampaignTutorialIds.complete); - this.refreshObjectiveHud(); + this.persistExplorationCheckpoint(true); + const previousCampaign = getCampaignState(); this.navigationPending = true; this.stopPlayerMovement(); soundDirector.playObjectiveAchieved(); this.showTransitionOverlay(); - void startGameScene(this, 'StoryScene', { - pages: prologueBrotherhoodPages(), - nextScene: 'PrologueMilitiaCampScene', - presentationBattleId: 'first-battle-zhuo-commandery', - presentationStage: 'story' - }).catch((error) => { - console.error('Failed to continue from the prologue village.', error); - this.navigationPending = false; - this.transitionOverlay?.destroy(); - this.transitionOverlay = undefined; - this.refreshInteractionPrompt(); - }); + void (async () => { + try { + completeCampaignTutorial( + prologueVillageCampaignTutorialIds.complete, + { + clearExplorationCheckpointScene: + 'prologue-village' + } + ); + this.refreshObjectiveHud(); + await startGameScene(this, 'StoryScene', { + pages: prologueBrotherhoodPages(), + nextScene: 'PrologueMilitiaCampScene', + presentationBattleId: + 'first-battle-zhuo-commandery', + presentationStage: 'story' + }); + return; + } catch (error) { + console.error( + 'Failed to continue from the prologue village.', + error + ); + try { + saveCampaignState(previousCampaign); + } catch (rollbackError) { + console.error( + 'Failed to restore the prologue village save after navigation failure.', + rollbackError + ); + } + this.navigationPending = false; + this.transitionOverlay?.destroy(); + this.transitionOverlay = undefined; + this.refreshObjectiveHud(); + this.refreshInteractionPrompt(); + } + })(); } private showTransitionOverlay() { diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index ac74a5b..10a64ac 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -1358,6 +1358,33 @@ export class TitleScene extends Phaser.Scene { }); return; } + const explorationCheckpoint = campaign.explorationCheckpoint; + if ( + explorationCheckpoint?.scene === 'camp-visit-exploration' && + campaign.activeCampVisitId + ) { + await this.navigateTo('CampVisitExplorationScene', { + visitId: campaign.activeCampVisitId + }); + return; + } + if ( + explorationCheckpoint?.scene === 'city-stay' && + campaign.activeCityStayId + ) { + await this.navigateTo('CityStayScene', { + cityStayId: campaign.activeCityStayId + }); + return; + } + if (explorationCheckpoint?.scene === 'prologue-village') { + await this.navigateTo('PrologueVillageScene'); + return; + } + if (explorationCheckpoint?.scene === 'prologue-militia-camp') { + await this.navigateTo('PrologueMilitiaCampScene'); + return; + } if ( shouldReviewThirdCampPreparationBeforeRetry(campaign) ) { diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index d87053d..9296421 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -3,6 +3,7 @@ import { unitClasses } from '../data/battleRules'; import { battleScenarios, type BattleScenarioId } from '../data/battles'; import { cloneCityEquipmentContributionSnapshot, + findCanonicalCityEquipmentOffer, normalizeCityEquipmentContributionSnapshot, normalizeCityEquipmentEquipIntent, normalizeCityEquipmentPreparationSnapshot, @@ -119,6 +120,20 @@ export type CampaignVictoryRewardAcknowledgements = Partial; +export type CampaignCityEquipmentPurchaseReceipt = { + version: 1; + intentId: string; + cityStayId: CityStayId; + offerId: string; + purchaseNumber: number; + completedAt: string; +}; + +export type CampaignCityEquipmentPurchaseReceipts = Record< + string, + CampaignCityEquipmentPurchaseReceipt +>; + export const campaignCampVisitIds = [ firstPursuitScoutVisitId, secondBattleReliefVisitId, @@ -126,6 +141,170 @@ export const campaignCampVisitIds = [ ] as const; export type CampaignCampVisitId = (typeof campaignCampVisitIds)[number]; +export type CampaignExplorationDirection = + | 'north' + | 'south' + | 'east' + | 'west'; + +export const campaignExplorationDialogueKeys = [ + 'third-intro', + 'visit-interaction', + 'camp-exit', + 'first-choice-response', + 'second-choice-response', + 'third-companion-response', + 'city-interaction', + 'city-exit', + 'city-resonance-response', + 'prologue-village-interaction', + 'prologue-village-oath', + 'prologue-camp-interaction', + 'prologue-camp-departure', + 'prologue-camp-command-response' +] as const; +export type CampaignExplorationDialogueKey = + (typeof campaignExplorationDialogueKeys)[number]; +const campaignExplorationDialogueKeySet = new Set( + campaignExplorationDialogueKeys +); +const campaignExplorationDirectionSet = + new Set([ + 'north', + 'south', + 'east', + 'west' + ]); + +export type CampaignExplorationCheckpointOverlay = + | { + type: 'dialogue'; + dialogueKey: CampaignExplorationDialogueKey; + sourceNpcId?: string; + lineIndex: number; + } + | { + type: 'choice'; + mode: + | 'visit' + | 'third-companion' + | 'city-resonance' + | 'prologue-command'; + sourceNpcId?: string; + choiceId?: string; + } + | { + type: 'shop'; + sourceNpcId: string; + }; + +type CampaignExplorationChoiceMode = Extract< + CampaignExplorationCheckpointOverlay, + { type: 'choice' } +>['mode']; + +type CampaignExplorationCheckpointOverlayPolicy = { + dialogueKeys: readonly CampaignExplorationDialogueKey[]; + choiceModes: readonly CampaignExplorationChoiceMode[]; + shop: boolean; +}; + +type CampaignExplorationCheckpointNormalizationMode = + | 'stored' + | 'write'; + +type CampaignExplorationCheckpointOverlayNormalization = + | { status: 'absent' } + | { + status: 'valid'; + overlay: CampaignExplorationCheckpointOverlay; + } + | { status: 'invalid' }; + +const campaignExplorationCheckpointOverlayPolicies: Readonly< + Record +> = { + [`camp-visit-exploration:${firstPursuitScoutVisitId}`]: { + dialogueKeys: [ + 'visit-interaction', + 'camp-exit', + 'first-choice-response' + ], + choiceModes: ['visit'], + shop: false + }, + [`camp-visit-exploration:${secondBattleReliefVisitId}`]: { + dialogueKeys: [ + 'visit-interaction', + 'camp-exit', + 'second-choice-response' + ], + choiceModes: ['visit'], + shop: false + }, + [`camp-visit-exploration:${thirdCampExplorationVisitId}`]: { + dialogueKeys: [ + 'third-intro', + 'visit-interaction', + 'camp-exit', + 'third-companion-response' + ], + choiceModes: ['third-companion'], + shop: false + }, + 'city-stay:*': { + dialogueKeys: [ + 'city-interaction', + 'city-exit', + 'city-resonance-response' + ], + choiceModes: ['city-resonance'], + shop: true + }, + 'prologue-village:prologue-village': { + dialogueKeys: [ + 'prologue-village-interaction', + 'prologue-village-oath' + ], + choiceModes: [], + shop: false + }, + 'prologue-militia-camp:prologue-militia-camp': { + dialogueKeys: [ + 'prologue-camp-interaction', + 'prologue-camp-departure', + 'prologue-camp-command-response' + ], + choiceModes: ['prologue-command'], + shop: false + } +}; + +export type CampaignExplorationCheckpoint = { + version: 1; + scene: + | 'camp-visit-exploration' + | 'city-stay' + | 'prologue-village' + | 'prologue-militia-camp'; + contextId: string; + player: { + x: number; + y: number; + direction: CampaignExplorationDirection; + }; + overlay?: CampaignExplorationCheckpointOverlay; + savedAt: string; +}; + +export type CampaignExplorationCommitOptions = { + explorationCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + >; + clearExplorationCheckpointScene?: CampaignExplorationCheckpoint['scene']; +}; + export type ThirdCampPreparationSelectionSnapshot = { sourceBattleId: typeof thirdCampPreparationSourceBattleId; targetBattleId: typeof thirdCampPreparationTargetBattleId; @@ -453,10 +632,30 @@ const maxCampaignReserveTrainingEntries = 96; const maxCampaignReserveTrainingAssignmentUnits = 96; const maxCampaignSortieAssignmentUnits = 96; const maxCampaignSortieOrderRewardClaims = 256; +const maxCampaignCityEquipmentPurchaseReceipts = 128; const maxCampaignDisplayTextLength = 180; const maxCampaignNumericValue = 999999; const maxCampaignObjectiveTargetRadius = 99; +export function campaignInventoryCap(itemLabel: string) { + const normalizedItemLabel = itemLabel.replace(/\s+/g, ' ').trim(); + return campaignInventoryCaps[normalizedItemLabel] ?? + maxCampaignUnknownInventoryAmount; +} + +export function normalizeCityEquipmentPurchaseIntentId( + value: unknown +) { + if (typeof value !== 'string') { + return undefined; + } + const intentId = value.trim(); + return intentId.length > 0 && + intentId.length <= maxCampaignStringListLength + ? intentId + : undefined; +} + export const campaignReserveTrainingFocusDefinitions: CampaignReserveTrainingFocusDefinition[] = [ { id: 'balanced', @@ -568,6 +767,7 @@ export type CampaignState = { bonds: CampBondSnapshot[]; inventory: Record; cityEquipmentPurchaseCounts: CityEquipmentPurchaseCounts; + cityEquipmentPurchaseReceipts: CampaignCityEquipmentPurchaseReceipts; cityEquipmentEquipIntent?: CityEquipmentEquipIntent; cityEquipmentPreparation?: CityEquipmentPreparationSnapshot; selectedSortieUnitIds: string[]; @@ -595,6 +795,7 @@ export type CampaignState = { pendingAftermathBattleId?: BattleScenarioId; activeCityStayId?: CityStayId; activeCampVisitId?: CampaignCampVisitId; + explorationCheckpoint?: CampaignExplorationCheckpoint; firstBattleReport?: FirstBattleReport; }; @@ -842,6 +1043,7 @@ export function createInitialCampaignState(): CampaignState { bonds: [], inventory: {}, cityEquipmentPurchaseCounts: {}, + cityEquipmentPurchaseReceipts: {}, selectedSortieUnitIds: [], sortieFormationAssignments: {}, sortieItemAssignments: {}, @@ -874,14 +1076,19 @@ export function getCampaignState() { } export function setCampaignState(state: CampaignState) { - campaignState = normalizeCampaignState(state); - campaignState.updatedAt = new Date().toISOString(); - persistCampaignState(campaignState); - return cloneCampaignState(campaignState); + const nextState = normalizeCampaignState(state); + nextState.updatedAt = new Date().toISOString(); + persistCampaignState(nextState); + campaignState = nextState; + return cloneCampaignState(nextState); } export function loadCampaignState(slot?: number) { - campaignState = readStoredCampaignState(slot) ?? readStoredCampaignState() ?? readLatestSlottedCampaignState() ?? createInitialCampaignState(); + campaignState = slot === undefined + ? readNewestStoredCampaignState() ?? createInitialCampaignState() + : readStoredCampaignState(slot) ?? + readNewestStoredCampaignState() ?? + createInitialCampaignState(); return cloneCampaignState(campaignState); } @@ -891,11 +1098,12 @@ export function readCampaignSaveState(slot: number) { } export function saveCampaignState(state = ensureCampaignState(), slot = state.activeSaveSlot) { - campaignState = normalizeCampaignState(state); - campaignState.activeSaveSlot = normalizeSlot(slot); - campaignState.updatedAt = new Date().toISOString(); - persistCampaignState(campaignState); - return cloneCampaignState(campaignState); + const nextState = normalizeCampaignState(state); + nextState.activeSaveSlot = normalizeSlot(slot); + nextState.updatedAt = new Date().toISOString(); + persistCampaignState(nextState); + campaignState = nextState; + return cloneCampaignState(nextState); } export function resetCampaignState() { @@ -936,7 +1144,7 @@ export function listCampaignSaveSlots(): CampaignSaveSlotSummary[] { } export function markCampaignStep(step: CampaignStep) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); state.step = step; if (!normalizeActiveCityStayId(state.activeCityStayId, state.latestBattleId, step)) { delete state.activeCityStayId; @@ -950,27 +1158,49 @@ export function markCampaignStep(step: CampaignStep) { )) { delete state.activeCampVisitId; } + const explorationCheckpoint = + normalizeCampaignExplorationCheckpoint( + state.explorationCheckpoint, + state + ); + if (explorationCheckpoint) { + state.explorationCheckpoint = explorationCheckpoint; + } else { + delete state.explorationCheckpoint; + } state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneCampaignState(state); } export function setActiveCityStayId(cityStayId?: CityStayId) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); const normalizedCityStayId = normalizeActiveCityStayId(cityStayId, state.latestBattleId, state.step); if (normalizedCityStayId) { state.activeCityStayId = normalizedCityStayId; delete state.activeCampVisitId; + if ( + state.explorationCheckpoint?.scene !== 'city-stay' || + state.explorationCheckpoint.contextId !== + normalizedCityStayId + ) { + delete state.explorationCheckpoint; + } } else { delete state.activeCityStayId; + if (state.explorationCheckpoint?.scene === 'city-stay') { + delete state.explorationCheckpoint; + } } state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneCampaignState(state); } export function setActiveCampVisitId(campVisitId?: CampaignCampVisitId) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); const normalizedCampVisitId = state.pendingAftermathBattleId ? undefined : normalizeActiveCampVisitId( @@ -983,11 +1213,64 @@ export function setActiveCampVisitId(campVisitId?: CampaignCampVisitId) { if (normalizedCampVisitId) { state.activeCampVisitId = normalizedCampVisitId; delete state.activeCityStayId; + if ( + state.explorationCheckpoint?.scene !== + 'camp-visit-exploration' || + state.explorationCheckpoint.contextId !== normalizedCampVisitId + ) { + delete state.explorationCheckpoint; + } } else { delete state.activeCampVisitId; + if ( + state.explorationCheckpoint?.scene === + 'camp-visit-exploration' + ) { + delete state.explorationCheckpoint; + } } state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; + return cloneCampaignState(state); +} + +export function setCampaignExplorationCheckpoint( + checkpoint: Omit +) { + const state = cloneCampaignState(ensureCampaignState()); + const normalized = normalizeCampaignExplorationCheckpoint( + { + ...checkpoint, + savedAt: new Date().toISOString() + }, + state, + 'write' + ); + if (!normalized) { + return cloneCampaignState(state); + } + state.explorationCheckpoint = normalized; + state.updatedAt = normalized.savedAt; + persistCampaignState(state); + campaignState = state; + return cloneCampaignState(state); +} + +export function clearCampaignExplorationCheckpoint( + contextId?: string +) { + const state = cloneCampaignState(ensureCampaignState()); + if ( + state.explorationCheckpoint && + (!contextId || + state.explorationCheckpoint.contextId === contextId) + ) { + delete state.explorationCheckpoint; + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + campaignState = state; + } return cloneCampaignState(state); } @@ -995,18 +1278,39 @@ export function hasCompletedCampaignTutorial(id: CampaignTutorialId, state = ens return state.completedTutorialIds.includes(id); } -export function completeCampaignTutorial(id: CampaignTutorialId) { - const state = ensureCampaignState(); +export function completeCampaignTutorial( + id: CampaignTutorialId, + commitOptions: CampaignExplorationCommitOptions = {} +) { + const state = cloneCampaignState(ensureCampaignState()); + let changed = false; if (!state.completedTutorialIds.includes(id)) { state.completedTutorialIds.push(id); + changed = true; + } + if ( + commitOptions.clearExplorationCheckpointScene && + state.explorationCheckpoint?.scene === + commitOptions.clearExplorationCheckpointScene + ) { + delete state.explorationCheckpoint; + changed = true; + } + changed = + applyCampaignExplorationCheckpoint( + state, + commitOptions.explorationCheckpoint + ) || changed; + if (changed) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; } return cloneCampaignState(state); } export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orderId?: CampaignSortiePresetId) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); if (!(battleId in battleScenarios)) { return cloneCampaignState(state); } @@ -1018,11 +1322,12 @@ export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orde } state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneCampaignState(state); } export function setCampaignSortieResonanceSelection(battleId: BattleScenarioId, bondId?: string) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); if (!hasCampaignBattleScenario(battleId)) { return cloneCampaignState(state); } @@ -1044,11 +1349,12 @@ export function setCampaignSortieResonanceSelection(battleId: BattleScenarioId, } state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneCampaignState(state); } export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); const normalizedFocusId = normalizeReserveTrainingFocusId(focusId); const rosterUnitIds = normalizedRosterUnitIds(state.roster); const allowedUnitIds = rosterUnitIds.size > 0 ? rosterUnitIds : undefined; @@ -1059,11 +1365,12 @@ export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTraining ) as CampaignReserveTrainingAssignments; state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneCampaignState(state); } export function setCampaignReserveTrainingAssignment(unitId: string, focusId?: CampaignReserveTrainingFocusId) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); const normalizedUnitId = normalizeKeyString(unitId); const rosterUnitIds = normalizedRosterUnitIds(state.roster); const allowedUnitIds = rosterUnitIds.size > 0 ? rosterUnitIds : undefined; @@ -1080,6 +1387,7 @@ export function setCampaignReserveTrainingAssignment(unitId: string, focusId?: C state.reserveTrainingAssignments = assignments; state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneCampaignState(state); } @@ -1105,8 +1413,8 @@ export function normalizeCampaignReserveTrainingAssignments( } export function setFirstBattleReport(report: FirstBattleReport) { - const state = ensureCampaignState(); - const reportClone = cloneReport(report); + const state = cloneCampaignState(ensureCampaignState()); + let reportClone = cloneReport(report); const battleId = reportClone.battleId; const normalizedRecommendation = normalizeCampaignSortieRecommendationSnapshot(reportClone.sortieRecommendation, { expectedBattleId: battleId @@ -1147,6 +1455,40 @@ export function setFirstBattleReport(report: FirstBattleReport) { delete reportClone.xuzhouStayBattlePayoff; } const previousSettlement = state.battleHistory[battleId]; + const replayingSettledVictory = + previousSettlement?.outcome === 'victory'; + const matchingSettledVictoryReport = + state.firstBattleReport?.battleId === battleId && + state.firstBattleReport.outcome === 'victory' + ? state.firstBattleReport + : undefined; + if ( + replayingSettledVictory && + reportClone.outcome !== 'victory' + ) { + return cloneReport( + matchingSettledVictoryReport ?? + reportFromSettledVictory( + previousSettlement, + reportClone, + state + ) + ); + } + if ( + replayingSettledVictory && + !matchingSettledVictoryReport && + ( + !state.firstBattleReport || + state.firstBattleReport.battleId === battleId + ) + ) { + reportClone = reportFromSettledVictory( + previousSettlement, + reportClone, + state + ); + } const completedCampDialogues = mergeCampCompletionIds( state.completedCampDialogues, reportClone.completedCampDialogues, @@ -1206,7 +1548,23 @@ export function setFirstBattleReport(report: FirstBattleReport) { } else { delete reportClone.sortieOrder; } - state.firstBattleReport = reportClone; + if (replayingSettledVictory) { + reportClone.units = preserveCampaignUnitProgressOnSettlementReplay( + reportClone.units, + state.roster + ); + reportClone.bonds = preserveCampaignBondProgressOnSettlementReplay( + reportClone.bonds, + state.bonds + ); + } + if ( + !replayingSettledVictory || + !state.firstBattleReport || + state.firstBattleReport?.battleId === battleId + ) { + state.firstBattleReport = reportClone; + } const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery']; const victoryStep: CampaignStep = stepRule.victory; const retryStep: CampaignStep = stepRule.retry; @@ -1236,68 +1594,106 @@ export function setFirstBattleReport(report: FirstBattleReport) { state.latestBattleId = battleId; delete state.activeCityStayId; delete state.activeCampVisitId; + delete state.explorationCheckpoint; delete state.pendingAftermathBattleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneReport(reportClone); } - state.step = victoryStep; - state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold); + if (!replayingSettledVictory) { + state.step = victoryStep; + } + const shouldApplyVictoryEconomy = + !replayingSettledVictory; + if (shouldApplyVictoryEconomy) { + state.gold += reportClone.rewardGold; + } const alliedReportUnits = reportClone.units.filter((unit) => unit.faction === 'ally'); const recruitUnitIds = new Set(reportClone.campaignRewards?.recruits.map((recruit) => recruit.unitId) ?? []); const reportUnitById = new Map(reportClone.units.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit])); const recruitedUnits = [...recruitUnitIds] .map((unitId) => reportUnitById.get(unitId) ?? campaignRecruitUnitById.get(unitId)) .filter((unit): unit is UnitData => Boolean(unit)); - state.roster = mergeRosterProgress(state.roster, [...alliedReportUnits, ...recruitedUnits]); - state.bonds = reportClone.bonds.map(cloneBondSnapshot); - backfillEarlyJianYongRecruit(state); - const reserveTraining = previousSettlement || reportClone.outcome !== 'victory' + if (!replayingSettledVictory) { + state.roster = mergeRosterProgress(state.roster, [ + ...alliedReportUnits, + ...recruitedUnits + ]); + state.bonds = reportClone.bonds.map(cloneBondSnapshot); + backfillEarlyJianYongRecruit(state); + } + const reserveTraining = replayingSettledVictory ? previousSettlement?.reserveTraining ?? [] : applyReserveTrainingProgress( - state.roster, - state.bonds, - reportClone.units.filter((unit) => unit.faction === 'ally'), - state.reserveTrainingFocus, - state.reserveTrainingAssignments - ); - if (!previousSettlement && reportClone.outcome === 'victory') { + state.roster, + state.bonds, + reportClone.units.filter( + (unit) => unit.faction === 'ally' + ), + state.reserveTrainingFocus, + state.reserveTrainingAssignments + ); + if (!replayingSettledVictory) { reportClone.bonds = state.bonds.map(cloneBondSnapshot); } state.completedCampDialogues = completedCampDialogues; state.completedCampVisits = completedCampVisits; - state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1); - state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1); + if (shouldApplyVictoryEconomy) { + state.inventory = applyRewardDelta( + state.inventory, + reportClone.itemRewards, + 1 + ); + } if (pendingSortieOrderReward && pendingSortieOrderClaimId) { state.gold += pendingSortieOrderReward.rewardGold; state.inventory = applyRewardDelta(state.inventory, pendingSortieOrderReward.rewardItems, 1); state.claimedSortieOrderRewardIds.push(pendingSortieOrderClaimId); } - state.battleHistory[battleId] = createBattleSettlement(reportClone, reserveTraining); - if (state.sortieOrderSelection?.battleId === battleId) { - delete state.sortieOrderSelection; + const nextSettlement = createBattleSettlement( + reportClone, + reserveTraining + ); + if (replayingSettledVictory && previousSettlement) { + nextSettlement.units = previousSettlement.units.map( + cloneCampaignUnitProgressSnapshot + ); + nextSettlement.bonds = previousSettlement.bonds.map( + (bond) => ({ ...bond }) + ); } - if (state.sortieResonanceSelection?.battleId === battleId) { - delete state.sortieResonanceSelection; + state.battleHistory[battleId] = nextSettlement; + if (!replayingSettledVictory) { + if (state.sortieOrderSelection?.battleId === battleId) { + delete state.sortieOrderSelection; + } + if (state.sortieResonanceSelection?.battleId === battleId) { + delete state.sortieResonanceSelection; + } + if (state.sortieRecommendationSelection?.battleId === battleId) { + delete state.sortieRecommendationSelection; + } + if ( + reportClone.outcome === 'victory' && + battleId === thirdCampPreparationTargetBattleId && + state.thirdCampPreparationSelection?.targetBattleId === + battleId + ) { + delete state.thirdCampPreparationSelection; + } + state.latestBattleId = battleId; + delete state.activeCityStayId; + delete state.activeCampVisitId; + delete state.explorationCheckpoint; + state.pendingAftermathBattleId = + battleId as BattleScenarioId; } - if (state.sortieRecommendationSelection?.battleId === battleId) { - delete state.sortieRecommendationSelection; - } - if ( - reportClone.outcome === 'victory' && - battleId === thirdCampPreparationTargetBattleId && - state.thirdCampPreparationSelection?.targetBattleId === battleId - ) { - delete state.thirdCampPreparationSelection; - } - state.latestBattleId = battleId; - delete state.activeCityStayId; - delete state.activeCampVisitId; - state.pendingAftermathBattleId = battleId as BattleScenarioId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneReport(reportClone); } @@ -1307,7 +1703,7 @@ export function getFirstBattleReport() { } export function completeCampaignAftermath(battleId: BattleScenarioId) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); if (state.pendingAftermathBattleId !== battleId) { return cloneCampaignState(state); } @@ -1315,6 +1711,7 @@ export function completeCampaignAftermath(battleId: BattleScenarioId) { delete state.pendingAftermathBattleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneCampaignState(state); } @@ -1448,7 +1845,7 @@ export function getPendingCampaignVictoryRewardNoticeReport() { } export function dismissCampaignVictoryRewardNotice(battleId: string) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); const normalizedBattleId = normalizeKeyString(battleId); const report = campaignVictoryRewardSourceFor(state, normalizedBattleId); if ( @@ -1467,6 +1864,7 @@ export function dismissCampaignVictoryRewardNotice(battleId: string) { ]; state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneCampaignState(state); } @@ -1474,7 +1872,7 @@ export function acknowledgeCampaignVictoryReward( battleId: string, requestedCategories?: readonly CampaignVictoryRewardCategoryId[] ) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); const normalizedBattleId = normalizeKeyString(battleId); const report = campaignVictoryRewardSourceFor(state, normalizedBattleId); if ( @@ -1512,6 +1910,7 @@ export function acknowledgeCampaignVictoryReward( if (categoryChanged || (fullyAcknowledged && !battleAcknowledged)) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; } return cloneCampaignState(state); } @@ -1559,8 +1958,26 @@ function uniqueVictoryRewardCategories(value: unknown): CampaignVictoryRewardCat return campaignVictoryRewardCategoryIds.filter((category) => categories.has(category)); } -export function applyCampBondExp(dialogueId: string, bondId: string, amount: number, choiceId?: string) { - const state = ensureCampaignState(); +export type CampActionCommitOptions = { + completedVisitIds?: string[]; + acknowledgedReward?: { + battleId: BattleScenarioId; + categories: CampaignVictoryRewardCategoryId[]; + }; + explorationCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + >; +}; + +export function applyCampBondExp( + dialogueId: string, + bondId: string, + amount: number, + choiceId?: string, + commitOptions: CampActionCommitOptions = {} +) { + const state = cloneCampaignState(ensureCampaignState()); if (!state.firstBattleReport) { return undefined; } @@ -1579,9 +1996,14 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num dialogueId ); const choiceRemembered = rememberCampChoice(state.campDialogueChoiceIds, dialogueId, choiceId); - if (completionSynced || choiceRemembered) { + const commitChanged = applyCampActionCommitOptions( + state, + commitOptions + ); + if (completionSynced || choiceRemembered || commitChanged) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; } return cloneReport(state.firstBattleReport); } @@ -1598,18 +2020,24 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num reportBond.level = campaignBond.level; reportBond.exp = campaignBond.exp; reportBond.battleExp = campaignBond.battleExp; + applyCampActionCommitOptions(state, commitOptions); state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneReport(state.firstBattleReport); } export function applyCampVisitReward( visitId: string, reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] }, - choiceId?: string + choiceId?: string, + commitOptions: Pick< + CampActionCommitOptions, + 'explorationCheckpoint' + > = {} ) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); if (!state.firstBattleReport) { return undefined; } @@ -1628,27 +2056,45 @@ export function applyCampVisitReward( visitId ); const choiceRemembered = rememberCampChoice(state.campVisitChoiceIds, visitId, choiceId); - if (completionSynced || choiceRemembered) { + const commitChanged = applyCampActionCommitOptions( + state, + commitOptions + ); + if (completionSynced || choiceRemembered || commitChanged) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; } return cloneCampaignState(state); } + let campaignBond: CampBondSnapshot | undefined; + let reportBond: CampBondSnapshot | undefined; + if (reward.bondId && reward.bondExp && reward.bondExp > 0) { + campaignBond = state.bonds.find( + (candidate) => candidate.id === reward.bondId + ); + reportBond = state.firstBattleReport.bonds.find( + (candidate) => candidate.id === reward.bondId + ); + if (!campaignBond || !reportBond) { + return undefined; + } + } + syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId); rememberCampChoice(state.campVisitChoiceIds, visitId, choiceId); - if (reward.bondId && reward.bondExp && reward.bondExp > 0) { - const campaignBond = state.bonds.find((candidate) => candidate.id === reward.bondId); - const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === reward.bondId); - if (campaignBond) { - advanceBond(campaignBond, reward.bondExp); - if (reportBond) { - reportBond.level = campaignBond.level; - reportBond.exp = campaignBond.exp; - reportBond.battleExp = campaignBond.battleExp; - } - } + if ( + campaignBond && + reportBond && + reward.bondExp && + reward.bondExp > 0 + ) { + advanceBond(campaignBond, reward.bondExp); + reportBond.level = campaignBond.level; + reportBond.exp = campaignBond.exp; + reportBond.battleExp = campaignBond.battleExp; } if (reward.gold && reward.gold > 0) { @@ -1659,11 +2105,87 @@ export function applyCampVisitReward( state.inventory = applyRewardDelta(state.inventory, reward.itemRewards, 1); } + applyCampActionCommitOptions(state, commitOptions); state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; return cloneCampaignState(state); } +function applyCampActionCommitOptions( + state: CampaignState, + options: CampActionCommitOptions +) { + let changed = false; + if (state.firstBattleReport) { + for (const visitId of uniqueCampHistoryIds( + options.completedVisitIds + )) { + changed = + syncCompletionFlag( + state.completedCampVisits, + state.firstBattleReport.completedCampVisits, + visitId + ) || changed; + } + } + + const acknowledgement = options.acknowledgedReward; + if ( + acknowledgement && + acknowledgement.battleId in battleScenarios + ) { + const previous = + state.acknowledgedVictoryRewardCategories[ + acknowledgement.battleId + ] ?? []; + const next = campaignVictoryRewardCategoryIds.filter( + (category) => + previous.includes(category) || + acknowledgement.categories.includes(category) + ); + if (JSON.stringify(next) !== JSON.stringify(previous)) { + state.acknowledgedVictoryRewardCategories = { + ...state.acknowledgedVictoryRewardCategories, + [acknowledgement.battleId]: next + }; + changed = true; + } + } + + changed = + applyCampaignExplorationCheckpoint( + state, + options.explorationCheckpoint + ) || changed; + return changed; +} + +function applyCampaignExplorationCheckpoint( + state: CampaignState, + checkpoint: + | Omit + | undefined +) { + if (!checkpoint) { + return false; + } + const normalized = + normalizeCampaignExplorationCheckpoint( + { + ...checkpoint, + savedAt: new Date().toISOString() + }, + state, + 'write' + ); + if (!normalized) { + return false; + } + state.explorationCheckpoint = normalized; + return true; +} + function hasCampCompletionFlag( stateFlags: string[], reportFlags: string[], @@ -1705,7 +2227,7 @@ function syncCompletionFlag(stateFlags: string[], reportFlags: string[], flagId: } export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[] = []) { - const state = ensureCampaignState(); + const state = cloneCampaignState(ensureCampaignState()); let changed = false; const existingUnitIds = new Set(state.roster.map((unit) => unit.id)); @@ -1753,6 +2275,7 @@ export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[] if (changed) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); + campaignState = state; } return cloneCampaignState(state); @@ -1843,7 +2366,8 @@ function appendRequiredLimitedMigrationEntry(entries: T[], entry: T, limit: n function ensureCampaignState() { if (!campaignState) { - campaignState = readStoredCampaignState() ?? createInitialCampaignState(); + campaignState = + readNewestStoredCampaignState() ?? createInitialCampaignState(); } return campaignState; } @@ -1882,6 +2406,11 @@ function normalizeCampaignState(state: Partial): CampaignState { normalizeCityEquipmentPurchaseCounts( normalized.cityEquipmentPurchaseCounts ); + normalized.cityEquipmentPurchaseReceipts = + normalizeCityEquipmentPurchaseReceipts( + normalized.cityEquipmentPurchaseReceipts, + normalized.cityEquipmentPurchaseCounts + ); normalized.cityEquipmentEquipIntent = normalizeCityEquipmentEquipIntent( normalized.cityEquipmentEquipIntent, @@ -2035,6 +2564,16 @@ function normalizeCampaignState(state: Partial): CampaignState { } else { delete normalized.pendingAftermathBattleId; } + const explorationCheckpoint = + normalizeCampaignExplorationCheckpoint( + normalized.explorationCheckpoint, + normalized + ); + if (explorationCheckpoint) { + normalized.explorationCheckpoint = explorationCheckpoint; + } else { + delete normalized.explorationCheckpoint; + } if (normalized.firstBattleReport && sortieUnitFilter) { normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter); } @@ -2319,13 +2858,87 @@ function normalizeInventory(value: unknown) { const normalizedAmount = normalizeNonNegativeInteger(amount); if (normalizedItemId && normalizedItemId.length <= maxCampaignStringListLength && normalizedAmount > 0) { const nextAmount = (inventory[normalizedItemId] ?? 0) + normalizedAmount; - const cap = campaignInventoryCaps[normalizedItemId] ?? maxCampaignUnknownInventoryAmount; + const cap = campaignInventoryCap(normalizedItemId); inventory[normalizedItemId] = Math.min(nextAmount, cap); } return inventory; }, {}); } +function normalizeCityEquipmentPurchaseReceipts( + value: unknown, + purchaseCounts: CityEquipmentPurchaseCounts +): CampaignCityEquipmentPurchaseReceipts { + const receipts = Object.entries(recordOrEmpty(value)) + .map(([storedIntentId, candidate], index) => { + if (!isPlainObject(candidate) || candidate.version !== 1) { + return undefined; + } + const intentId = + normalizeCityEquipmentPurchaseIntentId(candidate.intentId); + if (!intentId || intentId !== storedIntentId) { + return undefined; + } + const canonicalOffer = findCanonicalCityEquipmentOffer( + candidate.cityStayId, + candidate.offerId + ); + const purchaseNumber = normalizeNonNegativeInteger( + candidate.purchaseNumber + ); + const completedAt = + normalizeCampaignTimestamp(candidate.completedAt); + if ( + !canonicalOffer || + purchaseNumber <= 0 || + purchaseNumber > 99 || + purchaseNumber > + (purchaseCounts[canonicalOffer.offer.id] ?? 0) || + !completedAt + ) { + return undefined; + } + return { + version: 1 as const, + intentId, + cityStayId: canonicalOffer.cityStay.id, + offerId: canonicalOffer.offer.id, + purchaseNumber, + completedAt, + normalizationOrder: index + }; + }) + .filter( + ( + receipt + ): receipt is CampaignCityEquipmentPurchaseReceipt & { + normalizationOrder: number; + } => + Boolean(receipt) + ) + .sort( + (left, right) => + (campaignTimestampMs(right.completedAt) ?? 0) - + (campaignTimestampMs(left.completedAt) ?? 0) || + right.normalizationOrder - left.normalizationOrder + ) + .slice(0, maxCampaignCityEquipmentPurchaseReceipts); + + return receipts.reduce( + (normalized, receipt) => { + if (!normalized[receipt.intentId]) { + const { + normalizationOrder: _normalizationOrder, + ...normalizedReceipt + } = receipt; + normalized[receipt.intentId] = normalizedReceipt; + } + return normalized; + }, + {} + ); +} + function normalizeCampaignSortieOrderSelection(value: unknown): CampaignSortieOrderSelection | undefined { if (!isPlainObject(value)) { return undefined; @@ -3567,8 +4180,19 @@ function readStoredCampaignStateObject(slot?: number) { function persistCampaignState(state: CampaignState) { const storage = tryStorage(); - storage?.setItem(campaignStorageKey, JSON.stringify(state)); - storage?.setItem(campaignSaveSlotKey(state.activeSaveSlot), JSON.stringify(state)); + if (!storage) { + return; + } + const serialized = JSON.stringify(state); + storage.setItem( + campaignSaveSlotKey(state.activeSaveSlot), + serialized + ); + try { + storage.setItem(campaignStorageKey, serialized); + } catch { + // The save slot is canonical. The base key is a compatibility mirror. + } } function tryStorage() { @@ -3604,6 +4228,42 @@ function readLatestSlottedCampaignState() { return latestSlot ? readStoredCampaignState(latestSlot) : undefined; } +function readNewestStoredCampaignState() { + const candidates = [ + { + stored: readStoredCampaignStateObject(), + canonical: false + }, + ...Array.from( + { length: campaignSaveSlotCount }, + (_, index) => ({ + stored: readStoredCampaignStateObject(index + 1), + canonical: true + }) + ) + ].filter( + ( + candidate + ): candidate is { + stored: Partial; + canonical: boolean; + } => Boolean(candidate.stored) + ); + + const newest = candidates + .sort((left, right) => { + const timestampDelta = + (campaignTimestampMs(right.stored.updatedAt) ?? + -Number.MAX_SAFE_INTEGER) - + (campaignTimestampMs(left.stored.updatedAt) ?? + -Number.MAX_SAFE_INTEGER); + return timestampDelta !== 0 + ? timestampDelta + : Number(right.canonical) - Number(left.canonical); + })[0]?.stored; + return newest ? normalizeCampaignState(newest) : undefined; +} + function advanceBond(bond: CampBondSnapshot, amount: number) { const gained = normalizeNonNegativeInteger(amount); if (gained <= 0) { @@ -3700,6 +4360,50 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp }; } +function reportFromSettledVictory( + settlement: CampaignBattleSettlement, + fallbackReport: FirstBattleReport, + state: CampaignState +) { + const recovered = cloneReport({ + ...fallbackReport, + battleId: settlement.battleId, + battleTitle: settlement.battleTitle, + outcome: 'victory', + turnNumber: settlement.turnNumber, + rewardGold: settlement.rewardGold, + objectives: settlement.objectives.map((objective) => ({ + ...objective + })), + itemRewards: [...settlement.itemRewards], + campaignRewards: cloneCampaignRewardSnapshot( + settlement.campaignRewards, + settlement.battleId + ), + sortiePerformance: settlement.sortiePerformance, + sortieReview: settlement.sortieReview, + sortieOrder: settlement.sortieOrder, + sortieRecommendation: settlement.sortieRecommendation, + sortieCooperation: settlement.sortieCooperation, + cityEquipmentContribution: + settlement.cityEquipmentContribution, + xuzhouStayBattlePayoff: + settlement.xuzhouStayBattlePayoff, + createdAt: settlement.completedAt + }); + recovered.units = + preserveCampaignUnitProgressOnSettlementReplay( + recovered.units, + state.roster + ); + recovered.bonds = + preserveCampaignBondProgressOnSettlementReplay( + recovered.bonds, + state.bonds + ); + return recovered; +} + function applyRewardDelta(inventory: Record, rewards: string[], direction: 1 | -1) { return rewards.reduce>((nextInventory, reward) => { const parsed = parseRewardLabel(reward); @@ -3718,6 +4422,58 @@ function applyRewardDelta(inventory: Record, rewards: string[], }, { ...inventory }); } +function preserveCampaignUnitProgressOnSettlementReplay( + reportUnits: UnitData[], + roster: UnitData[] +) { + const rosterById = new Map( + roster.map((unit) => [unit.id, unit]) + ); + return reportUnits.map((unit) => { + const current = + unit.faction === 'ally' + ? rosterById.get(unit.id) + : undefined; + return cloneUnit(current ?? unit); + }); +} + +function preserveCampaignBondProgressOnSettlementReplay( + reportBonds: CampBondSnapshot[], + campaignBonds: CampBondSnapshot[] +) { + const campaignBondById = new Map( + campaignBonds.map((bond) => [bond.id, bond]) + ); + const reportBondIds = new Set( + reportBonds.map((bond) => bond.id) + ); + return [ + ...reportBonds.map((bond) => + cloneBondSnapshot( + campaignBondById.get(bond.id) ?? bond + ) + ), + ...campaignBonds + .filter((bond) => !reportBondIds.has(bond.id)) + .map(cloneBondSnapshot) + ]; +} + +function cloneCampaignUnitProgressSnapshot( + unit: CampaignUnitProgressSnapshot +): CampaignUnitProgressSnapshot { + return { + ...unit, + equipment: equipmentSlots.reduce< + UnitData['equipment'] + >((equipment, slot) => { + equipment[slot] = { ...unit.equipment[slot] }; + return equipment; + }, {} as UnitData['equipment']) + }; +} + function mergeRosterProgress(currentRoster: UnitData[], deployedUnits: UnitData[]) { const merged = new Map(); currentRoster.forEach((unit) => merged.set(unit.id, cloneUnit(unit))); @@ -3827,6 +4583,203 @@ function cloneCampaignState(state: CampaignState): CampaignState { return JSON.parse(JSON.stringify(state)) as CampaignState; } +function normalizeCampaignExplorationCheckpoint( + value: unknown, + campaign: Pick< + CampaignState, + 'step' | 'activeCampVisitId' | 'activeCityStayId' + >, + mode: CampaignExplorationCheckpointNormalizationMode = 'stored' +): CampaignExplorationCheckpoint | undefined { + if ( + !isPlainObject(value) || + value.version !== 1 || + typeof value.contextId !== 'string' || + !campaignExplorationCheckpointContextMatches( + value.scene, + value.contextId, + campaign + ) || + !isPlainObject(value.player) + ) { + return undefined; + } + + const x = Number(value.player.x); + const y = Number(value.player.y); + const direction = value.player.direction; + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + x < 0 || + x > 1920 || + y < 0 || + y > 1080 || + typeof direction !== 'string' || + !campaignExplorationDirectionSet.has( + direction as CampaignExplorationDirection + ) + ) { + return undefined; + } + + const scene = + value.scene as CampaignExplorationCheckpoint['scene']; + const overlay = + normalizeCampaignExplorationCheckpointOverlay( + value.overlay, + scene, + value.contextId + ); + if (overlay.status === 'invalid' && mode === 'write') { + return undefined; + } + return { + version: 1, + scene, + contextId: value.contextId, + player: { + x: Math.round(x * 100) / 100, + y: Math.round(y * 100) / 100, + direction: direction as CampaignExplorationDirection + }, + ...(overlay.status === 'valid' + ? { overlay: overlay.overlay } + : {}), + savedAt: + normalizeCampaignTimestamp(value.savedAt) ?? + new Date().toISOString() + }; +} + +function campaignExplorationCheckpointContextMatches( + scene: unknown, + contextId: string, + campaign: Pick< + CampaignState, + 'step' | 'activeCampVisitId' | 'activeCityStayId' + > +) { + if (scene === 'camp-visit-exploration') { + return campaign.activeCampVisitId === contextId; + } + if (scene === 'city-stay') { + return campaign.activeCityStayId === contextId; + } + if (scene === 'prologue-village') { + return ( + (campaign.step === 'prologue' || + campaign.step === 'prologue-town') && + contextId === 'prologue-village' + ); + } + if (scene === 'prologue-militia-camp') { + return ( + (campaign.step === 'prologue' || + campaign.step === 'prologue-camp') && + contextId === 'prologue-militia-camp' + ); + } + return false; +} + +function campaignExplorationCheckpointOverlayPolicy( + scene: CampaignExplorationCheckpoint['scene'], + contextId: string +) { + return ( + campaignExplorationCheckpointOverlayPolicies[ + `${scene}:${contextId}` + ] ?? + (scene === 'city-stay' + ? campaignExplorationCheckpointOverlayPolicies['city-stay:*'] + : undefined) + ); +} + +function normalizeCampaignExplorationCheckpointOverlay( + value: unknown, + scene: CampaignExplorationCheckpoint['scene'], + contextId: string +): CampaignExplorationCheckpointOverlayNormalization { + if (value === undefined) { + return { status: 'absent' }; + } + if (!isPlainObject(value)) { + return { status: 'invalid' }; + } + const policy = + campaignExplorationCheckpointOverlayPolicy(scene, contextId); + if (!policy) { + return { status: 'invalid' }; + } + const sourceNpcId = normalizeKeyString(value.sourceNpcId); + if (value.type === 'dialogue') { + const dialogueKey = value.dialogueKey; + if ( + typeof dialogueKey !== 'string' || + !campaignExplorationDialogueKeySet.has(dialogueKey) || + !policy.dialogueKeys.includes( + dialogueKey as CampaignExplorationDialogueKey + ) + ) { + return { status: 'invalid' }; + } + return { + status: 'valid', + overlay: { + type: 'dialogue', + dialogueKey: + dialogueKey as CampaignExplorationDialogueKey, + ...(sourceNpcId ? { sourceNpcId } : {}), + lineIndex: Math.min( + 99, + normalizeNonNegativeInteger(value.lineIndex) + ) + } + }; + } + if ( + value.type === 'choice' && + (value.mode === 'visit' || + value.mode === 'third-companion' || + value.mode === 'city-resonance' || + value.mode === 'prologue-command') + ) { + if ( + !policy.choiceModes.includes(value.mode) || + (value.mode === 'prologue-command' + ? value.choiceId !== undefined && + !isSortieOrderId(value.choiceId) + : value.choiceId !== undefined) + ) { + return { status: 'invalid' }; + } + return { + status: 'valid', + overlay: { + type: 'choice', + mode: value.mode, + ...(sourceNpcId ? { sourceNpcId } : {}), + ...(value.mode === 'prologue-command' && + isSortieOrderId(value.choiceId) + ? { choiceId: value.choiceId } + : {}) + } + }; + } + if (value.type === 'shop' && policy.shop && sourceNpcId) { + return { + status: 'valid', + overlay: { + type: 'shop', + sourceNpcId + } + }; + } + return { status: 'invalid' }; +} + function normalizeActiveCityStayId( cityStayId: unknown, latestBattleId: string | undefined, diff --git a/src/game/state/cityStayActions.ts b/src/game/state/cityStayActions.ts index 320f1f6..4d1e666 100644 --- a/src/game/state/cityStayActions.ts +++ b/src/game/state/cityStayActions.ts @@ -19,8 +19,11 @@ import { import { applyCampBondExp, applyCampVisitReward, + campaignInventoryCap, getCampaignState, + normalizeCityEquipmentPurchaseIntentId, saveCampaignState, + type CampaignExplorationCheckpoint, type CampaignState, type FirstBattleReport } from './campaignState'; @@ -58,7 +61,8 @@ export type CityResonanceActionResult = 'invalid-city' | 'invalid-action' | 'already-completed' | - 'bond-unavailable' + 'bond-unavailable' | + 'save-unavailable' >; export type CityEquipmentPurchaseResult = @@ -68,13 +72,18 @@ export type CityEquipmentPurchaseResult = offer: CityEquipmentOffer; item: ItemDefinition; purchaseNumber: number; + replayed: boolean; campaign: CampaignState; } | CityStayActionFailure< 'invalid-city' | 'invalid-action' | + 'invalid-purchase-intent' | + 'intent-conflict' | 'restricted-item' | 'insufficient-gold' | + 'inventory-full' | + 'purchase-limit-reached' | 'save-unavailable' >; @@ -96,7 +105,11 @@ export type CityEquipmentEquipIntentResult = export function collectCityStayInformation( cityStayId: CityStayId, - actionId: string + actionId: string, + postActionCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > ): CityInformationActionResult { const cityStay = canonicalCityStay(cityStayId); if (!cityStay) { @@ -114,11 +127,24 @@ export function collectCityStayInformation( return { ok: false, reason: 'already-completed' }; } - const updated = applyCampVisitReward( - cityStay.information.id, - { itemRewards: [cityStay.information.itemReward] }, - 'city-information-gathered' - ); + let updated: CampaignState | undefined; + try { + updated = applyCampVisitReward( + cityStay.information.id, + { itemRewards: [cityStay.information.itemReward] }, + 'city-information-gathered', + { + ...(postActionCheckpoint + ? { + explorationCheckpoint: + postActionCheckpoint + } + : {}) + } + ); + } catch { + return { ok: false, reason: 'save-unavailable' }; + } if (!updated) { return { ok: false, reason: 'save-unavailable' }; } @@ -133,7 +159,11 @@ export function collectCityStayInformation( export function chooseCityStayResonance( cityStayId: CityStayId, - actionId: string + actionId: string, + postActionCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > ): CityResonanceActionResult { const cityStay = canonicalCityStay(cityStayId); if (!cityStay) { @@ -155,7 +185,22 @@ export function chooseCityStayResonance( } const rewardExp = dialogue.rewardExp + choice.rewardExp; - const report = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp, choice.id); + let report: FirstBattleReport | undefined; + try { + report = applyCampBondExp( + dialogue.id, + dialogue.bondId, + rewardExp, + choice.id, + { + ...(postActionCheckpoint + ? { explorationCheckpoint: postActionCheckpoint } + : {}) + } + ); + } catch { + return { ok: false, reason: 'save-unavailable' }; + } if (!report) { return { ok: false, reason: 'bond-unavailable' }; } @@ -173,7 +218,8 @@ export function chooseCityStayResonance( export function purchaseCityStayEquipment( cityStayId: CityStayId, - actionId: string + actionId: string, + purchaseIntentId: string ): CityEquipmentPurchaseResult { const cityStay = canonicalCityStay(cityStayId); if (!cityStay) { @@ -189,11 +235,37 @@ export function purchaseCityStayEquipment( } const { offer, item } = canonicalOffer; + const intentId = + normalizeCityEquipmentPurchaseIntentId(purchaseIntentId); + if (!intentId) { + return { ok: false, reason: 'invalid-purchase-intent' }; + } + + const snapshot = getCampaignState(); + const previousReceipt = + snapshot.cityEquipmentPurchaseReceipts[intentId]; + if (previousReceipt) { + if ( + previousReceipt.cityStayId !== cityStay.id || + previousReceipt.offerId !== offer.id + ) { + return { ok: false, reason: 'intent-conflict' }; + } + return { + ok: true, + cityStay, + offer, + item, + purchaseNumber: previousReceipt.purchaseNumber, + replayed: true, + campaign: snapshot + }; + } + if (item.rank !== 'common') { return { ok: false, reason: 'restricted-item' }; } - const snapshot = getCampaignState(); if (!campaignSupportsCityStay(snapshot, cityStay)) { return { ok: false, reason: 'invalid-city' }; } @@ -202,13 +274,21 @@ export function purchaseCityStayEquipment( } const label = itemInventoryLabel(item.id); + if ( + (snapshot.inventory[label] ?? 0) >= + campaignInventoryCap(label) + ) { + return { ok: false, reason: 'inventory-full' }; + } const purchaseCounts = normalizeCityEquipmentPurchaseCounts( snapshot.cityEquipmentPurchaseCounts ); - const purchaseNumber = Math.min( - 99, - (purchaseCounts[offer.id] ?? 0) + 1 - ); + const previousPurchaseCount = purchaseCounts[offer.id] ?? 0; + if (previousPurchaseCount >= 99) { + return { ok: false, reason: 'purchase-limit-reached' }; + } + const purchaseNumber = previousPurchaseCount + 1; + const completedAt = new Date().toISOString(); const updated: CampaignState = { ...snapshot, gold: snapshot.gold - offer.price, @@ -219,6 +299,17 @@ export function purchaseCityStayEquipment( cityEquipmentPurchaseCounts: { ...purchaseCounts, [offer.id]: purchaseNumber + }, + cityEquipmentPurchaseReceipts: { + ...snapshot.cityEquipmentPurchaseReceipts, + [intentId]: { + version: 1, + intentId, + cityStayId: cityStay.id, + offerId: offer.id, + purchaseNumber, + completedAt + } } }; const persisted = persistPurchase(updated, snapshot); @@ -232,6 +323,7 @@ export function purchaseCityStayEquipment( offer, item, purchaseNumber, + replayed: false, campaign: persisted }; } diff --git a/src/game/state/firstPursuitScoutActions.ts b/src/game/state/firstPursuitScoutActions.ts index 7359d9e..dee3ac8 100644 --- a/src/game/state/firstPursuitScoutActions.ts +++ b/src/game/state/firstPursuitScoutActions.ts @@ -11,6 +11,7 @@ import { applyCampVisitReward, getCampaignState, setCampaignState, + type CampaignExplorationCheckpoint, type CampaignState } from './campaignState'; @@ -32,7 +33,11 @@ export type FirstPursuitScoutActionResult = }; export function completeFirstPursuitScoutVisit( - choiceId: string + choiceId: string, + postActionCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > ): FirstPursuitScoutActionResult { let campaign: CampaignState; try { @@ -69,7 +74,15 @@ export function completeFirstPursuitScoutVisit( bondExp: choice.bondExp, itemRewards: [...choice.itemRewards] }, - choice.id + choice.id, + { + ...(postActionCheckpoint + ? { + explorationCheckpoint: + postActionCheckpoint + } + : {}) + } ); } catch { try { diff --git a/src/game/state/secondBattleReliefActions.ts b/src/game/state/secondBattleReliefActions.ts index 91839f8..1eb5359 100644 --- a/src/game/state/secondBattleReliefActions.ts +++ b/src/game/state/secondBattleReliefActions.ts @@ -20,6 +20,7 @@ import { applyCampVisitReward, getCampaignState, setCampaignState, + type CampaignExplorationCheckpoint, type CampaignState } from './campaignState'; @@ -93,7 +94,11 @@ export function secondBattleReliefProgress( } export function recordSecondBattleReliefObjective( - objectiveId: string + objectiveId: string, + postActionCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > ): SecondBattleReliefObjectiveActionResult { let campaign: CampaignState; try { @@ -138,7 +143,9 @@ export function recordSecondBattleReliefObjective( const updated = persistReliefProgress( campaign, secondBattleReliefObjectiveProgressId(objective.id), - {} + {}, + undefined, + postActionCheckpoint ); if (!updated) { return { ok: false, reason: 'save-unavailable' }; @@ -156,7 +163,11 @@ export function recordSecondBattleReliefObjective( } export function completeSecondBattleReliefExploration( - choiceId: string + choiceId: string, + postActionCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > ): SecondBattleReliefCompletionResult { let campaign: CampaignState; try { @@ -190,7 +201,8 @@ export function completeSecondBattleReliefExploration( bondExp: choice.bondExp, itemRewards: [...choice.itemRewards] }, - choice.id + choice.id, + postActionCheckpoint ); if (!updated) { return { ok: false, reason: 'save-unavailable' }; @@ -235,13 +247,25 @@ function persistReliefProgress( bondExp?: number; itemRewards?: string[]; }, - choiceId?: string + choiceId?: string, + postActionCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > ) { try { return applyCampVisitReward( visitId, reward, - choiceId + choiceId, + { + ...(postActionCheckpoint + ? { + explorationCheckpoint: + postActionCheckpoint + } + : {}) + } ); } catch { restoreCampaignSnapshot(snapshot); diff --git a/src/game/state/thirdCampExplorationActions.ts b/src/game/state/thirdCampExplorationActions.ts index 87c6076..06b90df 100644 --- a/src/game/state/thirdCampExplorationActions.ts +++ b/src/game/state/thirdCampExplorationActions.ts @@ -22,6 +22,7 @@ import { applyCampBondExp, getCampaignState, setCampaignState, + type CampaignExplorationCheckpoint, type CampaignState } from './campaignState'; @@ -144,7 +145,12 @@ export function hasSeenThirdCampExplorationIntro( ); } -export function completeThirdCampExplorationIntro(): +export function completeThirdCampExplorationIntro( + postActionCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > +): ThirdCampExplorationIntroCompletionResult { const snapshot = readCampaignSnapshot(); if (!snapshot) { @@ -154,20 +160,26 @@ export function completeThirdCampExplorationIntro(): return { ok: false, reason: 'invalid-campaign' }; } - const updated = withVisitMarkers(snapshot, [ + const markerUpdated = withVisitMarkers(snapshot, [ thirdCampExplorationVisitId, thirdCampExplorationIntroSeenId ]); - if ( - updated.completedCampVisits.length === - snapshot.completedCampVisits.length - ) { + const changed = + markerUpdated.completedCampVisits.length !== + snapshot.completedCampVisits.length; + if (!changed && !postActionCheckpoint) { return { ok: true, campaign: snapshot, changed: false }; } - const persisted = persistCampaign(updated, snapshot); + const persisted = persistCampaign( + withExplorationCheckpoint( + markerUpdated, + postActionCheckpoint + ), + snapshot + ); return persisted - ? { ok: true, campaign: persisted, changed: true } + ? { ok: true, campaign: persisted, changed } : { ok: false, reason: 'save-unavailable' }; } @@ -207,7 +219,11 @@ export function enterThirdCampExploration(): } export function recordThirdCampExplorationActivity( - priorityId: string + priorityId: string, + postActionCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > ): ThirdCampExplorationActivityResult { const snapshot = readCampaignSnapshot(); if (!snapshot) { @@ -238,7 +254,9 @@ export function recordThirdCampExplorationActivity( const persisted = persistActivityCompletion( snapshot, - priorityId + priorityId, + snapshot, + postActionCheckpoint ); if (!persisted) { return { ok: false, reason: 'save-unavailable' }; @@ -257,7 +275,11 @@ export function recordThirdCampExplorationActivity( } export function completeThirdCampCompanionActivity( - choiceId: string + choiceId: string, + postActionCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > ): ThirdCampCompanionActivityResult { const snapshot = readCampaignSnapshot(); if (!snapshot) { @@ -282,29 +304,40 @@ export function completeThirdCampCompanionActivity( const dialogueAlreadyCompleted = snapshot.completedCampDialogues.includes(dialogue.id); - if (!dialogueAlreadyCompleted) { - const report = applyCompanionBondReward( - snapshot, - dialogue, - choice - ); - if (!report) { - return { ok: false, reason: 'bond-unavailable' }; - } - } - const rewardExp = dialogue.rewardExp + choice.rewardExp; - const afterDialogue = readCampaignSnapshot(); - if (!afterDialogue) { + let report; + try { + report = applyCampBondExp( + dialogue.id, + dialogue.bondId, + rewardExp, + choice.id, + { + completedVisitIds: [ + thirdCampExplorationVisitId, + thirdCampExplorationActivityProgressId( + 'companion' + ) + ], + ...(postActionCheckpoint + ? { + explorationCheckpoint: + postActionCheckpoint + } + : {}) + } + ); + } catch { restoreCampaignSnapshot(snapshot); return { ok: false, reason: 'save-unavailable' }; } - const persisted = persistActivityCompletion( - afterDialogue, - 'companion', - snapshot - ); + if (!report) { + restoreCampaignSnapshot(snapshot); + return { ok: false, reason: 'bond-unavailable' }; + } + const persisted = readCampaignSnapshot(); if (!persisted) { + restoreCampaignSnapshot(snapshot); return { ok: false, reason: 'save-unavailable' }; } @@ -361,7 +394,11 @@ function hasCompletedCompanionActivity(campaign: CampaignState) { function persistActivityCompletion( current: CampaignState, activityId: ThirdCampExplorationActivityId, - rollbackSnapshot: CampaignState = current + rollbackSnapshot: CampaignState = current, + postActionCheckpoint?: Omit< + CampaignExplorationCheckpoint, + 'savedAt' + > ) { const activityMarker = thirdCampExplorationActivityProgressId(activityId); @@ -374,7 +411,13 @@ function persistActivityCompletion( acknowledgedVictoryRewardCategories: acknowledgedRewardCategoryForActivity(updated, activityId) }; - return persistCampaign(updated, rollbackSnapshot); + return persistCampaign( + withExplorationCheckpoint( + updated, + postActionCheckpoint + ), + rollbackSnapshot + ); } function acknowledgedRewardCategoryForActivity( @@ -428,27 +471,26 @@ function withVisitMarkers( }; } -function applyCompanionBondReward( - snapshot: CampaignState, - dialogue: ThirdCampCompanionDialogueDefinition, - choice: ThirdCampCompanionDialogueChoice -) { - try { - const rewardExp = dialogue.rewardExp + choice.rewardExp; - const report = applyCampBondExp( - dialogue.id, - dialogue.bondId, - rewardExp, - choice.id - ); - if (!report) { - restoreCampaignSnapshot(snapshot); - } - return report; - } catch { - restoreCampaignSnapshot(snapshot); - return undefined; +function withExplorationCheckpoint( + campaign: CampaignState, + checkpoint: + | Omit + | undefined +): CampaignState { + if (!checkpoint) { + return campaign; } + return { + ...campaign, + explorationCheckpoint: { + ...checkpoint, + player: { ...checkpoint.player }, + ...(checkpoint.overlay + ? { overlay: { ...checkpoint.overlay } } + : {}), + savedAt: new Date().toISOString() + } + }; } function readCampaignSnapshot() {