feat: adopt 1920x1080 baseline

This commit is contained in:
2026-07-12 11:45:01 +09:00
parent c8e8ee4b3a
commit 3daad2861b
14 changed files with 1231 additions and 729 deletions

View File

@@ -4,6 +4,8 @@ import { chromium } from 'playwright';
const targetUrl = withDebugParam(process.env.RELEASE_QA_URL ?? 'http://127.0.0.1:4173/');
const screenshotDir = 'dist';
const baselineViewport = { width: 1920, height: 1080 };
const legacyUiScale = 1.5;
const expectedTitle = '\uC0BC\uAD6D\uC9C0: \uC138 \uD615\uC81C\uC758 \uB9F9\uC138';
const firstBattleUnlockLabel = '\uD669\uAC74 \uC794\uB2F9 \uCD94\uACA9 \uAC1C\uBC29';
const relevantLogPattern = /asset|audio|cannot|failed|map|missing|portrait|sprite|story|texture|unit/i;
@@ -11,6 +13,34 @@ const relevantLogPattern = /asset|audio|cannot|failed|map|missing|portrait|sprit
let serverProcess;
let browser;
async function clickLegacyUi(page, x, y, options) {
await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options);
}
async function moveLegacyUi(page, x, y, options) {
await page.mouse.move(x * legacyUiScale, y * legacyUiScale, options);
}
async function clickBattleDeploymentStart(page) {
const point = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const layout = scene?.layout;
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
if (!scene || !layout || !canvas || !bounds) {
return null;
}
const logicalX = layout.panelX + 24 + (layout.panelWidth - 48) / 2;
const logicalY = layout.panelY + layout.panelHeight - 76 + 17;
return {
x: bounds.left + logicalX * bounds.width / scene.scale.width,
y: bounds.top + logicalY * bounds.height / scene.scale.height
};
});
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a runtime deployment start point: ${JSON.stringify(point)}`);
await page.mouse.click(point.x, point.y);
}
try {
runCommand('node', ['scripts/verify-static-data.mjs']);
mkdirSync(screenshotDir, { recursive: true });
@@ -18,7 +48,7 @@ try {
runCommand(process.execPath, ['scripts/verify-save-retry-flow.mjs'], { VERIFY_URL: targetUrl });
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const page = await browser.newPage({ viewport: baselineViewport });
const consoleMessages = [];
const pageErrors = [];
const requestFailures = [];
@@ -50,7 +80,7 @@ try {
const emptySave = await readCampaignSave(page);
assert(!emptySave.current && !emptySave.slot1, `Expected first run without campaign save: ${JSON.stringify(emptySave)}`);
await page.mouse.click(962, 240);
await clickLegacyUi(page, 962, 240);
await waitForStoryReady(page);
await setDebugQueryParam(page, 'debugOrderCommandReady', '1');
await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true });
@@ -114,8 +144,8 @@ try {
firstBattleMiniMap?.visible === true &&
firstBattleMiniMap.title === '전장 지도' &&
sameJsonValue(firstBattleMiniMap.legend, ['아', '적', '목']) &&
firstBattleProbe.sceneBounds?.width === 1280 &&
firstBattleProbe.sceneBounds?.height === 720 &&
firstBattleProbe.sceneBounds?.width === 1920 &&
firstBattleProbe.sceneBounds?.height === 1080 &&
firstBattleMiniMap.cellSize >= 1 &&
firstBattleMiniMap.objectiveHighlighted === true &&
firstBattleMiniMap.selectedUnitId === null &&
@@ -143,14 +173,14 @@ try {
isFiniteBounds(firstBattleRecentActions) &&
boundsInside(firstBattleRecentActions, firstBattleProbe.panelBounds) &&
boundsInside(firstBattleRecentActions, firstBattleProbe.sceneBounds) &&
firstBattleRecentActions?.compact === true &&
firstBattleRecentActions.height === 84 &&
firstBattleRecentActions.rowCount === Math.min(2, firstBattleProbe.battleLog.length) &&
firstBattleRecentActions?.compact === false &&
firstBattleRecentActions.height === 118 &&
firstBattleRecentActions.rowCount === Math.min(3, firstBattleProbe.battleLog.length) &&
firstBattleProbe.sideTexts.includes('최근 행동') &&
firstBattleRecentActions.bottom === firstBattleRecentActions.y + firstBattleRecentActions.height &&
firstBattleRecentActions.gapToMiniMap >= 6 &&
firstBattleRecentActions.bottom <= firstBattleMiniMap.frameBounds.y,
`Expected the 720p recent-action panel to compact to two rows without touching the minimap frame: ${JSON.stringify(firstBattleProbe.hud)}`
`Expected the FHD recent-action panel to show three noncompact rows without touching the minimap frame: ${JSON.stringify(firstBattleProbe.hud)}`
);
await setDebugQueryParam(page, 'debugForceBondChain', '1');
const firstBattleBondChain = await page.evaluate(() => {
@@ -1983,7 +2013,7 @@ try {
await page.mouse.click(firstEvaluationClosePoint.x, firstEvaluationClosePoint.y);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 });
await page.mouse.click(738, 642);
await clickLegacyUi(page, 738, 642);
await waitForCampAfterBattleResult(page);
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
await assertCanvasPainted(page, 'first camp');
@@ -2523,7 +2553,7 @@ try {
firstCampSupplyMutationFixture.woundedHp < firstCampSupplyMutationFixture.maxHp,
`Expected a deterministic wounded report-unit fixture for camp supply recovery: ${JSON.stringify(firstCampSupplyMutationFixture)}`
);
await page.mouse.click(798, 38);
await clickLegacyUi(page, 798, 38);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'supplies',
undefined,
@@ -2590,7 +2620,7 @@ try {
const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? [];
const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? [];
await page.mouse.click(1160, 38);
await clickLegacyUi(page, 1160, 38);
await waitForSortiePrep(page);
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-prep.png`, fullPage: true });
await assertCanvasPainted(page, 'first camp sortie prep');
@@ -2651,7 +2681,7 @@ try {
}
];
const firstSortiePursuit = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview);
const logicalViewportBounds = { x: 0, y: 0, width: 1280, height: 720 };
const logicalViewportBounds = { x: 0, y: 0, width: 1920, height: 1080 };
assert(
sameJsonValue(firstSortiePursuit?.activePairs, expectedFirstSortiePursuitPairs) &&
firstSortiePursuit?.selectableOpportunities?.length === 0 &&
@@ -3033,7 +3063,7 @@ try {
`Expected the lower legacy-rate pair to be promoted and prioritized before battle launch: ${JSON.stringify(launchCoreResonance)}`
);
await advanceSortiePrepStep(page, 'loadout');
await page.mouse.click(1116, 656);
await clickLegacyUi(page, 1116, 656);
await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit');
await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true });
await assertCanvasPainted(page, 'second battle from first camp');
@@ -3348,7 +3378,7 @@ try {
secondBattleCoreResult.resultSummary.text === secondBattleCoreResult.summary &&
isFiniteBounds(secondBattleCoreResult.resultSummary.bounds) &&
boundsInside(secondBattleCoreResult.resultSummary.bounds, logicalViewportBounds) &&
secondBattleCoreResult.resultSummary.bounds.y + secondBattleCoreResult.resultSummary.bounds.height <= 416,
secondBattleCoreResult.resultSummary.bounds.y + secondBattleCoreResult.resultSummary.bounds.height <= 416 * legacyUiScale,
`Expected the victory report to surface persisted core-resonance attempts, successes, and damage: ${JSON.stringify(secondBattleCoreResult)}`
);
@@ -3365,7 +3395,7 @@ try {
});
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await page.mouse.click(962, 310);
await clickLegacyUi(page, 962, 310);
await waitForCamp(page);
const requiredCasualtyRecruitment = await page.evaluate(() => {
@@ -3432,7 +3462,7 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await page.mouse.click(962, 310);
await clickLegacyUi(page, 962, 310);
await waitForCamp(page);
const requiredCasualtyCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(
@@ -3738,7 +3768,7 @@ try {
});
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await page.mouse.click(962, 310);
await clickLegacyUi(page, 962, 310);
await waitForCamp(page);
await page.screenshot({ path: `${screenshotDir}/rc-mid-campaign-continue.png`, fullPage: true });
await assertCanvasPainted(page, 'mid campaign camp');
@@ -3770,7 +3800,7 @@ try {
);
const midCampNextBattleId = midCampState.nextSortieBattleId;
await page.mouse.click(1160, 38);
await clickLegacyUi(page, 1160, 38);
await waitForSortiePrep(page);
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-prep.png`, fullPage: true });
await assertCanvasPainted(page, 'mid camp sortie prep');
@@ -3890,7 +3920,7 @@ try {
isFiniteBounds(row.chipBounds) &&
boundsInside(row.chipBounds, midCoreResonancePageTwo.panelBounds)
),
`Expected the next-page control to expose later eligible resonance pairs inside the HD panel: ${JSON.stringify(midCoreResonancePageTwo)}`
`Expected the next-page control to expose later eligible resonance pairs inside the FHD panel: ${JSON.stringify(midCoreResonancePageTwo)}`
);
const midCoreLaterCandidate = midCoreResonancePageTwo.rows[0];
await page.mouse.click(
@@ -4032,7 +4062,7 @@ try {
`Expected hover swap preview to preserve runtime and saved selections: ${JSON.stringify(midFormationSwapPreviewState?.selectedSortieUnitIds)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation-hover.png`, fullPage: true });
await page.mouse.move(40, 710);
await moveLegacyUi(page, 40, 710);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.sortieHoveredUnitId === null && state?.sortieComparisonPreview?.source === 'focus';
@@ -4663,7 +4693,7 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await page.mouse.click(962, 310);
await clickLegacyUi(page, 962, 310);
await waitForCamp(page);
const restoredUnderCapacityState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(
@@ -4694,7 +4724,7 @@ try {
const midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? [];
const midCampFormationAssignments = reserveDoctrineSetup.state?.sortieFormationAssignments ?? {};
const midCampItemAssignments = reserveDoctrineSetup.state?.sortieItemAssignments ?? {};
await page.mouse.click(1160, 38);
await clickLegacyUi(page, 1160, 38);
await waitForSortiePrep(page);
const restoredMidBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(
@@ -4721,7 +4751,7 @@ try {
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-loadout.png`, fullPage: true });
await assertCanvasPainted(page, 'mid camp sortie loadout');
await page.mouse.click(1116, 656);
await clickLegacyUi(page, 1116, 656);
await advanceUntilBattle(page, midCampNextBattleId);
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-next-battle.png`, fullPage: true });
await assertCanvasPainted(page, 'mid camp next battle');
@@ -4990,7 +5020,7 @@ try {
undefined,
{ timeout: 30000 }
);
await page.mouse.click(738, 642);
await clickLegacyUi(page, 738, 642);
await waitForCampAfterBattleResult(page);
const midCampReviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
@@ -5143,7 +5173,7 @@ try {
});
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
await page.mouse.click(962, 310);
await clickLegacyUi(page, 962, 310);
await waitForCamp(page);
const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`);
@@ -5164,7 +5194,7 @@ try {
await captureStableCampScreenshot(page, `${screenshotDir}/rc-final-camp.png`, 0, 'final camp');
await assertFinalCampRosterPagination(page);
await page.mouse.click(1160, 38);
await clickLegacyUi(page, 1160, 38);
const finalTransitionScenes = await waitForStoryOrEnding(page);
assert(!finalTransitionScenes.includes('BattleScene'), `Expected final camp transition to avoid BattleScene: ${JSON.stringify(finalTransitionScenes)}`);
if (finalTransitionScenes.includes('StoryScene')) {
@@ -5181,7 +5211,7 @@ try {
await page.keyboard.press('Enter');
await waitForTitle(page);
await page.mouse.click(962, 310);
await clickLegacyUi(page, 962, 310);
await waitForEnding(page);
await page.screenshot({ path: `${screenshotDir}/rc-ending-continue.png`, fullPage: true });
await assertCanvasPainted(page, 'ending continue');
@@ -5260,12 +5290,16 @@ async function assertReleaseShellReady(page) {
async function assertHdPlusCanvasFit(page) {
const cases = [
{
viewport: { width: 1600, height: 900 },
expectedBounds: { x: 0, y: 0, width: 1600, height: 900 }
viewport: { width: 1920, height: 1080 },
expectedBounds: { x: 0, y: 0, width: 1920, height: 1080 }
},
{
viewport: { width: 1920, height: 1200 },
expectedBounds: { x: 0, y: 60, width: 1920, height: 1080 }
viewport: { width: 2560, height: 1440 },
expectedBounds: { x: 0, y: 0, width: 2560, height: 1440 }
},
{
viewport: { width: 2560, height: 1600 },
expectedBounds: { x: 0, y: 80, width: 2560, height: 1440 }
}
];
@@ -5278,8 +5312,8 @@ async function assertHdPlusCanvasFit(page) {
}
const bounds = canvas.getBoundingClientRect();
return (
canvas.width === 1280 &&
canvas.height === 720 &&
canvas.width === 1920 &&
canvas.height === 1080 &&
Math.abs(bounds.x - expectedBounds.x) <= 1 &&
Math.abs(bounds.y - expectedBounds.y) <= 1 &&
Math.abs(bounds.width - expectedBounds.width) <= 1 &&
@@ -5303,27 +5337,27 @@ async function assertHdPlusCanvasFit(page) {
: null
};
});
assert(state.canvas?.width === 1280 && state.canvas?.height === 720, `Expected a fixed HD game surface: ${JSON.stringify(state)}`);
assert(state.gameSize?.width === 1280 && state.gameSize?.height === 720, `Expected fixed HD scene coordinates: ${JSON.stringify(state)}`);
assert(state.canvas?.width === 1920 && state.canvas?.height === 1080, `Expected a fixed FHD game surface: ${JSON.stringify(state)}`);
assert(state.gameSize?.width === 1920 && state.gameSize?.height === 1080, `Expected fixed FHD scene coordinates: ${JSON.stringify(state)}`);
assert(
boundsNear(state.canvas?.bounds, testCase.expectedBounds),
`Expected HD surface to fit ${testCase.viewport.width}x${testCase.viewport.height}: ${JSON.stringify(state)}`
`Expected FHD surface to fit ${testCase.viewport.width}x${testCase.viewport.height}: ${JSON.stringify(state)}`
);
}
await page.setViewportSize({ width: 1280, height: 720 });
await page.setViewportSize(baselineViewport);
await page.waitForFunction(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return Boolean(
canvas &&
bounds &&
canvas.width === 1280 &&
canvas.height === 720 &&
canvas.width === 1920 &&
canvas.height === 1080 &&
Math.abs(bounds.x) <= 1 &&
Math.abs(bounds.y) <= 1 &&
Math.abs(bounds.width - 1280) <= 1 &&
Math.abs(bounds.height - 720) <= 1
Math.abs(bounds.width - 1920) <= 1 &&
Math.abs(bounds.height - 1080) <= 1
);
}, undefined, { timeout: 30000 });
}
@@ -5339,28 +5373,28 @@ function boundsNear(actual, expected, tolerance = 1) {
}
async function assertHdPlusInputMapping(browser, url) {
const page = await browser.newPage({ viewport: { width: 1920, height: 1200 } });
const page = await browser.newPage({ viewport: { width: 2560, height: 1600 } });
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => window.localStorage.clear());
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForTitle(page);
const clickPoint = await page.evaluate(() => {
const clickPoint = await page.evaluate(({ logicalX, logicalY }) => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
if (!canvas || !bounds) {
return null;
}
return {
x: bounds.x + (962 / canvas.width) * bounds.width,
y: bounds.y + (240 / canvas.height) * bounds.height,
x: bounds.x + (logicalX / canvas.width) * bounds.width,
y: bounds.y + (logicalY / canvas.height) * bounds.height,
canvasBounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
};
});
}, { logicalX: 962 * legacyUiScale, logicalY: 240 * legacyUiScale });
assert(
clickPoint && boundsNear(clickPoint.canvasBounds, { x: 0, y: 60, width: 1920, height: 1080 }),
`Expected a centered HD canvas before testing scaled input: ${JSON.stringify(clickPoint)}`
clickPoint && boundsNear(clickPoint.canvasBounds, { x: 0, y: 80, width: 2560, height: 1440 }),
`Expected a centered FHD canvas before testing above-FHD input: ${JSON.stringify(clickPoint)}`
);
await page.mouse.click(clickPoint.x, clickPoint.y);
@@ -5385,7 +5419,7 @@ async function assertLargeRosterHud(browser, url) {
];
const battleUrl = new URL(url);
battleUrl.searchParams.set('debugBattle', 'sixty-sixth-battle-wuzhang-final');
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const page = await browser.newPage({ viewport: baselineViewport });
try {
await page.goto(battleUrl.toString(), { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
@@ -5578,7 +5612,7 @@ async function startDeploymentIfNeeded(page, battleId) {
}
assert(state.deployedAllyIds?.length >= 3, `Expected deployment to show the selected ally formation: ${JSON.stringify(state)}`);
await page.mouse.click(1085, 637);
await clickBattleDeploymentStart(page);
await page.waitForFunction((expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === expectedBattleId && battle?.phase === 'idle' && battle?.triggeredBattleEvents?.includes('opening');
@@ -6106,16 +6140,16 @@ function assertCampRosterPageLayout(roster, expectedPage, pageCount, expectedSel
const listBounds = roster.listBounds;
assert(isPositiveBounds(listBounds), `Expected final-camp roster list bounds on page ${expectedPage + 1}: ${JSON.stringify(roster)}`);
assert(
isBoundsInside(listBounds, { x: 0, y: 0, width: 1280, height: 720 }),
`Expected final-camp roster list inside the 1280x720 canvas: ${JSON.stringify(roster)}`
isBoundsInside(listBounds, { x: 0, y: 0, width: 1920, height: 1080 }),
`Expected final-camp roster list inside the 1920x1080 canvas: ${JSON.stringify(roster)}`
);
assert(
isPositiveBounds(roster.navigationBounds?.container) &&
isPositiveBounds(roster.navigationBounds?.previous) &&
isPositiveBounds(roster.navigationBounds?.label) &&
isPositiveBounds(roster.navigationBounds?.next) &&
isBoundsInside(roster.navigationBounds.container, { x: 0, y: 0, width: 1280, height: 720 }),
`Expected complete final-camp roster navigation bounds inside the HD canvas: ${JSON.stringify(roster)}`
isBoundsInside(roster.navigationBounds.container, { x: 0, y: 0, width: 1920, height: 1080 }),
`Expected complete final-camp roster navigation bounds inside the FHD canvas: ${JSON.stringify(roster)}`
);
assert(
isBoundsInside(roster.navigationBounds.previous, roster.navigationBounds.container) &&
@@ -6130,9 +6164,9 @@ function assertCampRosterPageLayout(roster, expectedPage, pageCount, expectedSel
row.unitId === roster.visibleUnitIds[rowIndex] &&
isPositiveBounds(row) &&
isBoundsInside(row, listBounds) &&
isBoundsInside(row, { x: 0, y: 0, width: 1280, height: 720 }) &&
isBoundsInside(row, { x: 0, y: 0, width: 1920, height: 1080 }) &&
row.y + row.height <= roster.navigationBounds.container.y,
`Expected final-camp roster row ${rowIndex + 1} to match its visible officer and remain inside the list and HD canvas: ${JSON.stringify({
`Expected final-camp roster row ${rowIndex + 1} to match its visible officer and remain inside the list and FHD canvas: ${JSON.stringify({
row,
listBounds,
visibleUnitIds: roster.visibleUnitIds
@@ -6362,7 +6396,7 @@ async function waitForSortiePrep(page) {
}
async function advanceSortiePrepStep(page, expectedStep) {
await page.mouse.click(1116, 656);
await clickLegacyUi(page, 1116, 656);
await page.waitForFunction((step) => {
const camp = window.__HEROS_DEBUG__?.camp?.();
return camp?.sortieVisible === true && camp?.sortiePrepStep === step;
@@ -6944,7 +6978,7 @@ async function readSortiePresetControlPoint(page, control, presetId) {
function assertSortiePortraitRosterPage(view, expectedPage, expectedVisibleUnitIds) {
const expectedStart = expectedVisibleUnitIds.length > 0 ? expectedPage * 6 + 1 : 0;
const expectedEnd = expectedPage * 6 + expectedVisibleUnitIds.length;
const hdBounds = { x: 0, y: 0, width: 1280, height: 720 };
const hdBounds = { x: 0, y: 0, width: 1920, height: 1080 };
assert(
view?.enabled === true &&
view.total === 23 &&
@@ -6972,7 +7006,7 @@ function assertSortiePortraitRosterPage(view, expectedPage, expectedVisibleUnitI
bounds.unitId === expectedVisibleUnitIds[index] &&
isFiniteBounds(bounds) &&
boundsInside(bounds, hdBounds),
`Expected portrait card ${index + 1} on page ${expectedPage + 1} to match its visible officer inside the HD canvas: ${JSON.stringify({
`Expected portrait card ${index + 1} on page ${expectedPage + 1} to match its visible officer inside the FHD canvas: ${JSON.stringify({
bounds,
expectedUnitId: expectedVisibleUnitIds[index]
})}`