feat: refine battle feedback and early guidance

This commit is contained in:
2026-07-18 03:16:05 +09:00
parent 470516bfd9
commit af2f5dbf06
4 changed files with 650 additions and 87 deletions

View File

@@ -102,12 +102,75 @@ try {
initialStoryProgress.advanceHint === 'next' &&
initialStoryProgress.isLastPage === false &&
boundsWithinFhdViewport(initialStoryProgress.progressBounds) &&
boundsWithinFhdViewport(initialStoryProgress.progressPanelBounds),
boundsWithinFhdViewport(initialStoryProgress.progressPanelBounds) &&
boundsInside(initialStoryProgress.progressBounds, initialStoryProgress.progressPanelBounds),
`Expected localized, FHD-safe story progress guidance: ${JSON.stringify(initialStoryProgress)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true });
await assertCanvasPainted(page, 'new game story');
await advanceUntilBattle(page, 'first-battle-zhuo-commandery');
const firstBattleTransition = await advanceUntilBattle(page, 'first-battle-zhuo-commandery', {
startDeployment: false,
captureLastStory: true
});
assert(
firstBattleTransition.lastStory?.isLastPage === true &&
firstBattleTransition.lastStory?.nextScene === 'BattleScene' &&
firstBattleTransition.lastStory?.advanceHint === 'battle-deployment' &&
firstBattleTransition.lastStory?.advanceLabel === '전투 배치' &&
firstBattleTransition.lastStory?.progressLabel?.includes('전투 배치') &&
boundsWithinFhdViewport(firstBattleTransition.lastStory?.progressBounds) &&
boundsWithinFhdViewport(firstBattleTransition.lastStory?.progressPanelBounds) &&
boundsInside(
firstBattleTransition.lastStory?.progressBounds,
firstBattleTransition.lastStory?.progressPanelBounds
),
`Expected the final prologue page to name the next action as battle deployment: ${JSON.stringify(firstBattleTransition)}`
);
const firstBattleDeployment = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const firstDeploymentPanel = firstBattleDeployment?.battleHud?.deploymentPanel;
assert(
firstBattleDeployment?.phase === 'deployment' &&
firstDeploymentPanel?.recommended === true &&
firstDeploymentPanel?.changeCount === 0 &&
boundsInside(firstDeploymentPanel?.statusBounds, firstDeploymentPanel?.headerBounds),
`Expected first deployment to show an FHD-safe recommended-formation status chip: ${JSON.stringify(firstBattleDeployment)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-deployment.png`, fullPage: true });
await assertCanvasPainted(page, 'first battle deployment');
const changedFirstDeployment = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const recommended = scene?.deploymentSlots?.().filter((slot) => slot.unitId) ?? [];
const first = recommended[0]?.unitId ? scene?.debugUnitById?.(recommended[0].unitId) : undefined;
const second = recommended[1]?.unitId ? scene?.debugUnitById?.(recommended[1].unitId) : undefined;
if (!scene || !first || !second) {
return null;
}
scene.swapDeploymentUnits(first, second);
return window.__HEROS_DEBUG__?.battle()?.battleHud?.deploymentPanel ?? null;
});
assert(
changedFirstDeployment?.recommended === false &&
changedFirstDeployment?.changeCount >= 2 &&
boundsInside(changedFirstDeployment?.statusBounds, changedFirstDeployment?.headerBounds),
`Expected a changed first deployment to expose a restore-ready status: ${JSON.stringify(changedFirstDeployment)}`
);
await page.waitForTimeout(220);
const restoredFirstDeployment = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.restoreRecommendedDeployment?.();
return window.__HEROS_DEBUG__?.battle()?.battleHud?.deploymentPanel ?? null;
});
assert(
restoredFirstDeployment?.recommended === true && restoredFirstDeployment?.changeCount === 0,
`Expected one restore action to return the first deployment to the recommended formation: ${JSON.stringify(restoredFirstDeployment)}`
);
await page.waitForTimeout(180);
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');
}, undefined, { timeout: 30000 });
await page.screenshot({ path: `${screenshotDir}/rc-first-battle.png`, fullPage: true });
await assertCanvasPainted(page, 'first battle');
@@ -115,6 +178,7 @@ try {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle();
const sideTexts = textValues(scene?.sidePanelObjects);
const bannerTexts = textValues(scene?.battleEventObjects);
const miniMapTexts = textValues((scene?.miniMapObjects ?? []).filter((object) => object?.active && object?.visible));
const miniMapFrame = (scene?.miniMapObjects ?? []).find((object) => object?.name === 'mini-map-frame');
return {
@@ -122,6 +186,7 @@ try {
objectiveText: scene?.objectiveTrackerText?.text,
objectiveSubText: scene?.objectiveTrackerSubText?.text,
sideTexts,
bannerTexts,
miniMapTexts,
actualMiniMapFrameBounds: miniMapFrame ? boundsValue(miniMapFrame.getBounds()) : null,
hud: state?.battleHud ?? null,
@@ -156,9 +221,10 @@ try {
`Expected the compact objective subtext to retain only the defeat condition without duplicate route or bonus guidance: ${JSON.stringify(firstBattleProbe)}`
);
assert(
firstBattleProbe.sideTexts.some((text) => text.includes('출진 군세')) &&
!firstBattleProbe.sideTexts.some((text) => text.includes('...')),
`Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}`
firstBattleProbe.bannerTexts.some((text) => text.includes('출진 군세')) &&
firstBattleProbe.bannerTexts.some((text) => text.includes('첫 행동')) &&
!firstBattleProbe.bannerTexts.some((text) => text.includes('...')),
`Expected sortie doctrine and first-action guidance without ASCII truncation: ${JSON.stringify(firstBattleProbe.bannerTexts)}`
);
const firstBattleMiniMap = firstBattleProbe.hud?.miniMap;
const firstBattleRecentActions = firstBattleProbe.hud?.recentActions;
@@ -206,6 +272,93 @@ try {
firstBattleRecentActions.bottom <= firstBattleMiniMap.frameBounds.y,
`Expected the FHD recent-action panel to show three noncompact rows without touching the minimap frame: ${JSON.stringify(firstBattleProbe.hud)}`
);
const firstBattleMapFeedback = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const unit = scene?.debugUnitById?.('liu-bei');
const view = unit ? scene?.unitViews?.get(unit.id) : undefined;
if (!scene || !unit || !view) {
return null;
}
const originalX = view.sprite.x;
const originalY = view.sprite.y;
const originalSpeed = scene.battleSpeed;
scene.battleSpeed = 'normal';
view.sprite.x = scene.layout.mapX + 1;
view.sprite.y = scene.layout.mapY + 1;
scene.showMapResultPopup(unit, ['치명 피해 -999', 'HP 999→1', '공명 추격 +99'], '#ff8f5f', '#220909', 20, 38);
const normal = window.__HEROS_DEBUG__?.battle()?.mapResultFeedback;
const normalActualBounds = (scene.mapResultPopupObjects ?? [])
.filter((object) => object?.active)
.map((object) => boundsValue(object.getBounds()));
scene.battleSpeed = 'very-fast';
for (let index = 0; index < 6; index += 1) {
scene.showMapResultPopup(unit, [`연속 판정 ${index + 1}`, 'HP 경계 확인'], '#ffdf7b', '#220909', 18, 30);
}
const fast = window.__HEROS_DEBUG__?.battle()?.mapResultFeedback;
const fastActualBounds = (scene.mapResultPopupObjects ?? [])
.filter((object) => object?.active)
.map((object) => boundsValue(object.getBounds()));
view.sprite.x = originalX;
view.sprite.y = originalY;
scene.battleSpeed = originalSpeed;
scene.renderBattleSpeedControl?.();
return {
normal: { ...normal, actualBounds: normalActualBounds },
fast: { ...fast, actualBounds: fastActualBounds }
};
function boundsValue(bounds) {
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
}
});
assert(
firstBattleMapFeedback?.normal?.last?.duration === 680 &&
firstBattleMapFeedback.normal.last.delay === 80 &&
boundsInside(firstBattleMapFeedback.normal.last.bounds, firstBattleMapFeedback.normal.mapBounds) &&
boundsInside(firstBattleMapFeedback.normal.last.endBounds, firstBattleMapFeedback.normal.mapBounds) &&
firstBattleMapFeedback.fast?.last?.duration >= 260 &&
firstBattleMapFeedback.fast.last.duration < firstBattleMapFeedback.normal.last.duration &&
firstBattleMapFeedback.fast.last.delay >= 30 &&
firstBattleMapFeedback.fast.last.delay < firstBattleMapFeedback.normal.last.delay &&
boundsInside(firstBattleMapFeedback.fast.last.bounds, firstBattleMapFeedback.fast.mapBounds) &&
boundsInside(firstBattleMapFeedback.fast.last.endBounds, firstBattleMapFeedback.fast.mapBounds) &&
firstBattleMapFeedback.normal.actualBounds.length === 1 &&
firstBattleMapFeedback.normal.actualBounds.every((bounds) =>
boundsInside(bounds, firstBattleMapFeedback.normal.mapBounds)
) &&
firstBattleMapFeedback.fast.actualBounds.length > 0 &&
firstBattleMapFeedback.fast.actualBounds.every((bounds) =>
boundsInside(bounds, firstBattleMapFeedback.fast.mapBounds)
) &&
firstBattleMapFeedback.fast.activeCount <= firstBattleMapFeedback.fast.maxActive &&
firstBattleMapFeedback.fast.peakCount <= firstBattleMapFeedback.fast.maxActive,
`Expected bounded, speed-aware, capped map result feedback at FHD: ${JSON.stringify(firstBattleMapFeedback)}`
);
await page.waitForTimeout(120);
const animatingMapFeedback = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle()?.mapResultFeedback;
return {
mapBounds: state?.mapBounds ?? null,
activeBounds: (scene?.mapResultPopupObjects ?? [])
.filter((object) => object?.active)
.map((object) => {
const bounds = object.getBounds();
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
})
};
});
assert(
animatingMapFeedback.activeBounds.length > 0 &&
animatingMapFeedback.activeBounds.every((bounds) => boundsInside(bounds, animatingMapFeedback.mapBounds)),
`Expected animated map result feedback to remain inside the map: ${JSON.stringify(animatingMapFeedback)}`
);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.mapResultFeedback?.activeCount === 0,
undefined,
{ timeout: 3000 }
);
await setDebugQueryParam(page, 'debugForceBondChain', '1');
const firstBattleBondChain = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
@@ -2057,7 +2210,21 @@ try {
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 });
await clickLegacyUi(page, 738, 642);
await waitForCampAfterBattleResult(page);
const firstCampTransition = await waitForCampAfterBattleResult(page);
assert(
firstCampTransition.lastStory?.isLastPage === true &&
firstCampTransition.lastStory?.nextScene === 'CampScene' &&
firstCampTransition.lastStory?.advanceHint === 'camp-return' &&
firstCampTransition.lastStory?.advanceLabel === '군영으로' &&
firstCampTransition.lastStory?.progressLabel?.includes('군영으로') &&
boundsWithinFhdViewport(firstCampTransition.lastStory?.progressBounds) &&
boundsWithinFhdViewport(firstCampTransition.lastStory?.progressPanelBounds) &&
boundsInside(
firstCampTransition.lastStory?.progressBounds,
firstCampTransition.lastStory?.progressPanelBounds
),
`Expected the post-battle story to name the next action as returning to camp: ${JSON.stringify(firstCampTransition)}`
);
await waitForCampSkinTransition(page, 'yellow-turban');
await waitForCampSoundscapeTransition(page, 'camp-rally', 'campfire-ambience');
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
@@ -2643,6 +2810,21 @@ try {
undefined,
{ timeout: 30000 }
);
const firstCampSecondaryPanels = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.secondaryPanels);
const firstCampSuppliesPanel = firstCampSecondaryPanels?.supplies;
const firstCampReportPanel = firstCampSecondaryPanels?.report;
assert(
firstCampSuppliesPanel?.visible === true &&
firstCampReportPanel?.visible === true &&
boundsWithinFhdViewport(firstCampSuppliesPanel.bounds) &&
boundsWithinFhdViewport(firstCampReportPanel.bounds) &&
firstCampSuppliesPanel.equipmentBoxBounds?.length > 0 &&
firstCampSuppliesPanel.trophyBoxBounds?.length > 0 &&
firstCampSuppliesPanel.equipmentBoxBounds?.every((bounds) => boundsInside(bounds, firstCampSuppliesPanel.bounds)) &&
firstCampSuppliesPanel.trophyBoxBounds.every((bounds) => boundsInside(bounds, firstCampSuppliesPanel.bounds)) &&
firstCampSuppliesPanel.bounds.y + firstCampSuppliesPanel.bounds.height <= firstCampReportPanel.bounds.y,
`Expected supply cards inside their FHD panel without overlapping the battle report: ${JSON.stringify(firstCampSecondaryPanels)}`
);
const firstCampTabAudioState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.audio);
assert(
firstCampTabAudioState?.music?.currentKey === firstCampProbe.state?.audio?.music?.currentKey &&
@@ -2712,6 +2894,96 @@ try {
})}`
);
const equipmentPaginationFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene?.campaign) {
return null;
}
const originalInventory = { ...scene.campaign.inventory };
['황건도', '단궁', '두령부', '철창', '포의', '찰갑'].forEach((label) => {
scene.campaign.inventory[label] = Math.max(1, scene.campaign.inventory[label] ?? 0);
});
scene.activeTab = 'equipment';
scene.equipmentInventoryPage = 0;
scene.render();
return {
originalInventory,
panel: window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment ?? null
};
});
assert(
equipmentPaginationFixture?.panel?.visible === true &&
equipmentPaginationFixture.panel.page === 0 &&
equipmentPaginationFixture.panel.pageCount >= 2 &&
equipmentPaginationFixture.panel.visibleItemIds?.length === equipmentPaginationFixture.panel.pageSize &&
equipmentPaginationFixture.panel.nextEnabled === true &&
boundsWithinFhdViewport(equipmentPaginationFixture.panel.bounds) &&
boundsInside(equipmentPaginationFixture.panel.nextButtonBounds, equipmentPaginationFixture.panel.bounds),
`Expected the equipment manager to expose the first of multiple inventory pages: ${JSON.stringify(equipmentPaginationFixture)}`
);
const equipmentNextBounds = equipmentPaginationFixture.panel.nextButtonBounds;
await page.mouse.click(
equipmentNextBounds.x + equipmentNextBounds.width / 2,
equipmentNextBounds.y + equipmentNextBounds.height / 2
);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment?.page === 1, undefined, { timeout: 30000 });
const equipmentSecondPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment);
assert(
equipmentSecondPage?.visible === true &&
equipmentSecondPage.page === 1 &&
equipmentSecondPage.page === equipmentSecondPage.pageCount - 1 &&
equipmentSecondPage.previousEnabled === true &&
equipmentSecondPage.nextEnabled === false &&
equipmentSecondPage.visibleItemIds?.length > 0 &&
equipmentSecondPage.visibleItemIds.every((itemId) => !equipmentPaginationFixture.panel.visibleItemIds.includes(itemId)) &&
boundsInside(equipmentSecondPage.previousButtonBounds, equipmentSecondPage.bounds),
`Expected equipment pagination to reach distinct lower-priority items: ${JSON.stringify({ first: equipmentPaginationFixture.panel, second: equipmentSecondPage })}`
);
const equipmentClampedPage = await page.evaluate((originalInventory) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene?.campaign) {
return null;
}
scene.campaign.inventory = { ...originalInventory };
scene.render();
return window.__HEROS_DEBUG__?.camp()?.secondaryPanels?.equipment ?? null;
}, equipmentPaginationFixture.originalInventory);
assert(
equipmentClampedPage?.page === Math.max(0, equipmentClampedPage?.pageCount - 1) &&
equipmentClampedPage?.page < equipmentClampedPage?.pageCount,
`Expected equipment pagination to clamp after the inventory shrinks: ${JSON.stringify(equipmentClampedPage)}`
);
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene) {
return;
}
scene.equipmentInventoryPage = 0;
scene.activeTab = 'supplies';
scene.render();
});
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
scene?.showCampNotice?.('첫 번째 정비 알림');
scene?.time?.delayedCall(400, () => scene?.showCampNotice?.('두 번째 응답 알림'));
});
await page.waitForTimeout(1750);
const replacementNotice = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
return (scene?.dialogueObjects ?? [])
.filter((object) => object?.active)
.map((object) => object?.text)
.filter(Boolean);
});
assert(
replacementNotice.some((text) => text.includes('두 번째 응답 알림')),
`Expected a replacement camp notice to survive the earlier notice cleanup: ${JSON.stringify(replacementNotice)}`
);
await page.waitForFunction(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
return (scene?.dialogueObjects ?? []).filter((object) => object?.active).length === 0;
}, undefined, { timeout: 1500 });
const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? [];
const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? [];
await clickLegacyUi(page, 1160, 38);
@@ -7240,31 +7512,48 @@ async function waitForStoryOrEnding(page) {
return page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
}
async function advanceUntilBattle(page, battleId) {
async function advanceUntilBattle(page, battleId, options = {}) {
const { startDeployment = true, captureLastStory = false } = options;
let lastStory = null;
for (let i = 0; i < 48; i += 1) {
const ready = await page.evaluate((expectedBattleId) => {
const probe = await page.evaluate((expectedBattleId) => {
const state = window.__HEROS_DEBUG__?.battle();
return (
state?.scene === 'BattleScene' &&
state?.battleId === expectedBattleId &&
(state?.phase === 'deployment' || state?.phase === 'idle') &&
state?.mapBackgroundReady === true
);
return {
ready: (
state?.scene === 'BattleScene' &&
state?.battleId === expectedBattleId &&
(state?.phase === 'deployment' || state?.phase === 'idle') &&
state?.mapBackgroundReady === true
),
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null
};
}, battleId);
if (ready) {
await startDeploymentIfNeeded(page, battleId);
return;
if (
captureLastStory &&
probe.story?.isLastPage &&
probe.story?.progressBounds &&
probe.story?.progressPanelBounds
) {
lastStory = probe.story;
}
if (probe.ready) {
if (startDeployment) {
await startDeploymentIfNeeded(page, battleId);
}
return { lastStory };
}
await page.keyboard.press('Space');
await page.waitForTimeout(220);
}
await waitForBattleReady(page, battleId);
await waitForBattleReady(page, battleId, startDeployment);
return { lastStory };
}
async function waitForBattleReady(page, battleId) {
async function waitForBattleReady(page, battleId, startDeployment = true) {
await page.waitForFunction((expectedBattleId) => {
const state = window.__HEROS_DEBUG__?.battle();
return (
@@ -7274,7 +7563,9 @@ async function waitForBattleReady(page, battleId) {
state?.mapBackgroundReady === true
);
}, battleId, { timeout: 90000 });
await startDeploymentIfNeeded(page, battleId);
if (startDeployment) {
await startDeploymentIfNeeded(page, battleId);
}
}
async function startDeploymentIfNeeded(page, battleId) {
@@ -8204,17 +8495,25 @@ async function waitForCampAfterBattleResult(page) {
return activeScenes.includes('CampScene') || activeScenes.includes('StoryScene');
}, undefined, { timeout: 90000 });
let lastStory = null;
for (let i = 0; i < 48; i += 1) {
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
if (activeScenes.includes('CampScene')) {
const probe = await page.evaluate(() => ({
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null
}));
if (probe.story?.isLastPage && probe.story?.progressBounds && probe.story?.progressPanelBounds) {
lastStory = probe.story;
}
if (probe.activeScenes.includes('CampScene')) {
await waitForCamp(page);
return;
return { lastStory };
}
await page.keyboard.press('Space');
await page.waitForTimeout(240);
}
await waitForCamp(page);
return { lastStory };
}
async function advanceStoryUntilEnding(page) {