Files
heros_web/scripts/verify-prologue-village-data.mjs

404 lines
13 KiB
JavaScript

import { createServer } from 'vite';
const expectedOpeningPageIds = ['yellow-turban-chaos', 'liu-bei-resolve'];
const expectedBrotherhoodPageIds = ['militia-rises'];
const expectedDeparturePageIds = ['first-sortie', 'battle-briefing'];
const expectedRequiredObjectiveIds = [
'meet-zhang-fei',
'meet-guan-yu',
'register-volunteers'
];
const expectedCampaignMarkerIds = [
'prologue-village-entered',
'prologue-village-meet-zhang-fei',
'prologue-village-meet-guan-yu',
'prologue-village-register-volunteers',
// Kept so existing saves made before the narrative rewrite still normalize.
'prologue-village-check-supplies',
'prologue-village-complete'
];
const expectedCampObjectiveIds = [
'review-scout-report',
'ready-vanguard',
'inspect-arms'
];
const expectedCampMarkerIds = [
'prologue-camp-entered',
'prologue-camp-review-scout-report',
'prologue-camp-ready-vanguard',
'prologue-camp-inspect-arms',
'prologue-camp-complete'
];
const expectedFirstCommandOrderIds = ['elite', 'mobile', 'siege'];
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const village = await server.ssrLoadModule('/src/game/data/prologueVillage.ts');
const militiaCamp = await server.ssrLoadModule('/src/game/data/prologueMilitiaCamp.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');
const failures = [];
const openingPages = village.prologueOpeningPages();
const brotherhoodPages = village.prologueBrotherhoodPages();
const departurePages = village.prologueDeparturePages();
assertArrayEquals(
openingPages.map((page) => page.id),
expectedOpeningPageIds,
'opening story page order',
failures
);
assertArrayEquals(
brotherhoodPages.map((page) => page.id),
expectedBrotherhoodPageIds,
'brotherhood-to-camp story page order',
failures
);
assertArrayEquals(
departurePages.map((page) => page.id),
expectedDeparturePageIds,
'departure story page order',
failures
);
assert(
!JSON.stringify(departurePages).includes('잔당'),
'the first sortie must introduce the Yellow Turban force before later battles call them remnants',
failures
);
assertArrayEquals(
[...village.prologueVillageRequiredObjectiveIds],
expectedRequiredObjectiveIds,
'required objective order',
failures
);
const objectives = village.prologueVillageObjectiveDefinitions;
assert(objectives.length === 4, `expected 4 objective rows, received ${objectives.length}`, failures);
assert(
new Set(objectives.map((objective) => objective.id)).size === objectives.length,
'objective ids must be unique',
failures
);
assert(
objectives.at(-1)?.id === 'make-oath',
'the final objective must be make-oath',
failures
);
assertArrayEquals(
objectives.map((objective) => objective.prerequisiteId ?? null),
[null, 'meet-zhang-fei', 'meet-guan-yu', 'register-volunteers'],
'village objective prerequisite chain',
failures
);
const npcs = village.prologueVillageNpcDefinitions;
const objectiveNpcIds = npcs
.filter((npc) => npc.objectiveId)
.map((npc) => npc.objectiveId);
assertArrayEquals(
[...objectiveNpcIds].sort(),
[...expectedRequiredObjectiveIds].sort(),
'required NPC objective coverage',
failures
);
assert(
new Set(npcs.map((npc) => npc.id)).size === npcs.length,
'NPC ids must be unique',
failures
);
assert(npcs.some((npc) => !npc.objectiveId), 'at least one optional villager should be available', failures);
verifyNpcs(npcs, 'village', unitAssets, failures);
const zhangFeiMeetingText = dialogueText(npcs.find((npc) => npc.id === 'zhang-fei'));
assert(
zhangFeiMeetingText.includes('모병 격문') &&
zhangFeiMeetingText.includes('재산') &&
zhangFeiMeetingText.includes('의병'),
'Zhang Fei meeting must retain the recruitment notice, his means, and the volunteer-army proposal',
failures
);
const guanYuMeetingText = dialogueText(npcs.find((npc) => npc.id === 'guan-yu'));
assert(
guanYuMeetingText.includes('손수레') &&
guanYuMeetingText.includes('군문') &&
guanYuMeetingText.includes('하동 해량'),
'Guan Yu meeting must retain his cart arrival, enlistment journey, and origin',
failures
);
assert(
unitAssets.hasUnitSheetAsset('unit-liu-bei'),
'the controllable Liu Bei base sheet is required',
failures
);
assertArrayEquals(
Object.values(campaign.prologueVillageCampaignTutorialIds),
expectedCampaignMarkerIds,
'campaign progress marker ids',
failures
);
assert(
expectedCampaignMarkerIds.every((id) => campaign.campaignTutorialIds.includes(id)),
'every village marker must survive campaign save normalization',
failures
);
assertArrayEquals(
[...militiaCamp.prologueMilitiaCampRequiredObjectiveIds],
expectedCampObjectiveIds,
'militia camp required objective order',
failures
);
const campObjectives = militiaCamp.prologueMilitiaCampObjectiveDefinitions;
assertArrayEquals(
campObjectives.map((objective) => objective.id),
[...expectedCampObjectiveIds, 'receive-command'],
'militia camp objective rows',
failures
);
const preparationPayoffs = militiaCamp.prologueMilitiaCampPreparationPayoffs;
assertArrayEquals(
preparationPayoffs.map((payoff) => payoff.objectiveId),
expectedCampObjectiveIds,
'militia camp preparation payoff order',
failures
);
assertArrayEquals(
preparationPayoffs.map((payoff) => payoff.battleCallback),
['enemy-intel', 'deployment-flex', 'field-salve'],
'militia camp preparation battle callbacks',
failures
);
assert(
preparationPayoffs.every((payoff) => payoff.worldCue.length > 0 && payoff.battleLabel.length > 0),
'every militia camp preparation must name its world cue and first-battle callback',
failures
);
assert(
campObjectives.find((objective) => objective.id === 'review-scout-report')?.label.includes('피난로'),
'Guan Yu scout objective must describe the evacuation-route information actually reviewed',
failures
);
const campNpcs = militiaCamp.prologueMilitiaCampNpcDefinitions;
assertArrayEquals(
campNpcs.filter((npc) => npc.objectiveId).map((npc) => npc.objectiveId).sort(),
[...expectedCampObjectiveIds].sort(),
'militia camp objective NPC coverage',
failures
);
assert(
campNpcs.filter((npc) => npc.departure).length === 1 &&
campNpcs.find((npc) => npc.departure)?.id === 'zou-jing',
'militia camp must have Zou Jing as the single final command interaction',
failures
);
const firstCommandChoices = militiaCamp.prologueMilitiaCampCommandChoices;
assertArrayEquals(
firstCommandChoices.map((choice) => choice.orderId),
expectedFirstCommandOrderIds,
'first command choice order',
failures
);
assert(
new Set(firstCommandChoices.map((choice) => choice.orderId)).size ===
expectedFirstCommandOrderIds.length,
'the first command choices must cover three unique sortie orders',
failures
);
for (const choice of firstCommandChoices) {
const definition = sortieOrders.sortieOrderDefinition(choice.orderId);
assert(
definition.id === choice.orderId &&
definition.rewardGold > 0 &&
definition.rewardItems.length > 0,
`first command ${choice.orderId} must reuse a complete battle order definition`,
failures
);
assert(
choice.label === sortieOrders.sortieOrderDisplayLabel(
'first-battle-zhuo-commandery',
choice.orderId
) &&
choice.lead.length >= 8 &&
choice.battlefieldPromise.length >= 20 &&
choice.firstBattleCondition.length >= 20,
`first command ${choice.orderId} must explain its player value and battlefield promise`,
failures
);
assert(
choice.reaction.length === 2 &&
choice.reaction.some((line) => line.speaker === '관우') &&
choice.reaction.some((line) => line.speaker === '장비'),
`first command ${choice.orderId} must receive distinct Guan Yu and Zhang Fei reactions`,
failures
);
verifyDialogueLines(
choice.reaction,
`militia camp/first-command/${choice.orderId}`,
unitAssets,
failures
);
}
assert(
campNpcs.find((npc) => npc.id === 'zou-jing')?.dialogue.length === 1,
'Zou Jing command setup should be compressed to one line so the new decision adds no extra input',
failures
);
assert(
['guan-yu', 'zhang-fei', 'quartermaster'].every((npcId) => (
campNpcs.find((npc) => npc.id === npcId)?.dialogue.length === 2
)),
'mandatory camp inspections should each resolve in two lines before their visible world payoff',
failures
);
assert(
campNpcs.some((npc) => !npc.objectiveId && !npc.departure),
'militia camp should include at least one optional volunteer conversation',
failures
);
assert(
!JSON.stringify(campNpcs).includes('피란'),
'militia camp dialogue must use the established 피난 terminology consistently',
failures
);
assertArrayEquals(
campNpcs.filter((npc) => dialogueText(npc).includes('후송')).map((npc) => npc.id),
['quartermaster'],
'militia camp medical-evacuation responsibility',
failures
);
verifyNpcs(campNpcs, 'militia camp', unitAssets, failures);
const quartermasterText = dialogueText(campNpcs.find((npc) => npc.id === 'quartermaster'));
assert(
['장세평', '소쌍', '자웅일대검', '청룡언월도', '장팔사모'].every((term) => (
quartermasterText.includes(term)
)),
'quartermaster dialogue must connect the merchant support to all three signature weapons',
failures
);
assertArrayEquals(
Object.values(campaign.prologueMilitiaCampCampaignTutorialIds),
expectedCampMarkerIds,
'militia camp campaign progress marker ids',
failures
);
assert(
expectedCampMarkerIds.every((id) => campaign.campaignTutorialIds.includes(id)),
'every militia camp marker must survive campaign save normalization',
failures
);
assert(
campaign.createInitialCampaignState().step === 'new' &&
campaign.summarizeCampaignProgress({
...campaign.createInitialCampaignState(),
step: 'prologue-camp'
}).title === '탁현 의용군 막사',
'prologue-camp must be recognized by campaign progress summaries',
failures
);
if (failures.length > 0) {
throw new Error(
`Prologue village data verification failed with ${failures.length} issue(s):\n` +
failures.map((failure) => `- ${failure}`).join('\n')
);
}
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.`
);
} finally {
await server.close();
}
function assert(condition, message, failures) {
if (!condition) {
failures.push(message);
}
}
function assertArrayEquals(actual, expected, label, failures) {
assert(
JSON.stringify(actual) === JSON.stringify(expected),
`${label}: expected [${expected.join(', ')}], received [${actual.join(', ')}]`,
failures
);
}
function verifyNpcs(npcs, locationLabel, unitAssets, failures) {
assert(
new Set(npcs.map((npc) => npc.id)).size === npcs.length,
`${locationLabel}: NPC ids must be unique`,
failures
);
for (const npc of npcs) {
assert(
Number.isFinite(npc.x) && npc.x >= 70 && npc.x <= 1435 &&
Number.isFinite(npc.y) && npc.y >= 130 && npc.y <= 900,
`${locationLabel}/${npc.id}: position must remain inside the FHD play area`,
failures
);
assert(
unitAssets.hasUnitSheetAsset(npc.textureKey),
`${locationLabel}/${npc.id}: missing ${npc.textureKey}`,
failures
);
verifyDialogueLines(npc.dialogue, `${locationLabel}/${npc.id}/dialogue`, unitAssets, failures);
if (npc.lockedDialogue) {
verifyDialogueLines(
npc.lockedDialogue,
`${locationLabel}/${npc.id}/lockedDialogue`,
unitAssets,
failures
);
}
if (npc.repeatDialogue) {
verifyDialogueLines(
npc.repeatDialogue,
`${locationLabel}/${npc.id}/repeatDialogue`,
unitAssets,
failures
);
}
}
}
function verifyDialogueLines(lines, label, unitAssets, failures) {
assert(Array.isArray(lines) && lines.length > 0, `${label}: dialogue is required`, failures);
for (const [lineIndex, line] of (lines ?? []).entries()) {
assert(
typeof line.speaker === 'string' && line.speaker.trim().length > 0,
`${label}[${lineIndex}]: speaker is required`,
failures
);
assert(
typeof line.text === 'string' && line.text.trim().length >= 8 && line.text.length <= 180,
`${label}[${lineIndex}]: dialogue must be 8-180 characters`,
failures
);
if (line.textureKey) {
assert(
unitAssets.hasUnitSheetAsset(line.textureKey),
`${label}[${lineIndex}]: missing dialogue sprite ${line.textureKey}`,
failures
);
}
}
}
function dialogueText(npc) {
return [
...(npc?.dialogue ?? []),
...(npc?.lockedDialogue ?? []),
...(npc?.repeatDialogue ?? [])
].map((line) => line.text).join(' ');
}