Item links: jump to the recipe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-28 11:03:32 +00:00
parent 485008a9fa
commit a8a3a45846
3 changed files with 74 additions and 18 deletions

View file

@ -876,10 +876,41 @@ function pageHtml() {
return ({ town, council, orders, board, roster, notes, bank, achievements }[page] || town)();
}
// Where an item comes from: the recipe that makes it, else what finds it, else the fight that drops it.
// Of several, the highest one you can do, or the lowest if none yet. Skills your class can't open don't count.
let sources = null;
function sourceOf(id) {
if (!sources) {
sources = {};
const add = (key, a, rank) => (sources[key] ||= []).push({ a, rank });
for (const a of C.actions) {
if (a.special === "problem") continue;
for (const out of Object.keys(a.outputs)) add(out, a, 0);
for (const [find] of a.finds || []) add(find, a, 1);
for (const [drop] of a.drops || []) add(drop, a, 2);
if (a.loot) for (const loot of [a.loot.relic, a.loot.unique, ...a.loot.patterns]) add(loot, a, 2);
}
}
const open = (sources[id] || []).filter(({ a }) => { const s = skillOf(a.skill); return s && (!s.class || s.class === S.me.cls); });
if (!open.length) return null;
const best = Math.min(...open.map((x) => x.rank));
const list = open.filter((x) => x.rank === best).map((x) => x.a).sort((a, b) => a.level - b.level);
return list.filter((a) => !isLocked(a)).at(-1) || list[0];
}
// Clickable: go to where the item is made. `loose` opens the skill on its own tab ("any" requests).
function goto(id, loose = false) {
return sourceOf(id) ? ` data-goto="${esc(id)}"${loose ? " data-loose" : ""}` : "";
}
function gotoTip(id) {
const a = sourceOf(id);
return a ? `\n→ ${skillOf(a.skill).name}${a.group ? ` · ${a.group}` : ""}` : "";
}
// Recipe input as "have / need"; without `have`, just the quantity.
function itemChip(id, qty, have) {
function itemChip(id, qty, have, link = true) {
const short = have !== undefined && have < qty;
return `<span class="item-chip ${short ? "short" : ""}" title="${esc([item(id).name, itemStats(id)].filter(Boolean).join(": "))}">${item(id).icon} ${have !== undefined ? `${fmt(have)} <span class="have">/ ${fmt(qty)}</span>` : fmt(qty)}</span>`;
return `<span class="item-chip ${short ? "short" : ""}"${link ? goto(id) : ""} title="${esc([item(id).name, itemStats(id)].filter(Boolean).join(": ") + (link ? gotoTip(id) : ""))}">${item(id).icon} ${have !== undefined ? `${fmt(have)} <span class="have">/ ${fmt(qty)}</span>` : fmt(qty)}</span>`;
}
// A recipe's inputs as "have / need", with the fuel a lit fire pays struck through.
@ -888,15 +919,15 @@ function inputChips(a) {
return Object.entries(a.inputs).map(([id, q]) => {
const need = ins[id] || 0, have = me.bank[id] || 0;
if (need === q) return itemChip(id, q, have);
const tip = `title="${esc(`${item(id).name}: the ${C.fires[a.fire].name.toLowerCase()} pays ${q - need}`)}"`;
const tip = `${goto(id)} title="${esc(`${item(id).name}: the ${C.fires[a.fire].name.toLowerCase()} pays ${q - need}${gotoTip(id)}`)}"`;
return need ? `<span class="item-chip ${have < need ? "short" : ""}" ${tip}>${item(id).icon} ${fmt(have)} <span class="have">/ <s>${q}</s> ${need}</span></span>`
: `<span class="item-chip paid" ${tip}>${item(id).icon} <s>${q}</s></span>`;
}).join("");
}
// Progress towards a building cost: "have / need".
function costChip(icon, name, need, have) {
return `<span class="item-chip ${have < need ? "short" : ""}" title="${esc(`${name}: ${have < need ? `${fmt(need - have)} more needed` : "done"}`)}">${icon} ${fmt(have)} <span class="have">/ ${fmt(need)}</span></span>`;
function costChip(icon, name, need, have, id) {
return `<span class="item-chip ${have < need ? "short" : ""}"${id ? goto(id) : ""} title="${esc(`${name}: ${have < need ? `${fmt(need - have)} more needed` : "done"}${id ? gotoTip(id) : ""}`)}">${icon} ${fmt(have)} <span class="have">/ ${fmt(need)}</span></span>`;
}
// What the city still needs of each item from building funds, fittings and happenings, after the stockpile.
@ -1766,7 +1797,7 @@ function actionCard(a) {
if (Object.keys(a.inputs).length) {
io += inputChips(a) + (Object.keys(a.outputs).length || a.finds ? `<span class="arrow">→</span>` : "");
}
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q, undefined, false)).join("");
const chance = ([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}${q > 1 ? ` ×${q}` : ""}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${Math.round(odds * 100)}%</span>`;
if (a.finds) io += a.finds.map(chance).join("");
let extra = "";
@ -1810,7 +1841,7 @@ function actionCard(a) {
extra += gear.map((id) => gearChips(item(id))).join("");
if (made.length) extra += `<div class="small makes">${made.join(" · ")}</div>`;
return `
<div class="action ${active || second ? "active" : ""} ${locked ? "locked" : ""}">
<div class="action ${active || second ? "active" : ""} ${locked ? "locked" : ""}" data-card="${a.id}">
<div class="top"><div class="ico">${icon}</div><div>
<div class="name">${esc(a.name)} ${Object.keys(a.outputs).map((id) => needTag(id) + fittedTag(id)).join("")}</div>
<div class="sub">Level ${a.level} · ${dur(time)} · ${a.xp} XP</div>
@ -1912,6 +1943,7 @@ function itemPanel(id) {
<div class="ico">${it.icon}</div>
<h3>${esc(it.name)}</h3>
<div class="muted small">You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}</div>
${sourceOf(id) ? `<div class="row"><button class="btn sm" data-goto="${esc(id)}">${skillOf(sourceOf(id).skill).icon} ${esc(sourceOf(id).name)}</button></div>` : ""}
${GEAR_SLOTS.includes(it.slot) ? gearChips(it) : ""}
${it.slot ? equipRow(id, it) : ""}
${it.potion ? `<div class="row"><div class="small">${esc(it.potion.text)} for ${C.potion_charges} ${it.potion.skill === "combat" ? "fights" : "actions"}.</div>
@ -2157,7 +2189,7 @@ function happeningsPanel() {
const gifts = Object.entries(p.need || {}).map(([id, need]) => {
const missing = p.missing[id] || 0;
const mine = Math.min(missing, me.bank[id] || 0), stores = Math.min(missing, city.stockpile[id] || 0);
return `<div class="fund-row">${costChip(item(id).icon, item(id).name, need, need - missing)}<span class="btns">
return `<div class="fund-row">${costChip(item(id).icon, item(id).name, need, need - missing, id)}<span class="btns">
${mine ? `<button class="btn sm gold" data-aid="${p.kind}" data-item="${id}" data-source="bank">Give ${fmt(mine)}</button>` : ""}
${stores ? `<button class="btn sm" data-aid="${p.kind}" data-item="${id}" data-source="city">Stockpile ${fmt(stores)}</button>` : ""}
</span></div>`;
@ -2208,7 +2240,7 @@ function buildingCard(key, b) {
const gold = id === "gold";
const mine = Math.min(missing, gold ? me.gold : me.bank[id] || 0);
const stores = Math.min(missing, gold ? city.treasury : city.stockpile[id] || 0);
const chip = costChip(gold ? COIN : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing);
const chip = costChip(gold ? COIN : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing, !gold && id);
return `<div class="fund-row">${chip}<span class="btns">
${mine ? `<button class="btn sm gold" data-fund="${key}" data-item="${id}" data-source="bank" title="From your ${gold ? "purse" : "bank"}, earns favour">Give ${fmt(mine)}</button>` : ""}
${stores ? `<button class="btn sm" data-fund="${key}" data-item="${id}" data-source="city" title="From the city's ${gold ? "treasury" : "stockpile"}">${gold ? "Treasury" : "Stockpile"} ${fmt(stores)}</button>` : ""}
@ -2241,7 +2273,7 @@ function fittingRows(key, b) {
: `<span class="muted small">Construction ${recipe.level}</span>`;
}
return `<div class="fitting-row"><span class="fit-ico">${it.icon}</span>
<span class="fitting-text"><b>${esc(f.name)}</b> <span class="muted small">${esc(f.effect)}</span></span>
<span class="fitting-text"><b${goto(f.id)} title="${esc(gotoTip(f.id).trim())}">${esc(f.name)}</b> <span class="muted small">${esc(f.effect)}</span></span>
<span class="btns">${state}</span></div>`;
});
return rows.length ? `<div class="fittings">${rows.join("")}</div>` : "";
@ -2326,7 +2358,9 @@ function favourSpends() {
// A have / need row with its bar, and a Give button when you hold the item.
function reqRow(r) {
const mine = !r.kinds && S.me.bank[r.item] || 0;
return `<div class="req ${r.next ? "dim" : ""}"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
// "Any" rows and kinds of food open the skill as you left it; a named item opens its own recipe.
const link = goto(r.item, Boolean(r.any || r.kinds)), tip = link ? ` title="${esc(gotoTip(r.item).trim())}"` : "";
return `<div class="req ${r.next ? "dim" : ""}"><span${link}${tip}>${item(r.item).icon}</span><span><span${link}${tip}>${esc(r.label)}</span> <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
${mine ? `<button class="btn sm gold" data-donate="${r.item}" data-qty="all">Give ${fmt(mine)}</button>` : "<span></span>"}
<div class="bar"><span style="width:${r.want ? Math.min(100, r.have / r.want * 100) : 100}%"></span></div></div>`;
}
@ -2912,7 +2946,7 @@ function loop() {
document.addEventListener("click", (e) => {
if (queueOpen && !e.target.closest(".queue-panel, [data-queue-menu]")) { queueOpen = false; render(); }
const t = e.target.closest("button, [data-menu], [data-close], [data-aim]");
const t = e.target.closest("button, [data-menu], [data-close], [data-aim], [data-goto]");
if (!t) return;
const d = t.dataset;
if (t.matches("[data-battle-skip]")) return playBattle.skip?.();
@ -2925,12 +2959,20 @@ document.addEventListener("click", (e) => {
if (d.replay) return replayBattle(Number(d.replay));
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; showThreat(); return; }
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) {
if (d.navGroup) { tabs[d.nav.slice(6)] = d.navGroup; remember(`tab.${d.nav.slice(6)}`, d.navGroup); }
page = d.nav; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page);
setMenu(false);
render(); window.scrollTo(0, 0); return;
if (d.nav) return go(d.nav, d.navGroup);
if (d.goto) {
// To the recipe (or find, or fight) that gives this item, on its tab, flashed.
const a = sourceOf(d.goto);
if (!a) return;
go(`skill:${a.skill}`, "loose" in d ? null : a.group);
if ("loose" in d) return;
const card = $(`[data-card="${a.id}"]`);
if (!card) return;
card.scrollIntoView({ block: "center" });
card.classList.remove("flash");
void card.offsetWidth; // restart the flash on a second click
card.classList.add("flash");
return;
}
if (d.firewood) { firewood = Number(d.firewood); remember("firewood", d.firewood); return render(); }
if (d.press) {
@ -3062,6 +3104,14 @@ document.addEventListener("click", (e) => {
if ("devEvent" in d) return act("/api/dev/event", {});
});
function go(to, group) {
if (group) { tabs[to.slice(6)] = group; remember(`tab.${to.slice(6)}`, group); }
page = to; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page);
setMenu(false);
render(); window.scrollTo(0, 0);
}
function clearSearch() {
const field = $("[data-keep=order-search]");
if (field) field.value = "";