Queue: +Q from anything, After Q / Skip Q

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-27 20:01:00 +00:00
parent 54f703341e
commit 6e48c72d95
5 changed files with 93 additions and 15 deletions

4
app.py
View file

@ -364,7 +364,7 @@ class Handler(BaseHTTPRequestHandler):
once = body.get("once") or 0 once = body.get("once") or 0
if not isinstance(once, int) or not 0 <= once <= game.MAKE_MAX: if not isinstance(once, int) or not 0 <= once <= game.MAKE_MAX:
return f"1 to {game.MAKE_MAX:,}" return f"1 to {game.MAKE_MAX:,}"
return game.start_action(world, player, str(body.get("id")), now, once=int(once)) return game.start_action(world, player, str(body.get("id")), now, once=int(once), skip=body.get("skip") is True)
if path == "/api/queue": if path == "/api/queue":
qty = body.get("qty") qty = body.get("qty")
if not isinstance(qty, int) or not 1 <= qty <= game.MAKE_MAX: if not isinstance(qty, int) or not 1 <= qty <= game.MAKE_MAX:
@ -384,6 +384,8 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/queue/clear": if path == "/api/queue/clear":
player["queue"] = [] player["queue"] = []
return None return None
if path == "/api/queue/then":
return game.set_then(player, None)
if path == "/api/queue/skip": if path == "/api/queue/skip":
return game.skip_make(world, player, now) return game.skip_make(world, player, now)
if path == "/api/join": if path == "/api/join":

23
game.py
View file

@ -3225,6 +3225,19 @@ def queue_set(player: dict, index: int, qty: int) -> str | None:
return None return None
def set_then(player: dict, action_id: str | None) -> str | None:
"""What to go back to once the make and the queue are done: an action, or None to go idle."""
current = player["action"]
if not current or "left" not in current:
return "Not making anything"
current.pop("resume_plot2", None)
if action_id:
current["resume"] = action_id
else:
current.pop("resume", None)
return None
def skip_make(world: dict, player: dict, now: float) -> str | 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.""" """Drop the make that's running: on to the next in the queue, or back to what you were doing."""
current = player["action"] current = player["action"]
@ -3951,8 +3964,10 @@ MAKE_MAX = 1000 # most you can ask for in one "Make X"
QUEUE_MAX = 5 # makes lined up after the current one QUEUE_MAX = 5 # makes lined up after the current one
def start_action(world: dict, player: dict, action_id: str, now: float, once: int = 0) -> str | None: def start_action(world: dict, player: dict, action_id: str, now: float, once: int = 0, skip: bool = False) -> str | None:
"""Start an action. `once` does it that many times, then goes back to what you were doing.""" """Start an action. `once` does it that many times, then goes back to what you were doing.
While a make is running, a plain start is what comes after it and the queue, not instead,
unless `skip`: then the make and the queue go and it starts now."""
action = ACTION_BY_ID.get(action_id) action = ACTION_BY_ID.get(action_id)
if not action: if not action:
return "No such action" return "No such action"
@ -3961,6 +3976,9 @@ def start_action(world: dict, player: dict, action_id: str, now: float, once: in
return reason return reason
entry = {"id": action_id, "start": now} entry = {"id": action_id, "start": now}
current = player["action"] current = player["action"]
if not once and not skip and current and "left" in current:
set_then(player, action_id)
return None
if once: if once:
back = current and (current.get("resume") or current["id"]) back = current and (current.get("resume") or current["id"])
entry["left"] = int(once) entry["left"] = int(once)
@ -6244,6 +6262,7 @@ PATCH_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", "Crafting queue menu: tap ⏳ in the top bar to reorder, change counts, skip or clear."),
("new", "Updated crafting queue to be more intuitive"),
("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

@ -806,7 +806,7 @@ function queuePanel() {
${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> ${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>` : ""} <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>`} ${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> <div class="qp-foot"><span class="muted small qp-then">After queue: ${back ? `${esc(back.name)}${now ? `<button class="x" data-queue-then title="Idle after the queue">✕</button>` : ""}` : "idle"}</span>
${q.length ? `<button class="btn sm" data-queue-clear>Clear</button>` : ""}</div> ${q.length ? `<button class="btn sm" data-queue-clear>Clear</button>` : ""}</div>
</div>`; </div>`;
} }
@ -825,6 +825,15 @@ function queueRow() {
}).join(`<span class="arrow">→</span>`)}</div>`; }).join(`<span class="arrow">→</span>`)}</div>`;
} }
// Start, or while a make runs: After Q sets what comes once it and the queue are done,
// and on that one, Skip Q drops them and starts it now.
function startButton(a, size, why) {
const making = S.me.action?.left, after = making && S.me.action.resume === a.id;
const [label, title] = !making ? ["Start", ""] : after ? ["Skip Q", "Skip queue: drop the make and queue, start this now"]
: ["After Q", "After queue: do this once the make and queue are done"];
return `<button class="btn ${size} primary" data-start="${a.id}" ${after ? "data-skip" : ""} ${why ? "disabled" : ""} ${title ? `title="${title}"` : ""}>${label}</button>`;
}
// The recipe you're after, under the top bar on every page: have / need for each input. // The recipe you're after, under the top bar on every page: have / need for each input.
function trackedBar() { function trackedBar() {
const me = S.me, a = me.tracked && C.actions.find((x) => x.id === me.tracked); const me = S.me, a = me.tracked && C.actions.find((x) => x.id === me.tracked);
@ -834,7 +843,7 @@ function trackedBar() {
return `<div class="tracked ${ready ? "ready" : ""}"> 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> <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">${inputChips(a)}</span> <span class="io">${inputChips(a)}</span>
${go ? `<button class="btn sm primary" data-start="${a.id}">Start</button>` : ""} ${go ? startButton(a, "sm") : ""}
<button class="pin on" data-track="${a.id}" title="Untrack">📌</button> <button class="pin on" data-track="${a.id}" title="Untrack">📌</button>
</div>`; </div>`;
} }
@ -1715,8 +1724,13 @@ function actionCard(a) {
&& me.action && C.actions.find((x) => x.id === me.action.id)?.skill === "farming" && !me.action.left; && me.action && C.actions.find((x) => x.id === me.action.id)?.skill === "farming" && !me.action.left;
const why = me.blocked[a.id]; const why = me.blocked[a.id];
const locked = isLocked(a); const locked = isLocked(a);
// Queue it behind the make that's running: materials only have to be there when its turn comes. // Queue it behind the make that's running (materials only have to be there when its turn comes),
const canQueue = me.action?.left && !active && me.queue_why[a.id] === null; // or with nothing being made, start one: whatever you were doing is what comes after.
const making = Boolean(me.action?.left);
const canQueue = !active && me.queue_why[a.id] === null;
const queueOff = making ? me.queue.length >= C.queue_max : Boolean(why);
const queueTitle = making ? (queueOff ? `Queue full (${C.queue_max})` : "Queue: make this many after the current make")
: why || "Make this many, then queue more behind it";
const queued = me.queue.map((q, i) => q.id === a.id ? i + 1 : 0).filter(Boolean); const queued = me.queue.map((q, i) => q.id === a.id ? i + 1 : 0).filter(Boolean);
const out = Object.keys(a.outputs)[0]; const out = Object.keys(a.outputs)[0];
const icon = a.icon; const icon = a.icon;
@ -1786,11 +1800,11 @@ function actionCard(a) {
${second ? `<button class="btn" data-plot="">Clear plot 2</button>` ${second ? `<button class="btn" data-plot="">Clear plot 2</button>`
: active ? `<button class="btn danger" data-stop>Stop</button>` : active ? `<button class="btn danger" data-stop>Stop</button>`
: `<span class="btns">${!a.enemy && Object.keys(a.outputs).length : `<span class="btns">${!a.enemy && Object.keys(a.outputs).length
? `<form data-form="make" data-id="${a.id}" class="make" title="Make this many, then resume"><button class="btn" ${why ? "disabled" : ""}>Make</button><input type="number" min="1" max="${C.make_max}" value="1" data-keep="make-${a.id}" ${why && !canQueue ? "disabled" : ""}>${canQueue ? `<form data-form="make" data-id="${a.id}" class="make" title="Make this many, then resume"><button class="btn" ${why ? "disabled" : ""}>Make</button><input type="number" min="1" max="${C.make_max}" value="1" data-keep="make-${a.id}" ${why && (!canQueue || queueOff) ? "disabled" : ""}>${canQueue
? `<button class="btn q" data-q ${me.queue.length >= C.queue_max ? `disabled title="Queue full (${C.queue_max})"` : `title="Queue: make this many after the current make"`}>+Q</button>` : ""}</form>` ? `<button class="btn q" data-q ${queueOff ? "disabled" : ""} title="${esc(queueTitle)}">+Q</button>` : ""}</form>`
: `<button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Once, then resume">${a.enemy ? "Fight once" : "Once"}</button>`} : `<button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Once, then resume">${a.enemy ? "Fight once" : "Once"}</button>`}
${canPlot ? `<button class="btn" data-plot="${a.id}" ${why ? "disabled" : ""} title="Grow in plot 2">Plot 2</button>` : ""} ${canPlot ? `<button class="btn" data-plot="${a.id}" ${why ? "disabled" : ""} title="Grow in plot 2">Plot 2</button>` : ""}
<button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button></span>`} ${startButton(a, "", why)}</span>`}
</div> </div>
</div>`; </div>`;
} }
@ -2876,7 +2890,15 @@ document.addEventListener("click", (e) => {
animateFight(serverNow()); // the outline moves now; the server hears about it in the background animateFight(serverNow()); // the outline moves now; the server hears about it in the background
return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim }); return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim });
} }
if (d.start) return act("/api/action", { id: d.start }); if (d.start) {
// While a make runs, Start sets what comes after it and the queue; Skip Q starts it now.
if ("skip" in d) return act("/api/action", { id: d.start, skip: true });
const making = S.me?.action?.left;
return act("/api/action", { id: d.start }).then(() => {
if (making && S.me.action?.resume === d.start) toast(`After queue: ${C.actions.find((x) => x.id === d.start).name}`);
});
}
if ("queueThen" in d) return act("/api/queue/then", {});
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) });

View file

@ -868,3 +868,5 @@ button.swatch { width: 36px; height: 36px; }
.chip.on { border-color: var(--gold); } .chip.on { border-color: var(--gold); }
.qp-name .why { color: var(--warn); } .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; } .qp-row input::-webkit-inner-spin-button, .qp-row input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.qp-then { display: inline-flex; align-items: center; gap: 4px; }
.qp-then .x { background: none; border: 0; color: var(--muted); cursor: pointer; font-size: 11px; padding: 0 2px; }

View file

@ -145,12 +145,45 @@ class ActionTest(unittest.TestCase):
self.assertTrue(game.queue_add(world, p, high, 1, T0).startswith("Needs level")) self.assertTrue(game.queue_add(world, p, high, 1, T0).startswith("Needs level"))
self.assertIsNone(game.queue_remove(p, 0)) self.assertIsNone(game.queue_remove(p, 0))
self.assertEqual(len(p["queue"]), game.QUEUE_MAX - 1) self.assertEqual(len(p["queue"]), game.QUEUE_MAX - 1)
game.start_action(world, p, "mine_stone", T0)
self.assertEqual(p["queue"], [])
game.start_action(world, p, "smith_copper_helmet", T0, once=1)
game.queue_add(world, p, "smith_copper_legs", 1, T0)
game.drop_out(world, p, T0) game.drop_out(world, p, T0)
self.assertEqual(p["queue"], []) self.assertEqual(p["queue"], [])
game.start_action(world, p, "cut_logs", T0)
game.queue_add(world, p, "smith_copper_legs", 1, T0)
game.start_action(world, p, "mine_stone", T0) # no make running: starts now
self.assertEqual((p["action"]["id"], p["queue"]), ("mine_stone", []))
def test_start_during_a_make_sets_what_comes_after(self):
world, p = self._smith(20)
p["bank"]["logs"] = 5
game.start_action(world, p, "cut_logs", T0)
self.assertIsNone(game.queue_add(world, p, "smith_copper_helmet", 1, T0)) # on an endless action: starts it
self.assertEqual((p["action"]["id"], p["action"]["resume"]), ("smith_copper_helmet", "cut_logs"))
game.queue_add(world, p, "smith_copper_legs", 1, T0)
self.assertIsNone(game.start_action(world, p, "mine_stone", T0))
self.assertEqual((p["action"]["id"], p["action"]["resume"]), ("smith_copper_helmet", "mine_stone"))
self.assertEqual(len(p["queue"]), 1) # the queue stays
summary = game.process_player(world, p, T0 + 500, random.Random(1))
self.assertEqual((p["bank"].get("copper_helmet"), p["bank"].get("copper_legs")), (1, 1))
self.assertEqual((p["action"]["id"], summary["resumed"]), ("mine_stone", "mine_stone"))
def test_skip_queue_starts_now(self):
world, p = self._smith(20)
game.start_action(world, p, "smith_copper_helmet", T0, once=2)
game.queue_add(world, p, "smith_copper_legs", 1, T0)
game.start_action(world, p, "cut_logs", T0)
self.assertIsNone(game.start_action(world, p, "cut_logs", T0 + 5, skip=True))
self.assertEqual((p["action"], p["queue"]), ({"id": "cut_logs", "start": T0 + 5}, []))
def test_then_idle(self):
world, p = self._smith(20)
game.start_action(world, p, "smith_copper_helmet", T0, once=1)
self.assertEqual(p["action"]["resume"], "mine_stone")
self.assertIsNone(game.set_then(p, "cut_logs"))
self.assertEqual(p["action"]["resume"], "cut_logs")
self.assertIsNone(game.set_then(p, None))
game.process_player(world, p, T0 + 500, random.Random(1))
self.assertIsNone(p["action"])
self.assertEqual(game.set_then(p, None), "Not making anything")
def test_managing_the_queue(self): def test_managing_the_queue(self):
world, p = self._smith(20) world, p = self._smith(20)