355 lines
16 KiB
JavaScript
355 lines
16 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,
|
|
evaluateSortieOrderProgress,
|
|
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 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 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
|
|
};
|
|
}
|