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

1 import type { ProcessingRecipe } from "../../../../content/src/domains/crafting/schema";
2 import type { IntentResult, SimRules, WorldState } from "../../types";
3 import { withPlayer } from "../../state";
4 import type { IntentContext } from "../registry";
5 import { addGroundDrop, addItem, getInventory, takeSlot } from "../inventory";
6 import { getItem } from "../items";
7 import { consumeMaterials, ingredientMatches } from "./materials";
8 import { getCrafting, getCraftingArea, getCraftingContent, machineMinute, withCraftingArea, type MachineJob, type PlacedObject } from "./model";
9 import { craftingObject, inCraftedReach, placementDefinition, treeAt } from "./placement";
10 
11 export function machineDefinition(object: PlacedObject, rules: SimRules) {
12   const id = placementDefinition(rules, object.itemId)?.machineId;
13   return getCraftingContent(rules)?.machines.find((machine) => machine.id === id);
14 }
15 export function processingPrice(baseSellPrice: number, formula: { multiplier: number; add: number }): number { return Math.floor(baseSellPrice * formula.multiplier + formula.add); }
16 function job(state: WorldState, recipe: ProcessingRecipe): MachineJob {
17   const now = machineMinute(state);
18   return { recipeId: recipe.id, output: { id: recipe.output.itemId, quantity: recipe.output.quantity, quality: "regular" }, startedAt: now, readyAt: now + recipe.minutes, readyNotified: false };
19 }
20 export function startPassiveJob(state: WorldState, object: PlacedObject, rules: SimRules): PlacedObject {
21   if (object.machine || !object.tree) return object;
22   const recipe = machineDefinition(object, rules)?.recipes.find((entry) => entry.input === null && entry.treeKinds?.includes(object.tree!.kind));
23   return recipe ? { ...object, machine: job(state, recipe) } : object;
24 }
25 function replaceObject(state: WorldState, areaId: string, object: PlacedObject): WorldState {
26   const saved = getCraftingArea(state.areas[areaId]!);
27   return withCraftingArea(state, areaId, { ...saved, objects: saved.objects.map((entry) => entry.id === object.id ? object : entry) });
28 }
29 function failed(state: WorldState, error: string): IntentResult { return { state, events: [], error }; }
30 export function loadMachine(context: IntentContext, objectId: string, slot?: number, processingRecipeId?: string): IntentResult {
31   const { state, player, rules } = context, object = craftingObject(state, player.areaId, objectId, rules), machine = object && machineDefinition(object, rules);
32   if (!object || !machine || !inCraftedReach(player, object)) return failed(state, "Machine is out of reach");
33   if (object.machine) return failed(state, "Machine is already processing or ready");
34   if (machine.kind === "tapper") return failed(state, "Tappers start automatically on compatible trees");
35   const inventory = getInventory(player, rules), selected = slot === undefined ? inventory.selectedSlot : slot, stack = inventory.slots[selected];
36   const recipe = machine.recipes.find((entry) => (!processingRecipeId || entry.id === processingRecipeId) && entry.input && stack && ingredientMatches(stack, entry.input, rules));
37   if (!recipe?.input || !stack) return failed(state, "Selected item is not accepted by this machine");
38   const taken = takeSlot(player, selected, recipe.input.quantity, rules); if (!taken) return failed(state, "Not enough input items");
39   const supplied = consumeMaterials(state, taken.player, rules, recipe.fuel, 1, false); if (!supplied) return failed(state, "Missing machine fuel");
40   const product = getItem(rules, recipe.output.itemId), input = getItem(rules, stack.id);
41   if (!product || !input || recipe.priceFormula && product.baseSellPrice !== processingPrice(input.baseSellPrice, recipe.priceFormula)) return failed(state, "Machine product content is inconsistent");
42   return { state: withPlayer(replaceObject(supplied.state, player.areaId, { ...object, machine: job(state, recipe) }), supplied.player), events: [{ type: "machineLoaded", playerId: player.id, areaId: player.areaId, data: { objectId, recipeId: recipe.id } }] };
43 }
44 export function collectMachine(context: IntentContext, objectId: string): IntentResult {
45   const { state, player, rules } = context, object = craftingObject(state, player.areaId, objectId, rules);
46   if (!object || !machineDefinition(object, rules) || !inCraftedReach(player, object)) return failed(state, "Machine is out of reach");
47   if (object.tree && treeAt(state.areas[player.areaId]!, object, rules)?.id !== object.tree.id) return failed(state, "Tree is no longer available; retrieve the tapper");
48   if (!object.machine || object.machine.readyAt > machineMinute(state)) return failed(state, "Machine is not ready");
49   const added = addItem(player, object.machine.output, rules); if (added.remaining) return failed(state, "Inventory is full");
50   const product = object.machine.output;
51   let updated = { ...object, machine: null } as PlacedObject;
52   const tree = object.tree && treeAt(state.areas[player.areaId]!, object, rules);
53   if (tree && tree.id === object.tree?.id) updated = startPassiveJob(state, updated, rules);
54   return { state: withPlayer(replaceObject(state, player.areaId, updated), added.player), events: [{ type: "machineCollected", playerId: player.id, areaId: player.areaId, data: { objectId, itemId: product.id, quantity: product.quantity } }] };
55 }
56 export function announceReadyMachines(state: WorldState): IntentResult {
57   const events: IntentResult["events"] = [], now = machineMinute(state);
58   for (const area of Object.values(state.areas)) {
59     const saved = getCraftingArea(area); let changed = false;
60     const objects = saved.objects.map((object) => {
61       if (!object.machine || object.machine.readyAt > now || object.machine.readyNotified) return object;
62       changed = true; events.push({ type: "machineReady", areaId: area.id, data: { objectId: object.id, itemId: object.machine.output.id } });
63       return { ...object, machine: { ...object.machine, readyNotified: true } };
64     });
65     if (changed) state = withCraftingArea(state, area.id, { ...saved, objects });
66   }
67   return { state, events };
68 }
69 /** Tree removal belongs to its owner; detached tappers are recovered without producing ghost output. */
70 export function reconcileTappers(state: WorldState, rules: SimRules): WorldState {
71   for (const area of Object.values(state.areas)) {
72     const saved = getCraftingArea(area), detached = saved.objects.filter((object) => object.tree && treeAt(area, object, rules)?.id !== object.tree.id);
73     if (!detached.length) continue;
74     for (const object of detached) state = addGroundDrop(state, area.id, object.x, object.y, { id: object.itemId, quantity: 1, quality: "regular" });
75     const current = getCraftingArea(state.areas[area.id]!);
76     state = withCraftingArea(state, area.id, { ...current, objects: current.objects.filter((object) => !detached.some((entry) => entry.id === object.id)) });
77   }
78   return state;
79 }
80 /** Integrate a virtual 24h clock without moving the shared calendar while everyone is absent. */
81 export function offlineMachineElapsed(state: WorldState, rules: SimRules, elapsedMs: number): WorldState {
82   const settings = getCraftingContent(rules)?.settings.machineClock;
83   if (!settings || !Number.isFinite(elapsedMs) || elapsedMs <= 0) return state;
84   const saved = getCrafting(state), phase = saved.offlinePhaseMinutes ?? state.clock.minuteOfDay % 1440;
85   const minutes = elapsedMs / settings.gameMinuteMs, cycles = Math.floor(minutes / 1440), tail = minutes % 1440;
86   const nightMultiplier = settings.nightMinutesPerHour / 60;
87   let gain = cycles * (1200 + 240 * nightMultiplier), cursor = phase, remaining = tail;
88   while (remaining > 1e-9) {
89     const night = cursor >= 120 && cursor < 360, boundary = cursor < 120 ? 120 : night ? 360 : 1440;
90     const step = Math.min(remaining, boundary - cursor); gain += step * (night ? nightMultiplier : 1); remaining -= step; cursor = (cursor + step) % 1440;
91   }
92   return { ...state, revision: state.revision + 1, systems: { ...state.systems, crafting: { ...saved, offlineMachineMinutes: saved.offlineMachineMinutes + gain, offlinePhaseMinutes: (phase + minutes) % 1440 } } };
93 }
94