From a8a3a45846877e6d9497f304e3e90893880fd5a5 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 28 Oct 2026 11:03:32 +0000 Subject: [PATCH] Item links: jump to the recipe Co-Authored-By: Claude Opus 5.5 --- game.py | 1 + static/app.js | 86 +++++++++++++++++++++++++++++++++++++---------- static/styles.css | 5 +++ 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/game.py b/game.py index 86f8ad0..ef6dc26 100644 --- a/game.py +++ b/game.py @@ -6575,6 +6575,7 @@ ORDER_RECIPES, ORDER_RAW = _order_sources() # line here (new / balance / fix), and a new release a new entry on top. PATCH_NOTES = [ {"date": "2026-10-28", "title": "Queue", "notes": [ + ("new", "Items link to where they're made: click to jump to the recipe."), ("new", "Queue: ▲ on #1 swaps it with the current make."), ("new", "Updated Guard kit, added recipes for crafting professions to contribute to defence of town without needing combat-related loot."), ("new", "Townsfolk mood: food, firewood and seasonal clothes. Kindling (Firekeeping), smocks and cloaks (Tailoring)."), diff --git a/static/app.js b/static/app.js index d533c04..9b9b3b3 100644 --- a/static/app.js +++ b/static/app.js @@ -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 `${item(id).icon} ${have !== undefined ? `${fmt(have)} / ${fmt(qty)}` : fmt(qty)}`; + return `${item(id).icon} ${have !== undefined ? `${fmt(have)} / ${fmt(qty)}` : fmt(qty)}`; } // 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 ? `${item(id).icon} ${fmt(have)} / ${q} ${need}` : ``; }).join(""); } // Progress towards a building cost: "have / need". -function costChip(icon, name, need, have) { - return `${icon} ${fmt(have)} / ${fmt(need)}`; +function costChip(icon, name, need, have, id) { + return `${icon} ${fmt(have)} / ${fmt(need)}`; } // 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 ? `→` : ""); } - 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]) => `${item(id).icon} ${Math.round(odds * 100)}%`; 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 += `
${made.join(" · ")}
`; return ` -
+
${icon}
${esc(a.name)} ${Object.keys(a.outputs).map((id) => needTag(id) + fittedTag(id)).join("")}
Level ${a.level} · ${dur(time)} · ${a.xp} XP
@@ -1912,6 +1943,7 @@ function itemPanel(id) {
${it.icon}

${esc(it.name)}

You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}
+ ${sourceOf(id) ? `
` : ""} ${GEAR_SLOTS.includes(it.slot) ? gearChips(it) : ""} ${it.slot ? equipRow(id, it) : ""} ${it.potion ? `
${esc(it.potion.text)} for ${C.potion_charges} ${it.potion.skill === "combat" ? "fights" : "actions"}.
@@ -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 `
${costChip(item(id).icon, item(id).name, need, need - missing)} + return `
${costChip(item(id).icon, item(id).name, need, need - missing, id)} ${mine ? `` : ""} ${stores ? `` : ""}
`; @@ -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 `
${chip} ${mine ? `` : ""} ${stores ? `` : ""} @@ -2241,7 +2273,7 @@ function fittingRows(key, b) { : `Construction ${recipe.level}`; } return `
${it.icon} - ${esc(f.name)} ${esc(f.effect)} + ${esc(f.name)} ${esc(f.effect)} ${state}
`; }); return rows.length ? `
${rows.join("")}
` : ""; @@ -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 `
${item(r.item).icon}${esc(r.label)} ${fmt(r.have)} / ${fmt(r.want)} + // "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 `
${item(r.item).icon}${esc(r.label)} ${fmt(r.have)} / ${fmt(r.want)} ${mine ? `` : ""}
`; } @@ -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 = ""; diff --git a/static/styles.css b/static/styles.css index 0daff08..4af871d 100644 --- a/static/styles.css +++ b/static/styles.css @@ -213,6 +213,11 @@ textarea { width: 100%; resize: vertical; min-height: 60px; } .io .arrow { color: var(--muted); } .item-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 6px; background: var(--panel-2); } .item-chip.short { color: var(--bad); } +[data-goto] { cursor: pointer; } +.item-chip[data-goto]:hover { box-shadow: 0 0 0 1px var(--muted) inset; } +span[data-goto]:not(.item-chip):hover, b[data-goto]:hover { text-decoration: underline dotted; } +.action.flash { animation: flash 1.8s ease-out; } +@keyframes flash { 0%, 40% { border-color: var(--gold); box-shadow: 0 0 0 2px var(--gold) inset; } } .action .why { font-size: 13px; color: var(--warn); } .grid-head { grid-column: 1 / -1; margin: 10px 0 -4px; } .tool-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }