Files
heros_web/scripts/verify-story-checkpoint-browser.mjs

1795 lines
45 KiB
JavaScript

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 rendererNames = ['canvas', 'webgl'];
const renderer =
process.env.VERIFY_STORY_CHECKPOINT_RENDERER;
const legacyFirstVictoryStoryFixture = {
battleId: 'first-battle-zhuo-commandery',
battleTitle: '탁현 방어전',
rewardGold: 300,
alliedUnits: [
{
id: 'liu-bei',
name: '유비',
faction: 'ally',
className: '군웅',
classKey: 'lord',
level: 3,
exp: 18,
hp: 32,
maxHp: 32,
attack: 9,
move: 4,
stats: {
might: 76,
intelligence: 78,
leadership: 84,
agility: 70,
luck: 88
},
equipment: {
weapon: {
itemId: 'twin-oath-blades',
level: 1,
exp: 18
},
armor: {
itemId: 'oath-robe',
level: 1,
exp: 12
},
accessory: {
itemId: 'peach-charm',
level: 1,
exp: 0
}
},
x: 1,
y: 15
},
{
id: 'guan-yu',
name: '관우',
faction: 'ally',
className: '의용 보병',
classKey: 'infantry',
level: 3,
exp: 26,
hp: 30,
maxHp: 30,
attack: 10,
move: 3,
stats: {
might: 96,
intelligence: 74,
leadership: 91,
agility: 72,
luck: 68
},
equipment: {
weapon: {
itemId: 'green-dragon-glaive',
level: 1,
exp: 26
},
armor: {
itemId: 'reinforced-lamellar',
level: 1,
exp: 10
},
accessory: {
itemId: 'war-manual',
level: 1,
exp: 0
}
},
x: 2,
y: 15
},
{
id: 'zhang-fei',
name: '장비',
faction: 'ally',
className: '창병',
classKey: 'spearman',
level: 3,
exp: 20,
hp: 30,
maxHp: 30,
attack: 10,
move: 3,
stats: {
might: 95,
intelligence: 42,
leadership: 83,
agility: 67,
luck: 61
},
equipment: {
weapon: {
itemId: 'serpent-spear',
level: 1,
exp: 20
},
armor: {
itemId: 'lamellar-armor',
level: 1,
exp: 8
},
accessory: {
itemId: 'bravery-token',
level: 1,
exp: 0
}
},
x: 1,
y: 16
}
],
bonds: [
{
id: 'liu-bei__guan-yu',
unitIds: ['liu-bei', 'guan-yu'],
title: '도원결의',
level: 72,
exp: 20,
battleExp: 0,
description:
'같은 적을 노릴 때 공격 피해 보너스와 연계 공격 확률이 오른다.'
},
{
id: 'liu-bei__zhang-fei',
unitIds: ['liu-bei', 'zhang-fei'],
title: '의형제',
level: 68,
exp: 12,
battleExp: 0,
description:
'공격 목표가 겹치면 공명 경험치가 빠르게 쌓인다.'
},
{
id: 'guan-yu__zhang-fei',
unitIds: ['guan-yu', 'zhang-fei'],
title: '맹장 공명',
level: 64,
exp: 8,
battleExp: 0,
description:
'둘이 같은 전선을 압박하면 연계 공격의 기초 확률이 오른다.'
}
],
pageIds: [
'first-victory-village',
'first-victory-liu-bei',
'first-victory-guan-yu',
'first-victory-zhang-fei',
'first-victory-next'
]
};
if (!renderer) {
for (const requestedRenderer of rendererNames) {
const result = spawnSync(
process.execPath,
[fileURLToPath(import.meta.url)],
{
cwd: process.cwd(),
env: {
...process.env,
VERIFY_STORY_CHECKPOINT_RENDERER:
requestedRenderer
},
stdio: 'inherit'
}
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
assert(
rendererNames.includes(renderer),
`Unsupported story checkpoint renderer "${renderer}".`
);
const baseUrl =
process.env.VERIFY_STORY_CHECKPOINT_URL ??
`http://127.0.0.1:${
renderer === 'canvas' ? 41819 : 41820
}/heros_web/`;
const targetUrl = withDebugOptions(baseUrl, renderer);
let serverProcess;
let browser;
try {
serverProcess = await startVerificationPreview({
targetUrl,
explicitUrl: Boolean(
process.env.VERIFY_STORY_CHECKPOINT_URL
),
label: `${renderer} story checkpoint verification`
});
browser = await chromium.launch({
headless:
process.env.VERIFY_STORY_CHECKPOINT_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, false);
await assertDesktopRuntime(page, renderer);
const started = await page.evaluate(() => {
const title =
window.__HEROS_DEBUG__?.scene('TitleScene');
if (
!title ||
typeof title.startGame !== 'function'
) {
return false;
}
title.startGame();
return true;
});
assert.equal(
started,
true,
`${renderer}: new campaign could not be started.`
);
const openingPage = await waitForStoryPage(page, 0);
assert.equal(
openingPage.totalPages,
2,
`${renderer}: expected the short two-page opening story.`
);
await assertStoryCheckpointMatchesPage(
page,
openingPage,
`${renderer}: opening page`
);
const interrupted = await advanceStoryPage(page, 1);
const interruptedIdentity =
storyPageIdentity(interrupted);
await assertStoryCheckpointMatchesPage(
page,
interrupted,
`${renderer}: interrupted page`
);
await reloadToTitle(page);
await clickContinue(page);
const resumed = await waitForStoryPage(page, 1);
assert.deepEqual(
storyPageIdentity(resumed),
interruptedIdentity,
`${renderer}: reload and Continue did not restore the exact story page.`
);
await assertStoryCheckpointMatchesPage(
page,
resumed,
`${renderer}: exact resumed page`
);
const legacyFixture = await injectLegacyIndexCheckpoint(
page,
1
);
assert.equal(legacyFixture.pageIndex, 1);
assert.equal(legacyFixture.beatId, null);
await reloadToTitle(page);
await clickContinue(page);
const legacyResumed = await waitForStoryPage(page, 1);
assert.deepEqual(
storyPageIdentity(legacyResumed),
interruptedIdentity,
`${renderer}: legacy index-only checkpoint did not resume the exact page.`
);
const migratedCheckpoint =
await assertStoryCheckpointMatchesPage(
page,
legacyResumed,
`${renderer}: migrated legacy page`
);
assert.equal(
migratedCheckpoint.version,
1,
`${renderer}: legacy checkpoint version was not migrated.`
);
assert.equal(
migratedCheckpoint.beatId,
legacyResumed.currentPageId,
`${renderer}: legacy checkpoint did not gain a stable beat ID.`
);
const tamperResult =
await tamperCheckpointAndDispatchPageHide(page);
assert.equal(
tamperResult.runtimePageIndex,
1,
`${renderer}: storage tampering changed the live story page.`
);
assert.equal(
tamperResult.beforeDispatch.current.pageIndex,
0,
`${renderer}: current-save tamper fixture was not installed.`
);
assert.equal(
tamperResult.beforeDispatch.slot.pageIndex,
0,
`${renderer}: slot-save tamper fixture was not installed.`
);
assert.equal(
tamperResult.afterDispatch.current.pageIndex,
1,
`${renderer}: pagehide did not restore the current save to the live page.`
);
assert.equal(
tamperResult.afterDispatch.slot.pageIndex,
1,
`${renderer}: pagehide did not restore the slot save to the live page.`
);
assert.equal(
tamperResult.afterDispatch.current.beatId,
legacyResumed.currentPageId
);
assert.equal(
tamperResult.afterDispatch.slot.beatId,
legacyResumed.currentPageId
);
assert.equal(
tamperResult.afterDispatch.current.phase,
'reading'
);
assert.equal(
tamperResult.afterDispatch.slot.phase,
'reading'
);
assert.deepEqual(
tamperResult.afterDispatch.current,
tamperResult.afterDispatch.slot,
`${renderer}: pagehide did not keep current and slot checkpoints identical.`
);
assert.equal(
legacyResumed.isLastPage,
true,
`${renderer}: the checkpoint completion fixture is not on its final page.`
);
await installSceneStartGate(page, 'StoryScene');
await page.keyboard.press('Space');
await waitForSceneStartGate(page);
const pendingFinalHandoff =
await readCheckpointPhases(page);
assertDurableHandoffPending(
pendingFinalHandoff,
migratedCheckpoint,
`${renderer}: final-page handoff cleared before destination ownership`
);
await releaseSceneStartGate(page);
await waitForVillageReady(page);
const consumed = await readCheckpointPhases(page);
assertNoStoryCheckpoint(
consumed,
`${renderer}: completed story checkpoint was not consumed`
);
await page.evaluate(() => {
window.dispatchEvent(new Event('pagehide'));
});
await page.waitForTimeout(100);
const afterLatePageHide =
await readCheckpointPhases(page);
assertNoStoryCheckpoint(
afterLatePageHide,
`${renderer}: a late pagehide resurrected the completed story checkpoint`
);
await reloadToTitle(page);
await injectInterruptedStoryHandoff(
page,
migratedCheckpoint
);
const injectedHandoff =
await readCheckpointPhases(page);
assertDurableHandoffPending(
injectedHandoff,
injectedHandoff.current,
`${renderer}: interrupted handoff fixture was not persisted`
);
await reloadToTitle(page);
await installSceneStartGate(page, 'TitleScene');
await clickContinue(page);
await waitForSceneStartGate(page);
const pendingResumedHandoff =
await readCheckpointPhases(page);
assertDurableHandoffPending(
pendingResumedHandoff,
pendingResumedHandoff.current,
`${renderer}: resumed handoff cleared before destination ownership`
);
await releaseSceneStartGate(page);
await waitForVillageReady(page);
const resumedHandoff =
await readCheckpointPhases(page);
assertNoStoryCheckpoint(
resumedHandoff,
`${renderer}: a resumed final-page handoff was replayed or left pending`
);
await reloadToTitle(page);
const coexistenceFixture =
await injectStoryAndMatchingBattleResume(page);
await reloadToTitle(page);
await clickContinue(page);
const storyPreferredOverBattle =
await waitForStoryPage(
page,
coexistenceFixture.pageIndex
);
assert.equal(
storyPreferredOverBattle.currentPageId,
coexistenceFixture.beatId,
`${renderer}: a matching-generation battle snapshot displaced the newer story checkpoint.`
);
assert.equal(
(
await page.evaluate(
() =>
window.__HEROS_DEBUG__
?.activeScenes?.() ?? []
)
).includes('BattleScene'),
false,
`${renderer}: Continue opened BattleScene instead of the durable story checkpoint.`
);
await reloadToTitle(page);
const legacyVictoryReading =
await seedLegacyFirstVictoryCheckpoint(
page,
{
phase: 'reading',
pageIndex: 2
}
);
await reloadToTitle(page);
await clickContinue(page);
let legacyVictoryStory = await waitForStoryPage(
page,
legacyVictoryReading.pageIndex
);
assert.equal(
legacyVictoryStory.currentPageId,
legacyVictoryReading.beatId,
`${renderer}: legacy first-victory reading checkpoint did not restore its exact beat.`
);
assert.equal(
legacyVictoryStory.totalPages,
legacyVictoryReading.totalPages,
`${renderer}: legacy first-victory story page count changed during resume.`
);
for (
let pageIndex =
legacyVictoryReading.pageIndex + 1;
pageIndex < legacyVictoryReading.totalPages;
pageIndex += 1
) {
legacyVictoryStory = await advanceStoryPage(
page,
pageIndex
);
}
assert.equal(
legacyVictoryStory.isLastPage,
true,
`${renderer}: legacy first-victory story did not reach its final beat.`
);
await installSceneStartGate(page, 'StoryScene');
await page.keyboard.press('Space');
await waitForSceneStartGate(page);
const pendingLegacyVictoryHandoff =
await readCampaignMirrors(page);
assertLegacyFirstVictoryCheckpoint(
pendingLegacyVictoryHandoff,
{
...legacyVictoryReading,
phase: 'handoff',
pageIndex:
legacyVictoryReading.totalPages - 1,
beatId: legacyVictoryStory.currentPageId
},
`${renderer}: completed legacy first-victory story`
);
await releaseSceneStartGate(page);
await waitForTitle(page, true);
const completedLegacyVictory =
await readCampaignMirrors(page);
assertLegacyFirstVictoryTransition(
completedLegacyVictory,
legacyVictoryReading,
`${renderer}: Title-owned legacy first-victory handoff`
);
assert.equal(
(
await page.evaluate(
() =>
window.__HEROS_DEBUG__
?.activeScenes?.() ?? []
)
).includes('StoryScene'),
false,
`${renderer}: completed legacy first-victory story remained active after Title ownership.`
);
await clickContinue(page);
await waitForCampStep(page, 'first-camp');
assert.equal(
(
await page.evaluate(
() =>
window.__HEROS_DEBUG__
?.activeScenes?.() ?? []
)
).includes('StoryScene'),
false,
`${renderer}: Continue replayed the completed legacy first-victory story instead of opening Camp.`
);
await reloadToTitle(page);
const legacyVictoryHandoff =
await seedLegacyFirstVictoryCheckpoint(
page,
{
phase: 'handoff',
pageIndex: 4
}
);
await reloadToTitle(page);
const reloadedLegacyVictoryHandoff =
await readCampaignMirrors(page);
assertLegacyFirstVictoryCheckpoint(
reloadedLegacyVictoryHandoff,
legacyVictoryHandoff,
`${renderer}: directly reloaded legacy first-victory handoff`
);
await clickContinue(page);
await waitForCampStep(page, 'first-camp');
const resumedLegacyVictoryHandoff =
await readCampaignMirrors(page);
assertLegacyFirstVictoryTransition(
resumedLegacyVictoryHandoff,
legacyVictoryHandoff,
`${renderer}: directly resumed legacy first-victory handoff`
);
assert.equal(
(
await page.evaluate(
() =>
window.__HEROS_DEBUG__
?.activeScenes?.() ?? []
)
).includes('StoryScene'),
false,
`${renderer}: directly resumed legacy handoff replayed StoryScene.`
);
await assertDesktopRuntime(page, renderer);
assert.deepEqual(
pageErrors,
[],
`${renderer}: page errors: ${pageErrors.join('\n')}`
);
assert.deepEqual(
consoleErrors,
[],
`${renderer}: console errors: ${consoleErrors.join(
'\n'
)}`
);
console.log(
JSON.stringify(
{
renderer,
viewport: {
...desktopBrowserViewport,
deviceScaleFactor:
desktopBrowserDeviceScaleFactor,
browserZoom: '100%'
},
storyCheckpoint: {
exactPageResume: {
pageIndex: resumed.pageIndex,
currentPageId: resumed.currentPageId,
speaker: resumed.currentSpeaker,
background: resumed.background,
portrait: resumed.portraitTextureKey
},
runtimeCurrentSlotPhase: 'reading',
legacyIndexMigratedToBeatId:
migratedCheckpoint.beatId,
pageHideRestoredExactPage: true,
finalHandoffStayedUntilDestinationOwnership:
true,
finalHandoffConsumed: true,
resumedHandoffStayedUntilDestinationOwnership:
true,
interruptedFinalHandoffResumedToDestination:
true,
storyCheckpointPreferredOverMatchingBattleResume:
true,
latePageHideResurrectionPrevented: true,
legacyFirstVictoryReadingResumedAtBeat:
legacyVictoryReading.beatId,
legacyFirstVictoryTitleHandoffAdvancedToCamp:
true,
legacyFirstVictoryDidNotReplay: true,
legacyFirstVictoryReloadedHandoffAdvancedToCamp:
true
},
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);
return parsed.toString();
}
async function waitForDebugApi(page) {
await page.waitForFunction(
() =>
document.querySelector('canvas') !== null &&
window.__HEROS_GAME__ !== undefined &&
window.__HEROS_DEBUG__ !== undefined,
undefined,
{ timeout: 90000 }
);
}
async function waitForTitle(page, continueEnabled = true) {
await page.waitForFunction(
(requireContinue) => {
const debug = window.__HEROS_DEBUG__;
const title = debug?.title?.();
const continueItem =
title?.focus?.items?.find(
({ id }) => id === 'continue'
);
return (
debug?.activeScenes?.().includes('TitleScene') &&
title?.scene === 'TitleScene' &&
title?.navigating === false &&
title?.focus?.scope === 'main' &&
(!requireContinue ||
(continueItem?.enabled === true &&
Boolean(continueItem.bounds)))
);
},
continueEnabled,
{ timeout: 90000 }
);
await afterTwoFrames(page);
}
async function reloadToTitle(page) {
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await waitForTitle(page, true);
await assertDesktopRuntime(page, renderer);
}
async function clickContinue(page) {
const item = await page.evaluate(
() =>
window.__HEROS_DEBUG__
?.title?.()
?.focus?.items?.find(
({ id }) => id === 'continue'
) ?? null
);
assert.equal(
item?.enabled,
true,
`${renderer}: Continue is unavailable.`
);
assertFiniteBounds(item.bounds);
assertBoundsInsideViewport(item.bounds);
await page.mouse.click(
item.bounds.x + item.bounds.width / 2,
item.bounds.y + item.bounds.height / 2
);
}
async function installSceneStartGate(page, sourceSceneKey) {
const installed = await page.evaluate((sceneKey) => {
const scene =
window.__HEROS_DEBUG__?.scene(sceneKey);
const plugin = scene?.scene;
if (!plugin || typeof plugin.start !== 'function') {
return false;
}
const originalStart = plugin.start;
const gate = {
pending: null,
release() {
if (!gate.pending) {
throw new Error(
'The gated scene start has not been requested.'
);
}
const pending = gate.pending;
plugin.start = originalStart;
delete window.__HEROS_STORY_START_GATE__;
originalStart.call(
plugin,
pending.key,
pending.data
);
}
};
window.__HEROS_STORY_START_GATE__ = gate;
plugin.start = (key, data) => {
gate.pending = { key, data };
return plugin;
};
return true;
}, sourceSceneKey);
assert.equal(
installed,
true,
`${renderer}: could not install the destination-ownership gate on ${sourceSceneKey}.`
);
}
async function waitForSceneStartGate(page) {
await page.waitForFunction(
() =>
Boolean(
window.__HEROS_STORY_START_GATE__?.pending
),
undefined,
{ timeout: 90000 }
);
await afterTwoFrames(page);
}
async function releaseSceneStartGate(page) {
await page.evaluate(() => {
const gate =
window.__HEROS_STORY_START_GATE__;
if (!gate) {
throw new Error(
'The destination-ownership gate is unavailable.'
);
}
gate.release();
});
}
async function waitForStoryPage(page, expectedPageIndex) {
try {
await page.waitForFunction(
(pageIndex) => {
const debug = window.__HEROS_DEBUG__;
const scene = debug?.scene('StoryScene');
const story = scene?.getDebugState?.();
const checkpoint =
story?.storyCheckpoint?.persisted;
return (
debug?.activeScenes?.().includes('StoryScene') &&
story?.ready === true &&
scene?.transitioning === false &&
story.pageIndex === pageIndex &&
Boolean(story.currentPageId) &&
story.backgroundReady === true &&
story.activeTexture === story.background &&
checkpoint?.phase === 'reading' &&
checkpoint?.pageIndex === pageIndex &&
checkpoint?.beatId === story.currentPageId
);
},
expectedPageIndex,
{ timeout: 90000 }
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
story:
window.__HEROS_DEBUG__
?.scene('StoryScene')
?.getDebugState?.() ?? null
}));
throw new Error(
`${renderer}: story page ${expectedPageIndex} did not become ready: ` +
JSON.stringify(diagnostic),
{ cause: error }
);
}
await afterTwoFrames(page);
return readStory(page);
}
async function advanceStoryPage(page, expectedPageIndex) {
const before = await readStory(page);
assert.equal(
before?.pageIndex,
expectedPageIndex - 1,
`${renderer}: story was not on the preceding page.`
);
await page.keyboard.press('Space');
return waitForStoryPage(page, expectedPageIndex);
}
async function readStory(page) {
return page.evaluate(
() =>
window.__HEROS_DEBUG__
?.scene('StoryScene')
?.getDebugState?.() ?? null
);
}
function storyPageIdentity(story) {
assert(story, 'Story debug state is required.');
return {
pageIndex: story.pageIndex,
currentPageId: story.currentPageId,
currentSpeaker: story.currentSpeaker,
currentText: story.currentText,
requestedBackground: story.requestedBackground,
background: story.background,
activeTexture: story.activeTexture,
portraitKey: story.portraitKey,
portraitTextureKey: story.portraitTextureKey
};
}
async function readCheckpointPhases(page) {
return page.evaluate(() => {
const read = (key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
};
const story =
window.__HEROS_DEBUG__
?.scene('StoryScene')
?.getDebugState?.() ?? null;
return {
story,
runtime:
story?.storyCheckpoint?.persisted ?? null,
current:
read('heros-web:campaign-state')
?.storyCheckpoint ?? null,
slot:
read('heros-web:campaign-state:slot-1')
?.storyCheckpoint ?? null
};
});
}
async function assertStoryCheckpointMatchesPage(
page,
story,
label
) {
const checkpoints =
await readCheckpointPhases(page);
const runtime = checkpoints.runtime;
const current = checkpoints.current;
const slot = checkpoints.slot;
assert.equal(
story?.storyCheckpoint?.enabled,
true,
label
);
for (const [source, checkpoint] of [
['runtime', runtime],
['current', current],
['slot', slot]
]) {
assert.equal(
checkpoint?.version,
1,
`${label}: ${source} version`
);
assert.equal(
checkpoint?.sequenceId,
story.storyCheckpoint.sequenceId,
`${label}: ${source} sequence`
);
assert.equal(
checkpoint?.beatId,
story.currentPageId,
`${label}: ${source} beat`
);
assert.equal(
checkpoint?.pageIndex,
story.pageIndex,
`${label}: ${source} page`
);
assert.equal(
checkpoint?.phase,
'reading',
`${label}: ${source} phase`
);
}
assert.deepEqual(
current,
slot,
`${label}: current and slot checkpoints differ.`
);
return runtime;
}
async function injectLegacyIndexCheckpoint(
page,
pageIndex
) {
return page.evaluate((legacyPageIndex) => {
const keys = [
'heros-web:campaign-state',
'heros-web:campaign-state:slot-1'
];
let fixture;
for (const key of keys) {
const raw = window.localStorage.getItem(key);
if (!raw) {
throw new Error(`Missing campaign save ${key}`);
}
const save = JSON.parse(raw);
if (!save.storyCheckpoint) {
throw new Error(
`Missing story checkpoint in ${key}`
);
}
const legacy = {
...save.storyCheckpoint,
version: 0,
pageIndex: legacyPageIndex
};
delete legacy.beatId;
save.storyCheckpoint = legacy;
window.localStorage.setItem(
key,
JSON.stringify(save)
);
fixture ??= legacy;
}
return {
pageIndex: fixture.pageIndex,
beatId: fixture.beatId ?? null
};
}, pageIndex);
}
async function tamperCheckpointAndDispatchPageHide(page) {
return page.evaluate(() => {
const story =
window.__HEROS_DEBUG__
?.scene('StoryScene')
?.getDebugState?.();
if (
!story ||
story.pageIndex !== 1 ||
!story.storyCheckpoint?.sequenceId
) {
throw new Error(
'The live second story page is unavailable.'
);
}
const keys = [
'heros-web:campaign-state',
'heros-web:campaign-state:slot-1'
];
const readCheckpoints = () => {
const values = keys.map((key) => {
const raw = window.localStorage.getItem(key);
return raw
? JSON.parse(raw).storyCheckpoint ?? null
: null;
});
return {
current: values[0],
slot: values[1]
};
};
for (const key of keys) {
const raw = window.localStorage.getItem(key);
if (!raw) {
throw new Error(`Missing campaign save ${key}`);
}
const save = JSON.parse(raw);
save.storyCheckpoint = {
...save.storyCheckpoint,
beatId: story.storyCheckpoint.sequenceId,
pageIndex: 0,
phase: 'reading'
};
window.localStorage.setItem(
key,
JSON.stringify(save)
);
}
const beforeDispatch = readCheckpoints();
window.dispatchEvent(new Event('pagehide'));
return {
runtimePageIndex: story.pageIndex,
beforeDispatch,
afterDispatch: readCheckpoints()
};
});
}
async function injectInterruptedStoryHandoff(
page,
checkpoint
) {
await page.evaluate((sourceCheckpoint) => {
const keys = [
'heros-web:campaign-state',
'heros-web:campaign-state:slot-1'
];
const savedAt = new Date(
Date.now() + 1000
).toISOString();
for (const key of keys) {
const raw = window.localStorage.getItem(key);
if (!raw) {
throw new Error(`Missing campaign save ${key}`);
}
const save = JSON.parse(raw);
const revision = Math.max(
Number(save.storyCheckpointRevision) || 0,
Number(sourceCheckpoint.revision) || 0
) + 1;
save.step = 'prologue';
save.completedTutorialIds = [];
delete save.explorationCheckpoint;
delete save.activeCityStayId;
delete save.activeCampVisitId;
save.storyCheckpointRevision = revision;
save.storyCheckpoint = {
...sourceCheckpoint,
revision,
campaignStep: 'prologue',
phase: 'handoff',
savedAt
};
save.updatedAt = savedAt;
window.localStorage.setItem(
key,
JSON.stringify(save)
);
}
}, checkpoint);
}
async function injectStoryAndMatchingBattleResume(
page
) {
return page.evaluate(() => {
const campaignKeys = [
'heros-web:campaign-state',
'heros-web:campaign-state:slot-1'
];
const currentRaw =
window.localStorage.getItem(campaignKeys[0]);
if (!currentRaw) {
throw new Error(
'Missing campaign save for the coexistence fixture.'
);
}
const current = JSON.parse(currentRaw);
const revision =
Math.max(
Number(
current.storyCheckpointRevision
) || 0,
Number(
current.storyCheckpoint?.revision
) || 0
) + 1;
const generation =
(Number(current.battleResumeGeneration) ||
0) + 1;
const savedAt = new Date(
Date.now() + 2000
).toISOString();
const sequenceId =
'second-pursuit-rumor';
const beatId = 'second-guan-yu-vow';
const pageIndex = 2;
for (const key of campaignKeys) {
const raw =
window.localStorage.getItem(key);
if (!raw) {
throw new Error(
`Missing campaign save ${key}`
);
}
const save = JSON.parse(raw);
save.step = 'second-battle';
save.activeSaveSlot = 1;
save.battleResumeGeneration =
generation;
save.storyCheckpointRevision =
revision;
save.storyCheckpoint = {
version: 1,
revision,
sequenceId,
phase: 'reading',
beatId,
pageIndex,
campaignStep: 'second-battle',
continuationIntent: 'default',
savedAt
};
delete save.explorationCheckpoint;
delete save.activeCityStayId;
delete save.activeCampVisitId;
delete save.pendingAftermathBattleId;
save.updatedAt = savedAt;
window.localStorage.setItem(
key,
JSON.stringify(save)
);
}
window.localStorage.setItem(
'heros-web:battle:second-battle-yellow-turban-pursuit:slot-1',
JSON.stringify({
version: 1,
battleId:
'second-battle-yellow-turban-pursuit',
campaignStep: 'second-battle',
battleResumeGeneration: generation,
savedAt: new Date(
Date.now() + 3000
).toISOString(),
turnNumber: 2,
activeFaction: 'ally',
rosterTab: 'ally',
actedUnitIds: [],
attackIntents: [],
battleLog: ['coexistence fixture'],
units: [
{
id: 'liu-bei',
level: 1,
exp: 0,
hp: 30,
maxHp: 30,
attack: 10,
move: 4,
x: 1,
y: 1,
direction: 'south',
equipment: {
weapon: {
itemId: 'training-sword',
level: 1,
exp: 0
},
armor: {
itemId: 'cloth-armor',
level: 1,
exp: 0
},
accessory: {
itemId: 'grain-pouch',
level: 1,
exp: 0
}
}
}
],
bonds: []
})
);
return {
sequenceId,
beatId,
pageIndex,
generation
};
});
}
async function seedLegacyFirstVictoryCheckpoint(
page,
{
phase,
pageIndex
}
) {
const prepared = await page.evaluate(
({
requestedPhase,
requestedPageIndex,
fixture
}) => {
if (
!['reading', 'handoff'].includes(
requestedPhase
) ||
!Number.isInteger(requestedPageIndex) ||
requestedPageIndex < 0 ||
requestedPageIndex >= fixture.pageIds.length
) {
throw new Error(
'Invalid legacy first-victory checkpoint fixture.'
);
}
const campaignKeys = [
'heros-web:campaign-state',
'heros-web:campaign-state:slot-1'
];
const currentRaw =
window.localStorage.getItem(campaignKeys[0]) ??
window.localStorage.getItem(campaignKeys[1]);
if (!currentRaw) {
throw new Error(
'Missing campaign save for the legacy first-victory fixture.'
);
}
const campaign = JSON.parse(currentRaw);
const roster =
Array.isArray(campaign.roster) &&
campaign.roster.length > 0
? structuredClone(campaign.roster)
: structuredClone(fixture.alliedUnits);
const bonds =
Array.isArray(campaign.bonds) &&
campaign.bonds.length > 0
? structuredClone(campaign.bonds).map(
(bond) => ({
...bond,
battleExp:
Number.isFinite(bond.battleExp)
? bond.battleExp
: 0
})
)
: structuredClone(fixture.bonds);
const revision =
Math.max(
Number(
campaign.storyCheckpointRevision
) || 0,
Number(
campaign.storyCheckpoint?.revision
) || 0
) + 1;
const savedAt = new Date(
Date.now() + 4000
).toISOString();
const checkpoint = {
version: 1,
revision,
sequenceId: fixture.pageIds[0],
phase: requestedPhase,
beatId:
fixture.pageIds[requestedPageIndex],
pageIndex: requestedPageIndex,
campaignStep: 'first-victory-story',
continuationIntent: 'default',
savedAt
};
campaign.step = 'first-victory-story';
campaign.activeSaveSlot = 1;
campaign.roster = roster;
campaign.bonds = bonds;
campaign.storyCheckpointRevision = revision;
campaign.storyCheckpoint = checkpoint;
campaign.firstBattleReport = {
battleId: fixture.battleId,
battleTitle: fixture.battleTitle,
outcome: 'victory',
turnNumber: 6,
rewardGold: fixture.rewardGold,
defeatedEnemies: 1,
totalEnemies: 1,
objectives: [],
units: roster,
bonds,
itemRewards: [],
completedCampDialogues: [],
completedCampVisits: [],
createdAt:
'2026-07-29T00:00:00.000Z'
};
delete campaign.explorationCheckpoint;
delete campaign.activeCityStayId;
delete campaign.activeCampVisitId;
delete campaign.pendingAftermathBattleId;
campaign.updatedAt = savedAt;
return {
campaignKeys,
serializedCampaign:
JSON.stringify(campaign),
expected: {
phase: requestedPhase,
sequenceId: checkpoint.sequenceId,
revision: checkpoint.revision,
pageIndex: checkpoint.pageIndex,
beatId: checkpoint.beatId,
totalPages: fixture.pageIds.length,
storyCheckpointRevision: revision
}
};
},
{
requestedPhase: phase,
requestedPageIndex: pageIndex,
fixture: legacyFirstVictoryStoryFixture
}
);
// Install the fixture in the next document, after the outgoing StoryScene
// has received pagehide. This prevents its legitimate final save from
// overwriting a storage-only migration fixture and keeps the release build
// free from source-module imports or test-only product hooks.
const marker =
`heros-story-fixture:${renderer}:${phase}:${pageIndex}:` +
`${Date.now()}:${Math.random()}`;
await page.addInitScript(
({
fixtureMarker,
campaignKeys,
serializedCampaign
}) => {
if (
window.sessionStorage.getItem(
fixtureMarker
) === 'installed'
) {
return;
}
window.sessionStorage.setItem(
fixtureMarker,
'installed'
);
for (const key of campaignKeys) {
window.localStorage.setItem(
key,
serializedCampaign
);
}
},
{
fixtureMarker: marker,
campaignKeys: prepared.campaignKeys,
serializedCampaign:
prepared.serializedCampaign
}
);
return prepared.expected;
}
async function readCampaignMirrors(page) {
return page.evaluate(() => {
const read = (key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
};
return {
current: read('heros-web:campaign-state'),
slot: read(
'heros-web:campaign-state:slot-1'
)
};
});
}
function assertLegacyFirstVictoryCheckpoint(
mirrors,
expected,
label
) {
assert.deepEqual(
mirrors.current,
mirrors.slot,
`${label}: current and canonical slot diverged.`
);
for (const [source, campaign] of [
['current', mirrors.current],
['slot', mirrors.slot]
]) {
assert.equal(
campaign?.step,
'first-victory-story',
`${label}: ${source} campaign step`
);
assert.equal(
campaign?.storyCheckpoint?.sequenceId,
expected.sequenceId,
`${label}: ${source} sequence`
);
assert.equal(
campaign?.storyCheckpoint?.revision,
expected.revision,
`${label}: ${source} revision`
);
assert.equal(
campaign?.storyCheckpoint?.phase,
expected.phase,
`${label}: ${source} phase`
);
assert.equal(
campaign?.storyCheckpoint?.pageIndex,
expected.pageIndex,
`${label}: ${source} page`
);
assert.equal(
campaign?.storyCheckpoint?.beatId,
expected.beatId,
`${label}: ${source} beat`
);
}
}
function assertLegacyFirstVictoryTransition(
mirrors,
sourceCheckpoint,
label
) {
assert.deepEqual(
mirrors.current,
mirrors.slot,
`${label}: current and canonical slot diverged.`
);
for (const [source, campaign] of [
['current', mirrors.current],
['slot', mirrors.slot]
]) {
assert.equal(
campaign?.step,
'first-camp',
`${label}: ${source} did not advance to first-camp`
);
assert.equal(
campaign?.latestBattleId,
'first-battle-zhuo-commandery',
`${label}: ${source} latest battle`
);
assert.equal(
campaign?.storyCheckpoint,
undefined,
`${label}: ${source} retained the consumed checkpoint`
);
assert.equal(
campaign?.storyCheckpointRevision,
sourceCheckpoint.revision + 1,
`${label}: ${source} did not invalidate the consumed revision`
);
assert.equal(
campaign?.firstBattleReport?.outcome,
'victory',
`${label}: ${source} lost the settled victory report`
);
assert.equal(
campaign?.pendingAftermathBattleId,
undefined,
`${label}: ${source} retained a pending aftermath`
);
}
}
async function waitForCampStep(page, expectedStep) {
try {
await page.waitForFunction(
(step) => {
const debug = window.__HEROS_DEBUG__;
const activeScenes =
debug?.activeScenes?.() ?? [];
const camp = debug?.camp?.();
return (
activeScenes.includes('CampScene') &&
!activeScenes.includes('StoryScene') &&
camp?.scene === 'CampScene' &&
camp?.campaign?.step === step &&
Boolean(
camp?.campaignObjectiveJournal?.snapshot
)
);
},
expectedStep,
{ timeout: 90000 }
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
camp:
window.__HEROS_DEBUG__?.camp?.() ?? null,
title:
window.__HEROS_DEBUG__?.title?.() ?? null,
story:
window.__HEROS_DEBUG__
?.scene('StoryScene')
?.getDebugState?.() ?? null
}));
throw new Error(
`${renderer}: CampScene did not reach ${expectedStep}: ` +
JSON.stringify(diagnostic),
{ cause: error }
);
}
await afterTwoFrames(page);
await assertDesktopRuntime(page, renderer);
}
function assertNoStoryCheckpoint(checkpoints, label) {
assert.equal(checkpoints.runtime, null, label);
assert.equal(checkpoints.current, null, label);
assert.equal(checkpoints.slot, null, label);
}
function assertDurableHandoffPending(
checkpoints,
expected,
label
) {
for (const [source, checkpoint] of [
['current', checkpoints.current],
['slot', checkpoints.slot]
]) {
assert.equal(
checkpoint?.sequenceId,
expected?.sequenceId,
`${label}: ${source} sequence`
);
assert.equal(
checkpoint?.revision,
expected?.revision,
`${label}: ${source} revision`
);
assert.equal(
checkpoint?.phase,
'handoff',
`${label}: ${source} phase`
);
}
assert.deepEqual(
checkpoints.current,
checkpoints.slot,
`${label}: current and slot handoffs differ.`
);
}
async function waitForVillageReady(page) {
try {
await page.waitForFunction(
() => {
const debug = window.__HEROS_DEBUG__;
const village = debug?.village?.();
return (
debug
?.activeScenes?.()
.includes('PrologueVillageScene') &&
!debug
?.activeScenes?.()
.includes('StoryScene') &&
village?.scene === 'PrologueVillageScene' &&
village?.ready === true &&
village?.requiredTexturesReady === true
);
},
undefined,
{ timeout: 90000 }
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
village:
window.__HEROS_DEBUG__?.village?.() ?? null,
story:
window.__HEROS_DEBUG__
?.scene('StoryScene')
?.getDebugState?.() ?? null
}));
throw new Error(
`${renderer}: story did not hand off to the village: ` +
JSON.stringify(diagnostic),
{ cause: error }
);
}
await afterTwoFrames(page);
}
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 ||
'1',
rendererType:
window.__HEROS_GAME__?.renderer?.type ?? null,
canvas: canvas
? {
width: canvas.width,
height: canvas.height,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
bounds: bounds
? {
x: bounds.x,
y: bounds.y,
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
);
}
function assertFiniteBounds(bounds) {
assert(bounds, 'Interactive bounds are required.');
for (const value of [
bounds.x,
bounds.y,
bounds.width,
bounds.height
]) {
assert(Number.isFinite(value));
}
assert(bounds.width > 0);
assert(bounds.height > 0);
}
function assertBoundsInsideViewport(bounds) {
assert(
bounds.x >= 0 &&
bounds.y >= 0 &&
bounds.x + bounds.width <=
desktopBrowserViewport.width &&
bounds.y + bounds.height <=
desktopBrowserViewport.height,
`Bounds are outside the FHD viewport: ${JSON.stringify(
bounds
)}`
);
}
async function afterTwoFrames(page) {
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(resolve)
)
)
);
}