feat: unify FHD battle detail panels

This commit is contained in:
2026-07-12 14:40:00 +09:00
parent 31d76c6252
commit 8b2a935b85
2 changed files with 987 additions and 228 deletions

View File

@@ -5497,6 +5497,8 @@ async function assertLargeRosterHud(browser, url) {
assertLateBattleHeaderLayout(situationProbe, 'situation');
assertLateBattleSidebarLayout(situationProbe, 'situation', true);
await assertFinalBattleFhdDetailPanels(page);
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.renderRosterPanel?.('ally', '출전 위치 확정. 행동할 장수를 선택하세요.');
@@ -5593,6 +5595,160 @@ async function assertLargeRosterHud(browser, url) {
}
}
async function assertFinalBattleFhdDetailPanels(page) {
const allyFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const unit = scene?.debugUnitById?.('zhuge-liang');
const message = '대상 선택을 취소했습니다.\n공격, 책략, 도구, 대기 중 하나를 다시 선택하세요.\n우클릭하면 이동 취소로 돌아갑니다.';
if (!scene || !unit) {
return null;
}
scene.renderUnitDetail?.(unit, message);
return { unitId: unit.id, unitName: unit.name, messageLineCount: message.split('\n').length };
});
assert(
allyFixture?.unitId === 'zhuge-liang' && allyFixture.messageLineCount === 3,
`Expected the late-battle ally detail fixture to render Zhuge Liang with the real three-line command-cancel guidance: ${JSON.stringify(allyFixture)}`
);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.unitDetailPanel?.unitId === 'zhuge-liang',
undefined,
{ timeout: 30000 }
);
const allyDetailProbe = await readLargeRosterHudProbe(page);
assertUnitDetailPanelLayout(allyDetailProbe, 'zhuge-liang', 'ally');
const enemyFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const unit = scene?.debugUnitById?.('northern-twelfth-scout-e');
if (!scene || !unit) {
return null;
}
scene.renderUnitDetail?.(unit, scene.enemyDetailMessage?.(unit) ?? '적군 전력을 확인하세요.');
return { unitId: unit.id, unitName: unit.name };
});
assert(
enemyFixture?.unitId === 'northern-twelfth-scout-e' && enemyFixture.unitName?.length >= 8,
`Expected the late-battle enemy detail fixture to exercise a long officer name: ${JSON.stringify(enemyFixture)}`
);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.unitDetailPanel?.unitId === 'northern-twelfth-scout-e',
undefined,
{ timeout: 30000 }
);
const enemyDetailProbe = await readLargeRosterHudProbe(page);
assertUnitDetailPanelLayout(enemyDetailProbe, 'northern-twelfth-scout-e', 'long enemy');
await waitForBattleHudPaint(page);
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-unit-detail.png`, fullPage: true });
await assertCanvasPainted(page, 'final battle unit detail');
const bondFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return null;
}
const expectedBondIds = scene.activeSortieBonds?.().map((bond) => bond.id) ?? [];
scene.bondPage = 0;
scene.renderBondPanel?.();
return { expectedBondIds };
});
assert(
bondFixture?.expectedBondIds?.length > 4,
`Expected the exact eight-officer final sortie to exercise paged active bonds: ${JSON.stringify(bondFixture)}`
);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.bondPanel?.page === 0,
undefined,
{ timeout: 30000 }
);
const firstBondProbe = await readLargeRosterHudProbe(page);
const bondPageCount = firstBondProbe.bondPanel?.pageCount ?? 0;
assert(
bondPageCount > 1 && firstBondProbe.bondPanel?.totalCount === bondFixture.expectedBondIds.length,
`Expected paged final-battle bond debug state to match the active sortie bonds: ${JSON.stringify({ bondFixture, bondPanel: firstBondProbe.bondPanel })}`
);
const visibleBondIds = [];
for (let pageIndex = 0; pageIndex < bondPageCount; pageIndex += 1) {
if (pageIndex > 0) {
await page.evaluate((targetPage) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return;
}
scene.bondPage = targetPage;
scene.renderBondPanel?.();
}, pageIndex);
await page.waitForFunction(
(targetPage) => window.__HEROS_DEBUG__?.battle()?.battleHud?.bondPanel?.page === targetPage,
pageIndex,
{ timeout: 30000 }
);
}
const bondProbe = pageIndex === 0 ? firstBondProbe : await readLargeRosterHudProbe(page);
assertBondPanelLayout(bondProbe, pageIndex, bondPageCount, bondFixture.expectedBondIds.length);
visibleBondIds.push(...bondProbe.bondPanel.visibleBondIds);
}
assert(
visibleBondIds.length === bondFixture.expectedBondIds.length &&
new Set(visibleBondIds).size === visibleBondIds.length &&
sameJsonValue([...visibleBondIds].sort(), [...bondFixture.expectedBondIds].sort()),
`Expected every active final-battle bond exactly once across all pages: ${JSON.stringify({ expected: bondFixture.expectedBondIds, actual: visibleBondIds })}`
);
await waitForBattleHudPaint(page);
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-bond-panel.png`, fullPage: true });
await assertCanvasPainted(page, 'final battle bond panel');
const expectedThreatIds = [
'northern-twelfth-scout-e',
'northern-twelfth-camp-guard-e',
'northern-twelfth-strategist-c',
'northern-twelfth-cavalry-e'
];
const threatFixture = await page.evaluate((enemyIds) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const enemies = enemyIds.map((unitId) => scene?.debugUnitById?.(unitId)).filter(Boolean);
if (!scene || enemies.length !== enemyIds.length) {
return null;
}
scene.renderThreatDetail?.({ x: 38, y: 96, enemies, strongestBehavior: 'aggressive' });
return { enemyIds: enemies.map((enemy) => enemy.id), enemyNames: enemies.map((enemy) => enemy.name) };
}, expectedThreatIds);
assert(
sameJsonValue(threatFixture?.enemyIds, expectedThreatIds) && threatFixture.enemyNames.every((name) => name.length >= 8),
`Expected the final-battle threat fixture to use four real long-name enemies: ${JSON.stringify(threatFixture)}`
);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.battleHud?.threatPanel?.enemyRows?.length === 4,
undefined,
{ timeout: 30000 }
);
const threatProbe = await readLargeRosterHudProbe(page);
assertThreatPanelLayout(threatProbe, expectedThreatIds);
await waitForBattleHudPaint(page);
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-threat-panel.png`, fullPage: true });
await assertCanvasPainted(page, 'final battle threat panel');
await page.evaluate(() => {
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.renderTerrainDetail?.(38, 96);
});
await page.waitForFunction(() => {
const panel = window.__HEROS_DEBUG__?.battle()?.battleHud?.terrainPanel;
return panel?.tile?.x === 38 && panel?.tile?.y === 96;
}, undefined, { timeout: 30000 });
const terrainProbe = await readLargeRosterHudProbe(page);
assertTerrainPanelLayout(terrainProbe, 'camp');
await waitForBattleHudPaint(page);
await page.screenshot({ path: `${screenshotDir}/rc-final-battle-terrain-panel.png`, fullPage: true });
await assertCanvasPainted(page, 'final battle terrain panel');
}
async function waitForBattleHudPaint(page) {
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
}
async function readLargeRosterHudProbe(page) {
return page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -5604,6 +5760,10 @@ async function readLargeRosterHudProbe(page) {
header: hud?.header ?? null,
sidebar: hud?.sidebar ?? null,
deploymentPanel: hud?.deploymentPanel ?? null,
unitDetailPanel: hud?.unitDetailPanel ?? null,
bondPanel: hud?.bondPanel ?? null,
threatPanel: hud?.threatPanel ?? null,
terrainPanel: hud?.terrainPanel ?? null,
sceneBounds: scene
? { x: 0, y: 0, width: scene.scale.width, height: scene.scale.height }
: null,
@@ -5661,7 +5821,7 @@ function assertLateBattleSidebarLayout(probe, label, expectMiniMap) {
`Expected the late-battle ${label} sidebar below the sortie-order header: ${JSON.stringify(probe)}`
);
if (!expectMiniMap) {
assert(miniMap?.visible === false, `Expected deployment to reserve the full sidebar by hiding the minimap: ${JSON.stringify(probe)}`);
assert(miniMap?.visible === false, `Expected late-battle ${label} to reserve the full sidebar by hiding the minimap: ${JSON.stringify(probe)}`);
return;
}
assert(
@@ -5703,6 +5863,233 @@ function assertLateBattleDeploymentLayout(probe) {
);
}
function assertUnitDetailPanelLayout(probe, expectedUnitId, label) {
const panel = probe.unitDetailPanel;
assertLateBattleSidebarLayout(probe, `${label} unit detail`, false);
assert(
panel?.unitId === expectedUnitId &&
panel.summaryBounds?.length === 3 &&
panel.statBounds?.length === 5 &&
panel.effectBounds?.length > 0 &&
panel.effectBounds.length <= 3 &&
panel.equipmentBounds?.length === 3 &&
isFiniteBounds(panel.messageBounds),
`Expected complete FHD ${label} unit-detail sections: ${JSON.stringify(probe)}`
);
const allBounds = [
panel.headerBounds,
panel.nameBounds,
panel.levelBadgeBounds,
panel.hpBounds,
...panel.summaryBounds,
...panel.statBounds,
panel.statusBounds,
...panel.effectBounds,
...panel.equipmentBounds,
panel.messageBounds
];
assertDetailBoundsInside(probe, allBounds, `${label} unit detail`);
assert(
boundsInside(panel.nameBounds, panel.headerBounds) &&
boundsInside(panel.levelBadgeBounds, panel.headerBounds) &&
panel.nameBounds.x + panel.nameBounds.width <= panel.levelBadgeBounds.x + 1,
`Expected the FHD ${label} unit name to remain inside the header and left of the level badge: ${JSON.stringify(panel)}`
);
assertHorizontalBounds(panel.summaryBounds, `${label} unit summary cards`);
assertHorizontalBounds(panel.effectBounds, `${label} unit effect cards`);
assertSequentialBounds(panel.statBounds, `${label} unit stat rows`);
assertSequentialBounds(panel.equipmentBounds, `${label} unit equipment rows`);
const sectionBounds = [
panel.headerBounds,
panel.hpBounds,
boundsUnion(panel.summaryBounds),
boundsUnion(panel.statBounds),
panel.statusBounds,
boundsUnion(panel.effectBounds),
boundsUnion(panel.equipmentBounds),
panel.messageBounds
];
assertSequentialBounds(sectionBounds, `${label} unit-detail sections`);
}
function assertBondPanelLayout(probe, expectedPage, expectedPageCount, expectedTotalCount) {
const panel = probe.bondPanel;
assertLateBattleSidebarLayout(probe, `bond page ${expectedPage + 1}`, false);
const expectedVisibleCount = Math.min(
panel?.rowsPerPage ?? 0,
Math.max(0, expectedTotalCount - expectedPage * (panel?.rowsPerPage ?? 0))
);
assert(
panel?.page === expectedPage &&
panel.pageCount === expectedPageCount &&
panel.totalCount === expectedTotalCount &&
panel.rowsPerPage > 0 &&
panel.visibleBondIds?.length === expectedVisibleCount &&
panel.rowLayouts?.length === expectedVisibleCount &&
new Set(panel.visibleBondIds).size === panel.visibleBondIds.length &&
sameJsonValue(panel.rowLayouts.map((row) => row.bondId), panel.visibleBondIds) &&
isFiniteBounds(panel.navigationBounds) &&
isFiniteBounds(panel.messageBounds),
`Expected complete paged FHD bond state on page ${expectedPage + 1}: ${JSON.stringify(probe)}`
);
const rowBounds = panel.rowLayouts.map((row) => row.rowBounds);
const allBounds = [
panel.headerBounds,
...rowBounds,
...panel.rowLayouts.flatMap((row) => [row.nameBounds, row.levelBounds, row.titleBounds, row.gaugeBounds]),
panel.navigationBounds,
panel.messageBounds
];
assertDetailBoundsInside(probe, allBounds, `bond page ${expectedPage + 1}`);
panel.rowLayouts.forEach((row) => {
assert(
boundsInside(row.nameBounds, row.rowBounds) &&
boundsInside(row.levelBounds, row.rowBounds) &&
boundsInside(row.titleBounds, row.rowBounds) &&
boundsInside(row.gaugeBounds, row.rowBounds) &&
row.nameBounds.x + row.nameBounds.width <= row.levelBounds.x + 1 &&
row.titleBounds.x + row.titleBounds.width <= row.gaugeBounds.x + 1,
`Expected bond ${row.bondId} names, levels, titles, and gauges in separate FHD columns: ${JSON.stringify(row)}`
);
});
assertSequentialBounds(rowBounds, `bond rows on page ${expectedPage + 1}`);
assertSequentialBounds(
[panel.headerBounds, ...rowBounds, panel.navigationBounds, panel.messageBounds],
`bond sections on page ${expectedPage + 1}`
);
}
function assertThreatPanelLayout(probe, expectedEnemyIds) {
const panel = probe.threatPanel;
assertLateBattleSidebarLayout(probe, 'threat detail', true);
assert(
panel?.tile?.x === 38 &&
panel.tile.y === 96 &&
panel.summaryRows?.length === 4 &&
panel.enemyRows?.length === expectedEnemyIds.length &&
sameJsonValue(panel.enemyRows.map((row) => row.unitId), expectedEnemyIds) &&
isFiniteBounds(panel.messageBounds),
`Expected the four-enemy FHD threat fixture at the final-camp tile: ${JSON.stringify(probe)}`
);
const summaryBounds = panel.summaryRows.map((row) => row.bounds);
const enemyBounds = panel.enemyRows.map((row) => row.rowBounds);
const allBounds = [
panel.headerBounds,
...summaryBounds,
...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]),
...enemyBounds,
...panel.enemyRows.flatMap((row) => [row.nameBounds, row.valueBounds]),
panel.messageBounds
];
assertDetailBoundsInside(probe, allBounds, 'threat detail');
assertSituationRows(panel.summaryRows, 'threat summary');
panel.enemyRows.forEach((row) => {
assert(
boundsInside(row.nameBounds, row.rowBounds) &&
boundsInside(row.valueBounds, row.rowBounds) &&
!boundsOverlap(row.nameBounds, row.valueBounds, 2),
`Expected threat names and metrics on separate readable lines for ${row.unitId}: ${JSON.stringify(row)}`
);
});
assertSequentialBounds(enemyBounds, 'threat enemy rows');
assertSequentialBounds(
[panel.headerBounds, ...summaryBounds, ...enemyBounds, panel.messageBounds],
'threat detail sections'
);
assert(
panel.messageBounds.y + panel.messageBounds.height <= probe.miniMap.frameBounds.y + 1,
`Expected the threat guidance above the FHD minimap: ${JSON.stringify(probe)}`
);
}
function assertTerrainPanelLayout(probe, expectedTerrain) {
const panel = probe.terrainPanel;
assertLateBattleSidebarLayout(probe, 'terrain detail', true);
assert(
panel?.tile?.x === 38 &&
panel.tile.y === 96 &&
panel.terrain === expectedTerrain &&
panel.summaryRows?.length === 6 &&
isFiniteBounds(panel.messageBounds),
`Expected complete FHD ${expectedTerrain} terrain details at the final-camp tile: ${JSON.stringify(probe)}`
);
const summaryBounds = panel.summaryRows.map((row) => row.bounds);
const allBounds = [
panel.headerBounds,
...summaryBounds,
...panel.summaryRows.flatMap((row) => [row.labelBounds, row.valueBounds]),
panel.messageBounds
];
assertDetailBoundsInside(probe, allBounds, 'terrain detail');
assertSituationRows(panel.summaryRows, 'terrain summary');
assertSequentialBounds([panel.headerBounds, ...summaryBounds, panel.messageBounds], 'terrain detail sections');
assert(
panel.messageBounds.y + panel.messageBounds.height <= probe.miniMap.frameBounds.y + 1,
`Expected the terrain guidance above the FHD minimap: ${JSON.stringify(probe)}`
);
}
function assertSituationRows(rows, label) {
rows.forEach((row, index) => {
assert(
boundsInside(row.labelBounds, row.bounds) &&
boundsInside(row.valueBounds, row.bounds) &&
row.labelBounds.x + row.labelBounds.width <= row.valueBounds.x + 1,
`Expected ${label} row ${index + 1} labels and values in separate FHD columns: ${JSON.stringify(row)}`
);
});
assertSequentialBounds(rows.map((row) => row.bounds), `${label} rows`);
}
function assertDetailBoundsInside(probe, boundsList, label) {
assert(
isFiniteBounds(probe.sidebar?.bounds) &&
isFiniteBounds(probe.panelBounds) &&
isFiniteBounds(probe.sceneBounds) &&
boundsList.length > 0 &&
boundsList.every((bounds) =>
boundsInside(bounds, probe.sidebar.bounds) &&
boundsInside(bounds, probe.panelBounds) &&
boundsInside(bounds, probe.sceneBounds)
),
`Expected every ${label} bound inside the sidebar, panel, and FHD scene: ${JSON.stringify({ boundsList, probe })}`
);
}
function assertSequentialBounds(boundsList, label) {
assert(
boundsList.length > 0 &&
boundsList.every(isFiniteBounds) &&
boundsList.every((bounds, index) =>
index === 0 || boundsList[index - 1].y + boundsList[index - 1].height <= bounds.y + 1
),
`Expected non-overlapping sequential ${label}: ${JSON.stringify(boundsList)}`
);
}
function assertHorizontalBounds(boundsList, label) {
const ordered = [...boundsList].sort((left, right) => left.x - right.x);
assert(
ordered.length > 0 &&
ordered.every(isFiniteBounds) &&
ordered.every((bounds, index) =>
index === 0 || ordered[index - 1].x + ordered[index - 1].width <= bounds.x + 1
),
`Expected non-overlapping horizontal ${label}: ${JSON.stringify(ordered)}`
);
}
function boundsUnion(boundsList) {
const left = Math.min(...boundsList.map((bounds) => bounds.x));
const top = Math.min(...boundsList.map((bounds) => bounds.y));
const right = Math.max(...boundsList.map((bounds) => bounds.x + bounds.width));
const bottom = Math.max(...boundsList.map((bounds) => bounds.y + bounds.height));
return { x: left, y: top, width: right - left, height: bottom - top };
}
function assertLargeRosterPageLayout(probe, label) {
const { roster, panelBounds } = probe;
assert(boundsInside(roster.listBounds, panelBounds), `Expected roster rows on page ${label} inside the side panel: ${JSON.stringify(probe)}`);