From 4c9f299de7f2ee7b379dad8fdd3e1d848684e34f Mon Sep 17 00:00:00 2001 From: Wickedness Date: Wed, 29 Jul 2026 05:43:07 +0900 Subject: [PATCH] fix: preserve pursuit target through finisher --- scripts/verify-release-candidate.mjs | 213 ++++++++++++++++++++++++--- src/game/scenes/BattleScene.ts | 66 +++++++-- 2 files changed, 247 insertions(+), 32 deletions(-) diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index e412192..b0efcdc 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -73,6 +73,79 @@ async function clickSceneBounds(page, sceneKey, bounds) { await page.mouse.click(point.x, point.y); } +async function clickTitleContinue(page, slot = 1) { + const continueItem = await page.evaluate(() => { + const state = + window.__HEROS_DEBUG__ + ?.scene('TitleScene') + ?.getDebugState?.(); + return ( + state?.focus?.scope === 'main' + ? state.focus.items.find( + (item) => + item.id === 'continue' && + item.enabled === true + ) + : undefined + ) ?? null; + }); + assert( + continueItem?.bounds, + `Expected an enabled title continue control: ${JSON.stringify( + continueItem + )}` + ); + await clickSceneBounds( + page, + 'TitleScene', + continueItem.bounds + ); + await page.waitForFunction( + () => { + const activeScenes = + window.__HEROS_DEBUG__?.activeScenes() ?? []; + const state = + window.__HEROS_DEBUG__ + ?.scene('TitleScene') + ?.getDebugState?.(); + return ( + !activeScenes.includes('TitleScene') || + state?.navigating === true || + state?.panels?.saveSlots === true + ); + }, + undefined, + { timeout: 10000 } + ); + + const saveSlotItem = await page.evaluate( + (requestedSlot) => { + const state = + window.__HEROS_DEBUG__ + ?.scene('TitleScene') + ?.getDebugState?.(); + if (state?.panels?.saveSlots !== true) { + return null; + } + return ( + state.focus?.items?.find( + (item) => + item.id === `slot-${requestedSlot}` && + item.enabled === true + ) ?? null + ); + }, + slot + ); + if (saveSlotItem) { + await clickSceneBounds( + page, + 'TitleScene', + saveSlotItem.bounds + ); + } +} + async function clickBattleDeploymentStart(page) { const point = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -223,7 +296,7 @@ try { ); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await clickLegacyUi(page, 962, 310); + await clickTitleContinue(page); await waitForStoryReady(page); const resumedPrologue = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); const resumedPrologueSave = await readCampaignSave(page); @@ -1398,11 +1471,13 @@ try { cutInOrder.push(isPrimary ? 'primary' : result === counterResult || result.isCounter ? 'counter' : 'other'); if (isPrimary) { primaryCutInStarted = true; + this.updateCameraView(); visibleAtCutInStart = viewState(); } await originalPlayCombatCutIn.call(this, result); if (isPrimary) { primaryCutInCompleted = true; + this.updateCameraView(); visibleAtCutInEnd = viewState(); } }; @@ -1448,6 +1523,10 @@ try { visibleAtCutInEnd, defeatApplications, visibleAfterWrapper: viewState(), + pendingDefeatPresentationAfterWrapper: + scene.pendingDefeatPresentationUnitIds.has( + target.id + ), counterCalls, counterProduced, cutInOrder, @@ -1594,8 +1673,10 @@ try { productionFinisher.visibleAfterWrapper.spriteVisible === false && productionFinisher.visibleAfterWrapper.spriteAlpha === 0 && productionFinisher.visibleAfterWrapper.labelVisible === false && + productionFinisher.visibleAfterWrapper.hitZoneVisible === false && productionFinisher.visibleAfterWrapper.miniMapDotVisible === false && - productionFinisher.counterCalls === 1 && + productionFinisher.pendingDefeatPresentationAfterWrapper === false && + productionFinisher.counterCalls === 0 && productionFinisher.counterProduced === false && productionFinisher.result.counter === false && JSON.stringify(productionFinisher.cutInOrder) === JSON.stringify(['primary']), @@ -2692,7 +2773,7 @@ try { ); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await clickLegacyUi(page, 962, 310); + await clickTitleContinue(page); await waitForStoryReady(page); const resumedFirstAftermath = await page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.()); const resumedFirstAftermathSave = await readCampaignSave(page); @@ -5826,7 +5907,7 @@ try { }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await clickLegacyUi(page, 962, 310); + await clickTitleContinue(page); await waitForCamp(page); const requiredCasualtyRecruitment = await page.evaluate(() => { @@ -5893,7 +5974,7 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await clickLegacyUi(page, 962, 310); + await clickTitleContinue(page); await waitForCamp(page); const requiredCasualtyCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( @@ -6204,7 +6285,7 @@ try { }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await clickLegacyUi(page, 962, 310); + await clickTitleContinue(page); await waitForCamp(page); const midCampaignSelectionFixture = await page.evaluate((requestedUnitIds) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); @@ -7351,7 +7432,7 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await clickLegacyUi(page, 962, 310); + await clickTitleContinue(page); await waitForCamp(page); const restoredUnderCapacityState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( @@ -8334,7 +8415,7 @@ try { }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await clickLegacyUi(page, 962, 310); + await clickTitleContinue(page); await waitForCamp(page); await waitForCampSkinTransition(page, 'northern'); await waitForCampSoundscapeTransition(page, 'camp-frontier', 'mountain-wind-ambience'); @@ -8476,7 +8557,7 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); - await clickLegacyUi(page, 962, 310); + await clickTitleContinue(page); await waitForCamp(page); await waitForCampSkinTransition(page, 'northern'); const resumedFinalCampAfterRewardClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -8515,7 +8596,7 @@ try { await page.keyboard.press('Enter'); await waitForTitle(page); - await clickLegacyUi(page, 962, 310); + await clickTitleContinue(page); await waitForEnding(page); await page.screenshot({ path: `${screenshotDir}/rc-ending-continue.png`, fullPage: true }); await assertCanvasPainted(page, 'ending continue'); @@ -8573,8 +8654,28 @@ async function waitForTitle(page) { await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; - return activeScenes.includes('TitleScene'); + const state = + window.__HEROS_DEBUG__ + ?.scene('TitleScene') + ?.getDebugState?.(); + return ( + activeScenes.includes('TitleScene') && + state?.scene === 'TitleScene' && + state.navigating === false && + Array.isArray(state.focus?.items) && + state.focus.items.some( + (item) => + item.enabled === true && + Number.isFinite(item.bounds?.x) && + Number.isFinite(item.bounds?.y) && + Number.isFinite(item.bounds?.width) && + Number.isFinite(item.bounds?.height) + ) + ); }, undefined, { timeout: 90000 }); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); } async function assertReleaseShellReady(page) { @@ -10641,16 +10742,41 @@ async function waitForBattleOutcome(page, outcome) { } async function waitForCamp(page) { - await page.waitForFunction(() => { - const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; - const scene = window.__HEROS_DEBUG__?.scene('CampScene'); - return ( - activeScenes.includes('CampScene') && - !activeScenes.includes('BattleScene') && - window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' && - (scene?.contentObjects?.length ?? 0) > 0 + try { + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + const scene = window.__HEROS_DEBUG__?.scene('CampScene'); + return ( + activeScenes.includes('CampScene') && + !activeScenes.includes('BattleScene') && + window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' && + (scene?.contentObjects?.length ?? 0) > 0 + ); + }, undefined, { timeout: 90000 }); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes() ?? [], + title: + window.__HEROS_DEBUG__ + ?.scene('TitleScene') + ?.getDebugState?.() ?? null, + battle: + window.__HEROS_DEBUG__?.battle?.() ?? null, + camp: + window.__HEROS_DEBUG__?.camp?.() ?? null, + story: + window.__HEROS_DEBUG__ + ?.scene('StoryScene') + ?.getDebugState?.() ?? null + })); + throw new Error( + `Expected a ready camp scene: ${JSON.stringify( + diagnostic + )}`, + { cause: error } ); - }, undefined, { timeout: 90000 }); + } await page.evaluate(() => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve)); })); @@ -12411,12 +12537,33 @@ async function readBattleDoctrineProbe(page) { } async function seedCampaignSave(page, options) { + await page.evaluate(() => { + const game = window.__HEROS_GAME__; + game?.scene + .getScenes(true) + .forEach((scene) => + game.scene.stop(scene.scene.key) + ); + }); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(resolve) + ) + ) + ); await page.evaluate((seed) => { const storageKey = 'heros-web:campaign-state'; const current = JSON.parse(window.localStorage.getItem(storageKey) ?? 'null'); if (!current?.firstBattleReport) { throw new Error('Expected an existing campaign report before seeding RC save.'); } + // These fixtures model a completed victory already resting at camp. + // Do not inherit a live battle auto-save or an older navigation checkpoint. + Object.keys(window.localStorage) + .filter((key) => key.startsWith('heros-web:battle:')) + .forEach((key) => window.localStorage.removeItem(key)); const now = new Date().toISOString(); const objectives = [ @@ -12492,6 +12639,12 @@ async function seedCampaignSave(page, options) { step: seed.step, activeSaveSlot: 1, gold: seed.gold, + pendingAftermathBattleId: undefined, + activeCityStayId: undefined, + activeCampVisitId: undefined, + storyCheckpoint: undefined, + explorationCheckpoint: undefined, + sortiePreparationCheckpoint: undefined, inventory: { ...(current.inventory ?? {}), 콩: 12, 상처약: 6, 탁주: 4 }, selectedSortieUnitIds: seed.selectedSortieUnitIds, latestBattleId: seed.battleId, @@ -12516,6 +12669,26 @@ async function seedCampaignSave(page, options) { window.localStorage.setItem(storageKey, JSON.stringify(next)); window.localStorage.setItem(`${storageKey}:slot-1`, JSON.stringify(next)); + const persisted = JSON.parse( + window.localStorage.getItem( + `${storageKey}:slot-1` + ) ?? 'null' + ); + if ( + persisted?.step !== seed.step || + persisted?.storyCheckpoint || + persisted?.explorationCheckpoint || + persisted?.sortiePreparationCheckpoint || + persisted?.pendingAftermathBattleId || + persisted?.activeCityStayId || + persisted?.activeCampVisitId + ) { + throw new Error( + `Camp seed retained a navigation checkpoint: ${JSON.stringify( + persisted + )}` + ); + } }, options); } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 2604b54..1e66269 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -4121,6 +4121,7 @@ export class BattleScene extends Phaser.Scene { private miniMapObjectiveMarkers: Phaser.GameObjects.Rectangle[] = []; private miniMapViewport?: Phaser.GameObjects.Rectangle; private miniMapVisible = true; + private pendingDefeatPresentationUnitIds = new Set(); private recentActionLogLayout?: RecentActionLogLayout; private cameraTileX = 0; private cameraTileY = 0; @@ -4299,6 +4300,7 @@ export class BattleScene extends Phaser.Scene { this.miniMapObjectiveMarkers = []; this.miniMapViewport = undefined; this.miniMapVisible = true; + this.pendingDefeatPresentationUnitIds.clear(); this.recentActionLogLayout = undefined; this.edgeScrollElapsed = 0; this.pointerFeedbackMarker = undefined; @@ -4428,6 +4430,7 @@ export class BattleScene extends Phaser.Scene { this.mapResultPopupObjects.forEach((object) => object.active && object.destroy()); this.mapResultPopupObjects = []; this.hideBattleSoundCaption(); + this.pendingDefeatPresentationUnitIds.clear(); this.battleAutosaveReady = false; this.uninstallBattleAutosaveHandlers(); }); @@ -8377,14 +8380,16 @@ export class BattleScene extends Phaser.Scene { if (!unit) { return; } + const presentationVisible = + this.unitPresentationVisible(unit); const selected = this.selectedUnit?.id === unit.id; const objective = unit.id === leaderUnitId; const baseSize = Math.max(this.battleUiLength(3), cellSize); const markerSize = baseSize + (objective ? this.battleUiLength(2) : 0) + (selected ? this.battleUiLength(2) : 0); dot.setPosition(x + unit.x * cellSize + cellSize / 2, y + unit.y * cellSize + cellSize / 2); dot.setDisplaySize(markerSize, markerSize); - dot.setVisible(unit.hp > 0); - dot.setFillStyle(unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, unit.hp > 0 ? 0.96 : 0.18); + dot.setVisible(presentationVisible); + dot.setFillStyle(unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, presentationVisible ? 0.96 : 0.18); if (selected) { dot.setStrokeStyle(this.battleUiLength(2), 0xffffff, 1); } else if (objective) { @@ -8607,7 +8612,11 @@ export class BattleScene extends Phaser.Scene { this.setUnitBasePosition(view, this.tileCenterX(unit.x), this.tileCenterY(unit.y)); view.label.setPosition(view.baseX, view.baseY + this.layout.tileSize * 0.52); this.setUnitRoleSealPosition(view, view.baseX, view.baseY); - const visible = unit.hp > 0 && this.isTileVisible(unit.x, unit.y); + const presentationVisible = + this.unitPresentationVisible(unit); + const visible = + presentationVisible && + this.isTileVisible(unit.x, unit.y); view.sprite.setVisible(visible); this.setUnitHitZoneEnabled(view, visible); view.label.setVisible(visible); @@ -8701,10 +8710,19 @@ export class BattleScene extends Phaser.Scene { }); this.setUnitStatusBadgePositions(view, view.baseX, view.baseY); - const visible = unit.hp > 0 && this.isTileVisible(unit.x, unit.y); + const visible = + this.unitPresentationVisible(unit) && + this.isTileVisible(unit.x, unit.y); view.statusBadges.forEach((badge) => badge.container.setVisible(visible)); } + private unitPresentationVisible(unit: UnitData) { + return ( + unit.hp > 0 || + this.pendingDefeatPresentationUnitIds.has(unit.id) + ); + } + private refreshUnitLegibilityStyles() { battleUnits.forEach((unit) => this.applyUnitLegibilityStyle(unit)); } @@ -8714,7 +8732,7 @@ export class BattleScene extends Phaser.Scene { return; } - if (unit.hp <= 0) { + if (!this.unitPresentationVisible(unit)) { this.applyDefeatedStyle(unit, view); return; } @@ -14333,14 +14351,34 @@ export class BattleScene extends Phaser.Scene { this.triggerFirstEngagementEvent(attacker, target); this.phase = 'animating'; const result = this.resolveCombatAction(attacker, target, action, false, usable); - this.clearMarkers(); - await this.showBondMapEffect(result); - await this.presentCombatResult(result); - if (result.bondChain?.defeated) { - this.applyDefeatedStyle(result.defender); - this.updateMiniMap(); + const pendingDefeatPresentationUnitId = + result.bondChain?.defeated + ? result.defender.id + : undefined; + if (pendingDefeatPresentationUnitId) { + this.pendingDefeatPresentationUnitIds.add( + pendingDefeatPresentationUnitId + ); } - result.counter = this.resolveCounterAttack(result); + this.clearMarkers(); + try { + await this.showBondMapEffect(result); + await this.presentCombatResult(result); + } finally { + if (pendingDefeatPresentationUnitId) { + this.pendingDefeatPresentationUnitIds.delete( + pendingDefeatPresentationUnitId + ); + if (this.scene.isActive()) { + this.applyDefeatedStyle(result.defender); + this.updateMiniMap(); + } + } + } + result.counter = + result.defender.hp > 0 + ? this.resolveCounterAttack(result) + : undefined; if (result.counter) { await this.delay(180); await this.presentCombatResult(result.counter); @@ -23332,6 +23370,7 @@ export class BattleScene extends Phaser.Scene { } private applyBattleSaveState(state: BattleSaveState) { + this.pendingDefeatPresentationUnitIds.clear(); this.clearBattleEvents(); this.battleEventPresentationCount = 0; this.tacticalEventReactionHistory = []; @@ -29626,6 +29665,9 @@ export class BattleScene extends Phaser.Scene { } private applyDefeatedStyle(unit: UnitData, view = this.unitViews.get(unit.id)) { + if (this.pendingDefeatPresentationUnitIds.has(unit.id)) { + return; + } this.losePendingTacticalInitiativeEffect(unit); this.battleBuffs.delete(unit.id); this.battleStatuses.delete(unit.id);