From c8e8ee4b3a90d9bac1546315f6a274c129872873 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 12 Jul 2026 04:31:19 +0900 Subject: [PATCH] fix: block sorties with incapacitated required officers --- scripts/verify-release-candidate.mjs | 514 +++++++++++++++++++++++++++ src/game/scenes/CampScene.ts | 87 +++-- 2 files changed, 580 insertions(+), 21 deletions(-) diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index f69c11e..afbeaf8 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -3352,6 +3352,381 @@ try { `Expected the victory report to surface persisted core-resonance attempts, successes, and damage: ${JSON.stringify(secondBattleCoreResult)}` ); + const requiredCasualtySaveBefore = await readCampaignSave(page); + try { + await seedCampaignSave(page, { + battleId: 'seventeenth-battle-wolong-visit-road', + battleTitle: '융중 방문로', + step: 'seventeenth-camp', + gold: 2400, + turnNumber: 12, + defeatedEnemies: 10, + selectedSortieUnitIds: ['liu-bei', 'zhuge-liang', 'zhao-yun', 'guan-yu', 'zhang-fei', 'sun-qian'] + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForTitle(page); + await page.mouse.click(962, 310); + await waitForCamp(page); + + const requiredCasualtyRecruitment = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.camp(); + const unit = state?.campaign?.roster?.find((candidate) => candidate.id === 'zhuge-liang'); + return { + nextBattleId: state?.nextSortieBattleId ?? null, + unit: unit ? { id: unit.id, hp: unit.hp, maxHp: unit.maxHp } : null + }; + }); + assert( + requiredCasualtyRecruitment.nextBattleId === 'eighteenth-battle-bowang-ambush' && + requiredCasualtyRecruitment.unit?.hp > 0, + 'Expected the seventeenth camp to recruit Zhuge Liang before the casualty fixture: ' + + JSON.stringify(requiredCasualtyRecruitment) + ); + + const requiredCasualtySeed = await page.evaluate(() => { + const keys = ['heros-web:campaign-state', 'heros-web:campaign-state:slot-1']; + const seeded = []; + for (const key of keys) { + const raw = window.localStorage.getItem(key); + const campaign = raw ? JSON.parse(raw) : null; + const target = campaign?.roster?.find((unit) => unit.id === 'zhuge-liang'); + if (!campaign || !target || !campaign.firstBattleReport) { + seeded.push({ key, ready: false, rosterCount: campaign?.roster?.length ?? 0 }); + continue; + } + target.hp = 0; + const reportUnit = campaign.firstBattleReport.units?.find((unit) => unit.id === target.id); + if (reportUnit) { + reportUnit.hp = 0; + } else { + campaign.firstBattleReport.units = [...(campaign.firstBattleReport.units ?? []), { ...target, hp: 0 }]; + } + campaign.inventory = { ...(campaign.inventory ?? {}) }; + const salveBefore = 2; + campaign.inventory['상처약'] = salveBefore; + campaign.selectedSortieUnitIds = (campaign.selectedSortieUnitIds ?? []).filter( + (unitId) => unitId !== 'zhuge-liang' + ); + window.localStorage.setItem(key, JSON.stringify(campaign)); + seeded.push({ + key, + ready: true, + hp: target.hp, + maxHp: target.maxHp, + salveBefore, + selected: campaign.selectedSortieUnitIds.includes(target.id) + }); + } + return { + ready: seeded.length === keys.length && seeded.every((entry) => entry.ready), + seeded, + salveBefore: seeded.find((entry) => entry.key === keys[0])?.salveBefore ?? null + }; + }); + assert( + requiredCasualtySeed.ready === true && + requiredCasualtySeed.seeded.every((entry) => entry.hp === 0 && entry.maxHp > 0 && entry.selected === false), + 'Expected current and slot saves to retain a zero-HP required Zhuge Liang outside the saved selection: ' + + JSON.stringify(requiredCasualtySeed) + ); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForTitle(page); + await page.mouse.click(962, 310); + await waitForCamp(page); + const requiredCasualtyCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert( + requiredCasualtyCampState?.campaign?.step === 'seventeenth-camp' && + requiredCasualtyCampState?.nextSortieBattleId === 'eighteenth-battle-bowang-ambush' && + requiredCasualtyCampState?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang')?.hp === 0 && + !requiredCasualtyCampState?.selectedSortieUnitIds?.includes('zhuge-liang'), + 'Expected campaign loading to preserve the casualty while excluding it from the active sortie: ' + + JSON.stringify(requiredCasualtyCampState) + ); + + const requiredCasualtyStoryPoint = await readCampStaticTextControlPoint(page, '다음 이야기'); + await page.mouse.click(requiredCasualtyStoryPoint.x, requiredCasualtyStoryPoint.y); + await waitForSortiePrep(page); + await advanceSortiePrepStep(page, 'formation'); + const requiredCasualtyFormationState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const requiredCasualtyPortrait = requiredCasualtyFormationState?.sortiePortraitRoster?.find( + (unit) => unit.id === 'zhuge-liang' + ); + assert( + requiredCasualtyPortrait?.required === true && + requiredCasualtyPortrait.selected === false && + requiredCasualtyPortrait.available === false && + requiredCasualtyPortrait.hp === 0 && + requiredCasualtyPortrait.maxHp > 0 && + requiredCasualtyPortrait.availabilityLabel === '부상' && + requiredCasualtyPortrait.availabilityReason?.includes('보급 화면') && + requiredCasualtyFormationState?.sortiePortraitRosterView?.visibleUnitIds?.includes('zhuge-liang') && + requiredCasualtyFormationState?.sortiePrepSteps?.find((step) => step.id === 'formation')?.complete === false, + 'Expected the unavailable required officer to stay visible, disabled, and formation-incomplete: ' + + JSON.stringify({ + portrait: requiredCasualtyPortrait, + view: requiredCasualtyFormationState?.sortiePortraitRosterView, + steps: requiredCasualtyFormationState?.sortiePrepSteps + }) + ); + await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-card.png', fullPage: true }); + await assertCanvasPainted(page, 'required sortie casualty card'); + + await advanceSortiePrepStep(page, 'loadout'); + const requiredCasualtyLoadoutState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const requiredCasualtyReadiness = requiredCasualtyLoadoutState?.sortieChecklist?.find( + (item) => item.label?.includes('유비') && item.label?.includes('제갈량') && item.label?.includes('생존') + ); + const requiredCasualtyComposition = requiredCasualtyLoadoutState?.sortieChecklist?.find( + (item) => item.label === '출전 구성' + ); + const requiredCasualtyRecoveryTarget = requiredCasualtyLoadoutState?.sortieChecklist?.find( + (item) => item.label === '부상 장수 확인' + ); + assert( + requiredCasualtyReadiness?.complete === false && + requiredCasualtyReadiness.detail?.includes('제갈량 0/') && + requiredCasualtyReadiness.detail?.includes('회복 필요') && + requiredCasualtyComposition?.complete === false && + requiredCasualtyRecoveryTarget?.complete === false && + requiredCasualtyRecoveryTarget.detail?.includes('제갈량 0/') && + requiredCasualtyLoadoutState?.sortieChecklistSummary?.requiredComplete === false && + requiredCasualtyLoadoutState?.sortieChecklistSummary?.requiredRemainingCount >= 2 && + requiredCasualtyLoadoutState?.sortiePrepSteps?.find((step) => step.id === 'loadout')?.complete === false, + 'Expected the casualty to keep both required final checks incomplete: ' + + JSON.stringify({ + readiness: requiredCasualtyReadiness, + composition: requiredCasualtyComposition, + recoveryTarget: requiredCasualtyRecoveryTarget, + summary: requiredCasualtyLoadoutState?.sortieChecklistSummary, + steps: requiredCasualtyLoadoutState?.sortiePrepSteps + }) + ); + + const requiredCasualtySaveBeforeLaunch = await readCampaignSave(page); + const blockedLaunchPoint = await readSortieTextControlPoint(page, '출진'); + await page.mouse.click(blockedLaunchPoint.x, blockedLaunchPoint.y); + await page.waitForTimeout(120); + const blockedRequiredLaunch = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp(); + return { + activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], + sortieVisible: state?.sortieVisible ?? false, + sortiePrepStep: state?.sortiePrepStep ?? null, + notice: (scene?.dialogueObjects ?? []) + .filter((object) => object?.type === 'Text' && object?.active && object?.visible) + .map((object) => object.text) + .join(' ') + }; + }); + const requiredCasualtySaveAfterLaunch = await readCampaignSave(page); + assert( + blockedRequiredLaunch.activeScenes.includes('CampScene') && + !blockedRequiredLaunch.activeScenes.includes('StoryScene') && + !blockedRequiredLaunch.activeScenes.includes('BattleScene') && + blockedRequiredLaunch.sortieVisible === true && + blockedRequiredLaunch.sortiePrepStep === 'loadout' && + blockedRequiredLaunch.notice.includes('제갈량') && + blockedRequiredLaunch.notice.includes('보급 화면') && + sameJsonValue(requiredCasualtySaveAfterLaunch, requiredCasualtySaveBeforeLaunch), + 'Expected the real launch control to block a zero-HP required officer without mutating saves: ' + + JSON.stringify({ + blockedRequiredLaunch, + saveUnchanged: sameJsonValue(requiredCasualtySaveAfterLaunch, requiredCasualtySaveBeforeLaunch) + }) + ); + await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-blocked.png', fullPage: true }); + await assertCanvasPainted(page, 'required sortie casualty blocked'); + + const returnToCampPoint = await readSortieTextControlPoint(page, '군영으로'); + await page.mouse.click(returnToCampPoint.x, returnToCampPoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.sortieVisible === false, + undefined, + { timeout: 30000 } + ); + await selectCampRosterUnit(page, 'zhuge-liang'); + const suppliesTabPoint = await readCampTabControlPoint(page, 'supplies'); + await page.mouse.click(suppliesTabPoint.x, suppliesTabPoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.activeTab === 'supplies', + undefined, + { timeout: 30000 } + ); + const salveUsePoint = await readCampSupplyUseControlPoint(page, '상처약'); + await page.mouse.click(salveUsePoint.x, salveUsePoint.y); + await page.waitForTimeout(120); + const salveClickApplied = await page.evaluate(() => { + const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang'); + return target?.hp > 0; + }); + if (!salveClickApplied) { + const salveFallback = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang'); + const texts = (scene?.contentObjects ?? []).filter( + (object) => object?.type === 'Text' && object?.active && object?.visible + ); + const supplyTitle = texts.find((object) => object.text?.startsWith('상처약 x')); + const useText = texts + .filter((object) => object.text === '사용' && object.input?.enabled) + .sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0]; + if (!scene || !target || !supplyTitle || !useText) { + return { + ready: false, + targetHp: target?.hp ?? null, + supplyTitle: supplyTitle?.text ?? null, + useCount: texts.filter((object) => object.text === '사용').length + }; + } + useText.emit('pointerdown'); + return { ready: true, targetHp: target.hp, supplyTitle: supplyTitle.text }; + }); + assert( + salveFallback.ready === true, + 'Expected the visible salve control to expose its Phaser action after the canvas click fallback: ' + + JSON.stringify(salveFallback) + ); + } + await page.waitForFunction( + () => { + const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === 'zhuge-liang'); + return target?.hp > 0; + }, + undefined, + { timeout: 30000 } + ); + const requiredCasualtyRecoverySave = await readCampaignSave(page); + const recoveredCurrent = requiredCasualtyRecoverySave.current?.roster?.find((unit) => unit.id === 'zhuge-liang'); + const recoveredSlot = requiredCasualtyRecoverySave.slot1?.roster?.find((unit) => unit.id === 'zhuge-liang'); + const recoveredCurrentReport = requiredCasualtyRecoverySave.current?.firstBattleReport?.units?.find( + (unit) => unit.id === 'zhuge-liang' + ); + const recoveredSlotReport = requiredCasualtyRecoverySave.slot1?.firstBattleReport?.units?.find( + (unit) => unit.id === 'zhuge-liang' + ); + assert( + recoveredCurrent?.hp > 0 && + recoveredSlot?.hp === recoveredCurrent.hp && + recoveredCurrentReport?.hp === recoveredCurrent.hp && + recoveredSlotReport?.hp === recoveredCurrent.hp && + (requiredCasualtyRecoverySave.current?.inventory?.['상처약'] ?? 0) === requiredCasualtySeed.salveBefore - 1 && + (requiredCasualtyRecoverySave.slot1?.inventory?.['상처약'] ?? 0) === requiredCasualtySeed.salveBefore - 1, + 'Expected one real salve use to recover and persist the required officer exactly once: ' + + JSON.stringify({ + currentHp: recoveredCurrent?.hp, + slotHp: recoveredSlot?.hp, + currentReportHp: recoveredCurrentReport?.hp, + slotReportHp: recoveredSlotReport?.hp, + currentSalves: requiredCasualtyRecoverySave.current?.inventory?.['상처약'] ?? 0, + slotSalves: requiredCasualtyRecoverySave.slot1?.inventory?.['상처약'] ?? 0, + expectedSalves: requiredCasualtySeed.salveBefore - 1 + }) + ); + + const recoveredStoryPoint = await readCampStaticTextControlPoint(page, '다음 이야기'); + await page.mouse.click(recoveredStoryPoint.x, recoveredStoryPoint.y); + await waitForSortiePrep(page); + const recoveredRequiredBriefing = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert( + recoveredRequiredBriefing?.selectedSortieUnitIds?.includes('liu-bei') && + recoveredRequiredBriefing?.selectedSortieUnitIds?.includes('zhuge-liang'), + 'Expected reopening sortie prep to auto-select every recovered required officer: ' + + JSON.stringify(recoveredRequiredBriefing?.selectedSortieUnitIds) + ); + await advanceSortiePrepStep(page, 'formation'); + const recoveredRequiredFormation = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const recoveredRequiredPortrait = recoveredRequiredFormation?.sortiePortraitRoster?.find( + (unit) => unit.id === 'zhuge-liang' + ); + assert( + recoveredRequiredPortrait?.required === true && + recoveredRequiredPortrait.selected === true && + recoveredRequiredPortrait.available === true && + recoveredRequiredPortrait.hp === recoveredCurrent?.hp && + recoveredRequiredFormation?.sortiePrepSteps?.find((step) => step.id === 'formation')?.complete === true, + 'Expected the recovered required portrait and formation stage to become ready: ' + + JSON.stringify({ + portrait: recoveredRequiredPortrait, + steps: recoveredRequiredFormation?.sortiePrepSteps + }) + ); + await page.screenshot({ path: screenshotDir + '/rc-required-sortie-casualty-recovered.png', fullPage: true }); + await assertCanvasPainted(page, 'required sortie casualty recovered'); + + await advanceSortiePrepStep(page, 'loadout'); + const recoveredRequiredLoadout = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const recoveredReadiness = recoveredRequiredLoadout?.sortieChecklist?.find( + (item) => item.label?.includes('유비') && item.label?.includes('제갈량') && item.label?.includes('생존') + ); + const recoveredComposition = recoveredRequiredLoadout?.sortieChecklist?.find( + (item) => item.label === '출전 구성' + ); + const recoveredRecoveryTarget = recoveredRequiredLoadout?.sortieChecklist?.find( + (item) => item.label === '부상 장수 확인' + ); + const recoveredDeploymentIds = recoveredRequiredLoadout?.sortieDeploymentPreview?.map((slot) => slot.unitId) ?? []; + assert( + recoveredReadiness?.complete === true && + recoveredComposition?.complete === true && + recoveredRecoveryTarget?.complete === true && + recoveredRequiredLoadout?.sortieChecklistSummary?.requiredComplete === true && + recoveredDeploymentIds.filter((unitId) => unitId === 'liu-bei').length === 1 && + recoveredDeploymentIds.filter((unitId) => unitId === 'zhuge-liang').length === 1 && + new Set(recoveredDeploymentIds).size === recoveredDeploymentIds.length, + 'Expected recovery to complete required checks and produce one unique slot per required officer: ' + + JSON.stringify({ + recoveredReadiness, + recoveredComposition, + recoveredRecoveryTarget, + summary: recoveredRequiredLoadout?.sortieChecklistSummary, + deployment: recoveredRequiredLoadout?.sortieDeploymentPreview + }) + ); + + const recoveredLaunchPoint = await readSortieTextControlPoint(page, '출진'); + await page.mouse.click(recoveredLaunchPoint.x, recoveredLaunchPoint.y); + await page.waitForFunction( + () => (window.__HEROS_DEBUG__?.activeScenes() ?? []).includes('StoryScene'), + undefined, + { timeout: 30000 } + ); + const recoveredRequiredLaunchSave = await readCampaignSave(page); + assert( + recoveredRequiredLaunchSave.current?.step === 'eighteenth-battle' && + recoveredRequiredLaunchSave.slot1?.step === 'eighteenth-battle' && + recoveredRequiredLaunchSave.current?.selectedSortieUnitIds?.includes('liu-bei') && + recoveredRequiredLaunchSave.current?.selectedSortieUnitIds?.includes('zhuge-liang'), + 'Expected the same real launch control to continue after the required officer recovers: ' + + JSON.stringify({ + currentStep: recoveredRequiredLaunchSave.current?.step, + slotStep: recoveredRequiredLaunchSave.slot1?.step, + selected: recoveredRequiredLaunchSave.current?.selectedSortieUnitIds + }) + ); + } finally { + await page.evaluate((save) => { + const write = (key, value) => { + if (value === undefined) { + window.localStorage.removeItem(key); + return; + } + window.localStorage.setItem(key, JSON.stringify(value)); + }; + write('heros-web:campaign-state', save.current); + write('heros-web:campaign-state:slot-1', save.slot1); + }, requiredCasualtySaveBefore); + const requiredCasualtyRestoredSave = await readCampaignSave(page); + assert( + sameJsonValue(requiredCasualtyRestoredSave, requiredCasualtySaveBefore), + 'Expected the required-casualty fixture to restore both campaign saves: ' + + JSON.stringify({ before: requiredCasualtySaveBefore, after: requiredCasualtyRestoredSave }) + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForTitle(page); + } + await seedCampaignSave(page, { battleId: 'fifty-eighth-battle-qishan-retreat', battleTitle: '기산 후퇴로', @@ -5909,6 +6284,43 @@ async function readCampRosterRowPoint(page, unitId) { return probe; } +async function selectCampRosterUnit(page, unitId) { + const initial = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campRoster ?? null); + const maxAttempts = Math.max(1, initial?.pageCount ?? 1); + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const state = await page.evaluate(() => { + const camp = window.__HEROS_DEBUG__?.camp(); + return { + selectedUnitId: camp?.selectedUnitId ?? null, + roster: camp?.campRoster ?? null + }; + }); + if (state.roster?.visibleUnitIds?.includes(unitId)) { + const point = await readCampRosterRowPoint(page, unitId); + await page.mouse.click(point.x, point.y); + await page.waitForFunction( + (targetUnitId) => window.__HEROS_DEBUG__?.camp()?.selectedUnitId === targetUnitId, + unitId, + { timeout: 30000 } + ); + return page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + } + if (!state.roster?.nextEnabled || !state.selectedUnitId) { + break; + } + await navigateCampRosterPage(page, 'next', state.roster.page + 1, state.selectedUnitId); + } + const finalState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + throw new Error( + 'Could not find camp roster unit ' + + unitId + + ' across ' + + maxAttempts + + ' page(s): ' + + JSON.stringify(finalState?.campRoster) + ); +} + function isPositiveBounds(bounds) { return Boolean( bounds && @@ -6276,6 +6688,108 @@ async function readCampReportFormationHistoryControlPoint(page, control) { return probe; } +async function readCampStaticTextControlPoint(page, label) { + const probe = await page.evaluate((targetLabel) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const target = scene?.children?.list + ?.filter( + (object) => + object?.type === 'Text' && + object.text === targetLabel && + object.active && + object.visible && + object.input?.enabled + ) + .sort((left, right) => left.y - right.y || left.x - right.x)[0]; + if (!scene || !canvas || !target) { + return { ready: false, label: targetLabel }; + } + const bounds = target.getBounds(); + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + label: targetLabel, + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, label); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + 'Expected a visible interactive camp command for ' + label + ': ' + JSON.stringify(probe) + ); + return probe; +} + +async function readCampTabControlPoint(page, tab) { + const probe = await page.evaluate((targetTab) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const button = scene?.tabButtons?.find((candidate) => candidate.tab === targetTab); + const target = button?.bg; + if (!scene || !canvas || !target?.active || !target.visible || !target.input?.enabled) { + return { ready: false, tab: targetTab, availableTabs: scene?.tabButtons?.map((candidate) => candidate.tab) ?? [] }; + } + const bounds = target.getBounds(); + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + tab: targetTab, + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, tab); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + 'Expected a visible camp tab for ' + tab + ': ' + JSON.stringify(probe) + ); + return probe; +} + +async function readSortieTextControlPoint(page, label) { + const probe = await page.evaluate((targetLabel) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const candidates = (scene?.sortieObjects ?? []) + .filter( + (object) => + object?.type === 'Text' && + object.text === targetLabel && + object.active && + object.visible && + object.input?.enabled + ) + .sort((left, right) => left.y - right.y || left.x - right.x); + const target = candidates[0]; + if (!scene || !canvas || !target) { + return { ready: false, label: targetLabel, candidateCount: candidates.length }; + } + const bounds = target.getBounds(); + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + label: targetLabel, + candidateCount: candidates.length, + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, label); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + 'Expected a visible interactive sortie control for ' + label + ': ' + JSON.stringify(probe) + ); + return probe; +} + async function readCampContentTextControlPoint(page, label, occurrence = 0) { const probe = await page.evaluate(({ label, occurrence }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index ddc1777..68b907d 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -12888,7 +12888,15 @@ export class CampScene extends Phaser.Scene { } if (step === 'formation') { const plan = this.sortiePlanSummary(); - return plan.selectedCount > 0 && plan.formationCoverageComplete && this.hasCurrentSortieOrderSelection(); + const requiredCompositionReady = checklist.find( + (item) => item.label === '출전 구성' || item.label === '동행 구성' + )?.complete ?? true; + return ( + requiredCompositionReady && + plan.selectedCount > 0 && + plan.formationCoverageComplete && + this.hasCurrentSortieOrderSelection() + ); } return this.firstSortieFinalCheckItems(checklist).every((item) => item.complete); } @@ -13229,8 +13237,12 @@ export class CampScene extends Phaser.Scene { portraitView.frame.setStrokeStyle(2, accentColor, focused ? 0.92 : selected ? 0.78 : recommendation ? 0.62 : 0.42); portraitView.image?.setDisplaySize(70, 70); }); - const statusLabel = required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기'; - const statusColor = required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf'; + const statusLabel = !availability.available + ? required ? `필수 · ${availability.label}` : availability.label + : required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기'; + const statusColor = !availability.available + ? '#ff9d7d' + : required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf'; const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 9, statusLabel, this.textStyle(10, statusColor, true))); status.setOrigin(1, 0); status.setDepth(depth + 2); @@ -14713,6 +14725,11 @@ export class CampScene extends Phaser.Scene { const selected = new Set(this.selectedSortieUnitIds); const order = new Map(allies.map((unit, index) => [unit.id, index])); return [...allies].sort((a, b) => { + const aRequired = this.isRequiredSortieUnit(a.id); + const bRequired = this.isRequiredSortieUnit(b.id); + if (aRequired !== bRequired) { + return Number(bRequired) - Number(aRequired); + } const aSelected = selected.has(a.id); const bSelected = selected.has(b.id); if (aSelected !== bSelected) { @@ -14728,11 +14745,6 @@ export class CampScene extends Phaser.Scene { if (aRecommended !== bRecommended) { return Number(bRecommended) - Number(aRecommended); } - const aRequired = this.isRequiredSortieUnit(a.id); - const bRequired = this.isRequiredSortieUnit(b.id); - if (aRequired !== bRequired) { - return Number(bRequired) - Number(aRequired); - } return (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0); }); } @@ -16509,6 +16521,13 @@ export class CampScene extends Phaser.Scene { return this.requiredSortieUnitIdsFor(scenario).has(unitId); } + private requiredSortieRosterUnitsFor(scenario = this.nextSortieScenario()) { + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + return [...this.requiredSortieUnitIdsFor(scenario)] + .map((unitId) => rosterById.get(unitId)) + .filter((unit): unit is UnitData => Boolean(unit)); + } + private sortieMaxUnits(scenario = this.nextSortieScenario()) { return Math.min(this.nextSortieRule(scenario).maxUnits, this.sortieAllies().length); } @@ -16989,7 +17008,7 @@ export class CampScene extends Phaser.Scene { private sortieRecoveryTargets(thresholdRatio = 0.7) { return this.currentUnits() - .filter((unit) => unit.faction === 'ally' && unit.hp > 0 && unit.maxHp > 0 && unit.hp / unit.maxHp < thresholdRatio) + .filter((unit) => unit.faction === 'ally' && unit.maxHp > 0 && unit.hp / unit.maxHp < thresholdRatio) .sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp || a.name.localeCompare(b.name)); } @@ -17237,10 +17256,9 @@ export class CampScene extends Phaser.Scene { private sortieChecklist(): SortieChecklistItem[] { const units = this.currentUnits().filter((unit) => unit.faction === 'ally'); - const sortieAllies = this.sortieAllies(); const hasBattle = Boolean(this.nextSortieScenario()); - const requiredUnitIds = [...this.requiredSortieUnitIdsFor()].filter((id) => sortieAllies.some((unit) => unit.id === id)); - const requiredUnits = requiredUnitIds.map((id) => sortieAllies.find((unit) => unit.id === id)).filter(Boolean) as UnitData[]; + const requiredUnits = this.requiredSortieRosterUnitsFor(); + const requiredUnitIds = requiredUnits.map((unit) => unit.id); const requiredNames = requiredUnits.map((unit) => unit.name); const requiredLabelName = requiredNames.length === 0 ? hasBattle ? '필수 무장' : '필수 동행' @@ -17250,9 +17268,19 @@ export class CampScene extends Phaser.Scene { ? `${requiredNames[0]}·${requiredNames[1]}` : `${requiredNames[0]} 외 ${requiredNames.length - 1}명`; const requiredLabel = `${requiredLabelName} ${hasBattle ? '생존' : '참석'}`; - const requiredComplete = requiredUnits.length > 0 ? requiredUnits.every((unit) => unit.hp > 0) : true; + const requiredComplete = requiredUnits.length > 0 + ? requiredUnits.every((unit) => this.sortieUnitAvailability(unit).available) + : true; const requiredDetail = requiredUnits.length > 0 - ? requiredUnits.map((unit) => `${unit.name} ${unit.hp}/${unit.maxHp}`).join(', ') + ? requiredUnits.map((unit) => { + const availability = this.sortieUnitAvailability(unit); + const status = availability.available + ? '' + : unit.hp <= 0 + ? ' · 회복 필요' + : ` · ${availability.label}`; + return `${unit.name} ${unit.hp}/${unit.maxHp}${status}`; + }).join(', ') : '필수 무장 없음'; const injured = units.filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp); const recoveryTargets = this.sortieRecoveryTargets(); @@ -17296,7 +17324,7 @@ export class CampScene extends Phaser.Scene { }, { label: hasBattle ? '출전 구성' : '동행 구성', - complete: requiredUnitIds.every((id) => selected.some((unit) => unit.id === id)) && selected.length > 0, + complete: requiredComplete && requiredUnitIds.every((id) => selected.some((unit) => unit.id === id)) && selected.length > 0, detail: selected.length > 0 ? selected.map((unit) => unit.name).join(', ') : hasBattle ? '출전 무장 선택 필요' : '동행 무장 선택 필요', priority: 'required' }, @@ -17376,10 +17404,14 @@ export class CampScene extends Phaser.Scene { } if (!this.isFinalEpilogueFlow(flow) && !this.ensureSortieSelectionSaved()) { const hasBattle = Boolean(flow.nextBattleId); - const availableIds = new Set(this.sortieAllies().map((unit) => unit.id)); - const requiredNames = [...this.requiredSortieUnitIdsFor()] - .filter((unitId) => availableIds.has(unitId)) - .map((unitId) => this.unitName(unitId)); + const requiredUnits = this.requiredSortieRosterUnitsFor(); + const unavailableRequired = requiredUnits.find((unit) => !this.sortieUnitAvailability(unit).available); + const requiredNames = requiredUnits.map((unit) => unit.name); + if (unavailableRequired) { + const availability = this.sortieUnitAvailability(unavailableRequired); + this.showCampNotice(`${unavailableRequired.name}: ${availability.reason}`); + return; + } this.showCampNotice( requiredNames.length > 0 ? `${hasBattle ? '출전' : '동행'}할 무장을 선택하세요. ${requiredNames.join(', ')}는 반드시 포함되어야 합니다.` @@ -20481,8 +20513,12 @@ export class CampScene extends Phaser.Scene { private ensureSortieSelectionSaved() { this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.selectedSortieUnitIds); const selected = new Set(this.selectedSortieUnitIds); - const required = [...this.requiredSortieUnitIdsFor()].filter((id) => this.sortieAllies().some((unit) => unit.id === id)); - if (!required.every((id) => selected.has(id)) || this.selectedSortieUnitIds.length === 0) { + const requiredUnits = this.requiredSortieRosterUnitsFor(); + if ( + requiredUnits.some((unit) => !this.sortieUnitAvailability(unit).available) || + !requiredUnits.every((unit) => selected.has(unit.id)) || + this.selectedSortieUnitIds.length === 0 + ) { return false; } this.persistSortieSelection(); @@ -21142,10 +21178,19 @@ export class CampScene extends Phaser.Scene { cardBounds: this.sortiePortraitRosterLayout?.cardBounds.map((bounds) => ({ ...bounds })) ?? [] }, sortiePortraitRoster: portraitRosterUnits.map((unit) => { + const availability = this.sortieUnitAvailability(unit); const portraitKey = campaignPortraitKeysByUnitId[unit.id]; const unitPortraitTextureKey = portraitKey ? portraitCardTextureKey(portraitKey) ?? portraitTextureKey(portraitKey) : undefined; return { id: unit.id, + name: unit.name, + hp: unit.hp, + maxHp: unit.maxHp, + required: this.isRequiredSortieUnit(unit.id), + selected: this.isSortieSelected(unit.id), + available: availability.available, + availabilityLabel: availability.label, + availabilityReason: availability.reason, portraitTextureKey: unitPortraitTextureKey ?? null, portraitReady: Boolean(unitPortraitTextureKey && this.textures.exists(unitPortraitTextureKey)) };