Files
heros_web/scripts/verify-sortie-synergy.mjs

521 lines
25 KiB
JavaScript

import { createServer } from 'vite';
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const {
compareSortieSynergy,
coreSortieResonanceChainRateForLevel,
coreSortieResonanceMinLevel,
evaluateSortiePursuitPairs,
evaluateSortieSynergy,
firstPursuitRolePreviewSummaries,
firstPursuitSynergyBattleId,
sortieBondBonusForLevel,
sortieRoleEffectSummaries
} = await server.ssrLoadModule('/src/game/data/sortieSynergy.ts');
const {
evaluateSortieOrder,
evaluateSortieOrderProgress,
sortieOrderDefinitions,
sortieOrderRewardClaimId,
summarizeSortieOrderHistory
} = await server.ssrLoadModule('/src/game/data/sortieOrders.ts');
const {
compareUnitStrategyCoverage,
getUnitStrategyCoverage,
getUnitStrategyNames,
strategyCoverageDefinitions,
unitStrategyIds,
usableCatalog
} = await server.ssrLoadModule('/src/game/data/battleUsables.ts');
assert(
JSON.stringify(strategyCoverageDefinitions.map(({ id, label, usableId }) => ({ id, label, usableId }))) === JSON.stringify([
{ id: 'healing', label: '회복', usableId: 'aid' },
{ id: 'buff', label: '강화', usableId: 'encourage' },
{ id: 'offense', label: '공격', usableId: 'fireTactic' },
{ id: 'control', label: '제어', usableId: 'roar' }
]),
`Expected the four sortie strategy coverage roles to remain stable: ${JSON.stringify(strategyCoverageDefinitions)}`
);
assert(
JSON.stringify(getUnitStrategyNames(['liu-bei'])) === JSON.stringify(['응급', '인덕의 호령']),
`Expected Liu Bei's strategy names to be visible before sortie: ${JSON.stringify(getUnitStrategyNames(['liu-bei']))}`
);
const brotherStrategyCoverage = getUnitStrategyCoverage(['liu-bei', 'guan-yu', 'zhang-fei', 'liu-bei']);
assert(
brotherStrategyCoverage.count === 4 &&
brotherStrategyCoverage.total === 4 &&
brotherStrategyCoverage.missing.length === 0,
`Expected the three brothers to cover healing, buff, attack, and control: ${JSON.stringify(brotherStrategyCoverage)}`
);
const noOffenseStrategyCoverage = getUnitStrategyCoverage(['liu-bei', 'unknown-unit']);
assert(
noOffenseStrategyCoverage.count === 2 &&
noOffenseStrategyCoverage.missing.length === 2 &&
noOffenseStrategyCoverage.missing.some((definition) => definition.id === 'offense') &&
noOffenseStrategyCoverage.missing.some((definition) => definition.id === 'control'),
`Expected Liu Bei alone to leave offense and control uncovered: ${JSON.stringify(noOffenseStrategyCoverage)}`
);
const controlStrategyGain = compareUnitStrategyCoverage(['liu-bei', 'guan-yu'], ['liu-bei', 'zhang-fei']);
assert(
controlStrategyGain.delta === 1 &&
controlStrategyGain.trend === 'gain' &&
controlStrategyGain.gained.length === 1 &&
controlStrategyGain.gained[0]?.id === 'control' &&
controlStrategyGain.lost.length === 0,
`Expected Zhang Fei's signature control to add coverage while preserving offense: ${JSON.stringify(controlStrategyGain)}`
);
const unknownStrategyIds = Object.entries(unitStrategyIds)
.flatMap(([unitId, strategyIds]) => strategyIds.map((strategyId) => ({ unitId, strategyId })))
.filter(({ strategyId }) => usableCatalog[strategyId]?.command !== 'strategy');
assert(unknownStrategyIds.length === 0, `Expected every officer strategy id to resolve to a battle strategy: ${JSON.stringify(unknownStrategyIds)}`);
assert(
['bean', 'salve', 'wine'].every((itemId) => usableCatalog[itemId]?.command === 'item'),
'Expected the shared usable catalog to retain all three battle items.'
);
const expectedRoleEffectSummaries = {
front: '일반 공격 피해 -8%(최소 1) · 반격 +20%',
flank: '초반 2턴 이동 +1 · 공격 피해 +10%',
support: '공격 책략 +10% · 회복 +2'
};
assert(
JSON.stringify(sortieRoleEffectSummaries) === JSON.stringify(expectedRoleEffectSummaries),
`Expected the three generic sortie role effects to retain their combat values and copy: ${JSON.stringify(sortieRoleEffectSummaries)}`
);
assert(
firstPursuitRolePreviewSummaries === sortieRoleEffectSummaries,
'Expected the first-pursuit role preview export to remain a compatibility alias.'
);
const boundaryCases = [
[19, 0, 0],
[20, 3, 0],
[39, 3, 0],
[40, 6, 0],
[49, 6, 0],
[50, 6, 8],
[69, 9, 8],
[70, 9, 18],
[100, 15, 18]
];
boundaryCases.forEach(([level, damageBonus, chainRate]) => {
const actual = sortieBondBonusForLevel(level);
assert(
actual.damageBonus === damageBonus && actual.chainRate === chainRate,
`Unexpected Lv${level} bond bonus: ${JSON.stringify(actual)}`
);
});
assert(coreSortieResonanceMinLevel === 30, `Expected core resonance eligibility to begin at Lv30: ${coreSortieResonanceMinLevel}`);
const coreResonanceBoundaryCases = [
[29, 0],
[30, 8],
[49, 8],
[50, 18],
[69, 18],
[70, 28],
[100, 28]
];
coreResonanceBoundaryCases.forEach(([level, chainRate]) => {
const actual = coreSortieResonanceChainRateForLevel(level);
assert(actual === chainRate, `Unexpected Lv${level} core resonance chain rate: ${actual}`);
});
const members = [
{ id: 'liu-bei', name: '유비', role: 'support', terrainScore: 101 },
{ id: 'guan-yu', name: '관우', role: 'front', terrainScore: 105 },
{ id: 'zhang-fei', name: '장비', role: 'flank', terrainScore: 98 },
{ id: 'jian-yong', name: '간옹', role: 'support', terrainScore: 104 }
];
const bonds = [
{ id: 'oath', title: '도원결의', unitIds: ['liu-bei', 'guan-yu'], level: 72, exp: 4 },
{ id: 'brothers', title: '의형제', unitIds: ['guan-yu', 'zhang-fei'], level: 68, exp: 3 },
{ id: 'vow', title: '맹세', unitIds: ['liu-bei', 'zhang-fei'], level: 64, exp: 2 },
{ id: 'records', title: '군영 기록', unitIds: ['liu-bei', 'jian-yong'], level: 51, exp: 1 },
{ id: 'self', title: '잘못된 관계', unitIds: ['liu-bei', 'liu-bei'], level: 99, exp: 0 },
{ id: 'unknown', title: '알 수 없음', unitIds: ['liu-bei', 'ghost'], level: 99, exp: 0 }
];
const frozenInputs = JSON.stringify({ members, bonds });
const trio = evaluateSortieSynergy({
members,
bonds,
selectedUnitIds: ['liu-bei', 'guan-yu', 'zhang-fei', 'guan-yu'],
battleId: firstPursuitSynergyBattleId
});
assert(trio.selectedUnitIds.length === 3, `Expected duplicate selections to collapse: ${JSON.stringify(trio.selectedUnitIds)}`);
assert(trio.activeBondCount === 3, `Expected three active brother bonds: ${JSON.stringify(trio.activeBonds)}`);
assert(trio.strongestBond?.id === 'oath', `Expected Lv72 oath to be strongest: ${JSON.stringify(trio.strongestBond)}`);
assert(trio.strongestBond?.damageBonus === 9 && trio.strongestBond?.chainRate === 18, 'Expected strongest combat preview to match BattleScene rules.');
assert(trio.coveredRoleCount === 3 && trio.firstPursuitTrinityConfigured, `Expected full role coverage and trinity: ${JSON.stringify(trio)}`);
const withoutGuan = evaluateSortieSynergy({
members,
bonds,
selectedUnitIds: ['liu-bei', 'zhang-fei'],
battleId: firstPursuitSynergyBattleId
});
assert(withoutGuan.activeBondCount === 1 && withoutGuan.activeBonds[0]?.id === 'vow', `Expected only the Liu-Zhang bond after Guan Yu leaves: ${JSON.stringify(withoutGuan.activeBonds)}`);
assert(withoutGuan.coveredRoleCount === 2 && !withoutGuan.firstPursuitTrinityConfigured, 'Expected removing Guan Yu to break role coverage and trinity.');
const removal = compareSortieSynergy(trio, withoutGuan);
assert(removal.activeBondDelta === -2 && removal.lostBonds.length === 2, `Expected two lost bonds: ${JSON.stringify(removal)}`);
assert(removal.roleCoverageDelta === -1 && removal.lostRoles[0] === 'front', `Expected the front role to be lost: ${JSON.stringify(removal.lostRoles)}`);
const addition = compareSortieSynergy(withoutGuan, trio);
assert(addition.activeBondDelta === 2 && addition.gainedBonds.length === 2, `Expected adding Guan Yu to restore two bonds: ${JSON.stringify(addition)}`);
assert(addition.roleCoverageDelta === 1 && addition.gainedRoles[0] === 'front', `Expected adding Guan Yu to restore the front role: ${JSON.stringify(addition.gainedRoles)}`);
const restoredRoles = evaluateSortieSynergy({
members: members.map((member) => member.id === 'guan-yu' ? { ...member, role: 'support' } : member),
bonds,
selectedUnitIds: ['liu-bei', 'guan-yu', 'zhang-fei'],
battleId: firstPursuitSynergyBattleId
});
assert(restoredRoles.activeBondCount === 3, 'Expected role changes not to alter active bonds.');
assert(restoredRoles.coveredRoleCount === 2 && !restoredRoles.firstPursuitTrinityConfigured, 'Expected duplicate support roles to disable trinity.');
const reordered = evaluateSortieSynergy({
members: [...members].reverse(),
bonds: [...bonds].reverse(),
selectedUnitIds: ['zhang-fei', 'guan-yu', 'liu-bei'],
battleId: firstPursuitSynergyBattleId
});
assert(
reordered.activeBonds.map((bond) => bond.id).join(',') === trio.activeBonds.map((bond) => bond.id).join(','),
`Expected deterministic bond ordering: ${JSON.stringify(reordered.activeBonds)}`
);
assert(JSON.stringify({ members, bonds }) === frozenInputs, 'Expected synergy evaluation not to mutate its inputs.');
const pursuitMembers = [
{ id: 'selected-a', name: 'Selected A', role: 'front', terrainScore: 100 },
{ id: 'selected-b', name: 'Selected B', role: 'flank', terrainScore: 100 },
{ id: 'candidate-70', name: 'Candidate 70', role: 'support', terrainScore: 100 },
{ id: 'candidate-50', name: 'Candidate 50', role: 'support', terrainScore: 100 },
{ id: 'candidate-49', name: 'Candidate 49', role: 'support', terrainScore: 100 }
];
const pursuitBonds = [
{ id: 'active-70-zeta', title: 'Active Tie', unitIds: ['selected-a', 'selected-b'], level: 70 },
{ id: 'active-70-alpha', title: 'Active Tie', unitIds: ['selected-b', 'selected-a'], level: 70 },
{ id: 'active-50', title: 'Active 50', unitIds: ['selected-a', 'selected-b'], level: 50 },
{ id: 'active-30-core', title: 'Core 30', unitIds: ['selected-a', 'selected-b'], level: 30 },
{ id: 'opportunity-70-zeta', title: 'Zeta Opportunity', unitIds: ['selected-a', 'candidate-70'], level: 70 },
{ id: 'opportunity-70-alpha', title: 'Alpha Opportunity', unitIds: ['candidate-70', 'selected-a'], level: 70 },
{ id: 'opportunity-50-duplicate', title: 'Opportunity 50 Duplicate', unitIds: ['selected-a', 'candidate-70'], level: 50 },
{ id: 'opportunity-50', title: 'Opportunity 50', unitIds: ['selected-b', 'candidate-50'], level: 50 },
{ id: 'opportunity-49', title: 'Opportunity 49', unitIds: ['selected-a', 'candidate-49'], level: 49 }
];
const frozenPursuitInputs = JSON.stringify({ pursuitMembers, pursuitBonds });
const pursuitPairs = evaluateSortiePursuitPairs({
members: pursuitMembers,
bonds: pursuitBonds,
selectedUnitIds: ['selected-a', 'selected-b', 'selected-a']
});
assert(
pursuitPairs.activePairs.map((bond) => `${bond.id}:${bond.chainRate}`).join(',') === 'active-70-alpha:18',
`Expected a selected pair to keep its highest-level bond and resolve level ties by title/id while normalizing duplicate selections: ${JSON.stringify(pursuitPairs.activePairs)}`
);
assert(
pursuitPairs.activePairs.every((bond) => bond.core === false && bond.baseChainRate === bond.chainRate) &&
pursuitPairs.selectableOpportunities.every((bond) => bond.core === false && bond.baseChainRate === bond.chainRate),
`Expected non-designated pursuit pairs to preserve their legacy rates: ${JSON.stringify(pursuitPairs)}`
);
assert(
pursuitPairs.selectableOpportunities.map((bond) => `${bond.id}:${bond.chainRate}`).join(',') === 'opportunity-70-alpha:18,opportunity-50:8',
`Expected opportunities to keep one strongest unordered pair, resolve ties deterministically, expose Lv70/Lv50 rates, and hide Lv49: ${JSON.stringify(pursuitPairs.selectableOpportunities)}`
);
assert(
pursuitPairs.selectableOpportunities[0]?.selectedPartnerId === 'selected-a' &&
pursuitPairs.selectableOpportunities[0]?.selectedPartnerName === 'Selected A' &&
pursuitPairs.selectableOpportunities[0]?.candidateUnitId === 'candidate-70' &&
pursuitPairs.selectableOpportunities[0]?.candidateUnitName === 'Candidate 70',
`Expected pursuit opportunities to identify the selected partner and selectable candidate: ${JSON.stringify(pursuitPairs.selectableOpportunities[0])}`
);
const unavailablePursuitPairs = evaluateSortiePursuitPairs({
members: pursuitMembers,
bonds: pursuitBonds,
selectedUnitIds: ['selected-a', 'selected-b'],
selectableUnitIds: ['selected-a', 'selected-b', 'candidate-50', 'candidate-49']
});
assert(
unavailablePursuitPairs.selectableOpportunities.length === 1 &&
unavailablePursuitPairs.selectableOpportunities[0]?.candidateUnitId === 'candidate-50',
`Expected unavailable candidates and already-selected ids to stay out of pursuit opportunities: ${JSON.stringify(unavailablePursuitPairs.selectableOpportunities)}`
);
const promotedPursuitPairs = evaluateSortiePursuitPairs({
members: pursuitMembers,
bonds: pursuitBonds,
selectedUnitIds: ['selected-a', 'selected-b', 'candidate-70']
});
assert(
promotedPursuitPairs.activePairs.some((bond) => bond.id === 'opportunity-70-alpha' && bond.chainRate === 18) &&
promotedPursuitPairs.selectableOpportunities.every((bond) => bond.candidateUnitId !== 'candidate-70'),
`Expected a selected candidate to move from opportunity to active pair: ${JSON.stringify(promotedPursuitPairs)}`
);
const corePursuitPairs = evaluateSortiePursuitPairs({
members: pursuitMembers,
bonds: pursuitBonds,
selectedUnitIds: ['selected-a', 'selected-b', 'candidate-70'],
coreBondId: 'active-30-core'
});
assert(
corePursuitPairs.activePairs[0]?.id === 'active-30-core' &&
corePursuitPairs.activePairs[0].core === true &&
corePursuitPairs.activePairs[0].baseChainRate === 0 &&
corePursuitPairs.activePairs[0].chainRate === 8 &&
corePursuitPairs.activePairs.some((bond) => bond.id === 'opportunity-70-alpha' && !bond.core && bond.chainRate === 18),
`Expected an eligible Lv30 core pair to gain 8% pursuit and sort ahead of stronger ordinary pairs: ${JSON.stringify(corePursuitPairs.activePairs)}`
);
const inactiveCorePursuitPairs = evaluateSortiePursuitPairs({
members: pursuitMembers,
bonds: pursuitBonds,
selectedUnitIds: ['selected-a', 'selected-b'],
coreBondId: 'opportunity-49'
});
assert(
inactiveCorePursuitPairs.activePairs[0]?.id === 'active-70-alpha' &&
inactiveCorePursuitPairs.selectableOpportunities.every((bond) => bond.id !== 'opportunity-49' && !bond.core),
`Expected an ineligible or undeployed core id not to promote a pursuit pair: ${JSON.stringify(inactiveCorePursuitPairs)}`
);
assert(
JSON.stringify({ pursuitMembers, pursuitBonds }) === frozenPursuitInputs,
'Expected pursuit-pair evaluation not to mutate its inputs.'
);
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 siegeExtendedSupportInput = orderInput({
performance: {
...orderPerformance,
unitStats: orderPerformance.unitStats.map((stats) => stats.unitId === 'support-unit'
? {
...stats,
sortieBonusDamage: 0,
sortieBonusHealing: 0,
sortieExtendedBondTriggers: 1
}
: { ...stats })
}
});
const progressBoundaryCases = [
['elite exact grade', 'elite', orderInput()],
['elite below grade', 'elite', orderInput({ score: 74 })],
['elite empty deployment', 'elite', orderInput({ survivorCount: 0, deployedCount: 0 })],
['mobile exact turn', 'mobile', orderInput()],
['mobile late turn', 'mobile', orderInput({ turnNumber: 13 })],
['mobile invalid zero limit', 'mobile', orderInput({ turnNumber: 0, turnLimit: 0 })],
['mobile intent only', 'mobile', orderInput({ performance: intentOnlyPerformance })],
['siege terrain boundary', 'siege', orderInput()],
['siege terrain precedence', 'siege', orderInput({
terrainObjectiveTotal: 1,
terrainObjectiveAchieved: 0,
bonusObjectiveAchieved: 2
})],
['siege bonus fallback', 'siege', orderInput({
terrainObjectiveTotal: 0,
terrainObjectiveAchieved: 0,
bonusObjectiveAchieved: 1
})],
['siege extended support only', 'siege', siegeExtendedSupportInput]
];
progressBoundaryCases.forEach(([label, orderId, input]) => {
const progressEvaluation = evaluateSortieOrderProgress(orderId, input);
const finalEvaluation = evaluateSortieOrder(orderId, input);
const finalProgressEvaluation = {
orderId: finalEvaluation.orderId,
score: finalEvaluation.score,
achieved: finalEvaluation.achieved,
progress: finalEvaluation.progress
};
assert(
JSON.stringify(progressEvaluation) === JSON.stringify(finalProgressEvaluation),
`Expected public progress helper and final evaluator parity at ${label}: ${JSON.stringify({ progressEvaluation, finalProgressEvaluation })}`
);
});
const emptyEliteProgress = evaluateSortieOrderProgress('elite', orderInput({ survivorCount: 0, deployedCount: 0 }));
const zeroLimitMobileProgress = evaluateSortieOrderProgress('mobile', orderInput({ turnNumber: 0, turnLimit: 0 }));
const extendedSupportSiegeProgress = evaluateSortieOrderProgress('siege', siegeExtendedSupportInput);
assert(
emptyEliteProgress.progress.find((entry) => entry.id === 'elite-survival')?.achieved === false &&
zeroLimitMobileProgress.progress.find((entry) => entry.id === 'mobile-quick')?.achieved === false &&
extendedSupportSiegeProgress.progress.find((entry) => entry.id === 'siege-support')?.achieved === true,
`Expected progress helper to retain empty-deployment, zero-limit, and extended-support boundaries: ${JSON.stringify({ emptyEliteProgress, zeroLimitMobileProgress, extendedSupportSiegeProgress })}`
);
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 officer strategy coverage, generic sortie role effects, synergy previews, bond thresholds, role coverage, first-pursuit trinity, and sortie-order evaluation/reward/history boundaries.');
} finally {
await server.close();
}
function assert(condition, message) {
if (!condition) {
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
};
}