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

1 import type { IntentResult, Player, SimEvent, SimRules, TilePosition, WorldState } from "../../types";
2 import { createRng } from "../../rng";
3 import { inToolRange, target, tileIndex, withPlayer } from "../../state";
4 import type { IntentContext } from "../registry";
5 import { getInventory, takeSlot } from "../inventory";
6 import { edibleRestoration, getItem } from "../items";
7 import { applyFarmingToolTiles, applySoilFertilizer, removeCropAt } from "../farming";
8 import { blastDebris } from "../tools";
9 import { getMinePlayer } from "../mines/model";
10 import { activeBuffEffects, getCrafting, getCraftingArea, getCraftingContent, getPlayerCrafting, withCrafting, withCraftingArea, withPlayerCrafting } from "./model";
11 import { placementDefinition } from "./placement";
12 
13 const failed = (state: WorldState, error: string): IntentResult => ({ state, events: [], error });
14 export function eat(context: IntentContext, slot: number): IntentResult {
15   const { state, player, rules } = context, stack = getInventory(player, rules).slots[slot], item = stack && getItem(rules, stack.id);
16   if (!stack || !item) return failed(state, "Select an edible item");
17   const consumable = getCraftingContent(rules)?.consumables.find((entry) => entry.itemId === stack.id), base = edibleRestoration(item, stack.quality);
18   if (!consumable && !base) return failed(state, "Item is not edible");
19   const taken = takeSlot(player, slot, 1, rules); if (!taken) return failed(state, "Item is not in inventory");
20   const multiplier = { regular: 1, silver: 1.4, gold: 1.8, iridium: 2.6 }[stack.quality];
21   const restoration = consumable ? { energy: Math.floor(consumable.energy * multiplier), health: Math.floor(consumable.health * multiplier) } : base!;
22   const saved = getPlayerCrafting(taken.player), buffs = { ...saved.buffs };
23   // A plain food/drink heals without erasing an existing buff. A buff replaces only its own slot.
24   if (consumable?.buff) buffs[consumable.kind] = { itemId: consumable.itemId, remainingMs: consumable.buff.durationMs, effects: { ...consumable.buff.effects } };
25   let updated = withPlayerCrafting(taken.player, { ...saved, health: Math.max(0, Math.min(saved.maxHealth, saved.health + restoration.health)), buffs });
26   updated = { ...updated, energy: Math.max(0, Math.min(player.maxEnergy + (activeBuffEffects(updated).maxEnergy ?? 0), player.energy + restoration.energy)) };
27   return { state: withPlayer(state, updated), events: [{ type: "foodEaten", playerId: player.id, data: { itemId: stack.id, energy: restoration.energy, health: restoration.health } }] };
28 }
29 export function tickBuffs(state: WorldState, elapsedMs: number): WorldState {
30   if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return state;
31   for (const player of Object.values(state.players)) {
32     if (!player.connected) continue;
33     const saved = getPlayerCrafting(player); let changed = false;
34     const buffs = { ...saved.buffs };
35     for (const kind of ["food", "drink"] as const) {
36       const buff = buffs[kind]; if (!buff) continue;
37       changed = true; buffs[kind] = buff.remainingMs > elapsedMs ? { ...buff, remainingMs: buff.remainingMs - elapsedMs } : null;
38     }
39     if (changed) {
40       const updated = withPlayerCrafting(player, { ...saved, buffs });
41       state = withPlayer(state, { ...updated, energy: Math.min(updated.energy, updated.maxEnergy + (activeBuffEffects(updated).maxEnergy ?? 0)) });
42     }
43   }
44   return state;
45 }
46 export function fertilize(context: IntentContext, slot: number, tile: TilePosition): IntentResult {
47   const { state, player, rules } = context, hit = target(state, player, tile), stack = getInventory(player, rules).slots[slot];
48   if (!hit || !inToolRange(player, tile)) return failed(state, "Tile is out of reach");
49   const fertilizer = getCraftingContent(rules)?.fertilizers.find((entry) => entry.itemId === stack?.id);
50   if (!fertilizer || fertilizer.kind !== "quality") return failed(state, "Select a supported fertilizer");
51   const next = applySoilFertilizer(state, player.areaId, hit.index, fertilizer.qualityLevel);
52   if (!next) return failed(state, "Fertilize empty tilled soil or a freshly planted crop once");
53   const taken = takeSlot(player, slot, 1, rules); if (!taken) return failed(state, "Fertilizer is not in inventory");
54   return { state: withPlayer(next, taken.player), events: [{ type: "fertilizerApplied", playerId: player.id, areaId: player.areaId, data: { ...tile, tier: fertilizer.qualityLevel } }] };
55 }
56 export function placeBomb(context: IntentContext, slot: number, tile: TilePosition): IntentResult {
57   const { state, player, rules } = context, hit = target(state, player, tile), stack = getInventory(player, rules).slots[slot];
58   const bomb = getCraftingContent(rules)?.bombs.find((entry) => entry.itemId === stack?.id);
59   if (!bomb) return failed(state, "Select a bomb");
60   if (!hit || !inToolRange(player, tile) || hit.area.collision[hit.index] || hit.tile.objectId || hit.tile.crop || getCraftingArea(hit.area).bombs.some((entry) => entry.x === tile.x && entry.y === tile.y)) return failed(state, "Bomb cannot be placed here");
61   const taken = takeSlot(player, slot, 1, rules); if (!taken) return failed(state, "Bomb is not in inventory");
62   const saved = getCrafting(state), area = getCraftingArea(hit.area), id = `bomb_${saved.nextObjectId}`;
63   const next = withCraftingArea(withCrafting(state, { ...saved, nextObjectId: saved.nextObjectId + 1 }), hit.area.id, { ...area, bombs: [...area.bombs, { id, itemId: bomb.itemId, playerId: player.id, ...tile, explodeAtMs: saved.activeMs + bomb.fuseMs }] });
64   return { state: withPlayer(next, taken.player), events: [{ type: "bombPlaced", areaId: player.areaId, playerId: player.id, data: { bombId: id } }] };
65 }
66 export function tickBombs(state: WorldState, rules: SimRules, elapsedMs: number): IntentResult {
67   const saved = getCrafting(state), events: SimEvent[] = [];
68   const hasBombs = Object.values(state.areas).some((area) => getCraftingArea(area).bombs.length > 0);
69   if (!hasBombs || !Number.isFinite(elapsedMs) || elapsedMs <= 0) return { state, events };
70   state = withCrafting(state, { ...saved, activeMs: saved.activeMs + elapsedMs });
71   for (const area of Object.values(state.areas)) {
72     const bombs = getCraftingArea(area).bombs.filter((bomb) => bomb.explodeAtMs <= getCrafting(state).activeMs);
73     for (const bomb of bombs) {
74       const definition = getCraftingContent(rules)?.bombs.find((entry) => entry.itemId === bomb.itemId); if (!definition) continue;
75       const positions: TilePosition[] = [];
76       for (let dy = -definition.radius; dy <= definition.radius; dy++) for (let dx = -definition.radius; dx <= definition.radius; dx++) if (dx * dx + dy * dy <= definition.radius * definition.radius) {
77         const position = { x: bomb.x + dx, y: bomb.y + dy }, index = tileIndex(area, position.x, position.y); if (index === null) continue;
78         positions.push(position); state = removeCropAt(state, area.id, index);
79       }
80       const cleared = blastDebris(state, area.id, positions, rules); state = cleared.state;
81       for (const player of Object.values(state.players)) if (player.connected && player.areaId === area.id && Math.hypot(player.x - bomb.x, player.y - bomb.y) <= definition.radius) {
82         const playerState = getPlayerCrafting(player), defense = activeBuffEffects(player).defense ?? 0;
83         state = withPlayer(state, withPlayerCrafting(player, { ...playerState, health: Math.max(0, playerState.health - Math.max(1, definition.damage - defense)) }));
84       }
85       events.push({ type: "bombExploded", areaId: area.id, playerId: bomb.playerId, data: { bombId: bomb.id, cleared: cleared.cleared } });
86     }
87     if (bombs.length) { const current = getCraftingArea(state.areas[area.id]!); state = withCraftingArea(state, area.id, { ...current, bombs: current.bombs.filter((entry) => !bombs.some((bomb) => bomb.id === entry.id)) }); }
88   }
89   return { state, events };
90 }
91 export function scarecrowProtects(state: WorldState, areaId: string, tile: TilePosition, rules: SimRules): boolean {
92   const area = state.areas[areaId]; if (!area) return false;
93   return getCraftingArea(area).objects.some((object) => placementDefinition(rules, object.itemId)?.protectionOffsets?.some((offset) => object.x + offset.x === tile.x && object.y + offset.y === tile.y));
94 }
95 export function runMorningCrafting(state: WorldState, rules: SimRules): IntentResult {
96   const content = getCraftingContent(rules); if (!content) return { state, events: [] };
97   const events: SimEvent[] = [], rng = createRng(state.rngState), crows = content.settings.crows;
98   for (const areaId of Object.keys(state.areas).sort()) {
99     const area = state.areas[areaId]!, objects = getCraftingArea(area).objects;
100     if (crows.areaIds.includes(areaId) && area.outdoor) {
101       const planted = area.tiles.flatMap((tile, index) => tile.crop ? [index] : []);
102       if (planted.length >= crows.minimumCrops) {
103         const attempts = Math.min(crows.maximumAttempts, Math.floor(planted.length / crows.cropsPerAttempt));
104         for (let attempt = 0; attempt < attempts; attempt++) {
105           if (rng.next() >= crows.chance) continue;
106           for (let retry = 0; retry < crows.targetAttempts; retry++) {
107             const index = planted[rng.nextInt(planted.length)]!, tile = state.areas[areaId]!.tiles[index]!;
108             if (!tile.crop || tile.crop.stage < crows.minimumGrowthStage) continue;
109             const position = { x: index % area.width, y: Math.floor(index / area.width) };
110             if (!scarecrowProtects(state, areaId, position, rules)) { state = removeCropAt(state, areaId, index); events.push({ type: "cropsEaten", areaId, data: { ...position, cropId: tile.crop.cropId } }); }
111             break;
112           }
113         }
114       }
115     }
116     let watered = 0;
117     for (const object of objects) {
118       const offsets = placementDefinition(rules, object.itemId)?.sprinklerOffsets;
119       if (!offsets?.length) continue;
120       const water = applyFarmingToolTiles(state, areaId, "wateringCan", offsets.map((offset) => ({ x: object.x + offset.x, y: object.y + offset.y })));
121       state = water.state; watered += water.affected;
122     }
123     if (watered) events.push({ type: "sprinklersWatered", areaId, data: { watered } });
124   }
125   if (state.rngState !== rng.state) state = { ...state, revision: state.revision + 1, rngState: rng.state };
126   for (const player of Object.values(state.players)) if (player.connected) {
127     const saved = getPlayerCrafting(player);
128     // The combat overnight hook owns sleep/pass-out restoration when installed.
129     const health = getMinePlayer(player).starterReceived ? saved.health : saved.maxHealth;
130     if (saved.buffs.food || saved.buffs.drink || health !== saved.health) state = withPlayer(state, withPlayerCrafting(player, { ...saved, health, buffs: { food: null, drink: null } }));
131   }
132   return { state, events };
133 }
134 /** Hook for combat or other health owners until their own vitals slice is integrated. */
135 export function setCraftingHealth(player: Player, health: number, maxHealth = getPlayerCrafting(player).maxHealth): Player {
136   return Number.isFinite(health) && Number.isFinite(maxHealth) && maxHealth > 0 ? withPlayerCrafting(player, { ...getPlayerCrafting(player), maxHealth, health: Math.max(0, Math.min(maxHealth, health)) }) : player;
137 }
138