1112 lines
31 KiB
JavaScript
1112 lines
31 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { chromium } from 'playwright';
|
|
import { createServer } from 'vite';
|
|
import {
|
|
desktopBrowserContextOptions,
|
|
desktopBrowserDeviceScaleFactor,
|
|
desktopBrowserViewport
|
|
} from './desktop-browser-viewport.mjs';
|
|
|
|
const sourceBattleId = 'third-battle-guangzong-road';
|
|
const priorityBadgePattern = /우선/;
|
|
const resonanceChecklistLabel = '공명 대화';
|
|
|
|
const fixtures = [
|
|
{
|
|
id: 'canvas-fort-missed-quick-missed',
|
|
renderer: 'canvas',
|
|
expectedRendererType: 1,
|
|
fortAchieved: false,
|
|
quickAchieved: false,
|
|
sourceTurn: 34,
|
|
expectedVariant: 'fort-recovery',
|
|
expectedTargetDialogueId: 'liu-guan-after-guangzong-road'
|
|
},
|
|
{
|
|
id: 'canvas-fort-missed-quick-achieved',
|
|
renderer: 'canvas',
|
|
expectedRendererType: 1,
|
|
fortAchieved: false,
|
|
quickAchieved: true,
|
|
sourceTurn: 24,
|
|
expectedVariant: 'fort-recovery',
|
|
expectedTargetDialogueId: 'liu-guan-after-guangzong-road'
|
|
},
|
|
{
|
|
id: 'canvas-fort-achieved-quick-missed',
|
|
renderer: 'canvas',
|
|
expectedRendererType: 1,
|
|
fortAchieved: true,
|
|
quickAchieved: false,
|
|
sourceTurn: 34,
|
|
expectedVariant: 'tempo-recovery',
|
|
expectedTargetDialogueId: 'liu-zhang-after-guangzong-road'
|
|
},
|
|
{
|
|
id: 'canvas-fort-achieved-quick-achieved',
|
|
renderer: 'canvas',
|
|
expectedRendererType: 1,
|
|
fortAchieved: true,
|
|
quickAchieved: true,
|
|
sourceTurn: 24,
|
|
expectedVariant: 'decisive-advance',
|
|
expectedTargetDialogueId: 'guan-zhang-after-guangzong-road'
|
|
},
|
|
{
|
|
id: 'webgl-fort-achieved-quick-achieved',
|
|
renderer: 'webgl',
|
|
expectedRendererType: 2,
|
|
fortAchieved: true,
|
|
quickAchieved: true,
|
|
sourceTurn: 24,
|
|
expectedVariant: 'decisive-advance',
|
|
expectedTargetDialogueId: 'guan-zhang-after-guangzong-road'
|
|
}
|
|
];
|
|
|
|
const requestedRenderer =
|
|
cliOption('renderer') ??
|
|
process.env.VERIFY_THIRD_BATTLE_PRIORITY_RETURN_RENDERER ??
|
|
'both';
|
|
assert(
|
|
['canvas', 'webgl', 'both'].includes(requestedRenderer),
|
|
`Unknown renderer "${requestedRenderer}". Use canvas, webgl, or both.`
|
|
);
|
|
const selectedFixtures = fixtures.filter(
|
|
({ renderer }) =>
|
|
requestedRenderer === 'both' || renderer === requestedRenderer
|
|
);
|
|
|
|
const server = await createServer({
|
|
logLevel: 'error',
|
|
server: { host: '127.0.0.1', port: 0, hmr: false },
|
|
appType: 'spa'
|
|
});
|
|
let browser;
|
|
|
|
try {
|
|
mkdirSync('dist', { recursive: true });
|
|
await server.listen();
|
|
const address = server.httpServer?.address();
|
|
assert(
|
|
address && typeof address !== 'string',
|
|
'Expected a local Vite verification server.'
|
|
);
|
|
const baseUrl = `http://127.0.0.1:${address.port}/heros_web/`;
|
|
browser = await chromium.launch({
|
|
headless:
|
|
process.env.VERIFY_THIRD_BATTLE_PRIORITY_RETURN_HEADLESS !==
|
|
'0'
|
|
});
|
|
|
|
for (const fixture of selectedFixtures) {
|
|
await verifyFixture(browser, baseUrl, fixture);
|
|
}
|
|
|
|
console.log(
|
|
`Third-battle priority-return browser verification passed for ${selectedFixtures
|
|
.map(({ id }) => id)
|
|
.join(', ')} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, DPR ${desktopBrowserDeviceScaleFactor}.`
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await server.close();
|
|
}
|
|
|
|
async function verifyFixture(browser, baseUrl, fixture) {
|
|
const context = await browser.newContext(desktopBrowserContextOptions);
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30000);
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
page.on('pageerror', (error) =>
|
|
pageErrors.push(error.stack ?? error.message)
|
|
);
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
consoleErrors.push(message.text());
|
|
}
|
|
});
|
|
|
|
try {
|
|
const url = new URL(baseUrl);
|
|
url.searchParams.set('debug', '1');
|
|
url.searchParams.set('renderer', fixture.renderer);
|
|
await page.goto(url.toString(), {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await assertFhdViewport(page, fixture);
|
|
|
|
const seeded = await seedThirdVictoryCamp(page, fixture);
|
|
assert.deepEqual(
|
|
seeded,
|
|
{
|
|
step: 'third-camp',
|
|
latestBattleId: sourceBattleId,
|
|
reportBattleId: sourceBattleId,
|
|
settlementOutcome: 'victory',
|
|
sourceTurn: fixture.sourceTurn,
|
|
fortAchieved: fixture.fortAchieved,
|
|
quickAchieved: fixture.quickAchieved,
|
|
dialogueCompleted: false,
|
|
dialogueChoiceId: null
|
|
},
|
|
`${fixture.id}: unexpected seeded campaign state.`
|
|
);
|
|
|
|
await page.evaluate(async () => {
|
|
await window.__HEROS_DEBUG__.goToCamp();
|
|
});
|
|
let camp = await waitForPriorityReturn(
|
|
page,
|
|
fixture,
|
|
false
|
|
);
|
|
assert.equal(camp.campaign.step, 'third-camp');
|
|
assert.equal(camp.campBattleId, sourceBattleId);
|
|
assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, {
|
|
completed: false,
|
|
requireRowBounds: false
|
|
});
|
|
|
|
const dialogueTab = camp.campTabs.find(
|
|
({ id }) => id === 'dialogue'
|
|
);
|
|
assert(dialogueTab, `${fixture.id}: dialogue tab is missing.`);
|
|
assert.equal(dialogueTab.interactive, true);
|
|
assert.equal(
|
|
dialogueTab.newBadgeVisible,
|
|
true,
|
|
`${fixture.id}: the dialogue tab must advertise the pending priority return.`
|
|
);
|
|
assert.match(
|
|
dialogueTab.newBadgeText ?? '',
|
|
priorityBadgePattern,
|
|
`${fixture.id}: the dialogue tab badge must identify the priority return.`
|
|
);
|
|
assertBoundsInsideViewport(
|
|
dialogueTab.bounds,
|
|
`${fixture.id} dialogue tab`
|
|
);
|
|
assert.equal(
|
|
resonanceChecklist(camp).complete,
|
|
false,
|
|
`${fixture.id}: resonance dialogue must remain incomplete before the priority return.`
|
|
);
|
|
await capture(
|
|
page,
|
|
`dist/verification-third-battle-priority-return-${fixture.id}-tab-badge.png`
|
|
);
|
|
|
|
await clickSceneBounds(page, 'CampScene', dialogueTab.bounds);
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__HEROS_DEBUG__?.camp?.()?.activeTab ===
|
|
'dialogue'
|
|
);
|
|
camp = await waitForPriorityReturn(page, fixture, false, true);
|
|
const priorityReturn = camp.thirdBattlePriorityReturn;
|
|
assertPriorityReturn(priorityReturn, fixture, {
|
|
completed: false,
|
|
requireRowBounds: true
|
|
});
|
|
assertBoundsInsideViewport(
|
|
priorityReturn.rowBounds,
|
|
`${fixture.id} priority dialogue row`
|
|
);
|
|
|
|
let badgeProbe = await readPriorityRowBadge(
|
|
page,
|
|
priorityReturn.rowBounds
|
|
);
|
|
assert.equal(
|
|
badgeProbe.markers.length,
|
|
1,
|
|
`${fixture.id}: expected exactly one visible priority marker in the target row: ${JSON.stringify(badgeProbe)}`
|
|
);
|
|
assert.match(
|
|
badgeProbe.markers[0].text,
|
|
priorityBadgePattern
|
|
);
|
|
|
|
const rowControl = await readDialogueControlPoint(
|
|
page,
|
|
'row',
|
|
fixture.expectedTargetDialogueId
|
|
);
|
|
await page.mouse.click(rowControl.x, rowControl.y);
|
|
await page.waitForFunction(
|
|
(dialogueId) =>
|
|
window.__HEROS_DEBUG__?.camp?.()?.selectedDialogue
|
|
?.id === dialogueId,
|
|
fixture.expectedTargetDialogueId
|
|
);
|
|
|
|
camp = await readCamp(page);
|
|
const selectedDialogue = camp.selectedDialogue;
|
|
assert.equal(
|
|
selectedDialogue.id,
|
|
fixture.expectedTargetDialogueId
|
|
);
|
|
assert.equal(
|
|
selectedDialogue.title,
|
|
priorityReturn.dialogueTitle
|
|
);
|
|
assert.deepEqual(
|
|
selectedDialogue.lines,
|
|
priorityReturn.dialogueLines
|
|
);
|
|
assert.equal(selectedDialogue.completed, false);
|
|
assert(
|
|
selectedDialogue.choices.length > 0,
|
|
`${fixture.id}: the priority dialogue must expose at least one completion choice.`
|
|
);
|
|
selectedDialogue.choices.forEach((choice) => {
|
|
assertBoundsInsideViewport(
|
|
choice.bounds,
|
|
`${fixture.id} choice ${choice.id}`
|
|
);
|
|
});
|
|
assertNoOverlappingBounds(
|
|
selectedDialogue.choices.map(({ id, bounds }) => ({
|
|
id,
|
|
bounds
|
|
})),
|
|
`${fixture.id} dialogue choices`
|
|
);
|
|
assertVariantNarrative(priorityReturn, fixture);
|
|
await capture(
|
|
page,
|
|
`dist/verification-third-battle-priority-return-${fixture.id}-pending-dialogue.png`
|
|
);
|
|
|
|
const selectedChoice = selectedDialogue.choices[0];
|
|
const choiceControl = await readDialogueControlPoint(
|
|
page,
|
|
'choice',
|
|
fixture.expectedTargetDialogueId,
|
|
selectedChoice.id
|
|
);
|
|
await page.mouse.click(choiceControl.x, choiceControl.y);
|
|
await page.waitForFunction(
|
|
({ dialogueId, choiceId }) => {
|
|
const camp = window.__HEROS_DEBUG__?.camp?.();
|
|
return (
|
|
camp?.thirdBattlePriorityReturn?.completed === true &&
|
|
camp.thirdBattlePriorityReturn.pending === false &&
|
|
camp?.selectedDialogue?.id === dialogueId &&
|
|
camp.selectedDialogue.completed === true &&
|
|
camp?.campaign?.campDialogueChoiceIds?.[dialogueId] ===
|
|
choiceId
|
|
);
|
|
},
|
|
{
|
|
dialogueId: fixture.expectedTargetDialogueId,
|
|
choiceId: selectedChoice.id
|
|
}
|
|
);
|
|
|
|
camp = await readCamp(page);
|
|
assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, {
|
|
completed: true,
|
|
requireRowBounds: true
|
|
});
|
|
assert.equal(
|
|
camp.selectedDialogue.id,
|
|
fixture.expectedTargetDialogueId
|
|
);
|
|
assert.equal(camp.selectedDialogue.completed, true);
|
|
assert.equal(
|
|
camp.campaign.completedCampDialogues.filter(
|
|
(id) => id === fixture.expectedTargetDialogueId
|
|
).length,
|
|
1,
|
|
`${fixture.id}: priority dialogue completion must be recorded exactly once.`
|
|
);
|
|
assert.equal(
|
|
camp.campaign.campDialogueChoiceIds[
|
|
fixture.expectedTargetDialogueId
|
|
],
|
|
selectedChoice.id
|
|
);
|
|
assert.equal(
|
|
camp.campTabs.find(({ id }) => id === 'dialogue')
|
|
?.newBadgeVisible,
|
|
false,
|
|
`${fixture.id}: the dialogue tab priority badge must clear after completion.`
|
|
);
|
|
assert.equal(
|
|
resonanceChecklist(camp).complete,
|
|
true,
|
|
`${fixture.id}: completing the one priority return must satisfy the resonance checklist.`
|
|
);
|
|
badgeProbe = await readPriorityRowBadge(
|
|
page,
|
|
camp.thirdBattlePriorityReturn.rowBounds
|
|
);
|
|
assert.equal(
|
|
badgeProbe.markers.length,
|
|
0,
|
|
`${fixture.id}: the row priority badge must disappear after completion: ${JSON.stringify(badgeProbe)}`
|
|
);
|
|
assertPersistedDialogue(
|
|
await readCampaignSave(page),
|
|
fixture.expectedTargetDialogueId,
|
|
selectedChoice.id,
|
|
`${fixture.id} completed`
|
|
);
|
|
await capture(
|
|
page,
|
|
`dist/verification-third-battle-priority-return-${fixture.id}-completed.png`
|
|
);
|
|
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await assertFhdViewport(page, fixture);
|
|
await page.evaluate(async () => {
|
|
await window.__HEROS_DEBUG__.goToCamp();
|
|
});
|
|
camp = await waitForPriorityReturn(page, fixture, true);
|
|
assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, {
|
|
completed: true,
|
|
requireRowBounds: false
|
|
});
|
|
assert.equal(
|
|
camp.campTabs.find(({ id }) => id === 'dialogue')
|
|
?.newBadgeVisible,
|
|
false,
|
|
`${fixture.id}: the dialogue tab badge must remain cleared after Camp re-entry.`
|
|
);
|
|
assert.equal(
|
|
resonanceChecklist(camp).complete,
|
|
true,
|
|
`${fixture.id}: the resonance checklist must remain complete after Camp re-entry.`
|
|
);
|
|
assertPersistedDialogue(
|
|
await readCampaignSave(page),
|
|
fixture.expectedTargetDialogueId,
|
|
selectedChoice.id,
|
|
`${fixture.id} re-entered`
|
|
);
|
|
|
|
const reenteredDialogueTab = camp.campTabs.find(
|
|
({ id }) => id === 'dialogue'
|
|
);
|
|
await clickSceneBounds(
|
|
page,
|
|
'CampScene',
|
|
reenteredDialogueTab.bounds
|
|
);
|
|
camp = await waitForPriorityReturn(page, fixture, true, true);
|
|
const reenteredRow = await readDialogueControlPoint(
|
|
page,
|
|
'row',
|
|
fixture.expectedTargetDialogueId
|
|
);
|
|
await page.mouse.click(reenteredRow.x, reenteredRow.y);
|
|
await page.waitForFunction(
|
|
(dialogueId) => {
|
|
const selected =
|
|
window.__HEROS_DEBUG__?.camp?.()?.selectedDialogue;
|
|
return (
|
|
selected?.id === dialogueId &&
|
|
selected.completed === true
|
|
);
|
|
},
|
|
fixture.expectedTargetDialogueId
|
|
);
|
|
camp = await readCamp(page);
|
|
assert.equal(camp.selectedDialogue.completed, true);
|
|
badgeProbe = await readPriorityRowBadge(
|
|
page,
|
|
camp.thirdBattlePriorityReturn.rowBounds
|
|
);
|
|
assert.equal(
|
|
badgeProbe.markers.length,
|
|
0,
|
|
`${fixture.id}: the priority marker must stay absent after Camp re-entry.`
|
|
);
|
|
await capture(
|
|
page,
|
|
`dist/verification-third-battle-priority-return-${fixture.id}-reentered.png`
|
|
);
|
|
|
|
assert.deepEqual(
|
|
pageErrors,
|
|
[],
|
|
`${fixture.id}: expected no browser page errors: ${JSON.stringify(pageErrors)}`
|
|
);
|
|
assert.deepEqual(
|
|
consoleErrors.filter(
|
|
(message) =>
|
|
!message.includes(
|
|
'The AudioContext was not allowed to start'
|
|
)
|
|
),
|
|
[],
|
|
`${fixture.id}: expected no browser console errors: ${JSON.stringify(consoleErrors)}`
|
|
);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
async function seedThirdVictoryCamp(page, fixture) {
|
|
return page.evaluate(
|
|
async ({
|
|
battleId,
|
|
expectedDialogueId,
|
|
fortAchieved,
|
|
quickAchieved,
|
|
sourceTurn
|
|
}) => {
|
|
const campaignModule = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const { battleScenarios } = await import(
|
|
'/heros_web/src/game/data/battles.ts'
|
|
);
|
|
const scenario = battleScenarios[battleId];
|
|
if (!scenario) {
|
|
throw new Error(`Missing battle scenario ${battleId}.`);
|
|
}
|
|
|
|
const objectives = scenario.objectives.map(
|
|
(objective) => {
|
|
const achieved =
|
|
objective.id === 'fort'
|
|
? fortAchieved
|
|
: objective.id === 'quick'
|
|
? quickAchieved
|
|
: true;
|
|
return {
|
|
id: objective.id,
|
|
label: objective.label,
|
|
achieved,
|
|
status: achieved ? 'done' : 'failed',
|
|
detail: achieved ? '달성' : '미달성',
|
|
category:
|
|
objective.id === 'fort' ||
|
|
objective.id === 'quick'
|
|
? 'bonus'
|
|
: 'required',
|
|
rewardGold: objective.rewardGold
|
|
};
|
|
}
|
|
);
|
|
|
|
campaignModule.resetCampaignState();
|
|
campaignModule.setFirstBattleReport({
|
|
battleId: scenario.id,
|
|
battleTitle: scenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: sourceTurn,
|
|
rewardGold: scenario.baseVictoryGold,
|
|
defeatedEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
totalEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
objectives,
|
|
units: scenario.units,
|
|
bonds: scenario.bonds.map((bond) => ({
|
|
...bond,
|
|
battleExp: 0
|
|
})),
|
|
itemRewards: [],
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
createdAt: '2026-07-27T00:00:00.000Z'
|
|
});
|
|
let campaign = campaignModule.getCampaignState();
|
|
campaign.dismissedVictoryRewardNoticeBattleIds = [
|
|
...new Set([
|
|
...campaign.dismissedVictoryRewardNoticeBattleIds,
|
|
battleId
|
|
])
|
|
];
|
|
campaignModule.setCampaignState(campaign);
|
|
campaignModule.completeCampaignAftermath(scenario.id);
|
|
|
|
campaign = campaignModule.getCampaignState();
|
|
const settlement =
|
|
campaign.battleHistory[battleId];
|
|
return {
|
|
step: campaign.step,
|
|
latestBattleId: campaign.latestBattleId ?? null,
|
|
reportBattleId:
|
|
campaign.firstBattleReport?.battleId ?? null,
|
|
settlementOutcome: settlement?.outcome ?? null,
|
|
sourceTurn: settlement?.turnNumber ?? null,
|
|
fortAchieved:
|
|
settlement?.objectives.find(
|
|
({ id }) => id === 'fort'
|
|
)?.achieved ?? null,
|
|
quickAchieved:
|
|
settlement?.objectives.find(
|
|
({ id }) => id === 'quick'
|
|
)?.achieved ?? null,
|
|
dialogueCompleted:
|
|
campaign.completedCampDialogues.includes(
|
|
expectedDialogueId
|
|
),
|
|
dialogueChoiceId:
|
|
campaign.campDialogueChoiceIds[
|
|
expectedDialogueId
|
|
] ?? null
|
|
};
|
|
},
|
|
{
|
|
battleId: sourceBattleId,
|
|
expectedDialogueId: fixture.expectedTargetDialogueId,
|
|
fortAchieved: fixture.fortAchieved,
|
|
quickAchieved: fixture.quickAchieved,
|
|
sourceTurn: fixture.sourceTurn
|
|
}
|
|
);
|
|
}
|
|
|
|
async function waitForDebugApi(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector('canvas') !== null &&
|
|
window.__HEROS_GAME__ !== undefined &&
|
|
window.__HEROS_DEBUG__ !== undefined,
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function waitForPriorityReturn(
|
|
page,
|
|
fixture,
|
|
completed,
|
|
requireRowBounds = false
|
|
) {
|
|
try {
|
|
await page.waitForFunction(
|
|
({
|
|
battleId,
|
|
expectedVariant,
|
|
expectedTargetDialogueId,
|
|
fortAchieved,
|
|
quickAchieved,
|
|
sourceTurn,
|
|
completed,
|
|
requireRowBounds
|
|
}) => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const camp = debug?.camp?.();
|
|
const priority = camp?.thirdBattlePriorityReturn;
|
|
return (
|
|
debug?.activeScenes?.().includes('CampScene') &&
|
|
camp?.scene === 'CampScene' &&
|
|
camp?.campaign?.step === 'third-camp' &&
|
|
camp?.campBattleId === battleId &&
|
|
camp?.campTabs?.some(({ id }) => id === 'dialogue') &&
|
|
priority?.available === true &&
|
|
priority.sourceBattleId === battleId &&
|
|
priority.sourceTurn === sourceTurn &&
|
|
priority.fortAchieved === fortAchieved &&
|
|
priority.quickAchieved === quickAchieved &&
|
|
priority.variant === expectedVariant &&
|
|
priority.targetDialogueId === expectedTargetDialogueId &&
|
|
priority.completed === completed &&
|
|
priority.pending === !completed &&
|
|
(!requireRowBounds || Boolean(priority.rowBounds))
|
|
);
|
|
},
|
|
{
|
|
battleId: sourceBattleId,
|
|
expectedVariant: fixture.expectedVariant,
|
|
expectedTargetDialogueId:
|
|
fixture.expectedTargetDialogueId,
|
|
fortAchieved: fixture.fortAchieved,
|
|
quickAchieved: fixture.quickAchieved,
|
|
sourceTurn: fixture.sourceTurn,
|
|
completed,
|
|
requireRowBounds
|
|
},
|
|
{ timeout: 90000 }
|
|
);
|
|
} catch (error) {
|
|
const diagnostic = await page.evaluate(() => ({
|
|
activeScenes:
|
|
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
|
camp: window.__HEROS_DEBUG__?.camp?.() ?? null
|
|
}));
|
|
throw new Error(
|
|
`${fixture.id}: CampScene did not expose the expected thirdBattlePriorityReturn contract: ${JSON.stringify(diagnostic)}`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
return readCamp(page);
|
|
}
|
|
|
|
function assertPriorityReturn(
|
|
priority,
|
|
fixture,
|
|
{ completed, requireRowBounds }
|
|
) {
|
|
assert(
|
|
priority,
|
|
`${fixture.id}: thirdBattlePriorityReturn is missing.`
|
|
);
|
|
assert.equal(priority.available, true);
|
|
assert.equal(priority.sourceBattleId, sourceBattleId);
|
|
assert.equal(priority.sourceTurn, fixture.sourceTurn);
|
|
assert.equal(
|
|
priority.fortAchieved,
|
|
fixture.fortAchieved
|
|
);
|
|
assert.equal(
|
|
priority.quickAchieved,
|
|
fixture.quickAchieved
|
|
);
|
|
assert.equal(priority.variant, fixture.expectedVariant);
|
|
assert.equal(
|
|
priority.targetDialogueId,
|
|
fixture.expectedTargetDialogueId
|
|
);
|
|
assert.equal(typeof priority.dialogueTitle, 'string');
|
|
assert(priority.dialogueTitle.length > 0);
|
|
assert.equal(priority.dialogueLines.length, 3);
|
|
assert(
|
|
priority.dialogueLines.every(
|
|
(line) => typeof line === 'string' && line.length > 0
|
|
)
|
|
);
|
|
assert.equal(priority.completed, completed);
|
|
assert.equal(priority.pending, !completed);
|
|
if (requireRowBounds) {
|
|
assertFiniteBounds(
|
|
priority.rowBounds,
|
|
`${fixture.id} priority row`
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertVariantNarrative(priority, fixture) {
|
|
const text = [
|
|
priority.dialogueTitle,
|
|
...priority.dialogueLines
|
|
].join('\n');
|
|
if (!fixture.fortAchieved) {
|
|
assert.match(
|
|
text,
|
|
/요새/,
|
|
`${fixture.id}: fort-recovery dialogue must discuss the fort.`
|
|
);
|
|
assert.match(
|
|
text,
|
|
/확보하지 못|비워 둔|남겼/,
|
|
`${fixture.id}: fort-recovery dialogue must not praise a missed fort objective.`
|
|
);
|
|
return;
|
|
}
|
|
if (!fixture.quickAchieved) {
|
|
assert.match(
|
|
text,
|
|
/늦|때를 놓쳤|제한 초과/,
|
|
`${fixture.id}: tempo-recovery dialogue must acknowledge the missed quick objective.`
|
|
);
|
|
return;
|
|
}
|
|
assert.match(
|
|
text,
|
|
/확보|길이 열렸/,
|
|
`${fixture.id}: decisive-advance dialogue must acknowledge the achieved fort objective.`
|
|
);
|
|
assert.match(
|
|
text,
|
|
new RegExp(`${fixture.sourceTurn}턴`),
|
|
`${fixture.id}: decisive-advance dialogue must retain the source turn.`
|
|
);
|
|
}
|
|
|
|
function resonanceChecklist(camp) {
|
|
const item = camp?.sortieChecklist?.find(
|
|
({ label }) => label === resonanceChecklistLabel
|
|
);
|
|
assert(
|
|
item,
|
|
`CampScene must expose a "${resonanceChecklistLabel}" checklist item: ${JSON.stringify(camp?.sortieChecklist)}`
|
|
);
|
|
return item;
|
|
}
|
|
|
|
async function readPriorityRowBadge(page, rowBounds) {
|
|
return page.evaluate((requestedRowBounds) => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const objectBounds = (object) => {
|
|
const bounds = object?.getBounds?.();
|
|
return bounds
|
|
? {
|
|
x: bounds.x,
|
|
y: bounds.y,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
}
|
|
: null;
|
|
};
|
|
const intersects = (left, right) =>
|
|
Boolean(
|
|
left &&
|
|
right &&
|
|
left.x < right.x + right.width &&
|
|
left.x + left.width > right.x &&
|
|
left.y < right.y + right.height &&
|
|
left.y + left.height > right.y
|
|
);
|
|
const markers = (scene?.contentObjects ?? [])
|
|
.filter(
|
|
(object) =>
|
|
object?.type === 'Text' &&
|
|
object.active &&
|
|
object.visible &&
|
|
typeof object.text === 'string' &&
|
|
object.text.includes('우선')
|
|
)
|
|
.map((object) => ({
|
|
text: object.text,
|
|
bounds: objectBounds(object)
|
|
}))
|
|
.filter(({ bounds }) =>
|
|
intersects(bounds, requestedRowBounds)
|
|
);
|
|
return { rowBounds: requestedRowBounds, markers };
|
|
}, rowBounds);
|
|
}
|
|
|
|
async function readDialogueControlPoint(
|
|
page,
|
|
control,
|
|
dialogueId,
|
|
choiceId
|
|
) {
|
|
const probe = await page.evaluate(
|
|
({ control, dialogueId, choiceId }) => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
const state = window.__HEROS_DEBUG__?.camp?.();
|
|
const canvas = document.querySelector('canvas');
|
|
const priority = state?.thirdBattlePriorityReturn;
|
|
const dialogue =
|
|
state?.selectedDialogue?.id === dialogueId
|
|
? state.selectedDialogue
|
|
: null;
|
|
const choice = dialogue?.choices?.find(
|
|
({ id }) => id === choiceId
|
|
);
|
|
const bounds =
|
|
control === 'row'
|
|
? priority?.targetDialogueId === dialogueId
|
|
? priority.rowBounds
|
|
: null
|
|
: choice?.bounds;
|
|
const interactiveObject =
|
|
control === 'row'
|
|
? scene?.campDialogueRowButtons?.[dialogueId]
|
|
: scene?.campDialogueChoiceButtons?.[
|
|
`${dialogueId}:${choiceId}`
|
|
];
|
|
if (
|
|
!scene ||
|
|
!canvas ||
|
|
!bounds ||
|
|
!interactiveObject?.input?.enabled
|
|
) {
|
|
return {
|
|
ready: false,
|
|
control,
|
|
dialogueId,
|
|
choiceId: choiceId ?? null,
|
|
bounds: bounds ?? null,
|
|
priority: priority ?? null,
|
|
selectedDialogue: state?.selectedDialogue ?? null
|
|
};
|
|
}
|
|
const canvasBounds = canvas.getBoundingClientRect();
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
ready: true,
|
|
control,
|
|
dialogueId,
|
|
choiceId: choiceId ?? null,
|
|
bounds,
|
|
x:
|
|
canvasBounds.left +
|
|
(centerX * canvasBounds.width) /
|
|
scene.scale.width,
|
|
y:
|
|
canvasBounds.top +
|
|
(centerY * canvasBounds.height) /
|
|
scene.scale.height
|
|
};
|
|
},
|
|
{ control, dialogueId, choiceId }
|
|
);
|
|
assert(
|
|
probe.ready === true &&
|
|
Number.isFinite(probe.x) &&
|
|
Number.isFinite(probe.y),
|
|
`Expected an interactive ${control} for ${dialogueId}: ${JSON.stringify(probe)}`
|
|
);
|
|
assertBoundsInsideViewport(
|
|
probe.bounds,
|
|
`${dialogueId} ${control}`
|
|
);
|
|
return probe;
|
|
}
|
|
|
|
async function clickSceneBounds(page, sceneKey, bounds) {
|
|
assertFiniteBounds(bounds, `${sceneKey} click target`);
|
|
const point = await page.evaluate(
|
|
({ sceneKey, bounds }) => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene(sceneKey);
|
|
const canvas = document.querySelector('canvas');
|
|
const canvasBounds = canvas?.getBoundingClientRect();
|
|
if (!scene || !canvasBounds) {
|
|
return null;
|
|
}
|
|
const centerX = bounds.x + bounds.width / 2;
|
|
const centerY = bounds.y + bounds.height / 2;
|
|
return {
|
|
x:
|
|
canvasBounds.left +
|
|
(centerX * canvasBounds.width) /
|
|
scene.scale.width,
|
|
y:
|
|
canvasBounds.top +
|
|
(centerY * canvasBounds.height) /
|
|
scene.scale.height
|
|
};
|
|
},
|
|
{ sceneKey, bounds }
|
|
);
|
|
assert(
|
|
point &&
|
|
Number.isFinite(point.x) &&
|
|
Number.isFinite(point.y),
|
|
`Unable to map ${sceneKey} bounds to a browser point: ${JSON.stringify(bounds)}`
|
|
);
|
|
await page.mouse.click(point.x, point.y);
|
|
}
|
|
|
|
async function readCamp(page) {
|
|
return page.evaluate(() =>
|
|
window.__HEROS_DEBUG__?.camp?.()
|
|
);
|
|
}
|
|
|
|
async function readCampaignSave(page) {
|
|
return page.evaluate(() => {
|
|
const raw = window.localStorage.getItem(
|
|
'heros-web:campaign-state'
|
|
);
|
|
return raw ? JSON.parse(raw) : null;
|
|
});
|
|
}
|
|
|
|
function assertPersistedDialogue(
|
|
save,
|
|
targetDialogueId,
|
|
choiceId,
|
|
label
|
|
) {
|
|
assert(save, `${label}: current campaign save is missing.`);
|
|
assert.equal(save.step, 'third-camp');
|
|
assert.equal(save.latestBattleId, sourceBattleId);
|
|
assert.equal(
|
|
save.completedCampDialogues.filter(
|
|
(id) => id === targetDialogueId
|
|
).length,
|
|
1,
|
|
`${label}: the priority dialogue completion must persist exactly once.`
|
|
);
|
|
assert.equal(
|
|
save.firstBattleReport?.completedCampDialogues?.filter(
|
|
(id) => id === targetDialogueId
|
|
).length,
|
|
1,
|
|
`${label}: the report completion must persist exactly once.`
|
|
);
|
|
assert.equal(
|
|
save.campDialogueChoiceIds[targetDialogueId],
|
|
choiceId,
|
|
`${label}: the selected priority response must persist.`
|
|
);
|
|
}
|
|
|
|
function assertNoOverlappingBounds(entries, label) {
|
|
for (
|
|
let leftIndex = 0;
|
|
leftIndex < entries.length;
|
|
leftIndex += 1
|
|
) {
|
|
for (
|
|
let rightIndex = leftIndex + 1;
|
|
rightIndex < entries.length;
|
|
rightIndex += 1
|
|
) {
|
|
const left = entries[leftIndex];
|
|
const right = entries[rightIndex];
|
|
const overlapWidth =
|
|
Math.min(
|
|
left.bounds.x + left.bounds.width,
|
|
right.bounds.x + right.bounds.width
|
|
) - Math.max(left.bounds.x, right.bounds.x);
|
|
const overlapHeight =
|
|
Math.min(
|
|
left.bounds.y + left.bounds.height,
|
|
right.bounds.y + right.bounds.height
|
|
) - Math.max(left.bounds.y, right.bounds.y);
|
|
assert(
|
|
overlapWidth <= 0 || overlapHeight <= 0,
|
|
`${label}: ${left.id} and ${right.id} overlap: ${JSON.stringify({ left, right })}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertBoundsInsideViewport(bounds, label) {
|
|
assertFiniteBounds(bounds, label);
|
|
const epsilon = 0.01;
|
|
assert(
|
|
bounds.x >= -epsilon &&
|
|
bounds.y >= -epsilon &&
|
|
bounds.x + bounds.width <=
|
|
desktopBrowserViewport.width + epsilon &&
|
|
bounds.y + bounds.height <=
|
|
desktopBrowserViewport.height + epsilon,
|
|
`${label}: expected bounds inside the 1920x1080 viewport, received ${JSON.stringify(bounds)}`
|
|
);
|
|
}
|
|
|
|
function assertFiniteBounds(bounds, label) {
|
|
assert(bounds, `${label}: bounds are required.`);
|
|
assert(
|
|
['x', 'y', 'width', 'height'].every((key) =>
|
|
Number.isFinite(bounds[key])
|
|
) &&
|
|
bounds.width > 0 &&
|
|
bounds.height > 0,
|
|
`${label}: expected positive finite bounds, received ${JSON.stringify(bounds)}`
|
|
);
|
|
}
|
|
|
|
async function assertFhdViewport(page, fixture) {
|
|
const viewport = await page.evaluate(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
const bounds = canvas?.getBoundingClientRect();
|
|
return {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
dpr: window.devicePixelRatio,
|
|
visualScale: window.visualViewport?.scale ?? 1,
|
|
rendererType:
|
|
window.__HEROS_GAME__?.renderer?.type ?? null,
|
|
canvas: canvas
|
|
? {
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
clientWidth: canvas.clientWidth,
|
|
clientHeight: canvas.clientHeight,
|
|
bounds: bounds
|
|
? {
|
|
x: bounds.x,
|
|
y: bounds.y,
|
|
width: bounds.width,
|
|
height: bounds.height
|
|
}
|
|
: null
|
|
}
|
|
: null
|
|
};
|
|
});
|
|
assert.equal(viewport.width, desktopBrowserViewport.width);
|
|
assert.equal(
|
|
viewport.height,
|
|
desktopBrowserViewport.height
|
|
);
|
|
assert.equal(
|
|
viewport.dpr,
|
|
desktopBrowserDeviceScaleFactor
|
|
);
|
|
assert.equal(viewport.visualScale, 1);
|
|
assert.equal(
|
|
viewport.rendererType,
|
|
fixture.expectedRendererType,
|
|
`${fixture.id}: Phaser did not use the requested renderer.`
|
|
);
|
|
assert.equal(
|
|
viewport.canvas?.width,
|
|
desktopBrowserViewport.width
|
|
);
|
|
assert.equal(
|
|
viewport.canvas?.height,
|
|
desktopBrowserViewport.height
|
|
);
|
|
assert.equal(
|
|
viewport.canvas?.clientWidth,
|
|
desktopBrowserViewport.width
|
|
);
|
|
assert.equal(
|
|
viewport.canvas?.clientHeight,
|
|
desktopBrowserViewport.height
|
|
);
|
|
assert.equal(
|
|
viewport.canvas?.bounds?.width,
|
|
desktopBrowserViewport.width
|
|
);
|
|
assert.equal(
|
|
viewport.canvas?.bounds?.height,
|
|
desktopBrowserViewport.height
|
|
);
|
|
}
|
|
|
|
async function capture(page, path) {
|
|
await page.waitForTimeout(250);
|
|
const loopSlept = await page.evaluate(() => {
|
|
const loop = window.__HEROS_GAME__?.loop;
|
|
if (!loop || typeof loop.sleep !== 'function') {
|
|
return false;
|
|
}
|
|
loop.sleep();
|
|
return true;
|
|
});
|
|
await page.waitForTimeout(40);
|
|
try {
|
|
await page.screenshot({ path, fullPage: true });
|
|
} finally {
|
|
if (loopSlept) {
|
|
await page.evaluate(() =>
|
|
window.__HEROS_GAME__?.loop.wake()
|
|
);
|
|
await page.waitForTimeout(40);
|
|
}
|
|
}
|
|
}
|
|
|
|
function cliOption(name) {
|
|
const equalsPrefix = `--${name}=`;
|
|
const equalsOption = process.argv.find((argument) =>
|
|
argument.startsWith(equalsPrefix)
|
|
);
|
|
if (equalsOption) {
|
|
return equalsOption.slice(equalsPrefix.length);
|
|
}
|
|
const index = process.argv.indexOf(`--${name}`);
|
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
}
|