471 lines
18 KiB
JavaScript
471 lines
18 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { chromium } from 'playwright';
|
|
import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
|
|
|
|
const targetUrl = withCanvasRenderer(process.env.VERIFY_URL ?? 'http://localhost:5173/');
|
|
const baselineViewport = desktopBrowserViewport;
|
|
const legacyUiScale = 1.5;
|
|
|
|
function withCanvasRenderer(url) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('renderer', 'canvas');
|
|
return parsed.toString();
|
|
}
|
|
|
|
let serverProcess;
|
|
let browser;
|
|
|
|
try {
|
|
serverProcess = await ensureLocalServer(targetUrl);
|
|
|
|
browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage(desktopBrowserContextOptions);
|
|
|
|
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => window.localStorage.clear());
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await page.screenshot({ path: 'dist/verification-save-title-empty.png', fullPage: true });
|
|
|
|
await startTitleDefaultAction(page);
|
|
await advanceUntilBattle(page, 'first-battle-zhuo-commandery');
|
|
await page.screenshot({ path: 'dist/verification-save-new-battle.png', fullPage: true });
|
|
|
|
const autoSave = await readCampaignSave(page);
|
|
if (autoSave.current?.step !== 'first-battle' || autoSave.slot1?.step !== 'first-battle') {
|
|
throw new Error(`Expected first battle auto-save in current and slot 1 saves: ${JSON.stringify(autoSave)}`);
|
|
}
|
|
|
|
await page.evaluate(() => {
|
|
const battleId = 'first-battle-zhuo-commandery';
|
|
const installRecommendation = (key) => {
|
|
const raw = window.localStorage.getItem(key);
|
|
if (!raw) {
|
|
return;
|
|
}
|
|
const state = JSON.parse(raw);
|
|
const orderId = state.sortieOrderSelection?.orderId ?? 'elite';
|
|
state.sortieOrderSelection = { battleId, orderId };
|
|
delete state.sortieResonanceSelection;
|
|
const selectedUnitIds = state.selectedSortieUnitIds?.length
|
|
? [...state.selectedSortieUnitIds]
|
|
: ['liu-bei', 'guan-yu', 'zhang-fei'];
|
|
state.selectedSortieUnitIds = [...selectedUnitIds];
|
|
const fallbackRoles = ['front', 'flank', 'support', 'reserve'];
|
|
const formationAssignments = Object.fromEntries(selectedUnitIds.map((unitId, index) => [
|
|
unitId,
|
|
state.sortieFormationAssignments?.[unitId] ?? fallbackRoles[index] ?? 'reserve'
|
|
]));
|
|
state.sortieFormationAssignments = {
|
|
...(state.sortieFormationAssignments ?? {}),
|
|
...formationAssignments
|
|
};
|
|
state.sortieRecommendationSelection = {
|
|
version: 1,
|
|
battleId,
|
|
planId: 'order',
|
|
label: '재도전 검증안',
|
|
summary: '패배 후 같은 전장 보완 동선을 검증합니다.',
|
|
sortieOrderId: orderId,
|
|
selectedUnitIds,
|
|
formationAssignments,
|
|
unitReasons: Object.fromEntries(selectedUnitIds.map((unitId) => [unitId, '추천 역할 수행을 검증합니다.']))
|
|
};
|
|
window.localStorage.setItem(key, JSON.stringify(state));
|
|
};
|
|
installRecommendation('heros-web:campaign-state');
|
|
installRecommendation('heros-web:campaign-state:slot-1');
|
|
});
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await page.screenshot({ path: 'dist/verification-save-title-continue.png', fullPage: true });
|
|
|
|
await startTitleDefaultAction(page);
|
|
await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
|
const continuedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
if (
|
|
continuedBattle?.battleOutcome !== null ||
|
|
continuedBattle?.resultVisible !== false ||
|
|
continuedBattle?.turnNumber !== 1 ||
|
|
continuedBattle?.sortieRecommendation?.battleId !== 'first-battle-zhuo-commandery'
|
|
) {
|
|
throw new Error(`Expected continue to reopen a clean first battle: ${JSON.stringify(continuedBattle)}`);
|
|
}
|
|
await page.screenshot({ path: 'dist/verification-save-continued-battle.png', fullPage: true });
|
|
|
|
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('defeat'));
|
|
await waitForBattleOutcome(page, 'defeat');
|
|
await page.screenshot({ path: 'dist/verification-save-defeat-result.png', fullPage: true });
|
|
|
|
const defeatSave = await readCampaignSave(page);
|
|
if (defeatSave.current?.step !== 'first-battle' || defeatSave.current?.firstBattleReport?.outcome !== 'defeat') {
|
|
throw new Error(`Expected defeat to save retry-ready first battle state: ${JSON.stringify(defeatSave.current)}`);
|
|
}
|
|
|
|
const closedDefeatEvaluation = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation);
|
|
if (
|
|
closedDefeatEvaluation?.improvementAction?.available !== true ||
|
|
closedDefeatEvaluation.improvementAction.label !== '재도전 보완' ||
|
|
closedDefeatEvaluation.improvementAction.sourceBattleId !== 'first-battle-zhuo-commandery' ||
|
|
closedDefeatEvaluation.improvementAction.targetBattleId !== 'first-battle-zhuo-commandery'
|
|
) {
|
|
throw new Error(`Expected defeat to expose same-battle improvement semantics: ${JSON.stringify(closedDefeatEvaluation)}`);
|
|
}
|
|
await clickDebugBounds(page, 'BattleScene', closedDefeatEvaluation.toggleButtonBounds);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const openedDefeatEvaluation = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation);
|
|
if (!insideViewport(openedDefeatEvaluation?.improvementAction?.buttonBounds, baselineViewport)) {
|
|
throw new Error(`Expected an in-FHD defeat improvement button: ${JSON.stringify(openedDefeatEvaluation)}`);
|
|
}
|
|
await clickDebugBounds(page, 'BattleScene', openedDefeatEvaluation.improvementAction.buttonBounds);
|
|
try {
|
|
await page.waitForFunction(() => {
|
|
const camp = window.__HEROS_DEBUG__?.camp?.();
|
|
return camp?.sortieVisible === true &&
|
|
camp?.sortiePrepStep === 'formation' &&
|
|
camp?.nextSortieBattleId === 'first-battle-zhuo-commandery' &&
|
|
camp?.sortieComparisonAction?.kind === 'confirm-improvement';
|
|
}, undefined, { timeout: 30000 });
|
|
} catch {
|
|
const transitionState = await page.evaluate(() => ({
|
|
activeScenes: window.__HEROS_GAME__?.scene.getScenes(true).map((scene) => scene.scene.key),
|
|
battleOutcome: window.__HEROS_DEBUG__?.battle?.()?.battleOutcome,
|
|
camp: (() => {
|
|
const camp = window.__HEROS_DEBUG__?.camp?.();
|
|
return camp
|
|
? {
|
|
sortieVisible: camp.sortieVisible,
|
|
sortiePrepStep: camp.sortiePrepStep,
|
|
nextSortieBattleId: camp.nextSortieBattleId,
|
|
selectedSortieUnitIds: camp.campaign?.selectedSortieUnitIds,
|
|
guidedImprovement: camp.sortieGuidedImprovement,
|
|
comparisonAction: camp.sortieComparisonAction,
|
|
feedback: camp.sortiePlanFeedback
|
|
}
|
|
: null;
|
|
})()
|
|
}));
|
|
throw new Error(`Expected defeat improvement CTA to open same-battle Camp preview: ${JSON.stringify(transitionState)}`);
|
|
}
|
|
const defeatGuidedPreview = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
if (
|
|
defeatGuidedPreview?.sortieGuidedImprovement?.sourceBattleId !== 'first-battle-zhuo-commandery' ||
|
|
defeatGuidedPreview.sortieGuidedImprovement.targetBattleId !== 'first-battle-zhuo-commandery' ||
|
|
defeatGuidedPreview.sortieComparisonPreview?.source !== 'guided-improvement' ||
|
|
!insideViewport(defeatGuidedPreview.sortieComparisonAction?.buttonBounds, baselineViewport)
|
|
) {
|
|
throw new Error(`Expected same-battle guided preview after defeat: ${JSON.stringify(defeatGuidedPreview)}`);
|
|
}
|
|
await clickDebugBounds(page, 'CampScene', defeatGuidedPreview.sortieComparisonAction.buttonBounds);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp?.()?.sortieComparisonAction?.kind === 'undo-improvement',
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const appliedDefeatImprovement = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const appliedDefeatProposal = appliedDefeatImprovement?.sortieGuidedImprovement?.proposal;
|
|
if (
|
|
!appliedDefeatProposal ||
|
|
appliedDefeatImprovement?.sortieRecommendationSelection?.battleId !== 'first-battle-zhuo-commandery' ||
|
|
appliedDefeatImprovement.sortieRecommendationSelection.planId !== appliedDefeatProposal.planId
|
|
) {
|
|
throw new Error(`Expected defeat improvement to preserve same-battle recommendation provenance: ${JSON.stringify(appliedDefeatImprovement)}`);
|
|
}
|
|
await clickLegacyUi(page, 1116, 656);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp?.()?.sortiePrepStep === 'loadout',
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
await clickLegacyUi(page, 1116, 656);
|
|
await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
|
const retriedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
const retriedLiuBei = retriedBattle?.units?.find((unit) => unit.id === 'liu-bei');
|
|
const retriedEnemies = retriedBattle?.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0) ?? [];
|
|
const damagedAlliesAfterRetry = retriedBattle?.units?.filter((unit) => unit.faction === 'ally' && unit.hp !== unit.maxHp) ?? [];
|
|
if (
|
|
retriedBattle?.battleOutcome !== null ||
|
|
retriedBattle?.resultVisible !== false ||
|
|
retriedBattle?.turnNumber !== 1 ||
|
|
retriedBattle?.sortieRecommendation?.battleId !== 'first-battle-zhuo-commandery' ||
|
|
retriedBattle?.sortieRecommendation?.planId !== appliedDefeatProposal.planId ||
|
|
JSON.stringify(retriedBattle?.selectedSortieUnitIds) !== JSON.stringify(appliedDefeatProposal.projectedSelectedUnitIds) ||
|
|
!retriedLiuBei ||
|
|
retriedLiuBei.hp !== retriedLiuBei.maxHp ||
|
|
damagedAlliesAfterRetry.length > 0 ||
|
|
retriedEnemies.length <= 0
|
|
) {
|
|
throw new Error(`Expected retry to restart battle without stale result or damage state: ${JSON.stringify(retriedBattle)}`);
|
|
}
|
|
await page.screenshot({ path: 'dist/verification-save-retry-battle.png', fullPage: true });
|
|
|
|
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
|
|
await waitForBattleOutcome(page, 'victory');
|
|
await page.screenshot({ path: 'dist/verification-save-victory-result.png', fullPage: true });
|
|
|
|
await clickLegacyUi(page, 738, 642);
|
|
await advanceUntilCamp(page);
|
|
await page.screenshot({ path: 'dist/verification-save-victory-camp.png', fullPage: true });
|
|
|
|
const campState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const victorySave = await readCampaignSave(page);
|
|
if (
|
|
campState?.scene !== 'CampScene' ||
|
|
campState?.report?.rewardGold <= 0 ||
|
|
victorySave.current?.step !== 'first-camp' ||
|
|
victorySave.current?.firstBattleReport?.outcome !== 'victory' ||
|
|
victorySave.current?.gold <= 0
|
|
) {
|
|
throw new Error(`Expected victory to return to camp with persisted rewards: ${JSON.stringify({ campState, victorySave })}`);
|
|
}
|
|
|
|
console.log(`Verified new-game auto-save, browser reload continue, defeat retry, and victory camp return at ${targetUrl}`);
|
|
} finally {
|
|
await browser?.close();
|
|
if (serverProcess && !serverProcess.killed) {
|
|
serverProcess.kill();
|
|
}
|
|
}
|
|
|
|
async function waitForTitle(page) {
|
|
await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 });
|
|
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined, undefined, { timeout: 90000 });
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
return activeScenes.includes('TitleScene');
|
|
}, undefined, { timeout: 90000 });
|
|
}
|
|
|
|
async function startTitleDefaultAction(page) {
|
|
await waitForTitle(page);
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
async function advanceUntilBattle(page, battleId) {
|
|
for (let i = 0; i < 180; i += 1) {
|
|
const battleReady = await page.evaluate((expectedBattleId) => {
|
|
let state;
|
|
try {
|
|
state = window.__HEROS_DEBUG__?.battle();
|
|
} catch {
|
|
state = undefined;
|
|
}
|
|
return (
|
|
state?.scene === 'BattleScene' &&
|
|
state?.battleId === expectedBattleId &&
|
|
state?.battleOutcome === null &&
|
|
['deployment', 'idle'].includes(state?.phase) &&
|
|
state?.mapBackgroundReady === true &&
|
|
state?.resultVisible === false
|
|
);
|
|
}, battleId);
|
|
|
|
if (battleReady) {
|
|
return;
|
|
}
|
|
|
|
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
|
|
if (activeScenes.includes('PrologueVillageScene')) {
|
|
const advanced = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene');
|
|
return scene?.getDebugState?.()?.ready === true
|
|
? scene?.debugCompleteVillage?.() ?? false
|
|
: false;
|
|
});
|
|
if (advanced) {
|
|
await page.waitForTimeout(320);
|
|
continue;
|
|
}
|
|
}
|
|
if (activeScenes.includes('PrologueMilitiaCampScene')) {
|
|
const advanced = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene');
|
|
return scene?.getDebugState?.()?.ready === true
|
|
? scene?.debugCompleteCamp?.() ?? false
|
|
: false;
|
|
});
|
|
if (advanced) {
|
|
await page.waitForTimeout(320);
|
|
continue;
|
|
}
|
|
}
|
|
await page.keyboard.press(activeScenes.includes('TitleScene') ? 'Enter' : 'Space');
|
|
await page.waitForTimeout(240);
|
|
}
|
|
|
|
const state = await page.evaluate(() => ({
|
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
|
battle: window.__HEROS_DEBUG__?.battle(),
|
|
camp: window.__HEROS_DEBUG__?.camp()
|
|
}));
|
|
throw new Error(`Expected to advance into ${battleId}: ${JSON.stringify(state)}`);
|
|
}
|
|
|
|
async function waitForBattleReady(page, battleId) {
|
|
await page.waitForFunction((expectedBattleId) => {
|
|
let state;
|
|
try {
|
|
state = window.__HEROS_DEBUG__?.battle();
|
|
} catch {
|
|
state = undefined;
|
|
}
|
|
return (
|
|
state?.scene === 'BattleScene' &&
|
|
state?.battleId === expectedBattleId &&
|
|
state?.battleOutcome === null &&
|
|
['deployment', 'idle'].includes(state?.phase) &&
|
|
state?.mapBackgroundReady === true &&
|
|
state?.resultVisible === false
|
|
);
|
|
}, battleId, { timeout: 90000 });
|
|
}
|
|
|
|
async function waitForBattleOutcome(page, outcome) {
|
|
await page.waitForFunction((expectedOutcome) => {
|
|
let state;
|
|
try {
|
|
state = window.__HEROS_DEBUG__?.battle();
|
|
} catch {
|
|
state = undefined;
|
|
}
|
|
return state?.battleOutcome === expectedOutcome && state?.phase === 'resolved' && state?.resultVisible === true;
|
|
}, outcome, { timeout: 90000 });
|
|
}
|
|
|
|
async function advanceUntilCamp(page) {
|
|
for (let i = 0; i < 240; i += 1) {
|
|
const state = await page.evaluate(() => ({
|
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
|
battle: window.__HEROS_DEBUG__?.battle(),
|
|
camp: window.__HEROS_DEBUG__?.camp()
|
|
}));
|
|
|
|
if (state.activeScenes.includes('CampScene')) {
|
|
return;
|
|
}
|
|
|
|
if (state.activeScenes.includes('BattleScene') && state.battle?.battleOutcome === 'victory' && state.battle?.resultVisible) {
|
|
await clickLegacyUi(page, 738, 642);
|
|
} else {
|
|
await page.keyboard.press('Space');
|
|
}
|
|
await page.waitForTimeout(250);
|
|
}
|
|
|
|
const state = await page.evaluate(() => ({
|
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
|
battle: window.__HEROS_DEBUG__?.battle(),
|
|
camp: window.__HEROS_DEBUG__?.camp()
|
|
}));
|
|
throw new Error(`Expected victory flow to reach CampScene: ${JSON.stringify(state)}`);
|
|
}
|
|
|
|
async function readCampaignSave(page) {
|
|
return page.evaluate(() => {
|
|
const read = (key) => {
|
|
const raw = window.localStorage.getItem(key);
|
|
return raw ? JSON.parse(raw) : undefined;
|
|
};
|
|
return {
|
|
current: read('heros-web:campaign-state'),
|
|
slot1: read('heros-web:campaign-state:slot-1')
|
|
};
|
|
});
|
|
}
|
|
|
|
async function ensureLocalServer(url) {
|
|
if (await canReach(url)) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = new URL(url);
|
|
const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname);
|
|
if (!isLocal) {
|
|
throw new Error(`No server responded at ${url}`);
|
|
}
|
|
|
|
const stderr = [];
|
|
const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '5173'], {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
|
|
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
|
|
child.stdout.on('data', () => {});
|
|
|
|
for (let i = 0; i < 80; i += 1) {
|
|
if (await canReach(url)) {
|
|
return child;
|
|
}
|
|
await delay(250);
|
|
}
|
|
|
|
child.kill();
|
|
throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`);
|
|
}
|
|
|
|
async function canReach(url) {
|
|
try {
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function clickLegacyUi(page, x, y, options) {
|
|
await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options);
|
|
}
|
|
|
|
async function clickDebugBounds(page, sceneKey, bounds) {
|
|
if (!bounds) {
|
|
throw new Error(`Expected ${sceneKey} debug bounds before click.`);
|
|
}
|
|
const point = await page.evaluate(({ sceneKey, bounds }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene(sceneKey);
|
|
const canvas = document.querySelector('canvas');
|
|
if (!scene || !canvas) {
|
|
return null;
|
|
}
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, { sceneKey, bounds });
|
|
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
|
throw new Error(`Expected a canvas-scaled ${sceneKey} click point: ${JSON.stringify({ bounds, point })}`);
|
|
}
|
|
await page.mouse.click(point.x, point.y);
|
|
}
|
|
|
|
function insideViewport(bounds, viewport) {
|
|
return Boolean(
|
|
bounds &&
|
|
Number.isFinite(bounds.x) &&
|
|
Number.isFinite(bounds.y) &&
|
|
Number.isFinite(bounds.width) &&
|
|
Number.isFinite(bounds.height) &&
|
|
bounds.width > 0 &&
|
|
bounds.height > 0 &&
|
|
bounds.x >= 0 &&
|
|
bounds.y >= 0 &&
|
|
bounds.x + bounds.width <= viewport.width &&
|
|
bounds.y + bounds.height <= viewport.height
|
|
);
|
|
}
|