Crafting queue menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-27 15:45:15 +00:00
parent 77fa8ad3e6
commit 4abf165857
5 changed files with 115 additions and 2 deletions

13
app.py
View file

@ -373,6 +373,19 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/queue/remove": if path == "/api/queue/remove":
index = body.get("index") index = body.get("index")
return game.queue_remove(player, index) if isinstance(index, int) else "Not in the queue" 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": if path == "/api/join":
return game.join_fight(world, player, str(body.get("id")), now) return game.join_fight(world, player, str(body.get("id")), now)
if path == "/api/press": if path == "/api/press":

27
game.py
View file

@ -3195,6 +3195,32 @@ def queue_remove(player: dict, index: int) -> str | None:
return 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: 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. """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.""" `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": [ {"date": "2026-10-27", "title": "Prayer", "notes": [
("fix", "+Q button: rounded corners."), ("fix", "+Q button: rounded corners."),
("fix", "Make box: number field sits in front of +Q."), ("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."), ("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 costs adjusted."),
("balance", "Prayer potions now relevant for new costs."), ("balance", "Prayer potions now relevant for new costs."),

View file

@ -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 deliveredSeen = {}; // how much of each order had arrived
let earnedSeen = {}; // gold each order had paid you for reagents let earnedSeen = {}; // gold each order had paid you for reagents
let pickedClass = null; // registration let pickedClass = null; // registration
let queueOpen = false; // the crafting queue panel under the top bar
const tabs = {}; // skill -> selected recipe tab const tabs = {}; // skill -> selected recipe tab
let refreshing = false; let refreshing = false;
let refreshedFor = null; // action (start and length) a completion refresh was sent for let refreshedFor = null; // action (start and length) a completion refresh was sent for
@ -768,10 +769,13 @@ function topbar() {
let chips = ""; let chips = "";
if (S.me?.action) { if (S.me?.action) {
const action = C.actions.find((a) => a.id === (S.me.action.growing || S.me.action.id)); const action = C.actions.find((a) => a.id === (S.me.action.growing || S.me.action.id));
chips += `<button class="chip act" data-nav="skill:${action.skill}">${skillOf(action.skill).icon} <span class="label">${esc(action.name)}</span>${S.me.queue.length ? `<span class="small muted" title="${esc(queueTip())}">+${S.me.queue.length}</span>` : ""}<span class="bar mini ${S.me.action.fight ? "foe" : ""}"><span data-progress></span></span></button>`; chips += `<button class="chip act" data-nav="skill:${action.skill}">${skillOf(action.skill).icon} <span class="label">${esc(action.name)}</span><span class="bar mini ${S.me.action.fight ? "foe" : ""}"><span data-progress></span></span></button>`;
} else if (S.me) { } else if (S.me) {
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`; chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
} }
if (S.me?.action?.left || S.me?.queue.length) {
chips += `<button class="chip ${queueOpen ? "on" : ""}" data-queue-menu title="Crafting queue">⏳ ${S.me.queue.length}</button>`;
}
const fires = S.me ? Object.keys(C.fires).map((id) => [id, fireNow(id)]) : []; const fires = S.me ? Object.keys(C.fires).map((id) => [id, fireNow(id)]) : [];
if (fires.some(([, n]) => n.heat >= 0)) { 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"); 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; 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}${S.me ? trackedBar() : ""}`; return `<button class="menu-btn" data-menu aria-label="Menu">☰</button><h1>${esc(title)}</h1>${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 `<li class="qp-row"><span class="qp-pos">${i + 1}</span>${a.icon}
<span class="qp-name">${esc(a.name)}${why ? `<span class="why small" title="Skipped if still short when its turn comes">${esc(why)}</span>` : ""}</span>
<input type="number" min="1" max="${C.make_max}" value="${e.n}" data-qset="${i}" data-keep="qset-${i}-${e.id}" title="How many">
<button class="btn sm" data-queue-move="${i}" data-to="${i - 1}" ${i ? "" : "disabled"} title="Up">▲</button>
<button class="btn sm" data-queue-move="${i}" data-to="${i + 1}" ${i < q.length - 1 ? "" : "disabled"} title="Down">▼</button>
<button class="btn sm danger" data-unqueue="${i}" title="Remove">✕</button></li>`;
}).join("");
return `<div class="queue-panel">
<div class="qp-head"><b>⏳ Crafting queue</b><span class="muted small">${q.length} / ${C.queue_max}</span><button class="x" data-queue-menu title="Close">✕</button></div>
${now ? `<div class="qp-row now">${now.icon}<span class="qp-name">${esc(now.name)}<span class="muted small">${fmt(me.action.left)} left</span></span>
<button class="btn sm" data-queue-skip title="Drop this make, on to the next">Skip</button></div>` : ""}
${rows ? `<ol class="qp-list">${rows}</ol>` : `<p class="muted small">Empty. +Q on a recipe adds to it.</p>`}
<div class="qp-foot"><span class="muted small">Then ${back ? `back to ${esc(back.name.toLowerCase())}` : "idle"}</span>
${q.length ? `<button class="btn sm" data-queue-clear>Clear</button>` : ""}</div>
</div>`;
} }
function queueTip() { function queueTip() {
@ -2806,6 +2833,7 @@ function loop() {
// --- events --------------------------------------------------------------- // --- events ---------------------------------------------------------------
document.addEventListener("click", (e) => { 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]");
if (!t) return; if (!t) return;
const d = t.dataset; 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 (d.once) return act("/api/action", { id: d.once, once: 1 });
if ("stop" in d) return act("/api/stop", {}); if ("stop" in d) return act("/api/stop", {});
if (d.unqueue) return act("/api/queue/remove", { index: Number(d.unqueue) }); 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 (d.join) { tagging = true; return act("/api/join", { id: d.join }); }
if ("orderNew" in d) { if ("orderNew" in d) {
draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 }; draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 };
@ -2963,6 +2995,11 @@ document.addEventListener("input", (e) => {
}); });
document.addEventListener("change", (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 } }); 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; const key = e.target.dataset.draft;
if (!key || !draft) return; 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). // Number keys press your abilities, in the bar's order (the ones you know).
document.addEventListener("keydown", (e) => { 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; if (e.target.matches("input, textarea, select") || e.ctrlKey || e.metaKey || e.altKey) return;
const n = Number(e.key); const n = Number(e.key);
if (!n) return; if (!n) return;

View file

@ -852,3 +852,19 @@ button.swatch { width: 36px; height: 36px; }
.ach-line { margin: 10px 0 0; } .ach-line { margin: 10px 0 0; }
.queue { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin-top: 4px; } .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; } .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; }

View file

@ -152,6 +152,25 @@ class ActionTest(unittest.TestCase):
game.drop_out(world, p, T0) game.drop_out(world, p, T0)
self.assertEqual(p["queue"], []) 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): def test_offline_cap(self):
world = world_with(("fred", "commoner")) world = world_with(("fred", "commoner"))
p = world["players"]["fred"] p = world["players"]["fred"]