feat: carry camp promises into first victory

This commit is contained in:
2026-07-27 05:01:17 +09:00
parent 4d3f8b6a28
commit 0c16ff3c03
11 changed files with 888 additions and 23 deletions

View File

@@ -27,6 +27,7 @@ const expectedCampMarkerIds = [
'prologue-camp-review-scout-report',
'prologue-camp-ready-vanguard',
'prologue-camp-inspect-arms',
'prologue-camp-reassure-volunteer',
'prologue-camp-complete'
];
const expectedFirstCommandOrderIds = ['elite', 'mobile', 'siege'];
@@ -40,6 +41,8 @@ const server = await createServer({
try {
const village = await server.ssrLoadModule('/src/game/data/prologueVillage.ts');
const militiaCamp = await server.ssrLoadModule('/src/game/data/prologueMilitiaCamp.ts');
const narrativeMemory = await server.ssrLoadModule('/src/game/data/firstBattleNarrativeMemory.ts');
const scenario = await server.ssrLoadModule('/src/game/data/scenario.ts');
const sortieOrders = await server.ssrLoadModule('/src/game/data/sortieOrders.ts');
const campaign = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const unitAssets = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
@@ -262,6 +265,14 @@ try {
'militia camp should include at least one optional volunteer conversation',
failures
);
const nervousVolunteer = campNpcs.find((npc) => npc.id === 'nervous-volunteer');
assert(
nervousVolunteer?.dialogue.length === 2 &&
nervousVolunteer.repeatDialogue?.length === 1 &&
dialogueText(nervousVolunteer).includes('보조를 맞추고'),
'the nervous volunteer should make one optional promise and use a concise repeat line',
failures
);
assert(
!JSON.stringify(campNpcs).includes('피란'),
'militia camp dialogue must use the established 피난 terminology consistently',
@@ -302,6 +313,12 @@ try {
'prologue-camp must be recognized by campaign progress summaries',
failures
);
verifyFirstBattleNarrativeMemory(
narrativeMemory,
scenario.firstBattleVictoryPages,
sortieOrders,
failures
);
if (failures.length > 0) {
throw new Error(
@@ -313,7 +330,8 @@ try {
console.log(
`Verified ${npcs.length} village NPCs, ${campNpcs.length} militia camp NPCs, ` +
`${openingPages.length + brotherhoodPages.length + departurePages.length} prologue story pages, ` +
`${firstCommandChoices.length} first-command choices, sequential meetings, camp preparation, and save markers.`
`${firstCommandChoices.length} first-command choices, sequential meetings, camp preparation, ` +
'optional promise memory, six victory-recall variants, and save markers.'
);
} finally {
await server.close();
@@ -333,6 +351,169 @@ function assertArrayEquals(actual, expected, label, failures) {
);
}
function verifyFirstBattleNarrativeMemory(memory, sourcePages, sortieOrders, failures) {
const battleId = 'first-battle-zhuo-commandery';
const pageSnapshot = JSON.stringify(sourcePages);
const frozenPages = deepFreeze(sourcePages.map((page) => ({
...page,
...(page.cutscene ? { cutscene: structuredClone(page.cutscene) } : {})
})));
const progressIdsByOrder = {
elite: ['victory', 'elite-grade', 'elite-survival'],
mobile: ['victory', 'mobile-quick', 'mobile-breakthrough'],
siege: ['victory', 'siege-objective', 'siege-front', 'siege-support']
};
const missedIdByOrder = {
elite: 'elite-survival',
mobile: 'mobile-quick',
siege: 'siege-support'
};
const missedLabelByOrder = {
elite: '전원 생존',
mobile: '제한 턴',
siege: '후원 기여'
};
for (const orderId of ['elite', 'mobile', 'siege']) {
for (const achieved of [true, false]) {
const failedProgressId = achieved ? null : missedIdByOrder[orderId];
const report = {
battleId,
outcome: 'victory',
turnNumber: 7,
mvp: { unitId: 'liu-bei', name: ' 유비 ' },
sortieOrder: {
orderId,
achieved,
rewardGranted: false,
progress: progressIdsByOrder[orderId].map((id) => ({
id,
achieved: id !== failedProgressId,
value: id === failedProgressId ? 0 : 1,
target: 1
}))
}
};
const resolved = memory.resolveFirstBattleVictoryNarrative(frozenPages, report);
const guanYuPage = resolved.pages.find((page) => page.id === 'first-victory-guan-yu');
const zhangFeiPage = resolved.pages.find((page) => page.id === 'first-victory-zhang-fei');
const orderLabel = sortieOrders.sortieOrderDisplayLabel(battleId, orderId);
const label = `${orderId}/${achieved ? 'achieved' : 'missed'}`;
assert(resolved.memory.source === 'campaign', `${label} must use campaign memory`, failures);
assert(resolved.memory.orderId === orderId, `${label} must retain its order id`, failures);
assert(resolved.memory.orderLabel === orderLabel, `${label} must use the contextual order label`, failures);
assert(resolved.memory.achieved === achieved, `${label} must branch on achieved, not rewardGranted`, failures);
assert(resolved.memory.turnNumber === 7, `${label} must retain the actual turn`, failures);
assert(resolved.memory.mvp?.name === '유비', `${label} must normalize the MVP name`, failures);
assert(
guanYuPage?.text.includes(`${orderLabel} 군령`) &&
zhangFeiPage?.text.includes(`${orderLabel} 군령`),
`${label} dialogue must name the selected order`,
failures
);
assert(
guanYuPage?.text !== zhangFeiPage?.text,
`${label} must give Guan Yu and Zhang Fei distinct voices`,
failures
);
assert(
(guanYuPage?.text.length ?? 999) <= 96 && (zhangFeiPage?.text.length ?? 999) <= 96,
`${label} dialogue must fit the existing FHD story panel`,
failures
);
if (achieved) {
assert(
resolved.memory.missedCriterionIds.length === 0 &&
guanYuPage?.text.includes('모두 지켰') &&
zhangFeiPage?.text.includes('완수'),
`${label} must clearly remember command completion`,
failures
);
} else {
assert(
resolved.memory.missedCriterionIds.includes(failedProgressId) &&
guanYuPage?.text.includes(missedLabelByOrder[orderId]) &&
zhangFeiPage?.text.includes(missedLabelByOrder[orderId]),
`${label} must name the missed criterion`,
failures
);
}
if (orderId === 'mobile') {
assert(
guanYuPage?.text.includes('7턴') && zhangFeiPage?.text.includes('7턴'),
`${label} must remember the actual battle turn`,
failures
);
}
assert(
resolved.pages.every((page, index) => page !== frozenPages[index]),
`${label} must return new page objects`,
failures
);
assert(
resolved.pages.every((page, index) => (
['first-victory-guan-yu', 'first-victory-zhang-fei'].includes(page.id) ||
page.text === frozenPages[index].text
)),
`${label} must change only the two companion recall pages`,
failures
);
assert(
JSON.stringify(frozenPages) === pageSnapshot &&
JSON.stringify(sourcePages) === pageSnapshot,
`${label} must not mutate shared story pages`,
failures
);
}
}
const fallbackReports = [
[undefined, 'missing-report'],
[{ battleId: 'other-battle', outcome: 'victory' }, 'battle-mismatch'],
[{ battleId, outcome: 'defeat' }, 'non-victory'],
[{ battleId, outcome: 'victory' }, 'missing-order'],
[{
battleId,
outcome: 'victory',
sortieOrder: { orderId: 'elite', achieved: true, progress: [] }
}, 'invalid-order-progress']
];
fallbackReports.forEach(([report, reason]) => {
const resolved = memory.resolveFirstBattleVictoryNarrative(frozenPages, report);
assert(
resolved.memory.source === 'fallback' &&
resolved.memory.fallbackReason === reason &&
JSON.stringify(resolved.pages) === pageSnapshot,
`${reason} must preserve the authored victory dialogue exactly`,
failures
);
});
assert(
memory.volunteerPromiseLineForBattle(battleId, true) === memory.firstBattleVolunteerPromiseText &&
memory.firstBattleVolunteerPromiseText.length <= 42,
'the reassured volunteer should return in the existing three-line first-engagement banner',
failures
);
assert(
memory.volunteerPromiseLineForBattle(battleId, false) === undefined &&
memory.volunteerPromiseLineForBattle('second-battle-yellow-turban-pursuit', true) === undefined,
'the optional promise must not appear when skipped or leak into later battles',
failures
);
}
function deepFreeze(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
return value;
}
Object.freeze(value);
Object.values(value).forEach(deepFreeze);
return value;
}
function verifyNpcs(npcs, locationLabel, unitAssets, failures) {
assert(
new Set(npcs.map((npc) => npc.id)).size === npcs.length,