Exact source used by this build. SHA-256: 73b699972f91f2dcb2e5d0c3bcaf944e953f883dbfbf720195de84b0dc4041ce.
1 import type { BuffEffects, CraftingContent, Recipe } from "../../../../content/src/domains/crafting/schema";
2 import type { Area, Player, SimRules, WorldState } from "../../types";
3 import type { ItemStack } from "../inventory";
4 import { skillLevel } from "../skills";
5 import { getMinePlayer, setMineHealth } from "../mines/model";
6
7 export interface ActiveBuff { itemId: string; remainingMs: number; effects: BuffEffects }
8 export interface CraftingPlayer {
9 learnedRecipes: string[];
10 kitchenUpgradeLevel: number;
11 health: number;
12 maxHealth: number;
13 vitalsInitialized?: boolean;
14 buffs: { food: ActiveBuff | null; drink: ActiveBuff | null };
15 crafted: Record<string, number>;
16 cooked: Record<string, number>;
17 }
18 export interface CraftingWorld { nextObjectId: number; offlineMachineMinutes: number; offlinePhaseMinutes: number | null; activeMs: number }
19 /** A future tree system can supply the same persistent reference through registerCraftingTree. */
20 export interface CraftingTree { id: string; kind: string; x: number; y: number; mature: boolean }
21 export interface MachineJob { recipeId: string; output: ItemStack; startedAt: number; readyAt: number; readyNotified: boolean }
22 export interface PlacedObject {
23 id: string; itemId: string; x: number; y: number; width: number; height: number;
24 machine: MachineJob | null; tree?: CraftingTree;
25 }
26 export interface PlacedBomb { id: string; itemId: string; playerId: string; x: number; y: number; explodeAtMs: number }
27 export interface CraftingArea { objects: PlacedObject[]; trees: CraftingTree[]; bombs: PlacedBomb[] }
28 export function craftingWorldDefaults(): CraftingWorld { return { nextObjectId: 1, offlineMachineMinutes: 0, offlinePhaseMinutes: null, activeMs: 0 }; }
29 export function craftingPlayerDefaults(maxHealth = 100): CraftingPlayer {
30 return { learnedRecipes: [], kitchenUpgradeLevel: 0, health: maxHealth, maxHealth, vitalsInitialized: false, buffs: { food: null, drink: null }, crafted: {}, cooked: {} };
31 }
32 /** Data is validated by its content domain before the server injects it. */
33 export function getCraftingContent(rules: SimRules): CraftingContent | undefined {
34 const value = rules.content?.crafting;
35 return value && typeof value === "object" && "recipes" in value && Array.isArray(value.recipes) ? value as CraftingContent : undefined;
36 }
37 export function getCrafting(state: WorldState): CraftingWorld { return { ...craftingWorldDefaults(), ...state.systems.crafting as Partial<CraftingWorld> | undefined }; }
38 export function getPlayerCrafting(player: Player): CraftingPlayer {
39 const value = player.systems.crafting as Partial<CraftingPlayer> | undefined;
40 const mine = getMinePlayer(player);
41 return { ...craftingPlayerDefaults(), ...value, ...(mine.starterReceived ? { health: mine.health, maxHealth: mine.maxHealth } : {}), buffs: { food: null, drink: null, ...value?.buffs } };
42 }
43 export function getCraftingArea(area: Area): CraftingArea { return { objects: [], trees: [], bombs: [], ...area.systems.crafting as Partial<CraftingArea> | undefined }; }
44 export function withCrafting(state: WorldState, value: CraftingWorld): WorldState {
45 return { ...state, revision: state.revision + 1, systems: { ...state.systems, crafting: value } };
46 }
47 export function withCraftingArea(state: WorldState, areaId: string, value: CraftingArea): WorldState {
48 const area = state.areas[areaId];
49 return area ? { ...state, revision: state.revision + 1, areas: { ...state.areas, [areaId]: { ...area, systems: { ...area.systems, crafting: value } } } } : state;
50 }
51 export function withPlayerCrafting(player: Player, value: CraftingPlayer): Player {
52 const updated = getMinePlayer(player).starterReceived ? setMineHealth(player, value.health, value.maxHealth) : player;
53 return { ...updated, systems: { ...updated.systems, crafting: value } };
54 }
55 export function machineMinute(state: WorldState): number { return state.clock.machineMinutes + getCrafting(state).offlineMachineMinutes; }
56 export function recipeKnown(player: Player, recipe: Recipe): boolean {
57 return getPlayerCrafting(player).learnedRecipes.includes(recipe.id) || recipe.unlock.kind === "default" || recipe.unlock.kind === "skill" && skillLevel(player, recipe.unlock.skill) >= recipe.unlock.level;
58 }
59 /** Trusted mail hook; deliberately not a client intent. Friendship remains owned by its system. */
60 export function grantRecipeFromFriendshipMail(player: Player, rules: SimRules, mail: { recipeId: string; villagerId: string; hearts: number }): Player | null {
61 const recipe = getCraftingContent(rules)?.recipes.find((entry) => entry.id === mail.recipeId);
62 if (!recipe || recipe.unlock.kind !== "friendship" || recipe.unlock.villagerId !== mail.villagerId || !Number.isFinite(mail.hearts) || mail.hearts < recipe.unlock.hearts) return null;
63 return learnRecipe(player, recipe.id);
64 }
65 export function learnRecipe(player: Player, recipeId: string): Player {
66 const saved = getPlayerCrafting(player);
67 return saved.learnedRecipes.includes(recipeId) ? player : withPlayerCrafting(player, { ...saved, learnedRecipes: [...saved.learnedRecipes, recipeId] });
68 }
69 /** Trusted farmhouse upgrade hook; clients cannot grant themselves a kitchen. */
70 export function setKitchenUpgrade(player: Player, upgradeLevel: number): Player {
71 if (!Number.isSafeInteger(upgradeLevel) || upgradeLevel < 0) return player;
72 return withPlayerCrafting(player, { ...getPlayerCrafting(player), kitchenUpgradeLevel: upgradeLevel });
73 }
74 export function activeBuffEffects(player: Player): BuffEffects {
75 const effects: Record<string, number> = {};
76 for (const buff of Object.values(getPlayerCrafting(player).buffs)) if (buff && buff.remainingMs > 0) for (const [key, value] of Object.entries(buff.effects)) effects[key] = (effects[key] ?? 0) + value;
77 return effects;
78 }
79 /** Buff skill bonuses are derived; permanent XP and levels never change when food expires. */
80 export function buffedSkillLevel(player: Player, skill: Parameters<typeof skillLevel>[1]): number { return skillLevel(player, skill) + (activeBuffEffects(player)[skill] ?? 0); }
81 /** Content can arrive after addPlayer; initialize defaults once without resetting saved injured/custom vitals. */
82 export function hydrateCraftingPlayer(player: Player, rules: SimRules): Player {
83 const data = getCraftingContent(rules); if (!data) return player;
84 const saved = getPlayerCrafting(player), defaults = craftingPlayerDefaults();
85 const learnedRecipes = [...new Set([...saved.learnedRecipes, ...data.recipes.filter((recipe) => recipeKnown(player, recipe)).map((recipe) => recipe.id)])];
86 if (saved.vitalsInitialized && learnedRecipes.length === saved.learnedRecipes.length) return player;
87 const pristine = !saved.vitalsInitialized && saved.health === defaults.health && saved.maxHealth === defaults.maxHealth;
88 return withPlayerCrafting(player, { ...saved, learnedRecipes, vitalsInitialized: true,
89 ...(pristine ? { health: data.settings.defaultMaxHealth, maxHealth: data.settings.defaultMaxHealth } : {}) });
90 }
91 /** Consumer systems own the behavior of crab pots, bait and stairs; this is their typed item boundary. */
92 export function craftingItemHook(rules: SimRules, itemId: string): CraftingContent["hooks"][number] | undefined { return getCraftingContent(rules)?.hooks.find((hook) => hook.itemId === itemId); }
93