const state = { catalog: null, itemByKey: new Map(), }; const inventoryInput = document.querySelector("#inventory-input"); const quickItemInput = document.querySelector("#quick-item"); const quickQtyInput = document.querySelector("#quick-qty"); const itemList = document.querySelector("#item-list"); const summaryText = document.querySelector("#summary-text"); const resultsRoot = document.querySelector("#results"); const chipsRoot = document.querySelector("#inventory-chips"); document.querySelector("#analyze-btn").addEventListener("click", render); document.querySelector("#sample-btn").addEventListener("click", () => { inventoryInput.value = [ "Water", "2x Ableroot", "2x Oil", "2x Gravel Beetle", "Linen Cloth", "Seaweed", "Salt", ].join("\n"); render(); }); document.querySelector("#clear-btn").addEventListener("click", () => { inventoryInput.value = ""; quickItemInput.value = ""; quickQtyInput.value = "1"; render(); }); document.querySelector("#add-item").addEventListener("click", addQuickItem); quickItemInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); addQuickItem(); } }); inventoryInput.addEventListener("input", renderInventoryPreview); bootstrap().catch((error) => { summaryText.textContent = "Failed to load crafting catalog."; resultsRoot.innerHTML = `
${escapeHtml(error.message)}
`; }); async function bootstrap() { const response = await fetch("/api/catalog"); if (!response.ok) { throw new Error(`Catalog request failed with ${response.status}`); } state.catalog = await response.json(); state.itemByKey = buildItemIndex(state.catalog.items || []); hydrateItemList(state.catalog.item_names || []); summaryText.textContent = `Loaded ${state.catalog.item_names.length} known items and ${state.catalog.craftables.length} craftable entries.`; render(); } function buildItemIndex(items) { const index = new Map(); for (const item of items) { index.set(normalizeName(item.name), item); } return index; } function hydrateItemList(names) { itemList.innerHTML = names.map((name) => ``).join(""); } function addQuickItem() { const name = quickItemInput.value.trim(); const qty = Math.max(1, Number.parseInt(quickQtyInput.value || "1", 10) || 1); if (!name) { return; } const line = qty > 1 ? `${qty}x ${name}` : name; inventoryInput.value = inventoryInput.value.trim() ? `${inventoryInput.value.trim()}\n${line}` : line; quickItemInput.value = ""; quickQtyInput.value = "1"; render(); } function render() { renderInventoryPreview(); if (!state.catalog) { return; } const inventory = parseInventory(inventoryInput.value); const craftable = findCraftableRecipes(state.catalog.craftables, inventory); if (!craftable.length) { summaryText.textContent = `No craftable recipes found from ${inventory.totalKinds} inventory item types.`; resultsRoot.innerHTML = `
Add more ingredients or load the sample inventory to see matching recipes.
`; return; } summaryText.textContent = `${craftable.length} recipe matches from ${inventory.totalKinds} inventory item types.`; resultsRoot.innerHTML = craftable.map(renderRecipeCard).join(""); wireResultImages(); } function renderInventoryPreview() { const inventory = parseInventory(inventoryInput.value); const entries = Object.values(inventory.byKey).sort((a, b) => a.name.localeCompare(b.name)); if (!entries.length) { chipsRoot.innerHTML = `
No inventory entered yet
`; return; } chipsRoot.innerHTML = entries .map((entry) => `
${escapeHtml(entry.display)}
`) .join(""); } function parseInventory(raw) { const byKey = {}; const lines = raw .split("\n") .map((line) => line.trim()) .filter(Boolean); for (const line of lines) { const parsed = parseStackText(line); if (!parsed.name) { continue; } const key = normalizeName(parsed.name); if (!byKey[key]) { byKey[key] = { name: parsed.name, count: 0, display: "", }; } byKey[key].count += parsed.count; byKey[key].display = `${byKey[key].count}x ${byKey[key].name}`; } return { byKey, totalKinds: Object.keys(byKey).length, }; } function findCraftableRecipes(items, inventory) { const matches = []; for (const item of items) { for (const recipe of item.recipes || []) { if (normalizeName(recipe.result || "") !== normalizeName(item.name || "")) { continue; } const requirements = collapseRequirements(recipe.ingredients || []); if (!requirements.length) { continue; } let maxCrafts = Number.POSITIVE_INFINITY; let canCraft = true; for (const requirement of requirements) { const have = availableCountForIngredient(requirement.name, inventory); if (have < requirement.count) { canCraft = false; break; } maxCrafts = Math.min(maxCrafts, Math.floor(have / requirement.count)); } if (!canCraft) { continue; } matches.push({ item, recipe, maxCrafts: Number.isFinite(maxCrafts) ? maxCrafts : 0, resultYield: parseResultYield(recipe.result_count), }); } } return matches.sort((left, right) => { if (right.maxCrafts !== left.maxCrafts) { return right.maxCrafts - left.maxCrafts; } return left.item.name.localeCompare(right.item.name); }); } function renderRecipeCard(entry) { const initials = escapeHtml(entry.item.name.slice(0, 2).toUpperCase()); const ingredients = (entry.recipe.ingredients || []) .map((ingredient) => `${escapeHtml(ingredient)}`) .join(""); const canonicalImageUrl = normalizeImageUrl(entry.item.image_url || ""); const image = canonicalImageUrl ? `${escapeHtml(entry.item.name)}` : `
${initials}
`; const station = entry.recipe.station ? `Station: ${escapeHtml(entry.recipe.station)}` : "Station: Any"; const yieldLabel = entry.resultYield > 1 ? `${entry.resultYield}x per craft` : "1x per craft"; return `
${image}

${escapeHtml(entry.item.name)}

${station} · ${yieldLabel}

${entry.maxCrafts} craft${entry.maxCrafts === 1 ? "" : "s"} possible
${ingredients}
`; } function wireResultImages() { const images = resultsRoot.querySelectorAll("img[data-fallback-src]"); for (const image of images) { image.onerror = () => { const fallback = image.dataset.fallbackSrc || ""; if (!fallback || image.dataset.failedOnce === "1") { image.onerror = null; image.replaceWith(createFallbackNode(image.alt || "?")); return; } image.dataset.failedOnce = "1"; image.src = fallback; }; } } function createFallbackNode(label) { const node = document.createElement("div"); node.className = "result-fallback"; node.textContent = String(label).slice(0, 2).toUpperCase(); return node; } function parseStackText(raw) { const cleaned = raw.replace(/×/g, "x").trim(); if (!cleaned) { return { name: "", count: 0 }; } const prefixed = cleaned.match(/^(\d+)\s*x\s+(.+)$/i); if (prefixed) { return { count: Number.parseInt(prefixed[1], 10), name: prefixed[2].trim(), }; } return { count: 1, name: cleaned, }; } function parseResultYield(raw) { const parsed = parseStackText(raw || "1x Result"); return parsed.count || 1; } function normalizeImageUrl(raw) { const value = String(raw || "").trim(); if (!value) { return ""; } const [base, query = ""] = value.split("?"); const canonicalBase = base.replace(/\/revision\/latest\/scale-to-width-down\/\d+$/, "/revision/latest"); return query ? `${canonicalBase}?${query}` : canonicalBase; } function fallbackImageUrl(raw) { const value = normalizeImageUrl(raw); if (!value) { return ""; } const [base] = value.split("?"); return base.replace(/\/revision\/latest$/, ""); } function collapseRequirements(ingredients) { const counts = new Map(); for (const ingredient of ingredients) { const parsed = parseStackText(ingredient); if (!parsed.name) { continue; } const key = normalizeName(parsed.name); const existing = counts.get(key) || { name: parsed.name, count: 0, }; existing.count += parsed.count; counts.set(key, existing); } return Array.from(counts.values()); } function availableCountForIngredient(ingredientName, inventory) { const requirementKey = normalizeName(ingredientName); if (requirementKey === "water") { return countInventory(inventory, ["water", "clean water"]); } if (requirementKey === "meat") { return countMatchingInventory(inventory, (item) => isMeatLike(item)); } if (requirementKey === "vegetable") { return countMatchingInventory(inventory, (item) => isVegetableLike(item)); } if (requirementKey === "ration ingredient") { return countMatchingInventory(inventory, (item) => isRationIngredient(item)); } return inventory.byKey[requirementKey]?.count || 0; } function countInventory(inventory, keys) { return keys.reduce((sum, key) => sum + (inventory.byKey[key]?.count || 0), 0); } function countMatchingInventory(inventory, predicate) { let total = 0; for (const [key, entry] of Object.entries(inventory.byKey)) { if (!predicate(resolveInventoryItem(key, entry.name))) { continue; } total += entry.count; } return total; } function resolveInventoryItem(key, fallbackName) { const item = state.itemByKey.get(key); if (item) { return item; } return { name: fallbackName, categories: [], item_type: "", }; } function isMeatLike(item) { const name = normalizeName(item.name); return name.includes("meat") || name.includes("jerky"); } function isVegetableLike(item) { const name = normalizeName(item.name); const categories = normalizeList(item.categories || []); const itemType = normalizeName(item.item_type || ""); const foodLike = categories.includes("food") || categories.includes("consumables") || itemType === "food"; if (!foodLike) { return false; } if (name.includes("salt") || name.includes("water") || name.includes("meat") || name.includes("fish") || name.includes("egg")) { return false; } return true; } function isRationIngredient(item) { const name = normalizeName(item.name); const categories = normalizeList(item.categories || []); const itemType = normalizeName(item.item_type || ""); const foodLike = categories.includes("food") || categories.includes("consumables") || itemType === "food"; if (!foodLike) { return false; } return !name.includes("salt") && !name.includes("water"); } function normalizeList(values) { return values.map((value) => normalizeName(value)); } function normalizeName(value) { return value.toLowerCase().replace(/\s+/g, " ").trim(); } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); }