Files
heros_web/scripts/verify-third-camp-preparation-browser.mjs

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;
}