import { createServer } from 'vite'; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); try { const followup = await server.ssrLoadModule( '/src/game/data/firstBattleCampFollowup.ts' ); const narrative = await server.ssrLoadModule( '/src/game/data/firstBattleNarrativeMemory.ts' ); const orders = await server.ssrLoadModule('/src/game/data/sortieOrders.ts'); const failures = []; const missedCases = [ { orderId: 'elite', criterionId: 'elite-grade', criterionLabel: '편성 평가 A 이상', mentorUnitId: 'liu-bei', mentorName: '유비', targetDialogueId: 'liu-guan-after-first-battle' }, { orderId: 'elite', criterionId: 'elite-survival', criterionLabel: '출전 전원 생존', mentorUnitId: 'guan-yu', mentorName: '관우', targetDialogueId: 'liu-guan-after-first-battle' }, { orderId: 'mobile', criterionId: 'mobile-quick', criterionLabel: '제한 턴 이내 승리', mentorUnitId: 'zhang-fei', mentorName: '장비', targetDialogueId: 'liu-zhang-after-first-battle' }, { orderId: 'mobile', criterionId: 'mobile-breakthrough', criterionLabel: '돌파 기여 1회', mentorUnitId: 'zhang-fei', mentorName: '장비', targetDialogueId: 'liu-zhang-after-first-battle' }, { orderId: 'siege', criterionId: 'siege-objective', criterionLabel: '거점·보너스 목표 달성', mentorUnitId: 'guan-yu', mentorName: '관우', targetDialogueId: 'guan-zhang-after-first-battle' }, { orderId: 'siege', criterionId: 'siege-front', criterionLabel: '전열 기여 1 이상', mentorUnitId: 'guan-yu', mentorName: '관우', targetDialogueId: 'guan-zhang-after-first-battle' }, { orderId: 'siege', criterionId: 'siege-support', criterionLabel: '후원 기여 1 이상', mentorUnitId: 'guan-yu', mentorName: '관우', targetDialogueId: 'guan-zhang-after-first-battle' } ]; for (const testCase of missedCases) { const report = makeReport( narrative.firstBattleNarrativeBattleId, orders.sortieOrderCheckIdsByOrder, testCase.orderId, false, [testCase.criterionId] ); const snapshot = JSON.stringify(report); deepFreeze(report); const result = followup.resolveFirstBattleCampFollowup(report); const label = `${testCase.orderId}/${testCase.criterionId}`; assert(result.source === 'campaign', `${label}: campaign result required`, failures); if (result.source !== 'campaign') { continue; } assert(result.fallbackReason === null, `${label}: fallback reason must be null`, failures); assert(result.originalOrderId === testCase.orderId, `${label}: original order`, failures); assert(result.achieved === false, `${label}: missed status`, failures); assert(result.focusCriterionId === testCase.criterionId, `${label}: focus criterion`, failures); assert(result.focusCriterionLabel === testCase.criterionLabel, `${label}: focus label`, failures); assert(result.mentorUnitId === testCase.mentorUnitId, `${label}: mentor unit`, failures); assert(result.mentorName === testCase.mentorName, `${label}: mentor name`, failures); assert( result.targetDialogueId === testCase.targetDialogueId, `${label}: dialogue mapping`, failures ); assert( result.recommendedNextOrderId === testCase.orderId, `${label}: missed order must be retried`, failures ); assert( result.recommendationReason.includes(testCase.criterionLabel), `${label}: recommendation must name missed criterion`, failures ); verifyPresentation(result, label, failures); assert(JSON.stringify(report) === snapshot, `${label}: source report must remain immutable`, failures); } const expectedDialogueByOrder = { elite: 'liu-guan-after-first-battle', mobile: 'liu-zhang-after-first-battle', siege: 'guan-zhang-after-first-battle' }; for (const orderId of orders.sortieOrderIds) { const report = makeReport( narrative.firstBattleNarrativeBattleId, orders.sortieOrderCheckIdsByOrder, orderId, true, [] ); const result = followup.resolveFirstBattleCampFollowup(report); const label = `${orderId}/achieved`; assert(result.source === 'campaign', `${label}: campaign result required`, failures); if (result.source !== 'campaign') { continue; } assert(result.achieved === true, `${label}: achieved status`, failures); assert( result.recommendedNextOrderId === 'mobile', `${label}: pursuit context must recommend mobile`, failures ); assert( result.focusCriterionId === 'mobile-quick', `${label}: pursuit focus must be turn limit`, failures ); assert( result.targetDialogueId === expectedDialogueByOrder[orderId], `${label}: original order dialogue mapping`, failures ); assert( result.recommendationReason.includes('잔당 추격전'), `${label}: recommendation must explain the next battle context`, failures ); verifyPresentation(result, label, failures); } const firstMissedReport = makeReport( narrative.firstBattleNarrativeBattleId, orders.sortieOrderCheckIdsByOrder, 'siege', false, ['siege-objective', 'siege-support'] ); const firstMissed = followup.resolveFirstBattleCampFollowup(firstMissedReport); assert( firstMissed.source === 'campaign' && firstMissed.focusCriterionId === 'siege-objective', 'multiple misses must focus the first authored criterion', failures ); const malformedCases = [ { label: 'missing report', report: undefined, reason: 'missing-report' }, { label: 'old save without sortie order', report: { battleId: narrative.firstBattleNarrativeBattleId, outcome: 'victory' }, reason: 'missing-order' }, { label: 'different battle', report: { battleId: 'second-battle-yellow-turban-pursuit', outcome: 'victory' }, reason: 'battle-mismatch' }, { label: 'defeat', report: { battleId: narrative.firstBattleNarrativeBattleId, outcome: 'defeat' }, reason: 'non-victory' }, { label: 'missing progress criterion', report: { battleId: narrative.firstBattleNarrativeBattleId, outcome: 'victory', sortieOrder: { orderId: 'elite', achieved: false, progress: [{ id: 'victory', achieved: true }] } }, reason: 'invalid-order-progress' }, { label: 'inconsistent achieved flag', report: makeReport( narrative.firstBattleNarrativeBattleId, orders.sortieOrderCheckIdsByOrder, 'mobile', true, ['mobile-quick'] ), reason: 'invalid-order-progress' }, { label: 'inconsistent missed flag', report: makeReport( narrative.firstBattleNarrativeBattleId, orders.sortieOrderCheckIdsByOrder, 'siege', false, [] ), reason: 'invalid-order-progress' } ]; for (const testCase of malformedCases) { const snapshot = JSON.stringify(testCase.report); if (testCase.report) { deepFreeze(testCase.report); } const result = followup.resolveFirstBattleCampFollowup(testCase.report); assert(result.source === 'fallback', `${testCase.label}: fallback required`, failures); assert( result.source === 'fallback' && result.fallbackReason === testCase.reason, `${testCase.label}: expected ${testCase.reason}`, failures ); assert( JSON.stringify(testCase.report) === snapshot, `${testCase.label}: fallback must not mutate report`, failures ); } if (failures.length > 0) { throw new Error( `First-battle camp follow-up verification failed:\n- ${failures.join('\n- ')}` ); } console.log( `First-battle camp follow-up verification passed (${missedCases.length} missed mappings, ${orders.sortieOrderIds.length} achieved pursuit mappings).` ); } finally { await server.close(); } function makeReport( battleId, checkIdsByOrder, orderId, achieved, missedCriterionIds ) { return { battleId, outcome: 'victory', turnNumber: 6, mvp: { unitId: 'guan-yu', name: '관우' }, sortieOrder: { orderId, achieved, progress: checkIdsByOrder[orderId].map((id) => ({ id, achieved: id === 'victory' || !missedCriterionIds.includes(id), value: id === 'victory' || !missedCriterionIds.includes(id) ? 1 : 0, target: 1 })) } }; } function verifyPresentation(result, label, failures) { assert(result.dialogueTitle.trim().length >= 4, `${label}: dialogue title`, failures); assert(result.dialogueLines.length === 3, `${label}: exactly three dialogue lines`, failures); assert( result.dialogueLines.every((line) => line.includes(':') && line.length <= 72), `${label}: concise speaker-labelled dialogue`, failures ); assert( result.dialogueLines[0].startsWith(`${result.mentorName}:`), `${label}: mentor must open the dialogue`, failures ); assert(result.recommendationTitle.trim().length >= 4, `${label}: recommendation title`, failures); assert(result.recommendationReason.trim().length >= 12, `${label}: recommendation reason`, failures); assert(result.statusLabel.trim().length >= 4, `${label}: status label`, failures); } function assert(condition, message, failures) { if (!condition) { failures.push(message); } } function deepFreeze(value) { if (!value || typeof value !== 'object' || Object.isFrozen(value)) { return value; } Object.freeze(value); Object.values(value).forEach(deepFreeze); return value; }