From 7b30f0f60bf5471db6f024c94d95389a97d07693 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 28 Jul 2026 13:22:32 +0900 Subject: [PATCH] feat: preserve camp exploration resume context --- docs/game-design-improvement-goal.md | 28 +- package.json | 4 +- scripts/verify-camp-visit-resume-browser.mjs | 956 ++++++++++++++++++ scripts/verify-camp-visit-resume-state.mjs | 584 +++++++++++ scripts/verify-static-data.mjs | 1 + scripts/verify-third-camp-preparation.mjs | 140 ++- src/game/data/thirdCampExploration.ts | 2 + src/game/scenes/CampScene.ts | 18 + src/game/scenes/CampVisitExplorationScene.ts | 45 +- src/game/scenes/PrologueMilitiaCampScene.ts | 5 +- src/game/scenes/PrologueVillageScene.ts | 5 +- src/game/scenes/TitleScene.ts | 46 +- src/game/state/campaignState.ts | 141 +++ src/game/state/thirdCampExplorationActions.ts | 51 + 14 files changed, 2004 insertions(+), 22 deletions(-) create mode 100644 scripts/verify-camp-visit-resume-browser.mjs create mode 100644 scripts/verify-camp-visit-resume-state.mjs diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 35dbfbd..4470bdf 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -349,9 +349,31 @@ RPG로 다듬는다. Canvas·WebGL의 1920x1080 CSS viewport, 100% 확대, DPR 1에서 포인터·J·Esc 재열기, 카드 경계·비겹침·저장 불변을 자동 검증한다. -다음 묶음은 탐색 도중 저장한 기록이 군영으로 되돌아가지 않도록 -`activeCampVisitId`와 이어하기 요약을 보존하고, 저장 슬롯에서 -`방금 마친 일 → 지금 할 일`까지 충분히 읽을 수 있는 재개 흐름을 다듬는다. +### 탐색 재개와 읽지 않은 도입 보존 (2026-07-28) + +- 불러오기는 허브가 아니라 저장한 지점으로 돌아와야 하고 대화 중 저장을 + 막거나 허브로 돌려보내는 구현은 실패 사례라는 + [Microsoft Save Anytime 기준](https://learn.microsoft.com/en-us/xbox/accessibility/accessibility-feature-tags#gameplay-features---save-anytime)을 + 현재 체류 흐름의 기준으로 삼았다. +- 중단 지점에서 다시 시작하는 + [Fire Emblem: Shadow Dragon 공식 Suspend Point](https://www.nintendo.com/eu/media/downloads/games_8/emanuals/nintendo_ds_21/Manual_NintendoDS_FireEmblemShadowDragon_EN.pdf)와 + [Dragon Quest IX 공식 Quick-save](https://csassets.nintendo.com/noaext/image/private/t_KA_PDF/DS_Dragon_Quest_IX?_a=DATC1RAAZAA0)처럼, + 캠프 단계만 저장하지 않고 현재 탐색 장소를 독립된 재개 상태로 보존한다. +- `activeCampVisitId`는 첫 정찰 막사, 북쪽 마을·나루 구호 현장, 광종 + 연합군 주둔지의 안정 ID만 허용한다. 해당 승리·캠페인 단계가 일치할 + 때만 복원하며, 구버전·손상·이미 지난 저장에서는 자동으로 제거한다. +- 군영에서 탐색으로 전환하기 직전에 재개 상태를 저장하고, 탐색 장면도 + 직접 진입과 재로드를 방어한다. 정상적으로 군영에 복귀할 때만 지우며 + 장면 로딩 실패 시에는 이전 값을 복원한다. +- 타이틀 이어하기는 일반 군영보다 현재 탐색을 먼저 복원한다. 저장 + 카드에는 탐색 장소와 `방금 마친 일 → 지금 할 일`을 함께 요약한다. +- 마을·의용군 막사·광종 주둔지의 도입은 장면에 들어온 순간이 아니라 + 마지막 대사를 읽은 순간에만 완독 처리한다. 대화 도중 종료하면 다음 + 재개에서 첫 줄부터 안전하게 다시 들려준다. + +다음 묶음은 버전이 있는 `explorationCheckpoint`로 주인공 좌표·방향과 +대화 상대·대사 위치·선택창을 안정 ID로 보존하고, 선택·구매·공명 보상이 +재로드 경계에서도 정확히 한 번만 적용되는지 검증한다. ## 장기 개선 순서 diff --git a/package.json b/package.json index 115f3ce..94029df 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "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: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: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", @@ -88,7 +90,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: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: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-visit-resume-browser.mjs b/scripts/verify-camp-visit-resume-browser.mjs new file mode 100644 index 0000000..cf217f7 --- /dev/null +++ b/scripts/verify-camp-visit-resume-browser.mjs @@ -0,0 +1,956 @@ +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_VISIT_RESUME_RENDERER; +const rendererNames = ['canvas', 'webgl']; +const visitCases = [ + { + visitId: 'first-pursuit-scout-tent', + battleId: 'first-battle-zhuo-commandery', + step: 'first-camp', + verifyNormalReturn: true + }, + { + visitId: 'second-pursuit-aftermath-relief', + battleId: 'second-battle-yellow-turban-pursuit', + step: 'second-camp', + verifyNormalReturn: false + } +]; + +if (!renderer) { + for (const requestedRenderer of rendererNames) { + const result = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url)], + { + cwd: process.cwd(), + env: { + ...process.env, + VERIFY_CAMP_VISIT_RESUME_RENDERER: + requestedRenderer + }, + stdio: 'inherit' + } + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } + process.exit(0); +} + +assert( + rendererNames.includes(renderer), + `Unsupported camp visit resume renderer "${renderer}".` +); + +const baseUrl = + process.env.VERIFY_CAMP_VISIT_RESUME_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_VISIT_RESUME_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-visit-resume-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); + + const results = []; + for (const [visitIndex, visitCase] of visitCases.entries()) { + let seeded; + if (isLocalUrl(targetUrl)) { + seeded = await seedCampVisitResume(page, visitCase); + } else { + const bridge = await createSeedBridge( + browser, + localSeedUrl, + visitCase + ); + seedServerProcess ??= bridge.serverProcess; + seeded = bridge.seeded; + await installCampaignSaves(page, bridge.campaignSaves); + } + + assertSeededVisit(seeded, visitCase); + await reloadToTitle(page); + assertRuntimeBaseline( + await readRuntimeProbe(page, 'TitleScene'), + renderer, + `${visitCase.visitId} title` + ); + if (visitIndex === 0) { + await page.screenshot({ + path: `dist/camp-visit-resume-title-${renderer}.png` + }); + } + + const continueBounds = await readEnabledContinueBounds(page); + assertBoundsContained( + continueBounds, + desktopBrowserViewportBounds, + `${renderer} ${visitCase.visitId} continue button` + ); + await clickSceneBounds( + page, + 'TitleScene', + continueBounds + ); + const exploration = await waitForExplorationReady( + page, + visitCase.visitId + ); + assert.equal(exploration.locationId, visitCase.visitId); + assert.equal(exploration.visit.id, visitCase.visitId); + assert.equal(exploration.campaignStep, visitCase.step); + assertRuntimeBaseline( + await readRuntimeProbe( + page, + 'CampVisitExplorationScene' + ), + renderer, + `${visitCase.visitId} resumed exploration` + ); + if (visitIndex === 0) { + await page.screenshot({ + path: + `dist/camp-visit-resume-exploration-${renderer}.png` + }); + } + assertActiveVisitMarker( + await readCampaignStates(page), + visitCase.visitId, + `${renderer} ${visitCase.visitId} after continue` + ); + + const result = { + visitId: visitCase.visitId, + titleContinueClicked: true, + resumedScene: exploration.scene, + markerRestored: true + }; + + if (visitCase.verifyNormalReturn) { + const returnResult = + await returnIncompleteVisitThroughExit(page, visitCase); + Object.assign(result, returnResult); + } + results.push(result); + } + + 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%' + }, + visits: results, + 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 seedCampVisitResume(page, visitCase) { + 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.battleId]; + if (!scenario) { + throw new Error( + `Missing battle scenario ${requested.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(requested.visitId); + const campaign = campaignModule.getCampaignState(); + const slot = campaignModule.readCampaignSaveState(1); + return { + step: campaign.step, + latestBattleId: campaign.latestBattleId ?? null, + reportBattleId: + campaign.firstBattleReport?.battleId ?? null, + reportOutcome: + campaign.firstBattleReport?.outcome ?? null, + settlementOutcome: + campaign.battleHistory[requested.battleId]?.outcome ?? + null, + pendingAftermathBattleId: + campaign.pendingAftermathBattleId ?? null, + activeCampVisitId: + campaign.activeCampVisitId ?? null, + slotActiveCampVisitId: + slot?.activeCampVisitId ?? null + }; + }, visitCase); +} + +function assertSeededVisit(seeded, visitCase) { + assert.deepEqual( + seeded, + { + step: visitCase.step, + latestBattleId: visitCase.battleId, + reportBattleId: visitCase.battleId, + reportOutcome: 'victory', + settlementOutcome: 'victory', + pendingAftermathBattleId: null, + activeCampVisitId: visitCase.visitId, + slotActiveCampVisitId: visitCase.visitId + }, + `${renderer}: failed to seed ${visitCase.visitId}.` + ); +} + +async function createSeedBridge( + activeBrowser, + seedUrl, + visitCase +) { + 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 seedCampVisitResume(page, visitCase); + const campaignSaves = await readCampaignSaves(page); + assert( + Object.keys(campaignSaves).length > 0, + `Local seed bridge did not create ${visitCase.visitId}.` + ); + return { + campaignSaves, + seeded, + 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( + (item) => item.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 readEnabledContinueBounds(page) { + const item = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.title?.() + ?.focus?.items?.find((candidate) => + candidate.id === 'continue' + ) ?? null + ); + assert.equal( + item?.enabled, + true, + `${renderer}: title continue button is not enabled.` + ); + return assertFiniteBounds( + item.bounds, + `${renderer} title continue button` + ); +} + +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); +} + +async function waitForExplorationReady(page, visitId) { + try { + await page.waitForFunction( + (expectedVisitId) => { + const debug = window.__HEROS_DEBUG__; + const exploration = + debug?.campVisitExploration?.(); + return ( + debug + ?.activeScenes?.() + .includes('CampVisitExplorationScene') && + exploration?.ready === true && + exploration?.locationId === expectedVisitId && + exploration?.visit?.id === expectedVisitId && + exploration?.requiredTexturesReady === true + ); + }, + 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}: ${visitId} did not resume: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); + return page.evaluate(() => + window.__HEROS_DEBUG__?.campVisitExploration?.() + ); +} + +async function returnIncompleteVisitThroughExit( + page, + visitCase +) { + assert.equal( + visitCase.visitId, + 'first-pursuit-scout-tent' + ); + const before = await page.evaluate(() => + window.__HEROS_DEBUG__?.campVisitExploration?.() + ); + assert.equal( + before?.visit?.completed, + false, + `${renderer}: first scout visit seed must be incomplete.` + ); + + await page.waitForTimeout(400); + const teleported = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.debugTeleportTo?.('camp-exit') ?? false + ); + assert.equal( + teleported, + true, + `${renderer}: could not reach the camp exit.` + ); + await page.waitForFunction( + () => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + return ( + state?.interaction?.targetId === 'camp-exit' && + state?.interaction?.canInteract === true + ); + } + ); + + await page.keyboard.press('e'); + await page.waitForFunction( + () => { + const state = + window.__HEROS_DEBUG__?.campVisitExploration?.(); + return ( + state?.dialogue?.active === true && + state.dialogue.sourceNpcId === 'camp-exit' + ); + }, + undefined, + { timeout: 5000 } + ); + + for (let attempt = 0; attempt < 8; attempt += 1) { + const activeScenes = await page.evaluate( + () => + window.__HEROS_DEBUG__?.activeScenes?.() ?? [] + ); + if (activeScenes.includes('CampScene')) { + break; + } + const dialogueActive = await page.evaluate( + () => + window.__HEROS_DEBUG__ + ?.campVisitExploration?.() + ?.dialogue?.active === true + ); + if (dialogueActive) { + await page.keyboard.press('e'); + } + await page.waitForTimeout(180); + } + await waitForCampReady(page, visitCase.step); + assertRuntimeBaseline( + await readRuntimeProbe(page, 'CampScene'), + renderer, + 'first visit normal return' + ); + assertClearedVisitMarker( + await readCampaignStates(page), + `${renderer} first visit after normal return` + ); + + await page.evaluate(() => { + const camp = + window.__HEROS_DEBUG__?.scene('CampScene'); + if (!camp) { + throw new Error('CampScene is unavailable.'); + } + camp.scene.start('TitleScene'); + }); + await waitForTitleReadyWithoutReload(page); + const continueBounds = await readEnabledContinueBounds(page); + await clickSceneBounds( + page, + 'TitleScene', + continueBounds + ); + await waitForCampReady(page, visitCase.step); + assert( + !(await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('CampVisitExplorationScene') + )), + `${renderer}: cleared visit marker reopened exploration.` + ); + assertClearedVisitMarker( + await readCampaignStates(page), + `${renderer} first visit after second continue` + ); + + return { + normalExitReturnedTo: 'CampScene', + markerCleared: true, + secondTitleContinueReturnedTo: 'CampScene' + }; +} + +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 + })); + throw new Error( + `${renderer}: CampScene did not become ready: ` + + JSON.stringify(diagnostic), + { cause: error } + ); + } + await afterTwoFrames(page); +} + +async function waitForTitleReadyWithoutReload(page) { + await page.waitForFunction( + () => { + const debug = window.__HEROS_DEBUG__; + const title = debug?.title?.(); + return ( + debug?.activeScenes?.().includes('TitleScene') && + title?.scene === 'TitleScene' && + title?.navigating === false && + title?.focus?.items?.some( + (item) => + item.id === 'continue' && + item.enabled === true && + item.bounds + ) + ); + }, + undefined, + { timeout: 90000 } + ); + await afterTwoFrames(page); +} + +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 assertActiveVisitMarker(states, visitId, label) { + const entries = Object.entries(states); + assert( + entries.length >= 2, + `${label}: expected current and slot campaign saves.` + ); + entries.forEach(([key, state]) => { + assert.equal( + state.activeCampVisitId ?? null, + visitId, + `${label}: ${key} marker mismatch.` + ); + }); +} + +function assertClearedVisitMarker(states, label) { + const entries = Object.entries(states); + assert( + entries.length >= 2, + `${label}: expected current and slot campaign saves.` + ); + entries.forEach(([key, state]) => { + assert.equal( + state.activeCampVisitId ?? null, + null, + `${label}: ${key} retained an active visit marker.` + ); + }); +} + +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 new file mode 100644 index 0000000..0224ae0 --- /dev/null +++ b/scripts/verify-camp-visit-resume-state.mjs @@ -0,0 +1,584 @@ +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 fixtures = [ + { + visitId: firstPursuit.firstPursuitScoutVisitId, + sourceBattleId: firstPursuit.firstPursuitScoutSourceBattleId, + step: 'first-camp', + progress: { + title: '탁현 의용군 정찰 막사', + meta: '정찰 탐색 이어하기' + } + }, + { + visitId: secondRelief.secondBattleReliefVisitId, + sourceBattleId: secondRelief.secondBattleReliefSourceBattleId, + step: 'second-camp', + progress: { + title: '북쪽 마을·나루 구호', + meta: '현장 탐색 이어하기' + } + }, + { + visitId: thirdCamp.thirdCampExplorationVisitId, + sourceBattleId: thirdCamp.thirdCampExplorationSourceBattleId, + step: 'third-camp', + progress: { + title: '광종 연합군 주둔지', + meta: '출진 준비 이어하기' + } + } + ]; + + assert.deepEqual( + [...campaign.campaignCampVisitIds], + fixtures.map(({ visitId }) => visitId), + 'The exported camp-visit allowlist must contain the three resumable visits in campaign order.' + ); + + for (const fixture of fixtures) { + verifyValidSetterPersistence(fixture); + verifyNormalizationFailures(fixture); + verifyCampaignStepClearsVisit(fixture); + verifyBattleRecordUpdateClearsVisit(fixture, 'victory'); + verifyBattleRecordUpdateClearsVisit(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.' + ); + + function verifyValidSetterPersistence(fixture) { + storage.clear(); + campaign.setCampaignState(validCampVisitState(fixture, 2)); + + const activated = campaign.setActiveCampVisitId(fixture.visitId); + assert.equal( + activated.activeCampVisitId, + fixture.visitId, + `${fixture.visitId}: the valid setter call must activate the visit.` + ); + assert.equal( + activated.activeCityStayId, + undefined, + `${fixture.visitId}: activating a camp visit must leave no active city stay.` + ); + + assertPersistedVisit( + fixture.visitId, + 2, + `${fixture.visitId}: setter persistence` + ); + + const loadedFromBase = campaign.loadCampaignState(); + assert.equal( + loadedFromBase.activeCampVisitId, + fixture.visitId, + `${fixture.visitId}: base-save reload must retain the active visit.` + ); + const loadedFromSlot = campaign.loadCampaignState(2); + assert.equal( + loadedFromSlot.activeCampVisitId, + fixture.visitId, + `${fixture.visitId}: active-slot reload must retain the active visit.` + ); + + assert.deepEqual( + campaign.summarizeCampaignProgress(loadedFromSlot), + fixture.progress, + `${fixture.visitId}: campaign progress must describe the resumed exploration location.` + ); + const slotSummary = campaign + .listCampaignSaveSlots() + .find(({ slot }) => slot === 2); + assert.deepEqual( + { + title: slotSummary?.progressTitle, + meta: slotSummary?.progressMeta + }, + fixture.progress, + `${fixture.visitId}: the active save-slot summary must expose the same resume location and meta.` + ); + + const cleared = campaign.setActiveCampVisitId(); + assert.equal( + cleared.activeCampVisitId, + undefined, + `${fixture.visitId}: an empty setter call must clear the active visit.` + ); + assertPersistedVisit( + undefined, + 2, + `${fixture.visitId}: explicit clear persistence` + ); + + campaign.setCampaignState(validCampVisitState(fixture, 2)); + campaign.setActiveCampVisitId(fixture.visitId); + const invalidSetterResult = campaign.setActiveCampVisitId( + 'unknown-camp-visit' + ); + assert.equal( + invalidSetterResult.activeCampVisitId, + undefined, + `${fixture.visitId}: an unknown setter value must clear rather than retain a stale visit.` + ); + assertPersistedVisit( + undefined, + 2, + `${fixture.visitId}: invalid setter persistence` + ); + } + + function verifyNormalizationFailures(fixture) { + const invalidId = validCampVisitState(fixture); + invalidId.activeCampVisitId = 'unknown-camp-visit'; + assertNormalizedAway( + invalidId, + `${fixture.visitId}: unknown visit id` + ); + + const wrongStep = validCampVisitState(fixture); + wrongStep.step = 'fourth-camp'; + assertNormalizedAway( + wrongStep, + `${fixture.visitId}: mismatched campaign step` + ); + + const wrongLatestBattle = validCampVisitState(fixture); + const alternateBattleId = alternateBattleFor(fixture.sourceBattleId); + const alternateReport = battleReport(alternateBattleId, 'victory'); + wrongLatestBattle.battleHistory[alternateBattleId] = + battleSettlement(alternateReport); + wrongLatestBattle.latestBattleId = alternateBattleId; + assertNormalizedAway( + wrongLatestBattle, + `${fixture.visitId}: mismatched latest battle` + ); + + const defeatedSettlement = validCampVisitState(fixture); + defeatedSettlement.battleHistory[fixture.sourceBattleId].outcome = + 'defeat'; + assertNormalizedAway( + defeatedSettlement, + `${fixture.visitId}: defeated source settlement` + ); + + const missingReport = validCampVisitState(fixture); + delete missingReport.firstBattleReport; + assertNormalizedAway( + missingReport, + `${fixture.visitId}: missing firstBattleReport` + ); + + const defeatedReport = validCampVisitState(fixture); + defeatedReport.firstBattleReport.outcome = 'defeat'; + assertNormalizedAway( + defeatedReport, + `${fixture.visitId}: defeated firstBattleReport` + ); + + const wrongReportBattle = validCampVisitState(fixture); + wrongReportBattle.firstBattleReport = battleReport( + alternateBattleFor(fixture.sourceBattleId), + 'victory' + ); + assertNormalizedAway( + wrongReportBattle, + `${fixture.visitId}: mismatched firstBattleReport battle` + ); + + const pendingAftermath = validCampVisitState(fixture); + pendingAftermath.pendingAftermathBattleId = + fixture.sourceBattleId; + assertNormalizedAway( + pendingAftermath, + `${fixture.visitId}: pending victory aftermath` + ); + + storage.clear(); + const setterDuringAftermath = validCampVisitState(fixture); + setterDuringAftermath.pendingAftermathBattleId = + fixture.sourceBattleId; + campaign.setCampaignState(setterDuringAftermath); + assert.equal( + campaign.setActiveCampVisitId(fixture.visitId) + .activeCampVisitId, + undefined, + `${fixture.visitId}: a pending aftermath must reject exploration activation.` + ); + } + + function verifyCampaignStepClearsVisit(fixture) { + storage.clear(); + campaign.setCampaignState(validCampVisitState(fixture, 2)); + campaign.setActiveCampVisitId(fixture.visitId); + + const changed = campaign.markCampaignStep('fourth-camp'); + assert.equal( + changed.activeCampVisitId, + undefined, + `${fixture.visitId}: changing to an incompatible campaign step must clear the active visit.` + ); + assertPersistedVisit( + undefined, + 2, + `${fixture.visitId}: markCampaignStep persistence` + ); + } + + function verifyBattleRecordUpdateClearsVisit(fixture, outcome) { + storage.clear(); + campaign.setCampaignState(validCampVisitState(fixture, 2)); + campaign.setActiveCampVisitId(fixture.visitId); + + campaign.setFirstBattleReport( + battleReport(fixture.sourceBattleId, outcome) + ); + assert.equal( + campaign.getCampaignState().activeCampVisitId, + undefined, + `${fixture.visitId}: a ${outcome} battle-record update must clear the active visit.` + ); + assertPersistedVisit( + undefined, + 2, + `${fixture.visitId}: ${outcome} battle-record persistence` + ); + } + + function verifyCityMutualExclusion() { + const firstFixture = fixtures[0]; + const campState = validCampVisitState(firstFixture, 2); + campState.activeCampVisitId = firstFixture.visitId; + campState.activeCityStayId = 'xuzhou'; + const normalizedCamp = loadRawState(campState); + assert.equal( + normalizedCamp.activeCampVisitId, + firstFixture.visitId, + 'A valid camp visit must survive when a stale city marker is present.' + ); + assert.equal( + normalizedCamp.activeCityStayId, + undefined, + 'A stale city marker must normalize away in a valid camp-visit context.' + ); + + const cityState = validCityState(3); + cityState.activeCityStayId = 'xuzhou'; + cityState.activeCampVisitId = firstFixture.visitId; + const normalizedCity = loadRawState(cityState); + assert.equal( + normalizedCity.activeCityStayId, + 'xuzhou', + 'A valid city stay must survive when a stale camp-visit marker is present.' + ); + assert.equal( + normalizedCity.activeCampVisitId, + undefined, + 'A stale camp-visit marker must normalize away in a valid city context.' + ); + campaign.saveCampaignState(normalizedCity, 3); + assert.equal( + storedState(campaign.campaignStorageKey).activeCampVisitId, + undefined, + 'Persisting a normalized city state must remove the stale camp marker from the base save.' + ); + assert.equal( + storedState(slotKey(3)).activeCampVisitId, + undefined, + 'Persisting a normalized city state must remove the stale camp marker from the active slot.' + ); + + campaign.setCampaignState(validCampVisitState(firstFixture, 2)); + campaign.setActiveCampVisitId(firstFixture.visitId); + const invalidCitySetter = campaign.setActiveCityStayId('xuzhou'); + assert.equal( + invalidCitySetter.activeCampVisitId, + firstFixture.visitId, + 'An invalid city setter call must not erase a valid camp-visit resume marker.' + ); + + campaign.setCampaignState(validCityState(3)); + campaign.setActiveCityStayId('xuzhou'); + const invalidCampSetter = campaign.setActiveCampVisitId( + firstFixture.visitId + ); + assert.equal( + invalidCampSetter.activeCityStayId, + 'xuzhou', + 'An invalid camp setter call must not erase a valid city-stay marker.' + ); + assert.equal( + invalidCampSetter.activeCampVisitId, + undefined, + 'An invalid camp setter call in a city context must not create a camp marker.' + ); + } + + function verifyLegacySaveWithoutCampVisitField() { + const fixture = fixtures[0]; + const legacy = validCampVisitState(fixture, 1); + delete legacy.activeCampVisitId; + delete legacy.activeCityStayId; + + const loaded = loadRawState(legacy); + assert.equal( + loaded.activeCampVisitId, + undefined, + 'A legacy campaign save without activeCampVisitId must load safely.' + ); + assert.deepEqual( + campaign.summarizeCampaignProgress(loaded), + { + title: battleScenarios[fixture.sourceBattleId].title, + meta: '승리 후 군영' + }, + 'A legacy save without the new field must keep the established camp summary.' + ); + + campaign.saveCampaignState(loaded, 1); + assert.equal( + Object.hasOwn(storedState(campaign.campaignStorageKey), 'activeCampVisitId'), + false, + 'Saving a legacy state must not synthesize an inactive camp-visit field in the base save.' + ); + assert.equal( + Object.hasOwn(storedState(slotKey(1)), 'activeCampVisitId'), + false, + 'Saving a legacy state must not synthesize an inactive camp-visit field in the active slot.' + ); + + const minimalLegacy = { + updatedAt: '2026-07-01T00:00:00.000Z', + step: 'first-camp', + activeSaveSlot: 1, + gold: 73 + }; + const minimalLoaded = loadRawState(minimalLegacy); + assert.equal(minimalLoaded.version, 1); + assert.equal(minimalLoaded.step, 'first-camp'); + assert.equal(minimalLoaded.gold, 73); + assert.equal(minimalLoaded.activeCampVisitId, undefined); + } + + function assertNormalizedAway(state, label) { + const slot = state.activeSaveSlot ?? 1; + const loadedFromBase = loadRawState(state); + assert.equal( + loadedFromBase.activeCampVisitId, + undefined, + `${label}: base-save normalization must remove the active visit.` + ); + + storage.clear(); + const serialized = JSON.stringify(state); + storage.set(campaign.campaignStorageKey, serialized); + storage.set(slotKey(slot), serialized); + const loadedFromSlot = campaign.loadCampaignState(slot); + assert.equal( + loadedFromSlot.activeCampVisitId, + undefined, + `${label}: slot-save normalization must remove the active visit.` + ); + + campaign.saveCampaignState(loadedFromSlot, slot); + assertPersistedVisit(undefined, slot, `${label}: normalized persistence`); + } + + function assertPersistedVisit(expectedVisitId, slot, label) { + const base = storedState(campaign.campaignStorageKey); + const slotted = storedState(slotKey(slot)); + assert(base, `${label}: base campaign save is missing.`); + assert(slotted, `${label}: active slot save is missing.`); + assert.equal( + base.activeCampVisitId, + expectedVisitId, + `${label}: unexpected base-save active visit.` + ); + assert.equal( + slotted.activeCampVisitId, + expectedVisitId, + `${label}: unexpected slot-save active visit.` + ); + assert.equal( + base.activeSaveSlot, + slot, + `${label}: base save must retain the active slot.` + ); + assert.equal( + slotted.activeSaveSlot, + slot, + `${label}: slot save must retain the active slot.` + ); + } + + 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 validCampVisitState(fixture, slot = 1) { + const report = battleReport(fixture.sourceBattleId, 'victory'); + const state = campaign.createInitialCampaignState(); + state.updatedAt = '2026-07-28T08: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 = 1) { + const battleId = 'seventh-battle-xuzhou-rescue'; + const report = battleReport(battleId, 'victory'); + const state = campaign.createInitialCampaignState(); + state.updatedAt = '2026-07-28T08:20: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) + }; + delete state.pendingAftermathBattleId; + return state; + } + + function battleReport(battleId, outcome) { + 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, + turnNumber: 5, + rewardGold: outcome === 'victory' ? 100 : 0, + defeatedEnemies: + outcome === 'victory' + ? scenario.units.filter((unit) => unit.faction === 'enemy').length + : 0, + 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-28T08: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 alternateBattleFor(sourceBattleId) { + return Object.keys(battleScenarios).find( + (battleId) => + battleId !== sourceBattleId && + !fixtures.some((fixture) => fixture.sourceBattleId === battleId) + ); + } + + function storedState(key) { + const raw = storage.get(key); + return raw ? JSON.parse(raw) : undefined; + } + + 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-static-data.mjs b/scripts/verify-static-data.mjs index 7855917..4e5463e 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -5,6 +5,7 @@ const checks = [ 'scripts/verify-flow-segment-data.mjs', 'scripts/verify-campaign-flow-data.mjs', 'scripts/verify-campaign-save-normalization.mjs', + 'scripts/verify-camp-visit-resume-state.mjs', 'scripts/verify-campaign-completion.mjs', 'scripts/verify-campaign-presentation-profiles.mjs', 'scripts/verify-campaign-objective-journal.mjs', diff --git a/scripts/verify-third-camp-preparation.mjs b/scripts/verify-third-camp-preparation.mjs index c9b6687..7df343a 100644 --- a/scripts/verify-third-camp-preparation.mjs +++ b/scripts/verify-third-camp-preparation.mjs @@ -53,6 +53,13 @@ try { verifyActivityGates(dataModule); verifyTargetBattleMemoryIsolation(dataModule); verifyBattleLifecycleContract(dataModule); + verifyExplorationIntroSeenLifecycle( + dataModule, + explorationDataModule, + explorationActionModule, + campaignModule, + battleScenarios + ); verifyGuardedPersistentOverwrite( dataModule, actionModule, @@ -78,7 +85,7 @@ try { ); console.log( - 'Third-camp preparation verification passed (activity/selection separation, explicit confirmation, formation compatibility, guarded overwrite, fourth-battle isolation, three-turn lifecycle, and old-save safety).' + 'Third-camp preparation verification passed (intro completion separation, activity/selection separation, explicit confirmation, formation compatibility, guarded overwrite, fourth-battle isolation, three-turn lifecycle, and old-save safety).' ); } finally { await server.close(); @@ -159,6 +166,137 @@ function verifyCanonicalDefinitions(dataModule, battleScenarios) { assert.equal(fresh.effect.accuracyBonus, 10); } +function verifyExplorationIntroSeenLifecycle( + dataModule, + explorationDataModule, + explorationActionModule, + campaignModule, + battleScenarios +) { + assert.equal( + explorationDataModule.thirdCampExplorationIntroSeenId, + 'third-guangzong-sortie-camp:intro-seen', + 'The persisted intro marker must remain a stable save-data identifier.' + ); + + prepareThirdVictory(campaignModule, battleScenarios, dataModule); + assert.equal( + explorationActionModule.hasSeenThirdCampExplorationIntro(), + false + ); + assert.equal( + explorationActionModule.thirdCampExplorationProgress().introSeen, + false + ); + + const entry = explorationActionModule.enterThirdCampExploration(); + assert.equal(entry.ok, true); + assert.equal(entry.changed, true); + assert.equal( + entry.campaign.completedCampVisits.includes( + explorationDataModule.thirdCampExplorationVisitId + ), + true, + 'Entering the exploration must preserve the existing visit marker.' + ); + assert.equal( + entry.campaign.completedCampVisits.includes( + explorationDataModule.thirdCampExplorationIntroSeenId + ), + false, + 'Entering alone must not mark the introduction as fully read.' + ); + + let reloaded = campaignModule.loadCampaignState(); + assert.equal( + explorationActionModule.hasSeenThirdCampExplorationIntro(reloaded), + false, + 'Reloading during the introduction must leave it eligible to replay.' + ); + + const completed = + explorationActionModule.completeThirdCampExplorationIntro(); + assert.equal(completed.ok, true); + assert.equal(completed.changed, true); + assert.equal( + explorationActionModule.hasSeenThirdCampExplorationIntro( + completed.campaign + ), + true + ); + assert.equal( + completed.campaign.completedCampVisits.filter( + (id) => + id === + explorationDataModule.thirdCampExplorationVisitId + ).length, + 1 + ); + assert.equal( + completed.campaign.completedCampVisits.filter( + (id) => + id === + explorationDataModule.thirdCampExplorationIntroSeenId + ).length, + 1 + ); + assert.equal( + completed.campaign.firstBattleReport?.completedCampVisits.includes( + explorationDataModule.thirdCampExplorationIntroSeenId + ), + true, + 'The intro marker must remain mirrored in the report-backed visit history.' + ); + + reloaded = campaignModule.loadCampaignState(); + assert.equal( + explorationActionModule.hasSeenThirdCampExplorationIntro(reloaded), + true + ); + assert.equal( + explorationActionModule.thirdCampExplorationProgress(reloaded).introSeen, + true + ); + + const repeated = + explorationActionModule.completeThirdCampExplorationIntro(); + assert.equal(repeated.ok, true); + assert.equal(repeated.changed, false); + + prepareThirdVictory(campaignModule, battleScenarios, dataModule); + explorationActionModule.enterThirdCampExploration(); + rejectStorageWrites = true; + let failedCompletion; + try { + failedCompletion = + explorationActionModule.completeThirdCampExplorationIntro(); + } finally { + rejectStorageWrites = false; + } + assert.equal(failedCompletion?.ok, false); + assert.equal(failedCompletion?.reason, 'save-unavailable'); + assert.equal( + explorationActionModule.hasSeenThirdCampExplorationIntro(), + false, + 'A failed write must not leak an in-memory intro completion.' + ); + assert.equal( + campaignModule.getCampaignState().completedCampVisits.includes( + explorationDataModule.thirdCampExplorationVisitId + ), + true, + 'Rolling back the intro marker must preserve the earlier entry marker.' + ); + + const wrongStep = campaignModule.getCampaignState(); + wrongStep.step = 'fourth-battle'; + campaignModule.setCampaignState(wrongStep); + assert.deepEqual( + explorationActionModule.completeThirdCampExplorationIntro(), + { ok: false, reason: 'invalid-campaign' } + ); +} + function verifyActivityGates(dataModule) { const base = literalCampaign(dataModule); let availability = diff --git a/src/game/data/thirdCampExploration.ts b/src/game/data/thirdCampExploration.ts index 3fc68fa..255b4a8 100644 --- a/src/game/data/thirdCampExploration.ts +++ b/src/game/data/thirdCampExploration.ts @@ -12,6 +12,8 @@ import { export const thirdCampExplorationVisitId = 'third-guangzong-sortie-camp'; +export const thirdCampExplorationIntroSeenId = + 'third-guangzong-sortie-camp:intro-seen' as const; export const thirdCampExplorationSourceBattleId = thirdBattleReturnSourceBattleId; export const thirdCampExplorationTargetBattleId = diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index f0b24e1..d295b41 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -227,6 +227,7 @@ import { campaignSortiePresetIds, campaignReserveTrainingFocusDefinitions, completeCampaignAftermath, + campaignCampVisitIds, defaultCampaignReserveTrainingFocusId, dismissCampaignVictoryRewardNotice, ensureCampaignRosterUnits, @@ -239,6 +240,7 @@ import { listCampaignSaveSlots, campaignSaveSlotCount, saveCampaignState, + setActiveCampVisitId, setActiveCityStayId, setCampaignSortieResonanceSelection, setCampaignSortieOrderSelection, @@ -246,6 +248,7 @@ import { setCampaignReserveTrainingFocus, type CampaignSaveSlotSummary, type CampaignBattleSettlement, + type CampaignCampVisitId, type CampaignStep, type CampaignState, type CampaignReserveTrainingFocusDefinition, @@ -21601,6 +21604,14 @@ export class CampScene extends Phaser.Scene { this.navigationPending = true; this.showCampNavigationBlocker(); let stateCommitted = false; + let campVisitStateCommitted = false; + const requestedCampVisitId = + key === 'CampVisitExplorationScene' && + typeof data?.visitId === 'string' && + (campaignCampVisitIds as readonly string[]).includes(data.visitId) + ? data.visitId as CampaignCampVisitId + : undefined; + const previousActiveCampVisitId = getCampaignState().activeCampVisitId; void (async () => { try { await ensureLazyScene(this.game, key); @@ -21610,6 +21621,10 @@ export class CampScene extends Phaser.Scene { return; } + if (requestedCampVisitId) { + campVisitStateCommitted = true; + this.campaign = setActiveCampVisitId(requestedCampVisitId); + } if (commitState) { stateCommitted = true; commitState(); @@ -21621,6 +21636,9 @@ export class CampScene extends Phaser.Scene { if (stateCommitted) { recoverState?.(); } + if (campVisitStateCommitted) { + this.campaign = setActiveCampVisitId(previousActiveCampVisitId); + } console.error(`[CampScene] ${key} 전환에 실패했습니다.`, error); if (this.sys.isActive()) { this.showCampNotice('다음 장면을 불러오지 못했습니다. 잠시 후 다시 시도하십시오.'); diff --git a/src/game/scenes/CampVisitExplorationScene.ts b/src/game/scenes/CampVisitExplorationScene.ts index ddf77c3..ee18ec2 100644 --- a/src/game/scenes/CampVisitExplorationScene.ts +++ b/src/game/scenes/CampVisitExplorationScene.ts @@ -49,7 +49,11 @@ import { import { ExplorationInputController } from '../input/ExplorationInputController'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; -import { getCampaignState } from '../state/campaignState'; +import { + getCampaignState, + setActiveCampVisitId, + type CampaignCampVisitId +} from '../state/campaignState'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions'; import { @@ -58,8 +62,10 @@ import { secondBattleReliefProgress } from '../state/secondBattleReliefActions'; import { + completeThirdCampExplorationIntro, completeThirdCampCompanionActivity, enterThirdCampExploration, + hasSeenThirdCampExplorationIntro, recordThirdCampExplorationActivity, thirdCampExplorationProgress } from '../state/thirdCampExplorationActions'; @@ -331,7 +337,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { ThirdCampExplorationActivityId, Phaser.GameObjects.Container >(); - private thirdCampFirstEntry = false; + private thirdCampIntroPending = false; private transitionOverlay?: Phaser.GameObjects.Container; private completionToast?: Phaser.GameObjects.Container; private explorationInput?: ExplorationInputController; @@ -360,10 +366,12 @@ export class CampVisitExplorationScene extends Phaser.Scene { } init(data?: CampVisitExplorationSceneData) { + const requestedVisitId = + data?.visitId ?? getCampaignState().activeCampVisitId; this.visitId = - data?.visitId === thirdCampExplorationVisitId + requestedVisitId === thirdCampExplorationVisitId ? thirdCampExplorationVisitId - : data?.visitId === secondBattleReliefVisitId + : requestedVisitId === secondBattleReliefVisitId ? secondBattleReliefVisitId : firstPursuitScoutVisitId; } @@ -423,6 +431,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { }); return; } + setActiveCampVisitId(this.visitId as CampaignCampVisitId); if (this.isThirdCampVisit()) { const entry = enterThirdCampExploration(); if (!entry.ok) { @@ -436,7 +445,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { }); return; } - this.thirdCampFirstEntry = entry.changed; + this.thirdCampIntroPending = + !hasSeenThirdCampExplorationIntro(entry.campaign); } this.drawCamp(); @@ -470,16 +480,26 @@ export class CampVisitExplorationScene extends Phaser.Scene { ambienceVolume: 0.12 } ); - if (this.isThirdCampVisit() && this.thirdCampFirstEntry) { + 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 - ) + ); + } ); } else { this.showCompletionToast( @@ -1142,7 +1162,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.choiceSourceNpcId = undefined; this.choiceReadyAt = Number.POSITIVE_INFINITY; this.thirdWorldFeedback.clear(); - this.thirdCampFirstEntry = false; + this.thirdCampIntroPending = false; this.thirdActivityTitleText = undefined; this.thirdPriorityLocationText = undefined; this.moveTarget = undefined; @@ -3838,8 +3858,15 @@ 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; diff --git a/src/game/scenes/PrologueMilitiaCampScene.ts b/src/game/scenes/PrologueMilitiaCampScene.ts index cfbd493..e556569 100644 --- a/src/game/scenes/PrologueMilitiaCampScene.ts +++ b/src/game/scenes/PrologueMilitiaCampScene.ts @@ -307,7 +307,9 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { textureKey: 'unit-liu-bei', text: '맹세만으로 백성을 지킬 수는 없다. 막사를 직접 돌며 모두가 살아 돌아올 준비를 갖추자.' } - ]); + ], () => { + completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.entered); + }); } } @@ -671,7 +673,6 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { if (campaign.step !== 'first-battle') { markCampaignStep('prologue-camp'); } - completeCampaignTutorial(prologueMilitiaCampCampaignTutorialIds.entered); } private drawCamp() { diff --git a/src/game/scenes/PrologueVillageScene.ts b/src/game/scenes/PrologueVillageScene.ts index 8ca917e..9a849d2 100644 --- a/src/game/scenes/PrologueVillageScene.ts +++ b/src/game/scenes/PrologueVillageScene.ts @@ -327,7 +327,9 @@ export class PrologueVillageScene extends Phaser.Scene { textureKey: 'unit-liu-bei', text: '방금 등 뒤에서 들린 목소리의 주인을 찾아보자. 서쪽 격문 앞의 호걸에게 다가가 그 뜻을 들어 보자.' } - ]); + ], () => { + completeCampaignTutorial(prologueVillageCampaignTutorialIds.entered); + }); } } @@ -625,7 +627,6 @@ export class PrologueVillageScene extends Phaser.Scene { if (campaign.step === 'new' || campaign.step === 'prologue') { markCampaignStep('prologue-town'); } - completeCampaignTutorial(prologueVillageCampaignTutorialIds.entered); } private drawVillage() { diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 5759445..ac74a5b 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -39,6 +39,7 @@ import { prologueVillageCampaignTutorialIds, summarizeCampaignProgress, startNewCampaign, + type CampaignCampVisitId, type CampaignSaveSlotSummary } from '../state/campaignState'; import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; @@ -413,7 +414,7 @@ export class TitleScene extends Phaser.Scene { isEndingComplete: boolean ) { const badge = this.add.container(x, y); - const background = this.add.rectangle(0, 0, ui(216), ui(78), 0x21180f, 0.92); + const background = this.add.rectangle(0, 0, ui(236), ui(78), 0x21180f, 0.92); background.setStrokeStyle(ui(1), palette.gold, 0.52); const title = this.add.text(0, -ui(24), isEndingComplete ? '완결 저장' : '진행 저장', { @@ -421,7 +422,7 @@ export class TitleScene extends Phaser.Scene { fontSize: uiPx(18), color: '#f6e6bd', fontStyle: '700', - fixedWidth: ui(198), + fixedWidth: ui(220), align: 'center', padding: { top: ui(3), bottom: ui(3) } }); @@ -431,7 +432,7 @@ export class TitleScene extends Phaser.Scene { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(14), color: '#d8b15f', - fixedWidth: ui(198), + fixedWidth: ui(220), align: 'center' }); detail.setOrigin(0.5); @@ -440,7 +441,7 @@ export class TitleScene extends Phaser.Scene { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: uiPx(12), color: '#9fb0bf', - fixedWidth: ui(198), + fixedWidth: ui(220), align: 'center' }); meta.setOrigin(0.5); @@ -461,6 +462,11 @@ export class TitleScene extends Phaser.Scene { return this.compactSaveDetail(`${Object.keys(campaign.battleHistory).length}전 완료 · ${campaign.roster.length}명 합류`); } + if (campaign.activeCampVisitId || campaign.activeCityStayId) { + const progress = summarizeCampaignProgress(campaign); + return this.compactSaveDetail(`↪ ${progress.title}`); + } + const journal = resolveCampaignObjectiveJournal(campaign); return this.compactSaveDetail(`◆ ${journal.current.title}`); } @@ -476,9 +482,35 @@ export class TitleScene extends Phaser.Scene { if (battleResume) { return this.campaignBattleResumeMeta(battleResume); } + if (campaign.activeCampVisitId) { + return this.campVisitResumeMeta(campaign.activeCampVisitId); + } + if (campaign.activeCityStayId) { + const journal = resolveCampaignObjectiveJournal(campaign); + const recent = this.compactResumeLabel(journal.recentCompleted.title, 6); + const current = this.compactResumeLabel(journal.current.title, 7); + return `✓${recent} → ◆${current}`; + } return `슬롯 ${campaign.activeSaveSlot} · ${this.formatSaveUpdatedAt(campaign.updatedAt)}`; } + private campVisitResumeMeta(visitId: CampaignCampVisitId) { + switch (visitId) { + case 'first-pursuit-scout-tent': + return '✓ 탁현 승리 → ◆ 북문 정찰'; + case 'second-pursuit-aftermath-relief': + return '✓ 추격전 승리 → ◆ 현장 수습'; + case 'third-guangzong-sortie-camp': + return '✓ 광종길 승리 → ◆ 출진 준비'; + } + } + + private compactResumeLabel(label: string, maxLength: number) { + return label.length > maxLength + ? `${label.slice(0, Math.max(1, maxLength - 1))}…` + : label; + } + private campaignBattleResumeMeta(resume: CampaignBattleResume) { const faction = resume.activeFaction === 'ally' ? '아군 차례' : '적군 차례'; return `슬롯 ${resume.slot} · ${resume.turnNumber}턴 · ${faction}`; @@ -1348,6 +1380,12 @@ export class TitleScene extends Phaser.Scene { }); return; } + if (campaign.activeCampVisitId) { + await this.navigateTo('CampVisitExplorationScene', { + visitId: campaign.activeCampVisitId + }); + return; + } if (campaign.activeCityStayId) { await this.navigateTo('CityStayScene', { cityStayId: campaign.activeCityStayId diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 17e6be0..d87053d 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -20,6 +20,18 @@ import { type XuzhouStayBattlePayoffSnapshot } from '../data/xuzhouStayBattlePayoff'; import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays'; +import { + firstPursuitScoutSourceBattleId, + firstPursuitScoutVisitId +} from '../data/firstPursuitScoutMemory'; +import { + secondBattleReliefSourceBattleId, + secondBattleReliefVisitId +} from '../data/secondBattleReliefExploration'; +import { + thirdCampExplorationSourceBattleId, + thirdCampExplorationVisitId +} from '../data/thirdCampExploration'; import { isThirdCampPreparationPriorityId, resolveThirdCampPreparationAvailability, @@ -107,6 +119,13 @@ export type CampaignVictoryRewardAcknowledgements = Partial; +export const campaignCampVisitIds = [ + firstPursuitScoutVisitId, + secondBattleReliefVisitId, + thirdCampExplorationVisitId +] as const; +export type CampaignCampVisitId = (typeof campaignCampVisitIds)[number]; + export type ThirdCampPreparationSelectionSnapshot = { sourceBattleId: typeof thirdCampPreparationSourceBattleId; targetBattleId: typeof thirdCampPreparationTargetBattleId; @@ -575,6 +594,7 @@ export type CampaignState = { latestBattleId?: string; pendingAftermathBattleId?: BattleScenarioId; activeCityStayId?: CityStayId; + activeCampVisitId?: CampaignCampVisitId; firstBattleReport?: FirstBattleReport; }; @@ -650,6 +670,24 @@ const campaignBattleSteps: Record = { + [firstPursuitScoutVisitId]: { + sourceBattleId: firstPursuitScoutSourceBattleId, + step: 'first-camp' + }, + [secondBattleReliefVisitId]: { + sourceBattleId: secondBattleReliefSourceBattleId, + step: 'second-camp' + }, + [thirdCampExplorationVisitId]: { + sourceBattleId: thirdCampExplorationSourceBattleId, + step: 'third-camp' + } +}; + const campaignStepIds = new Set([ 'new', 'prologue', @@ -694,7 +732,29 @@ const campaignStepProgressLabels: Partial = { + [firstPursuitScoutVisitId]: { + title: '탁현 의용군 정찰 막사', + meta: '정찰 탐색 이어하기' + }, + [secondBattleReliefVisitId]: { + title: '북쪽 마을·나루 구호', + meta: '현장 탐색 이어하기' + }, + [thirdCampExplorationVisitId]: { + title: '광종 연합군 주둔지', + meta: '출진 준비 이어하기' + } +}; + export function summarizeCampaignProgress(state: CampaignState) { + if (state.activeCampVisitId) { + return campaignCampVisitProgressLabels[state.activeCampVisitId]; + } + const pendingAftermath = state.pendingAftermathBattleId ? state.battleHistory[state.pendingAftermathBattleId] : undefined; @@ -881,6 +941,15 @@ export function markCampaignStep(step: CampaignStep) { if (!normalizeActiveCityStayId(state.activeCityStayId, state.latestBattleId, step)) { delete state.activeCityStayId; } + if (!normalizeActiveCampVisitId( + state.activeCampVisitId, + state.latestBattleId, + step, + state.battleHistory, + state.firstBattleReport + )) { + delete state.activeCampVisitId; + } state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); @@ -891,6 +960,7 @@ export function setActiveCityStayId(cityStayId?: CityStayId) { const normalizedCityStayId = normalizeActiveCityStayId(cityStayId, state.latestBattleId, state.step); if (normalizedCityStayId) { state.activeCityStayId = normalizedCityStayId; + delete state.activeCampVisitId; } else { delete state.activeCityStayId; } @@ -899,6 +969,28 @@ export function setActiveCityStayId(cityStayId?: CityStayId) { return cloneCampaignState(state); } +export function setActiveCampVisitId(campVisitId?: CampaignCampVisitId) { + const state = ensureCampaignState(); + const normalizedCampVisitId = state.pendingAftermathBattleId + ? undefined + : normalizeActiveCampVisitId( + campVisitId, + state.latestBattleId, + state.step, + state.battleHistory, + state.firstBattleReport + ); + if (normalizedCampVisitId) { + state.activeCampVisitId = normalizedCampVisitId; + delete state.activeCityStayId; + } else { + delete state.activeCampVisitId; + } + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + return cloneCampaignState(state); +} + export function hasCompletedCampaignTutorial(id: CampaignTutorialId, state = ensureCampaignState()) { return state.completedTutorialIds.includes(id); } @@ -1143,6 +1235,7 @@ export function setFirstBattleReport(report: FirstBattleReport) { state.step = retryStep; state.latestBattleId = battleId; delete state.activeCityStayId; + delete state.activeCampVisitId; delete state.pendingAftermathBattleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); @@ -1200,6 +1293,7 @@ export function setFirstBattleReport(report: FirstBattleReport) { } state.latestBattleId = battleId; delete state.activeCityStayId; + delete state.activeCampVisitId; state.pendingAftermathBattleId = battleId as BattleScenarioId; state.updatedAt = new Date().toISOString(); @@ -1908,6 +2002,18 @@ function normalizeCampaignState(state: Partial): CampaignState { } else { delete normalized.activeCityStayId; } + const activeCampVisitId = normalizeActiveCampVisitId( + normalized.activeCampVisitId, + normalized.latestBattleId, + normalized.step, + normalized.battleHistory, + normalized.firstBattleReport + ); + if (activeCampVisitId && !activeCityStayId) { + normalized.activeCampVisitId = activeCampVisitId; + } else { + delete normalized.activeCampVisitId; + } const pendingAftermathBattleId = typeof normalized.pendingAftermathBattleId === 'string' && normalized.pendingAftermathBattleId in battleScenarios ? normalized.pendingAftermathBattleId as BattleScenarioId @@ -1924,6 +2030,8 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.battleHistory[pendingAftermathBattleId]?.outcome === 'victory' ) { normalized.pendingAftermathBattleId = pendingAftermathBattleId; + delete normalized.activeCityStayId; + delete normalized.activeCampVisitId; } else { delete normalized.pendingAftermathBattleId; } @@ -3740,6 +3848,39 @@ function normalizeActiveCityStayId( : undefined; } +function normalizeActiveCampVisitId( + campVisitId: unknown, + latestBattleId: string | undefined, + step: CampaignStep, + battleHistory: Record, + firstBattleReport?: FirstBattleReport +): CampaignCampVisitId | undefined { + if ( + typeof campVisitId !== 'string' || + !(campaignCampVisitIds as readonly string[]).includes(campVisitId) + ) { + return undefined; + } + + const normalizedCampVisitId = campVisitId as CampaignCampVisitId; + const rule = campaignCampVisitResumeRules[normalizedCampVisitId]; + const sourceSettlement = battleHistory[rule.sourceBattleId]; + if ( + latestBattleId !== rule.sourceBattleId || + step !== rule.step || + sourceSettlement?.outcome !== 'victory' + ) { + return undefined; + } + if ( + firstBattleReport?.battleId !== rule.sourceBattleId || + firstBattleReport.outcome !== 'victory' + ) { + return undefined; + } + return normalizedCampVisitId; +} + function cloneReport(report: FirstBattleReport): FirstBattleReport { const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport; cloned.itemRewards = normalizeRewardStrings(cloned.itemRewards); diff --git a/src/game/state/thirdCampExplorationActions.ts b/src/game/state/thirdCampExplorationActions.ts index 7c6a942..87c6076 100644 --- a/src/game/state/thirdCampExplorationActions.ts +++ b/src/game/state/thirdCampExplorationActions.ts @@ -4,6 +4,7 @@ import { resolveThirdCampCompanionDialogue, resolveThirdCampExplorationDefinition, thirdCampExplorationActivityProgressId, + thirdCampExplorationIntroSeenId, thirdCampExplorationSourceBattleId, thirdCampExplorationVisitId, type ThirdCampCompanionDialogueChoice, @@ -46,6 +47,20 @@ export type ThirdCampExplorationEntryResult = >; }; +export type ThirdCampExplorationIntroCompletionResult = + | { + ok: true; + campaign: CampaignState; + changed: boolean; + } + | { + ok: false; + reason: Extract< + ThirdCampExplorationFailure, + 'invalid-campaign' | 'save-unavailable' + >; + }; + export type ThirdCampExplorationActivityResult = | { ok: true; @@ -103,6 +118,7 @@ export function thirdCampExplorationProgress( entered: campaign.completedCampVisits.includes( thirdCampExplorationVisitId ), + introSeen: hasSeenThirdCampExplorationIntro(campaign), completedActivityIds, completedActivityCount: completedActivityIds.length, activities: @@ -120,6 +136,41 @@ export function thirdCampExplorationProgress( }; } +export function hasSeenThirdCampExplorationIntro( + campaign: CampaignState = getCampaignState() +) { + return campaign.completedCampVisits.includes( + thirdCampExplorationIntroSeenId + ); +} + +export function completeThirdCampExplorationIntro(): + ThirdCampExplorationIntroCompletionResult { + const snapshot = readCampaignSnapshot(); + if (!snapshot) { + return { ok: false, reason: 'save-unavailable' }; + } + if (!campaignSupportsThirdCampExploration(snapshot)) { + return { ok: false, reason: 'invalid-campaign' }; + } + + const updated = withVisitMarkers(snapshot, [ + thirdCampExplorationVisitId, + thirdCampExplorationIntroSeenId + ]); + if ( + updated.completedCampVisits.length === + snapshot.completedCampVisits.length + ) { + return { ok: true, campaign: snapshot, changed: false }; + } + + const persisted = persistCampaign(updated, snapshot); + return persisted + ? { ok: true, campaign: persisted, changed: true } + : { ok: false, reason: 'save-unavailable' }; +} + export function enterThirdCampExploration(): ThirdCampExplorationEntryResult { const snapshot = readCampaignSnapshot();