Queue: tend all until full

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-28 14:39:21 +00:00
parent e981e89e6c
commit 9daa648017
3 changed files with 43 additions and 12 deletions

20
game.py
View file

@ -3329,7 +3329,8 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
burn_fires(world, current["start"] + duration) burn_fires(world, current["start"] + duration)
reason = can_start(world, player, action) reason = can_start(world, player, action)
if reason: if reason:
if next_queued(world, player, current["start"], summary, skipped=current) or \ if fills(action) and "left" in current and finish_once(world, player, current["start"], reason, summary) or \
next_queued(world, player, current["start"], summary, skipped=current) or \
"order" in current and finish_once(world, player, current["start"], reason, summary): "order" in current and finish_once(world, player, current["start"], reason, summary):
current = player["action"] current = player["action"]
continue continue
@ -3346,7 +3347,7 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
current["start"] += duration current["start"] += duration
if current.get("plot2"): if current.get("plot2"):
current["turn"] = 1 - current.get("turn", 0) current["turn"] = 1 - current.get("turn", 0)
if player["action"] is current and "left" in current: if player["action"] is current and "left" in current and not fills(action):
current["left"] -= 1 current["left"] -= 1
if current["left"] <= 0 and finish_once(world, player, current["start"], summary=summary): if current["left"] <= 0 and finish_once(world, player, current["start"], summary=summary):
current = player["action"] current = player["action"]
@ -3354,7 +3355,8 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
# Out of materials: stop now, not once the next bar has filled. # Out of materials: stop now, not once the next bar has filled.
reason = can_start(world, player, growing(player)) reason = can_start(world, player, growing(player))
if reason: if reason:
if next_queued(world, player, current["start"], summary, skipped=current) or \ if fills(action) and "left" in current and finish_once(world, player, current["start"], reason, summary) or \
next_queued(world, player, current["start"], summary, skipped=current) or \
"order" in current and finish_once(world, player, current["start"], reason, summary): "order" in current and finish_once(world, player, current["start"], reason, summary):
current = player["action"] current = player["action"]
continue continue
@ -3373,15 +3375,20 @@ def stop(player: dict, reason: str | None = None) -> None:
def queueable(action: dict) -> bool: def queueable(action: dict) -> bool:
"""Crafting and gathering: makes or gathers something. Only these can be queued.""" """Crafting, gathering and tending. Only these can be queued."""
return bool(action["outputs"]) and not ( return fills(action) or bool(action["outputs"]) and not (
action.get("enemy") or action.get("special") or action.get("together")) action.get("enemy") or action.get("special") or action.get("together"))
def fills(action: dict) -> bool:
"""A queued tend has no count: it runs until your pile is full, then on with the queue."""
return action.get("special") == "tend"
def queue_reason(world: dict, player: dict, action: dict) -> str | None: def queue_reason(world: dict, player: dict, action: dict) -> str | None:
"""Why it can't go in the queue. Materials are checked when its turn comes.""" """Why it can't go in the queue. Materials are checked when its turn comes."""
if not queueable(action): if not queueable(action):
return "Only crafting and gathering can be queued" return "Only crafting, gathering and tending can be queued"
if skill_level(player, action["skill"]) < action["level"]: if skill_level(player, action["skill"]) < action["level"]:
return f"Needs level {action['level']}" return f"Needs level {action['level']}"
if action.get("pattern") and action["pattern"] not in player.get("patterns", []): if action.get("pattern") and action["pattern"] not in player.get("patterns", []):
@ -6747,6 +6754,7 @@ ORDER_RECIPES, ORDER_RAW = _order_sources()
# line here (new / balance / fix), and a new release a new entry on top. # line here (new / balance / fix), and a new release a new entry on top.
PATCH_NOTES = [ PATCH_NOTES = [
{"date": "2026-10-28", "title": "Queue", "notes": [ {"date": "2026-10-28", "title": "Queue", "notes": [
("new", "Firekeeping: +Q next to 🔥 Tend all queues tending until your piles are full, then the queue moves on."),
("new", "City work orders: 🏘️ City when posting, or 🔨 on anything a building needs (goes straight into its fund). Anyone can craft them, several at once. Reagents earn their worth in favour, the maker the rest."), ("new", "City work orders: 🏘️ City when posting, or 🔨 on anything a building needs (goes straight into its fund). Anyone can craft them, several at once. Reagents earn their worth in favour, the maker the rest."),
("new", "Work orders: no size cap (was 500 crafted / 2,000 materials)."), ("new", "Work orders: no size cap (was 500 crafted / 2,000 materials)."),
("new", "Work orders: no cap on open orders (was 5)."), ("new", "Work orders: no cap on open orders (was 5)."),

View file

@ -800,7 +800,8 @@ function queuePanel() {
const a = recipe(e.id), why = me.blocked[e.id]; const a = recipe(e.id), why = me.blocked[e.id];
return `<li class="qp-row"><span class="qp-pos">${i + 1}</span>${a.icon} 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> <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"> ${fills(a) ? `<span class="muted small" title="${esc(`${item(Object.keys(a.inputs)[0]).name} until your piles are full`)}">full</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">`}
${i || !now ? `<button class="btn sm" data-queue-move="${i}" data-to="${i - 1}" ${i ? "" : "disabled"} title="Up">▲</button>` ${i || !now ? `<button class="btn sm" data-queue-move="${i}" data-to="${i - 1}" ${i ? "" : "disabled"} title="Up">▲</button>`
: `<button class="btn sm" data-queue-now="0" title="Swap with current">▲</button>`} : `<button class="btn sm" data-queue-now="0" title="Swap with current">▲</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" data-queue-move="${i}" data-to="${i + 1}" ${i < q.length - 1 ? "" : "disabled"} title="Down">▼</button>
@ -808,7 +809,7 @@ function queuePanel() {
}).join(""); }).join("");
return `<div class="queue-panel"> return `<div class="queue-panel">
<div class="qp-head"><b>⏳ Queue</b><span class="muted small">${q.length}</span><button class="x" data-queue-menu title="Close">✕</button></div> <div class="qp-head"><b>⏳ Queue</b><span class="muted small">${q.length}</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> ${now ? `<div class="qp-row now">${now.icon}<span class="qp-name">${esc(now.name)}<span class="muted small">${fills(now) ? "until full" : `${fmt(me.action.left)} left`}</span></span>
<button class="btn sm" data-queue-now="0" ${q.length ? "" : "disabled"} title="Swap with next">▼</button> <button class="btn sm" data-queue-now="0" ${q.length ? "" : "disabled"} title="Swap with next">▼</button>
<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 or gathering adds to it.</p>`} ${rows ? `<ol class="qp-list">${rows}</ol>` : `<p class="muted small">Empty. +Q on a recipe or gathering adds to it.</p>`}
@ -817,8 +818,12 @@ function queuePanel() {
</div>`; </div>`;
} }
// A queued tend has no count: it runs until your piles are full.
const fills = (a) => a.special === "tend";
const queueCount = (a, n) => fills(a) ? "full" : `×${fmt(n)}`;
function queueTip() { function queueTip() {
return `Queued: ${S.me.queue.map((q) => `${C.actions.find((x) => x.id === q.id).name} ×${fmt(q.n)}`).join(" → ")}`; return `Queued: ${S.me.queue.map((q) => { const a = C.actions.find((x) => x.id === q.id); return `${a.name} ${queueCount(a, q.n)}`; }).join(" → ")}`;
} }
// Makes lined up after this one: tap ✕ to drop one. // Makes lined up after this one: tap ✕ to drop one.
@ -827,7 +832,7 @@ function queueRow() {
if (!q.length) return ""; if (!q.length) return "";
return `<div class="queue small">⏳ ${q.map((e, i) => { return `<div class="queue small">⏳ ${q.map((e, i) => {
const a = C.actions.find((x) => x.id === e.id); const a = C.actions.find((x) => x.id === e.id);
return `<span class="item-chip" title="${esc(a.name)}">${a.icon} ×${fmt(e.n)}<button class="x" data-unqueue="${i}" title="Remove">✕</button></span>`; return `<span class="item-chip" title="${esc(a.name)}">${a.icon} ${queueCount(a, e.n)}<button class="x" data-unqueue="${i}" title="Remove">✕</button></span>`;
}).join(`<span class="arrow">→</span>`)}</div>`; }).join(`<span class="arrow">→</span>`)}</div>`;
} }
@ -1165,6 +1170,7 @@ function tendAllTarget() {
function firekeepingPage() { function firekeepingPage() {
const me = S.me, fk = me.firekeeping, pick = pickFirewood(), speed = Object.values(S.city.fires)[0].speed; const me = S.me, fk = me.firekeeping, pick = pickFirewood(), speed = Object.values(S.city.fires)[0].speed;
const all = `tend_all_${C.firewood[pick]}`, tendingAll = me.action?.id.startsWith("tend_all_"), allWhy = me.blocked[all]; const all = `tend_all_${C.firewood[pick]}`, tendingAll = me.action?.id.startsWith("tend_all_"), allWhy = me.blocked[all];
const making = Boolean(me.action?.left), qWhy = me.queue_why[all] || (!making && allWhy);
const perLog = (tier) => +(C.tend_xp[tier] * fk.minutes).toFixed(1); const perLog = (tier) => +(C.tend_xp[tier] * fk.minutes).toFixed(1);
const logs = C.firewood.map((log, tier) => { const logs = C.firewood.map((log, tier) => {
const level = tendLevel(tier), locked = me.levels.firekeeping < level; const level = tendLevel(tier), locked = me.levels.firekeeping < level;
@ -1175,7 +1181,8 @@ function firekeepingPage() {
<span class="tag" title="Fire and XP per log">${glyph("stopwatch", "#98a1b4")} ${dur(fk.minutes * 60 / speed)} · ${perLog(pick)} XP</span> <span class="tag" title="Fire and XP per log">${glyph("stopwatch", "#98a1b4")} ${dur(fk.minutes * 60 / speed)} · ${perLog(pick)} XP</span>
<span class="tag" title="Most fuel you can bank in a fire">${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)}</span> <span class="tag" title="Most fuel you can bank in a fire">${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)}</span>
${tendingAll ? `<button class="btn danger" data-stop>Stop</button>` ${tendingAll ? `<button class="btn danger" data-stop>Stop</button>`
: `<button class="btn primary" data-start="${all}" ${allWhy ? "disabled" : ""} title="${esc(allWhy || "Each log goes on the lowest fire with room in your pile")}">🔥 Tend all</button>`}</div> : `<button class="btn primary" data-start="${all}" ${allWhy ? "disabled" : ""} title="${esc(allWhy || "Each log goes on the lowest fire with room in your pile")}">🔥 Tend all</button>`}
<button class="btn q" data-queue-add="${all}" ${qWhy ? "disabled" : ""} title="${esc(qWhy || (making ? "Queue: tend all until full after the current make" : "Tend all until full, then back to what you were doing"))}">+Q</button></div>
<div class="fire-grid">${Object.keys(C.fires).map((id) => fireCard(id, pick, tendAllTarget())).join("")}</div> <div class="fire-grid">${Object.keys(C.fires).map((id) => fireCard(id, pick, tendAllTarget())).join("")}</div>
<div class="section-title">Kindling</div> <div class="section-title">Kindling</div>
<div class="grid">${C.actions.filter((a) => a.skill === "firekeeping" && a.group === "Kindling").map(actionCard).join("")}</div>`; <div class="grid">${C.actions.filter((a) => a.skill === "firekeeping" && a.group === "Kindling").map(actionCard).join("")}</div>`;
@ -3079,6 +3086,7 @@ 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 (d.queueAdd) return act("/api/queue", { id: d.queueAdd, qty: 1 });
if ("queueMenu" in d) { queueOpen = !queueOpen; return render(); } 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 (d.queueMove) return act("/api/queue/move", { index: Number(d.queueMove), to: Number(d.to) });
if (d.queueNow) return act("/api/queue/now", { index: Number(d.queueNow) }); if (d.queueNow) return act("/api/queue/now", { index: Number(d.queueNow) });

View file

@ -142,7 +142,7 @@ class ActionTest(unittest.TestCase):
def test_queue_rules(self): def test_queue_rules(self):
world, p = self._smith(4) world, p = self._smith(4)
self.assertEqual(game.queue_add(world, p, "bury_bones", 1, T0), "Only crafting and gathering can be queued") self.assertEqual(game.queue_add(world, p, "bury_bones", 1, T0), "Only crafting, gathering and tending can be queued")
self.assertIsNone(game.queue_add(world, p, "smith_copper_helmet", 1, T0)) self.assertIsNone(game.queue_add(world, p, "smith_copper_helmet", 1, T0))
self.assertEqual(p["action"]["id"], "smith_copper_helmet") # nothing being made: it just starts self.assertEqual(p["action"]["id"], "smith_copper_helmet") # nothing being made: it just starts
self.assertEqual(p["action"]["resume"], "mine_stone") self.assertEqual(p["action"]["resume"], "mine_stone")
@ -3012,6 +3012,21 @@ class FirekeepingTest(unittest.TestCase):
self.fires[key].update(fuel=[{"tier": 0, "left": game.fire_bank(1) * 60, "by": "kai"}], at=T0 + 35) self.fires[key].update(fuel=[{"tier": 0, "left": game.fire_bank(1) * 60, "by": "kai"}], at=T0 + 35)
self.assertEqual(game.start_action(self.world, self.kai, "tend_all_logs", T0 + 35), "Your piles are all full") self.assertEqual(game.start_action(self.world, self.kai, "tend_all_logs", T0 + 35), "Your piles are all full")
def test_queued_tending_runs_until_full_then_on_with_the_queue(self):
self.kai["bank"] = {"logs": 500}
game.start_action(self.world, self.kai, "mine_stone", T0)
self.assertIsNone(game.queue_add(self.world, self.kai, "tend_all_logs", 1, T0)) # starts it, mining comes after
self.assertIsNone(game.queue_add(self.world, self.kai, "split_logs", 2, T0))
self.assertEqual(self.kai["action"]["left"], 1)
game.process_player(self.world, self.kai, T0 + 600, random.Random(1))
self.assertEqual(self.kai["action"]["id"], "tend_all_logs") # past one log: no count, still tending
summary = game.process_player(self.world, self.kai, T0 + 4 * 3600, random.Random(1))
self.assertEqual(summary["skipped"], [])
self.assertEqual(self.kai["bank"]["kindling"], 2 * game.KINDLING_YIELD[0])
self.assertEqual((self.kai["action"]["id"], summary["resumed"]), ("mine_stone", "mine_stone"))
tended = 500 - 2 - self.kai["bank"]["logs"]
self.assertGreater(tended, 4 * game.fire_bank(1) / game.fire_minutes(1)) # every pile filled
def test_better_logs_need_the_level(self): def test_better_logs_need_the_level(self):
self.kai["bank"] = {"maple_logs": 3} self.kai["bank"] = {"maple_logs": 3}
self.assertEqual(game.start_action(self.world, self.kai, "tend_forge_maple_logs", T0), "Needs level 45") self.assertEqual(game.start_action(self.world, self.kai, "tend_forge_maple_logs", T0), "Needs level 45")