Exact source used by this build. SHA-256: 7b0b991ebb5172ea7a8b2b0e2d3b1901fb54f09d2b712dc0740c28a4d7800a56.
1 import { z } from "zod";
2 import type { Area, Season, SimRules, WorldState } from "../../types";
3 import { createRng } from "../../rng";
4 import { inToolRange } from "../../state";
5 import { defineIntent, defineSystem } from "../registry";
6 import { addItem } from "../inventory";
7 import { getItem, type Quality } from "../items";
8 import { grantXp, skillLevel } from "../skills";
9 import { calendarDay, shippingBins } from "../shipping";
10 import { getShops } from "../shops";
11 import { recordVillagerQuestProgress } from "../villagers";
12
13 export interface ForageTable { areaId: string; season: Season; attempts: number; maximum: number; spawnChance: number; pickupXp: number; entries: { itemId: string; weight: number }[] }
14 export interface ForageNode { id: string; itemId: string; x: number; y: number; season: Season }
15 export interface AreaForage { nodes: ForageNode[] }
16 interface WorldForage { lastSpawnDay: string; nextId: number }
17 export function getForage(area: Area): AreaForage { return area.systems.forage as AreaForage ?? { nodes: [] }; }
18 export function forageTables(rules: SimRules): readonly ForageTable[] { return rules.content?.forage as ForageTable[] ?? []; }
19 /** Silver is a second, independent roll after the gold roll fails. */
20 export function forageQuality(level: number, goldRoll: number, silverRoll: number): Quality {
21 const effective = Math.max(0, Math.min(10, level));
22 if (goldRoll < effective / 30) return "gold";
23 return silverRoll < effective / 15 ? "silver" : "regular";
24 }
25 function validSpawn(area: Area, index: number, state: WorldState, rules: SimRules): boolean {
26 const tile = area.tiles[index], x = index % area.width, y = Math.floor(index / area.width);
27 if (!tile || area.collision[index] || tile.tilled || tile.crop || tile.objectId) return false;
28 if (Math.hypot(x - area.spawn.x, y - area.spawn.y) < 1) return false;
29 if (area.warps.some((warp) => x >= warp.x && x < warp.x + warp.width && y >= warp.y && y < warp.y + warp.height)) return false;
30 if (area.landmarks.some((landmark) => x >= landmark.x && x < landmark.x + landmark.width && y >= landmark.y && y < landmark.y + landmark.height)) return false;
31 if (Object.values(state.players).some((player) => player.areaId === area.id && Math.hypot(x - player.x, y - player.y) < 1)) return false;
32 return ![...getShops(rules), ...shippingBins(rules)].some((object) => object.areaId === area.id && object.x === x && object.y === y);
33 }
34 export function spawnDailyForage(state: WorldState, rules: SimRules): WorldState {
35 const saved = state.systems.forage as WorldForage | undefined, day = calendarDay(state);
36 if (saved?.lastSpawnDay === day) return state;
37 const rng = createRng(state.rngState), areas = { ...state.areas };
38 let nextId = saved?.nextId ?? 1;
39 for (const areaId of Object.keys(areas).sort()) {
40 const original = areas[areaId]!;
41 if (!original.outdoor || original.locked) continue;
42 const table = forageTables(rules).find((entry) => entry.areaId === areaId && entry.season === state.clock.season);
43 const oldNodes = getForage(original).nodes, nodes = oldNodes.filter((node) => node.season === state.clock.season);
44 const removed = new Set(oldNodes.filter((node) => node.season !== state.clock.season).map((node) => node.id));
45 const tiles = original.tiles.map((tile) => tile.objectId && removed.has(tile.objectId) ? { ...tile, objectId: null } : tile);
46 let area = { ...original, tiles };
47 if (table) for (let attempt = 0; attempt < table.attempts && nodes.length < table.maximum; attempt++) {
48 if (rng.next() >= table.spawnChance) continue;
49 const candidates = area.tiles.map((_, index) => index).filter((index) => validSpawn(area, index, state, rules));
50 if (!candidates.length) break;
51 const index = candidates[rng.nextInt(candidates.length)]!, weight = table.entries.reduce((sum, entry) => sum + entry.weight, 0);
52 let roll = rng.next() * weight;
53 const entry = table.entries.find((candidate) => (roll -= candidate.weight) < 0) ?? table.entries.at(-1)!;
54 if (!getItem(rules, entry.itemId)) continue;
55 const node: ForageNode = { id: `forage_${nextId++}`, itemId: entry.itemId, x: index % area.width, y: Math.floor(index / area.width), season: state.clock.season };
56 nodes.push(node); tiles[index] = { ...tiles[index]!, objectId: node.id }; area = { ...area, tiles };
57 }
58 areas[areaId] = { ...area, systems: { ...area.systems, forage: { nodes } } };
59 }
60 return { ...state, revision: state.revision + 1, rngState: rng.state, areas, systems: { ...state.systems, forage: { lastSpawnDay: day, nextId } } };
61 }
62
63 export const forageSystem = defineSystem({
64 id: "forage", worldDefaults: () => ({ lastSpawnDay: "", nextId: 1 }), playerDefaults: () => ({}), events: ["foragePicked"],
65 intents: [defineIntent("pickupForage", z.object({ type: z.literal("pickupForage"), nodeId: z.string().min(1) }), ({ state, player, playerId, rules }, intent) => {
66 const area = state.areas[player.areaId], node = area && getForage(area).nodes.find((entry) => entry.id === intent.nodeId);
67 if (!area || !node || !inToolRange(player, node)) return { state, events: [], error: "Forage is out of reach" };
68 const item = getItem(rules, node.itemId);
69 if (!item) return { state, events: [], error: "Unknown forage item" };
70 const rng = createRng(state.rngState), quality = item.qualityAllowed ? forageQuality(skillLevel(player, "foraging"), rng.next(), rng.next()) : "regular";
71 const added = addItem(player, { id: node.itemId, quality, quantity: 1 }, rules);
72 if (added.remaining > 0) return { state, events: [], error: "Inventory is full" };
73 const table = forageTables(rules).find((entry) => entry.areaId === area.id && entry.season === node.season), awarded = grantXp(added.player, "foraging", table?.pickupXp ?? 0, rules);
74 const index = node.y * area.width + node.x, tiles = area.tiles.slice();
75 if (tiles[index]?.objectId === node.id) tiles[index] = { ...tiles[index]!, objectId: null };
76 const next = { ...state, revision: state.revision + 1, rngState: rng.state, players: { ...state.players, [playerId]: awarded }, areas: { ...state.areas, [area.id]: { ...area, tiles, systems: { ...area.systems, forage: { nodes: getForage(area).nodes.filter((entry) => entry.id !== node.id) } } } } };
77 return { state: recordVillagerQuestProgress(next, playerId, { kind: "gathering", itemId: node.itemId, quantity: 1 }, rules), events: [{ type: "foragePicked", playerId, areaId: area.id, data: { itemId: node.itemId, quality } }] };
78 })],
79 onOvernight: ({ state, rules }) => ({ state: spawnDailyForage(state, rules), events: [] }),
80 });
81