Exact source used by this build. SHA-256: d3860de88aa54aa603696fce2874f21fe63303180ae82012b79898986acd1fcf.

1 import { z } from "zod";
2 import type { Area, CropDefinition, CropInstance, Player, SimRules, Tile, TilePosition, WorldState } from "../../types";
3 import { createRng } from "../../rng";
4 import { target, inToolRange, tileIndex, withTile, withPlayer } from "../../state";
5 import { isRainy } from "../weather";
6 import { defineIntent, defineSystem } from "../registry";
7 import { TilePositionSchema } from "../schema";
8 import { addItem, removeItem } from "../inventory";
9 import { getItem, type Quality } from "../items";
10 import { grantXp, farmingHarvestXp, farmingGrowthMultiplier, skillLevel } from "../skills";
11 
12 interface PlantMetadata { planterId: string; growthStages: number[] }
13 interface FarmingAreaState { plants: Record<string, PlantMetadata>; fertilizers?: Record<string, { tier: number; season: string }> }
14 function plantMetadata(state: WorldState, areaId: string): FarmingAreaState { return state.areas[areaId]?.systems.farming as FarmingAreaState ?? { plants: {} }; }
15 function withPlantMetadata(state: WorldState, areaId: string, index: number, value: PlantMetadata | null): WorldState {
16   const area = state.areas[areaId]!;
17   const plants = { ...plantMetadata(state, areaId).plants };
18   if (value) plants[index] = value; else delete plants[index];
19   return { ...state, revision: state.revision + 1, areas: { ...state.areas, [areaId]: { ...area, systems: { ...area.systems, farming: { ...plantMetadata(state, areaId), plants } } } } };
20 }
21 export function getSoilFertilizer(area: Area, index: number): number {
22   return (area.systems.farming as FarmingAreaState | undefined)?.fertilizers?.[index]?.tier ?? 0;
23 }
24 /** Fertilizers are applied through farming, which owns their harvest and seasonal lifetime. */
25 export function applySoilFertilizer(state: WorldState, areaId: string, index: number, tier: number): WorldState | null {
26   const area = state.areas[areaId], tile = area?.tiles[index];
27   if (!area || !tile?.tilled || tile.objectId || getSoilFertilizer(area, index) || !Number.isInteger(tier) || tier < 1 || tier > 3 || tile.crop && (tile.crop.stage > 0 || tile.crop.ready || tile.crop.regrowing)) return null;
28   const saved = plantMetadata(state, areaId);
29   return { ...state, revision: state.revision + 1, areas: { ...state.areas, [areaId]: { ...area, systems: { ...area.systems, farming: { ...saved, fertilizers: { ...saved.fertilizers, [index]: { tier, season: state.clock.season } } } } } } };
30 }
31 /** Independent rolls preserve the conditional gold/silver probabilities. */
32 export function fertilizedCropQuality(level: number, tier: number, roll: () => number): Quality {
33   const gold = .2 * level / 10 + .2 * tier * (level + 2) / 12 + .01;
34   if (tier >= 3 && roll() < gold / 2) return "iridium";
35   if (roll() < gold) return "gold";
36   if (tier >= 3 || roll() < Math.min(.75, gold * 2)) return "silver";
37   return "regular";
38 }
39 /** Environmental systems remove crops through their owner so growth metadata stays consistent. */
40 export function removeCropAt(state: WorldState, areaId: string, index: number): WorldState {
41   const area = state.areas[areaId], tile = area?.tiles[index];
42   if (!area || !tile?.crop) return state;
43   return withPlantMetadata(withTile(state, area, index, { ...tile, crop: null }), areaId, index, null);
44 }
45 function crops(rules: SimRules): Record<string, CropDefinition> {
46   const domain = rules.content?.crops;
47   return Array.isArray(domain) ? Object.fromEntries((domain as CropDefinition[]).map((crop) => [crop.id, crop])) : rules.crops;
48 }
49 function cropId(id: string, rules: SimRules): string {
50   const items = rules.content?.items as { id: string; category: string; legacyIds?: string[] }[] | undefined;
51   return items?.find((item) => item.category === "crop" && item.legacyIds?.includes(id))?.id ?? id;
52 }
53 function cropForSeed(seedId: string, rules: SimRules): CropDefinition | undefined {
54   const item = getItem(rules, seedId);
55   const cropId = item?.category === "seed" ? item.cropId : rules.seedToCrop?.[seedId];
56   return cropId ? crops(rules)[cropId] : undefined;
57 }
58 function nextCrop(crop: CropInstance, definition: CropDefinition, metadata: PlantMetadata | undefined): CropInstance {
59   if (crop.ready) return crop;
60   const stages = metadata?.growthStages ?? definition.growthStages.map((stage) => stage.days);
61   let stage = crop.stage;
62   if (!crop.regrowing) while (stage < stages.length - 1 && stages[stage] === 0) stage++;
63   const required = crop.regrowing ? definition.regrowthDays : stages[stage];
64   const days = crop.daysInStage + 1;
65   if (!required || days < required) return { ...crop, stage, daysInStage: days };
66   if (crop.regrowing) return { ...crop, daysInStage: 0, ready: true, regrowing: false };
67   let nextStage = stage + 1;
68   while (nextStage < stages.length && stages[nextStage] === 0) nextStage++;
69   if (nextStage >= stages.length) return { ...crop, stage, daysInStage: 0, ready: true };
70   return { ...crop, stage: nextStage, daysInStage: 0 };
71 }
72 /** Farming owns soil mutation even when the tools system drives a charged area. */
73 export function applyFarmingToolTiles(state: WorldState, areaId: string, tool: "hoe" | "wateringCan", positions: TilePosition[], limit = Infinity): { state: WorldState; affected: number } {
74   const area = state.areas[areaId]; if (!area) return { state, affected: 0 };
75   const tiles = area.tiles.slice(); let affected = 0;
76   for (const position of positions) {
77     if (affected >= limit) break;
78     const index = tileIndex(area, position.x, position.y), tile = index === null ? undefined : tiles[index];
79     if (index === null || !tile || area.collision[index]) continue;
80     if (tool === "hoe") {
81       if (!tile.tillable || tile.tilled || tile.objectId || tile.crop) continue;
82       tiles[index] = { ...tile, tilled: true, watered: area.outdoor && isRainy(state.weather) };
83     } else {
84       if (!tile.tilled) continue;
85       tiles[index] = { ...tile, watered: true };
86     }
87     affected++;
88   }
89   return affected ? { state: { ...state, revision: state.revision + 1, areas: { ...state.areas, [areaId]: { ...area, tiles } } }, affected } : { state, affected };
90 }
91 /** Growth speed reduces initial growth days; regrowth duration is unchanged. */
92 function plantedStages(definition: CropDefinition, player: Player, rules: SimRules): number[] {
93   const stages = definition.growthStages.map((stage) => stage.days);
94   const total = stages.reduce((sum, days) => sum + days, 0);
95   const multiplier = farmingGrowthMultiplier(player, rules);
96   let remove = Math.min(total - 1, Math.ceil(total * Math.max(0, 1 - multiplier) - 1e-9));
97   for (let pass = 0; remove > 0 && pass < total; pass++) for (let index = 0; index < stages.length && remove > 0; index++) {
98     if (stages[index]! <= 1) continue;
99     stages[index]!--; remove--;
100   }
101   for (let index = 0; index < stages.length && remove > 0; index++) if (stages[index]! > 0) { stages[index]!--; remove--; }
102   return stages;
103 }
104 export const farmingSystem = defineSystem({
105   id: "farming", worldDefaults: () => ({}), playerDefaults: () => ({}), events: ["plant", "harvest"],
106   initializePlayer: (state, player) => ({ inventory: player.areaId === state.starterPlayer.areaId ? { ...state.starterPlayer.inventory } : {} }),
107   intents: [
108     defineIntent("plant", z.object({ type: z.literal("plant"), seedId: z.string().min(1), tile: TilePositionSchema }), ({ state, player, playerId, rules, allowsOutOfSeasonCrop }, intent) => {
109       const hit = target(state, player, intent.tile), crop = cropForSeed(intent.seedId, rules);
110       if (!hit || !inToolRange(player, intent.tile)) return { state, events: [], error: "Tile is out of reach" };
111       if (!crop) return { state, events: [], error: "Unknown seed" };
112       if (!crop.seasons.includes(state.clock.season) && !allowsOutOfSeasonCrop?.(player.areaId, intent.tile.x, intent.tile.y)) return { state, events: [], error: "Crop is out of season" };
113       if (!hit.tile.tilled || hit.tile.crop || hit.tile.objectId) return { state, events: [], error: "Tile cannot be planted" };
114       const supplied = removeItem(player, intent.seedId, 1, rules);
115       if (!supplied) return { state, events: [], error: "Seed is not in inventory" };
116       const cropState: CropInstance = { cropId: crop.id, stage: 0, daysInStage: 0, ready: false, regrowing: false };
117       const changed = withPlantMetadata(withTile(state, hit.area, hit.index, { ...hit.tile, crop: cropState }), player.areaId, hit.index, { planterId: playerId, growthStages: plantedStages(crop, player, rules) });
118       return { state: withPlayer(changed, supplied), events: [{ type: "plant", playerId, areaId: player.areaId }] };
119     }),
120     defineIntent("harvest", z.object({ type: z.literal("harvest"), tile: TilePositionSchema }), ({ state, player, playerId, rules }, intent) => {
121       const hit = target(state, player, intent.tile);
122       if (!hit || !inToolRange(player, intent.tile)) return { state, events: [], error: "Tile is out of reach" };
123       const crop = hit.tile.crop && crops(rules)[cropId(hit.tile.crop.cropId, rules)];
124       if (!crop || !hit.tile.crop?.ready) return { state, events: [], error: "Crop is not ready" };
125       const fertilizer = getSoilFertilizer(hit.area, hit.index), rng = createRng(state.rngState);
126       const quality = fertilizer ? fertilizedCropQuality(skillLevel(player, "farming"), fertilizer, () => rng.next()) : "regular";
127       const received = addItem(player, { id: crop.id, quality, quantity: 1 }, rules);
128       if (received.remaining > 0) return { state, events: [], error: "Inventory is full" };
129       const tile: Tile = crop.regrowthDays === null ? { ...hit.tile, crop: null }
130         : { ...hit.tile, crop: { ...hit.tile.crop, cropId: crop.id, daysInStage: 0, ready: false, regrowing: true } };
131       const gained = grantXp(received.player, "farming", farmingHarvestXp(getItem(rules, crop.id)?.baseSellPrice ?? crop.sellPrice), rules);
132       let changed = withTile(fertilizer ? { ...state, rngState: rng.state } : state, hit.area, hit.index, tile);
133       if (!tile.crop) changed = withPlantMetadata(changed, player.areaId, hit.index, null);
134       return { state: withPlayer(changed, gained), events: [{ type: "harvest", playerId, areaId: player.areaId, data: { itemId: crop.id } }] };
135     }),
136   ],
137   onOvernight: ({ state, rules, allowsOutOfSeasonCrop }) => {
138     const definitions = crops(rules);
139     const areas = Object.fromEntries(Object.entries(state.areas).map(([id, area]) => {
140       const plants = { ...plantMetadata(state, id).plants };
141       const tiles = area.tiles.map((tile, index) => {
142       const normalizedId = tile.crop ? cropId(tile.crop.cropId, rules) : undefined;
143       const instance = tile.crop && tile.crop.cropId !== normalizedId ? { ...tile.crop, cropId: normalizedId! } : tile.crop;
144       const definition = normalizedId ? definitions[normalizedId] : undefined;
145       const crop = definition && !definition.seasons.includes(state.clock.season) && !allowsOutOfSeasonCrop?.(id, index % area.width, Math.floor(index / area.width)) ? null
146         : definition && (tile.watered || (area.outdoor && isRainy(state.weather))) ? nextCrop(instance!, definition, plants[index]) : instance;
147       if (!crop) delete plants[index];
148       return { ...tile, watered: false, crop };
149       });
150       const fertilizers = Object.fromEntries(Object.entries(plantMetadata(state, id).fertilizers ?? {}).filter(([index, value]) => value.season === state.clock.season || !!tiles[Number(index)]?.crop).map(([index, value]) => [index, { ...value, season: state.clock.season }]));
151       return [id, { ...area, tiles, systems: { ...area.systems, farming: { ...plantMetadata(state, id), plants, fertilizers } } }];
152     }));
153     return { state: { ...state, revision: state.revision + 1, areas }, events: [] };
154   },
155 });
156