feat: preserve camp exploration resume context

This commit is contained in:
2026-07-28 13:22:32 +09:00
parent 3c817175f0
commit 7b30f0f60b
14 changed files with 2004 additions and 22 deletions

View 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)]);
}
}

View 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();
}

View File

@@ -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',

View File

@@ -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 =