|
|
|
|
@@ -42,8 +42,15 @@ const cityCases = [
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
const [xuzhouCase, xinyeCase, chengduCase] = cityCases;
|
|
|
|
|
const rendererTypes = {
|
|
|
|
|
canvas: 1,
|
|
|
|
|
webgl: 2
|
|
|
|
|
};
|
|
|
|
|
const baseTargetUrl =
|
|
|
|
|
process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/';
|
|
|
|
|
const targetUrl = withDebugOptions(
|
|
|
|
|
process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/'
|
|
|
|
|
baseTargetUrl,
|
|
|
|
|
'canvas'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let serverProcess;
|
|
|
|
|
@@ -125,9 +132,16 @@ try {
|
|
|
|
|
`Expected no browser console errors: ${JSON.stringify(consoleErrors.slice(-8))}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await verifyRendererVisualInteractionCoverage(
|
|
|
|
|
browser,
|
|
|
|
|
baseTargetUrl,
|
|
|
|
|
'webgl'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`Verified direct inter-battle city exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
|
|
|
|
`DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard and world-pointer movement, distance-gated interaction, ` +
|
|
|
|
|
`DPR ${desktopBrowserDeviceScaleFactor} in Canvas and WebGL: Camp gateway entry, high-resolution city backgrounds, ` +
|
|
|
|
|
'SD exploration characters, portrait dialogue, real pointer navigation and automatic interaction, distance-gated interaction, ' +
|
|
|
|
|
'one-time information rewards, exact market purchases, saved resonance choices, reload/Continue restoration, ' +
|
|
|
|
|
'optional-objective exit, next-battle briefing integration, and Xuzhou/Xinye/Chengdu map and modal layouts.'
|
|
|
|
|
);
|
|
|
|
|
@@ -138,15 +152,133 @@ try {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function withDebugOptions(url) {
|
|
|
|
|
function withDebugOptions(url, renderer) {
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
parsed.searchParams.set('debug', '1');
|
|
|
|
|
parsed.searchParams.set('renderer', 'canvas');
|
|
|
|
|
parsed.searchParams.set('renderer', renderer);
|
|
|
|
|
return parsed.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openCleanCamp(page) {
|
|
|
|
|
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
|
|
|
|
async function verifyRendererVisualInteractionCoverage(
|
|
|
|
|
browserInstance,
|
|
|
|
|
url,
|
|
|
|
|
renderer
|
|
|
|
|
) {
|
|
|
|
|
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 {
|
|
|
|
|
await openCleanCamp(page, withDebugOptions(url, renderer));
|
|
|
|
|
await assertDesktopViewport(page);
|
|
|
|
|
|
|
|
|
|
for (const [index, cityCase] of cityCases.entries()) {
|
|
|
|
|
if (index > 0) {
|
|
|
|
|
await openCampScene(page);
|
|
|
|
|
}
|
|
|
|
|
await seedCityStay(page, cityCase);
|
|
|
|
|
await restartCamp(page, cityCase.cityStayId);
|
|
|
|
|
|
|
|
|
|
const gateway = await waitForCityGateway(
|
|
|
|
|
page,
|
|
|
|
|
cityCase.cityStayId
|
|
|
|
|
);
|
|
|
|
|
verifyGatewayLayout(gateway);
|
|
|
|
|
let city = await enterCityFromGateway(
|
|
|
|
|
page,
|
|
|
|
|
gateway,
|
|
|
|
|
cityCase.cityStayId
|
|
|
|
|
);
|
|
|
|
|
verifyExplorationLayout(city, cityCase.cityStayId);
|
|
|
|
|
await captureStableScreenshot(
|
|
|
|
|
page,
|
|
|
|
|
`dist/verification-city-${cityCase.cityStayId}-${renderer}-map.png`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await pointerInteractWith(page, 'information', {
|
|
|
|
|
expectTravel: true
|
|
|
|
|
});
|
|
|
|
|
city = await readCity(page);
|
|
|
|
|
verifyActiveDialoguePortrait(
|
|
|
|
|
city,
|
|
|
|
|
`${renderer} ${cityCase.cityStayId} information dialogue`
|
|
|
|
|
);
|
|
|
|
|
await captureStableScreenshot(
|
|
|
|
|
page,
|
|
|
|
|
`dist/verification-city-${cityCase.cityStayId}-${renderer}-information-pointer.png`
|
|
|
|
|
);
|
|
|
|
|
await page.keyboard.press('Escape');
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() =>
|
|
|
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active ===
|
|
|
|
|
false
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await pointerInteractWith(page, 'market', {
|
|
|
|
|
expectTravel: true
|
|
|
|
|
});
|
|
|
|
|
city = await readCity(page);
|
|
|
|
|
verifyShopLayout(city);
|
|
|
|
|
await page.keyboard.press('Escape');
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await pointerInteractWith(page, 'dialogue', {
|
|
|
|
|
expectTravel: true
|
|
|
|
|
});
|
|
|
|
|
city = await readCity(page);
|
|
|
|
|
verifyActiveDialoguePortrait(
|
|
|
|
|
city,
|
|
|
|
|
`${renderer} ${cityCase.cityStayId} companion dialogue`
|
|
|
|
|
);
|
|
|
|
|
await captureStableScreenshot(
|
|
|
|
|
page,
|
|
|
|
|
`dist/verification-city-${cityCase.cityStayId}-${renderer}-companion-pointer.png`
|
|
|
|
|
);
|
|
|
|
|
await page.keyboard.press('Escape');
|
|
|
|
|
await page.waitForFunction(
|
|
|
|
|
() =>
|
|
|
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active ===
|
|
|
|
|
false
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await readCity(page)).progress.completed,
|
|
|
|
|
0,
|
|
|
|
|
`${renderer} visual interaction coverage must not complete optional city activities.`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
pageErrors,
|
|
|
|
|
[],
|
|
|
|
|
`${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')
|
|
|
|
|
),
|
|
|
|
|
[],
|
|
|
|
|
`${renderer}: expected no browser console errors: ${JSON.stringify(consoleErrors.slice(-8))}`
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
await context.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openCleanCamp(page, url = targetUrl) {
|
|
|
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
|
|
|
|
await page.evaluate(() => window.localStorage.clear());
|
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
|
|
|
|
|
await waitForDebugApi(page);
|
|
|
|
|
@@ -385,6 +517,7 @@ async function waitForCityReady(page, expectedCityStayId) {
|
|
|
|
|
{ cause: error }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
await assertDesktopViewport(page);
|
|
|
|
|
await page.waitForTimeout(380);
|
|
|
|
|
return readCity(page);
|
|
|
|
|
}
|
|
|
|
|
@@ -405,10 +538,62 @@ function verifyExplorationLayout(city, expectedCityStayId) {
|
|
|
|
|
['dialogue', 'information', 'market']
|
|
|
|
|
);
|
|
|
|
|
assert.equal(city.requiredTexturesReady, true);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
{
|
|
|
|
|
textureKey: city.background?.textureKey,
|
|
|
|
|
ready: city.background?.ready,
|
|
|
|
|
sourceWidth: city.background?.sourceWidth,
|
|
|
|
|
sourceHeight: city.background?.sourceHeight,
|
|
|
|
|
fallback: city.background?.fallback
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
textureKey: `city-${expectedCityStayId}-stay-background`,
|
|
|
|
|
ready: true,
|
|
|
|
|
sourceWidth: desktopBrowserViewport.width,
|
|
|
|
|
sourceHeight: desktopBrowserViewport.height,
|
|
|
|
|
fallback: false
|
|
|
|
|
},
|
|
|
|
|
`${expectedCityStayId} must render its authored 1920x1080 city background texture.`
|
|
|
|
|
);
|
|
|
|
|
assertBoundsCoverViewport(
|
|
|
|
|
city.background?.bounds,
|
|
|
|
|
`${expectedCityStayId} background`
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
city.player?.textureKey?.startsWith('exploration-'),
|
|
|
|
|
`${expectedCityStayId} player must use an exploration-* SD texture: ${JSON.stringify(city.player)}`
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
city.player?.animationKey?.startsWith(`${city.player.textureKey}-`),
|
|
|
|
|
`${expectedCityStayId} player animation must use its SD texture: ${JSON.stringify(city.player)}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assertBoundsInsideViewport(city.player.bounds, `${expectedCityStayId} player`);
|
|
|
|
|
assertBoundsInsideViewport(city.movement.bounds, `${expectedCityStayId} movement area`);
|
|
|
|
|
city.actors.forEach((actor) => {
|
|
|
|
|
assert(
|
|
|
|
|
actor.textureKey?.startsWith('exploration-'),
|
|
|
|
|
`${expectedCityStayId} actor ${actor.id} must use an exploration-* SD texture: ${JSON.stringify(actor)}`
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
actor.animationKey?.startsWith(`${actor.textureKey}-`),
|
|
|
|
|
`${expectedCityStayId} actor ${actor.id} animation must use its SD texture: ${JSON.stringify(actor)}`
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
actor.moved,
|
|
|
|
|
false,
|
|
|
|
|
`${expectedCityStayId} actor ${actor.id} must remain at its authored position.`
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
actor.x,
|
|
|
|
|
actor.initialX,
|
|
|
|
|
`${expectedCityStayId} actor ${actor.id} X must match its authored position.`
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
actor.y,
|
|
|
|
|
actor.initialY,
|
|
|
|
|
`${expectedCityStayId} actor ${actor.id} Y must match its authored position.`
|
|
|
|
|
);
|
|
|
|
|
assertBoundsInsideViewport(actor.bounds, `${expectedCityStayId} actor ${actor.id}`);
|
|
|
|
|
});
|
|
|
|
|
city.blockers.forEach((blocker, index) => {
|
|
|
|
|
@@ -512,10 +697,14 @@ async function verifyInformationReward(page, cityCase, gatewayInformation) {
|
|
|
|
|
assert(!before.campaign.completedCampVisits.includes(cityCase.informationId));
|
|
|
|
|
const inventoryBefore = { ...before.campaign.inventory };
|
|
|
|
|
|
|
|
|
|
await interactWith(page, 'information');
|
|
|
|
|
await pointerInteractWith(page, 'information', { expectTravel: true });
|
|
|
|
|
await page.waitForFunction(() => (
|
|
|
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
|
|
|
));
|
|
|
|
|
verifyActiveDialoguePortrait(
|
|
|
|
|
await readCity(page),
|
|
|
|
|
`${cityCase.cityStayId} information dialogue`
|
|
|
|
|
);
|
|
|
|
|
await captureStableScreenshot(
|
|
|
|
|
page,
|
|
|
|
|
`dist/verification-city-${cityCase.cityStayId}-information.png`
|
|
|
|
|
@@ -543,10 +732,14 @@ async function verifyInformationReward(page, cityCase, gatewayInformation) {
|
|
|
|
|
'The information completion ID must be unique.'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await interactWith(page, 'information');
|
|
|
|
|
await pointerInteractWith(page, 'information');
|
|
|
|
|
await page.waitForFunction(() => (
|
|
|
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
|
|
|
));
|
|
|
|
|
verifyActiveDialoguePortrait(
|
|
|
|
|
await readCity(page),
|
|
|
|
|
`${cityCase.cityStayId} repeated information dialogue`
|
|
|
|
|
);
|
|
|
|
|
await advanceCityDialogueUntilClosed(page);
|
|
|
|
|
const afterRepeated = await readCity(page);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
@@ -571,7 +764,7 @@ async function verifyInformationReward(page, cityCase, gatewayInformation) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function verifyMarketPurchase(page, screenshotPath) {
|
|
|
|
|
await interactWith(page, 'market');
|
|
|
|
|
await pointerInteractWith(page, 'market', { expectTravel: true });
|
|
|
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true);
|
|
|
|
|
const before = await readCity(page);
|
|
|
|
|
verifyShopLayout(before);
|
|
|
|
|
@@ -627,10 +820,14 @@ async function verifyCompanionResonance(page, cityCase, screenshotPath) {
|
|
|
|
|
const bondBefore = campaignBefore.bonds.find((bond) => bond.id === cityCase.bondId);
|
|
|
|
|
assert(bondBefore, `Expected seeded bond ${cityCase.bondId}.`);
|
|
|
|
|
|
|
|
|
|
await interactWith(page, 'dialogue');
|
|
|
|
|
await pointerInteractWith(page, 'dialogue', { expectTravel: true });
|
|
|
|
|
await page.waitForFunction(() => (
|
|
|
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
|
|
|
));
|
|
|
|
|
verifyActiveDialoguePortrait(
|
|
|
|
|
await readCity(page),
|
|
|
|
|
`${cityCase.cityStayId} companion dialogue`
|
|
|
|
|
);
|
|
|
|
|
await advanceDialogueUntilChoice(page);
|
|
|
|
|
const choiceState = await readCity(page);
|
|
|
|
|
verifyChoiceLayout(choiceState);
|
|
|
|
|
@@ -696,6 +893,83 @@ function verifyChoiceLayout(city) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function verifyActiveDialoguePortrait(city, label) {
|
|
|
|
|
assert.equal(
|
|
|
|
|
city.dialogue?.active,
|
|
|
|
|
true,
|
|
|
|
|
`${label}: dialogue must be active.`
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
city.dialogue.portraitVisible,
|
|
|
|
|
true,
|
|
|
|
|
`${label}: the face portrait must be visible.`
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
city.dialogue.portraitTextureKey?.startsWith('portrait-'),
|
|
|
|
|
`${label}: expected a portrait-* face texture, received ${JSON.stringify(city.dialogue.portraitTextureKey)}.`
|
|
|
|
|
);
|
|
|
|
|
assertBoundsInsideViewport(
|
|
|
|
|
city.dialogue.portraitBounds,
|
|
|
|
|
`${label} face portrait`
|
|
|
|
|
);
|
|
|
|
|
assertBoundsInsideViewport(city.dialogue.bounds, `${label} panel`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function verifyDialoguePortraitPolicy(city, label) {
|
|
|
|
|
const portraitlessSpeakers = new Set([
|
|
|
|
|
'공명',
|
|
|
|
|
'체류 기록',
|
|
|
|
|
'길잡이'
|
|
|
|
|
]);
|
|
|
|
|
if (!portraitlessSpeakers.has(city.dialogue?.speaker)) {
|
|
|
|
|
verifyActiveDialoguePortrait(city, label);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
assert.equal(
|
|
|
|
|
city.dialogue?.active,
|
|
|
|
|
true,
|
|
|
|
|
`${label}: narrator dialogue must be active.`
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
city.dialogue.portraitVisible,
|
|
|
|
|
false,
|
|
|
|
|
`${label}: narrator line must leave the face portrait hidden.`
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
city.dialogue.portraitTextureKey,
|
|
|
|
|
null,
|
|
|
|
|
`${label}: narrator line must not expose a stale portrait texture.`
|
|
|
|
|
);
|
|
|
|
|
assertBoundsInsideViewport(
|
|
|
|
|
city.dialogue.bounds,
|
|
|
|
|
`${label} panel`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertActorsRemainAuthored(expectedActors, city, label) {
|
|
|
|
|
assert.equal(
|
|
|
|
|
city.actors.length,
|
|
|
|
|
expectedActors.length,
|
|
|
|
|
`${label}: actor count changed while the player moved.`
|
|
|
|
|
);
|
|
|
|
|
expectedActors.forEach((expected) => {
|
|
|
|
|
const actual = city.actors.find((actor) => actor.id === expected.id);
|
|
|
|
|
assert(actual, `${label}: actor ${expected.id} disappeared.`);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
{
|
|
|
|
|
id: actual.id,
|
|
|
|
|
x: actual.x,
|
|
|
|
|
y: actual.y,
|
|
|
|
|
initialX: actual.initialX,
|
|
|
|
|
initialY: actual.initialY,
|
|
|
|
|
moved: actual.moved
|
|
|
|
|
},
|
|
|
|
|
expected,
|
|
|
|
|
`${label}: actor ${expected.id} must not teleport or drift.`
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function reloadAndContinueCityStay(page, expectedCityStayId) {
|
|
|
|
|
const beforeReload = await readCity(page);
|
|
|
|
|
assert.equal(beforeReload.activeCityStayId, expectedCityStayId);
|
|
|
|
|
@@ -798,11 +1072,15 @@ async function verifyAdditionalCityLayout(page, cityCase, options) {
|
|
|
|
|
`dist/verification-city-${cityCase.cityStayId}-map.png`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await interactWith(page, 'information');
|
|
|
|
|
await pointerInteractWith(page, 'information', { expectTravel: true });
|
|
|
|
|
await page.waitForFunction(() => (
|
|
|
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
|
|
|
));
|
|
|
|
|
city = await readCity(page);
|
|
|
|
|
verifyActiveDialoguePortrait(
|
|
|
|
|
city,
|
|
|
|
|
`${cityCase.cityStayId} information dialogue`
|
|
|
|
|
);
|
|
|
|
|
assertBoundsInsideViewport(city.dialogue.bounds, `${cityCase.cityStayId} information dialogue`);
|
|
|
|
|
await captureStableScreenshot(
|
|
|
|
|
page,
|
|
|
|
|
@@ -814,7 +1092,7 @@ async function verifyAdditionalCityLayout(page, cityCase, options) {
|
|
|
|
|
));
|
|
|
|
|
assert.equal((await readCity(page)).progress.informationComplete, false);
|
|
|
|
|
|
|
|
|
|
await interactWith(page, 'market');
|
|
|
|
|
await pointerInteractWith(page, 'market', { expectTravel: true });
|
|
|
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true);
|
|
|
|
|
city = await readCity(page);
|
|
|
|
|
verifyShopLayout(city);
|
|
|
|
|
@@ -825,10 +1103,14 @@ async function verifyAdditionalCityLayout(page, cityCase, options) {
|
|
|
|
|
await page.keyboard.press('Escape');
|
|
|
|
|
await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false);
|
|
|
|
|
|
|
|
|
|
await interactWith(page, 'dialogue');
|
|
|
|
|
await pointerInteractWith(page, 'dialogue', { expectTravel: true });
|
|
|
|
|
await page.waitForFunction(() => (
|
|
|
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
|
|
|
));
|
|
|
|
|
verifyActiveDialoguePortrait(
|
|
|
|
|
await readCity(page),
|
|
|
|
|
`${cityCase.cityStayId} companion dialogue`
|
|
|
|
|
);
|
|
|
|
|
await advanceDialogueUntilChoice(page);
|
|
|
|
|
city = await readCity(page);
|
|
|
|
|
verifyChoiceLayout(city);
|
|
|
|
|
@@ -851,7 +1133,7 @@ async function verifyIncompleteCityExit(page, cityCase) {
|
|
|
|
|
assert.equal(before.progress.completed, 0);
|
|
|
|
|
assert.equal(before.progress.exitUnlocked, true);
|
|
|
|
|
|
|
|
|
|
await interactWith(page, 'exit');
|
|
|
|
|
await pointerInteractWith(page, 'exit', { expectTravel: true });
|
|
|
|
|
await page.waitForFunction(() => (
|
|
|
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
|
|
|
));
|
|
|
|
|
@@ -874,13 +1156,159 @@ async function verifyIncompleteCityExit(page, cityCase) {
|
|
|
|
|
assert(!campaign.completedCampDialogues.includes(cityCase.dialogueId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function interactWith(page, targetIdOrKind) {
|
|
|
|
|
const result = await page.evaluate((target) => {
|
|
|
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('CityStayScene');
|
|
|
|
|
return scene?.debugInteractWith?.(target) ?? false;
|
|
|
|
|
}, targetIdOrKind);
|
|
|
|
|
assert.equal(result, true, `Expected CityStayScene interaction with ${targetIdOrKind}.`);
|
|
|
|
|
await page.waitForTimeout(90);
|
|
|
|
|
async function pointerInteractWith(
|
|
|
|
|
page,
|
|
|
|
|
targetIdOrKind,
|
|
|
|
|
{ expectTravel = false } = {}
|
|
|
|
|
) {
|
|
|
|
|
const before = await readCity(page);
|
|
|
|
|
assert.equal(before.dialogue.active, false);
|
|
|
|
|
assert.equal(before.shop.open, false);
|
|
|
|
|
assert.equal(before.choice.open, false);
|
|
|
|
|
const actor = before.actors.find(
|
|
|
|
|
(candidate) =>
|
|
|
|
|
candidate.id === targetIdOrKind || candidate.kind === targetIdOrKind
|
|
|
|
|
);
|
|
|
|
|
const target = actor ?? (
|
|
|
|
|
targetIdOrKind === 'exit' || targetIdOrKind === before.exit?.id
|
|
|
|
|
? {
|
|
|
|
|
...before.exit,
|
|
|
|
|
id: before.exit.id,
|
|
|
|
|
kind: 'exit',
|
|
|
|
|
bounds: before.exit.bounds ?? null
|
|
|
|
|
}
|
|
|
|
|
: null
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
target,
|
|
|
|
|
`Expected a pointer target for ${targetIdOrKind}: ${JSON.stringify({
|
|
|
|
|
actors: before.actors.map(({ id, kind }) => ({ id, kind })),
|
|
|
|
|
exit: before.exit
|
|
|
|
|
})}`
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
before.movement?.autoInteraction,
|
|
|
|
|
`CityStayScene must expose automatic pointer interaction state: ${JSON.stringify(before.movement)}`
|
|
|
|
|
);
|
|
|
|
|
const actorPositions = before.actors.map(
|
|
|
|
|
({ id, x, y, initialX, initialY, moved }) => ({
|
|
|
|
|
id,
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
initialX,
|
|
|
|
|
initialY,
|
|
|
|
|
moved
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
assertActorsRemainAuthored(actorPositions, before, `${before.cityStayId} before ${target.id}`);
|
|
|
|
|
|
|
|
|
|
const beforeTriggerCount =
|
|
|
|
|
before.movement.autoInteraction.triggeredCount;
|
|
|
|
|
const playerStart = { x: before.player.x, y: before.player.y };
|
|
|
|
|
if (target.bounds) {
|
|
|
|
|
await clickSceneBounds(page, 'CityStayScene', target.bounds);
|
|
|
|
|
} else {
|
|
|
|
|
await clickScenePoint(page, 'CityStayScene', {
|
|
|
|
|
x: target.x,
|
|
|
|
|
y: target.y
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let routeObserved = false;
|
|
|
|
|
let maxPlayerDisplacement = 0;
|
|
|
|
|
let after;
|
|
|
|
|
for (let attempt = 0; attempt < 600; attempt += 1) {
|
|
|
|
|
after = await readCity(page);
|
|
|
|
|
assertActorsRemainAuthored(
|
|
|
|
|
actorPositions,
|
|
|
|
|
after,
|
|
|
|
|
`${before.cityStayId} while approaching ${target.id}`
|
|
|
|
|
);
|
|
|
|
|
const autoInteraction = after.movement?.autoInteraction;
|
|
|
|
|
if (
|
|
|
|
|
autoInteraction?.pending === true &&
|
|
|
|
|
autoInteraction.targetId === target.id
|
|
|
|
|
) {
|
|
|
|
|
routeObserved = true;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
after.movement?.targetId === target.id ||
|
|
|
|
|
after.movement?.waypoints?.length > 0
|
|
|
|
|
) {
|
|
|
|
|
routeObserved = true;
|
|
|
|
|
}
|
|
|
|
|
maxPlayerDisplacement = Math.max(
|
|
|
|
|
maxPlayerDisplacement,
|
|
|
|
|
Math.hypot(
|
|
|
|
|
after.player.x - playerStart.x,
|
|
|
|
|
after.player.y - playerStart.y
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
const interactionOpened =
|
|
|
|
|
target.kind === 'market'
|
|
|
|
|
? after.shop.open === true
|
|
|
|
|
: target.kind === 'exit'
|
|
|
|
|
? after.dialogue.active === true &&
|
|
|
|
|
after.dialogue.sourceTargetId === target.id
|
|
|
|
|
: after.dialogue.active === true &&
|
|
|
|
|
after.dialogue.sourceTargetId === target.id;
|
|
|
|
|
if (interactionOpened) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
await page.waitForTimeout(50);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert(
|
|
|
|
|
after,
|
|
|
|
|
`Expected CityStayScene state after clicking ${target.id}.`
|
|
|
|
|
);
|
|
|
|
|
const interactionOpened =
|
|
|
|
|
target.kind === 'market'
|
|
|
|
|
? after.shop.open === true
|
|
|
|
|
: after.dialogue.active === true &&
|
|
|
|
|
after.dialogue.sourceTargetId === target.id;
|
|
|
|
|
assert(
|
|
|
|
|
interactionOpened,
|
|
|
|
|
`Pointer click did not automatically interact with ${target.id}: ${JSON.stringify(after)}`
|
|
|
|
|
);
|
|
|
|
|
if (expectTravel) {
|
|
|
|
|
assert(
|
|
|
|
|
routeObserved,
|
|
|
|
|
`Expected pointer navigation state while approaching ${target.id}: ${JSON.stringify(after.movement)}`
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
maxPlayerDisplacement >= 40,
|
|
|
|
|
`Expected Liu Bei to visibly walk toward ${target.id}: ${JSON.stringify({
|
|
|
|
|
playerStart,
|
|
|
|
|
playerEnd: after.player,
|
|
|
|
|
maxPlayerDisplacement
|
|
|
|
|
})}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
assert.equal(
|
|
|
|
|
after.movement.autoInteraction.triggeredCount,
|
|
|
|
|
beforeTriggerCount + 1,
|
|
|
|
|
`Pointer interaction with ${target.id} must trigger exactly once.`
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
after.movement.autoInteraction.lastTriggeredTargetId,
|
|
|
|
|
target.id,
|
|
|
|
|
`Pointer interaction must report ${target.id} as its final automatic target.`
|
|
|
|
|
);
|
|
|
|
|
if (actor) {
|
|
|
|
|
const finalActor = after.actors.find(
|
|
|
|
|
(candidate) => candidate.id === actor.id
|
|
|
|
|
);
|
|
|
|
|
assert(
|
|
|
|
|
finalActor?.distance <= after.interaction.radius + 2,
|
|
|
|
|
`Automatic movement must finish inside ${actor.id}'s interaction radius: ${JSON.stringify(finalActor)}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
assertActorsRemainAuthored(
|
|
|
|
|
actorPositions,
|
|
|
|
|
after,
|
|
|
|
|
`${before.cityStayId} after ${target.id}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function advanceCityDialogueUntilClosed(page) {
|
|
|
|
|
@@ -889,6 +1317,10 @@ async function advanceCityDialogueUntilClosed(page) {
|
|
|
|
|
if (!city?.dialogue?.active) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
verifyDialoguePortraitPolicy(
|
|
|
|
|
city,
|
|
|
|
|
`${city.cityStayId} dialogue line ${city.dialogue.lineIndex + 1}`
|
|
|
|
|
);
|
|
|
|
|
await page.mouse.click(960, 850);
|
|
|
|
|
await page.waitForTimeout(130);
|
|
|
|
|
}
|
|
|
|
|
@@ -904,6 +1336,10 @@ async function advanceDialogueUntilChoice(page) {
|
|
|
|
|
if (!city?.dialogue?.active) {
|
|
|
|
|
throw new Error(`Dialogue closed without opening a choice: ${JSON.stringify(city)}`);
|
|
|
|
|
}
|
|
|
|
|
verifyDialoguePortraitPolicy(
|
|
|
|
|
city,
|
|
|
|
|
`${city.cityStayId} resonance line ${city.dialogue.lineIndex + 1}`
|
|
|
|
|
);
|
|
|
|
|
await page.mouse.click(960, 850);
|
|
|
|
|
await page.waitForTimeout(130);
|
|
|
|
|
}
|
|
|
|
|
@@ -989,6 +1425,18 @@ function assertBoundsInsideViewport(bounds, label) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertBoundsCoverViewport(bounds, label) {
|
|
|
|
|
assert(bounds, `${label}: bounds are required.`);
|
|
|
|
|
const epsilon = 0.01;
|
|
|
|
|
assert(
|
|
|
|
|
Math.abs(bounds.x) <= epsilon &&
|
|
|
|
|
Math.abs(bounds.y) <= epsilon &&
|
|
|
|
|
Math.abs(bounds.width - desktopBrowserViewport.width) <= epsilon &&
|
|
|
|
|
Math.abs(bounds.height - desktopBrowserViewport.height) <= epsilon,
|
|
|
|
|
`${label}: expected a full 1920x1080 display, received ${JSON.stringify(bounds)}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertBoundsInside(bounds, container, label) {
|
|
|
|
|
assert(bounds, `${label}: bounds are required.`);
|
|
|
|
|
const epsilon = 0.01;
|
|
|
|
|
@@ -1018,6 +1466,7 @@ async function assertDesktopViewport(page) {
|
|
|
|
|
height: window.innerHeight,
|
|
|
|
|
dpr: window.devicePixelRatio,
|
|
|
|
|
visualScale: window.visualViewport?.scale ?? 1,
|
|
|
|
|
rendererType: window.__HEROS_GAME__?.renderer?.type ?? null,
|
|
|
|
|
canvas: canvas
|
|
|
|
|
? {
|
|
|
|
|
width: canvas.width,
|
|
|
|
|
@@ -1041,6 +1490,17 @@ async function assertDesktopViewport(page) {
|
|
|
|
|
assert.equal(viewport.canvas?.clientHeight, desktopBrowserViewport.height);
|
|
|
|
|
assert.equal(viewport.canvas?.bounds?.width, desktopBrowserViewport.width);
|
|
|
|
|
assert.equal(viewport.canvas?.bounds?.height, desktopBrowserViewport.height);
|
|
|
|
|
const requestedRenderer =
|
|
|
|
|
new URL(page.url()).searchParams.get('renderer') ?? 'canvas';
|
|
|
|
|
assert(
|
|
|
|
|
requestedRenderer in rendererTypes,
|
|
|
|
|
`Unknown requested renderer "${requestedRenderer}".`
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
viewport.rendererType,
|
|
|
|
|
rendererTypes[requestedRenderer],
|
|
|
|
|
`${requestedRenderer}: Phaser must use the requested renderer.`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function clickSceneBounds(page, sceneKey, bounds) {
|
|
|
|
|
|