Exact source used by this build. SHA-256: d1d4c7c453cbb32fb35b5acd527ffd9d0c68e4eaece04b4a3cde8576dd582726.
1 import type { Area, IntentResult, SimRules, Weather, WorldState } from "../../types";
2 import { createRng, type SeededRng } from "../../rng";
3 import { tileIndex } from "../../state";
4 import { calendarDay } from "../shipping";
5 import { farmingAnimalModifiers } from "../skills";
6 import { isRainy, rollWeather } from "../weather";
7 import { absoluteDay, animalBonusChance, animalLargeChance, animalProductQuality, buildingDoor, getAnimals, getAnimalsContent, grassKey, type AnimalProduct, type AnimalSpecies, type AnimalsContent, type AnimalsState, type Building, type FarmAnimal, type ProductValue } from "./types";
8 import { createAnimalInterior, definitionFor, initializeAnimals, saveAnimals } from "./buildings";
9
10 const clamp = (value: number, max: number): number => Math.max(0, Math.min(max, value));
11 function yesterday(state: WorldState): string {
12 const seasons = ["spring", "summer", "autumn", "winter"] as const;
13 const day = absoluteDay(state) - 2, year = Math.floor(day / 112) + 1, within = ((day % 112) + 112) % 112;
14 return `${year}:${seasons[Math.floor(within / 28)]}:${within % 28 + 1}`;
15 }
16 export function makeAnimal(state: WorldState, value: AnimalsState, data: AnimalsContent, species: AnimalSpecies, building: Building, ownerId: string, name: string): FarmAnimal {
17 const interior = state.areas[building.interiorAreaId], spawn = interior?.spawn ?? { x: 2, y: 5 };
18 const occupants = Object.values(value.animals).filter((animal) => animal.buildingId === building.id).length;
19 const available = interior?.tiles.map((_, index) => index).filter((index) => !interior.collision[index] && !interior.warps.some((warp) => index % interior.width === warp.x && Math.floor(index / interior.width) === warp.y)) ?? [];
20 const at = available[occupants % Math.max(1, available.length)];
21 return { id: `animal_instance_${value.nextId}`, speciesId: species.id, name, buildingId: building.id, ownerId, ageDays: 0,
22 friendship: data.care.initialFriendship, mood: data.care.initialMood, areaId: building.interiorAreaId,
23 x: at === undefined ? spawn.x : at % interior!.width, y: at === undefined ? spawn.y : Math.floor(at / interior!.width),
24 pettedDay: "", fedDay: calendarDay(state), ateGrassDay: "", lastProductDay: absoluteDay(state), readyProduct: null, sleeping: false, outsideOvernight: false, truffleReady: false,
25 };
26 }
27 function productValue(animal: FarmAnimal, species: AnimalSpecies, data: AnimalsContent, state: WorldState, rules: SimRules, rng: SeededRng): ProductValue {
28 const owner = state.players[animal.ownerId], modifier = owner ? farmingAnimalModifiers(owner, species.buildingFamily, rules).qualityBonus : 0;
29 const quality = animalProductQuality(animal.friendship, animal.mood, [rng.next(), rng.next(), rng.next()], modifier);
30 const large = species.largeProductItemId && rng.next() < animalLargeChance(animal.friendship, animal.mood) ? species.largeProductItemId : null;
31 const bonus = species.bonusProductItemId && rng.next() < animalBonusChance(animal.friendship, animal.mood, data.care.dailyLuck) ? species.bonusProductItemId : null;
32 return { itemId: bonus || large || species.productItemId, quality };
33 }
34 function makeProduct(value: AnimalsState, animal: FarmAnimal, product: ProductValue, areaId = animal.areaId): AnimalProduct {
35 const id = `animal_product_instance_${value.nextId++}`;
36 return { id, animalId: animal.id, buildingId: animal.buildingId, areaId, x: animal.x, y: animal.y, ...product };
37 }
38 function completeConstruction(state: WorldState, value: AnimalsState, data: AnimalsContent): WorldState {
39 let next = state;
40 for (const [id, old] of Object.entries(value.buildings)) {
41 if (!old.construction) continue;
42 const remaining = old.construction.morningsRemaining - 1;
43 if (remaining > 0) { value.buildings[id] = { ...old, construction: { ...old.construction, morningsRemaining: remaining } }; continue; }
44 const def = data.buildings.find((entry) => entry.id === old.construction!.definitionId); if (!def) continue;
45 const building: Building = { ...old, definitionId: def.id, construction: null };
46 const interior = createAnimalInterior(building, def), previous = next.areas[building.interiorAreaId];
47 if (previous) {
48 // Upgrade preserves foreign metadata and every placed tile; intent rejects obstruction before construction starts.
49 interior.systems = { ...previous.systems, animals: interior.systems.animals };
50 for (let y = 0; y < Math.min(previous.height, interior.height); y++) for (let x = 0; x < Math.min(previous.width, interior.width); x++) {
51 const tile = previous.tiles[y * previous.width + x]!;
52 if (tile.objectId || tile.crop || tile.tilled) { interior.tiles[y * interior.width + x] = { ...tile }; interior.collision[y * interior.width + x] = previous.collision[y * previous.width + x]!; }
53 }
54 }
55 value.buildings[id] = building;
56 const safeIndex = interior.tiles.findIndex((tile, at) => !interior.collision[at] && !tile.objectId && !interior.warps.some((warp) => at % interior.width === warp.x && Math.floor(at / interior.width) === warp.y));
57 const spawnIndex = interior.spawn.y * interior.width + interior.spawn.x;
58 if ((interior.collision[spawnIndex] || interior.tiles[spawnIndex]?.objectId) && safeIndex >= 0) interior.spawn = { x: safeIndex % interior.width, y: Math.floor(safeIndex / interior.width) };
59 next = { ...next, areas: { ...next.areas, [interior.id]: interior }, players: Object.fromEntries(Object.entries(next.players).map(([playerId, player]) => [playerId, player.areaId === interior.id ? { ...player, ...interior.spawn } : player])) };
60 for (const [animalId, animal] of Object.entries(value.animals)) if (animal.areaId === interior.id) value.animals[animalId] = { ...animal, ...interior.spawn };
61 for (const [productId, product] of Object.entries(value.products)) if (product.areaId === interior.id) value.products[productId] = { ...product, ...interior.spawn };
62 if (def.kind === "stable" && !value.horse) { const door = buildingDoor(building, def); value.horse = { buildingId: id, name: data.horse.name, areaId: building.areaId, ...door, riderId: null, manifestKey: data.horse.manifestKey }; }
63 }
64 return next;
65 }
66 function hatch(state: WorldState, value: AnimalsState, data: AnimalsContent): void {
67 for (const [id, building] of Object.entries(value.buildings)) {
68 const egg = building.incubation, def = data.buildings.find((entry) => entry.id === building.definitionId);
69 if (!egg || !def) continue;
70 if (egg.readyAtMachineMinute > state.clock.machineMinutes) { value.buildings[id] = { ...building, incubation: { ...egg, morningsRemaining: Math.ceil((egg.readyAtMachineMinute - state.clock.machineMinutes) / 1600) } }; continue; }
71 if (Object.values(value.animals).filter((animal) => animal.buildingId === id).length >= def.capacity) continue;
72 const species = data.species.find((entry) => entry.id === egg.speciesId); if (!species) continue;
73 const animal = makeAnimal(state, value, data, species, building, egg.ownerId, "TBD");
74 value.nextId++; value.animals[animal.id] = animal; value.buildings[id] = { ...building, incubation: null };
75 }
76 }
77 function grassFree(state: WorldState, data: AnimalsContent, x: number, y: number): boolean {
78 const area = state.areas[data.farmAreaId]; if (!area) return false;
79 const at = tileIndex(area, x, y), tile = at === null ? undefined : area.tiles[at];
80 return at !== null && !!tile && !area.collision[at] && !tile.objectId && !tile.crop && !tile.tilled && tile.tillable
81 && !area.warps.some((warp) => x >= warp.x && x < warp.x + warp.width && y >= warp.y && y < warp.y + warp.height);
82 }
83 function growGrass(state: WorldState, value: AnimalsState, data: AnimalsContent, rng: SeededRng): void {
84 if (!data.grass.growthSeasons.includes(state.clock.season)) return;
85 const grass = data.grass;
86 const ordered = () => Object.entries(value.grass).sort(([a], [b]) => a.localeCompare(b));
87 const randomCount = (minimum: number, maximum: number) => minimum + rng.nextInt(maximum - minimum + 1);
88 // Every established patch grows once before the additional growth/spread checks.
89 for (const [key, patch] of ordered()) {
90 if (!grassFree(state, data, patch.x, patch.y)) continue;
91 value.grass[key] = { ...patch, tufts: Math.min(grass.maxTufts, patch.tufts + randomCount(grass.growMin, grass.growMax)) };
92 }
93 const passes = state.clock.year > 1 && state.clock.season === "spring" && state.clock.day === 1 ? grass.springGrowthPasses : 1;
94 for (let pass = 0; pass < passes; pass++) {
95 // New patches join the following spring pass, never recursively in this pass.
96 for (const [key, patch] of ordered()) {
97 if (!grassFree(state, data, patch.x, patch.y) || rng.next() >= grass.growChance) continue;
98 if (patch.tufts < grass.maxTufts) {
99 value.grass[key] = { ...patch, tufts: Math.min(grass.maxTufts, patch.tufts + randomCount(grass.extraGrowMin, grass.extraGrowMax)) };
100 continue;
101 }
102 for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]] as const) {
103 const x = patch.x + dx, y = patch.y + dy, target = `${x},${y}`;
104 if (!value.grass[target] && grassFree(state, data, x, y) && rng.next() < grass.spreadChance) value.grass[target] = { x, y, tufts: randomCount(grass.spreadMin, grass.spreadMax), manifestKey: grass.manifestKey };
105 }
106 }
107 }
108 }
109 function feedFromTrough(value: AnimalsState, animal: FarmAnimal, species: AnimalSpecies, data: AnimalsContent, day: string): FarmAnimal {
110 const building = value.buildings[animal.buildingId];
111 if (animal.fedDay === day || !building || building.trough < 1 || animal.areaId !== building.interiorAreaId) return animal;
112 value.buildings[building.id] = { ...building, trough: building.trough - 1 };
113 return { ...animal, fedDay: day, mood: clamp(animal.mood + species.fedMoodGain, data.care.moodMax) };
114 }
115 function autoFeed(value: AnimalsState, data: AnimalsContent): void {
116 for (const [id, building] of Object.entries(value.buildings)) {
117 const def = data.buildings.find((entry) => entry.id === building.definitionId); if (!def?.autoFeed || !def.interior) continue;
118 const amount = Math.min(value.hay, def.interior.troughs - building.trough);
119 if (amount > 0) { value.hay -= amount; value.buildings[id] = { ...building, trough: building.trough + amount }; }
120 }
121 }
122 export function overnightAnimals(incoming: WorldState, rules: SimRules, weatherOverride?: Weather): IntentResult {
123 const data = getAnimalsContent(rules); if (!data) return { state: incoming, events: [] };
124 let state = initializeAnimals(incoming, rules); const saved = getAnimals(state), day = calendarDay(state), previousDay = yesterday(state);
125 if (saved.lastOvernightDay === day) return { state, events: [] };
126 const value: AnimalsState = { ...saved, buildings: { ...saved.buildings }, animals: { ...saved.animals }, products: { ...saved.products }, grass: { ...saved.grass }, lastOvernightDay: day };
127 const rng = createRng(state.rngState);
128 state = completeConstruction(state, value, data);
129 autoFeed(value, data);
130 for (const [id, original] of Object.entries(value.animals).sort(([a], [b]) => a.localeCompare(b))) {
131 const species = data.species.find((entry) => entry.id === original.speciesId), building = value.buildings[original.buildingId];
132 if (!species || !building) continue;
133 let animal = { ...original, sleeping: false };
134 const interior = state.areas[building.interiorAreaId];
135 if (animal.areaId !== building.interiorAreaId && building.doorOpen && interior) animal = { ...animal, areaId: interior.id, ...interior.spawn };
136 animal = feedFromTrough(value, animal, species, data, previousDay);
137 const fed = animal.fedDay === previousDay, outside = animal.areaId !== building.interiorAreaId;
138 let friendship = animal.friendship, mood = animal.mood;
139 if (animal.pettedDay !== previousDay) { friendship -= Math.max(0, data.care.unpettedPenaltyBase - Math.floor(friendship / data.care.unpettedPenaltyFriendshipDivisor)); mood -= species.neglectedMoodPenalty; }
140 if (!fed) { friendship -= data.care.unfedFriendshipPenalty; mood -= data.care.unfedMoodPenalty; }
141 if (outside) { friendship -= data.care.outsideFriendshipPenalty; mood *= data.care.outsideMoodMultiplier; }
142 animal = { ...animal, ageDays: animal.ageDays + (fed ? 1 : 0), friendship: clamp(Math.floor(friendship), data.care.friendshipMax), mood: clamp(Math.floor(mood), data.care.moodMax), outsideOvernight: outside, truffleReady: false };
143 const owner = state.players[animal.ownerId], modifiers = owner ? farmingAnimalModifiers(owner, species.buildingFamily, rules) : { woolDaysReduction: 0 };
144 const woolReduction = species.role === "wool" ? modifiers.woolDaysReduction + (animal.friendship >= data.care.woolFriendshipThreshold ? data.care.woolFriendshipDaysReduction : 0) : 0;
145 const cycle = Math.max(1, species.productCycleDays - woolReduction);
146 const due = fed && !outside && animal.ageDays >= species.matureDays && !animal.readyProduct && absoluteDay(state) - animal.lastProductDay >= cycle;
147 if (due && (animal.mood >= data.care.productionMoodThreshold || rng.next() < animal.mood / data.care.productionMoodThreshold)) {
148 if (species.outdoorOnly) animal.truffleReady = true;
149 else {
150 const product = productValue(animal, species, data, state, rules, rng);
151 animal.lastProductDay = absoluteDay(state);
152 if (species.toolItemId && !building.autoGrabber) animal.readyProduct = product;
153 else {
154 const drop = makeProduct(value, animal, product, building.interiorAreaId);
155 if (building.autoGrabber) { const current = value.buildings[building.id]!; value.buildings[building.id] = { ...current, storedProducts: [...current.storedProducts, drop] }; }
156 else value.products[drop.id] = drop;
157 }
158 }
159 }
160 value.animals[id] = animal;
161 }
162 hatch(state, value, data);
163 growGrass(state, value, data, rng);
164 if (value.pet) {
165 const watered = value.pet.bowlWateredDay === previousDay;
166 // Animals run immediately before weather. Preview its pure roll without advancing RNG.
167 const morningWeather = weatherOverride ?? (rules.weatherOdds ? rollWeather(rng.state, state.clock.season, rules.weatherOdds).weather : state.weather);
168 const rainy = isRainy(morningWeather);
169 value.pet = { ...value.pet, friendship: clamp(value.pet.friendship + (watered && !rainy ? data.pets.waterFriendship : 0), data.pets.friendshipMax), bowlWateredDay: rainy ? day : value.pet.bowlWateredDay };
170 }
171 if (value.horse) {
172 const building = value.buildings[value.horse.buildingId], def = building && definitionFor(building, rules);
173 if (building && def) value.horse = { ...value.horse, riderId: null, areaId: building.areaId, ...buildingDoor(building, def) };
174 state = { ...state, players: Object.fromEntries(Object.entries(state.players).map(([id, player]) => [id, { ...player, systems: { ...player.systems, animals: { mounted: false } } }])) };
175 }
176 autoFeed(value, data);
177 return { state: saveAnimals({ ...state, rngState: rng.state }, value), events: [{ type: "animalsMorning", data: { products: Object.keys(value.products).length } }] };
178 }
179 function canWalk(area: Area | undefined, x: number, y: number): boolean {
180 if (!area) return false;
181 const at = tileIndex(area, x, y); return at !== null && !area.collision[at] && !area.tiles[at]?.objectId && !area.warps.some((warp) => x >= warp.x && x < warp.x + warp.width && y >= warp.y && y < warp.y + warp.height);
182 }
183 export function tickAnimals(incoming: WorldState, rules: SimRules, elapsedMs = 7000): IntentResult {
184 const data = getAnimalsContent(rules); if (!data) return { state: incoming, events: [] };
185 const state = initializeAnimals(incoming, rules), saved = getAnimals(state), day = calendarDay(state), tick = `${day}:${state.clock.minuteOfDay}`;
186 if (saved.lastTickMinute === tick) return { state, events: [] };
187 if (!Object.keys(saved.buildings).length && !Object.keys(saved.animals).length && !saved.pet && !saved.horse) return { state, events: [] };
188 const value: AnimalsState = { ...saved, buildings: { ...saved.buildings }, animals: { ...saved.animals }, products: { ...saved.products }, grass: { ...saved.grass }, lastTickMinute: tick };
189 const rng = createRng(state.rngState), minute = state.clock.minuteOfDay;
190 if (value.pet && isRainy(state.weather)) value.pet = { ...value.pet, bowlWateredDay: day };
191 const sunny = state.weather === "sunny" || state.weather === "wind", outsideTime = sunny && state.clock.season !== "winter" && minute < data.care.returnMinute;
192 autoFeed(value, data);
193 for (const [id, original] of Object.entries(value.animals).sort(([a], [b]) => a.localeCompare(b))) {
194 const species = data.species.find((entry) => entry.id === original.speciesId), building = value.buildings[original.buildingId], def = building && definitionFor(building, rules);
195 if (!species || !building || !def) continue;
196 let animal = { ...original, sleeping: minute >= data.care.sleepMinute };
197 const inside = animal.areaId === building.interiorAreaId, interior = state.areas[building.interiorAreaId];
198 if (inside && outsideTime && building.doorOpen) { const door = buildingDoor(building, def); animal = { ...animal, areaId: building.areaId, ...door }; }
199 else if (!inside && !outsideTime && building.doorOpen && interior) animal = { ...animal, areaId: interior.id, ...interior.spawn };
200 if (!animal.sleeping) {
201 const area = state.areas[animal.areaId];
202 if (animal.areaId === data.farmAreaId && outsideTime && animal.ateGrassDay !== day) {
203 const patch = Object.values(value.grass).filter((entry) => entry.tufts >= species.grassTufts).sort((a, b) => Math.hypot(a.x - animal.x, a.y - animal.y) - Math.hypot(b.x - animal.x, b.y - animal.y))[0];
204 if (patch && Math.hypot(patch.x - animal.x, patch.y - animal.y) <= 1.75) {
205 const key = grassKey(patch), remaining = patch.tufts - species.grassTufts;
206 if (remaining > 0) value.grass[key] = { ...patch, tufts: remaining }; else delete value.grass[key];
207 animal = { ...animal, ateGrassDay: day, fedDay: day, friendship: clamp(animal.friendship + data.care.grassFriendship, data.care.friendshipMax), mood: data.care.moodMax };
208 } else if (patch) {
209 const dx = Math.sign(patch.x - animal.x), dy = Math.sign(patch.y - animal.y);
210 if (canWalk(area, animal.x + dx, animal.y)) animal.x += dx;
211 else if (canWalk(area, animal.x, animal.y + dy)) animal.y += dy;
212 }
213 }
214 if (animal.fedDay !== day && animal.areaId === building.interiorAreaId) animal = feedFromTrough(value, animal, species, data, day);
215 const direction = [[0, -1], [1, 0], [0, 1], [-1, 0], [0, 0]][rng.nextInt(5)]!;
216 if (canWalk(area, animal.x + direction[0]!, animal.y + direction[1]!)) { animal.x += direction[0]!; animal.y += direction[1]!; }
217 if (species.outdoorOnly && animal.truffleReady && outsideTime && animal.areaId === data.farmAreaId) {
218 const chance = 1 - (1 - data.care.truffleChancePerFrame) ** (elapsedMs / 1000 * data.care.simulationFramesPerSecond);
219 if (rng.next() < chance && canWalk(area, animal.x, animal.y)) {
220 const product = makeProduct(value, animal, { itemId: species.productItemId, quality: "regular" });
221 value.products[product.id] = product;
222 animal.lastProductDay = absoluteDay(state); animal.truffleReady = rng.next() < animal.friendship / data.care.truffleExtraFriendshipDivisor;
223 }
224 }
225 }
226 if (state.clock.season === "winter" && animal.areaId === building.interiorAreaId && minute >= data.care.eveningMinute && animal.mood >= data.care.qualityMoodThreshold) {
227 animal.mood = building.heater ? clamp(animal.mood + species.weatherMoodDelta, data.care.moodMax) : Math.max(data.care.qualityMoodThreshold, animal.mood - species.weatherMoodDelta);
228 }
229 if (animal.areaId === building.areaId && (!sunny || state.clock.season === "winter" || minute >= data.care.sleepMinute)) animal.mood = clamp(animal.mood - species.weatherMoodDelta, data.care.moodMax);
230 else if (animal.areaId === building.areaId && minute >= data.care.eveningMinute && animal.mood < data.care.qualityMoodThreshold) animal.mood = clamp(animal.mood + species.weatherMoodDelta, data.care.moodMax);
231 value.animals[id] = animal;
232 }
233 hatch(state, value, data);
234 if (value.horse?.riderId) { const rider = state.players[value.horse.riderId]; if (rider) value.horse = { ...value.horse, areaId: rider.areaId, x: rider.x, y: rider.y }; }
235 const products = Object.keys(value.products).filter((id) => !saved.products[id]);
236 const hatched = Object.keys(value.animals).filter((id) => !saved.animals[id]);
237 // Walking/care stays in bounded checkpoints; new livestock and products are durable outcomes.
238 return { state: saveAnimals({ ...state, rngState: rng.state }, value), events: products.length || hatched.length ? [{ type: "animalsChanged", data: { products, hatched } }] : [] };
239 }
240