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

295 lines
13 KiB
JavaScript

import { createServer } from 'vite';
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const {
compareSortieSynergy,
evaluateSortieSynergy,
firstPursuitRolePreviewSummaries,
firstPursuitSynergyBattleId,
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%',
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)}`
);
});
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 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();
}
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
};
}