Exact source used by this build. SHA-256: ac542557eadc1c1103fa7c406c9770804e53b1f1111827b71a93dfbd7dea415f.
1 import type { SimRules, WorldState } from '../../types';
2 import { getVillagersWorld, scheduledEntry, villagersContent, villagersDay, villagersDayKey } from './model';
3 import { findNpcRoute, npcWalkable } from './pathfinding';
4 import type { NpcState, RoutePoint, ScheduleEntry, VillagersContent, VillagersWorld } from './types';
5 function worldWith(state: WorldState, villagers: VillagersWorld): WorldState { return { ...state, revision: state.revision + 1, systems: { ...state.systems, villagers } }; }
6 /** Initial saves hydrate at their active schedule stop; subsequent ticks always walk. */
7 export function initializeVillagers(state: WorldState, rules: SimRules): WorldState {
8 const data = villagersContent(rules); if (!data) return state;
9 const old = getVillagersWorld(state), day = villagersDayKey(state.clock), npcs = { ...old.npcs }; let changed = old.day !== day;
10 for (const villager of data.villagers) {
11 if (npcs[villager.id] && old.day === day) continue;
12 const entry = scheduledEntry(villager, state, data), poi = data.pois.find((value) => value.id === (entry?.poiId ?? villager.homePoiId));
13 if (!poi || !state.areas[poi.areaId] || !npcWalkable(state.areas[poi.areaId]!, poi.x, poi.y)) continue;
14 npcs[villager.id] = { id: villager.id, areaId: poi.areaId, x: poi.x, y: poi.y, facing: entry?.facing ?? poi.facing, poiId: poi.id, targetPoiId: poi.id, moving: false, path: [], traveledPath: [] }; changed = true;
15 }
16 const quest = data.quests[villagersDay(state.clock) % data.quests.length];
17 const board = old.board?.day === villagersDay(state.clock) ? old.board : quest ? { id: `posting:${villagersDay(state.clock)}:${quest.id}`, questId: quest.id, day: villagersDay(state.clock) } : null;
18 return changed || board !== old.board ? worldWith(state, { day, npcs, board }) : state;
19 }
20 function routeNpc(state: WorldState, npc: NpcState, entry: ScheduleEntry, data: VillagersContent): NpcState {
21 if (npc.targetPoiId === entry.poiId && (npc.path.length > 0 || npc.poiId === entry.poiId)) return npc;
22 const poi = data.pois.find((value) => value.id === entry.poiId); if (!poi) return npc;
23 const path = findNpcRoute(state, npc, poi);
24 return { ...npc, targetPoiId: poi.id, path: path ?? [], moving: !!path?.length, poiId: npc.poiId === poi.id ? poi.id : null };
25 }
26 function walkNpc(state: WorldState, original: NpcState, distance: number): NpcState {
27 if (distance <= 0) return original;
28 if (!original.path.length) return original.traveledPath.length ? { ...original, traveledPath: [] } : original;
29 let npc = { ...original, path: original.path.slice() }, path = npc.path;
30 const traveledPath: RoutePoint[] = [{ areaId: npc.areaId, x: npc.x, y: npc.y }];
31 while (path.length) {
32 const point = path[0]!;
33 if (point.areaId !== npc.areaId) {
34 const previous = traveledPath.at(-1), warp = previous?.warpId && state.areas[npc.areaId]?.warps.find((value) => value.id === previous.warpId);
35 if (!warp || warp.locked || state.areas[point.areaId]?.locked || warp.targetAreaId !== point.areaId) { path = []; break; }
36 npc = { ...npc, areaId: point.areaId, x: point.x, y: point.y }; traveledPath.push(point); path.shift(); continue;
37 }
38 const area = state.areas[npc.areaId];
39 if (!area || !npcWalkable(area, point.x, point.y)) { path = []; break; }
40 const dx = point.x - npc.x, dy = point.y - npc.y, length = Math.hypot(dx, dy);
41 if (length > 0 && distance <= 0) break;
42 const fraction = length === 0 ? 1 : Math.min(1, distance / length);
43 if (length > 0) npc.facing = Math.abs(dx) >= Math.abs(dy) ? dx >= 0 ? 'right' : 'left' : dy >= 0 ? 'down' : 'up';
44 npc = { ...npc, x: npc.x + dx * fraction, y: npc.y + dy * fraction }; distance -= length * fraction;
45 traveledPath.push({ areaId: npc.areaId, x: npc.x, y: npc.y, ...(fraction === 1 && point.warpId ? { warpId: point.warpId } : {}) });
46 if (fraction < 1) break;
47 path.shift();
48 }
49 return { ...npc, path, traveledPath, moving: path.length > 0, poiId: path.length > 0 ? null : npc.poiId };
50 }
51 export function tickVillagers(state: WorldState, rules: SimRules, elapsedMs: number): WorldState {
52 const data = villagersContent(rules); if (!data) return state;
53 const initialized = initializeVillagers(state, rules), old = getVillagersWorld(initialized), npcs: Record<string, NpcState> = {};
54 let changed = false;
55 for (const [id, initial] of Object.entries(old.npcs)) {
56 const villager = data.villagers.find((value) => value.id === id); if (!villager) { npcs[id] = initial; continue; }
57 // advanceClock splits elapsed segments before each calendar boundary. Route at
58 // the segment's current time; onClockTick selects the next route with zero travel.
59 const entry = scheduledEntry(villager, initialized, data);
60 if (!entry) { npcs[id] = initial; continue; }
61 let npc = walkNpc(initialized, routeNpc(initialized, initial, entry, data), Math.max(0, elapsedMs) / 1000 * data.config.npcTilesPerSecond);
62 const poi = data.pois.find((value) => value.id === npc.targetPoiId);
63 if (!npc.path.length && poi && npc.areaId === poi.areaId && Math.hypot(npc.x - poi.x, npc.y - poi.y) < .001 && (npc.poiId !== poi.id || npc.facing !== entry.facing || npc.moving)) npc = { ...npc, poiId: poi.id, facing: entry.facing, moving: false };
64 npcs[id] = npc;
65 changed ||= npc !== initial;
66 }
67 return changed ? worldWith(initialized, { ...old, npcs }) : initialized;
68 }
69 /** Shops without an assigned keeper retain their existing time-only behavior. */
70 export function shopkeeperPresent(state: WorldState, rules: SimRules, shopId: string): boolean {
71 const data = villagersContent(rules), keeper = data?.villagers.find((villager) => villager.shopId === shopId);
72 if (!data || !keeper) return true;
73 const hydrated = initializeVillagers(state, rules), npc = getVillagersWorld(hydrated).npcs[keeper.id], counter = data.pois.find((poi) => poi.id === keeper.counterPoiId);
74 return !!npc && !!counter && !npc.moving && npc.poiId === counter.id && npc.areaId === counter.areaId && Math.hypot(npc.x - counter.x, npc.y - counter.y) <= .75;
75 }
76