diff --git a/scripts/verify-third-camp-preparation-browser.mjs b/scripts/verify-third-camp-preparation-browser.mjs index 7d13898..38a79d4 100644 --- a/scripts/verify-third-camp-preparation-browser.mjs +++ b/scripts/verify-third-camp-preparation-browser.mjs @@ -28,7 +28,7 @@ const priorityFixtures = [ }, { id: 'equipment', - label: '장비 정비', + label: '선봉 방호', effectKind: 'prepared-equipment-guard', configuredValue: 8 }, @@ -142,6 +142,10 @@ async function verifyRenderer(browser, baseUrl, rendererFixture) { priority ); } + await verifyCompanionFormationMismatch( + page, + rendererFixture.renderer + ); assert.deepEqual( pageErrors, @@ -360,14 +364,15 @@ async function verifyCampPreparationUi(page, renderer) { 'equipment' ); assert.equal(exploration.visit.completedActivityCount, 1); - assert.equal(exploration.visit.selectedPriorityId, 'equipment'); + assert.equal(exploration.visit.selectedPriorityId, null); + assert.equal(exploration.visit.selectionConfirmed, false); assert.equal( exploration.visit.hudActivityTitle, '자유 준비 1 / 3' ); assert.equal( exploration.visit.hudPriorityLocation, - '장비 정비' + '아직 정하지 않음' ); assertThirdCampWorldFeedback(exploration, ['equipment']); assert( @@ -383,6 +388,12 @@ async function verifyCampPreparationUi(page, renderer) { return scene?.debugInteractWith?.('camp-exit') ?? false; }); assert.equal(partialExit, true); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.campVisitExploration?.() + ?.dialogue?.active === true + ); + await advanceExplorationDialogue(page); await waitForCamp(page); await page.reload({ waitUntil: 'domcontentloaded', @@ -404,7 +415,7 @@ async function verifyCampPreparationUi(page, renderer) { ['equipment'], 'A partial activity must survive a Camp round trip and page reload.' ); - assert.equal(exploration.visit.selectedPriorityId, 'equipment'); + assert.equal(exploration.visit.selectedPriorityId, null); assertThirdCampWorldFeedback(exploration, ['equipment']); exploration = await completeThirdCampNpcActivity( @@ -416,7 +427,7 @@ async function verifyCampPreparationUi(page, renderer) { [...exploration.visit.completedActivityIds].sort(), ['equipment', 'information'] ); - assert.equal(exploration.visit.selectedPriorityId, 'information'); + assert.equal(exploration.visit.selectedPriorityId, null); assertThirdCampWorldFeedback( exploration, ['equipment', 'information'] @@ -468,8 +479,7 @@ async function verifyCampPreparationUi(page, renderer) { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); return ( - state?.visit?.completedActivityCount === 3 && - state?.visit?.selectedPriorityId === 'companion' + state?.visit?.completedActivityCount === 3 ); } ); @@ -485,7 +495,7 @@ async function verifyCampPreparationUi(page, renderer) { ); assert.equal( exploration.visit.hudPriorityLocation, - '동료 공명' + '아직 정하지 않음' ); assertThirdCampWorldFeedback( exploration, @@ -544,6 +554,12 @@ async function verifyCampPreparationUi(page, renderer) { return scene?.debugInteractWith?.('camp-exit') ?? false; }); assert.equal(completedExit, true); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.campVisitExploration?.() + ?.dialogue?.active === true + ); + await advanceExplorationDialogue(page); await waitForCamp(page); await page.evaluate(() => { const scene = @@ -570,7 +586,45 @@ async function verifyCampPreparationUi(page, renderer) { priority.id ); } + assert.equal(camp.thirdCampPreparation.selectedPriorityId, null); + assert.equal(camp.thirdCampPreparation.confirmed, false); + assert.equal( + camp.thirdCampPreparation.keyboardFocusPriorityId, + 'information' + ); + await page.keyboard.press('ArrowDown'); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.camp?.() + ?.thirdCampPreparation?.keyboardFocusPriorityId === + 'equipment' + ); + await page.keyboard.press('Shift+Tab'); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.camp?.() + ?.thirdCampPreparation?.keyboardFocusPriorityId === + 'information' + ); + await page.keyboard.press('3'); + await page.waitForFunction( + () => { + const preparation = + window.__HEROS_DEBUG__?.camp?.()?.thirdCampPreparation; + return ( + preparation?.selectedPriorityId === 'companion' && + preparation.confirmed === true && + preparation.ready === true + ); + } + ); + camp = await readCamp(page); assertPreparationSelection(camp, 'companion'); + await verifyPreparationModalKeyboardIsolation(page); + camp = await verifyPreparationStepKeyboardIsolation( + page, + 'information' + ); assertFiniteBounds( camp.thirdCampPreparation.clearButtonBounds, 'preparation clear button' @@ -616,6 +670,368 @@ async function verifyCampPreparationUi(page, renderer) { null, 'cleared optional preparation' ); + await verifyRetryLockedPreparationCard(page); +} + +async function verifyPreparationModalKeyboardIsolation(page) { + await page.evaluate(async () => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const snapshot = campaignModule.getCampaignState(); + campaignModule.saveCampaignState(snapshot, 2); + campaignModule.saveCampaignState(snapshot, 1); + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + scene.campaign = campaignModule.getCampaignState(); + scene.showSortiePrep(); + }); + await page.waitForFunction( + () => { + const state = window.__HEROS_DEBUG__?.camp?.(); + return ( + state?.campaign?.activeSaveSlot === 1 && + state?.thirdCampPreparation?.browserOpen === true && + state.thirdCampPreparation.selectedPriorityId === + 'companion' + ); + } + ); + + await openCampSaveSlotPanel(page); + await page.keyboard.press('1'); + await waitForCampSaveModalClosed(page); + let camp = await readCamp(page); + assertPreparationSelection(camp, 'companion'); + assert.equal( + camp.campaign.activeSaveSlot, + 1, + 'The 1 key must save to slot 1 instead of selecting the first preparation card.' + ); + + await selectPreparationCard(page, 'equipment'); + await waitForCampSelection(page, 'equipment'); + await openCampSaveSlotPanel(page); + await page.keyboard.press('3'); + await waitForCampSaveModalClosed(page); + camp = await readCamp(page); + assertPreparationSelection(camp, 'equipment'); + assert.equal( + camp.campaign.activeSaveSlot, + 3, + 'The 3 key must save to slot 3 instead of selecting the third preparation card.' + ); + + await selectPreparationCard(page, 'information'); + await waitForCampSelection(page, 'information'); + await openCampSaveSlotPanel(page); + await page.keyboard.press('2'); + await page.waitForFunction( + () => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + return ( + scene?.saveSlotObjects?.length > 0 && + scene?.saveSlotConfirmObjects?.length > 0 && + scene?.pendingSaveSlot === 2 + ); + } + ); + camp = await readCamp(page); + assertPreparationSelection(camp, 'information'); + + await page.keyboard.press('1'); + await page.keyboard.press('3'); + const guardedConfirm = await readCampSaveModalState(page); + assert.equal(guardedConfirm.slotPanelOpen, true); + assert.equal(guardedConfirm.overwriteConfirmOpen, true); + assert.equal(guardedConfirm.pendingSaveSlot, 2); + assert.equal( + guardedConfirm.selectedPriorityId, + 'information', + 'Number keys owned by the overwrite confirmation must not leak to hidden preparation cards.' + ); + + await page.keyboard.press('Enter'); + await waitForCampSaveModalClosed(page); + camp = await readCamp(page); + assertPreparationSelection(camp, 'information'); + assert.equal( + camp.campaign.activeSaveSlot, + 2, + 'Enter must confirm the pending overwrite instead of confirming the focused preparation card.' + ); +} + +async function verifyPreparationStepKeyboardIsolation( + page, + selectedPriorityId +) { + let camp = await readCamp(page); + assertPreparationSelection(camp, selectedPriorityId); + assert.equal(camp.thirdCampPreparation.browserOpen, true); + assert.equal(camp.sortiePrimaryAction?.kind, 'next'); + assert.equal( + camp.sortiePrimaryAction?.nextStep, + 'formation' + ); + assertFiniteBounds( + camp.sortiePrimaryAction?.bounds, + 'sortie formation action' + ); + await clickSceneBounds( + page, + 'CampScene', + camp.sortiePrimaryAction.bounds + ); + await page.waitForFunction( + (selectedPriorityId) => { + const state = window.__HEROS_DEBUG__?.camp?.(); + return ( + state?.sortiePrepStep === 'formation' && + state?.thirdCampPreparation?.browserOpen === false && + state.thirdCampPreparation.selectedPriorityId === + selectedPriorityId + ); + }, + selectedPriorityId + ); + camp = await readCamp(page); + assert( + camp.thirdCampPreparation.priorities.every( + ({ cardBounds, actionButtonBounds }) => + cardBounds === null && actionButtonBounds === null + ), + 'Leaving the briefing must destroy every preparation-card input target.' + ); + + await page.keyboard.press('1'); + camp = await readCamp(page); + assert.equal(camp.sortiePrepStep, 'formation'); + assert.equal( + camp.thirdCampPreparation.selectedPriorityId, + selectedPriorityId, + 'A hidden preparation card must not consume number keys during formation.' + ); + + await page.keyboard.press('Enter'); + await page.waitForFunction( + (selectedPriorityId) => { + const state = window.__HEROS_DEBUG__?.camp?.(); + return ( + state?.sortiePrepStep === 'loadout' && + state?.thirdCampPreparation?.browserOpen === false && + state.thirdCampPreparation.selectedPriorityId === + selectedPriorityId + ); + }, + selectedPriorityId + ); + + await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + scene.setSortiePrepStep('briefing'); + }); + await waitForPreparationToggle(page); + camp = await openPreparationBrowser(page); + assertPreparationSelection(camp, selectedPriorityId); + return camp; +} + +async function verifyRetryLockedPreparationCard(page) { + await seedThirdVictory(page, { + acknowledgeActivities: false, + selectPriorityId: null + }); + await page.evaluate(async (targetBattleId) => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const actionModule = await import( + '/heros_web/src/game/state/thirdCampPreparationActions.ts' + ); + const explorationActionModule = await import( + '/heros_web/src/game/state/thirdCampExplorationActions.ts' + ); + const { battleScenarios } = await import( + '/heros_web/src/game/data/battles.ts' + ); + const scenario = battleScenarios[targetBattleId]; + if (!scenario) { + throw new Error( + `Missing retry scenario ${targetBattleId}.` + ); + } + const entry = + explorationActionModule.enterThirdCampExploration(); + const activity = + explorationActionModule.recordThirdCampExplorationActivity( + 'information' + ); + const selection = + actionModule.setThirdCampPreparationPriority( + 'information' + ); + if (!entry.ok || !activity.ok || !selection.ok) { + throw new Error( + `Failed to seed a legacy preparation selection: ${JSON.stringify( + { + entry, + activity, + selection + } + )}` + ); + } + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'defeat', + turnNumber: 7, + rewardGold: 0, + defeatedEnemies: 0, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: scenario.objectives.map((objective) => ({ + id: objective.id, + label: objective.label, + achieved: false, + status: 'failed', + detail: '미달성', + rewardGold: 0 + })), + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: [], + campaignRewards: { + supplies: [], + equipment: [], + reputation: [], + recruits: [], + unlocks: [], + note: '' + }, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T01:00:00.000Z' + }); + campaignModule.completeCampaignAftermath(targetBattleId); + const legacyRetry = campaignModule.getCampaignState(); + legacyRetry.thirdCampPreparationSelection = { + ...legacyRetry.thirdCampPreparationSelection, + confirmed: false + }; + campaignModule.setCampaignState(legacyRetry); + const game = window.__HEROS_GAME__; + for (const scene of game?.scene.getScenes(true) ?? []) { + game.scene.stop(scene.scene.key); + } + game.scene.start('CampScene', { + retryBattleId: targetBattleId, + openSortiePrep: true, + skipIntroStory: true + }); + }, targetBattleId); + await page.waitForFunction( + (targetBattleId) => { + const state = window.__HEROS_DEBUG__?.camp?.(); + return ( + window.__HEROS_DEBUG__ + ?.activeScenes?.() + .includes('CampScene') && + state?.campaign?.step === 'fourth-battle' && + state?.sortieVisible === true && + state?.sortiePrepStep === 'briefing' && + state?.thirdCampPreparation?.available === true && + state.thirdCampPreparation.targetBattleId === + targetBattleId && + state.thirdCampPreparation.selectedPriorityId === + 'information' && + state.thirdCampPreparation.confirmed === false && + state.thirdCampPreparation.ready === false && + state.thirdCampPreparation.browserOpen === false + ); + }, + targetBattleId, + { timeout: 90000 } + ); + let camp = await openPreparationBrowser(page); + assert.equal( + camp.thirdCampPreparation.selectedPriorityId, + 'information' + ); + assert.equal(camp.thirdCampPreparation.confirmed, false); + assert.equal(camp.thirdCampPreparation.ready, false); + assert.equal( + preparationPriority(camp, 'information').selectable, + true + ); + const locked = preparationPriority(camp, 'equipment'); + assert.equal(locked.selectable, false); + assert.equal( + locked.unavailableReason, + 'equipment-change-required' + ); + const lockedLabels = await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + return ( + scene?.children.list + .filter( + (child) => + child?.active && + child?.visible && + child?.text === '초회 한정' + ) + .map((child) => child.text) ?? [] + ); + }); + assert.equal( + lockedLabels.length, + 2, + 'Every still-locked CTA in a legacy retry must read "초회 한정".' + ); + await clickSceneBounds( + page, + 'CampScene', + locked.actionButtonBounds + ); + await page.waitForTimeout(300); + const retryState = await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + return { + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + camp: window.__HEROS_DEBUG__?.camp?.(), + navigationPending: scene?.navigationPending ?? null + }; + }); + assert.equal(retryState.navigationPending, false); + assert.equal( + retryState.activeScenes.includes( + 'CampVisitExplorationScene' + ), + false, + 'A retry-only locked preparation CTA must not reopen exploration.' + ); + assert.equal( + retryState.camp?.thirdCampPreparation?.browserOpen, + true + ); + assert.equal( + retryState.camp?.thirdCampPreparation?.selectedPriorityId, + 'information' + ); + assert.equal( + retryState.camp?.thirdCampPreparation?.confirmed, + false + ); } async function waitForThirdCampExploration(page) { @@ -806,7 +1222,8 @@ async function verifyBattlePriority( assert.deepEqual(seeded.selection, { sourceBattleId, targetBattleId, - priorityId: priority.id + priorityId: priority.id, + confirmed: true }); assert.equal(seeded.memory?.selectionRecordId, selectionRecordId); assert.equal(seeded.memory?.priorityId, priority.id); @@ -1048,9 +1465,119 @@ async function verifyBattlePriority( ); } +async function verifyCompanionFormationMismatch(page, renderer) { + await seedThirdVictory(page, { + acknowledgeActivities: true, + selectPriorityId: 'companion', + selectedSortieUnitIds: ['liu-bei', 'guan-yu'] + }); + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToCamp(); + }); + await waitForCamp(page); + await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + scene?.showSortiePrep?.(); + }); + let camp = await waitForPreparationToggle(page); + assert.deepEqual( + camp.thirdCampPreparation.compatibility, + { + compatible: false, + requiredUnitIds: ['guan-yu', 'zhang-fei'], + missingUnitIds: ['zhang-fei'], + disabledReason: 'required-units-missing' + } + ); + const checklist = camp.sortieChecklist.find( + ({ label }) => label === '출진 우선 준비' + ); + assert.equal(checklist?.complete, false); + assert.match(checklist?.detail ?? '', /장비.*미편성.*미발동/); + await capture( + page, + `dist/verification-third-camp-preparation-${renderer}-companion-mismatch-camp.png` + ); + + await page.evaluate(async (battleId) => { + await window.__HEROS_DEBUG__.goToBattle(battleId); + }, targetBattleId); + await page.waitForFunction( + ({ targetBattleId, selectionRecordId }) => { + const state = window.__HEROS_DEBUG__?.battle?.(); + const preparation = state?.thirdCampPreparation; + return ( + state?.scene === 'BattleScene' && + state.battleId === targetBattleId && + state.phase === 'deployment' && + preparation?.selectionRecordId === selectionRecordId && + preparation.compatibility?.compatible === false + ); + }, + { targetBattleId, selectionRecordId }, + { timeout: 90000 } + ); + let battle = await readBattle(page); + assert.equal(battle.thirdCampPreparation.effectActive, false); + assert.equal( + battle.thirdCampPreparation.presentation.deployment.status, + 'incompatible' + ); + assert.equal( + battle.thirdCampPreparation.presentation.turn.status, + 'incompatible' + ); + assert.match( + battle.thirdCampPreparation.presentation.deployment.text, + /편성되지 않아.*미적용/ + ); + await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene.phase = 'idle'; + scene.renderTacticalInitiativeChip(); + }); + battle = await readBattle(page); + assert.equal(battle.thirdCampPreparation.chip.visible, true); + assert.match( + battle.thirdCampPreparation.chip.text, + /미편성.*미발동/ + ); + await capture( + page, + `dist/verification-third-camp-preparation-${renderer}-companion-mismatch-battle.png` + ); + + await page.evaluate(() => { + window.__HEROS_DEBUG__.forceBattleOutcome('victory'); + }); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.battle?.() + ?.thirdCampPreparation?.presentation?.result?.status === + 'incompatible', + undefined, + { timeout: 90000 } + ); + battle = await readBattle(page); + assert.match( + battle.thirdCampPreparation.resultFeedbackText, + /편성되지 않아.*미적용/ + ); +} + async function seedThirdVictory( page, - { acknowledgeActivities, selectPriorityId } + { + acknowledgeActivities, + selectPriorityId, + selectedSortieUnitIds = [ + 'liu-bei', + 'guan-yu', + 'zhang-fei' + ] + } ) { return page.evaluate( async ({ @@ -1059,7 +1586,8 @@ async function seedThirdVictory( priorityDialogueId, priorityDialogueBondId, acknowledgeActivities, - selectPriorityId + selectPriorityId, + selectedSortieUnitIds }) => { const game = window.__HEROS_GAME__; for (const scene of game?.scene.getScenes(true) ?? []) { @@ -1160,9 +1688,7 @@ async function seedThirdVictory( ]) ]; campaign.selectedSortieUnitIds = [ - 'liu-bei', - 'guan-yu', - 'zhang-fei' + ...selectedSortieUnitIds ]; campaignModule.setCampaignState(campaign); campaignModule.completeCampaignAftermath(sourceBattleId); @@ -1262,7 +1788,8 @@ async function seedThirdVictory( priorityDialogueId, priorityDialogueBondId, acknowledgeActivities, - selectPriorityId + selectPriorityId, + selectedSortieUnitIds } ); } @@ -1606,6 +2133,72 @@ async function waitForPreparationToggle(page) { return readCamp(page); } +async function openCampSaveSlotPanel(page) { + const before = await readCampSaveModalState(page); + assert.equal( + before.slotPanelOpen, + false, + 'The save-slot panel must begin closed.' + ); + await page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (typeof scene?.showCampSaveSlotPanel !== 'function') { + throw new Error( + 'CampScene.showCampSaveSlotPanel is unavailable.' + ); + } + scene.showCampSaveSlotPanel(); + }); + await page.waitForFunction( + () => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + return ( + scene?.saveSlotObjects?.length > 0 && + scene?.saveSlotConfirmObjects?.length === 0 + ); + } + ); + return readCampSaveModalState(page); +} + +async function waitForCampSaveModalClosed(page) { + await page.waitForFunction( + () => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + return ( + scene?.saveSlotObjects?.length === 0 && + scene?.saveSlotConfirmObjects?.length === 0 && + scene?.pendingSaveSlot === undefined + ); + } + ); + return readCampSaveModalState(page); +} + +async function readCampSaveModalState(page) { + return page.evaluate(() => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + const camp = window.__HEROS_DEBUG__?.camp?.(); + return { + slotPanelOpen: + (scene?.saveSlotObjects?.length ?? 0) > 0, + overwriteConfirmOpen: + (scene?.saveSlotConfirmObjects?.length ?? 0) > 0, + pendingSaveSlot: scene?.pendingSaveSlot ?? null, + selectedPriorityId: + camp?.thirdCampPreparation?.selectedPriorityId ?? + null, + browserOpen: + camp?.thirdCampPreparation?.browserOpen ?? false, + activeSaveSlot: camp?.campaign?.activeSaveSlot ?? null + }; + }); +} + async function openPreparationBrowser(page) { let lastState = await readCamp(page); for (let attempt = 0; attempt < 2; attempt += 1) { @@ -1806,6 +2399,7 @@ function assertPreparationSelection(camp, priorityId) { const preparation = camp.thirdCampPreparation; assert.equal(preparation.selectionRecordId, selectionRecordId); assert.equal(preparation.ready, true); + assert.equal(preparation.confirmed, true); assert.equal( preparation.selectedPriorityId, priorityId @@ -1840,7 +2434,8 @@ async function assertCampaignSelectionPersisted( { sourceBattleId, targetBattleId, - priorityId + priorityId, + confirmed: true } ); } else { diff --git a/scripts/verify-third-camp-preparation.mjs b/scripts/verify-third-camp-preparation.mjs index 4ab9180..c9b6687 100644 --- a/scripts/verify-third-camp-preparation.mjs +++ b/scripts/verify-third-camp-preparation.mjs @@ -61,6 +61,15 @@ try { campaignModule, battleScenarios ); + verifyActivityCompletionSelectionSeparation( + dataModule, + actionModule, + explorationDataModule, + explorationActionModule, + campaignModule, + battleScenarios + ); + verifyFormationCompatibility(dataModule); verifyOldSaveAndPollutionSafety( dataModule, actionModule, @@ -69,7 +78,7 @@ try { ); console.log( - 'Third-camp preparation verification passed (persistent activity gates, guarded overwrite, fourth-battle isolation, three-turn lifecycle, applied/unused evaluation, and old-save pollution safety).' + 'Third-camp preparation verification passed (activity/selection separation, explicit confirmation, formation compatibility, guarded overwrite, fourth-battle isolation, three-turn lifecycle, and old-save safety).' ); } finally { await server.close(); @@ -486,7 +495,8 @@ function verifyGuardedPersistentOverwrite( assert.deepEqual(result.campaign.thirdCampPreparationSelection, { sourceBattleId: dataModule.thirdCampPreparationSourceBattleId, targetBattleId: dataModule.thirdCampPreparationTargetBattleId, - priorityId: 'information' + priorityId: 'information', + confirmed: true }); assert.equal( result.campaign.completedCampVisits.filter( @@ -618,6 +628,244 @@ function verifyGuardedPersistentOverwrite( ); } +function verifyActivityCompletionSelectionSeparation( + dataModule, + actionModule, + explorationDataModule, + explorationActionModule, + campaignModule, + battleScenarios +) { + const permutations = [ + ['information', 'equipment', 'companion'], + ['information', 'companion', 'equipment'], + ['equipment', 'information', 'companion'], + ['equipment', 'companion', 'information'], + ['companion', 'information', 'equipment'], + ['companion', 'equipment', 'information'] + ]; + + permutations.forEach((activityOrder) => { + prepareThirdVictory(campaignModule, battleScenarios, dataModule); + assert.equal( + explorationActionModule.enterThirdCampExploration().ok, + true + ); + activityOrder.forEach((activityId) => { + const result = + activityId === 'companion' + ? explorationActionModule.completeThirdCampCompanionActivity( + explorationDataModule.resolveThirdCampExplorationDefinition( + campaignModule.getCampaignState() + ).companionDialogue.choices[0].id + ) + : explorationActionModule.recordThirdCampExplorationActivity( + activityId + ); + assert.equal(result.ok, true); + assert.equal( + campaignModule.getCampaignState() + .thirdCampPreparationSelection, + undefined, + `Completing ${activityId} in ${activityOrder.join(' > ')} must not select a sortie bonus.` + ); + }); + const progress = actionModule.thirdCampPreparationProgress(); + assert.equal(progress.selectedPriorityId, null); + assert.equal(progress.confirmed, false); + assert.equal(progress.ready, false); + assert( + progress.availability.every(({ selectable }) => selectable), + 'All three completed activities must become selectable candidates.' + ); + }); + + prepareThirdVictory(campaignModule, battleScenarios, dataModule); + explorationActionModule.enterThirdCampExploration(); + explorationActionModule.recordThirdCampExplorationActivity( + 'information' + ); + const selected = + actionModule.setThirdCampPreparationPriority('information'); + assert.equal(selected.ok, true); + assert.equal(selected.campaign.thirdCampPreparationSelection.confirmed, true); + explorationActionModule.recordThirdCampExplorationActivity( + 'equipment' + ); + explorationActionModule.recordThirdCampExplorationActivity( + 'equipment' + ); + assert.deepEqual( + campaignModule.getCampaignState().thirdCampPreparationSelection, + selected.campaign.thirdCampPreparationSelection, + 'New and repeated activities must preserve the explicitly confirmed bonus.' + ); + + const legacy = campaignModule.getCampaignState(); + legacy.thirdCampPreparationSelection = { + ...legacy.thirdCampPreparationSelection + }; + delete legacy.thirdCampPreparationSelection.confirmed; + const migrated = campaignModule.setCampaignState(legacy); + const migratedProgress = + actionModule.thirdCampPreparationProgress(migrated); + assert.equal(migratedProgress.selectedPriorityId, 'information'); + assert.equal(migratedProgress.confirmed, false); + assert.equal(migratedProgress.ready, false); + assert.equal( + dataModule.resolveThirdCampPreparationMemory({ + campaign: migrated, + battleId: dataModule.thirdCampPreparationTargetBattleId + }), + undefined, + 'A legacy automatic selection must not silently become an active battle effect.' + ); + assert.equal( + dataModule.resolveThirdCampPreparationBattleResumeMemory({ + campaign: migrated, + battleId: dataModule.thirdCampPreparationTargetBattleId, + savedPriorityId: 'information' + })?.priorityId, + 'information', + 'A legacy battle already saved with the effect active must preserve that effect when resumed.' + ); + assert.equal( + dataModule.resolveThirdCampPreparationBattleResumeMemory({ + campaign: migrated, + battleId: dataModule.thirdCampPreparationTargetBattleId, + savedPriorityId: 'equipment' + }), + undefined, + 'A legacy battle save must not activate a different preparation than the campaign selected.' + ); + const reconfirmed = + actionModule.setThirdCampPreparationPriority('information'); + assert.equal(reconfirmed.ok, true); + assert.equal(reconfirmed.changed, true); + assert.equal(reconfirmed.campaign.thirdCampPreparationSelection.confirmed, true); + const forgedRetry = campaignModule.getCampaignState(); + forgedRetry.step = 'fourth-battle'; + forgedRetry.latestBattleId = + dataModule.thirdCampPreparationTargetBattleId; + campaignModule.setCampaignState(forgedRetry); + assert.deepEqual( + actionModule.setThirdCampPreparationPriority('equipment'), + { ok: false, reason: 'invalid-campaign' }, + 'A forged target latestBattleId without a target defeat record must not unlock retry preparation writes.' + ); + campaignModule.setCampaignState(reconfirmed.campaign); + + recordTargetDefeat( + campaignModule, + battleScenarios, + dataModule + ); + assert.deepEqual( + campaignModule.getCampaignState() + .thirdCampPreparationSelection, + { + sourceBattleId: + dataModule.thirdCampPreparationSourceBattleId, + targetBattleId: + dataModule.thirdCampPreparationTargetBattleId, + priorityId: 'information', + confirmed: true + }, + 'A target-battle defeat must preserve the confirmed preparation for an immediate retry.' + ); + const legacyRetry = campaignModule.getCampaignState(); + legacyRetry.thirdCampPreparationSelection.confirmed = false; + const migratedLegacyRetry = + campaignModule.setCampaignState(legacyRetry); + assert.equal( + dataModule.shouldReviewThirdCampPreparationBeforeRetry( + migratedLegacyRetry + ), + true, + 'A legacy unconfirmed retry must return through the sortie ledger before entering battle.' + ); + const retryReplacement = + actionModule.setThirdCampPreparationPriority('equipment'); + assert.equal(retryReplacement.ok, true); + assert.equal(retryReplacement.changed, true); + assert.equal( + retryReplacement.campaign.thirdCampPreparationSelection + .priorityId, + 'equipment', + 'A defeated fourth-battle retry must keep the preparation browser editable.' + ); + assert.equal( + dataModule.shouldReviewThirdCampPreparationBeforeRetry( + retryReplacement.campaign + ), + false, + 'Explicit retry confirmation must clear the legacy review redirect.' + ); +} + +function verifyFormationCompatibility(dataModule) { + const companion = dataModule.resolveThirdCampPreparationMemory({ + campaign: eligibleLiteralCampaign(dataModule, 'companion'), + battleId: dataModule.thirdCampPreparationTargetBattleId + }); + assert(companion); + const compatible = + dataModule.evaluateThirdCampPreparationCompatibility( + companion, + ['liu-bei', 'guan-yu', 'zhang-fei'] + ); + assert.equal(compatible.compatible, true); + assert.deepEqual(compatible.requiredUnitIds, [ + 'guan-yu', + 'zhang-fei' + ]); + assert.deepEqual(compatible.missingUnitIds, []); + + const incompatible = + dataModule.evaluateThirdCampPreparationCompatibility( + companion, + ['liu-bei', 'guan-yu'] + ); + assert.equal(incompatible.compatible, false); + assert.deepEqual(incompatible.missingUnitIds, ['zhang-fei']); + assert.equal(incompatible.disabledReason, 'required-units-missing'); + const result = + dataModule.resolveThirdCampPreparationBattlePresentation({ + memory: companion, + stage: 'result', + usageCount: 0, + compatibility: incompatible + }); + assert.equal(result?.status, 'incompatible'); + assert.match(result?.text ?? '', /편성되지 않아.*미적용/); + const expiredMismatch = + dataModule.resolveThirdCampPreparationBattlePresentation({ + memory: companion, + stage: 'turn', + turnNumber: 4, + usageCount: 0, + compatibility: incompatible + }); + assert.equal( + expiredMismatch?.status, + 'expired', + 'An incompatible preparation must still leave the HUD after its three-turn lifecycle ends.' + ); + + const information = dataModule.resolveThirdCampPreparationMemory({ + campaign: eligibleLiteralCampaign(dataModule, 'information'), + battleId: dataModule.thirdCampPreparationTargetBattleId + }); + assert.equal( + dataModule.evaluateThirdCampPreparationCompatibility( + information, + ['liu-bei'] + ).compatible, + true, + 'Information and equipment preparations must remain formation independent.' + ); +} + function verifyOldSaveAndPollutionSafety( dataModule, actionModule, @@ -744,7 +992,8 @@ function verifyOldSaveAndPollutionSafety( assert.deepEqual(state.thirdCampPreparationSelection, { sourceBattleId: dataModule.thirdCampPreparationSourceBattleId, targetBattleId: dataModule.thirdCampPreparationTargetBattleId, - priorityId: 'information' + priorityId: 'information', + confirmed: false }); validCandidate.thirdCampPreparationSelection.priorityId = 'equipment'; @@ -818,6 +1067,29 @@ function verifyOldSaveAndPollutionSafety( }), undefined ); + + const staleAfterTargetVictory = literalCampaign(dataModule, { + completedActivities: ['information'], + selectedPriorityId: 'information' + }); + staleAfterTargetVictory.battleHistory[ + dataModule.thirdCampPreparationTargetBattleId + ] = { + ...structuredClone( + staleAfterTargetVictory.battleHistory[ + dataModule.thirdCampPreparationSourceBattleId + ] + ), + battleId: dataModule.thirdCampPreparationTargetBattleId, + outcome: 'victory', + completedAt: new Date().toISOString() + }; + assert.equal( + campaignModule.setCampaignState(staleAfterTargetVictory) + .thirdCampPreparationSelection, + undefined, + 'A legacy save that already won the target battle must discard its stale preparation selection.' + ); } function prepareThirdVictory( @@ -883,6 +1155,63 @@ function prepareThirdVictory( return campaign; } +function recordTargetDefeat( + campaignModule, + battleScenarios, + dataModule +) { + const scenario = + battleScenarios[dataModule.thirdCampPreparationTargetBattleId]; + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'defeat', + turnNumber: 7, + rewardGold: 0, + defeatedEnemies: 0, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives: scenario.objectives.map((objective) => ({ + id: objective.id, + label: objective.label, + achieved: false, + status: 'failed', + detail: '미달성', + rewardGold: 0 + })), + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: [], + campaignRewards: { + supplies: [], + equipment: [], + reputation: [], + recruits: [], + unlocks: [], + note: '' + }, + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T01:00:00.000Z' + }); + const campaign = campaignModule.getCampaignState(); + assert.equal(campaign.step, 'fourth-battle'); + assert.equal( + campaign.latestBattleId, + dataModule.thirdCampPreparationTargetBattleId + ); + assert.equal( + campaign.thirdCampPreparationSelection?.confirmed, + true, + 'A defeat must retain the confirmed preparation for retry.' + ); + return campaign; +} + function completePriorityDialogue( dataModule, campaignModule, @@ -954,6 +1283,7 @@ function literalCampaign( completedActivities = [], completePriorityDialogue = false, selectedPriorityId, + selectionConfirmed = true, legacyReportOnly = false } = {} ) { @@ -992,7 +1322,8 @@ function literalCampaign( dataModule.thirdCampPreparationSourceBattleId, targetBattleId: dataModule.thirdCampPreparationTargetBattleId, - priorityId: selectedPriorityId + priorityId: selectedPriorityId, + confirmed: selectionConfirmed } } : {}), diff --git a/src/game/data/thirdCampExploration.ts b/src/game/data/thirdCampExploration.ts index 163a3ba..3fc68fa 100644 --- a/src/game/data/thirdCampExploration.ts +++ b/src/game/data/thirdCampExploration.ts @@ -333,7 +333,7 @@ export function resolveThirdCampExplorationDefinition( subtitle: '노식군의 큰 포위망 안에서 의용군은 동쪽 삼림 선봉을 맡습니다. 필요한 준비만 확인하고 언제든 출진할 수 있습니다.', description: - '작전판에서 적 선봉의 움직임을 읽고, 보급 수레에서 방호구를 손보거나, 먼저 돌아온 동료와 지난 전투를 되짚을 수 있습니다. 세 활동은 모두 선택 사항이며 어느 순서로 해도 됩니다. 활동을 마칠 때마다 그 준비가 자동으로 선택되고, 마지막으로 확인한 준비 하나만 다음 전투의 초반 효과가 됩니다.', + '작전판에서 적 선봉의 움직임을 읽고, 보급 수레에서 방호구를 손보거나, 먼저 돌아온 동료와 지난 전투를 되짚을 수 있습니다. 세 활동은 모두 선택 사항이며 어느 순서로 해도 됩니다. 활동을 마치면 출진 보너스 후보가 열리며, 실제로 적용할 하나는 군영 장부에서 따로 확정합니다.', background: { textureKey: 'third-guangzong-sortie-camp-background', source: 'original-guangzong-forward-camp' diff --git a/src/game/data/thirdCampPreparation.ts b/src/game/data/thirdCampPreparation.ts index d333b4a..0758481 100644 --- a/src/game/data/thirdCampPreparation.ts +++ b/src/game/data/thirdCampPreparation.ts @@ -96,18 +96,18 @@ export const thirdCampPreparationPriorities: readonly }, { id: 'equipment', - label: '장비 정비', + label: '선봉 방호', activityLabel: '승전 장비 확인', summary: - '3차 승전 장비를 확인하면 첫 3턴 아군의 첫 피격 피해를 한 번 8% 줄입니다.', + '수선한 방호구로 첫 3턴 아군의 첫 피격 피해를 한 번 8% 줄입니다.', deploymentLine: - '장비 정비 · 아군 첫 피격 1회 방호 준비', + '선봉 방호 · 아군 첫 피격 1회 방호 준비', activeLine: - '장비 정비 · 첫 3턴 동안 아군 첫 피격 1회 피해 -8%', + '선봉 방호 · 첫 3턴 동안 아군 첫 피격 1회 피해 -8%', appliedLine: - '장비 정비 · 아군 첫 피격 방호 적용', + '선봉 방호 · 아군 첫 피격 방호 적용', unusedLine: - '장비 정비 · 방호가 발동하기 전에 준비 시간이 끝남', + '선봉 방호 · 방호가 발동하기 전에 준비 시간이 끝남', effect: { kind: 'prepared-equipment-guard', activeThroughTurn: thirdCampPreparationActiveThroughTurn, @@ -216,13 +216,21 @@ export type ThirdCampPreparationBattlePresentation = { | 'active' | 'expired' | 'applied' - | 'unused'; + | 'unused' + | 'incompatible'; text: string; activeThroughTurn: typeof thirdCampPreparationActiveThroughTurn; turnNumber: number | null; usageCount: number; }; +export type ThirdCampPreparationCompatibility = { + compatible: boolean; + requiredUnitIds: string[]; + missingUnitIds: string[]; + disabledReason: 'required-units-missing' | null; +}; + export type ThirdCampPreparationCampaignState = { step?: unknown; latestBattleId?: unknown; @@ -236,6 +244,7 @@ export type ThirdCampPreparationCampaignState = { sourceBattleId?: unknown; targetBattleId?: unknown; priorityId?: unknown; + confirmed?: unknown; } | null; acknowledgedVictoryRewardCategories?: Readonly< Record @@ -381,6 +390,7 @@ export function resolveThirdCampPreparationSelection( priorityId, label: availability.priority.label, selectable: availability.selectable, + confirmed: selection.confirmed === true, ...(availability.evidence ? { evidence: cloneEvidence(availability.evidence) } : {}), @@ -391,17 +401,72 @@ export function resolveThirdCampPreparationSelection( : undefined; } +export function shouldReviewThirdCampPreparationBeforeRetry( + campaign?: ThirdCampPreparationCampaignState | null +) { + const selection = + resolveThirdCampPreparationSelection(campaign); + if ( + campaign?.step !== 'fourth-battle' || + !selection?.selectable || + selection.confirmed + ) { + return false; + } + const targetSettlement = + campaign.battleHistory?.[ + thirdCampPreparationTargetBattleId + ]; + return ( + targetSettlement?.outcome === 'defeat' || + (campaign.firstBattleReport?.battleId === + thirdCampPreparationTargetBattleId && + campaign.firstBattleReport.outcome === 'defeat') + ); +} + export function resolveThirdCampPreparationMemory(options: { campaign?: ThirdCampPreparationCampaignState | null; battleId?: string; }): ThirdCampPreparationMemory | undefined { + return resolveThirdCampPreparationMemoryCandidate(options); +} + +export function resolveThirdCampPreparationBattleResumeMemory( + options: { + campaign?: ThirdCampPreparationCampaignState | null; + battleId?: string; + savedPriorityId?: unknown; + } +): ThirdCampPreparationMemory | undefined { + if (!isThirdCampPreparationPriorityId(options.savedPriorityId)) { + return undefined; + } + return resolveThirdCampPreparationMemoryCandidate( + options, + options.savedPriorityId + ); +} + +function resolveThirdCampPreparationMemoryCandidate( + options: { + campaign?: ThirdCampPreparationCampaignState | null; + battleId?: string; + }, + resumedPriorityId?: ThirdCampPreparationPriorityId +): ThirdCampPreparationMemory | undefined { if (options.battleId !== thirdCampPreparationTargetBattleId) { return undefined; } const selection = resolveThirdCampPreparationSelection( options.campaign ); - if (!selection?.selectable || !selection.evidence) { + if ( + !selection?.selectable || + !selection.evidence || + (!selection.confirmed && + resumedPriorityId !== selection.priorityId) + ) { return undefined; } const priority = getThirdCampPreparationPriority( @@ -436,6 +501,7 @@ export function resolveThirdCampPreparationBattlePresentation(options: { stage: 'deployment' | 'turn' | 'result'; turnNumber?: number; usageCount?: number; + compatibility?: ThirdCampPreparationCompatibility; }): ThirdCampPreparationBattlePresentation | undefined { const priority = getThirdCampPreparationPriority( options.memory.priorityId @@ -451,6 +517,15 @@ export function resolveThirdCampPreparationBattlePresentation(options: { const usageCount = nonNegativeInteger(options.usageCount); if (options.stage === 'deployment') { + if (options.compatibility?.compatible === false) { + return presentationBase( + options.memory, + 'incompatible', + `${options.memory.label} · 필요한 공명조가 편성되지 않아 이번 전투에는 미적용`, + null, + usageCount + ); + } return presentationBase( options.memory, 'deployment', @@ -464,19 +539,42 @@ export function resolveThirdCampPreparationBattlePresentation(options: { if (!turnNumber) { return undefined; } - const active = - turnNumber <= thirdCampPreparationActiveThroughTurn; + if (turnNumber > thirdCampPreparationActiveThroughTurn) { + return presentationBase( + options.memory, + 'expired', + `${options.memory.label} · 준비 효과 종료`, + turnNumber, + usageCount + ); + } + if (options.compatibility?.compatible === false) { + return presentationBase( + options.memory, + 'incompatible', + `${options.memory.label} · 필요한 공명조가 편성되지 않아 이번 전투에는 미적용`, + turnNumber, + usageCount + ); + } return presentationBase( options.memory, - active ? 'active' : 'expired', - active - ? `${options.memory.activeLine} · ${turnNumber}/${thirdCampPreparationActiveThroughTurn}턴` - : `${options.memory.label} · 준비 효과 종료`, + 'active', + `${options.memory.activeLine} · ${turnNumber}/${thirdCampPreparationActiveThroughTurn}턴`, turnNumber, usageCount ); } + if (options.compatibility?.compatible === false) { + return presentationBase( + options.memory, + 'incompatible', + `${options.memory.label} · 필요한 공명조가 편성되지 않아 이번 전투에는 미적용`, + null, + usageCount + ); + } const applied = usageCount > 0; return presentationBase( options.memory, @@ -489,6 +587,27 @@ export function resolveThirdCampPreparationBattlePresentation(options: { ); } +export function evaluateThirdCampPreparationCompatibility( + memory: ThirdCampPreparationMemory | undefined, + selectedUnitIds: readonly string[] +): ThirdCampPreparationCompatibility { + const requiredUnitIds = + memory?.effect.kind === 'return-bond-opening' + ? [...memory.effect.unitIds] + : []; + const selected = new Set(selectedUnitIds); + const missingUnitIds = requiredUnitIds.filter( + (unitId) => !selected.has(unitId) + ); + return { + compatible: missingUnitIds.length === 0, + requiredUnitIds, + missingUnitIds, + disabledReason: + missingUnitIds.length > 0 ? 'required-units-missing' : null + }; +} + function selectThirdBattleVictoryRecord( campaign?: ThirdCampPreparationCampaignState | null ): ThirdBattleRecordSelection | undefined { diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index fd0740b..1e61364 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -154,13 +154,16 @@ import { type SecondBattleReliefMemory } from '../data/secondBattleReliefMemory'; import { + evaluateThirdCampPreparationCompatibility, resolveThirdCampPreparationBattlePresentation, + resolveThirdCampPreparationBattleResumeMemory, resolveThirdCampPreparationMemory, thirdCampPreparationActiveThroughTurn, thirdCampPreparationSelectionRecordId, thirdCampPreparationSourceBattleId, thirdCampPreparationTargetBattleId, type ThirdCampPreparationBattlePresentation, + type ThirdCampPreparationCompatibility, type ThirdCampPreparationMemory } from '../data/thirdCampPreparation'; import { @@ -6551,8 +6554,11 @@ export class BattleScene extends Phaser.Scene { const applied = this.thirdCampPreparationRuntime.usageCount > 0; const active = presentation.status === 'active'; + const incompatible = presentation.status === 'incompatible'; const tone = applied ? palette.green + : incompatible + ? palette.red : active ? palette.gold : 0x647485; @@ -6581,6 +6587,8 @@ export class BattleScene extends Phaser.Scene { fontSize: this.battleUiFontSize(11), color: applied ? '#a8ffd0' + : incompatible + ? '#ffb6a6' : active ? '#fff2b8' : '#9fb0bf', @@ -6789,6 +6797,9 @@ export class BattleScene extends Phaser.Scene { if (!presentation) { return undefined; } + if (presentation.status === 'incompatible') { + return `준비 ${presentation.label} · 미편성 · 미발동`; + } const applied = this.thirdCampPreparationRuntime.usageCount > 0; const active = presentation.status === 'active'; @@ -9329,6 +9340,11 @@ export class BattleScene extends Phaser.Scene { : `${this.cityEquipmentPreparationDisplayLine()} 준비 무장을 출전시키지 않아 이번 전투에는 반영되지 않습니다.`; } if (this.thirdCampPreparationMemory) { + const compatibility = + this.thirdCampPreparationSortieCompatibility(); + if (!compatibility.compatible) { + return `${this.thirdCampPreparationMemory.label}은 필요한 공명조가 편성되지 않아 이번 전투에 반영되지 않습니다.`; + } return `${this.thirdCampPreparationMemory.deploymentLine}. 효과는 3턴이 끝나면 종료됩니다.`; } if (this.secondBattleReliefMemory) { @@ -9404,7 +9420,12 @@ export class BattleScene extends Phaser.Scene { const order = sortieOrderDefinition(this.launchSortieOrderId); return `선택 군령 · ${order.label} · ${order.summary}`; })(); - return `${this.thirdCampPreparationMemory.deploymentLine}\n${strategySummary}`; + const compatibility = + this.thirdCampPreparationSortieCompatibility(); + const preparationLine = compatibility.compatible + ? this.thirdCampPreparationMemory.deploymentLine + : `${this.thirdCampPreparationMemory.label} · 공명조 미편성으로 이번 전투 미발동`; + return `${preparationLine}\n${strategySummary}`; } if (this.launchSortieRecommendation) { return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`; @@ -19668,9 +19689,18 @@ export class BattleScene extends Phaser.Scene { }; } + private thirdCampPreparationSortieCompatibility(): + ThirdCampPreparationCompatibility { + return evaluateThirdCampPreparationCompatibility( + this.thirdCampPreparationMemory, + this.launchSortieUnitIds + ); + } + private thirdCampPreparationActive() { return Boolean( this.thirdCampPreparationMemory && + this.thirdCampPreparationSortieCompatibility().compatible && !this.battleOutcome && this.turnNumber <= thirdCampPreparationActiveThroughTurn ); @@ -19720,7 +19750,7 @@ export class BattleScene extends Phaser.Scene { damageReduction: memory.effect.incomingDamageReductionPercent, labels: [ - `장비 정비 · 첫 피격 -${memory.effect.incomingDamageReductionPercent}%` + `선봉 방호 · 첫 피격 -${memory.effect.incomingDamageReductionPercent}%` ] }; } @@ -19818,7 +19848,9 @@ export class BattleScene extends Phaser.Scene { memory, stage, ...(stage === 'turn' ? { turnNumber: this.turnNumber } : {}), - usageCount: this.thirdCampPreparationRuntime.usageCount + usageCount: this.thirdCampPreparationRuntime.usageCount, + compatibility: + this.thirdCampPreparationSortieCompatibility() }); } @@ -19900,7 +19932,10 @@ export class BattleScene extends Phaser.Scene { ...(this.thirdCampPreparationMemory ? [ `군영 선택 · ${this.thirdCampPreparationMemory.label}`, - this.thirdCampPreparationMemory.activeLine + this.thirdCampPreparationSortieCompatibility() + .compatible + ? this.thirdCampPreparationMemory.activeLine + : `${this.thirdCampPreparationMemory.label} · 필요한 공명조 미편성으로 이번 전투 미발동` ] : []), ...cityInformationLines, @@ -21513,11 +21548,18 @@ export class BattleScene extends Phaser.Scene { campaign: getCampaignState(), battleId: battleScenario.id }); - this.thirdCampPreparationMemory = resolveThirdCampPreparationMemory({ - campaign: getCampaignState(), - battleId: battleScenario.id - }); const campaign = getCampaignState(); + this.thirdCampPreparationMemory = + resolveThirdCampPreparationMemory({ + campaign, + battleId: battleScenario.id + }) ?? + resolveThirdCampPreparationBattleResumeMemory({ + campaign, + battleId: battleScenario.id, + savedPriorityId: + state.thirdCampPreparation?.priorityId + }); this.xuzhouStayBattlePayoff = resolveXuzhouStayBattlePayoff( campaign, @@ -31785,6 +31827,7 @@ export class BattleScene extends Phaser.Scene { activeThroughTurn: thirdCampPreparationActiveThroughTurn, evidence: null, + compatibility: null, trackedEnemyUnitId: null, bondId: null, unitIds: [], @@ -31837,6 +31880,8 @@ export class BattleScene extends Phaser.Scene { this.thirdCampPreparationPresentation('result'); const saveSnapshot = this.thirdCampPreparationSaveState(); + const compatibility = + this.thirdCampPreparationSortieCompatibility(); const mechanicsProbe = (() => { if (memory.effect.kind === 'marked-vanguard-intel') { const effect = memory.effect; @@ -31964,6 +32009,12 @@ export class BattleScene extends Phaser.Scene { unitIds: [...memory.evidence.unitIds] } : { ...memory.evidence }, + compatibility: { + compatible: compatibility.compatible, + requiredUnitIds: [...compatibility.requiredUnitIds], + missingUnitIds: [...compatibility.missingUnitIds], + disabledReason: compatibility.disabledReason + }, trackedEnemyUnitId, bondId, unitIds, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 710391e..6be4741 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -55,8 +55,11 @@ import { thirdCampExplorationVisitId } from '../data/thirdCampExploration'; import { + evaluateThirdCampPreparationCompatibility, + resolveThirdCampPreparationMemory, thirdCampPreparationSourceBattleId, thirdCampPreparationTargetBattleId, + type ThirdCampPreparationEvidence, type ThirdCampPreparationPriorityId, type ThirdCampPreparationUnavailableReason } from '../data/thirdCampPreparation'; @@ -11501,6 +11504,8 @@ export class CampScene extends Phaser.Scene { private thirdCampPreparationCloseButton?: Phaser.GameObjects.Rectangle; private thirdCampPreparationClearButton?: Phaser.GameObjects.Rectangle; private thirdCampPreparationCardViews: ThirdCampPreparationCardView[] = []; + private thirdCampPreparationKeyboardIndex = 0; + private thirdCampIncompatibleSortieConfirmSignature?: string; private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; private sortieFormationAssignments: SortieFormationAssignments = {}; @@ -11554,6 +11559,8 @@ export class CampScene extends Phaser.Scene { init(data?: CampSceneData) { this.ownedCampTextureKeys.clear(); this.thirdCampPreparationBrowserOpen = false; + this.thirdCampPreparationKeyboardIndex = 0; + this.thirdCampIncompatibleSortieConfirmSignature = undefined; if (data?.completedAftermathBattleId) { completeCampaignAftermath(data.completedAftermathBattleId); } @@ -11740,7 +11747,11 @@ export class CampScene extends Phaser.Scene { }); this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey()); this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event)); - this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event)); + this.input.keyboard?.on('keydown', (event: KeyboardEvent) => { + if (!this.handleThirdCampPreparationKey(event)) { + this.handleSaveSlotKey(event); + } + }); loadBattleUiIcons(this, () => this.ensureCampAssets(() => this.drawCampScene())); } @@ -14147,6 +14158,98 @@ export class CampScene extends Phaser.Scene { this.startVictoryStory(); } + private handleThirdCampPreparationKey(event: KeyboardEvent) { + if ( + !this.thirdCampPreparationBrowserOpen || + this.sortiePrepStep !== 'briefing' || + this.sortieObjects.length === 0 || + this.navigationPending || + this.victoryRewardObjects.length > 0 || + this.equipmentSwapConfirmObjects.length > 0 || + this.saveSlotObjects.length > 0 || + this.saveSlotConfirmObjects.length > 0 || + this.reportFormationReviewVisible || + this.reportFormationHistoryVisible + ) { + return false; + } + if (event.repeat) { + return true; + } + + const progress = this.campaign + ? thirdCampPreparationProgress(this.campaign) + : undefined; + if (!progress) { + return false; + } + const priorities = progress.availability; + const moveFocus = (offset: number) => { + this.thirdCampPreparationKeyboardIndex = + (this.thirdCampPreparationKeyboardIndex + + offset + + priorities.length) % + priorities.length; + soundDirector.playSelect(); + this.showSortiePrep(); + }; + const chooseFocused = () => { + const entry = + priorities[this.thirdCampPreparationKeyboardIndex]; + if (!entry) { + return; + } + if (!entry.selectable) { + this.openThirdCampPreparationActivity( + entry.priority.id + ); + return; + } + this.selectThirdCampPreparationPriority(entry.priority.id); + }; + + if ( + event.key === 'ArrowDown' || + event.key === 'ArrowRight' || + (event.key === 'Tab' && !event.shiftKey) + ) { + event.preventDefault(); + moveFocus(1); + return true; + } + if ( + event.key === 'ArrowUp' || + event.key === 'ArrowLeft' || + (event.key === 'Tab' && event.shiftKey) + ) { + event.preventDefault(); + moveFocus(-1); + return true; + } + if ( + event.key === 'Enter' || + event.key === ' ' || + event.code === 'Space' + ) { + event.preventDefault(); + chooseFocused(); + return true; + } + if (/^[1-3]$/.test(event.key)) { + event.preventDefault(); + this.thirdCampPreparationKeyboardIndex = + Number(event.key) - 1; + chooseFocused(); + return true; + } + if (event.key === '0') { + event.preventDefault(); + this.clearThirdCampPreparationSelection(); + return true; + } + return false; + } + private addTabButton(label: string, tab: CampTab, x: number, y: number, width = 76, fontSize = 16) { const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, width, 34, 0x182431, 0.94)); bg.setStrokeStyle(1, palette.blue, 0.62); @@ -14471,8 +14574,10 @@ export class CampScene extends Phaser.Scene { private isThirdCampPreparationSlice(flow = this.currentSortieFlow()) { return ( - flow.afterBattleId === thirdCampPreparationSourceBattleId && - flow.nextBattleId === thirdCampPreparationTargetBattleId + flow.nextBattleId === thirdCampPreparationTargetBattleId && + (flow.afterBattleId === thirdCampPreparationSourceBattleId || + this.retrySortieBattleId === + thirdCampPreparationTargetBattleId) ); } @@ -14481,6 +14586,9 @@ export class CampScene extends Phaser.Scene { } private setSortiePrepStep(step: SortiePrepStep) { + if (step !== 'briefing') { + this.thirdCampPreparationBrowserOpen = false; + } if (this.sortiePrepStep === step) { return; } @@ -14651,7 +14759,9 @@ export class CampScene extends Phaser.Scene { const commandTitle = thirdCampPreparation ? thirdCampPreparation.ready ? `출진 우선 · ${thirdCampPreparation.selectedLabel}` - : '출진 우선 · 보너스 없음' + : thirdCampPreparation.selectedLabel + ? `출진 우선 · ${thirdCampPreparation.selectedLabel} 확인 필요` + : '출진 우선 · 보너스 없음' : firstBattleFollowup ? `지난 전투 인계 · ${firstBattleFollowup.mentorName}` : '지휘관 판단'; @@ -14659,9 +14769,22 @@ export class CampScene extends Phaser.Scene { const selectedPreparation = thirdCampPreparation?.availability.find( (entry) => entry.priority.id === thirdCampPreparation.selectedPriorityId ); + const preparationCompatibility = + this.thirdCampPreparationSortieState(); + const preparationCompatibilityWarning = + preparationCompatibility?.compatibility.compatible === false + ? `발동 불가 · ${preparationCompatibility.compatibility.missingUnitIds + .map((unitId) => this.unitName(unitId)) + .join('·')} 편성 필요.` + : ''; const commandSummary = thirdCampPreparation - ? selectedPreparation?.priority.summary ?? - '야영지에서 완료한 활동은 보너스 하나로 선택할 수 있습니다. 선택하지 않고 출진해도 됩니다.' + ? [ + selectedPreparation?.priority.summary ?? + '야영지에서 완료한 활동은 보너스 하나로 선택할 수 있습니다. 선택하지 않고 출진해도 됩니다.', + preparationCompatibilityWarning + ] + .filter(Boolean) + .join(' ') : firstBattleFollowup ? firstBattleFollowup.achieved ? `${firstBattleFollowup.originalOrderLabel} 완수 → ${firstBattleFollowup.recommendedNextOrderLabel} · ${firstBattleFollowup.focusCriterionLabel}` @@ -14684,7 +14807,11 @@ export class CampScene extends Phaser.Scene { this.renderThirdCampPreparationToggle( x + width - 86, commandY + 51, - thirdCampPreparation.ready ? '준비 변경' : '보너스 선택', + thirdCampPreparation.ready + ? '준비 변경' + : thirdCampPreparation.selectedPriorityId + ? '선택 확인' + : '보너스 선택', depth + 2 ); } else if (firstBattleFollowup) { @@ -14733,6 +14860,15 @@ export class CampScene extends Phaser.Scene { button.setStrokeStyle(2, palette.gold, 0.92); button.setInteractive({ useHandCursor: true }); const open = () => { + const progress = this.campaign + ? thirdCampPreparationProgress(this.campaign) + : undefined; + const selectedIndex = progress?.availability.findIndex( + (entry) => + entry.priority.id === progress.selectedPriorityId + ) ?? -1; + this.thirdCampPreparationKeyboardIndex = + selectedIndex >= 0 ? selectedIndex : 0; this.thirdCampPreparationBrowserOpen = true; soundDirector.playSelect(); this.showSortiePrep(); @@ -14777,8 +14913,8 @@ export class CampScene extends Phaser.Scene { this.add.text( x + 18, y + 41, - '야영지에서 완료한 활동 중 하나만 첫 3턴에 반영합니다. 미선택 출진도 가능합니다.', - this.textStyle(10, '#9fb0bf') + '완료 후보 1개 확정 · ↑↓/Tab 이동 · Enter/Space 확정 · 1~3 바로 선택 · 0 해제', + this.textStyle(9, '#9fb0bf') ) ).setDepth(depth + 1); @@ -14830,20 +14966,35 @@ export class CampScene extends Phaser.Scene { progress.availability.forEach((entry, index) => { const priorityId = entry.priority.id; const selected = progress.selectedPriorityId === priorityId; + const confirmed = selected && progress.confirmed; + const focused = + this.thirdCampPreparationKeyboardIndex === index; + const requirementLine = + this.thirdCampPreparationRequirementLine(entry.evidence); const cardY = y + 68 + index * 106; const cardHeight = 98; - const fill = selected + const fill = confirmed ? 0x183126 + : selected + ? 0x352a17 : entry.selectable ? 0x17232e : 0x111820; - const tone = selected ? palette.green : toneByPriority[priorityId]; + const tone = confirmed + ? palette.green + : selected + ? palette.gold + : toneByPriority[priorityId]; const card = this.trackSortie( this.add.rectangle(x + 16, cardY, width - 32, cardHeight, fill, entry.selectable ? 0.98 : 0.78) ); card.setOrigin(0); card.setDepth(depth + 1); - card.setStrokeStyle(selected ? 2 : 1, tone, selected ? 0.96 : entry.selectable ? 0.68 : 0.28); + card.setStrokeStyle( + focused ? 3 : selected ? 2 : 1, + focused ? 0xf4e3b5 : tone, + focused ? 1 : selected ? 0.96 : entry.selectable ? 0.68 : 0.28 + ); const seal = this.trackSortie( this.add.circle(x + 43, cardY + 31, 17, tone, entry.selectable ? 0.9 : 0.38) @@ -14857,7 +15008,7 @@ export class CampScene extends Phaser.Scene { this.add.text( x + 68, cardY + 10, - `${entry.priority.label} · ${entry.priority.activityLabel}`, + `${focused ? '▶ ' : ''}${entry.priority.label} · ${entry.priority.activityLabel}`, this.textStyle(13, entry.selectable ? '#ffdf7b' : '#77818c', true) ) ).setDepth(depth + 2); @@ -14866,7 +15017,7 @@ export class CampScene extends Phaser.Scene { x + 68, cardY + 33, entry.selectable - ? entry.priority.summary + ? `${requirementLine} · ${entry.priority.summary}` : this.thirdCampPreparationUnavailableLabel(entry.unavailableReason), { ...this.textStyle(10, entry.selectable ? '#d4dce6' : '#7f8994'), @@ -14885,7 +15036,11 @@ export class CampScene extends Phaser.Scene { actionY, 82, 32, - selected ? 0x28533c : entry.selectable ? 0x4a371d : 0x1a2630, + confirmed + ? 0x28533c + : entry.selectable + ? 0x4a371d + : 0x1a2630, entry.selectable ? 0.98 : 0.8 ) ); @@ -14896,10 +15051,12 @@ export class CampScene extends Phaser.Scene { this.add.text( actionX, actionY, - selected - ? '선택됨' + confirmed + ? '확정됨' + : selected + ? '확정' : entry.selectable - ? '선택' + ? '선택·확정' : this.thirdCampPreparationLockedActionLabel(priorityId), this.textStyle(10, entry.selectable ? '#fff2b8' : '#9fb0bf', true) ) @@ -14924,7 +15081,11 @@ export class CampScene extends Phaser.Scene { ); action.on('pointerout', () => action.setFillStyle( - selected ? 0x28533c : entry.selectable ? 0x4a371d : 0x1a2630, + confirmed + ? 0x28533c + : entry.selectable + ? 0x4a371d + : 0x1a2630, entry.selectable ? 0.98 : 0.8 ) ); @@ -14945,8 +15106,10 @@ export class CampScene extends Phaser.Scene { x + 18, y + height - 25, progress.ready - ? '선택 사항 · 해제하거나 다른 완료 활동으로 바꿀 수 있습니다.' - : '선택 사항 · 보너스 없이 그대로 출진할 수 있습니다.', + ? '확정 완료 · 해제하거나 다른 완료 활동으로 바꿀 수 있습니다.' + : progress.selectedPriorityId + ? '이전 자동 선택 · 적용하려면 위 카드에서 한 번 확정하세요.' + : '선택 사항 · 보너스 없이 그대로 출진할 수 있습니다.', { ...this.textStyle( 10, @@ -14997,6 +15160,9 @@ export class CampScene extends Phaser.Scene { private thirdCampPreparationUnavailableLabel( reason?: ThirdCampPreparationUnavailableReason ) { + if (this.campaign?.step !== thirdBattleReturnCampStep) { + return '첫 체류에서 완료하지 않은 활동입니다. 재도전 중에는 새 활동을 진행할 수 없습니다.'; + } if (reason === 'report-review-required') { return '야영지에서 추정과 작전판을 먼저 확인하면 선택할 수 있습니다.'; } @@ -15009,10 +15175,57 @@ export class CampScene extends Phaser.Scene { return '광종 구원로 승리 기록이 있어야 선택할 수 있습니다.'; } + private thirdCampPreparationSortieState() { + if (!this.isThirdCampPreparationSlice()) { + return undefined; + } + const campaign = this.campaign ?? getCampaignState(); + const memory = resolveThirdCampPreparationMemory({ + campaign, + battleId: thirdCampPreparationTargetBattleId + }); + if (!memory) { + return undefined; + } + return { + memory, + compatibility: evaluateThirdCampPreparationCompatibility( + memory, + this.selectedSortieUnitIds + ) + }; + } + + private thirdCampPreparationRequirementLine( + evidence?: ThirdCampPreparationEvidence + ) { + const requiredUnitIds = + evidence && 'unitIds' in evidence ? evidence.unitIds : []; + if (requiredUnitIds.length === 0) { + return '발동 조건 · 편성 무관'; + } + const selected = new Set(this.selectedSortieUnitIds); + const selectedCount = requiredUnitIds.filter((unitId) => + selected.has(unitId) + ).length; + return `발동 조건 · ${requiredUnitIds + .map((unitId) => this.unitName(unitId)) + .join('·')} 동시 출전 ${selectedCount}/${requiredUnitIds.length}`; + } + + private thirdCampIncompatibleSortieSignature( + priorityId: ThirdCampPreparationPriorityId, + selectedUnitIds: readonly string[] + ) { + return `${priorityId}|${[...selectedUnitIds].sort().join(',')}`; + } + private thirdCampPreparationLockedActionLabel( _priorityId: ThirdCampPreparationPriorityId ) { - return '야영지로'; + return this.campaign?.step === thirdBattleReturnCampStep + ? '야영지로' + : '초회 한정'; } private selectThirdCampPreparationPriority( @@ -15029,9 +15242,10 @@ export class CampScene extends Phaser.Scene { this.campaign = result.campaign; this.thirdCampPreparationBrowserOpen = true; + this.thirdCampIncompatibleSortieConfirmSignature = undefined; soundDirector.playSelect(); this.showCampNotice( - `${result.priority.label} 선택 · ${result.memory.activeLine}` + `${result.priority.label} 확정 · ${result.memory.activeLine}` ); this.showSortiePrep(); } @@ -15046,6 +15260,7 @@ export class CampScene extends Phaser.Scene { } this.campaign = result.campaign; this.thirdCampPreparationBrowserOpen = true; + this.thirdCampIncompatibleSortieConfirmSignature = undefined; soundDirector.playSelect(); this.showCampNotice( result.changed @@ -15058,6 +15273,12 @@ export class CampScene extends Phaser.Scene { private openThirdCampPreparationActivity( _priorityId: ThirdCampPreparationPriorityId ) { + if (this.campaign?.step !== thirdBattleReturnCampStep) { + this.showCampNotice( + '재도전에서는 완료한 야영지 후보만 바꿀 수 있습니다. 새 활동은 광종 야영지에서만 진행할 수 있습니다.' + ); + return; + } soundDirector.playSelect(); this.thirdCampPreparationBrowserOpen = false; this.startCampNavigation('CampVisitExplorationScene', { @@ -16850,8 +17071,16 @@ export class CampScene extends Phaser.Scene { checklist: SortieChecklistItem[] ) { const summary = this.sortieChecklistSummary(checklist); - const items = this.firstSortieFinalCheckItems(checklist); - const finalChecksComplete = items.every((item) => item.complete); + const requiredItems = this.firstSortieFinalCheckItems(checklist); + const preparationItem = checklist.find( + (item) => item.label === '출진 우선 준비' + ); + const items = preparationItem + ? [...requiredItems, preparationItem] + : requiredItems; + const finalChecksComplete = requiredItems.every( + (item) => item.complete + ); const heading = finalChecksComplete ? '출진 준비 완료' : summary.requiredComplete ? '권장 점검 남음' : '필수 확인 필요'; const headingColor = finalChecksComplete ? '#a8ffd0' : summary.requiredComplete ? '#ffdf7b' : '#ff9d7d'; const headingTone = finalChecksComplete ? palette.green : summary.requiredComplete ? palette.gold : palette.red; @@ -16862,7 +17091,7 @@ export class CampScene extends Phaser.Scene { this.trackSortie( this.add.text(x + 18, y + 16, heading, this.textStyle(20, headingColor, true)) ).setDepth(depth + 1); - this.trackSortie(this.add.text(x + 18, y + 45, `${items.filter((item) => item.complete).length}/${items.length} 핵심 조건`, this.textStyle(12, '#9fb0bf', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 18, y + 45, `${requiredItems.filter((item) => item.complete).length}/${requiredItems.length} 핵심 조건${preparationItem ? ' · 야영지 효과 표시' : ''}`, this.textStyle(12, '#9fb0bf', true))).setDepth(depth + 1); const cardGap = Math.min(67, Math.floor((height - 160) / Math.max(1, items.length))); const cardHeight = Math.max(42, cardGap - 8); @@ -16936,6 +17165,27 @@ export class CampScene extends Phaser.Scene { const pursuit = this.sortiePursuitPairs(); const selectedOrder = this.currentSortieOrderDefinition(); const cityEquipment = this.cityEquipmentSortieStatus(); + const thirdCampPreparation = this.isThirdCampPreparationSlice() && + this.campaign + ? thirdCampPreparationProgress(this.campaign) + : undefined; + const thirdCampSortie = this.thirdCampPreparationSortieState(); + const thirdCampLine = thirdCampPreparation + ? thirdCampPreparation.ready + ? thirdCampSortie?.compatibility.compatible === false + ? `야영지 ${thirdCampPreparation.selectedLabel} · 미발동 · ${thirdCampSortie.compatibility.missingUnitIds + .map((unitId) => this.unitName(unitId)) + .join('·')} 편성 필요` + : `야영지 ${thirdCampPreparation.selectedLabel} · 첫 3턴 · ${ + thirdCampSortie?.memory.effect.kind === + 'return-bond-opening' + ? '공명조 편성 충족' + : '편성 무관' + }` + : thirdCampPreparation.selectedPriorityId + ? `야영지 ${thirdCampPreparation.selectedLabel} · 확정 전 · 현재 미적용` + : '야영지 보너스 없음 · 선택 없이 출진 가능' + : undefined; const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.78)); bg.setOrigin(0); bg.setDepth(depth); @@ -16951,7 +17201,7 @@ export class CampScene extends Phaser.Scene { ) ).setDepth(depth + 1); this.renderSortieBattleUiIcon('focus', x + 22, y + 44, 22, depth + 1, 0.74); - const secondaryLine = cityEquipment?.line ?? + const secondaryLine = cityEquipment?.line ?? thirdCampLine ?? `${hasBattle ? `군령 ${selectedOrder?.label ?? '미선택'} · 추격 후보 ${pursuit.activePairs.length}조 · ` : ''}공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}명`; const secondaryText = this.trackSortie( this.add.text( @@ -16962,6 +17212,12 @@ export class CampScene extends Phaser.Scene { 12, cityEquipment ? cityEquipment.active ? '#a8ffd0' : '#ffdf7b' + : thirdCampLine + ? thirdCampSortie?.compatibility.compatible === false + ? '#ff9d7d' + : thirdCampPreparation?.ready + ? '#a8ffd0' + : '#ffdf7b' : reserveUnits.length > 0 ? '#c8d2dd' : '#9fb0bf', true ) @@ -16970,6 +17226,15 @@ export class CampScene extends Phaser.Scene { if (cityEquipment) { this.sortieCityEquipmentSummaryText = secondaryText; } + if (thirdCampLine) { + secondaryText.setInteractive({ useHandCursor: true }); + secondaryText.on('pointerdown', () => { + this.sortiePrepStep = 'briefing'; + this.thirdCampPreparationBrowserOpen = true; + soundDirector.playSelect(); + this.showSortiePrep(); + }); + } } private cityEquipmentSortieStatus() { @@ -20869,6 +21134,8 @@ export class CampScene extends Phaser.Scene { const thirdCampPreparation = this.isThirdCampPreparationSlice() && this.campaign ? thirdCampPreparationProgress(this.campaign) : undefined; + const thirdCampPreparationSortie = + this.thirdCampPreparationSortieState(); return [ { label: requiredLabel, @@ -20879,10 +21146,24 @@ export class CampScene extends Phaser.Scene { ...(thirdCampPreparation ? [{ label: '출진 우선 준비', - complete: true, + complete: + thirdCampPreparationSortie?.compatibility.compatible ?? + true, detail: thirdCampPreparation.ready - ? `${thirdCampPreparation.selectedLabel} · 첫 3턴 반영` - : '미선택 · 보너스 없이 출진 가능', + ? thirdCampPreparationSortie?.compatibility.compatible === + false + ? `${thirdCampPreparation.selectedLabel} · ${thirdCampPreparationSortie.compatibility.missingUnitIds + .map((unitId) => this.unitName(unitId)) + .join('·')} 미편성 · 이번 전투 미발동` + : `${thirdCampPreparation.selectedLabel} · 첫 3턴 반영 · ${ + thirdCampPreparationSortie?.memory.effect.kind === + 'return-bond-opening' + ? '공명조 편성 충족' + : '편성 무관' + }` + : thirdCampPreparation.selectedPriorityId + ? `${thirdCampPreparation.selectedLabel} · 이전 자동 선택 확인 필요 · 미적용` + : '미선택 · 보너스 없이 출진 가능', priority: 'recommended' as const }] : []), @@ -21034,6 +21315,33 @@ export class CampScene extends Phaser.Scene { return; } + const thirdCampSortie = + this.thirdCampPreparationSortieState(); + if ( + thirdCampSortie && + !thirdCampSortie.compatibility.compatible + ) { + const signature = + this.thirdCampIncompatibleSortieSignature( + thirdCampSortie.memory.priorityId, + this.selectedSortieUnitIds + ); + if ( + this.thirdCampIncompatibleSortieConfirmSignature !== + signature + ) { + this.thirdCampIncompatibleSortieConfirmSignature = + signature; + this.showCampNotice( + `${thirdCampSortie.memory.label} 미발동 경고 · ${thirdCampSortie.compatibility.missingUnitIds + .map((unitId) => this.unitName(unitId)) + .join('·')}을 편성해야 합니다. 그대로 출진하려면 출진을 한 번 더 누르세요.` + ); + this.showSortiePrep(); + return; + } + } + if (this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep) { this.hideSortiePrep(); this.startCampNavigation('EndingScene'); @@ -21271,6 +21579,7 @@ export class CampScene extends Phaser.Scene { this.sortieCoreResonanceSelectorOpen = false; this.sortieCoreResonancePage = 0; this.thirdCampPreparationBrowserOpen = false; + this.thirdCampIncompatibleSortieConfirmSignature = undefined; } this.sortieComparisonPanelView = undefined; this.sortiePursuitPanelView = undefined; @@ -26646,8 +26955,31 @@ export class CampScene extends Phaser.Scene { targetBattleId: thirdCampPreparation.targetBattleId, selectionRecordId: thirdCampPreparation.selectionRecordId, ready: thirdCampPreparation.ready, + confirmed: thirdCampPreparation.confirmed, selectedPriorityId: thirdCampPreparation.selectedPriorityId, selectedLabel: thirdCampPreparation.selectedLabel, + keyboardFocusIndex: + this.thirdCampPreparationKeyboardIndex, + keyboardFocusPriorityId: + thirdCampPreparation.availability[ + this.thirdCampPreparationKeyboardIndex + ]?.priority.id ?? null, + compatibility: (() => { + const sortie = this.thirdCampPreparationSortieState(); + return sortie + ? { + compatible: sortie.compatibility.compatible, + requiredUnitIds: [ + ...sortie.compatibility.requiredUnitIds + ], + missingUnitIds: [ + ...sortie.compatibility.missingUnitIds + ], + disabledReason: + sortie.compatibility.disabledReason + } + : null; + })(), browserOpen: this.thirdCampPreparationBrowserOpen, toggleButtonBounds: this.sortieInteractiveObjectBoundsDebug( this.thirdCampPreparationToggleButton @@ -26695,8 +27027,12 @@ export class CampScene extends Phaser.Scene { targetBattleId: thirdCampPreparationTargetBattleId, selectionRecordId: null, ready: false, + confirmed: false, selectedPriorityId: null, selectedLabel: null, + keyboardFocusIndex: 0, + keyboardFocusPriorityId: null, + compatibility: null, browserOpen: false, toggleButtonBounds: null, closeButtonBounds: null, @@ -26777,6 +27113,8 @@ export class CampScene extends Phaser.Scene { thirdCampExploration?.completedActivityCount ?? 0, selectedPriorityId: thirdCampExploration?.selectedPriorityId ?? null, + selectionConfirmed: + thirdCampExploration?.selectionConfirmed ?? false, ready: thirdCampExploration?.ready ?? false, mode: !thirdCampExplorationVisit ? 'unavailable' diff --git a/src/game/scenes/CampVisitExplorationScene.ts b/src/game/scenes/CampVisitExplorationScene.ts index cb5d199..14c4403 100644 --- a/src/game/scenes/CampVisitExplorationScene.ts +++ b/src/game/scenes/CampVisitExplorationScene.ts @@ -749,6 +749,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { })) ?? [], selectedPriorityId: thirdProgress?.selectedPriorityId ?? null, + selectionConfirmed: + thirdProgress?.selectionConfirmed ?? false, selectedPriorityLabel: selectedThirdPriority?.label ?? null, hudActivityTitle: @@ -933,8 +935,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.completeThirdCampActivity( activityId as ThirdCampExplorationActivityId ); - return thirdCampExplorationProgress().selectedPriorityId === - activityId; + return thirdCampExplorationProgress().completedActivityIds.includes( + activityId as ThirdCampExplorationActivityId + ); } private isSecondReliefVisit() { @@ -1061,7 +1064,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { ? getThirdCampPreparationPriority(progress.selectedPriorityId) : undefined; return progress.completedActivityCount > 0 - ? `선택 활동 ${progress.completedActivityCount} / 3 · 현재 우선 ${selected?.label ?? '미정'} · 언제든 출진 가능` + ? `준비 활동 ${progress.completedActivityCount} / 3 · 출진 보너스 ${selected?.label ?? '미선택'}${progress.ready ? '' : selected ? ' 확인 필요' : ''} · 군영 장부에서 확정` : '준비 활동 0 / 3 · 원하는 곳만 살피고 남쪽 출구에서 언제든 출진할 수 있습니다.'; } @@ -1721,7 +1724,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { 150, '자유 준비 0 / 3', '작전판 · 보급 수레 · 먼저 귀환한 동료', - '한 곳만 확인해도 방침이 정해지며, 마지막 활동이 현재 우선 기준이 됩니다.' + '활동을 마치면 보너스 후보가 열립니다. 현재 출진 보너스는 자동으로 바뀌지 않습니다.' ); this.objectiveCard = activityCard.background; this.thirdActivityTitleText = activityCard.titleText; @@ -1736,9 +1739,9 @@ export class CampVisitExplorationScene extends Phaser.Scene { const priorityCard = this.drawObjectiveCard( 342, - '현재 출진 우선', + '출진 보너스 확정', '아직 정하지 않음', - '완료한 활동을 다시 확인하면 보상 중복 없이 우선 기준만 바뀝니다.' + '실제로 적용할 하나는 군영 장부에서 따로 선택하고 확정합니다.' ); this.thirdPriorityLocationText = priorityCard.locationText; @@ -2913,7 +2916,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { const newlyCompleted = !before.completedActivityIds.includes(activityId); this.inputReadyAt = this.time.now + inputCarryoverGuardMs; - this.lastNotice = `${result.activity.shortLabel} · 현재 출진 우선`; + this.lastNotice = `${result.activity.shortLabel} · 보너스 후보 해금`; if (newlyCompleted) { soundDirector.playObjectiveAchieved(); } else { @@ -2925,8 +2928,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.refreshInteractionPrompt(); this.showCompletionToast( newlyCompleted - ? result.activity.completionLine - : `${result.priority.label}을 현재 출진 우선으로 다시 선택했습니다. 보상은 중복되지 않습니다.`, + ? `${result.activity.completionLine} 군영 장부에서 출진 보너스로 선택할 수 있습니다.` + : `${result.priority.label} 후보는 이미 해금되어 있습니다. 현재 출진 보너스는 그대로 유지됩니다.`, 'success', newlyCompleted ? 2100 : 1750 ); @@ -3572,7 +3575,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.inputReadyAt = this.time.now + inputCarryoverGuardMs; this.closeChoicePanel(); - this.lastNotice = '동료 공명 · 현재 출진 우선'; + this.lastNotice = '동료 공명 · 보너스 후보 해금'; if (alreadyCompleted) { soundDirector.playSelect(); } else { @@ -3593,8 +3596,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { { speaker: '공명 기록', text: alreadyCompleted - ? `${result.memory.label}을 현재 출진 우선으로 다시 선택했습니다. 공명 보상은 중복되지 않습니다.` - : `${result.choice.label} · 공명 +${result.rewardExp} · 다음 본영전 초반 협공 신호에 반영` + ? '동료 공명 후보는 이미 해금되어 있습니다. 현재 출진 보너스와 공명 보상은 바뀌지 않습니다.' + : `${result.choice.label} · 공명 +${result.rewardExp} · 군영 장부에서 초반 협공 보너스로 선택 가능` } ], () => @@ -3657,7 +3660,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { portraitKey: 'liuBei', text: progress.completedActivityCount > 0 - ? '살핀 준비와 현재 우선 기준은 장부에 남았다. 나머지는 선택이니 군영으로 돌아가 출진 여부를 정하자.' + ? '살핀 준비는 장부에 남았다. 실제로 적용할 보너스는 군영 장부에서 따로 정하고 출진하자.' : '아직 별도 준비를 확인하지 않았지만, 세 활동은 모두 선택이다. 군영 장부로 돌아가 바로 출진해도 된다.' }, { @@ -3766,8 +3769,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { sceneHeight / 2 + 44, this.isThirdCampVisit() ? this.visitComplete() - ? '선택한 출진 우선과 완료한 활동을 장부에 반영합니다.' - : '선택 활동을 남겨 두고 군영 장부로 돌아갑니다.' + ? '완료한 활동을 장부에 반영하고, 출진 보너스는 군영에서 따로 확정합니다.' + : '완료한 활동을 저장하고 보너스를 정할 군영 장부로 돌아갑니다.' : this.visitComplete() ? this.isSecondReliefVisit() ? '현장 수습과 광종 진군 방침을 출진 장부에 올립니다.' @@ -3826,21 +3829,27 @@ export class CampVisitExplorationScene extends Phaser.Scene { `자유 준비 ${progress.completedActivityCount} / 3` ); this.thirdPriorityLocationText?.setText( - selected?.label ?? '아직 정하지 않음' + selected + ? `${selected.label}${progress.ready ? ' · 확정됨' : ' · 확인 필요'}` + : '아직 정하지 않음' ); const activityLines = progress.activities .map( (activity) => `${activity.completed ? '✓' : '!'} ${activity.shortLabel}${ - activity.selected ? ' · 현재 우선' : '' + activity.selected + ? progress.ready + ? ' · 출진 확정' + : ' · 확인 필요' + : '' }` ) .join('\n'); this.progressText?.setText( - `선택 활동 ${progress.completedActivityCount} / 3 · 모두 선택\n` + - `현재 우선 · ${selected?.label ?? '아직 정하지 않음'}\n\n` + + `준비 활동 ${progress.completedActivityCount} / 3 · 모두 선택\n` + + `출진 보너스 · ${selected?.label ?? '미선택'}${selected && !progress.ready ? ' · 군영 확인 필요' : ''}\n\n` + `${activityLines}\n\n` + - '완료한 곳을 다시 확인하면 보상 중복 없이 우선 기준만 바뀝니다.' + '재방문해도 선택은 바뀌지 않습니다. 군영 장부에서 적용할 하나를 확정하세요.' ); this.refreshThirdWorldFeedback(); return; diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 3ecee6c..cc1a189 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -25,6 +25,10 @@ import { prologueDeparturePages, prologueOpeningPages } from '../data/prologueVillage'; +import { + shouldReviewThirdCampPreparationBeforeRetry, + thirdCampPreparationTargetBattleId +} from '../data/thirdCampPreparation'; import { battleIdForCampaignStep, isCampCampaignStep } from '../state/campaignRouting'; import { readCampaignBattleResume, type CampaignBattleResume } from '../state/battleSaveStorage'; import { @@ -1321,6 +1325,16 @@ export class TitleScene extends Phaser.Scene { }); return; } + if ( + shouldReviewThirdCampPreparationBeforeRetry(campaign) + ) { + await this.navigateTo('CampScene', { + openSortiePrep: true, + retryBattleId: thirdCampPreparationTargetBattleId, + skipIntroStory: true + }); + return; + } if (campaign.pendingAftermathBattleId) { const { battleScenarios } = await import('../data/battles'); const aftermathScenario = battleScenarios[campaign.pendingAftermathBattleId]; diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index a78f187..17e6be0 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -111,6 +111,7 @@ export type ThirdCampPreparationSelectionSnapshot = { sourceBattleId: typeof thirdCampPreparationSourceBattleId; targetBattleId: typeof thirdCampPreparationTargetBattleId; priorityId: ThirdCampPreparationPriorityId; + confirmed: boolean; }; const campaignVictoryRewardCategoryIdSet = new Set(campaignVictoryRewardCategoryIds); @@ -1190,6 +1191,13 @@ export function setFirstBattleReport(report: FirstBattleReport) { if (state.sortieRecommendationSelection?.battleId === battleId) { delete state.sortieRecommendationSelection; } + if ( + reportClone.outcome === 'victory' && + battleId === thirdCampPreparationTargetBattleId && + state.thirdCampPreparationSelection?.targetBattleId === battleId + ) { + delete state.thirdCampPreparationSelection; + } state.latestBattleId = battleId; delete state.activeCityStayId; state.pendingAftermathBattleId = battleId as BattleScenarioId; @@ -2035,6 +2043,16 @@ function normalizeThirdCampPreparationSelection( value: unknown, campaign: CampaignState ): ThirdCampPreparationSelectionSnapshot | undefined { + const targetSettlement = + campaign.battleHistory[thirdCampPreparationTargetBattleId]; + if ( + targetSettlement?.outcome === 'victory' || + (campaign.firstBattleReport?.battleId === + thirdCampPreparationTargetBattleId && + campaign.firstBattleReport.outcome === 'victory') + ) { + return undefined; + } if ( !isPlainObject(value) || value.sourceBattleId !== thirdCampPreparationSourceBattleId || @@ -2053,7 +2071,8 @@ function normalizeThirdCampPreparationSelection( ? { sourceBattleId: thirdCampPreparationSourceBattleId, targetBattleId: thirdCampPreparationTargetBattleId, - priorityId: value.priorityId + priorityId: value.priorityId, + confirmed: value.confirmed === true } : undefined; } diff --git a/src/game/state/thirdCampExplorationActions.ts b/src/game/state/thirdCampExplorationActions.ts index feff060..7c6a942 100644 --- a/src/game/state/thirdCampExplorationActions.ts +++ b/src/game/state/thirdCampExplorationActions.ts @@ -14,11 +14,7 @@ import { import { getThirdCampPreparationPriority, hasThirdCampPreparationSourceVictory, - resolveThirdCampPreparationMemory, resolveThirdCampPreparationSelection, - thirdCampPreparationSourceBattleId, - thirdCampPreparationTargetBattleId, - type ThirdCampPreparationMemory, type ThirdCampPreparationPriorityDefinition } from '../data/thirdCampPreparation'; import { @@ -55,7 +51,6 @@ export type ThirdCampExplorationActivityResult = ok: true; activity: ThirdCampExplorationActivityDefinition; priority: ThirdCampPreparationPriorityDefinition; - memory: ThirdCampPreparationMemory; campaign: CampaignState; changed: boolean; } @@ -77,7 +72,6 @@ export type ThirdCampCompanionActivityResult = dialogue: ThirdCampCompanionDialogueDefinition; choice: ThirdCampCompanionDialogueChoice; rewardExp: number; - memory: ThirdCampPreparationMemory; campaign: CampaignState; changed: boolean; } @@ -119,7 +113,10 @@ export function thirdCampExplorationProgress( selected: selection?.priorityId === activity.id })) ?? [], selectedPriorityId: selection?.priorityId ?? null, - ready: selection?.selectable === true + selectionConfirmed: selection?.confirmed === true, + ready: + selection?.selectable === true && + selection.confirmed === true }; } @@ -188,30 +185,23 @@ export function recordThirdCampExplorationActivity( return { ok: false, reason: 'choice-required' }; } - const persisted = persistActivitySelection( + const persisted = persistActivityCompletion( snapshot, priorityId ); if (!persisted) { return { ok: false, reason: 'save-unavailable' }; } - const memory = selectedMemory(persisted, priorityId); - if (!memory) { - restoreCampaignSnapshot(snapshot); - return { ok: false, reason: 'save-unavailable' }; - } return { ok: true, activity, priority, - memory, campaign: persisted, changed: !snapshot.completedCampVisits.includes( thirdCampExplorationActivityProgressId(priorityId) - ) || - snapshot.thirdCampPreparationSelection?.priorityId !== priorityId + ) }; } @@ -258,7 +248,7 @@ export function completeThirdCampCompanionActivity( restoreCampaignSnapshot(snapshot); return { ok: false, reason: 'save-unavailable' }; } - const persisted = persistActivitySelection( + const persisted = persistActivityCompletion( afterDialogue, 'companion', snapshot @@ -266,11 +256,6 @@ export function completeThirdCampCompanionActivity( if (!persisted) { return { ok: false, reason: 'save-unavailable' }; } - const memory = selectedMemory(persisted, 'companion'); - if (!memory) { - restoreCampaignSnapshot(snapshot); - return { ok: false, reason: 'save-unavailable' }; - } return { ok: true, @@ -278,15 +263,12 @@ export function completeThirdCampCompanionActivity( dialogue, choice, rewardExp, - memory, campaign: persisted, changed: !dialogueAlreadyCompleted || !snapshot.completedCampVisits.includes( thirdCampExplorationActivityProgressId('companion') - ) || - snapshot.thirdCampPreparationSelection?.priorityId !== - 'companion' + ) }; } @@ -325,7 +307,7 @@ function hasCompletedCompanionActivity(campaign: CampaignState) { ); } -function persistActivitySelection( +function persistActivityCompletion( current: CampaignState, activityId: ThirdCampExplorationActivityId, rollbackSnapshot: CampaignState = current @@ -339,12 +321,7 @@ function persistActivitySelection( updated = { ...updated, acknowledgedVictoryRewardCategories: - acknowledgedRewardCategoryForActivity(updated, activityId), - thirdCampPreparationSelection: { - sourceBattleId: thirdCampPreparationSourceBattleId, - targetBattleId: thirdCampPreparationTargetBattleId, - priorityId: activityId - } + acknowledgedRewardCategoryForActivity(updated, activityId) }; return persistCampaign(updated, rollbackSnapshot); } @@ -400,17 +377,6 @@ function withVisitMarkers( }; } -function selectedMemory( - campaign: CampaignState, - activityId: ThirdCampExplorationActivityId -) { - const memory = resolveThirdCampPreparationMemory({ - campaign, - battleId: thirdCampPreparationTargetBattleId - }); - return memory?.priorityId === activityId ? memory : undefined; -} - function applyCompanionBondReward( snapshot: CampaignState, dialogue: ThirdCampCompanionDialogueDefinition, diff --git a/src/game/state/thirdCampPreparationActions.ts b/src/game/state/thirdCampPreparationActions.ts index aaaf0f3..be0f1db 100644 --- a/src/game/state/thirdCampPreparationActions.ts +++ b/src/game/state/thirdCampPreparationActions.ts @@ -63,7 +63,10 @@ export function thirdCampPreparationProgress( availability, selectedPriorityId: selection?.priorityId ?? null, selectedLabel: selection?.label ?? null, - ready: selection?.selectable === true + confirmed: selection?.confirmed === true, + ready: + selection?.selectable === true && + selection.confirmed === true }; } @@ -94,7 +97,9 @@ export function setThirdCampPreparationPriority( const previousPriorityId = snapshot.thirdCampPreparationSelection?.priorityId; - if (previousPriorityId === priorityId) { + const previouslyConfirmed = + snapshot.thirdCampPreparationSelection?.confirmed === true; + if (previousPriorityId === priorityId && previouslyConfirmed) { const memory = resolveThirdCampPreparationMemory({ campaign: snapshot, battleId: thirdCampPreparationTargetBattleId @@ -115,7 +120,8 @@ export function setThirdCampPreparationPriority( thirdCampPreparationSelection: { sourceBattleId: thirdCampPreparationSourceBattleId, targetBattleId: thirdCampPreparationTargetBattleId, - priorityId + priorityId, + confirmed: true } }; const persisted = persistSelection(updated, snapshot); @@ -173,9 +179,26 @@ export function clearThirdCampPreparationPriority(): function campaignSupportsThirdCampPreparation( campaign: CampaignState ) { - return ( + const isInitialPreparation = campaign.step === 'third-camp' && - campaign.latestBattleId === thirdCampPreparationSourceBattleId && + campaign.latestBattleId === + thirdCampPreparationSourceBattleId; + const targetSettlement = + campaign.battleHistory[ + thirdCampPreparationTargetBattleId + ]; + const isTargetBattleRetry = + campaign.step === 'fourth-battle' && + ( + targetSettlement?.outcome === 'defeat' || + ( + campaign.firstBattleReport?.battleId === + thirdCampPreparationTargetBattleId && + campaign.firstBattleReport.outcome === 'defeat' + ) + ); + return ( + (isInitialPreparation || isTargetBattleRetry) && hasThirdCampPreparationSourceVictory(campaign) ); }