Exact source used by this build. SHA-256: 308f14b01680b681a96af688432a67234da9ec374dab5582673ccd6f5711762d.

1 import { z } from 'zod';
2 import type { IntentResult, Player, SimEvent, SimRules, WorldState } from '../../types';
3 import { defineIntent, defineSystem } from '../registry';
4 import { removeItem, takeSlot } from '../inventory';
5 import { getItem } from '../items';
6 import { creditMoney } from '../shipping/money';
7 import { blankVillagersPlayer, changeFriendship, friendshipHearts, getFriendship, getVillagersPlayer, getVillagersWorld, giftWeek, setFriendship, setVillagersPlayer, villagersContent, villagersDay, villagersDayKey } from './model';
8 import { giftPoints, giftTaste, isVillagerBirthday, resolveVillagerDialogue } from './friendship';
9 import { initializeVillagers, tickVillagers } from './schedules';
10 import type { VillagerDefinition, VillagersContent } from './types';
11 export * from './model';
12 export * from './friendship';
13 export * from './pathfinding';
14 export * from './schedules';
15 
16 const fail = (state: WorldState, error: string): IntentResult => ({ state, events: [], error });
17 function changed(state: WorldState, player: Player, type: string, data?: Record<string, unknown>): IntentResult { const slice = getVillagersPlayer(player); if (slice.lastProcessedDay === null) player = setVillagersPlayer(player, { ...slice, lastProcessedDay: villagersDay(state.clock) }); return { state: { ...state, revision: state.revision + 1, players: { ...state.players, [player.id]: player } }, events: [{ type, playerId: player.id, areaId: player.areaId, ...(data ? { data } : {}) }] }; }
18 function villagerAccess(state: WorldState, player: Player, rules: SimRules, id: string): { state: WorldState; data: VillagersContent; villager: VillagerDefinition } | { error: string } {
19   const data = villagersContent(rules), villager = data?.villagers.find((entry) => entry.id === id);
20   if (!data || !villager) return { error: 'Unknown villager' };
21   const next = initializeVillagers(state, rules), npc = getVillagersWorld(next).npcs[id];
22   if (!npc || npc.areaId !== player.areaId || Math.hypot(npc.x - player.x, npc.y - player.y) > data.config.interactionRange) return { error: 'Villager is out of reach' };
23   if (getVillagersPlayer(player).activeEvent) return { error: 'Finish the heart event first' };
24   return { state: next, data, villager };
25 }
26 function nearPoi(player: Player, poiId: string, data: VillagersContent): boolean { const poi = data.pois.find((entry) => entry.id === poiId); return !!poi && poi.areaId === player.areaId && Math.hypot(player.x - poi.x, player.y - poi.y) <= data.config.interactionRange; }
27 function withDialogue(player: Player, state: WorldState, villager: VillagerDefinition, data: VillagersContent, taste?: Parameters<typeof resolveVillagerDialogue>[4]): Player {
28   const line = resolveVillagerDialogue(state, player, villager, data, taste), slice = getVillagersPlayer(player);
29   return setVillagersPlayer(player, { ...slice, dialogue: line ? { villagerId: villager.id, key: line.key, text: line.text } : null });
30 }
31 function newDay(state: WorldState, rules: SimRules): IntentResult {
32   const data = villagersContent(rules); if (!data) return { state, events: [] };
33   let next = initializeVillagers(state, rules); const day = villagersDay(state.clock), week = giftWeek(state.clock, data.config.weeklyResetDay), events: SimEvent[] = [];
34   for (const original of Object.values(next.players)) {
35     const saved = getVillagersPlayer(original); if (saved.lastProcessedDay === day) continue;
36     let player = original;
37     for (const villager of data.villagers) {
38       const old = getFriendship(player, villager.id); if (!old.met) continue;
39       let friendship = { ...old }, points = old.points;
40       if (saved.lastProcessedDay !== null && day > saved.lastProcessedDay) {
41         const previousDay = day - 1, previousYear = Math.floor(previousDay / 112) + 1, previousSeason = ['spring', 'summer', 'autumn', 'winter'][Math.floor((previousDay % 112) / 28)]!, previousDate = previousDay % 28 + 1;
42         const talked = old.talkedDay === `${previousYear}:${previousSeason}:${previousDate}`;
43         const stop = old.status === 'spouse' ? Infinity : villager.marriageCandidate && old.status === 'friend' ? data.config.candidateDecayStop : data.config.friendshipCap;
44         if (!talked && points < stop) points = Math.max(0, points - data.config.decay[old.status === 'engaged' ? 'dating' : old.status]);
45         if (old.giftWeek !== week && old.giftsThisWeek >= data.config.giftsPerWeek) points += data.config.weeklyGiftBonus;
46       }
47       if (old.giftWeek !== week) friendship = { ...friendship, giftWeek: week, giftsThisWeek: 0 };
48       if (friendship.status === 'engaged' && friendship.weddingDay !== null && friendship.weddingDay <= day) { friendship = { ...friendship, status: 'spouse', weddingDay: null }; events.push({ type: 'villagerWedding', playerId: player.id, data: { villagerId: villager.id } }); }
49       player = changeFriendship(setFriendship(player, villager.id, friendship), villager, points - old.points, data);
50     }
51     let slice = getVillagersPlayer(player);
52     const mailbox = slice.mailbox.slice();
53     for (const letter of data.mail) {
54       if (mailbox.some((entry) => entry.mailId === letter.id)) continue;
55       const eligible = letter.trigger.kind === 'friendship' ? friendshipHearts(getFriendship(player, letter.villagerId).points, data) >= letter.trigger.minHearts : slice.completedQuestIds.includes(letter.trigger.questId);
56       if (eligible) { mailbox.push({ id: `letter:${letter.id}`, mailId: letter.id, receivedDay: day, read: false }); events.push({ type: 'villagerMailDelivered', playerId: player.id, data: { mailId: letter.id } }); }
57     }
58     slice = { ...slice, mailbox, lastProcessedDay: day, dialogue: null, activeEvent: null, quests: slice.quests.filter((quest) => quest.completed || quest.expiresDay >= day), spouseChores: Object.entries(slice.friendships).filter(([, friendship]) => friendship.status === 'spouse').flatMap(([villagerId]) => { const kind = data.config.spouseChores[day % data.config.spouseChores.length]; return kind ? [{ villagerId, day, kind }] : []; }) };
59     player = setVillagersPlayer(player, slice); next = changed(next, player, 'villagersUpdated').state;
60   }
61   return { state: next, events };
62 }
63 
64 /** Only other authoritative systems may report earned progress; no client progress intent exists. */
65 export function recordVillagerQuestProgress(state: WorldState, playerId: string, action: { kind: 'gathering' | 'slay' | 'fishing'; quantity: number; itemId?: string; monsterTags?: readonly string[] }, rules: SimRules): WorldState {
66   const data = villagersContent(rules), player = state.players[playerId];
67   if (!data || !player || !Number.isSafeInteger(action.quantity) || action.quantity <= 0) return state;
68   const slice = getVillagersPlayer(player); let any = false;
69   const quests = slice.quests.map((quest) => {
70     const definition = data.quests.find((entry) => entry.id === quest.questId), item = action.itemId ? getItem(rules, action.itemId) : undefined;
71     if (!definition || quest.completed || quest.expiresDay < villagersDay(state.clock) || definition.kind !== action.kind) return quest;
72     const matches = action.kind === 'slay' ? !!definition.monsterTag && !!action.monsterTags?.includes(definition.monsterTag) : !!item && (definition.itemId ? item.id === definition.itemId : !!definition.itemTag && item.tags.includes(definition.itemTag));
73     if (!matches) return quest;
74     const progress = Math.min(definition.quantity, quest.progress + action.quantity); any ||= progress !== quest.progress; return { ...quest, progress };
75   });
76   return any ? changed(state, setVillagersPlayer(player, { ...slice, quests }), 'villagerQuestProgress').state : state;
77 }
78 const villagerId = z.string().min(1), slot = z.number().int().min(0).max(35);
79 export const villagersSystem = defineSystem({
80   id: 'villagers', worldDefaults: () => ({ day: '', npcs: {}, board: null }), playerDefaults: () => ({ ...blankVillagersPlayer() }),
81   events: ['villagerTalked', 'villagerGifted', 'villagerDialogueClosed', 'villagersUpdated', 'heartEventStarted', 'heartEventAdvanced', 'heartEventCompleted', 'villagerMailDelivered', 'villagerMailRead', 'villagerQuestAccepted', 'villagerQuestCompleted', 'villagerQuestProgress', 'villagerRomanceChanged', 'villagerWedding'],
82   intents: [
83     defineIntent('talkVillager', z.object({ type: z.literal('talkVillager'), villagerId }), ({ state, player, rules }, intent) => {
84       const access = villagerAccess(state, player, rules, intent.villagerId); if ('error' in access) return fail(state, access.error);
85       const { data, villager } = access, friendship = getFriendship(player, villager.id), day = villagersDayKey(state.clock);
86       // Resolve before setting met, so first-meeting keys are reachable.
87       let updated = withDialogue(player, state, villager, data);
88       if (friendship.talkedDay !== day) updated = changeFriendship(updated, villager, data.config.talkPoints, data);
89       updated = setFriendship(updated, villager.id, { ...getFriendship(updated, villager.id), met: true, talkedDay: day });
90       return changed(access.state, updated, 'villagerTalked', { villagerId: villager.id, points: getFriendship(updated, villager.id).points });
91     }),
92     defineIntent('giftVillager', z.object({ type: z.literal('giftVillager'), villagerId, slot }), ({ state, player, rules }, intent) => {
93       const access = villagerAccess(state, player, rules, intent.villagerId); if ('error' in access) return fail(state, access.error);
94       const { data, villager } = access, friendship = getFriendship(player, villager.id), day = villagersDayKey(state.clock), week = giftWeek(state.clock, data.config.weeklyResetDay), count = friendship.giftWeek === week ? friendship.giftsThisWeek : 0, birthday = isVillagerBirthday(state, villager);
95       if (friendship.giftDay === day) return fail(state, 'Daily gift limit reached');
96       if (friendship.status !== 'spouse' && !birthday && count >= data.config.giftsPerWeek) return fail(state, 'Weekly gift limit reached');
97       const taken = takeSlot(player, intent.slot, 1, rules), item = taken && getItem(rules, taken.stack.id);
98       if (!taken || !item || item.category === 'tool' || item.category === 'equipment' || item.id === data.config.bouquetItemId || item.id === data.config.pendantItemId) return fail(state, 'Select a giftable inventory item');
99       const taste = giftTaste(item, villager, data), points = giftPoints(taste, taken.stack.quality, birthday, data);
100       let updated = changeFriendship(taken.player, villager, points, data);
101       updated = setFriendship(updated, villager.id, { ...getFriendship(updated, villager.id), met: true, giftDay: day, giftWeek: week, giftsThisWeek: count + 1 });
102       updated = withDialogue(updated, state, villager, data, taste);
103       return changed(access.state, updated, 'villagerGifted', { villagerId: villager.id, taste, points, birthday, quality: taken.stack.quality });
104     }),
105     defineIntent('closeVillagerDialogue', z.object({ type: z.literal('closeVillagerDialogue') }), ({ state, player }) => changed(state, setVillagersPlayer(player, { ...getVillagersPlayer(player), dialogue: null }), 'villagerDialogueClosed'), { allowPaused: true }),
106     defineIntent('startHeartEvent', z.object({ type: z.literal('startHeartEvent'), eventId: z.string().min(1) }), ({ state, player, rules }, intent) => {
107       const data = villagersContent(rules), event = data?.events.find((entry) => entry.id === intent.eventId), slice = getVillagersPlayer(player);
108       if (!data || !event || slice.activeEvent || slice.seenEvents.includes(event.id)) return fail(state, 'Heart event is unavailable');
109       const trigger = event.trigger;
110       if (player.areaId !== trigger.areaId || state.clock.minuteOfDay < trigger.startMinute || state.clock.minuteOfDay >= trigger.endMinute || !trigger.weather.includes(state.weather) || friendshipHearts(getFriendship(player, event.villagerId).points, data) < trigger.minHearts) return fail(state, 'Heart event requirements are not met');
111       return changed(state, setVillagersPlayer(player, { ...slice, dialogue: null, activeEvent: { eventId: event.id, step: 0 } }), 'heartEventStarted', { eventId: event.id });
112     }),
113     defineIntent('advanceHeartEvent', z.object({ type: z.literal('advanceHeartEvent'), choiceId: z.string().min(1).optional() }), ({ state, player, rules }, intent) => {
114       const data = villagersContent(rules), slice = getVillagersPlayer(player), active = slice.activeEvent, event = data?.events.find((entry) => entry.id === active?.eventId), step = active && event?.script[active.step];
115       if (!data || !active || !event || !step) return fail(state, 'No active heart event');
116       if (player.areaId !== event.trigger.areaId) return fail(state, 'Return to the event area');
117       let updated = player;
118       if (step.kind === 'choice') {
119         const choice = step.choices.find((entry) => entry.id === intent.choiceId), villager = data.villagers.find((entry) => entry.id === event.villagerId);
120         if (!choice || !villager) return fail(state, 'Choose an authored response');
121         const claim = `${event.id}:${active.step}`, previous = slice.eventChoices[claim];
122         if (previous && previous !== choice.id) return fail(state, 'This event choice was already made');
123         if (!previous) updated = changeFriendship(player, villager, choice.friendshipDelta, data);
124         updated = setVillagersPlayer(updated, { ...getVillagersPlayer(updated), eventChoices: { ...slice.eventChoices, [claim]: choice.id } });
125       }
126       else if (intent.choiceId !== undefined) return fail(state, 'This event step has no choice');
127       const complete = active.step + 1 >= event.script.length, current = getVillagersPlayer(updated);
128       updated = setVillagersPlayer(updated, { ...current, activeEvent: complete ? null : { ...active, step: active.step + 1 }, seenEvents: complete ? [...current.seenEvents, event.id] : current.seenEvents });
129       return changed(state, updated, complete ? 'heartEventCompleted' : 'heartEventAdvanced', { eventId: event.id });
130     }),
131     defineIntent('readVillagerMail', z.object({ type: z.literal('readVillagerMail'), letterId: z.string().min(1) }), ({ state, player, rules }, intent) => {
132       const data = villagersContent(rules), slice = getVillagersPlayer(player), letter = slice.mailbox.find((entry) => entry.id === intent.letterId), definition = data?.mail.find((entry) => entry.id === letter?.mailId);
133       if (!data || !letter || !definition || !nearPoi(player, data.config.mailboxPoiId, data)) return fail(state, 'Letter is unavailable at this mailbox');
134       const recipe = definition.recipeId;
135       return changed(state, setVillagersPlayer(player, { ...slice, mailbox: slice.mailbox.map((entry) => entry.id === letter.id ? { ...entry, read: true } : entry), knownRecipes: recipe && !slice.knownRecipes.includes(recipe) ? [...slice.knownRecipes, recipe] : slice.knownRecipes }), 'villagerMailRead', { mailId: definition.id, ...(recipe ? { recipeId: recipe } : {}) });
136     }),
137     defineIntent('acceptVillagerQuest', z.object({ type: z.literal('acceptVillagerQuest'), postingId: z.string().min(1) }), ({ state, player, rules }, intent) => {
138       const data = villagersContent(rules); if (!data) return fail(state, 'Bulletin board is unavailable');
139       const initialized = initializeVillagers(state, rules), posting = getVillagersWorld(initialized).board, definition = data.quests.find((entry) => entry.id === posting?.questId), slice = getVillagersPlayer(player);
140       if (!posting || !definition || posting.id !== intent.postingId || !nearPoi(player, data.config.bulletinPoiId, data)) return fail(state, 'Posting is unavailable at this board');
141       if (slice.quests.some((quest) => quest.id === posting.id) || slice.quests.some((quest) => !quest.completed && quest.expiresDay >= villagersDay(state.clock))) return fail(state, 'A request is already accepted');
142       return changed(initialized, setVillagersPlayer(player, { ...slice, quests: [...slice.quests, { id: posting.id, questId: definition.id, acceptedDay: posting.day, expiresDay: posting.day + definition.durationDays - 1, progress: 0, completed: false }] }), 'villagerQuestAccepted', { questId: definition.id });
143     }),
144     defineIntent('completeVillagerQuest', z.object({ type: z.literal('completeVillagerQuest'), questId: z.string().min(1) }), ({ state, player, rules }, intent) => {
145       const data = villagersContent(rules), slice = getVillagersPlayer(player), quest = slice.quests.find((entry) => entry.id === intent.questId || entry.questId === intent.questId && !entry.completed), definition = data?.quests.find((entry) => entry.id === quest?.questId);
146       if (!data || !quest || !definition || quest.completed || quest.expiresDay < villagersDay(state.clock)) return fail(state, 'Request is unavailable');
147       const access = villagerAccess(state, player, rules, definition.villagerId); if ('error' in access) return fail(state, access.error);
148       let updated = player;
149       if (definition.kind === 'delivery') { const supplied = definition.itemId && removeItem(updated, definition.itemId, definition.quantity, rules); if (!supplied) return fail(state, 'Requested items are missing'); updated = supplied; }
150       else if (quest.progress < definition.quantity) return fail(state, 'Request progress is incomplete');
151       const rewardItem = getItem(rules, definition.rewardItemId); if (!rewardItem) return fail(state, 'Reward content is unavailable');
152       const reward = Math.floor(rewardItem.baseSellPrice * definition.quantity * data.config.questPriceMultiplier);
153       updated = changeFriendship(updated, access.villager, data.config.questFriendship, data);
154       const friendship = getFriendship(updated, access.villager.id); updated = setFriendship(updated, access.villager.id, { ...friendship, met: true });
155       const current = getVillagersPlayer(updated);
156       updated = setVillagersPlayer(updated, { ...current, quests: current.quests.map((entry) => entry.id === quest.id ? { ...entry, progress: definition.quantity, completed: true } : entry), completedQuestIds: [...new Set([...current.completedQuestIds, definition.id])] });
157       return changed(creditMoney(access.state, player.id, reward), updated, 'villagerQuestCompleted', { questId: definition.id, reward });
158     }),
159     defineIntent('romanceVillager', z.object({ type: z.literal('romanceVillager'), villagerId, kind: z.enum(['bouquet', 'pendant']), slot }), ({ state, player, rules }, intent) => {
160       const access = villagerAccess(state, player, rules, intent.villagerId); if ('error' in access) return fail(state, access.error);
161       const { data, villager } = access, friendship = getFriendship(player, villager.id), hearts = friendshipHearts(friendship.points, data), taken = takeSlot(player, intent.slot, 1, rules);
162       if (!villager.marriageCandidate || !taken || taken.stack.id !== (intent.kind === 'bouquet' ? data.config.bouquetItemId : data.config.pendantItemId)) return fail(state, 'Select the required romance item');
163       if (intent.kind === 'bouquet' && (friendship.status !== 'friend' || hearts < data.config.bouquetHearts)) return fail(state, 'Bouquet requires eight hearts and an available candidate');
164       if (intent.kind === 'pendant' && (friendship.status !== 'dating' || hearts < data.config.pendantHearts || Object.values(getVillagersPlayer(player).friendships).some((value) => value.status === 'engaged' || value.status === 'spouse') || Object.values(state.players).some((other) => { const status = getFriendship(other, villager.id).status; return status === 'engaged' || status === 'spouse'; }))) return fail(state, 'Proposal requirements are not met');
165       let updated = setFriendship(taken.player, villager.id, { ...friendship, status: intent.kind === 'bouquet' ? 'dating' : 'engaged', weddingDay: intent.kind === 'pendant' ? villagersDay(state.clock) + data.config.weddingDelayDays : null });
166       if (intent.kind === 'bouquet') updated = changeFriendship(updated, villager, data.config.bouquetFriendshipPoints, data);
167       return changed(access.state, updated, 'villagerRomanceChanged', { villagerId: villager.id, kind: intent.kind });
168     }),
169   ],
170   onPlayerAdded: ({ state, rules }) => newDay(state, rules),
171   onPresenceChange: ({ state, rules }) => newDay(state, rules),
172   onOvernight: ({ state, rules }) => newDay(state, rules),
173   // Walking produces only coalesced state patches. Durable social outcomes keep
174   // their ordinary events; a walking frame never enters the durable event log.
175   onElapsedTime: ({ state, rules, elapsedMs }) => ({ state: tickVillagers(state, rules, elapsedMs ?? 0), events: [] }),
176   onClockTick: ({ state, rules }) => { const initialized = newDay(state, rules); return { state: tickVillagers(initialized.state, rules, 0), events: initialized.events }; },
177   updateIntervalMs: (state, rules) => {
178     const data = villagersContent(rules);
179     return data && Object.values(getVillagersWorld(state).npcs).some((npc) => npc.moving) ? data.config.updateIntervalMs : undefined;
180   },
181 });
182