fix: resume campaign progress safely

This commit is contained in:
2026-07-29 01:13:57 +09:00
parent 90de2a3559
commit 6f2b6e42a4
31 changed files with 9354 additions and 148 deletions

View File

@@ -46,17 +46,17 @@ function makeQaVersion() {
}
function assertNoTrackedChanges() {
const result = spawnSync('git', ['status', '--porcelain', '--untracked-files=no'], {
const result = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], {
cwd: process.cwd(),
encoding: 'utf8'
});
if (result.status !== 0) {
throw new Error(`Could not inspect tracked git status before shipping:\n${result.stderr.trim()}`);
throw new Error(`Could not inspect git status before shipping:\n${result.stderr.trim()}`);
}
const dirty = result.stdout.trim();
if (dirty) {
throw new Error(`Refusing to ship release with uncommitted tracked changes:\n${dirty}`);
throw new Error(`Refusing to ship release with uncommitted or untracked files:\n${dirty}`);
}
}

View File

@@ -0,0 +1,321 @@
import { spawn } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const localHostnames = new Set([
'localhost',
'127.0.0.1',
'0.0.0.0',
'::1'
]);
const defaultStartupTimeoutMs = 45_000;
const defaultFetchTimeoutMs = 1_500;
const ownedServerStabilityMs = 300;
const gracefulShutdownTimeoutMs = 4_000;
const forcedShutdownTimeoutMs = 4_000;
const maxCapturedOutputLength = 16_000;
/**
* Starts an owned Vite preview for local URLs. A pre-existing process on the
* requested port is deliberately never reused: --strictPort makes ownership
* unambiguous and the child must remain alive after the built page responds.
*
* Explicit non-local URLs are useful for opt-in production verification and
* are only probed; they never create a local process.
*/
export async function startVerificationPreview({
targetUrl,
explicitUrl = false,
label = 'browser verification',
cwd = process.cwd(),
startupTimeoutMs = defaultStartupTimeoutMs,
fetchTimeoutMs = defaultFetchTimeoutMs
}) {
const parsed = new URL(targetUrl);
if (!localHostnames.has(parsed.hostname)) {
if (!explicitUrl) {
throw new Error(
`${label}: refusing to start a local preview for non-local URL ${targetUrl}`
);
}
await assertExternalBuildReachable(
targetUrl,
label,
fetchTimeoutMs
);
return undefined;
}
if (!parsed.port) {
throw new Error(
`${label}: local verification URLs must use an explicit port (${targetUrl}).`
);
}
const distHtmlPath = path.resolve(cwd, 'dist', 'index.html');
const distHtml = await readFile(distHtmlPath, 'utf8').catch(
(error) => {
throw new Error(
`${label}: ${distHtmlPath} is unavailable. Build the release candidate before running browser verification.`,
{ cause: error }
);
}
);
const assetSignature = builtAssetSignature(
distHtml,
`${label}: local dist`
);
const viteCliPath = path.resolve(
cwd,
'node_modules',
'vite',
'bin',
'vite.js'
);
const child = spawn(
process.execPath,
[
viteCliPath,
'preview',
'--host',
'127.0.0.1',
'--port',
parsed.port,
'--strictPort'
],
{
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
);
const output = {
stdout: '',
stderr: '',
spawnError: undefined
};
child.stdout?.on('data', (chunk) => {
output.stdout = appendCapturedOutput(
output.stdout,
chunk
);
});
child.stderr?.on('data', (chunk) => {
output.stderr = appendCapturedOutput(
output.stderr,
chunk
);
});
child.on('error', (error) => {
output.spawnError = error;
});
const deadline = Date.now() + startupTimeoutMs;
while (Date.now() < deadline) {
if (processHasExited(child)) {
throw previewStartupError(
label,
targetUrl,
child,
output
);
}
if (
await builtPageResponds(
targetUrl,
assetSignature,
fetchTimeoutMs
)
) {
// A process already occupying the port can answer the first probe while
// Vite is still reporting EADDRINUSE. Requiring our strict-port child to
// survive a short stabilization window closes that race.
await delay(ownedServerStabilityMs);
if (processHasExited(child)) {
throw previewStartupError(
label,
targetUrl,
child,
output
);
}
return child;
}
await delay(200);
}
await stopVerificationPreview(child);
throw new Error(
`${label}: owned Vite preview did not serve the current dist at ${targetUrl}.${formatCapturedOutput(output)}`
);
}
export async function stopVerificationPreview(child) {
if (!child || processHasExited(child)) {
return;
}
child.kill('SIGTERM');
if (
await waitForProcessExit(
child,
gracefulShutdownTimeoutMs
)
) {
return;
}
child.kill('SIGKILL');
if (
await waitForProcessExit(
child,
forcedShutdownTimeoutMs
)
) {
return;
}
throw new Error(
`Owned Vite preview process ${child.pid ?? 'unknown'} did not exit after forced shutdown.`
);
}
async function assertExternalBuildReachable(
url,
label,
fetchTimeoutMs
) {
let response;
try {
response = await fetch(url, {
signal: AbortSignal.timeout(fetchTimeoutMs)
});
} catch (error) {
throw new Error(
`${label}: no external server responded at ${url} within ${fetchTimeoutMs}ms.`,
{ cause: error }
);
}
if (!response.ok) {
throw new Error(
`${label}: external server returned HTTP ${response.status} at ${url}.`
);
}
const html = await response.text();
builtAssetSignature(html, `${label}: external page`);
}
async function builtPageResponds(
url,
assetSignature,
fetchTimeoutMs
) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(fetchTimeoutMs)
});
if (!response.ok) {
return false;
}
const html = await response.text();
return (
!html.includes('/src/') &&
html.includes(assetSignature)
);
} catch {
return false;
}
}
function builtAssetSignature(html, label) {
if (html.includes('/src/')) {
throw new Error(
`${label} points at source modules instead of a built release.`
);
}
const match = html.match(
/<script\b[^>]*\bsrc=["']([^"']*\/assets\/[^"']+\.js)["'][^>]*>/i
);
if (!match) {
throw new Error(
`${label} does not contain a production JavaScript asset.`
);
}
return match[1];
}
function previewStartupError(
label,
targetUrl,
child,
output
) {
const cause = output.spawnError
? `\nSpawn error: ${output.spawnError.message}`
: '';
return new Error(
`${label}: owned Vite preview exited before becoming ready at ${targetUrl} ` +
`(exit ${child.exitCode ?? child.signalCode ?? 'unknown'}).` +
cause +
formatCapturedOutput(output)
);
}
function formatCapturedOutput(output) {
const sections = [];
if (output.stdout.trim()) {
sections.push(`stdout:\n${output.stdout.trim()}`);
}
if (output.stderr.trim()) {
sections.push(`stderr:\n${output.stderr.trim()}`);
}
return sections.length > 0
? `\n${sections.join('\n')}`
: '';
}
function appendCapturedOutput(current, chunk) {
const appended = current + chunk.toString();
return appended.length <= maxCapturedOutputLength
? appended
: appended.slice(-maxCapturedOutputLength);
}
function processHasExited(child) {
return (
child.exitCode !== null ||
child.signalCode !== null
);
}
function waitForProcessExit(child, timeoutMs) {
if (processHasExited(child)) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
let settled = false;
const finish = (exited) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
child.off('exit', onExit);
resolve(exited);
};
const onExit = () => finish(true);
const timeout = setTimeout(
() => finish(processHasExited(child)),
timeoutMs
);
child.once('exit', onExit);
if (processHasExited(child)) {
finish(true);
}
});
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const [battleSource, campaignSource] = await Promise.all([
readFile(
new URL(
'../src/game/scenes/BattleScene.ts',
import.meta.url
),
'utf8'
),
readFile(
new URL(
'../src/game/state/campaignState.ts',
import.meta.url
),
'utf8'
)
]);
const publishStart = battleSource.indexOf(
'private publishFirstBattleReport('
);
const publishEnd = battleSource.indexOf(
'\n private resultSubtitle(',
publishStart
);
assert(
publishStart >= 0 && publishEnd > publishStart,
'BattleScene must retain a bounded result-persistence method.'
);
const publishSource = battleSource.slice(
publishStart,
publishEnd
);
assert(
publishSource.includes(
'persistedReport = setFirstBattleReport('
),
'BattleScene must persist its complete battle report through the atomic campaign writer.'
);
assert(
publishSource.includes(
"this.resultReportPersistenceStatus =\n 'failed';"
) &&
publishSource.includes('return false;'),
'A failed campaign write must leave the result screen in an explicit retry state.'
);
assert(
publishSource.includes(
"this.resultReportPersistenceStatus =\n 'complete';"
) &&
publishSource.includes('return true;'),
'A successful campaign write must mark the result report complete.'
);
const continueStart = battleSource.indexOf(
'private handleResultContinue()'
);
const continueEnd = battleSource.indexOf(
'\n private hideBattleResult(',
continueStart
);
assert(
continueStart >= 0 && continueEnd > continueStart,
'BattleScene must retain a bounded result continuation handler.'
);
const continueSource = battleSource.slice(
continueStart,
continueEnd
);
assert(
continueSource.includes(
"this.resultReportPersistenceStatus ===\n 'failed'"
) &&
continueSource.includes(
'!this.publishFirstBattleReport(outcome)'
) &&
continueSource.includes(
'this.showBattleResult(outcome);'
),
'The result CTA must retry persistence in place and redraw the result only after recovery.'
);
assert(
battleSource.includes("return '저장 다시 시도';"),
'The failed result CTA must clearly say “저장 다시 시도”.'
);
assert(
battleSource.includes(
"outcome === 'victory' ||\n this.resultReportPersistenceStatus === 'failed'"
),
'Defeat results with an unsaved report must also expose the persistence retry CTA.'
);
assert(
battleSource.includes(
'const retryButton = this.addResultButton('
) &&
battleSource.includes(
'const titleButton = this.addResultButton('
) &&
battleSource.includes(
'this.setResultButtonEnabled(\n retryButton,\n false'
) &&
battleSource.includes(
'this.setResultButtonEnabled(\n titleButton,\n false'
),
'Normal retry and title navigation must be visibly disabled while result persistence has failed.'
);
assert(
battleSource.includes(
'this.resultNavigationPending ||\n this.blockResultNavigationWhilePersistenceFailed()'
),
'All result navigation must retain a persistence-failure guard even if a disabled button is invoked indirectly.'
);
assert(
battleSource.includes(
"const improvementEnabled =\n this.resultReportPersistenceStatus !==\n 'failed';"
) &&
battleSource.includes(
"improvementEnabled,\n false,\n () => this.openResultSortieImprovement(outcome)"
),
'Result-screen formation improvement must remain disabled until the battle report is durable.'
);
assert(
battleSource.includes(
'reportPersistence:\n this.resultReportPersistenceStatus'
),
'The result persistence state must remain observable to the browser regression.'
);
const persistenceStart = campaignSource.indexOf(
'function persistCampaignState('
);
const persistenceEnd = campaignSource.indexOf(
'\nfunction tryStorage()',
persistenceStart
);
assert(
persistenceStart >= 0 &&
persistenceEnd > persistenceStart,
'Campaign persistence must retain a bounded writer.'
);
const persistenceSource = campaignSource.slice(
persistenceStart,
persistenceEnd
);
const canonicalWrite = persistenceSource.indexOf(
'campaignSaveSlotKey(state.activeSaveSlot)'
);
const compatibilityWrite = persistenceSource.indexOf(
'storage.setItem(campaignStorageKey, serialized)'
);
assert(
canonicalWrite >= 0 &&
compatibilityWrite > canonicalWrite,
'The canonical campaign slot must be written before the compatibility mirror.'
);
console.log(
'Verified battle-result persistence failure/retry UI, debug observability, and canonical-slot-first atomic write contract.'
);

View File

@@ -0,0 +1,622 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
import {
startVerificationPreview,
stopVerificationPreview
} from './verification-preview-server.mjs';
const renderers = ['canvas', 'webgl'];
const renderer = process.env.VERIFY_BATTLE_SAVE_GENERATION_RENDERER;
if (!renderer) {
for (const requestedRenderer of renderers) {
const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url)], {
cwd: process.cwd(),
env: {
...process.env,
VERIFY_BATTLE_SAVE_GENERATION_RENDERER: requestedRenderer
},
stdio: 'inherit'
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
assert(
renderers.includes(renderer),
`Unsupported battle-save generation renderer "${renderer}".`
);
const battleId = 'first-battle-zhuo-commandery';
const campaignStep = 'first-battle';
const campaignGeneration = 4;
const staleGeneration = 3;
const campaignKey = 'heros-web:campaign-state';
const campaignSlotKey = `${campaignKey}:slot-1`;
const battleBaseKey = `heros-web:battle:${battleId}`;
const battleSlotKey = `${battleBaseKey}:slot-1`;
const legacyBattleKey = 'heros-web:first-battle-state';
const battleStorageKeys = [battleSlotKey, battleBaseKey, legacyBattleKey];
const baseUrl =
process.env.VERIFY_BATTLE_SAVE_GENERATION_URL ??
`http://127.0.0.1:${renderer === 'canvas' ? 41825 : 41826}/heros_web/`;
const targetUrl = withDebugOptions(baseUrl, renderer);
let browser;
let serverProcess;
try {
serverProcess = await startVerificationPreview({
targetUrl,
explicitUrl: Boolean(
process.env.VERIFY_BATTLE_SAVE_GENERATION_URL
),
label:
`${renderer} battle save generation verification`
});
browser = await chromium.launch({
headless: process.env.VERIFY_BATTLE_SAVE_GENERATION_HEADLESS !== '0',
args:
renderer === 'webgl'
? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']
: []
});
const context = await browser.newContext(desktopBrowserContextOptions);
const page = await context.newPage();
page.setDefaultTimeout(30000);
const pageErrors = [];
const consoleErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
await page.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await page.evaluate(() => window.localStorage.clear());
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForTitle(page);
await assertDesktopRuntime(page, renderer);
await page.evaluate(async (requestedBattleId) => {
await window.__HEROS_DEBUG__?.goToBattle(requestedBattleId);
}, battleId);
await waitForBattleReady(page);
const fixture = await page.evaluate(
({
requestedBattleId,
requestedCampaignStep,
requestedCampaignGeneration,
requestedStaleGeneration,
requestedCampaignKey,
requestedCampaignSlotKey,
requestedBattleBaseKey,
requestedBattleSlotKey,
requestedLegacyBattleKey,
requestedBattleStorageKeys
}) => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
if (
!scene ||
typeof scene.createBattleSaveState !== 'function' ||
typeof scene.saveBattleState !== 'function'
) {
throw new Error('BattleScene save internals are unavailable.');
}
// Persist a normalized campaign fixture and a fully valid battle-save
// shape through the production writer before applying generation markers.
scene.saveBattleState(1);
const campaignRaw =
window.localStorage.getItem(requestedCampaignSlotKey) ??
window.localStorage.getItem(requestedCampaignKey);
const battleRaw =
window.localStorage.getItem(requestedBattleSlotKey) ??
window.localStorage.getItem(requestedBattleBaseKey);
if (!campaignRaw || !battleRaw) {
throw new Error('The production save writer did not create the browser fixture.');
}
const campaign = JSON.parse(campaignRaw);
campaign.step = requestedCampaignStep;
campaign.activeSaveSlot = 1;
campaign.battleResumeGeneration = requestedCampaignGeneration;
campaign.storyCheckpoint = undefined;
campaign.explorationCheckpoint = undefined;
campaign.updatedAt = new Date().toISOString();
const serializedCampaign = JSON.stringify(campaign);
window.localStorage.setItem(requestedCampaignSlotKey, serializedCampaign);
window.localStorage.setItem(requestedCampaignKey, serializedCampaign);
const baseline = JSON.parse(battleRaw);
const markerIndex = baseline.units.findIndex((unit) => unit.id === 'liu-bei');
if (markerIndex < 0) {
throw new Error('The first-battle Liu Bei save fixture is unavailable.');
}
const baselineMarkerHp = baseline.units[markerIndex].hp;
const markerMaxHp = baseline.units[markerIndex].maxHp;
const staleMarkerHp = Math.max(1, Math.min(markerMaxHp, baselineMarkerHp - 7));
const validMarkerHp = Math.max(1, Math.min(markerMaxHp, baselineMarkerHp - 3));
if (
staleMarkerHp === baselineMarkerHp ||
validMarkerHp === baselineMarkerHp ||
staleMarkerHp === validMarkerHp
) {
throw new Error(
`Battle marker HP values are not distinguishable: ${JSON.stringify({
baselineMarkerHp,
staleMarkerHp,
validMarkerHp
})}`
);
}
const stale = structuredClone(baseline);
stale.battleId = requestedBattleId;
stale.campaignStep = requestedCampaignStep;
stale.battleResumeGeneration = requestedStaleGeneration;
stale.turnNumber = 8;
stale.activeFaction = 'ally';
stale.savedAt = new Date(Date.now() - 60_000).toISOString();
stale.units[markerIndex].hp = staleMarkerHp;
const valid = structuredClone(baseline);
valid.battleId = requestedBattleId;
valid.campaignStep = requestedCampaignStep;
valid.battleResumeGeneration = requestedCampaignGeneration;
valid.turnNumber = 6;
valid.activeFaction = 'ally';
valid.savedAt = new Date().toISOString();
valid.units[markerIndex].hp = validMarkerHp;
window.localStorage.removeItem(requestedBattleBaseKey);
window.localStorage.removeItem(requestedLegacyBattleKey);
window.localStorage.setItem(requestedBattleSlotKey, JSON.stringify(stale));
const storagePrototype = Object.getPrototypeOf(window.localStorage);
const originalRemoveItem = storagePrototype.removeItem;
const blockedKeys = new Set(requestedBattleStorageKeys);
const removalStats = {
attempts: 0,
blocked: 0,
keys: []
};
storagePrototype.removeItem = function removeItem(key) {
const normalizedKey = String(key);
if (this === window.localStorage && blockedKeys.has(normalizedKey)) {
removalStats.attempts += 1;
removalStats.blocked += 1;
removalStats.keys.push(normalizedKey);
throw new DOMException(
'Injected battle-save removal refusal',
'InvalidStateError'
);
}
return originalRemoveItem.call(this, key);
};
Object.defineProperty(window, '__HEROS_BATTLE_GENERATION_FIXTURE__', {
configurable: true,
value: {
baselineMarkerHp,
staleMarkerHp,
validMarkerHp,
valid,
removalStats
}
});
scene.scene.restart({
battleId: requestedBattleId,
resumeBattleSaveSlot: 1
});
return {
baselineMarkerHp,
staleMarkerHp,
validMarkerHp,
staleTurnNumber: stale.turnNumber,
validTurnNumber: valid.turnNumber
};
},
{
requestedBattleId: battleId,
requestedCampaignStep: campaignStep,
requestedCampaignGeneration: campaignGeneration,
requestedStaleGeneration: staleGeneration,
requestedCampaignKey: campaignKey,
requestedCampaignSlotKey: campaignSlotKey,
requestedBattleBaseKey: battleBaseKey,
requestedBattleSlotKey: battleSlotKey,
requestedLegacyBattleKey: legacyBattleKey,
requestedBattleStorageKeys: battleStorageKeys
}
);
await page.waitForFunction(
({ expectedBattleId, expectedBaselineHp }) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
const fixtureState = window.__HEROS_BATTLE_GENERATION_FIXTURE__;
return (
battle?.battleId === expectedBattleId &&
battle?.mapBackgroundReady === true &&
['deployment', 'idle'].includes(battle?.phase) &&
battle?.turnNumber === 1 &&
battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ===
expectedBaselineHp &&
fixtureState?.removalStats?.blocked >= 1
);
},
{
expectedBattleId: battleId,
expectedBaselineHp: fixture.baselineMarkerHp
},
{ timeout: 90000 }
);
const staleProbe = await page.evaluate(
({
requestedBattleSlotKey,
requestedStaleGeneration
}) => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
if (!scene) {
throw new Error('BattleScene is unavailable after stale resume rejection.');
}
const rejected =
scene.readBattleSaveState?.(1, true) === undefined;
const loadDisabled =
scene.isMapMenuActionDisabled?.('load') === true;
scene.showSaveSlotPanel?.('load');
const panelBeforeSelection =
window.__HEROS_DEBUG__?.battle?.();
scene.activateSaveSlotPanelSlot?.(1);
const panelAfterSelection =
window.__HEROS_DEBUG__?.battle?.();
scene.hideSaveSlotPanel?.();
const persisted = JSON.parse(
window.localStorage.getItem(requestedBattleSlotKey) ?? 'null'
);
const fixtureState =
window.__HEROS_BATTLE_GENERATION_FIXTURE__;
return {
rejected,
loadDisabled,
panelMode:
panelBeforeSelection?.saveSlotPanelMode ?? null,
panelStayedOpen:
panelAfterSelection?.saveSlotPanelVisible === true,
promptMode:
panelAfterSelection?.turnPromptMode ?? null,
persistedGeneration:
persisted?.battleResumeGeneration ?? null,
removalStats: {
attempts:
fixtureState?.removalStats?.attempts ?? 0,
blocked:
fixtureState?.removalStats?.blocked ?? 0,
keys: [
...(fixtureState?.removalStats?.keys ?? [])
]
}
};
},
{
requestedBattleSlotKey: battleSlotKey,
requestedStaleGeneration: staleGeneration
}
);
assert.equal(
staleProbe.rejected,
true,
`${renderer}: BattleScene accepted a stale generation.`
);
assert.equal(
staleProbe.loadDisabled,
true,
`${renderer}: manual Load remained enabled for a stale save.`
);
assert.equal(staleProbe.panelMode, 'load');
assert.equal(
staleProbe.panelStayedOpen,
true,
`${renderer}: selecting a stale Load row should be a no-op.`
);
assert.equal(
staleProbe.promptMode,
null,
`${renderer}: a stale save reached the Load confirmation prompt.`
);
assert.equal(
staleProbe.persistedGeneration,
staleGeneration,
`${renderer}: the removal-refusal fixture was not preserved.`
);
assert(
staleProbe.removalStats.blocked >= 1,
`${renderer}: no stale-save cleanup refusal was exercised.`
);
assert(
staleProbe.removalStats.keys.every((key) =>
battleStorageKeys.includes(key)
),
`${renderer}: removal refusal escaped the intended battle keys.`
);
const beforeControl = await page.evaluate(
({
requestedBattleSlotKey,
requestedCampaignGeneration
}) => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
const fixtureState =
window.__HEROS_BATTLE_GENERATION_FIXTURE__;
if (!scene || !fixtureState?.valid) {
throw new Error('The current-generation control fixture is unavailable.');
}
window.localStorage.setItem(
requestedBattleSlotKey,
JSON.stringify(fixtureState.valid)
);
const accepted = scene.readBattleSaveState?.(1, true);
const loadDisabled =
scene.isMapMenuActionDisabled?.('load') === true;
scene.showSaveSlotPanel?.('load');
const panel =
window.__HEROS_DEBUG__?.battle?.();
scene.activateSaveSlotPanelSlot?.(1);
const confirmation =
window.__HEROS_DEBUG__?.battle?.();
return {
acceptedGeneration:
accepted?.battleResumeGeneration ?? null,
acceptedTurnNumber:
accepted?.turnNumber ?? null,
loadDisabled,
panelMode: panel?.saveSlotPanelMode ?? null,
confirmationMode:
confirmation?.turnPromptMode ?? null,
confirmationVisible:
confirmation?.turnPromptVisible === true,
expectedGeneration: requestedCampaignGeneration
};
},
{
requestedBattleSlotKey: battleSlotKey,
requestedCampaignGeneration: campaignGeneration
}
);
assert.equal(
beforeControl.acceptedGeneration,
campaignGeneration,
`${renderer}: the current-generation control was not accepted.`
);
assert.equal(
beforeControl.acceptedTurnNumber,
fixture.validTurnNumber
);
assert.equal(beforeControl.loadDisabled, false);
assert.equal(beforeControl.panelMode, 'load');
assert.equal(beforeControl.confirmationMode, 'load-confirm');
assert.equal(beforeControl.confirmationVisible, true);
await page.evaluate(() => {
const scene = window.__HEROS_DEBUG__?.scene('BattleScene');
scene?.activateTurnPromptPrimaryAction?.();
});
await page.waitForFunction(
({
expectedBattleId,
expectedTurnNumber,
expectedMarkerHp
}) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
battle?.battleId === expectedBattleId &&
battle?.turnNumber === expectedTurnNumber &&
battle?.activeFaction === 'ally' &&
battle?.phase === 'idle' &&
battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ===
expectedMarkerHp
);
},
{
expectedBattleId: battleId,
expectedTurnNumber: fixture.validTurnNumber,
expectedMarkerHp: fixture.validMarkerHp
},
{ timeout: 90000 }
);
const restored = await page.evaluate(
({
requestedCampaignKey,
requestedCampaignSlotKey
}) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
const current = JSON.parse(
window.localStorage.getItem(requestedCampaignKey) ?? 'null'
);
const slot = JSON.parse(
window.localStorage.getItem(requestedCampaignSlotKey) ?? 'null'
);
return {
turnNumber: battle?.turnNumber ?? null,
activeFaction: battle?.activeFaction ?? null,
markerHp:
battle?.units?.find((unit) => unit.id === 'liu-bei')?.hp ?? null,
campaignGeneration:
current?.battleResumeGeneration ?? null,
slotGeneration:
slot?.battleResumeGeneration ?? null,
campaignStep: current?.step ?? null,
slotStep: slot?.step ?? null
};
},
{
requestedCampaignKey: campaignKey,
requestedCampaignSlotKey: campaignSlotKey
}
);
assert.equal(restored.turnNumber, fixture.validTurnNumber);
assert.equal(restored.activeFaction, 'ally');
assert.equal(restored.markerHp, fixture.validMarkerHp);
assert.equal(restored.campaignGeneration, campaignGeneration);
assert.equal(restored.slotGeneration, campaignGeneration);
assert.equal(restored.campaignStep, campaignStep);
assert.equal(restored.slotStep, campaignStep);
assert.equal(pageErrors.length, 0, pageErrors.join('\n'));
assert.equal(consoleErrors.length, 0, consoleErrors.join('\n'));
console.log(
JSON.stringify(
{
renderer,
viewport: desktopBrowserViewport,
deviceScaleFactor: desktopBrowserDeviceScaleFactor,
cssZoom: 1,
stale: {
campaignGeneration,
saveGeneration: staleGeneration,
requestedTurnNumber: fixture.staleTurnNumber,
liveTurnNumber: 1,
loadDisabled: staleProbe.loadDisabled,
removalRefusal: staleProbe.removalStats
},
control: {
campaignGeneration,
saveGeneration: beforeControl.acceptedGeneration,
restoredTurnNumber: restored.turnNumber,
restoredMarkerHp: restored.markerHp
},
pageErrors: pageErrors.length,
consoleErrors: consoleErrors.length
},
null,
2
)
);
} finally {
await browser?.close();
await stopVerificationPreview(serverProcess);
}
function withDebugOptions(url, requestedRenderer) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', requestedRenderer);
parsed.searchParams.set('motion', 'reduced');
return parsed.toString();
}
async function waitForDebugApi(page) {
await page.waitForFunction(
() =>
Boolean(
document.querySelector('canvas') &&
window.__HEROS_GAME__ &&
window.__HEROS_DEBUG__
),
undefined,
{ timeout: 90000 }
);
}
async function waitForTitle(page) {
await page.waitForFunction(
() =>
window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'),
undefined,
{ timeout: 90000 }
);
}
async function waitForBattleReady(page) {
await page.waitForFunction(
(expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes('BattleScene') &&
battle?.scene === 'BattleScene' &&
battle?.battleId === expectedBattleId &&
battle?.battleOutcome === null &&
['deployment', 'idle'].includes(battle?.phase) &&
battle?.mapBackgroundReady === true &&
battle?.resultVisible === false
);
},
battleId,
{ timeout: 90000 }
);
}
async function assertDesktopRuntime(page, expectedRenderer) {
const runtime = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
visualViewportScale: window.visualViewport?.scale ?? 1,
rootCssZoom: getComputedStyle(document.documentElement).zoom,
rendererType: window.__HEROS_GAME__?.renderer.type,
canvas: canvas
? {
width: canvas.width,
height: canvas.height,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
bounds: bounds
? {
width: bounds.width,
height: bounds.height
}
: null
}
: null
};
});
assert.equal(runtime.innerWidth, desktopBrowserViewport.width);
assert.equal(runtime.innerHeight, desktopBrowserViewport.height);
assert.equal(runtime.devicePixelRatio, desktopBrowserDeviceScaleFactor);
assert.equal(runtime.visualViewportScale, 1);
assert(
runtime.rootCssZoom === '1' || runtime.rootCssZoom === 'normal',
`Unexpected CSS zoom ${runtime.rootCssZoom}.`
);
assert.equal(runtime.rendererType, expectedRenderer === 'canvas' ? 1 : 2);
assert.equal(runtime.canvas?.width, desktopBrowserViewport.width);
assert.equal(runtime.canvas?.height, desktopBrowserViewport.height);
assert.equal(runtime.canvas?.clientWidth, desktopBrowserViewport.width);
assert.equal(runtime.canvas?.clientHeight, desktopBrowserViewport.height);
assert.equal(runtime.canvas?.bounds?.width, desktopBrowserViewport.width);
assert.equal(runtime.canvas?.bounds?.height, desktopBrowserViewport.height);
}

View File

@@ -90,6 +90,38 @@ try {
isValidBattleSaveState(legacyStateWithoutSortieOrder, options),
'Expected legacy battle saves without a sortie order to remain valid.'
);
const generatedBattleSave = {
...validState,
battleResumeGeneration: 7
};
assert(
isValidBattleSaveState(
generatedBattleSave,
options
) &&
parseBattleSaveState(
JSON.stringify(generatedBattleSave),
options
)?.battleResumeGeneration === 7,
'Expected a non-negative battle-resume generation to round-trip.'
);
assert(
!isValidBattleSaveState(
{
...validState,
battleResumeGeneration: -1
},
options
) &&
!isValidBattleSaveState(
{
...validState,
battleResumeGeneration: 1.5
},
options
),
'Expected invalid battle-resume generations to be rejected.'
);
const validPendingBattleEventState = {
...validState,
pendingBattleEvents: [

View File

@@ -22,7 +22,12 @@ try {
readBattleSaveStorageCandidate,
readCampaignBattleResume
} = await server.ssrLoadModule('/src/game/state/battleSaveStorage.ts');
const { resetCampaignState, startNewCampaign } = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const {
createInitialCampaignState,
resetCampaignState,
setCampaignState,
startNewCampaign
} = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const firstBattleId = 'first-battle-zhuo-commandery';
const campaign = { step: 'first-battle', activeSaveSlot: 2 };
@@ -39,6 +44,79 @@ try {
`Expected a matching slotted battle save to become a direct-resume candidate: ${JSON.stringify(directResume)}`
);
const generationCampaign = {
...campaign,
battleResumeGeneration: 4
};
storage.setItem(
slotKey,
JSON.stringify(
createBattleSave({
battleResumeGeneration: 3
})
)
);
assert(
readCampaignBattleResume(
generationCampaign,
2,
storage
) === undefined &&
storage.getItem(slotKey) === null,
'A superseded battle-save generation must be rejected and cleaned.'
);
storage.setItem(
slotKey,
JSON.stringify(
createBattleSave({
battleResumeGeneration: 4,
turnNumber: 6
})
)
);
assert(
readCampaignBattleResume(
generationCampaign,
2,
storage
)?.turnNumber === 6,
'A battle save from the current generation must remain resumable.'
);
const stickyStorage = createStorage({
refuseRemoval: true
});
stickyStorage.setItem(
slotKey,
JSON.stringify(
createBattleSave({
battleResumeGeneration: 3
})
)
);
assert(
readCampaignBattleResume(
generationCampaign,
2,
stickyStorage
) === undefined &&
stickyStorage.getItem(slotKey) !== null,
'A generation mismatch must remain logically blocked when storage refuses physical deletion.'
);
storage.setItem(
slotKey,
JSON.stringify(createBattleSave())
);
assert(
readCampaignBattleResume(
generationCampaign,
2,
storage
) === undefined,
'A legacy generation-zero battle save must not cross a later battle launch generation.'
);
storage.setItem(slotKey, '{broken-json');
assert(readCampaignBattleResume(campaign, 2, storage) === undefined, 'Broken JSON must not become a resume candidate.');
assert(storage.getItem(slotKey) === null, 'Broken slotted battle saves must be removed after validation.');
@@ -50,6 +128,50 @@ try {
const slotOneCampaign = { step: 'first-battle', activeSaveSlot: 1 };
const slotOneKey = battleSaveStorageSlotKey(firstBattleId, 1);
const baseKey = battleSaveStorageBaseKey(firstBattleId);
const generationFilteredStorage = createStorage({
refuseRemoval: true
});
generationFilteredStorage.setItem(
slotOneKey,
JSON.stringify(
createBattleSave({
battleResumeGeneration: 3,
turnNumber: 8
})
)
);
generationFilteredStorage.setItem(
baseKey,
JSON.stringify(
createBattleSave({
battleResumeGeneration: 4,
turnNumber: 5
})
)
);
const generationFilteredCandidate =
readBattleSaveStorageCandidate(
firstBattleId,
'first-battle',
1,
generationFilteredStorage,
3,
{},
{
...slotOneCampaign,
battleResumeGeneration: 4
}
);
assert(
generationFilteredCandidate
?.storageKey === baseKey &&
generationFilteredCandidate.state
.turnNumber === 5 &&
generationFilteredStorage.getItem(
slotOneKey
) !== null,
`A direct BattleScene-style read must reject a stale generation and continue to a current fallback even when physical deletion is refused: ${JSON.stringify(generationFilteredCandidate)}`
);
storage.setItem(slotOneKey, 'corrupted-slot-copy');
storage.setItem(baseKey, JSON.stringify(createBattleSave({ turnNumber: 3 })));
const fallbackResume = readCampaignBattleResume(slotOneCampaign, 1, storage);
@@ -150,11 +272,21 @@ try {
campaignStep: 'second-battle'
})));
browserStorage.setItem(slotOneKey, JSON.stringify(createBattleSave()));
startNewCampaign();
const priorSlotOneCampaign =
createInitialCampaignState();
priorSlotOneCampaign.step = 'first-battle';
priorSlotOneCampaign.activeSaveSlot = 1;
priorSlotOneCampaign.battleResumeGeneration = 7;
setCampaignState(priorSlotOneCampaign);
const newCampaign = startNewCampaign();
assert(
browserStorage.getItem(slotOneKey) === null && browserStorage.getItem(secondBattleKey) !== null,
'Starting a new campaign must clear slot 1 battle data without deleting resumable saves from preserved campaign slots.'
);
assert(
newCampaign.battleResumeGeneration === 8,
`A replacement campaign must advance beyond the overwritten slot generation before best-effort battle cleanup: ${newCampaign.battleResumeGeneration}`
);
assert(
clearCampaignBattleSavesForSlot(3, browserStorage) === 1 && browserStorage.getItem(secondBattleKey) === null,
'Slot-scoped cleanup must remove only battle saves belonging to the requested campaign slot.'
@@ -220,6 +352,18 @@ try {
/saveBattleState\(slot = 1\)[\s\S]*?const state = this\.createBattleSaveState\(\)[\s\S]*?clearCampaignBattleSavesForSlot\(normalizedSlot\)[\s\S]*?localStorage\.setItem\(this\.battleSaveStorageKeyForSlot\(normalizedSlot\)/.test(battleSceneSource),
'Battle slot overwrites must clear all destination battle resumes before writing the current battle snapshot.'
);
assert(
/battleResumeGeneration:\s*campaign\.battleResumeGeneration/.test(
battleSceneSource
),
'Every new battle snapshot must carry its campaign battle-resume generation.'
);
assert(
/readBattleSaveState\(slot: number, validateCurrentComposition = false\)[\s\S]*?readBattleSaveStorageCandidate\([\s\S]*?savedCampaign\s*\)/.test(
battleSceneSource
),
'BattleScene initial and manual loads must filter candidates through the loaded campaign generation.'
);
assert(
/stats: \{ \.\.\.unit\.stats \}/.test(battleSceneSource) &&
/unit\.stats = \{ \.\.\.savedUnit\.stats \}/.test(battleSceneSource) &&
@@ -227,12 +371,12 @@ try {
'Battle saves must persist new attributes and reconstruct battle-earned attributes for legacy saves.'
);
console.log('Verified direct battle-save resume routing, BattleScene restore hooks, stale cleanup, fallback order, generation cleanup, and non-battle isolation.');
console.log('Verified direct battle-save resume routing, durable launch-generation matching, BattleScene generation filtering, deletion-failure blocking, monotonic new-campaign replacement, restore hooks, stale cleanup, fallback order, generation cleanup, and non-battle isolation.');
} finally {
await server.close();
}
function createStorage() {
function createStorage(options = {}) {
const values = new Map();
return {
getItem(key) {
@@ -242,7 +386,9 @@ function createStorage() {
values.set(key, String(value));
},
removeItem(key) {
values.delete(key);
if (!options.refuseRemoval) {
values.delete(key);
}
},
clear() {
values.clear();

View File

@@ -206,12 +206,14 @@ function validateCampInteractionUxGuards() {
checkedGuardCount += 1;
expectCampUx(
steppedNavigationMethod.includes('const previousStep = getCampaignState().step') &&
steppedNavigationMethod.includes('markCampaignStep(step)') &&
steppedNavigationMethod.includes('getCampaignState().step === step') &&
steppedNavigationMethod.includes('markCampaignStep(previousStep)') &&
steppedNavigationMethod.includes('const previousCampaign = getCampaignState()') &&
steppedNavigationMethod.includes('beginCampaignStory(') &&
steppedNavigationMethod.includes('const startsBattle =') &&
steppedNavigationMethod.includes('markCampaignStep(step, {') &&
steppedNavigationMethod.includes('startBattle: startsBattle') &&
steppedNavigationMethod.includes('saveCampaignState(previousCampaign)') &&
navigationMethod.includes('recoverState?.()'),
'campaign-step navigation must restore the previous step when lazy scene loading fails'
'campaign-step navigation must atomically begin stories and restore the complete previous campaign when lazy scene loading fails'
);
checkedGuardCount += 1;

View File

@@ -153,8 +153,8 @@ assert(
'Defeat results must never expose the victory-story continuation route'
);
assert(
/if \(outcome === 'victory'\) \{\s*this\.resultContinueButton = this\.addResultButton/.test(battleSceneSource),
'The victory-story result CTA must only be created for victories'
/if \(\s*outcome === 'victory' \|\|\s*this\.resultReportPersistenceStatus ===\s*'failed'\s*\) \{\s*this\.resultContinueButton = this\.addResultButton/.test(battleSceneSource),
'The result CTA must be available for victories and for either outcome when report persistence needs an explicit retry'
);
assert(
/if \(outcome === 'victory'\) \{[\s\S]*?pages: battleScenario\.victoryPages,[\s\S]*?return;\s*\}\s*this\.beginResultNavigation\(\(\) => startLazyScene\(this, 'CampScene', campSceneData\)\);/.test(battleSceneSource),
@@ -178,8 +178,19 @@ assert(
'Continue must restore a pending aftermath before routing camp or ending campaign steps'
);
assert(
/if \(data\?\.completedAftermathBattleId\) \{\s*completeCampaignAftermath\(data\.completedAftermathBattleId\);\s*\}/.test(campSceneSource),
'CampScene must complete the pending aftermath before loading its campaign snapshot'
campSceneSource.includes(
'completeCampaignStoryHandoffAftermath('
) &&
campSceneSource.includes(
'completeCampaignAftermath('
) &&
campSceneSource.indexOf(
'completeCampaignStoryHandoffAftermath('
) <
campSceneSource.indexOf(
'this.campaign = getCampaignState()'
),
'CampScene must atomically consume an exact story aftermath handoff, preserve its legacy completion path, and do both before loading its campaign snapshot'
);
if (failures.length > 0) {

View File

@@ -101,7 +101,71 @@ try {
'Replaying the exact same victory report must not duplicate gold or items.'
);
campaign.completeCampaignAftermath(scenario.id);
const aftermathReading =
campaign.setCampaignStoryCheckpoint({
version: 1,
sequenceId:
'settlement-aftermath-fixture',
beatId: 'settlement-aftermath-fixture',
pageIndex: 0
});
const aftermathReference = {
sequenceId:
aftermathReading.storyCheckpoint.sequenceId,
revision:
aftermathReading.storyCheckpoint.revision
};
const aftermathHandoff =
campaign.markCampaignStoryHandoff(
aftermathReference.sequenceId,
aftermathReference.revision
);
const beforeMismatchedAftermath =
JSON.stringify(aftermathHandoff);
const staleAftermath =
campaign.completeCampaignStoryHandoffAftermath(
scenario.id,
{
...aftermathReference,
revision:
aftermathReference.revision + 1
}
);
assert.equal(
JSON.stringify(staleAftermath),
beforeMismatchedAftermath,
'A stale handoff reference must consume neither the pending aftermath nor its story checkpoint.'
);
const wrongBattleAftermath =
campaign.completeCampaignStoryHandoffAftermath(
'second-battle-yellow-turban-pursuit',
aftermathReference
);
assert.equal(
JSON.stringify(wrongBattleAftermath),
beforeMismatchedAftermath,
'A mismatched battle must consume neither half of an aftermath handoff.'
);
const completedAftermath =
campaign.completeCampaignStoryHandoffAftermath(
scenario.id,
aftermathReference
);
assert.equal(
completedAftermath.pendingAftermathBattleId,
undefined,
'An exact aftermath handoff must retire the pending settlement.'
);
assert.equal(
completedAftermath.storyCheckpoint,
undefined,
'An exact aftermath handoff must retire its story checkpoint atomically.'
);
assert.equal(
completedAftermath.storyCheckpointRevision,
aftermathReference.revision + 1,
'An exact aftermath handoff must invalidate its consumed revision.'
);
campaign.setActiveCampVisitId(
'first-pursuit-scout-tent'
);

File diff suppressed because it is too large Load Diff

View File

@@ -206,18 +206,43 @@ try {
await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true });
await assertCanvasPainted(page, 'new game story');
await assertFreshStoryAssetStreaming(page, requestedResourceUrls, freshStoryRequestStartIndex);
const interruptedPrologue = await page.evaluate(
() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()
);
const interruptedPrologueSave = await readCampaignSave(page);
assert(
Number.isInteger(interruptedPrologue?.pageIndex) &&
interruptedPrologue.pageIndex > 0 &&
Boolean(interruptedPrologue.currentPageId),
`Expected the prologue interruption fixture to be beyond its first page: ${JSON.stringify(interruptedPrologue)}`
);
assertStoryCheckpointMatchesPage(
interruptedPrologue,
interruptedPrologueSave,
'interrupted prologue story'
);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await clickLegacyUi(page, 962, 310);
await waitForStoryReady(page);
const resumedPrologue = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.());
const resumedPrologueSave = await readCampaignSave(page);
assert(
resumedPrologue?.pageIndex === 0 &&
resumedPrologue?.pageIndex === interruptedPrologue.pageIndex &&
resumedPrologue?.currentPageId === interruptedPrologue.currentPageId &&
resumedPrologue?.presentation?.battleId === 'first-battle-zhuo-commandery' &&
resumedPrologue.presentation.arcId === 'yellow-turban' &&
resumedPrologue.presentation.stage === 'story' &&
resumedPrologue.presentation.washVisible === true,
`Expected a resumed prologue to retain the founding campaign presentation: ${JSON.stringify(resumedPrologue)}`
`Expected a resumed prologue to retain the exact interrupted page and founding campaign presentation: ${JSON.stringify({
interrupted: interruptedPrologue,
resumed: resumedPrologue
})}`
);
assertStoryCheckpointMatchesPage(
resumedPrologue,
resumedPrologueSave,
'resumed prologue story'
);
const firstBattleTransition = await advanceUntilBattle(page, 'first-battle-zhuo-commandery', {
startDeployment: false,
@@ -2651,6 +2676,20 @@ try {
undefined,
{ timeout: 30000 }
);
const interruptedFirstAftermath = await page.evaluate(
() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()
);
const interruptedFirstAftermathSave = await readCampaignSave(page);
assert(
interruptedFirstAftermath?.pageIndex === 1 &&
Boolean(interruptedFirstAftermath.currentPageId),
`Expected the first aftermath interruption fixture on its second page: ${JSON.stringify(interruptedFirstAftermath)}`
);
assertStoryCheckpointMatchesPage(
interruptedFirstAftermath,
interruptedFirstAftermathSave,
'interrupted first aftermath'
);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await clickLegacyUi(page, 962, 310);
@@ -2658,13 +2697,23 @@ try {
const resumedFirstAftermath = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.());
const resumedFirstAftermathSave = await readCampaignSave(page);
assert(
resumedFirstAftermath?.pageIndex === 0 &&
resumedFirstAftermath?.pageIndex === interruptedFirstAftermath.pageIndex &&
resumedFirstAftermath?.currentPageId === interruptedFirstAftermath.currentPageId &&
resumedFirstAftermath?.totalPages === 5 &&
resumedFirstAftermath?.presentation?.battleId === 'first-battle-zhuo-commandery' &&
resumedFirstAftermath.presentation.stage === 'aftermath' &&
resumedFirstAftermathSave.current?.pendingAftermathBattleId === 'first-battle-zhuo-commandery' &&
resumedFirstAftermathSave.slot1?.pendingAftermathBattleId === 'first-battle-zhuo-commandery',
`Expected continue after an interrupted aftermath to restore that aftermath before camp: ${JSON.stringify({ story: resumedFirstAftermath, save: resumedFirstAftermathSave })}`
`Expected continue after an interrupted aftermath to restore the exact interrupted page before camp: ${JSON.stringify({
interrupted: interruptedFirstAftermath,
resumed: resumedFirstAftermath,
save: resumedFirstAftermathSave
})}`
);
assertStoryCheckpointMatchesPage(
resumedFirstAftermath,
resumedFirstAftermathSave,
'resumed first aftermath'
);
assert(
sameJsonValue(
@@ -2704,19 +2753,23 @@ try {
'first-victory-zhang-fei',
'first-victory-next'
];
const firstAftermathRewardDisplays = [
directFirstAftermath.rewardDisplay,
...firstCampTransition.rewardDisplays
];
assert(
sameJsonValue(
firstCampTransition.rewardDisplays.map((display) => display.pageId),
firstAftermathRewardDisplays.map((display) => display.pageId),
firstVictoryStoryPageIds
) &&
firstCampTransition.rewardDisplays.every((display) => (
firstAftermathRewardDisplays.every((display) => (
display.source === 'campaign' &&
display.battleId === 'first-battle-zhuo-commandery' &&
display.rewardGold === null &&
Array.isArray(display.items) &&
display.items.length === 0
)),
`Expected all five first-victory aftermath pages to retain campaign context without duplicating exact rewards: ${JSON.stringify(firstCampTransition.rewardDisplays)}`
`Expected all five first-victory aftermath pages to retain campaign context without duplicating exact rewards: ${JSON.stringify(firstAftermathRewardDisplays)}`
);
const firstNarrativeMemoriesByPage = Object.fromEntries(
firstCampTransition.narrativeMemories.map((memory) => [memory.pageId, memory])
@@ -6131,6 +6184,15 @@ try {
await waitForTitle(page);
}
const midCampaignSelectedUnitIds = [
'zhuge-liang',
'jiang-wei',
'wang-ping',
'zhao-yun',
'huang-quan',
'ma-liang',
'ma-dai'
];
await seedCampaignSave(page, {
battleId: 'fifty-eighth-battle-qishan-retreat',
battleTitle: '기산 후퇴로',
@@ -6138,12 +6200,36 @@ try {
gold: 7400,
turnNumber: 13,
defeatedEnemies: 16,
selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'huang-quan', 'ma-liang', 'ma-dai']
selectedSortieUnitIds: midCampaignSelectedUnitIds
});
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await clickLegacyUi(page, 962, 310);
await waitForCamp(page);
const midCampaignSelectionFixture = await page.evaluate((requestedUnitIds) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (
!scene ||
typeof scene.normalizedSortieUnitIds !== 'function' ||
typeof scene.persistSortieSelection !== 'function'
) {
return { ready: false, selectedUnitIds: [] };
}
scene.selectedSortieUnitIds = scene.normalizedSortieUnitIds(requestedUnitIds);
scene.persistSortieSelection();
return {
ready: true,
selectedUnitIds: [...scene.selectedSortieUnitIds]
};
}, midCampaignSelectedUnitIds);
assert(
midCampaignSelectionFixture.ready === true &&
sameJsonValue(
[...midCampaignSelectionFixture.selectedUnitIds].sort(),
[...midCampaignSelectedUnitIds].sort()
),
`Expected the mid-campaign fixture to select all seven officers after camp recruitment: ${JSON.stringify(midCampaignSelectionFixture)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-campaign-continue.png`, fullPage: true });
await assertCanvasPainted(page, 'mid campaign camp');
@@ -6597,8 +6683,12 @@ try {
JSON.stringify(swapPreview?.comparison?.projected?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
swapPreview?.metrics?.length === 4 &&
swapPreview.metrics.some((metric) => metric.delta !== 0) &&
swapPreview?.comparison?.lostRoles?.includes('flank') &&
swapPreview?.comparison?.activeBondDelta === 0 &&
swapPreview?.comparison?.current?.coveredRoleCount === 3 &&
swapPreview?.comparison?.projected?.coveredRoleCount === 3 &&
swapPreview?.comparison?.current?.formationRoleCounts?.flank === 2 &&
swapPreview?.comparison?.projected?.formationRoleCounts?.flank === 1 &&
swapPreview?.comparison?.lostRoles?.length === 0 &&
swapPreview?.comparison?.activeBondDelta === -1 &&
swapPreview?.comparison?.gainedBonds?.length > 0 &&
swapPreview?.comparison?.lostBonds?.length > 0 &&
swapPreview?.strategyCoverage?.current?.count === 4 &&
@@ -6617,7 +6707,7 @@ try {
midFormationSwapPreviewProbe.panel.headline?.includes('IN 관우') &&
midFormationSwapPreviewProbe.panel.impact?.includes('전법 4/4→4/4') &&
midFormationSwapPreviewProbe.panel.impact?.includes('추격') &&
midFormationSwapPreviewProbe.panel.impact?.includes('공백 돌파') &&
midFormationSwapPreviewProbe.panel.impact?.includes('역할 유지') &&
midFormationSwapPreviewProbe.panel.detail?.includes('전법 유지') &&
midFormationSwapPreviewProbe.panel.detail?.includes('추격 해제') &&
midFormationSwapPreviewProbe.panel.detail?.includes('8%') &&
@@ -7753,6 +7843,61 @@ try {
campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave)
)
);
const midImprovementStoryCheckpointConsumed = ['current', 'slot1'].every(
(slot) => {
const before = updatedMobileResultSave[slot];
const after = pendingMidImprovementSave[slot];
const checkpoint = before?.storyCheckpoint;
return (
checkpoint?.phase === 'reading' &&
checkpoint.pageIndex === 0 &&
checkpoint.campaignStep === before?.step &&
checkpoint.revision ===
before?.storyCheckpointRevision &&
checkpoint.continuationIntent === 'default' &&
typeof checkpoint.sequenceId === 'string' &&
checkpoint.sequenceId.length > 0 &&
after?.storyCheckpoint === undefined &&
after?.storyCheckpointRevision ===
expectedNextStoryCheckpointRevision(
before?.storyCheckpointRevision
)
);
}
);
const expectedMidImprovementTargetStep =
typeof midImprovementTargetBattleId === 'string'
? midImprovementTargetBattleId.replace(
/-battle-.+$/,
'-battle'
)
: undefined;
const midImprovementPreparationCheckpointPersisted = [
'current',
'slot1'
].every((slot) => {
const before = updatedMobileResultSave[slot];
const after = pendingMidImprovementSave[slot];
const checkpoint =
after?.sortiePreparationCheckpoint;
return (
before?.sortiePreparationCheckpoint ===
undefined &&
checkpoint?.version === 1 &&
checkpoint.intent ===
'sortie-improvement' &&
checkpoint.mode === 'next-sortie' &&
checkpoint.sourceBattleId ===
midCampNextBattleId &&
checkpoint.targetBattleId ===
midImprovementTargetBattleId &&
checkpoint.targetCampaignStep ===
expectedMidImprovementTargetStep &&
Number.isFinite(
Date.parse(checkpoint.savedAt)
)
);
});
const midImprovementAftermathCompleted =
updatedMobileResultSave.current?.pendingAftermathBattleId === midCampNextBattleId &&
updatedMobileResultSave.slot1?.pendingAftermathBattleId === midCampNextBattleId &&
@@ -7802,19 +7947,63 @@ try {
boundsWithinFhdViewport(midImprovementLedger.toggleButtonBounds) &&
midImprovementLedger.toggleInteractive === true &&
midImprovementLedger.cards.length === 0 &&
midImprovementLedger.actions.length === 0 &&
midImprovementCampEntrySaveUnchanged &&
midImprovementAftermathCompleted &&
midImprovementRewardDestinationPending,
midImprovementLedger.actions.length === 0 &&
midImprovementCampEntrySaveUnchanged &&
midImprovementStoryCheckpointConsumed &&
midImprovementPreparationCheckpointPersisted &&
midImprovementAftermathCompleted &&
midImprovementRewardDestinationPending,
`Expected next-sortie improvement arrival to remain nonblocking with passive settlement and an unopened on-demand ledger: ${JSON.stringify({
openSortiePrepOnCreate: pendingMidImprovementReward?.openSortiePrepOnCreate,
sortieVisible: pendingMidImprovementReward?.sortieVisible,
arrival: midImprovementArrival,
ledger: midImprovementLedger,
expectedBattleId: midCampNextBattleId,
saveUnchanged: midImprovementCampEntrySaveUnchanged,
aftermathCompleted: midImprovementAftermathCompleted,
rewardDestinationPending: midImprovementRewardDestinationPending,
expectedBattleId: midCampNextBattleId,
saveUnchanged: midImprovementCampEntrySaveUnchanged,
storyCheckpointConsumed:
midImprovementStoryCheckpointConsumed,
storyCheckpointBefore: {
current:
updatedMobileResultSave.current
?.storyCheckpoint,
slot1:
updatedMobileResultSave.slot1
?.storyCheckpoint
},
storyCheckpointAfter: {
current:
pendingMidImprovementSave.current
?.storyCheckpoint,
slot1:
pendingMidImprovementSave.slot1
?.storyCheckpoint
},
storyCheckpointRevisions: {
beforeCurrent:
updatedMobileResultSave.current
?.storyCheckpointRevision,
beforeSlot1:
updatedMobileResultSave.slot1
?.storyCheckpointRevision,
afterCurrent:
pendingMidImprovementSave.current
?.storyCheckpointRevision,
afterSlot1:
pendingMidImprovementSave.slot1
?.storyCheckpointRevision
},
preparationCheckpointPersisted:
midImprovementPreparationCheckpointPersisted,
preparationCheckpoint: {
current:
pendingMidImprovementSave.current
?.sortiePreparationCheckpoint,
slot1:
pendingMidImprovementSave.slot1
?.sortiePreparationCheckpoint
},
aftermathCompleted: midImprovementAftermathCompleted,
rewardDestinationPending: midImprovementRewardDestinationPending,
rewardAcknowledgements: midImprovementRewardAcknowledgements,
noticeDismissals: midImprovementNoticeDismissals
})}`
@@ -11375,6 +11564,39 @@ async function readCampaignSave(page) {
});
}
function assertStoryCheckpointMatchesPage(story, saves, label) {
const runtimeCheckpoint =
story?.storyCheckpoint?.persisted;
const currentCheckpoint = saves?.current?.storyCheckpoint;
const slotCheckpoint = saves?.slot1?.storyCheckpoint;
assert(
story?.storyCheckpoint?.enabled === true &&
Boolean(story.currentPageId) &&
runtimeCheckpoint?.sequenceId ===
story.storyCheckpoint.sequenceId &&
runtimeCheckpoint?.beatId === story.currentPageId &&
runtimeCheckpoint?.pageIndex === story.pageIndex &&
runtimeCheckpoint?.phase === 'reading' &&
currentCheckpoint?.sequenceId ===
runtimeCheckpoint.sequenceId &&
currentCheckpoint?.beatId === story.currentPageId &&
currentCheckpoint?.pageIndex === story.pageIndex &&
currentCheckpoint?.phase === 'reading' &&
slotCheckpoint?.sequenceId ===
runtimeCheckpoint.sequenceId &&
slotCheckpoint?.beatId === story.currentPageId &&
slotCheckpoint?.pageIndex === story.pageIndex &&
slotCheckpoint?.phase === 'reading' &&
sameJsonValue(currentCheckpoint, slotCheckpoint),
`Expected ${label} to persist its exact page checkpoint in runtime, current, and slot saves: ${JSON.stringify({
story,
runtimeCheckpoint,
currentCheckpoint,
slotCheckpoint
})}`
);
}
async function readBattleResultEvaluationControlPoint(page, control, presetId) {
const probe = await page.evaluate(({ control, presetId }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -12558,6 +12780,16 @@ function sameJsonValue(value, expected) {
return stableJson(value) === stableJson(expected);
}
function expectedNextStoryCheckpointRevision(revision) {
const normalized =
Number.isSafeInteger(revision) && revision >= 0
? revision
: 0;
return normalized >= Number.MAX_SAFE_INTEGER
? 0
: normalized + 1;
}
function assertBattleResultDebriefText(resultTexts, rewards, context) {
const texts = Array.isArray(resultTexts) ? resultTexts : [];
const recruits = rewards?.recruits ?? [];
@@ -12604,6 +12836,10 @@ function campaignSaveWithoutCampRecruitmentEffects(save) {
acknowledgedVictoryRewardBattleIds: _acknowledgedVictoryRewardBattleIds,
acknowledgedVictoryRewardCategories: _acknowledgedVictoryRewardCategories,
pendingAftermathBattleId: _pendingAftermathBattleId,
storyCheckpointRevision: _storyCheckpointRevision,
storyCheckpoint: _storyCheckpoint,
sortiePreparationCheckpoint:
_sortiePreparationCheckpoint,
firstBattleReport,
...stableFields
} = state;

View File

@@ -0,0 +1,658 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createServer } from 'vite';
const storage = new Map();
let failCanonicalSlotWrites = false;
let storageReadCount = 0;
globalThis.window = {
localStorage: {
getItem(key) {
storageReadCount += 1;
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
if (
failCanonicalSlotWrites &&
String(key).includes(':slot-')
) {
throw new Error(
'Simulated canonical campaign save-slot failure.'
);
}
storage.set(String(key), String(value));
},
removeItem(key) {
storage.delete(String(key));
},
clear() {
storage.clear();
}
}
};
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const campaign = await server.ssrLoadModule(
'/src/game/state/campaignState.ts'
);
const battleSaves = await server.ssrLoadModule(
'/src/game/state/battleSaveStorage.ts'
);
verifyDefeatPreparationDurability();
verifyVictoryPreparationAndStoryAtomicity();
verifyCanonicalFailureAtomicity();
verifyStaleCheckpointNormalization();
verifySceneRoutingContracts();
console.log(
'Verified durable sortie-improvement checkpoints for defeat retry and victory next-sortie flows: atomic story intent, title-over-battle-save priority, camp restoration, launch-time consumption, canonical-write failure invariance, stale-state normalization, and redundant default-story write avoidance.'
);
function verifyDefeatPreparationDurability() {
resetToBattle();
campaign.setFirstBattleReport(
reportFixture('defeat')
);
const before =
campaign.getCampaignState();
const prepared =
campaign.beginCampaignSortiePreparation({
mode: 'retry',
sourceBattleId:
'first-battle-zhuo-commandery',
targetBattleId:
'first-battle-zhuo-commandery'
});
assert.deepEqual(
preparationProjection(prepared),
{
intent: 'sortie-improvement',
mode: 'retry',
sourceBattleId:
'first-battle-zhuo-commandery',
targetBattleId:
'first-battle-zhuo-commandery',
targetCampaignStep: 'first-battle'
}
);
assert.equal(
prepared.storyCheckpoint,
undefined,
'Defeat preparation must not invent an aftermath story.'
);
assertPersistedState(
prepared,
'defeat retry preparation'
);
const readCountBeforeResume =
storageReadCount;
assert.equal(
battleSaves.readCampaignBattleResume(
prepared,
prepared.activeSaveSlot,
{
getItem() {
throw new Error(
'A battle save must not be inspected while explicit preparation is pending.'
);
},
setItem() {},
removeItem() {}
}
),
undefined,
'Explicit sortie preparation must outrank a prior battle resume.'
);
assert.equal(
storageReadCount,
readCountBeforeResume,
'The isolated resume probe unexpectedly touched browser storage.'
);
const reloaded =
campaign.loadCampaignState(
prepared.activeSaveSlot
);
assert.deepEqual(
preparationProjection(reloaded),
preparationProjection(prepared),
'Reload must preserve the exact retry preparation destination.'
);
const launched = campaign.markCampaignStep(
'first-battle',
{ startBattle: true }
);
assert.equal(
launched.sortiePreparationCheckpoint,
undefined,
'A committed retry launch must consume the preparation checkpoint.'
);
assert.equal(
launched.battleResumeGeneration,
before.battleResumeGeneration + 1,
'Retry launch must advance the battle-resume generation atomically.'
);
assertPersistedState(
launched,
'retry launch consumption'
);
}
function verifyVictoryPreparationAndStoryAtomicity() {
resetToBattle();
campaign.setFirstBattleReport(
reportFixture('victory'),
{
storyCheckpoint:
storyWrite('default')
}
);
const settled =
campaign.getCampaignState();
const storyRevision =
settled.storyCheckpoint.revision;
const prepared =
campaign.beginCampaignSortiePreparation(
{
mode: 'next-sortie',
sourceBattleId:
'first-battle-zhuo-commandery',
targetBattleId:
'second-battle-yellow-turban-pursuit'
},
{
storyCheckpoint:
storyWrite(
'sortie-improvement'
)
}
);
assert.deepEqual(
preparationProjection(prepared),
{
intent: 'sortie-improvement',
mode: 'next-sortie',
sourceBattleId:
'first-battle-zhuo-commandery',
targetBattleId:
'second-battle-yellow-turban-pursuit',
targetCampaignStep: 'second-battle'
}
);
assert.equal(
prepared.storyCheckpoint.revision,
storyRevision,
'Changing only the aftermath continuation intent must retain its exact story revision.'
);
assert.equal(
prepared.storyCheckpoint
.continuationIntent,
'sortie-improvement'
);
assertPersistedState(
prepared,
'victory improvement and aftermath story'
);
const handoff =
campaign.markCampaignStoryHandoff(
prepared.storyCheckpoint.sequenceId,
prepared.storyCheckpoint.revision
);
const arrived =
campaign.completeCampaignStoryHandoffAftermath(
'first-battle-zhuo-commandery',
{
sequenceId:
handoff.storyCheckpoint.sequenceId,
revision:
handoff.storyCheckpoint.revision
}
);
assert.equal(
arrived.storyCheckpoint,
undefined
);
assert(
arrived.sortiePreparationCheckpoint,
'Camp arrival must retain the explicit improvement overlay checkpoint.'
);
assertPersistedState(
arrived,
'victory camp arrival'
);
const launched =
campaign.beginCampaignStory(
'second-battle',
{
version: 1,
sequenceId:
'second-battle-intro-fixture',
beatId:
'second-battle-intro-fixture',
pageIndex: 0,
continuationIntent: 'default'
},
{ startBattle: true }
);
assert.equal(
launched.sortiePreparationCheckpoint,
undefined,
'Beginning the next sortie story must consume the preparation checkpoint in the same campaign write.'
);
assert.equal(
launched.step,
'second-battle'
);
assertPersistedState(
launched,
'next-sortie story launch consumption'
);
}
function verifyCanonicalFailureAtomicity() {
resetToBattle();
campaign.setFirstBattleReport(
reportFixture('defeat')
);
assertCanonicalWriteFailure(
() =>
campaign.beginCampaignSortiePreparation({
mode: 'retry',
sourceBattleId:
'first-battle-zhuo-commandery',
targetBattleId:
'first-battle-zhuo-commandery'
}),
'defeat sortie preparation'
);
resetToBattle();
campaign.setFirstBattleReport(
reportFixture('victory'),
{
storyCheckpoint:
storyWrite('default')
}
);
assertCanonicalWriteFailure(
() =>
campaign.beginCampaignSortiePreparation(
{
mode: 'next-sortie',
sourceBattleId:
'first-battle-zhuo-commandery',
targetBattleId:
'second-battle-yellow-turban-pursuit'
},
{
storyCheckpoint:
storyWrite(
'sortie-improvement'
)
}
),
'victory sortie preparation and story intent'
);
}
function verifyStaleCheckpointNormalization() {
resetToBattle();
campaign.setFirstBattleReport(
reportFixture('defeat')
);
const valid =
campaign.beginCampaignSortiePreparation({
mode: 'retry',
sourceBattleId:
'first-battle-zhuo-commandery',
targetBattleId:
'first-battle-zhuo-commandery'
});
for (const [label, mutate] of [
[
'advanced campaign step',
(state) => {
state.step = 'second-battle';
}
],
[
'mismatched target campaign step',
(state) => {
state.sortiePreparationCheckpoint
.targetCampaignStep =
'second-battle';
}
],
[
'forged non-next battle and matching step',
(state) => {
state.sortiePreparationCheckpoint
.targetBattleId =
'third-battle-guangzong-road';
state.sortiePreparationCheckpoint
.targetCampaignStep =
'third-battle';
}
],
[
'wrong preparation mode',
(state) => {
state.sortiePreparationCheckpoint.mode =
'next-sortie';
}
],
[
'invalid timestamp',
(state) => {
state.sortiePreparationCheckpoint.savedAt =
'not-a-time';
}
]
]) {
const stale = structuredClone(valid);
mutate(stale);
const loaded = loadRawState(stale);
assert.equal(
loaded.sortiePreparationCheckpoint,
undefined,
`${label} must retire a stale sortie-preparation checkpoint.`
);
}
}
function verifySceneRoutingContracts() {
const titleSource = sourceFile(
'src/game/scenes/TitleScene.ts'
);
const continueSource = sourceSection(
titleSource,
'private async continueGameAsync(',
'private async continueStoryCheckpoint('
);
assertOrder(
continueSource,
'await this.continueStoryCheckpoint(campaign)',
'campaign.sortiePreparationCheckpoint',
'Title must finish or resume aftermath story before restoring preparation.'
);
assertOrder(
continueSource,
'campaign.sortiePreparationCheckpoint',
'readCampaignBattleResume(',
'Explicit preparation must outrank an older battle save.'
);
const campSource = sourceFile(
'src/game/scenes/CampScene.ts'
);
const campInit = sourceSection(
campSource,
'init(data?: CampSceneData)',
'\n create()'
);
assert(
campInit.includes(
'.sortiePreparationCheckpoint'
) &&
campInit.includes(
'durablePreparation.targetBattleId'
) &&
campInit.includes(
"durablePreparation.mode === 'retry'"
),
'Camp init must rebuild the exact improvement overlay and retry target from campaign state.'
);
const battleSource = sourceFile(
'src/game/scenes/BattleScene.ts'
);
const improvementSource = sourceSection(
battleSource,
'private openResultSortieImprovement(',
'private publishFirstBattleReport('
);
assertOrder(
improvementSource,
'this.commitResultSortiePreparation(',
'startLazyScene(this,',
'Result navigation must not begin until its durable destination is committed.'
);
const storyCommitSource = sourceSection(
battleSource,
'private commitResultStoryCheckpoint(',
'private handleResultContinue()'
);
assert(
storyCommitSource.includes(
"current.continuationIntent ===\n continuationIntent"
) &&
storyCommitSource.includes(
'return true;'
),
'An already exact default aftermath checkpoint must avoid a redundant storage write.'
);
}
function resetToBattle() {
failCanonicalSlotWrites = false;
storage.clear();
const state =
campaign.createInitialCampaignState();
state.step = 'first-battle';
state.activeSaveSlot = 1;
return campaign.setCampaignState(state);
}
function reportFixture(outcome) {
return {
battleId:
'first-battle-zhuo-commandery',
battleTitle: '탁현 방어전',
outcome,
turnNumber: 3,
rewardGold:
outcome === 'victory' ? 100 : 0,
defeatedEnemies:
outcome === 'victory' ? 1 : 0,
totalEnemies: 1,
objectives: [],
units: [],
bonds: [],
itemRewards: [],
completedCampDialogues: [],
completedCampVisits: [],
createdAt:
'2026-07-29T01:00:00.000Z'
};
}
function storyWrite(continuationIntent) {
return {
version: 1,
sequenceId:
'first-aftermath-fixture',
beatId:
'first-aftermath-fixture',
pageIndex: 0,
continuationIntent
};
}
function preparationProjection(state) {
const checkpoint =
state.sortiePreparationCheckpoint;
assert(
checkpoint,
'Expected a sortie-preparation checkpoint.'
);
return {
intent: checkpoint.intent,
mode: checkpoint.mode,
sourceBattleId:
checkpoint.sourceBattleId,
targetBattleId:
checkpoint.targetBattleId,
targetCampaignStep:
checkpoint.targetCampaignStep
};
}
function loadRawState(state) {
failCanonicalSlotWrites = false;
storage.clear();
const serialized = JSON.stringify(state);
storage.set(
campaign.campaignStorageKey,
serialized
);
storage.set(
slotKey(state.activeSaveSlot),
serialized
);
return campaign.loadCampaignState(
state.activeSaveSlot
);
}
function assertPersistedState(expected, label) {
assert.deepEqual(
campaign.getCampaignState(),
expected,
`${label}: memory diverged from the returned campaign.`
);
assert.deepEqual(
JSON.parse(
storage.get(
campaign.campaignStorageKey
)
),
expected,
`${label}: compatibility mirror diverged.`
);
assert.deepEqual(
JSON.parse(
storage.get(
slotKey(expected.activeSaveSlot)
)
),
expected,
`${label}: canonical slot diverged.`
);
}
function assertCanonicalWriteFailure(
action,
label
) {
const before =
persistentSnapshot();
failCanonicalSlotWrites = true;
try {
assert.throws(
action,
/Simulated canonical campaign save-slot failure/,
`${label} must surface its canonical write failure.`
);
} finally {
failCanonicalSlotWrites = false;
}
assert.deepEqual(
campaign.getCampaignState(),
before.state,
`${label} changed in-memory campaign state after failure.`
);
assert.equal(
storage.get(
campaign.campaignStorageKey
),
before.base,
`${label} changed the compatibility mirror after failure.`
);
assert.equal(
storage.get(
slotKey(before.state.activeSaveSlot)
),
before.slot,
`${label} changed the canonical slot after failure.`
);
}
function persistentSnapshot() {
const state =
campaign.getCampaignState();
return {
state,
base: storage.get(
campaign.campaignStorageKey
),
slot: storage.get(
slotKey(state.activeSaveSlot)
)
};
}
function slotKey(slot) {
return `${campaign.campaignStorageKey}:slot-${slot}`;
}
function sourceFile(path) {
return readFileSync(
new URL(`../${path}`, import.meta.url),
'utf8'
);
}
function sourceSection(
source,
startMarker,
endMarker
) {
const start = source.indexOf(startMarker);
assert(
start >= 0,
`Missing source marker "${startMarker}".`
);
const end = source.indexOf(
endMarker,
start + startMarker.length
);
assert(
end > start,
`Missing source marker "${endMarker}".`
);
return source.slice(start, end);
}
function assertOrder(
source,
first,
second,
message
) {
const firstIndex = source.indexOf(first);
const secondIndex =
source.indexOf(second);
assert(
firstIndex >= 0 &&
secondIndex > firstIndex,
message
);
}
} finally {
await server.close();
}

View File

@@ -6,6 +6,8 @@ const checks = [
'scripts/verify-campaign-flow-data.mjs',
'scripts/verify-campaign-save-normalization.mjs',
'scripts/verify-campaign-exploration-checkpoint-normalization.mjs',
'scripts/verify-campaign-story-checkpoint-normalization.mjs',
'scripts/verify-sortie-preparation-checkpoint.mjs',
'scripts/verify-campaign-settlement-idempotency.mjs',
'scripts/verify-camp-visit-resume-state.mjs',
'scripts/verify-campaign-completion.mjs',
@@ -18,6 +20,7 @@ const checks = [
'scripts/verify-battle-save-normalization.mjs',
'scripts/verify-battle-event-queue.mjs',
'scripts/verify-battle-save-resume-routing.mjs',
'scripts/verify-battle-result-persistence-retry.mjs',
'scripts/verify-battle-usables.mjs',
'scripts/verify-sortie-synergy.mjs',
'scripts/verify-sortie-recommendations.mjs',

View File

@@ -40,6 +40,7 @@ try {
const errors = [];
const storySources = collectStorySources(scenarioModule, battleScenarios);
const uniquePageIds = new Set();
const firstSourceByPageId = new Map();
const usedBackgroundKeys = new Set();
const selectedBackgroundKeys = new Set();
const selectedBackgroundUrlsByBase = new Map();
@@ -93,6 +94,17 @@ try {
pages.forEach((page, index) => {
pageCount += 1;
const firstSource = firstSourceByPageId.get(page.id);
if (firstSource) {
errors.push(
`story page id global uniqueness: duplicate "${page.id}" at ${source}[${index}], first used at ${firstSource}`
);
} else {
firstSourceByPageId.set(
page.id,
`${source}[${index}]`
);
}
uniquePageIds.add(page.id);
validateStoryPage({
errors,
@@ -127,6 +139,11 @@ try {
if (pageCount !== expectedStoryPageCount) {
errors.push(`story page selection coverage: expected ${expectedStoryPageCount} pages, received ${pageCount}`);
}
if (uniquePageIds.size !== pageCount) {
errors.push(
`story page id global uniqueness: expected ${pageCount} unique ids, received ${uniquePageIds.size}`
);
}
if (portraitSelectionMetrics.pages !== pageCount) {
errors.push(
`story portrait selection coverage: checked ${portraitSelectionMetrics.pages}/${pageCount} pages`

File diff suppressed because it is too large Load Diff