feat: add playable first-battle militia camp
This commit is contained in:
@@ -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(' ');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user