feat: add city stay exploration and resonance
This commit is contained in:
@@ -98,6 +98,8 @@ function validateCampInteractionUxGuards() {
|
||||
const steppedNavigationMethod = extractPrivateMethod(source, 'startCampNavigationWithCampaignStep');
|
||||
const startStoryMethod = extractPrivateMethod(source, 'startVictoryStory');
|
||||
const saveMethod = extractPrivateMethod(source, 'saveCampToSlot');
|
||||
const wolongLeadMethod = extractPrivateMethod(source, 'hasWolongAudienceLead');
|
||||
const renderBondListMethod = extractPrivateMethod(source, 'renderBondList');
|
||||
let checkedGuardCount = 0;
|
||||
|
||||
expectCampUx(
|
||||
@@ -192,6 +194,21 @@ function validateCampInteractionUxGuards() {
|
||||
);
|
||||
checkedGuardCount += 1;
|
||||
|
||||
expectCampUx(
|
||||
startStoryMethod.includes("flow.nextBattleId === campBattleIds.seventeenth && !this.hasWolongAudienceLead()") &&
|
||||
wolongLeadMethod.includes("completedVisits.includes('jingzhou-scholar-rumors')") &&
|
||||
wolongLeadMethod.includes("completedVisits.includes('refugee-village-patrol')"),
|
||||
'the Wolong sortie gate must accept a completed information visit regardless of which visit reward choice was taken'
|
||||
);
|
||||
checkedGuardCount += 1;
|
||||
|
||||
expectCampUx(
|
||||
renderBondListMethod.includes('dialogue.id === this.selectedDialogueId') &&
|
||||
renderBondListMethod.includes('[selectedBond, ...relatedBonds.filter'),
|
||||
'the resonance list must keep the currently selected dialogue bond visible when more than four bonds are available'
|
||||
);
|
||||
checkedGuardCount += 1;
|
||||
|
||||
return checkedGuardCount;
|
||||
}
|
||||
|
||||
|
||||
849
scripts/verify-city-stay-browser.mjs
Normal file
849
scripts/verify-city-stay-browser.mjs
Normal file
@@ -0,0 +1,849 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
desktopBrowserContextOptions,
|
||||
desktopBrowserDeviceScaleFactor,
|
||||
desktopBrowserViewport
|
||||
} from './desktop-browser-viewport.mjs';
|
||||
|
||||
const xuzhouBattleId = 'seventh-battle-xuzhou-rescue';
|
||||
const xiaopeiBattleId = 'eighth-battle-xiaopei-supply-road';
|
||||
const xuzhouCityStayId = 'xuzhou';
|
||||
const seedGold = 5000;
|
||||
const additionalCityVisualCases = [
|
||||
{
|
||||
cityStayId: 'xinye',
|
||||
cityName: '신야',
|
||||
afterBattleId: 'seventeenth-battle-wolong-visit-road',
|
||||
nextBattleId: 'eighteenth-battle-bowang-ambush',
|
||||
battleTitle: '와룡 방문로',
|
||||
campStep: 'seventeenth-camp',
|
||||
partnerId: 'zhuge-liang',
|
||||
partnerName: '제갈량',
|
||||
bondId: 'liu-bei__zhuge-liang',
|
||||
bondTitle: '삼고초려',
|
||||
screenshotPath: 'dist/verification-city-xinye-information.png'
|
||||
},
|
||||
{
|
||||
cityStayId: 'chengdu',
|
||||
cityName: '성도',
|
||||
afterBattleId: 'thirty-third-battle-chengdu-surrender',
|
||||
nextBattleId: 'thirty-fourth-battle-jiameng-pass',
|
||||
battleTitle: '성도 항복',
|
||||
campStep: 'thirty-third-camp',
|
||||
partnerId: 'huang-quan',
|
||||
partnerName: '황권',
|
||||
bondId: 'liu-bei__huang-quan',
|
||||
bondTitle: '익주의 신뢰',
|
||||
screenshotPath: 'dist/verification-city-chengdu-information.png'
|
||||
}
|
||||
];
|
||||
const targetUrl = withDebugOptions(
|
||||
process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/'
|
||||
);
|
||||
|
||||
let serverProcess;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
serverProcess = await ensureLocalServer(targetUrl);
|
||||
browser = await chromium.launch({
|
||||
headless: process.env.VERIFY_CITY_STAY_HEADLESS !== '0'
|
||||
});
|
||||
const context = await browser.newContext(desktopBrowserContextOptions);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
|
||||
|
||||
await openCleanCamp(page);
|
||||
await assertDesktopViewport(page);
|
||||
await seedXuzhouCityStay(page);
|
||||
await restartCamp(page);
|
||||
|
||||
const initialState = await waitForXuzhouCityStay(page);
|
||||
verifyCityStayLayout(initialState);
|
||||
await captureStableScreenshot(page, 'dist/verification-city-xuzhou-information.png');
|
||||
assert.equal(
|
||||
initialState.victoryRewardAcknowledgement.visible,
|
||||
false,
|
||||
'The seeded victory-reward notice must not obstruct the city-stay flow.'
|
||||
);
|
||||
|
||||
const informationResult = await verifyInformationReward(page, initialState);
|
||||
const marketResult = await verifyMarketPurchase(page);
|
||||
const dialogueResult = await verifyCompanionDialogue(page);
|
||||
|
||||
await restartCamp(page);
|
||||
const restoredState = await waitForXuzhouCityStay(page);
|
||||
verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult);
|
||||
|
||||
await verifyNextBattleBriefing(page, restoredState.cityStay.information.briefingLines);
|
||||
await verifyAdditionalCityLayouts(page);
|
||||
|
||||
assert.deepEqual(
|
||||
pageErrors,
|
||||
[],
|
||||
`Expected zero browser page errors, received: ${JSON.stringify(pageErrors.slice(-5))}`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Verified the Xuzhou, Xinye, and Chengdu city stays at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
||||
`DPR ${desktopBrowserDeviceScaleFactor}: seven non-overlapping tabs and an in-viewport city panel; ` +
|
||||
'idempotent information rewards; exact market gold and inventory changes; saved companion choice; ' +
|
||||
'all outcomes surviving a full browser reload; completed city information appearing in each next battle briefing; ' +
|
||||
'and information, market, and resonance layouts remaining readable at the desktop baseline.'
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
if (serverProcess && !serverProcess.killed) {
|
||||
serverProcess.kill();
|
||||
}
|
||||
}
|
||||
|
||||
function withDebugOptions(url) {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('debug', '1');
|
||||
parsed.searchParams.set('renderer', 'canvas');
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function openCleanCamp(page) {
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.evaluate(() => window.localStorage.clear());
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await waitForDebugApi(page);
|
||||
await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp());
|
||||
await page.waitForFunction(() => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp();
|
||||
return (
|
||||
window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') &&
|
||||
camp?.scene === 'CampScene' &&
|
||||
camp?.report?.battleId
|
||||
);
|
||||
}, undefined, { timeout: 90000 });
|
||||
}
|
||||
|
||||
async function seedXuzhouCityStay(page) {
|
||||
const result = await page.evaluate(({ battleId, battleTitle, gold }) => {
|
||||
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 now = new Date().toISOString();
|
||||
const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0];
|
||||
const liuBei = templateUnit
|
||||
? {
|
||||
...structuredClone(templateUnit),
|
||||
id: 'liu-bei',
|
||||
name: '유비'
|
||||
}
|
||||
: null;
|
||||
const xuzhouBond = {
|
||||
id: 'liu-bei__mi-zhu',
|
||||
unitIds: ['liu-bei', 'mi-zhu'],
|
||||
title: '서주 후원',
|
||||
level: 42,
|
||||
exp: 0,
|
||||
battleExp: 0,
|
||||
description: '미축은 서주의 민심과 군량을 묶어 유비군이 오래 버틸 수 있게 돕는다.'
|
||||
};
|
||||
const report = {
|
||||
...scene.report,
|
||||
battleId,
|
||||
battleTitle,
|
||||
outcome: 'victory',
|
||||
turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1),
|
||||
rewardGold: 0,
|
||||
itemRewards: [],
|
||||
objectives: [],
|
||||
units: liuBei ? [liuBei] : [],
|
||||
bonds: [xuzhouBond],
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt: now
|
||||
};
|
||||
delete report.campaignRewards;
|
||||
|
||||
scene.campaign.step = 'seventh-camp';
|
||||
scene.campaign.gold = gold;
|
||||
scene.campaign.latestBattleId = battleId;
|
||||
scene.campaign.roster = liuBei ? [liuBei] : [];
|
||||
scene.campaign.bonds = [xuzhouBond];
|
||||
scene.campaign.firstBattleReport = report;
|
||||
scene.campaign.battleHistory = {
|
||||
[battleId]: {
|
||||
battleId,
|
||||
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 = [battleId];
|
||||
scene.campaign.acknowledgedVictoryRewardCategories = {};
|
||||
scene.campaign.dismissedVictoryRewardNoticeBattleIds = [battleId];
|
||||
delete scene.campaign.pendingAftermathBattleId;
|
||||
scene.report = report;
|
||||
|
||||
scene.persistSortieSelection();
|
||||
return {
|
||||
saved: true,
|
||||
latestBattleId: scene.campaign.latestBattleId,
|
||||
reportBattleId: scene.campaign.firstBattleReport?.battleId,
|
||||
gold: scene.campaign.gold
|
||||
};
|
||||
}, {
|
||||
battleId: xuzhouBattleId,
|
||||
battleTitle: '서주 구원전',
|
||||
gold: seedGold
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result,
|
||||
{
|
||||
saved: true,
|
||||
latestBattleId: xuzhouBattleId,
|
||||
reportBattleId: xuzhouBattleId,
|
||||
gold: seedGold
|
||||
},
|
||||
`Failed to persist the Xuzhou seed through CampScene.persistSortieSelection(): ${JSON.stringify(result)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function restartCamp(page, expectedCityStayId = xuzhouCityStayId) {
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await waitForDebugApi(page);
|
||||
await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp());
|
||||
await page.waitForFunction((expectedId) => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp();
|
||||
return (
|
||||
window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') &&
|
||||
camp?.scene === 'CampScene' &&
|
||||
camp?.cityStay?.id === expectedId &&
|
||||
camp?.cityStay?.active === true
|
||||
);
|
||||
}, expectedCityStayId, { timeout: 90000 });
|
||||
}
|
||||
|
||||
async function verifyAdditionalCityLayouts(page) {
|
||||
for (const visualCase of additionalCityVisualCases) {
|
||||
await openCampAboveBattle(page);
|
||||
|
||||
const seedResult = await seedCityStayPreview(page, { ...visualCase, gold: seedGold });
|
||||
assert.deepEqual(
|
||||
seedResult,
|
||||
{
|
||||
saved: true,
|
||||
latestBattleId: visualCase.afterBattleId,
|
||||
reportBattleId: visualCase.afterBattleId,
|
||||
cityStayId: visualCase.cityStayId
|
||||
},
|
||||
`Failed to persist the ${visualCase.cityName} city-stay preview: ${JSON.stringify(seedResult)}`
|
||||
);
|
||||
|
||||
await restartCamp(page, visualCase.cityStayId);
|
||||
const state = await waitForCityStay(page, visualCase.cityStayId);
|
||||
verifyCityStayLayout(state);
|
||||
assert.equal(state.cityStay.name, visualCase.cityName);
|
||||
assert.equal(state.cityStay.information.completed, false);
|
||||
assert.equal(state.cityStay.dialogue.completed, false);
|
||||
await captureStableScreenshot(page, visualCase.screenshotPath);
|
||||
|
||||
await selectCityLocation(page, 'market');
|
||||
const marketState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
assert.equal(marketState.cityStay.market.offers.length, 3);
|
||||
marketState.cityStay.market.offers.forEach((offer) => {
|
||||
assert(offer.buyBounds && offer.interactive && offer.canBuy);
|
||||
assertBoundsInside(
|
||||
offer.buyBounds,
|
||||
marketState.cityStay.panelBounds,
|
||||
`${visualCase.cityName} market offer "${offer.id}" must remain inside the city panel.`
|
||||
);
|
||||
});
|
||||
assertNoOverlappingBounds(
|
||||
marketState.cityStay.market.offers.map((offer) => ({ id: offer.id, bounds: offer.buyBounds })),
|
||||
`${visualCase.cityName} market offer buttons`
|
||||
);
|
||||
await captureStableScreenshot(
|
||||
page,
|
||||
visualCase.screenshotPath.replace('-information.png', '-market.png')
|
||||
);
|
||||
|
||||
await selectCityLocation(page, 'dialogue');
|
||||
const dialogueState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
assert.equal(dialogueState.cityStay.dialogue.choices.length, 2);
|
||||
dialogueState.cityStay.dialogue.choices.forEach((choice) => {
|
||||
assert(choice.bounds && choice.interactive);
|
||||
assertBoundsInside(
|
||||
choice.bounds,
|
||||
dialogueState.cityStay.panelBounds,
|
||||
`${visualCase.cityName} resonance choice "${choice.id}" must remain inside the city panel.`
|
||||
);
|
||||
});
|
||||
assertNoOverlappingBounds(
|
||||
dialogueState.cityStay.dialogue.choices.map((choice) => ({ id: choice.id, bounds: choice.bounds })),
|
||||
`${visualCase.cityName} resonance choice buttons`
|
||||
);
|
||||
await captureStableScreenshot(
|
||||
page,
|
||||
visualCase.screenshotPath.replace('-information.png', '-dialogue.png')
|
||||
);
|
||||
|
||||
await selectCityLocation(page, 'information');
|
||||
const functionalInitialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const informationResult = await verifyInformationReward(page, functionalInitialState);
|
||||
const marketResult = await verifyMarketPurchase(page, null);
|
||||
const dialogueResult = await verifyCompanionDialogue(page, null);
|
||||
|
||||
await restartCamp(page, visualCase.cityStayId);
|
||||
const restoredState = await waitForCityStay(page, visualCase.cityStayId);
|
||||
verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult);
|
||||
await verifyNextBattleBriefing(
|
||||
page,
|
||||
restoredState.cityStay.information.briefingLines,
|
||||
visualCase.nextBattleId,
|
||||
visualCase.cityName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function openCampAboveBattle(page) {
|
||||
await page.evaluate(async () => {
|
||||
const game = window.__HEROS_GAME__;
|
||||
if (!game || !window.__HEROS_DEBUG__) {
|
||||
throw new Error('The game debug API is unavailable while opening CampScene.');
|
||||
}
|
||||
if (game.scene.isActive('BattleScene')) {
|
||||
game.scene.stop('BattleScene');
|
||||
}
|
||||
await window.__HEROS_DEBUG__.goToCamp();
|
||||
game.scene.bringToTop('CampScene');
|
||||
});
|
||||
await page.waitForFunction(() => (
|
||||
!window.__HEROS_GAME__?.scene.isActive('BattleScene') &&
|
||||
window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') &&
|
||||
window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene'
|
||||
), undefined, { timeout: 90000 });
|
||||
}
|
||||
|
||||
async function seedCityStayPreview(page, visualCase) {
|
||||
return page.evaluate((config) => {
|
||||
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 now = new Date().toISOString();
|
||||
const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0];
|
||||
if (!templateUnit) {
|
||||
return { saved: false, reason: 'No unit template is available for the city preview.' };
|
||||
}
|
||||
|
||||
const liuBei = {
|
||||
...structuredClone(templateUnit),
|
||||
id: 'liu-bei',
|
||||
name: '유비'
|
||||
};
|
||||
const partner = {
|
||||
...structuredClone(templateUnit),
|
||||
id: config.partnerId,
|
||||
name: config.partnerName
|
||||
};
|
||||
const bond = {
|
||||
id: config.bondId,
|
||||
unitIds: ['liu-bei', config.partnerId],
|
||||
title: config.bondTitle,
|
||||
level: 42,
|
||||
exp: 0,
|
||||
battleExp: 0,
|
||||
description: `${config.cityName} 체류 중 함께 다음 전투를 준비하며 쌓은 공명입니다.`
|
||||
};
|
||||
const report = {
|
||||
...scene.report,
|
||||
battleId: config.afterBattleId,
|
||||
battleTitle: config.battleTitle,
|
||||
outcome: 'victory',
|
||||
turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1),
|
||||
rewardGold: 0,
|
||||
itemRewards: [],
|
||||
objectives: [],
|
||||
units: [liuBei, partner],
|
||||
bonds: [bond],
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt: now
|
||||
};
|
||||
delete report.campaignRewards;
|
||||
|
||||
scene.campaign.step = config.campStep;
|
||||
scene.campaign.gold = config.gold;
|
||||
scene.campaign.latestBattleId = config.afterBattleId;
|
||||
scene.campaign.roster = [liuBei, partner];
|
||||
scene.campaign.bonds = [bond];
|
||||
scene.campaign.firstBattleReport = report;
|
||||
scene.campaign.battleHistory = {
|
||||
[config.afterBattleId]: {
|
||||
battleId: config.afterBattleId,
|
||||
battleTitle: config.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 = [config.afterBattleId];
|
||||
scene.campaign.acknowledgedVictoryRewardCategories = {};
|
||||
scene.campaign.dismissedVictoryRewardNoticeBattleIds = [config.afterBattleId];
|
||||
delete scene.campaign.pendingAftermathBattleId;
|
||||
scene.report = report;
|
||||
scene.persistSortieSelection();
|
||||
|
||||
const cityStayId = scene.currentCityStay?.()?.id ?? null;
|
||||
return {
|
||||
saved: true,
|
||||
latestBattleId: scene.campaign.latestBattleId,
|
||||
reportBattleId: scene.campaign.firstBattleReport?.battleId,
|
||||
cityStayId
|
||||
};
|
||||
}, visualCase);
|
||||
}
|
||||
|
||||
async function waitForCityStay(page, expectedId) {
|
||||
await page.waitForFunction((cityStayId) => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp();
|
||||
return (
|
||||
camp?.scene === 'CampScene' &&
|
||||
camp?.cityStay?.id === cityStayId &&
|
||||
camp?.cityStay?.active === true &&
|
||||
camp?.cityStay?.panelBounds &&
|
||||
camp?.campTabs?.length === 7
|
||||
);
|
||||
}, expectedId, { timeout: 90000 });
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
}
|
||||
|
||||
async function waitForXuzhouCityStay(page) {
|
||||
return waitForCityStay(page, xuzhouCityStayId);
|
||||
}
|
||||
|
||||
function verifyCityStayLayout(state) {
|
||||
assert.equal(state.activeTab, 'city', 'The city tab should be active when the Xuzhou stay opens.');
|
||||
assert.equal(state.campTabs.length, 7, 'A city stay must expose exactly seven CampScene tabs.');
|
||||
assert(
|
||||
state.campTabs.every((tab) => tab.bounds && tab.interactive),
|
||||
`Every city-stay tab must be visible and interactive: ${JSON.stringify(state.campTabs)}`
|
||||
);
|
||||
assertNoOverlappingBounds(
|
||||
state.campTabs.map((tab) => ({ id: tab.id, bounds: tab.bounds })),
|
||||
'CampScene tabs'
|
||||
);
|
||||
|
||||
const sceneBounds = state.campSkin?.sceneBounds ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: desktopBrowserViewport.width,
|
||||
height: desktopBrowserViewport.height
|
||||
};
|
||||
assertBoundsInside(
|
||||
state.cityStay.panelBounds,
|
||||
sceneBounds,
|
||||
`The Xuzhou city panel must remain inside the ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} scene.`
|
||||
);
|
||||
state.campTabs.forEach((tab) => {
|
||||
assertBoundsInside(tab.bounds, sceneBounds, `CampScene tab "${tab.id}" must remain inside the scene.`);
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyInformationReward(page, initialState) {
|
||||
const information = initialState.cityStay.information;
|
||||
assert.equal(information.completed, false, 'Xuzhou information should initially be unclaimed.');
|
||||
assert(information.actionInteractive && information.actionBounds, 'The information action must be clickable.');
|
||||
|
||||
const reward = parseInventoryReward(information.itemReward);
|
||||
const beforeCount = initialState.campaign.inventory[reward.label] ?? 0;
|
||||
await clickSceneBounds(page, 'CampScene', information.actionBounds);
|
||||
await page.waitForFunction(({ visitId, label, expectedCount }) => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp();
|
||||
return (
|
||||
camp?.cityStay?.information?.id === visitId &&
|
||||
camp.cityStay.information.completed === true &&
|
||||
(camp.campaign?.inventory?.[label] ?? 0) === expectedCount
|
||||
);
|
||||
}, {
|
||||
visitId: information.id,
|
||||
label: reward.label,
|
||||
expectedCount: beforeCount + reward.amount
|
||||
});
|
||||
|
||||
const afterFirstClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||||
const cityStay = scene?.currentCityStay?.();
|
||||
if (!scene || !cityStay) {
|
||||
throw new Error('Xuzhou city stay disappeared before the idempotency probe.');
|
||||
}
|
||||
scene.gatherCityInformation(cityStay);
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
const afterRepeatedClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
|
||||
assert.equal(
|
||||
afterRepeatedClaim.campaign.inventory[reward.label] ?? 0,
|
||||
afterFirstClaim.campaign.inventory[reward.label] ?? 0,
|
||||
'Calling the completed information action again must not duplicate its inventory reward.'
|
||||
);
|
||||
assert.equal(
|
||||
afterRepeatedClaim.campaign.completedCampVisits.filter((id) => id === information.id).length,
|
||||
1,
|
||||
'The information completion record must remain unique.'
|
||||
);
|
||||
|
||||
return {
|
||||
id: information.id,
|
||||
inventoryLabel: reward.label,
|
||||
inventoryCount: afterRepeatedClaim.campaign.inventory[reward.label] ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyMarketPurchase(page, screenshotPath = 'dist/verification-city-xuzhou-market.png') {
|
||||
await selectCityLocation(page, 'market');
|
||||
const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
if (screenshotPath) {
|
||||
await captureStableScreenshot(page, screenshotPath);
|
||||
}
|
||||
const offer = before.cityStay.market.offers[0];
|
||||
assert(offer?.buyBounds && offer.interactive && offer.canBuy, 'The first Xuzhou market offer must be affordable and clickable.');
|
||||
|
||||
const goldBefore = before.cityStay.market.gold;
|
||||
const ownedBefore = offer.owned;
|
||||
await clickSceneBounds(page, 'CampScene', offer.buyBounds);
|
||||
await page.waitForFunction(({ offerId, expectedGold, expectedOwned }) => {
|
||||
const market = window.__HEROS_DEBUG__?.camp()?.cityStay?.market;
|
||||
const currentOffer = market?.offers?.find((candidate) => candidate.id === offerId);
|
||||
return market?.gold === expectedGold && currentOffer?.owned === expectedOwned;
|
||||
}, {
|
||||
offerId: offer.id,
|
||||
expectedGold: goldBefore - offer.price,
|
||||
expectedOwned: ownedBefore + 1
|
||||
});
|
||||
|
||||
const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const purchasedOffer = after.cityStay.market.offers.find((candidate) => candidate.id === offer.id);
|
||||
assert.equal(after.cityStay.market.gold, goldBefore - offer.price, 'The market must deduct the exact listed price.');
|
||||
assert.equal(purchasedOffer.owned, ownedBefore + 1, 'The purchased equipment inventory must increase by exactly one.');
|
||||
|
||||
return {
|
||||
offerId: offer.id,
|
||||
itemId: offer.itemId,
|
||||
itemName: offer.itemName,
|
||||
gold: after.cityStay.market.gold,
|
||||
owned: purchasedOffer.owned
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyCompanionDialogue(page, screenshotPath = 'dist/verification-city-xuzhou-dialogue.png') {
|
||||
await selectCityLocation(page, 'dialogue');
|
||||
const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
if (screenshotPath) {
|
||||
await captureStableScreenshot(page, screenshotPath);
|
||||
}
|
||||
const choice = before.cityStay.dialogue.choices[0];
|
||||
assert(choice?.bounds && choice.interactive, 'The first Xuzhou companion choice must be clickable.');
|
||||
|
||||
await clickSceneBounds(page, 'CampScene', choice.bounds);
|
||||
await page.waitForTimeout(150);
|
||||
const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
assert(
|
||||
after?.cityStay?.dialogue?.id === before.cityStay.dialogue.id &&
|
||||
after.cityStay.dialogue.completed === true &&
|
||||
after.cityStay.dialogue.choiceId === choice.id,
|
||||
`The companion choice did not complete synchronously: ${JSON.stringify({
|
||||
before: before.cityStay.dialogue,
|
||||
after: after?.cityStay?.dialogue,
|
||||
campaignBonds: after?.report?.bonds
|
||||
})}`
|
||||
);
|
||||
assert.equal(
|
||||
after.campaign.completedCampDialogues.filter((id) => id === before.cityStay.dialogue.id).length,
|
||||
1,
|
||||
'The companion dialogue completion record must be unique.'
|
||||
);
|
||||
assert.equal(
|
||||
after.campaign.campDialogueChoiceIds[before.cityStay.dialogue.id],
|
||||
choice.id,
|
||||
'The selected companion response must be saved by choice ID.'
|
||||
);
|
||||
|
||||
return {
|
||||
id: before.cityStay.dialogue.id,
|
||||
choiceId: choice.id
|
||||
};
|
||||
}
|
||||
|
||||
async function selectCityLocation(page, locationId) {
|
||||
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const location = state.cityStay.locations.find((candidate) => candidate.id === locationId);
|
||||
assert(location?.bounds && location.interactive, `City location "${locationId}" must be clickable.`);
|
||||
await clickSceneBounds(page, 'CampScene', location.bounds);
|
||||
await page.waitForFunction((expectedId) => (
|
||||
window.__HEROS_DEBUG__?.camp()?.cityStay?.selectedLocation === expectedId
|
||||
), locationId);
|
||||
}
|
||||
|
||||
function verifyRestoredOutcomes(state, information, market, dialogue) {
|
||||
assert.equal(state.cityStay.information.completed, true, 'Information completion must survive a CampScene restart.');
|
||||
assert.equal(
|
||||
state.campaign.inventory[information.inventoryLabel] ?? 0,
|
||||
information.inventoryCount,
|
||||
'The one-time information reward count must survive a CampScene restart.'
|
||||
);
|
||||
|
||||
const restoredOffer = state.cityStay.market.offers.find((offer) => offer.id === market.offerId);
|
||||
assert.equal(state.cityStay.market.gold, market.gold, 'The exact post-purchase gold balance must survive a CampScene restart.');
|
||||
assert.equal(restoredOffer?.owned, market.owned, 'The purchased equipment count must survive a CampScene restart.');
|
||||
|
||||
assert.equal(state.cityStay.dialogue.completed, true, 'Companion dialogue completion must survive a CampScene restart.');
|
||||
assert.equal(state.cityStay.dialogue.choiceId, dialogue.choiceId, 'The companion choice must survive a CampScene restart.');
|
||||
assert.equal(
|
||||
state.campaign.campDialogueChoiceIds[dialogue.id],
|
||||
dialogue.choiceId,
|
||||
'The persisted companion choice history must match the selected response.'
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyNextBattleBriefing(
|
||||
page,
|
||||
expectedBriefingLines,
|
||||
nextBattleId = xiaopeiBattleId,
|
||||
cityName = '서주'
|
||||
) {
|
||||
await page.evaluate((battleId) => window.__HEROS_DEBUG__?.goToBattle(battleId), nextBattleId);
|
||||
await page.waitForFunction((battleId) => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
window.__HEROS_DEBUG__?.activeScenes().includes('BattleScene') &&
|
||||
battle?.scene === 'BattleScene' &&
|
||||
battle?.battleId === battleId &&
|
||||
battle?.cityInformation
|
||||
);
|
||||
}, nextBattleId, { timeout: 90000 });
|
||||
|
||||
let battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
assert.equal(battle.cityInformation.available, true, `${nextBattleId} must expose its preceding city information.`);
|
||||
assert.equal(battle.cityInformation.completed, true, `${nextBattleId} must see the saved ${cityName} information completion.`);
|
||||
assert.deepEqual(
|
||||
battle.cityInformation.briefingLines,
|
||||
expectedBriefingLines,
|
||||
`${nextBattleId} must expose the exact authored ${cityName} briefing lines.`
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
scene?.showOpeningBattleEvent?.();
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const battleState = window.__HEROS_DEBUG__?.battle();
|
||||
return battleState?.activeBattleEvent?.key === 'opening' || battleState?.battleLog?.length > 0;
|
||||
});
|
||||
battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
const openingEvent = battle.activeBattleEvent?.key === 'opening'
|
||||
? battle.activeBattleEvent
|
||||
: battle.pendingBattleEvents?.find((event) => event.key === 'opening');
|
||||
const briefingRecordedInLog = expectedBriefingLines.every((line) => (
|
||||
battle.battleLog?.some((entry) => entry.includes(line))
|
||||
));
|
||||
assert(
|
||||
(openingEvent && expectedBriefingLines.every((line) => openingEvent.lines.includes(line))) || briefingRecordedInLog,
|
||||
`The opening battle briefing must record every completed ${cityName} information line: ${JSON.stringify({
|
||||
openingEvent,
|
||||
battleLog: battle.battleLog?.slice(-4)
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
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];
|
||||
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 })}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertBoundsInside(bounds, container, message) {
|
||||
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,
|
||||
`${message} ${JSON.stringify({ bounds, container })}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForDebugApi(page) {
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector('canvas') !== null &&
|
||||
window.__HEROS_GAME__ !== undefined &&
|
||||
window.__HEROS_DEBUG__ !== undefined
|
||||
), undefined, { timeout: 90000 });
|
||||
}
|
||||
|
||||
async function assertDesktopViewport(page) {
|
||||
const viewport = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('canvas');
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
dpr: window.devicePixelRatio,
|
||||
visualScale: window.visualViewport?.scale ?? 1,
|
||||
canvas: canvas ? { width: canvas.width, height: canvas.height } : null
|
||||
};
|
||||
});
|
||||
assert.deepEqual(
|
||||
viewport,
|
||||
{
|
||||
width: desktopBrowserViewport.width,
|
||||
height: desktopBrowserViewport.height,
|
||||
dpr: desktopBrowserDeviceScaleFactor,
|
||||
visualScale: 1,
|
||||
canvas: {
|
||||
width: desktopBrowserViewport.width,
|
||||
height: desktopBrowserViewport.height
|
||||
}
|
||||
},
|
||||
'The browser QA baseline must be an explicit 1920x1080 CSS viewport at DPR 1 and 100% zoom.'
|
||||
);
|
||||
}
|
||||
|
||||
async function clickSceneBounds(page, sceneKey, 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), `Expected a valid ${sceneKey} pointer coordinate.`);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
}
|
||||
|
||||
async function captureStableScreenshot(page, path) {
|
||||
await page.waitForTimeout(300);
|
||||
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(50);
|
||||
try {
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
} finally {
|
||||
if (loopSlept) {
|
||||
await page.evaluate(() => window.__HEROS_GAME__?.loop.wake());
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLocalServer(url) {
|
||||
if (await canReach(url)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) {
|
||||
throw new Error(`No server responded at ${url}`);
|
||||
}
|
||||
|
||||
const stderr = [];
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'node_modules/vite/bin/vite.js',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
parsed.port || '41795',
|
||||
'--strictPort'
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
);
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
|
||||
child.stdout.on('data', () => {});
|
||||
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
if (await canReach(url)) {
|
||||
return child;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
child.kill();
|
||||
throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`);
|
||||
}
|
||||
|
||||
async function canReach(url) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseInventoryReward(reward) {
|
||||
const match = /^(.*?)\s+([1-9]\d*)$/.exec(reward);
|
||||
assert(match, `Expected an inventory reward ending in a positive amount: ${reward}`);
|
||||
return { label: match[1], amount: Number(match[2]) };
|
||||
}
|
||||
252
scripts/verify-city-stay-data.mjs
Normal file
252
scripts/verify-city-stay-data.mjs
Normal file
@@ -0,0 +1,252 @@
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const expectedCityStayIds = ['xuzhou', 'xinye', 'chengdu'];
|
||||
const errors = [];
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const {
|
||||
cityStayDefinitions,
|
||||
findCityStayAfterBattle,
|
||||
findCityStayBeforeBattle,
|
||||
getCityStayDefinition
|
||||
} = await server.ssrLoadModule('/src/game/data/cityStays.ts');
|
||||
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
|
||||
const { itemCatalog } = await server.ssrLoadModule('/src/game/data/battleItems.ts');
|
||||
|
||||
validateCityStayCollection(cityStayDefinitions, battleScenarios, itemCatalog);
|
||||
validateLookupHelpers(
|
||||
cityStayDefinitions,
|
||||
getCityStayDefinition,
|
||||
findCityStayAfterBattle,
|
||||
findCityStayBeforeBattle
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`City stay data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Verified ${cityStayDefinitions.length} city stays, ${cityStayDefinitions.length * 3} common equipment offers, battle links, information visits, resonance dialogues, and typed lookup helpers.`
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function validateCityStayCollection(definitions, battleScenarios, itemCatalog) {
|
||||
if (!Array.isArray(definitions) || definitions.length !== 3) {
|
||||
errors.push(`cityStayDefinitions must contain exactly 3 entries (found ${definitions?.length ?? 'invalid'})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const actualIds = definitions.map((definition) => definition.id);
|
||||
if (JSON.stringify(actualIds) !== JSON.stringify(expectedCityStayIds)) {
|
||||
errors.push(`city stay ids/order must be ${expectedCityStayIds.join(', ')} (found ${actualIds.join(', ')})`);
|
||||
}
|
||||
|
||||
const battleOrder = Object.keys(battleScenarios);
|
||||
const seenCityIds = new Set();
|
||||
const seenVisitIds = new Set();
|
||||
const seenDialogueIds = new Set();
|
||||
const seenChoiceIds = new Set();
|
||||
const seenOfferIds = new Set();
|
||||
|
||||
definitions.forEach((definition, index) => {
|
||||
const context = `cityStayDefinitions[${index}]/${definition?.id ?? 'unknown'}`;
|
||||
assertUniqueNonEmptyString(definition?.id, seenCityIds, `${context}: id`);
|
||||
validateCityMeta(definition?.city, context);
|
||||
validateBattleLink(definition, context, battleScenarios, battleOrder);
|
||||
validateInformationVisit(definition?.information, context, seenVisitIds);
|
||||
validateDialogue(definition?.dialogue, context, definition, battleScenarios, seenDialogueIds, seenChoiceIds);
|
||||
validateEquipmentOffers(definition?.equipmentOffers, context, itemCatalog, seenOfferIds);
|
||||
});
|
||||
}
|
||||
|
||||
function validateCityMeta(city, context) {
|
||||
if (!city || typeof city !== 'object') {
|
||||
errors.push(`${context}: city metadata is missing`);
|
||||
return;
|
||||
}
|
||||
['name', 'region', 'title', 'description'].forEach((field) => {
|
||||
assertNonEmptyString(city[field], `${context}: city.${field}`);
|
||||
});
|
||||
}
|
||||
|
||||
function validateBattleLink(definition, context, battleScenarios, battleOrder) {
|
||||
const afterScenario = battleScenarios[definition.afterBattleId];
|
||||
const nextScenario = battleScenarios[definition.nextBattleId];
|
||||
if (!afterScenario) {
|
||||
errors.push(`${context}: unknown afterBattleId "${definition.afterBattleId}"`);
|
||||
}
|
||||
if (!nextScenario) {
|
||||
errors.push(`${context}: unknown nextBattleId "${definition.nextBattleId}"`);
|
||||
}
|
||||
if (!afterScenario || !nextScenario) {
|
||||
return;
|
||||
}
|
||||
|
||||
const afterIndex = battleOrder.indexOf(definition.afterBattleId);
|
||||
const nextIndex = battleOrder.indexOf(definition.nextBattleId);
|
||||
if (afterIndex < 0 || nextIndex !== afterIndex + 1) {
|
||||
errors.push(
|
||||
`${context}: next battle must immediately follow after battle (${definition.afterBattleId} -> ${definition.nextBattleId})`
|
||||
);
|
||||
}
|
||||
if (afterScenario.campaignReward?.unlockBattleId !== definition.nextBattleId) {
|
||||
errors.push(
|
||||
`${context}: after battle unlock "${afterScenario.campaignReward?.unlockBattleId ?? 'none'}" does not match nextBattleId`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateInformationVisit(information, context, seenVisitIds) {
|
||||
if (!information || typeof information !== 'object') {
|
||||
errors.push(`${context}: information visit is missing`);
|
||||
return;
|
||||
}
|
||||
|
||||
assertUniqueNonEmptyString(information.id, seenVisitIds, `${context}: information.id`);
|
||||
['title', 'location', 'description', 'itemReward'].forEach((field) => {
|
||||
assertNonEmptyString(information[field], `${context}: information.${field}`);
|
||||
});
|
||||
if (!Array.isArray(information.briefingLines) || information.briefingLines.length < 1 || information.briefingLines.length > 2) {
|
||||
errors.push(`${context}: information.briefingLines must contain 1 or 2 lines`);
|
||||
} else {
|
||||
information.briefingLines.forEach((line, lineIndex) => {
|
||||
assertNonEmptyString(line, `${context}: information.briefingLines[${lineIndex}]`);
|
||||
});
|
||||
}
|
||||
if (!/^(.*?)\s+([1-9]\d*)$/.test(information.itemReward ?? '')) {
|
||||
errors.push(`${context}: information.itemReward must end with a positive amount`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDialogue(dialogue, context, definition, battleScenarios, seenDialogueIds, seenChoiceIds) {
|
||||
if (!dialogue || typeof dialogue !== 'object') {
|
||||
errors.push(`${context}: resonance dialogue is missing`);
|
||||
return;
|
||||
}
|
||||
|
||||
assertUniqueNonEmptyString(dialogue.id, seenDialogueIds, `${context}: dialogue.id`);
|
||||
assertNonEmptyString(dialogue.title, `${context}: dialogue.title`);
|
||||
assertNonEmptyString(dialogue.bondId, `${context}: dialogue.bondId`);
|
||||
assertPositiveInteger(dialogue.rewardExp, `${context}: dialogue.rewardExp`);
|
||||
if (
|
||||
!Array.isArray(dialogue.unitIds) ||
|
||||
dialogue.unitIds.length !== 2 ||
|
||||
!dialogue.unitIds.every((unitId) => typeof unitId === 'string' && unitId.trim().length > 0) ||
|
||||
dialogue.unitIds[0] === dialogue.unitIds[1]
|
||||
) {
|
||||
errors.push(`${context}: dialogue.unitIds must contain two distinct unit ids`);
|
||||
}
|
||||
if (!Array.isArray(dialogue.lines) || dialogue.lines.length === 0) {
|
||||
errors.push(`${context}: dialogue.lines must not be empty`);
|
||||
} else {
|
||||
dialogue.lines.forEach((line, lineIndex) => {
|
||||
assertNonEmptyString(line, `${context}: dialogue.lines[${lineIndex}]`);
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(dialogue.choices) || dialogue.choices.length !== 2) {
|
||||
errors.push(`${context}: dialogue.choices must contain exactly 2 entries`);
|
||||
} else {
|
||||
dialogue.choices.forEach((choice, choiceIndex) => {
|
||||
const choiceContext = `${context}: dialogue.choices[${choiceIndex}]`;
|
||||
assertUniqueNonEmptyString(choice.id, seenChoiceIds, `${choiceContext}.id`);
|
||||
assertNonEmptyString(choice.label, `${choiceContext}.label`);
|
||||
assertNonEmptyString(choice.response, `${choiceContext}.response`);
|
||||
assertPositiveInteger(choice.rewardExp, `${choiceContext}.rewardExp`);
|
||||
});
|
||||
}
|
||||
|
||||
const nextScenario = battleScenarios[definition.nextBattleId];
|
||||
const bond = nextScenario?.bonds?.find((candidate) => candidate.id === dialogue.bondId);
|
||||
if (!bond) {
|
||||
errors.push(`${context}: dialogue bond "${dialogue.bondId}" is not available in the next battle`);
|
||||
} else if (
|
||||
!Array.isArray(dialogue.unitIds) ||
|
||||
dialogue.unitIds.length !== 2 ||
|
||||
!dialogue.unitIds.every((unitId) => bond.unitIds.includes(unitId))
|
||||
) {
|
||||
errors.push(
|
||||
`${context}: dialogue unit ids ${JSON.stringify(dialogue.unitIds)} do not match bond ${JSON.stringify(bond.unitIds)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateEquipmentOffers(offers, context, itemCatalog, seenOfferIds) {
|
||||
if (!Array.isArray(offers) || offers.length !== 3) {
|
||||
errors.push(`${context}: equipmentOffers must contain exactly 3 entries`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cityItemIds = new Set();
|
||||
offers.forEach((offer, offerIndex) => {
|
||||
const offerContext = `${context}: equipmentOffers[${offerIndex}]`;
|
||||
assertUniqueNonEmptyString(offer?.id, seenOfferIds, `${offerContext}.id`);
|
||||
assertNonEmptyString(offer?.itemId, `${offerContext}.itemId`);
|
||||
assertPositiveInteger(offer?.price, `${offerContext}.price`);
|
||||
if (cityItemIds.has(offer?.itemId)) {
|
||||
errors.push(`${offerContext}: duplicate city item "${offer?.itemId}"`);
|
||||
}
|
||||
cityItemIds.add(offer?.itemId);
|
||||
|
||||
const item = itemCatalog[offer?.itemId];
|
||||
if (!item) {
|
||||
errors.push(`${offerContext}: unknown equipment item "${offer?.itemId}"`);
|
||||
return;
|
||||
}
|
||||
if (item.rank !== 'common') {
|
||||
errors.push(`${offerContext}: item "${offer.itemId}" must have common rank (found ${item.rank})`);
|
||||
}
|
||||
if (offer.slot !== item.slot) {
|
||||
errors.push(`${offerContext}: slot "${offer.slot}" does not match catalog slot "${item.slot}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateLookupHelpers(
|
||||
definitions,
|
||||
getCityStayDefinition,
|
||||
findCityStayAfterBattle,
|
||||
findCityStayBeforeBattle
|
||||
) {
|
||||
definitions.forEach((definition) => {
|
||||
if (getCityStayDefinition(definition.id) !== definition) {
|
||||
errors.push(`getCityStayDefinition("${definition.id}") did not return the canonical definition`);
|
||||
}
|
||||
if (findCityStayAfterBattle(definition.afterBattleId) !== definition) {
|
||||
errors.push(`findCityStayAfterBattle("${definition.afterBattleId}") did not return ${definition.id}`);
|
||||
}
|
||||
if (findCityStayBeforeBattle(definition.nextBattleId) !== definition) {
|
||||
errors.push(`findCityStayBeforeBattle("${definition.nextBattleId}") did not return ${definition.id}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function assertUniqueNonEmptyString(value, seen, context) {
|
||||
assertNonEmptyString(value, context);
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
errors.push(`${context}: duplicate value "${value}"`);
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
|
||||
function assertNonEmptyString(value, context) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
errors.push(`${context} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveInteger(value, context) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
errors.push(`${context} must be a positive integer (found ${value})`);
|
||||
}
|
||||
}
|
||||
@@ -235,7 +235,11 @@ try {
|
||||
await clickBattleDeploymentStart(page);
|
||||
await page.waitForFunction(() => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return state?.battleId === 'first-battle-zhuo-commandery' && state?.phase === 'idle' && state?.triggeredBattleEvents?.includes('opening');
|
||||
return (
|
||||
state?.battleId === 'first-battle-zhuo-commandery' &&
|
||||
state?.phase === 'idle' &&
|
||||
state?.activeBattleEvent?.key === 'opening'
|
||||
);
|
||||
}, undefined, { timeout: 30000 });
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-first-battle.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'first battle');
|
||||
@@ -3829,7 +3833,16 @@ try {
|
||||
);
|
||||
await advanceSortiePrepStep(page, 'loadout');
|
||||
await clickLegacyUi(page, 1116, 656);
|
||||
await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit');
|
||||
await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit', { startDeployment: false });
|
||||
await clickBattleDeploymentStart(page);
|
||||
await page.waitForFunction(() => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return (
|
||||
state?.battleId === 'second-battle-yellow-turban-pursuit' &&
|
||||
state?.phase === 'idle' &&
|
||||
state?.activeBattleEvent?.key === 'opening'
|
||||
);
|
||||
}, undefined, { timeout: 30000 });
|
||||
await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true });
|
||||
await assertCanvasPainted(page, 'second battle from first camp');
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const checks = [
|
||||
'scripts/verify-battle-scenario-data.mjs',
|
||||
'scripts/verify-narrative-continuity.mjs',
|
||||
'scripts/verify-camp-reward-data.mjs',
|
||||
'scripts/verify-city-stay-data.mjs',
|
||||
'scripts/verify-victory-reward-acknowledgements.mjs',
|
||||
'scripts/verify-camp-skin-data.mjs',
|
||||
'scripts/verify-camp-soundscape-data.mjs',
|
||||
|
||||
Reference in New Issue
Block a user