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