Files
heros_web/scripts/verify-battle-save-generation-browser.mjs

623 lines
20 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 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);
}