1795 lines
45 KiB
JavaScript
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)
|
|
)
|
|
)
|
|
);
|
|
}
|