feat: add sortie operation orders

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

View File

@@ -35,6 +35,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();
}