fix: resume campaign progress safely
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
321
scripts/verification-preview-server.mjs
Normal file
321
scripts/verification-preview-server.mjs
Normal 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));
|
||||
}
|
||||
1530
scripts/verify-battle-result-persistence-retry-browser.mjs
Normal file
1530
scripts/verify-battle-result-persistence-retry-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
161
scripts/verify-battle-result-persistence-retry.mjs
Normal file
161
scripts/verify-battle-result-persistence-retry.mjs
Normal 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.'
|
||||
);
|
||||
622
scripts/verify-battle-save-generation-browser.mjs
Normal file
622
scripts/verify-battle-save-generation-browser.mjs
Normal 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);
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
1117
scripts/verify-campaign-story-checkpoint-normalization.mjs
Normal file
1117
scripts/verify-campaign-story-checkpoint-normalization.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
658
scripts/verify-sortie-preparation-checkpoint.mjs
Normal file
658
scripts/verify-sortie-preparation-checkpoint.mjs
Normal 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();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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`
|
||||
|
||||
1794
scripts/verify-story-checkpoint-browser.mjs
Normal file
1794
scripts/verify-story-checkpoint-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user