Exact source used by this build. SHA-256: 6ed6242da2df593ef07f9654e9bac429a556e8af2f6bc5b9c648fccab8a4cc1b.

1 import { z } from "zod";
2 import type { Area, Facing, IntentResult, Player, SimEvent, SimRules, TilePosition, WorldState } from "../../types";
3 import { inToolRange, target, tileIndex, withPlayer } from "../../state";
4 import { defineIntent, defineSystem } from "../registry";
5 import { TilePositionSchema } from "../schema";
6 import { addGroundDrop, countItem, removeItem } from "../inventory";
7 import { grantXp, skillLevel } from "../skills";
8 import { debitMoney } from "../shipping/money";
9 import { shopAccess } from "../shops";
10 import { recordVillagerQuestProgress } from "../villagers";
11 import { applyFarmingToolTiles } from "../farming";
12 import { getItem } from "../items";
13 
14 export const ToolIdSchema = z.enum(["hoe", "wateringCan", "pickaxe", "axe", "scythe", "trashCan"]);
15 export type ToolId = z.infer<typeof ToolIdSchema>;
16 type Skill = "farming" | "mining" | "foraging" | "fishing" | "combat";
17 interface Tier { tier: number; upgradePrice: number; barId: string | null; bars: number; capacity: number; refund: number; power: { length: number; width: number } }
18 interface ToolDefinition { id: ToolId; skill: Skill | null; energyCost: number; tiers: Tier[] }
19 interface DebrisDefinition { id: string; tool: ToolId; minTier: number; hitsByTier: number[]; drops: { id: string; quantity: number }[]; xp: number }
20 export interface Debris { id: string; kind: string; areaId: string; x: number; y: number; hits: number }
21 interface ToolsContent {
22   tools: ToolDefinition[]; debris: DebrisDefinition[]; placements: Omit<Debris, "hits">[];
23   upgradeMornings: number; chargeMs: number; proficiencyReduction: number; failedSwingEnergy: number; waterTileIds: number[];
24 }
25 export interface ToolState { tier: number; water: number; upgrade: { targetTier: number; morningsRemaining: number } | null }
26 export interface ToolsState { tools: Partial<Record<ToolId, ToolState>> }
27 interface DebrisState { initialized: boolean; debris: Record<string, Debris> }
28 
29 function content(rules: SimRules): ToolsContent | undefined {
30   const data = rules.content?.tools;
31   return data && typeof data === "object" && "tools" in data && Array.isArray(data.tools) ? data as ToolsContent : undefined;
32 }
33 export function getTools(player: Player, rules: SimRules): ToolsState {
34   const saved = player.systems.tools as ToolsState | undefined;
35   const tools: ToolsState["tools"] = { ...saved?.tools };
36   for (const tool of content(rules)?.tools ?? []) if (!tools[tool.id]) tools[tool.id] = { tier: 0, water: tool.tiers[0]?.capacity ?? 0, upgrade: null };
37   return { tools };
38 }
39 function getTool(player: Player, tool: ToolId, rules: SimRules): ToolState {
40   // The P0 rules input omits new domains; retain its basic hoe/can behavior.
41   return getTools(player, rules).tools[tool] ?? { tier: 0, water: tool === "wateringCan" ? 40 : 0, upgrade: null };
42 }
43 function setTool(player: Player, tool: ToolId, value: ToolState, rules: SimRules): Player {
44   return { ...player, systems: { ...player.systems, tools: { tools: { ...getTools(player, rules).tools, [tool]: value } } } };
45 }
46 /** Contextual water sources keep watering-can state owned by the tools system. */
47 export function refillToolAtSource(player: Player, rules: SimRules): Player {
48   const can = getTool(player, "wateringCan", rules);
49   const capacity = content(rules)?.tools.find((entry) => entry.id === "wateringCan")?.tiers[can.tier]?.capacity ?? 40;
50   return setTool(player, "wateringCan", { ...can, water: capacity }, rules);
51 }
52 export function consumeToolWater(player: Player, rules: SimRules, amount: number): Player | null {
53   const can = getTool(player, "wateringCan", rules);
54   if (!toolOwned(player, "wateringCan", rules) || can.upgrade || !Number.isSafeInteger(amount) || amount <= 0 || can.water < amount) return null;
55   return setTool(player, "wateringCan", { ...can, water: can.water - amount }, rules);
56 }
57 export function toolTier(player: Player, tool: ToolId, rules: SimRules): number { return getTool(player, tool, rules).tier; }
58 export function toolAvailable(player: Player, tool: ToolId, rules: SimRules): boolean { return getTool(player, tool, rules).upgrade === null; }
59 function toolOwned(player: Player, tool: ToolId, rules: SimRules): boolean { return tool === "trashCan" || !rules.content?.items || countItem(player, `tool_${tool}`, rules) > 0; }
60 export function trashRefundRate(player: Player, rules: SimRules): number {
61   return content(rules)?.tools.find((tool) => tool.id === "trashCan")?.tiers[toolTier(player, "trashCan", rules)]?.refund ?? 0;
62 }
63 export function getDebris(area: Area): Debris[] { return Object.values((area.systems.tools as DebrisState | undefined)?.debris ?? {}); }
64 /** Blasts use the debris owner; large tier-gated obstacles remain tool-only. */
65 export function blastDebris(state: WorldState, areaId: string, positions: readonly TilePosition[], rules: SimRules): { state: WorldState; cleared: number } {
66   const area = state.areas[areaId], data = content(rules);
67   if (!area || !data) return { state, cleared: 0 };
68   const points = new Set(positions.map((position) => `${position.x},${position.y}`));
69   const saved = area.systems.tools as DebrisState | undefined;
70   const debris = { ...saved?.debris }, tiles = area.tiles.slice();
71   const drops: { position: TilePosition; id: string; quantity: number }[] = [];
72   let changed = false, cleared = 0;
73   for (const entry of Object.values(debris)) {
74     const definition = data.debris.find((candidate) => candidate.id === entry.kind);
75     if (!points.has(`${entry.x},${entry.y}`) || !definition || definition.minTier > 0) continue;
76     changed = true;
77     const hits = entry.hits + 2;
78     if (hits < definition.hitsByTier[0]!) { debris[entry.id] = { ...entry, hits }; continue; }
79     delete debris[entry.id]; cleared++;
80     const index = entry.y * area.width + entry.x;
81     if (tiles[index]?.objectId === entry.id) tiles[index] = { ...tiles[index]!, objectId: null };
82     for (const drop of definition.drops) drops.push({ position: entry, ...drop });
83   }
84   if (!changed) return { state, cleared };
85   let next = { ...state, revision: state.revision + 1, areas: { ...state.areas, [areaId]: { ...area, tiles, systems: { ...area.systems, tools: { ...saved, initialized: true, debris } } } } };
86   for (const drop of drops) next = addGroundDrop(next, areaId, drop.position.x, drop.position.y, { id: drop.id, quantity: drop.quantity, quality: "regular" });
87   return { state: next, cleared };
88 }
89 
90 /** The anchor is the first tile ahead; width spreads perpendicular to facing. */
91 export function toolPowerArea(anchor: TilePosition, facing: Facing, power: { length: number; width: number }): TilePosition[] {
92   const forward = facing === "up" ? { x: 0, y: -1 } : facing === "down" ? { x: 0, y: 1 } : facing === "left" ? { x: -1, y: 0 } : { x: 1, y: 0 };
93   const tiles: TilePosition[] = [];
94   for (let distance = 0; distance < power.length; distance++) for (let offset = -Math.floor(power.width / 2); offset <= Math.floor(power.width / 2); offset++) {
95     tiles.push({ x: anchor.x + distance * forward.x + offset * forward.y, y: anchor.y + distance * forward.y - offset * forward.x });
96   }
97   return tiles;
98 }
99 export function toolEnergyCost(player: Player, tool: ToolId, rules: SimRules, success: boolean, charge = 0): number {
100   const data = content(rules), definition = data?.tools.find((entry) => entry.id === tool);
101   const base = definition?.energyCost ?? (tool === "scythe" || tool === "trashCan" ? 0 : 2);
102   if (base === 0) return 0;
103   if (!success) return data?.failedSwingEnergy ?? 1;
104   const skill = definition?.skill ?? (tool === "axe" ? "foraging" : tool === "pickaxe" ? "mining" : "farming");
105   return Math.max(0, base + (tool === "wateringCan" ? charge * 2 : 0) - (data?.proficiencyReduction ?? 0.1) * skillLevel(player, skill));
106 }
107 export function initializeDebris(state: WorldState, rules: SimRules): WorldState {
108   const data = content(rules); if (!data) return state;
109   let changed = false;
110   const areas = Object.fromEntries(Object.entries(state.areas).map(([id, area]) => {
111     if ((area.systems.tools as DebrisState | undefined)?.initialized) return [id, area];
112     const debris: Record<string, Debris> = {}, tiles = area.tiles.slice();
113     for (const placement of data.placements.filter((entry) => entry.areaId === id)) {
114       const index = tileIndex(area, placement.x, placement.y);
115       const tile = index === null ? undefined : tiles[index];
116       if (index === null || !tile || area.collision[index] || tile.objectId || tile.crop || tile.tilled) continue;
117       debris[placement.id] = { ...placement, hits: 0 };
118       tiles[index] = { ...tile, objectId: placement.id };
119     }
120     changed = true;
121     return [id, { ...area, tiles, systems: { ...area.systems, tools: { initialized: true, debris } } }];
122   }));
123   return changed ? { ...state, revision: state.revision + 1, areas } : state;
124 }
125 function failedSwing(state: WorldState, player: Player, tool: ToolId, rules: SimRules, message: string): IntentResult {
126   const cost = toolEnergyCost(player, tool, rules, false);
127   if (player.energy < cost) return { state, events: [], error: "Not enough energy" };
128   return { state: withPlayer(state, { ...player, energy: player.energy - cost }), events: [{ type: "toolFailed", playerId: player.id, areaId: player.areaId, message }] };
129 }
130 function refill(state: WorldState, player: Player, rules: SimRules, tile: TilePosition): IntentResult {
131   const data = content(rules), hit = target(state, player, tile), can = getTool(player, "wateringCan", rules);
132   if (!hit || !inToolRange(player, tile)) return { state, events: [], error: "Tile is out of reach" };
133   if (!toolOwned(player, "wateringCan", rules)) return { state, events: [], error: "Tool is not in inventory" };
134   if (can.upgrade) return { state, events: [], error: "Tool is being upgraded" };
135   if (!(data?.waterTileIds.includes(hit.area.groundTiles[hit.index] ?? 0) || hit.tile.ground === "water")) return { state, events: [], error: "Refill at water" };
136   const capacity = data?.tools.find((entry) => entry.id === "wateringCan")?.tiers[can.tier]?.capacity ?? 40;
137   return { state: withPlayer(state, setTool(player, "wateringCan", { ...can, water: capacity }, rules)), events: [{ type: "toolRefilled", playerId: player.id }] };
138 }
139 
140 export const toolsSystem = defineSystem({
141   id: "tools", worldDefaults: () => ({}), playerDefaults: () => ({ tools: {} }),
142   events: ["till", "water", "toolFailed", "toolRefilled", "toolUpgrading", "toolReady", "debrisHit", "debrisCleared"],
143   intents: [
144     defineIntent("useItemTool", z.object({ type: z.literal("useItemTool"), itemId: z.string().min(1), tile: TilePositionSchema, targetId: z.string().min(1).optional() }), (context, intent) => {
145       const { state, player, rules } = context;
146       if (getItem(rules, intent.itemId)?.category !== "tool" || countItem(player, intent.itemId, rules) < 1) return { state, events: [], error: "Tool is not in inventory" };
147       const baseTool = ToolIdSchema.safeParse(intent.itemId.replace(/^tool_/, ""));
148       if (baseTool.success && !toolAvailable(player, baseTool.data, rules)) return { state, events: [], error: "Tool is being upgraded" };
149       if (!target(state, player, intent.tile) || !inToolRange(player, intent.tile)) return { state, events: [], error: "Tile is out of reach" };
150       return context.useItemTool?.(context, intent) ?? { state, events: [], error: "No matching tool target" };
151     }),
152     defineIntent("useTool", z.object({ type: z.literal("useTool"), tool: ToolIdSchema.exclude(["trashCan"]), tile: TilePositionSchema,
153       charge: z.number().int().min(0).max(4).optional(), holdMs: z.number().finite().min(0).max(60000).optional(),
154     }), (context, intent) => {
155       const { state: incoming, player, playerId, rules } = context;
156       if (!toolOwned(player, intent.tool, rules)) return { state: incoming, events: [], error: "Tool is not in inventory" };
157       const tool = getTool(player, intent.tool, rules);
158       if (tool.upgrade) return { state: incoming, events: [], error: "Tool is being upgraded" };
159       const state = initializeDebris(incoming, rules), hit = target(state, player, intent.tile);
160       if (!hit || !inToolRange(player, intent.tile)) return { state: incoming, events: [], error: "Tile is out of reach" };
161       const handled = context.useItemTool?.(context, { itemId: `tool_${intent.tool}`, tile: intent.tile });
162       if (handled) return handled;
163       const data = content(rules), definition = data?.tools.find((entry) => entry.id === intent.tool);
164       const held = intent.holdMs === undefined ? 4 : Math.floor(intent.holdMs / (data?.chargeMs ?? 400));
165       const charge = Math.min(tool.tier, held, intent.charge ?? (intent.holdMs === undefined ? 0 : held));
166       if (intent.tool === "hoe" || intent.tool === "wateringCan") {
167         if (intent.tool === "wateringCan" && (data?.waterTileIds.includes(hit.area.groundTiles[hit.index] ?? 0) || hit.tile.ground === "water")) return refill(state, player, rules, intent.tile);
168         if (intent.tool === "wateringCan" && tool.water <= 0) return { state: incoming, events: [], error: "Watering can is empty" };
169         const power = definition?.tiers[charge]?.power ?? { length: 1, width: 1 };
170         const area = toolPowerArea(intent.tile, player.facing, power);
171         const result = applyFarmingToolTiles(state, player.areaId, intent.tool, area, intent.tool === "wateringCan" ? tool.water : Infinity);
172         if (!result.affected) return failedSwing(state, player, intent.tool, rules, intent.tool === "hoe" ? "Tile cannot be tilled" : "Tile is not tilled");
173         const cost = toolEnergyCost(player, intent.tool, rules, true, charge);
174         if (player.energy < cost) return { state: incoming, events: [], error: "Not enough energy" };
175         let updated = { ...player, energy: player.energy - cost };
176         if (intent.tool === "wateringCan") updated = setTool(updated, intent.tool, { ...tool, water: tool.water - result.affected }, rules);
177         return { state: withPlayer(result.state, updated), events: [{ type: intent.tool === "hoe" ? "till" : "water", playerId, areaId: player.areaId, data: { tiles: result.affected, charge } }] };
178       }
179       const debris = getDebris(hit.area).find((entry) => entry.id === hit.tile.objectId);
180       const obstacle = debris && data?.debris.find((entry) => entry.id === debris.kind);
181       if (!debris || !obstacle || obstacle.tool !== intent.tool) return failedSwing(state, player, intent.tool, rules, "No matching debris");
182       if (tool.tier < obstacle.minTier) return failedSwing(state, player, intent.tool, rules, "Tool tier is too low");
183       const cost = toolEnergyCost(player, intent.tool, rules, true);
184       if (player.energy < cost) return { state: incoming, events: [], error: "Not enough energy" };
185       const hits = debris.hits + 1, cleared = hits >= (obstacle.hitsByTier[tool.tier] ?? Infinity);
186       const remaining = { ...(hit.area.systems.tools as DebrisState).debris };
187       if (cleared) delete remaining[debris.id]; else remaining[debris.id] = { ...debris, hits };
188       const tiles = hit.area.tiles.slice();
189       if (cleared) tiles[hit.index] = { ...hit.tile, objectId: null };
190       const area = { ...hit.area, tiles, systems: { ...hit.area.systems, tools: { initialized: true, debris: remaining } } };
191       let next = { ...state, revision: state.revision + 1, areas: { ...state.areas, [area.id]: area } };
192       let updated = { ...player, energy: player.energy - cost };
193       if (cleared) {
194         for (const drop of obstacle.drops) next = addGroundDrop(next, player.areaId, debris.x, debris.y, { ...drop, quality: "regular" });
195         if (obstacle.xp && definition?.skill) updated = grantXp(updated, definition.skill, obstacle.xp, rules);
196       }
197       next = withPlayer(next, updated);
198       if (cleared) for (const drop of obstacle.drops) next = recordVillagerQuestProgress(next, playerId, { kind: "gathering", itemId: drop.id, quantity: drop.quantity }, rules);
199       return { state: next, events: [{ type: cleared ? "debrisCleared" : "debrisHit", playerId, areaId: player.areaId, data: { id: debris.id, hits } }] };
200     }),
201     defineIntent("refillWateringCan", z.object({ type: z.literal("refillWateringCan"), tile: TilePositionSchema }), ({ state, player, rules }, intent) => refill(state, player, rules, intent.tile)),
202     defineIntent("upgradeTool", z.object({ type: z.literal("upgradeTool"), tool: ToolIdSchema, shopId: z.string().min(1).optional() }), ({ state, player, playerId, rules }, intent) => {
203       if (!toolOwned(player, intent.tool, rules)) return { state, events: [], error: "Tool is not in inventory" };
204       const data = content(rules), tool = getTool(player, intent.tool, rules);
205       const definition = data?.tools.find((entry) => entry.id === intent.tool), tier = definition?.tiers[tool.tier + 1];
206       if (!data || !tier) return { state, events: [], error: "Tool is already at maximum tier" };
207       if (Object.values(getTools(player, rules).tools).some((entry) => entry?.upgrade)) return { state, events: [], error: "A tool is already being upgraded" };
208       const shops = rules.content?.shops as { id: string; toolUpgrades: boolean }[] | undefined;
209       if (shops) {
210         const shopId = intent.shopId ?? shops.find((shop) => shop.toolUpgrades)?.id;
211         if (!shopId) return { state, events: [], error: "No tool upgrade shop" };
212         const access = shopAccess(state, player, rules, shopId);
213         if ("error" in access) return { state, events: [], error: access.error };
214         if (!access.shop.toolUpgrades) return { state, events: [], error: "Shop does not upgrade tools" };
215       }
216       if (!tier.barId || countItem(player, tier.barId, rules) < tier.bars) return { state, events: [], error: "Not enough upgrade bars" };
217       const paid = debitMoney(state, playerId, tier.upgradePrice);
218       if (!paid) return { state, events: [], error: "Insufficient funds" };
219       const supplied = removeItem(player, tier.barId, tier.bars, rules);
220       if (!supplied) return { state, events: [], error: "Not enough upgrade bars" };
221       const updated = setTool(supplied, intent.tool, { ...tool, upgrade: { targetTier: tier.tier, morningsRemaining: data.upgradeMornings } }, rules);
222       return { state: withPlayer(paid, updated), events: [{ type: "toolUpgrading", playerId, data: { tool: intent.tool, tier: tier.tier } }] };
223     }),
224   ],
225   onClockTick: ({ state, rules }) => ({ state: initializeDebris(state, rules), events: [] }),
226   onPlayerAdded: ({ state, rules }) => ({ state: initializeDebris(state, rules), events: [] }),
227   onOvernight: ({ state, rules }) => {
228     const data = content(rules); if (!data) return { state, events: [] };
229     let next = initializeDebris(state, rules); const events: SimEvent[] = [];
230     for (const player of Object.values(next.players)) {
231       let updated = player;
232       for (const [id, tool] of Object.entries(getTools(player, rules).tools) as [ToolId, ToolState][]) {
233         if (!tool.upgrade) continue;
234         // Two complete wake-ups without the tool; the following morning completes it.
235         if (tool.upgrade.morningsRemaining > 0) updated = setTool(updated, id, { ...tool, upgrade: { ...tool.upgrade, morningsRemaining: tool.upgrade.morningsRemaining - 1 } }, rules);
236         else {
237           const tier = data.tools.find((entry) => entry.id === id)?.tiers[tool.upgrade.targetTier];
238           updated = setTool(updated, id, { tier: tool.upgrade.targetTier, water: tier?.capacity ?? 0, upgrade: null }, rules);
239           events.push({ type: "toolReady", playerId: player.id, data: { tool: id, tier: tool.upgrade.targetTier } });
240         }
241       }
242       if (updated !== player) next = withPlayer(next, updated);
243     }
244     return { state: next, events };
245   },
246 });
247