feat: upgrade city stay exploration

This commit is contained in:
2026-07-28 00:12:10 +09:00
parent 9d67e0ae1d
commit 1a81ce5689
18 changed files with 1733 additions and 218 deletions

View File

@@ -1,10 +1,13 @@
#!/usr/bin/env python3
"""Build a four-direction exploration sprite sheet from a three-view turnaround.
The input is expected to contain transparent front, right, and back views in
three equal horizontal columns. The output matches the runtime exploration
contract: 192px frames, four rows (south/east/north/west), and sixteen frames
per row (eight idle followed by eight walk frames).
The input is expected to contain transparent front, screen-right-facing, and
back views in three equal horizontal columns. In the middle view, the face and
feet must point toward the right edge of the image; an anatomical-right profile
that points screen-left must be mirrored before building. The output matches
the runtime exploration contract: 192px frames, four rows
(south/east/north/west), and sixteen frames per row (eight idle followed by
eight walk frames).
"""
from __future__ import annotations

View File

@@ -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) {

View File

@@ -22,7 +22,9 @@ try {
cityStayExplorationProfiles,
getCityStayExplorationProfile
} = await server.ssrLoadModule('/src/game/data/cityStayExploration.ts');
const { unitBaseSheetAssetInfo } = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
const { explorationCharacterAssetInfo } = await server.ssrLoadModule(
'/src/game/data/explorationCharacterAssets.ts'
);
const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
validateCityStayCollection(cityStayDefinitions, battleScenarios, itemCatalog);
@@ -36,7 +38,7 @@ try {
cityStayDefinitions,
cityStayExplorationProfiles,
getCityStayExplorationProfile,
unitBaseSheetAssetInfo,
explorationCharacterAssetInfo,
musicTracks,
ambienceTracks
);
@@ -246,7 +248,7 @@ function validateExplorationProfiles(
definitions,
profiles,
getProfile,
unitBaseSheetAssetInfo,
explorationCharacterAssetInfo,
musicTracks,
ambienceTracks
) {
@@ -366,14 +368,14 @@ function validateExplorationProfiles(
if (!knownDirections.has(actor?.direction)) {
errors.push(`${actorContext}.direction must be north/east/south/west`);
}
if (!unitBaseSheetAssetInfo(actor?.textureKey)) {
errors.push(`${actorContext}: unknown unit texture "${actor?.textureKey}"`);
if (!explorationCharacterAssetInfo(actor?.textureKey)) {
errors.push(`${actorContext}: unknown exploration character texture "${actor?.textureKey}"`);
}
});
}
if (!unitBaseSheetAssetInfo('unit-liu-bei')) {
errors.push(`${context}: player texture "unit-liu-bei" is missing`);
if (!explorationCharacterAssetInfo('exploration-liu-bei')) {
errors.push(`${context}: player texture "exploration-liu-bei" is missing`);
}
if (!Array.isArray(profile.buildings) || profile.buildings.length !== 3) {
errors.push(`${context}.buildings must contain exactly 3 entries`);

View File

@@ -21,11 +21,14 @@ const expectedBaseMappings = {
'unit-shu-infantry': 'exploration-shu-infantry'
};
const expectedNamedTextureFallbacks = {
'exploration-huang-quan': 'unit-shu-officer',
'exploration-jian-yong': 'unit-shu-officer',
'exploration-mi-zhu': 'unit-shu-officer',
'exploration-zhuge-liang': 'unit-shu-officer',
'exploration-zhuo-recruiting-clerk': 'unit-shu-officer',
'exploration-zhuo-villager': 'unit-shu-infantry',
'exploration-zhuo-quartermaster': 'unit-shu-officer',
'exploration-zou-jing': 'unit-shu-officer',
'exploration-jian-yong': 'unit-shu-officer'
'exploration-zou-jing': 'unit-shu-officer'
};
const expectedSceneNpcTextureKeys = [
{
@@ -136,17 +139,22 @@ assert.deepEqual(
deployedFiles.forEach((fileName) => {
const path = join(characterDirectory, fileName);
const dimensions = webpDimensions(path);
const inspection = inspectWebp(path);
assert.equal(
dimensions.width,
inspection.width,
expectedWidth,
`${path}: expected ${expectedWidth}px width for 16 frames per direction.`
);
assert.equal(
dimensions.height,
inspection.height,
expectedHeight,
`${path}: expected ${expectedHeight}px height for four direction rows.`
);
assert.equal(
inspection.hasAlpha,
true,
`${path}: exploration character sheets must preserve transparent backgrounds.`
);
});
console.log(
@@ -172,7 +180,7 @@ function readStringMap(source, mappingName) {
return mappings;
}
function webpDimensions(path) {
function inspectWebp(path) {
const bytes = readFileSync(path);
assert(
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
@@ -181,6 +189,8 @@ function webpDimensions(path) {
);
let offset = 12;
let dimensions;
let hasAlpha = false;
while (offset + 8 <= bytes.length) {
const chunkType = bytes.subarray(offset, offset + 4).toString('ascii');
const chunkSize = bytes.readUInt32LE(offset + 4);
@@ -191,20 +201,22 @@ function webpDimensions(path) {
);
if (chunkType === 'VP8X') {
return {
dimensions = {
width: bytes.readUIntLE(payloadOffset + 4, 3) + 1,
height: bytes.readUIntLE(payloadOffset + 7, 3) + 1
};
}
if (chunkType === 'VP8L') {
hasAlpha ||= (bytes[payloadOffset] & 0x10) !== 0;
} else if (chunkType === 'ALPH') {
hasAlpha = true;
} else if (chunkType === 'VP8L') {
const bits = bytes.readUInt32LE(payloadOffset + 1);
return {
dimensions ??= {
width: (bits & 0x3fff) + 1,
height: ((bits >>> 14) & 0x3fff) + 1
};
}
if (chunkType === 'VP8 ') {
return {
hasAlpha ||= (bits & 0x10000000) !== 0;
} else if (chunkType === 'VP8 ') {
dimensions ??= {
width: bytes.readUInt16LE(payloadOffset + 6) & 0x3fff,
height: bytes.readUInt16LE(payloadOffset + 8) & 0x3fff
};
@@ -213,5 +225,6 @@ function webpDimensions(path) {
offset = payloadOffset + chunkSize + (chunkSize % 2);
}
throw new Error(`${path}: could not read WebP dimensions.`);
assert(dimensions, `${path}: could not read WebP dimensions.`);
return { ...dimensions, hasAlpha };
}

View File

@@ -6,7 +6,10 @@ const expectedAssets = [
'prologue-village.webp',
'prologue-militia-camp.webp',
'second-pursuit-village-ferry.webp',
'third-guangzong-sortie-camp.webp'
'third-guangzong-sortie-camp.webp',
'city-xuzhou-stay.webp',
'city-xinye-stay.webp',
'city-chengdu-stay.webp'
];
const expectedWidth = 1920;
const expectedHeight = 1080;

View File

@@ -177,6 +177,17 @@ async function verifyFixture(browser, baseUrl, fixture) {
scene.activeTab = 'visit';
scene.scene.restart();
});
await page.waitForFunction(() => {
const exploration =
window.__HEROS_DEBUG__?.camp?.()
?.thirdCampExploration;
return (
exploration?.available === true &&
exploration?.selected === true &&
exploration?.explorationInteractive === true &&
Boolean(exploration.explorationButtonBounds)
);
}, undefined, { timeout: 90000 });
let camp = await waitForPriorityReturn(
page,
fixture,