diff --git a/app.py b/app.py index 6d81e58..a616e49 100644 --- a/app.py +++ b/app.py @@ -413,6 +413,8 @@ class Handler(BaseHTTPRequestHandler): return game.buy_perk(player, str(body.get("perk"))) if path == "/api/pin": 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 body.get("overflow") not in ("city", "stop"): return "Bad setting" diff --git a/game.py b/game.py index 6c96772..b0adba1 100644 --- a/game.py +++ b/game.py @@ -1090,7 +1090,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 21 +WORLD_VERSION = 22 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: player["equip"].setdefault(slot, None) world["version"] = 21 + if world["version"] < 22: + for player in world["players"].values(): + player.setdefault("tracked", None) + world["version"] = 22 return world @@ -1513,6 +1517,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict: "garrison": False, "donated": 0, "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 +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 ------------------------------------------------------------------------ # 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 diff --git a/static/app.js b/static/app.js index eb63973..bcbf497 100644 --- a/static/app.js +++ b/static/app.js @@ -384,7 +384,21 @@ function topbar() { const w = S.happenings?.weather; if (w) chips += ``; if (S.upcoming) chips += ``; - return `

${esc(title)}

${chips}`; + return `

${esc(title)}

${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 `
+ + ${Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")} + ${go ? `` : ""} + +
`; } function eventIcon(kind) { @@ -799,7 +813,7 @@ function actionCard(a) {
${icon}
${esc(a.name)}
Level ${a.level} · ${dur(time)} · ${a.xp} XP
-
+ ${Object.keys(a.inputs).length ? `` : ""} ${io ? `
${io}
` : ""} ${extra} ${me.action?.plot2 && (active || second) ? `
Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}
` : ""} @@ -1661,6 +1675,7 @@ document.addEventListener("click", (e) => { 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); @@ -1716,6 +1731,7 @@ document.addEventListener("click", (e) => { if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" }); if (d.perk) return act("/api/perk", { perk: d.perk }); 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.food) return act("/api/food", { item: d.food === "cheapest" ? null : d.food }); if (d.potion) return act("/api/potion", { item: d.potion }); diff --git a/static/styles.css b/static/styles.css index 269b756..1340786 100644 --- a/static/styles.css +++ b/static/styles.css @@ -110,6 +110,14 @@ a { color: var(--xp); } .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); } } .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%; } .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 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 .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; } -.skill-head .pin:hover { opacity: .7; } -.skill-head .pin.on { filter: none; opacity: 1; } +.pin:hover { opacity: .7; } +.pin.on { filter: none; opacity: 1; } +.action .top .pin { margin-left: auto; align-self: flex-start; font-size: 14px; } .action { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); diff --git a/tests/test_game.py b/tests/test_game.py index b877619..944a668 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1525,6 +1525,27 @@ class PinTest(unittest.TestCase): game.migrate(world, T0) 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): def test_everything_has_a_glyph_in_the_sprite(self):