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}`
: `${item(id).icon} ${q}`;
}).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 += `
` : "";
@@ -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 `