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 proxiedImageUrl = canonicalImageUrl ? imageProxyUrl(canonicalImageUrl) : "";
const image = proxiedImageUrl
? `
`
: `${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}
${station} · ${yieldLabel}
${entry.maxCrafts} craft${entry.maxCrafts === 1 ? "" : "s"} possible
${ingredients}
`;
}
function wireResultImages() {
const images = resultsRoot.querySelectorAll("img");
for (const image of images) {
image.onerror = () => {
image.onerror = null;
image.replaceWith(createFallbackNode(image.alt || "?"));
};
}
}
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 imageProxyUrl(raw) {
return `/api/image?url=${encodeURIComponent(raw)}`;
}
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("'", "'");
}