2351 lines
68 KiB
JavaScript
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);
|
|
}
|