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 cityStayId = 'xuzhou'; const sourceBattleId = 'seventh-battle-xuzhou-rescue'; const targetBattleId = 'eighth-battle-xiaopei-supply-road'; const offerId = 'city-xuzhou-iron-spear'; const itemId = 'iron-spear'; const itemName = '철창'; const previousItemId = 'training-sword'; const previousItemName = '연습검'; const preparedUnitId = 'mi-zhu'; const preparedUnitName = '미축'; const seedGold = 5000; const offerPrice = 620; const expectedGold = seedGold - offerPrice; const expectedActiveLine = '서주 구입 장비 · 미축 · 철창 · 공격 +2'; const expectedMissingLine = '서주 구입 장비 · 미축 미출전 · 미반영'; const rendererFixtures = { canvas: { renderer: 'canvas', expectedRendererType: 1 }, webgl: { renderer: 'webgl', expectedRendererType: 2 } }; const requestedRenderer = cliOption('renderer') ?? process.env.VERIFY_XUZHOU_EQUIPMENT_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_XUZHOU_EQUIPMENT_HEADLESS !== '0' }); for (const renderer of renderers) { await verifyRenderer(browser, baseUrl, rendererFixtures[renderer]); } console.log( `Xuzhou purchase-to-battle equipment verification passed for ${renderers.join( ' + ' )} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, ` + `DPR ${desktopBrowserDeviceScaleFactor}: real market purchase, reload-compatible equip CTA, ` + 'reload-persisted Mi Zhu focus, comparison/cancel/atomic storage-failure rollback/confirm invariants, ' + 'exact persisted preparation record, active/missing sortie header states, ' + 'real deployment confirmation, one deterministic combat contribution, battle-save restoration, and campaign result persistence.' ); } finally { await browser?.close(); await server.close(); } async function verifyRenderer(browserInstance, baseUrl, rendererFixture) { const context = await browserInstance.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 page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await assertFhdViewport(page, rendererFixture); await openCamp(page); const seed = await seedXuzhouCampaign(page); assert.equal(seed.saved, true, seed.reason ?? 'Xuzhou seed failed.'); assert.equal(seed.step, 'seventh-camp'); assert.equal(seed.gold, seedGold); assert.equal(seed.activeCityStayId, cityStayId); assert.deepEqual(seed.selectedSortieUnitIds, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', preparedUnitId ]); await openXuzhouCity(page); await openMarketWithRealInteraction(page); let city = await readCity(page); assertMarketBaseline(city, rendererFixture.renderer); await capture( page, screenshotPath(rendererFixture.renderer, 'market') ); const offerBefore = findOffer(city, offerId); await clickSceneBounds( page, 'CityStayScene', offerBefore.buttonBounds ); await page.waitForFunction( ({ expectedOfferId, gold }) => { const state = window.__HEROS_DEBUG__?.cityStay?.(); const offer = state?.shop?.offers?.find( (candidate) => candidate.id === expectedOfferId ); return ( state?.shop?.open === true && state?.shop?.gold === gold && offer?.owned === 1 && offer?.purchaseCount === 1 && offer?.equipInteractive === true ); }, { expectedOfferId: offerId, gold: expectedGold } ); city = await readCity(page); assertMarketReceipt(city, rendererFixture.renderer); assertCampaignAfterPurchase( await readSceneCampaign(page, 'CityStayScene') ); await capture( page, screenshotPath(rendererFixture.renderer, 'receipt') ); await reloadAndContinueCity(page); await openMarketWithRealInteraction(page); city = await readCity(page); assertMarketReceipt(city, `${rendererFixture.renderer} reload`); const restoredOffer = findOffer(city, offerId); await page.waitForTimeout(380); const ctaHitProbe = await armCityEquipmentCtaProbe( page, restoredOffer.equipCtaBounds ); assert.equal( ctaHitProbe.equipButtonHit, true, `${rendererFixture.renderer}: the real CTA center must hit its rectangle: ${JSON.stringify( ctaHitProbe )}` ); await clickSceneBounds( page, 'CityStayScene', restoredOffer.equipCtaBounds ); await page.waitForTimeout(320); const ctaClickProbe = await page.evaluate(() => ({ events: [ ...(window.__XUZHOU_EQUIPMENT_CTA_EVENTS__ ?? []) ], activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], navigationPending: window.__HEROS_DEBUG__?.cityStay?.() ?.navigationPending ?? null })); assert( ctaClickProbe.events.some( (event) => event.kind === 'equip-button' ) || ctaClickProbe.activeScenes.includes('CampScene') || ctaClickProbe.navigationPending === true, `${rendererFixture.renderer}: the real CTA center click was not delivered: ${JSON.stringify( { ctaHitProbe, ctaClickProbe, pageErrors, consoleErrors } )}` ); let camp; try { camp = await waitForEquipmentContext(page); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], city: window.__HEROS_DEBUG__?.cityStay?.(), camp: window.__HEROS_DEBUG__?.camp?.() })); throw new Error( `${error.message}\nEquipment routing diagnostic: ${JSON.stringify( diagnostic )}`, { cause: error } ); } assertEquipmentContext(camp, rendererFixture.renderer); await capture( page, screenshotPath(rendererFixture.renderer, 'camp') ); await reloadAndContinueEquipmentContext(page); camp = await waitForEquipmentContext(page); assertEquipmentContext( camp, `${rendererFixture.renderer} intent reload` ); await capture( page, screenshotPath( rendererFixture.renderer, 'camp-context-reloaded' ) ); const focusedRow = focusedEquipmentRow(camp); await clickSceneBounds( page, 'CampScene', focusedRow.actionButtonBounds ); camp = await waitForEquipmentComparison(page); assertEquipmentComparison(camp, rendererFixture.renderer); await capture( page, screenshotPath(rendererFixture.renderer, 'comparison') ); const beforeCancel = relevantEquipmentState( await readSceneCampaign(page, 'CampScene') ); await clickSceneBounds( page, 'CampScene', camp.equipmentSwapComparison.cancelButtonBounds ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp?.()?.equipmentSwapComparison === null ); camp = await readCamp(page); assert.equal( camp.cityEquipmentContext.preparationRecord, null, `${rendererFixture.renderer}: cancel must not create a preparation record.` ); assert.deepEqual( relevantEquipmentState( await readSceneCampaign(page, 'CampScene') ), beforeCancel, `${rendererFixture.renderer}: cancel must preserve inventory, equipment level/exp, purchase count, and preparation.` ); await clickSceneBounds( page, 'CampScene', focusedEquipmentRow(camp).actionButtonBounds ); camp = await waitForEquipmentComparison(page); const beforeStorageFailure = relevantEquipmentState( await readSceneCampaign(page, 'CampScene') ); await injectOneCampaignStorageFailure(page); let storageFailureTriggered = false; try { await clickSceneBounds( page, 'CampScene', camp.equipmentSwapComparison.confirmButtonBounds ); await page.waitForFunction( () => window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__ === true && window.__HEROS_DEBUG__?.camp?.() ?.equipmentSwapComparison === null ); } finally { storageFailureTriggered = await restoreCampaignStorageWrites(page); } assert.equal( storageFailureTriggered, true, `${rendererFixture.renderer}: the injected campaign write failure must be exercised.` ); camp = await readCamp(page); assert.deepEqual( relevantEquipmentState( await readSceneCampaign(page, 'CampScene') ), beforeStorageFailure, `${rendererFixture.renderer}: failed confirmation must roll back inventory, equipment progress, purchase intent, and preparation atomically.` ); assert.equal( camp.cityEquipmentContext.preparationRecord, null, `${rendererFixture.renderer}: failed confirmation must not leave a preparation record.` ); assert.equal( focusedEquipmentRow(camp).canEquip, true, `${rendererFixture.renderer}: the purchased spear must remain equip-ready after rollback.` ); await capture( page, screenshotPath( rendererFixture.renderer, 'storage-rollback' ) ); await clickSceneBounds( page, 'CampScene', focusedEquipmentRow(camp).actionButtonBounds ); camp = await waitForEquipmentComparison(page); await clickSceneBounds( page, 'CampScene', camp.equipmentSwapComparison.confirmButtonBounds ); await page.waitForFunction( ({ unitId, nextItemId }) => { const state = window.__HEROS_DEBUG__?.camp?.(); const unit = state?.campaign?.roster?.find( (candidate) => candidate.id === unitId ); return ( state?.equipmentSwapComparison === null && unit?.equipment?.weapon?.itemId === nextItemId && state?.campaign?.cityEquipmentPreparation?.offerId === 'city-xuzhou-iron-spear' ); }, { unitId: preparedUnitId, nextItemId: itemId } ); camp = await readCamp(page); assertConfirmedPreparation(camp, rendererFixture.renderer); await capture( page, screenshotPath(rendererFixture.renderer, 'equipped') ); await reloadAndContinueCamp(page); camp = await readCamp(page); assertConfirmedPreparation(camp, `${rendererFixture.renderer} reload`); await openSortieWithRealClick(page); camp = await waitForSortiePreparation(page, 'briefing'); assertSortiePreparationActive(camp, rendererFixture.renderer); await capture( page, screenshotPath(rendererFixture.renderer, 'sortie-active') ); await clickSceneBounds( page, 'CampScene', camp.sortiePrimaryAction.bounds ); camp = await waitForSortiePreparation(page, 'formation'); const miZhuToggle = sortieToggleBounds(camp, preparedUnitId); assertBoundsInsideViewport( miZhuToggle, `${rendererFixture.renderer}: Mi Zhu sortie toggle` ); await clickSceneBounds(page, 'CampScene', miZhuToggle); await page.waitForFunction( ({ unitId }) => { const state = window.__HEROS_DEBUG__?.camp?.(); return ( state?.sortiePrepStep === 'formation' && !state?.selectedSortieUnitIds?.includes(unitId) && state?.sortieCityEquipmentPreparation?.status === 'unit-not-selected' ); }, { unitId: preparedUnitId } ); camp = await readCamp(page); assertSortiePreparationMissing(camp, rendererFixture.renderer); await capture( page, screenshotPath(rendererFixture.renderer, 'sortie-missing') ); await clickSceneBounds( page, 'CampScene', sortieToggleBounds(camp, preparedUnitId) ); await page.waitForFunction( ({ unitId }) => { const state = window.__HEROS_DEBUG__?.camp?.(); return ( state?.sortiePrepStep === 'formation' && state?.selectedSortieUnitIds?.includes(unitId) && state?.sortieCityEquipmentPreparation?.status === 'active' ); }, { unitId: preparedUnitId } ); camp = await waitForSortiePreparation(page, 'formation'); assertSortiePreparationActive( camp, `${rendererFixture.renderer} reselected` ); assert.equal( camp.selectedSortieUnitIds.includes(preparedUnitId), true, `${rendererFixture.renderer}: Mi Zhu must be restored to the sortie before entering battle.` ); let battle = await openPreparedBattle(page); assertBattleDeploymentPreparation( battle, rendererFixture.renderer ); await capture( page, screenshotPath( rendererFixture.renderer, 'battle-deployment' ) ); battle = await startBattleWithRealClick(page, battle); assertBattleHudPreparation(battle, rendererFixture.renderer); await capture( page, screenshotPath(rendererFixture.renderer, 'battle-active') ); const contributionRoundTrip = await exerciseBattleContributionAndSave(page); assertBattleContributionRoundTrip( contributionRoundTrip, rendererFixture.renderer ); battle = await readBattle(page); assertBattleHudPreparation( battle, `${rendererFixture.renderer} contributed` ); await capture( page, screenshotPath( rendererFixture.renderer, 'battle-contributed' ) ); const result = await forceVictoryAndReadContribution(page); assertBattleResultContribution( result, contributionRoundTrip.restored.result, rendererFixture.renderer ); await capture( page, screenshotPath(rendererFixture.renderer, 'battle-result') ); await assertFhdViewport(page, rendererFixture); 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 openCamp(page) { await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCamp(); }); await page.waitForFunction( () => { const camp = window.__HEROS_DEBUG__?.camp?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') && camp?.scene === 'CampScene' && camp?.campaign ); }, undefined, { timeout: 15000 } ); } async function seedXuzhouCampaign(page) { return page.evaluate( ({ cityId, sourceId, targetId, gold, miZhuId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if ( !scene?.campaign || !scene.report || typeof scene.persistSortieSelection !== 'function' ) { return { saved: false, reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' }; } const templates = [ ...scene.campaign.roster, ...(scene.report.units ?? []) ]; const fallback = templates[0]; if (!fallback) { return { saved: false, reason: 'No UnitData template is available.' }; } const unit = (spec) => { const template = templates.find((candidate) => candidate.id === spec.id) ?? fallback; return { ...structuredClone(template), ...spec, faction: 'ally', stats: { ...spec.stats }, equipment: structuredClone(spec.equipment) }; }; const roster = [ unit({ id: 'liu-bei', name: '유비', className: '군주', classKey: 'lord', level: 5, exp: 10, hp: 34, maxHp: 34, attack: 9, move: 5, stats: { might: 72, intelligence: 82, leadership: 91, agility: 76, luck: 88 }, equipment: { weapon: { itemId: 'twin-oath-blades', level: 1, exp: 8 }, armor: { itemId: 'oath-robe', level: 1, exp: 6 }, accessory: { itemId: 'benevolence-seal', level: 1, exp: 0 } }, x: 2, y: 17 }), unit({ id: 'guan-yu', name: '관우', className: '중장보병', classKey: 'infantry', level: 5, exp: 12, hp: 40, maxHp: 40, attack: 12, move: 4, stats: { might: 96, intelligence: 78, leadership: 90, agility: 74, luck: 72 }, equipment: { weapon: { itemId: 'green-dragon-blade', level: 1, exp: 9 }, armor: { itemId: 'lamellar-armor', level: 1, exp: 7 }, accessory: { itemId: 'bravery-token', level: 1, exp: 0 } }, x: 3, y: 17 }), unit({ id: 'zhang-fei', name: '장비', className: '창병', classKey: 'spearman', level: 5, exp: 11, hp: 39, maxHp: 39, attack: 12, move: 5, stats: { might: 98, intelligence: 34, leadership: 82, agility: 82, luck: 68 }, equipment: { weapon: { itemId: 'serpent-spear', level: 1, exp: 10 }, armor: { itemId: 'lamellar-armor', level: 1, exp: 8 }, accessory: { itemId: 'bravery-token', level: 1, exp: 0 } }, x: 4, y: 18 }), unit({ id: 'jian-yong', name: '간옹', className: '책사', classKey: 'strategist', level: 4, exp: 7, hp: 27, maxHp: 27, attack: 7, move: 4, stats: { might: 42, intelligence: 80, leadership: 66, agility: 64, luck: 76 }, equipment: { weapon: { itemId: 'training-sword', level: 1, exp: 4 }, armor: { itemId: 'cloth-armor', level: 1, exp: 4 }, accessory: { itemId: 'war-manual', level: 1, exp: 0 } }, x: 3, y: 18 }), unit({ id: miZhuId, name: '미축', className: '군량관', classKey: 'quartermaster', level: 4, exp: 8, hp: 28, maxHp: 28, attack: 7, move: 4, stats: { might: 40, intelligence: 74, leadership: 70, agility: 58, luck: 82 }, equipment: { weapon: { itemId: 'training-sword', level: 1, exp: 6 }, armor: { itemId: 'cloth-armor', level: 1, exp: 6 }, accessory: { itemId: 'grain-pouch', level: 1, exp: 0 } }, x: 1, y: 18 }) ]; const selectedSortieUnitIds = roster.map( (candidate) => candidate.id ); const formationAssignments = { 'liu-bei': 'support', 'guan-yu': 'front', 'zhang-fei': 'flank', 'jian-yong': 'support', [miZhuId]: 'reserve' }; const now = new Date().toISOString(); const report = { ...structuredClone(scene.report), battleId: sourceId, battleTitle: '서주 구원전', outcome: 'victory', turnNumber: 18, rewardGold: 0, itemRewards: [], objectives: [], units: structuredClone(roster), bonds: [], completedCampDialogues: [], completedCampVisits: [], createdAt: now }; delete report.campaignRewards; scene.campaign.step = 'seventh-camp'; scene.campaign.gold = gold; scene.campaign.inventory = {}; scene.campaign.cityEquipmentPurchaseCounts = {}; delete scene.campaign.cityEquipmentPreparation; scene.campaign.latestBattleId = sourceId; scene.campaign.roster = roster; scene.campaign.bonds = []; scene.campaign.firstBattleReport = report; scene.campaign.battleHistory = { [sourceId]: { battleId: sourceId, battleTitle: report.battleTitle, outcome: 'victory', turnNumber: report.turnNumber, rewardGold: 0, itemRewards: [], objectives: [], units: [], bonds: [], completedAt: now } }; scene.campaign.completedCampDialogues = []; scene.campaign.completedCampVisits = []; scene.campaign.campDialogueChoiceIds = {}; scene.campaign.campVisitChoiceIds = {}; scene.campaign.acknowledgedVictoryRewardBattleIds = [ sourceId ]; scene.campaign.acknowledgedVictoryRewardCategories = {}; scene.campaign.dismissedVictoryRewardNoticeBattleIds = [ sourceId ]; delete scene.campaign.pendingAftermathBattleId; scene.campaign.activeCityStayId = cityId; scene.campaign.selectedSortieUnitIds = selectedSortieUnitIds; scene.campaign.sortieFormationAssignments = formationAssignments; scene.campaign.sortieItemAssignments = {}; scene.campaign.sortieOrderSelection = { battleId: targetId, orderId: 'elite' }; delete scene.campaign.sortieResonanceSelection; scene.report = report; scene.selectedSortieUnitIds = selectedSortieUnitIds; scene.sortieFormationAssignments = formationAssignments; scene.sortieItemAssignments = {}; scene.persistSortieSelection(); return { saved: true, step: scene.campaign.step, gold: scene.campaign.gold, activeCityStayId: scene.campaign.activeCityStayId, selectedSortieUnitIds: [ ...scene.campaign.selectedSortieUnitIds ] }; }, { cityId: cityStayId, sourceId: sourceBattleId, targetId: targetBattleId, gold: seedGold, miZhuId: preparedUnitId } ); } async function openXuzhouCity(page) { await page.evaluate(async (cityId) => { await window.__HEROS_DEBUG__.goToCityStay(cityId); }, cityStayId); await waitForCity(page); } async function waitForCity(page) { await page.waitForFunction( (expectedCityId) => { const city = window.__HEROS_DEBUG__?.cityStay?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes( 'CityStayScene' ) && city?.scene === 'CityStayScene' && city?.cityStayId === expectedCityId && city?.ready === true ); }, cityStayId, { timeout: 90000 } ); } async function openMarketWithRealInteraction(page) { const teleported = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CityStayScene'); return scene?.debugTeleportTo?.('market') ?? false; }); assert.equal( teleported, true, 'Expected to place the player within the market interaction radius.' ); await page.waitForTimeout(520); await page.keyboard.press('KeyE'); await page.waitForFunction( () => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true ); } function assertMarketBaseline(city, renderer) { assert.equal(city.shop.open, true); assert.equal(city.shop.gold, seedGold); assert.equal(city.shop.offers.length, 3); assertBoundsInsideViewport( city.shop.panelBounds, `${renderer}: market panel` ); assert.equal( new Set(city.shop.offers.map((offer) => offer.iconKind)).size, 3, `${renderer}: weapon, armor, and accessory must use distinct icon kinds.` ); city.shop.offers.forEach((offer) => { assertFiniteBounds( offer.itemIconBounds, `${renderer}: ${offer.id} item icon` ); assertBoundsInside( offer.itemIconBounds, city.shop.panelBounds, `${renderer}: ${offer.id} item icon` ); assertBoundsInside( offer.buttonBounds, city.shop.panelBounds, `${renderer}: ${offer.id} purchase button` ); }); assertNoOverlappingBounds( city.shop.offers.map((offer) => ({ id: offer.id, bounds: offer.itemIconBounds })), `${renderer}: market item icons` ); const ironSpear = findOffer(city, offerId); assert.equal(ironSpear.itemId, itemId); assert.equal(ironSpear.itemName, itemName); assert.equal(ironSpear.price, offerPrice); assert.equal(ironSpear.owned, 0); assert.equal(ironSpear.purchaseCount, 0); assert.equal(ironSpear.canBuy, true); assert.equal(ironSpear.interactive, true); assert.equal(ironSpear.equipCtaBounds, null); assert.equal(ironSpear.equipInteractive, false); const grainPouch = findOffer( city, 'city-xuzhou-grain-pouch' ); assert.equal( grainPouch.bonusText, '보급품 슬롯 · 전투 효과 준비 중' ); } function assertMarketReceipt(city, renderer) { assert.equal(city.shop.gold, expectedGold); const offer = findOffer(city, offerId); assert.equal(offer.owned, 1); assert.equal(offer.purchaseCount, 1); assert.equal(offer.interactive, true); assert.equal(offer.equipInteractive, true); assertFiniteBounds( offer.equipCtaBounds, `${renderer}: equip comparison CTA` ); assertBoundsInside( offer.equipCtaBounds, city.shop.panelBounds, `${renderer}: equip comparison CTA` ); assertNoOverlappingBounds( [ { id: 'purchase', bounds: offer.buttonBounds }, { id: 'equip-comparison', bounds: offer.equipCtaBounds } ], `${renderer}: purchased-row CTAs` ); } function assertCampaignAfterPurchase(campaign) { assert.equal(campaign.gold, expectedGold); assert.equal(campaign.inventory[itemName], 1); assert.equal( campaign.cityEquipmentPurchaseCounts[offerId], 1 ); assert.equal(campaign.cityEquipmentPreparation, undefined); const miZhu = campaign.roster.find( (unit) => unit.id === preparedUnitId ); assert.equal(miZhu.equipment.weapon.itemId, previousItemId); assert.equal(miZhu.equipment.weapon.level, 1); assert.equal(miZhu.equipment.weapon.exp, 6); } async function reloadAndContinueCity(page) { await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.waitForFunction( () => window.__HEROS_DEBUG__?.activeScenes?.().includes( 'TitleScene' ), undefined, { timeout: 90000 } ); await page.waitForTimeout(380); await page.keyboard.press('Enter'); await waitForCity(page); } async function waitForEquipmentContext(page) { await page.waitForFunction( ({ expectedOfferId, expectedUnitId, expectedItemId }) => { const camp = window.__HEROS_DEBUG__?.camp?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes( 'CampScene' ) && camp?.activeTab === 'equipment' && camp?.secondaryPanels?.equipment?.visible === true && camp?.cityEquipmentContext?.offerId === expectedOfferId && camp?.cityEquipmentContext?.unitId === expectedUnitId && camp?.cityEquipmentContext?.itemId === expectedItemId && camp?.cityEquipmentContext?.focusedActionButtonBounds ); }, { expectedOfferId: offerId, expectedUnitId: preparedUnitId, expectedItemId: itemId }, { timeout: 90000 } ); return readCamp(page); } function assertEquipmentContext(camp, renderer) { const context = camp.cityEquipmentContext; assert(context, `${renderer}: city equipment context is required.`); assert.deepEqual(camp.campaign.cityEquipmentEquipIntent, { version: 1, intentRecordId: 'city-equipment-equip-intent', cityStayId, sourceBattleId, targetBattleId, offerId, itemId, slot: 'weapon', price: offerPrice, purchaseNumber: 1 }); assert.equal(context.selectionRecordId, 'city-equipment-preparation'); assert.equal(context.cityStayId, cityStayId); assert.equal(context.cityName, '서주'); assert.equal(context.offerId, offerId); assert.equal(context.itemId, itemId); assert.equal(context.itemName, itemName); assert.equal(context.slot, 'weapon'); assert.equal(context.unitId, preparedUnitId); assert.equal(context.unitName, preparedUnitName); assert.equal(context.purchaseCount, 1); assert.equal(context.activeTab, 'equipment'); assert.equal(context.selectedUnitId, preparedUnitId); assert.equal(context.currentItemId, previousItemId); assert.equal(context.preparationRecord, null); assert.equal( camp.secondaryPanels.equipment.page, context.inventoryPage ); assert( camp.secondaryPanels.equipment.visibleItemIds.includes(itemId), `${renderer}: the purchased spear must be visible on the selected inventory page.` ); assertBoundsInsideViewport( camp.secondaryPanels.equipment.bounds, `${renderer}: equipment panel` ); assertBoundsInside( context.bannerBounds, camp.secondaryPanels.equipment.bounds, `${renderer}: purchase context banner` ); assertBoundsInside( context.focusedRowBounds, camp.secondaryPanels.equipment.bounds, `${renderer}: focused purchase row` ); assertBoundsInside( context.focusedActionButtonBounds, context.focusedRowBounds, `${renderer}: focused purchase action` ); assertNoOverlappingBounds( [ { id: 'context-banner', bounds: context.bannerBounds }, { id: 'focused-row', bounds: context.focusedRowBounds } ], `${renderer}: equipment context layout` ); const focused = focusedEquipmentRow(camp); assert.equal(focused.itemId, itemId); assert.equal(focused.focusedCityPurchase, true); assert.equal(focused.canEquip, true); assert.equal(focused.interactive, true); } function focusedEquipmentRow(camp) { const row = camp.secondaryPanels.equipment.rows.find( (candidate) => candidate.focusedCityPurchase ); assert(row, 'Expected a focused purchased equipment row.'); return row; } async function waitForEquipmentComparison(page) { await page.waitForFunction( () => { const comparison = window.__HEROS_DEBUG__?.camp?.()?.equipmentSwapComparison; return ( comparison?.visible === true && comparison?.confirmInteractive === true && comparison?.cancelInteractive === true ); }, undefined, { timeout: 30000 } ); return readCamp(page); } function assertEquipmentComparison(camp, renderer) { const comparison = camp.equipmentSwapComparison; assert(comparison, `${renderer}: equipment comparison is required.`); assert.equal(comparison.unitId, preparedUnitId); assert.equal(comparison.unitName, preparedUnitName); assert.equal(comparison.slot, 'weapon'); assert.equal(comparison.currentItemId, previousItemId); assert.equal(comparison.currentItemName, previousItemName); assert.equal(comparison.candidateItemId, itemId); assert.equal(comparison.candidateItemName, itemName); assert( comparison.deltaText.includes('공격+2'), `${renderer}: expected an exact +2 attack comparison, received "${comparison.deltaText}".` ); assertBoundsInsideViewport( comparison.panelBounds, `${renderer}: equipment comparison panel` ); assertBoundsInside( comparison.cancelButtonBounds, comparison.panelBounds, `${renderer}: comparison cancel` ); assertBoundsInside( comparison.confirmButtonBounds, comparison.panelBounds, `${renderer}: comparison confirm` ); assertNoOverlappingBounds( [ { id: 'cancel', bounds: comparison.cancelButtonBounds }, { id: 'confirm', bounds: comparison.confirmButtonBounds } ], `${renderer}: comparison actions` ); } function relevantEquipmentState(campaign) { const unit = campaign.roster.find( (candidate) => candidate.id === preparedUnitId ); return { gold: campaign.gold, inventory: structuredClone(campaign.inventory), purchaseCount: campaign.cityEquipmentPurchaseCounts?.[offerId] ?? 0, preparation: campaign.cityEquipmentPreparation ? structuredClone(campaign.cityEquipmentPreparation) : null, equipIntent: campaign.cityEquipmentEquipIntent ? structuredClone(campaign.cityEquipmentEquipIntent) : null, weapon: structuredClone(unit.equipment.weapon), unitLevel: unit.level, unitExp: unit.exp }; } async function injectOneCampaignStorageFailure(page) { await page.evaluate(() => { if (window.__XUZHOU_ORIGINAL_STORAGE_SET_ITEM__) { throw new Error( 'A campaign storage failure injection is already active.' ); } const original = Storage.prototype.setItem; window.__XUZHOU_ORIGINAL_STORAGE_SET_ITEM__ = original; window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__ = false; Storage.prototype.setItem = function (key, value) { if ( window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__ !== true && String(key).startsWith('heros-web:campaign-state') ) { window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__ = true; throw new DOMException( 'Injected Xuzhou equipment save failure.', 'QuotaExceededError' ); } return original.call(this, key, value); }; }); } async function restoreCampaignStorageWrites(page) { return page.evaluate(() => { const triggered = window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__ === true; const original = window.__XUZHOU_ORIGINAL_STORAGE_SET_ITEM__; if (original) { Storage.prototype.setItem = original; } delete window.__XUZHOU_ORIGINAL_STORAGE_SET_ITEM__; delete window.__XUZHOU_STORAGE_FAILURE_TRIGGERED__; return triggered; }); } function assertConfirmedPreparation(camp, renderer) { const campaign = camp.campaign; assert(campaign, `${renderer}: Camp campaign debug is required.`); assert.equal( campaign.cityEquipmentEquipIntent, undefined, `${renderer}: confirming the equipment swap must consume the reload intent.` ); const miZhu = campaign.roster.find( (unit) => unit.id === preparedUnitId ); assert(miZhu, `${renderer}: Mi Zhu must remain in the roster.`); assert.deepEqual(miZhu.equipment.weapon, { itemId, level: 1, exp: 6 }); assert.equal(campaign.inventory[itemName] ?? 0, 0); assert.equal(campaign.inventory[previousItemName], 1); assert.equal(campaign.cityEquipmentPurchaseCounts[offerId], 1); assert.deepEqual(campaign.cityEquipmentPreparation, { version: 1, selectionRecordId: 'city-equipment-preparation', cityStayId, sourceBattleId, targetBattleId, offerId, itemId, slot: 'weapon', price: offerPrice, purchaseNumber: 1, unitId: preparedUnitId, previousItemId }); if (camp.cityEquipmentContext) { assert.deepEqual( camp.cityEquipmentContext.preparationRecord, campaign.cityEquipmentPreparation ); assert.equal(camp.cityEquipmentContext.currentItemId, itemId); } } async function reloadAndContinueCamp(page) { await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.waitForFunction( () => window.__HEROS_DEBUG__?.activeScenes?.().includes( 'TitleScene' ), undefined, { timeout: 90000 } ); await page.waitForTimeout(380); await page.keyboard.press('Enter'); await page.waitForFunction( () => { const camp = window.__HEROS_DEBUG__?.camp?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes( 'CampScene' ) && camp?.scene === 'CampScene' && camp?.campaign?.cityEquipmentPreparation?.offerId === 'city-xuzhou-iron-spear' ); }, undefined, { timeout: 90000 } ); } async function reloadAndContinueEquipmentContext(page) { await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.waitForFunction( () => window.__HEROS_DEBUG__?.activeScenes?.().includes( 'TitleScene' ), undefined, { timeout: 90000 } ); await page.waitForTimeout(380); await page.keyboard.press('Enter'); await waitForEquipmentContext(page); } async function openSortieWithRealClick(page) { await page.waitForFunction( () => { const command = window.__HEROS_DEBUG__?.camp?.()?.sortieCommand; return command?.interactive === true && command?.bounds; }, undefined, { timeout: 30000 } ); const camp = await readCamp(page); assert.equal( camp.sortieCommand?.interactive, true, 'The Camp sortie command must be interactive.' ); assertBoundsInsideViewport( camp.sortieCommand?.bounds, 'Camp sortie command' ); await clickSceneBounds( page, 'CampScene', camp.sortieCommand.bounds ); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp?.()?.sortieVisible === true ); } async function waitForSortiePreparation(page, step) { await page.waitForFunction( (expectedStep) => { const camp = window.__HEROS_DEBUG__?.camp?.(); return ( camp?.sortieVisible === true && camp?.sortiePrepStep === expectedStep && camp?.sortiePrimaryAction?.interactive === true && camp?.sortieCityEquipmentPreparation?.visible === true ); }, step, { timeout: 30000 } ); return readCamp(page); } function assertSortiePreparationActive(camp, renderer) { const status = camp.sortieCityEquipmentPreparation; assert(status, `${renderer}: sortie preparation status is required.`); assert.equal(camp.nextSortieBattleId, targetBattleId); assert.equal(status.selectionRecordId, 'city-equipment-preparation'); assert.equal(status.sourceBattleId, sourceBattleId); assert.equal(status.targetBattleId, targetBattleId); assert.equal(status.offerId, offerId); assert.equal(status.itemId, itemId); assert.equal(status.itemName, itemName); assert.equal(status.unitId, preparedUnitId); assert.equal(status.unitName, preparedUnitName); assert.deepEqual(status.statDelta, { attack: 2, defense: 0, strategy: 0 }); assert.equal(status.unitSelected, true); assert.equal(status.equipmentValid, true); assert.equal(status.active, true); assert.equal(status.status, 'active'); assert.equal(status.line, expectedActiveLine); assert.equal(status.displayedText, expectedActiveLine); assertBoundsInsideViewport( status.bounds, `${renderer}: active sortie preparation line` ); } function sortieToggleBounds(camp, unitId) { const index = camp.sortiePortraitRosterView.visibleUnitIds.indexOf(unitId); assert( index >= 0, `Expected ${unitId} on the visible sortie roster page: ${JSON.stringify( camp.sortiePortraitRosterView.visibleUnitIds )}` ); const card = camp.sortiePortraitRosterView.cardBounds[index]; assert(card?.toggleButtonBounds, `Expected ${unitId} toggle bounds.`); return card.toggleButtonBounds; } function assertSortiePreparationMissing(camp, renderer) { const status = camp.sortieCityEquipmentPreparation; assert(status, `${renderer}: missing sortie status is required.`); assert.equal(status.unitSelected, false); assert.equal(status.equipmentValid, true); assert.equal(status.active, false); assert.equal(status.status, 'unit-not-selected'); assert.equal(status.line, expectedMissingLine); assert.equal(status.displayedText, expectedMissingLine); assert.equal( camp.selectedSortieUnitIds.includes(preparedUnitId), false ); assertBoundsInsideViewport( status.bounds, `${renderer}: missing sortie preparation line` ); } async function openPreparedBattle(page) { await page.evaluate(async (battleId) => { await window.__HEROS_DEBUG__.goToBattle(battleId); }, targetBattleId); await page.waitForFunction( ({ battleId, unitId, expectedOfferId }) => { const battle = window.__HEROS_DEBUG__?.battle?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes( 'BattleScene' ) && battle?.battleId === battleId && battle?.phase === 'deployment' && battle?.deployedAllyIds?.includes(unitId) && battle?.cityEquipmentPreparation?.active === true && battle?.cityEquipmentPreparation?.snapshot?.offerId === expectedOfferId && battle?.cityEquipmentPreparation?.mechanicsProbe?.offensive && battle?.battleHud?.deploymentPanel?.startButtonBounds ); }, { battleId: targetBattleId, unitId: preparedUnitId, expectedOfferId: offerId }, { timeout: 90000 } ); return readBattle(page); } function assertBattleDeploymentPreparation(battle, renderer) { assert.equal(battle.battleId, targetBattleId); assert.equal(battle.phase, 'deployment'); assert( battle.selectedSortieUnitIds.includes(preparedUnitId), `${renderer}: Mi Zhu must remain in the effective sortie selection.` ); assert( battle.deployedAllyIds.includes(preparedUnitId), `${renderer}: Mi Zhu must be instantiated as an allied battle unit.` ); const preparation = battle.cityEquipmentPreparation; assert(preparation?.active, `${renderer}: battle preparation is missing.`); assert.equal(preparation.deployed, true); assertPreparationIdentity( preparation.snapshot, `${renderer}: deployment snapshot` ); assert.equal(preparation.unitName, preparedUnitName); assert.equal(preparation.itemName, itemName); assert.equal(preparation.previousItemName, previousItemName); assert.deepEqual(preparation.preparedBonuses, { attack: 3, defense: 1, strategy: 0 }); assert.deepEqual(preparation.previousBonuses, { attack: 1, defense: 1, strategy: 0 }); assert.deepEqual(preparation.statDelta, { attack: 2, defense: 0, strategy: 0 }); assert.deepEqual(preparation.runtime, { deployed: true, offensiveActions: 0, defensiveHits: 0, bonusDamage: 0, preventedDamage: 0 }); assert.equal(preparation.chip.visible, false); assert.equal(preparation.chip.bounds, null); const probe = preparation.mechanicsProbe; assert(probe, `${renderer}: mechanics probe is required.`); assert.equal(probe.selectionRecordId, 'city-equipment-preparation'); assert.deepEqual(probe.preparedBonuses, preparation.preparedBonuses); assert.deepEqual(probe.baselineBonuses, preparation.previousBonuses); assert.equal(probe.offensive?.attackerId, preparedUnitId); assert.equal( probe.offensive.preparedDamage - probe.offensive.baselineDamage, 2, `${renderer}: iron spear must add exactly two real attack damage over the training sword baseline.` ); assert.equal(probe.offensive.bonusDamage, 2); const panel = battle.battleHud?.deploymentPanel; assert(panel, `${renderer}: deployment panel is required.`); assert( panel.subtitleText.includes(expectedActiveLine), `${renderer}: deployment subtitle must surface the exact Xuzhou equipment preparation: "${panel.subtitleText}".` ); assertBoundsInsideViewport( panel.headerBounds, `${renderer}: deployment header` ); assertBoundsInside( panel.statusBounds, panel.headerBounds, `${renderer}: deployment status` ); assertBoundsInside( panel.preparationSummaryBounds, panel.headerBounds, `${renderer}: deployment equipment summary` ); assertBoundsInsideViewport( panel.noticeBounds, `${renderer}: deployment notice` ); assertBoundsInside( panel.noticeTextBounds, panel.noticeBounds, `${renderer}: deployment notice text` ); assertBoundsInsideViewport( panel.restoreButtonBounds, `${renderer}: deployment restore action` ); assertBoundsInsideViewport( panel.startButtonBounds, `${renderer}: deployment start action` ); panel.roleBounds.forEach((bounds, index) => assertBoundsInsideViewport( bounds, `${renderer}: deployment role ${index + 1}` ) ); assertNoOverlappingBounds( [ { id: 'header', bounds: panel.headerBounds }, { id: 'notice', bounds: panel.noticeBounds }, ...panel.roleBounds.map((bounds, index) => ({ id: `role-${index + 1}`, bounds })), { id: 'restore', bounds: panel.restoreButtonBounds }, { id: 'start', bounds: panel.startButtonBounds } ], `${renderer}: deployment primary regions` ); } async function startBattleWithRealClick(page, initialBattle) { await page.waitForFunction( () => { const battle = window.__HEROS_DEBUG__?.battle?.(); return ( battle?.phase === 'deployment' && ['ready', 'degraded'].includes( battle?.combatAssets?.status ) && battle?.battleHud?.deploymentPanel ?.combatAssetsReady === true ); }, undefined, { timeout: 90000 } ); const readyBattle = await readBattle(page); assert.deepEqual( readyBattle.cityEquipmentPreparation.snapshot, initialBattle.cityEquipmentPreparation.snapshot, 'The preparation identity must not change while combat assets load.' ); await clickSceneBounds( page, 'BattleScene', readyBattle.battleHud.deploymentPanel.startButtonBounds ); await page.waitForFunction( () => { const battle = window.__HEROS_DEBUG__?.battle?.(); return ( battle?.phase === 'idle' && battle?.battleHud?.deploymentPanel === null && battle?.cityEquipmentPreparation?.chip?.visible === true ); }, undefined, { timeout: 90000 } ); return readBattle(page); } function assertBattleHudPreparation(battle, renderer) { const preparation = battle.cityEquipmentPreparation; assert(preparation?.active, `${renderer}: active battle preparation is required.`); assert.equal(preparation.deployed, true); assertPreparationIdentity( preparation.snapshot, `${renderer}: active battle snapshot` ); assert.equal(preparation.chip.visible, true); assert.equal(preparation.chip.text, expectedActiveLine); assertBoundsInsideViewport( preparation.chip.bounds, `${renderer}: active battle preparation chip` ); assert.equal( battle.battleHud?.deploymentPanel, null, `${renderer}: deployment panel must be gone after its real start action.` ); } async function exerciseBattleContributionAndSave(page) { return page.evaluate( ({ unitId, expectedBattleId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if ( !scene || typeof scene.resolveAttack !== 'function' || typeof scene.createBattleSaveState !== 'function' || typeof scene.applyBattleSaveState !== 'function' || typeof scene.resetCityEquipmentContributionRuntime !== 'function' ) { throw new Error( 'BattleScene equipment contribution/save hooks are unavailable.' ); } const before = window.__HEROS_DEBUG__.battle() .cityEquipmentPreparation; const enemyId = before?.mechanicsProbe?.offensive?.defenderId; const attacker = scene.debugUnitById(unitId); const defender = scene.debugUnitById(enemyId); if (!attacker || !defender) { throw new Error( `Prepared combat pair is unavailable: ${unitId} -> ${enemyId}.` ); } const url = new URL(window.location.href); url.searchParams.set('debugForceHit', '1'); window.history.replaceState( window.history.state, '', `${url.pathname}${url.search}${url.hash}` ); const originalRollPercent = scene.rollPercent; let attack; try { scene.rollPercent = () => false; attack = scene.resolveAttack(attacker, defender); } finally { scene.rollPercent = originalRollPercent; } scene.renderTacticalInitiativeChip(); const after = window.__HEROS_DEBUG__.battle() .cityEquipmentPreparation; const fullState = scene.createBattleSaveState(); if ( fullState.battleId !== expectedBattleId || !fullState.cityEquipmentContribution ) { throw new Error( 'Battle save is missing the deployed Xuzhou equipment contribution.' ); } const saveSnapshot = structuredClone( fullState.cityEquipmentContribution ); const detached = fullState.cityEquipmentContribution !== after.result; scene.resetCityEquipmentContributionRuntime(); scene.renderTacticalInitiativeChip(); const reset = window.__HEROS_DEBUG__.battle() .cityEquipmentPreparation; scene.applyBattleSaveState(structuredClone(fullState)); scene.renderTacticalInitiativeChip(); const restored = window.__HEROS_DEBUG__.battle() .cityEquipmentPreparation; return { before, attack: { attackerId: attack.attacker.id, defenderId: attack.defender.id, hit: attack.hit, critical: attack.critical, damage: attack.damage, previousDefenderHp: attack.previousDefenderHp }, after, saveSnapshot, reset, restored, detached }; }, { unitId: preparedUnitId, expectedBattleId: targetBattleId } ); } function assertBattleContributionRoundTrip(roundTrip, renderer) { assert.equal(roundTrip.attack.attackerId, preparedUnitId); assert.equal(roundTrip.attack.hit, true); assert.equal(roundTrip.attack.critical, false); assert(roundTrip.attack.damage > 0); assert( roundTrip.attack.damage <= roundTrip.attack.previousDefenderHp ); assert.deepEqual(roundTrip.before.runtime, { deployed: true, offensiveActions: 0, defensiveHits: 0, bonusDamage: 0, preventedDamage: 0 }); assert.deepEqual(roundTrip.after.runtime, { deployed: true, offensiveActions: 1, defensiveHits: 0, bonusDamage: 2, preventedDamage: 0 }); assertContributionSnapshot( roundTrip.after.result, `${renderer}: live contribution` ); assertContributionSnapshot( roundTrip.saveSnapshot, `${renderer}: battle save contribution` ); assert.equal( roundTrip.detached, true, `${renderer}: battle save contribution must be detached from the debug result snapshot.` ); assert.deepEqual(roundTrip.reset.runtime, { deployed: false, offensiveActions: 0, defensiveHits: 0, bonusDamage: 0, preventedDamage: 0 }); assert.deepEqual( roundTrip.restored.runtime, roundTrip.after.runtime, `${renderer}: create/apply must restore the exact contribution counters.` ); assert.deepEqual( roundTrip.restored.result, roundTrip.saveSnapshot, `${renderer}: create/apply must restore the exact saved contribution snapshot.` ); assert.equal(roundTrip.restored.chip.visible, true); assertBoundsInsideViewport( roundTrip.restored.chip.bounds, `${renderer}: restored contribution chip` ); } async function forceVictoryAndReadContribution(page) { await page.evaluate(() => { window.__HEROS_DEBUG__.forceBattleOutcome('victory'); }); await page.waitForFunction( (battleId) => { const battle = window.__HEROS_DEBUG__?.battle?.(); return ( battle?.battleId === battleId && battle?.battleOutcome === 'victory' && battle?.resultVisible === true && battle?.resultSettlement?.status === 'complete' && battle?.cityEquipmentPreparation?.result ?.offensiveActions === 1 ); }, targetBattleId, { timeout: 90000 } ); return page.evaluate(async (battleId) => { const battle = window.__HEROS_DEBUG__.battle(); const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const visibleResultTexts = (scene?.resultObjects ?? []) .filter( (object) => object?.type === 'Text' && object.active && object.visible && typeof object.text === 'string' ) .map((object) => object.text); const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); const campaign = campaignModule.loadCampaignState(); return { battle, visibleResultTexts, campaign: { reportBattleId: campaign.firstBattleReport?.battleId ?? null, reportContribution: campaign.firstBattleReport ?.cityEquipmentContribution ? structuredClone( campaign.firstBattleReport .cityEquipmentContribution ) : null, settlementBattleId: campaign.battleHistory?.[battleId]?.battleId ?? null, settlementContribution: campaign.battleHistory?.[ battleId ]?.cityEquipmentContribution ? structuredClone( campaign.battleHistory[battleId] .cityEquipmentContribution ) : null } }; }, targetBattleId); } function assertBattleResultContribution( result, expectedContribution, renderer ) { const battle = result.battle; assert.equal(battle.battleOutcome, 'victory'); assert.equal(battle.resultVisible, true); assert.equal(battle.resultSettlement.status, 'complete'); assertContributionSnapshot( battle.cityEquipmentPreparation.result, `${renderer}: result contribution` ); assert.deepEqual( battle.cityEquipmentPreparation.result, expectedContribution, `${renderer}: result must preserve the restored live contribution.` ); assert( battle.cityEquipmentPreparation.resultText.includes( '공격 사용 1회' ), `${renderer}: result feedback must report one actual Mi Zhu attack: "${battle.cityEquipmentPreparation.resultText}".` ); assert( battle.cityEquipmentPreparation.resultText.includes( '추가 피해 +2' ), `${renderer}: result feedback must report the exact +2 damage contribution.` ); assert( result.visibleResultTexts.some((text) => text.includes( battle.cityEquipmentPreparation.compactResultText ) ), `${renderer}: visible result subtitle must contain the compact persisted contribution feedback: ${JSON.stringify( result.visibleResultTexts )}` ); battle.resultActions.forEach((action) => { assertBoundsInsideViewport( action.bounds, `${renderer}: result action ${action.kind}` ); }); assertNoOverlappingBounds( battle.resultActions.map((action) => ({ id: action.kind, bounds: action.bounds })), `${renderer}: result actions` ); assert.equal( result.campaign.reportBattleId, targetBattleId ); assert.equal( result.campaign.settlementBattleId, targetBattleId ); assertContributionSnapshot( result.campaign.reportContribution, `${renderer}: campaign report contribution` ); assertContributionSnapshot( result.campaign.settlementContribution, `${renderer}: campaign settlement contribution` ); assert.deepEqual( result.campaign.reportContribution, expectedContribution, `${renderer}: campaign report must persist the exact battle contribution.` ); assert.deepEqual( result.campaign.settlementContribution, expectedContribution, `${renderer}: campaign settlement must persist the exact battle contribution.` ); } function assertPreparationIdentity(snapshot, label) { assert(snapshot, `${label}: snapshot is required.`); assert.equal(snapshot.version, 1); assert.equal( snapshot.selectionRecordId, 'city-equipment-preparation' ); assert.equal(snapshot.cityStayId, cityStayId); assert.equal(snapshot.sourceBattleId, sourceBattleId); assert.equal(snapshot.targetBattleId, targetBattleId); assert.equal(snapshot.offerId, offerId); assert.equal(snapshot.itemId, itemId); assert.equal(snapshot.slot, 'weapon'); assert.equal(snapshot.price, offerPrice); assert.equal(snapshot.purchaseNumber, 1); assert.equal(snapshot.unitId, preparedUnitId); assert.equal(snapshot.previousItemId, previousItemId); } function assertContributionSnapshot(snapshot, label) { assertPreparationIdentity(snapshot, label); assert.equal(snapshot.deployed, true); assert.equal(snapshot.offensiveActions, 1); assert.equal(snapshot.defensiveHits, 0); assert.equal(snapshot.bonusDamage, 2); assert.equal(snapshot.preventedDamage, 0); } async function readCity(page) { return page.evaluate( () => window.__HEROS_DEBUG__?.cityStay?.() ); } async function readCamp(page) { return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); } async function readBattle(page) { return page.evaluate(() => window.__HEROS_DEBUG__?.battle?.()); } async function readSceneCampaign(page, sceneKey) { return page.evaluate((key) => { const scene = window.__HEROS_DEBUG__?.scene(key); if (!scene?.campaign) { throw new Error(`${key} campaign is unavailable.`); } return structuredClone(scene.campaign); }, sceneKey); } function findOffer(city, requestedOfferId) { const offer = city.shop.offers.find( (candidate) => candidate.id === requestedOfferId ); assert(offer, `Expected market offer ${requestedOfferId}.`); return offer; } async function waitForDebugApi(page) { await page.waitForFunction( () => document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 } ); } async function assertFhdViewport(page, rendererFixture) { 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, 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, rendererFixture.expectedRendererType ); 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.deepEqual(viewport.canvas?.bounds, { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }); } async function armCityEquipmentCtaProbe(page, bounds) { assertFiniteBounds(bounds, 'City equipment CTA probe bounds'); const point = await page.evaluate((requestedBounds) => { const scene = window.__HEROS_GAME__?.scene.getScene('CityStayScene'); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); if (!scene || !canvasBounds) { return null; } return { x: canvasBounds.left + (requestedBounds.x + requestedBounds.width / 2) * canvasBounds.width / scene.scale.width, y: canvasBounds.top + (requestedBounds.y + requestedBounds.height / 2) * canvasBounds.height / scene.scale.height }; }, bounds); assert( point && Number.isFinite(point.x) && Number.isFinite(point.y), 'Unable to map the City equipment CTA probe point.' ); await page.mouse.move(point.x, point.y); await page.waitForTimeout(80); return page.evaluate(({ expectedOfferId, expectedPoint }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CityStayScene'); const view = scene?.shopOfferViews?.find( (candidate) => candidate.offerId === expectedOfferId ); const equipButton = view?.equipButton; const pointer = scene?.input?.activePointer; const hits = scene && pointer && typeof scene.input.hitTestPointer === 'function' ? scene.input.hitTestPointer(pointer) : []; window.__XUZHOU_EQUIPMENT_CTA_EVENTS__ = []; if (scene && equipButton) { equipButton.on('pointerdown', () => { window.__XUZHOU_EQUIPMENT_CTA_EVENTS__.push({ kind: 'equip-button' }); }); scene.input.on( 'gameobjectdown', (_pointer, gameObject) => { if (gameObject === equipButton) { window.__XUZHOU_EQUIPMENT_CTA_EVENTS__.push({ kind: 'gameobjectdown' }); } } ); } return { point: expectedPoint, pointer: pointer ? { x: pointer.x, y: pointer.y, worldX: pointer.worldX, worldY: pointer.worldY } : null, equipButtonReady: Boolean( equipButton?.active && equipButton?.visible && equipButton?.input?.enabled ), equipButtonHit: hits.includes(equipButton), hits: hits.map((object) => ({ type: object.type ?? null, depth: object.depth ?? null, text: typeof object.text === 'string' ? object.text : null, equipButton: object === equipButton })) }; }, { expectedOfferId: offerId, expectedPoint: point }); } async function clickSceneBounds(page, sceneKey, bounds) { assertFiniteBounds(bounds, `${sceneKey} click bounds`); const point = await page.evaluate( ({ requestedSceneKey, requestedBounds }) => { const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); if (!scene || !canvasBounds || !requestedBounds) { return null; } return { x: canvasBounds.left + (requestedBounds.x + requestedBounds.width / 2) * canvasBounds.width / scene.scale.width, y: canvasBounds.top + (requestedBounds.y + requestedBounds.height / 2) * canvasBounds.height / scene.scale.height }; }, { requestedSceneKey: sceneKey, requestedBounds: bounds } ); assert( point && Number.isFinite(point.x) && Number.isFinite(point.y) ); await page.mouse.click(point.x, point.y); } function assertFiniteBounds(bounds, label) { assert(bounds, `${label}: bounds are required.`); for (const key of ['x', 'y', 'width', 'height']) { assert( Number.isFinite(bounds[key]), `${label}: ${key} must be finite.` ); } assert(bounds.width > 0, `${label}: width must be positive.`); assert(bounds.height > 0, `${label}: height must be positive.`); } function assertBoundsInsideViewport(bounds, label) { assertBoundsInside( bounds, { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }, label ); } function assertBoundsInside(bounds, container, label) { assertFiniteBounds(bounds, label); assertFiniteBounds(container, `${label} container`); const epsilon = 0.01; assert( bounds.x >= container.x - epsilon && bounds.y >= container.y - epsilon && bounds.x + bounds.width <= container.x + container.width + epsilon && bounds.y + bounds.height <= container.y + container.height + epsilon, `${label}: expected bounds inside container, received ${JSON.stringify( { bounds, container } )}` ); } 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]; assertFiniteBounds(left.bounds, `${label} ${left.id}`); assertFiniteBounds(right.bounds, `${label} ${right.id}`); const overlapWidth = Math.min( left.bounds.x + left.bounds.width, right.bounds.x + right.bounds.width ) - Math.max(left.bounds.x, right.bounds.x); const overlapHeight = Math.min( left.bounds.y + left.bounds.height, right.bounds.y + right.bounds.height ) - Math.max(left.bounds.y, right.bounds.y); assert( overlapWidth <= 0 || overlapHeight <= 0, `${label} "${left.id}" and "${right.id}" overlap: ${JSON.stringify( { left, right } )}` ); } } } async function capture(page, path) { await page.waitForTimeout(240); 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 screenshotPath(renderer, stage) { return `dist/verification-xuzhou-equipment-${renderer}-${stage}.png`; } function cliOption(name) { const prefix = `--${name}=`; const value = process.argv.find((argument) => argument.startsWith(prefix) ); return value?.slice(prefix.length); }