Track a recipe under the top bar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 21:13:46 +01:00
parent 722cf0b382
commit a86594594a
5 changed files with 67 additions and 6 deletions

2
app.py
View file

@ -413,6 +413,8 @@ class Handler(BaseHTTPRequestHandler):
return game.buy_perk(player, str(body.get("perk"))) return game.buy_perk(player, str(body.get("perk")))
if path == "/api/pin": if path == "/api/pin":
return game.toggle_pin(player, str(body.get("skill"))) return game.toggle_pin(player, str(body.get("skill")))
if path == "/api/track":
return game.toggle_track(player, str(body.get("id")))
if path == "/api/settings": if path == "/api/settings":
if body.get("overflow") not in ("city", "stop"): if body.get("overflow") not in ("city", "stop"):
return "Bad setting" return "Bad setting"

15
game.py
View file

@ -1090,7 +1090,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world --------------------------------------------------------------- # --- world ---------------------------------------------------------------
WORLD_VERSION = 21 WORLD_VERSION = 22
def new_world(now: float, speed: float = 1.0) -> dict: def new_world(now: float, speed: float = 1.0) -> dict:
@ -1253,6 +1253,10 @@ def migrate(world: dict, now: float | None = None) -> dict:
for slot in EQUIP_SLOTS: for slot in EQUIP_SLOTS:
player["equip"].setdefault(slot, None) player["equip"].setdefault(slot, None)
world["version"] = 21 world["version"] = 21
if world["version"] < 22:
for player in world["players"].values():
player.setdefault("tracked", None)
world["version"] = 22
return world return world
@ -1513,6 +1517,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
"garrison": False, "garrison": False,
"donated": 0, "donated": 0,
"pinned": [], # skills pinned to the top of the sidebar "pinned": [], # skills pinned to the top of the sidebar
"tracked": None, # recipe shown under the top bar
} }
@ -3962,6 +3967,14 @@ def toggle_pin(player: dict, skill: str) -> str | None:
return None return None
def toggle_track(player: dict, action_id: str) -> str | None:
action = ACTION_BY_ID.get(action_id)
if not action or not action["inputs"]:
return "No such recipe"
player["tracked"] = None if player["tracked"] == action_id else action_id
return None
# --- icons ------------------------------------------------------------------------ # --- icons ------------------------------------------------------------------------
# Every item, skill, class, building, prayer and output-less action gets a # Every item, skill, class, building, prayer and output-less action gets a
# game-icons.net glyph (CC BY 3.0) and a tint. Families share a shape and are # game-icons.net glyph (CC BY 3.0) and a tint. Families share a shape and are

View file

@ -384,7 +384,21 @@ function topbar() {
const w = S.happenings?.weather; const w = S.happenings?.weather;
if (w) chips += `<button class="chip" data-nav="town" title="${esc(weatherTip(w))}">${w.icon}</button>`; if (w) chips += `<button class="chip" data-nav="town" title="${esc(weatherTip(w))}">${w.icon}</button>`;
if (S.upcoming) chips += `<button class="chip ${["raid", "dragon"].includes(S.upcoming.kind) ? "threat" : ""} ${S.upcoming.at_gates ? "alarm" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${S.upcoming.at_gates ? `<span class="label">${S.upcoming.kind === "dragon" ? "Overhead" : "At the gates"}</span> ` : ""}${eventWhen(S.upcoming, true)}</button>`; if (S.upcoming) chips += `<button class="chip ${["raid", "dragon"].includes(S.upcoming.kind) ? "threat" : ""} ${S.upcoming.at_gates ? "alarm" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${S.upcoming.at_gates ? `<span class="label">${S.upcoming.kind === "dragon" ? "Overhead" : "At the gates"}</span> ` : ""}${eventWhen(S.upcoming, true)}</button>`;
return `<button class="menu-btn" data-menu aria-label="Menu">☰</button><h1>${esc(title)}</h1>${chips}`; return `<button class="menu-btn" data-menu aria-label="Menu">☰</button><h1>${esc(title)}</h1>${chips}${S.me ? trackedBar() : ""}`;
}
// The recipe you're after, under the top bar on every page: have / need for each input.
function trackedBar() {
const me = S.me, a = me.tracked && C.actions.find((x) => x.id === me.tracked);
if (!a) return "";
const ready = Object.entries(a.inputs).every(([id, q]) => (me.bank[id] || 0) >= q);
const go = !me.blocked[a.id] && me.action?.id !== a.id;
return `<div class="tracked ${ready ? "ready" : ""}">
<button class="tracked-name" data-nav="skill:${a.skill}" data-nav-group="${esc(a.group || "")}" title="${esc(a.name)}">${a.icon}<span class="label">${esc(a.name)}</span></button>
<span class="io">${Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")}</span>
${go ? `<button class="btn sm primary" data-start="${a.id}">Start</button>` : ""}
<button class="pin on" data-track="${a.id}" title="Untrack">📌</button>
</div>`;
} }
function eventIcon(kind) { function eventIcon(kind) {
@ -799,7 +813,7 @@ function actionCard(a) {
<div class="top"><div class="ico">${icon}</div><div> <div class="top"><div class="ico">${icon}</div><div>
<div class="name">${esc(a.name)}</div> <div class="name">${esc(a.name)}</div>
<div class="sub">Level ${a.level} · ${dur(time)} · ${a.xp} XP</div> <div class="sub">Level ${a.level} · ${dur(time)} · ${a.xp} XP</div>
</div></div> </div>${Object.keys(a.inputs).length ? `<button class="pin ${me.tracked === a.id ? "on" : ""}" data-track="${a.id}" title="${me.tracked === a.id ? "Untrack" : "Track"}">📌</button>` : ""}</div>
${io ? `<div class="io">${io}</div>` : ""} ${io ? `<div class="io">${io}</div>` : ""}
${extra} ${extra}
${me.action?.plot2 && (active || second) ? `<div class="small muted">Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}</div>` : ""} ${me.action?.plot2 && (active || second) ? `<div class="small muted">Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}</div>` : ""}
@ -1661,6 +1675,7 @@ document.addEventListener("click", (e) => {
if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; showThreat(); return; } if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; showThreat(); return; }
if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; } if ("menu" in d) { setMenu(!$("#app").classList.contains("open")); return; }
if (d.nav) { 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; page = d.nav; remember("page", page); selected = null;
history.replaceState(null, "", "#" + page); history.replaceState(null, "", "#" + page);
setMenu(false); setMenu(false);
@ -1716,6 +1731,7 @@ document.addEventListener("click", (e) => {
if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" }); if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" });
if (d.perk) return act("/api/perk", { perk: d.perk }); if (d.perk) return act("/api/perk", { perk: d.perk });
if (d.pin) return act("/api/pin", { skill: d.pin }); if (d.pin) return act("/api/pin", { skill: d.pin });
if (d.track) return act("/api/track", { id: d.track });
if (d.favour) return act("/api/favour", { kind: d.favour }); if (d.favour) return act("/api/favour", { kind: d.favour });
if (d.food) return act("/api/food", { item: d.food === "cheapest" ? null : d.food }); if (d.food) return act("/api/food", { item: d.food === "cheapest" ? null : d.food });
if (d.potion) return act("/api/potion", { item: d.potion }); if (d.potion) return act("/api/potion", { item: d.potion });

View file

@ -110,6 +110,14 @@ a { color: var(--xp); }
.chip.alarm { color: var(--bad); animation: alarm 1.2s ease-in-out infinite; } .chip.alarm { color: var(--bad); animation: alarm 1.2s ease-in-out infinite; }
@keyframes alarm { 50% { background: rgba(226, 100, 92, .25); border-color: var(--bad); } } @keyframes alarm { 50% { background: rgba(226, 100, 92, .25); border-color: var(--bad); } }
.chip .mini { width: 70px; } .chip .mini { width: 70px; }
#top { flex-wrap: wrap; }
.tracked { flex-basis: 100%; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding-top: 8px; border-top: 1px solid var(--line); font-size: 13.5px; }
.tracked-name { display: inline-flex; align-items: center; gap: 6px; background: none; border: 0; padding: 0;
font-weight: 650; cursor: pointer; }
.tracked-name .gi { width: 1.4em; height: 1.4em; }
.tracked.ready .tracked-name { color: var(--good); }
.tracked .pin { margin-left: auto; }
main { padding: 24px; max-width: 1200px; width: 100%; } main { padding: 24px; max-width: 1200px; width: 100%; }
.loading { color: var(--muted); } .loading { color: var(--muted); }
@ -183,10 +191,11 @@ textarea { width: 100%; resize: vertical; min-height: 60px; }
.skill-head .info { flex: 1; min-width: 0; } .skill-head .info { flex: 1; min-width: 0; }
.skill-head h2 { margin: 0; font-size: 22px; text-transform: none; letter-spacing: 0; color: var(--text); } .skill-head h2 { margin: 0; font-size: 22px; text-transform: none; letter-spacing: 0; color: var(--text); }
.skill-head .lv { font-variant-numeric: tabular-nums; color: var(--muted); margin: 2px 0 8px; } .skill-head .lv { font-variant-numeric: tabular-nums; color: var(--muted); margin: 2px 0 8px; }
.skill-head .pin { background: none; border: 0; padding: 0 4px; font-size: 16px; cursor: pointer; vertical-align: middle; .pin { background: none; border: 0; padding: 0 4px; font-size: 16px; cursor: pointer; vertical-align: middle;
filter: grayscale(1); opacity: .35; } filter: grayscale(1); opacity: .35; }
.skill-head .pin:hover { opacity: .7; } .pin:hover { opacity: .7; }
.skill-head .pin.on { filter: none; opacity: 1; } .pin.on { filter: none; opacity: 1; }
.action .top .pin { margin-left: auto; align-self: flex-start; font-size: 14px; }
.action { .action {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);

View file

@ -1525,6 +1525,27 @@ class PinTest(unittest.TestCase):
game.migrate(world, T0) game.migrate(world, T0)
self.assertEqual(world["players"]["fred"]["pinned"], []) self.assertEqual(world["players"]["fred"]["pinned"], [])
def test_track_toggles_one_recipe(self):
world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
recipes = [a["id"] for a in game.ACTIONS if a["inputs"]][:2]
self.assertIsNone(game.toggle_track(p, recipes[0]))
self.assertEqual(p["tracked"], recipes[0])
self.assertIsNone(game.toggle_track(p, recipes[1]))
self.assertEqual(p["tracked"], recipes[1])
self.assertIsNone(game.toggle_track(p, recipes[1]))
self.assertIsNone(p["tracked"])
gather = next(a["id"] for a in game.ACTIONS if not a["inputs"])
self.assertIsNotNone(game.toggle_track(p, gather))
self.assertIsNotNone(game.toggle_track(p, "juggling"))
def test_old_players_get_tracking(self):
world = world_with(("fred", "commoner"))
del world["players"]["fred"]["tracked"]
world["version"] = 21
game.migrate(world, T0)
self.assertIsNone(world["players"]["fred"]["tracked"])
class IconTest(unittest.TestCase): class IconTest(unittest.TestCase):
def test_everything_has_a_glyph_in_the_sprite(self): def test_everything_has_a_glyph_in_the_sprite(self):