From 2b9e33f4c0b7af7877d4db7e772ca021afd305a6 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 11 Jul 2026 02:42:04 +0900 Subject: [PATCH] feat: unlock tactical commands from sortie orders --- scripts/verify-battle-save-normalization.mjs | 125 ++++++- .../verify-campaign-save-normalization.mjs | 102 +++++- scripts/verify-release-candidate.mjs | 203 ++++++++++- src/game/data/sortieOrders.ts | 15 + src/game/scenes/BattleScene.ts | 342 +++++++++++++++--- src/game/scenes/CampScene.ts | 98 ++++- src/game/state/battleSaveState.ts | 74 +++- src/game/state/campaignState.ts | 171 ++++++++- 8 files changed, 1060 insertions(+), 70 deletions(-) diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 25d6937..76e4ee3 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -63,7 +63,8 @@ try { const tacticalOptions = { ...options, expectedBattleId: 'second-battle-yellow-turban-pursuit', - allowTacticalCommand: true + allowTacticalCommand: true, + allowLegacyTacticalCommand: true }; const validTacticalBaseState = { ...validState, @@ -114,10 +115,62 @@ try { effectLost: true } }; + const validInitiativeCommandState = { + ...validFrontCommandState, + tacticalCommand: { ...validFrontCommandState.tacticalCommand, source: 'initiative' } + }; + const validUnlockedOrderCommandState = { + ...validTacticalBaseState, + battleStats: validState.battleStats, + sortieOrderCommandUnlocked: true, + sortieOrderCommandUnlockTurn: 2, + tacticalCommand: { + ...validFrontCommandState.tacticalCommand, + source: 'sortie-order' + } + }; assert(isValidBattleSaveState(validFrontCommandState, tacticalOptions), 'Expected pending front tactical command to pass validation.'); + assert(isValidBattleSaveState(validInitiativeCommandState, tacticalOptions), 'Expected an explicit initiative source to pass validation.'); assert(isValidBattleSaveState(validFlankCommandState, tacticalOptions), 'Expected resolved flank tactical command to pass validation.'); assert(isValidBattleSaveState(validSupportCommandState, tacticalOptions), 'Expected immediate support tactical command to pass validation.'); assert(isValidBattleSaveState(validLostFrontCommandState, tacticalOptions), 'Expected a lost tactical command to retain its result record.'); + assert( + isValidBattleSaveState(validUnlockedOrderCommandState, tacticalOptions), + 'Expected an unlocked sortie-order command to replace the counterplay requirement.' + ); + assert( + isValidBattleSaveState({ ...validUnlockedOrderCommandState, tacticalCommand: undefined }, tacticalOptions), + 'Expected an unlocked sortie-order command to remain unused in a valid save.' + ); + assert( + isValidBattleSaveState({ ...validTacticalBaseState, sortieOrderCommandUnlocked: false }, tacticalOptions), + 'Expected an explicitly locked sortie-order command without an unlock turn to pass validation.' + ); + const otherSortieOptions = { + ...options, + allowTacticalCommand: true, + allowLegacyTacticalCommand: false + }; + const otherSortieLegacyCommandState = { + ...validFrontCommandState, + battleId: options.expectedBattleId, + campaignStep: 'first-battle', + tacticalCommand: { ...validFrontCommandState.tacticalCommand } + }; + const otherSortieOrderCommandState = { + ...validUnlockedOrderCommandState, + battleId: options.expectedBattleId, + campaignStep: 'first-battle' + }; + assert( + isValidBattleSaveState(otherSortieOrderCommandState, otherSortieOptions) && + !isValidBattleSaveState(otherSortieLegacyCommandState, otherSortieOptions) && + !isValidBattleSaveState({ + ...otherSortieLegacyCommandState, + tacticalCommand: { ...otherSortieLegacyCommandState.tacticalCommand, source: 'initiative' } + }, otherSortieOptions), + 'Expected another sortie battle to allow sortie-order source while rejecting source-less and explicit legacy initiative commands.' + ); assert(!isValidBattleSaveState(validFrontCommandState, options), 'Expected tactical commands to be rejected when disabled for the battle.'); const tacticalRoleOptions = { ...tacticalOptions, @@ -131,6 +184,13 @@ try { JSON.stringify(parsedTacticalCommand) === JSON.stringify(validFrontCommandState.tacticalCommand), 'Expected every tactical command field to round-trip.' ); + const parsedOrderCommand = parseBattleSaveState(JSON.stringify(validUnlockedOrderCommandState), tacticalOptions); + assert( + parsedOrderCommand?.sortieOrderCommandUnlocked === true && + parsedOrderCommand.sortieOrderCommandUnlockTurn === 2 && + parsedOrderCommand.tacticalCommand?.source === 'sortie-order', + 'Expected sortie-order command unlock and source fields to round-trip.' + ); assert(parseBattleSaveState(JSON.stringify(validState), options)?.battleId === options.expectedBattleId, 'Expected valid JSON save to parse.'); assert( parseBattleSaveState(JSON.stringify(validState), options)?.sortieOrderId === 'elite', @@ -250,6 +310,7 @@ try { const tacticalRejectedCases = [ ['invalid tactical command shape', { tacticalCommand: 'front' }], ['invalid tactical command role', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, role: 'reserve' } }], + ['invalid tactical command source', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, source: 'morale' } }], ['unknown tactical command actor', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, actorId: 'ghost-unit' } }], ['enemy tactical command actor', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, actorId: 'rebel-1' } }], ['invalid tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 0 } }], @@ -275,6 +336,68 @@ try { assert(!isValidBattleSaveState(candidate, tacticalOptions), `Expected invalid tactical command save to be rejected: ${label}`); }); + const orderCommandRejectedCases = [ + ['unlocked command missing unlock turn', { sortieOrderCommandUnlockTurn: undefined }], + ['zero unlock turn', { sortieOrderCommandUnlockTurn: 0 }], + ['future unlock turn', { sortieOrderCommandUnlockTurn: 3 }], + ['fractional unlock turn', { sortieOrderCommandUnlockTurn: 1.5 }], + ['locked command retaining unlock turn', { sortieOrderCommandUnlocked: false }], + ['unlocked command without allied actions', { + battleStats: { + 'liu-bei': { ...validState.battleStats['liu-bei'], actions: 0 }, + 'rebel-1': { ...validState.battleStats['liu-bei'], actions: 3 } + } + }], + ['sortie-order source without unlock', { + sortieOrderCommandUnlocked: undefined, + sortieOrderCommandUnlockTurn: undefined + }] + ]; + orderCommandRejectedCases.forEach(([label, patch]) => { + const candidate = { ...validUnlockedOrderCommandState, ...patch }; + assert(!isValidBattleSaveState(candidate, tacticalOptions), `Expected invalid sortie-order command save to be rejected: ${label}`); + }); + assert( + !isValidBattleSaveState(validUnlockedOrderCommandState, options), + 'Expected sortie-order command unlock state to remain gated by allowTacticalCommand.' + ); + assert( + !isValidBattleSaveState( + { + ...validUnlockedOrderCommandState, + turnNumber: 3, + sortieOrderCommandUnlockTurn: 3, + tacticalCommand: { ...validUnlockedOrderCommandState.tacticalCommand, usedTurn: 2 } + }, + tacticalOptions + ), + 'Expected a sortie-order tactical command used before its unlock turn to be rejected.' + ); + assert( + !isValidBattleSaveState( + { + ...validInitiativeCommandState, + battleStats: validState.battleStats + }, + tacticalOptions + ), + 'Expected an explicit initiative command to retain the legacy counterplay threshold.' + ); + const unlockedInitiativeState = { + ...validTacticalBaseState, + sortieOrderCommandUnlocked: true, + sortieOrderCommandUnlockTurn: 2, + tacticalCommand: { ...validInitiativeCommandState.tacticalCommand } + }; + assert( + !isValidBattleSaveState(unlockedInitiativeState, tacticalOptions) && + !isValidBattleSaveState({ + ...unlockedInitiativeState, + tacticalCommand: { ...unlockedInitiativeState.tacticalCommand, source: undefined } + }, tacticalOptions), + 'Expected unlocked sortie-order state to reject explicit and source-less legacy initiative commands.' + ); + assert( !isValidBattleSaveState(validState, { ...options, validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1', 'zhang-fei']) }), 'Expected save missing a current battle unit to be rejected.' diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 5c4d030..2bc64c1 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -360,6 +360,62 @@ try { normalizedStringBooleans.progress.every((entry) => entry.achieved === false), `Expected malformed string booleans to stay false instead of restoring an unearned reward claim: ${JSON.stringify(normalizedStringBooleans)}` ); + const normalizedOrderCommand = normalizeCampaignSortieOrderResultSnapshot({ + ...normalizedEliteOrder, + command: { + source: 'sortie-order', + role: 'support', + actorId: ' liu-bei ', + usedTurn: '3.9', + effectPending: 'true', + effectValue: '1000001', + statusesCleared: '2.9', + effectLost: true, + extra: 'discard' + } + }); + assert( + normalizedOrderCommand?.command?.source === 'sortie-order' && + normalizedOrderCommand.command.role === 'support' && + normalizedOrderCommand.command.actorId === 'liu-bei' && + normalizedOrderCommand.command.usedTurn === 3 && + normalizedOrderCommand.command.effectPending === false && + normalizedOrderCommand.command.effectValue === 999999 && + normalizedOrderCommand.command.statusesCleared === 2 && + normalizedOrderCommand.command.effectLost === true && + !Object.prototype.hasOwnProperty.call(normalizedOrderCommand.command, 'extra') && + !Object.prototype.hasOwnProperty.call(normalizedEliteOrder, 'command'), + `Expected sortie-order commands to sanitize ids, numbers, booleans, and preserve command-less legacy snapshots: ${JSON.stringify(normalizedOrderCommand)}` + ); + const invalidOrderCommands = [ + { ...normalizedEliteOrder, command: { ...normalizedOrderCommand.command, source: 'morale' } }, + { ...normalizedEliteOrder, command: { ...normalizedOrderCommand.command, role: 'reserve' } }, + { ...normalizedEliteOrder, command: { ...normalizedOrderCommand.command, actorId: 'x'.repeat(97) } }, + { ...normalizedEliteOrder, command: { ...normalizedOrderCommand.command, usedTurn: 0 } } + ]; + assert( + invalidOrderCommands.every((snapshot) => ( + !Object.prototype.hasOwnProperty.call(normalizeCampaignSortieOrderResultSnapshot(snapshot) ?? {}, 'command') + )), + 'Expected malformed optional command records to be discarded without losing the sortie-order result.' + ); + const normalizedInitiativeCommand = normalizeCampaignSortieOrderResultSnapshot({ + ...normalizedEliteOrder, + command: { + ...normalizedOrderCommand.command, + source: 'initiative', + role: 'flank', + effectPending: true, + effectLost: 'false' + } + })?.command; + assert( + normalizedInitiativeCommand?.source === 'initiative' && + normalizedInitiativeCommand.role === 'flank' && + normalizedInitiativeCommand.effectPending === true && + normalizedInitiativeCommand.effectLost === false, + `Expected explicit initiative command sources and strict command booleans to normalize: ${JSON.stringify(normalizedInitiativeCommand)}` + ); storage.clear(); storage.set( @@ -900,7 +956,17 @@ try { { id: 'victory', achieved: true, value: 1, target: 1 }, { id: 'elite-grade', achieved: true, value: 88, target: 75 }, { id: 'elite-survival', achieved: true, value: 1, target: 1 } - ] + ], + command: { + source: 'sortie-order', + role: 'front', + actorId: performanceUnit.id, + usedTurn: 3, + effectPending: false, + effectValue: 8, + statusesCleared: 0, + effectLost: false + } }; const successfulMobileOrder = { version: 1, @@ -964,7 +1030,9 @@ try { firstOrderSettlement.claimedSortieOrderRewardIds[0] === `${firstScenario.id}:elite` && firstOrderSettlement.sortieOrderSelection === undefined && firstOrderSettlement.firstBattleReport?.sortieOrder?.rewardGranted === true && + firstOrderSettlement.firstBattleReport.sortieOrder.command?.source === 'sortie-order' && firstOrderSettlement.battleHistory[firstScenario.id]?.sortieOrder?.rewardGranted === true && + firstOrderSettlement.battleHistory[firstScenario.id].sortieOrder.command?.effectValue === 8 && firstOrderSettlement.sortieOrderHistory[firstScenario.id]?.elite?.rewardGranted === true, `Expected the first battle/order success to grant its independent reward once, persist every snapshot, and clear the matching selection: ${JSON.stringify(firstOrderSettlement)}` ); @@ -972,23 +1040,55 @@ try { assert( reloadedFirstOrderSettlement.firstBattleReport?.sortieOrder?.achieved === true && reloadedFirstOrderSettlement.firstBattleReport.sortieOrder.rewardGranted === true && + reloadedFirstOrderSettlement.firstBattleReport.sortieOrder.command?.actorId === performanceUnit.id && reloadedFirstOrderSettlement.battleHistory[firstScenario.id]?.sortieOrder?.achieved === true && + reloadedFirstOrderSettlement.battleHistory[firstScenario.id].turnNumber === orderBattleReport.turnNumber && reloadedFirstOrderSettlement.battleHistory[firstScenario.id].sortieOrder.rewardGranted === true && + reloadedFirstOrderSettlement.battleHistory[firstScenario.id].sortieOrder.command?.effectValue === 8 && reloadedFirstOrderSettlement.sortieOrderHistory[firstScenario.id]?.elite?.achieved === true && + reloadedFirstOrderSettlement.sortieOrderHistory[firstScenario.id].elite.command?.source === 'sortie-order' && reloadedFirstOrderSettlement.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`), `Expected report, settlement, independent history, and reward claim to survive save normalization together: ${JSON.stringify(reloadedFirstOrderSettlement)}` ); + const validContextualCommandStateRaw = storage.get(campaignStorageKey); + assert(validContextualCommandStateRaw, 'Expected a persisted command state for contextual corruption checks.'); + const corruptedContextualCommandState = JSON.parse(validContextualCommandStateRaw); + corruptedContextualCommandState.firstBattleReport.sortieOrder.command.actorId = 'ghost-unit'; + corruptedContextualCommandState.battleHistory[firstScenario.id].sortieOrder.command.role = 'support'; + corruptedContextualCommandState.sortieOrderHistory[firstScenario.id].elite.command.usedTurn = orderBattleReport.turnNumber + 1; + storage.set(campaignStorageKey, JSON.stringify(corruptedContextualCommandState)); + const normalizedContextualCommandState = loadCampaignState(); + assert( + normalizedContextualCommandState.firstBattleReport?.sortieOrder?.command === undefined && + normalizedContextualCommandState.battleHistory[firstScenario.id]?.sortieOrder?.command === undefined && + normalizedContextualCommandState.sortieOrderHistory[firstScenario.id]?.elite?.command === undefined && + normalizedContextualCommandState.firstBattleReport?.sortieOrder?.achieved === true && + normalizedContextualCommandState.battleHistory[firstScenario.id]?.sortieOrder?.achieved === true && + normalizedContextualCommandState.sortieOrderHistory[firstScenario.id]?.elite?.achieved === true, + `Expected contextual normalization to remove ghost actors, role mismatches, and future command turns without dropping their order results: ${JSON.stringify(normalizedContextualCommandState)}` + ); + storage.set(campaignStorageKey, validContextualCommandStateRaw); + loadCampaignState(); + orderBattleReport.sortieOrder.progress[0].achieved = false; + orderBattleReport.sortieOrder.command.effectValue = 999; firstOrderCanonicalReport.sortieOrder.progress[1].achieved = false; + firstOrderCanonicalReport.sortieOrder.command.actorId = 'ghost-unit'; const deepCopyOrderState = getCampaignState(); const detachedOrderState = getCampaignState(); detachedOrderState.firstBattleReport.sortieOrder.progress[2].achieved = false; + detachedOrderState.firstBattleReport.sortieOrder.command.effectLost = true; assert( deepCopyOrderState.firstBattleReport?.sortieOrder?.progress.every((entry) => entry.achieved) && + deepCopyOrderState.firstBattleReport.sortieOrder.command?.effectValue === 8 && + deepCopyOrderState.firstBattleReport.sortieOrder.command.actorId === performanceUnit.id && deepCopyOrderState.battleHistory[firstScenario.id]?.sortieOrder?.progress.every((entry) => entry.achieved) && + deepCopyOrderState.battleHistory[firstScenario.id].sortieOrder.command?.effectLost === false && deepCopyOrderState.sortieOrderHistory[firstScenario.id]?.elite?.progress.every((entry) => entry.achieved) && + deepCopyOrderState.sortieOrderHistory[firstScenario.id].elite.command?.effectValue === 8 && detachedOrderState.battleHistory[firstScenario.id]?.sortieOrder?.progress[2].achieved === true && + detachedOrderState.battleHistory[firstScenario.id].sortieOrder.command?.effectLost === false && getCampaignState().firstBattleReport?.sortieOrder?.progress[2].achieved === true, `Expected input, returned report, report/history, and returned campaign sortie-order progress to be deeply detached: ${JSON.stringify(deepCopyOrderState)}` ); diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 9d5b9c5..e9114b5 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -49,6 +49,7 @@ try { await page.mouse.click(962, 240); await waitForStoryReady(page); + await setDebugQueryParam(page, 'debugOrderCommandReady', '1'); await page.screenshot({ path: `${screenshotDir}/rc-new-game-story.png`, fullPage: true }); await assertCanvasPainted(page, 'new game story'); await advanceUntilBattle(page, 'first-battle-zhuo-commandery'); @@ -84,11 +85,36 @@ try { !firstBattleProbe.sideTexts.some((text) => text.includes('...')), `Expected sortie doctrine opening message without ASCII truncation: ${JSON.stringify(firstBattleProbe.sideTexts)}` ); - await assertLiveSortieOrderHud(page, { + const firstBattleOrderHud = await assertLiveSortieOrderHud(page, { battleId: 'first-battle-zhuo-commandery', orderId: 'elite', context: 'first battle' }); + assert( + firstBattleOrderHud.hud?.commandReady === true && + firstBattleOrderHud.hud.commandUnlocked === true && + firstBattleOrderHud.hud.commandUnlockTurn === 1 && + firstBattleOrderHud.hud.commandUsed === false && + firstBattleOrderHud.hud.commandSource === 'sortie-order' && + firstBattleOrderHud.hud.criteria.every((entry) => entry.id === 'victory' || entry.achieved === true) && + firstBattleOrderHud.battleLog.some((entry) => entry.includes('군령 발동 가능')) && + isFiniteBounds(firstBattleOrderHud.hud.commandBounds), + `Expected the deterministic fixture to reach the real criteria/action unlock path and expose a ready one-use order command: ${JSON.stringify(firstBattleOrderHud)}` + ); + + const firstBattleCommand = await activateSortieOrderCommand(page, 'support'); + assert( + firstBattleCommand.live?.commandReady === false && + firstBattleCommand.live?.commandUnlocked === true && + firstBattleCommand.live?.commandUsed === true && + firstBattleCommand.live?.commandSource === 'sortie-order' && + firstBattleCommand.live?.commandActorId === firstBattleCommand.live?.command?.actorId && + firstBattleCommand.live?.command?.source === 'sortie-order' && + firstBattleCommand.live?.command?.role === 'support' && + firstBattleCommand.live?.command?.effectPending === false && + firstBattleCommand.live?.command?.effectValue > 0, + `Expected selecting 재정비 to consume the order command and resolve deterministic healing immediately: ${JSON.stringify(firstBattleCommand)}` + ); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await waitForBattleOutcome(page, 'victory'); @@ -120,6 +146,7 @@ try { const firstResultSave = await readCampaignSave(page); const firstSortieOrder = firstResultState?.sortieOperationOrder; const firstSortieOrderResult = firstSortieOrder?.result; + const firstSortieOrderCommand = firstSortieOrderResult?.command; assert( firstSortieOrder?.selectedId === 'elite' && firstSortieOrder?.open === false && @@ -128,6 +155,17 @@ try { firstSortieOrderResult?.progress?.map((entry) => entry.id).join(',') === 'victory,elite-grade,elite-survival', `Expected the scripted first battle to expose an explicit elite order result: ${JSON.stringify(firstSortieOrder)}` ); + assert( + firstSortieOrderCommand?.source === 'sortie-order' && + firstSortieOrderCommand.role === 'support' && + typeof firstSortieOrderCommand.actorId === 'string' && firstSortieOrderCommand.actorId.length > 0 && + firstSortieOrderCommand.usedTurn === 1 && + firstSortieOrderCommand.effectPending === false && + firstSortieOrderCommand.effectValue > 0 && + firstSortieOrderCommand.statusesCleared >= 0 && + firstSortieOrderCommand.effectLost === false, + `Expected the result snapshot to retain the resolved 재정비 order command: ${JSON.stringify(firstSortieOrderResult)}` + ); assert( sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieOrder, firstSortieOrderResult) && sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieOrder, firstSortieOrderResult) && @@ -136,6 +174,15 @@ try { sameJsonValue(firstResultSave.current?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite, firstSortieOrderResult), `Expected the elite order snapshot to synchronize across report, settlement, history, current, and slot saves: ${JSON.stringify(firstResultSave)}` ); + assert( + sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieOrder?.command, firstSortieOrderCommand) && + sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieOrder?.command, firstSortieOrderCommand) && + sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder?.command, firstSortieOrderCommand) && + sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder?.command, firstSortieOrderCommand) && + sameJsonValue(firstResultSave.current?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite?.command, firstSortieOrderCommand) && + sameJsonValue(firstResultSave.slot1?.sortieOrderHistory?.['first-battle-zhuo-commandery']?.elite?.command, firstSortieOrderCommand), + `Expected the order-command snapshot to persist across current/slot reports, settlements, and order history: ${JSON.stringify(firstResultSave)}` + ); if (firstSortieOrderResult.achieved) { assert( firstSortieOrderResult.rewardGranted === true && @@ -212,8 +259,10 @@ try { firstOrderDetail.texts.some((text) => text.includes('정예 작전 명령')) && firstOrderDetail.texts.some((text) => text.includes('전투 승리')) && firstOrderDetail.texts.some((text) => text.includes('A등급 이상')) && - firstOrderDetail.texts.some((text) => text.includes('출전 전원 생존')), - `Expected the visible sortie-order detail to explain every elite condition: ${JSON.stringify(firstOrderDetail)}` + firstOrderDetail.texts.some((text) => text.includes('출전 전원 생존')) && + firstOrderDetail.texts.some((text) => text.includes('군령 발동') && text.includes('재정비')) && + firstOrderDetail.texts.some((text) => text.includes('1턴') && text.includes('회복')), + `Expected the visible sortie-order detail to explain every elite condition and its used command: ${JSON.stringify(firstOrderDetail)}` ); await page.screenshot({ path: `${screenshotDir}/rc-first-battle-sortie-order.png`, fullPage: true }); const firstOrderClosePoint = await readBattleSortieOrderControlPoint(page, 'close'); @@ -297,6 +346,10 @@ try { ); assert(firstCampProbe.summary?.includes('보유 군자금'), `Expected camp summary to show held gold: ${JSON.stringify(firstCampProbe)}`); assert(firstCampProbe.texts.some((text) => text.includes('전투 보상')), `Expected camp report to show battle reward: ${JSON.stringify(firstCampProbe.texts)}`); + assert( + firstCampProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비')), + `Expected the camp report to show the persisted command record: ${JSON.stringify(firstCampProbe.texts)}` + ); assert( firstCampProbe.texts.some((text) => text.includes(`다음 ${firstBattleUnlockLabel}`)), `Expected camp report to surface first battle unlock label: ${JSON.stringify(firstCampProbe.texts)}` @@ -346,14 +399,21 @@ try { firstCampOrder.orderId === 'elite' && firstCampOrder.achieved === firstSortieOrderResult.achieved && firstCampOrder.firstRewardGranted === firstSortieOrderResult.rewardGranted && + sameSortieOrderCommand(firstCampOrder.command, firstSortieOrderCommand) && + firstCampOrder.command?.label === '재정비' && + firstCampOrder.command?.sourceLabel === '군령 발동' && + firstCampOrder.command?.recordLine?.includes('회복') && firstCampHistory.records[0].sortieOrder?.orderId === 'elite' && firstCampHistory.records[0].sortieOrder?.achieved === firstSortieOrderResult.achieved && + sameSortieOrderCommand(firstCampHistory.records[0].sortieOrder?.command, firstSortieOrderCommand) && firstCampHistory.selected?.sortieOrder?.progress?.length === 3 && + sameSortieOrderCommand(firstCampHistory.selected?.sortieOrder?.command, firstSortieOrderCommand) && firstCampEliteSummary?.attempts === 1 && firstCampEliteSummary.successes === (firstSortieOrderResult.achieved ? 1 : 0) && firstCampEliteSummary.bestScore === firstSortieOrderResult.score, `Expected the camp report and formation ledger to surface the elite result and merit statistics: ${JSON.stringify({ firstCampOrder, firstCampHistory })}` ); + await setDebugQueryParam(page, 'debugOrderCommandReady', null); if (firstSortieOrderResult.rewardGranted) { assert( firstCampOrder.rewardBadgeBounds && firstCampOrder.rewardLine.includes('상처약 1'), @@ -524,6 +584,7 @@ try { firstCampHistoryOpenProbe.texts.includes('최고 편성') && firstCampHistoryOpenProbe.texts.includes('편성책 평균 · 군령 전적') && firstCampHistoryOpenProbe.texts.includes('현재 출전안 vs 최고 편성') && + firstCampHistoryOpenProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비') && text.includes('군령 발동')) && sameJsonValue(firstCampHistorySaveOpen, firstCampHistorySaveBeforeOpen), `Expected opening the first camp formation history to remain non-destructive and expose best/load controls: ${JSON.stringify(firstCampHistoryOpenProbe)}` ); @@ -2281,6 +2342,18 @@ function withDebugParam(url) { return parsed.toString(); } +async function setDebugQueryParam(page, key, value) { + await page.evaluate(({ key, value }) => { + const url = new URL(window.location.href); + if (value === null) { + url.searchParams.delete(key); + } else { + url.searchParams.set(key, value); + } + window.history.replaceState(window.history.state, '', url); + }, { key, value }); +} + function isWarning(type) { return type === 'warning' || type === 'warn'; } @@ -2414,6 +2487,14 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) { status: rawHud.status ?? '', tone: rawHud.tone ?? rawHud.status ?? '', projectedScore: rawHud.projectedScore, + commandReady: rawHud.commandReady, + commandUnlocked: rawHud.commandUnlocked, + commandUnlockTurn: rawHud.commandUnlockTurn, + commandUsed: rawHud.commandUsed, + commandSource: rawHud.commandSource, + commandActorId: rawHud.commandActorId, + commandBounds: rawHud.commandBounds ?? null, + command: rawHud.command ? { ...rawHud.command } : null, criteria: criteria.map((entry) => ({ id: entry?.id ?? null, label: entry?.label ?? '', @@ -2427,7 +2508,8 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) { } : null, expectedBattleId, - expectedOrderId + expectedOrderId, + battleLog: Array.isArray(state?.battleLog) ? [...state.battleLog] : [] }; }, { expectedBattleId: battleId, expectedOrderId: orderId }); @@ -2451,7 +2533,14 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) { typeof hud.victoryTone === 'string' && hud.victoryTone.length > 0 && typeof hud.status === 'string' && hud.status.length > 0 && typeof hud.tone === 'string' && hud.tone.length > 0 && - Number.isFinite(hud.projectedScore), + Number.isFinite(hud.projectedScore) && + typeof hud.commandReady === 'boolean' && + typeof hud.commandUnlocked === 'boolean' && + typeof hud.commandUsed === 'boolean' && + (hud.commandUnlockTurn === null || Number.isInteger(hud.commandUnlockTurn)) && + (hud.commandSource === null || hud.commandSource === 'sortie-order' || hud.commandSource === 'initiative') && + (hud.commandActorId === null || typeof hud.commandActorId === 'string') && + (hud.commandBounds === null || (isFiniteBounds(hud.commandBounds) && boundsInside(hud.commandBounds, hud.bounds))), `Expected ${context} to show a fully labelled live ${orderId} HUD with a finite projected score: ${JSON.stringify(probe)}` ); assert( @@ -2480,6 +2569,93 @@ async function assertLiveSortieOrderHud(page, { battleId, orderId, context }) { return probe; } +async function activateSortieOrderCommand(page, role) { + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene?.hideBattleEventBanner?.(); + }); + const openPoint = await readBattleOrderCommandControlPoint(page, 'open'); + await page.mouse.click(openPoint.x, openPoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.commandPanelOpen === true, + undefined, + { timeout: 30000 } + ); + + const panelProbe = await page.evaluate(() => { + const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder; + const cards = Object.fromEntries(Object.entries(order?.commandCards ?? {}).map(([cardRole, rawCard]) => [ + cardRole, + rawCard && typeof rawCard === 'object' && 'bounds' in rawCard + ? { ...rawCard, bounds: rawCard.bounds } + : { role: cardRole, bounds: rawCard } + ])); + return { + open: order?.commandPanelOpen ?? false, + panelBounds: order?.commandPanelBounds ?? null, + cards + }; + }); + assert( + panelProbe.open === true && + isFiniteBounds(panelProbe.panelBounds) && + ['front', 'flank', 'support'].every((cardRole) => ( + isFiniteBounds(panelProbe.cards?.[cardRole]?.bounds) && + boundsInside(panelProbe.cards[cardRole].bounds, panelProbe.panelBounds) + )) && + panelProbe.cards?.[role]?.enabled !== false, + `Expected the ready order command panel to expose three bounded role choices: ${JSON.stringify(panelProbe)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-first-battle-order-command.png`, fullPage: true }); + await assertCanvasPainted(page, 'first battle order command'); + + const rolePoint = await readBattleOrderCommandControlPoint(page, 'card', role); + await page.mouse.click(rolePoint.x, rolePoint.y); + await page.waitForFunction( + (expectedRole) => { + const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder; + return ( + order?.commandPanelOpen === false && + order?.live?.commandUsed === true && + order?.live?.command?.role === expectedRole + ); + }, + role, + { timeout: 30000 } + ); + return page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder); +} + +async function readBattleOrderCommandControlPoint(page, control, role) { + const probe = await page.evaluate(({ control, role }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const canvas = document.querySelector('canvas'); + const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder; + const rawCard = role ? order?.commandCards?.[role] : undefined; + const cardBounds = rawCard && typeof rawCard === 'object' && 'bounds' in rawCard ? rawCard.bounds : rawCard; + const bounds = control === 'open' ? order?.live?.commandBounds : cardBounds; + if (!scene || !canvas || !bounds) { + return { ready: false, control, role: role ?? null, bounds: bounds ?? null, order }; + } + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + control, + role: role ?? null, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, { control, role }); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected a visible battle order-command ${control} control: ${JSON.stringify(probe)}` + ); + return probe; +} + async function waitForBattleOutcome(page, outcome) { await page.waitForFunction((expectedOutcome) => { const state = window.__HEROS_DEBUG__?.battle(); @@ -3178,6 +3354,23 @@ function sameJsonValue(value, expected) { return stableJson(value) === stableJson(expected); } +function sameSortieOrderCommand(value, expected) { + const commandFields = [ + 'source', + 'role', + 'actorId', + 'usedTurn', + 'effectPending', + 'effectValue', + 'statusesCleared', + 'effectLost' + ]; + const snapshot = (command) => command + ? Object.fromEntries(commandFields.map((field) => [field, command[field]])) + : null; + return sameJsonValue(snapshot(value), snapshot(expected)); +} + function stableJson(value) { return JSON.stringify(normalize(value)); diff --git a/src/game/data/sortieOrders.ts b/src/game/data/sortieOrders.ts index 1b4c92d..ba49e1b 100644 --- a/src/game/data/sortieOrders.ts +++ b/src/game/data/sortieOrders.ts @@ -23,6 +23,20 @@ export type CampaignSortieOrderProgressSnapshot = { target: number; }; +export type CampaignSortieOrderCommandSource = 'sortie-order' | 'initiative'; +export type CampaignSortieOrderCommandRole = 'front' | 'flank' | 'support'; + +export type CampaignSortieOrderCommandSnapshot = { + source: CampaignSortieOrderCommandSource; + role: CampaignSortieOrderCommandRole; + actorId: string; + usedTurn: number; + effectPending: boolean; + effectValue: number; + statusesCleared: number; + effectLost: boolean; +}; + export type CampaignSortieOrderResultSnapshot = { version: 1; orderId: CampaignSortieOrderId; @@ -30,6 +44,7 @@ export type CampaignSortieOrderResultSnapshot = { achieved: boolean; progress: CampaignSortieOrderProgressSnapshot[]; rewardGranted: boolean; + command?: CampaignSortieOrderCommandSnapshot; }; export type SortieOrderResultSnapshot = CampaignSortieOrderResultSnapshot; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index cc6914a..a198408 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1639,6 +1639,7 @@ type IntentCounterplayActionOutcome = { }; type TacticalInitiativeRole = BattleSaveTacticalCommandRole; +type TacticalCommandSource = NonNullable; type TacticalInitiativeCombatEffect = { role?: TacticalInitiativeRole; @@ -3259,6 +3260,7 @@ export class BattleScene extends Phaser.Scene { private sortieOrderHudObjects: Phaser.GameObjects.GameObject[] = []; private sortieOrderHudStampObjects: Phaser.GameObjects.GameObject[] = []; private sortieOrderHudBounds?: SortieOrderHudBounds; + private sortieOrderHudCommandBounds?: SortieOrderHudBounds; private sortieOrderHudCriterionBounds: Partial> = {}; private sortieOrderHudSnapshot?: SortieOrderHudSnapshot; private sortieOrderHudPreviousAchieved = new Map(); @@ -3304,6 +3306,11 @@ export class BattleScene extends Phaser.Scene { private enemyIntentVisuals: EnemyIntentVisual[] = []; private intentCounterplayEvents: IntentCounterplayEvent[] = []; private tacticalCommand?: BattleSaveTacticalCommandState; + private sortieOrderCommandUnlocked = false; + private sortieOrderCommandUnlockTurn?: number; + private suppressSortieOrderCommandUnlock = false; + private tacticalInitiativePanelBounds?: SortieOrderHudBounds; + private tacticalInitiativeCardBounds: Partial> = {}; private battleLog: string[] = []; private enemyHomeTiles = new Map(); private battleStats = new Map(); @@ -3378,6 +3385,9 @@ export class BattleScene extends Phaser.Scene { this.applyDebugStatusUiSetup(); this.enemyHomeTiles = this.createEnemyHomeTiles(); this.tacticalCommand = undefined; + this.sortieOrderCommandUnlocked = false; + this.sortieOrderCommandUnlockTurn = undefined; + this.suppressSortieOrderCommandUnlock = false; this.battleStats = this.createBattleStats(); this.applyDebugTacticalInitiativeSetup(); this.enemyUsableUseKeys.clear(); @@ -3747,12 +3757,24 @@ export class BattleScene extends Phaser.Scene { } private applyDebugTacticalInitiativeSetup() { - if ( - !this.isFirstPursuitRoleEffectBattle() || - !this.debugToolsEnabled() || - typeof window === 'undefined' || - new URLSearchParams(window.location.search).get('debugInitiativeReady') !== '1' - ) { + if (!this.debugToolsEnabled() || typeof window === 'undefined') { + return; + } + const params = new URLSearchParams(window.location.search); + if (params.get('debugOrderCommandReady') === '1' && battleScenario.sortie) { + const actor = this.sortieRoleAssignments()[0]?.unit; + if (actor) { + const stats = this.statsFor(actor.id); + stats.actions = Math.max(1, stats.actions); + stats.defeats = Math.max(stats.defeats, battleUnits.filter((unit) => unit.faction === 'enemy').length); + } + const supportTarget = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0 && unit.hp === unit.maxHp); + if (supportTarget) { + supportTarget.hp = Math.max(1, supportTarget.maxHp - Math.min(tacticalInitiativeSupportHeal, Math.max(1, supportTarget.maxHp - 1))); + } + return; + } + if (!this.isFirstPursuitRoleEffectBattle() || params.get('debugInitiativeReady') !== '1') { return; } const actor = this.firstPursuitRoleAssignments()[0]?.unit; @@ -3997,7 +4019,7 @@ export class BattleScene extends Phaser.Scene { return this.tacticalCommand ? 0 : Math.min(tacticalInitiativeThreshold, this.tacticalInitiativeCounterplayCount()); } - private tacticalInitiativeReady() { + private legacyTacticalInitiativeReady() { return ( this.isFirstPursuitRoleEffectBattle() && !this.tacticalCommand && @@ -4005,21 +4027,45 @@ export class BattleScene extends Phaser.Scene { ); } + private sortieOrderCommandReady() { + return Boolean(battleScenario.sortie && this.sortieOrderCommandUnlocked && !this.tacticalCommand); + } + + private tacticalInitiativeReady() { + return this.sortieOrderCommandReady() || this.legacyTacticalInitiativeReady(); + } + + private tacticalCommandSource(): TacticalCommandSource | undefined { + if (this.tacticalCommand) { + return this.tacticalCommand.source ?? 'initiative'; + } + if (this.sortieOrderCommandReady()) { + return 'sortie-order'; + } + if (this.legacyTacticalInitiativeReady()) { + return 'initiative'; + } + return undefined; + } + + private tacticalCommandPanelSource(): TacticalCommandSource { + return this.tacticalCommandSource() ?? (this.isFirstPursuitRoleEffectBattle() ? 'initiative' : 'sortie-order'); + } + private tacticalInitiativeActor(role: TacticalInitiativeRole) { - return this.firstPursuitRoleAssignments().find((entry) => entry.role === role && entry.unit.hp > 0)?.unit; + return this.sortieRoleAssignments().find((entry) => entry.role === role && entry.unit.hp > 0)?.unit; } private tacticalInitiativeActorRolesForCampaign(campaign?: CampaignState) { - if (!this.isFirstPursuitRoleEffectBattle() || !campaign) { + if (!battleScenario.sortie) { return undefined; } const assignments = { ...this.scenarioRecommendedSortieFormationAssignments(), - ...campaign.sortieFormationAssignments + ...(campaign?.sortieFormationAssignments ?? this.effectiveSortieFormationAssignments(getCampaignState())) }; - return firstPursuitBrotherUnitIds.reduce>((roles, unitId) => { - const unit = battleUnits.find((candidate) => candidate.id === unitId && candidate.faction === 'ally'); - if (!unit) { + return battleUnits.reduce>((roles, unit) => { + if (unit.faction !== 'ally') { return roles; } const role = assignments[unit.id] ?? this.recommendedSortieFormationRole(unit); @@ -4032,7 +4078,7 @@ export class BattleScene extends Phaser.Scene { private tacticalInitiativeCommandActorMatchesRole(command: BattleSaveTacticalCommandState) { const actor = battleUnits.find((unit) => unit.id === command.actorId && unit.faction === 'ally'); - return Boolean(actor && this.firstPursuitRoleForUnit(actor) === command.role); + return Boolean(actor && this.sortieRoleForUnit(actor) === command.role); } private tacticalInitiativeCommandLabel(role: TacticalInitiativeRole, compact = false) { @@ -4099,7 +4145,7 @@ export class BattleScene extends Phaser.Scene { }; const command = this.tacticalCommand; if ( - !this.isFirstPursuitRoleEffectBattle() || + !battleScenario.sortie || ignoreInitiative || !command?.effectPending || !this.tacticalInitiativeCommandActorMatchesRole(command) || @@ -4485,6 +4531,7 @@ export class BattleScene extends Phaser.Scene { this.sortieOrderHudObjects.forEach((object) => object.destroy()); this.sortieOrderHudObjects = []; this.sortieOrderHudBounds = undefined; + this.sortieOrderHudCommandBounds = undefined; this.sortieOrderHudCriterionBounds = {}; if (!clearStamps) { return; @@ -4507,6 +4554,8 @@ export class BattleScene extends Phaser.Scene { return; } + this.tryUnlockSortieOrderCommand(evaluation); + const snapshot = this.sortieOrderHudSnapshotFor(evaluation); const suppressAnimation = this.sortieOrderHudSuppressNextAnimation || this.sortieOrderHudPreviousAchieved.size === 0; const newlyAchieved = snapshot.criteria.filter((criterion) => ( @@ -4542,7 +4591,7 @@ export class BattleScene extends Phaser.Scene { outer.setDepth(depth); outer.setStrokeStyle(1, this.sortieOrderHudTone(snapshot.status), snapshot.status === 'warning' || snapshot.status === 'failed' ? 0.96 : 0.74); - const headerWidth = 68; + const headerWidth = 96; const header = this.trackSortieOrderHudObject(this.add.rectangle(left, top, headerWidth, height, 0x241f18, 0.98)); header.setOrigin(0); header.setDepth(depth + 1); @@ -4553,12 +4602,36 @@ export class BattleScene extends Phaser.Scene { color: '#f2e3bf', fontStyle: '700' })).setDepth(depth + 2); - this.trackSortieOrderHudObject(this.add.text(left + 8, top + 20, snapshot.victoryLabel, { + const commandReady = this.tacticalInitiativeReady(); + const commandUsed = Boolean(this.tacticalCommand); + if (commandReady) { + header.setStrokeStyle(1, palette.gold, 0.96); + } + const commandLabel = commandUsed + ? `${this.tacticalInitiativeCommandLabel(this.tacticalCommand!.role, true)} 사용` + : commandReady + ? '명령 가능 · T' + : snapshot.victoryLabel; + const commandText = this.trackSortieOrderHudObject(this.add.text(left + 8, top + 20, commandLabel, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '9px', - color: this.sortieOrderHudTextColor(snapshot.victoryStatus), + color: commandReady ? '#ffdf7b' : commandUsed ? '#d8b15f' : this.sortieOrderHudTextColor(snapshot.victoryStatus), fontStyle: '700' - })).setDepth(depth + 2); + })); + commandText.setDepth(depth + 2); + this.sortieOrderHudCommandBounds = { x: left, y: top, width: headerWidth, height }; + if (commandReady) { + const open = () => { + this.suppressNextLeftClick = true; + this.showTacticalInitiativePanel(); + }; + header.setInteractive({ useHandCursor: true }); + commandText.setInteractive({ useHandCursor: true }); + header.on('pointerover', () => header.setFillStyle(0x4a3817, 1)); + header.on('pointerout', () => header.setFillStyle(0x241f18, 0.98)); + header.on('pointerdown', open); + commandText.on('pointerdown', open); + } const criterionWidth = (width - headerWidth) / Math.max(1, snapshot.criteria.length); snapshot.criteria.forEach((criterion, index) => { @@ -4711,9 +4784,9 @@ export class BattleScene extends Phaser.Scene { ? '철벽 대기' : '맹진 대기' : used - ? '명령 완료' + ? `${this.tacticalInitiativeCommandLabel(this.tacticalCommand!.role, true)} 사용` : ready - ? '전술 명령' + ? this.tacticalCommandSource() === 'sortie-order' ? '군령 발동' : '전술 명령' : `주도 ${diamonds}`; const tone = lost ? 0xc46b55 : pending ? palette.gold : used ? 0x647485 : ready ? palette.gold : palette.blue; const canOpen = @@ -4758,7 +4831,7 @@ export class BattleScene extends Phaser.Scene { private showTacticalInitiativePanel() { if ( - !this.isFirstPursuitRoleEffectBattle() || + !battleScenario.sortie || this.activeFaction !== 'ally' || (this.phase !== 'idle' && this.phase !== 'moving') || this.saveSlotPanelObjects.length > 0 || @@ -4798,12 +4871,18 @@ export class BattleScene extends Phaser.Scene { panel.setStrokeStyle(2, this.tacticalInitiativeReady() ? palette.gold : 0x647485, 0.92); panel.setInteractive(); this.trackTacticalInitiativePanelObject(panel); + this.tacticalInitiativePanelBounds = { x: left, y: top, width: panelWidth, height: panelHeight }; + this.tacticalInitiativeCardBounds = {}; + const panelSource = this.tacticalCommandPanelSource(); + const sourceTitle = panelSource === 'sortie-order' ? '군령 발동' : '전술 주도권'; const panelTitle = this.tacticalCommand - ? '전술 명령 · 전황 기록' + ? `${sourceTitle} · 전황 기록` : this.tacticalInitiativeReady() - ? '전술 명령 · 하나 선택' - : '전술 명령 · 주도권'; + ? `${sourceTitle} · 하나 선택` + : panelSource === 'sortie-order' + ? `${sourceTitle} · 조건 대기` + : `${sourceTitle} · 파훼 대기`; const title = this.add.text(left + 20, top + 14, panelTitle, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '22px', @@ -4820,8 +4899,12 @@ export class BattleScene extends Phaser.Scene { : this.tacticalCommand ? `사용 완료 · ${this.tacticalInitiativeCommandLabel(this.tacticalCommand.role)} · ${this.unitName(this.tacticalCommand.actorId)}` : this.tacticalInitiativeReady() - ? `주도권 ${tacticalInitiativeThreshold}/${tacticalInitiativeThreshold} · 전투당 한 번` - : `주도권 ${this.tacticalInitiativePoints()}/${tacticalInitiativeThreshold} · 파훼 ${tacticalInitiativeThreshold - this.tacticalInitiativePoints()}회 더 필요`; + ? panelSource === 'sortie-order' + ? `${this.sortieOrderLabel(this.launchSortieOrderId)} 군령 달성 · 전투당 한 번` + : `주도권 ${tacticalInitiativeThreshold}/${tacticalInitiativeThreshold} · 전투당 한 번` + : panelSource === 'sortie-order' + ? `${this.sortieOrderLabel(this.launchSortieOrderId)} 비승리 조건 달성 후 사용` + : `주도권 ${this.tacticalInitiativePoints()}/${tacticalInitiativeThreshold} · 파훼 ${tacticalInitiativeThreshold - this.tacticalInitiativePoints()}회 더 필요`; const subtitle = this.add.text(left + panelWidth - 20, top + 19, subtitleText, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '12px', @@ -4871,6 +4954,7 @@ export class BattleScene extends Phaser.Scene { const available = this.canUseTacticalInitiativeCommand(role); const definition = sortieRoleEffects[role]; const supportTarget = role === 'support' ? this.tacticalInitiativeSupportTarget() : undefined; + this.tacticalInitiativeCardBounds[role] = { x, y, width, height }; const bg = this.add.rectangle(x, y, width, height, selected ? 0x3a2d14 : available ? 0x172b35 : 0x111820, 0.98); bg.setOrigin(0); bg.setDepth(depth); @@ -4939,7 +5023,7 @@ export class BattleScene extends Phaser.Scene { : this.tacticalCommand ? '선택 종료' : !this.tacticalInitiativeReady() - ? '주도권 부족' + ? this.tacticalCommandPanelSource() === 'sortie-order' ? '군령 대기' : '주도권 부족' : !actor ? '담당 퇴각' : role === 'support' && !supportTarget @@ -4980,6 +5064,7 @@ export class BattleScene extends Phaser.Scene { } this.tacticalCommand = { + source: this.tacticalCommandSource() ?? this.tacticalCommandPanelSource(), role, actorId: actor.id, usedTurn: this.turnNumber, @@ -5022,11 +5107,13 @@ export class BattleScene extends Phaser.Scene { ); } - const message = `전술 명령 · ${this.tacticalInitiativeCommandLabel(role)} · ${actor.name}\n${effectText}`; + const sourceLabel = this.tacticalCommand.source === 'sortie-order' ? '군령 발동' : '전술 주도권'; + const message = `${sourceLabel} · ${this.tacticalInitiativeCommandLabel(role)} · ${actor.name}\n${effectText}`; this.hideTacticalInitiativePanel(); soundDirector.playStrategyPulse(); this.pushBattleLog(message); this.renderTacticalInitiativeChip(); + this.renderSortieOrderHud(); this.refreshEnemyIntentForecast(); if (this.phase === 'moving' && this.selectedUnit) { this.renderUnitDetail(this.selectedUnit, `${message}\n이동할 파란 칸을 선택하세요.`); @@ -5038,6 +5125,8 @@ export class BattleScene extends Phaser.Scene { private hideTacticalInitiativePanel() { this.tacticalInitiativePanelObjects.forEach((object) => object.destroy()); this.tacticalInitiativePanelObjects = []; + this.tacticalInitiativePanelBounds = undefined; + this.tacticalInitiativeCardBounds = {}; } private trackTacticalInitiativePanelObject(object: T) { @@ -5058,7 +5147,7 @@ export class BattleScene extends Phaser.Scene { return true; } - if (event.key.toLowerCase() !== 't' || !this.isFirstPursuitRoleEffectBattle()) { + if (event.key.toLowerCase() !== 't' || !battleScenario.sortie) { return false; } event.preventDefault(); @@ -7689,17 +7778,43 @@ export class BattleScene extends Phaser.Scene { } private tacticalInitiativeDebugState() { + const cards = this.tacticalCommandCardsDebugState(); return { active: this.isFirstPursuitRoleEffectBattle(), points: this.tacticalInitiativePoints(), threshold: tacticalInitiativeThreshold, ready: this.tacticalInitiativeReady(), + unlocked: this.sortieOrderCommandUnlocked, + unlockTurn: this.sortieOrderCommandUnlockTurn ?? null, + source: this.tacticalCommandSource() ?? null, + actorId: this.tacticalCommand?.actorId ?? null, totalCounterplays: this.tacticalInitiativeCounterplayCount(), command: this.tacticalCommand ? { ...this.tacticalCommand } : null, - panelOpen: this.tacticalInitiativePanelObjects.length > 0 + panelOpen: this.tacticalInitiativePanelObjects.length > 0, + panelBounds: this.tacticalInitiativePanelBounds ? { ...this.tacticalInitiativePanelBounds } : null, + cards }; } + private tacticalCommandCardsDebugState() { + return Object.fromEntries((['front', 'flank', 'support'] as TacticalInitiativeRole[]).map((role) => { + const selected = this.tacticalCommand?.role === role; + const assignedActor = this.tacticalInitiativeActor(role); + const recordedActor = selected + ? battleUnits.find((unit) => unit.id === this.tacticalCommand?.actorId && unit.faction === 'ally') + : undefined; + const actor = recordedActor ?? assignedActor; + return [role, { + role, + label: this.tacticalInitiativeCommandLabel(role), + actorId: actor?.id ?? null, + actorName: actor?.name ?? null, + enabled: this.canUseTacticalInitiativeCommand(role), + bounds: this.tacticalInitiativeCardBounds[role] ? { ...this.tacticalInitiativeCardBounds[role]! } : null + }]; + })); + } + private commandAccentColor(command: BattleCommand) { if (command === 'attack') { return 0xd8b15f; @@ -10507,27 +10622,33 @@ export class BattleScene extends Phaser.Scene { return true; } + const pendingOutcome = this.pendingBattleOutcome(); + if (!pendingOutcome) { + return false; + } + this.completeBattle(pendingOutcome, resultDelayMs); + return true; + } + + private pendingBattleOutcome(): BattleOutcome | undefined { const defeatTriggered = battleScenario.defeatConditions.some((condition) => { return condition.kind === 'unit-defeated' && !this.isUnitAlive(condition.unitId); }); if (defeatTriggered) { - this.completeBattle('defeat', resultDelayMs); - return true; + return 'defeat'; } const leader = battleUnits.find((unit) => unit.id === leaderUnitId); if (leader && leader.hp <= 0) { - this.completeBattle('victory', resultDelayMs); - return true; + return 'victory'; } const enemiesAlive = battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0); if (!enemiesAlive) { - this.completeBattle('victory', resultDelayMs); - return true; + return 'victory'; } - return false; + return undefined; } private completeBattle(outcome: BattleOutcome, resultDelayMs = 0) { @@ -11100,10 +11221,29 @@ export class BattleScene extends Phaser.Scene { performance: CampaignSortiePerformanceSnapshot, review: SortiePerformanceReview ) { - return evaluateSortieOrder( + const result = evaluateSortieOrder( this.launchSortieOrderId, this.sortieOrderEvaluationInput(outcome === 'victory', objectives, performance, review) ); + const command = this.resultSortieOrderCommandSnapshot(); + return command ? { ...result, command } : result; + } + + private resultSortieOrderCommandSnapshot() { + const command = this.tacticalCommand; + if (!command || !this.tacticalInitiativeCommandActorMatchesRole(command)) { + return undefined; + } + return { + source: command.source ?? 'initiative' as TacticalCommandSource, + role: command.role, + actorId: command.actorId, + usedTurn: Math.max(1, command.usedTurn), + effectPending: Boolean(command.effectPending), + effectValue: Math.max(0, command.effectValue), + statusesCleared: Math.max(0, command.statusesCleared), + effectLost: Boolean(command.effectLost) + }; } private sortieOrderEvaluationInput( @@ -11144,6 +11284,45 @@ export class BattleScene extends Phaser.Scene { ); } + private alliedMeaningfulActionCount() { + return battleUnits + .filter((unit) => unit.faction === 'ally') + .reduce((total, unit) => total + this.statsFor(unit.id).actions, 0); + } + + private sortieOrderCommandCriteriaMet(evaluation: SortieOrderProgressEvaluationSnapshot) { + const criteria = evaluation.progress.filter((progress) => progress.id !== 'victory'); + return criteria.length > 0 && criteria.every((progress) => progress.achieved); + } + + private tryUnlockSortieOrderCommand(evaluation?: SortieOrderProgressEvaluationSnapshot) { + if ( + this.sortieOrderCommandUnlocked || + this.tacticalCommand || + this.suppressSortieOrderCommandUnlock || + !battleScenario.sortie || + this.phase === 'deployment' || + this.phase === 'resolved' || + this.battleOutcome || + this.pendingBattleOutcome() || + this.alliedMeaningfulActionCount() <= 0 + ) { + return false; + } + const liveEvaluation = evaluation ?? this.liveSortieOrderEvaluation(); + if (!liveEvaluation || !this.sortieOrderCommandCriteriaMet(liveEvaluation)) { + return false; + } + + this.sortieOrderCommandUnlocked = true; + this.sortieOrderCommandUnlockTurn = this.turnNumber; + const message = `군령 발동 가능 · ${this.sortieOrderLabel(this.launchSortieOrderId)} · T`; + this.pushBattleLog(message); + soundDirector.playGrowthTick(); + this.renderTacticalInitiativeChip(); + return true; + } + private sortieOrderHudSnapshotFor(evaluation: SortieOrderProgressEvaluationSnapshot): SortieOrderHudSnapshot { const criteria = evaluation.progress .filter((progress) => progress.id !== 'victory') @@ -11302,7 +11481,7 @@ export class BattleScene extends Phaser.Scene { const depth = 132; const width = 820; const rowHeight = 46; - const height = 178 + result.progress.length * rowHeight; + const height = 178 + (result.progress.length + 1) * rowHeight; const x = Math.floor((this.scale.width - width) / 2); const y = Math.floor((this.scale.height - height) / 2); const accent = result.achieved ? 0x59d18c : 0xc87552; @@ -11342,6 +11521,33 @@ export class BattleScene extends Phaser.Scene { .setDepth(depth + 2); }); + const command = result.command; + const commandRowY = y + 120 + result.progress.length * rowHeight; + const commandRow = this.trackResultSortieOrder(this.add.rectangle(x + 20, commandRowY, width - 40, 38, 0x111c26, 0.94)); + commandRow.setOrigin(0); + commandRow.setDepth(depth + 1); + commandRow.setStrokeStyle(1, command ? palette.gold : 0x647485, 0.46); + this.trackResultSortieOrder(this.add.text(x + 34, commandRowY + 9, command ? '사용' : '미사용', this.resultFormationTextStyle(12, command ? '#ffdf7b' : '#9fb0bf', true))) + .setDepth(depth + 2); + const commandSource = command?.source === 'sortie-order' ? '군령 발동' : command ? '전술 주도권' : '전술 명령'; + this.trackResultSortieOrder(this.add.text( + x + 92, + commandRowY + 9, + command ? `${commandSource} · ${this.tacticalInitiativeCommandLabel(command.role)}` : commandSource, + this.resultFormationTextStyle(12, '#f2e3bf', true) + )).setDepth(depth + 2); + const nonVictoryCriteria = result.progress.filter((progress) => progress.id !== 'victory'); + const commandDetail = command + ? `${this.unitName(command.actorId)} · ${command.usedTurn}턴 · ${this.resultSortieOrderCommandEffect(command)}` + : this.tacticalInitiativeReady() + ? '명령 가능 상태에서 미사용' + : nonVictoryCriteria.length > 0 && nonVictoryCriteria.every((progress) => progress.achieved) + ? '전투 종료로 발동 기회 없음' + : '발동 조건 미충족'; + this.trackResultSortieOrder(this.add.text(x + width - 34, commandRowY + 9, commandDetail, this.resultFormationTextStyle(12, '#d4dce6', true))) + .setOrigin(1, 0) + .setDepth(depth + 2); + const rewardY = y + height - 46; const definition = sortieOrderDefinition(result.orderId); const rewardDetail = `군자금 +${definition.rewardGold} · ${definition.rewardItems.join(', ')}`; @@ -11360,6 +11566,22 @@ export class BattleScene extends Phaser.Scene { this.resultSortieOrderView = { closeButton }; } + private resultSortieOrderCommandEffect(command: NonNullable) { + if (command.effectLost) { + return '효과 상실'; + } + if (command.effectPending) { + return '효과 대기'; + } + if (command.role === 'front') { + return `피해 -${command.effectValue}`; + } + if (command.role === 'flank') { + return `추가 피해 +${command.effectValue}`; + } + return `회복 ${command.effectValue}${command.statusesCleared > 0 ? ` · 해제 ${command.statusesCleared}` : ''}`; + } + private hideResultSortieOrder() { this.resultSortieOrderObjects.forEach((object) => object.destroy()); this.resultSortieOrderObjects = []; @@ -14468,7 +14690,8 @@ export class BattleScene extends Phaser.Scene { const savedCampaign = readCampaignSaveState(normalizedSlot); return parseBattleSaveState(raw, { expectedBattleId: battleScenario.id, - allowTacticalCommand: this.isFirstPursuitRoleEffectBattle(), + allowTacticalCommand: Boolean(battleScenario.sortie), + allowLegacyTacticalCommand: this.isFirstPursuitRoleEffectBattle(), mapWidth: battleMap.width, mapHeight: battleMap.height, validUnitIds: new Set(battleUnits.map((unit) => unit.id)), @@ -14578,7 +14801,9 @@ export class BattleScene extends Phaser.Scene { battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })), battleStatuses: this.serializeBattleStatuses(), battleStats: this.serializeBattleStats(), - tacticalCommand: this.isFirstPursuitRoleEffectBattle() && this.tacticalCommand ? { ...this.tacticalCommand } : undefined, + tacticalCommand: battleScenario.sortie && this.tacticalCommand ? { ...this.tacticalCommand } : undefined, + sortieOrderCommandUnlocked: this.sortieOrderCommandUnlocked, + sortieOrderCommandUnlockTurn: this.sortieOrderCommandUnlockTurn, enemyUsableUseKeys: Array.from(this.enemyUsableUseKeys), triggeredBattleEvents: Array.from(this.triggeredBattleEvents) }; @@ -14623,10 +14848,14 @@ export class BattleScene extends Phaser.Scene { this.battleStatuses = this.deserializeBattleStatuses(state.battleStatuses); this.battleStats = this.deserializeBattleStats(state.battleStats); this.intentCounterplayEvents = []; - this.tacticalCommand = this.isFirstPursuitRoleEffectBattle() && + this.tacticalCommand = battleScenario.sortie && state.tacticalCommand && this.tacticalInitiativeCommandActorMatchesRole(state.tacticalCommand) - ? { ...state.tacticalCommand } + ? { ...state.tacticalCommand, source: state.tacticalCommand.source ?? 'initiative' } + : undefined; + this.sortieOrderCommandUnlocked = Boolean(state.sortieOrderCommandUnlocked); + this.sortieOrderCommandUnlockTurn = this.sortieOrderCommandUnlocked + ? Math.max(1, state.sortieOrderCommandUnlockTurn ?? this.turnNumber) : undefined; this.enemyUsableUseKeys = new Set(state.enemyUsableUseKeys ?? []); this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []); @@ -14656,8 +14885,11 @@ export class BattleScene extends Phaser.Scene { this.updateCameraView(); } this.resetActedStyles(); + this.suppressSortieOrderCommandUnlock = true; this.updateTurnText(); this.updateObjectiveTracker(); + this.suppressSortieOrderCommandUnlock = false; + this.renderSortieOrderHud(); this.renderRosterPanel(this.rosterTab, '저장된 전투 상태입니다.'); this.refreshEnemyIntentForecast(); } @@ -20987,6 +21219,14 @@ export class BattleScene extends Phaser.Scene { victoryStatus: snapshot.victoryStatus, victoryTone: snapshot.victoryStatus, projectedScore: snapshot.projectedScore, + commandReady: this.tacticalInitiativeReady(), + commandUnlocked: this.sortieOrderCommandUnlocked, + commandUnlockTurn: this.sortieOrderCommandUnlockTurn ?? null, + commandUsed: Boolean(this.tacticalCommand), + commandSource: this.tacticalCommandSource() ?? null, + commandActorId: this.tacticalCommand?.actorId ?? null, + command: this.tacticalCommand ? { ...this.tacticalCommand } : null, + commandBounds: this.sortieOrderHudCommandBounds ? { ...this.sortieOrderHudCommandBounds } : null, bounds: this.sortieOrderHudBounds ? { ...this.sortieOrderHudBounds } : null, criteria: snapshot.criteria.map((criterion) => ({ ...criterion, @@ -21004,16 +21244,28 @@ export class BattleScene extends Phaser.Scene { private resultSortieOrderDebugState() { const live = this.sortieOrderHudDebugState(); + const commandCards = this.tacticalCommandCardsDebugState(); return { available: true, selectedId: this.launchSortieOrderId, live, hud: live, + commandReady: this.tacticalInitiativeReady(), + commandUnlocked: this.sortieOrderCommandUnlocked, + commandUnlockTurn: this.sortieOrderCommandUnlockTurn ?? null, + commandUsed: Boolean(this.tacticalCommand), + commandSource: this.tacticalCommandSource() ?? null, + commandActorId: this.tacticalCommand?.actorId ?? null, + command: this.tacticalCommand ? { ...this.tacticalCommand } : null, + commandPanelOpen: this.tacticalInitiativePanelObjects.length > 0, + commandPanelBounds: this.tacticalInitiativePanelBounds ? { ...this.tacticalInitiativePanelBounds } : null, + commandCards, open: this.resultSortieOrderVisible, result: this.resultSortieOrder ? { ...this.resultSortieOrder, - progress: this.resultSortieOrder.progress.map((progress) => ({ ...progress })) + progress: this.resultSortieOrder.progress.map((progress) => ({ ...progress })), + command: this.resultSortieOrder.command ? { ...this.resultSortieOrder.command } : undefined } : null, toggleButtonBounds: this.resultObjectBoundsDebug(this.resultSortieOrderButton?.background), diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index f9c386a..ef4d684 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -52,6 +52,7 @@ import { sortieOrderDefinitions, summarizeSortieOrderHistory, type CampaignSortieOrderId, + type CampaignSortieOrderCommandSnapshot, type CampaignSortieOrderHistory, type CampaignSortieOrderProgressSnapshot, type CampaignSortieOrderResultSnapshot, @@ -17021,6 +17022,17 @@ export class CampScene extends Phaser.Scene { ); const sortieOrderResult = this.campaign?.battleHistory[report.battleId]?.sortieOrder ?? report.sortieOrder; + if (sortieOrderResult?.command) { + const commandRecord = this.sortieOrderCommandRecordLine(sortieOrderResult.command); + this.track( + this.add.text( + x + 126, + y + 17, + this.compactText(`명령 기록 · ${commandRecord}`, 57), + this.textStyle(10, '#a8ffd0', true) + ) + ); + } if (sortieOrderResult?.rewardGranted) { const definition = sortieOrderDefinition(sortieOrderResult.orderId); const rewardBadge = this.track(this.add.rectangle(x + 998, y + 11, 354, 18, 0x2d3022, 0.98)); @@ -17171,7 +17183,11 @@ export class CampScene extends Phaser.Scene { unitNames: Object.fromEntries(settlement.units.map((unit) => [unit.unitId, unit.name])), endUnits: Object.fromEntries(settlement.units.map((unit) => [unit.unitId, { hp: unit.hp, maxHp: unit.maxHp }])), sortieOrder: settlement.sortieOrder - ? { ...settlement.sortieOrder, progress: settlement.sortieOrder.progress.map((entry) => ({ ...entry })) } + ? { + ...settlement.sortieOrder, + progress: settlement.sortieOrder.progress.map((entry) => ({ ...entry })), + ...(settlement.sortieOrder.command ? { command: { ...settlement.sortieOrder.command } } : {}) + } : undefined }; } @@ -17964,6 +17980,16 @@ export class CampScene extends Phaser.Scene { : `보완 · ${this.compactText(review.improvement, 62)}`; this.trackReportFormationHistory(this.add.text(x + 84, y + 70, this.compactText(secondaryLine, 62), this.textStyle(11, record.sortieOrder?.achieved ? '#a8ffd0' : '#ffdf7b', true))) .setDepth(depth + 1); + if (record.sortieOrder?.command) { + this.trackReportFormationHistory( + this.add.text( + x + 84, + y + 86, + this.compactText(`명령 기록 · ${this.sortieOrderCommandRecordLine(record.sortieOrder.command, record.unitNames)}`, 62), + this.textStyle(10, '#d8b15f', true) + ) + ).setDepth(depth + 1); + } const metrics = [ { label: '목표', value: `${review.objectiveAchieved}/${review.objectiveTotal}` }, @@ -17973,8 +17999,9 @@ export class CampScene extends Phaser.Scene { ]; const metricGap = 8; const metricWidth = Math.floor((width - 36 - metricGap * 3) / 4); + const metricTop = y + (record.sortieOrder?.command ? 102 : 98); metrics.forEach((metric, index) => { - this.renderReportFormationHistoryMetric(metric.label, metric.value, x + 18 + index * (metricWidth + metricGap), y + 98, metricWidth, 44, depth + 1); + this.renderReportFormationHistoryMetric(metric.label, metric.value, x + 18 + index * (metricWidth + metricGap), metricTop, metricWidth, 44, depth + 1); }); const visibleIds = review.snapshot.selectedUnitIds.slice(0, 6); @@ -18008,6 +18035,62 @@ export class CampScene extends Phaser.Scene { .join(' · '); } + private sortieOrderCommandLabel(command: CampaignSortieOrderCommandSnapshot) { + if (command.role === 'front') { + return '철벽'; + } + if (command.role === 'flank') { + return '맹진'; + } + return '재정비'; + } + + private sortieOrderCommandSourceLabel(command: CampaignSortieOrderCommandSnapshot) { + return command.source === 'sortie-order' ? '군령 발동' : '전술 주도권'; + } + + private sortieOrderCommandEffectLine(command: CampaignSortieOrderCommandSnapshot) { + if (command.effectLost) { + return '효과 상실'; + } + if (command.effectPending) { + return '효과 대기'; + } + if (command.role === 'front') { + return `피해 ${command.effectValue} 감소`; + } + if (command.role === 'flank') { + return `추가 피해 ${command.effectValue}`; + } + const statuses = command.statusesCleared > 0 ? ` · 상태 ${command.statusesCleared}개 해제` : ''; + return `회복 ${command.effectValue}${statuses}`; + } + + private sortieOrderCommandRecordLine( + command: CampaignSortieOrderCommandSnapshot, + unitNames: Record = {} + ) { + const actorName = unitNames[command.actorId] ?? this.unitName(command.actorId); + return `${this.sortieOrderCommandLabel(command)} · ${actorName} · ${command.usedTurn}턴 · ${this.sortieOrderCommandSourceLabel(command)} · ${this.sortieOrderCommandEffectLine(command)}`; + } + + private sortieOrderCommandDebug( + command: CampaignSortieOrderCommandSnapshot | undefined, + unitNames: Record = {} + ) { + if (!command) { + return null; + } + return { + ...command, + label: this.sortieOrderCommandLabel(command), + actorName: unitNames[command.actorId] ?? this.unitName(command.actorId), + sourceLabel: this.sortieOrderCommandSourceLabel(command), + effectLine: this.sortieOrderCommandEffectLine(command), + recordLine: this.sortieOrderCommandRecordLine(command, unitNames) + }; + } + private sortieOrderProgressLabel(entry: CampaignSortieOrderProgressSnapshot) { const labels: Record = { victory: '승리', @@ -20015,6 +20098,7 @@ export class CampScene extends Phaser.Scene { : '', progressLine: this.sortieOrderResultProgressLine(reportSortieOrder), progress: reportSortieOrder.progress.map((entry) => ({ ...entry })), + command: this.sortieOrderCommandDebug(reportSortieOrder.command), rewardBadgeBounds: this.sortieObjectBoundsDebug(this.reportSortieOrderRewardBadge) } : { @@ -20027,6 +20111,7 @@ export class CampScene extends Phaser.Scene { rewardLine: '', progressLine: '', progress: [], + command: null, rewardBadgeBounds: null }, reportFormationEvaluation: reportFormationReview @@ -20114,7 +20199,8 @@ export class CampScene extends Phaser.Scene { score: record.sortieOrder.score, rewardGranted: record.sortieOrder.rewardGranted, progressLine: this.sortieOrderResultProgressLine(record.sortieOrder), - progress: record.sortieOrder.progress.map((entry) => ({ ...entry })) + progress: record.sortieOrder.progress.map((entry) => ({ ...entry })), + command: this.sortieOrderCommandDebug(record.sortieOrder.command, record.unitNames) } : null, selected: record.battleId === reportFormationHistorySelected?.battleId, @@ -20149,7 +20235,11 @@ export class CampScene extends Phaser.Scene { score: reportFormationHistorySelected.sortieOrder.score, rewardGranted: reportFormationHistorySelected.sortieOrder.rewardGranted, progressLine: this.sortieOrderResultProgressLine(reportFormationHistorySelected.sortieOrder), - progress: reportFormationHistorySelected.sortieOrder.progress.map((entry) => ({ ...entry })) + progress: reportFormationHistorySelected.sortieOrder.progress.map((entry) => ({ ...entry })), + command: this.sortieOrderCommandDebug( + reportFormationHistorySelected.sortieOrder.command, + reportFormationHistorySelected.unitNames + ) } : null, snapshot: { diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index 59f726d..b875fea 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -56,8 +56,10 @@ export type BattleSaveUnitStats = { }; export type BattleSaveTacticalCommandRole = 'front' | 'flank' | 'support'; +export type BattleSaveTacticalCommandSource = 'sortie-order' | 'initiative'; export type BattleSaveTacticalCommandState = { + source?: BattleSaveTacticalCommandSource; role: BattleSaveTacticalCommandRole; actorId: string; usedTurn: number; @@ -85,6 +87,8 @@ export type BattleSaveState = { battleBuffs?: BattleSaveBuffState[]; battleStatuses?: BattleSaveStatusState[]; battleStats?: Record; + sortieOrderCommandUnlocked?: boolean; + sortieOrderCommandUnlockTurn?: number; tacticalCommand?: BattleSaveTacticalCommandState; enemyUsableUseKeys?: string[]; triggeredBattleEvents?: string[]; @@ -93,6 +97,7 @@ export type BattleSaveState = { type BattleSaveValidationOptions = { expectedBattleId?: string; allowTacticalCommand?: boolean; + allowLegacyTacticalCommand?: boolean; mapWidth?: number; mapHeight?: number; validUnitIds?: ReadonlySet; @@ -204,11 +209,20 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida !isOptionalBuffArray(state.battleBuffs, options) || !isOptionalStatusArray(state.battleStatuses, options) || !isOptionalStatsRecord(state.battleStats, options) || + !isOptionalSortieOrderCommandUnlockState( + state.sortieOrderCommandUnlocked, + state.sortieOrderCommandUnlockTurn, + Number(state.turnNumber), + state.battleStats, + options + ) || !isOptionalTacticalCommandState( state.tacticalCommand, Number(state.turnNumber), units, state.battleStats, + state.sortieOrderCommandUnlocked === true, + state.sortieOrderCommandUnlockTurn, options ) || !isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) || @@ -260,6 +274,10 @@ function isTacticalCommandRole(value: unknown): value is BattleSaveTacticalComma return value === 'front' || value === 'flank' || value === 'support'; } +function isTacticalCommandSource(value: unknown): value is BattleSaveTacticalCommandSource { + return value === 'sortie-order' || value === 'initiative'; +} + function isSortieOrderId(value: unknown): value is SortieOrderId { return value === 'elite' || value === 'mobile' || value === 'siege'; } @@ -572,11 +590,35 @@ function isOptionalBattleUnitStatValue(value: unknown) { return value === undefined || isBattleUnitStatValue(value); } +function isOptionalSortieOrderCommandUnlockState( + unlocked: unknown, + unlockTurn: unknown, + turnNumber: number, + battleStats: unknown, + options: BattleSaveValidationOptions +) { + if (unlocked === undefined && unlockTurn === undefined) { + return true; + } + if (!options.allowTacticalCommand || typeof unlocked !== 'boolean') { + return false; + } + if (!unlocked) { + return unlockTurn === undefined; + } + return ( + isIntegerInRange(unlockTurn, 1, turnNumber) && + alliedActionTotal(battleStats, options) >= 1 + ); +} + function isOptionalTacticalCommandState( value: unknown, turnNumber: number, units: unknown[], battleStats: unknown, + sortieOrderCommandUnlocked: boolean, + sortieOrderCommandUnlockTurn: unknown, options: BattleSaveValidationOptions ) { if (value === undefined) { @@ -587,6 +629,7 @@ function isOptionalTacticalCommandState( } if ( !isRecord(value) || + (value.source !== undefined && !isTacticalCommandSource(value.source)) || !isTacticalCommandRole(value.role) || typeof value.actorId !== 'string' || !isKnownUnitId(value.actorId, options) || @@ -596,11 +639,26 @@ function isOptionalTacticalCommandState( typeof value.effectPending !== 'boolean' || !isBattleUnitStatValue(value.effectValue) || !isBattleUnitStatValue(value.statusesCleared) || - (value.effectLost !== undefined && typeof value.effectLost !== 'boolean') || - tacticalCommandCounterplayTotal(battleStats, options) < tacticalCommandCounterplayThreshold + (value.effectLost !== undefined && typeof value.effectLost !== 'boolean') ) { return false; } + if (value.source === 'sortie-order') { + if ( + !sortieOrderCommandUnlocked || + !isIntegerInRange(sortieOrderCommandUnlockTurn, 1, Number(value.usedTurn)) + ) { + return false; + } + } else { + if ( + !options.allowLegacyTacticalCommand || + sortieOrderCommandUnlocked || + tacticalCommandCounterplayTotal(battleStats, options) < tacticalCommandCounterplayThreshold + ) { + return false; + } + } if (value.role === 'support') { return ( !value.effectPending && @@ -641,6 +699,18 @@ function tacticalCommandCounterplayTotal(value: unknown, options: BattleSaveVali }, 0); } +function alliedActionTotal(value: unknown, options: BattleSaveValidationOptions) { + if (!isRecord(value)) { + return 0; + } + return Object.entries(value).reduce((total, [unitId, stats]) => { + if ((options.validAllyUnitIds && !options.validAllyUnitIds.has(unitId)) || !isRecord(stats)) { + return total; + } + return total + Number(stats.actions ?? 0); + }, 0); +} + function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) { return ( value === undefined || diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 994868e..c10aea3 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -9,6 +9,7 @@ import { sortieOrderDefinition, sortieOrderIds, sortieOrderRewardClaimId, + type CampaignSortieOrderCommandSnapshot, type SortieOrderId, type SortieOrderResultSnapshot } from '../data/sortieOrders'; @@ -363,6 +364,7 @@ export type CampaignBattleSettlement = { battleId: string; battleTitle: string; outcome: FirstBattleReport['outcome']; + turnNumber: number; rewardGold: number; itemRewards: string[]; campaignRewards?: CampaignRewardSnapshot; @@ -791,7 +793,14 @@ export function setFirstBattleReport(report: FirstBattleReport) { reportClone.completedCampVisits = completedCampVisits; let pendingSortieOrderReward: ReturnType | undefined; let pendingSortieOrderClaimId: string | undefined; - const sortieOrder = normalizeCampaignSortieOrderResultSnapshot(reportClone.sortieOrder, reportClone.outcome); + const sortieOrder = normalizeCampaignSortieOrderResultSnapshot( + reportClone.sortieOrder, + reportClone.outcome, + { + performance: normalizeCampaignSortiePerformanceSnapshot(reportClone.sortiePerformance), + turnNumber: normalizeNonNegativeInteger(reportClone.turnNumber) + } + ); if (sortieOrder) { if (reportClone.outcome !== 'victory') { const victoryProgress = sortieOrder.progress.find((entry) => entry.id === 'victory'); @@ -820,7 +829,8 @@ export function setFirstBattleReport(report: FirstBattleReport) { history[sortieOrder.orderId] = { ...bestOrderResult, rewardGranted: Boolean(previousOrderResult?.rewardGranted || sortieOrder.rewardGranted), - progress: bestOrderResult.progress.map((entry) => ({ ...entry })) + progress: bestOrderResult.progress.map((entry) => ({ ...entry })), + ...(bestOrderResult.command ? { command: { ...bestOrderResult.command } } : {}) }; state.sortieOrderHistory[battleId as BattleScenarioId] = history; reportClone.sortieOrder = sortieOrder; @@ -1099,6 +1109,11 @@ function normalizeCampaignState(state: Partial): CampaignState { if (normalized.firstBattleReport && sortieUnitFilter) { normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter); } + normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory( + normalized.sortieOrderHistory, + normalized.battleHistory, + normalized.firstBattleReport + ); return normalized; } @@ -1253,7 +1268,8 @@ function normalizeCampaignSortieOrderSelection(value: unknown): CampaignSortieOr export function normalizeCampaignSortieOrderResultSnapshot( value: unknown, - outcome?: FirstBattleReport['outcome'] + outcome?: FirstBattleReport['outcome'], + commandContext?: CampaignSortieOrderCommandNormalizationContext ): CampaignSortieOrderResultSnapshot | undefined { if (!isPlainObject(value) || value.version !== 1 || !isSortieOrderId(value.orderId)) { return undefined; @@ -1284,24 +1300,85 @@ export function normalizeCampaignSortieOrderResultSnapshot( } } const achieved = progress.every((entry) => entry.achieved); + const normalizedCommand = normalizeCampaignSortieOrderCommandSnapshot(value.command); + const command = normalizedCommand && ( + !commandContext || campaignSortieOrderCommandMatchesContext(normalizedCommand, commandContext) + ) + ? normalizedCommand + : undefined; return { version: 1, orderId, score: Math.min(100, normalizeNonNegativeInteger(value.score)), achieved, progress, - rewardGranted: achieved && value.rewardGranted === true + rewardGranted: achieved && value.rewardGranted === true, + ...(command ? { command } : {}) }; } -function normalizeCampaignSortieOrderHistory(value: unknown): CampaignSortieOrderHistory { +type CampaignSortieOrderCommandNormalizationContext = { + performance?: CampaignSortiePerformanceSnapshot; + turnNumber: number; +}; + +function campaignSortieOrderCommandMatchesContext( + command: CampaignSortieOrderCommandSnapshot, + context: CampaignSortieOrderCommandNormalizationContext +) { + const performance = context.performance; + return Boolean( + performance && + command.usedTurn <= context.turnNumber && + performance.selectedUnitIds.includes(command.actorId) && + performance.formationAssignments[command.actorId] === command.role + ); +} + +function normalizeCampaignSortieOrderCommandSnapshot(value: unknown): CampaignSortieOrderCommandSnapshot | undefined { + if ( + !isPlainObject(value) || + (value.source !== 'sortie-order' && value.source !== 'initiative') || + (value.role !== 'front' && value.role !== 'flank' && value.role !== 'support') + ) { + return undefined; + } + const actorId = normalizeKeyString(value.actorId); + const usedTurn = normalizeNonNegativeInteger(value.usedTurn); + if (!actorId || usedTurn <= 0) { + return undefined; + } + return { + source: value.source, + role: value.role, + actorId, + usedTurn, + effectPending: value.effectPending === true, + effectValue: normalizeNonNegativeInteger(value.effectValue), + statusesCleared: normalizeNonNegativeInteger(value.statusesCleared), + effectLost: value.effectLost === true + }; +} + +function normalizeCampaignSortieOrderHistory( + value: unknown, + battleHistory: Record = {}, + latestReport?: FirstBattleReport +): CampaignSortieOrderHistory { return Object.entries(recordOrEmpty(value)).reduce((history, [battleId, orderResults]) => { if (!(battleId in battleScenarios)) { return history; } const normalizedByOrder = sortieOrderIds.reduce>>( (results, orderId) => { - const result = normalizeCampaignSortieOrderResultSnapshot(recordOrEmpty(orderResults)[orderId]); + const rawResult = recordOrEmpty(orderResults)[orderId]; + const standaloneResult = normalizeCampaignSortieOrderResultSnapshot(rawResult); + const commandContext = standaloneResult + ? campaignSortieOrderHistoryCommandContext(battleId, standaloneResult, battleHistory, latestReport) + : undefined; + const result = commandContext + ? normalizeCampaignSortieOrderResultSnapshot(rawResult, undefined, commandContext) + : standaloneResult; if (result?.orderId === orderId) { results[orderId] = result; } @@ -1316,6 +1393,59 @@ function normalizeCampaignSortieOrderHistory(value: unknown): CampaignSortieOrde }, {}); } +function campaignSortieOrderHistoryCommandContext( + battleId: string, + result: CampaignSortieOrderResultSnapshot, + battleHistory: Record, + latestReport?: FirstBattleReport +): CampaignSortieOrderCommandNormalizationContext | undefined { + const settlement = battleHistory[battleId]; + const candidates = [ + settlement?.sortiePerformance && settlement.sortieOrder + ? { + performance: settlement.sortiePerformance, + turnNumber: settlement.turnNumber, + result: settlement.sortieOrder + } + : undefined, + latestReport?.battleId === battleId && latestReport.sortiePerformance && latestReport.sortieOrder + ? { + performance: latestReport.sortiePerformance, + turnNumber: latestReport.turnNumber, + result: latestReport.sortieOrder + } + : undefined + ]; + const matching = candidates.find((candidate) => ( + candidate && campaignSortieOrderResultsShareEvaluation(candidate.result, result) + )); + return matching + ? { performance: matching.performance, turnNumber: matching.turnNumber } + : undefined; +} + +function campaignSortieOrderResultsShareEvaluation( + left: CampaignSortieOrderResultSnapshot, + right: CampaignSortieOrderResultSnapshot +) { + return ( + left.orderId === right.orderId && + left.score === right.score && + left.achieved === right.achieved && + left.progress.length === right.progress.length && + left.progress.every((entry, index) => { + const other = right.progress[index]; + return Boolean( + other && + entry.id === other.id && + entry.achieved === other.achieved && + entry.value === other.value && + entry.target === other.target + ); + }) + ); +} + function normalizeCampaignSortieOrderRewardClaims( value: unknown, history: CampaignSortieOrderHistory @@ -1370,7 +1500,10 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost ? normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance) : undefined; const sortieOrder = sortieReview - ? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome) + ? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome, { + performance: sortiePerformance, + turnNumber: report.turnNumber + }) : undefined; const filtered = { ...report, @@ -1407,7 +1540,10 @@ function filterBattleHistoryRosterReferences( ? normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance) : undefined; const sortieOrder = sortieReview - ? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome) + ? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome, { + performance: sortiePerformance, + turnNumber: settlement.turnNumber + }) : undefined; filteredHistory[battleId] = { ...settlement, @@ -1452,14 +1588,19 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance); const sortieReview = normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance); + const turnNumber = normalizeNonNegativeInteger(settlement.turnNumber); const sortieOrder = sortieReview - ? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome) + ? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome, { + performance: sortiePerformance, + turnNumber + }) : undefined; return { battleId, battleTitle, outcome: settlement.outcome, + turnNumber, rewardGold: normalizeNonNegativeInteger(settlement.rewardGold), itemRewards: normalizeRewardStrings(settlement.itemRewards), campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards, battleId), @@ -1791,15 +1932,19 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance); const sortieReview = normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance); + const turnNumber = normalizeNonNegativeInteger(report.turnNumber); const sortieOrder = sortieReview - ? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome) + ? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome, { + performance: sortiePerformance, + turnNumber + }) : undefined; return { battleId, battleTitle, outcome: report.outcome, - turnNumber: normalizeNonNegativeInteger(report.turnNumber), + turnNumber, rewardGold: normalizeNonNegativeInteger(report.rewardGold), defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies), totalEnemies: normalizeNonNegativeInteger(report.totalEnemies), @@ -2026,6 +2171,7 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp battleId: report.battleId, battleTitle: report.battleTitle, outcome: report.outcome, + turnNumber: report.turnNumber, rewardGold: report.rewardGold, itemRewards: [...report.itemRewards], campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards, report.battleId), @@ -2070,7 +2216,8 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp ? { sortieOrder: { ...report.sortieOrder, - progress: report.sortieOrder.progress.map((entry) => ({ ...entry })) + progress: report.sortieOrder.progress.map((entry) => ({ ...entry })), + ...(report.sortieOrder.command ? { command: { ...report.sortieOrder.command } } : {}) } } : {}),