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-reassure-volunteer', '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 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'); 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 ); 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', 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 ); verifyFirstBattleNarrativeMemory( narrativeMemory, scenario.firstBattleVictoryPages, sortieOrders, 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, ` + 'optional promise memory, six victory-recall variants, 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 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, `${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(' '); }