feat: add playable first-battle militia camp

This commit is contained in:
2026-07-26 23:20:47 +09:00
parent 412b8691b5
commit 9beed0ac02
20 changed files with 2477 additions and 178 deletions

View File

@@ -1,24 +1,34 @@
import { createServer } from 'vite';
const expectedOpeningPageIds = ['yellow-turban-chaos', 'liu-bei-resolve'];
const expectedDeparturePageIds = [
'first-sortie',
'yellow-turban-nearby',
'first-battle-plan-talk',
'battle-briefing'
];
const expectedBrotherhoodPageIds = ['militia-rises'];
const expectedDeparturePageIds = ['first-sortie', 'battle-briefing'];
const expectedRequiredObjectiveIds = [
'meet-zhang-fei',
'meet-guan-yu',
'check-supplies'
'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',
@@ -28,11 +38,13 @@ 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 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),
@@ -40,12 +52,23 @@ try {
'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,
@@ -65,6 +88,12 @@ try {
'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
@@ -83,35 +112,23 @@ try {
);
assert(npcs.some((npc) => !npc.objectiveId), 'at least one optional villager should be available', 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,
`${npc.id}: position must remain inside the FHD village play area`,
failures
);
assert(unitAssets.hasUnitSheetAsset(npc.textureKey), `${npc.id}: missing ${npc.textureKey}`, failures);
assert(Array.isArray(npc.dialogue) && npc.dialogue.length > 0, `${npc.id}: dialogue is required`, failures);
for (const [lineIndex, line] of npc.dialogue.entries()) {
assert(
typeof line.speaker === 'string' && line.speaker.trim().length > 0,
`${npc.id}[${lineIndex}]: speaker is required`,
failures
);
assert(
typeof line.text === 'string' && line.text.trim().length >= 8 && line.text.length <= 180,
`${npc.id}[${lineIndex}]: dialogue must be 8-180 characters`,
failures
);
if (line.textureKey) {
assert(
unitAssets.hasUnitSheetAsset(line.textureKey),
`${npc.id}[${lineIndex}]: missing dialogue sprite ${line.textureKey}`,
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'),
@@ -130,6 +147,83 @@ try {
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` +
@@ -138,8 +232,9 @@ try {
}
console.log(
`Verified ${npcs.length} village NPCs, ${expectedRequiredObjectiveIds.length} required interactions, ` +
`${openingPages.length} opening pages, ${departurePages.length} departure pages, and save markers.`
`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();
@@ -158,3 +253,72 @@ function assertArrayEquals(actual, expected, label, failures) {
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(' ');
}