feat: add sortie operation orders
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
290
src/game/data/sortieOrders.ts
Normal file
290
src/game/data/sortieOrders.ts
Normal 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 '최고 전공 갱신';
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user