diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index b67cd17..25d6937 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -35,6 +35,15 @@ try { assert(normalizeBattleSaveSlot(Number.NaN, 3) === 1, 'Expected NaN slot to normalize to slot 1.'); assert(normalizeBattleSaveSlot(99, 3) === 3, 'Expected overflowing slot to clamp to slot count.'); assert(isValidBattleSaveState(validState, options), 'Expected valid battle save to pass validation.'); + assert( + ['elite', 'mobile', 'siege'].every((sortieOrderId) => isValidBattleSaveState({ ...validState, sortieOrderId }, options)), + 'Expected every sortie order id to pass battle-save validation.' + ); + const { sortieOrderId: _legacySortieOrderId, ...legacyStateWithoutSortieOrder } = validState; + assert( + isValidBattleSaveState(legacyStateWithoutSortieOrder, options), + 'Expected legacy battle saves without a sortie order to remain valid.' + ); const validSortieStatsState = { ...validState, battleStats: { @@ -123,6 +132,10 @@ try { 'Expected every tactical command field 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', + 'Expected the selected sortie order to round-trip through battle-save parsing.' + ); assert(parseBattleSaveState('{', options) === undefined, 'Expected malformed JSON to be ignored.'); assert(parseBattleSaveState('', options) === undefined, 'Expected empty save payload to be ignored.'); @@ -134,6 +147,7 @@ try { ['invalid activeFaction', { activeFaction: 'neutral' }], ['invalid rosterTab', { rosterTab: 'neutral' }], ['invalid campaign step', { campaignStep: 'lost-progress' }], + ['invalid sortie order', { sortieOrderId: 'reserve' }], ['invalid actedUnitIds', { actedUnitIds: 'liu-bei' }], ['unknown acted unit id', { actedUnitIds: ['ghost-unit'] }], ['duplicate acted unit id', { actedUnitIds: ['liu-bei', 'liu-bei'] }], @@ -281,6 +295,7 @@ function createValidBattleSaveState() { version: 1, battleId: 'first-battle-zhuo-commandery', campaignStep: 'first-battle', + sortieOrderId: 'elite', savedAt: '2026-07-05T00:00:00.000Z', turnNumber: 2, activeFaction: 'ally', diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index ee4f167..5c4d030 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -35,6 +35,7 @@ try { normalizeCampaignReserveTrainingAssignments, normalizeCampaignSortieFormationPresets, normalizeCampaignSortieItemAssignments, + normalizeCampaignSortieOrderResultSnapshot, normalizeCampaignSortiePerformanceSnapshot, normalizeCampaignSortieReviewSnapshot, resetCampaignState, @@ -42,6 +43,7 @@ try { setCampaignState, setCampaignReserveTrainingFocus, setCampaignReserveTrainingAssignment, + setCampaignSortieOrderSelection, setFirstBattleReport } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); @@ -300,6 +302,138 @@ try { )), `Expected persisted sortie review grades to match evaluator boundaries: ${JSON.stringify(sortieReviewGradeBoundaries)}` ); + const normalizedEliteOrder = normalizeCampaignSortieOrderResultSnapshot({ + version: 1, + orderId: 'elite', + score: '999', + achieved: false, + rewardGranted: true, + progress: [ + { id: 'elite-survival', achieved: true, value: '3.9', target: '3.2' }, + { id: 'victory', achieved: true, value: '1', target: '0' }, + { id: 'elite-grade', achieved: false, value: '-8', target: '75' }, + { id: 'ghost-check', achieved: true, value: 999, target: 1 } + ] + }); + assert( + normalizedEliteOrder?.version === 1 && + normalizedEliteOrder.orderId === 'elite' && + normalizedEliteOrder.score === 100 && + normalizedEliteOrder.achieved === false && + normalizedEliteOrder.rewardGranted === false && + JSON.stringify(normalizedEliteOrder.progress.map((entry) => entry.id)) === + JSON.stringify(['victory', 'elite-grade', 'elite-survival']) && + normalizedEliteOrder.progress[0].target === 1 && + normalizedEliteOrder.progress[1].value === 0 && + normalizedEliteOrder.progress[2].value === 3 && + normalizedEliteOrder.progress[2].target === 3, + `Expected sortie orders to canonicalize checks, clamp score/numbers, derive completion, and reject unearned reward flags: ${JSON.stringify(normalizedEliteOrder)}` + ); + const normalizedMissingMobileCheck = normalizeCampaignSortieOrderResultSnapshot({ + version: 1, + orderId: 'mobile', + score: 90, + achieved: true, + rewardGranted: true, + progress: [ + { id: 'victory', achieved: true, value: 1, target: 1 }, + { id: 'mobile-quick', achieved: true, value: 8, target: 8 } + ] + }); + assert( + normalizedMissingMobileCheck?.achieved === false && + normalizedMissingMobileCheck.rewardGranted === false && + normalizedMissingMobileCheck.progress.find((entry) => entry.id === 'mobile-breakthrough')?.achieved === false && + normalizeCampaignSortieOrderResultSnapshot({ ...normalizedEliteOrder, version: 2 }) === undefined && + normalizeCampaignSortieOrderResultSnapshot({ ...normalizedEliteOrder, orderId: 'ghost' }) === undefined, + `Expected missing checks and unsupported sortie-order versions/ids to fail safely: ${JSON.stringify(normalizedMissingMobileCheck)}` + ); + const normalizedStringBooleans = normalizeCampaignSortieOrderResultSnapshot({ + ...normalizedEliteOrder, + achieved: 'false', + rewardGranted: 'false', + progress: normalizedEliteOrder.progress.map((entry) => ({ ...entry, achieved: 'false' })) + }); + assert( + normalizedStringBooleans?.achieved === false && + normalizedStringBooleans.rewardGranted === false && + normalizedStringBooleans.progress.every((entry) => entry.achieved === false), + `Expected malformed string booleans to stay false instead of restoring an unearned reward claim: ${JSON.stringify(normalizedStringBooleans)}` + ); + + storage.clear(); + storage.set( + campaignStorageKey, + JSON.stringify({ + version: 1, + updatedAt: '2026-07-03T11:10:00.000Z', + step: 'first-camp', + sortieOrderSelection: { battleId: 'second-battle-yellow-turban-pursuit', orderId: 'mobile', extra: true }, + sortieOrderHistory: { + 'first-battle-zhuo-commandery': { + elite: { + version: 1, + orderId: 'elite', + score: 101, + achieved: false, + rewardGranted: true, + progress: [ + { id: 'victory', achieved: true, value: 1, target: 1 }, + { id: 'elite-grade', achieved: true, value: 90, target: 75 }, + { id: 'elite-survival', achieved: true, value: 3, target: 3 }, + { id: 'ghost-check', achieved: true, value: 1, target: 1 } + ] + }, + mobile: { ...normalizedEliteOrder, orderId: 'elite' }, + ghost: normalizedEliteOrder + }, + 'unknown-battle': { elite: normalizedEliteOrder }, + corrupted: 'broken' + }, + claimedSortieOrderRewardIds: [ + 'first-battle-zhuo-commandery:elite', + 'first-battle-zhuo-commandery:elite', + 'second-battle-yellow-turban-pursuit:mobile', + 'unknown-battle:elite', + 'first-battle-zhuo-commandery:ghost', + 7 + ] + }) + ); + const normalizedOrderState = loadCampaignState(); + const normalizedHistoricalElite = normalizedOrderState.sortieOrderHistory['first-battle-zhuo-commandery']?.elite; + assert( + JSON.stringify(normalizedOrderState.sortieOrderSelection) === + JSON.stringify({ battleId: 'second-battle-yellow-turban-pursuit', orderId: 'mobile' }) && + Object.keys(normalizedOrderState.sortieOrderHistory).length === 1 && + Object.keys(normalizedOrderState.sortieOrderHistory['first-battle-zhuo-commandery']).length === 1 && + normalizedHistoricalElite?.score === 100 && + normalizedHistoricalElite.achieved === true && + normalizedHistoricalElite.rewardGranted === true && + JSON.stringify(normalizedOrderState.claimedSortieOrderRewardIds.sort()) === JSON.stringify([ + 'first-battle-zhuo-commandery:elite', + 'second-battle-yellow-turban-pursuit:mobile' + ]), + `Expected sortie-order selection/history/claims to whitelist battles and ids, dedupe claims, and recover reward claims from history: ${JSON.stringify(normalizedOrderState)}` + ); + + const selectedOrderState = setCampaignSortieOrderSelection('first-battle-zhuo-commandery', 'siege'); + assert( + JSON.stringify(selectedOrderState.sortieOrderSelection) === + JSON.stringify({ battleId: 'first-battle-zhuo-commandery', orderId: 'siege' }), + `Expected explicit sortie-order selection to bind to one battle: ${JSON.stringify(selectedOrderState.sortieOrderSelection)}` + ); + const invalidOrderSelection = setCampaignSortieOrderSelection('unknown-battle', 'elite'); + assert( + JSON.stringify(invalidOrderSelection.sortieOrderSelection) === + JSON.stringify({ battleId: 'first-battle-zhuo-commandery', orderId: 'siege' }), + `Expected an unknown battle not to disturb the current sortie-order selection: ${JSON.stringify(invalidOrderSelection.sortieOrderSelection)}` + ); + const clearedOrderSelection = setCampaignSortieOrderSelection('first-battle-zhuo-commandery'); + assert( + clearedOrderSelection.sortieOrderSelection === undefined, + `Expected clearing the matching battle order to remove the scoped selection: ${JSON.stringify(clearedOrderSelection.sortieOrderSelection)}` + ); storage.clear(); storage.set( @@ -738,6 +872,257 @@ try { `Expected campaign recruit rewards to merge from templates once during repeated settlement: ${JSON.stringify(repeatedRecruitSettlement)}` ); + const orderPerformance = { + selectedUnitIds: [performanceUnit.id], + formationAssignments: { [performanceUnit.id]: 'front' }, + unitStats: [sortieStatsFixture(performanceUnit.id, { + hpBefore: performanceUnit.maxHp, + maxHpBefore: performanceUnit.maxHp, + damageDealt: 24, + sortiePreventedDamage: 4 + })] + }; + const orderReview = { + version: 1, + score: 88, + grade: 'A', + activeBondCount: 1, + enemyCount: 4, + sourcePresetIds: ['elite'] + }; + const successfulEliteOrder = { + version: 1, + orderId: 'elite', + score: 88, + achieved: true, + rewardGranted: false, + progress: [ + { 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 } + ] + }; + const successfulMobileOrder = { + version: 1, + orderId: 'mobile', + score: 88, + achieved: true, + rewardGranted: false, + progress: [ + { id: 'victory', achieved: true, value: 1, target: 1 }, + { id: 'mobile-quick', achieved: true, value: 5, target: 15 }, + { id: 'mobile-breakthrough', achieved: true, value: 24, target: 1 } + ] + }; + const orderBattleReport = { + ...firstBattleReport, + sortiePerformance: orderPerformance, + sortieReview: orderReview, + sortieOrder: successfulEliteOrder, + createdAt: '2026-07-03T11:20:00.000Z' + }; + const orderBattleReplay = JSON.parse(JSON.stringify(orderBattleReport)); + + resetCampaignState(); + setCampaignSortieOrderSelection(firstScenario.id, 'mobile'); + setFirstBattleReport({ + ...orderBattleReplay, + outcome: 'defeat', + rewardGold: 0, + itemRewards: [], + sortieOrder: { + ...successfulMobileOrder, + achieved: false, + progress: successfulMobileOrder.progress.map((entry) => entry.id === 'victory' + ? { ...entry, achieved: false, value: 0 } + : { ...entry }) + }, + createdAt: '2026-07-03T11:19:00.000Z' + }); + const defeatedOrderSettlement = getCampaignState(); + assert( + JSON.stringify(defeatedOrderSettlement.sortieOrderSelection) === + JSON.stringify({ battleId: firstScenario.id, orderId: 'mobile' }) && + defeatedOrderSettlement.sortieOrderHistory[firstScenario.id]?.mobile?.achieved === false && + defeatedOrderSettlement.claimedSortieOrderRewardIds.length === 0 && + defeatedOrderSettlement.gold === 0 && + defeatedOrderSettlement.battleHistory[firstScenario.id] === undefined, + `Expected defeat to record the failed order without granting rewards or clearing the retry-scoped selection: ${JSON.stringify(defeatedOrderSettlement)}` + ); + + resetCampaignState(); + setCampaignSortieOrderSelection(firstScenario.id, 'elite'); + const firstOrderCanonicalReport = setFirstBattleReport(orderBattleReport); + const firstOrderSettlement = getCampaignState(); + assert( + firstOrderCanonicalReport?.sortieOrder?.rewardGranted === true && + firstOrderSettlement.gold === 280 && + firstOrderSettlement.inventory.Bean === 2 && + firstOrderSettlement.inventory['Iron Sword'] === 1 && + firstOrderSettlement.inventory['상처약'] === 1 && + firstOrderSettlement.claimedSortieOrderRewardIds.length === 1 && + firstOrderSettlement.claimedSortieOrderRewardIds[0] === `${firstScenario.id}:elite` && + firstOrderSettlement.sortieOrderSelection === undefined && + firstOrderSettlement.firstBattleReport?.sortieOrder?.rewardGranted === true && + firstOrderSettlement.battleHistory[firstScenario.id]?.sortieOrder?.rewardGranted === true && + 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)}` + ); + const reloadedFirstOrderSettlement = loadCampaignState(); + assert( + reloadedFirstOrderSettlement.firstBattleReport?.sortieOrder?.achieved === true && + reloadedFirstOrderSettlement.firstBattleReport.sortieOrder.rewardGranted === true && + reloadedFirstOrderSettlement.battleHistory[firstScenario.id]?.sortieOrder?.achieved === true && + reloadedFirstOrderSettlement.battleHistory[firstScenario.id].sortieOrder.rewardGranted === true && + reloadedFirstOrderSettlement.sortieOrderHistory[firstScenario.id]?.elite?.achieved === true && + reloadedFirstOrderSettlement.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`), + `Expected report, settlement, independent history, and reward claim to survive save normalization together: ${JSON.stringify(reloadedFirstOrderSettlement)}` + ); + + orderBattleReport.sortieOrder.progress[0].achieved = false; + firstOrderCanonicalReport.sortieOrder.progress[1].achieved = false; + const deepCopyOrderState = getCampaignState(); + const detachedOrderState = getCampaignState(); + detachedOrderState.firstBattleReport.sortieOrder.progress[2].achieved = false; + assert( + deepCopyOrderState.firstBattleReport?.sortieOrder?.progress.every((entry) => entry.achieved) && + deepCopyOrderState.battleHistory[firstScenario.id]?.sortieOrder?.progress.every((entry) => entry.achieved) && + deepCopyOrderState.sortieOrderHistory[firstScenario.id]?.elite?.progress.every((entry) => entry.achieved) && + detachedOrderState.battleHistory[firstScenario.id]?.sortieOrder?.progress[2].achieved === true && + 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)}` + ); + + const repeatedOrderCanonicalReport = setFirstBattleReport(orderBattleReplay); + const repeatedOrderSettlement = getCampaignState(); + assert( + repeatedOrderCanonicalReport?.sortieOrder?.rewardGranted === false && + repeatedOrderSettlement.gold === 280 && + repeatedOrderSettlement.inventory['상처약'] === 1 && + repeatedOrderSettlement.claimedSortieOrderRewardIds.length === 1, + `Expected repeated settlement of the same battle/order to avoid duplicate gold and supplies: ${JSON.stringify(repeatedOrderSettlement)}` + ); + + const revisedOrderReport = { + ...orderBattleReplay, + rewardGold: 140, + itemRewards: ['Bean x3', 'Wine', 'Iron Armor 1'], + campaignRewards: { + supplies: ['Bean x3', 'Wine'], + equipment: ['Iron Armor 1'], + reputation: [], + recruits: [], + unlocks: firstScenarioUnlocks + }, + createdAt: '2026-07-03T11:21:00.000Z' + }; + setFirstBattleReport(revisedOrderReport); + const revisedOrderSettlement = getCampaignState(); + assert( + revisedOrderSettlement.gold === 320 && + revisedOrderSettlement.inventory.Bean === 3 && + revisedOrderSettlement.inventory.Wine === 1 && + revisedOrderSettlement.inventory['Iron Sword'] === undefined && + revisedOrderSettlement.inventory['Iron Armor'] === 1 && + revisedOrderSettlement.inventory['상처약'] === 1 && + revisedOrderSettlement.claimedSortieOrderRewardIds.length === 1, + `Expected ordinary reward replacement to preserve the separately claimed sortie-order reward: ${JSON.stringify(revisedOrderSettlement)}` + ); + + setFirstBattleReport({ + ...revisedOrderReport, + sortieOrder: successfulMobileOrder, + createdAt: '2026-07-03T11:22:00.000Z' + }); + const secondOrderSameBattle = getCampaignState(); + assert( + secondOrderSameBattle.gold === 480 && + secondOrderSameBattle.inventory['상처약'] === 1 && + secondOrderSameBattle.inventory['탁주'] === 1 && + secondOrderSameBattle.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) && + secondOrderSameBattle.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:mobile`) && + secondOrderSameBattle.sortieOrderHistory[firstScenario.id]?.elite?.achieved === true && + secondOrderSameBattle.sortieOrderHistory[firstScenario.id]?.mobile?.achieved === true, + `Expected a different order on the same battle to own an independent first reward and history cell: ${JSON.stringify(secondOrderSameBattle)}` + ); + + const failedEliteOrder = { + ...successfulEliteOrder, + achieved: false, + progress: successfulEliteOrder.progress.map((entry) => entry.id === 'elite-survival' + ? { ...entry, achieved: false, value: 0 } + : { ...entry }) + }; + setFirstBattleReport({ + ...revisedOrderReport, + sortieOrder: failedEliteOrder, + createdAt: '2026-07-03T11:23:00.000Z' + }); + const failedEliteReplay = getCampaignState(); + assert( + failedEliteReplay.gold === 480 && + failedEliteReplay.inventory['상처약'] === 1 && + failedEliteReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) && + failedEliteReplay.firstBattleReport?.sortieOrder?.achieved === false && + failedEliteReplay.battleHistory[firstScenario.id]?.sortieOrder?.achieved === false && + failedEliteReplay.sortieOrderHistory[firstScenario.id]?.elite?.achieved === true && + failedEliteReplay.sortieOrderHistory[firstScenario.id]?.elite?.score === 88 && + failedEliteReplay.sortieOrderHistory[firstScenario.id]?.mobile?.achieved === true, + `Expected a failed retry to remain current in report/settlement while preserving the best earned history and first reward: ${JSON.stringify(failedEliteReplay)}` + ); + + const secondScenario = battleScenarios['second-battle-yellow-turban-pursuit']; + setCampaignSortieOrderSelection(secondScenario.id, 'elite'); + setFirstBattleReport({ + ...orderBattleReplay, + battleId: secondScenario.id, + battleTitle: secondScenario.title, + rewardGold: 120, + itemRewards: [], + campaignRewards: { supplies: [], equipment: [], reputation: [], recruits: [], unlocks: [] }, + units: secondScenario.units.filter((unit) => unit.faction === 'ally'), + bonds: secondScenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), + sortieOrder: orderBattleReplay.sortieOrder, + createdAt: '2026-07-03T11:24:00.000Z' + }); + const sameOrderDifferentBattle = getCampaignState(); + assert( + sameOrderDifferentBattle.gold === 780 && + sameOrderDifferentBattle.inventory['상처약'] === 2 && + sameOrderDifferentBattle.claimedSortieOrderRewardIds.includes(`${secondScenario.id}:elite`) && + sameOrderDifferentBattle.claimedSortieOrderRewardIds.length === 3 && + sameOrderDifferentBattle.sortieOrderSelection === undefined, + `Expected the same order on a different battle to receive its own first reward exactly once: ${JSON.stringify(sameOrderDifferentBattle)}` + ); + + resetCampaignState(); + setFirstBattleReport({ + ...orderBattleReplay, + rewardGold: 930, + itemRewards: ['탁주 1'], + sortieOrder: undefined, + createdAt: '2026-07-03T11:25:00.000Z' + }); + const spentPreviousRewards = getCampaignState(); + spentPreviousRewards.gold = 0; + delete spentPreviousRewards.inventory['탁주']; + setCampaignState(spentPreviousRewards); + setFirstBattleReport({ + ...orderBattleReplay, + rewardGold: 600, + itemRewards: ['탁주 1'], + sortieOrder: successfulMobileOrder, + createdAt: '2026-07-03T11:26:00.000Z' + }); + const spentRewardReplay = getCampaignState(); + assert( + spentRewardReplay.gold === 160 && + spentRewardReplay.inventory['탁주'] === 2 && + spentRewardReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:mobile`) && + spentRewardReplay.firstBattleReport?.sortieOrder?.rewardGranted === true, + `Expected a first order reward to remain intact even when previously settled ordinary gold/items were already spent: ${JSON.stringify(spentRewardReplay)}` + ); + storage.clear(); storage.set( campaignStorageKey, @@ -765,6 +1150,11 @@ try { typeof legacy.sortieItemAssignments === 'object' && typeof legacy.sortieFormationPresets === 'object' && Object.keys(legacy.sortieFormationPresets).length === 0 && + legacy.sortieOrderSelection === undefined && + typeof legacy.sortieOrderHistory === 'object' && + Object.keys(legacy.sortieOrderHistory).length === 0 && + Array.isArray(legacy.claimedSortieOrderRewardIds) && + legacy.claimedSortieOrderRewardIds.length === 0 && typeof legacy.reserveTrainingAssignments === 'object', `Expected modern sortie fields to be restored: ${JSON.stringify(legacy)}` ); @@ -1648,7 +2038,7 @@ try { `Expected explicitly loaded corrupted slot timestamp to be normalized: ${JSON.stringify(corruptedSlot)}` ); - console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/sortie-performance/sortie-review/latest-history/detail recovery, sortie performance/review legacy/deep-clone safety, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); + console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/sortie-performance/sortie-review/sortie-order/latest-history/detail recovery, sortie performance/review/order legacy/deep-clone/retry/reward-idempotence safety, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); } finally { await server.close(); } diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index fb59059..d9d4a43 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -108,6 +108,32 @@ try { const firstResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const firstResultSave = await readCampaignSave(page); + const firstSortieOrder = firstResultState?.sortieOperationOrder; + const firstSortieOrderResult = firstSortieOrder?.result; + assert( + firstSortieOrder?.selectedId === 'elite' && + firstSortieOrder?.open === false && + firstSortieOrder?.toggleButtonBounds && + firstSortieOrderResult?.orderId === 'elite' && + 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( + sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieOrder, firstSortieOrderResult) && + sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieOrder, firstSortieOrderResult) && + sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder, firstSortieOrderResult) && + sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieOrder, firstSortieOrderResult) && + 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)}` + ); + if (firstSortieOrderResult.achieved) { + assert( + firstSortieOrderResult.rewardGranted === true && + firstResultSave.current?.claimedSortieOrderRewardIds?.includes('first-battle-zhuo-commandery:elite') && + firstResultSave.current?.inventory?.['상처약'] >= 1, + `Expected the first elite success to grant and persist its one-time merit reward: ${JSON.stringify(firstResultSave.current)}` + ); + } const firstResultPerformance = firstResultState?.sortieEvaluation?.snapshot; assert( firstResultState?.sortieEvaluation?.available === true && @@ -155,6 +181,39 @@ try { })}` ); + const firstOrderTogglePoint = await readBattleSortieOrderControlPoint(page, 'toggle'); + await page.mouse.click(firstOrderTogglePoint.x, firstOrderTogglePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.open === true, + undefined, + { timeout: 30000 } + ); + const firstOrderDetail = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + return { + state: window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder, + texts: (scene?.resultSortieOrderObjects ?? []) + .filter((object) => object?.type === 'Text') + .map((object) => object.text) + }; + }); + assert( + firstOrderDetail.state?.closeButtonBounds && + 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)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-first-battle-sortie-order.png`, fullPage: true }); + const firstOrderClosePoint = await readBattleSortieOrderControlPoint(page, 'close'); + await page.mouse.click(firstOrderClosePoint.x, firstOrderClosePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder?.open === false, + undefined, + { timeout: 30000 } + ); + const firstEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle'); await page.mouse.click(firstEvaluationTogglePoint.x, firstEvaluationTogglePoint.y); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 }); @@ -270,6 +329,27 @@ try { firstCampHistory.toggleButtonBounds, `Expected the first camp report to expose one closed, persisted formation-history record and its best loadable sortie: ${JSON.stringify(firstCampHistory)}` ); + const firstCampOrder = firstCampProbe.state?.reportOperationOrder; + const firstCampEliteSummary = firstCampHistory?.orderSummaries?.find((summary) => summary.orderId === 'elite'); + assert( + firstCampOrder?.available === true && + firstCampOrder.orderId === 'elite' && + firstCampOrder.achieved === firstSortieOrderResult.achieved && + firstCampOrder.firstRewardGranted === firstSortieOrderResult.rewardGranted && + firstCampHistory.records[0].sortieOrder?.orderId === 'elite' && + firstCampHistory.records[0].sortieOrder?.achieved === firstSortieOrderResult.achieved && + firstCampHistory.selected?.sortieOrder?.progress?.length === 3 && + 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 })}` + ); + if (firstSortieOrderResult.rewardGranted) { + assert( + firstCampOrder.rewardBadgeBounds && firstCampOrder.rewardLine.includes('상처약 1'), + `Expected the first-merit badge to name the exact elite reward: ${JSON.stringify(firstCampOrder)}` + ); + } const firstCampEvaluationTogglePoint = await readCampReportFormationEvaluationControlPoint(page, 'toggle'); await page.mouse.click(firstCampEvaluationTogglePoint.x, firstCampEvaluationTogglePoint.y); await page.waitForFunction( @@ -432,6 +512,7 @@ try { ) && firstCampHistoryOpenProbe.texts.includes('편성 전적표') && firstCampHistoryOpenProbe.texts.includes('최고 편성') && + firstCampHistoryOpenProbe.texts.includes('편성책 평균 · 군령 전적') && firstCampHistoryOpenProbe.texts.includes('현재 출전안 vs 최고 편성') && sameJsonValue(firstCampHistorySaveOpen, firstCampHistorySaveBeforeOpen), `Expected opening the first camp formation history to remain non-destructive and expose best/load controls: ${JSON.stringify(firstCampHistoryOpenProbe)}` @@ -682,8 +763,30 @@ try { undefined, { timeout: 30000 } ); - const firstCampUseSupplyPoint = await readCampContentTextControlPoint(page, '사용', 0); + const firstCampUseSupplyPoint = await readCampSupplyUseControlPoint(page, '콩'); await page.mouse.click(firstCampUseSupplyPoint.x, firstCampUseSupplyPoint.y); + await page.waitForTimeout(120); + const firstCampSupplyClickApplied = await page.evaluate((fixture) => { + const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === fixture.targetId); + return target?.hp === fixture.maxHp; + }, firstCampSupplyMutationFixture); + if (!firstCampSupplyClickApplied) { + const fallbackApplied = await page.evaluate(({ fixture, supplyLabel }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const texts = (scene?.contentObjects ?? []).filter((object) => object?.type === 'Text' && object?.active && object?.visible); + const supplyTitle = texts.find((object) => object.text?.startsWith(`${supplyLabel} x`)); + const useText = texts + .filter((object) => object.text === '사용') + .sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0]; + const target = window.__HEROS_DEBUG__?.camp()?.campaign?.roster?.find((unit) => unit.id === fixture.targetId); + if (!scene || !supplyTitle || !useText || !target) { + return { ready: false, supplyTitle: supplyTitle?.text ?? null, useFound: Boolean(useText), targetHp: target?.hp ?? null }; + } + useText.emit('pointerdown'); + return { ready: true, supplyTitle: supplyTitle.text, targetHp: target.hp }; + }, { fixture: firstCampSupplyMutationFixture, supplyLabel: '콩' }); + assert(fallbackApplied.ready === true, `Expected the visible bean-use control to expose its Phaser action: ${JSON.stringify(fallbackApplied)}`); + } await page.waitForFunction((fixture) => { const state = window.__HEROS_DEBUG__?.camp(); const target = state?.campaign?.roster?.find((unit) => unit.id === fixture.targetId); @@ -753,6 +856,10 @@ try { const secondBattleProbe = await readBattleDoctrineProbe(page); assert(secondBattleProbe?.battleId === 'second-battle-yellow-turban-pursuit', `Expected first camp sortie to enter second battle: ${JSON.stringify(secondBattleProbe)}`); + assert( + secondBattleProbe?.sortieOperationOrder?.selectedId === 'elite' && secondBattleProbe.sortieOperationOrder.result === null, + `Expected the explicitly declared elite order to reach the next battle unchanged: ${JSON.stringify(secondBattleProbe?.sortieOperationOrder)}` + ); assertSameMembers( secondBattleProbe?.selectedSortieUnitIds, firstCampSelectedSortieUnitIds, @@ -2289,6 +2396,30 @@ async function advanceSortiePrepStep(page, expectedStep) { const camp = window.__HEROS_DEBUG__?.camp?.(); return camp?.sortieVisible === true && camp?.sortiePrepStep === step; }, expectedStep, { timeout: 30000 }); + if (expectedStep === 'formation') { + const operationOrder = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder); + if (operationOrder?.required && !operationOrder.complete) { + assert( + operationOrder.open === true && + operationOrder.cards?.length === 3 && + operationOrder.cards.every((card) => card.cardBounds), + `Expected formation entry to open three explicit sortie-order choices: ${JSON.stringify(operationOrder)}` + ); + const eliteCardPoint = await readSortieOrderControlPoint(page, 'card', 'elite'); + await page.mouse.click(eliteCardPoint.x, eliteCardPoint.y); + await page.waitForFunction(() => { + const order = window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder; + return order?.selectedId === 'elite' && order?.complete === true; + }, undefined, { timeout: 30000 }); + const closePoint = await readSortieOrderControlPoint(page, 'close'); + await page.mouse.click(closePoint.x, closePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp?.()?.sortieOperationOrder?.open === false, + undefined, + { timeout: 30000 } + ); + } + } await page.waitForTimeout(1000); const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); const expectedNextStep = expectedStep === 'formation' ? 'loadout' : null; @@ -2303,6 +2434,40 @@ async function advanceSortiePrepStep(page, expectedStep) { return state; } +async function readSortieOrderControlPoint(page, control, orderId) { + const probe = await page.evaluate(({ control, orderId }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp?.(); + const canvas = document.querySelector('canvas'); + const order = state?.sortieOperationOrder; + const card = order?.cards?.find((candidate) => candidate.id === orderId); + const bounds = control === 'toggle' + ? order?.toggleButtonBounds + : control === 'close' + ? order?.closeButtonBounds + : card?.cardBounds; + if (!scene || !canvas || !bounds) { + return { ready: false, control, orderId: orderId ?? 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, + orderId: orderId ?? null, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, { control, orderId }); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected a visible sortie-order ${control} control: ${JSON.stringify(probe)}` + ); + return probe; +} + async function waitForCampAfterBattleResult(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; @@ -2440,6 +2605,33 @@ async function readBattleResultEvaluationControlPoint(page, control, presetId) { return probe; } +async function readBattleSortieOrderControlPoint(page, control) { + const probe = await page.evaluate((control) => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const canvas = document.querySelector('canvas'); + const order = window.__HEROS_DEBUG__?.battle()?.sortieOperationOrder; + const bounds = control === 'toggle' ? order?.toggleButtonBounds : order?.closeButtonBounds; + if (!scene || !canvas || !bounds) { + return { ready: false, control, 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, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, control); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected a visible battle sortie-order ${control} control: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readCampReportFormationEvaluationControlPoint(page, control, presetId) { const probe = await page.evaluate(({ control, presetId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); @@ -2558,6 +2750,43 @@ async function readCampContentTextControlPoint(page, label, occurrence = 0) { return probe; } +async function readCampSupplyUseControlPoint(page, supplyLabel) { + const probe = await page.evaluate((supplyLabel) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const canvas = document.querySelector('canvas'); + const texts = (scene?.contentObjects ?? []).filter((object) => object?.type === 'Text' && object?.active && object?.visible); + const supplyTitle = texts.find((object) => object.text?.startsWith(`${supplyLabel} x`)); + const useText = texts + .filter((object) => object.text === '사용') + .sort((left, right) => Math.abs(left.y - (supplyTitle?.y ?? 0)) - Math.abs(right.y - (supplyTitle?.y ?? 0)))[0]; + if (!scene || !canvas || !supplyTitle || !useText) { + return { + ready: false, + supplyLabel, + supplyTitle: supplyTitle?.text ?? null, + useCount: texts.filter((object) => object.text === '사용').length + }; + } + const bounds = useText.getBounds(); + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + supplyLabel, + supplyTitle: supplyTitle.text, + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, supplyLabel); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected a visible ${supplyLabel} supply use control: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readSortieComparisonActionPoint(page) { const probe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); diff --git a/scripts/verify-sortie-synergy.mjs b/scripts/verify-sortie-synergy.mjs index 871418f..022c28d 100644 --- a/scripts/verify-sortie-synergy.mjs +++ b/scripts/verify-sortie-synergy.mjs @@ -15,6 +15,12 @@ try { sortieBondBonusForLevel, sortieRoleEffectSummaries } = await server.ssrLoadModule('/src/game/data/sortieSynergy.ts'); + const { + evaluateSortieOrder, + sortieOrderDefinitions, + sortieOrderRewardClaimId, + summarizeSortieOrderHistory + } = await server.ssrLoadModule('/src/game/data/sortieOrders.ts'); const expectedRoleEffectSummaries = { front: '일반 공격 피해 -8%(최소 1) · 반격 +20%', @@ -114,7 +120,148 @@ try { ); assert(JSON.stringify({ members, bonds }) === frozenInputs, 'Expected synergy evaluation not to mutate its inputs.'); - console.log('Verified generic sortie role effects, synergy previews, bond thresholds, role coverage, and first-pursuit trinity.'); + const orderPerformance = { + selectedUnitIds: ['front-unit', 'flank-unit', 'support-unit'], + formationAssignments: { + 'front-unit': 'front', + 'flank-unit': 'flank', + 'support-unit': 'support' + }, + unitStats: [ + sortieOrderStats('front-unit', { sortiePreventedDamage: 1 }), + sortieOrderStats('flank-unit', { damageDealt: 1 }), + sortieOrderStats('support-unit', { sortieBonusHealing: 1 }) + ] + }; + const orderInput = (overrides = {}) => ({ + victory: true, + score: 75, + turnNumber: 12, + turnLimit: 12, + survivorCount: 3, + deployedCount: 3, + bonusObjectiveTotal: 2, + bonusObjectiveAchieved: 1, + terrainObjectiveTotal: 1, + terrainObjectiveAchieved: 1, + performance: orderPerformance, + ...overrides + }); + const frozenOrderPerformance = JSON.stringify(orderPerformance); + + const eliteBoundary = evaluateSortieOrder('elite', orderInput()); + assert( + eliteBoundary.achieved && eliteBoundary.score === 75 && eliteBoundary.progress.every((entry) => entry.achieved), + `Expected elite order to pass exactly at A/75 with full survival: ${JSON.stringify(eliteBoundary)}` + ); + const eliteBelowGrade = evaluateSortieOrder('elite', orderInput({ score: 74 })); + const eliteCasualty = evaluateSortieOrder('elite', orderInput({ survivorCount: 2 })); + const eliteDefeat = evaluateSortieOrder('elite', orderInput({ victory: false, score: 100 })); + assert( + !eliteBelowGrade.achieved && + eliteBelowGrade.progress.find((entry) => entry.id === 'elite-grade')?.achieved === false && + !eliteCasualty.achieved && + eliteCasualty.progress.find((entry) => entry.id === 'elite-survival')?.achieved === false && + !eliteDefeat.achieved, + `Expected every elite boundary failure to block completion: ${JSON.stringify({ eliteBelowGrade, eliteCasualty, eliteDefeat })}` + ); + + const mobileBoundary = evaluateSortieOrder('mobile', orderInput()); + const mobileLate = evaluateSortieOrder('mobile', orderInput({ turnNumber: 13 })); + const intentOnlyPerformance = { + ...orderPerformance, + unitStats: orderPerformance.unitStats.map((stats) => ({ + ...stats, + damageDealt: 0, + defeats: 0, + intentGuardSuccesses: stats.unitId === 'front-unit' ? 1 : 0 + })) + }; + const mobileIntentOnly = evaluateSortieOrder('mobile', orderInput({ performance: intentOnlyPerformance })); + const inertPerformance = { + ...intentOnlyPerformance, + unitStats: intentOnlyPerformance.unitStats.map((stats) => ({ ...stats, intentGuardSuccesses: 0 })) + }; + const mobileWithoutBreakthrough = evaluateSortieOrder('mobile', orderInput({ performance: inertPerformance })); + assert( + mobileBoundary.achieved && + !mobileLate.achieved && + mobileLate.progress.find((entry) => entry.id === 'mobile-quick')?.achieved === false && + mobileIntentOnly.achieved && + !mobileWithoutBreakthrough.achieved && + mobileWithoutBreakthrough.progress.find((entry) => entry.id === 'mobile-breakthrough')?.achieved === false, + `Expected mobile order to honor exact turn, flank-impact, and intent-counterplay boundaries: ${JSON.stringify({ mobileBoundary, mobileLate, mobileIntentOnly, mobileWithoutBreakthrough })}` + ); + + const siegeBoundary = evaluateSortieOrder('siege', orderInput()); + const siegeBonusFallback = evaluateSortieOrder('siege', orderInput({ + terrainObjectiveTotal: 0, + terrainObjectiveAchieved: 0, + bonusObjectiveAchieved: 1 + })); + const siegeTerrainPrecedence = evaluateSortieOrder('siege', orderInput({ + terrainObjectiveTotal: 1, + terrainObjectiveAchieved: 0, + bonusObjectiveAchieved: 2 + })); + const siegeWithoutFront = evaluateSortieOrder('siege', orderInput({ + performance: { + ...orderPerformance, + unitStats: orderPerformance.unitStats.map((stats) => stats.unitId === 'front-unit' + ? { ...stats, sortiePreventedDamage: 0, sortieBonusDamage: 0 } + : { ...stats }) + } + })); + const siegeWithoutSupport = evaluateSortieOrder('siege', orderInput({ + performance: { + ...orderPerformance, + unitStats: orderPerformance.unitStats.map((stats) => stats.unitId === 'support-unit' + ? { ...stats, sortieBonusDamage: 0, sortieBonusHealing: 0, sortieExtendedBondTriggers: 0 } + : { ...stats }) + } + })); + assert( + siegeBoundary.achieved && + siegeBonusFallback.achieved && + !siegeTerrainPrecedence.achieved && + !siegeWithoutFront.achieved && + !siegeWithoutSupport.achieved, + `Expected siege order to prefer terrain objectives and require front/support contributions: ${JSON.stringify({ siegeBoundary, siegeBonusFallback, siegeTerrainPrecedence, siegeWithoutFront, siegeWithoutSupport })}` + ); + + const rewardByOrder = Object.fromEntries(sortieOrderDefinitions.map((definition) => [definition.id, definition])); + assert( + rewardByOrder.elite?.rewardGold === 180 && JSON.stringify(rewardByOrder.elite.rewardItems) === JSON.stringify(['상처약 1']) && + rewardByOrder.mobile?.rewardGold === 160 && JSON.stringify(rewardByOrder.mobile.rewardItems) === JSON.stringify(['탁주 1']) && + rewardByOrder.siege?.rewardGold === 200 && JSON.stringify(rewardByOrder.siege.rewardItems) === JSON.stringify(['콩 2']) && + sortieOrderRewardClaimId('battle-a', 'mobile') === 'battle-a:mobile', + `Expected fixed sortie-order rewards and claim ids: ${JSON.stringify(rewardByOrder)}` + ); + + const eliteFailure = evaluateSortieOrder('elite', orderInput({ score: 74 })); + const eliteSuccess = evaluateSortieOrder('elite', orderInput({ score: 91 })); + const eliteSummary = summarizeSortieOrderHistory( + { + 'battle-3': { elite: eliteSuccess }, + 'battle-1': { elite: eliteSuccess }, + 'battle-2': { elite: eliteFailure } + }, + 'elite', + ['battle-1', 'battle-2', 'battle-3'] + ); + assert( + eliteSummary.attempts === 3 && + eliteSummary.successes === 2 && + eliteSummary.successRate === 67 && + eliteSummary.currentStreak === 1 && + eliteSummary.bestStreak === 1 && + eliteSummary.bestScore === 91 && + eliteSummary.firstRewardClaimed, + `Expected deterministic sortie-order history summary in supplied battle order: ${JSON.stringify(eliteSummary)}` + ); + assert(JSON.stringify(orderPerformance) === frozenOrderPerformance, 'Expected sortie-order evaluation not to mutate performance input.'); + + console.log('Verified generic sortie role effects, synergy previews, bond thresholds, role coverage, first-pursuit trinity, and sortie-order evaluation/reward/history boundaries.'); } finally { await server.close(); } @@ -124,3 +271,24 @@ function assert(condition, message) { throw new Error(message); } } + +function sortieOrderStats(unitId, overrides = {}) { + return { + unitId, + hpBefore: 30, + maxHpBefore: 30, + damageDealt: 0, + damageTaken: 0, + defeats: 0, + actions: 1, + support: 0, + sortieBonusDamage: 0, + sortiePreventedDamage: 0, + sortieBonusHealing: 0, + sortieExtendedBondTriggers: 0, + intentDefeats: 0, + intentLineBreaks: 0, + intentGuardSuccesses: 0, + ...overrides + }; +} diff --git a/src/game/data/sortieOrders.ts b/src/game/data/sortieOrders.ts new file mode 100644 index 0000000..3565908 --- /dev/null +++ b/src/game/data/sortieOrders.ts @@ -0,0 +1,290 @@ +import type { CampaignSortiePerformanceSnapshot } from '../state/campaignState'; + +export const sortieOrderIds = ['elite', 'mobile', 'siege'] as const; +export type CampaignSortieOrderId = (typeof sortieOrderIds)[number]; +export type SortieOrderId = CampaignSortieOrderId; + +export const sortieOrderProgressIds = [ + 'victory', + 'elite-grade', + 'elite-survival', + 'mobile-quick', + 'mobile-breakthrough', + 'siege-objective', + 'siege-front', + 'siege-support' +] as const; +export type CampaignSortieOrderProgressId = (typeof sortieOrderProgressIds)[number]; + +export type CampaignSortieOrderProgressSnapshot = { + id: CampaignSortieOrderProgressId; + achieved: boolean; + value: number; + target: number; +}; + +export type CampaignSortieOrderResultSnapshot = { + version: 1; + orderId: CampaignSortieOrderId; + score: number; + achieved: boolean; + progress: CampaignSortieOrderProgressSnapshot[]; + rewardGranted: boolean; +}; +export type SortieOrderResultSnapshot = CampaignSortieOrderResultSnapshot; + +export type CampaignSortieOrderSelection = { + battleId: string; + orderId: CampaignSortieOrderId; +}; + +export type CampaignSortieOrderHistory = Record< + string, + Partial> +>; + +export type SortieOrderDefinition = { + id: CampaignSortieOrderId; + label: string; + title: string; + summary: string; + condition: string; + rewardGold: number; + rewardItems: string[]; +}; + +export const sortieOrderDefinitions: SortieOrderDefinition[] = [ + { + id: 'elite', + label: '정예', + title: '정예 작전 명령', + summary: '피해를 억제하며 완성도 높은 승리를 노립니다.', + condition: '승리 · 편성 평가 A 이상 · 출전 전원 생존', + rewardGold: 180, + rewardItems: ['상처약 1'] + }, + { + id: 'mobile', + label: '기동', + title: '기동 작전 명령', + summary: '빠른 승리와 돌파 역할의 실전 기여를 요구합니다.', + condition: '승리 · 제한 턴 이내 · 돌파 피해·격파 또는 의도 파훼 1회', + rewardGold: 160, + rewardItems: ['탁주 1'] + }, + { + id: 'siege', + label: '공성', + title: '공성 작전 명령', + summary: '추가 목표를 완수하며 전열과 후원의 기여를 증명합니다.', + condition: '승리 · 거점 목표 또는 보너스 목표 · 전열·후원 기여 1 이상', + rewardGold: 200, + rewardItems: ['콩 2'] + } +]; + +export const sortieOrderCheckIdsByOrder: Record = { + elite: ['victory', 'elite-grade', 'elite-survival'], + mobile: ['victory', 'mobile-quick', 'mobile-breakthrough'], + siege: ['victory', 'siege-objective', 'siege-front', 'siege-support'] +}; + +export type SortieOrderEvaluationInput = { + victory: boolean; + score: number; + turnNumber: number; + turnLimit: number; + survivorCount: number; + deployedCount: number; + bonusObjectiveTotal: number; + bonusObjectiveAchieved: number; + terrainObjectiveTotal: number; + terrainObjectiveAchieved: number; + performance: CampaignSortiePerformanceSnapshot; +}; + +export type SortieOrderHistorySummary = { + orderId: CampaignSortieOrderId; + attempts: number; + successes: number; + successRate: number; + currentStreak: number; + bestStreak: number; + bestScore: number; + nextMeritLabel: string; + firstRewardClaimed: boolean; +}; + +export function isSortieOrderId(value: unknown): value is CampaignSortieOrderId { + return typeof value === 'string' && (sortieOrderIds as readonly string[]).includes(value); +} + +export function sortieOrderDefinition(orderId: CampaignSortieOrderId) { + return sortieOrderDefinitions.find((definition) => definition.id === orderId) ?? sortieOrderDefinitions[0]; +} + +export function sortieOrderRewardClaimId(battleId: string, orderId: CampaignSortieOrderId) { + return `${battleId}:${orderId}`; +} + +export const sortieOrderClaimKey = sortieOrderRewardClaimId; + +export function evaluateSortieOrder( + orderId: CampaignSortieOrderId, + rawInput: SortieOrderEvaluationInput +): CampaignSortieOrderResultSnapshot { + const input = normalizeEvaluationInput(rawInput); + const victory = progress('victory', input.victory ? 1 : 0, 1, input.victory); + let orderProgress: CampaignSortieOrderProgressSnapshot[]; + + if (orderId === 'elite') { + orderProgress = [ + victory, + progress('elite-grade', input.score, 75, input.score >= 75), + progress( + 'elite-survival', + input.survivorCount, + input.deployedCount, + input.deployedCount > 0 && input.survivorCount === input.deployedCount + ) + ]; + } else if (orderId === 'mobile') { + const flankStats = roleStats(input.performance, 'flank'); + const flankDamage = flankStats.reduce((sum, stats) => sum + stats.damageDealt, 0); + const flankDefeats = flankStats.reduce((sum, stats) => sum + stats.defeats, 0); + const counterplays = input.performance.unitStats.reduce( + (sum, stats) => sum + stats.intentDefeats + stats.intentLineBreaks + stats.intentGuardSuccesses, + 0 + ); + const breakthrough = flankDamage + flankDefeats + counterplays; + orderProgress = [ + victory, + progress('mobile-quick', input.turnNumber, input.turnLimit, input.turnLimit > 0 && input.turnNumber <= input.turnLimit), + progress('mobile-breakthrough', breakthrough, 1, breakthrough >= 1) + ]; + } else { + const objectiveValue = input.terrainObjectiveTotal > 0 + ? input.terrainObjectiveAchieved + : input.bonusObjectiveAchieved; + const frontContribution = roleStats(input.performance, 'front').reduce( + (sum, stats) => sum + stats.sortiePreventedDamage + stats.sortieBonusDamage, + 0 + ); + const supportContribution = roleStats(input.performance, 'support').reduce( + (sum, stats) => sum + stats.sortieBonusDamage + stats.sortieBonusHealing + stats.sortieExtendedBondTriggers, + 0 + ); + orderProgress = [ + victory, + progress('siege-objective', objectiveValue, 1, objectiveValue >= 1), + progress('siege-front', frontContribution, 1, frontContribution >= 1), + progress('siege-support', supportContribution, 1, supportContribution >= 1) + ]; + } + + return { + version: 1, + orderId, + score: input.score, + achieved: orderProgress.every((entry) => entry.achieved), + progress: orderProgress, + rewardGranted: false + }; +} + +export function summarizeSortieOrderHistory( + history: CampaignSortieOrderHistory, + orderId: CampaignSortieOrderId, + battleOrder: readonly string[] = Object.keys(history) +): SortieOrderHistorySummary { + const orderedBattleIds = [ + ...battleOrder.filter((battleId, index) => battleOrder.indexOf(battleId) === index), + ...Object.keys(history).filter((battleId) => !battleOrder.includes(battleId)) + ]; + const results = orderedBattleIds + .map((battleId) => history[battleId]?.[orderId]) + .filter((result): result is CampaignSortieOrderResultSnapshot => Boolean(result)); + let currentStreak = 0; + let bestStreak = 0; + results.forEach((result) => { + currentStreak = result.achieved ? currentStreak + 1 : 0; + bestStreak = Math.max(bestStreak, currentStreak); + }); + const successes = results.filter((result) => result.achieved).length; + const bestScore = results.reduce((best, result) => Math.max(best, nonNegativeInteger(result.score, 100)), 0); + + return { + orderId, + attempts: results.length, + successes, + successRate: results.length > 0 ? Math.round((successes / results.length) * 100) : 0, + currentStreak, + bestStreak, + bestScore, + nextMeritLabel: nextMeritLabel(successes), + firstRewardClaimed: successes > 0 + }; +} + +function normalizeEvaluationInput(input: SortieOrderEvaluationInput): SortieOrderEvaluationInput { + return { + ...input, + victory: Boolean(input.victory), + score: nonNegativeInteger(input.score, 100), + turnNumber: nonNegativeInteger(input.turnNumber), + turnLimit: nonNegativeInteger(input.turnLimit), + survivorCount: nonNegativeInteger(input.survivorCount), + deployedCount: nonNegativeInteger(input.deployedCount), + bonusObjectiveTotal: nonNegativeInteger(input.bonusObjectiveTotal), + bonusObjectiveAchieved: Math.min( + nonNegativeInteger(input.bonusObjectiveAchieved), + nonNegativeInteger(input.bonusObjectiveTotal) + ), + terrainObjectiveTotal: nonNegativeInteger(input.terrainObjectiveTotal), + terrainObjectiveAchieved: Math.min( + nonNegativeInteger(input.terrainObjectiveAchieved), + nonNegativeInteger(input.terrainObjectiveTotal) + ) + }; +} + +function progress( + id: CampaignSortieOrderProgressId, + value: number, + target: number, + achieved: boolean +): CampaignSortieOrderProgressSnapshot { + return { + id, + achieved, + value: nonNegativeInteger(value), + target: nonNegativeInteger(target) + }; +} + +function roleStats(performance: CampaignSortiePerformanceSnapshot, role: 'front' | 'flank' | 'support') { + const selectedIds = new Set( + performance.selectedUnitIds.filter((unitId) => performance.formationAssignments[unitId] === role) + ); + return performance.unitStats.filter((stats) => selectedIds.has(stats.unitId)); +} + +function nonNegativeInteger(value: number, maximum = 999999) { + return Math.min(maximum, Math.max(0, Number.isFinite(value) ? Math.floor(value) : 0)); +} + +function nextMeritLabel(successes: number) { + if (successes <= 0) { + return '첫 작전 성공'; + } + if (successes < 3) { + return `3전공까지 ${3 - successes}승`; + } + if (successes < 5) { + return `5전공까지 ${5 - successes}승`; + } + if (successes < 10) { + return `10전공까지 ${10 - successes}승`; + } + return '최고 전공 갱신'; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 77bdc6a..32f4476 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -56,6 +56,7 @@ import { sortiePerformanceRoleContributionLine, type SortiePerformanceReview } from '../data/sortiePerformanceReview'; +import { evaluateSortieOrder, sortieOrderDefinition, sortieOrderRewardClaimId, type SortieOrderId } from '../data/sortieOrders'; import { campaignSortiePresetIds, campaignSaveSlotCount, @@ -70,6 +71,7 @@ import { type CampaignRewardSnapshot, type CampaignState, type CampaignSortieItemAssignments, + type CampaignSortieOrderResultSnapshot, type CampaignSortiePerformanceSnapshot, type CampaignSortiePresetId, type CampaignStep @@ -1234,6 +1236,7 @@ type BattleSceneData = { selectedSortieUnitIds?: string[]; sortieFormationAssignments?: SortieFormationAssignments; sortieItemAssignments?: CampaignSortieItemAssignments; + sortieOrderId?: SortieOrderId; }; type CommandButton = { @@ -1289,6 +1292,10 @@ type ResultFormationReviewView = { presetButtons: Partial>; }; +type ResultSortieOrderView = { + closeButton: Phaser.GameObjects.Rectangle; +}; + type ResultButtonView = { background: Phaser.GameObjects.Rectangle; label: Phaser.GameObjects.Text; @@ -3230,6 +3237,11 @@ export class BattleScene extends Phaser.Scene { private resultPresetOverwriteConfirmId?: CampaignSortiePresetId; private resultFormationReviewButton?: ResultButtonView; private resultFormationReviewView?: ResultFormationReviewView; + private resultSortieOrderObjects: Phaser.GameObjects.GameObject[] = []; + private resultSortieOrderVisible = false; + private resultSortieOrderButton?: ResultButtonView; + private resultSortieOrderView?: ResultSortieOrderView; + private resultSortieOrder?: CampaignSortieOrderResultSnapshot; private saveSlotPanelObjects: Phaser.GameObjects.GameObject[] = []; private saveSlotPanelMode?: SaveSlotMode; private unitViews = new Map(); @@ -3272,6 +3284,7 @@ export class BattleScene extends Phaser.Scene { private launchSortieUnitIds: string[] = []; private launchSortieFormationAssignments: SortieFormationAssignments = {}; private launchSortieItemAssignments: CampaignSortieItemAssignments = {}; + private launchSortieOrderId: SortieOrderId = 'elite'; constructor() { super('BattleScene'); @@ -3279,9 +3292,14 @@ export class BattleScene extends Phaser.Scene { init(data?: BattleSceneData) { configureBattleScenario(getBattleScenario(data?.battleId)); + const campaign = getCampaignState(); + const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id + ? campaign.sortieOrderSelection.orderId + : undefined; this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(data?.selectedSortieUnitIds); this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(data?.sortieFormationAssignments); this.launchSortieItemAssignments = this.normalizeLaunchSortieItemAssignments(data?.sortieItemAssignments); + this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(data?.sortieOrderId ?? selectedOrderId); } create() { @@ -3289,6 +3307,7 @@ export class BattleScene extends Phaser.Scene { this.resultFormationReviewVisible = false; this.resultFormationReviewFeedback = ''; this.resultPresetOverwriteConfirmId = undefined; + this.resultSortieOrder = undefined; const campaign = getCampaignState(); this.resetBattleData(campaign); if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') { @@ -3354,6 +3373,12 @@ export class BattleScene extends Phaser.Scene { this.confirmLockedTargetPreview(); }); this.input.keyboard?.on('keydown-ESC', () => { + if (this.resultSortieOrderVisible) { + soundDirector.playSelect(); + this.hideResultSortieOrder(); + return; + } + if (this.resultFormationReviewVisible) { soundDirector.playSelect(); this.hideResultFormationReview(); @@ -3717,6 +3742,10 @@ export class BattleScene extends Phaser.Scene { return normalizeCampaignSortieItemAssignments(assignments); } + private normalizeLaunchSortieOrderId(orderId?: SortieOrderId): SortieOrderId { + return orderId === 'mobile' || orderId === 'siege' ? orderId : 'elite'; + } + private effectiveSortieUnitIds(campaign?: CampaignState) { return this.launchSortieUnitIds.length > 0 ? this.launchSortieUnitIds : campaign?.selectedSortieUnitIds ?? []; } @@ -10406,6 +10435,26 @@ export class BattleScene extends Phaser.Scene { })); this.resultSettlementText.setDepth(depth + 2); + if (this.resultSortieOrder) { + const orderLabel = this.sortieOrderLabel(this.resultSortieOrder.orderId); + const orderStatus = this.resultSortieOrder.rewardGranted + ? '첫 공훈' + : this.resultSortieOrder.achieved ? '달성' : '미달'; + this.resultSortieOrderButton = this.addResultButton( + `${orderLabel} ${orderStatus}`, + left + 318, + top + 612, + 144, + () => this.toggleResultSortieOrder(), + depth + 3 + ); + this.resultSortieOrderButton.background.setStrokeStyle( + 1, + this.resultSortieOrder.achieved ? 0x59d18c : 0xc87552, + 0.9 + ); + } + const sortiePerformance = this.resultSortiePerformanceSnapshot(); if (sortiePerformance) { const formationReview = this.resultFormationReview(outcome, sortiePerformance); @@ -10443,7 +10492,7 @@ export class BattleScene extends Phaser.Scene { } this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { soundDirector.playSelect(); - this.scene.restart({ battleId: battleScenario.id }); + this.scene.restart({ battleId: battleScenario.id, sortieOrderId: this.launchSortieOrderId }); }, depth + 3); this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { soundDirector.playSelect(); @@ -10455,6 +10504,7 @@ export class BattleScene extends Phaser.Scene { private hideBattleResult() { this.resultAnimationToken += 1; + this.hideResultSortieOrder(); this.resultFormationReviewObjects.forEach((object) => object.destroy()); this.resultFormationReviewObjects = []; this.resultFormationReviewVisible = false; @@ -10462,6 +10512,7 @@ export class BattleScene extends Phaser.Scene { this.resultPresetOverwriteConfirmId = undefined; this.resultFormationReviewButton = undefined; this.resultFormationReviewView = undefined; + this.resultSortieOrderButton = undefined; this.resultObjects.forEach((object) => object.destroy()); this.resultObjects = []; this.resultGaugeAnimations = []; @@ -10658,15 +10709,21 @@ export class BattleScene extends Phaser.Scene { const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length; const sortiePerformance = this.resultSortiePerformanceSnapshot(); - const sortieReview = sortiePerformance + const formationReview = sortiePerformance + ? this.resultFormationReview(outcome, sortiePerformance) + : undefined; + const sortieReview = sortiePerformance && formationReview ? createCampaignSortieReviewSnapshot( - this.resultFormationReview(outcome, sortiePerformance), + formationReview, totalEnemies, this.resultFormationSourcePresetIds(sortiePerformance) ) : undefined; + const sortieOrder = sortiePerformance && formationReview + ? this.resultSortieOrderEvaluation(outcome, objectives, sortiePerformance, formationReview) + : undefined; - setFirstBattleReport({ + const persistedReport = setFirstBattleReport({ battleId: battleScenario.id, battleTitle: battleScenario.title, outcome, @@ -10692,10 +10749,12 @@ export class BattleScene extends Phaser.Scene { campaignRewards: rewardSnapshot, sortiePerformance, sortieReview, + sortieOrder, completedCampDialogues: [], completedCampVisits: [], createdAt: new Date().toISOString() }); + this.resultSortieOrder = persistedReport.sortieOrder ?? sortieOrder; } private resultSubtitle(outcome: BattleOutcome, rewards: CampaignRewardSnapshot) { @@ -10773,6 +10832,30 @@ export class BattleScene extends Phaser.Scene { }); } + private resultSortieOrderEvaluation( + outcome: BattleOutcome, + objectives: BattleObjectiveResult[], + performance: CampaignSortiePerformanceSnapshot, + review: SortiePerformanceReview + ) { + const objectiveById = new Map(objectives.map((objective) => [objective.id, objective] as const)); + const terrainObjectives = battleScenario.objectives.filter((objective) => objective.kind === 'secure-terrain'); + const bonusObjectives = objectives.filter((objective) => objective.category === 'bonus'); + return evaluateSortieOrder(this.launchSortieOrderId, { + victory: outcome === 'victory', + score: review.score, + turnNumber: this.turnNumber, + turnLimit: battleScenario.quickVictoryTurnLimit, + survivorCount: review.survivorCount, + deployedCount: review.deployedCount, + bonusObjectiveTotal: bonusObjectives.length, + bonusObjectiveAchieved: bonusObjectives.filter((objective) => objective.achieved).length, + terrainObjectiveTotal: terrainObjectives.length, + terrainObjectiveAchieved: terrainObjectives.filter((objective) => objectiveById.get(objective.id)?.achieved).length, + performance + }); + } + private resultFormationSnapshotMatchesPreset(presetId: CampaignSortiePresetId, snapshot: CampaignSortiePerformanceSnapshot) { return sortiePerformanceMatchesPreset(snapshot, getCampaignState().sortieFormationPresets[presetId]); } @@ -10781,6 +10864,176 @@ export class BattleScene extends Phaser.Scene { return campaignSortiePresetIds.filter((presetId) => this.resultFormationSnapshotMatchesPreset(presetId, snapshot)); } + private sortieOrderLabel(orderId: SortieOrderId) { + return sortieOrderDefinition(orderId).label; + } + + private sortieOrderSummary(orderId: SortieOrderId) { + return sortieOrderDefinition(orderId).summary; + } + + private sortieOrderProgressLabel(progressId: string) { + const labels: Record = { + victory: '전투 승리', + grade: 'A등급 이상', + 'elite-grade': 'A등급 이상', + survival: '출전 전원 생존', + 'all-survived': '출전 전원 생존', + 'elite-survival': '출전 전원 생존', + 'turn-limit': '제한 턴 이내', + turns: '제한 턴 이내', + 'mobile-quick': '제한 턴 이내', + 'mobile-contribution': '돌파·의도 기여', + 'flank-or-intent': '돌파·의도 기여', + 'mobile-breakthrough': '돌파·의도 기여', + 'bonus-objective': '추가 목표 완수', + objective: '추가 목표 완수', + 'siege-objective': '추가 목표 완수', + 'front-contribution': '전열 기여', + front: '전열 기여', + 'siege-front': '전열 기여', + 'support-contribution': '후원 기여', + support: '후원 기여', + 'siege-support': '후원 기여' + }; + return labels[progressId] ?? progressId; + } + + private sortieOrderProgressValue(progress: CampaignSortieOrderResultSnapshot['progress'][number]) { + const value = String(progress.value); + const target = String(progress.target); + if (progress.id === 'mobile-quick') { + return `${value}턴 / 제한 ${target}턴`; + } + if (progress.id === 'elite-grade') { + return `${value}점 / ${target}점`; + } + return `${value} / ${target}`; + } + + private toggleResultSortieOrder() { + if (this.resultSortieOrderVisible) { + this.hideResultSortieOrder(); + return; + } + this.showResultSortieOrder(); + } + + private showResultSortieOrder() { + const result = this.resultSortieOrder; + if (!result) { + return; + } + this.hideResultFormationReview(); + this.resultSortieOrderObjects.forEach((object) => object.destroy()); + this.resultSortieOrderObjects = []; + this.resultSortieOrderView = undefined; + this.resultSortieOrderVisible = true; + this.resultSortieOrderButton?.label.setText('정산 보기'); + + const depth = 132; + const width = 820; + const rowHeight = 46; + const height = 178 + result.progress.length * 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; + const bg = this.trackResultSortieOrder(this.add.rectangle(x, y, width, height, 0x0b1219, 0.995)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(2, accent, 0.92); + bg.setInteractive(); + + const orderLabel = this.sortieOrderLabel(result.orderId); + this.trackResultSortieOrder(this.add.text(x + 20, y + 14, `${orderLabel} 작전 명령`, this.resultFormationTextStyle(22, '#f2e3bf', true))) + .setDepth(depth + 1); + const closeButton = this.addResultSortieOrderButton('닫기', x + width - 110, y + 10, 92, 30, () => this.hideResultSortieOrder(), depth + 2); + + const status = result.achieved ? '달성' : '미달성'; + const statusBg = this.trackResultSortieOrder(this.add.rectangle(x + 20, y + 54, width - 40, 54, result.achieved ? 0x173126 : 0x321d1b, 0.96)); + statusBg.setOrigin(0); + statusBg.setDepth(depth + 1); + statusBg.setStrokeStyle(1, accent, 0.72); + this.trackResultSortieOrder(this.add.text(x + 34, y + 64, `${orderLabel} · ${status}`, this.resultFormationTextStyle(17, result.achieved ? '#a8ffd0' : '#ffb6a6', true))) + .setDepth(depth + 2); + this.trackResultSortieOrder(this.add.text(x + 150, y + 67, this.sortieOrderSummary(result.orderId), this.resultFormationTextStyle(12, '#d4dce6'))) + .setDepth(depth + 2); + + result.progress.forEach((progress, index) => { + const rowY = y + 120 + index * rowHeight; + const row = this.trackResultSortieOrder(this.add.rectangle(x + 20, rowY, width - 40, 38, 0x111c26, 0.94)); + row.setOrigin(0); + row.setDepth(depth + 1); + row.setStrokeStyle(1, progress.achieved ? palette.green : palette.red, 0.46); + this.trackResultSortieOrder(this.add.text(x + 34, rowY + 9, progress.achieved ? '달성' : '미달', this.resultFormationTextStyle(12, progress.achieved ? '#a8ffd0' : '#ffb6a6', true))) + .setDepth(depth + 2); + this.trackResultSortieOrder(this.add.text(x + 92, rowY + 9, this.sortieOrderProgressLabel(progress.id), this.resultFormationTextStyle(12, '#f2e3bf', true))) + .setDepth(depth + 2); + this.trackResultSortieOrder(this.add.text(x + width - 34, rowY + 9, this.sortieOrderProgressValue(progress), 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(', ')}`; + const rewardClaimed = getCampaignState().claimedSortieOrderRewardIds.includes( + sortieOrderRewardClaimId(battleScenario.id, result.orderId) + ); + const rewardLine = result.rewardGranted + ? `첫 공훈 획득 · ${orderLabel} · ${rewardDetail} · 전공 휘장 기록` + : result.achieved + ? `${orderLabel} 작전 달성 · 최초 달성 보상은 이미 수령했습니다.` + : rewardClaimed + ? `${orderLabel} 작전 미달 · 최초 보상은 이미 수령했으며 재도전으로 최고 전공을 갱신할 수 있습니다.` + : `모든 조건 달성 시 ${rewardDetail}과 ${orderLabel} 전공 휘장을 처음 한 번 획득합니다.`; + this.trackResultSortieOrder(this.add.text(x + 20, rewardY, rewardLine, this.resultFormationTextStyle(12, result.rewardGranted ? '#ffdf7b' : '#9fb0bf', true))) + .setDepth(depth + 2); + this.resultSortieOrderView = { closeButton }; + } + + private hideResultSortieOrder() { + this.resultSortieOrderObjects.forEach((object) => object.destroy()); + this.resultSortieOrderObjects = []; + this.resultSortieOrderVisible = false; + this.resultSortieOrderView = undefined; + if (this.resultSortieOrder) { + const orderLabel = this.sortieOrderLabel(this.resultSortieOrder.orderId); + const status = this.resultSortieOrder.rewardGranted + ? '첫 공훈' + : this.resultSortieOrder.achieved ? '달성' : '미달'; + this.resultSortieOrderButton?.label.setText(`${orderLabel} ${status}`); + } + } + + private addResultSortieOrderButton( + label: string, + x: number, + y: number, + width: number, + height: number, + action: () => void, + depth: number + ) { + const button = this.trackResultSortieOrder(this.add.rectangle(x, y, width, height, 0x1a2630, 0.98)); + button.setOrigin(0); + button.setDepth(depth); + button.setStrokeStyle(1, palette.gold, 0.68); + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(0x283947, 1)); + button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.98)); + button.on('pointerdown', action); + this.trackResultSortieOrder(this.add.text(x + width / 2, y + height / 2, label, this.resultFormationTextStyle(11, '#f2e3bf', true))) + .setOrigin(0.5) + .setDepth(depth + 1); + return button; + } + + private trackResultSortieOrder(object: T) { + this.resultSortieOrderObjects.push(object); + return object; + } + private toggleResultFormationReview(outcome: BattleOutcome) { if (this.resultFormationReviewVisible) { this.hideResultFormationReview(); @@ -10794,6 +11047,7 @@ export class BattleScene extends Phaser.Scene { if (!snapshot) { return; } + this.hideResultSortieOrder(); this.resultFormationReviewObjects.forEach((object) => object.destroy()); this.resultFormationReviewObjects = []; this.resultFormationReviewView = undefined; @@ -13908,6 +14162,13 @@ export class BattleScene extends Phaser.Scene { this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(campaign.selectedSortieUnitIds); this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(campaign.sortieFormationAssignments); this.launchSortieItemAssignments = this.normalizeLaunchSortieItemAssignments(campaign.sortieItemAssignments); + this.launchSortieOrderId = this.normalizeLaunchSortieOrderId( + state.sortieOrderId ?? ( + campaign.sortieOrderSelection?.battleId === battleScenario.id + ? campaign.sortieOrderSelection.orderId + : undefined + ) + ); this.applyBattleSaveState(state); this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { @@ -13920,6 +14181,7 @@ export class BattleScene extends Phaser.Scene { version: 1, battleId: battleScenario.id, campaignStep: getCampaignState().step, + sortieOrderId: this.launchSortieOrderId, savedAt: new Date().toISOString(), turnNumber: this.turnNumber, activeFaction: this.activeFaction, @@ -13967,8 +14229,10 @@ export class BattleScene extends Phaser.Scene { this.selectedUsable = undefined; this.lockedTargetPreview = undefined; this.battleOutcome = undefined; + this.resultSortieOrder = undefined; this.phase = 'idle'; + this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(state.sortieOrderId ?? this.launchSortieOrderId); this.turnNumber = Math.max(1, state.turnNumber || 1); this.activeFaction = state.activeFaction === 'enemy' ? 'enemy' : 'ally'; this.rosterTab = state.rosterTab === 'enemy' ? 'enemy' : 'ally'; @@ -20331,6 +20595,22 @@ export class BattleScene extends Phaser.Scene { return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; } + private resultSortieOrderDebugState() { + return { + available: true, + selectedId: this.launchSortieOrderId, + open: this.resultSortieOrderVisible, + result: this.resultSortieOrder + ? { + ...this.resultSortieOrder, + progress: this.resultSortieOrder.progress.map((progress) => ({ ...progress })) + } + : null, + toggleButtonBounds: this.resultObjectBoundsDebug(this.resultSortieOrderButton?.background), + closeButtonBounds: this.resultObjectBoundsDebug(this.resultSortieOrderView?.closeButton) + }; + } + getDebugState() { const campaign = getCampaignState(); const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign); @@ -20344,6 +20624,7 @@ export class BattleScene extends Phaser.Scene { const resultSourcePresetIds = resultFormationReview ? this.resultFormationSourcePresetIds(resultFormationReview.snapshot) : []; + const sortieOrderDebug = this.resultSortieOrderDebugState(); return { scene: this.scene.key, @@ -20354,6 +20635,8 @@ export class BattleScene extends Phaser.Scene { selectedSortieUnitIds: effectiveSortieUnitIds, sortieFormationAssignments: { ...sortieFormationAssignments }, sortieItemAssignments, + sortieOrder: sortieOrderDebug, + sortieOperationOrder: sortieOrderDebug, sortieDoctrine: this.debugSortieDoctrine(), firstSortieRoleEffects: this.debugFirstPursuitRoleEffects(), firstSortieRoleMechanicsProbe: this.debugFirstPursuitRoleMechanicsProbe(), diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 5af5048..f9c386a 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -47,6 +47,16 @@ import { sortiePerformanceRoleContributionLine, type SortiePerformanceReview } from '../data/sortiePerformanceReview'; +import { + sortieOrderDefinition, + sortieOrderDefinitions, + summarizeSortieOrderHistory, + type CampaignSortieOrderId, + type CampaignSortieOrderHistory, + type CampaignSortieOrderProgressSnapshot, + type CampaignSortieOrderResultSnapshot, + type SortieOrderHistorySummary +} from '../data/sortieOrders'; import { caoBreakRecruitBonds, caoBreakRecruitUnits, @@ -132,6 +142,7 @@ import { listCampaignSaveSlots, campaignSaveSlotCount, saveCampaignState, + setCampaignSortieOrderSelection, setCampaignReserveTrainingAssignment, setCampaignReserveTrainingFocus, type CampaignSaveSlotSummary, @@ -221,6 +232,7 @@ type SortieChecklistItem = { }; type SortiePrepStep = 'briefing' | 'formation' | 'loadout'; +type SortieFormationPanelMode = 'roster' | 'preset' | 'order'; const firstSortiePrepSteps: { id: SortiePrepStep; number: string; label: string; hint: string }[] = [ { id: 'briefing', number: '01', label: '전장 파악', hint: '목표와 위험 확인' }, @@ -446,6 +458,11 @@ type SortiePresetBrowserView = { cards: Partial>; }; +type SortieOrderBrowserView = { + closeButton: Phaser.GameObjects.Rectangle; + cards: Partial>; +}; + type ReportFormationReviewView = { closeButton: Phaser.GameObjects.Rectangle; presetButtons: Partial>; @@ -460,6 +477,7 @@ type ReportFormationHistoryRecord = { sourcePresetIds: CampaignSortiePresetId[]; unitNames: Record; endUnits: Record; + sortieOrder?: CampaignSortieOrderResultSnapshot; }; type ReportFormationHistoryProjection = { @@ -10981,6 +10999,7 @@ export class CampScene extends Phaser.Scene { private reportFormationHistorySelectedBattleId?: string; private reportFormationHistoryFeedback = ''; private reportFormationHistoryToggleButton?: Phaser.GameObjects.Rectangle; + private reportSortieOrderRewardBadge?: Phaser.GameObjects.Rectangle; private reportFormationHistoryView?: ReportFormationHistoryView; private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState; private tabButtons: CampTabButtonView[] = []; @@ -10992,13 +11011,15 @@ export class CampScene extends Phaser.Scene { private sortieHoveredUnitId?: string; private sortiePinnedSwapCandidateUnitId?: string; private sortieSwapUndoState?: SortieSwapUndoState; - private sortiePresetBrowserOpen = false; + private sortieFormationPanelMode: SortieFormationPanelMode = 'roster'; private sortieHoveredPresetId?: CampaignSortiePresetId; private sortiePinnedPresetId?: CampaignSortiePresetId; private sortiePresetOverwriteConfirmId?: CampaignSortiePresetId; private sortiePresetUndoState?: SortiePresetUndoState; private sortiePresetBrowserView?: SortiePresetBrowserView; private sortiePresetToggleButton?: Phaser.GameObjects.Rectangle; + private sortieOrderBrowserView?: SortieOrderBrowserView; + private sortieOrderToggleButton?: Phaser.GameObjects.Rectangle; private sortieComparisonPanelView?: SortieComparisonPanelView; private sortieRosterScroll = 0; private sortiePlanFeedback = ''; @@ -11023,13 +11044,15 @@ export class CampScene extends Phaser.Scene { this.sortieComparisonPanelView = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieSwapUndoState = undefined; - this.sortiePresetBrowserOpen = false; + this.sortieFormationPanelMode = 'roster'; this.sortieHoveredPresetId = undefined; this.sortiePinnedPresetId = undefined; this.sortiePresetOverwriteConfirmId = undefined; this.sortiePresetUndoState = undefined; this.sortiePresetBrowserView = undefined; this.sortiePresetToggleButton = undefined; + this.sortieOrderBrowserView = undefined; + this.sortieOrderToggleButton = undefined; this.saveSlotObjects = []; this.saveSlotConfirmObjects = []; this.pendingSaveSlot = undefined; @@ -11045,6 +11068,7 @@ export class CampScene extends Phaser.Scene { this.reportFormationHistorySelectedBattleId = undefined; this.reportFormationHistoryFeedback = ''; this.reportFormationHistoryToggleButton = undefined; + this.reportSortieOrderRewardBadge = undefined; this.reportFormationHistoryView = undefined; this.reportFormationHistoryUndoState = undefined; this.tabButtons = []; @@ -12465,6 +12489,13 @@ export class CampScene extends Phaser.Scene { return; } + if (this.sortieObjects.length > 0 && this.sortieFormationPanelMode !== 'roster') { + soundDirector.playSelect(); + this.closeSortieFormationPanelBrowser(); + this.showSortiePrep(); + return; + } + if (this.sortieObjects.length > 0) { soundDirector.playSelect(); this.hideSortiePrep(); @@ -12740,7 +12771,10 @@ export class CampScene extends Phaser.Scene { this.sortiePrepStep = step; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieHoveredUnitId = undefined; - this.closeSortiePresetBrowserState(); + this.closeSortieFormationPanelBrowser(); + if (step === 'formation' && this.nextSortieScenario() && !this.hasCurrentSortieOrderSelection()) { + this.sortieFormationPanelMode = 'order'; + } this.sortieRosterScroll = 0; soundDirector.playSelect(); this.showSortiePrep(); @@ -12752,14 +12786,14 @@ export class CampScene extends Phaser.Scene { } if (step === 'formation') { const plan = this.sortiePlanSummary(); - return plan.selectedCount > 0 && plan.formationCoverageComplete; + return plan.selectedCount > 0 && plan.formationCoverageComplete && this.hasCurrentSortieOrderSelection(); } return this.firstSortieFinalCheckItems(checklist).every((item) => item.complete); } private firstSortieFinalCheckItems(checklist: SortieChecklistItem[]) { - const labels = new Set(['출전 구성', '전열 역할', '전투 보급']); - return checklist.filter((item) => item.priority === 'required' || labels.has(item.label)).slice(0, 4); + const labels = new Set(['출전 구성', '작전 명령', '전열 역할', '전투 보급']); + return checklist.filter((item) => item.priority === 'required' || labels.has(item.label)); } private renderFirstSortieStepNavigation( @@ -12906,7 +12940,11 @@ export class CampScene extends Phaser.Scene { const gap = 18; const rosterWidth = 704; const planWidth = width - rosterWidth - gap; - this.renderFirstSortieCommanderCards(x, y, rosterWidth, height, depth); + if (this.sortieOrderBrowserOpen) { + this.renderCampaignSortieOrderBrowser(x, y, rosterWidth, height, depth); + } else { + this.renderFirstSortieCommanderCards(x, y, rosterWidth, height, depth); + } this.renderFirstSortieRoleDiagram(x + rosterWidth + gap, y, planWidth, 292, depth); this.renderFirstSortieSynergyPanel(x + rosterWidth + gap, y + 310, planWidth, height - 310, depth); } @@ -12915,7 +12953,9 @@ export class CampScene extends Phaser.Scene { const gap = 18; const rosterWidth = 610; const planWidth = width - rosterWidth - gap; - if (this.sortiePresetBrowserOpen) { + if (this.sortieOrderBrowserOpen) { + this.renderCampaignSortieOrderBrowser(x, y, rosterWidth, height, depth); + } else if (this.sortiePresetBrowserOpen) { this.renderCampaignSortiePresetBrowser(x, y, rosterWidth, height, depth); } else { this.renderCampaignSortiePortraitRoster(x, y, rosterWidth, height, depth); @@ -13126,6 +13166,93 @@ export class CampScene extends Phaser.Scene { }); } + private renderCampaignSortieOrderBrowser(x: number, y: number, width: number, height: number, depth: number) { + const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.98)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.gold, 0.7); + + this.trackSortie(this.add.text(x + 18, y + 13, '작전 명령', this.textStyle(19, '#f2e3bf', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 126, y + 18, '편성과 별개 · 전투 능력 보너스 없음', this.textStyle(10, '#9fb0bf', true))) + .setDepth(depth + 1); + + const closeButton = this.trackSortie(this.add.rectangle(x + width - 96, y + 9, 78, 30, 0x18232e, 0.96)); + closeButton.setOrigin(0); + closeButton.setDepth(depth + 2); + closeButton.setStrokeStyle(1, palette.blue, 0.58); + closeButton.setInteractive({ useHandCursor: true }); + closeButton.on('pointerover', () => closeButton.setFillStyle(0x243442, 1).setStrokeStyle(1, palette.gold, 0.9)); + closeButton.on('pointerout', () => closeButton.setFillStyle(0x18232e, 0.96).setStrokeStyle(1, palette.blue, 0.58)); + closeButton.on('pointerdown', () => this.toggleSortieOrderBrowser(false)); + this.trackSortie(this.add.text(x + width - 57, y + 24, '무장 목록', this.textStyle(11, '#d8ecff', true))) + .setOrigin(0.5) + .setDepth(depth + 3); + + const selectedOrderId = this.currentSortieOrderId(); + const summaries = new Map(this.sortieOrderSummaries().map((summary) => [summary.orderId, summary])); + const scenario = this.nextSortieScenario(); + const currentBattleResults = scenario ? this.campaign?.sortieOrderHistory[scenario.id] : undefined; + const cards: SortieOrderBrowserView['cards'] = {}; + const cardX = x + 18; + const cardWidth = width - 36; + const cardHeight = 104; + const cardGap = 8; + sortieOrderDefinitions.forEach((definition, index) => { + const cardY = y + 50 + index * (cardHeight + cardGap); + const selected = selectedOrderId === definition.id; + const summary = summaries.get(definition.id); + const currentResult = currentBattleResults?.[definition.id]; + const accent = this.sortiePresetDefinition(definition.id).accent; + const fill = selected ? 0x1d3028 : currentResult?.achieved ? 0x172a22 : 0x151f2a; + const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, fill, 0.98)); + card.setOrigin(0); + card.setDepth(depth + 1); + card.setStrokeStyle(selected ? 2 : 1, selected ? palette.gold : currentResult?.achieved ? palette.green : accent, selected ? 0.96 : 0.52); + card.setInteractive({ useHandCursor: true }); + card.on('pointerover', () => card.setFillStyle(selected ? 0x263d32 : 0x22303c, 1).setStrokeStyle(2, palette.gold, 0.96)); + card.on('pointerout', () => card.setFillStyle(fill, 0.98).setStrokeStyle(selected ? 2 : 1, selected ? palette.gold : currentResult?.achieved ? palette.green : accent, selected ? 0.96 : 0.52)); + card.on('pointerdown', () => this.selectSortieOrder(definition.id)); + cards[definition.id] = card; + + const accentBar = this.trackSortie(this.add.rectangle(cardX + 4, cardY + 4, 5, cardHeight - 8, accent, 0.94)); + accentBar.setOrigin(0); + accentBar.setDepth(depth + 2); + const seal = this.trackSortie(this.add.circle(cardX + 35, cardY + 26, 15, accent, selected ? 0.9 : 0.7)); + seal.setDepth(depth + 2); + seal.setStrokeStyle(1, selected ? palette.gold : accent, 0.9); + this.trackSortie(this.add.text(cardX + 35, cardY + 26, definition.label.slice(0, 1), this.textStyle(13, '#f2e3bf', true))) + .setOrigin(0.5) + .setDepth(depth + 3); + this.trackSortie(this.add.text(cardX + 60, cardY + 10, definition.title, this.textStyle(17, selected ? '#fff2b8' : '#f2e3bf', true))) + .setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 60, cardY + 33, this.compactText(definition.condition, width > 650 ? 72 : 58), this.textStyle(11, '#d4dce6', true))) + .setDepth(depth + 2); + const rateLine = summary && summary.attempts > 0 + ? `전적 ${summary.successes}/${summary.attempts} 성공 · ${summary.successRate}% · 연속 ${summary.currentStreak} · 최고 ${summary.bestStreak}` + : '전적 없음 · 이 군령의 첫 성공을 기록할 수 있습니다.'; + this.trackSortie(this.add.text(cardX + 22, cardY + 57, this.compactText(rateLine, width > 650 ? 78 : 62), this.textStyle(10, summary?.attempts ? '#a8ffd0' : '#9fb0bf', true))) + .setDepth(depth + 2); + const rewardLine = currentResult?.rewardGranted + ? `이 전장 첫 보상 획득 · ${definition.rewardGold} 군자금 · ${definition.rewardItems.join(', ')}` + : `첫 성공 보상 · ${definition.rewardGold} 군자금 · ${definition.rewardItems.join(', ')} · 다음 ${summary?.nextMeritLabel ?? '첫 작전 성공'}`; + this.trackSortie(this.add.text(cardX + 22, cardY + 79, this.compactText(rewardLine, width > 650 ? 82 : 66), this.textStyle(10, '#ffdf7b', true))) + .setDepth(depth + 2); + + const badgeLabel = selected ? '선택됨' : currentResult?.achieved ? '달성 기록' : '선택'; + const badge = this.trackSortie(this.add.rectangle(cardX + cardWidth - 82, cardY + 9, 66, 22, selected ? 0x304333 : 0x18232e, 0.98)); + badge.setOrigin(0); + badge.setDepth(depth + 3); + badge.setStrokeStyle(1, selected ? palette.green : accent, selected ? 0.86 : 0.56); + this.trackSortie(this.add.text(cardX + cardWidth - 49, cardY + 20, badgeLabel, this.textStyle(9, selected ? '#e7ffd9' : '#d8ecff', true))) + .setOrigin(0.5) + .setDepth(depth + 4); + }); + + this.trackSortie(this.add.text(x + 18, y + height - 24, '명령 선택은 도전 목표·전적·최초 보상만 바꾸며 편성책과 보급을 유지합니다.', this.textStyle(10, '#9fb0bf', true))) + .setDepth(depth + 1); + this.sortieOrderBrowserView = { closeButton, cards }; + } + private renderCampaignSortiePresetBrowser(x: number, y: number, width: number, height: number, depth: number) { const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.98)); bg.setOrigin(0); @@ -13392,6 +13519,20 @@ export class CampScene extends Phaser.Scene { ); if (!preview) { + if (this.sortieOrderBrowserOpen) { + const order = this.currentSortieOrderDefinition(); + const summary = order ? this.sortieOrderSummaries().find((candidate) => candidate.orderId === order.id) : undefined; + view.title.setText('작전 명령 선택'); + view.summary.setText(order ? `${order.label} · 성공 ${summary?.successes ?? 0}/${summary?.attempts ?? 0}` : '정예 · 기동 · 공성'); + view.headline.setText(order ? this.compactText(order.condition, 46) : '이번 전투에서 달성할 군령을 선택하십시오.').setColor(order ? '#a8ffd0' : '#ffdf7b'); + view.metrics.forEach((metric) => { + metric.background.setFillStyle(0x111922, 0.9).setStrokeStyle(1, 0x53606c, 0.34); + metric.value.setText('-').setColor('#77818c'); + }); + view.impact.setText(order ? this.compactText(order.summary, 66) : '군령은 편성책·장비·보급을 변경하지 않습니다.').setColor('#d4dce6'); + view.detail.setText(order ? `다음 공훈 · ${summary?.nextMeritLabel ?? '첫 작전 성공'}` : '왼쪽 군령 카드를 선택하면 출진 체크가 완료됩니다.').setColor('#d8b15f'); + return; + } view.title.setText('편성 변화'); view.summary.setText(`공명 ${current.activeBondCount} · 역할 ${current.coveredRoleCount}/3 · 지형 ${current.terrainGrade}`); view.headline.setText('출전 무장을 클릭해 교체 기준으로 고정하십시오.').setColor('#9fb0bf'); @@ -13630,7 +13771,18 @@ export class CampScene extends Phaser.Scene { bg.setDepth(depth); bg.setStrokeStyle(1, palette.gold, 0.54); this.trackSortie(this.add.text(x + 18, y + 14, '배치 도식', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1); - this.trackSortie(this.add.text(x + width - 18, y + 18, '역할 변경은 왼쪽 카드', this.textStyle(10, '#9fb0bf', true))).setOrigin(1, 0).setDepth(depth + 1); + const selectedOrder = this.currentSortieOrderDefinition(); + this.sortieOrderToggleButton = this.renderSortiePanelButton( + selectedOrder ? `${selectedOrder.label} 군령` : '군령 선택', + x + width - 106, + y + 10, + 88, + 24, + true, + this.sortieOrderBrowserOpen, + () => this.toggleSortieOrderBrowser(), + depth + 1 + ); this.renderSortieDeploymentMap(x + 16, y + 48, width - 32, 126, depth + 1, true); const selectedUnits = this.selectedSortieUnits(); @@ -13907,16 +14059,18 @@ export class CampScene extends Phaser.Scene { ).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); + const cardGap = Math.min(67, Math.floor((height - 160) / Math.max(1, items.length))); + const cardHeight = Math.max(42, cardGap - 8); items.forEach((item, index) => { - const cardY = y + 74 + index * 67; + const cardY = y + 74 + index * cardGap; const tone = item.complete ? palette.green : item.priority === 'required' ? palette.red : palette.gold; - const card = this.trackSortie(this.add.rectangle(x + 14, cardY, width - 28, 57, item.complete ? 0x172a22 : 0x211b14, 0.92)); + const card = this.trackSortie(this.add.rectangle(x + 14, cardY, width - 28, cardHeight, item.complete ? 0x172a22 : 0x211b14, 0.92)); card.setOrigin(0); card.setDepth(depth + 1); card.setStrokeStyle(1, tone, 0.5); this.trackSortie(this.add.text(x + 26, cardY + 8, item.complete ? '✓' : item.priority === 'required' ? '!' : '·', this.textStyle(14, item.complete ? '#a8ffd0' : item.priority === 'required' ? '#ff9d7d' : '#ffdf7b', true))).setDepth(depth + 2); this.trackSortie(this.add.text(x + 48, cardY + 8, item.label, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 2); - this.trackSortie(this.add.text(x + 26, cardY + 31, this.compactText(item.detail, 34), this.textStyle(11, '#9fb0bf'))).setDepth(depth + 2); + this.trackSortie(this.add.text(x + 26, cardY + Math.min(31, cardHeight - 17), this.compactText(item.detail, 34), this.textStyle(10, '#9fb0bf'))).setDepth(depth + 2); }); const rewardY = y + height - 76; @@ -13969,6 +14123,7 @@ export class CampScene extends Phaser.Scene { const maxUnits = this.sortieMaxUnits(); const hasBattle = Boolean(this.nextSortieScenario()); const synergy = this.sortieSynergySnapshot(); + const selectedOrder = this.currentSortieOrderDefinition(); const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.78)); bg.setOrigin(0); bg.setDepth(depth); @@ -13989,7 +14144,7 @@ export class CampScene extends Phaser.Scene { x + 44, y + 35, this.compactText( - `조합 공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}명`, + `${hasBattle ? `군령 ${selectedOrder?.label ?? '미선택'} · ` : ''}공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}명`, 35 ), this.textStyle(12, reserveUnits.length > 0 ? '#c8d2dd' : '#9fb0bf', true) @@ -14274,13 +14429,14 @@ export class CampScene extends Phaser.Scene { const scenario = this.nextSortieScenario(); const hasBattle = Boolean(scenario); const canUsePresetBook = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && !this.isFirstSortiePrepSlice(); - const canRecommend = this.canApplyRecommendedSortiePlan(scenario) && !this.sortiePresetBrowserOpen; + const canUseSortieOrder = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && hasBattle; + const canRecommend = this.canApplyRecommendedSortiePlan(scenario) && this.sortieFormationPanelMode === 'roster'; this.trackSortie(this.add.text(x + 18, y + 14, hasBattle ? '출전 슬롯' : '동행 명단', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1); - if (canUsePresetBook) { - this.renderSortiePanelButton('추천', x + width - 142, y + 10, 54, 22, canRecommend, false, () => this.applyRecommendedSortiePlan(), depth + 1); + if (canUsePresetBook && canUseSortieOrder) { + this.renderSortiePanelButton('추천', x + width - 230, y + 10, 48, 22, canRecommend, false, () => this.applyRecommendedSortiePlan(), depth + 1); this.sortiePresetToggleButton = this.renderSortiePanelButton( '편성책', - x + width - 82, + x + width - 176, y + 10, 64, 22, @@ -14289,16 +14445,30 @@ export class CampScene extends Phaser.Scene { () => this.toggleSortiePresetBrowser(), depth + 1 ); + const selectedOrder = this.currentSortieOrderDefinition(); + this.sortieOrderToggleButton = this.renderSortiePanelButton( + selectedOrder ? `${selectedOrder.label} 군령` : '군령 선택', + x + width - 106, + y + 10, + 88, + 22, + true, + this.sortieOrderBrowserOpen, + () => this.toggleSortieOrderBrowser(), + depth + 1 + ); } else { this.sortiePresetToggleButton = undefined; + this.sortieOrderToggleButton = undefined; this.renderSortiePanelButton(hasBattle ? '추천 편성' : '추천 동행', x + width - 104, y + 10, 86, 22, canRecommend, false, () => this.applyRecommendedSortiePlan(), depth + 1); } + const selectedOrder = this.currentSortieOrderDefinition(); this.trackSortie( this.add.text( x + 18, y + 38, hasBattle - ? `${plan.selectedCount}/${plan.maxCount}명 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}` + ? this.compactText(`군령 ${selectedOrder?.label ?? '미선택'} · ${plan.selectedCount}/${plan.maxCount}명 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}`, 54) : `${plan.selectedCount}/${plan.maxCount}명 · 군영 의정 · ${this.sortieRecommendedClassLine()}`, this.textStyle(11, '#d8b15f', true) ) @@ -14340,7 +14510,7 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(focused ? 2 : 1, focused ? palette.gold : filled ? palette.green : 0x53606c, focused ? 0.92 : filled ? 0.58 : 0.34); - if (!this.sortiePresetBrowserOpen) { + if (this.sortieFormationPanelMode === 'roster') { bg.setInteractive({ useHandCursor: true }); bg.on('pointerdown', () => { if (!unit) { @@ -14368,7 +14538,7 @@ export class CampScene extends Phaser.Scene { private renderSortieRoleQuickButtons(x: number, y: number, width: number, height: number, depth: number) { const focusedUnit = this.sortieFocusedUnit(); - const enabled = Boolean(focusedUnit && this.isSortieSelected(focusedUnit.id) && !this.sortiePresetBrowserOpen); + const enabled = Boolean(focusedUnit && this.isSortieSelected(focusedUnit.id) && this.sortieFormationPanelMode === 'roster'); const hasBattle = Boolean(this.nextSortieScenario()); const gap = 6; const buttonWidth = Math.floor((width - gap * (sortieFormationSlotDefinitions.length - 1)) / sortieFormationSlotDefinitions.length); @@ -14897,6 +15067,9 @@ export class CampScene extends Phaser.Scene { const selectedUnitIds = [...this.selectedSortieUnitIds]; const selectedIds = new Set(selectedUnitIds); const current = this.sortieSynergySnapshot(selectedUnitIds, scenario); + if (this.sortieOrderBrowserOpen) { + return undefined; + } const presetId = this.sortieHoveredPresetId ?? this.sortiePinnedPresetId; if (presetId) { const projection = this.sortiePresetProjection(presetId, scenario); @@ -15096,12 +15269,72 @@ export class CampScene extends Phaser.Scene { }; } + private currentSortieOrderId(): CampaignSortieOrderId | undefined { + const scenario = this.nextSortieScenario(); + const selection = this.campaign?.sortieOrderSelection; + return scenario && selection?.battleId === scenario.id ? selection.orderId : undefined; + } + + private currentSortieOrderDefinition() { + const orderId = this.currentSortieOrderId(); + return orderId ? sortieOrderDefinition(orderId) : undefined; + } + + private hasCurrentSortieOrderSelection() { + return Boolean(this.currentSortieOrderId()); + } + + private sortieOrderHistoryForSummary(): CampaignSortieOrderHistory { + return Object.fromEntries( + Object.entries(this.campaign?.sortieOrderHistory ?? {}).map(([battleId, results]) => [battleId, { ...results }]) + ); + } + + private sortieOrderHistoryBattleOrder() { + return Object.values(this.campaign?.battleHistory ?? {}) + .sort((left, right) => String(left.completedAt).localeCompare(String(right.completedAt))) + .map((settlement) => settlement.battleId); + } + + private sortieOrderSummaries(): SortieOrderHistorySummary[] { + const history = this.sortieOrderHistoryForSummary(); + const battleOrder = this.sortieOrderHistoryBattleOrder(); + return sortieOrderDefinitions.map((definition) => summarizeSortieOrderHistory(history, definition.id, battleOrder)); + } + + private toggleSortieOrderBrowser(forceOpen?: boolean) { + const open = forceOpen ?? !this.sortieOrderBrowserOpen; + if (open) { + this.closeSortiePresetBrowserState(); + this.sortieFormationPanelMode = 'order'; + this.sortieHoveredUnitId = undefined; + this.sortiePinnedSwapCandidateUnitId = undefined; + } else { + this.sortieFormationPanelMode = 'roster'; + } + soundDirector.playSelect(); + this.showSortiePrep(); + } + + private selectSortieOrder(orderId: CampaignSortieOrderId) { + const scenario = this.nextSortieScenario(); + if (!scenario) { + this.showCampNotice('현재 선택할 수 있는 전투 작전 명령이 없습니다.'); + return; + } + const definition = sortieOrderDefinition(orderId); + this.campaign = setCampaignSortieOrderSelection(scenario.id, orderId); + this.sortiePlanFeedback = `${definition.label} 군령 선택 · ${definition.condition}`; + soundDirector.playSelect(); + this.showSortiePrep(); + } + private toggleSortiePresetBrowser(forceOpen?: boolean) { const open = forceOpen ?? !this.sortiePresetBrowserOpen; if (!open) { this.closeSortiePresetBrowserState(); } else { - this.sortiePresetBrowserOpen = true; + this.sortieFormationPanelMode = 'preset'; this.sortieHoveredUnitId = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieHoveredPresetId = undefined; @@ -15112,12 +15345,27 @@ export class CampScene extends Phaser.Scene { } private closeSortiePresetBrowserState() { - this.sortiePresetBrowserOpen = false; + if (this.sortieFormationPanelMode === 'preset') { + this.sortieFormationPanelMode = 'roster'; + } this.sortieHoveredPresetId = undefined; this.sortiePinnedPresetId = undefined; this.sortiePresetOverwriteConfirmId = undefined; } + private closeSortieFormationPanelBrowser() { + this.closeSortiePresetBrowserState(); + this.sortieFormationPanelMode = 'roster'; + } + + private get sortiePresetBrowserOpen() { + return this.sortieFormationPanelMode === 'preset'; + } + + private get sortieOrderBrowserOpen() { + return this.sortieFormationPanelMode === 'order'; + } + private pinSortiePreset(presetId: CampaignSortiePresetId) { const projection = this.sortiePresetProjection(presetId); const definition = this.sortiePresetDefinition(presetId); @@ -16442,6 +16690,16 @@ export class CampScene extends Phaser.Scene { detail: selected.length > 0 ? selected.map((unit) => unit.name).join(', ') : hasBattle ? '출전 무장 선택 필요' : '동행 무장 선택 필요', priority: 'required' }, + ...(hasBattle + ? [{ + label: '작전 명령', + complete: this.hasCurrentSortieOrderSelection(), + detail: this.currentSortieOrderDefinition() + ? `${this.currentSortieOrderDefinition()?.label} · ${this.currentSortieOrderDefinition()?.summary}` + : '정예·기동·공성 중 하나를 명시적으로 선택', + priority: 'required' as const + }] + : []), { label: hasBattle ? '추천 병종' : '추천 편성', complete: recommendedClassComplete, @@ -16499,6 +16757,13 @@ export class CampScene extends Phaser.Scene { private startVictoryStory() { const flow = this.currentSortieFlow(); + if (flow.nextBattleId && !this.hasCurrentSortieOrderSelection()) { + this.sortiePrepStep = 'formation'; + this.sortieFormationPanelMode = 'order'; + this.showCampNotice('출진 전에 정예·기동·공성 중 하나의 작전 명령을 선택하십시오.'); + this.showSortiePrep(); + return; + } if (!this.isFinalEpilogueFlow(flow) && !this.ensureSortieSelectionSaved()) { const hasBattle = Boolean(flow.nextBattleId); const availableIds = new Set(this.sortieAllies().map((unit) => unit.id)); @@ -16557,12 +16822,14 @@ export class CampScene extends Phaser.Scene { const selectedSortieUnitIds = [...this.selectedSortieUnitIds]; const sortieFormationAssignments = { ...this.sortieFormationAssignments }; const sortieItemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments); + const sortieOrderId = this.currentSortieOrderId(); if (this.skipIntroStoryForSortie) { void startLazyScene(this, 'BattleScene', { battleId: flow.nextBattleId, selectedSortieUnitIds, sortieFormationAssignments, - sortieItemAssignments + sortieItemAssignments, + sortieOrderId }); return; } @@ -16570,7 +16837,7 @@ export class CampScene extends Phaser.Scene { void startLazyScene(this, 'StoryScene', { pages: flow.pages, nextScene: 'BattleScene', - nextSceneData: { battleId: flow.nextBattleId, selectedSortieUnitIds, sortieFormationAssignments, sortieItemAssignments } + nextSceneData: { battleId: flow.nextBattleId, selectedSortieUnitIds, sortieFormationAssignments, sortieItemAssignments, sortieOrderId } }); } @@ -16585,10 +16852,13 @@ export class CampScene extends Phaser.Scene { if (clearPinnedSwap) { this.sortiePinnedSwapCandidateUnitId = undefined; this.closeSortiePresetBrowserState(); + this.sortieFormationPanelMode = 'roster'; } this.sortieComparisonPanelView = undefined; this.sortiePresetBrowserView = undefined; this.sortiePresetToggleButton = undefined; + this.sortieOrderBrowserView = undefined; + this.sortieOrderToggleButton = undefined; } private trackSortie(object: T) { @@ -16726,6 +16996,7 @@ export class CampScene extends Phaser.Scene { this.reportFormationReviewToggleButton = undefined; this.reportFormationHistoryToggleButton = undefined; + this.reportSortieOrderRewardBadge = undefined; const x = 42; const y = 590; const bg = this.track(this.add.rectangle(x, y, 1180, 84, 0x101820, 0.86)); @@ -16749,6 +17020,22 @@ export class CampScene extends Phaser.Scene { ) ); + const sortieOrderResult = this.campaign?.battleHistory[report.battleId]?.sortieOrder ?? report.sortieOrder; + if (sortieOrderResult?.rewardGranted) { + const definition = sortieOrderDefinition(sortieOrderResult.orderId); + const rewardBadge = this.track(this.add.rectangle(x + 998, y + 11, 354, 18, 0x2d3022, 0.98)); + rewardBadge.setStrokeStyle(1, palette.gold, 0.86); + this.track( + this.add.text( + x + 998, + y + 11, + this.compactText(`첫 공훈 · ${definition.label} · 군자금 +${definition.rewardGold} · ${definition.rewardItems.join(', ')}`, 44), + this.textStyle(9, '#fff2b8', true) + ) + ).setOrigin(0.5); + this.reportSortieOrderRewardBadge = rewardBadge; + } + const review = this.reportFormationReview(); const historyRecords = this.reportFormationHistoryRecords(); const actions: { label: string; action: () => void; assign: (button: Phaser.GameObjects.Rectangle) => void }[] = []; @@ -16882,7 +17169,10 @@ export class CampScene extends Phaser.Scene { review: resolvedReview, sourcePresetIds: [...storedReview.sourcePresetIds], 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 }])) + 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 })) } + : undefined }; } @@ -17469,6 +17759,7 @@ export class CampScene extends Phaser.Scene { const bestRecord = this.reportFormationHistoryBestRecord(records); const bestLoadableRecord = this.reportFormationHistoryBestLoadableRecord(records); const presetAverages = this.reportFormationPresetAverages(records); + const orderSummaries = this.sortieOrderSummaries(); const bestProjection = this.reportFormationHistoryProjection(bestLoadableRecord); const comparison = this.reportFormationHistoryComparison(bestProjection); const pageCount = Math.max(1, Math.ceil(records.length / reportFormationHistoryPageSize)); @@ -17530,7 +17821,7 @@ export class CampScene extends Phaser.Scene { ); this.renderReportFormationHistoryDetail(selectedRecord, 378, 112, 832, 208, depth + 2); this.renderReportFormationHistoryBest(bestRecord, 378, 334, 400, 190, depth + 2); - this.renderReportFormationHistoryAverages(presetAverages, 790, 334, 420, 190, depth + 2); + this.renderReportFormationHistoryAverages(presetAverages, orderSummaries, 790, 334, 420, 190, depth + 2); this.renderReportFormationHistoryComparison( bestRecord, bestLoadableRecord, @@ -17650,11 +17941,28 @@ export class CampScene extends Phaser.Scene { this.trackReportFormationHistory(this.add.text(x + 42, y + 58, review.grade, this.textStyle(27, review.gradeTextColor, true))) .setOrigin(0.5) .setDepth(depth + 2); - this.trackReportFormationHistory(this.add.text(x + 84, y + 16, this.compactText(`${record.battleTitle} · ${review.score}점`, 38), this.textStyle(20, review.gradeTextColor, true))) + this.trackReportFormationHistory(this.add.text(x + 84, y + 16, this.compactText(`${record.battleTitle} · ${review.score}점`, record.sortieOrder ? 30 : 38), this.textStyle(20, review.gradeTextColor, true))) .setDepth(depth + 1); + if (record.sortieOrder) { + const definition = sortieOrderDefinition(record.sortieOrder.orderId); + const tone = record.sortieOrder.rewardGranted ? palette.gold : record.sortieOrder.achieved ? palette.green : palette.red; + const label = record.sortieOrder.rewardGranted + ? `${definition.label} · 첫 공훈 획득` + : `${definition.label} 군령 · ${record.sortieOrder.achieved ? '성공' : '미달'}`; + const badge = this.trackReportFormationHistory(this.add.rectangle(x + width - 198, y + 14, 180, 24, record.sortieOrder.achieved ? 0x172a22 : 0x2a1b18, 0.98)); + badge.setOrigin(0); + badge.setDepth(depth + 1); + badge.setStrokeStyle(1, tone, 0.78); + this.trackReportFormationHistory(this.add.text(x + width - 108, y + 26, label, this.textStyle(10, record.sortieOrder.achieved ? '#a8ffd0' : '#ff9d7d', true))) + .setOrigin(0.5) + .setDepth(depth + 2); + } this.trackReportFormationHistory(this.add.text(x + 84, y + 49, `강점 · ${this.compactText(review.strengths.join(' · '), 62)}`, this.textStyle(11, '#d4dce6', true))) .setDepth(depth + 1); - this.trackReportFormationHistory(this.add.text(x + 84, y + 70, `보완 · ${this.compactText(review.improvement, 62)}`, this.textStyle(11, '#ffdf7b', true))) + const secondaryLine = record.sortieOrder + ? `군령 · ${this.sortieOrderResultProgressLine(record.sortieOrder)}` + : `보완 · ${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); const metrics = [ @@ -17694,6 +18002,29 @@ export class CampScene extends Phaser.Scene { }); } + private sortieOrderResultProgressLine(result: CampaignSortieOrderResultSnapshot) { + return result.progress + .map((entry) => this.sortieOrderProgressLabel(entry)) + .join(' · '); + } + + private sortieOrderProgressLabel(entry: CampaignSortieOrderProgressSnapshot) { + const labels: Record = { + victory: '승리', + 'elite-grade': '평가', + 'elite-survival': '생존', + 'mobile-quick': '제한 턴', + 'mobile-breakthrough': '돌파', + 'siege-objective': '추가 목표', + 'siege-front': '전열', + 'siege-support': '후원' + }; + if (entry.id === 'victory') { + return `${entry.achieved ? '✓' : '✕'} ${labels[entry.id]}`; + } + return `${entry.achieved ? '✓' : '✕'} ${labels[entry.id]} ${entry.value}/${entry.target}`; + } + private renderReportFormationHistoryBest( record: ReportFormationHistoryRecord, x: number, @@ -17743,6 +18074,7 @@ export class CampScene extends Phaser.Scene { private renderReportFormationHistoryAverages( averages: ReportFormationPresetAverage[], + orderSummaries: SortieOrderHistorySummary[], x: number, y: number, width: number, @@ -17753,13 +18085,14 @@ export class CampScene extends Phaser.Scene { background.setOrigin(0); background.setDepth(depth); background.setStrokeStyle(1, palette.blue, 0.58); - this.trackReportFormationHistory(this.add.text(x + 18, y + 14, '편성책 평균', this.textStyle(17, '#f2e3bf', true))).setDepth(depth + 1); - this.trackReportFormationHistory(this.add.text(x + width - 18, y + 17, '출전 당시 저장본 기준', this.textStyle(10, '#9fb0bf', true))) + this.trackReportFormationHistory(this.add.text(x + 18, y + 14, '편성책 평균 · 군령 전적', this.textStyle(17, '#f2e3bf', true))).setDepth(depth + 1); + this.trackReportFormationHistory(this.add.text(x + width - 18, y + 17, '출전 당시 기록', this.textStyle(10, '#9fb0bf', true))) .setOrigin(1, 0) .setDepth(depth + 1); averages.forEach((average, index) => { const definition = this.sortiePresetDefinition(average.presetId); + const orderSummary = orderSummaries.find((summary) => summary.orderId === average.presetId); const rowY = y + 46 + index * 45; const row = this.trackReportFormationHistory(this.add.rectangle(x + 18, rowY, width - 36, 38, 0x151f2a, 0.94)); row.setOrigin(0); @@ -17773,13 +18106,23 @@ export class CampScene extends Phaser.Scene { this.trackReportFormationHistory(this.add.text(x + 43, rowY + 19, average.grade ?? '-', this.textStyle(13, gradeColor?.text ?? '#7f8994', true))) .setOrigin(0.5) .setDepth(depth + 3); - this.trackReportFormationHistory(this.add.text(x + 70, rowY + 9, definition.label, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 2); - const summary = average.count > 0 - ? `평균 ${average.averageScore?.toFixed(1)}점 · ${average.count}전 · 최고 ${average.bestScore}` - : '기록 없음'; - const summaryText = this.trackReportFormationHistory(this.add.text(x + width - 28, rowY + 10, summary, this.textStyle(11, average.count > 0 ? '#d4dce6' : '#7f8994', true))); + this.trackReportFormationHistory(this.add.text(x + 70, rowY + 5, definition.label, this.textStyle(12, '#f2e3bf', true))).setDepth(depth + 2); + const presetLine = average.count > 0 + ? `편성 ${average.averageScore?.toFixed(1)} · ${average.count}전` + : '편성 기록 없음'; + this.trackReportFormationHistory(this.add.text(x + 112, rowY + 6, presetLine, this.textStyle(9, average.count > 0 ? '#c8d2dd' : '#7f8994', true))) + .setDepth(depth + 2); + const orderLine = orderSummary && orderSummary.attempts > 0 + ? `군령 ${orderSummary.successes}/${orderSummary.attempts} · ${orderSummary.successRate}%` + : '군령 0전 · -'; + const summaryText = this.trackReportFormationHistory(this.add.text(x + width - 28, rowY + 6, orderLine, this.textStyle(10, orderSummary?.attempts ? '#a8ffd0' : '#7f8994', true))); summaryText.setOrigin(1, 0); summaryText.setDepth(depth + 2); + const meritLine = orderSummary && orderSummary.attempts > 0 + ? `연속 ${orderSummary.currentStreak} · 최고 연속 ${orderSummary.bestStreak} · 최고 ${orderSummary.bestScore}점 · ${orderSummary.nextMeritLabel}` + : `연속 0 · 최고 0점 · ${orderSummary?.nextMeritLabel ?? '첫 작전 성공'}`; + this.trackReportFormationHistory(this.add.text(x + 70, rowY + 22, this.compactText(meritLine, 48), this.textStyle(9, '#d8b15f', true))) + .setDepth(depth + 2); }); } @@ -17811,7 +18154,7 @@ export class CampScene extends Phaser.Scene { : `최고 기록 · ${bestRecord.battleTitle} ${bestRecord.review.grade} ${bestRecord.review.score}점`; this.trackReportFormationHistory(this.add.text(x + 18, y + 39, this.compactText(countLine, 62), this.textStyle(11, '#d4dce6', true))).setDepth(depth + 1); this.trackReportFormationHistory(this.add.text(x + 18, y + 60, this.compactText(synergyLine, 62), this.textStyle(11, '#d8b15f', true))).setDepth(depth + 1); - const feedback = this.reportFormationHistoryFeedback || '명단·역할만 불러오며 공통 무장의 보급만 유지합니다.'; + const feedback = this.reportFormationHistoryFeedback || '명단·역할만 불러오며 군령과 공통 무장의 보급은 유지합니다.'; this.trackReportFormationHistory(this.add.text(x + 18, y + 84, this.compactText(feedback, 66), this.textStyle(10, this.reportFormationHistoryFeedback ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 1); const canLoad = Boolean(this.nextSortieScenario() && projection && comparison && !comparison.matchesCurrent); @@ -17890,7 +18233,7 @@ export class CampScene extends Phaser.Scene { before, applied: this.captureSortieConfiguration() }; - this.reportFormationHistoryFeedback = `최고 편성 적용 · 명단 ${projection.selectedUnitIds.length}명과 역할 불러옴 · 공통 무장 보급 유지`; + this.reportFormationHistoryFeedback = `최고 편성 적용 · 명단 ${projection.selectedUnitIds.length}명과 역할 불러옴 · 군령·공통 무장 보급 유지`; soundDirector.playSelect(); this.renderReportFormationHistory(); } @@ -19555,6 +19898,7 @@ export class CampScene extends Phaser.Scene { this.contentObjects = []; this.reportFormationReviewToggleButton = undefined; this.reportFormationHistoryToggleButton = undefined; + this.reportSortieOrderRewardBadge = undefined; } private sortieDeploymentPreviewDebug() { @@ -19591,6 +19935,9 @@ export class CampScene extends Phaser.Scene { const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview); const recommendedPresetId = this.recommendedSortiePresetId(); const sortieFormationPresets = this.sortieFormationPresets(); + const sortieOrderId = this.currentSortieOrderId(); + const sortieOrderSummaries = this.sortieOrderSummaries(); + const sortieOrderBattleResults = sortieScenario ? this.campaign?.sortieOrderHistory[sortieScenario.id] : undefined; const reportFormationReview = this.reportFormationReview(); const reportFormationMatchingPresetIds = reportFormationReview ? this.reportFormationSourcePresetIds(reportFormationReview.snapshot) @@ -19598,6 +19945,9 @@ export class CampScene extends Phaser.Scene { const reportFormationSourcePresetIds = this.report ? this.campaign?.battleHistory[this.report.battleId]?.sortieReview?.sourcePresetIds ?? this.report.sortieReview?.sourcePresetIds ?? [] : []; + const reportSortieOrder = this.report + ? this.campaign?.battleHistory[this.report.battleId]?.sortieOrder ?? this.report.sortieOrder + : undefined; const reportFormationHistoryRecords = this.reportFormationHistoryRecords(); this.ensureReportFormationHistorySelection(reportFormationHistoryRecords); const reportFormationHistorySelected = this.reportFormationHistorySelectedRecord(reportFormationHistoryRecords); @@ -19612,6 +19962,73 @@ export class CampScene extends Phaser.Scene { return { scene: this.scene.key, activeTab: this.activeTab, + sortieOperationOrder: { + available: Boolean(sortieScenario), + required: Boolean(sortieScenario && stagedSortiePrep), + open: this.sortieOrderBrowserOpen, + formationPanelMode: this.sortieFormationPanelMode, + selectionBattleId: this.campaign?.sortieOrderSelection?.battleId ?? null, + selectedId: sortieOrderId ?? null, + complete: this.hasCurrentSortieOrderSelection(), + toggleButtonBounds: this.sortieObjectBoundsDebug(this.sortieOrderToggleButton), + closeButtonBounds: this.sortieObjectBoundsDebug(this.sortieOrderBrowserView?.closeButton), + cards: sortieOrderDefinitions.map((definition) => { + const summary = sortieOrderSummaries.find((candidate) => candidate.orderId === definition.id); + const currentResult = sortieOrderBattleResults?.[definition.id]; + return { + id: definition.id, + label: definition.label, + title: definition.title, + summary: definition.summary, + condition: definition.condition, + selected: sortieOrderId === definition.id, + attempts: summary?.attempts ?? 0, + successes: summary?.successes ?? 0, + successRate: summary?.successRate ?? 0, + currentStreak: summary?.currentStreak ?? 0, + bestStreak: summary?.bestStreak ?? 0, + bestScore: summary?.bestScore ?? 0, + nextMeritLabel: summary?.nextMeritLabel ?? '첫 작전 성공', + firstRewardClaimed: summary?.firstRewardClaimed ?? false, + currentBattleResult: currentResult + ? { + achieved: currentResult.achieved, + score: currentResult.score, + rewardGranted: currentResult.rewardGranted, + progress: currentResult.progress.map((entry) => ({ ...entry })) + } + : null, + cardBounds: this.sortieObjectBoundsDebug(this.sortieOrderBrowserView?.cards[definition.id]) + }; + }) + }, + reportOperationOrder: reportSortieOrder + ? { + available: true, + orderId: reportSortieOrder.orderId, + label: sortieOrderDefinition(reportSortieOrder.orderId).label, + achieved: reportSortieOrder.achieved, + score: reportSortieOrder.score, + firstRewardGranted: reportSortieOrder.rewardGranted, + rewardLine: reportSortieOrder.rewardGranted + ? `${sortieOrderDefinition(reportSortieOrder.orderId).label} 첫 공훈 · 군자금 +${sortieOrderDefinition(reportSortieOrder.orderId).rewardGold} · ${sortieOrderDefinition(reportSortieOrder.orderId).rewardItems.join(', ')}` + : '', + progressLine: this.sortieOrderResultProgressLine(reportSortieOrder), + progress: reportSortieOrder.progress.map((entry) => ({ ...entry })), + rewardBadgeBounds: this.sortieObjectBoundsDebug(this.reportSortieOrderRewardBadge) + } + : { + available: false, + orderId: null, + label: null, + achieved: false, + score: 0, + firstRewardGranted: false, + rewardLine: '', + progressLine: '', + progress: [], + rewardBadgeBounds: null + }, reportFormationEvaluation: reportFormationReview ? { available: true, @@ -19690,6 +20107,16 @@ export class CampScene extends Phaser.Scene { roleCoverage: record.review.roleCoverage, activeBondCount: record.review.activeBondCount, sourcePresetIds: [...record.sourcePresetIds], + sortieOrder: record.sortieOrder + ? { + orderId: record.sortieOrder.orderId, + achieved: record.sortieOrder.achieved, + score: record.sortieOrder.score, + rewardGranted: record.sortieOrder.rewardGranted, + progressLine: this.sortieOrderResultProgressLine(record.sortieOrder), + progress: record.sortieOrder.progress.map((entry) => ({ ...entry })) + } + : null, selected: record.battleId === reportFormationHistorySelected?.battleId, rowBounds: this.sortieObjectBoundsDebug(this.reportFormationHistoryView?.rowButtons[record.battleId]) })), @@ -19714,6 +20141,17 @@ export class CampScene extends Phaser.Scene { roleCoverage: reportFormationHistorySelected.review.roleCoverage, activeBondCount: reportFormationHistorySelected.review.activeBondCount, sourcePresetIds: [...reportFormationHistorySelected.sourcePresetIds], + sortieOrder: reportFormationHistorySelected.sortieOrder + ? { + orderId: reportFormationHistorySelected.sortieOrder.orderId, + label: sortieOrderDefinition(reportFormationHistorySelected.sortieOrder.orderId).label, + achieved: reportFormationHistorySelected.sortieOrder.achieved, + score: reportFormationHistorySelected.sortieOrder.score, + rewardGranted: reportFormationHistorySelected.sortieOrder.rewardGranted, + progressLine: this.sortieOrderResultProgressLine(reportFormationHistorySelected.sortieOrder), + progress: reportFormationHistorySelected.sortieOrder.progress.map((entry) => ({ ...entry })) + } + : null, snapshot: { selectedUnitIds: [...reportFormationHistorySelected.review.snapshot.selectedUnitIds], formationAssignments: { ...reportFormationHistorySelected.review.snapshot.formationAssignments }, @@ -19746,6 +20184,7 @@ export class CampScene extends Phaser.Scene { } : null, presetAverages: this.reportFormationPresetAverages(reportFormationHistoryRecords).map((average) => ({ ...average })), + orderSummaries: sortieOrderSummaries.map((summary) => ({ ...summary })), comparison: reportFormationHistoryComparison ? { ...reportFormationHistoryComparison, @@ -19776,6 +20215,7 @@ export class CampScene extends Phaser.Scene { best: null, loadableBest: null, presetAverages: [], + orderSummaries: sortieOrderSummaries.map((summary) => ({ ...summary })), comparison: null, feedback: '', loadUndoAvailable: false, diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index 7298691..59f726d 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -2,6 +2,7 @@ import type { BattleBond, UnitData } from '../data/scenario'; import { isCampaignStep, type CampaignStep } from './campaignState'; import type { UnitDirection } from '../data/unitAssets'; import { equipmentExpToNext, equipmentSlots, type EquipmentSlot } from '../data/battleItems'; +import type { SortieOrderId } from '../data/sortieOrders'; export type BattleSaveFaction = 'ally' | 'enemy'; export type BattleSaveRosterTab = 'ally' | 'enemy'; @@ -70,6 +71,7 @@ export type BattleSaveState = { version: 1; battleId: string; campaignStep?: CampaignStep; + sortieOrderId?: SortieOrderId; savedAt: string; turnNumber: number; activeFaction: BattleSaveFaction; @@ -172,6 +174,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida return false; } + if (state.sortieOrderId !== undefined && !isSortieOrderId(state.sortieOrderId)) { + return false; + } + const attackIntents = state.attackIntents; const units = state.units; @@ -254,6 +260,10 @@ function isTacticalCommandRole(value: unknown): value is BattleSaveTacticalComma return value === 'front' || value === 'flank' || value === 'support'; } +function isSortieOrderId(value: unknown): value is SortieOrderId { + return value === 'elite' || value === 'mobile' || value === 'siege'; +} + function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) && diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 9a62c03..994868e 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -3,6 +3,15 @@ import { unitClasses } from '../data/battleRules'; import { battleScenarios, type BattleScenarioId } from '../data/battles'; import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario'; import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment'; +import { + isSortieOrderId, + sortieOrderCheckIdsByOrder, + sortieOrderDefinition, + sortieOrderIds, + sortieOrderRewardClaimId, + type SortieOrderId, + type SortieOrderResultSnapshot +} from '../data/sortieOrders'; import { battleIdForCampaignStep, isCampCampaignStep } from './campaignRouting'; export type BattleObjectiveSnapshot = { @@ -79,6 +88,8 @@ export type CampaignSortieReviewSnapshot = { sourcePresetIds: CampaignSortiePresetId[]; }; +export type CampaignSortieOrderResultSnapshot = SortieOrderResultSnapshot; + export type FirstBattleReport = { battleId: string; battleTitle: string; @@ -95,6 +106,7 @@ export type FirstBattleReport = { campaignRewards?: CampaignRewardSnapshot; sortiePerformance?: CampaignSortiePerformanceSnapshot; sortieReview?: CampaignSortieReviewSnapshot; + sortieOrder?: CampaignSortieOrderResultSnapshot; completedCampDialogues: string[]; completedCampVisits: string[]; createdAt: string; @@ -315,6 +327,7 @@ const maxCampaignBattleBondEntries = 96; const maxCampaignReserveTrainingEntries = 96; const maxCampaignReserveTrainingAssignmentUnits = 96; const maxCampaignSortieAssignmentUnits = 96; +const maxCampaignSortieOrderRewardClaims = 256; const maxCampaignDisplayTextLength = 180; const maxCampaignNumericValue = 999999; const maxCampaignObjectiveTargetRadius = 99; @@ -359,13 +372,14 @@ export type CampaignBattleSettlement = { reserveTraining?: CampaignReserveTrainingSnapshot[]; sortiePerformance?: CampaignSortiePerformanceSnapshot; sortieReview?: CampaignSortieReviewSnapshot; + sortieOrder?: CampaignSortieOrderResultSnapshot; completedAt: string; }; export type CampaignSortieItemAssignments = Record>; -export const campaignSortiePresetIds = ['elite', 'mobile', 'siege'] as const; -export type CampaignSortiePresetId = (typeof campaignSortiePresetIds)[number]; +export const campaignSortiePresetIds = sortieOrderIds; +export type CampaignSortiePresetId = SortieOrderId; export type CampaignSortiePreset = { selectedUnitIds: string[]; @@ -374,6 +388,15 @@ export type CampaignSortiePreset = { export type CampaignSortieFormationPresets = Partial>; +export type CampaignSortieOrderSelection = { + battleId: BattleScenarioId; + orderId: CampaignSortiePresetId; +}; + +export type CampaignSortieOrderHistory = Partial< + Record>> +>; + export type CampaignState = { version: 1; updatedAt: string; @@ -387,6 +410,9 @@ export type CampaignState = { sortieFormationAssignments: SortieFormationAssignments; sortieItemAssignments: CampaignSortieItemAssignments; sortieFormationPresets: CampaignSortieFormationPresets; + sortieOrderSelection?: CampaignSortieOrderSelection; + sortieOrderHistory: CampaignSortieOrderHistory; + claimedSortieOrderRewardIds: string[]; reserveTrainingFocus: CampaignReserveTrainingFocusId; reserveTrainingAssignments: CampaignReserveTrainingAssignments; completedCampDialogues: string[]; @@ -589,6 +615,8 @@ export function createInitialCampaignState(): CampaignState { sortieFormationAssignments: {}, sortieItemAssignments: {}, sortieFormationPresets: {}, + sortieOrderHistory: {}, + claimedSortieOrderRewardIds: [], reserveTrainingFocus: defaultCampaignReserveTrainingFocusId, reserveTrainingAssignments: {}, completedCampDialogues: [], @@ -676,6 +704,22 @@ export function markCampaignStep(step: CampaignStep) { return cloneCampaignState(state); } +export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orderId?: CampaignSortiePresetId) { + const state = ensureCampaignState(); + if (!(battleId in battleScenarios)) { + return cloneCampaignState(state); + } + + if (orderId && isSortieOrderId(orderId)) { + state.sortieOrderSelection = { battleId, orderId }; + } else if (!state.sortieOrderSelection || state.sortieOrderSelection.battleId === battleId) { + delete state.sortieOrderSelection; + } + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + return cloneCampaignState(state); +} + export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) { const state = ensureCampaignState(); const normalizedFocusId = normalizeReserveTrainingFocusId(focusId); @@ -745,16 +789,60 @@ export function setFirstBattleReport(report: FirstBattleReport) { reportClone.completedCampDialogues = completedCampDialogues; reportClone.completedCampVisits = completedCampVisits; + let pendingSortieOrderReward: ReturnType | undefined; + let pendingSortieOrderClaimId: string | undefined; + const sortieOrder = normalizeCampaignSortieOrderResultSnapshot(reportClone.sortieOrder, reportClone.outcome); + if (sortieOrder) { + if (reportClone.outcome !== 'victory') { + const victoryProgress = sortieOrder.progress.find((entry) => entry.id === 'victory'); + if (victoryProgress) { + victoryProgress.value = 0; + victoryProgress.achieved = false; + } + sortieOrder.achieved = false; + } + sortieOrder.rewardGranted = false; + const claimId = sortieOrderRewardClaimId(battleId, sortieOrder.orderId); + if (sortieOrder.achieved && !state.claimedSortieOrderRewardIds.includes(claimId)) { + pendingSortieOrderReward = sortieOrderDefinition(sortieOrder.orderId); + pendingSortieOrderClaimId = claimId; + sortieOrder.rewardGranted = true; + } + const history = state.sortieOrderHistory[battleId as BattleScenarioId] ?? {}; + const previousOrderResult = history[sortieOrder.orderId]; + const keepPreviousBest = Boolean( + previousOrderResult && ( + (previousOrderResult.achieved && !sortieOrder.achieved) || + (previousOrderResult.achieved === sortieOrder.achieved && previousOrderResult.score > sortieOrder.score) + ) + ); + const bestOrderResult = keepPreviousBest ? previousOrderResult! : sortieOrder; + history[sortieOrder.orderId] = { + ...bestOrderResult, + rewardGranted: Boolean(previousOrderResult?.rewardGranted || sortieOrder.rewardGranted), + progress: bestOrderResult.progress.map((entry) => ({ ...entry })) + }; + state.sortieOrderHistory[battleId as BattleScenarioId] = history; + reportClone.sortieOrder = sortieOrder; + } else { + delete reportClone.sortieOrder; + } state.firstBattleReport = reportClone; const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery']; const victoryStep: CampaignStep = stepRule.victory; const retryStep: CampaignStep = stepRule.retry; if (reportClone.outcome !== 'victory') { + if (reportClone.sortieOrder) { + state.sortieOrderSelection = { + battleId: battleId as BattleScenarioId, + orderId: reportClone.sortieOrder.orderId + }; + } state.step = retryStep; state.latestBattleId = battleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); - return; + return cloneReport(reportClone); } state.step = victoryStep; @@ -783,11 +871,20 @@ export function setFirstBattleReport(report: FirstBattleReport) { state.completedCampVisits = completedCampVisits; state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1); state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1); + if (pendingSortieOrderReward && pendingSortieOrderClaimId) { + state.gold += pendingSortieOrderReward.rewardGold; + state.inventory = applyRewardDelta(state.inventory, pendingSortieOrderReward.rewardItems, 1); + state.claimedSortieOrderRewardIds.push(pendingSortieOrderClaimId); + } state.battleHistory[battleId] = createBattleSettlement(reportClone, reserveTraining); + if (state.sortieOrderSelection?.battleId === battleId) { + delete state.sortieOrderSelection; + } state.latestBattleId = battleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); + return cloneReport(reportClone); } export function getFirstBattleReport() { @@ -978,6 +1075,15 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(recordOrEmpty(normalized.sortieFormationAssignments), sortieUnitFilter); normalized.sortieItemAssignments = normalizeCampaignSortieItemAssignments(recordOrEmpty(normalized.sortieItemAssignments), sortieUnitFilter); normalized.sortieFormationPresets = normalizeCampaignSortieFormationPresets(normalized.sortieFormationPresets, sortieUnitFilter); + normalized.sortieOrderSelection = normalizeCampaignSortieOrderSelection(normalized.sortieOrderSelection); + if (!normalized.sortieOrderSelection) { + delete normalized.sortieOrderSelection; + } + normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(normalized.sortieOrderHistory); + normalized.claimedSortieOrderRewardIds = normalizeCampaignSortieOrderRewardClaims( + normalized.claimedSortieOrderRewardIds, + normalized.sortieOrderHistory + ); normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); normalized.reserveTrainingAssignments = normalizeCampaignReserveTrainingAssignments(recordOrEmpty(normalized.reserveTrainingAssignments), sortieUnitFilter); normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); @@ -1134,6 +1240,108 @@ function normalizeInventory(value: unknown) { }, {}); } +function normalizeCampaignSortieOrderSelection(value: unknown): CampaignSortieOrderSelection | undefined { + if (!isPlainObject(value)) { + return undefined; + } + const battleId = typeof value.battleId === 'string' ? value.battleId : ''; + if (!(battleId in battleScenarios) || !isSortieOrderId(value.orderId)) { + return undefined; + } + return { battleId: battleId as BattleScenarioId, orderId: value.orderId }; +} + +export function normalizeCampaignSortieOrderResultSnapshot( + value: unknown, + outcome?: FirstBattleReport['outcome'] +): CampaignSortieOrderResultSnapshot | undefined { + if (!isPlainObject(value) || value.version !== 1 || !isSortieOrderId(value.orderId)) { + return undefined; + } + const orderId = value.orderId; + const progressById = new Map( + arrayOrEmpty(value.progress) + .filter(isPlainObject) + .map((entry) => [entry.id, entry] as const) + ); + const progress = sortieOrderCheckIdsByOrder[orderId].map((id) => { + const entry = progressById.get(id); + if (!entry) { + return { id, achieved: false, value: 0, target: 1 }; + } + return { + id, + achieved: entry.achieved === true, + value: normalizeNonNegativeInteger(entry.value), + target: Math.max(1, normalizeNonNegativeInteger(entry.target)) + }; + }); + if (outcome === 'defeat') { + const victoryProgress = progress.find((entry) => entry.id === 'victory'); + if (victoryProgress) { + victoryProgress.value = 0; + victoryProgress.achieved = false; + } + } + const achieved = progress.every((entry) => entry.achieved); + return { + version: 1, + orderId, + score: Math.min(100, normalizeNonNegativeInteger(value.score)), + achieved, + progress, + rewardGranted: achieved && value.rewardGranted === true + }; +} + +function normalizeCampaignSortieOrderHistory(value: unknown): 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]); + if (result?.orderId === orderId) { + results[orderId] = result; + } + return results; + }, + {} + ); + if (Object.keys(normalizedByOrder).length > 0) { + history[battleId as BattleScenarioId] = normalizedByOrder; + } + return history; + }, {}); +} + +function normalizeCampaignSortieOrderRewardClaims( + value: unknown, + history: CampaignSortieOrderHistory +) { + const validClaims = new Set(); + arrayOrEmpty(value).forEach((claimId) => { + if (typeof claimId !== 'string') { + return; + } + const separatorIndex = claimId.lastIndexOf(':'); + const battleId = claimId.slice(0, separatorIndex); + const orderId = claimId.slice(separatorIndex + 1); + if (separatorIndex > 0 && battleId in battleScenarios && isSortieOrderId(orderId)) { + validClaims.add(sortieOrderRewardClaimId(battleId, orderId)); + } + }); + Object.entries(history).forEach(([battleId, results]) => { + sortieOrderIds.forEach((orderId) => { + if (results?.[orderId]?.rewardGranted) { + validClaims.add(sortieOrderRewardClaimId(battleId, orderId)); + } + }); + }); + return [...validClaims].slice(0, maxCampaignSortieOrderRewardClaims); +} + function normalizeBattleHistory(value: unknown) { return Object.entries(recordOrEmpty(value)).reduce>((history, [, settlement]) => { const normalizedSettlement = normalizeCampaignBattleSettlement(settlement); @@ -1161,12 +1369,16 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost const sortieReview = sortiePerformanceUnchanged ? normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance) : undefined; + const sortieOrder = sortieReview + ? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome) + : undefined; const filtered = { ...report, units: report.units.filter((unit) => unit.faction !== 'ally' || rosterUnitIds.has(unit.id)), bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))), ...(sortiePerformance ? { sortiePerformance } : {}), - ...(sortieReview ? { sortieReview } : {}) + ...(sortieReview ? { sortieReview } : {}), + ...(sortieOrder ? { sortieOrder } : {}) }; if (!sortiePerformance) { delete filtered.sortiePerformance; @@ -1174,6 +1386,9 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost if (!sortieReview) { delete filtered.sortieReview; } + if (!sortieOrder) { + delete filtered.sortieOrder; + } if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) { delete filtered.mvp; } @@ -1191,13 +1406,17 @@ function filterBattleHistoryRosterReferences( const sortieReview = sortiePerformanceUnchanged ? normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance) : undefined; + const sortieOrder = sortieReview + ? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome) + : undefined; filteredHistory[battleId] = { ...settlement, units: settlement.units.filter((unit) => rosterUnitIds.has(unit.unitId)), bonds: bondIds.size > 0 ? settlement.bonds.filter((bond) => bondIds.has(bond.id)) : settlement.bonds, reserveTraining: settlement.reserveTraining?.filter((entry) => rosterUnitIds.has(entry.unitId)), ...(sortiePerformance ? { sortiePerformance } : {}), - ...(sortieReview ? { sortieReview } : {}) + ...(sortieReview ? { sortieReview } : {}), + ...(sortieOrder ? { sortieOrder } : {}) }; if (!sortiePerformance) { delete filteredHistory[battleId].sortiePerformance; @@ -1205,6 +1424,9 @@ function filterBattleHistoryRosterReferences( if (!sortieReview) { delete filteredHistory[battleId].sortieReview; } + if (!sortieOrder) { + delete filteredHistory[battleId].sortieOrder; + } return filteredHistory; }, {}); } @@ -1230,6 +1452,9 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance); const sortieReview = normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance); + const sortieOrder = sortieReview + ? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome) + : undefined; return { battleId, @@ -1244,6 +1469,7 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries), ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), + ...(sortieOrder ? { sortieOrder } : {}), completedAt }; } @@ -1565,6 +1791,9 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance); const sortieReview = normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance); + const sortieOrder = sortieReview + ? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome) + : undefined; return { battleId, @@ -1585,6 +1814,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi ), ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), + ...(sortieOrder ? { sortieOrder } : {}), completedCampDialogues: uniqueStrings(report.completedCampDialogues), completedCampVisits: uniqueStrings(report.completedCampVisits), createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() @@ -1836,6 +2066,14 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp } } : {}), + ...(report.sortieOrder + ? { + sortieOrder: { + ...report.sortieOrder, + progress: report.sortieOrder.progress.map((entry) => ({ ...entry })) + } + } + : {}), completedAt: report.createdAt }; }