feat: add sortie operation orders

This commit is contained in:
2026-07-11 01:23:35 +09:00
parent 0920039b9c
commit 23bd55735b
9 changed files with 2112 additions and 49 deletions

View File

@@ -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',

View File

@@ -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();
}

View File

@@ -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');

View File

@@ -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
};
}

View File

@@ -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<Record<CampaignSortieOrderId, CampaignSortieOrderResultSnapshot>>
>;
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<CampaignSortieOrderId, readonly CampaignSortieOrderProgressId[]> = {
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 '최고 전공 갱신';
}

View File

@@ -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<Record<CampaignSortiePresetId, Phaser.GameObjects.Rectangle>>;
};
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<string, UnitView>();
@@ -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<string, string> = {
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<T extends Phaser.GameObjects.GameObject>(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(),

View File

@@ -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<Record<CampaignSortiePresetId, SortiePresetCardView>>;
};
type SortieOrderBrowserView = {
closeButton: Phaser.GameObjects.Rectangle;
cards: Partial<Record<CampaignSortieOrderId, Phaser.GameObjects.Rectangle>>;
};
type ReportFormationReviewView = {
closeButton: Phaser.GameObjects.Rectangle;
presetButtons: Partial<Record<CampaignSortiePresetId, Phaser.GameObjects.Rectangle>>;
@@ -460,6 +477,7 @@ type ReportFormationHistoryRecord = {
sourcePresetIds: CampaignSortiePresetId[];
unitNames: Record<string, string>;
endUnits: Record<string, { hp: number; maxHp: number }>;
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<T extends Phaser.GameObjects.GameObject>(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<CampaignSortieOrderProgressSnapshot['id'], string> = {
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,

View File

@@ -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) &&

View File

@@ -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<string, Record<string, number>>;
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<Record<CampaignSortiePresetId, CampaignSortiePreset>>;
export type CampaignSortieOrderSelection = {
battleId: BattleScenarioId;
orderId: CampaignSortiePresetId;
};
export type CampaignSortieOrderHistory = Partial<
Record<BattleScenarioId, Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>
>;
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<typeof sortieOrderDefinition> | 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>): 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<unknown>(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<CampaignSortieOrderHistory>((history, [battleId, orderResults]) => {
if (!(battleId in battleScenarios)) {
return history;
}
const normalizedByOrder = sortieOrderIds.reduce<Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>(
(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<string>();
arrayOrEmpty<unknown>(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<unknown>(value)).reduce<Record<string, CampaignBattleSettlement>>((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
};
}