Exact source used by this build. SHA-256: d6e53b4d63585e341b31490c04c0f1f78a1593a38f2fb813d8954f29e21f6da5.
1 import json from "../../../data/crafting/crafting.json";
2 import { ITEMS, validateItemReferences, type Item } from "../items";
3 import { SHOPS } from "../shops";
4 import { TOOLS } from "../tools";
5 import type { ContentDomain } from "../types";
6 import { CraftingSchema, type CraftingContent, type Ingredient } from "./schema";
7
8 export * from "./schema";
9 export const CRAFTING = CraftingSchema.parse(json);
10 function unique(values: readonly string[], label: string): void {
11 if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`);
12 }
13 function matchingItems(ingredient: Ingredient, items: readonly Item[]): Item[] {
14 return items.filter((item) => ingredient.itemId ? item.id === ingredient.itemId : ingredient.tag ? item.tags.includes(ingredient.tag) : item.category === ingredient.category);
15 }
16 /** Explicit external selectors are typed integration slots, never invented item IDs. */
17 export function validateCraftingReferences(value: unknown, items: readonly Item[] = ITEMS): CraftingContent {
18 const data = CraftingSchema.parse(value);
19 unique(data.recipes.map((recipe) => recipe.id), "recipe id");
20 unique(data.placements.map((placement) => placement.itemId), "placement item");
21 unique(data.machines.map((machine) => machine.id), "machine id");
22 unique(data.machines.flatMap((machine) => machine.recipes.map((recipe) => recipe.id)), "processing recipe id");
23 unique(data.consumables.map((food) => food.itemId), "consumable item");
24 unique(data.visuals.map((visual) => visual.itemId), "visual item");
25 unique(data.visuals.flatMap((visual) => [visual.icon, ...(visual.world ? [visual.world] : [])]), "visual manifest key");
26 const validateIngredient = (ingredient: Ingredient): void => {
27 if (ingredient.itemId) validateItemReferences([ingredient.itemId], items);
28 if (ingredient.tag && !matchingItems(ingredient, items).length && !data.externalTags.some((tag) => tag === ingredient.tag)) throw new Error(`Missing ingredient tag: ${ingredient.tag}`);
29 if (ingredient.category && !matchingItems(ingredient, items).length && !data.externalCategories.some((category) => category === ingredient.category)) throw new Error(`Missing ingredient category: ${ingredient.category}`);
30 };
31 const outputs = data.recipes.map((recipe) => recipe.output.itemId);
32 for (const recipe of data.recipes) {
33 recipe.ingredients.forEach(validateIngredient);
34 const unlock = recipe.unlock;
35 if (unlock.kind === "purchase" && !SHOPS.some((shop) => shop.id === unlock.shopId)) throw new Error(`Missing recipe shop: ${unlock.shopId}`);
36 if (recipe.kind === "cooking" && !data.consumables.some((food) => food.itemId === recipe.output.itemId)) throw new Error(`Cooking output has no consumable: ${recipe.id}`);
37 }
38 for (const machine of data.machines) for (const recipe of machine.recipes) {
39 if (recipe.input) validateIngredient(recipe.input);
40 recipe.fuel.forEach(validateIngredient);
41 outputs.push(recipe.output.itemId);
42 if (recipe.treeKinds?.some((kind) => !TOOLS.debris.some((tree) => tree.id === kind))) throw new Error(`Missing tapper tree: ${recipe.id}`);
43 if (machine.kind === "tapper" && (!recipe.treeKinds?.length || recipe.input !== null)) throw new Error(`Tapper recipe requires a tree and no inserted input: ${recipe.id}`);
44 if (machine.kind !== "tapper" && !recipe.input) throw new Error(`Processing recipe requires input: ${recipe.id}`);
45 if (recipe.priceFormula) {
46 const input = recipe.input && matchingItems(recipe.input, items);
47 const output = items.find((item) => item.id === recipe.output.itemId);
48 if (!input?.length || !output || input.some((item) => Math.floor(item.baseSellPrice * recipe.priceFormula!.multiplier + recipe.priceFormula!.add) !== output.baseSellPrice)) throw new Error(`Processing price formula mismatch: ${recipe.id}`);
49 }
50 }
51 validateItemReferences([...outputs, ...data.placements.map((p) => p.itemId), ...data.consumables.map((food) => food.itemId), ...data.fertilizers.map((item) => item.itemId), ...data.bombs.map((item) => item.itemId), ...data.hooks.flatMap((item) => [item.itemId, ...(item.targetItemId ? [item.targetItemId] : [])]), ...data.visuals.map((visual) => visual.itemId)], items);
52 for (const placement of data.placements) {
53 if (placement.kind === "machine" && !data.machines.some((machine) => machine.id === placement.machineId)) throw new Error(`Missing placement machine: ${placement.itemId}`);
54 if (placement.kind === "sprinkler" && !placement.sprinklerOffsets?.length) throw new Error(`Sprinkler has no coverage: ${placement.itemId}`);
55 if (placement.kind === "scarecrow" && !placement.protectionOffsets?.length) throw new Error(`Scarecrow has no coverage: ${placement.itemId}`);
56 for (const offsets of [placement.sprinklerOffsets, placement.protectionOffsets]) if (offsets) {
57 unique(offsets.map((offset) => `${offset.x},${offset.y}`), "coverage offset");
58 if (offsets.some((offset) => offset.x === 0 && offset.y === 0)) throw new Error(`Coverage includes occupied tile: ${placement.itemId}`);
59 }
60 if (!data.visuals.some((visual) => visual.itemId === placement.itemId && visual.world === placement.visualKey)) throw new Error(`Missing world visual: ${placement.itemId}`);
61 }
62 for (const food of data.consumables) {
63 const item = items.find((item) => item.id === food.itemId)!;
64 if (!item.edibility || item.edibility.energy !== food.energy || item.edibility.health !== food.health) throw new Error(`Consumable restoration mismatch: ${food.itemId}`);
65 }
66 for (const recipe of data.recipes) if (!data.visuals.some((visual) => visual.itemId === recipe.output.itemId && visual.icon === recipe.visualKey)) throw new Error(`Missing recipe visual: ${recipe.id}`);
67 return data;
68 }
69 export const craftingDomain: ContentDomain = { id: "crafting", data: CRAFTING, validate() { validateCraftingReferences(json); } };
70 craftingDomain.validate();
71