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 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 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 }; }