Files
heros_web/scripts/verify-first-battle-camp-followup.mjs

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