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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user