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 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 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 ); 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 ); 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, ` + '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(' '); }