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