feat: preserve camp exploration resume context
This commit is contained in:
@@ -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로 보존하고, 선택·구매·공명 보상이
|
||||
재로드 경계에서도 정확히 한 번만 적용되는지 검증한다.
|
||||
|
||||
## 장기 개선 순서
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
956
scripts/verify-camp-visit-resume-browser.mjs
Normal file
956
scripts/verify-camp-visit-resume-browser.mjs
Normal file
@@ -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)]);
|
||||
}
|
||||
}
|
||||
584
scripts/verify-camp-visit-resume-state.mjs
Normal file
584
scripts/verify-camp-visit-resume-state.mjs
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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('다음 장면을 불러오지 못했습니다. 잠시 후 다시 시도하십시오.');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Record<string, Campa
|
||||
|
||||
export type CampaignCampChoiceHistory = Record<string, string>;
|
||||
|
||||
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<string, { victory: CampaignStep; retry: Campai
|
||||
'sixty-sixth-battle-wuzhang-final': { victory: 'sixty-sixth-camp', retry: 'sixty-sixth-battle' }
|
||||
};
|
||||
|
||||
const campaignCampVisitResumeRules: Record<
|
||||
CampaignCampVisitId,
|
||||
{ sourceBattleId: BattleScenarioId; step: CampaignStep }
|
||||
> = {
|
||||
[firstPursuitScoutVisitId]: {
|
||||
sourceBattleId: firstPursuitScoutSourceBattleId,
|
||||
step: 'first-camp'
|
||||
},
|
||||
[secondBattleReliefVisitId]: {
|
||||
sourceBattleId: secondBattleReliefSourceBattleId,
|
||||
step: 'second-camp'
|
||||
},
|
||||
[thirdCampExplorationVisitId]: {
|
||||
sourceBattleId: thirdCampExplorationSourceBattleId,
|
||||
step: 'third-camp'
|
||||
}
|
||||
};
|
||||
|
||||
const campaignStepIds = new Set<CampaignStep>([
|
||||
'new',
|
||||
'prologue',
|
||||
@@ -694,7 +732,29 @@ const campaignStepProgressLabels: Partial<Record<CampaignStep, { title: string;
|
||||
'ending-complete': { title: '북벌의 끝과 남은 뜻', meta: '완료' }
|
||||
};
|
||||
|
||||
const campaignCampVisitProgressLabels: Record<
|
||||
CampaignCampVisitId,
|
||||
{ title: string; meta: string }
|
||||
> = {
|
||||
[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>): 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>): 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<string, CampaignBattleSettlement>,
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user