Exact source used by this build. SHA-256: 83f2731f70a8aed9e1d6f0c6169c31413437cd328a816eb9046bbcb06eb0831c.
1 import json from "../../../data/villagers/villagers.json";
2 import voice01 from "../../../data/villagers/voices/villager_01.json";
3 import voice02 from "../../../data/villagers/voices/villager_02.json";
4 import voice03 from "../../../data/villagers/voices/villager_03.json";
5 import voice04 from "../../../data/villagers/voices/villager_04.json";
6 import voice05 from "../../../data/villagers/voices/villager_05.json";
7 import voice06 from "../../../data/villagers/voices/villager_06.json";
8 import voice07 from "../../../data/villagers/voices/villager_07.json";
9 import voice08 from "../../../data/villagers/voices/villager_08.json";
10 import voice09 from "../../../data/villagers/voices/villager_09.json";
11 import voice10 from "../../../data/villagers/voices/villager_10.json";
12 import { ITEMS, validateItemReferences } from "../items";
13 import { AREA_MAPS, isMapBlocked } from "../maps";
14 import { SHOPS } from "../shops";
15 import { ANIMALS } from "../animals";
16 import { FESTIVALS } from "../festivals";
17 import type { ContentDomain } from "../types";
18 import { VillagersContentSchema, VoiceSheetSchema, type VillagersContent, type VillagerDefinition } from "./schema";
19 export * from "./schema";
20
21 export const VILLAGER_VOICE_SHEETS = Object.fromEntries([voice01, voice02, voice03, voice04, voice05, voice06, voice07, voice08, voice09, voice10].map((voice, index) => [`voices/villager_${String(index + 1).padStart(2, "0")}.json`, VoiceSheetSchema.parse(voice)]));
22 function schedules(villager: VillagerDefinition) {
23 return [villager.schedules.weekday, villager.schedules.rain, ...Object.values(villager.schedules.weekdays), ...Object.values(villager.schedules.seasons), ...Object.values(villager.schedules.festivals)];
24 }
25 /** POI coordinates come from their canonical Tiled source, never a second authored position. */
26 const mapPois = Object.values(AREA_MAPS).flatMap((area) => area.pois.map((poi) => ({ id: poi.id, areaId: area.id, x: poi.x + .5, y: poi.y + .5, facing: "down" as const })));
27 const authored = VillagersContentSchema.omit({ pois: true }).parse(json);
28 const referenceIds = new Set<string>([
29 authored.config.bulletinPoiId, authored.config.mailboxPoiId,
30 ...authored.villagers.flatMap((villager) => [villager.homePoiId, villager.workplacePoiId, ...(villager.counterPoiId ? [villager.counterPoiId] : []), ...[villager.schedules.weekday, villager.schedules.rain, ...Object.values(villager.schedules.weekdays), ...Object.values(villager.schedules.seasons), ...Object.values(villager.schedules.festivals)].flatMap((schedule) => schedule.map((entry) => entry.poiId))]),
31 ...authored.events.flatMap((event) => [...event.actors.map((actor) => actor.poiId), ...event.script.flatMap((step) => step.kind === "move" ? [step.poiId] : [])]),
32 ]);
33 export const VILLAGERS = VillagersContentSchema.parse({ ...authored, pois: mapPois.filter((poi) => referenceIds.has(poi.id)) });
34 export const VILLAGER_BY_ID = Object.fromEntries(VILLAGERS.villagers.map((villager) => [villager.id, villager]));
35 export const VILLAGER_POI_BY_ID = Object.fromEntries(VILLAGERS.pois.map((poi) => [poi.id, poi]));
36
37 /** Cross-domain invariants stay here so the pure simulation receives already validated JSON. */
38 export function validateVillagerReferences(value: unknown): VillagersContent {
39 const data = VillagersContentSchema.parse(value);
40 const unique = (ids: readonly string[], label: string) => { if (new Set(ids).size !== ids.length) throw new Error(`${label} ids must be unique`); };
41 unique(data.villagers.map((entry) => entry.id), "Villager"); unique(data.pois.map((entry) => entry.id), "Villager POI"); unique(data.dialogue.map((entry) => entry.key), "Dialogue"); unique(data.events.map((entry) => entry.id), "Heart event"); unique(data.mail.map((entry) => entry.id), "Mail"); unique(data.quests.map((entry) => entry.id), "Quest"); unique(data.festivalDays.map((entry) => entry.id), "Festival");
42 const villagers = new Set(data.villagers.map((entry) => entry.id)), pois = new Map(data.pois.map((entry) => [entry.id, entry])), dialogue = new Set(data.dialogue.map((entry) => entry.key)), quests = new Set(data.quests.map((entry) => entry.id)), festivals = new Set(data.festivalDays.map((entry) => entry.id));
43 const needVillager = (id: string) => { if (!villagers.has(id)) throw new Error(`Missing villager id: ${id}`); };
44 const needPoi = (id: string) => { const poi = pois.get(id); if (!poi) throw new Error(`Missing schedule POI: ${id}`); return poi; };
45 const needDialogue = (key: string) => { if (!dialogue.has(key)) throw new Error(`Missing dialogue key: ${key}`); };
46 const needManifest = (key: string) => { if (!data.manifest[key]) throw new Error(`Missing visual manifest key: ${key}`); };
47 for (const poi of data.pois) {
48 const area = AREA_MAPS[poi.areaId], original = area?.pois.find((entry) => entry.id === poi.id);
49 if (!area || !original) throw new Error(`Missing map POI: ${poi.id}`);
50 if (poi.x !== original.x + .5 || poi.y !== original.y + .5 || isMapBlocked(area, poi.x, poi.y)) throw new Error(`Schedule POI must match an accessible map tile: ${poi.id}`);
51 }
52 needPoi(data.config.bulletinPoiId); needPoi(data.config.mailboxPoiId);
53 validateItemReferences([data.config.bouquetItemId, data.config.pendantItemId]);
54 for (const tier of data.config.heartTiers) needManifest(tier.iconKey);
55 const tags = new Set(ITEMS.flatMap((item) => item.tags));
56 for (const festival of data.festivalDays) {
57 if (!FESTIVALS.some((entry) => entry.id === festival.id && entry.season === festival.season && entry.day === festival.day)) throw new Error(`Villager festival date must match festival calendar: ${festival.id}`);
58 }
59 for (const tastes of [data.universalTastes, ...data.villagers.map((villager) => villager.giftTastes)]) {
60 const itemIds = Object.values(tastes).flatMap((taste) => taste.itemIds), tasteTags = Object.values(tastes).flatMap((taste) => taste.tags);
61 validateItemReferences(itemIds); unique(itemIds, "Gift taste item"); unique(tasteTags, "Gift taste tag");
62 for (const tag of tasteTags) if (!tags.has(tag)) throw new Error(`Missing gift item tag: ${tag}`);
63 }
64 for (const villager of data.villagers) {
65 needPoi(villager.homePoiId); needPoi(villager.workplacePoiId);
66 if (!VILLAGER_VOICE_SHEETS[villager.voiceSheet] || villager.voiceSheet !== `voices/${villager.id}.json`) throw new Error(`Missing matching voice sheet: ${villager.voiceSheet}`);
67 needManifest(villager.spriteKey); needManifest(villager.portraitKey); needManifest(villager.iconKey);
68 for (const relationship of villager.relationships) { needVillager(relationship.villagerId); if (relationship.villagerId === villager.id) throw new Error("A relationship must reference another villager"); }
69 if (data.festivalDays.some((day) => day.season === villager.birthday.season && day.day === villager.birthday.day)) throw new Error(`Birthday overlaps festival: ${villager.id}`);
70 for (const schedule of schedules(villager)) for (const entry of schedule) needPoi(entry.poiId);
71 for (const festivalId of Object.keys(villager.schedules.festivals)) if (!festivals.has(festivalId)) throw new Error(`Unknown festival schedule: ${festivalId}`);
72 if (villager.shopId && villager.counterPoiId) {
73 const counter = needPoi(villager.counterPoiId), shop = [...SHOPS, ...ANIMALS.shops].find((entry) => entry.id === villager.shopId);
74 if (!shop || shop.areaId !== counter.areaId || Math.floor(counter.x) !== shop.x || Math.floor(counter.y) !== shop.y) throw new Error(`Shopkeeper counter must match shop: ${villager.id}`);
75 }
76 }
77 for (const entry of data.dialogue) { if (entry.villagerId) needVillager(entry.villagerId); if (entry.conditions.location && !AREA_MAPS[entry.conditions.location]) throw new Error(`Unknown dialogue location: ${entry.conditions.location}`); }
78 for (const event of data.events) {
79 needVillager(event.villagerId); if (!AREA_MAPS[event.trigger.areaId]) throw new Error(`Unknown event area: ${event.trigger.areaId}`);
80 unique(event.actors.map((actor) => actor.id), "Event actor"); const actors = new Set(event.actors.map((actor) => actor.id));
81 for (const actor of event.actors) { if (actor.id !== "player") needVillager(actor.id); if (needPoi(actor.poiId).areaId !== event.trigger.areaId) throw new Error("Event actors must start in the trigger area"); }
82 for (const step of event.script) {
83 if ("actorId" in step && !actors.has(step.actorId)) throw new Error(`Missing event actor: ${step.actorId}`);
84 if (step.kind === "move" && needPoi(step.poiId).areaId !== event.trigger.areaId) throw new Error("Event movement must remain in the trigger area");
85 if (step.kind === "dialogue" || step.kind === "choice") needDialogue(step.key);
86 if (step.kind === "choice") { unique(step.choices.map((choice) => choice.id), "Event choice"); for (const choice of step.choices) needDialogue(choice.key); }
87 }
88 }
89 for (const letter of data.mail) { needVillager(letter.villagerId); needDialogue(letter.key); needManifest(letter.manifestKey); if (letter.trigger.kind === "quest" && !quests.has(letter.trigger.questId)) throw new Error(`Missing mail quest: ${letter.trigger.questId}`); }
90 for (const quest of data.quests) {
91 needVillager(quest.villagerId); needDialogue(quest.key); needManifest(quest.manifestKey); validateItemReferences([quest.rewardItemId, ...(quest.itemId ? [quest.itemId] : [])]);
92 if (quest.kind === "slay" ? !quest.monsterTag : !quest.itemId && !quest.itemTag) throw new Error(`Missing quest target: ${quest.id}`);
93 if (quest.itemTag && !tags.has(quest.itemTag)) throw new Error(`Missing quest item tag: ${quest.itemTag}`);
94 if (quest.kind === "delivery" && !quest.itemId) throw new Error(`Delivery quest needs an item id: ${quest.id}`);
95 }
96 return data;
97 }
98 export const villagersDomain: ContentDomain = { id: "villagers", data: VILLAGERS, validate() { validateVillagerReferences(VILLAGERS); } };
99 villagersDomain.validate();
100