335 lines
9.9 KiB
JavaScript
335 lines
9.9 KiB
JavaScript
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;
|
|
}
|