From 4abf165857c55fc5fe4d0f721433da33cc71ee4d Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 27 Oct 2026 15:45:15 +0000 Subject: [PATCH] Crafting queue menu Co-Authored-By: Claude Opus 5.5 --- app.py | 13 +++++++++++++ game.py | 27 +++++++++++++++++++++++++++ static/app.js | 42 ++++++++++++++++++++++++++++++++++++++++-- static/styles.css | 16 ++++++++++++++++ tests/test_game.py | 19 +++++++++++++++++++ 5 files changed, 115 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index 4b90e66..7f00984 100644 --- a/app.py +++ b/app.py @@ -373,6 +373,19 @@ class Handler(BaseHTTPRequestHandler): if path == "/api/queue/remove": index = body.get("index") return game.queue_remove(player, index) if isinstance(index, int) else "Not in the queue" + if path == "/api/queue/move": + index, to = body.get("index"), body.get("to") + return game.queue_move(player, index, to) if isinstance(index, int) and isinstance(to, int) else "Not in the queue" + if path == "/api/queue/set": + index, qty = body.get("index"), body.get("qty") + if not isinstance(index, int) or not isinstance(qty, int): + return "Bad quantity" + return game.queue_set(player, index, qty) + if path == "/api/queue/clear": + player["queue"] = [] + return None + if path == "/api/queue/skip": + return game.skip_make(world, player, now) if path == "/api/join": return game.join_fight(world, player, str(body.get("id")), now) if path == "/api/press": diff --git a/game.py b/game.py index f80a63e..28bade8 100644 --- a/game.py +++ b/game.py @@ -3195,6 +3195,32 @@ def queue_remove(player: dict, index: int) -> str | None: return None +def queue_move(player: dict, index: int, to: int) -> str | None: + queue = player["queue"] + if not (0 <= index < len(queue) and 0 <= to < len(queue)): + return "Not in the queue" + queue.insert(to, queue.pop(index)) + return None + + +def queue_set(player: dict, index: int, qty: int) -> str | None: + if not 0 <= index < len(player["queue"]): + return "Not in the queue" + if not 1 <= qty <= MAKE_MAX: + return f"1 to {MAKE_MAX:,}" + player["queue"][index]["n"] = qty + return None + + +def skip_make(world: dict, player: dict, now: float) -> str | None: + """Drop the make that's running: on to the next in the queue, or back to what you were doing.""" + current = player["action"] + if not current or "left" not in current or "enemy" in ACTION_BY_ID[current["id"]]: + return "Not making anything" + finish_once(world, player, now) + return None + + def next_queued(world: dict, player: dict, at: float, summary: dict | None, skipped: dict | None = None) -> bool: """Start the next make in the queue (True), skipping any you can't make right now. `skipped` is a make that ran out of materials before its count was done.""" @@ -6196,6 +6222,7 @@ PATCH_NOTES = [ {"date": "2026-10-27", "title": "Prayer", "notes": [ ("fix", "+Q button: rounded corners."), ("fix", "Make box: number field sits in front of +Q."), + ("new", "Crafting queue menu: tap ⏳ in the top bar to reorder, change counts, skip or clear."), ("new", "Bury on the spot: bones from combat turns into prayer points and 1/2 bury XP as they drop. Toggle in the Prayer menu."), ("balance", "Prayer costs adjusted."), ("balance", "Prayer potions now relevant for new costs."), diff --git a/static/app.js b/static/app.js index b43e1b7..f39a581 100644 --- a/static/app.js +++ b/static/app.js @@ -20,6 +20,7 @@ let seenOrders = null; // order ids already known, so only new ones toast let deliveredSeen = {}; // how much of each order had arrived let earnedSeen = {}; // gold each order had paid you for reagents let pickedClass = null; // registration +let queueOpen = false; // the crafting queue panel under the top bar const tabs = {}; // skill -> selected recipe tab let refreshing = false; let refreshedFor = null; // action (start and length) a completion refresh was sent for @@ -768,10 +769,13 @@ function topbar() { let chips = ""; if (S.me?.action) { const action = C.actions.find((a) => a.id === (S.me.action.growing || S.me.action.id)); - chips += ``; + chips += ``; } else if (S.me) { chips += `Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}`; } + if (S.me?.action?.left || S.me?.queue.length) { + chips += ``; + } const fires = S.me ? Object.keys(C.fires).map((id) => [id, fireNow(id)]) : []; if (fires.some(([, n]) => n.heat >= 0)) { const tip = fires.map(([id, n]) => `${C.fires[id].name}: ${n.heat >= 0 ? `${C.flames[n.heat][0]}, ${dur(n.left / n.speed)}` : "out"}`).join("\n"); @@ -781,7 +785,30 @@ function topbar() { const w = S.happenings?.weather; if (w) chips += ``; if (S.upcoming) chips += ``; - return `

${esc(title)}

${chips}${S.me ? trackedBar() : ""}`; + return `

${esc(title)}

${chips}${S.me ? trackedBar() : ""}${S.me && queueOpen ? queuePanel() : ""}`; +} + +// The crafting queue, opened from the ⏳ chip: what's being made, what's next, and in what order. +function queuePanel() { + const me = S.me, q = me.queue, recipe = (id) => C.actions.find((x) => x.id === id); + const now = me.action?.left && recipe(me.action.id), back = me.action?.resume && recipe(me.action.resume); + const rows = q.map((e, i) => { + const a = recipe(e.id), why = me.blocked[e.id]; + return `
  • ${i + 1}${a.icon} + ${esc(a.name)}${why ? `${esc(why)}` : ""} + + + +
  • `; + }).join(""); + return `
    +
    ⏳ Crafting queue${q.length} / ${C.queue_max}
    + ${now ? `
    ${now.icon}${esc(now.name)}${fmt(me.action.left)} left +
    ` : ""} + ${rows ? `
      ${rows}
    ` : `

    Empty. +Q on a recipe adds to it.

    `} +
    Then ${back ? `back to ${esc(back.name.toLowerCase())}` : "idle"} + ${q.length ? `` : ""}
    +
    `; } function queueTip() { @@ -2806,6 +2833,7 @@ function loop() { // --- events --------------------------------------------------------------- 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]"); if (!t) return; const d = t.dataset; @@ -2851,6 +2879,10 @@ document.addEventListener("click", (e) => { if (d.once) return act("/api/action", { id: d.once, once: 1 }); if ("stop" in d) return act("/api/stop", {}); if (d.unqueue) return act("/api/queue/remove", { index: Number(d.unqueue) }); + if ("queueMenu" in d) { queueOpen = !queueOpen; return render(); } + if (d.queueMove) return act("/api/queue/move", { index: Number(d.queueMove), to: Number(d.to) }); + if ("queueSkip" in d) return act("/api/queue/skip", {}); + if ("queueClear" in d) return act("/api/queue/clear", {}); if (d.join) { tagging = true; return act("/api/join", { id: d.join }); } if ("orderNew" in d) { draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 }; @@ -2963,6 +2995,11 @@ document.addEventListener("input", (e) => { }); document.addEventListener("change", (e) => { + if (e.target.dataset.qset) { + const n = Math.floor(Number(e.target.value)); + if (!(n >= 1 && n <= C.make_max)) return toast(`1 to ${fmt(C.make_max)}`, "bad"); + return act("/api/queue/set", { index: Number(e.target.dataset.qset), qty: n }); + } if ("lookTitle" in e.target.dataset) return act("/api/look", { look: { ...S.me.look, title: e.target.value || null } }); const key = e.target.dataset.draft; if (!key || !draft) return; @@ -2987,6 +3024,7 @@ $("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.ta // Number keys press your abilities, in the bar's order (the ones you know). document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && queueOpen) { queueOpen = false; return render(); } if (e.target.matches("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return; const n = Number(e.key); if (!n) return; diff --git a/static/styles.css b/static/styles.css index 058f06f..d9dadc1 100644 --- a/static/styles.css +++ b/static/styles.css @@ -852,3 +852,19 @@ button.swatch { width: 36px; height: 36px; } .ach-line { margin: 10px 0 0; } .queue { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 4px; } .queue .x { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 0 0 0 4px; font-size: 11px; } +#top .queue-panel { position: absolute; top: calc(100% + 6px); right: 24px; z-index: 6; width: min(400px, calc(100vw - 32px)); + display: grid; gap: 8px; padding: 12px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); + box-shadow: 0 12px 32px rgba(0, 0, 0, .45); } +.qp-head, .qp-row, .qp-foot { display: flex; align-items: center; gap: 8px; } +.qp-head b { flex: 1; } +.qp-head .x { background: none; border: 0; color: var(--muted); cursor: pointer; font-size: 14px; } +.qp-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +.qp-row .gi { width: 1.4em; height: 1.4em; } +.qp-row.now { padding-bottom: 8px; border-bottom: 1px solid var(--line); } +.qp-pos { width: 1.2em; text-align: right; color: var(--muted); font-size: 12px; } +.qp-name { flex: 1; min-width: 0; display: grid; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.qp-row input { width: 56px; padding: 3px 6px; font-size: 13px; appearance: textfield; } +.qp-foot { justify-content: space-between; padding-top: 8px; border-top: 1px solid var(--line); } +.chip.on { border-color: var(--gold); } +.qp-name .why { color: var(--warn); } +.qp-row input::-webkit-inner-spin-button, .qp-row input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } diff --git a/tests/test_game.py b/tests/test_game.py index 2f1eb8d..ce3dbe1 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -152,6 +152,25 @@ class ActionTest(unittest.TestCase): game.drop_out(world, p, T0) self.assertEqual(p["queue"], []) + def test_managing_the_queue(self): + world, p = self._smith(20) + game.start_action(world, p, "smith_copper_helmet", T0, once=2) + for item in ("smith_copper_legs", "smith_copper_sword", "smith_copper_rivets"): + game.queue_add(world, p, item, 1, T0) + self.assertIsNone(game.queue_move(p, 2, 0)) + self.assertEqual([q["id"] for q in p["queue"]], ["smith_copper_rivets", "smith_copper_legs", "smith_copper_sword"]) + self.assertEqual(game.queue_move(p, 0, 3), "Not in the queue") + self.assertIsNone(game.queue_set(p, 1, 3)) + self.assertEqual(p["queue"][1]["n"], 3) + self.assertEqual(game.queue_set(p, 1, 0), f"1 to {game.MAKE_MAX:,}") + self.assertIsNone(game.skip_make(world, p, T0 + 5)) # the helmets: on to the rivets + self.assertEqual((p["action"]["id"], p["action"]["left"], p["action"]["resume"]), ("smith_copper_rivets", 1, "mine_stone")) + self.assertEqual(len(p["queue"]), 2) + p["queue"] = [] + game.skip_make(world, p, T0 + 6) # nothing left: back to work + self.assertEqual(p["action"]["id"], "mine_stone") + self.assertEqual(game.skip_make(world, p, T0 + 7), "Not making anything") + def test_offline_cap(self): world = world_with(("fred", "commoner")) p = world["players"]["fred"]