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