10484 lines
512 KiB
JavaScript
10484 lines
512 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { chromium } from 'playwright';
|
|
import {
|
|
desktopBrowserContextOptions,
|
|
desktopBrowserDeviceScaleFactor,
|
|
desktopBrowserViewport,
|
|
desktopBrowserViewportBounds
|
|
} from './desktop-browser-viewport.mjs';
|
|
|
|
const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/');
|
|
const screenshotDir = 'dist';
|
|
const baselineViewport = desktopBrowserViewport;
|
|
const phaserWebglRendererType = 2;
|
|
const legacyUiScale = 1.5;
|
|
const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138';
|
|
const firstBattleUnlockLabel = '\uD669\uAC74 \uC794\uB2F9 \uCD94\uACA9 \uAC1C\uBC29';
|
|
const relevantLogPattern = /asset|audio|cannot|failed|map|missing|portrait|sprite|story|texture|unit/i;
|
|
|
|
let serverProcess;
|
|
let browser;
|
|
|
|
async function clickLegacyUi(page, x, y, options) {
|
|
await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options);
|
|
}
|
|
|
|
async function moveLegacyUi(page, x, y, options) {
|
|
await page.mouse.move(x * legacyUiScale, y * legacyUiScale, options);
|
|
}
|
|
|
|
async function clickBattleDeploymentStart(page) {
|
|
const point = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const startButton = window.__HEROS_DEBUG__?.battle()?.battleHud?.deploymentPanel?.startButtonBounds;
|
|
const canvas = document.querySelector('canvas');
|
|
const bounds = canvas?.getBoundingClientRect();
|
|
if (!scene || !startButton || !canvas || !bounds) {
|
|
return null;
|
|
}
|
|
const logicalX = startButton.x + startButton.width / 2;
|
|
const logicalY = startButton.y + startButton.height / 2;
|
|
return {
|
|
x: bounds.left + logicalX * bounds.width / scene.scale.width,
|
|
y: bounds.top + logicalY * bounds.height / scene.scale.height
|
|
};
|
|
});
|
|
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a runtime deployment start point: ${JSON.stringify(point)}`);
|
|
await page.mouse.click(point.x, point.y);
|
|
}
|
|
|
|
async function waitForBattleDeploymentConfirmation(page) {
|
|
await page.waitForFunction(() => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
const combatAssets = battle?.combatAssets;
|
|
return (
|
|
battle?.phase === 'idle' &&
|
|
(combatAssets?.status === 'ready' || combatAssets?.status === 'degraded') &&
|
|
combatAssets?.pendingDeploymentConfirmation === false &&
|
|
battle?.battleHud?.deploymentPanel === null
|
|
);
|
|
}, undefined, { timeout: 90000 });
|
|
}
|
|
|
|
async function readEquipmentSwapConfirmControlPoint(page) {
|
|
await page.waitForFunction(() => {
|
|
const comparison = window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison;
|
|
const bounds = comparison?.confirmButtonBounds;
|
|
return (
|
|
comparison?.visible === true &&
|
|
comparison.confirmInteractive === true &&
|
|
Number.isFinite(bounds?.x) &&
|
|
Number.isFinite(bounds?.y) &&
|
|
Number.isFinite(bounds?.width) &&
|
|
Number.isFinite(bounds?.height) &&
|
|
bounds.width > 0 &&
|
|
bounds.height > 0
|
|
);
|
|
}, undefined, { timeout: 30000 });
|
|
await page.evaluate(() => new Promise((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
}));
|
|
const comparison = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison ?? null);
|
|
assert(
|
|
comparison?.visible === true &&
|
|
comparison.confirmInteractive === true &&
|
|
isFiniteBounds(comparison.confirmButtonBounds),
|
|
`Expected a stable interactive equipment confirmation control: ${JSON.stringify(comparison)}`
|
|
);
|
|
const bounds = comparison.confirmButtonBounds;
|
|
return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
|
|
}
|
|
|
|
try {
|
|
runCommand('node', ['scripts/verify-static-data.mjs']);
|
|
mkdirSync(screenshotDir, { recursive: true });
|
|
serverProcess = await ensurePreviewServer(targetUrl);
|
|
runCommand(process.execPath, ['scripts/verify-save-retry-flow.mjs'], { VERIFY_URL: targetUrl });
|
|
|
|
browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage(desktopBrowserContextOptions);
|
|
const consoleMessages = [];
|
|
const pageErrors = [];
|
|
const requestFailures = [];
|
|
const requestedResourceUrls = [];
|
|
page.on('console', (message) => {
|
|
consoleMessages.push({ type: message.type(), text: message.text() });
|
|
});
|
|
page.on('pageerror', (error) => {
|
|
pageErrors.push(error.message);
|
|
});
|
|
page.on('requestfailed', (request) => {
|
|
requestFailures.push({
|
|
url: request.url(),
|
|
type: request.resourceType(),
|
|
failure: request.failure()?.errorText ?? 'unknown'
|
|
});
|
|
});
|
|
page.on('request', (request) => {
|
|
requestedResourceUrls.push(request.url());
|
|
});
|
|
|
|
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => window.localStorage.clear());
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await assertReleaseShellReady(page);
|
|
await assertHdPlusCanvasFit(page);
|
|
await assertHdPlusInputMapping(browser, targetUrl);
|
|
await assertLargeRosterHud(browser, targetUrl);
|
|
await assertWebglBattleQuickTabSmoke(browser, targetUrl, 'first-battle-zhuo-commandery');
|
|
await page.screenshot({ path: `${screenshotDir}/rc-title-first-run.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'title first run');
|
|
|
|
const emptySave = await readCampaignSave(page);
|
|
assert(!emptySave.current && !emptySave.slot1, `Expected first run without campaign save: ${JSON.stringify(emptySave)}`);
|
|
|
|
const freshStoryRequestStartIndex = requestedResourceUrls.length;
|
|
await clickLegacyUi(page, 962, 240);
|
|
await waitForStoryReady(page);
|
|
await setDebugQueryParam(page, 'debugOrderCommandReady', '1');
|
|
const initialStoryProgress = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.());
|
|
assert(
|
|
initialStoryProgress?.pageIndex === 0 &&
|
|
initialStoryProgress?.totalPages > 1 &&
|
|
initialStoryProgress?.progressLabel?.startsWith(`1 / ${initialStoryProgress.totalPages}`) &&
|
|
initialStoryProgress.progressLabel.includes('클릭') &&
|
|
initialStoryProgress.progressLabel.includes('스페이스') &&
|
|
initialStoryProgress.progressLabel.includes('다음') &&
|
|
initialStoryProgress.advanceHint === 'next' &&
|
|
initialStoryProgress.isLastPage === false &&
|
|
boundsWithinFhdViewport(initialStoryProgress.progressBounds) &&
|
|
boundsWithinFhdViewport(initialStoryProgress.progressPanelBounds) &&
|
|
boundsInside(initialStoryProgress.progressBounds, initialStoryProgress.progressPanelBounds),
|
|
`Expected localized, FHD-safe story progress guidance: ${JSON.stringify(initialStoryProgress)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'new game story');
|
|
await assertFreshStoryAssetStreaming(page, requestedResourceUrls, freshStoryRequestStartIndex);
|
|
const firstBattleTransition = await advanceUntilBattle(page, 'first-battle-zhuo-commandery', {
|
|
startDeployment: false,
|
|
captureLastStory: true
|
|
});
|
|
assert(
|
|
firstBattleTransition.lastStory?.isLastPage === true &&
|
|
firstBattleTransition.lastStory?.nextScene === 'BattleScene' &&
|
|
firstBattleTransition.lastStory?.advanceHint === 'battle-deployment' &&
|
|
firstBattleTransition.lastStory?.advanceLabel === '전투 배치' &&
|
|
firstBattleTransition.lastStory?.progressLabel?.includes('전투 배치') &&
|
|
boundsWithinFhdViewport(firstBattleTransition.lastStory?.progressBounds) &&
|
|
boundsWithinFhdViewport(firstBattleTransition.lastStory?.progressPanelBounds) &&
|
|
boundsInside(
|
|
firstBattleTransition.lastStory?.progressBounds,
|
|
firstBattleTransition.lastStory?.progressPanelBounds
|
|
),
|
|
`Expected the final prologue page to name the next action as battle deployment: ${JSON.stringify(firstBattleTransition)}`
|
|
);
|
|
const firstBattleDeployment = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
const firstDeploymentPanel = firstBattleDeployment?.battleHud?.deploymentPanel;
|
|
assert(
|
|
firstBattleDeployment?.phase === 'deployment' &&
|
|
firstDeploymentPanel?.recommended === true &&
|
|
firstDeploymentPanel?.changeCount === 0 &&
|
|
boundsInside(firstDeploymentPanel?.statusBounds, firstDeploymentPanel?.headerBounds),
|
|
`Expected first deployment to show an FHD-safe recommended-formation status chip: ${JSON.stringify(firstBattleDeployment)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-deployment.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first battle deployment');
|
|
|
|
const changedFirstDeployment = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const recommended = scene?.deploymentSlots?.().filter((slot) => slot.unitId) ?? [];
|
|
const first = recommended[0]?.unitId ? scene?.debugUnitById?.(recommended[0].unitId) : undefined;
|
|
const second = recommended[1]?.unitId ? scene?.debugUnitById?.(recommended[1].unitId) : undefined;
|
|
if (!scene || !first || !second) {
|
|
return null;
|
|
}
|
|
scene.swapDeploymentUnits(first, second);
|
|
return window.__HEROS_DEBUG__?.battle()?.battleHud?.deploymentPanel ?? null;
|
|
});
|
|
assert(
|
|
changedFirstDeployment?.recommended === false &&
|
|
changedFirstDeployment?.changeCount >= 2 &&
|
|
boundsInside(changedFirstDeployment?.statusBounds, changedFirstDeployment?.headerBounds),
|
|
`Expected a changed first deployment to expose a restore-ready status: ${JSON.stringify(changedFirstDeployment)}`
|
|
);
|
|
await page.waitForTimeout(220);
|
|
const restoredFirstDeployment = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
scene?.restoreRecommendedDeployment?.();
|
|
return window.__HEROS_DEBUG__?.battle()?.battleHud?.deploymentPanel ?? null;
|
|
});
|
|
assert(
|
|
restoredFirstDeployment?.recommended === true && restoredFirstDeployment?.changeCount === 0,
|
|
`Expected one restore action to return the first deployment to the recommended formation: ${JSON.stringify(restoredFirstDeployment)}`
|
|
);
|
|
await page.waitForTimeout(180);
|
|
await clickBattleDeploymentStart(page);
|
|
await page.waitForFunction(() => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return state?.battleId === 'first-battle-zhuo-commandery' && state?.phase === 'idle' && state?.triggeredBattleEvents?.includes('opening');
|
|
}, undefined, { timeout: 30000 });
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-battle.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first battle');
|
|
|
|
const firstBattleProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
const sideTexts = textValues(scene?.sidePanelObjects);
|
|
const bannerTexts = textValues(scene?.battleEventObjects);
|
|
const miniMapTexts = textValues((scene?.miniMapObjects ?? []).filter((object) => object?.active && object?.visible));
|
|
const miniMapFrame = (scene?.miniMapObjects ?? []).find((object) => object?.name === 'mini-map-frame');
|
|
return {
|
|
battleId: state?.battleId,
|
|
objectiveText: scene?.objectiveTrackerText?.text,
|
|
objectiveSubText: scene?.objectiveTrackerSubText?.text,
|
|
sideTexts,
|
|
bannerTexts,
|
|
miniMapTexts,
|
|
actualMiniMapFrameBounds: miniMapFrame ? boundsValue(miniMapFrame.getBounds()) : null,
|
|
hud: state?.battleHud ?? null,
|
|
camera: state?.camera ?? null,
|
|
battleLog: [...(state?.battleLog ?? [])],
|
|
sceneBounds: scene ? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height } : null,
|
|
panelBounds: scene
|
|
? {
|
|
x: scene.layout.panelX,
|
|
y: scene.layout.panelY,
|
|
width: scene.layout.panelWidth,
|
|
height: scene.layout.panelHeight
|
|
}
|
|
: null
|
|
};
|
|
|
|
function textValues(objects = []) {
|
|
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
|
|
}
|
|
|
|
function boundsValue(bounds) {
|
|
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
|
}
|
|
});
|
|
assert(firstBattleProbe.battleId === 'first-battle-zhuo-commandery', `Expected first battle: ${JSON.stringify(firstBattleProbe)}`);
|
|
assert(firstBattleProbe.objectiveText?.startsWith('승리 목표:'), `Expected clear victory objective label: ${JSON.stringify(firstBattleProbe)}`);
|
|
assert(firstBattleProbe.objectiveSubText?.includes('패배 조건:'), `Expected clear defeat condition label: ${JSON.stringify(firstBattleProbe)}`);
|
|
assert(
|
|
firstBattleProbe.objectiveSubText?.startsWith('패배 조건:') &&
|
|
!firstBattleProbe.objectiveSubText.includes('진군:') &&
|
|
!firstBattleProbe.objectiveSubText.includes('보조:'),
|
|
`Expected the compact objective subtext to retain only the defeat condition without duplicate route or bonus guidance: ${JSON.stringify(firstBattleProbe)}`
|
|
);
|
|
assert(
|
|
firstBattleProbe.bannerTexts.some((text) => text.includes('출진 군세')) &&
|
|
firstBattleProbe.bannerTexts.some((text) => text.includes('첫 행동')) &&
|
|
!firstBattleProbe.bannerTexts.some((text) => text.includes('...')),
|
|
`Expected sortie doctrine and first-action guidance without ASCII truncation: ${JSON.stringify(firstBattleProbe.bannerTexts)}`
|
|
);
|
|
const firstBattleMiniMap = firstBattleProbe.hud?.miniMap;
|
|
const firstBattleRecentActions = firstBattleProbe.hud?.recentActions;
|
|
assert(
|
|
firstBattleMiniMap?.visible === true &&
|
|
firstBattleMiniMap.title === '전장 지도' &&
|
|
sameJsonValue(firstBattleMiniMap.legend, ['아', '적', '목']) &&
|
|
firstBattleProbe.sceneBounds?.width === 1920 &&
|
|
firstBattleProbe.sceneBounds?.height === 1080 &&
|
|
firstBattleMiniMap.cellSize >= 1 &&
|
|
firstBattleMiniMap.objectiveHighlighted === true &&
|
|
firstBattleMiniMap.selectedUnitId === null &&
|
|
firstBattleMiniMap.selectedHighlighted === false &&
|
|
firstBattleProbe.miniMapTexts.includes('전장 지도') &&
|
|
['아', '적', '목'].every((label) => firstBattleProbe.miniMapTexts.includes(label)) &&
|
|
sameJsonValue(firstBattleProbe.actualMiniMapFrameBounds, firstBattleMiniMap.frameBounds) &&
|
|
isFiniteBounds(firstBattleProbe.sceneBounds) &&
|
|
isFiniteBounds(firstBattleProbe.panelBounds) &&
|
|
isFiniteBounds(firstBattleMiniMap.frameBounds) &&
|
|
isFiniteBounds(firstBattleMiniMap.headerBounds) &&
|
|
isFiniteBounds(firstBattleMiniMap.mapBounds) &&
|
|
isFiniteBounds(firstBattleMiniMap.viewportBounds) &&
|
|
boundsInside(firstBattleProbe.panelBounds, firstBattleProbe.sceneBounds) &&
|
|
boundsInside(firstBattleMiniMap.frameBounds, firstBattleProbe.panelBounds) &&
|
|
boundsInside(firstBattleMiniMap.headerBounds, firstBattleMiniMap.frameBounds) &&
|
|
boundsInside(firstBattleMiniMap.mapBounds, firstBattleMiniMap.frameBounds) &&
|
|
boundsInside(firstBattleMiniMap.viewportBounds, firstBattleMiniMap.mapBounds, 2) &&
|
|
firstBattleMiniMap.mapBounds.width === firstBattleProbe.camera?.mapWidth * firstBattleMiniMap.cellSize &&
|
|
firstBattleMiniMap.mapBounds.height === firstBattleProbe.camera?.mapHeight * firstBattleMiniMap.cellSize &&
|
|
firstBattleMiniMap.headerBounds.y + firstBattleMiniMap.headerBounds.height <= firstBattleMiniMap.mapBounds.y,
|
|
`Expected a framed tactical minimap with title, legend, objective emphasis, and separate map hit bounds: ${JSON.stringify(firstBattleProbe)}`
|
|
);
|
|
assert(
|
|
isFiniteBounds(firstBattleRecentActions) &&
|
|
boundsInside(firstBattleRecentActions, firstBattleProbe.panelBounds) &&
|
|
boundsInside(firstBattleRecentActions, firstBattleProbe.sceneBounds) &&
|
|
firstBattleRecentActions?.compact === false &&
|
|
firstBattleRecentActions.height === 126 &&
|
|
firstBattleRecentActions.rowCount === Math.min(3, firstBattleProbe.battleLog.length) &&
|
|
firstBattleRecentActions.textBounds.length === firstBattleRecentActions.rowCount &&
|
|
firstBattleRecentActions.textBounds.every((bounds) => boundsInside(bounds, firstBattleRecentActions)) &&
|
|
firstBattleProbe.sideTexts.includes('최근 행동') &&
|
|
firstBattleRecentActions.bottom === firstBattleRecentActions.y + firstBattleRecentActions.height &&
|
|
firstBattleRecentActions.gapToMiniMap >= 6 &&
|
|
firstBattleRecentActions.bottom <= firstBattleMiniMap.frameBounds.y,
|
|
`Expected the FHD recent-action panel to show three noncompact rows without touching the minimap frame: ${JSON.stringify(firstBattleProbe.hud)}`
|
|
);
|
|
const firstBattleMapFeedback = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const unit = scene?.debugUnitById?.('liu-bei');
|
|
const view = unit ? scene?.unitViews?.get(unit.id) : undefined;
|
|
if (!scene || !unit || !view) {
|
|
return null;
|
|
}
|
|
|
|
const originalX = view.sprite.x;
|
|
const originalY = view.sprite.y;
|
|
const originalSpeed = scene.battleSpeed;
|
|
scene.battleSpeed = 'normal';
|
|
view.sprite.x = scene.layout.mapX + 1;
|
|
view.sprite.y = scene.layout.mapY + 1;
|
|
scene.showMapResultPopup(unit, ['치명 피해 -999', 'HP 999→1', '공명 추격 +99'], '#ff8f5f', '#220909', 20, 38);
|
|
const normal = window.__HEROS_DEBUG__?.battle()?.mapResultFeedback;
|
|
const normalActualBounds = (scene.mapResultPopupObjects ?? [])
|
|
.filter((object) => object?.active)
|
|
.map((object) => boundsValue(object.getBounds()));
|
|
scene.battleSpeed = 'very-fast';
|
|
for (let index = 0; index < 6; index += 1) {
|
|
scene.showMapResultPopup(unit, [`연속 판정 ${index + 1}`, 'HP 경계 확인'], '#ffdf7b', '#220909', 18, 30);
|
|
}
|
|
const fast = window.__HEROS_DEBUG__?.battle()?.mapResultFeedback;
|
|
const fastActualBounds = (scene.mapResultPopupObjects ?? [])
|
|
.filter((object) => object?.active)
|
|
.map((object) => boundsValue(object.getBounds()));
|
|
view.sprite.x = originalX;
|
|
view.sprite.y = originalY;
|
|
scene.battleSpeed = originalSpeed;
|
|
scene.renderBattleSpeedControl?.();
|
|
return {
|
|
normal: { ...normal, actualBounds: normalActualBounds },
|
|
fast: { ...fast, actualBounds: fastActualBounds }
|
|
};
|
|
|
|
function boundsValue(bounds) {
|
|
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
|
}
|
|
});
|
|
assert(
|
|
firstBattleMapFeedback?.normal?.last?.duration === 680 &&
|
|
firstBattleMapFeedback.normal.last.delay === 80 &&
|
|
boundsInside(firstBattleMapFeedback.normal.last.bounds, firstBattleMapFeedback.normal.mapBounds) &&
|
|
boundsInside(firstBattleMapFeedback.normal.last.endBounds, firstBattleMapFeedback.normal.mapBounds) &&
|
|
firstBattleMapFeedback.fast?.last?.duration >= 260 &&
|
|
firstBattleMapFeedback.fast.last.duration < firstBattleMapFeedback.normal.last.duration &&
|
|
firstBattleMapFeedback.fast.last.delay >= 30 &&
|
|
firstBattleMapFeedback.fast.last.delay < firstBattleMapFeedback.normal.last.delay &&
|
|
boundsInside(firstBattleMapFeedback.fast.last.bounds, firstBattleMapFeedback.fast.mapBounds) &&
|
|
boundsInside(firstBattleMapFeedback.fast.last.endBounds, firstBattleMapFeedback.fast.mapBounds) &&
|
|
firstBattleMapFeedback.normal.actualBounds.length === 1 &&
|
|
firstBattleMapFeedback.normal.actualBounds.every((bounds) =>
|
|
boundsInside(bounds, firstBattleMapFeedback.normal.mapBounds)
|
|
) &&
|
|
firstBattleMapFeedback.fast.actualBounds.length > 0 &&
|
|
firstBattleMapFeedback.fast.actualBounds.every((bounds) =>
|
|
boundsInside(bounds, firstBattleMapFeedback.fast.mapBounds)
|
|
) &&
|
|
firstBattleMapFeedback.fast.activeCount <= firstBattleMapFeedback.fast.maxActive &&
|
|
firstBattleMapFeedback.fast.peakCount <= firstBattleMapFeedback.fast.maxActive,
|
|
`Expected bounded, speed-aware, capped map result feedback at FHD: ${JSON.stringify(firstBattleMapFeedback)}`
|
|
);
|
|
await page.waitForTimeout(120);
|
|
const animatingMapFeedback = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const state = window.__HEROS_DEBUG__?.battle()?.mapResultFeedback;
|
|
return {
|
|
mapBounds: state?.mapBounds ?? null,
|
|
activeBounds: (scene?.mapResultPopupObjects ?? [])
|
|
.filter((object) => object?.active)
|
|
.map((object) => {
|
|
const bounds = object.getBounds();
|
|
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
|
})
|
|
};
|
|
});
|
|
assert(
|
|
animatingMapFeedback.activeBounds.length > 0 &&
|
|
animatingMapFeedback.activeBounds.every((bounds) => boundsInside(bounds, animatingMapFeedback.mapBounds)),
|
|
`Expected animated map result feedback to remain inside the map: ${JSON.stringify(animatingMapFeedback)}`
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.mapResultFeedback?.activeCount === 0,
|
|
undefined,
|
|
{ timeout: 3000 }
|
|
);
|
|
await setDebugQueryParam(page, 'debugForceBondChain', '1');
|
|
const firstBattleBondChain = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
const mechanics = state?.combatMechanicsProbe;
|
|
const attacker = mechanics?.attackerId ? scene?.debugUnitById(mechanics.attackerId) : undefined;
|
|
const partner = mechanics?.partnerId ? scene?.debugUnitById(mechanics.partnerId) : undefined;
|
|
const enemy = mechanics?.enemyId ? scene?.debugUnitById(mechanics.enemyId) : undefined;
|
|
if (!scene || !attacker || !partner || !enemy) {
|
|
return { chain: mechanics?.chain ?? null, forcedRoll: null, preview: null };
|
|
}
|
|
|
|
const originalIntents = scene.attackIntents.map((intent) => ({ ...intent }));
|
|
const originalActedUnitIds = new Set(scene.actedUnitIds);
|
|
scene.attackIntents = [{ attackerId: partner.id, targetId: enemy.id }];
|
|
scene.actedUnitIds = new Set([...originalActedUnitIds, partner.id]);
|
|
const preview = scene.combatPreview(attacker, enemy, 'attack');
|
|
scene.attackIntents = originalIntents;
|
|
scene.actedUnitIds = originalActedUnitIds;
|
|
|
|
return {
|
|
chain: mechanics.chain ?? null,
|
|
forcedRoll: scene.debugBondChainRollOverride(),
|
|
preview: {
|
|
rate: preview.bondChainRate,
|
|
damage: preview.bondChainDamage,
|
|
bondId: preview.bondChainBondId ?? null,
|
|
partnerId: preview.bondChainPartnerId ?? null,
|
|
partnerName: preview.bondChainPartnerName ?? null,
|
|
label: preview.bondChainLabel ?? null
|
|
}
|
|
};
|
|
});
|
|
await setDebugQueryParam(page, 'debugForceBondChain', '0');
|
|
const firstBattleBondChainForcedFailure = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
return scene?.debugBondChainRollOverride() ?? null;
|
|
});
|
|
await setDebugQueryParam(page, 'debugForceBondChain', null);
|
|
assert(
|
|
firstBattleBondChain.preview?.rate === 18 &&
|
|
firstBattleBondChain.preview.damage > 0 &&
|
|
firstBattleBondChain.preview.bondId &&
|
|
firstBattleBondChain.preview.partnerId === firstBattleBondChain.chain?.expectedSupporterId &&
|
|
firstBattleBondChain.preview.partnerName &&
|
|
firstBattleBondChain.preview.label,
|
|
`Expected a complete level-72 resonance-pursuit preview contract: ${JSON.stringify(firstBattleBondChain)}`
|
|
);
|
|
assert(
|
|
firstBattleBondChain.forcedRoll === true &&
|
|
firstBattleBondChainForcedFailure === false &&
|
|
firstBattleBondChain.chain?.rate === 18 &&
|
|
firstBattleBondChain.chain.supportDamage === firstBattleBondChain.preview.damage &&
|
|
firstBattleBondChain.chain.ineligibleWithoutPriorIntent === true &&
|
|
firstBattleBondChain.chain.eligibleWithMatchingPriorIntent === true &&
|
|
firstBattleBondChain.chain.strategyIgnored === true &&
|
|
firstBattleBondChain.chain.counterIgnored === true &&
|
|
firstBattleBondChain.chain.missedBaseBlocked === true &&
|
|
firstBattleBondChain.chain.lethalBaseBlocked === true &&
|
|
firstBattleBondChain.chain.survivingBaseAllowed === true &&
|
|
firstBattleBondChain.chain.forcedSuccessTriggers === true &&
|
|
firstBattleBondChain.chain.forcedFailureBlocked === true,
|
|
`Expected pursuit eligibility, hit/survival gates, and deterministic roll overrides: ${JSON.stringify(firstBattleBondChain)}`
|
|
);
|
|
assert(
|
|
firstBattleBondChain.chain?.statCredit?.partnerDamage === firstBattleBondChain.chain.supportDamage &&
|
|
firstBattleBondChain.chain.statCredit.partnerDefeats === 1 &&
|
|
firstBattleBondChain.chain.statCredit.partnerActions === 0 &&
|
|
firstBattleBondChain.chain.statCredit.defenderDamageTaken === firstBattleBondChain.chain.supportDamage &&
|
|
firstBattleBondChain.chain.followUpKillCancelsCounter === true &&
|
|
firstBattleBondChain.chain.intentClearedAtTurnReset === true,
|
|
`Expected pursuit damage and defeat credit to belong to the supporter, cancel counters on defeat, and reset next turn: ${JSON.stringify(firstBattleBondChain.chain)}`
|
|
);
|
|
|
|
const firstBattleBondChainReadyUi = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const stateBefore = window.__HEROS_DEBUG__?.battle();
|
|
const mechanics = stateBefore?.combatMechanicsProbe;
|
|
const attacker = mechanics?.attackerId ? scene?.debugUnitById(mechanics.attackerId) : undefined;
|
|
const partner = mechanics?.partnerId ? scene?.debugUnitById(mechanics.partnerId) : undefined;
|
|
const target = mechanics?.enemyId ? scene?.debugUnitById(mechanics.enemyId) : undefined;
|
|
const mismatchTarget = stateBefore?.units?.find(
|
|
(unit) => unit.faction === 'enemy' && unit.hp > 0 && unit.id !== mechanics?.enemyId
|
|
);
|
|
if (
|
|
!scene ||
|
|
!attacker ||
|
|
!partner ||
|
|
!target ||
|
|
!mismatchTarget ||
|
|
stateBefore?.phase !== 'idle' ||
|
|
stateBefore.selectedUnitId !== null ||
|
|
stateBefore.commandMenuVisible !== false
|
|
) {
|
|
return {
|
|
ready: false,
|
|
reason: 'first battle was not in a clean idle state for the pursuit-ready UI fixture',
|
|
stateBefore,
|
|
mechanics
|
|
};
|
|
}
|
|
|
|
const touchedUnits = [attacker, partner, target];
|
|
const original = {
|
|
phase: scene.phase,
|
|
selectedUnit: scene.selectedUnit,
|
|
pendingMove: scene.pendingMove,
|
|
targetingAction: scene.targetingAction,
|
|
selectedUsable: scene.selectedUsable,
|
|
lockedTargetPreview: scene.lockedTargetPreview,
|
|
attackIntents: scene.attackIntents.map((intent) => ({ ...intent })),
|
|
actedUnitIds: new Set(scene.actedUnitIds),
|
|
cameraTileX: scene.cameraTileX,
|
|
cameraTileY: scene.cameraTileY,
|
|
miniMapVisible: scene.miniMapVisible,
|
|
rosterTab: scene.rosterTab,
|
|
units: touchedUnits.map((unit) => ({
|
|
id: unit.id,
|
|
x: unit.x,
|
|
y: unit.y,
|
|
hp: unit.hp,
|
|
maxHp: unit.maxHp
|
|
}))
|
|
};
|
|
|
|
const fixtureViewportBounds = { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height };
|
|
const fixturePanelBounds = {
|
|
x: scene.layout.panelX,
|
|
y: scene.layout.panelY,
|
|
width: scene.layout.panelWidth,
|
|
height: scene.layout.panelHeight
|
|
};
|
|
const fixtureTargetPosition = { x: 5, y: 15 };
|
|
const fixtureTargetTileBounds = () => ({
|
|
x: scene.tileTopLeftX(target.x),
|
|
y: scene.tileTopLeftY(target.y),
|
|
width: scene.layout.tileSize,
|
|
height: scene.layout.tileSize
|
|
});
|
|
const fixturePartnerTileBounds = () => ({
|
|
x: scene.tileTopLeftX(partner.x),
|
|
y: scene.tileTopLeftY(partner.y),
|
|
width: scene.layout.tileSize,
|
|
height: scene.layout.tileSize
|
|
});
|
|
const capture = () => {
|
|
const state = scene.getDebugState();
|
|
return {
|
|
phase: state.phase,
|
|
selectedUnitId: state.selectedUnitId,
|
|
selectedUsable: state.selectedUsable,
|
|
preview: state.bondChainReadyPreview ?? null,
|
|
targetTileBounds: fixtureTargetTileBounds(),
|
|
partnerTileBounds: fixturePartnerTileBounds(),
|
|
viewportBounds: fixtureViewportBounds,
|
|
panelBounds: fixturePanelBounds
|
|
};
|
|
};
|
|
const stageTargeting = ({ user, action, usable, intentAttackerId, intentTargetId, actedUnitId }) => {
|
|
scene.clearMarkers();
|
|
scene.hideCommandMenu();
|
|
scene.selectedUnit = user;
|
|
scene.pendingMove = undefined;
|
|
scene.targetingAction = undefined;
|
|
scene.selectedUsable = undefined;
|
|
scene.lockedTargetPreview = undefined;
|
|
scene.phase = 'command';
|
|
scene.attackIntents = [{ attackerId: intentAttackerId, targetId: intentTargetId }];
|
|
scene.actedUnitIds = new Set([actedUnitId]);
|
|
scene.beginDamageTargeting(user, action, usable);
|
|
};
|
|
|
|
let probes;
|
|
try {
|
|
attacker.x = 4;
|
|
attacker.y = 15;
|
|
partner.x = 3;
|
|
partner.y = 15;
|
|
target.x = fixtureTargetPosition.x;
|
|
target.y = fixtureTargetPosition.y;
|
|
target.maxHp = Math.max(target.maxHp, 5000);
|
|
target.hp = target.maxHp;
|
|
scene.centerCameraOnTile(target.x, target.y);
|
|
touchedUnits.forEach((unit) => scene.positionUnitView(unit));
|
|
|
|
stageTargeting({
|
|
user: attacker,
|
|
action: 'attack',
|
|
intentAttackerId: partner.id,
|
|
intentTargetId: target.id,
|
|
actedUnitId: partner.id
|
|
});
|
|
const eligibleCombatPreview = scene.combatPreview(attacker, target, 'attack');
|
|
scene.renderAttackPreview(eligibleCombatPreview, false);
|
|
const eligible = { ...capture(), baseDamage: eligibleCombatPreview.damage, targetHp: target.hp };
|
|
const eligibleCamera = { x: scene.cameraTileX, y: scene.cameraTileY };
|
|
scene.cameraTileX = scene.maxCameraTileX();
|
|
scene.cameraTileY = 0;
|
|
scene.updateCameraView();
|
|
const offscreen = capture();
|
|
scene.cameraTileX = eligibleCamera.x;
|
|
scene.cameraTileY = eligibleCamera.y;
|
|
scene.updateCameraView();
|
|
const returned = capture();
|
|
|
|
stageTargeting({
|
|
user: attacker,
|
|
action: 'attack',
|
|
intentAttackerId: partner.id,
|
|
intentTargetId: mismatchTarget.id,
|
|
actedUnitId: partner.id
|
|
});
|
|
const mismatch = capture();
|
|
|
|
const damageStrategy = scene.availableUsables(partner, 'strategy').find((usable) => usable.effect === 'damage');
|
|
if (!damageStrategy) {
|
|
throw new Error(`Expected ${partner.id} to have a damaging strategy for the pursuit-ready UI fixture.`);
|
|
}
|
|
partner.x = target.x;
|
|
partner.y = target.y - 1;
|
|
scene.positionUnitView(partner);
|
|
stageTargeting({
|
|
user: partner,
|
|
action: damageStrategy.command,
|
|
usable: damageStrategy,
|
|
intentAttackerId: attacker.id,
|
|
intentTargetId: target.id,
|
|
actedUnitId: attacker.id
|
|
});
|
|
const strategy = capture();
|
|
|
|
partner.x = 3;
|
|
partner.y = 15;
|
|
scene.positionUnitView(partner);
|
|
const lethalBaseDamage = scene.combatPreview(attacker, target, 'attack').damage;
|
|
target.hp = Math.max(1, lethalBaseDamage);
|
|
stageTargeting({
|
|
user: attacker,
|
|
action: 'attack',
|
|
intentAttackerId: partner.id,
|
|
intentTargetId: target.id,
|
|
actedUnitId: partner.id
|
|
});
|
|
const lethal = {
|
|
...capture(),
|
|
baseDamage: scene.combatPreview(attacker, target, 'attack').damage,
|
|
targetHp: target.hp
|
|
};
|
|
|
|
target.hp = target.maxHp;
|
|
partner.x = 18;
|
|
partner.y = 17;
|
|
scene.positionUnitView(partner);
|
|
stageTargeting({
|
|
user: attacker,
|
|
action: 'attack',
|
|
intentAttackerId: partner.id,
|
|
intentTargetId: target.id,
|
|
actedUnitId: partner.id
|
|
});
|
|
const far = capture();
|
|
|
|
probes = {
|
|
ready: true,
|
|
attackerId: attacker.id,
|
|
partnerId: partner.id,
|
|
partnerName: partner.name,
|
|
targetId: target.id,
|
|
targetName: target.name,
|
|
expectedRate: mechanics.chain?.rate ?? null,
|
|
eligible,
|
|
offscreen,
|
|
returned,
|
|
mismatch,
|
|
strategy,
|
|
lethal,
|
|
far
|
|
};
|
|
} finally {
|
|
scene.clearMarkers();
|
|
scene.hideCommandMenu();
|
|
original.units.forEach((snapshot) => {
|
|
const unit = scene.debugUnitById(snapshot.id);
|
|
if (!unit) {
|
|
return;
|
|
}
|
|
unit.x = snapshot.x;
|
|
unit.y = snapshot.y;
|
|
unit.hp = snapshot.hp;
|
|
unit.maxHp = snapshot.maxHp;
|
|
});
|
|
scene.attackIntents = original.attackIntents.map((intent) => ({ ...intent }));
|
|
scene.actedUnitIds = new Set(original.actedUnitIds);
|
|
scene.selectedUnit = original.selectedUnit;
|
|
scene.pendingMove = original.pendingMove;
|
|
scene.targetingAction = original.targetingAction;
|
|
scene.selectedUsable = original.selectedUsable;
|
|
scene.lockedTargetPreview = original.lockedTargetPreview;
|
|
scene.phase = original.phase;
|
|
scene.cameraTileX = original.cameraTileX;
|
|
scene.cameraTileY = original.cameraTileY;
|
|
scene.updateCameraView();
|
|
scene.refreshUnitLegibilityStyles();
|
|
scene.renderRosterPanel(original.rosterTab, '행동할 장수를 선택하세요.');
|
|
scene.setMiniMapVisible(original.miniMapVisible);
|
|
}
|
|
|
|
const restored = scene.getDebugState();
|
|
return {
|
|
...probes,
|
|
restored: {
|
|
phase: restored.phase,
|
|
selectedUnitId: restored.selectedUnitId,
|
|
commandMenuVisible: restored.commandMenuVisible,
|
|
markerCount: restored.markerCount,
|
|
actedUnitIds: [...restored.actedUnitIds].sort(),
|
|
attackIntents: restored.attackIntents,
|
|
camera: restored.camera,
|
|
pursuitReady: restored.bondChainReadyPreview ?? null,
|
|
units: restored.units
|
|
.filter((unit) => original.units.some((snapshot) => snapshot.id === unit.id))
|
|
.map(({ id, x, y, hp, maxHp }) => ({ id, x, y, hp, maxHp }))
|
|
.sort((left, right) => left.id.localeCompare(right.id))
|
|
},
|
|
expectedRestored: {
|
|
phase: stateBefore.phase,
|
|
selectedUnitId: stateBefore.selectedUnitId,
|
|
commandMenuVisible: stateBefore.commandMenuVisible,
|
|
markerCount: stateBefore.markerCount,
|
|
actedUnitIds: [...stateBefore.actedUnitIds].sort(),
|
|
attackIntents: stateBefore.attackIntents,
|
|
camera: stateBefore.camera,
|
|
units: original.units
|
|
.map(({ id, x, y, hp, maxHp }) => ({ id, x, y, hp, maxHp }))
|
|
.sort((left, right) => left.id.localeCompare(right.id))
|
|
}
|
|
};
|
|
});
|
|
|
|
assert(
|
|
firstBattleBondChainReadyUi.ready === true && firstBattleBondChainReadyUi.expectedRate === 18,
|
|
`Expected a deterministic level-72 pursuit-ready UI fixture: ${JSON.stringify(firstBattleBondChainReadyUi)}`
|
|
);
|
|
const readyCandidate = firstBattleBondChainReadyUi.eligible?.preview?.candidates?.[0];
|
|
const readyHud = firstBattleBondChainReadyUi.eligible?.preview?.hud;
|
|
const readyContext = '명중 후 대상 생존 시 · 지원 거리 충족';
|
|
assert(
|
|
firstBattleBondChainReadyUi.eligible?.phase === 'targeting' &&
|
|
firstBattleBondChainReadyUi.eligible.selectedUnitId === firstBattleBondChainReadyUi.attackerId &&
|
|
firstBattleBondChainReadyUi.eligible.selectedUsable === null &&
|
|
firstBattleBondChainReadyUi.eligible.preview?.visible === true &&
|
|
firstBattleBondChainReadyUi.eligible.preview.action === 'attack' &&
|
|
firstBattleBondChainReadyUi.eligible.preview.attackerId === firstBattleBondChainReadyUi.attackerId &&
|
|
sameJsonValue(firstBattleBondChainReadyUi.eligible.preview.eligibleTargetIds, [firstBattleBondChainReadyUi.targetId]) &&
|
|
firstBattleBondChainReadyUi.eligible.preview.candidates?.length === 1 &&
|
|
firstBattleBondChainReadyUi.eligible.baseDamage < firstBattleBondChainReadyUi.eligible.targetHp &&
|
|
readyCandidate?.targetId === firstBattleBondChainReadyUi.targetId &&
|
|
readyCandidate.targetName === firstBattleBondChainReadyUi.targetName &&
|
|
readyCandidate.partnerId === firstBattleBondChainReadyUi.partnerId &&
|
|
readyCandidate.partnerName === firstBattleBondChainReadyUi.partnerName &&
|
|
readyCandidate.rate === 18 &&
|
|
readyCandidate.damage > 0 &&
|
|
readyCandidate.text === `추격 후보 ${firstBattleBondChainReadyUi.partnerName} 18% · 예상 +${readyCandidate.damage}` &&
|
|
readyCandidate.context === readyContext &&
|
|
readyCandidate.targetMarkerId === `bond-chain-ready-${firstBattleBondChainReadyUi.targetId}` &&
|
|
readyCandidate.partnerMarkerId === `bond-chain-partner-${firstBattleBondChainReadyUi.partnerId}` &&
|
|
readyCandidate.targetMarkerVisible === true &&
|
|
readyCandidate.labelVisible === true &&
|
|
readyCandidate.partnerMarkerVisible === true,
|
|
`Expected matching acted intent to expose exactly one 18% pursuit candidate with clear partner, damage, and survival context: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}`
|
|
);
|
|
const returnedCandidate = firstBattleBondChainReadyUi.returned?.preview?.candidates?.[0];
|
|
assert(
|
|
firstBattleBondChainReadyUi.offscreen?.preview?.visible === false &&
|
|
sameJsonValue(firstBattleBondChainReadyUi.offscreen.preview.eligibleTargetIds, []) &&
|
|
sameJsonValue(firstBattleBondChainReadyUi.offscreen.preview.candidates, []) &&
|
|
firstBattleBondChainReadyUi.offscreen.preview.hud === null &&
|
|
firstBattleBondChainReadyUi.offscreen.preview.focused === null &&
|
|
firstBattleBondChainReadyUi.returned?.preview?.visible === true &&
|
|
returnedCandidate?.targetId === readyCandidate.targetId &&
|
|
returnedCandidate.partnerId === readyCandidate.partnerId &&
|
|
returnedCandidate.damage === readyCandidate.damage &&
|
|
returnedCandidate.targetMarkerVisible === true &&
|
|
returnedCandidate.labelVisible === true &&
|
|
returnedCandidate.partnerMarkerVisible === true &&
|
|
firstBattleBondChainReadyUi.returned.preview.hud?.targetId === readyCandidate.targetId,
|
|
`Expected camera movement to hide offscreen pursuit markers and restore the same visible target, partner, and HUD on return: ${JSON.stringify(firstBattleBondChainReadyUi)}`
|
|
);
|
|
assert(
|
|
isFiniteBounds(readyCandidate?.targetMarkerBounds) &&
|
|
isFiniteBounds(readyCandidate?.partnerMarkerBounds) &&
|
|
isFiniteBounds(readyCandidate?.labelBounds) &&
|
|
isFiniteBounds(readyCandidate?.tileBounds) &&
|
|
isFiniteBounds(firstBattleBondChainReadyUi.eligible?.targetTileBounds) &&
|
|
isFiniteBounds(firstBattleBondChainReadyUi.eligible?.partnerTileBounds) &&
|
|
sameJsonValue(readyCandidate.tileBounds, firstBattleBondChainReadyUi.eligible.targetTileBounds) &&
|
|
sameJsonValue(readyCandidate.bounds, readyCandidate.targetMarkerBounds) &&
|
|
boundsInside(readyCandidate.targetMarkerBounds, readyCandidate.tileBounds, 2) &&
|
|
boundsInside(readyCandidate.labelBounds, readyCandidate.targetMarkerBounds, 3) &&
|
|
boundsInside(readyCandidate.partnerMarkerBounds, firstBattleBondChainReadyUi.eligible.partnerTileBounds, 2) &&
|
|
boundsInside(readyCandidate.tileBounds, firstBattleBondChainReadyUi.eligible.viewportBounds) &&
|
|
boundsInside(firstBattleBondChainReadyUi.eligible.partnerTileBounds, firstBattleBondChainReadyUi.eligible.viewportBounds),
|
|
`Expected pursuit target badge, label, and partner marker to remain inside their visible map tiles: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}`
|
|
);
|
|
assert(
|
|
readyHud?.targetId === readyCandidate.targetId &&
|
|
readyHud.partnerId === readyCandidate.partnerId &&
|
|
readyHud.partnerName === readyCandidate.partnerName &&
|
|
readyHud.rate === readyCandidate.rate &&
|
|
readyHud.damage === readyCandidate.damage &&
|
|
readyHud.locked === false &&
|
|
readyHud.text === readyCandidate.text &&
|
|
readyHud.context === readyContext &&
|
|
isFiniteBounds(readyHud.bounds) &&
|
|
isFiniteBounds(readyHud.textBounds) &&
|
|
isFiniteBounds(readyHud.contextBounds) &&
|
|
boundsInside(readyHud.bounds, firstBattleBondChainReadyUi.eligible.panelBounds) &&
|
|
boundsInside(readyHud.bounds, firstBattleBondChainReadyUi.eligible.viewportBounds) &&
|
|
boundsInside(readyHud.textBounds, readyHud.bounds, 2) &&
|
|
boundsInside(readyHud.contextBounds, readyHud.bounds, 2),
|
|
`Expected the hovered target forecast to show the same two-line pursuit HUD entirely inside the side panel: ${JSON.stringify(firstBattleBondChainReadyUi.eligible)}`
|
|
);
|
|
|
|
const hiddenReadyStates = [
|
|
['mismatched target intent', firstBattleBondChainReadyUi.mismatch, 'attack', firstBattleBondChainReadyUi.attackerId, null],
|
|
['strategy action', firstBattleBondChainReadyUi.strategy, 'strategy', firstBattleBondChainReadyUi.partnerId, 'azureDragonStrike'],
|
|
['lethal base attack', firstBattleBondChainReadyUi.lethal, 'attack', firstBattleBondChainReadyUi.attackerId, null],
|
|
['out-of-support-range partner', firstBattleBondChainReadyUi.far, 'attack', firstBattleBondChainReadyUi.attackerId, null]
|
|
];
|
|
assert(
|
|
firstBattleBondChainReadyUi.lethal?.baseDamage >= firstBattleBondChainReadyUi.lethal?.targetHp,
|
|
`Expected the lethal fixture to prove the base attack would defeat the target before pursuit: ${JSON.stringify(firstBattleBondChainReadyUi.lethal)}`
|
|
);
|
|
hiddenReadyStates.forEach(([context, probe, action, attackerId, selectedUsable]) => {
|
|
assert(
|
|
probe?.phase === 'targeting' &&
|
|
probe.selectedUnitId === attackerId &&
|
|
probe.selectedUsable === selectedUsable &&
|
|
probe.preview?.visible === false &&
|
|
probe.preview.action === action &&
|
|
probe.preview.attackerId === attackerId &&
|
|
sameJsonValue(probe.preview.eligibleTargetIds, []) &&
|
|
sameJsonValue(probe.preview.candidates, []) &&
|
|
probe.preview.hud === null &&
|
|
sameJsonValue(probe.preview.markers, []) &&
|
|
probe.preview.focused === null,
|
|
`Expected pursuit-ready UI to remain hidden for ${context} without stale markers or HUD: ${JSON.stringify(probe)}`
|
|
);
|
|
});
|
|
|
|
const { pursuitReady: restoredPursuitReady, ...restoredFixtureState } = firstBattleBondChainReadyUi.restored;
|
|
assert(
|
|
sameJsonValue(restoredFixtureState, firstBattleBondChainReadyUi.expectedRestored) &&
|
|
restoredPursuitReady?.visible === false &&
|
|
restoredPursuitReady.action === null &&
|
|
restoredPursuitReady.attackerId === null &&
|
|
sameJsonValue(restoredPursuitReady.eligibleTargetIds, []) &&
|
|
sameJsonValue(restoredPursuitReady.candidates, []) &&
|
|
restoredPursuitReady.hud === null,
|
|
`Expected the pursuit-ready UI fixture to restore battle positions, intents, acted state, camera, selection, markers, and idle HUD before the existing RC flow: ${JSON.stringify(firstBattleBondChainReadyUi)}`
|
|
);
|
|
|
|
const firstBattleBondChainJudgement = await page.evaluate(async () => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const stateBefore = window.__HEROS_DEBUG__?.battle();
|
|
const mechanics = stateBefore?.combatMechanicsProbe;
|
|
const attackerId = mechanics?.attackerId;
|
|
const partnerId = mechanics?.partnerId;
|
|
const targetId = mechanics?.enemyId;
|
|
if (
|
|
!scene ||
|
|
!attackerId ||
|
|
!partnerId ||
|
|
!targetId ||
|
|
stateBefore?.phase !== 'idle' ||
|
|
stateBefore.selectedUnitId !== null ||
|
|
stateBefore.commandMenuVisible !== false
|
|
) {
|
|
return {
|
|
ready: false,
|
|
reason: 'first battle was not in a clean idle state for the resonance judgement fixture',
|
|
stateBefore,
|
|
mechanics
|
|
};
|
|
}
|
|
|
|
const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
const comparableSave = (state) => {
|
|
const { savedAt: _savedAt, ...comparable } = state;
|
|
return comparable;
|
|
};
|
|
const collectText = (objects) => {
|
|
const texts = [];
|
|
const visited = new Set();
|
|
const visit = (object) => {
|
|
if (!object || visited.has(object)) {
|
|
return;
|
|
}
|
|
visited.add(object);
|
|
if (object.type === 'Text' && typeof object.text === 'string') {
|
|
texts.push(object.text);
|
|
}
|
|
if (Array.isArray(object.list)) {
|
|
object.list.forEach(visit);
|
|
}
|
|
};
|
|
objects.forEach(visit);
|
|
return texts;
|
|
};
|
|
const sceneObjectsCreatedAfter = (before) => scene.children.list.filter((object) => !before.has(object));
|
|
const clearCreatedSceneObjects = (objects) => {
|
|
objects.forEach((object) => {
|
|
scene.tweens?.killTweensOf(object);
|
|
if (object?.active) {
|
|
object.destroy();
|
|
}
|
|
});
|
|
};
|
|
const setDebugForceHit = (enabled) => {
|
|
const url = new URL(window.location.href);
|
|
if (enabled) {
|
|
url.searchParams.set('debugForceHit', '1');
|
|
} else {
|
|
url.searchParams.delete('debugForceHit');
|
|
}
|
|
window.history.replaceState({}, '', url);
|
|
};
|
|
const setDebugForceBondChain = (value) => {
|
|
const url = new URL(window.location.href);
|
|
if (value === null) {
|
|
url.searchParams.delete('debugForceBondChain');
|
|
} else {
|
|
url.searchParams.set('debugForceBondChain', value);
|
|
}
|
|
window.history.replaceState({}, '', url);
|
|
};
|
|
const statsFor = (snapshot, unitId) => clone(snapshot?.[unitId] ?? {});
|
|
const statsDelta = (before, after) => Object.fromEntries(
|
|
Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).map((key) => [
|
|
key,
|
|
(after[key] ?? 0) - (before[key] ?? 0)
|
|
])
|
|
);
|
|
|
|
const originalUrl = window.location.href;
|
|
const originalSave = clone(scene.createBattleSaveState());
|
|
const originalCamera = { x: scene.cameraTileX, y: scene.cameraTileY };
|
|
const originalJudgement = clone(scene.bondChainJudgementLast);
|
|
const originalPartnerClassKey = scene.debugUnitById(partnerId)?.classKey;
|
|
const originalBattleSpeed = scene.battleSpeed;
|
|
const originalFastForwardHeld = scene.fastForwardHeld;
|
|
const originalRollPercent = scene.rollPercent;
|
|
const hadOwnRollPercent = Object.prototype.hasOwnProperty.call(scene, 'rollPercent');
|
|
const originalPhase = scene.phase;
|
|
const originalSelectedUnit = scene.selectedUnit;
|
|
const originalPendingMove = scene.pendingMove;
|
|
const originalTargetingAction = scene.targetingAction;
|
|
const originalSelectedUsable = scene.selectedUsable;
|
|
const originalLockedTargetPreview = scene.lockedTargetPreview;
|
|
|
|
const restoreFixtureState = () => {
|
|
scene.hideCombatCutIn();
|
|
scene.applyBattleSaveState(clone(originalSave));
|
|
scene.cameraTileX = originalCamera.x;
|
|
scene.cameraTileY = originalCamera.y;
|
|
scene.updateCameraView();
|
|
scene.phase = originalPhase;
|
|
scene.selectedUnit = originalSelectedUnit;
|
|
scene.pendingMove = originalPendingMove;
|
|
scene.targetingAction = originalTargetingAction;
|
|
scene.selectedUsable = originalSelectedUsable;
|
|
scene.lockedTargetPreview = originalLockedTargetPreview;
|
|
scene.bondChainJudgementLast = clone(originalJudgement);
|
|
const partner = scene.debugUnitById(partnerId);
|
|
if (partner && originalPartnerClassKey) {
|
|
partner.classKey = originalPartnerClassKey;
|
|
}
|
|
scene.battleSpeed = originalBattleSpeed;
|
|
scene.fastForwardHeld = originalFastForwardHeld;
|
|
scene.renderBattleSpeedControl();
|
|
scene.clearMarkers();
|
|
scene.hideCommandMenu();
|
|
};
|
|
|
|
const prepareScenario = (mode, partnerClassKey) => {
|
|
restoreFixtureState();
|
|
const attacker = scene.debugUnitById(attackerId);
|
|
const partner = scene.debugUnitById(partnerId);
|
|
const target = scene.debugUnitById(targetId);
|
|
if (!attacker || !partner || !target) {
|
|
throw new Error(`Missing judgement fixture units: ${JSON.stringify({ attackerId, partnerId, targetId })}`);
|
|
}
|
|
if (partnerClassKey) {
|
|
partner.classKey = partnerClassKey;
|
|
}
|
|
|
|
attacker.x = 4;
|
|
attacker.y = 15;
|
|
partner.x = 3;
|
|
partner.y = 15;
|
|
target.x = 5;
|
|
target.y = 15;
|
|
target.maxHp = Math.max(target.maxHp, 5000);
|
|
target.hp = target.maxHp;
|
|
scene.attackIntents = [{ attackerId: partner.id, targetId: target.id }];
|
|
scene.actedUnitIds = new Set([partner.id]);
|
|
scene.centerCameraOnTile(target.x, target.y);
|
|
[attacker, partner, target].forEach((unit) => scene.positionUnitView(unit));
|
|
|
|
const preview = scene.combatPreview(attacker, target, 'attack');
|
|
if (!(preview.damage > 0 && preview.bondChainDamage > 0 && preview.bondChainRate > 0)) {
|
|
throw new Error(`Expected a live resonance preview in ${mode}: ${JSON.stringify({
|
|
damage: preview.damage,
|
|
rate: preview.bondChainRate,
|
|
chainDamage: preview.bondChainDamage,
|
|
partnerId: preview.bondChainPartnerId
|
|
})}`);
|
|
}
|
|
target.hp = mode === 'lethal'
|
|
? preview.damage
|
|
: preview.damage + preview.bondChainDamage;
|
|
return { attacker, partner, target, preview };
|
|
};
|
|
|
|
const captureNeutralMapCue = async (result) => {
|
|
const before = new Set(scene.children.list);
|
|
const effect = scene.showBondMapEffect(result);
|
|
const created = sceneObjectsCreatedAfter(before);
|
|
const texts = collectText(created);
|
|
await effect;
|
|
return texts;
|
|
};
|
|
const captureMapResult = (result) => {
|
|
const before = new Set(scene.children.list);
|
|
scene.showCombatMapResult(result);
|
|
const created = sceneObjectsCreatedAfter(before);
|
|
const texts = collectText(created);
|
|
clearCreatedSceneObjects(created);
|
|
return texts;
|
|
};
|
|
const presentCombatResult = async (result) => {
|
|
let presented = null;
|
|
let settled = false;
|
|
const telemetryStartedAt = performance.now();
|
|
const telemetry = { gauge: [], impact: [], partnerMotion: [] };
|
|
const originalAnimateCombatGauge = scene.animateCombatGauge;
|
|
const originalShowCombatImpact = scene.showCombatImpact;
|
|
const originalPlayCombatActionMotion = scene.playCombatActionMotion;
|
|
const methodOwnership = {
|
|
animateCombatGauge: Object.prototype.hasOwnProperty.call(scene, 'animateCombatGauge'),
|
|
showCombatImpact: Object.prototype.hasOwnProperty.call(scene, 'showCombatImpact'),
|
|
playCombatActionMotion: Object.prototype.hasOwnProperty.call(scene, 'playCombatActionMotion')
|
|
};
|
|
const elapsed = () => performance.now() - telemetryStartedAt;
|
|
const isPartnerResult = (candidate) =>
|
|
Boolean(result.bondChain) && candidate?.attacker?.id === result.bondChain.partnerId;
|
|
const restoreMethod = (name, original) => {
|
|
if (methodOwnership[name]) {
|
|
scene[name] = original;
|
|
} else {
|
|
delete scene[name];
|
|
}
|
|
};
|
|
|
|
scene.animateCombatGauge = function (fill, from, to, duration) {
|
|
const isChainGauge = Boolean(result.bondChain) &&
|
|
duration === 300 &&
|
|
Math.abs(from - result.bondChain.previousDefenderHp / result.defender.maxHp) < 0.0001 &&
|
|
Math.abs(to - result.defender.hp / result.defender.maxHp) < 0.0001;
|
|
const event = isChainGauge
|
|
? { startAt: elapsed(), endAt: null, from, to, duration, startScale: fill.scaleX, endScale: null }
|
|
: null;
|
|
if (event) {
|
|
telemetry.gauge.push(event);
|
|
}
|
|
const animation = originalAnimateCombatGauge.call(this, fill, from, to, duration);
|
|
return animation.then(() => {
|
|
if (event) {
|
|
event.endAt = elapsed();
|
|
event.endScale = fill.scaleX;
|
|
}
|
|
});
|
|
};
|
|
scene.showCombatImpact = function (impactResult, ...args) {
|
|
if (isPartnerResult(impactResult)) {
|
|
telemetry.impact.push({ at: elapsed(), damage: impactResult.damage });
|
|
}
|
|
return originalShowCombatImpact.call(this, impactResult, ...args);
|
|
};
|
|
scene.playCombatActionMotion = async function (motionResult, ...args) {
|
|
const isPartnerMotion = isPartnerResult(motionResult);
|
|
const event = isPartnerMotion ? { startAt: elapsed(), endAt: null } : null;
|
|
if (event) {
|
|
telemetry.partnerMotion.push(event);
|
|
}
|
|
await originalPlayCombatActionMotion.call(this, motionResult, ...args);
|
|
if (event) {
|
|
event.endAt = elapsed();
|
|
}
|
|
};
|
|
|
|
try {
|
|
const playback = scene.playCombatCutIn(result).finally(() => {
|
|
settled = true;
|
|
});
|
|
for (let attempt = 0; attempt < 240 && !settled; attempt += 1) {
|
|
const judgement = scene.getDebugState().bondChainJudgement;
|
|
if (judgement?.presented && !judgement.completed) {
|
|
presented = clone(judgement);
|
|
break;
|
|
}
|
|
await new Promise((resolve) => window.setTimeout(resolve, 25));
|
|
}
|
|
await playback;
|
|
return {
|
|
presented,
|
|
completed: clone(scene.getDebugState().bondChainJudgement),
|
|
telemetry
|
|
};
|
|
} finally {
|
|
restoreMethod('animateCombatGauge', originalAnimateCombatGauge);
|
|
restoreMethod('showCombatImpact', originalShowCombatImpact);
|
|
restoreMethod('playCombatActionMotion', originalPlayCombatActionMotion);
|
|
}
|
|
};
|
|
|
|
const runScenario = async ({ mode, forcedRoll, present, naturalRoll = false, partnerClassKey, battleSpeed }) => {
|
|
const { attacker, partner, target, preview } = prepareScenario(mode, partnerClassKey);
|
|
if (battleSpeed) {
|
|
scene.battleSpeed = battleSpeed;
|
|
scene.fastForwardHeld = false;
|
|
scene.renderBattleSpeedControl();
|
|
}
|
|
setDebugForceHit(mode !== 'miss');
|
|
const rollCalls = [];
|
|
const chainRollCalls = [];
|
|
const originalBondChainRollSucceeded = scene.bondChainRollSucceeded;
|
|
const hadOwnBondChainRollSucceeded = Object.prototype.hasOwnProperty.call(scene, 'bondChainRollSucceeded');
|
|
scene.rollPercent = (rate) => {
|
|
rollCalls.push(rate);
|
|
return naturalRoll && rate === preview.bondChainRate;
|
|
};
|
|
scene.bondChainRollSucceeded = function (rate, forcedResult) {
|
|
const rollCountBefore = rollCalls.length;
|
|
const succeeded = originalBondChainRollSucceeded.call(this, rate, forcedResult);
|
|
chainRollCalls.push({
|
|
rate,
|
|
forcedResult: forcedResult ?? null,
|
|
rollPercentCalls: rollCalls.length - rollCountBefore,
|
|
succeeded
|
|
});
|
|
return succeeded;
|
|
};
|
|
const statsBefore = clone(scene.serializeBattleStats());
|
|
try {
|
|
const result = scene.resolveCombatAction(attacker, target, 'attack', false, undefined, forcedRoll);
|
|
const resolutionChainRollCalls = clone(chainRollCalls);
|
|
const statsAfter = clone(scene.serializeBattleStats());
|
|
const outcomeChip = clone(scene.combatOutcomeExtraChip(result));
|
|
const mapCueLabel = scene.bondChainMapCueLabel(result);
|
|
const neutralMapTexts = present ? await captureNeutralMapCue(result) : [];
|
|
let presentationDurationMs = null;
|
|
let judgement;
|
|
if (present) {
|
|
const presentationStartedAt = performance.now();
|
|
judgement = await presentCombatResult(result);
|
|
presentationDurationMs = performance.now() - presentationStartedAt;
|
|
} else {
|
|
judgement = {
|
|
presented: null,
|
|
completed: clone(scene.getDebugState().bondChainJudgement)
|
|
};
|
|
}
|
|
const formatted = scene.formatCombatResult(result);
|
|
const mapTitle = scene.combatMapDamageTitle(result);
|
|
const mapResultTexts = present ? captureMapResult(result) : [];
|
|
return {
|
|
mode,
|
|
attackerId: attacker.id,
|
|
partnerId: partner.id,
|
|
partnerName: partner.name,
|
|
partnerClassKey: partner.classKey,
|
|
battleSpeed: scene.battleSpeed,
|
|
presentationDurationMs,
|
|
targetId: target.id,
|
|
preview: {
|
|
damage: preview.damage,
|
|
chainRate: preview.bondChainRate,
|
|
chainDamage: preview.bondChainDamage,
|
|
chainPartnerId: preview.bondChainPartnerId ?? null
|
|
},
|
|
initialTargetHp: result.previousDefenderHp,
|
|
finalTargetHp: result.defender.hp,
|
|
hit: result.hit,
|
|
baseDefeated: result.baseDefeated,
|
|
defeated: result.defeated,
|
|
damage: result.damage,
|
|
attempt: clone(result.bondChainAttempt) ?? null,
|
|
chain: clone(result.bondChain) ?? null,
|
|
outcomeChip,
|
|
mapCueLabel,
|
|
neutralMapTexts,
|
|
formatted,
|
|
mapTitle,
|
|
mapResultTexts,
|
|
judgement,
|
|
chainRollCalls: resolutionChainRollCalls,
|
|
partnerStatsDelta: statsDelta(statsFor(statsBefore, partner.id), statsFor(statsAfter, partner.id)),
|
|
targetStatsDelta: statsDelta(statsFor(statsBefore, target.id), statsFor(statsAfter, target.id))
|
|
};
|
|
} finally {
|
|
if (hadOwnRollPercent) {
|
|
scene.rollPercent = originalRollPercent;
|
|
} else {
|
|
delete scene.rollPercent;
|
|
}
|
|
if (hadOwnBondChainRollSucceeded) {
|
|
scene.bondChainRollSucceeded = originalBondChainRollSucceeded;
|
|
} else {
|
|
delete scene.bondChainRollSucceeded;
|
|
}
|
|
}
|
|
};
|
|
|
|
const runProductionScenario = async (shouldDefeat) => {
|
|
const { attacker, target, preview } = prepareScenario('success');
|
|
if (!shouldDefeat) {
|
|
target.hp += Math.max(20, preview.bondChainDamage);
|
|
}
|
|
const targetView = scene.unitViews.get(target.id);
|
|
const targetMiniMapDot = scene.miniMapUnitDots.get(target.id);
|
|
if (!targetView) {
|
|
throw new Error(`Missing production finisher target view: ${target.id}`);
|
|
}
|
|
|
|
const viewState = () => ({
|
|
spriteVisible: targetView.sprite.visible,
|
|
spriteAlpha: targetView.sprite.alpha,
|
|
labelVisible: targetView.label.visible,
|
|
labelAlpha: targetView.label.alpha,
|
|
hitZoneVisible: targetView.hitZone.visible,
|
|
miniMapDotVisible: targetMiniMapDot?.visible ?? null
|
|
});
|
|
const originalForceBondChain = new URL(originalUrl).searchParams.get('debugForceBondChain');
|
|
const originalResolveCombatAction = scene.resolveCombatAction;
|
|
const originalPlayCombatCutIn = scene.playCombatCutIn;
|
|
const originalApplyDefeatedStyle = scene.applyDefeatedStyle;
|
|
const originalResolveCounterAttack = scene.resolveCounterAttack;
|
|
const originalCombatPresentationMode = scene.combatPresentationMode;
|
|
const methodOwnership = {
|
|
resolveCombatAction: Object.prototype.hasOwnProperty.call(scene, 'resolveCombatAction'),
|
|
playCombatCutIn: Object.prototype.hasOwnProperty.call(scene, 'playCombatCutIn'),
|
|
applyDefeatedStyle: Object.prototype.hasOwnProperty.call(scene, 'applyDefeatedStyle'),
|
|
resolveCounterAttack: Object.prototype.hasOwnProperty.call(scene, 'resolveCounterAttack')
|
|
};
|
|
let primaryResult = null;
|
|
let counterResult = null;
|
|
let visibleAfterResolve = null;
|
|
let visibleAtCutInStart = null;
|
|
let visibleAtCutInEnd = null;
|
|
let primaryCutInStarted = false;
|
|
let primaryCutInCompleted = false;
|
|
let counterCalls = 0;
|
|
let counterProduced = false;
|
|
const cutInOrder = [];
|
|
const defeatApplications = [];
|
|
|
|
const restoreMethod = (name, original) => {
|
|
if (methodOwnership[name]) {
|
|
scene[name] = original;
|
|
} else {
|
|
delete scene[name];
|
|
}
|
|
};
|
|
|
|
setDebugForceHit(true);
|
|
setDebugForceBondChain('1');
|
|
// This fixture verifies full-screen pursuit/counter ordering and defeated-style timing.
|
|
// Force the opt-in "always" mode now that ordinary counters use the compact map tier by default.
|
|
scene.combatPresentationMode = 'always';
|
|
scene.rollPercent = () => false;
|
|
scene.resolveCombatAction = function (...args) {
|
|
const result = originalResolveCombatAction.apply(this, args);
|
|
if (args[3] === true) {
|
|
counterResult = result;
|
|
} else {
|
|
primaryResult = result;
|
|
visibleAfterResolve = viewState();
|
|
}
|
|
return result;
|
|
};
|
|
scene.playCombatCutIn = async function (result) {
|
|
const isPrimary = result === primaryResult;
|
|
cutInOrder.push(isPrimary ? 'primary' : result === counterResult || result.isCounter ? 'counter' : 'other');
|
|
if (isPrimary) {
|
|
primaryCutInStarted = true;
|
|
visibleAtCutInStart = viewState();
|
|
}
|
|
await originalPlayCombatCutIn.call(this, result);
|
|
if (isPrimary) {
|
|
primaryCutInCompleted = true;
|
|
visibleAtCutInEnd = viewState();
|
|
}
|
|
};
|
|
scene.applyDefeatedStyle = function (unit, view) {
|
|
if (unit.id === target.id) {
|
|
defeatApplications.push({
|
|
primaryCutInStarted,
|
|
primaryCutInCompleted,
|
|
before: viewState()
|
|
});
|
|
}
|
|
return originalApplyDefeatedStyle.call(this, unit, view);
|
|
};
|
|
scene.resolveCounterAttack = function (result) {
|
|
counterCalls += 1;
|
|
const counter = originalResolveCounterAttack.call(this, result);
|
|
counterProduced = counterProduced || Boolean(counter);
|
|
return counter;
|
|
};
|
|
|
|
try {
|
|
scene.phase = 'targeting';
|
|
scene.selectedUnit = attacker;
|
|
await scene.tryResolveDamageTarget(attacker, target, 'attack');
|
|
return {
|
|
result: primaryResult
|
|
? {
|
|
hit: primaryResult.hit,
|
|
baseDefeated: primaryResult.baseDefeated,
|
|
defeated: primaryResult.defeated,
|
|
chainDefeated: primaryResult.bondChain?.defeated ?? false,
|
|
counter: Boolean(primaryResult.counter)
|
|
}
|
|
: null,
|
|
visibleAfterResolve,
|
|
visibleAtCutInStart,
|
|
visibleAtCutInEnd,
|
|
defeatApplications,
|
|
visibleAfterWrapper: viewState(),
|
|
counterCalls,
|
|
counterProduced,
|
|
cutInOrder
|
|
};
|
|
} finally {
|
|
restoreMethod('resolveCombatAction', originalResolveCombatAction);
|
|
restoreMethod('playCombatCutIn', originalPlayCombatCutIn);
|
|
restoreMethod('applyDefeatedStyle', originalApplyDefeatedStyle);
|
|
restoreMethod('resolveCounterAttack', originalResolveCounterAttack);
|
|
scene.combatPresentationMode = originalCombatPresentationMode;
|
|
if (hadOwnRollPercent) {
|
|
scene.rollPercent = originalRollPercent;
|
|
} else {
|
|
delete scene.rollPercent;
|
|
}
|
|
setDebugForceBondChain(originalForceBondChain);
|
|
}
|
|
};
|
|
|
|
let scenarios;
|
|
let restored;
|
|
try {
|
|
const productionFinisher = await runProductionScenario(true);
|
|
const productionSurvivor = await runProductionScenario(false);
|
|
const success = await runScenario({ mode: 'success', forcedRoll: true, present: true });
|
|
const failure = await runScenario({ mode: 'failure', forcedRoll: false, present: true });
|
|
const rangedNormal = await runScenario({
|
|
mode: 'success',
|
|
forcedRoll: true,
|
|
present: true,
|
|
partnerClassKey: 'archer',
|
|
battleSpeed: 'normal'
|
|
});
|
|
const rangedFast = await runScenario({
|
|
mode: 'success',
|
|
forcedRoll: true,
|
|
present: true,
|
|
partnerClassKey: 'strategist',
|
|
battleSpeed: 'fast'
|
|
});
|
|
const rangedVeryFast = await runScenario({
|
|
mode: 'success',
|
|
forcedRoll: true,
|
|
present: true,
|
|
partnerClassKey: 'strategist',
|
|
battleSpeed: 'very-fast'
|
|
});
|
|
const natural = await runScenario({ mode: 'success', forcedRoll: undefined, present: false, naturalRoll: true });
|
|
const naturalFailure = await runScenario({ mode: 'failure', forcedRoll: undefined, present: false });
|
|
const miss = await runScenario({ mode: 'miss', forcedRoll: true, present: false });
|
|
const lethal = await runScenario({ mode: 'lethal', forcedRoll: true, present: false });
|
|
scenarios = {
|
|
productionFinisher,
|
|
productionSurvivor,
|
|
success,
|
|
failure,
|
|
rangedNormal,
|
|
rangedFast,
|
|
rangedVeryFast,
|
|
natural,
|
|
naturalFailure,
|
|
miss,
|
|
lethal
|
|
};
|
|
} finally {
|
|
// Let the final lethal scenario's map popup and delayed defeat styling settle
|
|
// before restoring the live battle. Otherwise those callbacks can fire after
|
|
// restoration and hide the revived fixture target in the following RC flow.
|
|
await new Promise((resolve) => window.setTimeout(resolve, 900));
|
|
restoreFixtureState();
|
|
window.history.replaceState({}, '', originalUrl);
|
|
const restoredState = scene.getDebugState();
|
|
restored = {
|
|
save: comparableSave(clone(scene.createBattleSaveState())),
|
|
camera: { x: scene.cameraTileX, y: scene.cameraTileY },
|
|
phase: restoredState.phase,
|
|
selectedUnitId: restoredState.selectedUnitId,
|
|
pendingMove: restoredState.pendingMove,
|
|
selectedUsable: restoredState.selectedUsable,
|
|
commandMenuVisible: restoredState.commandMenuVisible,
|
|
markerCount: restoredState.markerCount,
|
|
judgement: clone(restoredState.bondChainJudgement),
|
|
partnerClassKey: scene.debugUnitById(partnerId)?.classKey ?? null,
|
|
battleSpeed: scene.battleSpeed,
|
|
fastForwardHeld: scene.fastForwardHeld,
|
|
combatObjectCount: scene.combatCutInObjects.length,
|
|
debugForceHit: new URLSearchParams(window.location.search).get('debugForceHit')
|
|
};
|
|
}
|
|
|
|
return {
|
|
ready: true,
|
|
sceneBounds: { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height },
|
|
expectedRate: mechanics.chain?.rate ?? null,
|
|
scenarios,
|
|
restored,
|
|
expectedRestored: {
|
|
save: comparableSave(originalSave),
|
|
camera: originalCamera,
|
|
phase: stateBefore.phase,
|
|
selectedUnitId: stateBefore.selectedUnitId,
|
|
pendingMove: stateBefore.pendingMove,
|
|
selectedUsable: stateBefore.selectedUsable,
|
|
commandMenuVisible: stateBefore.commandMenuVisible,
|
|
markerCount: stateBefore.markerCount,
|
|
judgement: originalJudgement ?? null,
|
|
partnerClassKey: originalPartnerClassKey ?? null,
|
|
battleSpeed: originalBattleSpeed,
|
|
fastForwardHeld: originalFastForwardHeld,
|
|
combatObjectCount: 0,
|
|
debugForceHit: new URL(originalUrl).searchParams.get('debugForceHit')
|
|
}
|
|
};
|
|
});
|
|
|
|
assert(
|
|
firstBattleBondChainJudgement.ready === true && firstBattleBondChainJudgement.expectedRate === 18,
|
|
`Expected a deterministic level-72 resonance judgement fixture: ${JSON.stringify(firstBattleBondChainJudgement)}`
|
|
);
|
|
const productionFinisher = firstBattleBondChainJudgement.scenarios.productionFinisher;
|
|
const productionViewVisible = (view) =>
|
|
view?.spriteVisible === true && view.spriteAlpha > 0.9 && view.labelVisible === true && view.labelAlpha > 0.9;
|
|
assert(
|
|
productionFinisher.result?.hit === true &&
|
|
productionFinisher.result.baseDefeated === false &&
|
|
productionFinisher.result.defeated === true &&
|
|
productionFinisher.result.chainDefeated === true &&
|
|
productionViewVisible(productionFinisher.visibleAfterResolve) &&
|
|
productionViewVisible(productionFinisher.visibleAtCutInStart) &&
|
|
productionViewVisible(productionFinisher.visibleAtCutInEnd) &&
|
|
productionFinisher.visibleAfterResolve.miniMapDotVisible === true &&
|
|
productionFinisher.visibleAtCutInStart.miniMapDotVisible === true &&
|
|
productionFinisher.visibleAtCutInEnd.miniMapDotVisible === true &&
|
|
productionFinisher.defeatApplications.length >= 1 &&
|
|
productionFinisher.defeatApplications.every((application) =>
|
|
application.primaryCutInStarted === true && application.primaryCutInCompleted === true
|
|
) &&
|
|
productionViewVisible(productionFinisher.defeatApplications[0].before) &&
|
|
productionFinisher.visibleAfterWrapper.spriteVisible === false &&
|
|
productionFinisher.visibleAfterWrapper.spriteAlpha === 0 &&
|
|
productionFinisher.visibleAfterWrapper.labelVisible === false &&
|
|
productionFinisher.visibleAfterWrapper.miniMapDotVisible === false &&
|
|
productionFinisher.counterCalls === 1 &&
|
|
productionFinisher.counterProduced === false &&
|
|
productionFinisher.result.counter === false &&
|
|
JSON.stringify(productionFinisher.cutInOrder) === JSON.stringify(['primary']),
|
|
`Expected the production attack path to keep a pursuit-defeated target visible through the finisher, then hide it and suppress counterattack: ${JSON.stringify(productionFinisher)}`
|
|
);
|
|
const productionSurvivor = firstBattleBondChainJudgement.scenarios.productionSurvivor;
|
|
assert(
|
|
productionSurvivor.result?.hit === true &&
|
|
productionSurvivor.result.baseDefeated === false &&
|
|
productionSurvivor.result.defeated === false &&
|
|
productionSurvivor.result.chainDefeated === false &&
|
|
productionSurvivor.result.counter === true &&
|
|
productionViewVisible(productionSurvivor.visibleAfterResolve) &&
|
|
productionViewVisible(productionSurvivor.visibleAtCutInStart) &&
|
|
productionViewVisible(productionSurvivor.visibleAtCutInEnd) &&
|
|
productionSurvivor.defeatApplications.length === 0 &&
|
|
productionViewVisible(productionSurvivor.visibleAfterWrapper) &&
|
|
productionSurvivor.counterCalls === 1 &&
|
|
productionSurvivor.counterProduced === true &&
|
|
JSON.stringify(productionSurvivor.cutInOrder) === JSON.stringify(['primary', 'counter']),
|
|
`Expected the production nonlethal pursuit path to preserve the defender and continue into its counterattack cut-in: ${JSON.stringify(productionSurvivor)}`
|
|
);
|
|
const judgementSuccess = firstBattleBondChainJudgement.scenarios.success;
|
|
const successAttempt = judgementSuccess.attempt;
|
|
const successChain = judgementSuccess.chain;
|
|
const successPresented = judgementSuccess.judgement.presented;
|
|
const successCompleted = judgementSuccess.judgement.completed;
|
|
const expectedSuccessMotion = judgementSuccess.partnerClassKey === 'archer' ? 'ranged' : 'melee';
|
|
const neutralSuccessCue = [judgementSuccess.outcomeChip?.label, judgementSuccess.mapCueLabel, ...judgementSuccess.neutralMapTexts]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
assert(
|
|
judgementSuccess.hit === true &&
|
|
judgementSuccess.baseDefeated === false &&
|
|
judgementSuccess.defeated === true &&
|
|
successAttempt?.succeeded === true &&
|
|
successAttempt.partnerId === judgementSuccess.partnerId &&
|
|
successAttempt.partnerName === judgementSuccess.partnerName &&
|
|
successAttempt.rate === 18 &&
|
|
successAttempt.expectedDamage === judgementSuccess.preview.chainDamage &&
|
|
successChain?.partnerId === successAttempt.partnerId &&
|
|
successChain.partnerName === successAttempt.partnerName &&
|
|
successChain.rate === successAttempt.rate &&
|
|
successChain.damage === successAttempt.expectedDamage &&
|
|
successChain.previousDefenderHp === successChain.damage &&
|
|
successChain.defeated === true &&
|
|
judgementSuccess.initialTargetHp === judgementSuccess.damage + successChain.damage &&
|
|
judgementSuccess.finalTargetHp === 0,
|
|
`Expected forced pursuit success to preserve the attempt, full supporter damage, and follow-up defeat contract: ${JSON.stringify(judgementSuccess)}`
|
|
);
|
|
assert(
|
|
judgementSuccess.partnerStatsDelta.damageDealt === successChain.damage &&
|
|
judgementSuccess.partnerStatsDelta.defeats === 1 &&
|
|
judgementSuccess.partnerStatsDelta.actions === 0 &&
|
|
judgementSuccess.targetStatsDelta.damageTaken === judgementSuccess.damage + successChain.damage,
|
|
`Expected successful pursuit damage and defeat credit to belong only to the supporter while the defender receives both hits: ${JSON.stringify(judgementSuccess)}`
|
|
);
|
|
assert(
|
|
judgementSuccess.outcomeChip?.label === '판정 18%' &&
|
|
judgementSuccess.mapCueLabel === `공명 판정 · ${judgementSuccess.partnerName} 18%` &&
|
|
judgementSuccess.neutralMapTexts.some((text) => text.includes(judgementSuccess.mapCueLabel)) &&
|
|
!neutralSuccessCue.includes('추격') &&
|
|
!neutralSuccessCue.includes('격파') &&
|
|
!neutralSuccessCue.includes('호흡 일치') &&
|
|
!neutralSuccessCue.includes('호흡 불발'),
|
|
`Expected successful pursuit to remain a neutral percentage judgement in the outcome chip and pre-contact map cue: ${JSON.stringify(judgementSuccess)}`
|
|
);
|
|
assert(
|
|
successPresented?.status === 'success' &&
|
|
successPresented.partnerId === judgementSuccess.partnerId &&
|
|
successPresented.partnerName === judgementSuccess.partnerName &&
|
|
successPresented.rate === 18 &&
|
|
successPresented.expectedDamage === successChain.damage &&
|
|
successPresented.damage === successChain.damage &&
|
|
successPresented.defeated === true &&
|
|
successPresented.presented === true &&
|
|
successPresented.completed === false &&
|
|
successPresented.motion === expectedSuccessMotion &&
|
|
successPresented.title === '공명 판정 18%' &&
|
|
successPresented.detail === `${judgementSuccess.partnerName} · 호흡 일치` &&
|
|
isFiniteBounds(successPresented.bounds) &&
|
|
boundsInside(successPresented.bounds, firstBattleBondChainJudgement.sceneBounds),
|
|
`Expected the successful judgement to expose the bounded percentage verdict before revealing pursuit contact: ${JSON.stringify(judgementSuccess.judgement)}`
|
|
);
|
|
assert(
|
|
successCompleted?.status === 'success' &&
|
|
successCompleted.presented === true &&
|
|
successCompleted.completed === true &&
|
|
successCompleted.motion === expectedSuccessMotion &&
|
|
successCompleted.damage === successChain.damage &&
|
|
successCompleted.defeated === true &&
|
|
successCompleted.title.includes('공명 격파') &&
|
|
isFiniteBounds(successCompleted.bounds) &&
|
|
boundsInside(successCompleted.bounds, firstBattleBondChainJudgement.sceneBounds) &&
|
|
judgementSuccess.formatted.includes(`공명 판정 · ${judgementSuccess.partnerName} 18% · 호흡 일치`) &&
|
|
judgementSuccess.formatted.includes(`추격 피해 +${successChain.damage} · 공명 격파`) &&
|
|
judgementSuccess.mapTitle.includes('공명 격파') &&
|
|
judgementSuccess.mapResultTexts.some((text) => text.includes('공명 격파')),
|
|
`Expected successful pursuit contact to retain its completed debug snapshot and explicit finisher result feedback: ${JSON.stringify(judgementSuccess)}`
|
|
);
|
|
|
|
const rangedScenarios = [
|
|
firstBattleBondChainJudgement.scenarios.rangedNormal,
|
|
firstBattleBondChainJudgement.scenarios.rangedFast,
|
|
firstBattleBondChainJudgement.scenarios.rangedVeryFast
|
|
];
|
|
const rangedTelemetryIsSynchronized = (scenario) => {
|
|
const gauge = scenario.judgement.telemetry?.gauge?.[0];
|
|
const impact = scenario.judgement.telemetry?.impact?.[0];
|
|
const motion = scenario.judgement.telemetry?.partnerMotion?.[0];
|
|
return scenario.judgement.telemetry?.gauge?.length === 1 &&
|
|
scenario.judgement.telemetry?.impact?.length === 1 &&
|
|
scenario.judgement.telemetry?.partnerMotion?.length === 1 &&
|
|
gauge.duration === 300 &&
|
|
Math.abs(gauge.startScale - gauge.from) < 0.001 &&
|
|
Math.abs(gauge.endScale - gauge.to) < 0.001 &&
|
|
gauge.endAt > gauge.startAt &&
|
|
impact.damage === scenario.chain.damage &&
|
|
Math.abs(impact.at - gauge.startAt) < 80 &&
|
|
motion.startAt <= gauge.startAt &&
|
|
motion.endAt + 80 >= gauge.startAt;
|
|
};
|
|
const rangedGaugeDurations = rangedScenarios.map((scenario) => {
|
|
const gauge = scenario.judgement.telemetry.gauge[0];
|
|
return gauge.endAt - gauge.startAt;
|
|
});
|
|
assert(
|
|
rangedScenarios.map((scenario) => scenario.battleSpeed).join(',') === 'normal,fast,very-fast' &&
|
|
rangedScenarios[0].partnerClassKey === 'archer' &&
|
|
rangedScenarios[1].partnerClassKey === 'strategist' &&
|
|
rangedScenarios[2].partnerClassKey === 'strategist' &&
|
|
rangedScenarios.every((scenario) =>
|
|
scenario.attempt?.succeeded === true &&
|
|
scenario.judgement.presented?.status === 'success' &&
|
|
scenario.judgement.presented.motion === 'ranged' &&
|
|
scenario.judgement.presented.title === '공명 판정 18%' &&
|
|
scenario.judgement.completed?.completed === true &&
|
|
scenario.judgement.completed.motion === 'ranged' &&
|
|
rangedTelemetryIsSynchronized(scenario) &&
|
|
isFiniteBounds(scenario.judgement.completed.bounds) &&
|
|
boundsInside(scenario.judgement.completed.bounds, firstBattleBondChainJudgement.sceneBounds) &&
|
|
Number.isFinite(scenario.presentationDurationMs) &&
|
|
scenario.presentationDurationMs > 0
|
|
) &&
|
|
rangedScenarios[0].presentationDurationMs > rangedScenarios[1].presentationDurationMs &&
|
|
rangedScenarios[1].presentationDurationMs > rangedScenarios[2].presentationDurationMs &&
|
|
rangedGaugeDurations[0] > rangedGaugeDurations[1] &&
|
|
rangedGaugeDurations[1] > rangedGaugeDurations[2],
|
|
`Expected archer and extended-range pursuit motions to complete in all three battle speeds with ordered scaled timing: ${JSON.stringify(rangedScenarios)}`
|
|
);
|
|
|
|
const judgementNatural = firstBattleBondChainJudgement.scenarios.natural;
|
|
assert(
|
|
judgementNatural.hit === true &&
|
|
judgementNatural.baseDefeated === false &&
|
|
judgementNatural.attempt?.succeeded === true &&
|
|
judgementNatural.chain?.damage === judgementNatural.preview.chainDamage &&
|
|
judgementNatural.chainRollCalls.length === 1 &&
|
|
judgementNatural.chainRollCalls[0].rate === judgementNatural.preview.chainRate &&
|
|
judgementNatural.chainRollCalls[0].forcedResult === null &&
|
|
judgementNatural.chainRollCalls[0].rollPercentCalls === 1 &&
|
|
judgementNatural.chainRollCalls[0].succeeded === true,
|
|
`Expected the natural pursuit path to make exactly one unforced percentage roll after the base hit: ${JSON.stringify(judgementNatural)}`
|
|
);
|
|
const judgementNaturalFailure = firstBattleBondChainJudgement.scenarios.naturalFailure;
|
|
assert(
|
|
judgementNaturalFailure.hit === true &&
|
|
judgementNaturalFailure.baseDefeated === false &&
|
|
judgementNaturalFailure.attempt?.succeeded === false &&
|
|
judgementNaturalFailure.chain === null &&
|
|
judgementNaturalFailure.chainRollCalls.length === 1 &&
|
|
judgementNaturalFailure.chainRollCalls[0].rate === judgementNaturalFailure.preview.chainRate &&
|
|
judgementNaturalFailure.chainRollCalls[0].forcedResult === null &&
|
|
judgementNaturalFailure.chainRollCalls[0].rollPercentCalls === 1 &&
|
|
judgementNaturalFailure.chainRollCalls[0].succeeded === false,
|
|
`Expected the natural failed pursuit path to consume one unforced roll without applying follow-up damage: ${JSON.stringify(judgementNaturalFailure)}`
|
|
);
|
|
|
|
const judgementFailure = firstBattleBondChainJudgement.scenarios.failure;
|
|
const failureAttempt = judgementFailure.attempt;
|
|
const failurePresented = judgementFailure.judgement.presented;
|
|
const failureCompleted = judgementFailure.judgement.completed;
|
|
const expectedFailureMotion = judgementFailure.partnerClassKey === 'archer' ? 'ranged' : 'melee';
|
|
const failureLine = `공명 판정 · ${judgementFailure.partnerName} 18% · 호흡 불발`;
|
|
const neutralFailureCue = [judgementFailure.outcomeChip?.label, judgementFailure.mapCueLabel, ...judgementFailure.neutralMapTexts]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
assert(
|
|
judgementFailure.hit === true &&
|
|
judgementFailure.baseDefeated === false &&
|
|
judgementFailure.defeated === false &&
|
|
failureAttempt?.succeeded === false &&
|
|
failureAttempt.partnerId === judgementFailure.partnerId &&
|
|
failureAttempt.partnerName === judgementFailure.partnerName &&
|
|
failureAttempt.rate === 18 &&
|
|
failureAttempt.expectedDamage === judgementFailure.preview.chainDamage &&
|
|
judgementFailure.chain === null &&
|
|
judgementFailure.finalTargetHp === judgementFailure.initialTargetHp - judgementFailure.damage,
|
|
`Expected forced pursuit failure to retain the attempted partner and rate without applying follow-up damage: ${JSON.stringify(judgementFailure)}`
|
|
);
|
|
assert(
|
|
judgementFailure.partnerStatsDelta.damageDealt === 0 &&
|
|
judgementFailure.partnerStatsDelta.defeats === 0 &&
|
|
judgementFailure.partnerStatsDelta.actions === 0 &&
|
|
judgementFailure.targetStatsDelta.damageTaken === judgementFailure.damage,
|
|
`Expected failed pursuit to leave supporter and defender pursuit statistics untouched: ${JSON.stringify(judgementFailure)}`
|
|
);
|
|
assert(
|
|
judgementFailure.outcomeChip?.label === '판정 18%' &&
|
|
judgementFailure.mapCueLabel === `공명 판정 · ${judgementFailure.partnerName} 18%` &&
|
|
judgementFailure.neutralMapTexts.some((text) => text.includes(judgementFailure.mapCueLabel)) &&
|
|
!neutralFailureCue.includes('추격') &&
|
|
!neutralFailureCue.includes('격파') &&
|
|
!neutralFailureCue.includes('호흡 불발') &&
|
|
judgementFailure.formatted.includes(failureLine) &&
|
|
judgementFailure.mapResultTexts.some((text) => text.includes(failureLine)),
|
|
`Expected failed pursuit to keep the pre-contact cue neutral and then name the partner, rate, and exact failure reason: ${JSON.stringify(judgementFailure)}`
|
|
);
|
|
assert(
|
|
failurePresented?.status === 'failed' &&
|
|
failurePresented.partnerId === judgementFailure.partnerId &&
|
|
failurePresented.partnerName === judgementFailure.partnerName &&
|
|
failurePresented.rate === 18 &&
|
|
failurePresented.expectedDamage === judgementFailure.preview.chainDamage &&
|
|
failurePresented.damage === 0 &&
|
|
failurePresented.defeated === false &&
|
|
failurePresented.presented === true &&
|
|
failurePresented.completed === false &&
|
|
failurePresented.motion === expectedFailureMotion &&
|
|
`${failurePresented.title} ${failurePresented.detail}`.includes('호흡 불발') &&
|
|
isFiniteBounds(failurePresented.bounds) &&
|
|
boundsInside(failurePresented.bounds, firstBattleBondChainJudgement.sceneBounds),
|
|
`Expected the failed judgement to expose one bounded in-progress non-damaging presentation: ${JSON.stringify(judgementFailure.judgement)}`
|
|
);
|
|
assert(
|
|
failureCompleted?.status === 'failed' &&
|
|
failureCompleted.presented === true &&
|
|
failureCompleted.completed === true &&
|
|
failureCompleted.motion === expectedFailureMotion &&
|
|
failureCompleted.damage === 0 &&
|
|
failureCompleted.defeated === false &&
|
|
`${failureCompleted.title} ${failureCompleted.detail}`.includes('호흡 불발') &&
|
|
isFiniteBounds(failureCompleted.bounds) &&
|
|
boundsInside(failureCompleted.bounds, firstBattleBondChainJudgement.sceneBounds),
|
|
`Expected failed pursuit presentation to complete once while retaining its judgement payload: ${JSON.stringify(judgementFailure.judgement)}`
|
|
);
|
|
|
|
const judgementMiss = firstBattleBondChainJudgement.scenarios.miss;
|
|
const judgementLethal = firstBattleBondChainJudgement.scenarios.lethal;
|
|
assert(
|
|
judgementMiss.hit === false &&
|
|
judgementMiss.damage === 0 &&
|
|
judgementMiss.attempt === null &&
|
|
judgementMiss.chain === null &&
|
|
judgementMiss.finalTargetHp === judgementMiss.initialTargetHp &&
|
|
judgementMiss.judgement.completed === null,
|
|
`Expected a missed base attack to skip resonance judgement entirely: ${JSON.stringify(judgementMiss)}`
|
|
);
|
|
assert(
|
|
judgementLethal.hit === true &&
|
|
judgementLethal.baseDefeated === true &&
|
|
judgementLethal.defeated === true &&
|
|
judgementLethal.attempt === null &&
|
|
judgementLethal.chain === null &&
|
|
judgementLethal.finalTargetHp === 0 &&
|
|
judgementLethal.judgement.completed === null,
|
|
`Expected a lethal base attack to skip resonance judgement entirely: ${JSON.stringify(judgementLethal)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(firstBattleBondChainJudgement.restored, firstBattleBondChainJudgement.expectedRestored),
|
|
`Expected the judgement fixture to restore the full saveable battle state, camera, URL flags, selection, markers, cut-in objects, and prior debug snapshot: ${JSON.stringify(firstBattleBondChainJudgement)}`
|
|
);
|
|
|
|
const firstBattleCombatCutInStage = await page.evaluate(async () => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const stateBefore = scene?.getDebugState?.();
|
|
const mechanics = stateBefore?.combatMechanicsProbe;
|
|
const attackerId = mechanics?.attackerId;
|
|
const targetId = mechanics?.enemyId;
|
|
if (
|
|
!scene ||
|
|
!attackerId ||
|
|
!targetId ||
|
|
stateBefore?.phase !== 'idle' ||
|
|
stateBefore.selectedUnitId !== null ||
|
|
stateBefore.commandMenuVisible !== false
|
|
) {
|
|
return {
|
|
ready: false,
|
|
reason: 'first battle was not in a clean idle state for the map-backed combat cut-in fixture',
|
|
stateBefore,
|
|
mechanics
|
|
};
|
|
}
|
|
|
|
const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
const comparableSave = (state) => {
|
|
const { savedAt: _savedAt, ...comparable } = state;
|
|
return comparable;
|
|
};
|
|
const attacker = scene.debugUnitById(attackerId);
|
|
const target = scene.debugUnitById(targetId);
|
|
if (!attacker || !target) {
|
|
return {
|
|
ready: false,
|
|
reason: 'map-backed combat cut-in fixture units were missing',
|
|
attackerId,
|
|
targetId
|
|
};
|
|
}
|
|
|
|
const originalUrl = window.location.href;
|
|
const originalSave = clone(scene.createBattleSaveState());
|
|
const originalCamera = { x: scene.cameraTileX, y: scene.cameraTileY };
|
|
const originalPhase = scene.phase;
|
|
const originalSelectedUnit = scene.selectedUnit;
|
|
const originalPendingMove = scene.pendingMove;
|
|
const originalTargetingAction = scene.targetingAction;
|
|
const originalSelectedUsable = scene.selectedUsable;
|
|
const originalLockedTargetPreview = scene.lockedTargetPreview;
|
|
const originalBattleSpeed = scene.battleSpeed;
|
|
const originalFastForwardHeld = scene.fastForwardHeld;
|
|
const originalJudgement = clone(scene.bondChainJudgementLast);
|
|
const originalCutInActive = clone(scene.combatCutInStageActive);
|
|
const originalCutInLast = clone(scene.combatCutInStageLast);
|
|
const originalDelay = scene.delay;
|
|
const hadOwnDelay = Object.prototype.hasOwnProperty.call(scene, 'delay');
|
|
const expectedRestored = {
|
|
save: comparableSave(clone(originalSave)),
|
|
camera: { ...originalCamera },
|
|
phase: stateBefore.phase,
|
|
selectedUnitId: stateBefore.selectedUnitId,
|
|
pendingMove: clone(stateBefore.pendingMove),
|
|
selectedUsable: stateBefore.selectedUsable,
|
|
commandMenuVisible: stateBefore.commandMenuVisible,
|
|
markerCount: stateBefore.markerCount,
|
|
combatCutIn: clone(stateBefore.combatCutIn)
|
|
};
|
|
|
|
attacker.x = 4;
|
|
attacker.y = 15;
|
|
target.x = 5;
|
|
target.y = 15;
|
|
target.maxHp = Math.max(target.maxHp, 500);
|
|
target.hp = target.maxHp;
|
|
scene.attackIntents = [];
|
|
scene.actedUnitIds = new Set();
|
|
scene.centerCameraOnTile(target.x, target.y);
|
|
scene.positionUnitView(attacker);
|
|
scene.positionUnitView(target);
|
|
|
|
const fixtureUrl = new URL(window.location.href);
|
|
fixtureUrl.searchParams.set('debugForceHit', '1');
|
|
window.history.replaceState({}, '', fixtureUrl);
|
|
const result = scene.resolveCombatAction(attacker, target, 'attack', false, undefined, false);
|
|
|
|
let releaseDelay;
|
|
let gateCalls = 0;
|
|
const delayGate = new Promise((resolve) => {
|
|
releaseDelay = resolve;
|
|
});
|
|
scene.battleSpeed = 'very-fast';
|
|
scene.fastForwardHeld = false;
|
|
scene.delay = () => {
|
|
gateCalls += 1;
|
|
return delayGate;
|
|
};
|
|
|
|
const playback = scene.playCombatCutIn(result).then(
|
|
() => ({ ok: true, error: null }),
|
|
(error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
);
|
|
for (let attempt = 0; attempt < 10 && gateCalls === 0; attempt += 1) {
|
|
await Promise.resolve();
|
|
}
|
|
|
|
const active = clone(scene.getDebugState().combatCutIn);
|
|
const frame = active?.current?.textureKey
|
|
? scene.textures.getFrame(active.current.textureKey)
|
|
: null;
|
|
const textureFrameBounds = frame
|
|
? { x: 0, y: 0, width: frame.realWidth, height: frame.realHeight }
|
|
: null;
|
|
const preview = scene.combatPreview(attacker, target, 'attack');
|
|
const sceneBounds = { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height };
|
|
|
|
const restoreFixtureState = () => {
|
|
scene.hideCombatCutIn();
|
|
scene.applyBattleSaveState(clone(originalSave));
|
|
scene.cameraTileX = originalCamera.x;
|
|
scene.cameraTileY = originalCamera.y;
|
|
scene.updateCameraView();
|
|
scene.phase = originalPhase;
|
|
scene.selectedUnit = originalSelectedUnit;
|
|
scene.pendingMove = originalPendingMove;
|
|
scene.targetingAction = originalTargetingAction;
|
|
scene.selectedUsable = originalSelectedUsable;
|
|
scene.lockedTargetPreview = originalLockedTargetPreview;
|
|
scene.bondChainJudgementLast = clone(originalJudgement);
|
|
scene.combatCutInStageActive = clone(originalCutInActive);
|
|
scene.combatCutInStageLast = clone(originalCutInLast);
|
|
scene.battleSpeed = originalBattleSpeed;
|
|
scene.fastForwardHeld = originalFastForwardHeld;
|
|
scene.renderBattleSpeedControl();
|
|
scene.clearMarkers();
|
|
scene.hideCommandMenu();
|
|
window.history.replaceState({}, '', originalUrl);
|
|
|
|
const restoredState = scene.getDebugState();
|
|
return {
|
|
save: comparableSave(clone(scene.createBattleSaveState())),
|
|
camera: { x: scene.cameraTileX, y: scene.cameraTileY },
|
|
phase: restoredState.phase,
|
|
selectedUnitId: restoredState.selectedUnitId,
|
|
pendingMove: clone(restoredState.pendingMove),
|
|
selectedUsable: restoredState.selectedUsable,
|
|
commandMenuVisible: restoredState.commandMenuVisible,
|
|
markerCount: restoredState.markerCount,
|
|
combatCutIn: clone(restoredState.combatCutIn)
|
|
};
|
|
};
|
|
|
|
window.__RC_COMBAT_CUTIN_STAGE_FIXTURE__ = {
|
|
scene,
|
|
playback,
|
|
releaseDelay,
|
|
originalDelay,
|
|
hadOwnDelay,
|
|
restoreFixtureState
|
|
};
|
|
|
|
return {
|
|
ready: true,
|
|
active,
|
|
gateCalls,
|
|
attackerId,
|
|
targetId,
|
|
previewTerrainLabel: preview.terrainLabel,
|
|
sceneBounds,
|
|
mapTextureKey: stateBefore.mapTextureKey,
|
|
textureFrameBounds,
|
|
expectedRestored
|
|
};
|
|
});
|
|
assert(
|
|
firstBattleCombatCutInStage.ready === true,
|
|
`Expected a ready first-battle map-backed combat cut-in fixture: ${JSON.stringify(firstBattleCombatCutInStage)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-combat-cutin-map-stage.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first battle map-backed combat cut-in stage');
|
|
|
|
const firstBattleCombatCutInSettled = await page.evaluate(async () => {
|
|
const fixture = window.__RC_COMBAT_CUTIN_STAGE_FIXTURE__;
|
|
if (!fixture) {
|
|
return { ready: false, reason: 'map-backed combat cut-in fixture handle was missing' };
|
|
}
|
|
const { scene } = fixture;
|
|
if (fixture.hadOwnDelay) {
|
|
scene.delay = fixture.originalDelay;
|
|
} else {
|
|
delete scene.delay;
|
|
}
|
|
fixture.releaseDelay();
|
|
const playback = await Promise.race([
|
|
fixture.playback,
|
|
new Promise((resolve) => window.setTimeout(
|
|
() => resolve({ ok: false, error: 'map-backed combat cut-in playback timed out' }),
|
|
15000
|
|
))
|
|
]);
|
|
const settled = JSON.parse(JSON.stringify(scene.getDebugState().combatCutIn));
|
|
const restored = fixture.restoreFixtureState();
|
|
delete window.__RC_COMBAT_CUTIN_STAGE_FIXTURE__;
|
|
return { ready: true, playback, settled, restored };
|
|
});
|
|
|
|
const activeCombatCutIn = firstBattleCombatCutInStage.active;
|
|
const combatCutInStage = activeCombatCutIn?.current;
|
|
const combatCutInCrop = combatCutInStage?.sourceCropPx;
|
|
const combatCutInTexture = combatCutInStage?.textureBoundsPx;
|
|
const combatCutInStageAspect = combatCutInStage?.stageBounds?.width / combatCutInStage?.stageBounds?.height;
|
|
const combatCutInCropAspect = combatCutInCrop?.width / combatCutInCrop?.height;
|
|
assert(
|
|
activeCombatCutIn?.active === true &&
|
|
activeCombatCutIn.objectCount > 0 &&
|
|
firstBattleCombatCutInStage.gateCalls > 0 &&
|
|
combatCutInStage?.mode === 'attack' &&
|
|
combatCutInStage.mapBacked === true &&
|
|
combatCutInStage.textureKey === firstBattleCombatCutInStage.mapTextureKey,
|
|
`Expected a gated real attack playback to expose one active map-backed cut-in stage: ${JSON.stringify(firstBattleCombatCutInStage)}`
|
|
);
|
|
assert(
|
|
isFiniteBounds(combatCutInTexture) &&
|
|
sameJsonValue(combatCutInTexture, firstBattleCombatCutInStage.textureFrameBounds) &&
|
|
isFiniteBounds(combatCutInCrop) &&
|
|
combatCutInCrop.x >= 0 &&
|
|
combatCutInCrop.y >= 0 &&
|
|
combatCutInCrop.x + combatCutInCrop.width <= combatCutInTexture.width + 0.01 &&
|
|
combatCutInCrop.y + combatCutInCrop.height <= combatCutInTexture.height + 0.01,
|
|
`Expected the cut-in source crop to remain inside the loaded battle-map texture: ${JSON.stringify(combatCutInStage)}`
|
|
);
|
|
assert(
|
|
isFiniteBounds(combatCutInStage.panelBounds) &&
|
|
isFiniteBounds(combatCutInStage.stageBounds) &&
|
|
boundsInside(combatCutInStage.panelBounds, firstBattleCombatCutInStage.sceneBounds) &&
|
|
boundsInside(combatCutInStage.stageBounds, combatCutInStage.panelBounds) &&
|
|
Math.abs(combatCutInStageAspect - combatCutInCropAspect) < 0.001,
|
|
`Expected panel, stage, and source crop to share a bounded HD composition without aspect distortion: ${JSON.stringify(combatCutInStage)}`
|
|
);
|
|
assert(
|
|
combatCutInStage.actor?.id === firstBattleCombatCutInStage.attackerId &&
|
|
combatCutInStage.actor.faction === 'ally' &&
|
|
combatCutInStage.actor.tile.x === 4 &&
|
|
combatCutInStage.actor.tile.y === 15 &&
|
|
typeof combatCutInStage.actor.terrain === 'string' &&
|
|
combatCutInStage.actor.terrain.length > 0 &&
|
|
combatCutInStage.target?.id === firstBattleCombatCutInStage.targetId &&
|
|
combatCutInStage.target.faction === 'enemy' &&
|
|
combatCutInStage.target.tile.x === 5 &&
|
|
combatCutInStage.target.tile.y === 15 &&
|
|
typeof combatCutInStage.target.terrain === 'string' &&
|
|
combatCutInStage.target.terrain.length > 0 &&
|
|
typeof firstBattleCombatCutInStage.previewTerrainLabel === 'string' &&
|
|
firstBattleCombatCutInStage.previewTerrainLabel.length > 0 &&
|
|
combatCutInStage.focalTile.x === 4.5 &&
|
|
combatCutInStage.focalTile.y === 15,
|
|
`Expected the cut-in crop contract to retain actor, target, terrain, and midpoint focus semantics: ${JSON.stringify(firstBattleCombatCutInStage)}`
|
|
);
|
|
assert(
|
|
firstBattleCombatCutInSettled.ready === true &&
|
|
firstBattleCombatCutInSettled.playback?.ok === true &&
|
|
firstBattleCombatCutInSettled.settled?.active === false &&
|
|
firstBattleCombatCutInSettled.settled.objectCount === 0 &&
|
|
firstBattleCombatCutInSettled.settled.current === null &&
|
|
sameJsonValue(firstBattleCombatCutInSettled.settled.last, combatCutInStage),
|
|
`Expected the real attack playback to finish cleanly while retaining its last map-stage snapshot: ${JSON.stringify(firstBattleCombatCutInSettled)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(firstBattleCombatCutInSettled.restored, firstBattleCombatCutInStage.expectedRestored),
|
|
`Expected the cut-in fixture to restore battle, camera, selection, marker, and prior debug state: ${JSON.stringify(firstBattleCombatCutInSettled)}`
|
|
);
|
|
|
|
const firstBattleOrderHud = await assertLiveSortieOrderHud(page, {
|
|
battleId: 'first-battle-zhuo-commandery',
|
|
orderId: 'elite',
|
|
context: 'first battle'
|
|
});
|
|
assert(
|
|
firstBattleOrderHud.hud?.commandReady === true &&
|
|
firstBattleOrderHud.hud.commandUnlocked === true &&
|
|
firstBattleOrderHud.hud.commandUnlockTurn === 1 &&
|
|
firstBattleOrderHud.hud.commandUsed === false &&
|
|
firstBattleOrderHud.hud.commandSource === 'sortie-order' &&
|
|
firstBattleOrderHud.hud.criteria.every((entry) => entry.id === 'victory' || entry.achieved === true) &&
|
|
firstBattleOrderHud.battleLog.some((entry) => entry.includes('군령 발동 가능')) &&
|
|
isFiniteBounds(firstBattleOrderHud.hud.commandBounds),
|
|
`Expected the deterministic fixture to reach the real criteria/action unlock path and expose a ready one-use order command: ${JSON.stringify(firstBattleOrderHud)}`
|
|
);
|
|
|
|
const firstBattleCommand = await activateSortieOrderCommand(page, 'support');
|
|
assert(
|
|
firstBattleCommand.live?.commandReady === false &&
|
|
firstBattleCommand.live?.commandUnlocked === true &&
|
|
firstBattleCommand.live?.commandUsed === true &&
|
|
firstBattleCommand.live?.commandSource === 'sortie-order' &&
|
|
firstBattleCommand.live?.commandActorId === firstBattleCommand.live?.command?.actorId &&
|
|
firstBattleCommand.live?.command?.source === 'sortie-order' &&
|
|
firstBattleCommand.live?.command?.role === 'support' &&
|
|
firstBattleCommand.live?.command?.effectPending === false &&
|
|
firstBattleCommand.live?.command?.effectValue > 0,
|
|
`Expected selecting 재정비 to consume the order command and resolve deterministic healing immediately: ${JSON.stringify(firstBattleCommand)}`
|
|
);
|
|
|
|
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
|
|
await waitForBattleOutcome(page, 'victory');
|
|
const initialFirstSettlement = await page.evaluate(() => ({
|
|
settlement: window.__HEROS_DEBUG__?.battle()?.resultSettlement,
|
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? []
|
|
}));
|
|
if (initialFirstSettlement.settlement?.total > 0) {
|
|
assert(
|
|
['pending', 'animating'].includes(initialFirstSettlement.settlement.status) &&
|
|
initialFirstSettlement.settlement.skipped === false &&
|
|
initialFirstSettlement.settlement.cta?.state === 'settlement' &&
|
|
initialFirstSettlement.settlement.cta.label === '정산 완료하기' &&
|
|
initialFirstSettlement.settlement.cta.destination === null &&
|
|
initialFirstSettlement.settlement.cta.interactive === true &&
|
|
boundsWithinFhdViewport(initialFirstSettlement.settlement.cta.bounds),
|
|
`Expected a growing victory result to begin as an explicit settlement action: ${JSON.stringify(initialFirstSettlement)}`
|
|
);
|
|
await clickLegacyUi(page, 738, 642);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement?.status === 'complete', undefined, { timeout: 30000 });
|
|
const skippedFirstSettlement = await page.evaluate(() => ({
|
|
settlement: window.__HEROS_DEBUG__?.battle()?.resultSettlement,
|
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? []
|
|
}));
|
|
assert(
|
|
skippedFirstSettlement.settlement?.skipped === true &&
|
|
skippedFirstSettlement.settlement.progress === skippedFirstSettlement.settlement.total &&
|
|
skippedFirstSettlement.settlement.cta?.state === 'navigation' &&
|
|
skippedFirstSettlement.settlement.cta.label === '승리 이야기로' &&
|
|
skippedFirstSettlement.settlement.cta.destination === 'victory-story' &&
|
|
skippedFirstSettlement.activeScenes.includes('BattleScene') &&
|
|
!skippedFirstSettlement.activeScenes.includes('StoryScene'),
|
|
`Expected the first CTA click to finish settlement without leaving battle: ${JSON.stringify(skippedFirstSettlement)}`
|
|
);
|
|
} else {
|
|
assert(
|
|
initialFirstSettlement.settlement?.status === 'complete' &&
|
|
initialFirstSettlement.settlement.progress === 0 &&
|
|
initialFirstSettlement.settlement.cta?.state === 'navigation' &&
|
|
initialFirstSettlement.settlement.cta.label === '승리 이야기로' &&
|
|
initialFirstSettlement.settlement.cta.destination === 'victory-story' &&
|
|
initialFirstSettlement.activeScenes.includes('BattleScene'),
|
|
`Expected a no-growth victory to expose its completed story CTA immediately: ${JSON.stringify(initialFirstSettlement)}`
|
|
);
|
|
}
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-result.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first battle result');
|
|
|
|
const resultProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return {
|
|
outcome: state?.battleOutcome,
|
|
resultTexts: textValues(scene?.resultObjects)
|
|
};
|
|
|
|
function textValues(objects = []) {
|
|
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
|
|
}
|
|
});
|
|
assert(resultProbe.outcome === 'victory', `Expected forced victory result: ${JSON.stringify(resultProbe)}`);
|
|
assert(resultProbe.resultTexts.includes('목표 정산'), `Expected result objective settlement title: ${JSON.stringify(resultProbe.resultTexts)}`);
|
|
assert(
|
|
['전투 수입', '최고 전공', '획득 물자', '새 전력 · 해금'].every((label) => resultProbe.resultTexts.includes(label)),
|
|
`Expected the result reward panel to expose four scannable reward cards: ${JSON.stringify(resultProbe.resultTexts)}`
|
|
);
|
|
assert(
|
|
resultProbe.resultTexts.some((text) => text.includes('해금')),
|
|
`Expected result reward cards to surface progression unlocks: ${JSON.stringify(resultProbe.resultTexts)}`
|
|
);
|
|
assert(!resultProbe.resultTexts.some((text) => text.includes('미달')), `Expected result screen to avoid harsh optional-goal wording: ${JSON.stringify(resultProbe.resultTexts)}`);
|
|
|
|
const firstResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
const firstResultPrimaryActions = firstResultState?.resultActions?.filter((action) => action.primary) ?? [];
|
|
const firstResultContinueAction = firstResultState?.resultActions?.find((action) => action.kind === 'continue');
|
|
const firstResultRetryAction = firstResultState?.resultActions?.find((action) => action.kind === 'retry');
|
|
const firstResultTitleAction = firstResultState?.resultActions?.find((action) => action.kind === 'title');
|
|
assert(
|
|
firstResultPrimaryActions.length === 1 &&
|
|
firstResultContinueAction?.label === '승리 이야기로' &&
|
|
firstResultContinueAction.primary === true &&
|
|
firstResultContinueAction.tone === 'primary' &&
|
|
firstResultContinueAction.lineWidth >= 2 &&
|
|
firstResultContinueAction.interactive === true &&
|
|
boundsWithinFhdViewport(firstResultContinueAction.bounds) &&
|
|
firstResultRetryAction?.tone === 'secondary' &&
|
|
firstResultTitleAction?.tone === 'ghost',
|
|
`Expected the completed victory result to expose one correctly named story CTA: ${JSON.stringify(firstResultState?.resultActions)}`
|
|
);
|
|
const firstResultSave = await readCampaignSave(page);
|
|
const firstSortieOrder = firstResultState?.sortieOperationOrder;
|
|
const firstSortieOrderResult = firstSortieOrder?.result;
|
|
const firstSortieOrderCommand = firstSortieOrderResult?.command;
|
|
const firstSortieOrderBonus = firstSortieOrderResult?.rewardGranted
|
|
? { rewardGold: 180, rewardItems: ['상처약 1'] }
|
|
: { rewardGold: 0, rewardItems: [] };
|
|
const expectedFirstDisplayedRewardGold =
|
|
(firstResultSave.current?.firstBattleReport?.rewardGold ?? 0) + firstSortieOrderBonus.rewardGold;
|
|
assert(
|
|
firstSortieOrder?.selectedId === 'elite' &&
|
|
firstSortieOrder?.open === false &&
|
|
firstSortieOrder?.toggleButtonBounds &&
|
|
firstSortieOrderResult?.orderId === 'elite' &&
|
|
firstSortieOrderResult?.progress?.map((entry) => entry.id).join(',') === 'victory,elite-grade,elite-survival',
|
|
`Expected the scripted first battle to expose an explicit elite order result: ${JSON.stringify(firstSortieOrder)}`
|
|
);
|
|
assert(
|
|
resultProbe.resultTexts.includes(`군자금 +${expectedFirstDisplayedRewardGold}`),
|
|
`Expected the result reward card to include the newly granted sortie-order merit reward: ${JSON.stringify({ expectedFirstDisplayedRewardGold, resultTexts: resultProbe.resultTexts })}`
|
|
);
|
|
assert(
|
|
firstSortieOrderCommand?.source === 'sortie-order' &&
|
|
firstSortieOrderCommand.role === 'support' &&
|
|
typeof firstSortieOrderCommand.actorId === 'string' && firstSortieOrderCommand.actorId.length > 0 &&
|
|
firstSortieOrderCommand.usedTurn === 1 &&
|
|
firstSortieOrderCommand.effectPending === false &&
|
|
firstSortieOrderCommand.effectValue > 0 &&
|
|
firstSortieOrderCommand.statusesCleared >= 0 &&
|
|
firstSortieOrderCommand.effectLost === false,
|
|
`Expected the result snapshot to retain the resolved 재정비 order command: ${JSON.stringify(firstSortieOrderResult)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieOrder, firstSortieOrderResult) &&
|
|
sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieOrder, firstSortieOrderResult) &&
|
|
sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder, firstSortieOrderResult) &&
|
|
sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder, firstSortieOrderResult) &&
|
|
sameJsonValue(firstResultSave.current?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite, firstSortieOrderResult),
|
|
`Expected the elite order snapshot to synchronize across report, settlement, history, current, and slot saves: ${JSON.stringify(firstResultSave)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieOrder?.command, firstSortieOrderCommand) &&
|
|
sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieOrder?.command, firstSortieOrderCommand) &&
|
|
sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder?.command, firstSortieOrderCommand) &&
|
|
sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder?.command, firstSortieOrderCommand) &&
|
|
sameJsonValue(firstResultSave.current?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite?.command, firstSortieOrderCommand) &&
|
|
sameJsonValue(firstResultSave.slot1?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite?.command, firstSortieOrderCommand),
|
|
`Expected the order-command snapshot to persist across current/slot reports, settlements, and order history: ${JSON.stringify(firstResultSave)}`
|
|
);
|
|
if (firstSortieOrderResult.achieved) {
|
|
assert(
|
|
firstSortieOrderResult.rewardGranted === true &&
|
|
firstResultSave.current?.claimedSortieOrderRewardIds?.includes('first-battle-zhuo-commandery:elite') &&
|
|
firstResultSave.current?.inventory?.['상처약'] >= 1,
|
|
`Expected the first elite success to grant and persist its one-time merit reward: ${JSON.stringify(firstResultSave.current)}`
|
|
);
|
|
}
|
|
const firstResultPerformance = firstResultState?.sortieEvaluation?.snapshot;
|
|
assert(
|
|
firstResultState?.sortieEvaluation?.available === true &&
|
|
firstResultState.sortieEvaluation.open === false &&
|
|
['S', 'A', 'B', 'C', 'D'].includes(firstResultState.sortieEvaluation.grade) &&
|
|
Number.isFinite(firstResultState.sortieEvaluation.score),
|
|
`Expected the first result to expose a closed formation evaluation with a grade: ${JSON.stringify(firstResultState?.sortieEvaluation)}`
|
|
);
|
|
assert(
|
|
firstResultPerformance?.selectedUnitIds?.length === firstResultState?.deployedAllyIds?.length &&
|
|
firstResultPerformance.selectedUnitIds.every((unitId) => firstResultState.deployedAllyIds.includes(unitId)) &&
|
|
firstResultPerformance.selectedUnitIds.every((unitId) => ['front', 'flank', 'support', 'reserve'].includes(firstResultPerformance.formationAssignments?.[unitId])) &&
|
|
firstResultPerformance.unitStats?.length === firstResultPerformance.selectedUnitIds.length &&
|
|
!Object.prototype.hasOwnProperty.call(firstResultPerformance, 'sortieItemAssignments') &&
|
|
!Object.prototype.hasOwnProperty.call(firstResultPerformance, 'itemAssignments'),
|
|
`Expected the first result snapshot to materialize every deployed officer and role without supplies: ${JSON.stringify(firstResultPerformance)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(firstResultSave.current?.firstBattleReport?.sortiePerformance, firstResultPerformance) &&
|
|
sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortiePerformance, firstResultPerformance) &&
|
|
sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance, firstResultPerformance) &&
|
|
sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance, firstResultPerformance),
|
|
`Expected the first formation result snapshot to synchronize across report, history, current, and slot-1 saves: ${JSON.stringify(firstResultSave)}`
|
|
);
|
|
const expectedFirstSortieReview = {
|
|
version: 1,
|
|
score: firstResultState.sortieEvaluation.score,
|
|
grade: firstResultState.sortieEvaluation.grade,
|
|
activeBondCount: firstResultState.sortieEvaluation.activeBondCount,
|
|
enemyCount: firstResultState.units.filter((unit) => unit.faction === 'enemy').length,
|
|
sourcePresetIds: []
|
|
};
|
|
assert(
|
|
sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) &&
|
|
sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieReview, expectedFirstSortieReview) &&
|
|
sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview) &&
|
|
sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview) &&
|
|
firstResultSave.current.firstBattleReport.sortieReview.sourcePresetIds.length === 0,
|
|
`Expected the first persisted sortie review to match the live grade, score, enemies, bonds, and empty preset attribution everywhere: ${JSON.stringify({
|
|
expected: expectedFirstSortieReview,
|
|
currentReport: firstResultSave.current?.firstBattleReport?.sortieReview,
|
|
slotReport: firstResultSave.slot1?.firstBattleReport?.sortieReview,
|
|
currentHistory: firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview,
|
|
slotHistory: firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview
|
|
})}`
|
|
);
|
|
|
|
const firstOrderTogglePoint = await readBattleSortieOrderControlPoint(page, 'toggle');
|
|
await page.mouse.click(firstOrderTogglePoint.x, firstOrderTogglePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.open === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const firstOrderDetail = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
return {
|
|
state: window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder,
|
|
texts: (scene?.resultSortieOrderObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text)
|
|
};
|
|
});
|
|
assert(
|
|
firstOrderDetail.state?.closeButtonBounds &&
|
|
firstOrderDetail.texts.some((text) => text.includes('정예 작전 명령')) &&
|
|
firstOrderDetail.texts.some((text) => text.includes('전투 승리')) &&
|
|
firstOrderDetail.texts.some((text) => text.includes('A등급 이상')) &&
|
|
firstOrderDetail.texts.some((text) => text.includes('출전 전원 생존')) &&
|
|
firstOrderDetail.texts.some((text) => text.includes('군령 발동') && text.includes('재정비')) &&
|
|
firstOrderDetail.texts.some((text) => text.includes('1턴') && text.includes('회복')),
|
|
`Expected the visible sortie-order detail to explain every elite condition and its used command: ${JSON.stringify(firstOrderDetail)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-sortie-order.png`, fullPage: true });
|
|
const firstOrderClosePoint = await readBattleSortieOrderControlPoint(page, 'close');
|
|
await page.mouse.click(firstOrderClosePoint.x, firstOrderClosePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.open === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const firstEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle');
|
|
await page.mouse.click(firstEvaluationTogglePoint.x, firstEvaluationTogglePoint.y);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 });
|
|
const firstEvaluationProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return {
|
|
evaluation: state?.sortieEvaluation,
|
|
texts: (scene?.resultFormationReviewObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text)
|
|
};
|
|
});
|
|
assert(
|
|
firstEvaluationProbe.texts.includes('이번 편성 평가') &&
|
|
firstEvaluationProbe.texts.some((text) => text.includes('명단·역할만 저장') && text.includes('장비·보급 제외')) &&
|
|
firstEvaluationProbe.evaluation?.presetCards?.length === 3 &&
|
|
firstEvaluationProbe.evaluation.presetCards.every((card) => card.saved === false && card.matching === false && card.actionButtonBounds),
|
|
`Expected the visible first-battle evaluation to explain its scope and show three empty preset actions: ${JSON.stringify(firstEvaluationProbe)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-formation-evaluation.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first battle formation evaluation');
|
|
const firstEvaluationClosePoint = await readBattleResultEvaluationControlPoint(page, 'close');
|
|
await page.mouse.click(firstEvaluationClosePoint.x, firstEvaluationClosePoint.y);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 });
|
|
|
|
await clickLegacyUi(page, 738, 642);
|
|
const firstCampTransition = await waitForCampAfterBattleResult(page);
|
|
assert(
|
|
firstCampTransition.lastStory?.isLastPage === true &&
|
|
firstCampTransition.lastStory?.nextScene === 'CampScene' &&
|
|
firstCampTransition.lastStory?.advanceHint === 'camp-return' &&
|
|
firstCampTransition.lastStory?.advanceLabel === '군영으로' &&
|
|
firstCampTransition.lastStory?.progressLabel?.includes('군영으로') &&
|
|
boundsWithinFhdViewport(firstCampTransition.lastStory?.progressBounds) &&
|
|
boundsWithinFhdViewport(firstCampTransition.lastStory?.progressPanelBounds) &&
|
|
boundsInside(
|
|
firstCampTransition.lastStory?.progressBounds,
|
|
firstCampTransition.lastStory?.progressPanelBounds
|
|
),
|
|
`Expected the post-battle story to name the next action as returning to camp: ${JSON.stringify(firstCampTransition)}`
|
|
);
|
|
const firstRewardDisplaysByPage = Object.fromEntries(
|
|
firstCampTransition.rewardDisplays.map((display) => [display.pageId, display])
|
|
);
|
|
const persistedFirstRewards = firstResultSave.current?.firstBattleReport?.campaignRewards;
|
|
const expectedFirstStorySupplies = [
|
|
...(persistedFirstRewards?.supplies ?? []),
|
|
...firstSortieOrderBonus.rewardItems
|
|
];
|
|
assert(
|
|
firstCampTransition.rewardDisplays.length === 5 &&
|
|
firstCampTransition.rewardDisplays.every((display) => (
|
|
display.source === 'campaign' && display.battleId === 'first-battle-zhuo-commandery'
|
|
)) &&
|
|
firstRewardDisplaysByPage['first-victory-village']?.rewardGold === expectedFirstDisplayedRewardGold &&
|
|
sameJsonValue(
|
|
firstRewardDisplaysByPage['first-victory-village']?.items.filter((item) => item.category === 'supply').map((item) => item.label),
|
|
expectedFirstStorySupplies
|
|
) &&
|
|
sameJsonValue(
|
|
firstRewardDisplaysByPage['first-victory-liu-bei']?.items.filter((item) => item.category === 'equipment').map((item) => item.label),
|
|
persistedFirstRewards?.equipment
|
|
) &&
|
|
sameJsonValue(
|
|
firstRewardDisplaysByPage['first-victory-liu-bei']?.items.filter((item) => item.category === 'reputation').map((item) => item.label),
|
|
persistedFirstRewards?.reputation
|
|
) &&
|
|
sameJsonValue(
|
|
[
|
|
...(firstRewardDisplaysByPage['first-victory-guan-yu']?.items ?? []),
|
|
...(firstRewardDisplaysByPage['first-victory-zhang-fei']?.items ?? [])
|
|
].filter((item) => item.category === 'recruit').map((item) => item.id.replace('recruit-', '')),
|
|
persistedFirstRewards?.recruits.map((recruit) => recruit.unitId)
|
|
) &&
|
|
sameJsonValue(
|
|
firstRewardDisplaysByPage['first-victory-next']?.items.filter((item) => item.category === 'unlock').map((item) => item.id.replace('unlock-', '')),
|
|
persistedFirstRewards?.unlocks.map((unlock) => unlock.battleId)
|
|
),
|
|
`Expected every first-victory story reward card to match the persisted battle report: ${JSON.stringify({ displays: firstCampTransition.rewardDisplays, persistedFirstRewards })}`
|
|
);
|
|
await waitForCampSkinTransition(page, 'yellow-turban');
|
|
await waitForCampSoundscapeTransition(page, 'camp-rally', 'campfire-ambience');
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first camp');
|
|
|
|
const firstCampProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
return {
|
|
state: window.__HEROS_DEBUG__?.camp(),
|
|
summary: scene?.reportSummary?.(),
|
|
texts: textValues(scene?.contentObjects)
|
|
};
|
|
|
|
function textValues(objects = []) {
|
|
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
|
|
}
|
|
});
|
|
assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`);
|
|
const firstArrivalReward = firstCampProbe.state?.victoryRewardAcknowledgement;
|
|
assert(
|
|
firstArrivalReward?.visible === true &&
|
|
firstArrivalReward.battleId === 'first-battle-zhuo-commandery' &&
|
|
firstArrivalReward.cards.length === 6 &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'gold')?.items.includes(`+${expectedFirstDisplayedRewardGold} 군자금`) &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'supplies')?.items.includes('상처약 1') &&
|
|
firstArrivalReward.cards.every((card) => card.iconReady === true) &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'equipment')?.iconKind === 'item' &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'equipment')?.new === true &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'equipment')?.actionId === 'equipment' &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'equipment')?.interactive === true &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'recruits')?.iconKind === 'portrait' &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'recruits')?.new === true &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'recruits')?.actionId === 'sortie' &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.new === true &&
|
|
firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.actionId === 'sortie' &&
|
|
firstArrivalReward.cards.every((card) => boundsWithinFhdViewport(card.bounds)) &&
|
|
firstArrivalReward.actions.map((action) => action.id).join(',') === 'equipment,supplies,sortie,close' &&
|
|
firstArrivalReward.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)) &&
|
|
firstCampProbe.state?.sortieRoster
|
|
?.filter((unit) => ['guan-yu', 'zhang-fei', 'jian-yong'].includes(unit.id)).length === 3 &&
|
|
firstCampProbe.state?.sortieRoster
|
|
?.filter((unit) => ['guan-yu', 'zhang-fei', 'jian-yong'].includes(unit.id))
|
|
.every((unit) => unit.serviceHistory?.recentRecruit === true && unit.serviceHistory?.badge?.kind === 'recent-recruit') &&
|
|
firstCampProbe.state?.campTabs?.filter((tab) => ['supplies', 'equipment'].includes(tab.id)).every((tab) => tab.newBadgeVisible === true),
|
|
`Expected first camp arrival to expose bounded reward cards, deep links, and NEW badges: ${JSON.stringify(firstArrivalReward)}`
|
|
);
|
|
const firstArrivalEquipmentPoint = await readCampVictoryRewardCardControlPoint(page, 'equipment');
|
|
await page.mouse.click(firstArrivalEquipmentPoint.x, firstArrivalEquipmentPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const camp = window.__HEROS_DEBUG__?.camp();
|
|
return camp?.victoryRewardAcknowledgement?.visible === false && camp?.activeTab === 'equipment';
|
|
}, undefined, { timeout: 30000 });
|
|
const acknowledgedFirstArrival = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const acknowledgedFirstArrivalSave = await readCampaignSave(page);
|
|
assert(
|
|
!acknowledgedFirstArrivalSave.current?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') &&
|
|
!acknowledgedFirstArrivalSave.slot1?.acknowledgedVictoryRewardBattleIds?.includes('first-battle-zhuo-commandery') &&
|
|
acknowledgedFirstArrivalSave.current?.acknowledgedVictoryRewardCategories?.['first-battle-zhuo-commandery']?.includes('equipment') &&
|
|
acknowledgedFirstArrivalSave.slot1?.acknowledgedVictoryRewardCategories?.['first-battle-zhuo-commandery']?.includes('equipment') &&
|
|
acknowledgedFirstArrival.campTabs.find((tab) => tab.id === 'equipment')?.newBadgeVisible === false &&
|
|
acknowledgedFirstArrival.campTabs.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true,
|
|
`Expected an equipment deep link to acknowledge only equipment while preserving other reward NEW states: ${JSON.stringify({ acknowledgedFirstArrival, acknowledgedFirstArrivalSave })}`
|
|
);
|
|
await clickLegacyUi(page, 576, 38);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'status', undefined, { timeout: 30000 });
|
|
assertCampSkinState(firstCampProbe.state, 'yellow-turban', 'first camp');
|
|
assertCampSoundscapeState(firstCampProbe.state, {
|
|
musicKey: 'camp-rally',
|
|
ambienceKey: 'campfire-ambience',
|
|
musicVolume: 0.22,
|
|
ambienceVolume: 0.12
|
|
}, 'first camp');
|
|
assert(
|
|
firstCampProbe.state?.campRoster?.pageCount === 1 &&
|
|
firstCampProbe.state.campRoster.totalCount === 4 &&
|
|
sameJsonValue(
|
|
[...firstCampProbe.state.campRoster.visibleUnitIds].sort(),
|
|
['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong'].sort()
|
|
) &&
|
|
firstCampProbe.state.campRoster.rowBounds.length === 4 &&
|
|
firstCampProbe.state.campRoster.rowBounds.every((row) => row.height >= 100) &&
|
|
firstCampProbe.state.campRoster.navigationBounds === null &&
|
|
firstCampProbe.state.campRoster.previousEnabled === false &&
|
|
firstCampProbe.state.campRoster.nextEnabled === false,
|
|
`Expected the four-officer first camp to retain spacious cards without redundant pagination: ${JSON.stringify(firstCampProbe.state?.campRoster)}`
|
|
);
|
|
assert(
|
|
firstCampProbe.state?.nextSortieBattleId === 'second-battle-yellow-turban-pursuit',
|
|
`Expected first camp sortie flow to prepare unlocked second battle: ${JSON.stringify(firstCampProbe.state)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(
|
|
(firstCampProbe.state?.sortieDeploymentPreview ?? []).map((slot) => slot.unitId).sort(),
|
|
['liu-bei', 'guan-yu', 'zhang-fei'].sort()
|
|
),
|
|
`Expected first camp deployment preview to keep exactly the founding trio assigned while Jian Yong remains a selectable reserve: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
|
|
);
|
|
assert(firstCampProbe.state?.sortieHasBattle === true, `Expected first camp to expose a playable next battle: ${JSON.stringify(firstCampProbe.state)}`);
|
|
assert(
|
|
firstCampProbe.state?.sortieRecommendationEnabled === true,
|
|
`Expected first camp to support recommended sortie planning: ${JSON.stringify(firstCampProbe.state)}`
|
|
);
|
|
assert(
|
|
firstCampProbe.state?.sortiePlan?.selectedCount === firstCampProbe.state?.sortieDeploymentPreview?.length,
|
|
`Expected deployment preview to match selected sortie count: ${JSON.stringify(firstCampProbe.state?.sortiePlan)} / ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
|
|
);
|
|
assertUnique(
|
|
firstCampProbe.state?.sortieDeploymentPreview?.map((slot) => slot.unitId),
|
|
`Expected first camp deployment preview to avoid duplicate units: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
|
|
);
|
|
assertUnique(
|
|
firstCampProbe.state?.sortieDeploymentPreview?.map((slot) => `${slot.x},${slot.y}`),
|
|
`Expected first camp deployment preview to avoid duplicate tiles: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
|
|
);
|
|
assert(
|
|
firstCampProbe.summary?.includes('군자금') && !firstCampProbe.summary.includes('\n'),
|
|
`Expected the compact camp summary to show held gold on one line: ${JSON.stringify(firstCampProbe)}`
|
|
);
|
|
assert(firstCampProbe.texts.some((text) => text.includes('전투 보상')), `Expected camp report to show battle reward: ${JSON.stringify(firstCampProbe.texts)}`);
|
|
assert(
|
|
firstCampProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비')),
|
|
`Expected the camp report to show the persisted command record: ${JSON.stringify(firstCampProbe.texts)}`
|
|
);
|
|
assert(
|
|
firstCampProbe.texts.some((text) => text.includes(`다음 ${firstBattleUnlockLabel}`)),
|
|
`Expected camp report to surface first battle unlock label: ${JSON.stringify(firstCampProbe.texts)}`
|
|
);
|
|
assert(
|
|
firstCampProbe.state?.report?.campaignRewards?.unlocks?.some(
|
|
(unlock) => unlock.battleId === 'second-battle-yellow-turban-pursuit' && unlock.title === firstBattleUnlockLabel
|
|
),
|
|
`Expected campaign report debug state to retain first battle unlock reward: ${JSON.stringify(firstCampProbe.state?.report?.campaignRewards)}`
|
|
);
|
|
assert(
|
|
firstCampProbe.state?.campTabs?.length === 6 &&
|
|
firstCampProbe.state.campTabs.filter((tab) => tab.active).length === 1 &&
|
|
firstCampProbe.state.campTabs.every(
|
|
(tab) => tab.interactive === true && boundsWithinFhdViewport(tab.bounds)
|
|
) &&
|
|
firstCampProbe.state.campTabs.find((tab) => tab.id === 'status')?.visualState === 'active' &&
|
|
firstCampProbe.state.campTabs.find((tab) => tab.id === 'status')?.indicatorVisible === true,
|
|
`Expected six distinct, FHD-safe camp tabs with one active state: ${JSON.stringify(firstCampProbe.state?.campTabs)}`
|
|
);
|
|
const firstCampDialogueTabPoint = await readCampTabControlPoint(page, 'dialogue');
|
|
await page.mouse.move(firstCampDialogueTabPoint.x, firstCampDialogueTabPoint.y);
|
|
await page.waitForTimeout(80);
|
|
const firstCampTabHoverState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
firstCampTabHoverState?.activeTab === 'status' &&
|
|
firstCampTabHoverState?.campTabs?.find((tab) => tab.id === 'dialogue')?.visualState === 'hover' &&
|
|
firstCampTabHoverState.campTabs.find((tab) => tab.id === 'dialogue')?.indicatorVisible === true &&
|
|
firstCampTabHoverState.campTabs.find((tab) => tab.id === 'dialogue')?.lineWidth >= 2,
|
|
`Expected camp tab hover to add emphasis without changing the active tab: ${JSON.stringify(firstCampTabHoverState?.campTabs)}`
|
|
);
|
|
await page.mouse.move(4, 4);
|
|
await page.waitForTimeout(80);
|
|
const firstCampTabResetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
firstCampTabResetState?.campTabs?.find((tab) => tab.id === 'dialogue')?.visualState === 'idle' &&
|
|
firstCampTabResetState.campTabs.find((tab) => tab.id === 'dialogue')?.indicatorVisible === false,
|
|
`Expected camp tab hover styling to reset on pointerout: ${JSON.stringify(firstCampTabResetState?.campTabs)}`
|
|
);
|
|
|
|
const firstCampEvaluation = firstCampProbe.state?.reportFormationEvaluation;
|
|
const firstCampHistory = firstCampProbe.state?.reportFormationHistory;
|
|
const firstCampEvaluationSaveBefore = await readCampaignSave(page);
|
|
assert(
|
|
firstCampEvaluation?.available === true &&
|
|
firstCampEvaluation.open === false &&
|
|
firstCampEvaluation.battleId === 'first-battle-zhuo-commandery' &&
|
|
firstCampEvaluation.grade === firstResultState?.sortieEvaluation?.grade &&
|
|
firstCampEvaluation.score === firstResultState?.sortieEvaluation?.score &&
|
|
sameJsonValue(firstCampEvaluation.snapshot, firstResultPerformance) &&
|
|
firstCampEvaluation.toggleButtonBounds,
|
|
`Expected the first camp report to expose the persisted battle formation evaluation: ${JSON.stringify(firstCampEvaluation)}`
|
|
);
|
|
assert(
|
|
firstCampHistory?.available === true &&
|
|
firstCampHistory.open === false &&
|
|
firstCampHistory.recordCount === 1 &&
|
|
firstCampHistory.page === 0 &&
|
|
firstCampHistory.pageCount === 1 &&
|
|
firstCampHistory.records?.length === 1 &&
|
|
firstCampHistory.records[0].battleId === 'first-battle-zhuo-commandery' &&
|
|
firstCampHistory.records[0].grade === expectedFirstSortieReview.grade &&
|
|
firstCampHistory.records[0].score === expectedFirstSortieReview.score &&
|
|
sameJsonValue(firstCampHistory.records[0].sourcePresetIds, expectedFirstSortieReview.sourcePresetIds) &&
|
|
firstCampHistory.selectedBattleId === 'first-battle-zhuo-commandery' &&
|
|
firstCampHistory.best?.battleId === 'first-battle-zhuo-commandery' &&
|
|
firstCampHistory.loadableBest?.battleId === 'first-battle-zhuo-commandery' &&
|
|
firstCampHistory.loadUndoAvailable === false &&
|
|
firstCampHistory.toggleButtonBounds,
|
|
`Expected the first camp report to expose one closed, persisted formation-history record and its best loadable sortie: ${JSON.stringify(firstCampHistory)}`
|
|
);
|
|
const firstCampOrder = firstCampProbe.state?.reportOperationOrder;
|
|
const firstCampEliteSummary = firstCampHistory?.orderSummaries?.find((summary) => summary.orderId === 'elite');
|
|
assert(
|
|
firstCampOrder?.available === true &&
|
|
firstCampOrder.orderId === 'elite' &&
|
|
firstCampOrder.achieved === firstSortieOrderResult.achieved &&
|
|
firstCampOrder.firstRewardGranted === firstSortieOrderResult.rewardGranted &&
|
|
sameSortieOrderCommand(firstCampOrder.command, firstSortieOrderCommand) &&
|
|
firstCampOrder.command?.label === '재정비' &&
|
|
firstCampOrder.command?.sourceLabel === '군령 발동' &&
|
|
firstCampOrder.command?.recordLine?.includes('회복') &&
|
|
firstCampHistory.records[0].sortieOrder?.orderId === 'elite' &&
|
|
firstCampHistory.records[0].sortieOrder?.achieved === firstSortieOrderResult.achieved &&
|
|
sameSortieOrderCommand(firstCampHistory.records[0].sortieOrder?.command, firstSortieOrderCommand) &&
|
|
firstCampHistory.selected?.sortieOrder?.progress?.length === 3 &&
|
|
sameSortieOrderCommand(firstCampHistory.selected?.sortieOrder?.command, firstSortieOrderCommand) &&
|
|
firstCampEliteSummary?.attempts === 1 &&
|
|
firstCampEliteSummary.successes === (firstSortieOrderResult.achieved ? 1 : 0) &&
|
|
firstCampEliteSummary.bestScore === firstSortieOrderResult.score,
|
|
`Expected the camp report and formation ledger to surface the elite result and merit statistics: ${JSON.stringify({ firstCampOrder, firstCampHistory })}`
|
|
);
|
|
await setDebugQueryParam(page, 'debugOrderCommandReady', null);
|
|
if (firstSortieOrderResult.rewardGranted) {
|
|
assert(
|
|
firstCampOrder.rewardBadgeBounds && firstCampOrder.rewardLine.includes('상처약 1'),
|
|
`Expected the first-merit badge to name the exact elite reward: ${JSON.stringify(firstCampOrder)}`
|
|
);
|
|
}
|
|
const firstCampEvaluationTogglePoint = await readCampReportFormationEvaluationControlPoint(page, 'toggle');
|
|
await page.mouse.click(firstCampEvaluationTogglePoint.x, firstCampEvaluationTogglePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const firstCampEvaluationProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
return {
|
|
evaluation: window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation,
|
|
texts: (scene?.reportFormationReviewObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text)
|
|
};
|
|
});
|
|
const firstCampEvaluationSaveOpen = await readCampaignSave(page);
|
|
assert(
|
|
firstCampEvaluationProbe.evaluation?.open === true &&
|
|
firstCampEvaluationProbe.texts.includes('최근 전투 편성 평가') &&
|
|
firstCampEvaluationProbe.texts.some((text) => text.includes('명단·역할만 저장') && text.includes('장비·보급 제외')) &&
|
|
firstCampEvaluationProbe.evaluation?.presetCards?.length === 3 &&
|
|
firstCampEvaluationProbe.evaluation.presetCards.every(
|
|
(card) => card.saved === false && card.matching === false && card.actionButtonBounds
|
|
) &&
|
|
sameJsonValue(firstCampEvaluationSaveOpen, firstCampEvaluationSaveBefore),
|
|
`Expected reopening the first camp formation evaluation to remain non-destructive and show three empty preset actions: ${JSON.stringify(firstCampEvaluationProbe)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-formation-evaluation.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first camp formation evaluation');
|
|
const firstCampEvaluationClosePoint = await readCampReportFormationEvaluationControlPoint(page, 'close');
|
|
await page.mouse.click(firstCampEvaluationClosePoint.x, firstCampEvaluationClosePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const firstCampHistoryMutationFixture = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const campaign = scene?.campaign;
|
|
const snapshot = campaign?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance;
|
|
const targetId = snapshot?.selectedUnitIds?.find((unitId) => scene?.selectedSortieUnitIds?.includes(unitId));
|
|
const historicalRole = targetId ? snapshot?.formationAssignments?.[targetId] : undefined;
|
|
const divergentRole = ['front', 'flank', 'support', 'reserve'].find((role) => role !== historicalRole);
|
|
if (
|
|
!scene ||
|
|
!campaign ||
|
|
!snapshot?.selectedUnitIds?.length ||
|
|
!targetId ||
|
|
!historicalRole ||
|
|
!divergentRole ||
|
|
typeof scene.persistSortieSelection !== 'function' ||
|
|
typeof scene.render !== 'function'
|
|
) {
|
|
return {
|
|
ready: false,
|
|
targetId: targetId ?? null,
|
|
historicalRole: historicalRole ?? null,
|
|
divergentRole: divergentRole ?? null
|
|
};
|
|
}
|
|
|
|
const before = {
|
|
selectedUnitIds: [...scene.selectedSortieUnitIds],
|
|
formationAssignments: { ...scene.sortieFormationAssignments },
|
|
itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}),
|
|
focusedUnitId: scene.sortieFocusedUnitId,
|
|
planFeedback: scene.sortiePlanFeedback,
|
|
rosterScroll: scene.sortieRosterScroll
|
|
};
|
|
scene.sortieFormationAssignments = {
|
|
...scene.sortieFormationAssignments,
|
|
[targetId]: divergentRole
|
|
};
|
|
scene.persistSortieSelection();
|
|
scene.render();
|
|
return {
|
|
ready: true,
|
|
targetId,
|
|
historicalRole,
|
|
divergentRole,
|
|
before,
|
|
divergent: {
|
|
selectedUnitIds: [...scene.selectedSortieUnitIds],
|
|
formationAssignments: { ...scene.sortieFormationAssignments },
|
|
itemAssignments: structuredClone(scene.sortieItemAssignments ?? {})
|
|
}
|
|
};
|
|
});
|
|
assert(
|
|
firstCampHistoryMutationFixture.ready === true &&
|
|
firstCampHistoryMutationFixture.historicalRole !== firstCampHistoryMutationFixture.divergentRole &&
|
|
firstCampHistoryMutationFixture.divergent?.formationAssignments?.[firstCampHistoryMutationFixture.targetId] ===
|
|
firstCampHistoryMutationFixture.divergentRole,
|
|
`Expected a deterministic current-formation mismatch before exercising best-history loading: ${JSON.stringify(firstCampHistoryMutationFixture)}`
|
|
);
|
|
const firstCampHistorySaveBeforeOpen = await readCampaignSave(page);
|
|
assert(
|
|
sameJsonValue(
|
|
firstCampHistorySaveBeforeOpen.current?.selectedSortieUnitIds,
|
|
firstCampHistoryMutationFixture.divergent.selectedUnitIds
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistorySaveBeforeOpen.current?.sortieFormationAssignments,
|
|
firstCampHistoryMutationFixture.divergent.formationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistorySaveBeforeOpen.current?.sortieItemAssignments,
|
|
firstCampHistoryMutationFixture.divergent.itemAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistorySaveBeforeOpen.slot1?.selectedSortieUnitIds,
|
|
firstCampHistoryMutationFixture.divergent.selectedUnitIds
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistorySaveBeforeOpen.slot1?.sortieFormationAssignments,
|
|
firstCampHistoryMutationFixture.divergent.formationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistorySaveBeforeOpen.slot1?.sortieItemAssignments,
|
|
firstCampHistoryMutationFixture.divergent.itemAssignments
|
|
),
|
|
`Expected the deliberate history-load mismatch to synchronize across current and slot-1 saves: ${JSON.stringify(firstCampHistorySaveBeforeOpen)}`
|
|
);
|
|
|
|
const firstCampHistoryTogglePoint = await readCampReportFormationHistoryControlPoint(page, 'toggle');
|
|
await page.mouse.click(firstCampHistoryTogglePoint.x, firstCampHistoryTogglePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.open === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const firstCampHistoryOpenProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
return {
|
|
history: window.__HEROS_DEBUG__?.camp()?.reportFormationHistory,
|
|
texts: (scene?.reportFormationHistoryObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text)
|
|
};
|
|
});
|
|
const firstCampHistorySaveOpen = await readCampaignSave(page);
|
|
const firstCampHistoryLoadProjection = firstCampHistoryOpenProbe.history?.loadableBest;
|
|
assert(
|
|
firstCampHistoryOpenProbe.history?.open === true &&
|
|
firstCampHistoryOpenProbe.history.recordCount === 1 &&
|
|
firstCampHistoryOpenProbe.history.comparison?.matchesCurrent === false &&
|
|
firstCampHistoryOpenProbe.history.closeButtonBounds &&
|
|
firstCampHistoryOpenProbe.history.bestButtonBounds &&
|
|
firstCampHistoryOpenProbe.history.loadBestButtonBounds &&
|
|
firstCampHistoryOpenProbe.history.undoButtonBounds === null &&
|
|
sameJsonValue(firstCampHistoryLoadProjection?.projectedSelectedUnitIds, firstResultPerformance.selectedUnitIds) &&
|
|
firstResultPerformance.selectedUnitIds.every(
|
|
(unitId) => firstCampHistoryLoadProjection?.projectedFormationAssignments?.[unitId] === firstResultPerformance.formationAssignments[unitId]
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryLoadProjection?.projectedItemAssignments,
|
|
firstCampHistoryMutationFixture.divergent.itemAssignments
|
|
) &&
|
|
firstCampHistoryOpenProbe.texts.includes('편성 전적표') &&
|
|
firstCampHistoryOpenProbe.texts.includes('최고 편성') &&
|
|
firstCampHistoryOpenProbe.texts.includes('편성책 평균 · 군령 전적') &&
|
|
firstCampHistoryOpenProbe.texts.includes('현재 출전안 vs 최고 편성') &&
|
|
firstCampHistoryOpenProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비') && text.includes('군령 발동')) &&
|
|
sameJsonValue(firstCampHistorySaveOpen, firstCampHistorySaveBeforeOpen),
|
|
`Expected opening the first camp formation history to remain non-destructive and expose best/load controls: ${JSON.stringify(firstCampHistoryOpenProbe)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-formation-history.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first camp formation history');
|
|
|
|
const firstCampHistoryBestPoint = await readCampReportFormationHistoryControlPoint(page, 'best');
|
|
await page.mouse.click(firstCampHistoryBestPoint.x, firstCampHistoryBestPoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.selectedBattleId === 'first-battle-zhuo-commandery',
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const firstCampHistorySaveAfterBest = await readCampaignSave(page);
|
|
assert(
|
|
sameJsonValue(firstCampHistorySaveAfterBest, firstCampHistorySaveBeforeOpen),
|
|
`Expected selecting the best history card to leave campaign saves untouched: ${JSON.stringify(firstCampHistorySaveAfterBest)}`
|
|
);
|
|
|
|
const firstCampHistoryLoadBestPoint = await readCampReportFormationHistoryControlPoint(page, 'loadBest');
|
|
await page.mouse.click(firstCampHistoryLoadBestPoint.x, firstCampHistoryLoadBestPoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.loadUndoAvailable === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const firstCampHistoryAppliedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const firstCampHistoryAppliedSave = await readCampaignSave(page);
|
|
assert(
|
|
sameJsonValue(firstCampHistoryAppliedState?.selectedSortieUnitIds, firstCampHistoryLoadProjection.projectedSelectedUnitIds) &&
|
|
sameJsonValue(
|
|
firstCampHistoryAppliedState?.sortieFormationAssignments,
|
|
firstCampHistoryLoadProjection.projectedFormationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryAppliedState?.sortieItemAssignments,
|
|
firstCampHistoryLoadProjection.projectedItemAssignments
|
|
) &&
|
|
firstCampHistoryAppliedState?.reportFormationHistory?.comparison?.matchesCurrent === true &&
|
|
firstCampHistoryAppliedState?.reportFormationHistory?.loadUndoAvailable === true &&
|
|
firstCampHistoryAppliedState?.reportFormationHistory?.undoButtonBounds &&
|
|
firstCampHistoryAppliedState?.reportFormationHistory?.feedback?.includes('최고 편성 적용') &&
|
|
sameJsonValue(
|
|
firstCampHistoryAppliedSave.current?.selectedSortieUnitIds,
|
|
firstCampHistoryLoadProjection.projectedSelectedUnitIds
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryAppliedSave.slot1?.selectedSortieUnitIds,
|
|
firstCampHistoryLoadProjection.projectedSelectedUnitIds
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryAppliedSave.current?.sortieFormationAssignments,
|
|
firstCampHistoryAppliedState.sortieFormationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryAppliedSave.slot1?.sortieFormationAssignments,
|
|
firstCampHistoryAppliedState.sortieFormationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryAppliedSave.current?.sortieItemAssignments,
|
|
firstCampHistoryLoadProjection.projectedItemAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryAppliedSave.slot1?.sortieItemAssignments,
|
|
firstCampHistoryLoadProjection.projectedItemAssignments
|
|
) &&
|
|
sameJsonValue(firstCampHistoryAppliedSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) &&
|
|
sameJsonValue(
|
|
firstCampHistoryAppliedSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview,
|
|
expectedFirstSortieReview
|
|
) &&
|
|
sameJsonValue(firstCampHistoryAppliedSave.slot1?.firstBattleReport?.sortieReview, expectedFirstSortieReview) &&
|
|
sameJsonValue(
|
|
firstCampHistoryAppliedSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview,
|
|
expectedFirstSortieReview
|
|
),
|
|
`Expected one best-history click to apply persisted names and roles, preserve common supplies, and keep the stored review immutable: ${JSON.stringify({
|
|
state: firstCampHistoryAppliedState,
|
|
save: firstCampHistoryAppliedSave
|
|
})}`
|
|
);
|
|
|
|
const firstCampHistoryUndoPoint = await readCampReportFormationHistoryControlPoint(page, 'undo');
|
|
await page.mouse.click(firstCampHistoryUndoPoint.x, firstCampHistoryUndoPoint.y);
|
|
await page.waitForFunction(
|
|
(fixture) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.reportFormationHistory?.loadUndoAvailable === false &&
|
|
JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(fixture.divergent.selectedUnitIds) &&
|
|
JSON.stringify(state?.sortieFormationAssignments) === JSON.stringify(fixture.divergent.formationAssignments) &&
|
|
JSON.stringify(state?.sortieItemAssignments) === JSON.stringify(fixture.divergent.itemAssignments);
|
|
},
|
|
firstCampHistoryMutationFixture,
|
|
{ timeout: 30000 }
|
|
);
|
|
const firstCampHistoryUndoneState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const firstCampHistoryUndoneSave = await readCampaignSave(page);
|
|
assert(
|
|
firstCampHistoryUndoneState?.reportFormationHistory?.open === true &&
|
|
firstCampHistoryUndoneState.reportFormationHistory.loadUndoAvailable === false &&
|
|
firstCampHistoryUndoneState.reportFormationHistory.undoButtonBounds === null &&
|
|
firstCampHistoryUndoneState.reportFormationHistory.comparison?.matchesCurrent === false &&
|
|
firstCampHistoryUndoneState.reportFormationHistory.feedback?.includes('되돌리고') &&
|
|
sameJsonValue(
|
|
firstCampHistoryUndoneSave.current?.selectedSortieUnitIds,
|
|
firstCampHistoryMutationFixture.divergent.selectedUnitIds
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryUndoneSave.current?.sortieFormationAssignments,
|
|
firstCampHistoryMutationFixture.divergent.formationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryUndoneSave.current?.sortieItemAssignments,
|
|
firstCampHistoryMutationFixture.divergent.itemAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryUndoneSave.slot1?.selectedSortieUnitIds,
|
|
firstCampHistoryMutationFixture.divergent.selectedUnitIds
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryUndoneSave.slot1?.sortieFormationAssignments,
|
|
firstCampHistoryMutationFixture.divergent.formationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryUndoneSave.slot1?.sortieItemAssignments,
|
|
firstCampHistoryMutationFixture.divergent.itemAssignments
|
|
) &&
|
|
sameJsonValue(firstCampHistoryUndoneSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) &&
|
|
sameJsonValue(
|
|
firstCampHistoryUndoneSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview,
|
|
expectedFirstSortieReview
|
|
),
|
|
`Expected the real history undo control to restore the exact prior names, roles, and supplies without touching the battle review: ${JSON.stringify({
|
|
state: firstCampHistoryUndoneState,
|
|
save: firstCampHistoryUndoneSave
|
|
})}`
|
|
);
|
|
|
|
const firstCampHistoryClosePoint = await readCampReportFormationHistoryControlPoint(page, 'close');
|
|
await page.mouse.click(firstCampHistoryClosePoint.x, firstCampHistoryClosePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.open === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const firstCampHistoryClosedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
firstCampHistoryClosedState?.reportFormationHistory?.recordCount === 1 &&
|
|
firstCampHistoryClosedState.reportFormationHistory.open === false &&
|
|
firstCampHistoryClosedState.reportFormationHistory.closeButtonBounds === null &&
|
|
firstCampHistoryClosedState.reportFormationHistory.toggleButtonBounds,
|
|
`Expected the real history close control to return to the first camp report: ${JSON.stringify(firstCampHistoryClosedState?.reportFormationHistory)}`
|
|
);
|
|
|
|
await page.evaluate((before) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.render !== 'function') {
|
|
throw new Error('Expected CampScene runtime methods while restoring the first-camp history fixture.');
|
|
}
|
|
scene.selectedSortieUnitIds = [...before.selectedUnitIds];
|
|
scene.sortieFormationAssignments = { ...before.formationAssignments };
|
|
scene.sortieItemAssignments = structuredClone(before.itemAssignments);
|
|
scene.sortieFocusedUnitId = before.focusedUnitId;
|
|
scene.sortiePlanFeedback = before.planFeedback;
|
|
scene.sortieRosterScroll = before.rosterScroll;
|
|
scene.persistSortieSelection();
|
|
scene.render();
|
|
}, firstCampHistoryMutationFixture.before);
|
|
await page.waitForFunction(
|
|
(before) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(before.selectedUnitIds) &&
|
|
JSON.stringify(state?.sortieFormationAssignments) === JSON.stringify(before.formationAssignments) &&
|
|
JSON.stringify(state?.sortieItemAssignments) === JSON.stringify(before.itemAssignments);
|
|
},
|
|
firstCampHistoryMutationFixture.before,
|
|
{ timeout: 30000 }
|
|
);
|
|
const firstCampHistoryRestoredSave = await readCampaignSave(page);
|
|
assert(
|
|
sameJsonValue(
|
|
firstCampHistoryRestoredSave.current?.selectedSortieUnitIds,
|
|
firstCampHistoryMutationFixture.before.selectedUnitIds
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryRestoredSave.current?.sortieFormationAssignments,
|
|
firstCampHistoryMutationFixture.before.formationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryRestoredSave.current?.sortieItemAssignments,
|
|
firstCampHistoryMutationFixture.before.itemAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryRestoredSave.slot1?.selectedSortieUnitIds,
|
|
firstCampHistoryMutationFixture.before.selectedUnitIds
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryRestoredSave.slot1?.sortieFormationAssignments,
|
|
firstCampHistoryMutationFixture.before.formationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstCampHistoryRestoredSave.slot1?.sortieItemAssignments,
|
|
firstCampHistoryMutationFixture.before.itemAssignments
|
|
),
|
|
`Expected the RC-only mismatch fixture to restore the original first-camp sortie before continuing: ${JSON.stringify(firstCampHistoryRestoredSave)}`
|
|
);
|
|
|
|
const firstCampSupplyMutationFixture = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const campaign = scene?.campaign;
|
|
const report = scene?.report;
|
|
const targetId = state?.reportFormationEvaluation?.snapshot?.selectedUnitIds?.find((unitId) =>
|
|
campaign?.roster?.some((unit) => unit.id === unitId)
|
|
);
|
|
const target = campaign?.roster?.find((unit) => unit.id === targetId);
|
|
const reportUnit = report?.units?.find((unit) => unit.id === targetId && unit.faction === 'ally');
|
|
const settlementUnit = campaign?.battleHistory?.[report?.battleId]?.units?.find((unit) => unit.unitId === targetId);
|
|
if (!scene || !campaign || !report || !target || !reportUnit || !settlementUnit || typeof scene.render !== 'function') {
|
|
return { ready: false, targetId: targetId ?? null };
|
|
}
|
|
|
|
const beanBefore = campaign.inventory?.['콩'] ?? 0;
|
|
target.hp = Math.max(1, target.maxHp - 12);
|
|
reportUnit.hp = target.hp;
|
|
campaign.inventory['콩'] = beanBefore + 1;
|
|
scene.selectedUnitId = target.id;
|
|
scene.render();
|
|
return {
|
|
ready: true,
|
|
targetId: target.id,
|
|
woundedHp: target.hp,
|
|
maxHp: target.maxHp,
|
|
settlementHp: settlementUnit.hp,
|
|
beanBefore
|
|
};
|
|
});
|
|
assert(
|
|
firstCampSupplyMutationFixture.ready === true &&
|
|
firstCampSupplyMutationFixture.targetId &&
|
|
firstCampSupplyMutationFixture.woundedHp < firstCampSupplyMutationFixture.maxHp,
|
|
`Expected a deterministic wounded report-unit fixture for camp supply recovery: ${JSON.stringify(firstCampSupplyMutationFixture)}`
|
|
);
|
|
await clickLegacyUi(page, 798, 38);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'supplies',
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const firstCampSecondaryPanels = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.secondaryPanels);
|
|
const firstCampSuppliesPanel = firstCampSecondaryPanels?.supplies;
|
|
const firstCampReportPanel = firstCampSecondaryPanels?.report;
|
|
assert(
|
|
firstCampSuppliesPanel?.visible === true &&
|
|
firstCampReportPanel?.visible === true &&
|
|
boundsWithinFhdViewport(firstCampSuppliesPanel.bounds) &&
|
|
boundsWithinFhdViewport(firstCampReportPanel.bounds) &&
|
|
firstCampSuppliesPanel.equipmentBoxBounds?.length > 0 &&
|
|
firstCampSuppliesPanel.trophyBoxBounds?.length > 0 &&
|
|
firstCampSuppliesPanel.equipmentBoxBounds?.every((bounds) => boundsInside(bounds, firstCampSuppliesPanel.bounds)) &&
|
|
firstCampSuppliesPanel.trophyBoxBounds.every((bounds) => boundsInside(bounds, firstCampSuppliesPanel.bounds)) &&
|
|
firstCampSuppliesPanel.bounds.y + firstCampSuppliesPanel.bounds.height <= firstCampReportPanel.bounds.y,
|
|
`Expected supply cards inside their FHD panel without overlapping the battle report: ${JSON.stringify(firstCampSecondaryPanels)}`
|
|
);
|
|
const firstCampTabAudioState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.audio);
|
|
assert(
|
|
firstCampTabAudioState?.music?.currentKey === firstCampProbe.state?.audio?.music?.currentKey &&
|
|
firstCampTabAudioState?.ambience?.currentKey === firstCampProbe.state?.audio?.ambience?.currentKey &&
|
|
firstCampTabAudioState?.music?.elementRevision === firstCampProbe.state?.audio?.music?.elementRevision &&
|
|
firstCampTabAudioState?.ambience?.elementRevision === firstCampProbe.state?.audio?.ambience?.elementRevision &&
|
|
firstCampTabAudioState?.music?.transition?.revision === firstCampProbe.state?.audio?.music?.transition?.revision &&
|
|
firstCampTabAudioState?.ambience?.transition?.revision === firstCampProbe.state?.audio?.ambience?.transition?.revision,
|
|
`Expected camp tab changes to keep the existing soundscape elements: ${JSON.stringify({ before: firstCampProbe.state?.audio, after: firstCampTabAudioState })}`
|
|
);
|
|
const firstCampUseSupplyPoint = await readCampSupplyUseControlPoint(page, '콩');
|
|
await page.mouse.click(firstCampUseSupplyPoint.x, firstCampUseSupplyPoint.y);
|
|
await page.waitForTimeout(120);
|
|
const firstCampSupplyClickApplied = await page.evaluate((fixture) => {
|
|
const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === fixture.targetId);
|
|
return target?.hp === fixture.maxHp;
|
|
}, firstCampSupplyMutationFixture);
|
|
if (!firstCampSupplyClickApplied) {
|
|
const fallbackApplied = await page.evaluate(({ fixture, supplyLabel }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const texts = (scene?.contentObjects ?? []).filter((object) => object?.type === 'Text' && object?.active && object?.visible);
|
|
const supplyTitle = texts.find((object) => object.text?.startsWith(`${supplyLabel} x`));
|
|
const useText = texts
|
|
.filter((object) => object.text === '사용')
|
|
.sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0];
|
|
const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === fixture.targetId);
|
|
if (!scene || !supplyTitle || !useText || !target) {
|
|
return { ready: false, supplyTitle: supplyTitle?.text ?? null, useFound: Boolean(useText), targetHp: target?.hp ?? null };
|
|
}
|
|
useText.emit('pointerdown');
|
|
return { ready: true, supplyTitle: supplyTitle.text, targetHp: target.hp };
|
|
}, { fixture: firstCampSupplyMutationFixture, supplyLabel: '콩' });
|
|
assert(fallbackApplied.ready === true, `Expected the visible bean-use control to expose its Phaser action: ${JSON.stringify(fallbackApplied)}`);
|
|
}
|
|
await page.waitForFunction((fixture) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const target = state?.campaign?.roster?.find((unit) => unit.id === fixture.targetId);
|
|
return target?.hp === fixture.maxHp;
|
|
}, firstCampSupplyMutationFixture, { timeout: 30000 });
|
|
const firstCampAfterSupplyProbe = await page.evaluate((fixture) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const report = scene?.report;
|
|
const settlement = scene?.campaign?.battleHistory?.[report?.battleId];
|
|
return {
|
|
evaluation: state?.reportFormationEvaluation,
|
|
rosterHp: state?.campaign?.roster?.find((unit) => unit.id === fixture.targetId)?.hp ?? null,
|
|
reportHp: report?.units?.find((unit) => unit.id === fixture.targetId && unit.faction === 'ally')?.hp ?? null,
|
|
settlementHp: settlement?.units?.find((unit) => unit.unitId === fixture.targetId)?.hp ?? null,
|
|
beanAmount: state?.campaign?.inventory?.['콩'] ?? 0
|
|
};
|
|
}, firstCampSupplyMutationFixture);
|
|
assert(
|
|
firstCampAfterSupplyProbe.rosterHp === firstCampSupplyMutationFixture.maxHp &&
|
|
firstCampAfterSupplyProbe.reportHp === firstCampSupplyMutationFixture.maxHp &&
|
|
firstCampAfterSupplyProbe.settlementHp === firstCampSupplyMutationFixture.settlementHp &&
|
|
firstCampAfterSupplyProbe.beanAmount === firstCampSupplyMutationFixture.beanBefore &&
|
|
firstCampAfterSupplyProbe.evaluation?.grade === firstCampEvaluation.grade &&
|
|
firstCampAfterSupplyProbe.evaluation?.score === firstCampEvaluation.score &&
|
|
firstCampAfterSupplyProbe.evaluation?.survivorCount === firstCampEvaluation.survivorCount &&
|
|
firstCampAfterSupplyProbe.evaluation?.recoveryNeededCount === firstCampEvaluation.recoveryNeededCount &&
|
|
sameJsonValue(firstCampAfterSupplyProbe.evaluation?.snapshot, firstCampEvaluation.snapshot),
|
|
`Expected camp supply use to update report HP without rewriting the settlement-backed formation evaluation: ${JSON.stringify({
|
|
before: firstCampEvaluation,
|
|
fixture: firstCampSupplyMutationFixture,
|
|
after: firstCampAfterSupplyProbe
|
|
})}`
|
|
);
|
|
|
|
const equipmentPaginationFixture = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
if (!scene?.campaign) {
|
|
return null;
|
|
}
|
|
const originalInventory = { ...scene.campaign.inventory };
|
|
['황건도', '단궁', '두령부', '철창', '포의', '찰갑'].forEach((label) => {
|
|
scene.campaign.inventory[label] = Math.max(1, scene.campaign.inventory[label] ?? 0);
|
|
});
|
|
scene.activeTab = 'equipment';
|
|
scene.equipmentInventoryPage = 0;
|
|
scene.render();
|
|
return {
|
|
originalInventory,
|
|
panel: window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment ?? null
|
|
};
|
|
});
|
|
assert(
|
|
equipmentPaginationFixture?.panel?.visible === true &&
|
|
equipmentPaginationFixture.panel.page === 0 &&
|
|
equipmentPaginationFixture.panel.pageCount >= 2 &&
|
|
equipmentPaginationFixture.panel.visibleItemIds?.length === equipmentPaginationFixture.panel.pageSize &&
|
|
equipmentPaginationFixture.panel.nextEnabled === true &&
|
|
equipmentPaginationFixture.panel.rows?.length === equipmentPaginationFixture.panel.visibleItemIds.length &&
|
|
equipmentPaginationFixture.panel.rows.every(
|
|
(row) => boundsInside(row.bounds, equipmentPaginationFixture.panel.bounds) &&
|
|
boundsInside(row.actionButtonBounds, row.bounds)
|
|
) &&
|
|
boundsWithinFhdViewport(equipmentPaginationFixture.panel.bounds) &&
|
|
boundsInside(equipmentPaginationFixture.panel.nextButtonBounds, equipmentPaginationFixture.panel.bounds),
|
|
`Expected the equipment manager to expose the first of multiple inventory pages: ${JSON.stringify(equipmentPaginationFixture)}`
|
|
);
|
|
const equipmentComparisonCandidate = equipmentPaginationFixture.panel.rows.find((row) => row.canEquip && row.interactive);
|
|
assert(
|
|
equipmentComparisonCandidate && boundsWithinFhdViewport(equipmentComparisonCandidate.actionButtonBounds),
|
|
`Expected a visible, interactive equipment candidate in the FHD inventory page: ${JSON.stringify(equipmentPaginationFixture.panel.rows)}`
|
|
);
|
|
const equipmentBeforeComparison = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const unit = scene?.campaign?.roster?.find((candidate) => candidate.id === scene?.selectedUnitId);
|
|
return unit
|
|
? {
|
|
unitId: unit.id,
|
|
equipment: structuredClone(unit.equipment),
|
|
inventory: structuredClone(scene.campaign.inventory)
|
|
}
|
|
: null;
|
|
});
|
|
await page.mouse.click(
|
|
equipmentComparisonCandidate.actionButtonBounds.x + equipmentComparisonCandidate.actionButtonBounds.width / 2,
|
|
equipmentComparisonCandidate.actionButtonBounds.y + equipmentComparisonCandidate.actionButtonBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison?.visible === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const equipmentComparison = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const unit = state?.campaign?.roster?.find((candidate) => candidate.id === state?.equipmentSwapComparison?.unitId);
|
|
return {
|
|
comparison: state?.equipmentSwapComparison ?? null,
|
|
equipment: unit ? structuredClone(unit.equipment) : null,
|
|
inventory: structuredClone(state?.campaign?.inventory ?? {})
|
|
};
|
|
});
|
|
assert(
|
|
equipmentBeforeComparison &&
|
|
equipmentComparison.comparison?.candidateItemId === equipmentComparisonCandidate.itemId &&
|
|
equipmentComparison.comparison.currentItemId !== equipmentComparison.comparison.candidateItemId &&
|
|
equipmentComparison.comparison.confirmInteractive === true &&
|
|
equipmentComparison.comparison.cancelInteractive === true &&
|
|
boundsWithinFhdViewport(equipmentComparison.comparison.panelBounds) &&
|
|
boundsInside(equipmentComparison.comparison.confirmButtonBounds, equipmentComparison.comparison.panelBounds) &&
|
|
boundsInside(equipmentComparison.comparison.cancelButtonBounds, equipmentComparison.comparison.panelBounds) &&
|
|
sameJsonValue(equipmentComparison.equipment, equipmentBeforeComparison.equipment) &&
|
|
sameJsonValue(equipmentComparison.inventory, equipmentBeforeComparison.inventory),
|
|
`Expected candidate selection to open a non-destructive current/candidate comparison: ${JSON.stringify({ before: equipmentBeforeComparison, after: equipmentComparison })}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-equipment-comparison.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first camp equipment comparison');
|
|
|
|
await page.mouse.click(
|
|
equipmentComparison.comparison.cancelButtonBounds.x + equipmentComparison.comparison.cancelButtonBounds.width / 2,
|
|
equipmentComparison.comparison.cancelButtonBounds.y + equipmentComparison.comparison.cancelButtonBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.equipmentSwapComparison === null,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const equipmentAfterCancel = await page.evaluate((unitId) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const unit = state?.campaign?.roster?.find((candidate) => candidate.id === unitId);
|
|
return { equipment: structuredClone(unit?.equipment ?? {}), inventory: structuredClone(state?.campaign?.inventory ?? {}) };
|
|
}, equipmentBeforeComparison.unitId);
|
|
assert(
|
|
sameJsonValue(equipmentAfterCancel.equipment, equipmentBeforeComparison.equipment) &&
|
|
sameJsonValue(equipmentAfterCancel.inventory, equipmentBeforeComparison.inventory),
|
|
`Expected cancel to leave equipment and inventory untouched: ${JSON.stringify({ before: equipmentBeforeComparison, after: equipmentAfterCancel })}`
|
|
);
|
|
|
|
await page.mouse.click(
|
|
equipmentComparisonCandidate.actionButtonBounds.x + equipmentComparisonCandidate.actionButtonBounds.width / 2,
|
|
equipmentComparisonCandidate.actionButtonBounds.y + equipmentComparisonCandidate.actionButtonBounds.height / 2
|
|
);
|
|
const equipmentConfirmPoint = await readEquipmentSwapConfirmControlPoint(page);
|
|
await page.mouse.click(equipmentConfirmPoint.x, equipmentConfirmPoint.y);
|
|
await page.waitForFunction(
|
|
({ unitId, slot, candidateItemId }) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const unit = state?.campaign?.roster?.find((candidate) => candidate.id === unitId);
|
|
return state?.equipmentSwapComparison === null && unit?.equipment?.[slot]?.itemId === candidateItemId;
|
|
},
|
|
{
|
|
unitId: equipmentComparison.comparison.unitId,
|
|
slot: equipmentComparison.comparison.slot,
|
|
candidateItemId: equipmentComparison.comparison.candidateItemId
|
|
},
|
|
{ timeout: 30000 }
|
|
);
|
|
const confirmedEquipmentSwap = await page.evaluate(({ unitId, slot }) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.campaign?.roster?.find((candidate) => candidate.id === unitId)?.equipment?.[slot]?.itemId ?? null;
|
|
}, { unitId: equipmentComparison.comparison.unitId, slot: equipmentComparison.comparison.slot });
|
|
assert(
|
|
confirmedEquipmentSwap === equipmentComparison.comparison.candidateItemId,
|
|
`Expected the explicit confirm pointer to apply the candidate equipment: ${JSON.stringify({ comparison: equipmentComparison.comparison, confirmedEquipmentSwap })}`
|
|
);
|
|
|
|
await page.evaluate(({ unitId, slot, currentItemId }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
scene?.swapUnitEquipment?.(unitId, slot, currentItemId);
|
|
}, {
|
|
unitId: equipmentComparison.comparison.unitId,
|
|
slot: equipmentComparison.comparison.slot,
|
|
currentItemId: equipmentComparison.comparison.currentItemId
|
|
});
|
|
const equipmentRestoreConfirmPoint = await readEquipmentSwapConfirmControlPoint(page);
|
|
await page.mouse.click(equipmentRestoreConfirmPoint.x, equipmentRestoreConfirmPoint.y);
|
|
await page.waitForFunction(
|
|
({ unitId, slot, currentItemId }) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const unit = state?.campaign?.roster?.find((candidate) => candidate.id === unitId);
|
|
return state?.equipmentSwapComparison === null && unit?.equipment?.[slot]?.itemId === currentItemId;
|
|
},
|
|
{
|
|
unitId: equipmentComparison.comparison.unitId,
|
|
slot: equipmentComparison.comparison.slot,
|
|
currentItemId: equipmentComparison.comparison.currentItemId
|
|
},
|
|
{ timeout: 30000 }
|
|
);
|
|
await page.evaluate(() => new Promise((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
}));
|
|
const equipmentNextBounds = equipmentPaginationFixture.panel.nextButtonBounds;
|
|
await page.mouse.click(
|
|
equipmentNextBounds.x + equipmentNextBounds.width / 2,
|
|
equipmentNextBounds.y + equipmentNextBounds.height / 2
|
|
);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment?.page === 1, undefined, { timeout: 30000 });
|
|
const equipmentSecondPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment);
|
|
assert(
|
|
equipmentSecondPage?.visible === true &&
|
|
equipmentSecondPage.page === 1 &&
|
|
equipmentSecondPage.page === equipmentSecondPage.pageCount - 1 &&
|
|
equipmentSecondPage.previousEnabled === true &&
|
|
equipmentSecondPage.nextEnabled === false &&
|
|
equipmentSecondPage.visibleItemIds?.length > 0 &&
|
|
equipmentSecondPage.visibleItemIds.every((itemId) => !equipmentPaginationFixture.panel.visibleItemIds.includes(itemId)) &&
|
|
boundsInside(equipmentSecondPage.previousButtonBounds, equipmentSecondPage.bounds),
|
|
`Expected equipment pagination to reach distinct lower-priority items: ${JSON.stringify({ first: equipmentPaginationFixture.panel, second: equipmentSecondPage })}`
|
|
);
|
|
const equipmentClampedPage = await page.evaluate((originalInventory) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
if (!scene?.campaign) {
|
|
return null;
|
|
}
|
|
scene.campaign.inventory = { ...originalInventory };
|
|
scene.render();
|
|
return window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment ?? null;
|
|
}, equipmentPaginationFixture.originalInventory);
|
|
assert(
|
|
equipmentClampedPage?.page === Math.max(0, equipmentClampedPage?.pageCount - 1) &&
|
|
equipmentClampedPage?.page < equipmentClampedPage?.pageCount,
|
|
`Expected equipment pagination to clamp after the inventory shrinks: ${JSON.stringify(equipmentClampedPage)}`
|
|
);
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
if (!scene) {
|
|
return;
|
|
}
|
|
scene.equipmentInventoryPage = 0;
|
|
scene.activeTab = 'supplies';
|
|
scene.render();
|
|
});
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
scene?.showCampNotice?.('첫 번째 정비 알림');
|
|
scene?.time?.delayedCall(400, () => scene?.showCampNotice?.('두 번째 응답 알림'));
|
|
});
|
|
await page.waitForTimeout(1750);
|
|
const replacementNotice = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
return (scene?.dialogueObjects ?? [])
|
|
.filter((object) => object?.active)
|
|
.map((object) => object?.text)
|
|
.filter(Boolean);
|
|
});
|
|
assert(
|
|
replacementNotice.some((text) => text.includes('두 번째 응답 알림')),
|
|
`Expected a replacement camp notice to survive the earlier notice cleanup: ${JSON.stringify(replacementNotice)}`
|
|
);
|
|
await page.waitForFunction(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
return (scene?.dialogueObjects ?? []).filter((object) => object?.active).length === 0;
|
|
}, undefined, { timeout: 1500 });
|
|
|
|
const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? [];
|
|
const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? [];
|
|
await openCampSortiePrep(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-prep.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first camp sortie prep');
|
|
const firstSortieBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
firstSortieBriefingState?.stagedSortiePrep === true &&
|
|
firstSortieBriefingState?.sortiePrepStep === 'briefing' &&
|
|
firstSortieBriefingState?.sortiePrimaryAction?.kind === 'next' &&
|
|
firstSortieBriefingState?.sortiePrimaryAction?.nextStep === 'formation' &&
|
|
firstSortieBriefingState?.sortiePrimaryAction?.primary === true &&
|
|
firstSortieBriefingState?.sortiePrimaryAction?.interactive === true &&
|
|
firstSortieBriefingState?.sortiePrimaryAction?.lineWidth >= 2 &&
|
|
firstSortieBriefingState?.sortieNextActionGuide?.label?.includes('무장 편성으로 이동') &&
|
|
boundsWithinFhdViewport(firstSortieBriefingState?.sortieNextActionGuide?.bounds) &&
|
|
boundsWithinFhdViewport(firstSortieBriefingState?.sortiePrimaryAction?.bounds),
|
|
`Expected the first sortie to open at the staged battlefield briefing: ${JSON.stringify(firstSortieBriefingState)}`
|
|
);
|
|
await advanceSortiePrepStep(page, 'formation');
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-formation.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first camp sortie formation');
|
|
const firstSortieFormationState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const firstSortieSynergy = firstSortieFormationState?.sortieSynergyPreview;
|
|
assert(
|
|
firstSortieFormationState?.sortiePrimaryAction?.kind === 'next' &&
|
|
firstSortieFormationState.sortiePrimaryAction.nextStep === 'loadout' &&
|
|
firstSortieFormationState?.sortieNextActionGuide?.label?.includes('장비·보급 점검으로 이동') &&
|
|
boundsWithinFhdViewport(firstSortieFormationState?.sortieNextActionGuide?.bounds) &&
|
|
firstSortieSynergy?.activeBondCount === 3 &&
|
|
firstSortieSynergy?.coveredRoleCount === 3 &&
|
|
firstSortieSynergy?.strongestBond?.damageBonus === 9 &&
|
|
firstSortieSynergy?.strongestBond?.chainRate === 18 &&
|
|
firstSortieSynergy?.firstPursuitTrinityConfigured === true,
|
|
`Expected first sortie formation to expose its next action and preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieFormationState)}`
|
|
);
|
|
|
|
const expectedFirstSortiePursuitPairs = [
|
|
{
|
|
id: 'liu-bei__guan-yu',
|
|
unitIds: ['liu-bei', 'guan-yu'],
|
|
unitNames: ['유비', '관우'],
|
|
title: '도원결의',
|
|
level: 72,
|
|
chainRate: 18,
|
|
baseChainRate: 18,
|
|
core: false,
|
|
damageBonus: 9
|
|
},
|
|
{
|
|
id: 'liu-bei__zhang-fei',
|
|
unitIds: ['liu-bei', 'zhang-fei'],
|
|
unitNames: ['유비', '장비'],
|
|
title: '의형제',
|
|
level: 68,
|
|
chainRate: 8,
|
|
baseChainRate: 8,
|
|
core: false,
|
|
damageBonus: 9
|
|
},
|
|
{
|
|
id: 'guan-yu__zhang-fei',
|
|
unitIds: ['guan-yu', 'zhang-fei'],
|
|
unitNames: ['관우', '장비'],
|
|
title: '맹장 공명',
|
|
level: 64,
|
|
chainRate: 8,
|
|
baseChainRate: 8,
|
|
core: false,
|
|
damageBonus: 9
|
|
}
|
|
];
|
|
const firstSortiePursuit = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview);
|
|
const logicalViewportBounds = desktopBrowserViewportBounds;
|
|
assert(
|
|
sameJsonValue(firstSortiePursuit?.activePairs, expectedFirstSortiePursuitPairs) &&
|
|
firstSortiePursuit?.selectableOpportunities?.length === 0 &&
|
|
firstSortiePursuit?.selectedCoreBondId === null &&
|
|
firstSortiePursuit?.selectionBattleId === null &&
|
|
sameJsonValue(
|
|
firstSortiePursuit?.coreCandidates?.map((candidate) => ({
|
|
id: candidate.id,
|
|
level: candidate.level,
|
|
baseChainRate: candidate.baseChainRate,
|
|
coreChainRate: candidate.coreChainRate,
|
|
selected: candidate.selected
|
|
})),
|
|
[
|
|
{ id: 'liu-bei__guan-yu', level: 72, baseChainRate: 18, coreChainRate: 28, selected: false },
|
|
{ id: 'liu-bei__zhang-fei', level: 68, baseChainRate: 8, coreChainRate: 18, selected: false },
|
|
{ id: 'guan-yu__zhang-fei', level: 64, baseChainRate: 8, coreChainRate: 18, selected: false }
|
|
]
|
|
),
|
|
`Expected the selected founding trio to expose ordered 18/8/8 resonance-pursuit pairs without reserve opportunities: ${JSON.stringify(firstSortiePursuit)}`
|
|
);
|
|
assert(
|
|
firstSortiePursuit?.summaryText === '핵심 공명조 · 미지정 · 후보 3조' &&
|
|
firstSortiePursuit?.ruleText === 'Lv30+ 한 조 · 핵심 추격 8/18/28% · 재클릭 해제' &&
|
|
isFiniteBounds(firstSortiePursuit.panelBounds) &&
|
|
isFiniteBounds(firstSortiePursuit.ruleBounds) &&
|
|
firstSortiePursuit.page === 0 &&
|
|
firstSortiePursuit.pageCount === 1 &&
|
|
firstSortiePursuit.pageText === '1/1' &&
|
|
firstSortiePursuit.prevEnabled === false &&
|
|
firstSortiePursuit.nextEnabled === false &&
|
|
isFiniteBounds(firstSortiePursuit.prevBounds) &&
|
|
isFiniteBounds(firstSortiePursuit.nextBounds) &&
|
|
firstSortiePursuit.prevClickBounds === null &&
|
|
firstSortiePursuit.nextClickBounds === null &&
|
|
boundsInside(firstSortiePursuit.prevBounds, firstSortiePursuit.panelBounds) &&
|
|
boundsInside(firstSortiePursuit.nextBounds, firstSortiePursuit.panelBounds) &&
|
|
boundsInside(firstSortiePursuit.panelBounds, logicalViewportBounds) &&
|
|
boundsInside(firstSortiePursuit.ruleBounds, firstSortiePursuit.panelBounds) &&
|
|
sameJsonValue(firstSortiePursuit.rows?.map((row) => ({ bondId: row.bondId, state: row.state, selected: row.selected, chainRate: row.chainRate, text: row.text })), [
|
|
{ bondId: 'liu-bei__guan-yu', state: 'candidate', selected: false, chainRate: 28, text: '지정 28% 유비↔관우' },
|
|
{ bondId: 'liu-bei__zhang-fei', state: 'candidate', selected: false, chainRate: 18, text: '지정 18% 유비↔장비' },
|
|
{ bondId: 'guan-yu__zhang-fei', state: 'candidate', selected: false, chainRate: 18, text: '지정 18% 관우↔장비' }
|
|
]) &&
|
|
firstSortiePursuit.rows.every(
|
|
(row) =>
|
|
isFiniteBounds(row.bounds) &&
|
|
isFiniteBounds(row.chipBounds) &&
|
|
isFiniteBounds(row.textBounds) &&
|
|
sameJsonValue(row.bounds, row.chipBounds) &&
|
|
boundsInside(row.bounds, firstSortiePursuit.panelBounds) &&
|
|
boundsInside(row.textBounds, row.bounds, 2)
|
|
),
|
|
`Expected all three interactive core-resonance chips and their labels to remain visible inside the first-sortie panel: ${JSON.stringify(firstSortiePursuit)}`
|
|
);
|
|
|
|
const coreResonanceToggleTarget = firstSortiePursuit.rows.find(
|
|
(row) => row.bondId === 'liu-bei__zhang-fei'
|
|
);
|
|
assert(
|
|
isFiniteBounds(coreResonanceToggleTarget?.chipBounds),
|
|
`Expected the lower-level brother pair to expose an interactive core-resonance chip: ${JSON.stringify(coreResonanceToggleTarget)}`
|
|
);
|
|
await page.mouse.click(
|
|
coreResonanceToggleTarget.chipBounds.x + coreResonanceToggleTarget.chipBounds.width / 2,
|
|
coreResonanceToggleTarget.chipBounds.y + coreResonanceToggleTarget.chipBounds.height / 2
|
|
);
|
|
await page.waitForFunction(() => {
|
|
const pursuit = window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview;
|
|
return pursuit?.selectedCoreBondId === 'liu-bei__zhang-fei' &&
|
|
pursuit?.rows?.some((row) => row.bondId === 'liu-bei__zhang-fei' && row.selected === true);
|
|
}, undefined, { timeout: 30000 });
|
|
const selectedCoreResonance = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview);
|
|
const selectedCoreResonanceSave = await readCampaignSave(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-core-resonance-selected.png`, fullPage: true });
|
|
assert(
|
|
selectedCoreResonance?.selectionBattleId === 'second-battle-yellow-turban-pursuit' &&
|
|
selectedCoreResonance?.selectedCoreBondId === 'liu-bei__zhang-fei' &&
|
|
selectedCoreResonance?.summaryText === '핵심 공명조 · 지정 · 후보 3조' &&
|
|
selectedCoreResonance?.activePairs?.[0]?.id === 'liu-bei__zhang-fei' &&
|
|
selectedCoreResonance.activePairs[0].core === true &&
|
|
selectedCoreResonance.activePairs[0].baseChainRate === 8 &&
|
|
selectedCoreResonance.activePairs[0].chainRate === 18 &&
|
|
selectedCoreResonance?.coreCandidates?.[0]?.id === 'liu-bei__zhang-fei' &&
|
|
selectedCoreResonance.coreCandidates[0].selected === true &&
|
|
selectedCoreResonance?.rows?.[0]?.bondId === 'liu-bei__zhang-fei' &&
|
|
selectedCoreResonance.rows[0].state === 'selected' &&
|
|
selectedCoreResonance.rows[0].text === '핵심 18% 유비↔장비' &&
|
|
selectedCoreResonanceSave.current?.sortieResonanceSelection?.battleId === 'second-battle-yellow-turban-pursuit' &&
|
|
selectedCoreResonanceSave.current.sortieResonanceSelection.bondId === 'liu-bei__zhang-fei',
|
|
`Expected one clicked pair to become the persisted, prioritized 18% core resonance: ${JSON.stringify({ selectedCoreResonance, save: selectedCoreResonanceSave.current?.sortieResonanceSelection })}`
|
|
);
|
|
|
|
const coreResonanceAutoClearFixture = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
if (!scene || typeof scene.toggleSortieUnit !== 'function') {
|
|
return { ready: false };
|
|
}
|
|
const before = {
|
|
selectedUnitIds: [...scene.selectedSortieUnitIds],
|
|
formationAssignments: { ...scene.sortieFormationAssignments },
|
|
itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}),
|
|
focusedUnitId: scene.sortieFocusedUnitId ?? null,
|
|
planFeedback: scene.sortiePlanFeedback,
|
|
rosterScroll: scene.sortieRosterScroll
|
|
};
|
|
scene.toggleSortieUnit('zhang-fei');
|
|
return { ready: true, before };
|
|
});
|
|
await page.waitForFunction(
|
|
() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortiePursuitPreview?.selectedCoreBondId === null &&
|
|
!state?.selectedSortieUnitIds?.includes('zhang-fei');
|
|
},
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const clearedCoreResonance = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview);
|
|
const clearedCoreCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const clearedCoreResonanceSave = await readCampaignSave(page);
|
|
assert(
|
|
coreResonanceAutoClearFixture.ready === true &&
|
|
clearedCoreResonance?.selectedCoreBondId === null &&
|
|
clearedCoreResonance?.activePairs?.every((pair) => pair.core === false) &&
|
|
clearedCoreResonance?.rows?.every((row) => row.selected === false && row.state === 'candidate') &&
|
|
!clearedCoreCampState?.selectedSortieUnitIds?.includes('zhang-fei') &&
|
|
clearedCoreCampState?.sortiePlanFeedback?.includes('핵심 공명조 자동 해제') &&
|
|
clearedCoreResonanceSave.current?.sortieResonanceSelection === undefined,
|
|
`Expected removing a designated member to clear the persisted core pair and retain ordinary pursuit behavior: ${JSON.stringify({ clearedCoreCampState, clearedCoreResonance, save: clearedCoreResonanceSave.current?.sortieResonanceSelection })}`
|
|
);
|
|
await page.evaluate((before) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
scene.selectedSortieUnitIds = [...before.selectedUnitIds];
|
|
scene.sortieFormationAssignments = { ...before.formationAssignments };
|
|
scene.sortieItemAssignments = structuredClone(before.itemAssignments);
|
|
scene.sortieFocusedUnitId = before.focusedUnitId ?? undefined;
|
|
scene.sortiePlanFeedback = before.planFeedback;
|
|
scene.sortieRosterScroll = before.rosterScroll;
|
|
scene.persistSortieSelection();
|
|
scene.showSortiePrep();
|
|
}, coreResonanceAutoClearFixture.before);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.selectedSortieUnitIds?.includes('zhang-fei') === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const firstSortiePursuitSaveBefore = await readCampaignSave(page);
|
|
const firstSortiePursuitMutation = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
if (
|
|
!scene ||
|
|
typeof scene.persistSortieSelection !== 'function' ||
|
|
typeof scene.showSortiePrep !== 'function'
|
|
) {
|
|
return { ready: false };
|
|
}
|
|
|
|
const before = {
|
|
selectedUnitIds: [...scene.selectedSortieUnitIds],
|
|
formationAssignments: { ...scene.sortieFormationAssignments },
|
|
itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}),
|
|
focusedUnitId: scene.sortieFocusedUnitId ?? null,
|
|
planFeedback: scene.sortiePlanFeedback,
|
|
rosterScroll: scene.sortieRosterScroll
|
|
};
|
|
scene.selectedSortieUnitIds = scene.selectedSortieUnitIds.filter((unitId) => unitId !== 'guan-yu');
|
|
const formationAssignments = { ...scene.sortieFormationAssignments };
|
|
delete formationAssignments['guan-yu'];
|
|
scene.sortieFormationAssignments = formationAssignments;
|
|
const itemAssignments = structuredClone(scene.sortieItemAssignments ?? {});
|
|
delete itemAssignments['guan-yu'];
|
|
scene.sortieItemAssignments = itemAssignments;
|
|
scene.sortieFocusedUnitId = 'guan-yu';
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
ready: true,
|
|
before,
|
|
after: {
|
|
selectedUnitIds: state?.selectedSortieUnitIds,
|
|
formationAssignments: state?.sortieFormationAssignments,
|
|
itemAssignments: state?.sortieItemAssignments,
|
|
focusedUnitId: state?.sortieFocusedUnitId,
|
|
pursuit: state?.sortiePursuitPreview,
|
|
focusedSynergy: state?.sortieFocusedSynergyPreview
|
|
}
|
|
};
|
|
});
|
|
const expectedGuanYuPursuitOpportunities = [
|
|
{
|
|
...expectedFirstSortiePursuitPairs[0],
|
|
selectedPartnerId: 'liu-bei',
|
|
selectedPartnerName: '유비',
|
|
candidateUnitId: 'guan-yu',
|
|
candidateUnitName: '관우'
|
|
},
|
|
{
|
|
...expectedFirstSortiePursuitPairs[2],
|
|
selectedPartnerId: 'zhang-fei',
|
|
selectedPartnerName: '장비',
|
|
candidateUnitId: 'guan-yu',
|
|
candidateUnitName: '관우'
|
|
}
|
|
];
|
|
assert(
|
|
firstSortiePursuitMutation.ready === true &&
|
|
!firstSortiePursuitMutation.after?.selectedUnitIds?.includes('guan-yu') &&
|
|
firstSortiePursuitMutation.after?.focusedUnitId === 'guan-yu' &&
|
|
!Object.prototype.hasOwnProperty.call(firstSortiePursuitMutation.after?.formationAssignments ?? {}, 'guan-yu') &&
|
|
!Object.prototype.hasOwnProperty.call(firstSortiePursuitMutation.after?.itemAssignments ?? {}, 'guan-yu'),
|
|
`Expected the RC fixture to bench Guan Yu and clear only his live role and supply assignments: ${JSON.stringify(firstSortiePursuitMutation)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(firstSortiePursuitMutation.after?.pursuit?.activePairs, [expectedFirstSortiePursuitPairs[1]]) &&
|
|
sameJsonValue(
|
|
firstSortiePursuitMutation.after?.pursuit?.selectableOpportunities,
|
|
expectedGuanYuPursuitOpportunities
|
|
),
|
|
`Expected benching Guan Yu to leave one active 8% pursuit and expose his 18%/8% partner opportunities: ${JSON.stringify(firstSortiePursuitMutation.after?.pursuit)}`
|
|
);
|
|
assert(
|
|
firstSortiePursuitMutation.after?.focusedSynergy?.mode === 'add' &&
|
|
firstSortiePursuitMutation.after.focusedSynergy.comparison?.activeBondDelta === 2 &&
|
|
sameJsonValue(
|
|
firstSortiePursuitMutation.after.focusedSynergy.comparison.gainedBonds?.map((bond) => bond.id),
|
|
['liu-bei__guan-yu', 'guan-yu__zhang-fei']
|
|
) &&
|
|
firstSortiePursuitMutation.after.focusedSynergy.comparison.lostBonds?.length === 0 &&
|
|
sameJsonValue(
|
|
firstSortiePursuitMutation.after.focusedSynergy.projected?.activeBonds?.map((bond) => bond.id),
|
|
expectedFirstSortiePursuitPairs.map((pair) => pair.id)
|
|
),
|
|
`Expected Guan Yu's focused add projection to restore both pursuit bonds and the full trio: ${JSON.stringify(firstSortiePursuitMutation.after?.focusedSynergy)}`
|
|
);
|
|
|
|
const firstSortiePursuitRemoveProjection = await page.evaluate((before) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') {
|
|
return { ready: false };
|
|
}
|
|
const scenario = scene.nextSortieScenario();
|
|
const current = scene.sortieSynergySnapshot(before.selectedUnitIds, scenario, before.formationAssignments);
|
|
const projectedUnitIds = before.selectedUnitIds.filter((unitId) => unitId !== 'guan-yu');
|
|
const projectedFormationAssignments = { ...before.formationAssignments };
|
|
delete projectedFormationAssignments['guan-yu'];
|
|
const projected = scene.sortieSynergySnapshot(projectedUnitIds, scenario, projectedFormationAssignments);
|
|
const pursuitChanges = scene.changedSortiePursuitPairs(current, projected);
|
|
|
|
scene.selectedSortieUnitIds = [...before.selectedUnitIds];
|
|
scene.sortieFormationAssignments = { ...before.formationAssignments };
|
|
scene.sortieItemAssignments = structuredClone(before.itemAssignments);
|
|
scene.sortieFocusedUnitId = before.focusedUnitId ?? undefined;
|
|
scene.sortiePlanFeedback = before.planFeedback;
|
|
scene.sortieRosterScroll = before.rosterScroll;
|
|
scene.persistSortieSelection();
|
|
scene.showSortiePrep();
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
ready: true,
|
|
selectedUnitIds: state?.selectedSortieUnitIds,
|
|
pursuit: state?.sortiePursuitPreview,
|
|
removeProjection: {
|
|
currentPairIds: pursuitChanges.currentPairs.map((pair) => pair.id),
|
|
projectedPairIds: pursuitChanges.projectedPairs.map((pair) => pair.id),
|
|
gainedPairIds: pursuitChanges.gainedPairs.map((pair) => pair.id),
|
|
lostPairIds: pursuitChanges.lostPairs.map((pair) => pair.id),
|
|
activePairDelta: pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length
|
|
}
|
|
};
|
|
}, firstSortiePursuitMutation.before);
|
|
assert(
|
|
firstSortiePursuitRemoveProjection.ready === true &&
|
|
sameJsonValue(firstSortiePursuitRemoveProjection.selectedUnitIds, firstSortiePursuitMutation.before?.selectedUnitIds) &&
|
|
sameJsonValue(firstSortiePursuitRemoveProjection.pursuit?.activePairs, expectedFirstSortiePursuitPairs) &&
|
|
firstSortiePursuitRemoveProjection.pursuit?.selectableOpportunities?.length === 0 &&
|
|
sameJsonValue(
|
|
firstSortiePursuitRemoveProjection.removeProjection?.currentPairIds,
|
|
expectedFirstSortiePursuitPairs.map((pair) => pair.id)
|
|
) &&
|
|
sameJsonValue(firstSortiePursuitRemoveProjection.removeProjection?.projectedPairIds, ['liu-bei__zhang-fei']) &&
|
|
firstSortiePursuitRemoveProjection.removeProjection?.activePairDelta === -2 &&
|
|
sameJsonValue(
|
|
firstSortiePursuitRemoveProjection.removeProjection?.lostPairIds,
|
|
['liu-bei__guan-yu', 'guan-yu__zhang-fei']
|
|
) &&
|
|
firstSortiePursuitRemoveProjection.removeProjection?.gainedPairIds?.length === 0,
|
|
`Expected the restored trio to preview exactly the two pursuit pairs lost by removing Guan Yu: ${JSON.stringify(firstSortiePursuitRemoveProjection)}`
|
|
);
|
|
|
|
const firstSortiePursuitRestored = await page.evaluate((before) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') {
|
|
return { ready: false };
|
|
}
|
|
scene.selectedSortieUnitIds = [...before.selectedUnitIds];
|
|
scene.sortieFormationAssignments = { ...before.formationAssignments };
|
|
scene.sortieItemAssignments = structuredClone(before.itemAssignments);
|
|
scene.sortieFocusedUnitId = before.focusedUnitId ?? undefined;
|
|
scene.sortiePlanFeedback = before.planFeedback;
|
|
scene.sortieRosterScroll = before.rosterScroll;
|
|
scene.persistSortieSelection();
|
|
scene.showSortiePrep();
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
ready: true,
|
|
selectedUnitIds: state?.selectedSortieUnitIds,
|
|
formationAssignments: state?.sortieFormationAssignments,
|
|
itemAssignments: state?.sortieItemAssignments,
|
|
focusedUnitId: state?.sortieFocusedUnitId ?? null,
|
|
planFeedback: state?.sortiePlanFeedback,
|
|
rosterScroll: scene.sortieRosterScroll,
|
|
deploymentPreview: state?.sortieDeploymentPreview,
|
|
pursuit: state?.sortiePursuitPreview
|
|
};
|
|
}, firstSortiePursuitMutation.before);
|
|
const firstSortiePursuitSaveRestored = await readCampaignSave(page);
|
|
assert(
|
|
firstSortiePursuitRestored.ready === true &&
|
|
sameJsonValue(firstSortiePursuitRestored.selectedUnitIds, firstSortiePursuitMutation.before?.selectedUnitIds) &&
|
|
sameJsonValue(firstSortiePursuitRestored.formationAssignments, firstSortiePursuitMutation.before?.formationAssignments) &&
|
|
sameJsonValue(firstSortiePursuitRestored.itemAssignments, firstSortiePursuitMutation.before?.itemAssignments) &&
|
|
firstSortiePursuitRestored.focusedUnitId === firstSortiePursuitMutation.before?.focusedUnitId &&
|
|
firstSortiePursuitRestored.planFeedback === firstSortiePursuitMutation.before?.planFeedback &&
|
|
firstSortiePursuitRestored.rosterScroll === firstSortiePursuitMutation.before?.rosterScroll &&
|
|
sameJsonValue(firstSortiePursuitRestored.deploymentPreview, firstCampDeploymentPreview) &&
|
|
sameJsonValue(firstSortiePursuitRestored.pursuit?.activePairs, expectedFirstSortiePursuitPairs),
|
|
`Expected the pursuit RC fixture to restore the exact live formation, roles, supplies, focus, feedback, scroll, and deployment: ${JSON.stringify(firstSortiePursuitRestored)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(
|
|
firstSortiePursuitSaveRestored.current?.selectedSortieUnitIds,
|
|
firstSortiePursuitSaveBefore.current?.selectedSortieUnitIds
|
|
) &&
|
|
sameJsonValue(
|
|
firstSortiePursuitSaveRestored.current?.sortieFormationAssignments,
|
|
firstSortiePursuitSaveBefore.current?.sortieFormationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstSortiePursuitSaveRestored.current?.sortieItemAssignments,
|
|
firstSortiePursuitSaveBefore.current?.sortieItemAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstSortiePursuitSaveRestored.slot1?.selectedSortieUnitIds,
|
|
firstSortiePursuitSaveBefore.slot1?.selectedSortieUnitIds
|
|
) &&
|
|
sameJsonValue(
|
|
firstSortiePursuitSaveRestored.slot1?.sortieFormationAssignments,
|
|
firstSortiePursuitSaveBefore.slot1?.sortieFormationAssignments
|
|
) &&
|
|
sameJsonValue(
|
|
firstSortiePursuitSaveRestored.slot1?.sortieItemAssignments,
|
|
firstSortiePursuitSaveBefore.slot1?.sortieItemAssignments
|
|
),
|
|
`Expected the pursuit RC fixture to restore current and slot-1 sortie saves before launch: ${JSON.stringify(firstSortiePursuitSaveRestored)}`
|
|
);
|
|
|
|
const launchCoreResonanceRow = firstSortiePursuitRestored.pursuit?.rows?.find(
|
|
(row) => row.bondId === 'liu-bei__zhang-fei'
|
|
);
|
|
assert(
|
|
isFiniteBounds(launchCoreResonanceRow?.chipBounds),
|
|
`Expected the restored formation to keep the Liu Bei/Zhang Fei core-resonance control available: ${JSON.stringify(firstSortiePursuitRestored.pursuit)}`
|
|
);
|
|
await page.mouse.click(
|
|
launchCoreResonanceRow.chipBounds.x + launchCoreResonanceRow.chipBounds.width / 2,
|
|
launchCoreResonanceRow.chipBounds.y + launchCoreResonanceRow.chipBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectedCoreBondId === 'liu-bei__zhang-fei',
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const launchCoreResonance = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview);
|
|
assert(
|
|
launchCoreResonance?.activePairs?.[0]?.id === 'liu-bei__zhang-fei' &&
|
|
launchCoreResonance.activePairs[0].core === true &&
|
|
launchCoreResonance.activePairs[0].chainRate === 18,
|
|
`Expected the lower legacy-rate pair to be promoted and prioritized before battle launch: ${JSON.stringify(launchCoreResonance)}`
|
|
);
|
|
await advanceSortiePrepStep(page, 'loadout');
|
|
await clickLegacyUi(page, 1116, 656);
|
|
await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit');
|
|
await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'second battle from first camp');
|
|
|
|
const secondBattleProbe = await readBattleDoctrineProbe(page);
|
|
assert(secondBattleProbe?.battleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie to enter second battle: ${JSON.stringify(secondBattleProbe)}`);
|
|
assert(
|
|
secondBattleProbe?.sortieOperationOrder?.selectedId === 'elite' && secondBattleProbe.sortieOperationOrder.result === null,
|
|
`Expected the explicitly declared elite order to reach the next battle unchanged: ${JSON.stringify(secondBattleProbe?.sortieOperationOrder)}`
|
|
);
|
|
await assertLiveSortieOrderHud(page, {
|
|
battleId: 'second-battle-yellow-turban-pursuit',
|
|
orderId: 'elite',
|
|
context: 'second battle'
|
|
});
|
|
assertSameMembers(
|
|
secondBattleProbe?.selectedSortieUnitIds,
|
|
firstCampSelectedSortieUnitIds,
|
|
`Expected second battle to receive first camp selected sortie ids: ${JSON.stringify(secondBattleProbe?.selectedSortieUnitIds)} / ${JSON.stringify(firstCampSelectedSortieUnitIds)}`
|
|
);
|
|
assertSameMembers(
|
|
secondBattleProbe?.deployedAllyIds,
|
|
firstCampSelectedSortieUnitIds,
|
|
`Expected second battle deployed allies to match first camp sortie ids: ${JSON.stringify(secondBattleProbe?.deployedAllyIds)} / ${JSON.stringify(firstCampSelectedSortieUnitIds)}`
|
|
);
|
|
assertDeploymentMatchesPreview(
|
|
secondBattleProbe?.deployedAllyPositions,
|
|
firstCampDeploymentPreview,
|
|
`Expected second battle ally positions to match first camp deployment preview: ${JSON.stringify(secondBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(firstCampDeploymentPreview)}`
|
|
);
|
|
assertSortieDoctrine(secondBattleProbe, { requireFullCoverage: true, requireVisibleSeals: true });
|
|
assert(
|
|
secondBattleProbe.firstSortieRoleEffects?.active === true &&
|
|
secondBattleProbe.firstSortieRoleEffects?.fullCoverage === true &&
|
|
secondBattleProbe.firstSortieRoleEffects?.trinityActive === true &&
|
|
secondBattleProbe.firstSortieRoleEffects?.trinitySupportRange === 2,
|
|
`Expected the second battle to retain its three-role Peach Garden trinity special: ${JSON.stringify(secondBattleProbe.firstSortieRoleEffects)}`
|
|
);
|
|
assert(
|
|
secondBattleProbe.sortieDoctrine?.roles?.every((role) => role.unitIds.length === 1) &&
|
|
secondBattleProbe.sortieDoctrine?.activeBondCount === 3,
|
|
`Expected the second battle doctrine to expose one officer per role and all three active bonds: ${JSON.stringify(secondBattleProbe.sortieDoctrine)}`
|
|
);
|
|
assert(
|
|
secondBattleProbe.coreSortieResonance?.selected === true &&
|
|
secondBattleProbe.coreSortieResonance.selectedBondId === 'liu-bei__zhang-fei' &&
|
|
secondBattleProbe.coreSortieResonance.valid === true &&
|
|
secondBattleProbe.coreSortieResonance.active === true &&
|
|
secondBattleProbe.coreSortieResonance.level === 68 &&
|
|
secondBattleProbe.coreSortieResonance.rate === 18 &&
|
|
sameJsonValue(secondBattleProbe.coreSortieResonance.unitIds, ['liu-bei', 'zhang-fei']) &&
|
|
sameJsonValue(secondBattleProbe.coreSortieResonance.stats, {
|
|
attempts: 0,
|
|
successes: 0,
|
|
failures: 0,
|
|
additionalDamage: 0
|
|
}) &&
|
|
sameJsonValue(secondBattleProbe.sortieResonance, secondBattleProbe.coreSortieResonance),
|
|
`Expected the camp designation to reach battle as one active 18% core resonance with clean statistics: ${JSON.stringify(secondBattleProbe.coreSortieResonance)}`
|
|
);
|
|
const secondBattleCorePriority = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const attacker = scene?.debugUnitById('liu-bei');
|
|
const corePartner = scene?.debugUnitById('zhang-fei');
|
|
const ordinaryPartner = scene?.debugUnitById('guan-yu');
|
|
const targetState = window.__HEROS_DEBUG__?.battle()?.units?.find((unit) => unit.faction === 'enemy' && unit.hp > 0);
|
|
const target = targetState ? scene?.debugUnitById(targetState.id) : undefined;
|
|
if (!scene || !attacker || !corePartner || !ordinaryPartner || !target) {
|
|
return { ready: false };
|
|
}
|
|
|
|
const touchedUnits = [attacker, corePartner, ordinaryPartner, target];
|
|
const original = {
|
|
units: touchedUnits.map((unit) => ({ unit, x: unit.x, y: unit.y })),
|
|
attackIntents: scene.attackIntents.map((intent) => ({ ...intent })),
|
|
actedUnitIds: new Set(scene.actedUnitIds),
|
|
targetHp: target.hp,
|
|
battleStats: new Map(
|
|
[...scene.battleStats.entries()].map(([unitId, stats]) => [unitId, structuredClone(stats)])
|
|
)
|
|
};
|
|
try {
|
|
Object.assign(attacker, { x: 5, y: 5 });
|
|
Object.assign(corePartner, { x: 5, y: 6 });
|
|
Object.assign(ordinaryPartner, { x: 4, y: 5 });
|
|
Object.assign(target, { x: 6, y: 5 });
|
|
scene.attackIntents = [
|
|
{ attackerId: corePartner.id, targetId: target.id },
|
|
{ attackerId: ordinaryPartner.id, targetId: target.id }
|
|
];
|
|
scene.actedUnitIds = new Set([...original.actedUnitIds, corePartner.id, ordinaryPartner.id]);
|
|
const candidate = scene.activeBondChainCandidate(attacker, target, 'attack');
|
|
const preview = scene.combatPreview(attacker, target, 'attack');
|
|
const failedResolution = scene.resolveBondChainFollowUp(preview, true, false);
|
|
const successfulResolution = scene.resolveBondChainFollowUp(preview, true, true);
|
|
const coreStats = window.__HEROS_DEBUG__?.battle()?.coreSortieResonance?.stats ?? null;
|
|
const pursuitTriggers = scene.statsFor(attacker.id).sortieExtendedBondTriggers;
|
|
return {
|
|
ready: true,
|
|
candidate: candidate ? {
|
|
bondId: candidate.bondId,
|
|
partnerId: candidate.partner.id,
|
|
rate: candidate.rate,
|
|
coreResonance: candidate.coreResonance,
|
|
label: candidate.label
|
|
} : null,
|
|
preview: {
|
|
bondId: preview.bondChainBondId ?? null,
|
|
partnerId: preview.bondChainPartnerId ?? null,
|
|
rate: preview.bondChainRate,
|
|
coreResonance: preview.bondChainCoreResonance,
|
|
label: preview.bondChainLabel ?? null
|
|
},
|
|
failedResolution: failedResolution ? {
|
|
succeeded: failedResolution.attempt.succeeded,
|
|
coreResonance: failedResolution.attempt.coreResonance,
|
|
hasResult: Boolean(failedResolution.result)
|
|
} : null,
|
|
successfulResolution: successfulResolution ? {
|
|
succeeded: successfulResolution.attempt.succeeded,
|
|
coreResonance: successfulResolution.attempt.coreResonance,
|
|
damage: successfulResolution.result?.damage ?? 0
|
|
} : null,
|
|
coreStats,
|
|
pursuitTriggers
|
|
};
|
|
} finally {
|
|
original.units.forEach(({ unit, x, y }) => Object.assign(unit, { x, y }));
|
|
target.hp = original.targetHp;
|
|
scene.battleStats = original.battleStats;
|
|
scene.attackIntents = original.attackIntents;
|
|
scene.actedUnitIds = original.actedUnitIds;
|
|
}
|
|
});
|
|
assert(
|
|
secondBattleCorePriority.ready === true &&
|
|
secondBattleCorePriority.candidate?.bondId === 'liu-bei__zhang-fei' &&
|
|
secondBattleCorePriority.candidate.partnerId === 'zhang-fei' &&
|
|
secondBattleCorePriority.candidate.rate === 18 &&
|
|
secondBattleCorePriority.candidate.coreResonance === true &&
|
|
secondBattleCorePriority.candidate.label.includes('핵심 공명') &&
|
|
secondBattleCorePriority.preview?.bondId === 'liu-bei__zhang-fei' &&
|
|
secondBattleCorePriority.preview.partnerId === 'zhang-fei' &&
|
|
secondBattleCorePriority.preview.rate === 18 &&
|
|
secondBattleCorePriority.preview.coreResonance === true &&
|
|
secondBattleCorePriority.failedResolution?.succeeded === false &&
|
|
secondBattleCorePriority.failedResolution.coreResonance === true &&
|
|
secondBattleCorePriority.failedResolution.hasResult === false &&
|
|
secondBattleCorePriority.successfulResolution?.succeeded === true &&
|
|
secondBattleCorePriority.successfulResolution.coreResonance === true &&
|
|
secondBattleCorePriority.successfulResolution.damage > 0 &&
|
|
secondBattleCorePriority.pursuitTriggers === 1 &&
|
|
sameJsonValue(secondBattleCorePriority.coreStats, {
|
|
attempts: 2,
|
|
successes: 1,
|
|
failures: 1,
|
|
additionalDamage: secondBattleCorePriority.successfulResolution.damage
|
|
}),
|
|
`Expected the designated Lv68 pair to outrank Liu Bei's ordinary Lv72 pursuit candidate: ${JSON.stringify(secondBattleCorePriority)}`
|
|
);
|
|
assert(
|
|
secondBattleProbe.openingBannerTexts?.some((text) => text.includes('세 형제 생존 중 공명 지원 거리 2칸')) &&
|
|
secondBattleProbe.openingBannerTexts?.some((text) => text.includes('전술 명령')),
|
|
`Expected the second battle opening banner to retain trinity and tactical-command guidance: ${JSON.stringify(secondBattleProbe.openingBannerTexts)}`
|
|
);
|
|
const roleSealResyncProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
if (!scene) {
|
|
return undefined;
|
|
}
|
|
const officerIds = ['liu-bei', 'guan-yu', 'zhang-fei'];
|
|
const campaignCurrentKey = 'heros-web:campaign-state';
|
|
const campaignSlotKey = `${campaignCurrentKey}:slot-1`;
|
|
const battleKey = 'heros-web:battle:second-battle-yellow-turban-pursuit';
|
|
const storageKeys = [campaignCurrentKey, campaignSlotKey, battleKey, `${battleKey}:slot-1`, 'heros-web:first-battle-state'];
|
|
const storageSnapshot = new Map(storageKeys.map((key) => [key, window.localStorage.getItem(key)]));
|
|
const loadDiagnostics = [];
|
|
const captureUnits = () => {
|
|
const byId = new Map((window.__HEROS_DEBUG__?.battle()?.units ?? []).map((unit) => [unit.id, unit]));
|
|
return officerIds.map((id) => {
|
|
const unit = byId.get(id);
|
|
return {
|
|
id,
|
|
role: unit?.formationRole ?? null,
|
|
effect: unit?.sortieRoleEffect?.label ?? null,
|
|
seal: unit?.roleSealText ?? null,
|
|
sealPresent: unit?.roleSealPresent ?? false,
|
|
sealVisible: unit?.roleSealVisible ?? false
|
|
};
|
|
});
|
|
};
|
|
const loadAssignments = (savedCampaignRaw, assignments) => {
|
|
const campaign = JSON.parse(savedCampaignRaw);
|
|
campaign.sortieFormationAssignments = assignments;
|
|
delete campaign.sortieResonanceSelection;
|
|
window.localStorage.setItem(campaignSlotKey, JSON.stringify(campaign));
|
|
const battleRaw = window.localStorage.getItem(`${battleKey}:slot-1`) ?? window.localStorage.getItem(battleKey);
|
|
const savedBattle = battleRaw ? JSON.parse(battleRaw) : null;
|
|
loadDiagnostics.push({
|
|
assignments,
|
|
persistedAssignments: JSON.parse(window.localStorage.getItem(campaignSlotKey))?.sortieFormationAssignments ?? null,
|
|
battleSavePresent: Boolean(savedBattle),
|
|
battleSaveReadable: Boolean(scene.readBattleSaveState?.(1)),
|
|
tacticalCommand: savedBattle?.tacticalCommand ?? null,
|
|
sortieResonanceBondId: savedBattle?.sortieResonanceBondId ?? null
|
|
});
|
|
scene.launchSortieResonanceBondId = undefined;
|
|
scene.coreResonancePursuitAttempts = 0;
|
|
scene.coreResonancePursuitSuccesses = 0;
|
|
scene.coreResonancePursuitFailures = 0;
|
|
scene.coreResonancePursuitDamage = 0;
|
|
scene.loadBattleState?.(1);
|
|
};
|
|
|
|
scene.saveBattleState?.(1);
|
|
const savedBattleRaw = window.localStorage.getItem(`${battleKey}:slot-1`) ?? window.localStorage.getItem(battleKey);
|
|
const savedBattle = savedBattleRaw ? JSON.parse(savedBattleRaw) : null;
|
|
const battleSaveReadableAfterSave = Boolean(scene.readBattleSaveState?.(1));
|
|
const savedBattleBonds = structuredClone(savedBattle?.bonds ?? []);
|
|
const savedCampaignRaw = window.localStorage.getItem(campaignSlotKey);
|
|
if (!savedCampaignRaw) {
|
|
return { error: 'campaign slot save missing' };
|
|
}
|
|
|
|
loadAssignments(savedCampaignRaw, { 'liu-bei': 'front', 'guan-yu': 'support', 'zhang-fei': 'reserve' });
|
|
const changed = captureUnits();
|
|
const changedCoreResonance = structuredClone(
|
|
window.__HEROS_DEBUG__?.battle()?.coreSortieResonance ?? null
|
|
);
|
|
|
|
loadAssignments(savedCampaignRaw, { 'liu-bei': 'reserve', 'guan-yu': 'reserve', 'zhang-fei': 'reserve' });
|
|
scene.hideBattleEventBanner?.();
|
|
scene.triggeredBattleEvents?.delete('opening');
|
|
scene.showOpeningBattleEvent?.();
|
|
const allReserve = {
|
|
doctrineActive: window.__HEROS_DEBUG__?.battle()?.sortieDoctrine?.active ?? false,
|
|
coveredRoleCount: window.__HEROS_DEBUG__?.battle()?.sortieDoctrine?.coveredRoleCount ?? -1,
|
|
bannerTexts: (scene.battleEventObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text)
|
|
};
|
|
scene.hideBattleEventBanner?.();
|
|
|
|
window.localStorage.setItem(campaignSlotKey, savedCampaignRaw);
|
|
scene.launchSortieResonanceBondId = undefined;
|
|
scene.coreResonancePursuitAttempts = 0;
|
|
scene.coreResonancePursuitSuccesses = 0;
|
|
scene.coreResonancePursuitFailures = 0;
|
|
scene.coreResonancePursuitDamage = 0;
|
|
scene.loadBattleState?.(1);
|
|
const restored = captureUnits();
|
|
const restoredCoreResonance = structuredClone(
|
|
window.__HEROS_DEBUG__?.battle()?.coreSortieResonance ?? null
|
|
);
|
|
const reserveBondPreserved = scene.bondStates?.has('liu-bei__jian-yong') ?? false;
|
|
storageSnapshot.forEach((value, key) => {
|
|
if (value === null) {
|
|
window.localStorage.removeItem(key);
|
|
} else {
|
|
window.localStorage.setItem(key, value);
|
|
}
|
|
});
|
|
return {
|
|
changed,
|
|
changedCoreResonance,
|
|
allReserve,
|
|
restored,
|
|
restoredCoreResonance,
|
|
loadDiagnostics,
|
|
battleSaveReadableAfterSave,
|
|
savedBattleBonds,
|
|
reserveBondPreserved
|
|
};
|
|
});
|
|
assert(
|
|
roleSealResyncProbe?.battleSaveReadableAfterSave === true &&
|
|
roleSealResyncProbe.savedBattleBonds.length > 0 &&
|
|
roleSealResyncProbe.savedBattleBonds.every((bond) => (
|
|
bond.unitIds.every((unitId) => secondBattleProbe.deployedAllyIds.includes(unitId))
|
|
)) &&
|
|
!roleSealResyncProbe.savedBattleBonds.some((bond) => bond.id === 'liu-bei__jian-yong') &&
|
|
roleSealResyncProbe.reserveBondPreserved === true &&
|
|
roleSealResyncProbe.loadDiagnostics.every((diagnostic) => (
|
|
diagnostic.battleSavePresent === true && diagnostic.battleSaveReadable === true
|
|
)),
|
|
`Expected a three-officer battle save to exclude reserve bonds and remain readable across formation reloads: ${JSON.stringify(roleSealResyncProbe)}`
|
|
);
|
|
assert(
|
|
JSON.stringify(roleSealResyncProbe?.changed) === JSON.stringify([
|
|
{ id: 'liu-bei', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true },
|
|
{ id: 'guan-yu', role: 'support', effect: '후원 지휘', seal: '후', sealPresent: true, sealVisible: true },
|
|
{ id: 'zhang-fei', role: 'reserve', effect: null, seal: null, sealPresent: false, sealVisible: false }
|
|
]),
|
|
`Expected role seals to resync when a loaded formation changes roles: ${JSON.stringify(roleSealResyncProbe)}`
|
|
);
|
|
assert(
|
|
roleSealResyncProbe?.changedCoreResonance?.selectedBondId === 'liu-bei__zhang-fei' &&
|
|
roleSealResyncProbe.changedCoreResonance.active === true &&
|
|
sameJsonValue(roleSealResyncProbe.changedCoreResonance.stats, {
|
|
attempts: 2,
|
|
successes: 1,
|
|
failures: 1,
|
|
additionalDamage: secondBattleCorePriority.successfulResolution.damage
|
|
}),
|
|
`Expected battle save data—not the cleared in-memory or campaign fallback—to restore the core designation and totals: ${JSON.stringify(roleSealResyncProbe?.changedCoreResonance)}`
|
|
);
|
|
assert(
|
|
roleSealResyncProbe?.allReserve?.doctrineActive === true &&
|
|
roleSealResyncProbe?.allReserve?.coveredRoleCount === 0 &&
|
|
roleSealResyncProbe?.allReserve?.bannerTexts?.some((text) => text.startsWith('출진 군세')),
|
|
`Expected an all-reserve formation to keep the sortie-doctrine opening banner: ${JSON.stringify(roleSealResyncProbe?.allReserve)}`
|
|
);
|
|
assert(
|
|
JSON.stringify(roleSealResyncProbe?.restored) === JSON.stringify([
|
|
{ id: 'liu-bei', role: 'support', effect: '후원 지휘', seal: '후', sealPresent: true, sealVisible: true },
|
|
{ id: 'guan-yu', role: 'front', effect: '전열 방호', seal: '전', sealPresent: true, sealVisible: true },
|
|
{ id: 'zhang-fei', role: 'flank', effect: '돌파 선봉', seal: '돌', sealPresent: true, sealVisible: true }
|
|
]),
|
|
`Expected loaded role seals to restore the original formation cleanly: ${JSON.stringify(roleSealResyncProbe?.restored)}`
|
|
);
|
|
assert(
|
|
roleSealResyncProbe?.restoredCoreResonance?.selectedBondId === 'liu-bei__zhang-fei' &&
|
|
roleSealResyncProbe.restoredCoreResonance.active === true &&
|
|
roleSealResyncProbe.restoredCoreResonance.rate === 18 &&
|
|
sameJsonValue(roleSealResyncProbe.restoredCoreResonance.stats, {
|
|
attempts: 2,
|
|
successes: 1,
|
|
failures: 1,
|
|
additionalDamage: secondBattleCorePriority.successfulResolution.damage
|
|
}),
|
|
`Expected battle-slot reloads to preserve the selected core pair and its accumulated pursuit statistics: ${JSON.stringify(roleSealResyncProbe?.restoredCoreResonance)}`
|
|
);
|
|
|
|
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.resultVisible === true, undefined, { timeout: 30000 });
|
|
const secondBattleCoreResult = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const resonance = window.__HEROS_DEBUG__?.battle()?.coreSortieResonance;
|
|
const texts = (scene?.resultObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text);
|
|
return {
|
|
stats: resonance?.stats ?? null,
|
|
resultSummary: resonance?.resultSummary ?? null,
|
|
summary: texts.find((text) => text.startsWith('핵심 공명 · 시도')) ?? null
|
|
};
|
|
});
|
|
await page.screenshot({ path: `${screenshotDir}/rc-second-battle-core-resonance-result.png`, fullPage: true });
|
|
assert(
|
|
secondBattleCoreResult.summary ===
|
|
`핵심 공명 · 시도 2 · 성공 1 · 추가 피해 +${secondBattleCorePriority.successfulResolution.damage}` &&
|
|
sameJsonValue(secondBattleCoreResult.stats, roleSealResyncProbe.restoredCoreResonance.stats) &&
|
|
secondBattleCoreResult.resultSummary?.visible === true &&
|
|
secondBattleCoreResult.resultSummary.text === secondBattleCoreResult.summary &&
|
|
isFiniteBounds(secondBattleCoreResult.resultSummary.bounds) &&
|
|
boundsInside(secondBattleCoreResult.resultSummary.bounds, logicalViewportBounds) &&
|
|
secondBattleCoreResult.resultSummary.bounds.y + secondBattleCoreResult.resultSummary.bounds.height <= 416 * legacyUiScale,
|
|
`Expected the victory report to surface persisted core-resonance attempts, successes, and damage: ${JSON.stringify(secondBattleCoreResult)}`
|
|
);
|
|
|
|
const requiredCasualtySaveBefore = await readCampaignSave(page);
|
|
try {
|
|
await seedCampaignSave(page, {
|
|
battleId: 'seventeenth-battle-wolong-visit-road',
|
|
battleTitle: '융중 방문로',
|
|
step: 'seventeenth-camp',
|
|
gold: 2400,
|
|
turnNumber: 12,
|
|
defeatedEnemies: 10,
|
|
selectedSortieUnitIds: ['liu-bei', 'zhuge-liang', 'zhao-yun', 'guan-yu', 'zhang-fei', 'sun-qian']
|
|
});
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await clickLegacyUi(page, 962, 310);
|
|
await waitForCamp(page);
|
|
|
|
const requiredCasualtyRecruitment = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const unit = state?.campaign?.roster?.find((candidate) => candidate.id === 'zhuge-liang');
|
|
return {
|
|
nextBattleId: state?.nextSortieBattleId ?? null,
|
|
unit: unit ? { id: unit.id, hp: unit.hp, maxHp: unit.maxHp } : null
|
|
};
|
|
});
|
|
assert(
|
|
requiredCasualtyRecruitment.nextBattleId === 'eighteenth-battle-bowang-ambush' &&
|
|
requiredCasualtyRecruitment.unit?.hp > 0,
|
|
'Expected the seventeenth camp to recruit Zhuge Liang before the casualty fixture: ' +
|
|
JSON.stringify(requiredCasualtyRecruitment)
|
|
);
|
|
|
|
const requiredCasualtySeed = await page.evaluate(() => {
|
|
const keys = ['heros-web:campaign-state', 'heros-web:campaign-state:slot-1'];
|
|
const seeded = [];
|
|
for (const key of keys) {
|
|
const raw = window.localStorage.getItem(key);
|
|
const campaign = raw ? JSON.parse(raw) : null;
|
|
const target = campaign?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
|
if (!campaign || !target || !campaign.firstBattleReport) {
|
|
seeded.push({ key, ready: false, rosterCount: campaign?.roster?.length ?? 0 });
|
|
continue;
|
|
}
|
|
target.hp = 0;
|
|
const reportUnit = campaign.firstBattleReport.units?.find((unit) => unit.id === target.id);
|
|
if (reportUnit) {
|
|
reportUnit.hp = 0;
|
|
} else {
|
|
campaign.firstBattleReport.units = [...(campaign.firstBattleReport.units ?? []), { ...target, hp: 0 }];
|
|
}
|
|
campaign.inventory = { ...(campaign.inventory ?? {}) };
|
|
const salveBefore = 2;
|
|
campaign.inventory['상처약'] = salveBefore;
|
|
campaign.selectedSortieUnitIds = (campaign.selectedSortieUnitIds ?? []).filter(
|
|
(unitId) => unitId !== 'zhuge-liang'
|
|
);
|
|
window.localStorage.setItem(key, JSON.stringify(campaign));
|
|
seeded.push({
|
|
key,
|
|
ready: true,
|
|
hp: target.hp,
|
|
maxHp: target.maxHp,
|
|
salveBefore,
|
|
selected: campaign.selectedSortieUnitIds.includes(target.id)
|
|
});
|
|
}
|
|
return {
|
|
ready: seeded.length === keys.length && seeded.every((entry) => entry.ready),
|
|
seeded,
|
|
salveBefore: seeded.find((entry) => entry.key === keys[0])?.salveBefore ?? null
|
|
};
|
|
});
|
|
assert(
|
|
requiredCasualtySeed.ready === true &&
|
|
requiredCasualtySeed.seeded.every((entry) => entry.hp === 0 && entry.maxHp > 0 && entry.selected === false),
|
|
'Expected current and slot saves to retain a zero-HP required Zhuge Liang outside the saved selection: ' +
|
|
JSON.stringify(requiredCasualtySeed)
|
|
);
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await clickLegacyUi(page, 962, 310);
|
|
await waitForCamp(page);
|
|
const requiredCasualtyCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
requiredCasualtyCampState?.campaign?.step === 'seventeenth-camp' &&
|
|
requiredCasualtyCampState?.nextSortieBattleId === 'eighteenth-battle-bowang-ambush' &&
|
|
requiredCasualtyCampState?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang')?.hp === 0 &&
|
|
!requiredCasualtyCampState?.selectedSortieUnitIds?.includes('zhuge-liang'),
|
|
'Expected campaign loading to preserve the casualty while excluding it from the active sortie: ' +
|
|
JSON.stringify(requiredCasualtyCampState)
|
|
);
|
|
|
|
await openCampSortiePrep(page);
|
|
await advanceSortiePrepStep(page, 'formation');
|
|
const requiredCasualtyFormationState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const requiredCasualtyPortrait = requiredCasualtyFormationState?.sortiePortraitRoster?.find(
|
|
(unit) => unit.id === 'zhuge-liang'
|
|
);
|
|
assert(
|
|
requiredCasualtyPortrait?.required === true &&
|
|
requiredCasualtyPortrait.selected === false &&
|
|
requiredCasualtyPortrait.available === false &&
|
|
requiredCasualtyPortrait.hp === 0 &&
|
|
requiredCasualtyPortrait.maxHp > 0 &&
|
|
requiredCasualtyPortrait.availabilityLabel === '부상' &&
|
|
requiredCasualtyPortrait.availabilityReason?.includes('보급 화면') &&
|
|
requiredCasualtyFormationState?.sortiePortraitRosterView?.visibleUnitIds?.includes('zhuge-liang') &&
|
|
requiredCasualtyFormationState?.sortiePrepSteps?.find((step) => step.id === 'formation')?.complete === false,
|
|
'Expected the unavailable required officer to stay visible, disabled, and formation-incomplete: ' +
|
|
JSON.stringify({
|
|
portrait: requiredCasualtyPortrait,
|
|
view: requiredCasualtyFormationState?.sortiePortraitRosterView,
|
|
steps: requiredCasualtyFormationState?.sortiePrepSteps
|
|
})
|
|
);
|
|
await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-card.png', fullPage: true });
|
|
await assertCanvasPainted(page, 'required sortie casualty card');
|
|
|
|
await advanceSortiePrepStep(page, 'loadout');
|
|
const requiredCasualtyLoadoutState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const requiredCasualtyReadiness = requiredCasualtyLoadoutState?.sortieChecklist?.find(
|
|
(item) => item.label?.includes('유비') && item.label?.includes('제갈량') && item.label?.includes('생존')
|
|
);
|
|
const requiredCasualtyComposition = requiredCasualtyLoadoutState?.sortieChecklist?.find(
|
|
(item) => item.label === '출전 구성'
|
|
);
|
|
const requiredCasualtyRecoveryTarget = requiredCasualtyLoadoutState?.sortieChecklist?.find(
|
|
(item) => item.label === '부상 장수 확인'
|
|
);
|
|
assert(
|
|
requiredCasualtyReadiness?.complete === false &&
|
|
requiredCasualtyReadiness.detail?.includes('제갈량 0/') &&
|
|
requiredCasualtyReadiness.detail?.includes('회복 필요') &&
|
|
requiredCasualtyComposition?.complete === false &&
|
|
requiredCasualtyRecoveryTarget?.complete === false &&
|
|
requiredCasualtyRecoveryTarget.detail?.includes('제갈량 0/') &&
|
|
requiredCasualtyLoadoutState?.sortieChecklistSummary?.requiredComplete === false &&
|
|
requiredCasualtyLoadoutState?.sortieChecklistSummary?.requiredRemainingCount >= 2 &&
|
|
requiredCasualtyLoadoutState?.sortiePrepSteps?.find((step) => step.id === 'loadout')?.complete === false,
|
|
'Expected the casualty to keep both required final checks incomplete: ' +
|
|
JSON.stringify({
|
|
readiness: requiredCasualtyReadiness,
|
|
composition: requiredCasualtyComposition,
|
|
recoveryTarget: requiredCasualtyRecoveryTarget,
|
|
summary: requiredCasualtyLoadoutState?.sortieChecklistSummary,
|
|
steps: requiredCasualtyLoadoutState?.sortiePrepSteps
|
|
})
|
|
);
|
|
|
|
const requiredCasualtySaveBeforeLaunch = await readCampaignSave(page);
|
|
const blockedLaunchPoint = await readSortieTextControlPoint(page, '출진');
|
|
await page.mouse.click(blockedLaunchPoint.x, blockedLaunchPoint.y);
|
|
await page.waitForTimeout(120);
|
|
const blockedRequiredLaunch = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
|
sortieVisible: state?.sortieVisible ?? false,
|
|
sortiePrepStep: state?.sortiePrepStep ?? null,
|
|
notice: (scene?.dialogueObjects ?? [])
|
|
.filter((object) => object?.type === 'Text' && object?.active && object?.visible)
|
|
.map((object) => object.text)
|
|
.join(' ')
|
|
};
|
|
});
|
|
const requiredCasualtySaveAfterLaunch = await readCampaignSave(page);
|
|
assert(
|
|
blockedRequiredLaunch.activeScenes.includes('CampScene') &&
|
|
!blockedRequiredLaunch.activeScenes.includes('StoryScene') &&
|
|
!blockedRequiredLaunch.activeScenes.includes('BattleScene') &&
|
|
blockedRequiredLaunch.sortieVisible === true &&
|
|
blockedRequiredLaunch.sortiePrepStep === 'loadout' &&
|
|
blockedRequiredLaunch.notice.includes('제갈량') &&
|
|
blockedRequiredLaunch.notice.includes('보급 화면') &&
|
|
sameJsonValue(requiredCasualtySaveAfterLaunch, requiredCasualtySaveBeforeLaunch),
|
|
'Expected the real launch control to block a zero-HP required officer without mutating saves: ' +
|
|
JSON.stringify({
|
|
blockedRequiredLaunch,
|
|
saveUnchanged: sameJsonValue(requiredCasualtySaveAfterLaunch, requiredCasualtySaveBeforeLaunch)
|
|
})
|
|
);
|
|
await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-blocked.png', fullPage: true });
|
|
await assertCanvasPainted(page, 'required sortie casualty blocked');
|
|
|
|
const returnToCampPoint = await readSortieTextControlPoint(page, '군영으로');
|
|
await page.mouse.click(returnToCampPoint.x, returnToCampPoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.sortieVisible === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
await selectCampRosterUnit(page, 'zhuge-liang');
|
|
const suppliesTabPoint = await readCampTabControlPoint(page, 'supplies');
|
|
await page.mouse.click(suppliesTabPoint.x, suppliesTabPoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'supplies',
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const salveUsePoint = await readCampSupplyUseControlPoint(page, '상처약');
|
|
await page.mouse.click(salveUsePoint.x, salveUsePoint.y);
|
|
await page.waitForTimeout(120);
|
|
const salveClickApplied = await page.evaluate(() => {
|
|
const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
|
return target?.hp > 0;
|
|
});
|
|
if (!salveClickApplied) {
|
|
const salveFallback = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
|
const texts = (scene?.contentObjects ?? []).filter(
|
|
(object) => object?.type === 'Text' && object?.active && object?.visible
|
|
);
|
|
const supplyTitle = texts.find((object) => object.text?.startsWith('상처약 x'));
|
|
const useText = texts
|
|
.filter((object) => object.text === '사용' && object.input?.enabled)
|
|
.sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0];
|
|
if (!scene || !target || !supplyTitle || !useText) {
|
|
return {
|
|
ready: false,
|
|
targetHp: target?.hp ?? null,
|
|
supplyTitle: supplyTitle?.text ?? null,
|
|
useCount: texts.filter((object) => object.text === '사용').length
|
|
};
|
|
}
|
|
useText.emit('pointerdown');
|
|
return { ready: true, targetHp: target.hp, supplyTitle: supplyTitle.text };
|
|
});
|
|
assert(
|
|
salveFallback.ready === true,
|
|
'Expected the visible salve control to expose its Phaser action after the canvas click fallback: ' +
|
|
JSON.stringify(salveFallback)
|
|
);
|
|
}
|
|
await page.waitForFunction(
|
|
() => {
|
|
const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
|
return target?.hp > 0;
|
|
},
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const requiredCasualtyRecoverySave = await readCampaignSave(page);
|
|
const recoveredCurrent = requiredCasualtyRecoverySave.current?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
|
const recoveredSlot = requiredCasualtyRecoverySave.slot1?.roster?.find((unit) => unit.id === 'zhuge-liang');
|
|
const recoveredCurrentReport = requiredCasualtyRecoverySave.current?.firstBattleReport?.units?.find(
|
|
(unit) => unit.id === 'zhuge-liang'
|
|
);
|
|
const recoveredSlotReport = requiredCasualtyRecoverySave.slot1?.firstBattleReport?.units?.find(
|
|
(unit) => unit.id === 'zhuge-liang'
|
|
);
|
|
assert(
|
|
recoveredCurrent?.hp > 0 &&
|
|
recoveredSlot?.hp === recoveredCurrent.hp &&
|
|
recoveredCurrentReport?.hp === recoveredCurrent.hp &&
|
|
recoveredSlotReport?.hp === recoveredCurrent.hp &&
|
|
(requiredCasualtyRecoverySave.current?.inventory?.['상처약'] ?? 0) === requiredCasualtySeed.salveBefore - 1 &&
|
|
(requiredCasualtyRecoverySave.slot1?.inventory?.['상처약'] ?? 0) === requiredCasualtySeed.salveBefore - 1,
|
|
'Expected one real salve use to recover and persist the required officer exactly once: ' +
|
|
JSON.stringify({
|
|
currentHp: recoveredCurrent?.hp,
|
|
slotHp: recoveredSlot?.hp,
|
|
currentReportHp: recoveredCurrentReport?.hp,
|
|
slotReportHp: recoveredSlotReport?.hp,
|
|
currentSalves: requiredCasualtyRecoverySave.current?.inventory?.['상처약'] ?? 0,
|
|
slotSalves: requiredCasualtyRecoverySave.slot1?.inventory?.['상처약'] ?? 0,
|
|
expectedSalves: requiredCasualtySeed.salveBefore - 1
|
|
})
|
|
);
|
|
|
|
await openCampSortiePrep(page);
|
|
const recoveredRequiredBriefing = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
recoveredRequiredBriefing?.selectedSortieUnitIds?.includes('liu-bei') &&
|
|
recoveredRequiredBriefing?.selectedSortieUnitIds?.includes('zhuge-liang'),
|
|
'Expected reopening sortie prep to auto-select every recovered required officer: ' +
|
|
JSON.stringify(recoveredRequiredBriefing?.selectedSortieUnitIds)
|
|
);
|
|
await advanceSortiePrepStep(page, 'formation');
|
|
const recoveredRequiredFormation = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const recoveredRequiredPortrait = recoveredRequiredFormation?.sortiePortraitRoster?.find(
|
|
(unit) => unit.id === 'zhuge-liang'
|
|
);
|
|
assert(
|
|
recoveredRequiredPortrait?.required === true &&
|
|
recoveredRequiredPortrait.selected === true &&
|
|
recoveredRequiredPortrait.available === true &&
|
|
recoveredRequiredPortrait.hp === recoveredCurrent?.hp &&
|
|
recoveredRequiredFormation?.sortiePrepSteps?.find((step) => step.id === 'formation')?.complete === true,
|
|
'Expected the recovered required portrait and formation stage to become ready: ' +
|
|
JSON.stringify({
|
|
portrait: recoveredRequiredPortrait,
|
|
steps: recoveredRequiredFormation?.sortiePrepSteps
|
|
})
|
|
);
|
|
await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-recovered.png', fullPage: true });
|
|
await assertCanvasPainted(page, 'required sortie casualty recovered');
|
|
|
|
await advanceSortiePrepStep(page, 'loadout');
|
|
const recoveredRequiredLoadout = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const recoveredReadiness = recoveredRequiredLoadout?.sortieChecklist?.find(
|
|
(item) => item.label?.includes('유비') && item.label?.includes('제갈량') && item.label?.includes('생존')
|
|
);
|
|
const recoveredComposition = recoveredRequiredLoadout?.sortieChecklist?.find(
|
|
(item) => item.label === '출전 구성'
|
|
);
|
|
const recoveredRecoveryTarget = recoveredRequiredLoadout?.sortieChecklist?.find(
|
|
(item) => item.label === '부상 장수 확인'
|
|
);
|
|
const recoveredDeploymentIds = recoveredRequiredLoadout?.sortieDeploymentPreview?.map((slot) => slot.unitId) ?? [];
|
|
assert(
|
|
recoveredReadiness?.complete === true &&
|
|
recoveredComposition?.complete === true &&
|
|
recoveredRecoveryTarget?.complete === true &&
|
|
recoveredRequiredLoadout?.sortieChecklistSummary?.requiredComplete === true &&
|
|
recoveredDeploymentIds.filter((unitId) => unitId === 'liu-bei').length === 1 &&
|
|
recoveredDeploymentIds.filter((unitId) => unitId === 'zhuge-liang').length === 1 &&
|
|
new Set(recoveredDeploymentIds).size === recoveredDeploymentIds.length,
|
|
'Expected recovery to complete required checks and produce one unique slot per required officer: ' +
|
|
JSON.stringify({
|
|
recoveredReadiness,
|
|
recoveredComposition,
|
|
recoveredRecoveryTarget,
|
|
summary: recoveredRequiredLoadout?.sortieChecklistSummary,
|
|
deployment: recoveredRequiredLoadout?.sortieDeploymentPreview
|
|
})
|
|
);
|
|
|
|
const recoveredLaunchPoint = await readSortieTextControlPoint(page, '출진');
|
|
await page.mouse.click(recoveredLaunchPoint.x, recoveredLaunchPoint.y);
|
|
await page.waitForFunction(
|
|
() => (window.__HEROS_DEBUG__?.activeScenes() ?? []).includes('StoryScene'),
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const recoveredRequiredLaunchSave = await readCampaignSave(page);
|
|
assert(
|
|
recoveredRequiredLaunchSave.current?.step === 'eighteenth-battle' &&
|
|
recoveredRequiredLaunchSave.slot1?.step === 'eighteenth-battle' &&
|
|
recoveredRequiredLaunchSave.current?.selectedSortieUnitIds?.includes('liu-bei') &&
|
|
recoveredRequiredLaunchSave.current?.selectedSortieUnitIds?.includes('zhuge-liang'),
|
|
'Expected the same real launch control to continue after the required officer recovers: ' +
|
|
JSON.stringify({
|
|
currentStep: recoveredRequiredLaunchSave.current?.step,
|
|
slotStep: recoveredRequiredLaunchSave.slot1?.step,
|
|
selected: recoveredRequiredLaunchSave.current?.selectedSortieUnitIds
|
|
})
|
|
);
|
|
} finally {
|
|
await page.evaluate((save) => {
|
|
const write = (key, value) => {
|
|
if (value === undefined) {
|
|
window.localStorage.removeItem(key);
|
|
return;
|
|
}
|
|
window.localStorage.setItem(key, JSON.stringify(value));
|
|
};
|
|
write('heros-web:campaign-state', save.current);
|
|
write('heros-web:campaign-state:slot-1', save.slot1);
|
|
}, requiredCasualtySaveBefore);
|
|
const requiredCasualtyRestoredSave = await readCampaignSave(page);
|
|
assert(
|
|
sameJsonValue(requiredCasualtyRestoredSave, requiredCasualtySaveBefore),
|
|
'Expected the required-casualty fixture to restore both campaign saves: ' +
|
|
JSON.stringify({ before: requiredCasualtySaveBefore, after: requiredCasualtyRestoredSave })
|
|
);
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
}
|
|
|
|
await seedCampaignSave(page, {
|
|
battleId: 'fifty-eighth-battle-qishan-retreat',
|
|
battleTitle: '기산 후퇴로',
|
|
step: 'fifty-eighth-camp',
|
|
gold: 7400,
|
|
turnNumber: 13,
|
|
defeatedEnemies: 16,
|
|
selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'huang-quan', 'ma-liang', 'ma-dai']
|
|
});
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await clickLegacyUi(page, 962, 310);
|
|
await waitForCamp(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-campaign-continue.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid campaign camp');
|
|
|
|
const midCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(midCampState?.campaign?.step === 'fifty-eighth-camp', `Expected mid-campaign save to continue into camp: ${JSON.stringify(midCampState)}`);
|
|
assert(midCampState.campBattleId === 'fifty-eighth-battle-qishan-retreat', `Expected mid-campaign battle id: ${JSON.stringify(midCampState)}`);
|
|
assert(midCampState.sortiePlan?.selectedCount > 0, `Expected mid-campaign formation state: ${JSON.stringify(midCampState?.sortiePlan)}`);
|
|
assert(midCampState.sortieHasBattle === true, `Expected mid-campaign camp to keep a playable sortie target: ${JSON.stringify(midCampState)}`);
|
|
assert(
|
|
typeof midCampState.nextSortieBattleId === 'string' && midCampState.nextSortieBattleId.length > 0,
|
|
`Expected mid-campaign sortie flow to expose a battle id: ${JSON.stringify(midCampState)}`
|
|
);
|
|
assert(
|
|
midCampState.sortiePlan?.selectedCount <= midCampState.sortiePlan?.maxCount,
|
|
`Expected mid-campaign sortie selection to stay within limit: ${JSON.stringify(midCampState?.sortiePlan)}`
|
|
);
|
|
assert(
|
|
midCampState.sortiePlan?.selectedCount === midCampState.sortieDeploymentPreview?.length,
|
|
`Expected mid-campaign deployment preview to match selected sortie count: ${JSON.stringify(midCampState?.sortiePlan)} / ${JSON.stringify(midCampState?.sortieDeploymentPreview)}`
|
|
);
|
|
assertUnique(
|
|
midCampState.sortieDeploymentPreview?.map((slot) => slot.unitId),
|
|
`Expected mid-campaign deployment preview to avoid duplicate units: ${JSON.stringify(midCampState?.sortieDeploymentPreview)}`
|
|
);
|
|
assertUnique(
|
|
midCampState.sortieDeploymentPreview?.map((slot) => `${slot.x},${slot.y}`),
|
|
`Expected mid-campaign deployment preview to avoid duplicate tiles: ${JSON.stringify(midCampState?.sortieDeploymentPreview)}`
|
|
);
|
|
|
|
const midCampNextBattleId = midCampState.nextSortieBattleId;
|
|
await openCampSortiePrep(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-prep.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp sortie prep');
|
|
|
|
const midBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
midBriefingState?.stagedSortiePrep === true &&
|
|
midBriefingState?.firstSortiePrep === false &&
|
|
midBriefingState?.sortiePrepStep === 'briefing' &&
|
|
midBriefingState?.sortiePrimaryAction?.kind === 'next' &&
|
|
midBriefingState?.sortiePrimaryAction?.nextStep === 'formation',
|
|
`Expected a mid-campaign battle sortie to open at the staged briefing: ${JSON.stringify(midBriefingState)}`
|
|
);
|
|
assert(
|
|
midBriefingState?.sortiePortraitRosterView?.enabled === true &&
|
|
midBriefingState?.sortiePortraitRosterView?.total === 23 &&
|
|
midBriefingState?.sortiePortraitRosterView?.visibleCount === 6 &&
|
|
midBriefingState?.sortiePortraitRosterView?.start === 1 &&
|
|
midBriefingState?.sortiePortraitRosterView?.end === 6,
|
|
`Expected the mid-campaign staged formation to expose the first six-card portrait roster page: ${JSON.stringify(midBriefingState?.sortiePortraitRosterView)}`
|
|
);
|
|
|
|
await advanceSortiePrepStep(page, 'formation');
|
|
const midFormationSwapFixture = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const preservedUnitId = state?.selectedSortieUnitIds?.find((unitId) => unitId !== 'ma-dai');
|
|
if (!scene || !preservedUnitId || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') {
|
|
return { ready: false, preservedUnitId: preservedUnitId ?? null };
|
|
}
|
|
|
|
scene.sortieFormationAssignments = {
|
|
...scene.sortieFormationAssignments,
|
|
'ma-dai': 'flank'
|
|
};
|
|
scene.sortieItemAssignments = {
|
|
...scene.sortieItemAssignments,
|
|
'ma-dai': { ...(scene.sortieItemAssignments?.['ma-dai'] ?? {}), bean: 1 },
|
|
[preservedUnitId]: { ...(scene.sortieItemAssignments?.[preservedUnitId] ?? {}), salve: 1 }
|
|
};
|
|
scene.persistSortieSelection();
|
|
scene.showSortiePrep();
|
|
scene.persistSortieSelection();
|
|
scene.showSortiePrep();
|
|
return { ready: true, preservedUnitId };
|
|
});
|
|
assert(
|
|
midFormationSwapFixture.ready === true && midFormationSwapFixture.preservedUnitId,
|
|
`Expected a saved role and supply fixture for the swap regression: ${JSON.stringify(midFormationSwapFixture)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp sortie formation');
|
|
|
|
const midFormationPortraitState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const missingMidFormationPortraits = midFormationPortraitState?.sortiePortraitRoster?.filter(
|
|
(unit) => !unit.portraitReady || !unit.portraitTextureKey?.startsWith('portrait-card-')
|
|
) ?? [];
|
|
assert(
|
|
midFormationPortraitState?.sortiePortraitRoster?.length === 23 && missingMidFormationPortraits.length === 0,
|
|
`Expected all 23 campaign officers to use loaded lightweight portrait cards: ${JSON.stringify(missingMidFormationPortraits)}`
|
|
);
|
|
const midPortraitsMissingStrategyCopy = midFormationPortraitState?.sortiePortraitRoster?.filter(
|
|
(unit) => !Array.isArray(unit.strategyNames) || unit.strategyNames.length === 0 || !unit.strategyLine?.trim()
|
|
) ?? [];
|
|
assert(
|
|
midPortraitsMissingStrategyCopy.length === 0 &&
|
|
sameJsonValue(midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'guan-yu')?.strategyNames, ['청룡일섬']) &&
|
|
midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'guan-yu')?.strategyLine?.startsWith('고유 청룡일섬') &&
|
|
sameJsonValue(midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'zhang-fei')?.strategyNames, ['장판교 대갈']) &&
|
|
midFormationPortraitState?.sortiePortraitRoster?.find((unit) => unit.id === 'zhang-fei')?.strategyLine?.startsWith('고유 장판교 대갈'),
|
|
`Expected every sortie portrait card to expose its battle strategies: ${JSON.stringify(midPortraitsMissingStrategyCopy)}`
|
|
);
|
|
assert(
|
|
midFormationPortraitState?.sortieStrategyCoverage?.count === 4 &&
|
|
midFormationPortraitState.sortieStrategyCoverage.total === 4 &&
|
|
midFormationPortraitState.sortieStrategyCoverage.missing.length === 0 &&
|
|
sameJsonValue(midFormationPortraitState.sortieStrategyCoverage.covered.map((definition) => definition.label), ['회복', '강화', '공격', '제어']) &&
|
|
Boolean(midFormationPortraitState?.sortieFocusedUnit?.strategyLine?.trim()),
|
|
`Expected the selected formation and focused officer to expose complete strategy coverage: ${JSON.stringify({
|
|
coverage: midFormationPortraitState?.sortieStrategyCoverage,
|
|
focused: midFormationPortraitState?.sortieFocusedUnit
|
|
})}`
|
|
);
|
|
|
|
const midRecommendationToggleBounds = midFormationPortraitState?.sortieRecommendationBrowser?.toggleButtonBounds;
|
|
const midRecommendationSelectionBefore = {
|
|
selectedUnitIds: [...(midFormationPortraitState?.selectedSortieUnitIds ?? [])],
|
|
formationAssignments: { ...(midFormationPortraitState?.sortieFormationAssignments ?? {}) },
|
|
itemAssignments: { ...(midFormationPortraitState?.sortieItemAssignments ?? {}) }
|
|
};
|
|
assert(
|
|
isFiniteBounds(midRecommendationToggleBounds),
|
|
`Expected the mid-campaign formation to expose the recommendation browser toggle: ${JSON.stringify(midFormationPortraitState?.sortieRecommendationBrowser)}`
|
|
);
|
|
await page.mouse.click(
|
|
midRecommendationToggleBounds.x + midRecommendationToggleBounds.width / 2,
|
|
midRecommendationToggleBounds.y + midRecommendationToggleBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationBrowser?.open === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const midRecommendationOpenState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const midRecommendationTarget = midRecommendationOpenState?.sortieRecommendationBrowser?.cards?.find(
|
|
(card) => card.applicable && !card.current &&
|
|
isFiniteBounds(card.backgroundBounds) && isFiniteBounds(card.compareButtonBounds)
|
|
);
|
|
assert(
|
|
midRecommendationTarget && isFiniteBounds(midRecommendationOpenState?.sortieRecommendationBrowser?.closeButtonBounds),
|
|
`Expected an applicable recommendation card that differs from the current formation: ${JSON.stringify(midRecommendationOpenState?.sortieRecommendationBrowser)}`
|
|
);
|
|
await page.mouse.move(
|
|
midRecommendationTarget.backgroundBounds.x + midRecommendationTarget.backgroundBounds.width / 2,
|
|
midRecommendationTarget.backgroundBounds.y + midRecommendationTarget.backgroundBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
(planId) => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationBrowser?.reasonPanel?.planId === planId,
|
|
midRecommendationTarget.id,
|
|
{ timeout: 30000 }
|
|
);
|
|
const midRecommendationHoverState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const midRecommendationHoverBrowser = midRecommendationHoverState?.sortieRecommendationBrowser;
|
|
const midRecommendationReasonPanel = midRecommendationHoverBrowser?.reasonPanel;
|
|
const midRecommendationReasonRows = midRecommendationReasonPanel?.rows ?? [];
|
|
const midRecommendationRequiredRow = midRecommendationReasonRows.find((row) => row.unitId === 'zhuge-liang');
|
|
const midRecommendationIncomingIds = midRecommendationTarget.selectedUnitIds.filter(
|
|
(unitId) => !midRecommendationSelectionBefore.selectedUnitIds.includes(unitId)
|
|
);
|
|
const midRecommendationOutgoingIds = midRecommendationSelectionBefore.selectedUnitIds.filter(
|
|
(unitId) => !midRecommendationTarget.selectedUnitIds.includes(unitId)
|
|
);
|
|
const midRecommendationOfficerNames = Object.fromEntries(
|
|
(midRecommendationHoverState?.sortiePortraitRoster ?? []).map((unit) => [unit.id, unit.name])
|
|
);
|
|
const midRecommendationFirstOutgoingName = midRecommendationOfficerNames[midRecommendationOutgoingIds[0]];
|
|
assert(
|
|
midRecommendationHoverBrowser?.hoveredPlanId === midRecommendationTarget.id &&
|
|
midRecommendationReasonPanel?.emptyVisible === false &&
|
|
sameJsonValue(midRecommendationReasonRows.map((row) => row.unitId), midRecommendationTarget.selectedUnitIds) &&
|
|
midRecommendationReasonRows.every(
|
|
(row) => row.fullReason === midRecommendationTarget.unitReasons[row.unitId] &&
|
|
isFiniteBounds(row.bounds) && boundsInside(row.bounds, midRecommendationReasonPanel.bounds) &&
|
|
isFiniteBounds(row.memberBounds) && boundsInside(row.memberBounds, row.bounds) &&
|
|
isFiniteBounds(row.reasonBounds) && boundsInside(row.reasonBounds, row.bounds)
|
|
) &&
|
|
midRecommendationIncomingIds.every(
|
|
(unitId) => midRecommendationReasonRows.find((row) => row.unitId === unitId)?.state === 'incoming'
|
|
) &&
|
|
(midRecommendationFirstOutgoingName
|
|
? midRecommendationReasonPanel.changeSummary.includes(`OUT ${midRecommendationFirstOutgoingName}`)
|
|
: true) &&
|
|
(midRecommendationRequiredRow?.state === 'required' || midRecommendationRequiredRow?.state === 'required-role') &&
|
|
midRecommendationRequiredRow.fullReason.includes('필수 출전') &&
|
|
midRecommendationRequiredRow.fullReason.includes('고유 ') &&
|
|
sameJsonValue(
|
|
{
|
|
selectedUnitIds: midRecommendationHoverState?.selectedSortieUnitIds,
|
|
formationAssignments: midRecommendationHoverState?.sortieFormationAssignments,
|
|
itemAssignments: midRecommendationHoverState?.sortieItemAssignments
|
|
},
|
|
midRecommendationSelectionBefore
|
|
),
|
|
`Expected recommendation hover to render every source-backed officer reason inside the FHD panel without mutating formation: ${JSON.stringify({
|
|
target: midRecommendationTarget,
|
|
browser: midRecommendationHoverBrowser,
|
|
before: midRecommendationSelectionBefore,
|
|
after: {
|
|
selectedUnitIds: midRecommendationHoverState?.selectedSortieUnitIds,
|
|
formationAssignments: midRecommendationHoverState?.sortieFormationAssignments,
|
|
itemAssignments: midRecommendationHoverState?.sortieItemAssignments
|
|
}
|
|
})}`
|
|
);
|
|
|
|
await page.mouse.click(
|
|
midRecommendationTarget.compareButtonBounds.x + midRecommendationTarget.compareButtonBounds.width / 2,
|
|
midRecommendationTarget.compareButtonBounds.y + midRecommendationTarget.compareButtonBounds.height / 2
|
|
);
|
|
await page.mouse.move(40, 40);
|
|
await page.waitForFunction(
|
|
(planId) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const browser = state?.sortieRecommendationBrowser;
|
|
return browser?.pinnedPlanId === planId && browser?.hoveredPlanId === null && browser?.reasonPanel?.planId === planId &&
|
|
state?.sortieComparisonPreview?.source === 'pinned-recommendation' &&
|
|
state?.sortieComparisonAction?.kind === 'confirm-recommendation';
|
|
},
|
|
midRecommendationTarget.id,
|
|
{ timeout: 30000 }
|
|
);
|
|
const midRecommendationPinnedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
midRecommendationPinnedState?.sortieRecommendationBrowser?.reasonPanel?.rows?.length === midRecommendationTarget.selectedUnitIds.length &&
|
|
sameJsonValue(
|
|
{
|
|
selectedUnitIds: midRecommendationPinnedState?.selectedSortieUnitIds,
|
|
formationAssignments: midRecommendationPinnedState?.sortieFormationAssignments,
|
|
itemAssignments: midRecommendationPinnedState?.sortieItemAssignments
|
|
},
|
|
midRecommendationSelectionBefore
|
|
),
|
|
`Expected a pinned recommendation to preserve its officer reasons without applying the formation: ${JSON.stringify(midRecommendationPinnedState?.sortieRecommendationBrowser)}`
|
|
);
|
|
const midRecommendationApplyPoint = await readSortieComparisonActionPoint(page);
|
|
assert(midRecommendationApplyPoint.label === '적용 확정', `Expected the pinned plan to expose its apply action: ${JSON.stringify(midRecommendationApplyPoint)}`);
|
|
await page.mouse.click(midRecommendationApplyPoint.x, midRecommendationApplyPoint.y);
|
|
await page.waitForFunction(
|
|
({ planId, selectedUnitIds }) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortieRecommendationBrowser?.open === false &&
|
|
state?.sortieRecommendationSelection?.planId === planId &&
|
|
JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(selectedUnitIds) &&
|
|
state?.sortieComparisonAction?.kind === 'undo-recommendation';
|
|
},
|
|
{ planId: midRecommendationTarget.id, selectedUnitIds: midRecommendationTarget.selectedUnitIds },
|
|
{ timeout: 30000 }
|
|
);
|
|
const midRecommendationAppliedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const midRecommendationAppliedSave = await readCampaignSave(page);
|
|
assert(
|
|
midRecommendationAppliedState?.sortieRecommendationSelection?.battleId === midCampNextBattleId &&
|
|
midRecommendationAppliedState.sortieRecommendationSelection.label === midRecommendationTarget.label &&
|
|
sameJsonValue(midRecommendationAppliedState.sortieRecommendationSelection.selectedUnitIds, midRecommendationTarget.selectedUnitIds) &&
|
|
sameJsonValue(midRecommendationAppliedState.sortieRecommendationSelection.formationAssignments, midRecommendationTarget.formationAssignments) &&
|
|
sameJsonValue(midRecommendationAppliedState.sortieRecommendationSelection.unitReasons, midRecommendationTarget.unitReasons) &&
|
|
sameJsonValue(midRecommendationAppliedSave.current?.sortieRecommendationSelection, midRecommendationAppliedState.sortieRecommendationSelection),
|
|
`Expected applying a recommendation to persist its plan and officer reasons: ${JSON.stringify({
|
|
state: midRecommendationAppliedState?.sortieRecommendationSelection,
|
|
save: midRecommendationAppliedSave.current?.sortieRecommendationSelection
|
|
})}`
|
|
);
|
|
const midRecommendationUndoPoint = await readSortieComparisonActionPoint(page);
|
|
assert(midRecommendationUndoPoint.label === '되돌리기', `Expected an applied recommendation to expose undo: ${JSON.stringify(midRecommendationUndoPoint)}`);
|
|
await page.mouse.click(midRecommendationUndoPoint.x, midRecommendationUndoPoint.y);
|
|
await page.waitForFunction(
|
|
(before) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortieRecommendationSelection === null &&
|
|
JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(before.selectedUnitIds) &&
|
|
JSON.stringify(state?.sortieFormationAssignments) === JSON.stringify(before.formationAssignments) &&
|
|
JSON.stringify(state?.sortieItemAssignments) === JSON.stringify(before.itemAssignments);
|
|
},
|
|
midRecommendationSelectionBefore,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const midCoreSelectorToggle = midFormationPortraitState?.sortiePursuitPreview;
|
|
assert(
|
|
midCoreSelectorToggle?.selectorOpen === false &&
|
|
midCoreSelectorToggle.toggleLabel === '핵심 공명조' &&
|
|
isFiniteBounds(midCoreSelectorToggle.toggleBounds) &&
|
|
isFiniteBounds(midCoreSelectorToggle.toggleClickBounds) &&
|
|
boundsInside(midCoreSelectorToggle.toggleBounds, midCoreSelectorToggle.panelBounds),
|
|
`Expected the generic formation comparison panel to expose an in-panel core-resonance mode switch: ${JSON.stringify(midCoreSelectorToggle)}`
|
|
);
|
|
await page.mouse.click(
|
|
midCoreSelectorToggle.toggleClickBounds.x + midCoreSelectorToggle.toggleClickBounds.width / 2,
|
|
midCoreSelectorToggle.toggleClickBounds.y + midCoreSelectorToggle.toggleClickBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectorOpen === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const midCoreSelectorState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const midCoreResonancePageOne = midCoreSelectorState?.sortiePursuitPreview;
|
|
assert(
|
|
midCoreResonancePageOne?.coreCandidates?.length > 3 &&
|
|
midCoreResonancePageOne.selectorOpen === true &&
|
|
midCoreResonancePageOne.toggleLabel === '편성 비교' &&
|
|
midCoreResonancePageOne.page === 0 &&
|
|
midCoreResonancePageOne.pageCount === Math.ceil(midCoreResonancePageOne.coreCandidates.length / 3) &&
|
|
midCoreResonancePageOne.rows.length === 3 &&
|
|
midCoreResonancePageOne.prevEnabled === false &&
|
|
midCoreResonancePageOne.nextEnabled === true &&
|
|
midCoreResonancePageOne.prevClickBounds === null &&
|
|
isFiniteBounds(midCoreResonancePageOne.nextClickBounds) &&
|
|
boundsInside(midCoreResonancePageOne.nextBounds, midCoreResonancePageOne.panelBounds),
|
|
`Expected the mid-campaign formation to paginate more than three eligible core-resonance pairs: ${JSON.stringify(midCoreResonancePageOne)}`
|
|
);
|
|
const midCoreFirstPageIds = midCoreResonancePageOne.rows.map((row) => row.bondId);
|
|
await page.mouse.click(
|
|
midCoreResonancePageOne.nextClickBounds.x + midCoreResonancePageOne.nextClickBounds.width / 2,
|
|
midCoreResonancePageOne.nextClickBounds.y + midCoreResonancePageOne.nextClickBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.page === 1,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const midCoreResonancePageTwo = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-core-resonance-page-2.png`, fullPage: true });
|
|
assert(
|
|
midCoreResonancePageTwo?.page === 1 &&
|
|
midCoreResonancePageTwo.pageText === `2/${midCoreResonancePageTwo.pageCount}` &&
|
|
midCoreResonancePageTwo.prevEnabled === true &&
|
|
isFiniteBounds(midCoreResonancePageTwo.prevClickBounds) &&
|
|
midCoreResonancePageTwo.rows.length > 0 &&
|
|
midCoreResonancePageTwo.rows.every(
|
|
(row) => !midCoreFirstPageIds.includes(row.bondId) &&
|
|
isFiniteBounds(row.chipBounds) &&
|
|
boundsInside(row.chipBounds, midCoreResonancePageTwo.panelBounds)
|
|
),
|
|
`Expected the next-page control to expose later eligible resonance pairs inside the FHD panel: ${JSON.stringify(midCoreResonancePageTwo)}`
|
|
);
|
|
const midCoreLaterCandidate = midCoreResonancePageTwo.rows[0];
|
|
await page.mouse.click(
|
|
midCoreLaterCandidate.chipBounds.x + midCoreLaterCandidate.chipBounds.width / 2,
|
|
midCoreLaterCandidate.chipBounds.y + midCoreLaterCandidate.chipBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
(bondId) => {
|
|
const pursuit = window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview;
|
|
return pursuit?.selectedCoreBondId === bondId && pursuit?.page === 0 && pursuit?.rows?.[0]?.bondId === bondId;
|
|
},
|
|
midCoreLaterCandidate.bondId,
|
|
{ timeout: 30000 }
|
|
);
|
|
const midCoreLaterSelected = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview);
|
|
assert(
|
|
midCoreLaterSelected?.selectedCoreBondId === midCoreLaterCandidate.bondId &&
|
|
midCoreLaterSelected.page === 0 &&
|
|
midCoreLaterSelected.rows[0]?.bondId === midCoreLaterCandidate.bondId &&
|
|
midCoreLaterSelected.rows[0].selected === true &&
|
|
midCoreLaterSelected.rows[0].state === 'selected',
|
|
`Expected a pair reached through page two to become the visible persisted core designation: ${JSON.stringify(midCoreLaterSelected)}`
|
|
);
|
|
await page.mouse.click(
|
|
midCoreLaterSelected.rows[0].chipBounds.x + midCoreLaterSelected.rows[0].chipBounds.width / 2,
|
|
midCoreLaterSelected.rows[0].chipBounds.y + midCoreLaterSelected.rows[0].chipBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectedCoreBondId === null,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const midCoreSelectorAfterClear = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview);
|
|
await page.mouse.click(
|
|
midCoreSelectorAfterClear.toggleClickBounds.x + midCoreSelectorAfterClear.toggleClickBounds.width / 2,
|
|
midCoreSelectorAfterClear.toggleClickBounds.y + midCoreSelectorAfterClear.toggleClickBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectorOpen === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const midFormationSelectedBefore = [...(midFormationPortraitState?.selectedSortieUnitIds ?? [])];
|
|
const midFormationCampaignSelectedBefore = [...(midFormationPortraitState?.campaign?.selectedSortieUnitIds ?? [])];
|
|
const midFormationAssignmentsBefore = { ...(midFormationPortraitState?.sortieFormationAssignments ?? {}) };
|
|
const midFormationItemAssignmentsBefore = structuredClone(midFormationPortraitState?.sortieItemAssignments ?? {});
|
|
const midFormationSaveBefore = await readCampaignSave(page);
|
|
assert(
|
|
midFormationAssignmentsBefore['ma-dai'] === 'flank' &&
|
|
midFormationItemAssignmentsBefore['ma-dai']?.bean === 1 &&
|
|
midFormationItemAssignmentsBefore[midFormationSwapFixture.preservedUnitId]?.salve === 1,
|
|
`Expected the swap fixture to retain Ma Dai and another officer's assignments: ${JSON.stringify({
|
|
formation: midFormationAssignmentsBefore,
|
|
items: midFormationItemAssignmentsBefore,
|
|
preservedUnitId: midFormationSwapFixture.preservedUnitId
|
|
})}`
|
|
);
|
|
const maDaiCardPoint = await readSortiePortraitRosterCardPoint(page, 'ma-dai');
|
|
await page.mouse.click(maDaiCardPoint.x, maDaiCardPoint.y);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieFocusedUnitId === 'ma-dai', undefined, { timeout: 30000 });
|
|
const swapFocusState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
JSON.stringify(swapFocusState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) &&
|
|
JSON.stringify(swapFocusState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore),
|
|
`Expected focusing Ma Dai to preserve the actual sortie roster: ${JSON.stringify(swapFocusState?.selectedSortieUnitIds)}`
|
|
);
|
|
|
|
const guanYuHoverPoint = await readSortiePortraitRosterCardPoint(page, 'guan-yu');
|
|
await page.mouse.move(guanYuHoverPoint.x, guanYuHoverPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap';
|
|
}, undefined, { timeout: 30000 });
|
|
const midFormationSwapPreviewProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const view = scene?.sortieComparisonPanelView;
|
|
return {
|
|
state: window.__HEROS_DEBUG__?.camp(),
|
|
panel: {
|
|
title: view?.title?.text ?? null,
|
|
headline: view?.headline?.text ?? null,
|
|
impact: view?.impact?.text ?? null,
|
|
detail: view?.detail?.text ?? null
|
|
}
|
|
};
|
|
});
|
|
const midFormationSwapPreviewState = midFormationSwapPreviewProbe.state;
|
|
const swapPreview = midFormationSwapPreviewState?.sortieComparisonPreview;
|
|
const projectedSwapIdSet = new Set(
|
|
midFormationSelectedBefore.map((unitId) => unitId === 'ma-dai' ? 'guan-yu' : unitId)
|
|
);
|
|
const expectedProjectedSwapIds = [
|
|
...(midFormationSwapPreviewState?.sortieRoster ?? [])
|
|
.filter((unit) => unit.required && projectedSwapIdSet.has(unit.id))
|
|
.map((unit) => unit.id),
|
|
...(midFormationSwapPreviewState?.sortieRoster ?? [])
|
|
.filter((unit) => !unit.required && projectedSwapIdSet.has(unit.id))
|
|
.map((unit) => unit.id)
|
|
].slice(0, midFormationSelectedBefore.length);
|
|
assert(
|
|
midFormationPortraitState.sortiePortraitRoster.some(
|
|
(unit) => unit.id === swapPreview?.incomingUnitId && unit.portraitReady && unit.portraitTextureKey?.startsWith('portrait-card-')
|
|
),
|
|
`Expected portrait-card hover feedback for a mapped swap candidate: ${JSON.stringify(swapPreview?.incomingUnitId)}`
|
|
);
|
|
assert(
|
|
swapPreview?.mode === 'swap' &&
|
|
swapPreview?.incomingUnitId === 'guan-yu' &&
|
|
swapPreview?.outgoingUnitId === 'ma-dai' &&
|
|
JSON.stringify(swapPreview?.selectedUnitIds) === JSON.stringify(midFormationSelectedBefore) &&
|
|
JSON.stringify(swapPreview?.projectedSelectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
|
|
JSON.stringify(swapPreview?.current?.selectedUnitIds) === JSON.stringify(midFormationSelectedBefore) &&
|
|
JSON.stringify(swapPreview?.projected?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
|
|
JSON.stringify(swapPreview?.comparison?.current?.selectedUnitIds) === JSON.stringify(midFormationSelectedBefore) &&
|
|
JSON.stringify(swapPreview?.comparison?.projected?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
|
|
swapPreview?.metrics?.length === 4 &&
|
|
swapPreview.metrics.some((metric) => metric.delta !== 0) &&
|
|
swapPreview?.comparison?.lostRoles?.includes('flank') &&
|
|
swapPreview?.comparison?.activeBondDelta === 0 &&
|
|
swapPreview?.comparison?.gainedBonds?.length > 0 &&
|
|
swapPreview?.comparison?.lostBonds?.length > 0 &&
|
|
swapPreview?.strategyCoverage?.current?.count === 4 &&
|
|
swapPreview?.strategyCoverage?.projected?.count === 4 &&
|
|
swapPreview?.strategyCoverage?.current?.total === 4 &&
|
|
swapPreview?.strategyCoverage?.projected?.total === 4 &&
|
|
swapPreview?.strategyCoverage?.delta === 0 &&
|
|
swapPreview?.strategyCoverage?.trend === 'even' &&
|
|
swapPreview?.strategyCoverage?.gained?.length === 0 &&
|
|
swapPreview?.strategyCoverage?.lost?.length === 0,
|
|
`Expected a meaningful non-destructive Ma Dai to Guan Yu swap preview: ${JSON.stringify(swapPreview)}`
|
|
);
|
|
assert(
|
|
midFormationSwapPreviewProbe.panel.title === '가상 교체 · 마대 → 관우' &&
|
|
midFormationSwapPreviewProbe.panel.headline?.includes('OUT 마대') &&
|
|
midFormationSwapPreviewProbe.panel.headline?.includes('IN 관우') &&
|
|
midFormationSwapPreviewProbe.panel.impact?.includes('전법 4/4→4/4') &&
|
|
midFormationSwapPreviewProbe.panel.impact?.includes('추격') &&
|
|
midFormationSwapPreviewProbe.panel.impact?.includes('공백 돌파') &&
|
|
midFormationSwapPreviewProbe.panel.detail?.includes('전법 유지') &&
|
|
midFormationSwapPreviewProbe.panel.detail?.includes('추격 해제') &&
|
|
midFormationSwapPreviewProbe.panel.detail?.includes('8%') &&
|
|
midFormationSwapPreviewProbe.panel.detail?.includes('실제 편성 미변경'),
|
|
`Expected the visible comparison panel to render the swap preview: ${JSON.stringify(midFormationSwapPreviewProbe.panel)}`
|
|
);
|
|
assert(
|
|
JSON.stringify(midFormationSwapPreviewState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) &&
|
|
JSON.stringify(midFormationSwapPreviewState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore),
|
|
`Expected hover swap preview to preserve runtime and saved selections: ${JSON.stringify(midFormationSwapPreviewState?.selectedSortieUnitIds)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation-hover.png`, fullPage: true });
|
|
await moveLegacyUi(page, 40, 710);
|
|
await page.waitForFunction(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortieHoveredUnitId === null && state?.sortieComparisonPreview?.source === 'focus';
|
|
}, undefined, { timeout: 30000 });
|
|
const afterSwapPreviewProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const view = scene?.sortieComparisonPanelView;
|
|
return {
|
|
state: window.__HEROS_DEBUG__?.camp(),
|
|
panel: {
|
|
title: view?.title?.text ?? null,
|
|
headline: view?.headline?.text ?? null
|
|
}
|
|
};
|
|
});
|
|
const afterSwapPreviewState = afterSwapPreviewProbe.state;
|
|
assert(
|
|
JSON.stringify(afterSwapPreviewState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) &&
|
|
JSON.stringify(afterSwapPreviewState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore),
|
|
`Expected pointerout to restore focus preview without mutating selections: ${JSON.stringify(afterSwapPreviewState?.selectedSortieUnitIds)}`
|
|
);
|
|
assert(
|
|
afterSwapPreviewState?.sortieComparisonPreview?.source === 'focus' &&
|
|
afterSwapPreviewState?.sortieComparisonPreview?.mode === 'remove' &&
|
|
afterSwapPreviewState?.sortieComparisonPreview?.unitId === 'ma-dai' &&
|
|
afterSwapPreviewState?.sortieComparisonPreview?.incomingUnitId === null &&
|
|
afterSwapPreviewState?.sortieComparisonPreview?.outgoingUnitId === 'ma-dai' &&
|
|
afterSwapPreviewProbe.panel.title === '편성 변화 · 마대' &&
|
|
!afterSwapPreviewProbe.panel.headline?.includes('관우'),
|
|
`Expected pointerout to restore the visible Ma Dai focus preview: ${JSON.stringify(afterSwapPreviewProbe)}`
|
|
);
|
|
|
|
const guanYuSwapPoint = await readSortiePortraitRosterCardPoint(page, 'guan-yu');
|
|
await page.mouse.move(guanYuSwapPoint.x, guanYuSwapPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap';
|
|
}, undefined, { timeout: 30000 });
|
|
await page.mouse.click(guanYuSwapPoint.x, guanYuSwapPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortiePinnedSwapCandidateUnitId === 'guan-yu' &&
|
|
state?.sortieComparisonPreview?.source === 'pinned-swap' &&
|
|
state?.sortieComparisonAction?.kind === 'confirm-swap';
|
|
}, undefined, { timeout: 30000 });
|
|
const pinnedSwapProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const view = scene?.sortieComparisonPanelView;
|
|
return {
|
|
state,
|
|
action: {
|
|
buttonVisible: view?.actionButton?.visible ?? false,
|
|
labelVisible: view?.actionLabel?.visible ?? false,
|
|
label: view?.actionLabel?.text ?? null
|
|
}
|
|
};
|
|
});
|
|
const pinnedSwapState = pinnedSwapProbe.state;
|
|
const pinnedSwapPreview = pinnedSwapState?.sortieComparisonPreview;
|
|
const pinnedSwapSave = await readCampaignSave(page);
|
|
const previewIncomingRole = pinnedSwapState?.sortieRoster?.find((unit) => unit.id === 'guan-yu')?.formationRole;
|
|
assert(
|
|
pinnedSwapPreview?.source === 'pinned-swap' &&
|
|
pinnedSwapPreview?.mode === 'swap' &&
|
|
pinnedSwapPreview?.outgoingUnitId === 'ma-dai' &&
|
|
pinnedSwapPreview?.incomingUnitId === 'guan-yu' &&
|
|
JSON.stringify(pinnedSwapPreview?.projectedSelectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
|
|
previewIncomingRole === 'front' &&
|
|
pinnedSwapState?.sortieComparisonAction?.label === '교체 확정' &&
|
|
pinnedSwapState?.sortieComparisonAction?.outgoingUnitId === 'ma-dai' &&
|
|
pinnedSwapState?.sortieComparisonAction?.incomingUnitId === 'guan-yu' &&
|
|
pinnedSwapProbe.action.buttonVisible === true &&
|
|
pinnedSwapProbe.action.labelVisible === true &&
|
|
pinnedSwapProbe.action.label === '교체 확정',
|
|
`Expected clicking Guan Yu's card body to pin the canonical Ma Dai swap with a visible confirm action: ${JSON.stringify(pinnedSwapProbe)}`
|
|
);
|
|
assert(
|
|
JSON.stringify(pinnedSwapState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) &&
|
|
JSON.stringify(pinnedSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore) &&
|
|
sameJsonValue(pinnedSwapState?.sortieFormationAssignments, midFormationAssignmentsBefore) &&
|
|
sameJsonValue(pinnedSwapState?.sortieItemAssignments, midFormationItemAssignmentsBefore) &&
|
|
JSON.stringify(pinnedSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.current?.selectedSortieUnitIds) &&
|
|
JSON.stringify(pinnedSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.slot1?.selectedSortieUnitIds) &&
|
|
sameJsonValue(pinnedSwapSave.current?.sortieFormationAssignments, midFormationSaveBefore.current?.sortieFormationAssignments) &&
|
|
sameJsonValue(pinnedSwapSave.slot1?.sortieFormationAssignments, midFormationSaveBefore.slot1?.sortieFormationAssignments) &&
|
|
sameJsonValue(pinnedSwapSave.current?.sortieItemAssignments, midFormationSaveBefore.current?.sortieItemAssignments) &&
|
|
sameJsonValue(pinnedSwapSave.slot1?.sortieItemAssignments, midFormationSaveBefore.slot1?.sortieItemAssignments),
|
|
`Expected pinning the swap to preserve runtime, campaign, current-save, and slot-1 assignments: ${JSON.stringify({
|
|
state: pinnedSwapState,
|
|
save: pinnedSwapSave
|
|
})}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-swap-pinned.png`, fullPage: true });
|
|
|
|
const confirmSwapActionPoint = await readSortieComparisonActionPoint(page);
|
|
assert(
|
|
confirmSwapActionPoint.label === '교체 확정',
|
|
`Expected the scaled Phaser action button to expose the confirm label: ${JSON.stringify(confirmSwapActionPoint)}`
|
|
);
|
|
await page.mouse.click(confirmSwapActionPoint.x, confirmSwapActionPoint.y);
|
|
await page.waitForFunction((projectedIds) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(projectedIds) &&
|
|
state?.sortieFormationAssignments?.['ma-dai'] === undefined &&
|
|
state?.sortieFormationAssignments?.['guan-yu'] === 'front' &&
|
|
state?.sortieItemAssignments?.['ma-dai'] === undefined &&
|
|
state?.sortieItemAssignments?.['guan-yu'] === undefined &&
|
|
state?.sortieSwapUndo?.available === true &&
|
|
state?.sortieComparisonAction?.kind === 'undo-swap';
|
|
}, expectedProjectedSwapIds, { timeout: 30000 });
|
|
const confirmedSwapState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const confirmedSwapSave = await readCampaignSave(page);
|
|
const expectedConfirmedAssignments = { ...midFormationAssignmentsBefore };
|
|
delete expectedConfirmedAssignments['ma-dai'];
|
|
expectedConfirmedAssignments['guan-yu'] = previewIncomingRole;
|
|
const expectedConfirmedItemAssignments = structuredClone(midFormationItemAssignmentsBefore);
|
|
delete expectedConfirmedItemAssignments['ma-dai'];
|
|
delete expectedConfirmedItemAssignments['guan-yu'];
|
|
assert(
|
|
JSON.stringify(confirmedSwapState?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
|
|
JSON.stringify(confirmedSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
|
|
JSON.stringify(confirmedSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
|
|
JSON.stringify(confirmedSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds),
|
|
`Expected swap confirmation to persist the canonical projected roster everywhere: ${JSON.stringify({
|
|
expectedProjectedSwapIds,
|
|
runtime: confirmedSwapState?.selectedSortieUnitIds,
|
|
campaign: confirmedSwapState?.campaign?.selectedSortieUnitIds,
|
|
current: confirmedSwapSave.current?.selectedSortieUnitIds,
|
|
slot1: confirmedSwapSave.slot1?.selectedSortieUnitIds
|
|
})}`
|
|
);
|
|
assert(
|
|
sameJsonValue(confirmedSwapState?.sortieFormationAssignments, expectedConfirmedAssignments) &&
|
|
sameJsonValue(confirmedSwapState?.campaign?.sortieFormationAssignments, expectedConfirmedAssignments) &&
|
|
sameJsonValue(confirmedSwapSave.current?.sortieFormationAssignments, expectedConfirmedAssignments) &&
|
|
sameJsonValue(confirmedSwapSave.slot1?.sortieFormationAssignments, expectedConfirmedAssignments) &&
|
|
sameJsonValue(confirmedSwapState?.sortieItemAssignments, expectedConfirmedItemAssignments) &&
|
|
sameJsonValue(confirmedSwapState?.campaign?.sortieItemAssignments, expectedConfirmedItemAssignments) &&
|
|
sameJsonValue(confirmedSwapSave.current?.sortieItemAssignments, expectedConfirmedItemAssignments) &&
|
|
sameJsonValue(confirmedSwapSave.slot1?.sortieItemAssignments, expectedConfirmedItemAssignments),
|
|
`Expected confirmation to remove Ma Dai's role and supply, assign Guan Yu's preview role without transferring supply, and preserve every other assignment: ${JSON.stringify({
|
|
expectedFormation: expectedConfirmedAssignments,
|
|
expectedItems: expectedConfirmedItemAssignments,
|
|
state: confirmedSwapState,
|
|
save: confirmedSwapSave
|
|
})}`
|
|
);
|
|
assert(
|
|
confirmedSwapState?.sortieFocusedUnitId === 'guan-yu' &&
|
|
confirmedSwapState?.sortiePinnedSwapCandidateUnitId === null &&
|
|
confirmedSwapState?.sortieSwapUndo?.available === true &&
|
|
confirmedSwapState?.sortieSwapUndo?.outgoingUnitId === 'ma-dai' &&
|
|
confirmedSwapState?.sortieSwapUndo?.incomingUnitId === 'guan-yu' &&
|
|
confirmedSwapState?.sortieComparisonAction?.kind === 'undo-swap' &&
|
|
confirmedSwapState?.sortieComparisonAction?.label === '되돌리기' &&
|
|
confirmedSwapState?.sortiePlanFeedback?.includes('장비·보급'),
|
|
`Expected confirmed swap feedback and one-use undo action: ${JSON.stringify(confirmedSwapState)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-swap-confirmed.png`, fullPage: true });
|
|
|
|
const openPresetBookForSavePoint = await readSortiePresetControlPoint(page, 'toggle');
|
|
await page.mouse.click(openPresetBookForSavePoint.x, openPresetBookForSavePoint.y);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === true, undefined, { timeout: 30000 });
|
|
const emptyPresetBookState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
emptyPresetBookState?.sortiePresetBrowser?.cards?.length === 3 &&
|
|
emptyPresetBookState.sortiePresetBrowser.cards.every((card) => card.saved === false) &&
|
|
emptyPresetBookState?.sortieSwapUndo?.available === true,
|
|
`Expected three empty preset cards without consuming the pending swap undo: ${JSON.stringify(emptyPresetBookState?.sortiePresetBrowser)}`
|
|
);
|
|
const saveMobilePresetPoint = await readSortiePresetControlPoint(page, 'save', 'mobile');
|
|
await page.mouse.click(saveMobilePresetPoint.x, saveMobilePresetPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortieFormationPresets?.mobile?.selectedUnitIds?.length > 0 &&
|
|
state?.sortieSwapUndo?.available === true &&
|
|
state?.sortieComparisonAction?.kind === 'undo-swap';
|
|
}, undefined, { timeout: 30000 });
|
|
const savedMobilePresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const savedMobilePresetSave = await readCampaignSave(page);
|
|
const savedMobilePreset = savedMobilePresetState?.sortieFormationPresets?.mobile;
|
|
assert(
|
|
JSON.stringify(savedMobilePreset?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
|
|
savedMobilePreset?.selectedUnitIds?.every((unitId) => Boolean(savedMobilePreset.formationAssignments?.[unitId])) &&
|
|
Object.keys(savedMobilePreset ?? {}).sort().join(',') === 'formationAssignments,selectedUnitIds' &&
|
|
savedMobilePreset?.sortieItemAssignments === undefined &&
|
|
savedMobilePreset?.itemAssignments === undefined,
|
|
`Expected mobile preset save to materialize every selected role without storing supplies: ${JSON.stringify(savedMobilePreset)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(savedMobilePresetSave.current?.sortieFormationPresets?.mobile, savedMobilePreset) &&
|
|
sameJsonValue(savedMobilePresetSave.slot1?.sortieFormationPresets?.mobile, savedMobilePreset) &&
|
|
sameJsonValue(savedMobilePresetState?.sortieItemAssignments, expectedConfirmedItemAssignments) &&
|
|
sameJsonValue(savedMobilePresetState?.campaign?.sortieItemAssignments, expectedConfirmedItemAssignments) &&
|
|
savedMobilePresetState?.sortieSwapUndo?.available === true,
|
|
`Expected preset metadata to sync to current and slot saves without changing the applied roster, roles, supplies, or swap undo: ${JSON.stringify({
|
|
state: savedMobilePresetState,
|
|
save: savedMobilePresetSave
|
|
})}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-preset-saved.png`, fullPage: true });
|
|
|
|
const undoSwapActionPoint = await readSortieComparisonActionPoint(page);
|
|
assert(
|
|
undoSwapActionPoint.label === '되돌리기',
|
|
`Expected the scaled Phaser action button to switch to undo: ${JSON.stringify(undoSwapActionPoint)}`
|
|
);
|
|
await page.mouse.click(undoSwapActionPoint.x, undoSwapActionPoint.y);
|
|
await page.waitForFunction((selectedBefore) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(selectedBefore) &&
|
|
state?.sortieFocusedUnitId === 'ma-dai' &&
|
|
state?.sortieComparisonAction === null &&
|
|
!state?.sortieSwapUndo?.available;
|
|
}, midFormationSelectedBefore, { timeout: 30000 });
|
|
const undoneSwapProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const view = scene?.sortieComparisonPanelView;
|
|
return {
|
|
state,
|
|
action: {
|
|
buttonVisible: view?.actionButton?.visible ?? false,
|
|
labelVisible: view?.actionLabel?.visible ?? false,
|
|
label: view?.actionLabel?.text ?? null
|
|
}
|
|
};
|
|
});
|
|
const undoneSwapState = undoneSwapProbe.state;
|
|
const undoneSwapSave = await readCampaignSave(page);
|
|
const undoRestoreChecks = {
|
|
runtimeSelection: JSON.stringify(undoneSwapState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore),
|
|
campaignSelection: JSON.stringify(undoneSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore),
|
|
currentSaveSelection: JSON.stringify(undoneSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.current?.selectedSortieUnitIds),
|
|
slotSelection: JSON.stringify(undoneSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.slot1?.selectedSortieUnitIds),
|
|
runtimeFormation: sameJsonValue(undoneSwapState?.sortieFormationAssignments, midFormationAssignmentsBefore),
|
|
campaignFormation: sameJsonValue(undoneSwapState?.campaign?.sortieFormationAssignments, midFormationAssignmentsBefore),
|
|
currentSaveFormation: sameJsonValue(undoneSwapSave.current?.sortieFormationAssignments, midFormationSaveBefore.current?.sortieFormationAssignments),
|
|
slotFormation: sameJsonValue(undoneSwapSave.slot1?.sortieFormationAssignments, midFormationSaveBefore.slot1?.sortieFormationAssignments),
|
|
runtimeItems: sameJsonValue(undoneSwapState?.sortieItemAssignments, midFormationItemAssignmentsBefore),
|
|
campaignItems: sameJsonValue(undoneSwapState?.campaign?.sortieItemAssignments, midFormationItemAssignmentsBefore),
|
|
currentSaveItems: sameJsonValue(undoneSwapSave.current?.sortieItemAssignments, midFormationSaveBefore.current?.sortieItemAssignments),
|
|
slotItems: sameJsonValue(undoneSwapSave.slot1?.sortieItemAssignments, midFormationSaveBefore.slot1?.sortieItemAssignments)
|
|
};
|
|
assert(
|
|
Object.values(undoRestoreChecks).every(Boolean),
|
|
`Expected one-time undo to restore the exact roster, role map, and supply map in runtime and both saves: ${JSON.stringify({
|
|
checks: undoRestoreChecks,
|
|
expected: {
|
|
runtimeSelected: midFormationSelectedBefore,
|
|
campaignSelected: midFormationCampaignSelectedBefore,
|
|
currentSaveSelected: midFormationSaveBefore.current?.selectedSortieUnitIds,
|
|
slotSelected: midFormationSaveBefore.slot1?.selectedSortieUnitIds,
|
|
formation: midFormationAssignmentsBefore,
|
|
items: midFormationItemAssignmentsBefore
|
|
},
|
|
actual: {
|
|
runtimeSelected: undoneSwapState?.selectedSortieUnitIds,
|
|
campaignSelected: undoneSwapState?.campaign?.selectedSortieUnitIds,
|
|
currentSaveSelected: undoneSwapSave.current?.selectedSortieUnitIds,
|
|
slotSelected: undoneSwapSave.slot1?.selectedSortieUnitIds,
|
|
runtimeFormation: undoneSwapState?.sortieFormationAssignments,
|
|
campaignFormation: undoneSwapState?.campaign?.sortieFormationAssignments,
|
|
currentSaveFormation: undoneSwapSave.current?.sortieFormationAssignments,
|
|
slotFormation: undoneSwapSave.slot1?.sortieFormationAssignments,
|
|
runtimeItems: undoneSwapState?.sortieItemAssignments,
|
|
campaignItems: undoneSwapState?.campaign?.sortieItemAssignments,
|
|
currentSaveItems: undoneSwapSave.current?.sortieItemAssignments,
|
|
slotItems: undoneSwapSave.slot1?.sortieItemAssignments
|
|
}
|
|
})}`
|
|
);
|
|
assert(
|
|
undoneSwapState?.sortieFocusedUnitId === 'ma-dai' &&
|
|
undoneSwapState?.sortiePinnedSwapCandidateUnitId === null &&
|
|
undoneSwapState?.sortieComparisonAction === null &&
|
|
!undoneSwapState?.sortieSwapUndo?.available &&
|
|
undoneSwapProbe.action.buttonVisible === false &&
|
|
undoneSwapProbe.action.labelVisible === false,
|
|
`Expected undo to restore Ma Dai focus and consume the panel action exactly once: ${JSON.stringify(undoneSwapProbe)}`
|
|
);
|
|
|
|
const presetBaselineState = undoneSwapState;
|
|
const presetBaselineSave = undoneSwapSave;
|
|
const openPresetBookForApplyPoint = await readSortiePresetControlPoint(page, 'toggle');
|
|
await page.mouse.click(openPresetBookForApplyPoint.x, openPresetBookForApplyPoint.y);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === true, undefined, { timeout: 30000 });
|
|
const presetBookState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const mobilePresetCard = presetBookState?.sortiePresetBrowser?.cards?.find((card) => card.id === 'mobile');
|
|
assert(
|
|
presetBookState?.sortiePresetBrowser?.cards?.length === 3 &&
|
|
presetBookState?.sortiePresetBrowser?.recommendedPresetId === 'mobile' &&
|
|
mobilePresetCard?.saved === true &&
|
|
mobilePresetCard?.applicable === true &&
|
|
mobilePresetCard?.recommended === true &&
|
|
mobilePresetCard?.current === false &&
|
|
presetBookState.sortiePresetBrowser.cards.filter((card) => !card.saved).length === 2,
|
|
`Expected the only saved valid preset to receive the scenario recommendation badge: ${JSON.stringify(presetBookState?.sortiePresetBrowser)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-preset-book.png`, fullPage: true });
|
|
|
|
const hoverMobilePresetPoint = await readSortiePresetControlPoint(page, 'card', 'mobile');
|
|
await page.mouse.move(hoverMobilePresetPoint.x, hoverMobilePresetPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortiePresetBrowser?.hoveredPresetId === 'mobile' &&
|
|
state?.sortieComparisonPreview?.source === 'hover-preset' &&
|
|
state?.sortieComparisonPreview?.presetId === 'mobile';
|
|
}, undefined, { timeout: 30000 });
|
|
const hoverPresetProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const view = scene?.sortieComparisonPanelView;
|
|
return {
|
|
state: window.__HEROS_DEBUG__?.camp(),
|
|
panel: {
|
|
title: view?.title?.text ?? null,
|
|
headline: view?.headline?.text ?? null,
|
|
detail: view?.detail?.text ?? null
|
|
}
|
|
};
|
|
});
|
|
const hoverPresetState = hoverPresetProbe.state;
|
|
const hoverPresetSave = await readCampaignSave(page);
|
|
const hoverProjectionCard = hoverPresetState?.sortiePresetBrowser?.cards?.find((card) => card.id === 'mobile');
|
|
assert(
|
|
hoverPresetState?.sortieComparisonPreview?.mode === 'preset' &&
|
|
hoverPresetState?.sortieComparisonPreview?.metrics?.length === 4 &&
|
|
hoverPresetState.sortieComparisonPreview.metrics[0]?.label === '공격 합' &&
|
|
hoverPresetState.sortieComparisonPreview.metrics[1]?.label === '지력 합' &&
|
|
hoverPresetState.sortieComparisonPreview.metrics[2]?.label === '평균 기동' &&
|
|
hoverPresetProbe.panel.title === '기동 편성책 비교' &&
|
|
hoverPresetProbe.panel.headline?.includes('유지') &&
|
|
hoverPresetProbe.panel.detail?.includes('실제 편성 미변경'),
|
|
`Expected a whole-formation, non-destructive preset comparison: ${JSON.stringify(hoverPresetProbe)}`
|
|
);
|
|
assert(
|
|
JSON.stringify(hoverPresetState?.selectedSortieUnitIds) === JSON.stringify(presetBaselineState?.selectedSortieUnitIds) &&
|
|
sameJsonValue(hoverPresetState?.sortieFormationAssignments, presetBaselineState?.sortieFormationAssignments) &&
|
|
sameJsonValue(hoverPresetState?.sortieItemAssignments, presetBaselineState?.sortieItemAssignments) &&
|
|
sameJsonValue(hoverPresetSave.current, presetBaselineSave.current) &&
|
|
sameJsonValue(hoverPresetSave.slot1, presetBaselineSave.slot1),
|
|
`Expected preset hover to preserve runtime, campaign, current save, and slot save: ${JSON.stringify(hoverPresetState)}`
|
|
);
|
|
|
|
const compareMobilePresetPoint = await readSortiePresetControlPoint(page, 'compare', 'mobile');
|
|
await page.mouse.click(compareMobilePresetPoint.x, compareMobilePresetPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortiePresetBrowser?.pinnedPresetId === 'mobile' &&
|
|
state?.sortieComparisonPreview?.source === 'pinned-preset' &&
|
|
state?.sortieComparisonAction?.kind === 'confirm-preset';
|
|
}, undefined, { timeout: 30000 });
|
|
const pinnedPresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const pinnedPresetSave = await readCampaignSave(page);
|
|
const pinnedProjectionCard = pinnedPresetState?.sortiePresetBrowser?.cards?.find((card) => card.id === 'mobile');
|
|
assert(
|
|
JSON.stringify(pinnedProjectionCard?.projectedSelectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) &&
|
|
sameJsonValue(pinnedProjectionCard?.projectedFormationAssignments, savedMobilePreset?.formationAssignments) &&
|
|
pinnedPresetState?.sortieComparisonAction?.label === '적용 확정' &&
|
|
sameJsonValue(pinnedPresetSave.current, presetBaselineSave.current) &&
|
|
sameJsonValue(pinnedPresetSave.slot1, presetBaselineSave.slot1),
|
|
`Expected pinning the preset to keep every saved value unchanged until confirmation: ${JSON.stringify(pinnedPresetState)}`
|
|
);
|
|
const expectedPresetSelectedIds = [...(pinnedProjectionCard?.projectedSelectedUnitIds ?? [])];
|
|
const expectedPresetFormationAssignments = { ...(pinnedProjectionCard?.projectedFormationAssignments ?? {}) };
|
|
const expectedPresetItemAssignments = structuredClone(pinnedProjectionCard?.projectedItemAssignments ?? {});
|
|
const applyPresetActionPoint = await readSortieComparisonActionPoint(page);
|
|
assert(applyPresetActionPoint.label === '적용 확정', `Expected preset confirmation label: ${JSON.stringify(applyPresetActionPoint)}`);
|
|
await page.mouse.click(applyPresetActionPoint.x, applyPresetActionPoint.y);
|
|
await page.waitForFunction((expectedIds) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(expectedIds) &&
|
|
state?.sortiePresetUndo?.available === true &&
|
|
state?.sortieComparisonAction?.kind === 'undo-preset' &&
|
|
state?.sortiePresetBrowser?.open === false;
|
|
}, expectedPresetSelectedIds, { timeout: 30000 });
|
|
const appliedPresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const appliedPresetSave = await readCampaignSave(page);
|
|
assert(
|
|
JSON.stringify(appliedPresetState?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) &&
|
|
JSON.stringify(appliedPresetState?.campaign?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) &&
|
|
JSON.stringify(appliedPresetSave.current?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) &&
|
|
JSON.stringify(appliedPresetSave.slot1?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) &&
|
|
sameJsonValue(appliedPresetState?.sortieFormationAssignments, expectedPresetFormationAssignments) &&
|
|
sameJsonValue(appliedPresetState?.campaign?.sortieFormationAssignments, expectedPresetFormationAssignments) &&
|
|
sameJsonValue(appliedPresetSave.current?.sortieFormationAssignments, expectedPresetFormationAssignments) &&
|
|
sameJsonValue(appliedPresetSave.slot1?.sortieFormationAssignments, expectedPresetFormationAssignments),
|
|
`Expected preset confirmation to persist the canonical roster and stored roles everywhere: ${JSON.stringify(appliedPresetState)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(appliedPresetState?.sortieItemAssignments, expectedPresetItemAssignments) &&
|
|
sameJsonValue(appliedPresetState?.campaign?.sortieItemAssignments, expectedPresetItemAssignments) &&
|
|
sameJsonValue(appliedPresetSave.current?.sortieItemAssignments, expectedPresetItemAssignments) &&
|
|
sameJsonValue(appliedPresetSave.slot1?.sortieItemAssignments, expectedPresetItemAssignments) &&
|
|
appliedPresetState?.sortieItemAssignments?.['ma-dai'] === undefined &&
|
|
appliedPresetState?.sortieItemAssignments?.['guan-yu'] === undefined &&
|
|
appliedPresetState?.sortiePresetUndo?.presetId === 'mobile',
|
|
`Expected preset apply to preserve only shared officers' supplies without auto-transferring them: ${JSON.stringify({
|
|
expected: expectedPresetItemAssignments,
|
|
actual: appliedPresetState?.sortieItemAssignments
|
|
})}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-preset-applied.png`, fullPage: true });
|
|
|
|
const undoPresetActionPoint = await readSortieComparisonActionPoint(page);
|
|
assert(undoPresetActionPoint.label === '되돌리기', `Expected preset undo label: ${JSON.stringify(undoPresetActionPoint)}`);
|
|
await page.mouse.click(undoPresetActionPoint.x, undoPresetActionPoint.y);
|
|
await page.waitForFunction((baselineIds) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(baselineIds) &&
|
|
state?.sortiePresetUndo === null &&
|
|
state?.sortieComparisonAction === null;
|
|
}, presetBaselineState?.selectedSortieUnitIds ?? [], { timeout: 30000 });
|
|
const undonePresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const undonePresetSave = await readCampaignSave(page);
|
|
assert(
|
|
JSON.stringify(undonePresetState?.selectedSortieUnitIds) === JSON.stringify(presetBaselineState?.selectedSortieUnitIds) &&
|
|
sameJsonValue(undonePresetState?.sortieFormationAssignments, presetBaselineState?.sortieFormationAssignments) &&
|
|
sameJsonValue(undonePresetState?.sortieItemAssignments, presetBaselineState?.sortieItemAssignments) &&
|
|
JSON.stringify(undonePresetSave.current?.selectedSortieUnitIds) === JSON.stringify(presetBaselineSave.current?.selectedSortieUnitIds) &&
|
|
JSON.stringify(undonePresetSave.slot1?.selectedSortieUnitIds) === JSON.stringify(presetBaselineSave.slot1?.selectedSortieUnitIds) &&
|
|
sameJsonValue(undonePresetSave.current?.sortieFormationAssignments, presetBaselineSave.current?.sortieFormationAssignments) &&
|
|
sameJsonValue(undonePresetSave.slot1?.sortieFormationAssignments, presetBaselineSave.slot1?.sortieFormationAssignments) &&
|
|
sameJsonValue(undonePresetSave.current?.sortieItemAssignments, presetBaselineSave.current?.sortieItemAssignments) &&
|
|
sameJsonValue(undonePresetSave.slot1?.sortieItemAssignments, presetBaselineSave.slot1?.sortieItemAssignments) &&
|
|
sameJsonValue(undonePresetSave.current?.sortieFormationPresets?.mobile, savedMobilePreset) &&
|
|
sameJsonValue(undonePresetSave.slot1?.sortieFormationPresets?.mobile, savedMobilePreset),
|
|
`Expected one-time preset undo to restore the exact roster, roles, and supplies while retaining the saved preset: ${JSON.stringify({
|
|
state: undonePresetState,
|
|
save: undonePresetSave
|
|
})}`
|
|
);
|
|
|
|
const openPresetBookForSiegeFixturePoint = await readSortiePresetControlPoint(page, 'toggle');
|
|
await page.mouse.click(openPresetBookForSiegeFixturePoint.x, openPresetBookForSiegeFixturePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const saveSiegePresetPoint = await readSortiePresetControlPoint(page, 'save', 'siege');
|
|
await page.mouse.click(saveSiegePresetPoint.x, saveSiegePresetPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortiePresetBrowser?.open === true &&
|
|
state?.sortieFormationPresets?.siege?.selectedUnitIds?.length > 0;
|
|
}, undefined, { timeout: 30000 });
|
|
const savedSiegeFixtureState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const savedSiegeFixtureSave = await readCampaignSave(page);
|
|
const savedSiegePreset = savedSiegeFixtureState?.sortieFormationPresets?.siege;
|
|
assert(
|
|
savedSiegePreset?.selectedUnitIds?.length === savedSiegeFixtureState?.selectedSortieUnitIds?.length &&
|
|
savedSiegePreset.selectedUnitIds.every((unitId) => Boolean(savedSiegePreset.formationAssignments?.[unitId])) &&
|
|
JSON.stringify(Object.keys(savedSiegePreset).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) &&
|
|
savedSiegePreset.sortieItemAssignments === undefined &&
|
|
savedSiegePreset.itemAssignments === undefined &&
|
|
sameJsonValue(savedSiegeFixtureSave.current?.sortieFormationPresets?.siege, savedSiegePreset) &&
|
|
sameJsonValue(savedSiegeFixtureSave.slot1?.sortieFormationPresets?.siege, savedSiegePreset),
|
|
`Expected a deterministic pre-battle siege preset fixture containing only the current roster and roles: ${JSON.stringify({
|
|
state: savedSiegeFixtureState,
|
|
save: savedSiegeFixtureSave
|
|
})}`
|
|
);
|
|
const closePresetBookAfterSiegeFixturePoint = await readSortiePresetControlPoint(page, 'close');
|
|
await page.mouse.click(closePresetBookAfterSiegeFixturePoint.x, closePresetBookAfterSiegeFixturePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const midPortraitUndoRestoredPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
|
|
assert(
|
|
midPortraitUndoRestoredPage?.page === 1 &&
|
|
midPortraitUndoRestoredPage.scroll === midPortraitUndoRestoredPage.pageSize &&
|
|
midPortraitUndoRestoredPage.previousEnabled === true &&
|
|
isFiniteBounds(midPortraitUndoRestoredPage.previousButtonBounds),
|
|
`Expected swap undo to restore the second portrait page before the independent paging roundtrip: ${JSON.stringify(midPortraitUndoRestoredPage)}`
|
|
);
|
|
const midPortraitPagingResetPoint = await readSortiePortraitRosterNavigationPoint(page, 'previous');
|
|
await page.mouse.click(midPortraitPagingResetPoint.x, midPortraitPagingResetPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
|
|
return view?.page === 0 &&
|
|
view.scroll === 0 &&
|
|
view.previousEnabled === false &&
|
|
view.visibleUnitIds?.length === 6 &&
|
|
view.cardBounds?.length === 6;
|
|
}, undefined, { timeout: 30000 });
|
|
|
|
const midPortraitPagingBaseline = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
view: state?.sortiePortraitRosterView ?? null,
|
|
allUnitIds: state?.sortiePortraitRoster?.map((unit) => unit.id) ?? [],
|
|
focusedUnitId: state?.sortieFocusedUnitId ?? null,
|
|
selectedUnitIds: state?.selectedSortieUnitIds ?? [],
|
|
campaignSelectedUnitIds: state?.campaign?.selectedSortieUnitIds ?? [],
|
|
formationAssignments: state?.sortieFormationAssignments ?? {},
|
|
itemAssignments: state?.sortieItemAssignments ?? {}
|
|
};
|
|
});
|
|
const midPortraitPagingSaveBefore = await readCampaignSave(page);
|
|
assert(
|
|
midPortraitPagingBaseline.allUnitIds.length === 23 &&
|
|
new Set(midPortraitPagingBaseline.allUnitIds).size === 23,
|
|
`Expected the portrait paging fixture to contain exactly 23 unique campaign officers: ${JSON.stringify(midPortraitPagingBaseline)}`
|
|
);
|
|
|
|
const midPortraitForwardUnitIds = [];
|
|
let midPortraitCurrentView = midPortraitPagingBaseline.view;
|
|
for (let expectedPage = 0; expectedPage < 4; expectedPage += 1) {
|
|
const expectedVisibleUnitIds = midPortraitPagingBaseline.allUnitIds.slice(expectedPage * 6, expectedPage * 6 + 6);
|
|
assertSortiePortraitRosterPage(midPortraitCurrentView, expectedPage, expectedVisibleUnitIds);
|
|
midPortraitForwardUnitIds.push(...midPortraitCurrentView.visibleUnitIds);
|
|
if (expectedPage < 3) {
|
|
const nextPoint = await readSortiePortraitRosterNavigationPoint(page, 'next');
|
|
await page.mouse.click(nextPoint.x, nextPoint.y);
|
|
await page.waitForFunction(
|
|
({ pageIndex, visibleUnitIds }) => {
|
|
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
|
|
return view?.page === pageIndex && JSON.stringify(view.visibleUnitIds) === JSON.stringify(visibleUnitIds);
|
|
},
|
|
{ pageIndex: expectedPage + 1, visibleUnitIds: midPortraitPagingBaseline.allUnitIds.slice((expectedPage + 1) * 6, (expectedPage + 2) * 6) },
|
|
{ timeout: 30000 }
|
|
);
|
|
midPortraitCurrentView = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
|
|
}
|
|
}
|
|
assert(
|
|
midPortraitForwardUnitIds.length === 23 &&
|
|
new Set(midPortraitForwardUnitIds).size === 23 &&
|
|
sameJsonValue(midPortraitForwardUnitIds, midPortraitPagingBaseline.allUnitIds) &&
|
|
midPortraitCurrentView.visibleUnitIds.length === 5,
|
|
`Expected four portrait pages to expose all 23 officers exactly once and finish with five officers: ${JSON.stringify({
|
|
expected: midPortraitPagingBaseline.allUnitIds,
|
|
actual: midPortraitForwardUnitIds,
|
|
lastPage: midPortraitCurrentView
|
|
})}`
|
|
);
|
|
await page.waitForTimeout(300);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-roster-last-page.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp sortie portrait roster last page');
|
|
|
|
for (let expectedPage = 2; expectedPage >= 0; expectedPage -= 1) {
|
|
const previousPoint = await readSortiePortraitRosterNavigationPoint(page, 'previous');
|
|
await page.mouse.click(previousPoint.x, previousPoint.y);
|
|
const expectedVisibleUnitIds = midPortraitPagingBaseline.allUnitIds.slice(expectedPage * 6, expectedPage * 6 + 6);
|
|
await page.waitForFunction(
|
|
({ pageIndex, visibleUnitIds }) => {
|
|
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
|
|
return view?.page === pageIndex && JSON.stringify(view.visibleUnitIds) === JSON.stringify(visibleUnitIds);
|
|
},
|
|
{ pageIndex: expectedPage, visibleUnitIds: expectedVisibleUnitIds },
|
|
{ timeout: 30000 }
|
|
);
|
|
midPortraitCurrentView = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
|
|
assertSortiePortraitRosterPage(midPortraitCurrentView, expectedPage, expectedVisibleUnitIds);
|
|
}
|
|
|
|
const midPortraitPagingAfter = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
focusedUnitId: state?.sortieFocusedUnitId ?? null,
|
|
selectedUnitIds: state?.selectedSortieUnitIds ?? [],
|
|
campaignSelectedUnitIds: state?.campaign?.selectedSortieUnitIds ?? [],
|
|
formationAssignments: state?.sortieFormationAssignments ?? {},
|
|
itemAssignments: state?.sortieItemAssignments ?? {}
|
|
};
|
|
});
|
|
const midPortraitPagingSaveAfter = await readCampaignSave(page);
|
|
assert(
|
|
midPortraitCurrentView?.page === 0 &&
|
|
midPortraitPagingAfter.focusedUnitId === midPortraitPagingBaseline.focusedUnitId &&
|
|
sameJsonValue(midPortraitPagingAfter.selectedUnitIds, midPortraitPagingBaseline.selectedUnitIds) &&
|
|
sameJsonValue(midPortraitPagingAfter.campaignSelectedUnitIds, midPortraitPagingBaseline.campaignSelectedUnitIds) &&
|
|
sameJsonValue(midPortraitPagingAfter.formationAssignments, midPortraitPagingBaseline.formationAssignments) &&
|
|
sameJsonValue(midPortraitPagingAfter.itemAssignments, midPortraitPagingBaseline.itemAssignments) &&
|
|
sameJsonValue(midPortraitPagingSaveAfter, midPortraitPagingSaveBefore),
|
|
`Expected portrait paging to return to page one without changing focus, selection, formation, supplies, or saves: ${JSON.stringify({
|
|
before: midPortraitPagingBaseline,
|
|
after: midPortraitPagingAfter,
|
|
saveBefore: midPortraitPagingSaveBefore,
|
|
saveAfter: midPortraitPagingSaveAfter
|
|
})}`
|
|
);
|
|
|
|
const fullRosterCandidateProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const candidate = state?.sortieRoster?.find((unit) => !unit.selected && unit.available);
|
|
if (!candidate || typeof scene?.toggleSortieUnit !== 'function') {
|
|
return { candidateId: null, selectedBefore: state?.selectedSortieUnitIds ?? [] };
|
|
}
|
|
scene.toggleSortieUnit(candidate.id);
|
|
return { candidateId: candidate.id, selectedBefore: state.selectedSortieUnitIds ?? [] };
|
|
});
|
|
assert(fullRosterCandidateProbe.candidateId, `Expected a full-roster swap candidate: ${JSON.stringify(fullRosterCandidateProbe)}`);
|
|
await page.waitForFunction((probe) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortieFocusedUnitId === probe.candidateId &&
|
|
state?.sortiePrepStep === 'formation' &&
|
|
state?.sortieFocusedSynergyPreview?.mode === 'waiting' &&
|
|
state?.selectedSortieUnitIds?.length === probe.selectedBefore.length &&
|
|
!state.selectedSortieUnitIds.includes(probe.candidateId);
|
|
}, fullRosterCandidateProbe, { timeout: 30000 });
|
|
|
|
const underCapacityProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const removable = state?.sortieRoster?.find((unit) => unit.selected && !unit.required);
|
|
if (!removable || typeof scene?.toggleSortieUnit !== 'function') {
|
|
return { removedUnitId: null, selectedBefore: state?.selectedSortieUnitIds ?? [] };
|
|
}
|
|
scene.toggleSortieUnit(removable.id);
|
|
return { removedUnitId: removable.id, selectedBefore: state.selectedSortieUnitIds ?? [] };
|
|
});
|
|
assert(underCapacityProbe.removedUnitId, `Expected a removable mid-campaign officer: ${JSON.stringify(underCapacityProbe)}`);
|
|
await page.waitForFunction((probe) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.sortieVisible === true &&
|
|
state?.sortiePrepStep === 'formation' &&
|
|
state?.selectedSortieUnitIds?.length === probe.selectedBefore.length - 1 &&
|
|
!state.selectedSortieUnitIds.includes(probe.removedUnitId) &&
|
|
state?.sortieFocusedSynergyPreview?.mode === 'add';
|
|
}, underCapacityProbe, { timeout: 30000 });
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await clickLegacyUi(page, 962, 310);
|
|
await waitForCamp(page);
|
|
const restoredUnderCapacityState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
restoredUnderCapacityState?.selectedSortieUnitIds?.length === underCapacityProbe.selectedBefore.length - 1 &&
|
|
!restoredUnderCapacityState.selectedSortieUnitIds.includes(underCapacityProbe.removedUnitId),
|
|
`Expected an under-capacity manual formation to survive scene recreation: ${JSON.stringify(restoredUnderCapacityState?.selectedSortieUnitIds)} / ${JSON.stringify(underCapacityProbe)}`
|
|
);
|
|
|
|
const reserveDoctrineSetup = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const supportUnitIds = (state?.sortieRoster ?? [])
|
|
.filter((unit) => unit.selected && unit.formationRole === 'support')
|
|
.map((unit) => unit.id);
|
|
scene.sortieFormationAssignments = {
|
|
...scene.sortieFormationAssignments,
|
|
...Object.fromEntries(supportUnitIds.map((unitId) => [unitId, 'reserve']))
|
|
};
|
|
scene.persistSortieSelection();
|
|
return { supportUnitIds, state: window.__HEROS_DEBUG__?.camp() };
|
|
});
|
|
assert(
|
|
reserveDoctrineSetup.supportUnitIds.length > 0 && reserveDoctrineSetup.state?.sortiePlan?.formationCoverageComplete === false,
|
|
`Expected the mid-campaign doctrine setup to leave support uncovered with reserve officers: ${JSON.stringify(reserveDoctrineSetup)}`
|
|
);
|
|
|
|
let midCampSelectedSortieUnitIds = reserveDoctrineSetup.state?.selectedSortieUnitIds ?? [];
|
|
let midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? [];
|
|
let midCampFormationAssignments = reserveDoctrineSetup.state?.sortieFormationAssignments ?? {};
|
|
let midCampItemAssignments = reserveDoctrineSetup.state?.sortieItemAssignments ?? {};
|
|
await openCampSortiePrep(page);
|
|
const restoredMidBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
restoredMidBriefingState?.stagedSortiePrep === true && restoredMidBriefingState?.sortiePrepStep === 'briefing',
|
|
`Expected reopening the mid-campaign sortie to restart at briefing: ${JSON.stringify(restoredMidBriefingState)}`
|
|
);
|
|
const restoredMidFormationState = await advanceSortiePrepStep(page, 'formation');
|
|
assert(
|
|
restoredMidFormationState?.sortiePlan?.formationCoverageComplete === false &&
|
|
JSON.stringify(restoredMidFormationState?.sortieFormationAssignments) === JSON.stringify(midCampFormationAssignments) &&
|
|
JSON.stringify(restoredMidFormationState?.sortieItemAssignments) === JSON.stringify(midCampItemAssignments),
|
|
`Expected the staged formation screen to preserve role and supply assignments: ${JSON.stringify(restoredMidFormationState)}`
|
|
);
|
|
const launchRecommendationToggleBounds = restoredMidFormationState?.sortieRecommendationBrowser?.toggleButtonBounds;
|
|
assert(
|
|
isFiniteBounds(launchRecommendationToggleBounds),
|
|
`Expected the restored formation step to expose recommendation plans before launch: ${JSON.stringify(restoredMidFormationState?.sortieRecommendationBrowser)}`
|
|
);
|
|
await page.mouse.click(
|
|
launchRecommendationToggleBounds.x + launchRecommendationToggleBounds.width / 2,
|
|
launchRecommendationToggleBounds.y + launchRecommendationToggleBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationBrowser?.open === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const launchRecommendationBrowser = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationBrowser);
|
|
const launchRecommendationCard = launchRecommendationBrowser?.cards?.find(
|
|
(card) => card.applicable && !card.current && isFiniteBounds(card.compareButtonBounds)
|
|
);
|
|
assert(
|
|
launchRecommendationCard,
|
|
`Expected an applicable recommendation card to apply through the real pre-battle UI: ${JSON.stringify(launchRecommendationBrowser)}`
|
|
);
|
|
await page.mouse.click(
|
|
launchRecommendationCard.compareButtonBounds.x + launchRecommendationCard.compareButtonBounds.width / 2,
|
|
launchRecommendationCard.compareButtonBounds.y + launchRecommendationCard.compareButtonBounds.height / 2
|
|
);
|
|
await page.waitForFunction(
|
|
(planId) => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationBrowser?.pinnedPlanId === planId &&
|
|
window.__HEROS_DEBUG__?.camp()?.sortieComparisonAction?.kind === 'confirm-recommendation',
|
|
launchRecommendationCard.id,
|
|
{ timeout: 30000 }
|
|
);
|
|
const launchRecommendationApplyPoint = await readSortieComparisonActionPoint(page);
|
|
assert(
|
|
launchRecommendationApplyPoint.label === '적용 확정',
|
|
`Expected the launch recommendation to expose its real apply action: ${JSON.stringify(launchRecommendationApplyPoint)}`
|
|
);
|
|
await page.mouse.click(launchRecommendationApplyPoint.x, launchRecommendationApplyPoint.y);
|
|
await page.waitForFunction(
|
|
(planId) => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationSelection?.planId === planId,
|
|
launchRecommendationCard.id,
|
|
{ timeout: 30000 }
|
|
);
|
|
const midRecommendationLaunchFixture = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
ready: Boolean(state?.sortieRecommendationSelection),
|
|
selection: structuredClone(state?.sortieRecommendationSelection),
|
|
state
|
|
};
|
|
});
|
|
assert(
|
|
midRecommendationLaunchFixture.ready === true &&
|
|
midRecommendationLaunchFixture.selection?.planId === launchRecommendationCard.id &&
|
|
sameJsonValue(midRecommendationLaunchFixture.state?.selectedSortieUnitIds, midRecommendationLaunchFixture.selection.selectedUnitIds) &&
|
|
sameJsonValue(midRecommendationLaunchFixture.state?.sortieFormationAssignments, midRecommendationLaunchFixture.selection.formationAssignments),
|
|
`Expected the recommendation created by the real apply flow to become the launch provenance: ${JSON.stringify(midRecommendationLaunchFixture)}`
|
|
);
|
|
midCampSelectedSortieUnitIds = [...midRecommendationLaunchFixture.state.selectedSortieUnitIds];
|
|
midCampDeploymentPreview = structuredClone(midRecommendationLaunchFixture.state.sortieDeploymentPreview ?? []);
|
|
midCampFormationAssignments = { ...midRecommendationLaunchFixture.state.sortieFormationAssignments };
|
|
midCampItemAssignments = structuredClone(midRecommendationLaunchFixture.state.sortieItemAssignments ?? {});
|
|
|
|
const restoredMidLoadoutState = await advanceSortiePrepStep(page, 'loadout');
|
|
assertSameMembers(
|
|
restoredMidLoadoutState?.selectedSortieUnitIds,
|
|
midCampSelectedSortieUnitIds,
|
|
`Expected briefing, formation, and loadout steps to preserve the selected mid-campaign roster: ${JSON.stringify(restoredMidLoadoutState?.selectedSortieUnitIds)}`
|
|
);
|
|
assert(
|
|
JSON.stringify(restoredMidLoadoutState?.sortieFormationAssignments) === JSON.stringify(midCampFormationAssignments) &&
|
|
JSON.stringify(restoredMidLoadoutState?.sortieItemAssignments) === JSON.stringify(midCampItemAssignments) &&
|
|
sameJsonValue(restoredMidLoadoutState?.sortieRecommendationSelection, midRecommendationLaunchFixture.selection),
|
|
`Expected loadout navigation to preserve role and supply assignments: ${JSON.stringify(restoredMidLoadoutState)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-loadout.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp sortie loadout');
|
|
await clickLegacyUi(page, 1116, 656);
|
|
await advanceUntilBattle(page, midCampNextBattleId);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-next-battle.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp next battle');
|
|
|
|
const midNextBattleProbe = await readBattleDoctrineProbe(page);
|
|
assert(midNextBattleProbe?.battleId === midCampNextBattleId, `Expected mid-campaign sortie to enter next battle: ${JSON.stringify(midNextBattleProbe)} / ${midCampNextBattleId}`);
|
|
assert(
|
|
midNextBattleProbe?.sortieRecommendation?.planId === midRecommendationLaunchFixture.selection.planId &&
|
|
sameJsonValue(midNextBattleProbe.sortieRecommendation.selectedUnitIds, midRecommendationLaunchFixture.selection.selectedUnitIds) &&
|
|
sameJsonValue(midNextBattleProbe.sortieRecommendation.unitReasons, midRecommendationLaunchFixture.selection.unitReasons),
|
|
`Expected the battle launch to retain recommendation provenance: ${JSON.stringify(midNextBattleProbe?.sortieRecommendation)}`
|
|
);
|
|
assertSameMembers(
|
|
midNextBattleProbe?.selectedSortieUnitIds,
|
|
midCampSelectedSortieUnitIds,
|
|
`Expected mid-campaign next battle to receive selected sortie ids: ${JSON.stringify(midNextBattleProbe?.selectedSortieUnitIds)} / ${JSON.stringify(midCampSelectedSortieUnitIds)}`
|
|
);
|
|
assertSameMembers(
|
|
midNextBattleProbe?.deployedAllyIds,
|
|
midCampSelectedSortieUnitIds,
|
|
`Expected mid-campaign next battle deployed allies to match sortie ids: ${JSON.stringify(midNextBattleProbe?.deployedAllyIds)} / ${JSON.stringify(midCampSelectedSortieUnitIds)}`
|
|
);
|
|
assertDeploymentMatchesPreview(
|
|
midNextBattleProbe?.deployedAllyPositions,
|
|
midCampDeploymentPreview,
|
|
`Expected mid-campaign next battle ally positions to match deployment preview: ${JSON.stringify(midNextBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(midCampDeploymentPreview)}`
|
|
);
|
|
const expectedMidRoleCoverage = new Set(
|
|
Object.values(midCampFormationAssignments).filter((role) => ['front', 'flank', 'support'].includes(role))
|
|
).size;
|
|
const expectedMidReserve = Object.values(midCampFormationAssignments).includes('reserve');
|
|
assertSortieDoctrine(midNextBattleProbe, { requireReserve: expectedMidReserve });
|
|
assert(
|
|
midNextBattleProbe.sortieDoctrine?.coveredRoleCount === expectedMidRoleCoverage,
|
|
`Expected the launched doctrine coverage to match the applied recommendation roles: ${JSON.stringify({
|
|
doctrine: midNextBattleProbe.sortieDoctrine,
|
|
assignments: midCampFormationAssignments
|
|
})}`
|
|
);
|
|
assert(
|
|
midNextBattleProbe.firstSortieRoleEffects?.active === false &&
|
|
midNextBattleProbe.firstSortieRoleEffects?.trinityActive === false,
|
|
`Expected Peach Garden trinity to remain exclusive to the second battle: ${JSON.stringify(midNextBattleProbe.firstSortieRoleEffects)}`
|
|
);
|
|
assert(
|
|
midNextBattleProbe.units.some((unit) =>
|
|
unit.faction === 'ally' &&
|
|
!['liu-bei', 'guan-yu', 'zhang-fei'].includes(unit.id) &&
|
|
['front', 'flank', 'support'].includes(unit.formationRole) &&
|
|
unit.sortieRoleEffect
|
|
),
|
|
`Expected a non-Peach-Garden officer to receive a generic sortie role effect: ${JSON.stringify(midNextBattleProbe.units)}`
|
|
);
|
|
|
|
const midResultFixture = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
const contributor = state?.units?.find((unit) =>
|
|
unit.faction === 'ally' && ['front', 'flank', 'support'].includes(unit.formationRole)
|
|
);
|
|
const wounded = state?.units?.find((unit) =>
|
|
unit.faction === 'ally' && unit.id !== contributor?.id && unit.hp > 1
|
|
) ?? contributor;
|
|
const liveWounded = wounded?.id ? scene?.debugUnitById?.(wounded.id) : undefined;
|
|
if (!scene || !contributor || !liveWounded || typeof scene.statsFor !== 'function') {
|
|
return { ready: false, contributorId: contributor?.id ?? null, woundedUnitId: wounded?.id ?? null };
|
|
}
|
|
|
|
const stats = scene.statsFor(contributor.id);
|
|
Object.assign(stats, {
|
|
damageDealt: 137,
|
|
damageTaken: 22,
|
|
defeats: 3,
|
|
actions: 5,
|
|
support: 11,
|
|
strategyUses: 2,
|
|
signatureStrategyUses: 1,
|
|
sortieBonusDamage: 17,
|
|
sortiePreventedDamage: 9,
|
|
sortieBonusHealing: 4,
|
|
sortieExtendedBondTriggers: 2,
|
|
intentDefeats: 1,
|
|
intentLineBreaks: 1,
|
|
intentGuardSuccesses: 1
|
|
});
|
|
liveWounded.hp = Math.max(1, liveWounded.maxHp - 7);
|
|
return {
|
|
ready: true,
|
|
contributorId: contributor.id,
|
|
woundedUnitId: liveWounded.id,
|
|
woundedHp: liveWounded.hp,
|
|
woundedMaxHp: liveWounded.maxHp,
|
|
expectedStats: {
|
|
damageDealt: 137,
|
|
damageTaken: 22,
|
|
defeats: 3,
|
|
actions: 5,
|
|
support: 11,
|
|
strategyUses: 2,
|
|
signatureStrategyUses: 1,
|
|
sortieBonusDamage: 17,
|
|
sortiePreventedDamage: 9,
|
|
sortieBonusHealing: 4,
|
|
sortieExtendedBondTriggers: 2,
|
|
intentDefeats: 1,
|
|
intentLineBreaks: 1,
|
|
intentGuardSuccesses: 1
|
|
}
|
|
};
|
|
});
|
|
assert(
|
|
midResultFixture.ready === true &&
|
|
midResultFixture.contributorId &&
|
|
midResultFixture.woundedUnitId &&
|
|
midResultFixture.woundedHp < midResultFixture.woundedMaxHp,
|
|
`Expected a deterministic contribution and recovery fixture for the mid-campaign result: ${JSON.stringify(midResultFixture)}`
|
|
);
|
|
|
|
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
|
|
await waitForBattleOutcome(page, 'victory');
|
|
const midResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
const midResultSaveBeforeUpdate = await readCampaignSave(page);
|
|
const midResultPerformance = midResultState?.sortieEvaluation?.snapshot;
|
|
const midResultSortieReviewBeforePresetUpdate = structuredClone(
|
|
midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortieReview
|
|
);
|
|
const midContributorPerformance = midResultPerformance?.unitStats?.find((stats) => stats.unitId === midResultFixture.contributorId);
|
|
const midBattleRoleById = Object.fromEntries(
|
|
(midNextBattleProbe?.deployedAllyPositions ?? []).map((unit) => [unit.id, unit.formationRole])
|
|
);
|
|
const expectedMidResultPreset = {
|
|
selectedUnitIds: [...(midResultPerformance?.selectedUnitIds ?? [])],
|
|
formationAssignments: { ...(midResultPerformance?.formationAssignments ?? {}) }
|
|
};
|
|
assert(
|
|
midResultState?.sortieEvaluation?.available === true &&
|
|
midResultState.sortieEvaluation.open === false &&
|
|
midResultState.sortieEvaluation.totalDamage === midResultFixture.expectedStats.damageDealt &&
|
|
midResultState.sortieEvaluation.totalDamageTaken === midResultFixture.expectedStats.damageTaken &&
|
|
midResultState.sortieEvaluation.totalDefeats === midResultFixture.expectedStats.defeats &&
|
|
midResultState.sortieEvaluation.totalSupport === midResultFixture.expectedStats.support &&
|
|
midResultState.sortieEvaluation.recoveryNeededCount > 0 &&
|
|
midResultState.sortieEvaluation.roleCoverage === expectedMidRoleCoverage,
|
|
`Expected the mid-campaign evaluation to reflect the deterministic contribution, injury, and applied role coverage: ${JSON.stringify(midResultState?.sortieEvaluation)}`
|
|
);
|
|
const midRecommendationFeedback = midResultState?.sortieEvaluation?.recommendationFeedback;
|
|
const midRecommendationContributor = midRecommendationFeedback?.rows?.find((row) => row.unitId === midResultFixture.contributorId);
|
|
assert(
|
|
midRecommendationFeedback?.planId === midRecommendationLaunchFixture.selection.planId &&
|
|
['hit', 'partial', 'miss'].includes(midRecommendationFeedback.status) &&
|
|
Number.isInteger(midRecommendationFeedback.score) &&
|
|
midRecommendationFeedback.kpis?.length === 3 &&
|
|
sameJsonValue(midRecommendationFeedback.rows.map((row) => row.unitId), midRecommendationLaunchFixture.selection.selectedUnitIds) &&
|
|
midRecommendationFeedback.rows.every(
|
|
(row) => row.fullReason === midRecommendationLaunchFixture.selection.unitReasons[row.unitId] && row.cardBounds === null
|
|
) &&
|
|
midRecommendationFeedback.counts.hit + midRecommendationFeedback.counts.partial + midRecommendationFeedback.counts.miss === midRecommendationFeedback.rows.length &&
|
|
midRecommendationContributor?.strategyUses === 2 &&
|
|
midRecommendationContributor.signatureStrategyUses === 1,
|
|
`Expected the closed result to expose source-backed recommendation outcome data without rendered card bounds: ${JSON.stringify(midRecommendationFeedback)}`
|
|
);
|
|
assert(
|
|
midContributorPerformance &&
|
|
Object.entries(midResultFixture.expectedStats).every(([key, value]) => midContributorPerformance[key] === value) &&
|
|
midContributorPerformance.hpBefore > 0 &&
|
|
midContributorPerformance.maxHpBefore >= midContributorPerformance.hpBefore &&
|
|
sameJsonValue([...(midResultPerformance?.selectedUnitIds ?? [])].sort(), [...midCampSelectedSortieUnitIds].sort()) &&
|
|
Object.keys(midResultPerformance?.formationAssignments ?? {}).length === midCampSelectedSortieUnitIds.length &&
|
|
midCampSelectedSortieUnitIds.every(
|
|
(unitId) => midResultPerformance?.formationAssignments?.[unitId] === midBattleRoleById[unitId]
|
|
),
|
|
`Expected the saved result snapshot to retain the exact roster, roles, starting HP, and contribution stats: ${JSON.stringify(midResultPerformance)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
|
|
sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
|
|
sameJsonValue(midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
|
|
sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
|
|
!sameJsonValue(midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile, expectedMidResultPreset),
|
|
`Expected the mid result snapshot to synchronize everywhere while remaining meaningfully different from the saved mobile preset: ${JSON.stringify({
|
|
snapshot: midResultPerformance,
|
|
mobile: midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile
|
|
})}`
|
|
);
|
|
assert(
|
|
sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortieRecommendation, midRecommendationLaunchFixture.selection) &&
|
|
sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortieRecommendation, midRecommendationLaunchFixture.selection) &&
|
|
sameJsonValue(midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortieRecommendation, midRecommendationLaunchFixture.selection) &&
|
|
sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortieRecommendation, midRecommendationLaunchFixture.selection),
|
|
`Expected recommendation provenance to persist beside the battle performance snapshot: ${JSON.stringify({
|
|
report: midResultSaveBeforeUpdate.current?.firstBattleReport?.sortieRecommendation,
|
|
history: midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortieRecommendation
|
|
})}`
|
|
);
|
|
assert(
|
|
midResultSortieReviewBeforePresetUpdate?.version === 1 &&
|
|
midResultSortieReviewBeforePresetUpdate.score === midResultState?.sortieEvaluation?.score &&
|
|
midResultSortieReviewBeforePresetUpdate.grade === midResultState?.sortieEvaluation?.grade &&
|
|
midResultSortieReviewBeforePresetUpdate.activeBondCount === midResultState?.sortieEvaluation?.activeBondCount &&
|
|
midResultSortieReviewBeforePresetUpdate.enemyCount === midNextBattleProbe.units.filter((unit) => unit.faction === 'enemy').length &&
|
|
sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
|
|
sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
|
|
sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate),
|
|
`Expected the mid-campaign result to persist one canonical sortie review before any result-screen preset update: ${JSON.stringify({
|
|
review: midResultSortieReviewBeforePresetUpdate,
|
|
evaluation: midResultState?.sortieEvaluation
|
|
})}`
|
|
);
|
|
|
|
const midEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle');
|
|
await page.mouse.click(midEvaluationTogglePoint.x, midEvaluationTogglePoint.y);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 });
|
|
const midEvaluationProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
|
|
return {
|
|
evaluation,
|
|
texts: (scene?.resultFormationReviewObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text)
|
|
};
|
|
});
|
|
const midEvaluationSave = await readCampaignSave(page);
|
|
const mobileResultCard = midEvaluationProbe.evaluation?.presetCards?.find((card) => card.id === 'mobile');
|
|
const openedRecommendationFeedback = midEvaluationProbe.evaluation?.recommendationFeedback;
|
|
const fhdViewportBounds = desktopBrowserViewportBounds;
|
|
assert(
|
|
midEvaluationProbe.texts.includes('이번 편성 평가') &&
|
|
midEvaluationProbe.texts.some((text) => text.includes('추천안 검증') && text.includes(midRecommendationLaunchFixture.selection.label)) &&
|
|
midEvaluationProbe.texts.some((text) => text.includes('기동') && text.includes('갱신')) &&
|
|
isFiniteBounds(openedRecommendationFeedback?.panelBounds) &&
|
|
boundsInside(openedRecommendationFeedback.panelBounds, fhdViewportBounds) &&
|
|
sameJsonValue(openedRecommendationFeedback.displayedUnitIds, midRecommendationLaunchFixture.selection.selectedUnitIds) &&
|
|
openedRecommendationFeedback.missingUnitIds.length === 0 &&
|
|
openedRecommendationFeedback.unexpectedUnitIds.length === 0 &&
|
|
openedRecommendationFeedback.rows.every(
|
|
(row) => isFiniteBounds(row.cardBounds) && boundsInside(row.cardBounds, fhdViewportBounds) &&
|
|
isFiniteBounds(row.nameBounds) && boundsInside(row.nameBounds, row.cardBounds) &&
|
|
isFiniteBounds(row.statusBounds) && boundsInside(row.statusBounds, row.cardBounds) &&
|
|
typeof row.displayText === 'string' && row.displayText.length <= 14 && !row.displayText.includes('HP')
|
|
) &&
|
|
mobileResultCard?.saved === true &&
|
|
mobileResultCard.matching === false &&
|
|
mobileResultCard.actionButtonBounds &&
|
|
sameJsonValue(midEvaluationSave, midResultSaveBeforeUpdate),
|
|
`Expected opening the evaluation to expose a non-destructive mobile preset update action: ${JSON.stringify(midEvaluationProbe)}`
|
|
);
|
|
|
|
const firstMobileUpdatePoint = await readBattleResultEvaluationControlPoint(page, 'preset', 'mobile');
|
|
await page.mouse.click(firstMobileUpdatePoint.x, firstMobileUpdatePoint.y);
|
|
await page.waitForFunction(() => {
|
|
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
|
|
return evaluation?.open === true &&
|
|
evaluation?.overwriteConfirmId === 'mobile' &&
|
|
evaluation?.feedback?.includes('한 번 더');
|
|
}, undefined, { timeout: 30000 });
|
|
const pendingMobileUpdateState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
const pendingMobileUpdateSave = await readCampaignSave(page);
|
|
assert(
|
|
pendingMobileUpdateState?.resultVisible === true &&
|
|
pendingMobileUpdateState?.sortieEvaluation?.sourcePresetIds?.includes('mobile') === false &&
|
|
sameJsonValue(pendingMobileUpdateSave, midResultSaveBeforeUpdate),
|
|
`Expected the first mobile update click to request confirmation without mutating either save: ${JSON.stringify({
|
|
evaluation: pendingMobileUpdateState?.sortieEvaluation,
|
|
save: pendingMobileUpdateSave
|
|
})}`
|
|
);
|
|
|
|
const confirmMobileUpdatePoint = await readBattleResultEvaluationControlPoint(page, 'preset', 'mobile');
|
|
await page.mouse.click(confirmMobileUpdatePoint.x, confirmMobileUpdatePoint.y);
|
|
await page.waitForFunction(() => {
|
|
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
|
|
return evaluation?.open === true &&
|
|
evaluation?.overwriteConfirmId === null &&
|
|
evaluation?.sourcePresetIds?.includes('mobile') &&
|
|
evaluation?.feedback?.includes('갱신 완료');
|
|
}, undefined, { timeout: 30000 });
|
|
const updatedMobileResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
const updatedMobileResultSave = await readCampaignSave(page);
|
|
const updatedCurrentMobilePreset = updatedMobileResultSave.current?.sortieFormationPresets?.mobile;
|
|
const updatedSlotMobilePreset = updatedMobileResultSave.slot1?.sortieFormationPresets?.mobile;
|
|
assert(
|
|
sameJsonValue(updatedCurrentMobilePreset, expectedMidResultPreset) &&
|
|
sameJsonValue(updatedSlotMobilePreset, expectedMidResultPreset) &&
|
|
JSON.stringify(Object.keys(updatedCurrentMobilePreset ?? {}).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) &&
|
|
updatedCurrentMobilePreset?.sortieItemAssignments === undefined &&
|
|
updatedCurrentMobilePreset?.itemAssignments === undefined &&
|
|
updatedCurrentMobilePreset?.unitStats === undefined,
|
|
`Expected the confirmed result action to store only the evaluated roster and roles in current and slot-1 mobile presets: ${JSON.stringify(updatedMobileResultSave)}`
|
|
);
|
|
assert(
|
|
JSON.stringify(updatedMobileResultSave.current?.selectedSortieUnitIds) === JSON.stringify(midResultSaveBeforeUpdate.current?.selectedSortieUnitIds) &&
|
|
sameJsonValue(updatedMobileResultSave.current?.sortieFormationAssignments, midResultSaveBeforeUpdate.current?.sortieFormationAssignments) &&
|
|
sameJsonValue(updatedMobileResultSave.current?.sortieItemAssignments, midResultSaveBeforeUpdate.current?.sortieItemAssignments) &&
|
|
sameJsonValue(updatedMobileResultSave.slot1?.selectedSortieUnitIds, midResultSaveBeforeUpdate.slot1?.selectedSortieUnitIds) &&
|
|
sameJsonValue(updatedMobileResultSave.slot1?.sortieFormationAssignments, midResultSaveBeforeUpdate.slot1?.sortieFormationAssignments) &&
|
|
sameJsonValue(updatedMobileResultSave.slot1?.sortieItemAssignments, midResultSaveBeforeUpdate.slot1?.sortieItemAssignments) &&
|
|
sameJsonValue(updatedMobileResultSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
|
|
sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
|
|
sameJsonValue(updatedMobileResultSave.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
|
|
sameJsonValue(updatedMobileResultSave.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
|
|
sameJsonValue(updatedMobileResultSave.current?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
|
|
sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
|
|
sameJsonValue(updatedMobileResultSave.current?.sortieFormationPresets?.elite, midResultSaveBeforeUpdate.current?.sortieFormationPresets?.elite) &&
|
|
sameJsonValue(updatedMobileResultSave.current?.sortieFormationPresets?.siege, midResultSaveBeforeUpdate.current?.sortieFormationPresets?.siege),
|
|
`Expected result-to-preset update to leave the active sortie, supplies, report snapshot, and other presets unchanged: ${JSON.stringify({
|
|
before: midResultSaveBeforeUpdate,
|
|
after: updatedMobileResultSave
|
|
})}`
|
|
);
|
|
assert(
|
|
updatedMobileResultState?.sortieEvaluation?.presetCards?.find((card) => card.id === 'mobile')?.matching === true &&
|
|
updatedMobileResultState?.sortieEvaluation?.feedback?.includes('장비·보급은 저장하지 않았습니다.'),
|
|
`Expected visible completion feedback and a matching mobile result card after update: ${JSON.stringify(updatedMobileResultState?.sortieEvaluation)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-formation-evaluation-updated.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp formation evaluation update');
|
|
|
|
const midResultImprovementAction = updatedMobileResultState?.sortieEvaluation?.improvementAction;
|
|
const midImprovementTargetBattleId = midResultImprovementAction?.targetBattleId;
|
|
assert(
|
|
midResultImprovementAction?.available === true &&
|
|
midResultImprovementAction.label === '다음 출전 보완' &&
|
|
midResultImprovementAction.sourceBattleId === midCampNextBattleId &&
|
|
typeof midImprovementTargetBattleId === 'string' &&
|
|
midImprovementTargetBattleId !== midCampNextBattleId &&
|
|
midResultImprovementAction.planId === midRecommendationLaunchFixture.selection.planId &&
|
|
isFiniteBounds(midResultImprovementAction.buttonBounds) &&
|
|
boundsInside(midResultImprovementAction.buttonBounds, fhdViewportBounds),
|
|
`Expected the victory evaluation to expose an in-FHD next-sortie improvement action: ${JSON.stringify(midResultImprovementAction)}`
|
|
);
|
|
const midImprovementPoint = await readBattleResultEvaluationControlPoint(page, 'improvement');
|
|
await page.mouse.click(midImprovementPoint.x, midImprovementPoint.y);
|
|
await waitForCamp(page);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const pendingMidImprovementReward = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const pendingMidImprovementSave = await readCampaignSave(page);
|
|
const midImprovementCampEntrySaveUnchanged = sameJsonValue(
|
|
campaignSaveWithoutCampRecruitmentEffects(pendingMidImprovementSave),
|
|
campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave)
|
|
);
|
|
const midImprovementRewardAcknowledgements = campaignVictoryAcknowledgementSnapshot(
|
|
pendingMidImprovementSave,
|
|
midCampNextBattleId
|
|
);
|
|
const midImprovementRewardDestinationPending = Object.values(midImprovementRewardAcknowledgements).every(
|
|
(entry) => !entry.battleAcknowledged &&
|
|
!entry.categories.includes('equipment') &&
|
|
!entry.categories.includes('supplies')
|
|
);
|
|
assert(
|
|
pendingMidImprovementReward?.openSortiePrepOnCreate === true &&
|
|
pendingMidImprovementReward.sortieVisible === false &&
|
|
pendingMidImprovementReward.victoryRewardAcknowledgement?.battleId === midCampNextBattleId &&
|
|
midImprovementCampEntrySaveUnchanged &&
|
|
midImprovementRewardDestinationPending,
|
|
`Expected next-sortie improvement to preserve its requested prep while showing the unacknowledged victory reward first: ${JSON.stringify({
|
|
openSortiePrepOnCreate: pendingMidImprovementReward?.openSortiePrepOnCreate,
|
|
sortieVisible: pendingMidImprovementReward?.sortieVisible,
|
|
rewardBattleId: pendingMidImprovementReward?.victoryRewardAcknowledgement?.battleId,
|
|
expectedBattleId: midCampNextBattleId,
|
|
saveUnchanged: midImprovementCampEntrySaveUnchanged,
|
|
rewardDestinationPending: midImprovementRewardDestinationPending,
|
|
rewardAcknowledgements: midImprovementRewardAcknowledgements
|
|
})}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-improvement-victory-reward.png`, fullPage: true });
|
|
const midImprovementSortiePoint = await readCampVictoryRewardControlPoint(page, 'sortie');
|
|
await page.mouse.click(midImprovementSortiePoint.x, midImprovementSortiePoint.y);
|
|
await waitForSortiePrep(page);
|
|
|
|
const guidedPreviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const guidedPreviewSave = await readCampaignSave(page);
|
|
const guidedPreview = guidedPreviewState?.sortieGuidedImprovement;
|
|
const guidedPreviewDestinationIsolation = sameJsonValue(pendingMidImprovementSave, guidedPreviewSave);
|
|
assert(
|
|
guidedPreviewState?.sortiePrepStep === 'formation' &&
|
|
guidedPreviewState.nextSortieBattleId === midImprovementTargetBattleId &&
|
|
guidedPreview?.stage === 'preview' &&
|
|
guidedPreview.sourceBattleId === midCampNextBattleId &&
|
|
guidedPreview.targetBattleId === midImprovementTargetBattleId &&
|
|
guidedPreview.proposal?.planId === midResultImprovementAction.planId &&
|
|
guidedImprovementHasOneAction(guidedPreview.proposal) &&
|
|
guidedPreviewState.sortieComparisonPreview?.source === 'guided-improvement' &&
|
|
guidedPreviewState.sortieComparisonAction?.kind === 'confirm-improvement' &&
|
|
isFiniteBounds(guidedPreviewState.sortieComparisonAction?.buttonBounds) &&
|
|
boundsInside(guidedPreviewState.sortieComparisonAction.buttonBounds, fhdViewportBounds) &&
|
|
guidedPreviewDestinationIsolation &&
|
|
guidedPreviewState?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true &&
|
|
guidedPreviewState?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true,
|
|
`Expected a non-destructive one-officer improvement preview for the next battle: ${JSON.stringify({
|
|
prepStep: guidedPreviewState?.sortiePrepStep,
|
|
nextBattleId: guidedPreviewState?.nextSortieBattleId,
|
|
guidedImprovement: guidedPreview,
|
|
comparisonPreviewSource: guidedPreviewState?.sortieComparisonPreview?.source,
|
|
comparisonAction: guidedPreviewState?.sortieComparisonAction,
|
|
campEntrySaveUnchanged: midImprovementCampEntrySaveUnchanged,
|
|
destinationIsolation: guidedPreviewDestinationIsolation,
|
|
rewardBadges: guidedPreviewState?.campTabs
|
|
?.filter((tab) => ['equipment', 'supplies'].includes(tab.id))
|
|
.map((tab) => ({ id: tab.id, new: tab.newBadgeVisible }))
|
|
})}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-sortie-guided-improvement-preview.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid sortie guided improvement preview');
|
|
|
|
const guidedApplyPoint = await readSortieComparisonActionPoint(page);
|
|
await page.mouse.click(guidedApplyPoint.x, guidedApplyPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const camp = window.__HEROS_DEBUG__?.camp?.();
|
|
return camp?.sortieGuidedImprovement?.stage === 'applied' &&
|
|
camp?.sortieComparisonAction?.kind === 'undo-improvement';
|
|
}, undefined, { timeout: 30000 });
|
|
const guidedAppliedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const guidedAppliedSave = await readCampaignSave(page);
|
|
const guidedProposal = guidedAppliedState?.sortieGuidedImprovement?.proposal;
|
|
assert(
|
|
sameJsonValue(guidedAppliedState?.selectedSortieUnitIds, guidedProposal?.projectedSelectedUnitIds) &&
|
|
selectedAssignmentsMatch(guidedAppliedState?.sortieFormationAssignments, guidedProposal?.projectedFormationAssignments, guidedProposal?.projectedSelectedUnitIds) &&
|
|
sameJsonValue(guidedAppliedSave.current?.selectedSortieUnitIds, guidedProposal?.projectedSelectedUnitIds) &&
|
|
selectedAssignmentsMatch(guidedAppliedSave.current?.sortieFormationAssignments, guidedProposal?.projectedFormationAssignments, guidedProposal?.projectedSelectedUnitIds) &&
|
|
guidedAppliedSave.current?.sortieRecommendationSelection?.battleId === midImprovementTargetBattleId &&
|
|
guidedAppliedSave.current?.sortieRecommendationSelection?.planId === midResultImprovementAction.planId &&
|
|
guidedAppliedState?.sortieGuidedImprovementUndo?.available === true,
|
|
`Expected confirmation to persist exactly the projected roster/role change and revised recommendation provenance: ${JSON.stringify({
|
|
state: guidedAppliedState,
|
|
save: guidedAppliedSave
|
|
})}`
|
|
);
|
|
|
|
const guidedUndoPoint = await readSortieComparisonActionPoint(page);
|
|
await page.mouse.click(guidedUndoPoint.x, guidedUndoPoint.y);
|
|
await page.waitForFunction((baselineIds) => {
|
|
const camp = window.__HEROS_DEBUG__?.camp?.();
|
|
return camp?.sortieGuidedImprovement === null &&
|
|
camp?.sortieGuidedImprovementUndo === null &&
|
|
JSON.stringify(camp?.selectedSortieUnitIds) === JSON.stringify(baselineIds);
|
|
}, guidedProposal.baselineSelectedUnitIds, { timeout: 30000 });
|
|
const guidedUndoState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const guidedUndoSave = await readCampaignSave(page);
|
|
assert(
|
|
sameJsonValue(guidedUndoState?.selectedSortieUnitIds, guidedProposal.baselineSelectedUnitIds) &&
|
|
selectedAssignmentsMatch(guidedUndoState?.sortieFormationAssignments, guidedProposal.baselineFormationAssignments, guidedProposal.baselineSelectedUnitIds) &&
|
|
sameJsonValue(guidedUndoSave.current?.selectedSortieUnitIds, guidedProposal.baselineSelectedUnitIds) &&
|
|
selectedAssignmentsMatch(guidedUndoSave.current?.sortieFormationAssignments, guidedProposal.baselineFormationAssignments, guidedProposal.baselineSelectedUnitIds) &&
|
|
guidedUndoSave.current?.sortieRecommendationSelection === undefined,
|
|
`Expected one-click undo to restore the exact pre-improvement formation: ${JSON.stringify({
|
|
state: guidedUndoState,
|
|
save: guidedUndoSave
|
|
})}`
|
|
);
|
|
|
|
const returnFromGuidedImprovementPoint = await readSortieTextControlPoint(page, '군영으로');
|
|
await page.mouse.click(returnFromGuidedImprovementPoint.x, returnFromGuidedImprovementPoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp?.()?.sortieVisible === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const midCampReviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const midCampReviewSaveBefore = await readCampaignSave(page);
|
|
const midCampFormationEvaluation = midCampReviewState?.reportFormationEvaluation;
|
|
const midCampMobileCard = midCampFormationEvaluation?.presetCards?.find((card) => card.id === 'mobile');
|
|
const midCampSiegeCard = midCampFormationEvaluation?.presetCards?.find((card) => card.id === 'siege');
|
|
assert(
|
|
midCampFormationEvaluation?.available === true &&
|
|
midCampFormationEvaluation.open === false &&
|
|
midCampFormationEvaluation.battleId === midCampNextBattleId &&
|
|
midCampFormationEvaluation.grade === updatedMobileResultState?.sortieEvaluation?.grade &&
|
|
midCampFormationEvaluation.score === updatedMobileResultState?.sortieEvaluation?.score &&
|
|
sameJsonValue(midCampFormationEvaluation.snapshot, midResultPerformance) &&
|
|
sameJsonValue(
|
|
recommendationFeedbackSemantic(midCampFormationEvaluation.recommendationFeedback),
|
|
recommendationFeedbackSemantic(updatedMobileResultState?.sortieEvaluation?.recommendationFeedback)
|
|
) &&
|
|
midCampFormationEvaluation.improvementAction?.available === true &&
|
|
midCampFormationEvaluation.improvementAction.label === '다음 출전 보완' &&
|
|
midCampFormationEvaluation.improvementAction.sourceBattleId === midCampNextBattleId &&
|
|
midCampFormationEvaluation.improvementAction.targetBattleId === midImprovementTargetBattleId &&
|
|
midCampFormationEvaluation.improvementAction.planId === midResultImprovementAction.planId &&
|
|
sameJsonValue(midCampFormationEvaluation.sourcePresetIds, midResultSortieReviewBeforePresetUpdate.sourcePresetIds) &&
|
|
midCampMobileCard?.saved === true &&
|
|
midCampMobileCard.matching === true &&
|
|
midCampSiegeCard?.saved === true &&
|
|
midCampSiegeCard.matching === false &&
|
|
midCampFormationEvaluation.toggleButtonBounds &&
|
|
!sameJsonValue(savedSiegePreset, expectedMidResultPreset),
|
|
`Expected the next camp report to reopen the persisted result while keeping the older siege preset distinct: ${JSON.stringify({
|
|
evaluation: midCampFormationEvaluation,
|
|
savedSiegePreset
|
|
})}`
|
|
);
|
|
|
|
const midCampReviewTogglePoint = await readCampReportFormationEvaluationControlPoint(page, 'toggle');
|
|
await page.mouse.click(midCampReviewTogglePoint.x, midCampReviewTogglePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const openedMidCampReviewProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
return {
|
|
state: window.__HEROS_DEBUG__?.camp(),
|
|
texts: (scene?.reportFormationReviewObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text)
|
|
};
|
|
});
|
|
const openedMidCampReviewSave = await readCampaignSave(page);
|
|
const openedMidCampSiegeCard = openedMidCampReviewProbe.state?.reportFormationEvaluation?.presetCards?.find(
|
|
(card) => card.id === 'siege'
|
|
);
|
|
const openedCampRecommendationFeedback = openedMidCampReviewProbe.state?.reportFormationEvaluation?.recommendationFeedback;
|
|
assert(
|
|
openedMidCampReviewProbe.state?.reportFormationEvaluation?.open === true &&
|
|
openedMidCampReviewProbe.texts.includes('최근 전투 편성 평가') &&
|
|
openedMidCampReviewProbe.texts.some((text) => text.includes('추천안 검증') && text.includes(midRecommendationLaunchFixture.selection.label)) &&
|
|
openedMidCampReviewProbe.texts.includes('다음 출전 보완') &&
|
|
isFiniteBounds(openedMidCampReviewProbe.state?.reportFormationEvaluation?.improvementAction?.buttonBounds) &&
|
|
boundsInside(openedMidCampReviewProbe.state.reportFormationEvaluation.improvementAction.buttonBounds, fhdViewportBounds) &&
|
|
isFiniteBounds(openedCampRecommendationFeedback?.panelBounds) &&
|
|
boundsInside(openedCampRecommendationFeedback.panelBounds, fhdViewportBounds) &&
|
|
sameJsonValue(openedCampRecommendationFeedback.displayedUnitIds, midRecommendationLaunchFixture.selection.selectedUnitIds) &&
|
|
openedCampRecommendationFeedback.missingUnitIds.length === 0 &&
|
|
openedCampRecommendationFeedback.unexpectedUnitIds.length === 0 &&
|
|
openedCampRecommendationFeedback.rows.every(
|
|
(row) => isFiniteBounds(row.cardBounds) && boundsInside(row.cardBounds, fhdViewportBounds) &&
|
|
isFiniteBounds(row.nameBounds) && boundsInside(row.nameBounds, row.cardBounds) &&
|
|
isFiniteBounds(row.statusBounds) && boundsInside(row.statusBounds, row.cardBounds) &&
|
|
typeof row.displayText === 'string' && row.displayText.length <= 14 && !row.displayText.includes('HP')
|
|
) &&
|
|
openedMidCampReviewProbe.texts.some((text) => text.includes('공성') && text.includes('갱신')) &&
|
|
openedMidCampSiegeCard?.saved === true &&
|
|
openedMidCampSiegeCard.matching === false &&
|
|
openedMidCampSiegeCard.actionButtonBounds &&
|
|
sameJsonValue(openedMidCampReviewSave, midCampReviewSaveBefore),
|
|
`Expected opening the camp report evaluation to remain non-destructive and expose the siege update action: ${JSON.stringify(openedMidCampReviewProbe)}`
|
|
);
|
|
|
|
const firstCampSiegeUpdatePoint = await readCampReportFormationEvaluationControlPoint(page, 'preset', 'siege');
|
|
await page.mouse.click(firstCampSiegeUpdatePoint.x, firstCampSiegeUpdatePoint.y);
|
|
await page.waitForFunction(() => {
|
|
const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation;
|
|
return evaluation?.open === true &&
|
|
evaluation?.overwriteConfirmId === 'siege' &&
|
|
evaluation?.feedback?.includes('한 번 더');
|
|
}, undefined, { timeout: 30000 });
|
|
const pendingCampSiegeUpdateState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const pendingCampSiegeUpdateSave = await readCampaignSave(page);
|
|
assert(
|
|
sameJsonValue(
|
|
pendingCampSiegeUpdateState?.reportFormationEvaluation?.sourcePresetIds,
|
|
midResultSortieReviewBeforePresetUpdate.sourcePresetIds
|
|
) &&
|
|
pendingCampSiegeUpdateState?.reportFormationEvaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === false &&
|
|
sameJsonValue(pendingCampSiegeUpdateSave, midCampReviewSaveBefore),
|
|
`Expected the first camp siege update click to request confirmation without mutating either save: ${JSON.stringify({
|
|
evaluation: pendingCampSiegeUpdateState?.reportFormationEvaluation,
|
|
save: pendingCampSiegeUpdateSave
|
|
})}`
|
|
);
|
|
|
|
const confirmCampSiegeUpdatePoint = await readCampReportFormationEvaluationControlPoint(page, 'preset', 'siege');
|
|
await page.mouse.click(confirmCampSiegeUpdatePoint.x, confirmCampSiegeUpdatePoint.y);
|
|
await page.waitForFunction(() => {
|
|
const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation;
|
|
return evaluation?.open === true &&
|
|
evaluation?.overwriteConfirmId === null &&
|
|
evaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === true &&
|
|
evaluation?.feedback?.includes('갱신 완료');
|
|
}, undefined, { timeout: 30000 });
|
|
const updatedCampSiegeState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const updatedCampSiegeSave = await readCampaignSave(page);
|
|
const updatedCurrentSiegePreset = updatedCampSiegeSave.current?.sortieFormationPresets?.siege;
|
|
const updatedSlotSiegePreset = updatedCampSiegeSave.slot1?.sortieFormationPresets?.siege;
|
|
assert(
|
|
sameJsonValue(updatedCurrentSiegePreset, expectedMidResultPreset) &&
|
|
sameJsonValue(updatedSlotSiegePreset, expectedMidResultPreset) &&
|
|
JSON.stringify(Object.keys(updatedCurrentSiegePreset ?? {}).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) &&
|
|
updatedCurrentSiegePreset?.sortieItemAssignments === undefined &&
|
|
updatedCurrentSiegePreset?.itemAssignments === undefined &&
|
|
updatedCurrentSiegePreset?.unitStats === undefined,
|
|
`Expected the confirmed camp action to store only the reviewed roster and roles in both siege preset saves: ${JSON.stringify(updatedCampSiegeSave)}`
|
|
);
|
|
assert(
|
|
JSON.stringify(updatedCampSiegeSave.current?.selectedSortieUnitIds) === JSON.stringify(midCampReviewSaveBefore.current?.selectedSortieUnitIds) &&
|
|
sameJsonValue(updatedCampSiegeSave.current?.sortieFormationAssignments, midCampReviewSaveBefore.current?.sortieFormationAssignments) &&
|
|
sameJsonValue(updatedCampSiegeSave.current?.sortieItemAssignments, midCampReviewSaveBefore.current?.sortieItemAssignments) &&
|
|
JSON.stringify(updatedCampSiegeSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midCampReviewSaveBefore.slot1?.selectedSortieUnitIds) &&
|
|
sameJsonValue(updatedCampSiegeSave.slot1?.sortieFormationAssignments, midCampReviewSaveBefore.slot1?.sortieFormationAssignments) &&
|
|
sameJsonValue(updatedCampSiegeSave.slot1?.sortieItemAssignments, midCampReviewSaveBefore.slot1?.sortieItemAssignments) &&
|
|
sameJsonValue(updatedCampSiegeSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
|
|
sameJsonValue(updatedCampSiegeSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
|
|
sameJsonValue(updatedCampSiegeSave.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
|
|
sameJsonValue(updatedCampSiegeSave.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
|
|
sameJsonValue(updatedCampSiegeSave.current?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
|
|
sameJsonValue(updatedCampSiegeSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) &&
|
|
sameJsonValue(updatedCampSiegeSave.current?.sortieFormationPresets?.mobile, midCampReviewSaveBefore.current?.sortieFormationPresets?.mobile) &&
|
|
sameJsonValue(updatedCampSiegeSave.current?.sortieFormationPresets?.elite, midCampReviewSaveBefore.current?.sortieFormationPresets?.elite) &&
|
|
JSON.stringify(updatedCampSiegeState?.selectedSortieUnitIds) === JSON.stringify(midCampReviewState?.selectedSortieUnitIds) &&
|
|
sameJsonValue(updatedCampSiegeState?.sortieFormationAssignments, midCampReviewState?.sortieFormationAssignments) &&
|
|
sameJsonValue(updatedCampSiegeState?.sortieItemAssignments, midCampReviewState?.sortieItemAssignments),
|
|
`Expected the camp review update to preserve the active sortie, supplies, report snapshot, and other presets: ${JSON.stringify({
|
|
before: midCampReviewSaveBefore,
|
|
after: updatedCampSiegeSave
|
|
})}`
|
|
);
|
|
assert(
|
|
updatedCampSiegeState?.reportFormationEvaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === true &&
|
|
updatedCampSiegeState?.reportFormationEvaluation?.feedback?.includes('장비·보급은 저장하지 않았습니다.'),
|
|
`Expected visible completion feedback and a matching siege card after the camp update: ${JSON.stringify(updatedCampSiegeState?.reportFormationEvaluation)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-report-formation-evaluation-updated.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'mid camp report formation evaluation update');
|
|
const closeUpdatedCampReviewPoint = await readCampReportFormationEvaluationControlPoint(page, 'close');
|
|
await page.mouse.click(closeUpdatedCampReviewPoint.x, closeUpdatedCampReviewPoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
await seedCampaignSave(page, {
|
|
battleId: 'sixty-sixth-battle-wuzhang-final',
|
|
battleTitle: '오장원 최종전',
|
|
step: 'sixty-sixth-camp',
|
|
gold: 9800,
|
|
turnNumber: 18,
|
|
defeatedEnemies: 20,
|
|
selectedSortieUnitIds: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan'],
|
|
pendingVictoryRewardNotice: true
|
|
});
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await clickLegacyUi(page, 962, 310);
|
|
await waitForCamp(page);
|
|
await waitForCampSkinTransition(page, 'northern');
|
|
await waitForCampSoundscapeTransition(page, 'camp-frontier', 'mountain-wind-ambience');
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`);
|
|
assertCampSkinState(finalCampState, 'northern', 'final camp');
|
|
assertCampSoundscapeState(finalCampState, {
|
|
musicKey: 'camp-frontier',
|
|
ambienceKey: 'mountain-wind-ambience',
|
|
musicVolume: 0.2,
|
|
ambienceVolume: 0.09
|
|
}, 'final camp');
|
|
assert(finalCampState.sortieHasBattle === false, `Expected final camp to expose no playable sortie target: ${JSON.stringify(finalCampState)}`);
|
|
assert(finalCampState.stagedSortiePrep === false, `Expected the non-battle final camp to avoid the three-stage sortie flow: ${JSON.stringify(finalCampState)}`);
|
|
assert(finalCampState.nextSortieBattleId === null, `Expected final camp to route to ending instead of a battle: ${JSON.stringify(finalCampState)}`);
|
|
assert(
|
|
finalCampState.reportFormationEvaluation?.available === false &&
|
|
finalCampState.reportFormationEvaluation.open === false &&
|
|
finalCampState.reportFormationEvaluation.toggleButtonBounds === null &&
|
|
finalCampState.reportFormationEvaluation.presetCards?.length === 0,
|
|
`Expected a latest report without sortie performance to hide the camp evaluation instead of falling back to older history: ${JSON.stringify(finalCampState.reportFormationEvaluation)}`
|
|
);
|
|
assert(
|
|
Array.isArray(finalCampState.sortieDeploymentPreview) && finalCampState.sortieDeploymentPreview.length === 0,
|
|
`Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}`
|
|
);
|
|
assert(
|
|
finalCampState.victoryRewardAcknowledgement?.visible === true &&
|
|
finalCampState.victoryRewardAcknowledgement?.battleId === 'sixty-sixth-battle-wuzhang-final',
|
|
`Expected the seeded final victory to expose its undismissed reward notice: ${JSON.stringify(finalCampState.victoryRewardAcknowledgement)}`
|
|
);
|
|
const finalCampRewardSaveBeforeClose = await readCampaignSave(page);
|
|
const finalCampRewardClosePoint = await readCampVictoryRewardControlPoint(page, 'close');
|
|
await page.mouse.click(finalCampRewardClosePoint.x, finalCampRewardClosePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const finalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const finalCampRewardSaveAfterClose = await readCampaignSave(page);
|
|
const finalBattleId = 'sixty-sixth-battle-wuzhang-final';
|
|
assert(
|
|
sameJsonValue(
|
|
campaignSaveWithoutVictoryRewardNoticeDismissals(finalCampRewardSaveAfterClose),
|
|
campaignSaveWithoutVictoryRewardNoticeDismissals(finalCampRewardSaveBeforeClose)
|
|
) &&
|
|
sameJsonValue(
|
|
campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveAfterClose, finalBattleId),
|
|
campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveBeforeClose, finalBattleId)
|
|
) &&
|
|
campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).current === false &&
|
|
campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId).slot1 === false &&
|
|
campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId).current === true &&
|
|
campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId).slot1 === true &&
|
|
finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true &&
|
|
finalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true,
|
|
`Expected closing the final-camp reward notice to persist only its dismissal while preserving destination-specific NEW states: ${JSON.stringify({
|
|
beforeDismissal: campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveBeforeClose, finalBattleId),
|
|
afterDismissal: campaignVictoryNoticeDismissalSnapshot(finalCampRewardSaveAfterClose, finalBattleId),
|
|
beforeAcknowledgement: campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveBeforeClose, finalBattleId),
|
|
afterAcknowledgement: campaignVictoryAcknowledgementSnapshot(finalCampRewardSaveAfterClose, finalBattleId),
|
|
tabs: finalCampAfterRewardClose?.campTabs
|
|
?.filter((tab) => ['equipment', 'supplies'].includes(tab.id))
|
|
.map((tab) => ({ id: tab.id, new: tab.newBadgeVisible }))
|
|
})}`
|
|
);
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await clickLegacyUi(page, 962, 310);
|
|
await waitForCamp(page);
|
|
await waitForCampSkinTransition(page, 'northern');
|
|
const resumedFinalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
assert(
|
|
resumedFinalCampAfterRewardClose?.campaign?.step === 'sixty-sixth-camp' &&
|
|
resumedFinalCampAfterRewardClose?.victoryRewardAcknowledgement?.visible === false &&
|
|
resumedFinalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'equipment')?.newBadgeVisible === true &&
|
|
resumedFinalCampAfterRewardClose?.campTabs?.find((tab) => tab.id === 'supplies')?.newBadgeVisible === true,
|
|
`Expected title continue to keep the dismissed reward notice closed without consuming NEW states: ${JSON.stringify(resumedFinalCampAfterRewardClose)}`
|
|
);
|
|
await captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp');
|
|
await assertFinalCampRosterPagination(page);
|
|
|
|
await clickLegacyUi(page, 1160, 38);
|
|
const finalTransitionScenes = await waitForStoryOrEnding(page);
|
|
assert(!finalTransitionScenes.includes('BattleScene'), `Expected final camp transition to avoid BattleScene: ${JSON.stringify(finalTransitionScenes)}`);
|
|
if (finalTransitionScenes.includes('StoryScene')) {
|
|
await waitForStoryReady(page);
|
|
await advanceStoryUntilEnding(page);
|
|
}
|
|
await waitForEnding(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-ending.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'ending');
|
|
|
|
const endingState = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.());
|
|
assert(endingState?.campaignStep === 'ending-complete', `Expected ending-complete state: ${JSON.stringify(endingState)}`);
|
|
assert(endingState.latestBattleId === 'sixty-sixth-battle-wuzhang-final', `Expected Wuzhang final as latest battle: ${JSON.stringify(endingState)}`);
|
|
|
|
await page.keyboard.press('Enter');
|
|
await waitForTitle(page);
|
|
await clickLegacyUi(page, 962, 310);
|
|
await waitForEnding(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-ending-continue.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'ending continue');
|
|
|
|
const endingContinueState = await page.evaluate(() => ({
|
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
|
ending: window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.()
|
|
}));
|
|
assert(
|
|
endingContinueState.activeScenes.includes('EndingScene') && endingContinueState.ending?.campaignStep === 'ending-complete',
|
|
`Expected title continue to reopen ending: ${JSON.stringify(endingContinueState)}`
|
|
);
|
|
|
|
const relevantLogs = consoleMessages.filter(
|
|
(message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text))
|
|
);
|
|
const blockingRequestFailures = requestFailures.filter((request) => request.failure !== 'net::ERR_ABORTED');
|
|
assert(relevantLogs.length === 0, `Unexpected browser console issues: ${JSON.stringify(relevantLogs)}`);
|
|
assert(pageErrors.length === 0, `Unexpected browser runtime errors: ${JSON.stringify(pageErrors)}`);
|
|
assert(blockingRequestFailures.length === 0, `Unexpected browser request failures: ${JSON.stringify(blockingRequestFailures)}`);
|
|
|
|
console.log(`Verified release-candidate user flow at ${targetUrl}`);
|
|
} finally {
|
|
await browser?.close();
|
|
if (serverProcess && !serverProcess.killed) {
|
|
serverProcess.kill();
|
|
}
|
|
}
|
|
|
|
function withDebugParam(url) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('debug', '1');
|
|
parsed.searchParams.set('renderer', 'canvas');
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function setDebugQueryParam(page, key, value) {
|
|
await page.evaluate(({ key, value }) => {
|
|
const url = new URL(window.location.href);
|
|
if (value === null) {
|
|
url.searchParams.delete(key);
|
|
} else {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
window.history.replaceState(window.history.state, '', url);
|
|
}, { key, value });
|
|
}
|
|
|
|
function isWarning(type) {
|
|
return type === 'warning' || type === 'warn';
|
|
}
|
|
|
|
async function waitForTitle(page) {
|
|
await page.waitForFunction(() => document.querySelector('canvas') !== null, 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 assertReleaseShellReady(page) {
|
|
const state = await page.evaluate(() => ({
|
|
title: document.title,
|
|
canvasCount: document.querySelectorAll('canvas').length,
|
|
debugApiPresent: Boolean(window.__HEROS_DEBUG__),
|
|
gameApiPresent: Boolean(window.__HEROS_GAME__)
|
|
}));
|
|
|
|
assert(state.title === expectedTitle, `Expected release page title "${expectedTitle}": ${JSON.stringify(state)}`);
|
|
assert(state.canvasCount === 1, `Expected exactly one release game canvas: ${JSON.stringify(state)}`);
|
|
assert(state.debugApiPresent === true, `Expected release QA debug API to be enabled: ${JSON.stringify(state)}`);
|
|
assert(state.gameApiPresent === true, `Expected release QA game handle to be enabled: ${JSON.stringify(state)}`);
|
|
}
|
|
|
|
async function assertHdPlusCanvasFit(page) {
|
|
const cases = [
|
|
{
|
|
viewport: desktopBrowserViewport,
|
|
expectedBounds: desktopBrowserViewportBounds
|
|
},
|
|
{
|
|
viewport: { width: 2560, height: 1440 },
|
|
expectedBounds: { x: 0, y: 0, width: 2560, height: 1440 }
|
|
},
|
|
{
|
|
viewport: { width: 2560, height: 1600 },
|
|
expectedBounds: { x: 0, y: 80, width: 2560, height: 1440 }
|
|
}
|
|
];
|
|
|
|
for (const testCase of cases) {
|
|
await page.setViewportSize(testCase.viewport);
|
|
await page.waitForFunction(({ expectedBounds }) => {
|
|
const canvas = document.querySelector('canvas');
|
|
if (!canvas) {
|
|
return false;
|
|
}
|
|
const bounds = canvas.getBoundingClientRect();
|
|
return (
|
|
canvas.width === 1920 &&
|
|
canvas.height === 1080 &&
|
|
Math.abs(bounds.x - expectedBounds.x) <= 1 &&
|
|
Math.abs(bounds.y - expectedBounds.y) <= 1 &&
|
|
Math.abs(bounds.width - expectedBounds.width) <= 1 &&
|
|
Math.abs(bounds.height - expectedBounds.height) <= 1
|
|
);
|
|
}, testCase, { timeout: 30000 });
|
|
|
|
const state = await page.evaluate(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
const bounds = canvas?.getBoundingClientRect();
|
|
const game = window.__HEROS_GAME__;
|
|
return {
|
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
gameSize: game ? { width: game.scale.width, height: game.scale.height } : null,
|
|
canvas: canvas && bounds
|
|
? {
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
|
|
}
|
|
: null
|
|
};
|
|
});
|
|
assert(state.canvas?.width === 1920 && state.canvas?.height === 1080, `Expected a fixed FHD game surface: ${JSON.stringify(state)}`);
|
|
assert(state.gameSize?.width === 1920 && state.gameSize?.height === 1080, `Expected fixed FHD scene coordinates: ${JSON.stringify(state)}`);
|
|
assert(
|
|
boundsNear(state.canvas?.bounds, testCase.expectedBounds),
|
|
`Expected FHD surface to fit ${testCase.viewport.width}x${testCase.viewport.height}: ${JSON.stringify(state)}`
|
|
);
|
|
}
|
|
|
|
await page.setViewportSize(baselineViewport);
|
|
await page.waitForFunction(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
const bounds = canvas?.getBoundingClientRect();
|
|
return Boolean(
|
|
canvas &&
|
|
bounds &&
|
|
canvas.width === 1920 &&
|
|
canvas.height === 1080 &&
|
|
Math.abs(bounds.x) <= 1 &&
|
|
Math.abs(bounds.y) <= 1 &&
|
|
Math.abs(bounds.width - 1920) <= 1 &&
|
|
Math.abs(bounds.height - 1080) <= 1
|
|
);
|
|
}, undefined, { timeout: 30000 });
|
|
}
|
|
|
|
function boundsNear(actual, expected, tolerance = 1) {
|
|
return Boolean(
|
|
actual &&
|
|
Math.abs(actual.x - expected.x) <= tolerance &&
|
|
Math.abs(actual.y - expected.y) <= tolerance &&
|
|
Math.abs(actual.width - expected.width) <= tolerance &&
|
|
Math.abs(actual.height - expected.height) <= tolerance
|
|
);
|
|
}
|
|
|
|
async function assertHdPlusInputMapping(browser, url) {
|
|
const page = await browser.newPage({ viewport: { width: 2560, height: 1600 }, deviceScaleFactor: desktopBrowserDeviceScaleFactor });
|
|
try {
|
|
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => window.localStorage.clear());
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
|
|
const clickPoint = await page.evaluate(({ logicalX, logicalY }) => {
|
|
const canvas = document.querySelector('canvas');
|
|
const bounds = canvas?.getBoundingClientRect();
|
|
if (!canvas || !bounds) {
|
|
return null;
|
|
}
|
|
return {
|
|
x: bounds.x + (logicalX / canvas.width) * bounds.width,
|
|
y: bounds.y + (logicalY / canvas.height) * bounds.height,
|
|
canvasBounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
|
|
};
|
|
}, { logicalX: 962 * legacyUiScale, logicalY: 240 * legacyUiScale });
|
|
assert(
|
|
clickPoint && boundsNear(clickPoint.canvasBounds, { x: 0, y: 80, width: 2560, height: 1440 }),
|
|
`Expected a centered FHD canvas before testing above-FHD input: ${JSON.stringify(clickPoint)}`
|
|
);
|
|
|
|
await page.mouse.click(clickPoint.x, clickPoint.y);
|
|
await waitForStoryReady(page);
|
|
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
|
|
assert(activeScenes.includes('StoryScene'), `Expected scaled title input to enter the story: ${JSON.stringify(activeScenes)}`);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
|
|
async function assertLargeRosterHud(browser, url) {
|
|
const expectedRosterIds = [
|
|
'zhuge-liang',
|
|
'jiang-wei',
|
|
'wang-ping',
|
|
'zhao-yun',
|
|
'ma-dai',
|
|
'ma-chao',
|
|
'huang-quan',
|
|
'li-yan'
|
|
];
|
|
const battleUrl = new URL(url);
|
|
battleUrl.searchParams.set('debugBattle', 'sixty-sixth-battle-wuzhang-final');
|
|
const page = await browser.newPage(desktopBrowserContextOptions);
|
|
try {
|
|
await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return battle?.battleId === 'sixty-sixth-battle-wuzhang-final' && battle?.mapBackgroundReady === true;
|
|
}, undefined, { timeout: 90000 });
|
|
await page.evaluate((selectedSortieUnitIds) => {
|
|
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.scene.restart({
|
|
battleId: 'sixty-sixth-battle-wuzhang-final',
|
|
selectedSortieUnitIds
|
|
});
|
|
}, expectedRosterIds);
|
|
await page.waitForFunction((expectedIds) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.battleId === 'sixty-sixth-battle-wuzhang-final' &&
|
|
battle?.mapBackgroundReady === true &&
|
|
JSON.stringify([...battle.deployedAllyIds].sort()) === JSON.stringify([...expectedIds].sort())
|
|
);
|
|
}, expectedRosterIds, { timeout: 90000 });
|
|
|
|
const deploymentProbe = await readLargeRosterHudProbe(page);
|
|
assertLateBattleHeaderLayout(deploymentProbe, 'deployment');
|
|
assertLateBattleSidebarLayout(deploymentProbe, 'deployment', false);
|
|
assertLateBattleDeploymentLayout(deploymentProbe);
|
|
assertSideQuickTabsLayout(deploymentProbe, null, null, false);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-deployment.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'final battle deployment');
|
|
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
if (scene?.phase === 'deployment') {
|
|
scene.confirmPreBattleDeployment?.();
|
|
}
|
|
});
|
|
await waitForBattleDeploymentConfirmation(page);
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
scene?.hideBattleEventBanner?.();
|
|
scene?.renderSituationPanel?.();
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
const hud = battle?.battleHud;
|
|
return (
|
|
battle?.phase === 'idle' &&
|
|
hud?.miniMap?.visible === true &&
|
|
hud?.sidebar?.bounds &&
|
|
hud?.deploymentPanel === null &&
|
|
hud?.quickTabs?.visible === true &&
|
|
hud?.quickTabs?.enabled === true &&
|
|
hud?.quickTabs?.activeTab === 'situation' &&
|
|
hud?.quickTabs?.currentView === 'situation'
|
|
);
|
|
}, undefined, { timeout: 30000 });
|
|
|
|
const situationProbe = await readLargeRosterHudProbe(page);
|
|
assertLateBattleHeaderLayout(situationProbe, 'situation');
|
|
assertLateBattleSidebarLayout(situationProbe, 'situation', true);
|
|
assertSideQuickTabsLayout(situationProbe, 'situation', 'situation');
|
|
|
|
await assertFinalBattleQuickTabs(page);
|
|
|
|
await assertFinalBattleFhdDetailPanels(page);
|
|
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.');
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const roster = window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel;
|
|
return roster?.tab === 'ally' && roster?.pageCount > 1;
|
|
}, undefined, { timeout: 30000 });
|
|
|
|
const firstPage = await readLargeRosterHudProbe(page);
|
|
assert(firstPage.roster.totalCount === expectedRosterIds.length, `Expected the exact eight-officer sortie roster: ${JSON.stringify(firstPage)}`);
|
|
assert(firstPage.roster.page === 0 && firstPage.roster.pageCount >= 2, `Expected paged late-battle roster: ${JSON.stringify(firstPage)}`);
|
|
assert(
|
|
firstPage.roster.visibleUnitIds.length > 0 && firstPage.roster.visibleUnitIds.length <= firstPage.roster.rowsPerPage,
|
|
`Expected only the current roster page to render: ${JSON.stringify(firstPage)}`
|
|
);
|
|
assertLargeRosterPageLayout(firstPage, 'first');
|
|
assert(firstPage.recentActions === null, `Expected the paged roster to prioritize selectable units over the action log: ${JSON.stringify(firstPage)}`);
|
|
|
|
const visibleRosterIds = new Set(firstPage.roster.visibleUnitIds);
|
|
let visibleRosterCount = firstPage.roster.visibleUnitIds.length;
|
|
let currentPage = firstPage;
|
|
for (let pageIndex = 1; pageIndex < firstPage.roster.pageCount; pageIndex += 1) {
|
|
await page.mouse.click(
|
|
currentPage.roster.navigationBounds.x + currentPage.roster.navigationBounds.width - 39,
|
|
currentPage.roster.navigationBounds.y + 15
|
|
);
|
|
await page.waitForFunction(
|
|
(expectedPage) => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.page === expectedPage,
|
|
pageIndex,
|
|
{ timeout: 30000 }
|
|
);
|
|
currentPage = await readLargeRosterHudProbe(page);
|
|
visibleRosterCount += currentPage.roster.visibleUnitIds.length;
|
|
currentPage.roster.visibleUnitIds.forEach((unitId) => visibleRosterIds.add(unitId));
|
|
assertLargeRosterPageLayout(currentPage, `${pageIndex + 1}`);
|
|
}
|
|
assert(
|
|
visibleRosterCount === expectedRosterIds.length &&
|
|
JSON.stringify([...visibleRosterIds].sort()) === JSON.stringify([...expectedRosterIds].sort()),
|
|
`Expected roster paging to expose every selected officer exactly once: ${JSON.stringify({ expectedRosterIds, visibleRosterCount, visibleRosterIds: [...visibleRosterIds] })}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-roster-last-page.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'final battle roster last page');
|
|
|
|
await page.evaluate(() => {
|
|
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.renderRosterPanel?.('enemy', '적군 전력을 확인하세요.');
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const roster = window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel;
|
|
return roster?.tab === 'enemy' && roster?.pageCount > 1;
|
|
}, undefined, { timeout: 30000 });
|
|
await page.evaluate(() => new Promise((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
}));
|
|
const enemyPage = await readLargeRosterHudProbe(page);
|
|
assert(
|
|
enemyPage.roster.rowLayouts.some((row) => row.unitName.length >= 8),
|
|
`Expected the enemy roster fixture to exercise a long unit name: ${JSON.stringify(enemyPage)}`
|
|
);
|
|
assertLargeRosterPageLayout(enemyPage, 'enemy first');
|
|
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-enemy-roster.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'final battle enemy roster');
|
|
|
|
await page.evaluate((selectedSortieUnitIds) => {
|
|
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.scene.restart({
|
|
battleId: 'sixty-sixth-battle-wuzhang-final',
|
|
selectedSortieUnitIds
|
|
});
|
|
}, expectedRosterIds);
|
|
await page.waitForFunction((expectedIds) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.battleId === 'sixty-sixth-battle-wuzhang-final' &&
|
|
battle?.mapBackgroundReady === true &&
|
|
battle?.phase === 'deployment' &&
|
|
JSON.stringify([...battle.deployedAllyIds].sort()) === JSON.stringify([...expectedIds].sort())
|
|
);
|
|
}, expectedRosterIds, { timeout: 90000 });
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
scene?.confirmPreBattleDeployment?.();
|
|
});
|
|
await waitForBattleDeploymentConfirmation(page);
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
scene?.hideBattleEventBanner?.();
|
|
scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.');
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
const hud = battle?.battleHud;
|
|
return (
|
|
battle?.phase === 'idle' &&
|
|
hud?.rosterPanel?.pageCount > 1 &&
|
|
hud?.quickTabs?.visible === true &&
|
|
hud?.quickTabs?.enabled === true &&
|
|
hud?.quickTabs?.activeTab === 'roster' &&
|
|
hud?.quickTabs?.currentView === 'roster'
|
|
);
|
|
}, undefined, { timeout: 30000 });
|
|
const restartedPage = await readLargeRosterHudProbe(page);
|
|
assert(
|
|
restartedPage.roster.page === 0,
|
|
`Expected a restarted battle to reset the roster to page one: ${JSON.stringify(restartedPage)}`
|
|
);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
|
|
async function assertWebglBattleQuickTabSmoke(browser, url, battleId) {
|
|
const battleUrl = new URL(url);
|
|
battleUrl.searchParams.set('renderer', 'webgl');
|
|
battleUrl.searchParams.set('debugBattle', battleId);
|
|
const page = await browser.newPage(desktopBrowserContextOptions);
|
|
const pageErrors = [];
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
|
|
try {
|
|
await page.addInitScript(() => window.localStorage.clear());
|
|
await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' });
|
|
await waitForBattleReady(page, battleId);
|
|
await page.waitForFunction(() => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return battle?.phase === 'idle' && battle.battleHud?.quickTabs?.enabled === true;
|
|
}, undefined, { timeout: 90000 });
|
|
|
|
if (battleId === 'first-battle-zhuo-commandery') {
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial?.active === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
await page.evaluate(() => {
|
|
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.completeFirstBattleTutorial?.('skipped');
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.firstBattleTutorial?.active === false &&
|
|
battle.firstBattleTutorial.completion === 'skipped' &&
|
|
battle.battleHud?.quickTabs?.enabled === true
|
|
);
|
|
}, undefined, { timeout: 30000 });
|
|
}
|
|
|
|
const rendererProbe = await page.evaluate(() => {
|
|
const game = window.__HEROS_GAME__;
|
|
const scene = game?.scene.getScene('BattleScene');
|
|
return {
|
|
type: game?.renderer?.type ?? null,
|
|
name: game?.renderer?.constructor?.name ?? null,
|
|
sceneWidth: scene?.scale.width ?? null,
|
|
sceneHeight: scene?.scale.height ?? null
|
|
};
|
|
});
|
|
assert(
|
|
rendererProbe.type === phaserWebglRendererType &&
|
|
rendererProbe.sceneWidth === baselineViewport.width &&
|
|
rendererProbe.sceneHeight === baselineViewport.height,
|
|
`Expected the ${battleId} input smoke to use the FHD WebGL renderer: ${JSON.stringify(rendererProbe)}`
|
|
);
|
|
|
|
let actionableUnitPoint = await readActionableBattleUnitPoint(page);
|
|
await page.mouse.click(actionableUnitPoint.x, actionableUnitPoint.y);
|
|
await page.waitForFunction(
|
|
(unitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.phase === 'moving' &&
|
|
battle.selectedUnitId === unitId &&
|
|
battle.pendingMove === null &&
|
|
battle.battleHud?.quickTabs?.enabled === true
|
|
);
|
|
},
|
|
actionableUnitPoint.unitId,
|
|
{ timeout: 30000 }
|
|
);
|
|
await page.keyboard.press('3');
|
|
await waitForSideQuickTabView(page, 'bond', 'bond');
|
|
const keyboardReturnState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
assert(
|
|
keyboardReturnState?.phase === 'idle' &&
|
|
keyboardReturnState.selectedUnitId === null &&
|
|
keyboardReturnState.pendingMove === null &&
|
|
keyboardReturnState.markerCount === 0,
|
|
`Expected WebGL keyboard navigation to cancel an uncommitted selection: ${JSON.stringify(keyboardReturnState)}`
|
|
);
|
|
|
|
actionableUnitPoint = await readActionableBattleUnitPoint(page);
|
|
await page.mouse.click(actionableUnitPoint.x, actionableUnitPoint.y);
|
|
await page.waitForFunction(
|
|
(unitId) => window.__HEROS_DEBUG__?.battle()?.phase === 'moving' && window.__HEROS_DEBUG__?.battle()?.selectedUnitId === unitId,
|
|
actionableUnitPoint.unitId,
|
|
{ timeout: 30000 }
|
|
);
|
|
await page.mouse.click(actionableUnitPoint.x, actionableUnitPoint.y);
|
|
await page.waitForFunction(
|
|
(unitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return battle?.phase === 'command' && battle.selectedUnitId === unitId && battle.commandMenuVisible === true;
|
|
},
|
|
actionableUnitPoint.unitId,
|
|
{ timeout: 30000 }
|
|
);
|
|
const commandLockState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
assert(
|
|
commandLockState?.pendingMove?.unitId === actionableUnitPoint.unitId &&
|
|
commandLockState.battleHud?.quickTabs?.enabled === false,
|
|
`Expected committed movement and its command menu to keep quick tabs locked: ${JSON.stringify(commandLockState)}`
|
|
);
|
|
const waitCommandPoint = await readBattleCommandControlPoint(page, 'wait');
|
|
await page.mouse.click(waitCommandPoint.x, waitCommandPoint.y);
|
|
await page.waitForFunction(
|
|
(completedUnitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.phase === 'moving' &&
|
|
battle.selectedUnitId &&
|
|
battle.selectedUnitId !== completedUnitId &&
|
|
battle.actedUnitIds?.includes(completedUnitId) &&
|
|
battle.battleHud?.quickTabs?.enabled === true
|
|
);
|
|
},
|
|
actionableUnitPoint.unitId,
|
|
{ timeout: 90000 }
|
|
);
|
|
|
|
const rosterTabPoint = await readSideQuickTabControlPoint(page, 'roster');
|
|
await page.mouse.click(rosterTabPoint.x, rosterTabPoint.y);
|
|
await waitForSideQuickTabView(page, 'roster', 'roster');
|
|
const autoFocusReturnState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
assert(
|
|
autoFocusReturnState?.phase === 'idle' &&
|
|
autoFocusReturnState.selectedUnitId === null &&
|
|
autoFocusReturnState.pendingMove === null &&
|
|
autoFocusReturnState.markerCount === 0 &&
|
|
autoFocusReturnState.actedUnitIds?.includes(actionableUnitPoint.unitId),
|
|
`Expected a WebGL mouse tab click to leave auto-focused movement safely: ${JSON.stringify(autoFocusReturnState)}`
|
|
);
|
|
|
|
const emptyTilePoint = await readEmptyBattleTilePoint(page);
|
|
const transitionRevision = autoFocusReturnState.battleHud?.panelTransition?.revision;
|
|
await page.mouse.move(emptyTilePoint.x, emptyTilePoint.y);
|
|
await page.waitForFunction(
|
|
({ tileX, tileY }) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.pointerFeedbackVisible === true &&
|
|
battle.pointerFeedbackTile?.x === tileX &&
|
|
battle.pointerFeedbackTile?.y === tileY &&
|
|
battle.battleHud?.quickTabs?.activeTab === 'roster' &&
|
|
battle.battleHud.quickTabs.currentView === 'roster'
|
|
);
|
|
},
|
|
{ tileX: emptyTilePoint.tile.x, tileY: emptyTilePoint.tile.y },
|
|
{ timeout: 30000 }
|
|
);
|
|
const hoverState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
assert(
|
|
hoverState?.battleHud?.terrainPanel === null &&
|
|
hoverState.battleHud.panelTransition?.revision === transitionRevision,
|
|
`Expected WebGL terrain hover to preserve the roster panel: ${JSON.stringify(hoverState)}`
|
|
);
|
|
|
|
await page.mouse.click(emptyTilePoint.x, emptyTilePoint.y);
|
|
await waitForSideQuickTabView(page, 'situation', 'terrain-detail');
|
|
await page.keyboard.press('Escape');
|
|
await waitForSideQuickTabView(page, 'situation', 'situation');
|
|
const screenshot = await page.screenshot({ path: `${screenshotDir}/rc-webgl-${battleId}-quick-tabs.png`, fullPage: true });
|
|
assert(
|
|
screenshot.byteLength >= 100_000,
|
|
`Expected a visibly painted ${battleId} WebGL frame: ${JSON.stringify({ byteLength: screenshot.byteLength })}`
|
|
);
|
|
assert(pageErrors.length === 0, `Unexpected ${battleId} WebGL runtime errors: ${JSON.stringify(pageErrors)}`);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
|
|
async function assertFinalBattleQuickTabs(page) {
|
|
const initialProbe = await readLargeRosterHudProbe(page);
|
|
assertSideQuickTabsLayout(initialProbe, 'situation', 'situation');
|
|
|
|
const rosterTab = initialProbe.quickTabs.tabs.find((tab) => tab.id === 'roster');
|
|
await page.mouse.move(
|
|
rosterTab.bounds.x + rosterTab.bounds.width / 2,
|
|
rosterTab.bounds.y + rosterTab.bounds.height / 2
|
|
);
|
|
await page.waitForFunction(() => {
|
|
const quickTabs = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs;
|
|
return quickTabs?.hoveredTab === 'roster' && quickTabs.tabs?.find((tab) => tab.id === 'roster')?.visualState === 'hover';
|
|
}, undefined, { timeout: 30000 });
|
|
const hoverProbe = await readLargeRosterHudProbe(page);
|
|
assert(
|
|
hoverProbe.quickTabs.activeTab === 'situation' && hoverProbe.quickTabs.hoveredTab === 'roster',
|
|
`Expected hover feedback without changing the active quick tab: ${JSON.stringify(hoverProbe)}`
|
|
);
|
|
await page.mouse.move(initialProbe.sceneBounds.x + 20, initialProbe.sceneBounds.y + 20);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.hoveredTab === null,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
await page.mouse.click(
|
|
rosterTab.bounds.x + rosterTab.bounds.width / 2,
|
|
rosterTab.bounds.y + rosterTab.bounds.height / 2
|
|
);
|
|
await waitForSideQuickTabView(page, 'roster', 'roster');
|
|
const rosterProbe = await readLargeRosterHudProbe(page);
|
|
assertSideQuickTabsLayout(rosterProbe, 'roster', 'roster');
|
|
assertCompletedSidePanelTransition(rosterProbe, 'tab', 'roster');
|
|
|
|
await page.keyboard.press('1');
|
|
await waitForSideQuickTabView(page, 'situation', 'situation');
|
|
await page.keyboard.press('2');
|
|
await waitForSideQuickTabView(page, 'roster', 'roster');
|
|
const keyboardRosterProbe = await readLargeRosterHudProbe(page);
|
|
assertSideQuickTabsLayout(keyboardRosterProbe, 'roster', 'roster');
|
|
assertCompletedSidePanelTransition(keyboardRosterProbe, 'tab', 'roster');
|
|
|
|
await page.keyboard.press('3');
|
|
await waitForSideQuickTabView(page, 'bond', 'bond');
|
|
const bondProbe = await readLargeRosterHudProbe(page);
|
|
assertSideQuickTabsLayout(bondProbe, 'bond', 'bond');
|
|
assertCompletedSidePanelTransition(bondProbe, 'tab', 'bond');
|
|
|
|
await page.keyboard.press('4');
|
|
await waitForSideQuickTabView(page, 'threat', 'threat-overview');
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount > 0, undefined, { timeout: 30000 });
|
|
const threatProbe = await readLargeRosterHudProbe(page);
|
|
assertSideQuickTabsLayout(threatProbe, 'threat', 'threat-overview');
|
|
assert(threatProbe.markerCount > 0, `Expected the threat quick tab to paint map markers: ${JSON.stringify(threatProbe)}`);
|
|
assertCompletedSidePanelTransition(threatProbe, 'tab', 'threat-overview');
|
|
|
|
await page.keyboard.press('1');
|
|
await waitForSideQuickTabView(page, 'situation', 'situation');
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount === 0, undefined, { timeout: 30000 });
|
|
const situationProbe = await readLargeRosterHudProbe(page);
|
|
assertSideQuickTabsLayout(situationProbe, 'situation', 'situation');
|
|
assert(situationProbe.markerCount === 0, `Expected another quick tab to clear threat markers: ${JSON.stringify(situationProbe)}`);
|
|
assertCompletedSidePanelTransition(situationProbe, 'tab', 'situation');
|
|
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
if (!scene) {
|
|
return;
|
|
}
|
|
scene.phase = 'animating';
|
|
scene.renderSituationPanel?.('빠른 탭 입력 차단 점검');
|
|
});
|
|
await page.waitForFunction(() => {
|
|
const quickTabs = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs;
|
|
return quickTabs?.visible === true && quickTabs.enabled === false && quickTabs.activeTab === 'situation';
|
|
}, undefined, { timeout: 30000 });
|
|
const disabledProbe = await readLargeRosterHudProbe(page);
|
|
const disabledRosterTab = disabledProbe.quickTabs.tabs.find((tab) => tab.id === 'roster');
|
|
assert(
|
|
disabledProbe.quickTabs.tabs.find((tab) => tab.id === 'situation')?.visualState === 'disabled-active' &&
|
|
disabledRosterTab?.visualState === 'disabled',
|
|
`Expected a dimmed active tab and disabled alternatives while battle input is locked: ${JSON.stringify(disabledProbe)}`
|
|
);
|
|
await page.keyboard.press('2');
|
|
await page.mouse.click(
|
|
disabledRosterTab.bounds.x + disabledRosterTab.bounds.width / 2,
|
|
disabledRosterTab.bounds.y + disabledRosterTab.bounds.height / 2
|
|
);
|
|
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
|
const blockedProbe = await readLargeRosterHudProbe(page);
|
|
assert(
|
|
blockedProbe.quickTabs.activeTab === 'situation' && blockedProbe.quickTabs.currentView === 'situation',
|
|
`Expected disabled mouse and keyboard quick-tab input to be ignored: ${JSON.stringify(blockedProbe)}`
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
if (scene) {
|
|
scene.phase = 'idle';
|
|
}
|
|
});
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.enabled === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const restoredProbe = await readLargeRosterHudProbe(page);
|
|
const restoredRosterTab = restoredProbe.quickTabs.tabs.find((tab) => tab.id === 'roster');
|
|
await page.mouse.click(
|
|
restoredRosterTab.bounds.x + restoredRosterTab.bounds.width / 2,
|
|
restoredRosterTab.bounds.y + restoredRosterTab.bounds.height / 2
|
|
);
|
|
await waitForSideQuickTabView(page, 'roster', 'roster');
|
|
await page.keyboard.press('1');
|
|
await waitForSideQuickTabView(page, 'situation', 'situation');
|
|
}
|
|
|
|
async function waitForSideQuickTabView(page, activeTab, currentView) {
|
|
await page.waitForFunction(
|
|
({ expectedTab, expectedView }) => {
|
|
const hud = window.__HEROS_DEBUG__?.battle()?.battleHud;
|
|
return (
|
|
hud?.quickTabs?.activeTab === expectedTab &&
|
|
hud.quickTabs.currentView === expectedView &&
|
|
!hud.panelTransition?.active &&
|
|
hud.panelTransition?.completedRevision === hud.panelTransition?.revision
|
|
);
|
|
},
|
|
{ expectedTab: activeTab, expectedView: currentView },
|
|
{ timeout: 30000 }
|
|
);
|
|
}
|
|
|
|
async function readSideQuickTabControlPoint(page, tabId) {
|
|
const point = await page.evaluate((expectedTabId) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const tab = window.__HEROS_DEBUG__?.battle()?.battleHud?.quickTabs?.tabs?.find((candidate) => candidate.id === expectedTabId);
|
|
const canvas = document.querySelector('canvas');
|
|
const canvasBounds = canvas?.getBoundingClientRect();
|
|
if (!scene || !tab?.bounds || !canvas || !canvasBounds) {
|
|
return null;
|
|
}
|
|
const logicalX = tab.bounds.x + tab.bounds.width / 2;
|
|
const logicalY = tab.bounds.y + tab.bounds.height / 2;
|
|
return {
|
|
tabId: expectedTabId,
|
|
x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, tabId);
|
|
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a visible ${tabId} quick-tab control: ${JSON.stringify(point)}`);
|
|
return point;
|
|
}
|
|
|
|
async function readBattleCommandControlPoint(page, command) {
|
|
const point = await page.evaluate((expectedCommand) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const entry = scene?.commandButtons?.find((candidate) => candidate.command === expectedCommand);
|
|
const canvas = document.querySelector('canvas');
|
|
const canvasBounds = canvas?.getBoundingClientRect();
|
|
const bounds = entry?.background?.getBounds?.();
|
|
if (!scene || !bounds || !canvas || !canvasBounds) {
|
|
return null;
|
|
}
|
|
const logicalX = bounds.x + bounds.width / 2;
|
|
const logicalY = bounds.y + bounds.height / 2;
|
|
return {
|
|
command: expectedCommand,
|
|
x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, command);
|
|
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a visible ${command} command control: ${JSON.stringify(point)}`);
|
|
return point;
|
|
}
|
|
|
|
async function readActionableBattleUnitPoint(page) {
|
|
const point = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
const canvas = document.querySelector('canvas');
|
|
const canvasBounds = canvas?.getBoundingClientRect();
|
|
if (!scene || !battle || !canvas || !canvasBounds) {
|
|
return null;
|
|
}
|
|
const unit = battle.units?.find((candidate) => {
|
|
const view = scene.unitViews?.get(candidate.id);
|
|
return candidate.faction === 'ally' && candidate.hp > 0 && !candidate.acted && view?.sprite?.visible && view.sprite.active;
|
|
});
|
|
const sprite = unit ? scene.unitViews?.get(unit.id)?.sprite : null;
|
|
const bounds = sprite?.getBounds?.();
|
|
if (!unit || !bounds) {
|
|
return null;
|
|
}
|
|
const logicalX = bounds.x + bounds.width / 2;
|
|
const logicalY = bounds.y + bounds.height / 2;
|
|
return {
|
|
unitId: unit.id,
|
|
tile: { x: unit.x, y: unit.y },
|
|
x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height
|
|
};
|
|
});
|
|
assert(
|
|
point?.unitId && Number.isFinite(point.x) && Number.isFinite(point.y),
|
|
`Expected a visible actionable ally for real-input QA: ${JSON.stringify(point)}`
|
|
);
|
|
return point;
|
|
}
|
|
|
|
async function readEmptyBattleTilePoint(page) {
|
|
const point = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
const canvas = document.querySelector('canvas');
|
|
const canvasBounds = canvas?.getBoundingClientRect();
|
|
const camera = battle?.camera;
|
|
if (!scene || !battle || !camera || !canvas || !canvasBounds) {
|
|
return null;
|
|
}
|
|
|
|
const occupiedTiles = new Set(
|
|
(battle.units ?? []).filter((unit) => unit.hp > 0).map((unit) => `${unit.x},${unit.y}`)
|
|
);
|
|
const markerTiles = new Set(
|
|
(scene.markers ?? [])
|
|
.filter((marker) => marker?.active && marker?.visible)
|
|
.map((marker) => `${marker.getData('tileX')},${marker.getData('tileY')}`)
|
|
);
|
|
const candidates = [];
|
|
const maxX = Math.min(camera.mapWidth, camera.x + camera.visibleColumns);
|
|
const maxY = Math.min(camera.mapHeight, camera.y + camera.visibleRows);
|
|
const centerX = camera.x + camera.visibleColumns / 2;
|
|
const centerY = camera.y + camera.visibleRows / 2;
|
|
for (let y = camera.y; y < maxY; y += 1) {
|
|
for (let x = camera.x; x < maxX; x += 1) {
|
|
const key = `${x},${y}`;
|
|
if (occupiedTiles.has(key) || markerTiles.has(key)) {
|
|
continue;
|
|
}
|
|
candidates.push({ x, y, distance: Math.abs(x - centerX) + Math.abs(y - centerY) });
|
|
}
|
|
}
|
|
candidates.sort((left, right) => left.distance - right.distance || left.y - right.y || left.x - right.x);
|
|
const tile = candidates[0];
|
|
if (!tile) {
|
|
return null;
|
|
}
|
|
const logicalX = scene.tileCenterX(tile.x);
|
|
const logicalY = scene.tileCenterY(tile.y);
|
|
return {
|
|
tile: { x: tile.x, y: tile.y },
|
|
x: canvasBounds.left + logicalX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + logicalY * canvasBounds.height / scene.scale.height
|
|
};
|
|
});
|
|
assert(
|
|
point?.tile && Number.isFinite(point.x) && Number.isFinite(point.y),
|
|
`Expected an empty visible battle tile for pointer QA: ${JSON.stringify(point)}`
|
|
);
|
|
return point;
|
|
}
|
|
|
|
async function assertFinalBattleFhdDetailPanels(page) {
|
|
const allyFixture = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const unit = scene?.debugUnitById?.('zhuge-liang');
|
|
const message = '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n우클릭하면 이동 취소로 돌아갑니다.';
|
|
if (!scene || !unit) {
|
|
return null;
|
|
}
|
|
scene.renderUnitDetail?.(unit, message);
|
|
return { unitId: unit.id, unitName: unit.name, messageLineCount: message.split('\n').length };
|
|
});
|
|
assert(
|
|
allyFixture?.unitId === 'zhuge-liang' && allyFixture.messageLineCount === 3,
|
|
`Expected the late-battle ally detail fixture to render Zhuge Liang with the real three-line command-cancel guidance: ${JSON.stringify(allyFixture)}`
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.unitDetailPanel?.unitId === 'zhuge-liang',
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const allyDetailProbe = await readLargeRosterHudProbe(page);
|
|
assertUnitDetailPanelLayout(allyDetailProbe, 'zhuge-liang', 'ally');
|
|
assertCompletedSidePanelTransition(allyDetailProbe, 'detail', 'officer-detail');
|
|
|
|
const enemyFixture = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const unit = scene?.debugUnitById?.('northern-twelfth-scout-e');
|
|
if (!scene || !unit) {
|
|
return null;
|
|
}
|
|
scene.renderUnitDetail?.(unit, scene.enemyDetailMessage?.(unit) ?? '적군 전력을 확인하세요.');
|
|
return { unitId: unit.id, unitName: unit.name };
|
|
});
|
|
assert(
|
|
enemyFixture?.unitId === 'northern-twelfth-scout-e' && enemyFixture.unitName?.length >= 8,
|
|
`Expected the late-battle enemy detail fixture to exercise a long officer name: ${JSON.stringify(enemyFixture)}`
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.unitDetailPanel?.unitId === 'northern-twelfth-scout-e',
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const enemyDetailProbe = await readLargeRosterHudProbe(page);
|
|
assertUnitDetailPanelLayout(enemyDetailProbe, 'northern-twelfth-scout-e', 'long enemy');
|
|
await waitForBattleHudPaint(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-unit-detail.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'final battle unit detail');
|
|
|
|
await page.mouse.click(
|
|
enemyDetailProbe.unitDetailPanel.returnButtonBounds.x + enemyDetailProbe.unitDetailPanel.returnButtonBounds.width / 2,
|
|
enemyDetailProbe.unitDetailPanel.returnButtonBounds.y + enemyDetailProbe.unitDetailPanel.returnButtonBounds.height / 2
|
|
);
|
|
await waitForSideQuickTabView(page, 'roster', 'roster');
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.rosterPanel?.tab === 'enemy',
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const returnedRosterProbe = await readLargeRosterHudProbe(page);
|
|
assert(
|
|
returnedRosterProbe.roster?.tab === 'enemy' && returnedRosterProbe.unitDetailPanel === null && returnedRosterProbe.phase === 'idle',
|
|
`Expected the shared unit-detail return to restore the matching roster: ${JSON.stringify(returnedRosterProbe)}`
|
|
);
|
|
assertCompletedSidePanelTransition(returnedRosterProbe, 'back', 'roster');
|
|
|
|
const bondFixture = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
if (!scene) {
|
|
return null;
|
|
}
|
|
const expectedBondIds = scene.activeSortieBonds?.().map((bond) => bond.id) ?? [];
|
|
scene.bondPage = 0;
|
|
scene.renderBondPanel?.();
|
|
return { expectedBondIds };
|
|
});
|
|
assert(
|
|
bondFixture?.expectedBondIds?.length > 4,
|
|
`Expected the exact eight-officer final sortie to exercise paged active bonds: ${JSON.stringify(bondFixture)}`
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.bondPanel?.page === 0,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const firstBondProbe = await readLargeRosterHudProbe(page);
|
|
const bondPageCount = firstBondProbe.bondPanel?.pageCount ?? 0;
|
|
assert(
|
|
bondPageCount > 1 && firstBondProbe.bondPanel?.totalCount === bondFixture.expectedBondIds.length,
|
|
`Expected paged final-battle bond debug state to match the active sortie bonds: ${JSON.stringify({ bondFixture, bondPanel: firstBondProbe.bondPanel })}`
|
|
);
|
|
const visibleBondIds = [];
|
|
for (let pageIndex = 0; pageIndex < bondPageCount; pageIndex += 1) {
|
|
if (pageIndex > 0) {
|
|
await page.evaluate((targetPage) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
if (!scene) {
|
|
return;
|
|
}
|
|
scene.bondPage = targetPage;
|
|
scene.renderBondPanel?.();
|
|
}, pageIndex);
|
|
await page.waitForFunction(
|
|
(targetPage) => window.__HEROS_DEBUG__?.battle()?.battleHud?.bondPanel?.page === targetPage,
|
|
pageIndex,
|
|
{ timeout: 30000 }
|
|
);
|
|
}
|
|
const bondProbe = pageIndex === 0 ? firstBondProbe : await readLargeRosterHudProbe(page);
|
|
assertBondPanelLayout(bondProbe, pageIndex, bondPageCount, bondFixture.expectedBondIds.length);
|
|
visibleBondIds.push(...bondProbe.bondPanel.visibleBondIds);
|
|
}
|
|
assert(
|
|
visibleBondIds.length === bondFixture.expectedBondIds.length &&
|
|
new Set(visibleBondIds).size === visibleBondIds.length &&
|
|
sameJsonValue([...visibleBondIds].sort(), [...bondFixture.expectedBondIds].sort()),
|
|
`Expected every active final-battle bond exactly once across all pages: ${JSON.stringify({ expected: bondFixture.expectedBondIds, actual: visibleBondIds })}`
|
|
);
|
|
await waitForBattleHudPaint(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-bond-panel.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'final battle bond panel');
|
|
|
|
const expectedThreatIds = [
|
|
'northern-twelfth-scout-e',
|
|
'northern-twelfth-camp-guard-e',
|
|
'northern-twelfth-strategist-c',
|
|
'northern-twelfth-cavalry-e'
|
|
];
|
|
const threatFixture = await page.evaluate((enemyIds) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const enemies = enemyIds.map((unitId) => scene?.debugUnitById?.(unitId)).filter(Boolean);
|
|
if (!scene || enemies.length !== enemyIds.length) {
|
|
return null;
|
|
}
|
|
scene.renderThreatDetail?.({ x: 38, y: 96, enemies, strongestBehavior: 'aggressive' });
|
|
return { enemyIds: enemies.map((enemy) => enemy.id), enemyNames: enemies.map((enemy) => enemy.name) };
|
|
}, expectedThreatIds);
|
|
assert(
|
|
sameJsonValue(threatFixture?.enemyIds, expectedThreatIds) && threatFixture.enemyNames.every((name) => name.length >= 8),
|
|
`Expected the final-battle threat fixture to use four real long-name enemies: ${JSON.stringify(threatFixture)}`
|
|
);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.threatPanel?.enemyRows?.length === 4,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
const threatProbe = await readLargeRosterHudProbe(page);
|
|
assertThreatPanelLayout(threatProbe, expectedThreatIds);
|
|
assertCompletedSidePanelTransition(threatProbe, 'detail', 'threat-detail');
|
|
await waitForBattleHudPaint(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-threat-panel.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'final battle threat panel');
|
|
|
|
await page.mouse.click(
|
|
threatProbe.threatPanel.returnButtonBounds.x + threatProbe.threatPanel.returnButtonBounds.width / 2,
|
|
threatProbe.threatPanel.returnButtonBounds.y + threatProbe.threatPanel.returnButtonBounds.height / 2
|
|
);
|
|
await waitForSideQuickTabView(page, 'threat', 'threat-overview');
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.markerCount > 0, undefined, { timeout: 30000 });
|
|
const returnedThreatProbe = await readLargeRosterHudProbe(page);
|
|
assert(
|
|
returnedThreatProbe.threatPanel === null && returnedThreatProbe.markerCount > 0,
|
|
`Expected the shared threat return to restore the map range overview: ${JSON.stringify(returnedThreatProbe)}`
|
|
);
|
|
assertCompletedSidePanelTransition(returnedThreatProbe, 'back', 'threat-overview');
|
|
|
|
const terrainObjectiveFixture = await page.evaluate(() => {
|
|
const battleState = window.__HEROS_DEBUG__?.battle?.();
|
|
const objective = battleState?.objectiveDefinitions?.find((candidate) => candidate.id === 'preserve-wuzhang-camps');
|
|
if (!objective?.targetTile || !objective.terrain) {
|
|
return null;
|
|
}
|
|
window.__HEROS_GAME__
|
|
?.scene.getScene('BattleScene')
|
|
?.renderTerrainDetail?.(objective.targetTile.x, objective.targetTile.y);
|
|
return objective;
|
|
});
|
|
assert(
|
|
terrainObjectiveFixture?.kind === 'secure-terrain' &&
|
|
terrainObjectiveFixture.terrain === 'camp' &&
|
|
Number.isInteger(terrainObjectiveFixture.targetTile?.x) &&
|
|
Number.isInteger(terrainObjectiveFixture.targetTile?.y),
|
|
`Expected final battle debug data to expose the Wuzhang camp objective contract: ${JSON.stringify(terrainObjectiveFixture)}`
|
|
);
|
|
await page.waitForFunction(
|
|
(targetTile) => {
|
|
const panel = window.__HEROS_DEBUG__?.battle()?.battleHud?.terrainPanel;
|
|
return panel?.tile?.x === targetTile.x && panel?.tile?.y === targetTile.y;
|
|
},
|
|
terrainObjectiveFixture.targetTile,
|
|
{ timeout: 30000 }
|
|
);
|
|
const terrainProbe = await readLargeRosterHudProbe(page);
|
|
assertTerrainPanelLayout(terrainProbe, terrainObjectiveFixture);
|
|
assertCompletedSidePanelTransition(terrainProbe, 'detail', 'terrain-detail');
|
|
await waitForBattleHudPaint(page);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-terrain-panel.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'final battle terrain panel');
|
|
|
|
await page.keyboard.press('Escape');
|
|
await waitForSideQuickTabView(page, 'situation', 'situation');
|
|
const returnedSituationProbe = await readLargeRosterHudProbe(page);
|
|
assert(
|
|
returnedSituationProbe.terrainPanel === null && returnedSituationProbe.miniMap?.visible === true,
|
|
`Expected the shared terrain return to restore the situation overview: ${JSON.stringify(returnedSituationProbe)}`
|
|
);
|
|
assertCompletedSidePanelTransition(returnedSituationProbe, 'back', 'situation');
|
|
}
|
|
|
|
async function waitForBattleHudPaint(page) {
|
|
await page.waitForFunction(() => {
|
|
const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition;
|
|
return !transition || (!transition.active && transition.completedRevision === transition.revision);
|
|
}, undefined, { timeout: 30000 });
|
|
await page.evaluate(() => new Promise((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
}));
|
|
}
|
|
|
|
async function readLargeRosterHudProbe(page) {
|
|
await page.waitForFunction(() => {
|
|
const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition;
|
|
return !transition || (!transition.active && transition.completedRevision === transition.revision);
|
|
}, undefined, { timeout: 30000 });
|
|
return page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
const hud = battle?.battleHud;
|
|
return {
|
|
roster: hud?.rosterPanel ?? null,
|
|
recentActions: hud?.recentActions ?? null,
|
|
miniMap: hud?.miniMap ?? null,
|
|
header: hud?.header ?? null,
|
|
sidebar: hud?.sidebar ?? null,
|
|
deploymentPanel: hud?.deploymentPanel ?? null,
|
|
unitDetailPanel: hud?.unitDetailPanel ?? null,
|
|
bondPanel: hud?.bondPanel ?? null,
|
|
threatPanel: hud?.threatPanel ?? null,
|
|
terrainPanel: hud?.terrainPanel ?? null,
|
|
quickTabs: hud?.quickTabs ?? null,
|
|
panelTransition: hud?.panelTransition ?? null,
|
|
markerCount: battle?.markerCount ?? 0,
|
|
phase: battle?.phase ?? null,
|
|
selectedUnitId: hud?.miniMap?.selectedUnitId ?? null,
|
|
sceneBounds: scene
|
|
? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height }
|
|
: null,
|
|
panelBounds: scene
|
|
? { x: scene.layout.panelX, y: scene.layout.panelY, width: scene.layout.panelWidth, height: scene.layout.panelHeight }
|
|
: null
|
|
};
|
|
});
|
|
}
|
|
|
|
function assertSideQuickTabsLayout(probe, expectedActiveTab, expectedView, expectedVisible = true) {
|
|
const quickTabs = probe.quickTabs;
|
|
if (!expectedVisible) {
|
|
assert(
|
|
quickTabs?.visible === false && quickTabs.tabs?.length === 0 && quickTabs.bounds === null,
|
|
`Expected deployment to omit side quick tabs: ${JSON.stringify(probe)}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const tabs = quickTabs?.tabs ?? [];
|
|
const activeTabs = tabs.filter((tab) => tab.active);
|
|
assert(
|
|
quickTabs?.visible === true &&
|
|
quickTabs.enabled === true &&
|
|
quickTabs.activeTab === expectedActiveTab &&
|
|
quickTabs.currentView === expectedView &&
|
|
tabs.length === 4 &&
|
|
sameJsonValue(tabs.map((tab) => tab.id), ['situation', 'roster', 'bond', 'threat']) &&
|
|
sameJsonValue(tabs.map((tab) => tab.label), ['전황', '부대', '공명', '위협']) &&
|
|
sameJsonValue(tabs.map((tab) => tab.shortcut), ['1', '2', '3', '4']) &&
|
|
activeTabs.length === 1 &&
|
|
activeTabs[0].id === expectedActiveTab &&
|
|
activeTabs[0].visualState === 'active',
|
|
`Expected one active 1-4 quick tab for ${expectedView}: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
isFiniteBounds(quickTabs.bounds) &&
|
|
boundsInside(quickTabs.bounds, probe.panelBounds) &&
|
|
boundsInside(quickTabs.bounds, probe.sceneBounds) &&
|
|
probe.header.sortieOrderBounds.y + probe.header.sortieOrderBounds.height <= quickTabs.bounds.y + 1 &&
|
|
quickTabs.bounds.y + quickTabs.bounds.height <= quickTabs.bodyTop + 1 &&
|
|
quickTabs.bodyTop === probe.sidebar.bodyTop &&
|
|
probe.sidebar.bounds.y >= quickTabs.bodyTop,
|
|
`Expected quick tabs between the FHD header and side-panel body: ${JSON.stringify(probe)}`
|
|
);
|
|
tabs.forEach((tab) => {
|
|
assert(
|
|
boundsInside(tab.bounds, quickTabs.bounds) &&
|
|
boundsInside(tab.labelBounds, tab.bounds) &&
|
|
boundsInside(tab.shortcutBounds, tab.bounds),
|
|
`Expected quick-tab content inside its FHD button: ${JSON.stringify(tab)}`
|
|
);
|
|
});
|
|
assertHorizontalBounds(tabs.map((tab) => tab.bounds), `${expectedView} quick tabs`);
|
|
}
|
|
|
|
function assertCompletedSidePanelTransition(probe, expectedTrigger, expectedView) {
|
|
const transition = probe.panelTransition;
|
|
assert(
|
|
transition?.active === false &&
|
|
transition.completedRevision === transition.revision &&
|
|
transition.revision > 0 &&
|
|
transition.last?.kind === 'fade-slide' &&
|
|
transition.last.trigger === expectedTrigger &&
|
|
transition.last.to === expectedView &&
|
|
transition.last.durationMs >= 100 &&
|
|
transition.last.durationMs <= 240 &&
|
|
transition.last.offsetX > 0 &&
|
|
transition.last.objectCount > 0,
|
|
`Expected a completed side-panel transition into ${expectedView}: ${JSON.stringify(probe)}`
|
|
);
|
|
}
|
|
|
|
function assertLateBattleHeaderLayout(probe, label) {
|
|
const { header, panelBounds, sceneBounds } = probe;
|
|
const headerBounds = [
|
|
header?.titleBounds,
|
|
header?.turnBounds,
|
|
header?.speedBounds,
|
|
header?.objectiveBounds,
|
|
header?.defeatBounds,
|
|
header?.sortieOrderBounds
|
|
];
|
|
assert(
|
|
isFiniteBounds(panelBounds) &&
|
|
isFiniteBounds(sceneBounds) &&
|
|
boundsInside(panelBounds, sceneBounds) &&
|
|
headerBounds.every((bounds) => boundsInside(bounds, panelBounds) && boundsInside(bounds, sceneBounds)),
|
|
`Expected every late-battle ${label} header element inside the FHD side panel and scene: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
header.titleBounds.y + header.titleBounds.height <= Math.min(header.turnBounds.y, header.speedBounds.y) &&
|
|
header.turnBounds.x + header.turnBounds.width <= header.speedBounds.x &&
|
|
Math.max(
|
|
header.turnBounds.y + header.turnBounds.height,
|
|
header.speedBounds.y + header.speedBounds.height
|
|
) <= header.objectiveBounds.y &&
|
|
header.objectiveBounds.y + header.objectiveBounds.height <= header.defeatBounds.y &&
|
|
header.defeatBounds.y + header.defeatBounds.height <= header.sortieOrderBounds.y &&
|
|
header.sortieOrderBounds.y + header.sortieOrderBounds.height <= header.contentTop,
|
|
`Expected the wrapped late-battle ${label} objective, defeat condition, and sortie order to remain separated: ${JSON.stringify(header)}`
|
|
);
|
|
}
|
|
|
|
function assertLateBattleSidebarLayout(probe, label, expectMiniMap) {
|
|
const { sidebar, miniMap, header, panelBounds, sceneBounds } = probe;
|
|
assert(
|
|
isFiniteBounds(sidebar?.bounds) &&
|
|
boundsInside(sidebar.bounds, panelBounds) &&
|
|
boundsInside(sidebar.bounds, sceneBounds) &&
|
|
sidebar.contentTop === header?.contentTop &&
|
|
sidebar.bounds.y >= sidebar.contentTop &&
|
|
sidebar.bounds.y + sidebar.bounds.height <= sidebar.contentBottom,
|
|
`Expected the late-battle ${label} sidebar inside its FHD content region: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
header.sortieOrderBounds.y + header.sortieOrderBounds.height <= sidebar.bounds.y,
|
|
`Expected the late-battle ${label} sidebar below the sortie-order header: ${JSON.stringify(probe)}`
|
|
);
|
|
if (!expectMiniMap) {
|
|
assert(miniMap?.visible === false, `Expected late-battle ${label} to reserve the full sidebar by hiding the minimap: ${JSON.stringify(probe)}`);
|
|
return;
|
|
}
|
|
assert(
|
|
miniMap?.visible === true &&
|
|
boundsInside(miniMap.frameBounds, panelBounds) &&
|
|
boundsInside(miniMap.frameBounds, sceneBounds) &&
|
|
sidebar.bounds.y + sidebar.bounds.height <= miniMap.frameBounds.y,
|
|
`Expected the late-battle ${label} sidebar to stay above the FHD minimap: ${JSON.stringify(probe)}`
|
|
);
|
|
}
|
|
|
|
function assertLateBattleDeploymentLayout(probe) {
|
|
const { deploymentPanel, sidebar, panelBounds, sceneBounds } = probe;
|
|
const roleBounds = deploymentPanel?.roleBounds ?? [];
|
|
const deploymentBounds = [
|
|
deploymentPanel?.headerBounds,
|
|
deploymentPanel?.noticeBounds,
|
|
...roleBounds,
|
|
deploymentPanel?.restoreButtonBounds,
|
|
deploymentPanel?.startButtonBounds
|
|
];
|
|
assert(
|
|
roleBounds.length > 0 && deploymentBounds.every((bounds) =>
|
|
boundsInside(bounds, sidebar?.bounds) &&
|
|
boundsInside(bounds, panelBounds) &&
|
|
boundsInside(bounds, sceneBounds)
|
|
) && boundsInside(deploymentPanel?.noticeTextBounds, deploymentPanel?.noticeBounds),
|
|
`Expected every late-battle deployment section inside the FHD sidebar and scene: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
deploymentPanel.headerBounds.y + deploymentPanel.headerBounds.height <= deploymentPanel.noticeBounds.y &&
|
|
deploymentPanel.noticeBounds.y + deploymentPanel.noticeBounds.height <= roleBounds[0].y &&
|
|
roleBounds.every((bounds, index) =>
|
|
index === 0 || roleBounds[index - 1].y + roleBounds[index - 1].height <= bounds.y
|
|
) &&
|
|
roleBounds.at(-1).y + roleBounds.at(-1).height <= deploymentPanel.restoreButtonBounds.y &&
|
|
deploymentPanel.restoreButtonBounds.y + deploymentPanel.restoreButtonBounds.height <= deploymentPanel.startButtonBounds.y,
|
|
`Expected the late-battle deployment header, notice, roles, and actions not to overlap: ${JSON.stringify(deploymentPanel)}`
|
|
);
|
|
}
|
|
|
|
function assertUnitDetailPanelLayout(probe, expectedUnitId, label) {
|
|
const panel = probe.unitDetailPanel;
|
|
assertLateBattleSidebarLayout(probe, `${label} unit detail`, false);
|
|
assertSideQuickTabsLayout(probe, 'roster', 'officer-detail');
|
|
assert(
|
|
panel?.unitId === expectedUnitId &&
|
|
panel.summaryBounds?.length === 3 &&
|
|
panel.statBounds?.length === 5 &&
|
|
panel.effectBounds?.length > 0 &&
|
|
panel.effectBounds.length <= 3 &&
|
|
panel.equipmentBounds?.length === 3 &&
|
|
isFiniteBounds(panel.messageBounds),
|
|
`Expected complete FHD ${label} unit-detail sections: ${JSON.stringify(probe)}`
|
|
);
|
|
|
|
const allBounds = [
|
|
panel.headerBounds,
|
|
panel.nameBounds,
|
|
panel.levelBadgeBounds,
|
|
panel.returnButtonBounds,
|
|
panel.hpBounds,
|
|
...panel.summaryBounds,
|
|
...panel.statBounds,
|
|
panel.statusBounds,
|
|
...panel.effectBounds,
|
|
...panel.equipmentBounds,
|
|
panel.messageBounds
|
|
];
|
|
assertDetailBoundsInside(probe, allBounds, `${label} unit detail`);
|
|
assert(
|
|
boundsInside(panel.nameBounds, panel.headerBounds) &&
|
|
boundsInside(panel.levelBadgeBounds, panel.headerBounds) &&
|
|
boundsInside(panel.returnButtonBounds, panel.headerBounds) &&
|
|
panel.returnTarget === 'roster' &&
|
|
panel.nameBounds.x + panel.nameBounds.width <= panel.levelBadgeBounds.x + 1,
|
|
`Expected the FHD ${label} unit name to remain inside the header and left of the level badge: ${JSON.stringify(panel)}`
|
|
);
|
|
assertHorizontalBounds(panel.summaryBounds, `${label} unit summary cards`);
|
|
assertHorizontalBounds(panel.effectBounds, `${label} unit effect cards`);
|
|
assertSequentialBounds(panel.statBounds, `${label} unit stat rows`);
|
|
assertSequentialBounds(panel.equipmentBounds, `${label} unit equipment rows`);
|
|
|
|
const sectionBounds = [
|
|
panel.headerBounds,
|
|
panel.hpBounds,
|
|
boundsUnion(panel.summaryBounds),
|
|
boundsUnion(panel.statBounds),
|
|
panel.statusBounds,
|
|
boundsUnion(panel.effectBounds),
|
|
boundsUnion(panel.equipmentBounds),
|
|
panel.messageBounds
|
|
];
|
|
assertSequentialBounds(sectionBounds, `${label} unit-detail sections`);
|
|
}
|
|
|
|
function assertBondPanelLayout(probe, expectedPage, expectedPageCount, expectedTotalCount) {
|
|
const panel = probe.bondPanel;
|
|
assertLateBattleSidebarLayout(probe, `bond page ${expectedPage + 1}`, false);
|
|
assertSideQuickTabsLayout(probe, 'bond', 'bond');
|
|
const expectedVisibleCount = Math.min(
|
|
panel?.rowsPerPage ?? 0,
|
|
Math.max(0, expectedTotalCount - expectedPage * (panel?.rowsPerPage ?? 0))
|
|
);
|
|
assert(
|
|
panel?.page === expectedPage &&
|
|
panel.pageCount === expectedPageCount &&
|
|
panel.totalCount === expectedTotalCount &&
|
|
panel.rowsPerPage > 0 &&
|
|
panel.visibleBondIds?.length === expectedVisibleCount &&
|
|
panel.rowLayouts?.length === expectedVisibleCount &&
|
|
new Set(panel.visibleBondIds).size === panel.visibleBondIds.length &&
|
|
sameJsonValue(panel.rowLayouts.map((row) => row.bondId), panel.visibleBondIds) &&
|
|
isFiniteBounds(panel.navigationBounds) &&
|
|
isFiniteBounds(panel.messageBounds),
|
|
`Expected complete paged FHD bond state on page ${expectedPage + 1}: ${JSON.stringify(probe)}`
|
|
);
|
|
|
|
const rowBounds = panel.rowLayouts.map((row) => row.rowBounds);
|
|
const allBounds = [
|
|
panel.headerBounds,
|
|
...rowBounds,
|
|
...panel.rowLayouts.flatMap((row) => [row.nameBounds, row.levelBounds, row.titleBounds, row.gaugeBounds]),
|
|
panel.navigationBounds,
|
|
panel.messageBounds
|
|
];
|
|
assertDetailBoundsInside(probe, allBounds, `bond page ${expectedPage + 1}`);
|
|
panel.rowLayouts.forEach((row) => {
|
|
assert(
|
|
boundsInside(row.nameBounds, row.rowBounds) &&
|
|
boundsInside(row.levelBounds, row.rowBounds) &&
|
|
boundsInside(row.titleBounds, row.rowBounds) &&
|
|
boundsInside(row.gaugeBounds, row.rowBounds) &&
|
|
row.nameBounds.x + row.nameBounds.width <= row.levelBounds.x + 1 &&
|
|
row.titleBounds.x + row.titleBounds.width <= row.gaugeBounds.x + 1,
|
|
`Expected bond ${row.bondId} names, levels, titles, and gauges in separate FHD columns: ${JSON.stringify(row)}`
|
|
);
|
|
});
|
|
assertSequentialBounds(rowBounds, `bond rows on page ${expectedPage + 1}`);
|
|
assertSequentialBounds(
|
|
[panel.headerBounds, ...rowBounds, panel.navigationBounds, panel.messageBounds],
|
|
`bond sections on page ${expectedPage + 1}`
|
|
);
|
|
}
|
|
|
|
function assertThreatPanelLayout(probe, expectedEnemyIds) {
|
|
const panel = probe.threatPanel;
|
|
assertLateBattleSidebarLayout(probe, 'threat detail', true);
|
|
assertSideQuickTabsLayout(probe, 'threat', 'threat-detail');
|
|
assert(
|
|
panel?.tile?.x === 38 &&
|
|
panel.tile.y === 96 &&
|
|
panel.summaryRows?.length === 4 &&
|
|
panel.enemyRows?.length === expectedEnemyIds.length &&
|
|
sameJsonValue(panel.enemyRows.map((row) => row.unitId), expectedEnemyIds) &&
|
|
isFiniteBounds(panel.messageBounds),
|
|
`Expected the four-enemy FHD threat fixture at the final-camp tile: ${JSON.stringify(probe)}`
|
|
);
|
|
const summaryBounds = panel.summaryRows.map((row) => row.bounds);
|
|
const enemyBounds = panel.enemyRows.map((row) => row.rowBounds);
|
|
const allBounds = [
|
|
panel.headerBounds,
|
|
panel.returnButtonBounds,
|
|
...summaryBounds,
|
|
...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]),
|
|
...enemyBounds,
|
|
...panel.enemyRows.flatMap((row) => [row.nameBounds, row.valueBounds]),
|
|
panel.messageBounds
|
|
];
|
|
assertDetailBoundsInside(probe, allBounds, 'threat detail');
|
|
assert(
|
|
panel.returnTarget === 'threat-overview' && boundsInside(panel.returnButtonBounds, panel.headerBounds),
|
|
`Expected threat detail to expose an in-header range return: ${JSON.stringify(panel)}`
|
|
);
|
|
assertSituationGrid(panel.summaryRows, 'threat summary', 2);
|
|
panel.enemyRows.forEach((row) => {
|
|
assert(
|
|
boundsInside(row.nameBounds, row.rowBounds) &&
|
|
boundsInside(row.valueBounds, row.rowBounds) &&
|
|
!boundsOverlap(row.nameBounds, row.valueBounds, 2),
|
|
`Expected threat names and metrics on separate readable lines for ${row.unitId}: ${JSON.stringify(row)}`
|
|
);
|
|
});
|
|
assertSequentialBounds(enemyBounds, 'threat enemy rows');
|
|
assertSequentialBounds(
|
|
[panel.headerBounds, boundsUnion(summaryBounds), ...enemyBounds, panel.messageBounds],
|
|
'threat detail sections'
|
|
);
|
|
assert(
|
|
panel.messageBounds.y + panel.messageBounds.height <= probe.miniMap.frameBounds.y + 1,
|
|
`Expected the threat guidance above the FHD minimap: ${JSON.stringify(probe)}`
|
|
);
|
|
}
|
|
|
|
function assertTerrainPanelLayout(probe, expectedObjective) {
|
|
const panel = probe.terrainPanel;
|
|
const expectedTerrain = expectedObjective.terrain;
|
|
const expectedTile = expectedObjective.targetTile;
|
|
assertLateBattleSidebarLayout(probe, 'terrain detail', true);
|
|
assertSideQuickTabsLayout(probe, 'situation', 'terrain-detail');
|
|
assert(
|
|
panel?.tile?.x === expectedTile.x &&
|
|
panel.tile.y === expectedTile.y &&
|
|
panel.terrain === expectedTerrain &&
|
|
panel.summaryRows?.length === 6 &&
|
|
isFiniteBounds(panel.messageBounds),
|
|
`Expected complete FHD ${expectedTerrain} terrain details at ${expectedObjective.id}: ${JSON.stringify(probe)}`
|
|
);
|
|
const summaryBounds = panel.summaryRows.map((row) => row.bounds);
|
|
const allBounds = [
|
|
panel.headerBounds,
|
|
panel.returnButtonBounds,
|
|
...summaryBounds,
|
|
...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]),
|
|
panel.messageBounds
|
|
];
|
|
assertDetailBoundsInside(probe, allBounds, 'terrain detail');
|
|
assert(
|
|
panel.returnTarget === 'situation' && boundsInside(panel.returnButtonBounds, panel.headerBounds),
|
|
`Expected terrain detail to expose an in-header situation return: ${JSON.stringify(panel)}`
|
|
);
|
|
assertSituationRows(panel.summaryRows, 'terrain summary');
|
|
assertSequentialBounds([panel.headerBounds, ...summaryBounds, panel.messageBounds], 'terrain detail sections');
|
|
assert(
|
|
panel.messageBounds.y + panel.messageBounds.height <= probe.miniMap.frameBounds.y + 1,
|
|
`Expected the terrain guidance above the FHD minimap: ${JSON.stringify(probe)}`
|
|
);
|
|
}
|
|
|
|
function assertSituationRows(rows, label) {
|
|
rows.forEach((row, index) => {
|
|
assert(
|
|
boundsInside(row.labelBounds, row.bounds) &&
|
|
boundsInside(row.valueBounds, row.bounds) &&
|
|
row.labelBounds.x + row.labelBounds.width <= row.valueBounds.x + 1,
|
|
`Expected ${label} row ${index + 1} labels and values in separate FHD columns: ${JSON.stringify(row)}`
|
|
);
|
|
});
|
|
assertSequentialBounds(rows.map((row) => row.bounds), `${label} rows`);
|
|
}
|
|
|
|
function assertSituationGrid(rows, label, columnCount) {
|
|
rows.forEach((row, index) => {
|
|
assert(
|
|
boundsInside(row.labelBounds, row.bounds) &&
|
|
boundsInside(row.valueBounds, row.bounds) &&
|
|
row.labelBounds.x + row.labelBounds.width <= row.valueBounds.x + 1,
|
|
`Expected ${label} cell ${index + 1} labels and values in separate columns: ${JSON.stringify(row)}`
|
|
);
|
|
});
|
|
const rowGroups = [];
|
|
for (let index = 0; index < rows.length; index += columnCount) {
|
|
const rowBounds = rows.slice(index, index + columnCount).map((row) => row.bounds);
|
|
assertHorizontalBounds(rowBounds, `${label} row ${rowGroups.length + 1}`);
|
|
rowGroups.push(boundsUnion(rowBounds));
|
|
}
|
|
assertSequentialBounds(rowGroups, `${label} rows`);
|
|
}
|
|
|
|
function assertDetailBoundsInside(probe, boundsList, label) {
|
|
assert(
|
|
isFiniteBounds(probe.sidebar?.bounds) &&
|
|
isFiniteBounds(probe.panelBounds) &&
|
|
isFiniteBounds(probe.sceneBounds) &&
|
|
boundsList.length > 0 &&
|
|
boundsList.every((bounds) =>
|
|
boundsInside(bounds, probe.sidebar.bounds) &&
|
|
boundsInside(bounds, probe.panelBounds) &&
|
|
boundsInside(bounds, probe.sceneBounds)
|
|
),
|
|
`Expected every ${label} bound inside the sidebar, panel, and FHD scene: ${JSON.stringify({ boundsList, probe })}`
|
|
);
|
|
}
|
|
|
|
function assertSequentialBounds(boundsList, label) {
|
|
assert(
|
|
boundsList.length > 0 &&
|
|
boundsList.every(isFiniteBounds) &&
|
|
boundsList.every((bounds, index) =>
|
|
index === 0 || boundsList[index - 1].y + boundsList[index - 1].height <= bounds.y + 1
|
|
),
|
|
`Expected non-overlapping sequential ${label}: ${JSON.stringify(boundsList)}`
|
|
);
|
|
}
|
|
|
|
function assertHorizontalBounds(boundsList, label) {
|
|
const ordered = [...boundsList].sort((left, right) => left.x - right.x);
|
|
assert(
|
|
ordered.length > 0 &&
|
|
ordered.every(isFiniteBounds) &&
|
|
ordered.every((bounds, index) =>
|
|
index === 0 || ordered[index - 1].x + ordered[index - 1].width <= bounds.x + 1
|
|
),
|
|
`Expected non-overlapping horizontal ${label}: ${JSON.stringify(ordered)}`
|
|
);
|
|
}
|
|
|
|
function boundsUnion(boundsList) {
|
|
const left = Math.min(...boundsList.map((bounds) => bounds.x));
|
|
const top = Math.min(...boundsList.map((bounds) => bounds.y));
|
|
const right = Math.max(...boundsList.map((bounds) => bounds.x + bounds.width));
|
|
const bottom = Math.max(...boundsList.map((bounds) => bounds.y + bounds.height));
|
|
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
}
|
|
|
|
function assertLargeRosterPageLayout(probe, label) {
|
|
const { roster, panelBounds } = probe;
|
|
assert(boundsInside(roster.listBounds, panelBounds), `Expected roster rows on page ${label} inside the side panel: ${JSON.stringify(probe)}`);
|
|
assert(boundsInside(roster.navigationBounds, panelBounds), `Expected roster navigation on page ${label} inside the side panel: ${JSON.stringify(probe)}`);
|
|
assert(boundsInside(roster.messageBounds, panelBounds), `Expected roster guidance on page ${label} inside the side panel: ${JSON.stringify(probe)}`);
|
|
assert(
|
|
roster.rowLayouts.length === roster.visibleUnitIds.length &&
|
|
roster.rowLayouts.every((row) =>
|
|
boundsInside(row.rowBounds, roster.listBounds) &&
|
|
boundsInside(row.nameBounds, row.rowBounds) &&
|
|
boundsInside(row.metaBounds, row.rowBounds) &&
|
|
boundsInside(row.hpBounds, row.rowBounds) &&
|
|
boundsInside(row.gaugeBounds, row.rowBounds) &&
|
|
row.nameBounds.x + row.nameBounds.width <= row.hpBounds.x &&
|
|
row.metaBounds.x + row.metaBounds.width <= row.gaugeBounds.x
|
|
),
|
|
`Expected roster page ${label} names, classes, HP, and gauges to remain in separate columns: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
roster.listBounds.y + roster.listBounds.height <= roster.navigationBounds.y &&
|
|
roster.navigationBounds.y + roster.navigationBounds.height <= roster.messageBounds.y &&
|
|
roster.gapToMiniMap >= 6,
|
|
`Expected roster page ${label} content to stay vertically separated: ${JSON.stringify(probe)}`
|
|
);
|
|
}
|
|
|
|
async function waitForStoryReady(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
|
|
return activeScenes.includes('StoryScene') && story?.ready === true;
|
|
}, undefined, { timeout: 90000 });
|
|
}
|
|
|
|
async function assertFreshStoryAssetStreaming(page, requestedResourceUrls, requestStartIndex) {
|
|
let stableInitialBackground = null;
|
|
for (let sampleIndex = 0; sampleIndex < 8; sampleIndex += 1) {
|
|
const probe = await readStoryAssetStreamingProbe(page);
|
|
assertStoryAssetStreamingProbe(probe, 0, 'fresh story page 1');
|
|
stableInitialBackground ??= probe.story.background;
|
|
assert(
|
|
probe.story.background === stableInitialBackground,
|
|
`Expected repeated reads of the same story page to retain one deterministic background: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
storyStreamingIndexes(probe.story.assetStreaming).every((pageIndex) => pageIndex <= 1),
|
|
`Expected fresh story loading to stay within pages 1-2 before input: ${JSON.stringify(probe)}`
|
|
);
|
|
await page.waitForTimeout(75);
|
|
}
|
|
|
|
let probe = await advanceOneStoryPage(page, 1);
|
|
assertStoryAssetStreamingProbe(probe, 1, 'fresh story page 2');
|
|
probe = await advanceOneStoryPage(page, 2);
|
|
assertStoryAssetStreamingProbe(probe, 2, 'fresh story page 3');
|
|
|
|
while ((probe.story?.cutsceneActors?.length ?? 0) === 0 && probe.story?.pageIndex < probe.story?.totalPages - 1) {
|
|
const targetPageIndex = probe.story.pageIndex + 1;
|
|
probe = await advanceOneStoryPage(page, targetPageIndex);
|
|
assertStoryAssetStreamingProbe(probe, targetPageIndex, `fresh story page ${targetPageIndex + 1}`);
|
|
}
|
|
|
|
assert(
|
|
(probe.story?.cutsceneActors?.length ?? 0) > 0 && probe.expectedUnitBaseTextureKeys.length > 0,
|
|
`Expected the fresh story regression to reach a cutscene that uses unit base sheets: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
sameJsonValue(
|
|
[...probe.story.assetStreaming.currentUnitBaseTexturesLoaded].sort(),
|
|
[...probe.expectedUnitBaseTextureKeys].sort()
|
|
),
|
|
`Expected every current cutscene unit base sheet to be loaded: ${JSON.stringify(probe)}`
|
|
);
|
|
|
|
const repeatedSortieBackgrounds = new Set();
|
|
while (probe.story?.pageIndex < probe.story?.totalPages - 1) {
|
|
const targetPageIndex = probe.story.pageIndex + 1;
|
|
probe = await advanceOneStoryPage(page, targetPageIndex);
|
|
assertStoryAssetStreamingProbe(probe, targetPageIndex, `fresh story page ${targetPageIndex + 1}`);
|
|
if (probe.story.requestedBackground === 'story-sortie') {
|
|
repeatedSortieBackgrounds.add(probe.story.background);
|
|
}
|
|
}
|
|
assert(
|
|
repeatedSortieBackgrounds.size >= 2,
|
|
`Expected repeated sortie story pages to distribute deterministic background variants: ${JSON.stringify([
|
|
...repeatedSortieBackgrounds
|
|
])}`
|
|
);
|
|
|
|
const actionRequests = requestedResourceUrls
|
|
.slice(requestStartIndex)
|
|
.filter((url) => /\/unit-[^/?]+-actions(?:-[^/?]+)?\.(?:png|webp)(?:[?#]|$)/i.test(decodeURIComponent(url)));
|
|
assert(
|
|
actionRequests.length === 0,
|
|
`Expected story streaming not to request battle action sheets: ${JSON.stringify(actionRequests)}`
|
|
);
|
|
}
|
|
|
|
async function advanceOneStoryPage(page, expectedPageIndex) {
|
|
const previousPageIndex = expectedPageIndex - 1;
|
|
const before = await readStoryAssetStreamingProbe(page);
|
|
assert(
|
|
before.story?.pageIndex === previousPageIndex,
|
|
`Expected story page ${previousPageIndex + 1} before advancing once: ${JSON.stringify(before)}`
|
|
);
|
|
|
|
await page.keyboard.press('Space');
|
|
await page.waitForFunction((targetPageIndex) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('StoryScene');
|
|
const story = scene?.getDebugState?.();
|
|
return (
|
|
story?.pageIndex === targetPageIndex &&
|
|
story?.assetStreaming?.currentPageReady === true &&
|
|
story?.backgroundReady === true &&
|
|
story?.activeTexture === story?.background &&
|
|
scene?.transitioning === false
|
|
);
|
|
}, expectedPageIndex, { timeout: 90000 });
|
|
|
|
const after = await readStoryAssetStreamingProbe(page);
|
|
assert(
|
|
after.story?.pageIndex === previousPageIndex + 1,
|
|
`Expected exactly one story page advance without skips: ${JSON.stringify(after)}`
|
|
);
|
|
return after;
|
|
}
|
|
|
|
async function readStoryAssetStreamingProbe(page) {
|
|
return page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('StoryScene');
|
|
const story = scene?.getDebugState?.() ?? null;
|
|
const plan = story && scene?.storyPageAssetPlan?.(story.pageIndex);
|
|
const textureKeys = scene?.textures?.list ? Object.keys(scene.textures.list) : [];
|
|
return {
|
|
viewport: {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
devicePixelRatio: window.devicePixelRatio
|
|
},
|
|
story,
|
|
expectedUnitBaseTextureKeys: plan?.unitTextureKeys ?? [],
|
|
loadedUnitActionTextureKeys: textureKeys.filter((key) => key.endsWith('-actions')),
|
|
transitioning: scene?.transitioning ?? null
|
|
};
|
|
});
|
|
}
|
|
|
|
function assertStoryAssetStreamingProbe(probe, expectedPageIndex, label) {
|
|
const streaming = probe.story?.assetStreaming;
|
|
assert(
|
|
probe.viewport?.width === baselineViewport.width &&
|
|
probe.viewport?.height === baselineViewport.height &&
|
|
probe.viewport?.devicePixelRatio === desktopBrowserDeviceScaleFactor,
|
|
`Expected ${label} at the explicit 1920x1080 DPR1 baseline: ${JSON.stringify(probe.viewport)}`
|
|
);
|
|
assert(
|
|
probe.story?.ready === true &&
|
|
probe.story.pageIndex === expectedPageIndex &&
|
|
streaming?.currentPageReady === true &&
|
|
probe.story.backgroundReady === true &&
|
|
probe.story.activeTexture === probe.story.background,
|
|
`Expected ${label} to use its loaded deterministic background: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
Array.isArray(streaming?.failedAssetKeys) && streaming.failedAssetKeys.length === 0,
|
|
`Expected ${label} without story asset failures: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
storyStreamingIndexes(streaming).every((pageIndex) => pageIndex <= expectedPageIndex + 1),
|
|
`Expected ${label} streaming not to run beyond the current/next page window: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
probe.expectedUnitBaseTextureKeys.every((key) => streaming.currentUnitBaseTexturesLoaded.includes(key)),
|
|
`Expected ${label} current unit base sheets to be ready: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
streaming.currentUnitActionTexturesLoaded.length === 0 && probe.loadedUnitActionTextureKeys.length === 0,
|
|
`Expected ${label} to keep battle action sheets unloaded: ${JSON.stringify(probe)}`
|
|
);
|
|
}
|
|
|
|
function storyStreamingIndexes(streaming) {
|
|
return [
|
|
...(streaming?.readyPageIndexes ?? []),
|
|
...(streaming?.queuedPageIndexes ?? []),
|
|
...(Number.isInteger(streaming?.loadingPageIndex) ? [streaming.loadingPageIndex] : [])
|
|
];
|
|
}
|
|
|
|
async function waitForStoryOrEnding(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
return activeScenes.includes('StoryScene') || activeScenes.includes('EndingScene');
|
|
}, undefined, { timeout: 30000 });
|
|
return page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
|
|
}
|
|
|
|
async function advanceUntilBattle(page, battleId, options = {}) {
|
|
const { startDeployment = true, captureLastStory = false } = options;
|
|
let lastStory = null;
|
|
for (let i = 0; i < 48; i += 1) {
|
|
const probe = await page.evaluate((expectedBattleId) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return {
|
|
ready: (
|
|
state?.scene === 'BattleScene' &&
|
|
state?.battleId === expectedBattleId &&
|
|
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
|
state?.mapBackgroundReady === true
|
|
),
|
|
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null
|
|
};
|
|
}, battleId);
|
|
|
|
if (
|
|
captureLastStory &&
|
|
probe.story?.isLastPage &&
|
|
probe.story?.progressBounds &&
|
|
probe.story?.progressPanelBounds
|
|
) {
|
|
lastStory = probe.story;
|
|
}
|
|
|
|
if (probe.ready) {
|
|
if (startDeployment) {
|
|
await startDeploymentIfNeeded(page, battleId);
|
|
}
|
|
return { lastStory };
|
|
}
|
|
|
|
await page.keyboard.press('Space');
|
|
await page.waitForTimeout(220);
|
|
}
|
|
|
|
await waitForBattleReady(page, battleId, startDeployment);
|
|
return { lastStory };
|
|
}
|
|
|
|
async function waitForBattleReady(page, battleId, startDeployment = true) {
|
|
await page.waitForFunction((expectedBattleId) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
state?.scene === 'BattleScene' &&
|
|
state?.battleId === expectedBattleId &&
|
|
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
|
state?.mapBackgroundReady === true
|
|
);
|
|
}, battleId, { timeout: 90000 });
|
|
if (startDeployment) {
|
|
await startDeploymentIfNeeded(page, battleId);
|
|
}
|
|
}
|
|
|
|
async function startDeploymentIfNeeded(page, battleId) {
|
|
const state = await page.evaluate((expectedBattleId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return battle?.battleId === expectedBattleId ? battle : undefined;
|
|
}, battleId);
|
|
if (state?.phase !== 'deployment') {
|
|
return;
|
|
}
|
|
|
|
assert(state.deployedAllyIds?.length >= 3, `Expected deployment to show the selected ally formation: ${JSON.stringify(state)}`);
|
|
await clickBattleDeploymentStart(page);
|
|
await page.waitForFunction((expectedBattleId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return battle?.battleId === expectedBattleId && battle?.phase === 'idle' && battle?.triggeredBattleEvents?.includes('opening');
|
|
}, battleId, { timeout: 30000 });
|
|
}
|
|
|
|
async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) {
|
|
const probe = await page.evaluate(({ expectedBattleId, expectedOrderId }) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const operationOrder = state?.sortieOperationOrder;
|
|
const rawHud = operationOrder?.live ?? operationOrder?.hud;
|
|
const criteria = rawHud?.criteria ?? rawHud?.progress ?? [];
|
|
return {
|
|
battleId: state?.battleId ?? null,
|
|
phase: state?.phase ?? null,
|
|
battleOutcome: state?.battleOutcome ?? null,
|
|
resultVisible: state?.resultVisible ?? false,
|
|
selectedId: operationOrder?.selectedId ?? null,
|
|
result: operationOrder?.result ?? null,
|
|
sceneBounds: scene
|
|
? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height }
|
|
: null,
|
|
panelBounds: scene?.layout
|
|
? {
|
|
x: scene.layout.panelX,
|
|
y: scene.layout.panelY,
|
|
width: scene.layout.panelWidth,
|
|
height: scene.layout.panelHeight
|
|
}
|
|
: null,
|
|
hud: rawHud
|
|
? {
|
|
visible: rawHud.visible,
|
|
bounds: rawHud.bounds ?? null,
|
|
orderId: rawHud.orderId ?? null,
|
|
label: rawHud.label ?? '',
|
|
victoryLabel: rawHud.victoryLabel ?? '',
|
|
victoryTone: rawHud.victoryTone ?? rawHud.victoryStatus ?? '',
|
|
status: rawHud.status ?? '',
|
|
tone: rawHud.tone ?? rawHud.status ?? '',
|
|
projectedScore: rawHud.projectedScore,
|
|
commandReady: rawHud.commandReady,
|
|
commandUnlocked: rawHud.commandUnlocked,
|
|
commandUnlockTurn: rawHud.commandUnlockTurn,
|
|
commandUsed: rawHud.commandUsed,
|
|
commandSource: rawHud.commandSource,
|
|
commandActorId: rawHud.commandActorId,
|
|
commandBounds: rawHud.commandBounds ?? null,
|
|
command: rawHud.command ? { ...rawHud.command } : null,
|
|
criteria: criteria.map((entry) => ({
|
|
id: entry?.id ?? null,
|
|
label: entry?.label ?? '',
|
|
value: entry?.value,
|
|
tone: entry?.tone ?? entry?.status ?? '',
|
|
achieved: entry?.achieved,
|
|
bounds: entry?.bounds ?? null
|
|
})),
|
|
stampedIds: Array.isArray(rawHud.stampedIds) ? [...rawHud.stampedIds] : rawHud.stampedIds,
|
|
animationCount: rawHud.animationCount
|
|
}
|
|
: null,
|
|
expectedBattleId,
|
|
expectedOrderId,
|
|
battleLog: Array.isArray(state?.battleLog) ? [...state.battleLog] : []
|
|
};
|
|
}, { expectedBattleId: battleId, expectedOrderId: orderId });
|
|
|
|
const hud = probe.hud;
|
|
const requiredCriteriaIds = ['elite-grade', 'elite-survival'];
|
|
const criterionIds = hud?.criteria?.map((entry) => entry.id) ?? [];
|
|
assert(
|
|
probe.battleId === battleId &&
|
|
probe.phase !== 'resolved' &&
|
|
probe.battleOutcome === null &&
|
|
probe.resultVisible === false &&
|
|
probe.selectedId === orderId &&
|
|
probe.result === null,
|
|
`Expected ${context} to expose the selected ${orderId} order before its result: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
hud?.visible === true &&
|
|
hud.orderId === orderId &&
|
|
typeof hud.label === 'string' && hud.label.includes('정예') &&
|
|
typeof hud.victoryLabel === 'string' && hud.victoryLabel.length > 0 &&
|
|
typeof hud.victoryTone === 'string' && hud.victoryTone.length > 0 &&
|
|
typeof hud.status === 'string' && hud.status.length > 0 &&
|
|
typeof hud.tone === 'string' && hud.tone.length > 0 &&
|
|
Number.isFinite(hud.projectedScore) &&
|
|
typeof hud.commandReady === 'boolean' &&
|
|
typeof hud.commandUnlocked === 'boolean' &&
|
|
typeof hud.commandUsed === 'boolean' &&
|
|
(hud.commandUnlockTurn === null || Number.isInteger(hud.commandUnlockTurn)) &&
|
|
(hud.commandSource === null || hud.commandSource === 'sortie-order' || hud.commandSource === 'initiative') &&
|
|
(hud.commandActorId === null || typeof hud.commandActorId === 'string') &&
|
|
(hud.commandBounds === null || (isFiniteBounds(hud.commandBounds) && boundsInside(hud.commandBounds, hud.bounds))),
|
|
`Expected ${context} to show a fully labelled live ${orderId} HUD with a finite projected score: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
isFiniteBounds(probe.sceneBounds) &&
|
|
isFiniteBounds(probe.panelBounds) &&
|
|
isFiniteBounds(hud.bounds) &&
|
|
boundsInside(hud.bounds, probe.sceneBounds) &&
|
|
boundsInside(hud.bounds, probe.panelBounds) &&
|
|
requiredCriteriaIds.every((id) => criterionIds.includes(id)) &&
|
|
hud.criteria.length >= requiredCriteriaIds.length &&
|
|
hud.criteria.every((entry) => (
|
|
typeof entry.id === 'string' && entry.id.length > 0 &&
|
|
typeof entry.label === 'string' && entry.label.length > 0 &&
|
|
Object.prototype.hasOwnProperty.call(entry, 'value') &&
|
|
typeof entry.tone === 'string' && entry.tone.length > 0 &&
|
|
typeof entry.achieved === 'boolean' &&
|
|
isFiniteBounds(entry.bounds) &&
|
|
boundsInside(entry.bounds, hud.bounds)
|
|
)),
|
|
`Expected ${context} live order criteria and bounds to remain inside the visible HUD panel: ${JSON.stringify(probe)}`
|
|
);
|
|
assert(
|
|
Array.isArray(hud.stampedIds) && hud.stampedIds.length === 0 && hud.animationCount === 0,
|
|
`Expected ${context} live order HUD to start without stamped criteria or animations: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function activateSortieOrderCommand(page, role) {
|
|
await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
scene?.hideBattleEventBanner?.();
|
|
});
|
|
const openPoint = await readBattleOrderCommandControlPoint(page, 'open');
|
|
await page.mouse.click(openPoint.x, openPoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.commandPanelOpen === true,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const panelProbe = await page.evaluate(() => {
|
|
const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder;
|
|
const cards = Object.fromEntries(Object.entries(order?.commandCards ?? {}).map(([cardRole, rawCard]) => [
|
|
cardRole,
|
|
rawCard && typeof rawCard === 'object' && 'bounds' in rawCard
|
|
? { ...rawCard, bounds: rawCard.bounds }
|
|
: { role: cardRole, bounds: rawCard }
|
|
]));
|
|
return {
|
|
open: order?.commandPanelOpen ?? false,
|
|
panelBounds: order?.commandPanelBounds ?? null,
|
|
cards
|
|
};
|
|
});
|
|
assert(
|
|
panelProbe.open === true &&
|
|
isFiniteBounds(panelProbe.panelBounds) &&
|
|
['front', 'flank', 'support'].every((cardRole) => (
|
|
isFiniteBounds(panelProbe.cards?.[cardRole]?.bounds) &&
|
|
boundsInside(panelProbe.cards[cardRole].bounds, panelProbe.panelBounds)
|
|
)) &&
|
|
panelProbe.cards?.[role]?.enabled !== false,
|
|
`Expected the ready order command panel to expose three bounded role choices: ${JSON.stringify(panelProbe)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-order-command.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first battle order command');
|
|
|
|
const rolePoint = await readBattleOrderCommandControlPoint(page, 'card', role);
|
|
await page.mouse.click(rolePoint.x, rolePoint.y);
|
|
await page.waitForFunction(
|
|
(expectedRole) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
const order = state?.sortieOperationOrder;
|
|
return (
|
|
order?.commandPanelOpen === false &&
|
|
order?.live?.commandUsed === true &&
|
|
order?.live?.command?.role === expectedRole &&
|
|
state?.tacticalInitiative?.cutIn?.active === true
|
|
);
|
|
},
|
|
role,
|
|
{ timeout: 30000 }
|
|
);
|
|
await page.waitForTimeout(180);
|
|
|
|
const activeCutInProbe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return {
|
|
cutIn: state?.tacticalInitiative?.cutIn ?? null,
|
|
command: state?.sortieOperationOrder?.live?.command ?? null,
|
|
sceneBounds: scene
|
|
? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height }
|
|
: null
|
|
};
|
|
});
|
|
assert(
|
|
activeCutInProbe.cutIn?.active === true &&
|
|
activeCutInProbe.cutIn.count === 1 &&
|
|
activeCutInProbe.cutIn.last?.source === 'sortie-order' &&
|
|
activeCutInProbe.cutIn.last?.role === role &&
|
|
typeof activeCutInProbe.cutIn.last?.actorId === 'string' &&
|
|
activeCutInProbe.cutIn.last.actorId.length > 0 &&
|
|
activeCutInProbe.cutIn.last.actorId === activeCutInProbe.command?.actorId &&
|
|
activeCutInProbe.cutIn.last?.label === '\uC7AC\uC815\uBE44' &&
|
|
isFiniteBounds(activeCutInProbe.cutIn.bounds) &&
|
|
isFiniteBounds(activeCutInProbe.cutIn.last?.bounds) &&
|
|
isFiniteBounds(activeCutInProbe.sceneBounds) &&
|
|
boundsInside(activeCutInProbe.cutIn.bounds, activeCutInProbe.sceneBounds) &&
|
|
boundsInside(activeCutInProbe.cutIn.last.bounds, activeCutInProbe.sceneBounds),
|
|
`Expected the selected order command to show one bounded in-scene cut-in with its source, role, actor, and label: ${JSON.stringify(activeCutInProbe)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-order-command-cutin.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'first battle order command cut-in');
|
|
|
|
await page.waitForFunction(
|
|
(expectedRole) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
const order = state?.sortieOperationOrder;
|
|
const cutIn = state?.tacticalInitiative?.cutIn;
|
|
return (
|
|
cutIn?.active === false &&
|
|
cutIn.count === 1 &&
|
|
cutIn.last?.source === 'sortie-order' &&
|
|
cutIn.last?.role === expectedRole &&
|
|
order?.live?.command?.role === expectedRole &&
|
|
order.live.command.effectPending === false &&
|
|
order.live.command.effectValue > 0
|
|
);
|
|
},
|
|
role,
|
|
{ timeout: 30000 }
|
|
);
|
|
const settledProbe = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return {
|
|
order: state?.sortieOperationOrder ?? null,
|
|
cutIn: state?.tacticalInitiative?.cutIn ?? null
|
|
};
|
|
});
|
|
assert(
|
|
settledProbe.cutIn?.active === false &&
|
|
settledProbe.cutIn.count === 1 &&
|
|
settledProbe.cutIn.last?.source === 'sortie-order' &&
|
|
settledProbe.cutIn.last?.role === role &&
|
|
settledProbe.cutIn.last?.actorId === settledProbe.order?.live?.command?.actorId &&
|
|
settledProbe.cutIn.last?.label === '\uC7AC\uC815\uBE44' &&
|
|
isFiniteBounds(settledProbe.cutIn.last?.bounds) &&
|
|
settledProbe.order?.live?.command?.effectPending === false &&
|
|
settledProbe.order?.live?.command?.effectValue > 0,
|
|
`Expected the cut-in to finish once while retaining its last payload before resolving the support effect: ${JSON.stringify(settledProbe)}`
|
|
);
|
|
return settledProbe.order;
|
|
}
|
|
|
|
async function readBattleOrderCommandControlPoint(page, control, role) {
|
|
const probe = await page.evaluate(({ control, role }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder;
|
|
const rawCard = role ? order?.commandCards?.[role] : undefined;
|
|
const cardBounds = rawCard && typeof rawCard === 'object' && 'bounds' in rawCard ? rawCard.bounds : rawCard;
|
|
const bounds = control === 'open' ? order?.live?.commandBounds : cardBounds;
|
|
if (!scene || !canvas || !bounds) {
|
|
return { ready: false, control, role: role ?? null, bounds: bounds ?? null, order };
|
|
}
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
control,
|
|
role: role ?? null,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, { control, role });
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible battle order-command ${control} control: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function waitForBattleOutcome(page, outcome) {
|
|
await page.waitForFunction((expectedOutcome) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return state?.battleOutcome === expectedOutcome && state?.phase === 'resolved' && state?.resultVisible === true;
|
|
}, outcome, { timeout: 90000 });
|
|
}
|
|
|
|
async function waitForCamp(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
const scene = window.__HEROS_DEBUG__?.scene('CampScene');
|
|
return (
|
|
activeScenes.includes('CampScene') &&
|
|
!activeScenes.includes('BattleScene') &&
|
|
window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' &&
|
|
(scene?.contentObjects?.length ?? 0) > 0
|
|
);
|
|
}, undefined, { timeout: 90000 });
|
|
await page.evaluate(() => new Promise((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
}));
|
|
}
|
|
|
|
async function waitForCampSkinTransition(page, expectedSkinId) {
|
|
await page.waitForFunction((skinId) => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
const campSkin = window.__HEROS_DEBUG__?.camp?.()?.campSkin;
|
|
const transition = campSkin?.transition;
|
|
return (
|
|
activeScenes.includes('CampScene') &&
|
|
campSkin?.id === skinId &&
|
|
campSkin.textureReady === true &&
|
|
Boolean(campSkin.renderedTextureKey) &&
|
|
transition?.active === false &&
|
|
transition.revision > 0 &&
|
|
transition.completedRevision === transition.revision
|
|
);
|
|
}, expectedSkinId, { timeout: 30000 });
|
|
await page.evaluate(() => new Promise((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
}));
|
|
}
|
|
|
|
async function waitForCampSoundscapeTransition(page, expectedMusicKey, expectedAmbienceKey) {
|
|
await page.waitForFunction(
|
|
({ musicKey, ambienceKey }) => {
|
|
const audio = window.__HEROS_DEBUG__?.camp?.()?.audio;
|
|
const channels = [audio?.music, audio?.ambience];
|
|
return (
|
|
audio?.music?.currentKey === musicKey &&
|
|
audio?.ambience?.currentKey === ambienceKey &&
|
|
channels.every((channel) =>
|
|
channel?.transition?.active === false &&
|
|
channel.transition.revision > 0 &&
|
|
channel.transition.completedRevision === channel.transition.revision
|
|
)
|
|
);
|
|
},
|
|
{ musicKey: expectedMusicKey, ambienceKey: expectedAmbienceKey },
|
|
{ timeout: 30000 }
|
|
);
|
|
}
|
|
|
|
async function waitForCampVisualFrame(page, expectedPage) {
|
|
await page.waitForFunction(
|
|
(pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber,
|
|
expectedPage,
|
|
{ timeout: 15000 }
|
|
);
|
|
await page.evaluate(() => new Promise((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
}));
|
|
await page.waitForFunction(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
const context = canvas?.getContext('2d', { willReadFrequently: true });
|
|
if (!canvas || !context) {
|
|
return false;
|
|
}
|
|
|
|
const colorBuckets = new Set();
|
|
let litSamples = 0;
|
|
for (let y = 110; y <= 560; y += 30) {
|
|
for (let x = 400; x <= 1200; x += 30) {
|
|
const [r, g, b, a] = context.getImageData(x, y, 1, 1).data;
|
|
if (a > 8 && r + g + b > 54) {
|
|
litSamples += 1;
|
|
}
|
|
colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`);
|
|
}
|
|
}
|
|
return litSamples >= 18 && colorBuckets.size >= 12;
|
|
}, undefined, { timeout: 15000 });
|
|
}
|
|
|
|
async function captureStableCampScreenshot(page, path, expectedPage, label) {
|
|
await waitForCampVisualFrame(page, expectedPage);
|
|
const capture = await page.evaluate(() => new Promise((resolve) => {
|
|
const game = window.__HEROS_GAME__;
|
|
const canvas = document.querySelector('canvas');
|
|
if (!game?.loop || !game.renderer || !canvas) {
|
|
resolve({ loopAvailable: false, dataUrl: null });
|
|
return;
|
|
}
|
|
game.renderer.once('postrender', () => {
|
|
game.loop.sleep();
|
|
resolve({ loopAvailable: true, dataUrl: canvas.toDataURL('image/png') });
|
|
});
|
|
}));
|
|
try {
|
|
assert(
|
|
capture.dataUrl?.startsWith('data:image/png;base64,'),
|
|
`Expected a complete canvas capture for ${label}: ${JSON.stringify({ loopAvailable: capture.loopAvailable, hasDataUrl: Boolean(capture.dataUrl) })}`
|
|
);
|
|
writeFileSync(path, Buffer.from(capture.dataUrl.split(',')[1], 'base64'));
|
|
await assertCanvasPainted(page, label);
|
|
} finally {
|
|
if (capture.loopAvailable) {
|
|
await page.evaluate(() => new Promise((resolve) => {
|
|
window.__HEROS_GAME__?.loop?.wake();
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function assertFinalCampRosterPagination(page) {
|
|
const initialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const initialRoster = initialState?.campRoster;
|
|
const initialSelectedUnitId = initialState?.selectedUnitId ?? null;
|
|
const initialSave = await readCampaignSave(page);
|
|
const expectedUnitIds = (initialState?.campaign?.roster ?? []).map((unit) => unit.id);
|
|
|
|
assert(
|
|
initialRoster?.page === 0 &&
|
|
initialRoster.pageCount === 4 &&
|
|
initialRoster.rowsPerPage === 6 &&
|
|
initialRoster.totalCount === 23 &&
|
|
expectedUnitIds.length === 23 &&
|
|
new Set(expectedUnitIds).size === 23 &&
|
|
expectedUnitIds.includes(initialSelectedUnitId),
|
|
`Expected the final camp roster to open as four six-row pages for 23 unique officers: ${JSON.stringify({
|
|
campRoster: initialRoster,
|
|
expectedUnitIds
|
|
})}`
|
|
);
|
|
|
|
const visitedUnitIds = [];
|
|
let verificationError;
|
|
|
|
try {
|
|
for (let expectedPage = 0; expectedPage < initialRoster.pageCount; expectedPage += 1) {
|
|
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const roster = state?.campRoster;
|
|
assertCampRosterPageLayout(roster, expectedPage, initialRoster.pageCount, initialSelectedUnitId, state?.selectedUnitId);
|
|
|
|
const expectedVisibleCount = expectedPage === initialRoster.pageCount - 1
|
|
? initialRoster.totalCount - initialRoster.rowsPerPage * (initialRoster.pageCount - 1)
|
|
: initialRoster.rowsPerPage;
|
|
assert(
|
|
roster.visibleUnitIds.length === expectedVisibleCount &&
|
|
roster.rowBounds.length === expectedVisibleCount &&
|
|
sameJsonValue(roster.rowBounds.map((row) => row.unitId), roster.visibleUnitIds),
|
|
`Expected final camp roster page ${expectedPage + 1} to expose ${expectedVisibleCount} matching rows: ${JSON.stringify(roster)}`
|
|
);
|
|
|
|
roster.visibleUnitIds.forEach((unitId) => {
|
|
assert(
|
|
expectedUnitIds.includes(unitId) && !visitedUnitIds.includes(unitId),
|
|
`Expected each final camp officer to appear on exactly one roster page: ${JSON.stringify({
|
|
expectedPage,
|
|
unitId,
|
|
visitedUnitIds,
|
|
expectedUnitIds
|
|
})}`
|
|
);
|
|
visitedUnitIds.push(unitId);
|
|
});
|
|
|
|
if (expectedPage === 1) {
|
|
const selectionTargetUnitId = roster.visibleUnitIds[0];
|
|
assert(
|
|
selectionTargetUnitId && selectionTargetUnitId !== initialSelectedUnitId,
|
|
`Expected the second roster page to expose a selectable officer other than the initial detail: ${JSON.stringify(roster)}`
|
|
);
|
|
const selectionPoint = await readCampRosterRowPoint(page, selectionTargetUnitId);
|
|
await page.mouse.click(selectionPoint.x, selectionPoint.y);
|
|
await page.waitForFunction(
|
|
({ pageNumber, unitId }) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === unitId;
|
|
},
|
|
{ pageNumber: expectedPage, unitId: selectionTargetUnitId },
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
await navigateCampRosterPage(page, 'previous', 0, selectionTargetUnitId);
|
|
const restorePoint = await readCampRosterRowPoint(page, initialSelectedUnitId);
|
|
await page.mouse.click(restorePoint.x, restorePoint.y);
|
|
await page.waitForFunction(
|
|
(unitId) => window.__HEROS_DEBUG__?.camp()?.selectedUnitId === unitId,
|
|
initialSelectedUnitId,
|
|
{ timeout: 30000 }
|
|
);
|
|
await navigateCampRosterPage(page, 'next', expectedPage, initialSelectedUnitId);
|
|
}
|
|
|
|
if (expectedPage === initialRoster.pageCount - 1) {
|
|
assert(
|
|
roster.visibleUnitIds.length === 5 && roster.previousEnabled === true && roster.nextEnabled === false,
|
|
`Expected the final roster page to contain five officers with only previous navigation enabled: ${JSON.stringify(roster)}`
|
|
);
|
|
await captureStableCampScreenshot(
|
|
page,
|
|
`${screenshotDir}/rc-final-camp-roster-last-page.png`,
|
|
expectedPage,
|
|
'final camp roster last page'
|
|
);
|
|
continue;
|
|
}
|
|
|
|
await navigateCampRosterPage(page, 'next', expectedPage + 1, initialSelectedUnitId);
|
|
}
|
|
|
|
assert(
|
|
visitedUnitIds.length === 23 &&
|
|
new Set(visitedUnitIds).size === 23 &&
|
|
sameJsonValue([...visitedUnitIds].sort(), [...expectedUnitIds].sort()),
|
|
`Expected pagination to expose all 23 final-camp officers exactly once: ${JSON.stringify({
|
|
visitedUnitIds,
|
|
expectedUnitIds
|
|
})}`
|
|
);
|
|
|
|
for (let expectedPage = initialRoster.pageCount - 2; expectedPage >= 0; expectedPage -= 1) {
|
|
await navigateCampRosterPage(page, 'previous', expectedPage, initialSelectedUnitId);
|
|
}
|
|
} catch (error) {
|
|
verificationError = error;
|
|
} finally {
|
|
const restoreState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const currentPage = restoreState?.campRoster?.page ?? 0;
|
|
const restoreSelectedUnitId = restoreState?.selectedUnitId ?? null;
|
|
for (let pageNumber = currentPage; pageNumber > 0; pageNumber -= 1) {
|
|
await navigateCampRosterPage(page, 'previous', pageNumber - 1, restoreSelectedUnitId);
|
|
}
|
|
}
|
|
|
|
if (verificationError) {
|
|
throw verificationError;
|
|
}
|
|
|
|
const restoredState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
const restoredSave = await readCampaignSave(page);
|
|
assert(
|
|
restoredState?.campRoster?.page === initialRoster.page &&
|
|
restoredState?.selectedUnitId === initialSelectedUnitId &&
|
|
sameJsonValue(restoredSave, initialSave),
|
|
`Expected the final-camp roster fixture to restore its page, selected detail, and saves: ${JSON.stringify({
|
|
initialPage: initialRoster.page,
|
|
restoredPage: restoredState?.campRoster?.page,
|
|
initialSelectedUnitId,
|
|
restoredSelectedUnitId: restoredState?.selectedUnitId,
|
|
saveUnchanged: sameJsonValue(restoredSave, initialSave)
|
|
})}`
|
|
);
|
|
}
|
|
|
|
function assertCampRosterPageLayout(roster, expectedPage, pageCount, expectedSelectedUnitId, actualSelectedUnitId) {
|
|
assert(
|
|
roster?.page === expectedPage &&
|
|
roster.pageCount === pageCount &&
|
|
roster.rowsPerPage === 6 &&
|
|
roster.totalCount === 23 &&
|
|
actualSelectedUnitId === expectedSelectedUnitId,
|
|
`Expected stable final-camp roster state on page ${expectedPage + 1}: ${JSON.stringify({
|
|
roster,
|
|
expectedSelectedUnitId,
|
|
actualSelectedUnitId
|
|
})}`
|
|
);
|
|
assert(
|
|
roster.previousEnabled === (expectedPage > 0) &&
|
|
roster.nextEnabled === (expectedPage < pageCount - 1),
|
|
`Expected correct final-camp roster navigation state on page ${expectedPage + 1}: ${JSON.stringify(roster)}`
|
|
);
|
|
|
|
const listBounds = roster.listBounds;
|
|
assert(isPositiveBounds(listBounds), `Expected final-camp roster list bounds on page ${expectedPage + 1}: ${JSON.stringify(roster)}`);
|
|
assert(
|
|
isBoundsInside(listBounds, desktopBrowserViewportBounds),
|
|
`Expected final-camp roster list inside the 1920x1080 canvas: ${JSON.stringify(roster)}`
|
|
);
|
|
assert(
|
|
isPositiveBounds(roster.navigationBounds?.container) &&
|
|
isPositiveBounds(roster.navigationBounds?.previous) &&
|
|
isPositiveBounds(roster.navigationBounds?.label) &&
|
|
isPositiveBounds(roster.navigationBounds?.next) &&
|
|
isBoundsInside(roster.navigationBounds.container, desktopBrowserViewportBounds),
|
|
`Expected complete final-camp roster navigation bounds inside the FHD canvas: ${JSON.stringify(roster)}`
|
|
);
|
|
assert(
|
|
isBoundsInside(roster.navigationBounds.previous, roster.navigationBounds.container) &&
|
|
isBoundsInside(roster.navigationBounds.label, roster.navigationBounds.container) &&
|
|
isBoundsInside(roster.navigationBounds.next, roster.navigationBounds.container) &&
|
|
listBounds.y + listBounds.height <= roster.navigationBounds.container.y,
|
|
`Expected final-camp roster controls to stay inside their navigation bar and below every row: ${JSON.stringify(roster)}`
|
|
);
|
|
|
|
roster.rowBounds.forEach((row, rowIndex) => {
|
|
assert(
|
|
row.unitId === roster.visibleUnitIds[rowIndex] &&
|
|
isPositiveBounds(row) &&
|
|
isBoundsInside(row, listBounds) &&
|
|
isBoundsInside(row, desktopBrowserViewportBounds) &&
|
|
row.y + row.height <= roster.navigationBounds.container.y,
|
|
`Expected final-camp roster row ${rowIndex + 1} to match its visible officer and remain inside the list and FHD canvas: ${JSON.stringify({
|
|
row,
|
|
listBounds,
|
|
visibleUnitIds: roster.visibleUnitIds
|
|
})}`
|
|
);
|
|
});
|
|
|
|
for (let leftIndex = 0; leftIndex < roster.rowBounds.length; leftIndex += 1) {
|
|
for (let rightIndex = leftIndex + 1; rightIndex < roster.rowBounds.length; rightIndex += 1) {
|
|
assert(
|
|
!boundsOverlap(roster.rowBounds[leftIndex], roster.rowBounds[rightIndex]),
|
|
`Expected final-camp roster rows on page ${expectedPage + 1} not to overlap: ${JSON.stringify({
|
|
left: roster.rowBounds[leftIndex],
|
|
right: roster.rowBounds[rightIndex]
|
|
})}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readCampRosterNavigationPoint(page, direction) {
|
|
const probe = await page.evaluate((navigationDirection) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const roster = window.__HEROS_DEBUG__?.camp()?.campRoster;
|
|
const bounds = roster?.navigationBounds?.[navigationDirection];
|
|
const enabled = navigationDirection === 'previous' ? roster?.previousEnabled : roster?.nextEnabled;
|
|
if (!scene || !canvas || !bounds || !enabled) {
|
|
return { ready: false, direction: navigationDirection, enabled: enabled ?? false, bounds: bounds ?? null, roster };
|
|
}
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
direction: navigationDirection,
|
|
enabled,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, direction);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected an enabled final-camp roster ${direction} control: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function navigateCampRosterPage(page, direction, expectedPage, expectedSelectedUnitId) {
|
|
const maxAttempts = 3;
|
|
const attemptTimeout = 4000;
|
|
let lastError;
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
const before = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
page: state?.campRoster?.page ?? null,
|
|
selectedUnitId: state?.selectedUnitId ?? null,
|
|
campRoster: state?.campRoster ?? null
|
|
};
|
|
});
|
|
if (before.page === expectedPage && before.selectedUnitId === expectedSelectedUnitId) {
|
|
return before;
|
|
}
|
|
if (before.selectedUnitId !== expectedSelectedUnitId) {
|
|
throw new Error(
|
|
`Final-camp roster ${direction} navigation changed the selected detail before attempt ${attempt}: ${JSON.stringify({
|
|
expectedPage,
|
|
expectedSelectedUnitId,
|
|
before
|
|
})}`
|
|
);
|
|
}
|
|
|
|
try {
|
|
const point = await readCampRosterNavigationPoint(page, direction);
|
|
await page.mouse.click(point.x, point.y);
|
|
await page.waitForFunction(
|
|
({ pageNumber, selectedUnitId }) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return state?.campRoster?.page === pageNumber && state?.selectedUnitId === selectedUnitId;
|
|
},
|
|
{ pageNumber: expectedPage, selectedUnitId: expectedSelectedUnitId },
|
|
{ timeout: attemptTimeout }
|
|
);
|
|
return await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
page: state?.campRoster?.page ?? null,
|
|
selectedUnitId: state?.selectedUnitId ?? null,
|
|
campRoster: state?.campRoster ?? null
|
|
};
|
|
});
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (attempt < maxAttempts) {
|
|
await page.waitForTimeout(120);
|
|
}
|
|
}
|
|
}
|
|
|
|
const finalState = await page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
page: state?.campRoster?.page ?? null,
|
|
selectedUnitId: state?.selectedUnitId ?? null,
|
|
campRoster: state?.campRoster ?? null
|
|
};
|
|
});
|
|
const lastErrorSummary = lastError instanceof Error
|
|
? `${lastError.name}: ${lastError.message}`
|
|
: String(lastError ?? 'unknown input failure');
|
|
throw new Error(
|
|
`Failed final-camp roster ${direction} navigation to page ${expectedPage + 1} after ${maxAttempts} attempts: ${JSON.stringify({
|
|
expectedPage,
|
|
expectedSelectedUnitId,
|
|
finalState,
|
|
lastError: lastErrorSummary
|
|
})}`
|
|
);
|
|
}
|
|
|
|
async function readCampRosterRowPoint(page, unitId) {
|
|
const probe = await page.evaluate((targetUnitId) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const roster = window.__HEROS_DEBUG__?.camp()?.campRoster;
|
|
const bounds = roster?.rowBounds?.find((row) => row.unitId === targetUnitId);
|
|
if (!scene || !canvas || !bounds) {
|
|
return { ready: false, unitId: targetUnitId, bounds: bounds ?? null, roster };
|
|
}
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
unitId: targetUnitId,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, unitId);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible final-camp roster row for ${unitId}: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function selectCampRosterUnit(page, unitId) {
|
|
const initial = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campRoster ?? null);
|
|
const maxAttempts = Math.max(1, initial?.pageCount ?? 1);
|
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
const state = await page.evaluate(() => {
|
|
const camp = window.__HEROS_DEBUG__?.camp();
|
|
return {
|
|
selectedUnitId: camp?.selectedUnitId ?? null,
|
|
roster: camp?.campRoster ?? null
|
|
};
|
|
});
|
|
if (state.roster?.visibleUnitIds?.includes(unitId)) {
|
|
const point = await readCampRosterRowPoint(page, unitId);
|
|
await page.mouse.click(point.x, point.y);
|
|
await page.waitForFunction(
|
|
(targetUnitId) => window.__HEROS_DEBUG__?.camp()?.selectedUnitId === targetUnitId,
|
|
unitId,
|
|
{ timeout: 30000 }
|
|
);
|
|
return page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
}
|
|
if (!state.roster?.nextEnabled || !state.selectedUnitId) {
|
|
break;
|
|
}
|
|
await navigateCampRosterPage(page, 'next', state.roster.page + 1, state.selectedUnitId);
|
|
}
|
|
const finalState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
|
throw new Error(
|
|
'Could not find camp roster unit ' +
|
|
unitId +
|
|
' across ' +
|
|
maxAttempts +
|
|
' page(s): ' +
|
|
JSON.stringify(finalState?.campRoster)
|
|
);
|
|
}
|
|
|
|
function isPositiveBounds(bounds) {
|
|
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
|
|
);
|
|
}
|
|
|
|
function boundsWithinFhdViewport(bounds, tolerance = 1) {
|
|
return Boolean(
|
|
isPositiveBounds(bounds) &&
|
|
bounds.x >= -tolerance &&
|
|
bounds.y >= -tolerance &&
|
|
bounds.x + bounds.width <= 1920 + tolerance &&
|
|
bounds.y + bounds.height <= 1080 + tolerance
|
|
);
|
|
}
|
|
|
|
function isBoundsInside(inner, outer, tolerance = 0.01) {
|
|
return (
|
|
isPositiveBounds(inner) &&
|
|
isPositiveBounds(outer) &&
|
|
inner.x >= outer.x - tolerance &&
|
|
inner.y >= outer.y - tolerance &&
|
|
inner.x + inner.width <= outer.x + outer.width + tolerance &&
|
|
inner.y + inner.height <= outer.y + outer.height + tolerance
|
|
);
|
|
}
|
|
|
|
function boundsOverlap(left, right, tolerance = 0.01) {
|
|
return (
|
|
left.x < right.x + right.width - tolerance &&
|
|
left.x + left.width > right.x + tolerance &&
|
|
left.y < right.y + right.height - tolerance &&
|
|
left.y + left.height > right.y + tolerance
|
|
);
|
|
}
|
|
|
|
async function waitForSortiePrep(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
const camp = window.__HEROS_DEBUG__?.camp?.();
|
|
return activeScenes.includes('CampScene') && camp?.sortieVisible === true;
|
|
}, undefined, { timeout: 30000 });
|
|
}
|
|
|
|
async function openCampSortiePrep(page) {
|
|
const rewardVisible = await page.evaluate(
|
|
() => window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement?.visible === true
|
|
);
|
|
const point = rewardVisible
|
|
? await readCampVictoryRewardControlPoint(page, 'sortie')
|
|
: await readCampStaticTextControlPoint(page, '다음 이야기');
|
|
await page.mouse.click(point.x, point.y);
|
|
await waitForSortiePrep(page);
|
|
}
|
|
|
|
async function advanceSortiePrepStep(page, expectedStep) {
|
|
await clickLegacyUi(page, 1116, 656);
|
|
await page.waitForFunction((step) => {
|
|
const camp = window.__HEROS_DEBUG__?.camp?.();
|
|
return camp?.sortieVisible === true && camp?.sortiePrepStep === step;
|
|
}, expectedStep, { timeout: 30000 });
|
|
if (expectedStep === 'formation') {
|
|
const operationOrder = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder);
|
|
if (operationOrder?.required && !operationOrder.complete) {
|
|
assert(
|
|
operationOrder.open === true &&
|
|
operationOrder.cards?.length === 3 &&
|
|
operationOrder.cards.every((card) => card.cardBounds),
|
|
`Expected formation entry to open three explicit sortie-order choices: ${JSON.stringify(operationOrder)}`
|
|
);
|
|
const eliteCardPoint = await readSortieOrderControlPoint(page, 'card', 'elite');
|
|
await page.mouse.click(eliteCardPoint.x, eliteCardPoint.y);
|
|
await page.waitForFunction(() => {
|
|
const order = window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder;
|
|
return order?.selectedId === 'elite' && order?.complete === true;
|
|
}, undefined, { timeout: 30000 });
|
|
const closePoint = await readSortieOrderControlPoint(page, 'close');
|
|
await page.mouse.click(closePoint.x, closePoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder?.open === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
}
|
|
}
|
|
await page.waitForTimeout(1000);
|
|
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.());
|
|
const expectedNextStep = expectedStep === 'formation' ? 'loadout' : null;
|
|
assert(
|
|
state?.stagedSortiePrep === true &&
|
|
state?.sortiePrepSteps?.filter((step) => step.active).length === 1 &&
|
|
state?.sortiePrepSteps?.some((step) => step.id === expectedStep && step.active) &&
|
|
state?.sortiePrimaryAction?.kind === (expectedNextStep ? 'next' : 'launch') &&
|
|
state?.sortiePrimaryAction?.nextStep === expectedNextStep,
|
|
`Expected staged sortie navigation to reach ${expectedStep}: ${JSON.stringify(state)}`
|
|
);
|
|
return state;
|
|
}
|
|
|
|
async function readSortieOrderControlPoint(page, control, orderId) {
|
|
const probe = await page.evaluate(({ control, orderId }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp?.();
|
|
const canvas = document.querySelector('canvas');
|
|
const order = state?.sortieOperationOrder;
|
|
const card = order?.cards?.find((candidate) => candidate.id === orderId);
|
|
const bounds = control === 'toggle'
|
|
? order?.toggleButtonBounds
|
|
: control === 'close'
|
|
? order?.closeButtonBounds
|
|
: card?.cardBounds;
|
|
if (!scene || !canvas || !bounds) {
|
|
return { ready: false, control, orderId: orderId ?? null, bounds: bounds ?? null, order };
|
|
}
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
control,
|
|
orderId: orderId ?? null,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, { control, orderId });
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible sortie-order ${control} control: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readCampVictoryRewardControlPoint(page, actionId) {
|
|
const probe = await page.evaluate((requestedActionId) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const acknowledgement = window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement;
|
|
const action = acknowledgement?.actions?.find((candidate) => candidate.id === requestedActionId);
|
|
const bounds = action?.bounds;
|
|
if (!scene || !canvas || !bounds || !action?.interactive) {
|
|
return { ready: false, actionId: requestedActionId, acknowledgement, bounds: bounds ?? null };
|
|
}
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
actionId: requestedActionId,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, actionId);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible camp victory-reward ${actionId} control: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readCampVictoryRewardCardControlPoint(page, cardId) {
|
|
const probe = await page.evaluate((requestedCardId) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const acknowledgement = window.__HEROS_DEBUG__?.camp?.()?.victoryRewardAcknowledgement;
|
|
const card = acknowledgement?.cards?.find((candidate) => candidate.id === requestedCardId);
|
|
const bounds = card?.bounds;
|
|
if (!scene || !canvas || !bounds || !card?.interactive) {
|
|
return { ready: false, cardId: requestedCardId, acknowledgement, bounds: bounds ?? null };
|
|
}
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
cardId: requestedCardId,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, cardId);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected an interactive camp victory-reward ${cardId} card: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function waitForCampAfterBattleResult(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
return activeScenes.includes('CampScene') || activeScenes.includes('StoryScene');
|
|
}, undefined, { timeout: 90000 });
|
|
|
|
let lastStory = null;
|
|
const rewardDisplays = new Map();
|
|
for (let i = 0; i < 48; i += 1) {
|
|
const probe = await page.evaluate(() => ({
|
|
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
|
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null
|
|
}));
|
|
if (probe.story?.isLastPage && probe.story?.progressBounds && probe.story?.progressPanelBounds) {
|
|
lastStory = probe.story;
|
|
}
|
|
if (
|
|
probe.story?.rewardDisplay?.source === 'campaign' &&
|
|
probe.story.rewardDisplay.pageId === probe.story.currentPageId
|
|
) {
|
|
rewardDisplays.set(probe.story.rewardDisplay.pageId, probe.story.rewardDisplay);
|
|
}
|
|
if (probe.activeScenes.includes('CampScene')) {
|
|
await waitForCamp(page);
|
|
return { lastStory, rewardDisplays: [...rewardDisplays.values()] };
|
|
}
|
|
await page.keyboard.press('Space');
|
|
await page.waitForTimeout(240);
|
|
}
|
|
|
|
await waitForCamp(page);
|
|
return { lastStory, rewardDisplays: [...rewardDisplays.values()] };
|
|
}
|
|
|
|
async function advanceStoryUntilEnding(page) {
|
|
for (let i = 0; i < 48; i += 1) {
|
|
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
|
|
if (activeScenes.includes('EndingScene')) {
|
|
return;
|
|
}
|
|
|
|
await page.keyboard.press('Space');
|
|
await page.waitForTimeout(240);
|
|
}
|
|
}
|
|
|
|
async function waitForEnding(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
const ending = window.__HEROS_DEBUG__?.scene('EndingScene')?.getDebugState?.();
|
|
return activeScenes.includes('EndingScene') && ending?.ready === true && ending?.campaignStep === 'ending-complete';
|
|
}, undefined, { timeout: 90000 });
|
|
}
|
|
|
|
async function assertCanvasPainted(page, context) {
|
|
const probe = await page.evaluate(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
if (!canvas) {
|
|
return { readable: false, missing: true, sampleCount: 0, nonTransparentRatio: 0, distinctColorBuckets: 0 };
|
|
}
|
|
|
|
const canvasContext = canvas.getContext('2d', { willReadFrequently: true });
|
|
if (!canvasContext) {
|
|
return { readable: false, missing: false, sampleCount: 0, nonTransparentRatio: 0, distinctColorBuckets: 0 };
|
|
}
|
|
|
|
const stepX = Math.max(1, Math.floor(canvas.width / 64));
|
|
const stepY = Math.max(1, Math.floor(canvas.height / 64));
|
|
const colorBuckets = new Set();
|
|
let sampleCount = 0;
|
|
let nonTransparentCount = 0;
|
|
|
|
for (let y = Math.floor(stepY / 2); y < canvas.height; y += stepY) {
|
|
for (let x = Math.floor(stepX / 2); x < canvas.width; x += stepX) {
|
|
const [r, g, b, a] = canvasContext.getImageData(x, y, 1, 1).data;
|
|
sampleCount += 1;
|
|
if (a > 8) {
|
|
nonTransparentCount += 1;
|
|
}
|
|
colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 5}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
readable: true,
|
|
missing: false,
|
|
sampleCount,
|
|
nonTransparentRatio: sampleCount > 0 ? nonTransparentCount / sampleCount : 0,
|
|
distinctColorBuckets: colorBuckets.size
|
|
};
|
|
});
|
|
|
|
assert(probe.readable === true, `Expected readable canvas pixels during ${context}: ${JSON.stringify(probe)}`);
|
|
assert(probe.sampleCount >= 500, `Expected enough canvas pixel samples during ${context}: ${JSON.stringify(probe)}`);
|
|
assert(probe.nonTransparentRatio > 0.5, `Expected non-empty canvas pixels during ${context}: ${JSON.stringify(probe)}`);
|
|
assert(probe.distinctColorBuckets >= 12, `Expected visually varied canvas pixels during ${context}: ${JSON.stringify(probe)}`);
|
|
}
|
|
|
|
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 readBattleResultEvaluationControlPoint(page, control, presetId) {
|
|
const probe = await page.evaluate(({ control, presetId }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
|
|
const canvas = document.querySelector('canvas');
|
|
const presetCard = evaluation?.presetCards?.find((card) => card.id === presetId);
|
|
const bounds = control === 'toggle'
|
|
? evaluation?.toggleButtonBounds
|
|
: control === 'close'
|
|
? evaluation?.closeButtonBounds
|
|
: control === 'improvement'
|
|
? evaluation?.improvementAction?.buttonBounds
|
|
: presetCard?.actionButtonBounds;
|
|
if (!scene || !canvas || !bounds) {
|
|
return {
|
|
ready: false,
|
|
control,
|
|
presetId: presetId ?? null,
|
|
evaluationOpen: evaluation?.open ?? false,
|
|
bounds: bounds ?? null
|
|
};
|
|
}
|
|
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
control,
|
|
presetId: presetId ?? null,
|
|
evaluationOpen: evaluation?.open ?? false,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, { control, presetId });
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible Phaser battle-result evaluation ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readBattleSortieOrderControlPoint(page, control) {
|
|
const probe = await page.evaluate((control) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder;
|
|
const bounds = control === 'toggle' ? order?.toggleButtonBounds : order?.closeButtonBounds;
|
|
if (!scene || !canvas || !bounds) {
|
|
return { ready: false, control, bounds: bounds ?? null, order };
|
|
}
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
control,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, control);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible battle sortie-order ${control} control: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readCampReportFormationEvaluationControlPoint(page, control, presetId) {
|
|
const probe = await page.evaluate(({ control, presetId }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation;
|
|
const canvas = document.querySelector('canvas');
|
|
const presetCard = evaluation?.presetCards?.find((card) => card.id === presetId);
|
|
const bounds = control === 'toggle'
|
|
? evaluation?.toggleButtonBounds
|
|
: control === 'close'
|
|
? evaluation?.closeButtonBounds
|
|
: control === 'improvement'
|
|
? evaluation?.improvementAction?.buttonBounds
|
|
: presetCard?.actionButtonBounds;
|
|
if (!scene || !canvas || !bounds) {
|
|
return {
|
|
ready: false,
|
|
control,
|
|
presetId: presetId ?? null,
|
|
evaluationOpen: evaluation?.open ?? false,
|
|
bounds: bounds ?? null
|
|
};
|
|
}
|
|
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
control,
|
|
presetId: presetId ?? null,
|
|
evaluationOpen: evaluation?.open ?? false,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, { control, presetId });
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible Phaser camp-report evaluation ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readCampReportFormationHistoryControlPoint(page, control) {
|
|
const probe = await page.evaluate((control) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const history = window.__HEROS_DEBUG__?.camp()?.reportFormationHistory;
|
|
const canvas = document.querySelector('canvas');
|
|
const bounds = control === 'toggle'
|
|
? history?.toggleButtonBounds
|
|
: control === 'close'
|
|
? history?.closeButtonBounds
|
|
: control === 'best'
|
|
? history?.bestButtonBounds
|
|
: control === 'loadBest'
|
|
? history?.loadBestButtonBounds
|
|
: history?.undoButtonBounds;
|
|
if (!scene || !canvas || !bounds) {
|
|
return {
|
|
ready: false,
|
|
control,
|
|
historyOpen: history?.open ?? false,
|
|
loadUndoAvailable: history?.loadUndoAvailable ?? false,
|
|
bounds: bounds ?? null
|
|
};
|
|
}
|
|
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
control,
|
|
historyOpen: history?.open ?? false,
|
|
loadUndoAvailable: history?.loadUndoAvailable ?? false,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, control);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible Phaser camp formation-history ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readCampStaticTextControlPoint(page, label) {
|
|
const probe = await page.evaluate((targetLabel) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const target = scene?.children?.list
|
|
?.filter(
|
|
(object) =>
|
|
object?.type === 'Text' &&
|
|
object.text === targetLabel &&
|
|
object.active &&
|
|
object.visible &&
|
|
object.input?.enabled
|
|
)
|
|
.sort((left, right) => left.y - right.y || left.x - right.x)[0];
|
|
if (!scene || !canvas || !target) {
|
|
return { ready: false, label: targetLabel };
|
|
}
|
|
const bounds = target.getBounds();
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
label: targetLabel,
|
|
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, label);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
'Expected a visible interactive camp command for ' + label + ': ' + JSON.stringify(probe)
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readCampTabControlPoint(page, tab) {
|
|
const probe = await page.evaluate((targetTab) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const button = scene?.tabButtons?.find((candidate) => candidate.tab === targetTab);
|
|
const target = button?.bg;
|
|
if (!scene || !canvas || !target?.active || !target.visible || !target.input?.enabled) {
|
|
return { ready: false, tab: targetTab, availableTabs: scene?.tabButtons?.map((candidate) => candidate.tab) ?? [] };
|
|
}
|
|
const bounds = target.getBounds();
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
tab: targetTab,
|
|
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, tab);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
'Expected a visible camp tab for ' + tab + ': ' + JSON.stringify(probe)
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readSortieTextControlPoint(page, label) {
|
|
const probe = await page.evaluate((targetLabel) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const candidates = (scene?.sortieObjects ?? [])
|
|
.filter(
|
|
(object) =>
|
|
object?.type === 'Text' &&
|
|
object.text === targetLabel &&
|
|
object.active &&
|
|
object.visible &&
|
|
object.input?.enabled
|
|
)
|
|
.sort((left, right) => left.y - right.y || left.x - right.x);
|
|
const target = candidates[0];
|
|
if (!scene || !canvas || !target) {
|
|
return { ready: false, label: targetLabel, candidateCount: candidates.length };
|
|
}
|
|
const bounds = target.getBounds();
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
label: targetLabel,
|
|
candidateCount: candidates.length,
|
|
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, label);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
'Expected a visible interactive sortie control for ' + label + ': ' + JSON.stringify(probe)
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readCampContentTextControlPoint(page, label, occurrence = 0) {
|
|
const probe = await page.evaluate(({ label, occurrence }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const candidates = (scene?.contentObjects ?? [])
|
|
.filter((object) => object?.type === 'Text' && object?.text === label && object?.active && object?.visible)
|
|
.sort((left, right) => left.y - right.y || left.x - right.x);
|
|
const target = candidates[occurrence];
|
|
if (!scene || !canvas || !target) {
|
|
return { ready: false, label, occurrence, candidateCount: candidates.length };
|
|
}
|
|
|
|
const bounds = target.getBounds();
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
label,
|
|
occurrence,
|
|
candidateCount: candidates.length,
|
|
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, { label, occurrence });
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible Phaser camp text control "${label}" with a canvas-scaled click point: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readCampSupplyUseControlPoint(page, supplyLabel) {
|
|
const probe = await page.evaluate((supplyLabel) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const texts = (scene?.contentObjects ?? []).filter((object) => object?.type === 'Text' && object?.active && object?.visible);
|
|
const supplyTitle = texts.find((object) => object.text?.startsWith(`${supplyLabel} x`));
|
|
const useText = texts
|
|
.filter((object) => object.text === '사용')
|
|
.sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0];
|
|
if (!scene || !canvas || !supplyTitle || !useText) {
|
|
return {
|
|
ready: false,
|
|
supplyLabel,
|
|
supplyTitle: supplyTitle?.text ?? null,
|
|
useCount: texts.filter((object) => object.text === '사용').length
|
|
};
|
|
}
|
|
const bounds = useText.getBounds();
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
supplyLabel,
|
|
supplyTitle: supplyTitle.text,
|
|
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, supplyLabel);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible ${supplyLabel} supply use control: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readSortieComparisonActionPoint(page) {
|
|
const probe = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const actionButton = scene?.sortieComparisonPanelView?.actionButton;
|
|
const actionLabel = scene?.sortieComparisonPanelView?.actionLabel;
|
|
const canvas = document.querySelector('canvas');
|
|
if (!scene || !actionButton?.visible || !actionLabel?.visible || !canvas) {
|
|
return {
|
|
ready: false,
|
|
buttonVisible: actionButton?.visible ?? false,
|
|
labelVisible: actionLabel?.visible ?? false,
|
|
label: actionLabel?.text ?? null
|
|
};
|
|
}
|
|
|
|
const bounds = actionButton.getBounds();
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const logicalWidth = scene.scale.width;
|
|
const logicalHeight = scene.scale.height;
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
label: actionLabel.text,
|
|
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
|
|
canvasBounds: {
|
|
x: canvasBounds.x,
|
|
y: canvasBounds.y,
|
|
width: canvasBounds.width,
|
|
height: canvasBounds.height
|
|
},
|
|
logicalSize: { width: logicalWidth, height: logicalHeight },
|
|
x: canvasBounds.left + centerX * canvasBounds.width / logicalWidth,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / logicalHeight
|
|
};
|
|
});
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible Phaser comparison action with a canvas-scaled click point: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readSortiePresetControlPoint(page, control, presetId) {
|
|
const probe = await page.evaluate(({ control, presetId }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const canvas = document.querySelector('canvas');
|
|
const presetCard = state?.sortiePresetBrowser?.cards?.find((card) => card.id === presetId);
|
|
const bounds = control === 'toggle'
|
|
? state?.sortiePresetBrowser?.toggleButtonBounds
|
|
: control === 'close'
|
|
? state?.sortiePresetBrowser?.closeButtonBounds
|
|
: control === 'card'
|
|
? presetCard?.backgroundBounds
|
|
: control === 'compare'
|
|
? presetCard?.compareButtonBounds
|
|
: presetCard?.saveButtonBounds;
|
|
if (!scene || !canvas || !bounds) {
|
|
return { ready: false, control, presetId: presetId ?? null, bounds: bounds ?? null };
|
|
}
|
|
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
control,
|
|
presetId: presetId ?? null,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, { control, presetId });
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible Phaser preset ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
function assertSortiePortraitRosterPage(view, expectedPage, expectedVisibleUnitIds) {
|
|
const expectedStart = expectedVisibleUnitIds.length > 0 ? expectedPage * 6 + 1 : 0;
|
|
const expectedEnd = expectedPage * 6 + expectedVisibleUnitIds.length;
|
|
const hdBounds = desktopBrowserViewportBounds;
|
|
assert(
|
|
view?.enabled === true &&
|
|
view.total === 23 &&
|
|
view.page === expectedPage &&
|
|
view.pageCount === 4 &&
|
|
view.pageSize === 6 &&
|
|
view.visibleCount === expectedVisibleUnitIds.length &&
|
|
view.start === expectedStart &&
|
|
view.end === expectedEnd &&
|
|
sameJsonValue(view.visibleUnitIds, expectedVisibleUnitIds) &&
|
|
view.previousEnabled === (expectedPage > 0) &&
|
|
view.nextEnabled === (expectedPage < 3) &&
|
|
isFiniteBounds(view.previousButtonBounds) &&
|
|
isFiniteBounds(view.nextButtonBounds) &&
|
|
boundsInside(view.previousButtonBounds, hdBounds) &&
|
|
boundsInside(view.nextButtonBounds, hdBounds) &&
|
|
view.cardBounds?.length === expectedVisibleUnitIds.length,
|
|
`Expected exact six-officer portrait page ${expectedPage + 1}/4 metadata and navigation state: ${JSON.stringify({
|
|
expectedVisibleUnitIds,
|
|
view
|
|
})}`
|
|
);
|
|
view.cardBounds.forEach((bounds, index) => {
|
|
assert(
|
|
bounds.unitId === expectedVisibleUnitIds[index] &&
|
|
isFiniteBounds(bounds) &&
|
|
boundsInside(bounds, hdBounds),
|
|
`Expected portrait card ${index + 1} on page ${expectedPage + 1} to match its visible officer inside the FHD canvas: ${JSON.stringify({
|
|
bounds,
|
|
expectedUnitId: expectedVisibleUnitIds[index]
|
|
})}`
|
|
);
|
|
});
|
|
}
|
|
|
|
async function readSortiePortraitRosterNavigationPoint(page, direction) {
|
|
const probe = await page.evaluate((navigationDirection) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
|
|
const bounds = navigationDirection === 'previous' ? view?.previousButtonBounds : view?.nextButtonBounds;
|
|
const enabled = navigationDirection === 'previous' ? view?.previousEnabled : view?.nextEnabled;
|
|
if (!scene || !canvas || !bounds || !enabled) {
|
|
return { ready: false, direction: navigationDirection, enabled: enabled ?? false, bounds: bounds ?? null, view };
|
|
}
|
|
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
direction: navigationDirection,
|
|
enabled,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, direction);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected an enabled portrait roster ${direction} control with a canvas-scaled click point: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readSortiePortraitRosterCardPoint(page, unitId) {
|
|
const target = await page.evaluate((targetUnitId) => {
|
|
const state = window.__HEROS_DEBUG__?.camp();
|
|
const view = state?.sortiePortraitRosterView;
|
|
const unitIndex = state?.sortiePortraitRoster?.findIndex((unit) => unit.id === targetUnitId) ?? -1;
|
|
return {
|
|
ready: Boolean(view?.enabled && unitIndex >= 0 && view.pageSize > 0),
|
|
unitId: targetUnitId,
|
|
unitIndex,
|
|
currentPage: view?.page ?? null,
|
|
targetPage: unitIndex >= 0 && view?.pageSize > 0 ? Math.floor(unitIndex / view.pageSize) : null,
|
|
pageCount: view?.pageCount ?? null
|
|
};
|
|
}, unitId);
|
|
assert(
|
|
target.ready === true &&
|
|
Number.isInteger(target.currentPage) &&
|
|
Number.isInteger(target.targetPage) &&
|
|
target.targetPage >= 0 &&
|
|
target.targetPage < target.pageCount,
|
|
`Expected ${unitId} to belong to a valid portrait roster page: ${JSON.stringify(target)}`
|
|
);
|
|
|
|
let currentPage = target.currentPage;
|
|
while (currentPage !== target.targetPage) {
|
|
const direction = currentPage < target.targetPage ? 'next' : 'previous';
|
|
const navigationPoint = await readSortiePortraitRosterNavigationPoint(page, direction);
|
|
await page.mouse.click(navigationPoint.x, navigationPoint.y);
|
|
const expectedPage = currentPage + (direction === 'next' ? 1 : -1);
|
|
await page.waitForFunction(
|
|
(pageIndex) => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView?.page === pageIndex,
|
|
expectedPage,
|
|
{ timeout: 30000 }
|
|
);
|
|
currentPage = expectedPage;
|
|
}
|
|
await page.waitForFunction(
|
|
({ pageIndex, targetUnitId }) => {
|
|
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
|
|
return view?.page === pageIndex &&
|
|
view.visibleUnitIds?.includes(targetUnitId) &&
|
|
view.cardBounds?.some((card) => card.unitId === targetUnitId);
|
|
},
|
|
{ pageIndex: target.targetPage, targetUnitId: unitId },
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const probe = await page.evaluate((targetUnitId) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
|
|
const bounds = view?.cardBounds?.find((card) => card.unitId === targetUnitId);
|
|
if (!scene || !canvas || !bounds) {
|
|
return { ready: false, unitId: targetUnitId, bounds: bounds ?? null, view };
|
|
}
|
|
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
unitId: targetUnitId,
|
|
bounds,
|
|
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, unitId);
|
|
assert(
|
|
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
|
|
`Expected a visible portrait roster card for ${unitId} with a canvas-scaled pointer point: ${JSON.stringify(probe)}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function readBattleDoctrineProbe(page) {
|
|
return page.evaluate(() => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const roleSeals = (state?.units ?? [])
|
|
.filter((unit) => unit.faction === 'ally')
|
|
.map((unit) => {
|
|
const seal = scene?.unitViews?.get(unit.id)?.roleSeal;
|
|
return {
|
|
unitId: unit.id,
|
|
text: seal?.text ?? null,
|
|
visible: seal?.visible ?? false
|
|
};
|
|
});
|
|
const openingBannerTexts = (scene?.battleEventObjects ?? [])
|
|
.filter((object) => object?.type === 'Text')
|
|
.map((object) => object.text);
|
|
return { ...state, roleSeals, openingBannerTexts };
|
|
});
|
|
}
|
|
|
|
async function seedCampaignSave(page, options) {
|
|
await page.evaluate((seed) => {
|
|
const storageKey = 'heros-web:campaign-state';
|
|
const current = JSON.parse(window.localStorage.getItem(storageKey) ?? 'null');
|
|
if (!current?.firstBattleReport) {
|
|
throw new Error('Expected an existing campaign report before seeding RC save.');
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const objectives = [
|
|
{ id: 'leader', label: '지휘관 격파', achieved: true, status: 'done', detail: '전장 목표 완료', rewardGold: 420 },
|
|
{ id: 'survive', label: '본대 보존', achieved: true, status: 'done', detail: '핵심 장수 생존', rewardGold: 260 },
|
|
{ id: 'supply', label: '보급선 유지', achieved: true, status: 'done', detail: '수레와 군량 확보', rewardGold: 220 },
|
|
{ id: 'quick', label: '빠른 정리', achieved: false, status: 'failed', detail: `${seed.turnNumber}/12턴`, rewardGold: 180 }
|
|
];
|
|
const units = current.roster?.length ? current.roster : current.firstBattleReport.units;
|
|
const bonds = current.bonds?.length ? current.bonds : current.firstBattleReport.bonds;
|
|
const report = {
|
|
...current.firstBattleReport,
|
|
battleId: seed.battleId,
|
|
battleTitle: seed.battleTitle,
|
|
outcome: 'victory',
|
|
turnNumber: seed.turnNumber,
|
|
rewardGold: 1080,
|
|
defeatedEnemies: seed.defeatedEnemies,
|
|
totalEnemies: seed.defeatedEnemies,
|
|
objectives,
|
|
units,
|
|
bonds,
|
|
mvp: current.firstBattleReport.mvp ?? { unitId: 'zhuge-liang', name: '제갈량', damageDealt: 320, defeats: 2 },
|
|
itemRewards: ['상처약 2', '탁주 1'],
|
|
completedCampDialogues: current.completedCampDialogues ?? [],
|
|
completedCampVisits: current.completedCampVisits ?? [],
|
|
createdAt: now
|
|
};
|
|
delete report.sortiePerformance;
|
|
delete report.sortieReview;
|
|
const settlement = {
|
|
battleId: report.battleId,
|
|
battleTitle: report.battleTitle,
|
|
outcome: 'victory',
|
|
rewardGold: report.rewardGold,
|
|
itemRewards: report.itemRewards,
|
|
objectives,
|
|
units: units.map((unit) => ({
|
|
unitId: unit.id,
|
|
name: unit.name,
|
|
level: unit.level ?? 1,
|
|
exp: unit.exp ?? 0,
|
|
hp: unit.hp ?? unit.maxHp ?? 1,
|
|
maxHp: unit.maxHp ?? 1,
|
|
equipment: unit.equipment
|
|
})),
|
|
bonds: bonds.map((bond) => ({
|
|
id: bond.id,
|
|
title: bond.title,
|
|
level: bond.level ?? 1,
|
|
exp: bond.exp ?? 0,
|
|
battleExp: bond.battleExp ?? 0
|
|
})),
|
|
reserveTraining: [],
|
|
completedAt: now
|
|
};
|
|
const pendingVictoryRewardNotice = seed.pendingVictoryRewardNotice === true;
|
|
const acknowledgedVictoryRewardCategories = {
|
|
...(current.acknowledgedVictoryRewardCategories ?? {})
|
|
};
|
|
if (pendingVictoryRewardNotice) {
|
|
delete acknowledgedVictoryRewardCategories[seed.battleId];
|
|
}
|
|
const previouslyRecordedVictoryBattleIds = Object.entries(current.battleHistory ?? {})
|
|
.filter(([, settlement]) => settlement?.outcome === 'victory')
|
|
.map(([battleId]) => battleId);
|
|
if (current.firstBattleReport?.outcome === 'victory') {
|
|
previouslyRecordedVictoryBattleIds.push(current.firstBattleReport.battleId);
|
|
}
|
|
const next = {
|
|
...current,
|
|
updatedAt: now,
|
|
step: seed.step,
|
|
activeSaveSlot: 1,
|
|
gold: seed.gold,
|
|
inventory: { ...(current.inventory ?? {}), 콩: 12, 상처약: 6, 탁주: 4 },
|
|
selectedSortieUnitIds: seed.selectedSortieUnitIds,
|
|
latestBattleId: seed.battleId,
|
|
firstBattleReport: report,
|
|
acknowledgedVictoryRewardBattleIds: pendingVictoryRewardNotice
|
|
? (current.acknowledgedVictoryRewardBattleIds ?? []).filter((battleId) => battleId !== seed.battleId)
|
|
: [...new Set([...(current.acknowledgedVictoryRewardBattleIds ?? []), seed.battleId])],
|
|
acknowledgedVictoryRewardCategories,
|
|
dismissedVictoryRewardNoticeBattleIds: pendingVictoryRewardNotice
|
|
? [
|
|
...new Set([
|
|
...(current.dismissedVictoryRewardNoticeBattleIds ?? []),
|
|
...previouslyRecordedVictoryBattleIds.filter((battleId) => battleId !== seed.battleId)
|
|
])
|
|
].filter((battleId) => battleId !== seed.battleId)
|
|
: [...(current.dismissedVictoryRewardNoticeBattleIds ?? [])],
|
|
battleHistory: {
|
|
...(current.battleHistory ?? {}),
|
|
[seed.battleId]: settlement
|
|
}
|
|
};
|
|
|
|
window.localStorage.setItem(storageKey, JSON.stringify(next));
|
|
window.localStorage.setItem(`${storageKey}:slot-1`, JSON.stringify(next));
|
|
}, options);
|
|
}
|
|
|
|
async function ensurePreviewServer(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', 'preview', '--host', '127.0.0.1', '--port', parsed.port || '4173'], {
|
|
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 < 120; i += 1) {
|
|
if (await canReach(url)) {
|
|
return child;
|
|
}
|
|
await delay(250);
|
|
}
|
|
|
|
child.kill();
|
|
throw new Error(`Vite preview 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));
|
|
}
|
|
|
|
function assertCampSkinState(state, expectedSkinId, label) {
|
|
const campSkin = state?.campSkin;
|
|
const sceneBounds = campSkin?.sceneBounds;
|
|
const transition = campSkin?.transition;
|
|
const fullSceneLayers = [campSkin?.backdrop, campSkin?.wash, campSkin?.vignette];
|
|
const ornamentLayers = [campSkin?.header, campSkin?.accentLine, campSkin?.seal, campSkin?.badge, campSkin?.summary];
|
|
|
|
assert(
|
|
campSkin?.id === expectedSkinId &&
|
|
campSkin.textureReady === true &&
|
|
typeof campSkin.textureKey === 'string' &&
|
|
campSkin.textureKey.length > 0 &&
|
|
campSkin.renderedTextureKey === campSkin.textureKey,
|
|
`Expected ${label} to render the ${expectedSkinId} camp skin texture: ${JSON.stringify(campSkin)}`
|
|
);
|
|
assert(
|
|
isFiniteBounds(sceneBounds) &&
|
|
sceneBounds.x === 0 &&
|
|
sceneBounds.y === 0 &&
|
|
sceneBounds.width === baselineViewport.width &&
|
|
sceneBounds.height === baselineViewport.height,
|
|
`Expected ${label} camp skin to use the 1920x1080 scene bounds: ${JSON.stringify(campSkin)}`
|
|
);
|
|
assert(
|
|
fullSceneLayers.every((view) =>
|
|
isVisibleCampSkinView(view) && boundsCover(view.bounds, sceneBounds)
|
|
),
|
|
`Expected ${label} camp backdrop, color wash, and vignette to cover the FHD scene: ${JSON.stringify(campSkin)}`
|
|
);
|
|
assert(
|
|
ornamentLayers.every((view) =>
|
|
isVisibleCampSkinView(view) && boundsInside(view.bounds, sceneBounds)
|
|
),
|
|
`Expected ${label} camp header ornaments to remain inside the FHD scene: ${JSON.stringify(campSkin)}`
|
|
);
|
|
assert(
|
|
isVisibleCampSkinView(campSkin?.summary) &&
|
|
isVisibleCampSkinView(campSkin?.header) &&
|
|
boundsInside(campSkin.summary.bounds, campSkin.header.bounds),
|
|
`Expected ${label} camp summary to stay on one line inside the header: ${JSON.stringify(campSkin)}`
|
|
);
|
|
assert(
|
|
transition?.active === false &&
|
|
transition.revision > 0 &&
|
|
transition.completedRevision === transition.revision &&
|
|
transition.last?.kind === 'backdrop-fade' &&
|
|
transition.last.skinId === expectedSkinId &&
|
|
transition.last.durationMs >= 250 &&
|
|
transition.last.durationMs <= 600 &&
|
|
transition.last.fromAlpha === 0 &&
|
|
transition.last.toAlpha > 0 &&
|
|
transition.last.toAlpha <= 1 &&
|
|
Math.abs(transition.last.toAlpha - campSkin.backdrop.alpha) <= 0.01,
|
|
`Expected ${label} camp backdrop transition to finish cleanly: ${JSON.stringify(campSkin)}`
|
|
);
|
|
}
|
|
|
|
function assertCampSoundscapeState(state, expected, label) {
|
|
const soundscape = state?.campSoundscape;
|
|
const audio = state?.audio;
|
|
|
|
assert(
|
|
soundscape?.skinId === state?.campSkin?.id &&
|
|
soundscape.musicKey === expected.musicKey &&
|
|
soundscape.ambienceKey === expected.ambienceKey &&
|
|
Math.abs(soundscape.musicVolume - expected.musicVolume) <= 0.001 &&
|
|
Math.abs(soundscape.ambienceVolume - expected.ambienceVolume) <= 0.001,
|
|
`Expected ${label} to select its era soundscape profile: ${JSON.stringify(soundscape)}`
|
|
);
|
|
|
|
for (const [channelName, expectedKey, expectedVolume, minFadeMs, maxFadeMs] of [
|
|
['music', expected.musicKey, expected.musicVolume, 700, 1100],
|
|
['ambience', expected.ambienceKey, expected.ambienceVolume, 1000, 1400]
|
|
]) {
|
|
const channel = audio?.[channelName];
|
|
assert(
|
|
channel?.currentKey === expectedKey &&
|
|
channel.pendingKey === null &&
|
|
channel.hasPendingRequest === false &&
|
|
channel.activeElementCount === 1 &&
|
|
channel.elementRevision > 0 &&
|
|
Math.abs(channel.targetVolume - expectedVolume) <= 0.001 &&
|
|
channel.transition?.active === false &&
|
|
channel.transition.revision > 0 &&
|
|
channel.transition.completedRevision === channel.transition.revision &&
|
|
channel.transition.last?.toKey === expectedKey &&
|
|
channel.transition.last.durationMs >= minFadeMs &&
|
|
channel.transition.last.durationMs <= maxFadeMs,
|
|
`Expected ${label} ${channelName} channel to finish one clean crossfade: ${JSON.stringify(channel)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function isVisibleCampSkinView(view) {
|
|
return Boolean(
|
|
view?.visible === true &&
|
|
Number.isFinite(view.alpha) &&
|
|
view.alpha > 0 &&
|
|
view.alpha <= 1 &&
|
|
Number.isFinite(view.depth) &&
|
|
isFiniteBounds(view.bounds)
|
|
);
|
|
}
|
|
|
|
function isFiniteBounds(bounds) {
|
|
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
|
|
);
|
|
}
|
|
|
|
function boundsInside(inner, outer, tolerance = 1) {
|
|
return isFiniteBounds(inner) && isFiniteBounds(outer) &&
|
|
inner.x >= outer.x - tolerance &&
|
|
inner.y >= outer.y - tolerance &&
|
|
inner.x + inner.width <= outer.x + outer.width + tolerance &&
|
|
inner.y + inner.height <= outer.y + outer.height + tolerance;
|
|
}
|
|
|
|
function boundsCover(outer, inner, tolerance = 1) {
|
|
return isFiniteBounds(outer) && isFiniteBounds(inner) &&
|
|
outer.x <= inner.x + tolerance &&
|
|
outer.y <= inner.y + tolerance &&
|
|
outer.x + outer.width >= inner.x + inner.width - tolerance &&
|
|
outer.y + outer.height >= inner.y + inner.height - tolerance;
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
function assertUnique(values, message) {
|
|
assert(Array.isArray(values) && values.length > 0, message);
|
|
assert(values.every(Boolean), message);
|
|
assert(new Set(values).size === values.length, message);
|
|
}
|
|
|
|
function assertSameMembers(values, expected, message) {
|
|
assert(Array.isArray(values) && Array.isArray(expected), message);
|
|
assert(values.length === expected.length, message);
|
|
const valueSet = new Set(values);
|
|
assert(expected.every((value) => valueSet.has(value)), message);
|
|
}
|
|
|
|
function selectedAssignmentsMatch(actual, expected, selectedUnitIds) {
|
|
return Boolean(
|
|
actual && expected && Array.isArray(selectedUnitIds) &&
|
|
selectedUnitIds.every((unitId) => actual[unitId] === expected[unitId])
|
|
);
|
|
}
|
|
|
|
function guidedImprovementHasOneAction(proposal) {
|
|
if (!proposal) {
|
|
return false;
|
|
}
|
|
const baselineIds = proposal.baselineSelectedUnitIds ?? [];
|
|
const projectedIds = proposal.projectedSelectedUnitIds ?? [];
|
|
const idSet = new Set([...baselineIds, ...projectedIds]);
|
|
const rosterDelta = [...idSet].filter(
|
|
(unitId) => baselineIds.includes(unitId) !== projectedIds.includes(unitId)
|
|
);
|
|
const roleDelta = [...idSet].filter(
|
|
(unitId) => proposal.baselineFormationAssignments?.[unitId] !== proposal.projectedFormationAssignments?.[unitId]
|
|
);
|
|
if (proposal.kind === 'swap') {
|
|
return baselineIds.length === projectedIds.length &&
|
|
rosterDelta.length === 2 &&
|
|
!projectedIds.includes(proposal.outgoingUnitId) &&
|
|
projectedIds.includes(proposal.incomingUnitId);
|
|
}
|
|
if (proposal.kind === 'add') {
|
|
return projectedIds.length === baselineIds.length + 1 &&
|
|
rosterDelta.length === 1 &&
|
|
rosterDelta[0] === proposal.incomingUnitId &&
|
|
projectedIds.includes(proposal.incomingUnitId);
|
|
}
|
|
if (proposal.kind === 'role') {
|
|
return sameJsonValue(baselineIds, projectedIds) &&
|
|
roleDelta.length === 1 &&
|
|
roleDelta[0] === proposal.unitId;
|
|
}
|
|
return proposal.kind === 'tactic' &&
|
|
sameJsonValue(baselineIds, projectedIds) &&
|
|
roleDelta.length === 0 &&
|
|
typeof proposal.instruction === 'string' &&
|
|
proposal.instruction.length > 0;
|
|
}
|
|
|
|
function sameJsonValue(value, expected) {
|
|
return stableJson(value) === stableJson(expected);
|
|
}
|
|
|
|
function campaignSaveWithoutCampRecruitmentEffects(save) {
|
|
const stableState = (state) => {
|
|
if (!state) {
|
|
return state;
|
|
}
|
|
const {
|
|
updatedAt: _updatedAt,
|
|
roster: _roster,
|
|
bonds: _bonds,
|
|
acknowledgedVictoryRewardBattleIds: _acknowledgedVictoryRewardBattleIds,
|
|
acknowledgedVictoryRewardCategories: _acknowledgedVictoryRewardCategories,
|
|
firstBattleReport,
|
|
...stableFields
|
|
} = state;
|
|
if (!firstBattleReport) {
|
|
return stableFields;
|
|
}
|
|
const {
|
|
units: _reportUnits,
|
|
bonds: _reportBonds,
|
|
...stableReportFields
|
|
} = firstBattleReport;
|
|
return {
|
|
...stableFields,
|
|
firstBattleReport: stableReportFields
|
|
};
|
|
};
|
|
|
|
return {
|
|
current: stableState(save?.current),
|
|
slot1: stableState(save?.slot1)
|
|
};
|
|
}
|
|
|
|
function campaignVictoryAcknowledgementSnapshot(save, battleId) {
|
|
const stateSnapshot = (state) => ({
|
|
battleAcknowledged: (state?.acknowledgedVictoryRewardBattleIds ?? []).includes(battleId),
|
|
categories: state?.acknowledgedVictoryRewardCategories?.[battleId] ?? []
|
|
});
|
|
return {
|
|
current: stateSnapshot(save?.current),
|
|
slot1: stateSnapshot(save?.slot1)
|
|
};
|
|
}
|
|
|
|
function campaignVictoryNoticeDismissalSnapshot(save, battleId) {
|
|
const stateSnapshot = (state) => (state?.dismissedVictoryRewardNoticeBattleIds ?? []).includes(battleId);
|
|
return {
|
|
current: stateSnapshot(save?.current),
|
|
slot1: stateSnapshot(save?.slot1)
|
|
};
|
|
}
|
|
|
|
function campaignSaveWithoutVictoryRewardNoticeDismissals(save) {
|
|
const stableState = (state) => {
|
|
if (!state) {
|
|
return state;
|
|
}
|
|
const {
|
|
updatedAt: _updatedAt,
|
|
dismissedVictoryRewardNoticeBattleIds: _dismissedVictoryRewardNoticeBattleIds,
|
|
...stableFields
|
|
} = state;
|
|
return stableFields;
|
|
};
|
|
return {
|
|
current: stableState(save?.current),
|
|
slot1: stableState(save?.slot1)
|
|
};
|
|
}
|
|
|
|
function recommendationFeedbackSemantic(feedback) {
|
|
if (!feedback) {
|
|
return null;
|
|
}
|
|
return {
|
|
...feedback,
|
|
panelBounds: null,
|
|
displayedUnitIds: [],
|
|
rows: (feedback.rows ?? []).map((row) => ({
|
|
...row,
|
|
cardBounds: null,
|
|
nameBounds: null,
|
|
statusBounds: null,
|
|
displayText: null
|
|
}))
|
|
};
|
|
}
|
|
|
|
function sameSortieOrderCommand(value, expected) {
|
|
const commandFields = [
|
|
'source',
|
|
'role',
|
|
'actorId',
|
|
'usedTurn',
|
|
'effectPending',
|
|
'effectValue',
|
|
'statusesCleared',
|
|
'effectLost'
|
|
];
|
|
const snapshot = (command) => command
|
|
? Object.fromEntries(commandFields.map((field) => [field, command[field]]))
|
|
: null;
|
|
return sameJsonValue(snapshot(value), snapshot(expected));
|
|
}
|
|
|
|
function stableJson(value) {
|
|
return JSON.stringify(normalize(value));
|
|
|
|
function normalize(entry) {
|
|
if (Array.isArray(entry)) {
|
|
return entry.map(normalize);
|
|
}
|
|
if (entry && typeof entry === 'object') {
|
|
return Object.fromEntries(
|
|
Object.keys(entry)
|
|
.sort()
|
|
.map((key) => [key, normalize(entry[key])])
|
|
);
|
|
}
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
function assertDeploymentMatchesPreview(positions, preview, message) {
|
|
assert(Array.isArray(positions) && Array.isArray(preview), message);
|
|
assert(positions.length === preview.length, message);
|
|
const positionsById = new Map(positions.map((position) => [position.id, position]));
|
|
assert(
|
|
preview.every((slot) => {
|
|
const position = positionsById.get(slot.unitId);
|
|
return position?.x === slot.x && position?.y === slot.y;
|
|
}),
|
|
message
|
|
);
|
|
}
|
|
|
|
function assertSortieDoctrine(probe, options = {}) {
|
|
const doctrine = probe?.sortieDoctrine;
|
|
const expectedSealByRole = { front: '전', flank: '돌', support: '후' };
|
|
const expectedRoles = Object.keys(expectedSealByRole);
|
|
const roles = doctrine?.roles ?? [];
|
|
const roleById = new Map(roles.map((role) => [role.role, role]));
|
|
const allyUnits = (probe?.units ?? []).filter((unit) => unit.faction === 'ally');
|
|
const sealsByUnitId = new Map((probe?.roleSeals ?? []).map((seal) => [seal.unitId, seal]));
|
|
const effectUnits = allyUnits.filter((unit) => expectedRoles.includes(unit.formationRole));
|
|
const reserveUnits = allyUnits.filter((unit) => unit.formationRole === 'reserve');
|
|
const coveredRoleCount = roles.filter((role) => role.unitIds.length > 0).length;
|
|
|
|
assert(doctrine?.active === true, `Expected sortie doctrine to be active: ${JSON.stringify(probe)}`);
|
|
assert(doctrine?.openingBannerShown === true, `Expected sortie doctrine opening banner to be shown: ${JSON.stringify(doctrine)}`);
|
|
assert(
|
|
roles.length === expectedRoles.length && expectedRoles.every((role) => roleById.has(role)),
|
|
`Expected doctrine debug state to expose front, flank, and support roles: ${JSON.stringify(doctrine)}`
|
|
);
|
|
assert(
|
|
doctrine.coveredRoleCount === coveredRoleCount,
|
|
`Expected doctrine role coverage to match non-empty role groups: ${JSON.stringify(doctrine)}`
|
|
);
|
|
if (options.requireFullCoverage) {
|
|
assert(coveredRoleCount === 3, `Expected all three doctrine roles to be covered: ${JSON.stringify(doctrine)}`);
|
|
}
|
|
|
|
assert(effectUnits.length > 0, `Expected at least one deployed officer with a doctrine role effect: ${JSON.stringify(allyUnits)}`);
|
|
effectUnits.forEach((unit) => {
|
|
const role = roleById.get(unit.formationRole);
|
|
const seal = sealsByUnitId.get(unit.id);
|
|
assert(
|
|
role?.unitIds.includes(unit.id) &&
|
|
unit.sortieRoleEffect?.label === role.label &&
|
|
unit.sortieRoleEffect?.summary === role.summary,
|
|
`Expected ${unit.id} to expose the assigned ${unit.formationRole} doctrine effect: ${JSON.stringify({ unit, role })}`
|
|
);
|
|
assert(
|
|
seal?.text === expectedSealByRole[unit.formationRole] && unit.roleSealVisible === seal.visible,
|
|
`Expected ${unit.id} to show the ${expectedSealByRole[unit.formationRole]} role seal: ${JSON.stringify({ unit, seal })}`
|
|
);
|
|
if (options.requireVisibleSeals) {
|
|
assert(
|
|
unit.roleSealVisible === true && seal.visible === true,
|
|
`Expected ${unit.id} role seal to be visible in the opening formation: ${JSON.stringify({ unit, seal })}`
|
|
);
|
|
}
|
|
});
|
|
|
|
if (options.requireReserve) {
|
|
assert(reserveUnits.length > 0, `Expected the doctrine probe to include a reserve officer: ${JSON.stringify(allyUnits)}`);
|
|
}
|
|
reserveUnits.forEach((unit) => {
|
|
const seal = sealsByUnitId.get(unit.id);
|
|
assert(
|
|
unit.sortieRoleEffect === null &&
|
|
unit.roleSealVisible === false &&
|
|
seal?.visible === false &&
|
|
seal?.text === null &&
|
|
roles.every((role) => !role.unitIds.includes(unit.id)),
|
|
`Expected reserve officer ${unit.id} to have no doctrine effect or role seal: ${JSON.stringify({ unit, seal, doctrine })}`
|
|
);
|
|
});
|
|
}
|
|
|
|
function runCommand(command, args, env = {}) {
|
|
const result = spawnSync(command, args, { cwd: process.cwd(), env: { ...process.env, ...env }, stdio: 'inherit' });
|
|
if (result.status !== 0) {
|
|
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
|
|
}
|
|
}
|