Files
heros_web/scripts/verify-xuzhou-equipment-link-browser.mjs

2351 lines
68 KiB
JavaScript

import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { chromium } from 'playwright';
import { createServer } from 'vite';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const 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.resultText
)
),
`${renderer}: visible result subtitle must contain the 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);
}