436 lines
12 KiB
JavaScript
436 lines
12 KiB
JavaScript
|
|
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 = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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) => `<option value="${escapeHtml(name)}"></option>`).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 = `
|
|||
|
|
<div class="empty-state">
|
|||
|
|
Add more ingredients or load the sample inventory to see matching recipes.
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
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 = `<div class="chip">No inventory entered yet</div>`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
chipsRoot.innerHTML = entries
|
|||
|
|
.map((entry) => `<div class="chip">${escapeHtml(entry.display)}</div>`)
|
|||
|
|
.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) => `<span class="ingredient-pill">${escapeHtml(ingredient)}</span>`)
|
|||
|
|
.join("");
|
|||
|
|
|
|||
|
|
const canonicalImageUrl = normalizeImageUrl(entry.item.image_url || "");
|
|||
|
|
const image = canonicalImageUrl
|
|||
|
|
? `<img src="${escapeHtml(canonicalImageUrl)}" data-fallback-src="${escapeHtml(fallbackImageUrl(canonicalImageUrl))}" alt="${escapeHtml(entry.item.name)}">`
|
|||
|
|
: `<div class="result-fallback">${initials}</div>`;
|
|||
|
|
|
|||
|
|
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 `
|
|||
|
|
<article class="result-card">
|
|||
|
|
${image}
|
|||
|
|
<div>
|
|||
|
|
<div class="result-top">
|
|||
|
|
<div>
|
|||
|
|
<h3 class="result-name">
|
|||
|
|
<a href="${escapeHtml(entry.item.url)}" target="_blank" rel="noreferrer">${escapeHtml(entry.item.name)}</a>
|
|||
|
|
</h3>
|
|||
|
|
<p class="meta">${station} · ${yieldLabel}</p>
|
|||
|
|
</div>
|
|||
|
|
<div class="recipe-badge">${entry.maxCrafts} craft${entry.maxCrafts === 1 ? "" : "s"} possible</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="ingredients">${ingredients}</div>
|
|||
|
|
</div>
|
|||
|
|
</article>
|
|||
|
|
`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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("'", "'");
|
|||
|
|
}
|