1657 lines
44 KiB
JavaScript
1657 lines
44 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 targetBattleId = 'fourth-battle-guangzong-camp';
|
|
const selectionRecordId = 'third-camp-preparation-priority';
|
|
const trackedEnemyUnitId = 'guangzong-main-vanguard-a';
|
|
const priorityDialogueId = 'guan-zhang-after-guangzong-road';
|
|
const priorityDialogueBondId = 'guan-yu__zhang-fei';
|
|
const activeThroughTurn = 3;
|
|
|
|
const priorityFixtures = [
|
|
{
|
|
id: 'information',
|
|
label: '본영 정보',
|
|
effectKind: 'marked-vanguard-intel',
|
|
configuredValue: 10
|
|
},
|
|
{
|
|
id: 'equipment',
|
|
label: '장비 정비',
|
|
effectKind: 'prepared-equipment-guard',
|
|
configuredValue: 8
|
|
},
|
|
{
|
|
id: 'companion',
|
|
label: '동료 공명',
|
|
effectKind: 'return-bond-opening',
|
|
configuredValue: 5
|
|
}
|
|
];
|
|
|
|
const rendererFixtures = {
|
|
canvas: {
|
|
renderer: 'canvas',
|
|
expectedRendererType: 1
|
|
},
|
|
webgl: {
|
|
renderer: 'webgl',
|
|
expectedRendererType: 2
|
|
}
|
|
};
|
|
|
|
const requestedRenderer =
|
|
cliOption('renderer') ??
|
|
process.env.VERIFY_THIRD_CAMP_PREPARATION_RENDERER ??
|
|
'both';
|
|
assert(
|
|
['canvas', 'webgl', 'both'].includes(requestedRenderer),
|
|
`Unknown renderer "${requestedRenderer}". Use canvas, webgl, or both.`
|
|
);
|
|
const renderers =
|
|
requestedRenderer === 'both'
|
|
? ['canvas', 'webgl']
|
|
: [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_CAMP_PREPARATION_HEADLESS !== '0'
|
|
});
|
|
|
|
for (const renderer of renderers) {
|
|
await verifyRenderer(
|
|
browser,
|
|
baseUrl,
|
|
rendererFixtures[renderer]
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`Third-camp preparation browser verification passed for ${renderers.join(
|
|
' + '
|
|
)} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, DPR ${desktopBrowserDeviceScaleFactor}: camp activity routing and persistence, all three battle mechanics, normalized runtime saves, result memory, and turn-4 expiry.`
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await server.close();
|
|
}
|
|
|
|
async function verifyRenderer(browser, baseUrl, rendererFixture) {
|
|
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', rendererFixture.renderer);
|
|
await page.goto(url.toString(), {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await assertFhdViewport(page, rendererFixture);
|
|
|
|
if (rendererFixture.renderer === 'webgl') {
|
|
await verifyCampPreparationUi(page);
|
|
await assertFhdViewport(page, rendererFixture);
|
|
}
|
|
|
|
for (const priority of priorityFixtures) {
|
|
await verifyBattlePriority(
|
|
page,
|
|
rendererFixture,
|
|
priority
|
|
);
|
|
}
|
|
|
|
assert.deepEqual(
|
|
pageErrors,
|
|
[],
|
|
`${rendererFixture.renderer}: expected no browser page errors: ${JSON.stringify(
|
|
pageErrors.slice(-8)
|
|
)}`
|
|
);
|
|
assert.deepEqual(
|
|
consoleErrors.filter(
|
|
(message) =>
|
|
!message.includes(
|
|
'The AudioContext was not allowed to start'
|
|
)
|
|
),
|
|
[],
|
|
`${rendererFixture.renderer}: expected no browser console errors: ${JSON.stringify(
|
|
consoleErrors.slice(-8)
|
|
)}`
|
|
);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
async function verifyCampPreparationUi(page) {
|
|
const seeded = await seedThirdVictory(page, {
|
|
acknowledgeActivities: false,
|
|
selectPriorityId: null
|
|
});
|
|
assert.equal(seeded.step, 'third-camp');
|
|
assert.equal(seeded.latestBattleId, sourceBattleId);
|
|
assert.equal(seeded.selection, null);
|
|
assert.deepEqual(seeded.acknowledgedCategories, []);
|
|
assert.equal(seeded.priorityDialogueCompleted, false);
|
|
|
|
await page.evaluate(async () => {
|
|
await window.__HEROS_DEBUG__.goToCamp();
|
|
});
|
|
await waitForCamp(page);
|
|
await page.evaluate(() => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
if (typeof scene?.showSortiePrep !== 'function') {
|
|
throw new Error(
|
|
'CampScene.showSortiePrep is unavailable.'
|
|
);
|
|
}
|
|
scene.showSortiePrep();
|
|
});
|
|
let camp = await waitForPreparationToggle(page);
|
|
assert.equal(camp.sortieVisible, true);
|
|
assert.equal(
|
|
preparationPriority(camp, 'information').selectable,
|
|
true,
|
|
'Opening the briefing must count as reviewing the unlocked battle information.'
|
|
);
|
|
assert.equal(
|
|
preparationPriority(camp, 'information').evidence?.category,
|
|
'unlocks'
|
|
);
|
|
assert.equal(
|
|
preparationPriority(camp, 'equipment').selectable,
|
|
false
|
|
);
|
|
assert.equal(
|
|
preparationPriority(camp, 'companion').selectable,
|
|
false
|
|
);
|
|
assertFiniteBounds(
|
|
camp.thirdCampPreparation.toggleButtonBounds,
|
|
'camp preparation toggle'
|
|
);
|
|
assertBoundsInsideViewport(
|
|
camp.thirdCampPreparation.toggleButtonBounds,
|
|
'camp preparation toggle'
|
|
);
|
|
|
|
camp = await openPreparationBrowser(page);
|
|
assertPreparationBrowserLayout(camp, 'initial browser');
|
|
await capture(
|
|
page,
|
|
'dist/verification-third-camp-preparation-webgl-browser.png'
|
|
);
|
|
|
|
await selectPreparationCard(page, 'information');
|
|
camp = await waitForCampSelection(page, 'information');
|
|
assertPreparationSelection(camp, 'information');
|
|
await assertCampaignSelectionPersisted(
|
|
page,
|
|
'information',
|
|
'information selection'
|
|
);
|
|
|
|
const equipmentLocked =
|
|
preparationPriority(camp, 'equipment');
|
|
assert.equal(equipmentLocked.selectable, false);
|
|
await clickSceneBounds(
|
|
page,
|
|
'CampScene',
|
|
equipmentLocked.actionButtonBounds
|
|
);
|
|
await page.waitForFunction(
|
|
() => {
|
|
const state = window.__HEROS_DEBUG__?.camp?.();
|
|
return (
|
|
state?.activeTab === 'equipment' &&
|
|
state?.sortieVisible === false &&
|
|
state?.thirdCampPreparation?.priorities?.find(
|
|
({ id }) => id === 'equipment'
|
|
)?.selectable === true
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
camp = await readCamp(page);
|
|
assert.equal(camp.activeTab, 'equipment');
|
|
assert.equal(
|
|
preparationPriority(camp, 'equipment').evidence?.category,
|
|
'equipment'
|
|
);
|
|
assertPreparationSelection(camp, 'information');
|
|
|
|
camp = await openPreparationBrowser(page);
|
|
assertPreparationBrowserLayout(camp, 'equipment unlocked');
|
|
await selectPreparationCard(page, 'equipment');
|
|
camp = await waitForCampSelection(page, 'equipment');
|
|
assertPreparationSelection(camp, 'equipment');
|
|
await assertCampaignSelectionPersisted(
|
|
page,
|
|
'equipment',
|
|
'equipment selection'
|
|
);
|
|
|
|
const companionLocked =
|
|
preparationPriority(camp, 'companion');
|
|
assert.equal(companionLocked.selectable, false);
|
|
await clickSceneBounds(
|
|
page,
|
|
'CampScene',
|
|
companionLocked.actionButtonBounds
|
|
);
|
|
await page.waitForFunction(
|
|
(dialogueId) => {
|
|
const state = window.__HEROS_DEBUG__?.camp?.();
|
|
return (
|
|
state?.activeTab === 'dialogue' &&
|
|
state?.sortieVisible === false &&
|
|
state?.selectedDialogue?.id === dialogueId &&
|
|
state.selectedDialogue.completed === false &&
|
|
state.selectedDialogue.choices?.length > 0
|
|
);
|
|
},
|
|
priorityDialogueId,
|
|
{ timeout: 30000 }
|
|
);
|
|
camp = await readCamp(page);
|
|
assert.equal(
|
|
camp.selectedDialogue.thirdBattlePriorityReturn,
|
|
true,
|
|
'The companion activity must route to the exact result-priority return dialogue.'
|
|
);
|
|
const dialogueChoice = camp.selectedDialogue.choices[0];
|
|
assertFiniteBounds(
|
|
dialogueChoice.bounds,
|
|
`dialogue choice ${dialogueChoice.id}`
|
|
);
|
|
assertBoundsInsideViewport(
|
|
dialogueChoice.bounds,
|
|
`dialogue choice ${dialogueChoice.id}`
|
|
);
|
|
await clickSceneBounds(
|
|
page,
|
|
'CampScene',
|
|
dialogueChoice.bounds
|
|
);
|
|
await page.waitForFunction(
|
|
({ dialogueId, choiceId }) => {
|
|
const state = window.__HEROS_DEBUG__?.camp?.();
|
|
return (
|
|
state?.selectedDialogue?.id === dialogueId &&
|
|
state.selectedDialogue.completed === true &&
|
|
state.campaign?.campDialogueChoiceIds?.[dialogueId] ===
|
|
choiceId &&
|
|
state?.thirdCampPreparation?.priorities?.find(
|
|
({ id }) => id === 'companion'
|
|
)?.selectable === true
|
|
);
|
|
},
|
|
{
|
|
dialogueId: priorityDialogueId,
|
|
choiceId: dialogueChoice.id
|
|
},
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
camp = await openPreparationBrowser(page);
|
|
assertPreparationBrowserLayout(camp, 'companion unlocked');
|
|
const companion = preparationPriority(camp, 'companion');
|
|
assert.equal(companion.evidenceKind, 'priority-return-dialogue');
|
|
assert.equal(companion.evidence.dialogueId, priorityDialogueId);
|
|
assert.equal(companion.evidence.bondId, priorityDialogueBondId);
|
|
await selectPreparationCard(page, 'companion');
|
|
camp = await waitForCampSelection(page, 'companion');
|
|
assertPreparationSelection(camp, 'companion');
|
|
await assertCampaignSelectionPersisted(
|
|
page,
|
|
'companion',
|
|
'companion selection'
|
|
);
|
|
await capture(
|
|
page,
|
|
'dist/verification-third-camp-preparation-webgl-selected.png'
|
|
);
|
|
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await page.evaluate(async () => {
|
|
await window.__HEROS_DEBUG__.goToCamp();
|
|
});
|
|
await waitForCamp(page);
|
|
camp = await openPreparationBrowser(page);
|
|
assertPreparationBrowserLayout(camp, 're-entered browser');
|
|
assertPreparationSelection(camp, 'companion');
|
|
await assertCampaignSelectionPersisted(
|
|
page,
|
|
'companion',
|
|
're-entered companion selection'
|
|
);
|
|
assert.equal(
|
|
camp.campaign.completedCampVisits.includes(
|
|
selectionRecordId
|
|
),
|
|
false,
|
|
'A mutable preparation selection must not masquerade as a completed camp visit.'
|
|
);
|
|
}
|
|
|
|
async function verifyBattlePriority(
|
|
page,
|
|
rendererFixture,
|
|
priority
|
|
) {
|
|
const fixtureLabel = `${rendererFixture.renderer}-${priority.id}`;
|
|
const seeded = await seedThirdVictory(page, {
|
|
acknowledgeActivities: true,
|
|
selectPriorityId: priority.id
|
|
});
|
|
assert.deepEqual(seeded.selection, {
|
|
sourceBattleId,
|
|
targetBattleId,
|
|
priorityId: priority.id
|
|
});
|
|
assert.equal(seeded.memory?.selectionRecordId, selectionRecordId);
|
|
assert.equal(seeded.memory?.priorityId, priority.id);
|
|
assert.equal(seeded.memory?.label, priority.label);
|
|
|
|
await page.evaluate(async (battleId) => {
|
|
await window.__HEROS_DEBUG__.goToBattle(battleId);
|
|
}, targetBattleId);
|
|
let battle = await waitForBattlePreparation(
|
|
page,
|
|
priority.id
|
|
);
|
|
await assertFhdViewport(page, rendererFixture);
|
|
assertBattleMemory(
|
|
battle.thirdCampPreparation,
|
|
priority,
|
|
'deployment'
|
|
);
|
|
assert.equal(battle.phase, 'deployment');
|
|
assert.equal(
|
|
battle.turnNumber,
|
|
1,
|
|
`${fixtureLabel}: each reused BattleScene must initialize at turn 1.`
|
|
);
|
|
assert.equal(
|
|
battle.activeFaction,
|
|
'ally',
|
|
`${fixtureLabel}: each reused BattleScene must initialize with the allied faction.`
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.presentation.deployment.status,
|
|
'deployment'
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.presentation.deployment
|
|
.selectionRecordId,
|
|
selectionRecordId
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.presentation.turn
|
|
.selectionRecordId,
|
|
selectionRecordId
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.presentation.turn.status,
|
|
'active'
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.chip.visible,
|
|
false,
|
|
'The turn chip must stay out of the deployment overlay.'
|
|
);
|
|
if (priority.id === 'information') {
|
|
assert.equal(
|
|
battle.thirdCampPreparation.trackedEnemyUnitId,
|
|
trackedEnemyUnitId
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.deploymentIntent.enabled,
|
|
true
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.deploymentIntent.visible,
|
|
true
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.deploymentIntent.enemyId,
|
|
trackedEnemyUnitId
|
|
);
|
|
assert(
|
|
battle.thirdCampPreparation.deploymentIntent.forecast,
|
|
`${fixtureLabel}: the marked vanguard forecast must be visible during deployment.`
|
|
);
|
|
} else {
|
|
assert.equal(
|
|
battle.thirdCampPreparation.deploymentIntent.enabled,
|
|
false
|
|
);
|
|
}
|
|
|
|
assertMechanicsProbe(
|
|
battle.thirdCampPreparation.mechanicsProbe,
|
|
priority,
|
|
`${fixtureLabel} deployment`
|
|
);
|
|
await capture(
|
|
page,
|
|
`dist/verification-third-camp-preparation-${fixtureLabel}-deployment.png`
|
|
);
|
|
|
|
const consumed = await consumePreparationAndRoundTripSave(
|
|
page,
|
|
priority.id
|
|
);
|
|
assertMechanicsProbe(
|
|
consumed.before.mechanicsProbe,
|
|
priority,
|
|
`${fixtureLabel} turn`
|
|
);
|
|
assert.equal(consumed.before.chip.visible, true);
|
|
assertBoundsInsideViewport(
|
|
consumed.before.chip.bounds,
|
|
`${fixtureLabel} active chip`
|
|
);
|
|
assertRuntimeConsumed(
|
|
consumed.after.runtime,
|
|
priority,
|
|
consumed.preventedDamage
|
|
);
|
|
assertRuntimeConsumed(
|
|
consumed.normalized,
|
|
priority,
|
|
consumed.preventedDamage
|
|
);
|
|
assert.deepEqual(
|
|
consumed.restored.runtime,
|
|
consumed.after.runtime,
|
|
`${fixtureLabel}: normalized save progress must restore exactly.`
|
|
);
|
|
assert.deepEqual(
|
|
consumed.restored.saveSnapshot,
|
|
consumed.normalized,
|
|
`${fixtureLabel}: the restored debug save snapshot must match the normalized field.`
|
|
);
|
|
assert.equal(
|
|
consumed.normalized.selectionRecordId,
|
|
selectionRecordId
|
|
);
|
|
assert.equal(
|
|
consumed.normalized.priorityId,
|
|
priority.id
|
|
);
|
|
assert.equal(consumed.normalized.label, priority.label);
|
|
assert.equal(
|
|
consumed.normalized.markedEnemyUnitId,
|
|
priority.id === 'information'
|
|
? trackedEnemyUnitId
|
|
: undefined
|
|
);
|
|
assert.equal(consumed.deepCloned, true);
|
|
assert.equal(consumed.after.chip.visible, true);
|
|
assertBoundsInsideViewport(
|
|
consumed.after.chip.bounds,
|
|
`${fixtureLabel} consumed chip`
|
|
);
|
|
|
|
const expired = await expirePreparationAtTurnFour(page);
|
|
assertBattleMemory(expired, priority, 'turn 4');
|
|
assert.equal(expired.effectActive, false);
|
|
assert.equal(expired.presentation.turn.status, 'expired');
|
|
assert.equal(
|
|
expired.presentation.turn.selectionRecordId,
|
|
selectionRecordId
|
|
);
|
|
assert.equal(expired.presentation.turn.turnNumber, 4);
|
|
assert.equal(expired.chip.visible, true);
|
|
assert.match(
|
|
expired.chip.text,
|
|
/사용 완료|종료/,
|
|
'Turn 4 must show either the already-consumed state or an unused preparation expiry.'
|
|
);
|
|
assertBoundsInsideViewport(
|
|
expired.chip.bounds,
|
|
`${fixtureLabel} expired chip`
|
|
);
|
|
assertExpiredMechanicsProbe(
|
|
expired.mechanicsProbe,
|
|
priority,
|
|
fixtureLabel
|
|
);
|
|
assertRuntimeConsumed(
|
|
expired.runtime,
|
|
priority,
|
|
consumed.preventedDamage
|
|
);
|
|
|
|
await page.evaluate(() => {
|
|
window.__HEROS_DEBUG__.forceBattleOutcome('victory');
|
|
});
|
|
await page.waitForFunction(
|
|
() => {
|
|
const state = window.__HEROS_DEBUG__?.battle?.();
|
|
return (
|
|
state?.battleOutcome === 'victory' &&
|
|
state?.resultVisible === true &&
|
|
state?.thirdCampPreparation?.presentation?.result
|
|
?.selectionRecordId ===
|
|
'third-camp-preparation-priority'
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
battle = await readBattle(page);
|
|
assertBattleMemory(
|
|
battle.thirdCampPreparation,
|
|
priority,
|
|
'result'
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.presentation.result.status,
|
|
'applied'
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.presentation.result
|
|
.selectionRecordId,
|
|
selectionRecordId
|
|
);
|
|
assert.equal(
|
|
battle.thirdCampPreparation.presentation.result.usageCount,
|
|
1
|
|
);
|
|
const resultTextProbe = await page.evaluate(() => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
return (scene?.resultObjects ?? [])
|
|
.filter(
|
|
(object) =>
|
|
object?.type === 'Text' &&
|
|
object.active &&
|
|
object.visible &&
|
|
typeof object.text === 'string'
|
|
)
|
|
.map((object) => object.text);
|
|
});
|
|
assert(
|
|
resultTextProbe.some((text) =>
|
|
text.includes(
|
|
battle.thirdCampPreparation.presentation.result.text
|
|
)
|
|
),
|
|
`${fixtureLabel}: the visible result subtitle must include the applied camp preparation memory: ${JSON.stringify(
|
|
resultTextProbe
|
|
)}`
|
|
);
|
|
await capture(
|
|
page,
|
|
`dist/verification-third-camp-preparation-${fixtureLabel}-result.png`
|
|
);
|
|
}
|
|
|
|
async function seedThirdVictory(
|
|
page,
|
|
{ acknowledgeActivities, selectPriorityId }
|
|
) {
|
|
return page.evaluate(
|
|
async ({
|
|
sourceBattleId,
|
|
targetBattleId,
|
|
priorityDialogueId,
|
|
priorityDialogueBondId,
|
|
acknowledgeActivities,
|
|
selectPriorityId
|
|
}) => {
|
|
const game = window.__HEROS_GAME__;
|
|
for (const scene of game?.scene.getScenes(true) ?? []) {
|
|
game.scene.stop(scene.scene.key);
|
|
}
|
|
window.localStorage.clear();
|
|
|
|
const campaignModule = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const actionModule = await import(
|
|
'/heros_web/src/game/state/thirdCampPreparationActions.ts'
|
|
);
|
|
const preparationModule = await import(
|
|
'/heros_web/src/game/data/thirdCampPreparation.ts'
|
|
);
|
|
const { battleScenarios } = await import(
|
|
'/heros_web/src/game/data/battles.ts'
|
|
);
|
|
const scenario = battleScenarios[sourceBattleId];
|
|
if (!scenario) {
|
|
throw new Error(
|
|
`Missing source scenario ${sourceBattleId}.`
|
|
);
|
|
}
|
|
|
|
campaignModule.resetCampaignState();
|
|
campaignModule.setFirstBattleReport({
|
|
battleId: scenario.id,
|
|
battleTitle: scenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: 22,
|
|
rewardGold: scenario.baseVictoryGold,
|
|
defeatedEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
totalEnemies: scenario.units.filter(
|
|
(unit) => unit.faction === 'enemy'
|
|
).length,
|
|
objectives: scenario.objectives.map((objective) => ({
|
|
id: objective.id,
|
|
label: objective.label,
|
|
achieved: true,
|
|
status: 'done',
|
|
detail: '달성',
|
|
category:
|
|
objective.id === 'fort' ||
|
|
objective.id === 'quick'
|
|
? 'bonus'
|
|
: 'required',
|
|
rewardGold: objective.rewardGold
|
|
})),
|
|
units: scenario.units,
|
|
bonds: scenario.bonds.map((bond) => ({
|
|
...bond,
|
|
battleExp: 0
|
|
})),
|
|
itemRewards: [...scenario.itemRewards],
|
|
campaignRewards: {
|
|
supplies: [
|
|
...(scenario.campaignReward?.supplies ?? [])
|
|
],
|
|
equipment: [
|
|
...(scenario.campaignReward?.equipment ?? [])
|
|
],
|
|
reputation: [
|
|
...(scenario.campaignReward?.reputation ?? [])
|
|
],
|
|
recruits: [],
|
|
unlocks: scenario.campaignReward?.unlockBattleId
|
|
? [
|
|
{
|
|
battleId:
|
|
scenario.campaignReward.unlockBattleId,
|
|
title:
|
|
scenario.campaignReward.unlockLabel ??
|
|
scenario.campaignReward.unlockBattleId
|
|
}
|
|
]
|
|
: [],
|
|
note: scenario.campaignReward?.note
|
|
},
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
createdAt: '2026-07-27T00:00:00.000Z'
|
|
});
|
|
let campaign = campaignModule.getCampaignState();
|
|
campaign.dismissedVictoryRewardNoticeBattleIds = [
|
|
...new Set([
|
|
...campaign.dismissedVictoryRewardNoticeBattleIds,
|
|
sourceBattleId
|
|
])
|
|
];
|
|
campaign.selectedSortieUnitIds = [
|
|
'liu-bei',
|
|
'guan-yu',
|
|
'zhang-fei'
|
|
];
|
|
campaignModule.setCampaignState(campaign);
|
|
campaignModule.completeCampaignAftermath(sourceBattleId);
|
|
|
|
if (acknowledgeActivities) {
|
|
campaignModule.acknowledgeCampaignVictoryReward(
|
|
sourceBattleId,
|
|
['unlocks', 'equipment']
|
|
);
|
|
const dialogueReport = campaignModule.applyCampBondExp(
|
|
priorityDialogueId,
|
|
priorityDialogueBondId,
|
|
1,
|
|
'verify-third-camp-preparation'
|
|
);
|
|
if (
|
|
!dialogueReport?.completedCampDialogues.includes(
|
|
priorityDialogueId
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'Failed to complete the priority return dialogue seed.'
|
|
);
|
|
}
|
|
}
|
|
|
|
let selectionResult = null;
|
|
if (selectPriorityId) {
|
|
selectionResult =
|
|
actionModule.setThirdCampPreparationPriority(
|
|
selectPriorityId
|
|
);
|
|
if (!selectionResult.ok) {
|
|
throw new Error(
|
|
`Failed to select ${selectPriorityId}: ${selectionResult.reason}`
|
|
);
|
|
}
|
|
}
|
|
|
|
campaign = campaignModule.loadCampaignState();
|
|
const memory =
|
|
preparationModule.resolveThirdCampPreparationMemory({
|
|
campaign,
|
|
battleId: targetBattleId
|
|
});
|
|
return {
|
|
step: campaign.step,
|
|
latestBattleId: campaign.latestBattleId ?? null,
|
|
selection:
|
|
campaign.thirdCampPreparationSelection ?? null,
|
|
acknowledgedCategories:
|
|
campaign.acknowledgedVictoryRewardCategories?.[
|
|
sourceBattleId
|
|
] ?? [],
|
|
priorityDialogueCompleted:
|
|
campaign.completedCampDialogues.includes(
|
|
priorityDialogueId
|
|
),
|
|
memory: memory
|
|
? {
|
|
sourceBattleId: memory.sourceBattleId,
|
|
targetBattleId: memory.targetBattleId,
|
|
selectionRecordId: memory.selectionRecordId,
|
|
priorityId: memory.priorityId,
|
|
label: memory.label,
|
|
effectKind: memory.effect.kind
|
|
}
|
|
: null,
|
|
selectionChanged:
|
|
selectionResult?.ok === true
|
|
? selectionResult.changed
|
|
: null
|
|
};
|
|
},
|
|
{
|
|
sourceBattleId,
|
|
targetBattleId,
|
|
priorityDialogueId,
|
|
priorityDialogueBondId,
|
|
acknowledgeActivities,
|
|
selectPriorityId
|
|
}
|
|
);
|
|
}
|
|
|
|
async function consumePreparationAndRoundTripSave(
|
|
page,
|
|
priorityId
|
|
) {
|
|
return page.evaluate(async (priorityId) => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
if (
|
|
!scene ||
|
|
typeof scene.renderTacticalInitiativeChip !==
|
|
'function' ||
|
|
typeof scene.createBattleSaveState !== 'function'
|
|
) {
|
|
throw new Error(
|
|
'BattleScene preparation debug hooks are unavailable.'
|
|
);
|
|
}
|
|
|
|
scene.phase = 'idle';
|
|
scene.renderTacticalInitiativeChip();
|
|
const before =
|
|
window.__HEROS_DEBUG__.battle().thirdCampPreparation;
|
|
const probe = before.mechanicsProbe;
|
|
let preventedDamage = 0;
|
|
|
|
if (priorityId === 'information') {
|
|
const attacker = scene.debugUnitById(
|
|
probe.attackerId
|
|
);
|
|
const defender = scene.debugUnitById(
|
|
probe.defenderId
|
|
);
|
|
const preview = scene.combatPreview(
|
|
attacker,
|
|
defender,
|
|
'attack'
|
|
);
|
|
scene.recordThirdCampPreparationCombatUse(
|
|
preview,
|
|
false,
|
|
0
|
|
);
|
|
} else if (priorityId === 'equipment') {
|
|
const attacker = scene.debugUnitById(
|
|
probe.attackerId
|
|
);
|
|
const defender = scene.debugUnitById(
|
|
probe.defenderId
|
|
);
|
|
const preview = scene.combatPreview(
|
|
attacker,
|
|
defender,
|
|
'attack'
|
|
);
|
|
preventedDamage = probe.preventedDamage;
|
|
scene.recordThirdCampPreparationCombatUse(
|
|
preview,
|
|
true,
|
|
preventedDamage
|
|
);
|
|
} else {
|
|
scene.recordThirdCampPreparationCompanionAttempt({
|
|
bondId: probe.bondId,
|
|
succeeded: true
|
|
});
|
|
}
|
|
|
|
const after =
|
|
window.__HEROS_DEBUG__.battle().thirdCampPreparation;
|
|
const fullState = scene.createBattleSaveState();
|
|
const saveModule = await import(
|
|
'/heros_web/src/game/state/battleSaveState.ts'
|
|
);
|
|
const normalized = saveModule.normalizeBattleSaveState(
|
|
fullState,
|
|
{
|
|
expectedBattleId: fullState.battleId,
|
|
allowTacticalCommand: true
|
|
}
|
|
);
|
|
if (!normalized?.thirdCampPreparation) {
|
|
throw new Error(
|
|
`Normalized ${priorityId} preparation progress is missing.`
|
|
);
|
|
}
|
|
const deepCloned =
|
|
normalized !== fullState &&
|
|
normalized.thirdCampPreparation !==
|
|
fullState.thirdCampPreparation;
|
|
|
|
scene.resetThirdCampPreparationRuntime();
|
|
scene.restoreThirdCampPreparationRuntime(
|
|
normalized.thirdCampPreparation
|
|
);
|
|
scene.renderTacticalInitiativeChip();
|
|
const restored =
|
|
window.__HEROS_DEBUG__.battle().thirdCampPreparation;
|
|
return {
|
|
before,
|
|
after,
|
|
normalized: normalized.thirdCampPreparation,
|
|
restored,
|
|
preventedDamage,
|
|
deepCloned
|
|
};
|
|
}, priorityId);
|
|
}
|
|
|
|
async function expirePreparationAtTurnFour(page) {
|
|
return page.evaluate(() => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
scene.turnNumber = 4;
|
|
scene.phase = 'idle';
|
|
scene.renderTacticalInitiativeChip();
|
|
return window.__HEROS_DEBUG__.battle()
|
|
.thirdCampPreparation;
|
|
});
|
|
}
|
|
|
|
function assertBattleMemory(memory, priority, stage) {
|
|
assert(
|
|
memory?.active,
|
|
`${priority.id} ${stage}: preparation memory is missing.`
|
|
);
|
|
assert.equal(memory.sourceBattleId, sourceBattleId);
|
|
assert.equal(memory.targetBattleId, targetBattleId);
|
|
assert.equal(memory.selectionRecordId, selectionRecordId);
|
|
assert.equal(memory.priorityId, priority.id);
|
|
assert.equal(memory.label, priority.label);
|
|
assert.equal(memory.effectKind, priority.effectKind);
|
|
assert.equal(memory.activeThroughTurn, activeThroughTurn);
|
|
}
|
|
|
|
function assertMechanicsProbe(probe, priority, label) {
|
|
assert(probe, `${label}: mechanicsProbe is missing.`);
|
|
assert.equal(probe.kind, priority.effectKind);
|
|
if (priority.id === 'information') {
|
|
assert.equal(probe.defenderId, trackedEnemyUnitId);
|
|
assert.equal(
|
|
probe.configuredHitBonus,
|
|
priority.configuredValue
|
|
);
|
|
assert.equal(
|
|
probe.expectedHitBonus,
|
|
priority.configuredValue
|
|
);
|
|
assert.equal(
|
|
probe.hitRate,
|
|
Math.min(
|
|
98,
|
|
probe.baselineHitRate + priority.configuredValue
|
|
),
|
|
`${label}: +10%p must be applied before the global 98% hit cap.`
|
|
);
|
|
assert.equal(
|
|
probe.appliedHitBonus,
|
|
probe.hitRate - probe.baselineHitRate
|
|
);
|
|
return;
|
|
}
|
|
if (priority.id === 'equipment') {
|
|
assert.equal(
|
|
probe.configuredReductionPercent,
|
|
priority.configuredValue
|
|
);
|
|
assert.equal(
|
|
probe.expectedReductionPercent,
|
|
priority.configuredValue
|
|
);
|
|
const expectedDamage =
|
|
probe.baselineDamage > 1
|
|
? Math.min(
|
|
probe.baselineDamage - 1,
|
|
Math.max(
|
|
1,
|
|
Math.round(
|
|
probe.baselineDamage *
|
|
(1 - priority.configuredValue / 100)
|
|
)
|
|
)
|
|
)
|
|
: probe.baselineDamage;
|
|
assert.equal(probe.damage, expectedDamage);
|
|
assert.equal(
|
|
probe.preventedDamage,
|
|
probe.baselineDamage - probe.damage
|
|
);
|
|
assert(
|
|
probe.preventedDamage > 0,
|
|
`${label}: the one-time 8% guard must prevent at least one point for the deterministic probe.`
|
|
);
|
|
return;
|
|
}
|
|
assert.equal(probe.bondId, priorityDialogueBondId);
|
|
assert.equal(probe.bondAvailable, true);
|
|
assert.equal(
|
|
probe.preparationBonus,
|
|
priority.configuredValue
|
|
);
|
|
assert.equal(
|
|
probe.expectedPreparationBonus,
|
|
priority.configuredValue
|
|
);
|
|
assert.equal(
|
|
probe.effectiveRate,
|
|
Math.min(
|
|
100,
|
|
probe.baseRate +
|
|
probe.memoryBonus +
|
|
priority.configuredValue
|
|
)
|
|
);
|
|
}
|
|
|
|
function assertExpiredMechanicsProbe(
|
|
probe,
|
|
priority,
|
|
label
|
|
) {
|
|
assert(probe, `${label}: turn-4 mechanicsProbe is missing.`);
|
|
if (priority.id === 'information') {
|
|
assert.equal(probe.configuredHitBonus, 0);
|
|
assert.equal(probe.appliedHitBonus, 0);
|
|
assert.equal(probe.hitRate, probe.baselineHitRate);
|
|
return;
|
|
}
|
|
if (priority.id === 'equipment') {
|
|
assert.equal(probe.configuredReductionPercent, 0);
|
|
assert.equal(probe.preventedDamage, 0);
|
|
assert.equal(probe.damage, probe.baselineDamage);
|
|
return;
|
|
}
|
|
assert.equal(probe.preparationBonus, 0);
|
|
assert.equal(
|
|
probe.effectiveRate,
|
|
Math.min(100, probe.baseRate + probe.memoryBonus)
|
|
);
|
|
}
|
|
|
|
function assertRuntimeConsumed(
|
|
runtime,
|
|
priority,
|
|
preventedDamage
|
|
) {
|
|
assert(runtime, `${priority.id}: runtime snapshot is missing.`);
|
|
assert.equal(
|
|
runtime.informationConsumed,
|
|
priority.id === 'information'
|
|
);
|
|
assert.equal(
|
|
runtime.equipmentConsumed,
|
|
priority.id === 'equipment'
|
|
);
|
|
assert.equal(
|
|
runtime.companionAttempts,
|
|
priority.id === 'companion' ? 1 : 0
|
|
);
|
|
assert.equal(
|
|
runtime.companionSuccesses,
|
|
priority.id === 'companion' ? 1 : 0
|
|
);
|
|
assert.equal(runtime.usageCount, 1);
|
|
assert.equal(
|
|
runtime.preventedDamage,
|
|
priority.id === 'equipment' ? preventedDamage : 0
|
|
);
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(
|
|
runtime,
|
|
'markedEnemyUnitId'
|
|
) &&
|
|
priority.id === 'information'
|
|
) {
|
|
assert.equal(
|
|
runtime.markedEnemyUnitId,
|
|
trackedEnemyUnitId
|
|
);
|
|
} else if (
|
|
Object.prototype.hasOwnProperty.call(
|
|
runtime,
|
|
'markedEnemyUnitId'
|
|
)
|
|
) {
|
|
assert.equal(runtime.markedEnemyUnitId, undefined);
|
|
}
|
|
}
|
|
|
|
async function waitForDebugApi(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector('canvas') !== null &&
|
|
window.__HEROS_GAME__ !== undefined &&
|
|
window.__HEROS_DEBUG__ !== undefined,
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function waitForCamp(page) {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const state = window.__HEROS_DEBUG__?.camp?.();
|
|
return (
|
|
window.__HEROS_DEBUG__
|
|
?.activeScenes?.()
|
|
.includes('CampScene') &&
|
|
state?.scene === 'CampScene' &&
|
|
state?.campaign?.step === 'third-camp' &&
|
|
state?.campBattleId ===
|
|
'third-battle-guangzong-road'
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
return readCamp(page);
|
|
}
|
|
|
|
async function waitForPreparationToggle(page) {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const state = window.__HEROS_DEBUG__?.camp?.();
|
|
return (
|
|
state?.sortieVisible === true &&
|
|
state?.sortiePrepStep === 'briefing' &&
|
|
state?.thirdCampPreparation?.available === true &&
|
|
state.thirdCampPreparation.browserOpen === false &&
|
|
Boolean(
|
|
state.thirdCampPreparation.toggleButtonBounds
|
|
)
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
return readCamp(page);
|
|
}
|
|
|
|
async function openPreparationBrowser(page) {
|
|
const before = await readCamp(page);
|
|
if (before?.thirdCampPreparation?.browserOpen) {
|
|
return before;
|
|
}
|
|
if (!before?.sortieVisible) {
|
|
await page.evaluate(() => {
|
|
const scene =
|
|
window.__HEROS_GAME__?.scene.getScene('CampScene');
|
|
scene.showSortiePrep();
|
|
});
|
|
}
|
|
const ready = await waitForPreparationToggle(page);
|
|
await clickSceneBounds(
|
|
page,
|
|
'CampScene',
|
|
ready.thirdCampPreparation.toggleButtonBounds
|
|
);
|
|
await page.waitForFunction(
|
|
() => {
|
|
const preparation =
|
|
window.__HEROS_DEBUG__?.camp?.()
|
|
?.thirdCampPreparation;
|
|
return (
|
|
preparation?.browserOpen === true &&
|
|
Boolean(preparation.closeButtonBounds) &&
|
|
preparation.priorities?.length === 3 &&
|
|
preparation.priorities.every(
|
|
({ cardBounds, actionButtonBounds }) =>
|
|
Boolean(cardBounds && actionButtonBounds)
|
|
)
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
return readCamp(page);
|
|
}
|
|
|
|
async function selectPreparationCard(page, priorityId) {
|
|
const camp = await readCamp(page);
|
|
const priority = preparationPriority(camp, priorityId);
|
|
assert.equal(
|
|
priority.selectable,
|
|
true,
|
|
`${priorityId}: expected the card to be selectable.`
|
|
);
|
|
await clickSceneBounds(
|
|
page,
|
|
'CampScene',
|
|
priority.actionButtonBounds
|
|
);
|
|
}
|
|
|
|
async function waitForCampSelection(page, priorityId) {
|
|
await page.waitForFunction(
|
|
({ priorityId, selectionRecordId }) => {
|
|
const preparation =
|
|
window.__HEROS_DEBUG__?.camp?.()
|
|
?.thirdCampPreparation;
|
|
return (
|
|
preparation?.selectionRecordId ===
|
|
selectionRecordId &&
|
|
preparation?.ready === true &&
|
|
preparation?.selectedPriorityId === priorityId &&
|
|
preparation?.browserOpen === true &&
|
|
preparation?.priorities?.find(
|
|
({ id }) => id === priorityId
|
|
)?.selected === true
|
|
);
|
|
},
|
|
{ priorityId, selectionRecordId },
|
|
{ timeout: 30000 }
|
|
);
|
|
return readCamp(page);
|
|
}
|
|
|
|
async function waitForBattlePreparation(page, priorityId) {
|
|
try {
|
|
await page.waitForFunction(
|
|
({ targetBattleId, priorityId, selectionRecordId }) => {
|
|
const state = window.__HEROS_DEBUG__?.battle?.();
|
|
const preparation = state?.thirdCampPreparation;
|
|
return (
|
|
state?.scene === 'BattleScene' &&
|
|
state?.battleId === targetBattleId &&
|
|
state?.phase === 'deployment' &&
|
|
['ready', 'degraded'].includes(
|
|
state?.combatAssets?.status
|
|
) &&
|
|
preparation?.active === true &&
|
|
preparation?.effectActive === true &&
|
|
preparation?.selectionRecordId ===
|
|
selectionRecordId &&
|
|
preparation?.priorityId === priorityId &&
|
|
preparation?.presentation?.deployment
|
|
?.selectionRecordId === selectionRecordId &&
|
|
preparation?.presentation?.turn
|
|
?.selectionRecordId === selectionRecordId &&
|
|
Boolean(preparation?.mechanicsProbe) &&
|
|
(priorityId !== 'information' ||
|
|
preparation?.deploymentIntent?.visible === true)
|
|
);
|
|
},
|
|
{ targetBattleId, priorityId, selectionRecordId },
|
|
{ timeout: 90000 }
|
|
);
|
|
} catch (error) {
|
|
const diagnostic = await page.evaluate(() => ({
|
|
activeScenes:
|
|
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
|
battle: window.__HEROS_DEBUG__?.battle?.() ?? null
|
|
}));
|
|
throw new Error(
|
|
`BattleScene did not expose ${priorityId} preparation during deployment: ${JSON.stringify(
|
|
diagnostic
|
|
)}`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
return readBattle(page);
|
|
}
|
|
|
|
function assertPreparationBrowserLayout(camp, label) {
|
|
const preparation = camp.thirdCampPreparation;
|
|
assert.equal(preparation.available, true);
|
|
assert.equal(preparation.browserOpen, true);
|
|
assert.equal(preparation.selectionRecordId, selectionRecordId);
|
|
assertFiniteBounds(
|
|
preparation.closeButtonBounds,
|
|
`${label} close button`
|
|
);
|
|
assertBoundsInsideViewport(
|
|
preparation.closeButtonBounds,
|
|
`${label} close button`
|
|
);
|
|
assert.equal(preparation.priorities.length, 3);
|
|
|
|
const cards = preparation.priorities.map((priority) => {
|
|
assertFiniteBounds(
|
|
priority.cardBounds,
|
|
`${label} ${priority.id} card`
|
|
);
|
|
assertFiniteBounds(
|
|
priority.actionButtonBounds,
|
|
`${label} ${priority.id} action`
|
|
);
|
|
assertBoundsInsideViewport(
|
|
priority.cardBounds,
|
|
`${label} ${priority.id} card`
|
|
);
|
|
assertBoundsInsideViewport(
|
|
priority.actionButtonBounds,
|
|
`${label} ${priority.id} action`
|
|
);
|
|
assertBoundsContains(
|
|
priority.cardBounds,
|
|
priority.actionButtonBounds,
|
|
`${label} ${priority.id} action inside card`
|
|
);
|
|
return {
|
|
id: priority.id,
|
|
bounds: priority.cardBounds
|
|
};
|
|
});
|
|
assertNoOverlappingBounds(cards, `${label} cards`);
|
|
assertNoOverlappingBounds(
|
|
preparation.priorities.map((priority) => ({
|
|
id: priority.id,
|
|
bounds: priority.actionButtonBounds
|
|
})),
|
|
`${label} action buttons`
|
|
);
|
|
cards.forEach((card) => {
|
|
assert(
|
|
!boundsIntersect(
|
|
card.bounds,
|
|
preparation.closeButtonBounds
|
|
),
|
|
`${label}: ${card.id} card must not overlap the close button.`
|
|
);
|
|
});
|
|
}
|
|
|
|
function assertPreparationSelection(camp, priorityId) {
|
|
const preparation = camp.thirdCampPreparation;
|
|
assert.equal(preparation.selectionRecordId, selectionRecordId);
|
|
assert.equal(preparation.ready, true);
|
|
assert.equal(
|
|
preparation.selectedPriorityId,
|
|
priorityId
|
|
);
|
|
assert.equal(
|
|
preparation.priorities.filter(
|
|
({ selected }) => selected
|
|
).length,
|
|
1
|
|
);
|
|
assert.equal(
|
|
preparationPriority(camp, priorityId).selected,
|
|
true
|
|
);
|
|
}
|
|
|
|
async function assertCampaignSelectionPersisted(
|
|
page,
|
|
priorityId,
|
|
label
|
|
) {
|
|
const persisted = await page.evaluate(() => {
|
|
const raw = window.localStorage.getItem(
|
|
'heros-web:campaign-state'
|
|
);
|
|
return raw ? JSON.parse(raw) : null;
|
|
});
|
|
assert(persisted, `${label}: campaign save is missing.`);
|
|
assert.deepEqual(
|
|
persisted.thirdCampPreparationSelection,
|
|
{
|
|
sourceBattleId,
|
|
targetBattleId,
|
|
priorityId
|
|
}
|
|
);
|
|
assert.equal(
|
|
persisted.completedCampVisits.includes(
|
|
selectionRecordId
|
|
),
|
|
false
|
|
);
|
|
assert.equal(
|
|
persisted.firstBattleReport?.completedCampVisits?.includes(
|
|
selectionRecordId
|
|
),
|
|
false
|
|
);
|
|
}
|
|
|
|
function preparationPriority(camp, priorityId) {
|
|
const priority =
|
|
camp?.thirdCampPreparation?.priorities?.find(
|
|
({ id }) => id === priorityId
|
|
);
|
|
assert(
|
|
priority,
|
|
`CampScene is missing the ${priorityId} preparation card: ${JSON.stringify(
|
|
camp?.thirdCampPreparation
|
|
)}`
|
|
);
|
|
return priority;
|
|
}
|
|
|
|
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 readBattle(page) {
|
|
return page.evaluate(() =>
|
|
window.__HEROS_DEBUG__?.battle?.()
|
|
);
|
|
}
|
|
|
|
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];
|
|
assert(
|
|
!boundsIntersect(left.bounds, right.bounds),
|
|
`${label}: ${left.id} and ${right.id} overlap: ${JSON.stringify(
|
|
{ left, right }
|
|
)}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function boundsIntersect(left, right) {
|
|
return 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
|
|
);
|
|
}
|
|
|
|
function assertBoundsContains(outer, inner, label) {
|
|
const epsilon = 0.01;
|
|
assert(
|
|
inner.x >= outer.x - epsilon &&
|
|
inner.y >= outer.y - epsilon &&
|
|
inner.x + inner.width <=
|
|
outer.x + outer.width + epsilon &&
|
|
inner.y + inner.height <=
|
|
outer.y + outer.height + epsilon,
|
|
`${label}: ${JSON.stringify({ outer, inner })}`
|
|
);
|
|
}
|
|
|
|
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.renderer}: 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(180);
|
|
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;
|
|
}
|