3546 lines
183 KiB
JavaScript
3546 lines
183 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { chromium } from 'playwright';
|
|
|
|
const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/');
|
|
const screenshotDir = 'dist';
|
|
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;
|
|
|
|
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({ viewport: { width: 1280, height: 720 } });
|
|
const consoleMessages = [];
|
|
const pageErrors = [];
|
|
const requestFailures = [];
|
|
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'
|
|
});
|
|
});
|
|
|
|
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => window.localStorage.clear());
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await assertReleaseShellReady(page);
|
|
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)}`);
|
|
|
|
await page.mouse.click(962, 240);
|
|
await waitForStoryReady(page);
|
|
await setDebugQueryParam(page, 'debugOrderCommandReady', '1');
|
|
await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'new game story');
|
|
await advanceUntilBattle(page, 'first-battle-zhuo-commandery');
|
|
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);
|
|
return {
|
|
battleId: state?.battleId,
|
|
objectiveText: scene?.objectiveTrackerText?.text,
|
|
objectiveSubText: scene?.objectiveTrackerSubText?.text,
|
|
sideTexts
|
|
};
|
|
|
|
function textValues(objects = []) {
|
|
return objects.filter((object) => object?.type === 'Text').map((object) => object.text);
|
|
}
|
|
});
|
|
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.sideTexts.some((text) => text.includes('출진 군세')) &&
|
|
!firstBattleProbe.sideTexts.some((text) => text.includes('...')),
|
|
`Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}`
|
|
);
|
|
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');
|
|
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(resultProbe.resultTexts.some((text) => text.includes('목표 보상')), `Expected reward panel to name objective rewards: ${JSON.stringify(resultProbe.resultTexts)}`);
|
|
assert(
|
|
resultProbe.resultTexts.some((text) => text.includes(`해금 ${firstBattleUnlockLabel}`)),
|
|
`Expected result reward panel to surface first battle unlock label: ${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 firstResultSave = await readCampaignSave(page);
|
|
const firstSortieOrder = firstResultState?.sortieOperationOrder;
|
|
const firstSortieOrderResult = firstSortieOrder?.result;
|
|
const firstSortieOrderCommand = firstSortieOrderResult?.command;
|
|
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(
|
|
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 page.mouse.click(738, 642);
|
|
await waitForCampAfterBattleResult(page);
|
|
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)}`);
|
|
assert(
|
|
firstCampProbe.state?.nextSortieBattleId === 'second-battle-yellow-turban-pursuit',
|
|
`Expected first camp sortie flow to prepare unlocked second battle: ${JSON.stringify(firstCampProbe.state)}`
|
|
);
|
|
assert(
|
|
['liu-bei', 'guan-yu', 'zhang-fei'].every((unitId) =>
|
|
firstCampProbe.state?.sortieDeploymentPreview?.some((slot) => slot.unitId === unitId)
|
|
),
|
|
`Expected first camp deployment preview to keep the founding trio assigned: ${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('보유 군자금'), `Expected camp summary to show held gold: ${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)}`
|
|
);
|
|
|
|
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 page.mouse.click(798, 38);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'supplies',
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
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 firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? [];
|
|
const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? [];
|
|
await page.mouse.click(1160, 38);
|
|
await waitForSortiePrep(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',
|
|
`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 firstSortieSynergy = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieSynergyPreview);
|
|
assert(
|
|
firstSortieSynergy?.activeBondCount === 3 &&
|
|
firstSortieSynergy?.coveredRoleCount === 3 &&
|
|
firstSortieSynergy?.strongestBond?.damageBonus === 9 &&
|
|
firstSortieSynergy?.strongestBond?.chainRate === 18 &&
|
|
firstSortieSynergy?.firstPursuitTrinityConfigured === true,
|
|
`Expected first sortie to preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieSynergy)}`
|
|
);
|
|
await advanceSortiePrepStep(page, 'loadout');
|
|
await page.mouse.click(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.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 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;
|
|
window.localStorage.setItem(campaignSlotKey, JSON.stringify(campaign));
|
|
scene.loadBattleState?.(1);
|
|
};
|
|
|
|
scene.saveBattleState?.(1);
|
|
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();
|
|
|
|
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.loadBattleState?.(1);
|
|
const restored = captureUnits();
|
|
storageSnapshot.forEach((value, key) => {
|
|
if (value === null) {
|
|
window.localStorage.removeItem(key);
|
|
} else {
|
|
window.localStorage.setItem(key, value);
|
|
}
|
|
});
|
|
return { changed, allReserve, restored };
|
|
});
|
|
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?.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)}`
|
|
);
|
|
|
|
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 page.mouse.click(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 page.mouse.click(1160, 38);
|
|
await waitForSortiePrep(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 > 8 &&
|
|
midBriefingState?.sortiePortraitRosterView?.visibleCount === 8 &&
|
|
midBriefingState?.sortiePortraitRosterView?.start === 1 &&
|
|
midBriefingState?.sortiePortraitRosterView?.end === 8,
|
|
`Expected the mid-campaign staged formation to expose an eight-card portrait roster window: ${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 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
|
|
})}`
|
|
);
|
|
await page.mouse.click(210, 447);
|
|
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)}`
|
|
);
|
|
|
|
await page.mouse.move(530, 544);
|
|
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,
|
|
`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('공백 돌파') &&
|
|
midFormationSwapPreviewProbe.panel.detail?.includes('+맥성 북문') &&
|
|
midFormationSwapPreviewProbe.panel.detail?.includes('-산길과 말발굽') &&
|
|
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 page.mouse.move(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)}`
|
|
);
|
|
|
|
await page.mouse.move(530, 544);
|
|
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(530, 544);
|
|
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 midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
|
|
await page.mouse.click(658, 196);
|
|
await page.waitForFunction((previousScroll) => {
|
|
const view = window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView;
|
|
return view?.scroll > previousScroll;
|
|
}, midFormationFirstPage?.scroll ?? 0, { timeout: 30000 });
|
|
const midFormationSecondPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
|
|
assert(
|
|
midFormationSecondPage?.start > 1 && midFormationSecondPage?.end > 8,
|
|
`Expected the portrait roster next-page control to reveal later officers: ${JSON.stringify(midFormationSecondPage)}`
|
|
);
|
|
await page.mouse.click(610, 196);
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView?.scroll === 0, undefined, { timeout: 30000 });
|
|
|
|
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 page.mouse.click(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)}`
|
|
);
|
|
|
|
const midCampSelectedSortieUnitIds = reserveDoctrineSetup.state?.selectedSortieUnitIds ?? [];
|
|
const midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? [];
|
|
const midCampFormationAssignments = reserveDoctrineSetup.state?.sortieFormationAssignments ?? {};
|
|
const midCampItemAssignments = reserveDoctrineSetup.state?.sortieItemAssignments ?? {};
|
|
await page.mouse.click(1160, 38);
|
|
await waitForSortiePrep(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 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),
|
|
`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 page.mouse.click(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}`);
|
|
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)}`
|
|
);
|
|
assertSortieDoctrine(midNextBattleProbe, { requireReserve: true });
|
|
assert(
|
|
midNextBattleProbe.sortieDoctrine?.coveredRoleCount < 3,
|
|
`Expected the seeded mid-campaign formation to allow a missing doctrine role: ${JSON.stringify(midNextBattleProbe.sortieDoctrine)}`
|
|
);
|
|
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,
|
|
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,
|
|
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 < 3,
|
|
`Expected the mid-campaign evaluation to reflect the deterministic contribution, injury, and missing role: ${JSON.stringify(midResultState?.sortieEvaluation)}`
|
|
);
|
|
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(
|
|
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');
|
|
assert(
|
|
midEvaluationProbe.texts.includes('이번 편성 평가') &&
|
|
midEvaluationProbe.texts.some((text) => text.includes('기동') && text.includes('갱신')) &&
|
|
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 closeMidBattleEvaluationPoint = await readBattleResultEvaluationControlPoint(page, 'close');
|
|
await page.mouse.click(closeMidBattleEvaluationPoint.x, closeMidBattleEvaluationPoint.y);
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false,
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
await page.mouse.click(738, 642);
|
|
await waitForCampAfterBattleResult(page);
|
|
|
|
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(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'
|
|
);
|
|
assert(
|
|
openedMidCampReviewProbe.state?.reportFormationEvaluation?.open === true &&
|
|
openedMidCampReviewProbe.texts.includes('최근 전투 편성 평가') &&
|
|
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']
|
|
});
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await waitForTitle(page);
|
|
await page.mouse.click(962, 310);
|
|
await waitForCamp(page);
|
|
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)}`);
|
|
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)}`
|
|
);
|
|
await page.screenshot({ path: `${screenshotDir}/rc-final-camp.png`, fullPage: true });
|
|
await assertCanvasPainted(page, 'final camp');
|
|
|
|
await page.mouse.click(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 page.mouse.click(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 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 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) {
|
|
for (let i = 0; i < 48; i += 1) {
|
|
const ready = await page.evaluate((expectedBattleId) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
state?.scene === 'BattleScene' &&
|
|
state?.battleId === expectedBattleId &&
|
|
(state?.phase === 'deployment' || state?.phase === 'idle') &&
|
|
state?.mapBackgroundReady === true
|
|
);
|
|
}, battleId);
|
|
|
|
if (ready) {
|
|
await startDeploymentIfNeeded(page, battleId);
|
|
return;
|
|
}
|
|
|
|
await page.keyboard.press('Space');
|
|
await page.waitForTimeout(220);
|
|
}
|
|
|
|
await waitForBattleReady(page, battleId);
|
|
}
|
|
|
|
async function waitForBattleReady(page, battleId) {
|
|
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 });
|
|
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 page.mouse.click(1085, 637);
|
|
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') &&
|
|
window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' &&
|
|
(scene?.contentObjects?.length ?? 0) > 0
|
|
);
|
|
}, undefined, { timeout: 90000 });
|
|
}
|
|
|
|
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 advanceSortiePrepStep(page, expectedStep) {
|
|
await page.mouse.click(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 waitForCampAfterBattleResult(page) {
|
|
await page.waitForFunction(() => {
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
|
|
return activeScenes.includes('CampScene') || activeScenes.includes('StoryScene');
|
|
}, undefined, { timeout: 90000 });
|
|
|
|
for (let i = 0; i < 48; i += 1) {
|
|
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
|
|
if (activeScenes.includes('CampScene')) {
|
|
await waitForCamp(page);
|
|
return;
|
|
}
|
|
await page.keyboard.press('Space');
|
|
await page.waitForTimeout(240);
|
|
}
|
|
|
|
await waitForCamp(page);
|
|
}
|
|
|
|
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
|
|
: 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
|
|
: 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 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;
|
|
}
|
|
|
|
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 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,
|
|
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 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 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 sameJsonValue(value, expected) {
|
|
return stableJson(value) === stableJson(expected);
|
|
}
|
|
|
|
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(' ')}`);
|
|
}
|
|
}
|