Crafting queue

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-26 22:51:56 +00:00
parent 912f94f775
commit 40e5274c4e
5 changed files with 207 additions and 17 deletions

10
app.py
View file

@ -146,6 +146,8 @@ def player_view(world: dict, player: dict, now: float) -> dict:
/ game.speed(world) / game.speed(world)
if "enemy" in action else game.action_time(world, player, action) for action in game.ACTIONS} if "enemy" in action else game.action_time(world, player, action) for action in game.ACTIONS}
view["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS} view["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS}
view["queue_why"] = {action["id"]: game.queue_reason(world, player, action) for action in game.ACTIONS
if game.queueable(action)}
view["drill_per_action"] = game.drill_per_action(player) view["drill_per_action"] = game.drill_per_action(player)
view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS
if action.get("special") == "recon"} if action.get("special") == "recon"}
@ -363,6 +365,14 @@ class Handler(BaseHTTPRequestHandler):
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))
if path == "/api/queue":
qty = body.get("qty")
if not isinstance(qty, int) or not 1 <= qty <= game.MAKE_MAX:
return f"1 to {game.MAKE_MAX:,}"
return game.queue_add(world, player, str(body.get("id")), qty, now)
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/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":

108
game.py
View file

@ -1524,7 +1524,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world --------------------------------------------------------------- # --- world ---------------------------------------------------------------
WORLD_VERSION = 31 WORLD_VERSION = 32
def new_world(now: float, speed: float = 1.0) -> dict: def new_world(now: float, speed: float = 1.0) -> dict:
@ -1770,6 +1770,11 @@ def migrate(world: dict, now: float | None = None) -> dict:
held = [*player["bank"], *player["equip"].values()] held = [*player["bank"], *player["equip"].values()]
player.setdefault("found", [item for item in UNIQUES if item in held]) player.setdefault("found", [item for item in UNIQUES if item in held])
world["version"] = 31 world["version"] = 31
if world["version"] < 32:
# Crafting queue: makes lined up after the current one.
for player in world["players"].values():
player.setdefault("queue", [])
world["version"] = 32
return world return world
@ -2024,7 +2029,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
"food": None, # preferred food in fights; None eats the cheapest first "food": None, # preferred food in fights; None eats the cheapest first
"pp": 0, "prayers": [], "potions": {}, "pp": 0, "prayers": [], "potions": {},
"hp": 44, "hp_at": now, "hp": 44, "hp_at": now,
"action": None, "stopped": None, "action": None, "stopped": None, "queue": [],
"last_active": now, "overflow": "city", "last_active": now, "overflow": "city",
"perks": {"bank_slot": 0}, "manual": [], "patterns": [], "found": [], "eat_below": EAT_BELOW, "perks": {"bank_slot": 0}, "manual": [], "patterns": [], "found": [], "eat_below": EAT_BELOW,
"rented": 0, "boosts": [], "bought": None, "deeds": {}, "owned": [], "look": dict(DEFAULT_LOOK), "rented": 0, "boosts": [], "bought": None, "deeds": {}, "owned": [], "look": dict(DEFAULT_LOOK),
@ -3030,7 +3035,7 @@ def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summar
def empty_summary() -> dict: def empty_summary() -> dict:
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "used": {}, "damage": 0, return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "used": {}, "damage": 0,
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None, "gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None, "skipped": [],
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0, "sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0,
"kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 0} "kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 0}
@ -3075,6 +3080,9 @@ 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):
current = player["action"]
continue
stop(player, reason) stop(player, reason)
summary["stopped"] = reason summary["stopped"] = reason
break break
@ -3090,13 +3098,15 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
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:
current["left"] -= 1 current["left"] -= 1
if current["left"] <= 0 and finish_once(player, current["start"]): if current["left"] <= 0 and finish_once(world, player, current["start"], summary=summary):
current = player["action"] current = player["action"]
summary["resumed"] = current["id"]
if player["action"] is current and "enemy" not in action and not current.get("plot2"): if player["action"] is current and "enemy" not in action and not current.get("plot2"):
# 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):
current = player["action"]
continue
stop(player, reason) stop(player, reason)
summary["stopped"] = reason summary["stopped"] = reason
break break
@ -3108,10 +3118,77 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
def stop(player: dict, reason: str | None = None) -> None: def stop(player: dict, reason: str | None = None) -> None:
player["action"] = None player["action"] = None
player["stopped"] = reason player["stopped"] = reason
player["queue"] = []
def finish_once(player: dict, at: float, reason: str | None = None) -> bool: def queueable(action: dict) -> bool:
"""A one-off is done: back to what you were doing before (True), or stop.""" """Crafting: turns materials into something. Only these can be queued."""
return bool(action["inputs"] and action["outputs"]) and not (
action.get("enemy") or action.get("special") or action.get("together"))
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."""
if not queueable(action):
return "Only crafting can be queued"
if skill_level(player, action["skill"]) < action["level"]:
return f"Needs level {action['level']}"
if action.get("pattern") and action["pattern"] not in player.get("patterns", []):
return "Learn its pattern first"
for key, level in action.get("city", {}).items():
if building_level(world, key) < level:
return f"Needs {BUILDINGS[key]['name']} level {level}"
return None
def queue_add(world: dict, player: dict, action_id: str, qty: int, now: float) -> str | None:
"""Line a make up after the current one. With no make running, it just starts."""
action = ACTION_BY_ID.get(action_id)
if not action:
return "No such action"
reason = queue_reason(world, player, action)
if reason:
return reason
if not (player["action"] and "left" in player["action"]):
return start_action(world, player, action_id, now, once=qty)
if len(player["queue"]) >= QUEUE_MAX:
return f"Queue full ({QUEUE_MAX})"
player["queue"].append({"id": action_id, "n": qty})
return None
def queue_remove(player: dict, index: int) -> str | None:
if not 0 <= index < len(player["queue"]):
return "Not in the queue"
player["queue"].pop(index)
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."""
current = player["action"]
if skipped is not None and (current is not skipped or "left" not in skipped or not player["queue"]):
return False
gone = summary.setdefault("skipped", []) if summary is not None else []
if skipped is not None:
gone.append(skipped["id"])
while player["queue"]:
entry = player["queue"].pop(0)
action = ACTION_BY_ID.get(entry["id"])
if action and not can_start(world, player, action):
player["action"] = {**{k: v for k, v in current.items() if k in ("resume", "resume_plot2")},
"id": entry["id"], "start": at, "left": entry["n"]}
return True
gone.append(entry["id"])
return False
def finish_once(world: dict, player: dict, at: float, reason: str | None = None, summary: dict | None = None) -> bool:
"""A one-off is done: on to the next in the queue, back to what you were doing before (True), or stop.
Going back is noted in `summary` as resumed."""
if next_queued(world, player, at, summary):
return True
current = player["action"] current = player["action"]
back = current.get("resume") back = current.get("resume")
if not back: if not back:
@ -3120,6 +3197,8 @@ def finish_once(player: dict, at: float, reason: str | None = None) -> bool:
player["action"] = {"id": back, "start": at} player["action"] = {"id": back, "start": at}
if current.get("resume_plot2"): if current.get("resume_plot2"):
player["action"].update(plot2=current["resume_plot2"], turn=0) player["action"].update(plot2=current["resume_plot2"], turn=0)
if summary is not None:
summary["resumed"] = back
return True return True
@ -3349,8 +3428,7 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
if current["left"] <= 0: if current["left"] <= 0:
if world["parties"].get(current.get("party")) is party: if world["parties"].get(current.get("party")) is party:
leave_party(world, player, pid, end) leave_party(world, player, pid, end)
if finish_once(player, end): finish_once(world, player, end, summary=summary)
summary["resumed"] = player["action"]["id"]
if action.get("dungeon") and world["parties"].get(pid) is party: if action.get("dungeon") and world["parties"].get(pid) is party:
_advance_run(world, party, action, fight, members, end, rng) _advance_run(world, party, action, fight, members, end, rng)
@ -3398,9 +3476,7 @@ def _advance_run(world: dict, party: dict, action: dict, fight: dict, members: l
del world["parties"][party["id"]] del world["parties"][party["id"]]
for player in members: for player in members:
summary = player.setdefault("away", empty_summary()) summary = player.setdefault("away", empty_summary())
if finish_once(player, now, reason): if not finish_once(world, player, now, reason, summary):
summary["resumed"] = player["action"]["id"]
else:
summary["stopped"] = reason summary["stopped"] = reason
@ -3802,6 +3878,7 @@ def working_order(world: dict, player: dict, action: dict) -> dict | None:
MAKE_MAX = 1000 # most you can ask for in one "Make X" MAKE_MAX = 1000 # most you can ask for in one "Make X"
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) -> str | None:
@ -3822,7 +3899,9 @@ def start_action(world: dict, player: dict, action_id: str, now: float, once: in
plot2 = current.get("resume_plot2") or current.get("plot2") plot2 = current.get("resume_plot2") or current.get("plot2")
if plot2: if plot2:
entry["resume_plot2"] = plot2 entry["resume_plot2"] = plot2
elif current and current.get("plot2") and current["plot2"] != action_id and action["skill"] == "farming": else:
player["queue"] = [] # something new: the lined-up makes go
if not once and current and current.get("plot2") and current["plot2"] != action_id and action["skill"] == "farming":
# Replanting the first plot leaves the second one growing. # Replanting the first plot leaves the second one growing.
entry.update(plot2=current["plot2"], turn=0) entry.update(plot2=current["plot2"], turn=0)
if current and current.get("party") in world["parties"]: if current and current.get("party") in world["parties"]:
@ -6090,6 +6169,7 @@ PATCH_NOTES = [
("new", "Fittings: ✓ Fitted on ones the town has (✓ Paused while damaged)."), ("new", "Fittings: ✓ Fitted on ones the town has (✓ Paused while damaged)."),
("fix", "Council threat card names the dragon or raid band, not always \"Raiders\"."), ("fix", "Council threat card names the dragon or raid band, not always \"Raiders\"."),
("new", "Donate, Give, Sell: type an amount (starts at 1). 1/10/100 buttons gone, All stays."), ("new", "Donate, Give, Sell: type an amount (starts at 1). 1/10/100 buttons gone, All stays."),
("new", "Added crafting queue"),
]}, ]},
{"date": "2026-10-25", "title": "Titles", "notes": [ {"date": "2026-10-25", "title": "Titles", "notes": [
("new", "🏆 Achievements (under You): 127. Patterns, uniques, titles, emblems, looks, home. Tracks only, awards nothing."), ("new", "🏆 Achievements (under You): 127. Patterns, uniques, titles, emblems, looks, home. Tracks only, awards nothing."),
@ -6203,7 +6283,7 @@ def content() -> dict:
"city_uses": {item: city_uses(item) for item in ITEMS}, "city_uses": {item: city_uses(item) for item in ITEMS},
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, "prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS,
"potion_charges": POTION_CHARGES, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW, "potion_charges": POTION_CHARGES, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW,
"order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "make_max": MAKE_MAX, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), "order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "make_max": MAKE_MAX, "queue_max": QUEUE_MAX, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
"equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE, "eat_below": EAT_BELOW, "eat_choices": EAT_CHOICES, "equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE, "eat_below": EAT_BELOW, "eat_choices": EAT_CHOICES,
"abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD, "abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD,
"weapon_kits": combat.WEAPON_KITS, "group_size": GROUP_SIZE, "dungeon_size": DUNGEON_SIZE, "dungeon_wipes": DUNGEON_WIPES, "trade_ability": combat.TRADE_ABILITY, "potion_below": combat.POTION_BELOW, "weapon_kits": combat.WEAPON_KITS, "group_size": GROUP_SIZE, "dungeon_size": DUNGEON_SIZE, "dungeon_wipes": DUNGEON_WIPES, "trade_ability": combat.TRADE_ABILITY, "potion_below": combat.POTION_BELOW,

View file

@ -189,6 +189,7 @@ function showSummary(sum) {
if (sold.length) toast(`📦 Bank full: sold ${sold.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} for ${fmt(sum.sold_gold)} gold`); if (sold.length) toast(`📦 Bank full: sold ${sold.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} for ${fmt(sum.sold_gold)} gold`);
for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level"); for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level");
if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad"); if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad");
if (sum.skipped?.length) toast(`⏭️ Skipped: ${skippedNames(sum)}`, "bad");
for (const id of sum.potion_out || []) toast(`🧪 Your ${item(id).name.toLowerCase()} ran out`, "bad"); for (const id of sum.potion_out || []) toast(`🧪 Your ${item(id).name.toLowerCase()} ran out`, "bad");
for (const id of Object.keys(sum.potions_drunk || {})) toast(`🧪 Drank another ${item(id).name.toLowerCase()}`); for (const id of Object.keys(sum.potions_drunk || {})) toast(`🧪 Drank another ${item(id).name.toLowerCase()}`);
if (sum.prayer_out) toast("🙏 Out of prayer points", "bad"); if (sum.prayer_out) toast("🙏 Out of prayer points", "bad");
@ -196,6 +197,11 @@ function showSummary(sum) {
if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`); if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`);
} }
// Queued makes passed over for want of materials.
function skippedNames(sum) {
return sum.skipped.map((id) => C.actions.find((x) => x.id === id).name).join(", ");
}
function awayModal(sum) { function awayModal(sum) {
const items = Object.entries(sum.items).map(([id, q]) => `<li>${item(id).icon} ${fmt(q)} ${esc(item(id).name)}</li>`).join(""); const items = Object.entries(sum.items).map(([id, q]) => `<li>${item(id).icon} ${fmt(q)} ${esc(item(id).name)}</li>`).join("");
const xp = Object.entries(sum.xp).map(([id, q]) => `<li>${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP</li>`).join(""); const xp = Object.entries(sum.xp).map(([id, q]) => `<li>${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP</li>`).join("");
@ -214,6 +220,7 @@ function awayModal(sum) {
${levels ? `<ul>${levels}</ul>` : ""} ${levels ? `<ul>${levels}</ul>` : ""}
${sold ? `<p class="muted small">Bank full, sold for ${fmt(sum.sold_gold)} gold:</p><ul>${sold}</ul>` : ""} ${sold ? `<p class="muted small">Bank full, sold for ${fmt(sum.sold_gold)} gold:</p><ul>${sold}</ul>` : ""}
${spilled ? `<p class="muted small">Bank full, sent to the stockpile:</p><ul>${spilled}</ul>` : ""} ${spilled ? `<p class="muted small">Bank full, sent to the stockpile:</p><ul>${spilled}</ul>` : ""}
${sum.skipped?.length ? `<p class="tag bad">⏭️ Skipped: ${esc(skippedNames(sum))}</p>` : ""}
${sum.stopped ? `<p class="tag bad">Stopped: ${esc(sum.stopped)}</p>` : ""} ${sum.stopped ? `<p class="tag bad">Stopped: ${esc(sum.stopped)}</p>` : ""}
<p><button class="btn primary" data-close>Back to work</button></p>`); <p><button class="btn primary" data-close>Back to work</button></p>`);
} }
@ -761,7 +768,7 @@ 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><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>${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>`;
} 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>`;
} }
@ -777,6 +784,20 @@ function topbar() {
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() : ""}`;
} }
function queueTip() {
return `Queued: ${S.me.queue.map((q) => `${C.actions.find((x) => x.id === q.id).name} ×${fmt(q.n)}`).join(" → ")}`;
}
// Makes lined up after this one: tap ✕ to drop one.
function queueRow() {
const q = S.me.queue;
if (!q.length) return "";
return `<div class="queue small">⏳ ${q.map((e, i) => {
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>`;
}).join(`<span class="arrow">→</span>`)}</div>`;
}
// 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);
@ -1656,6 +1677,9 @@ 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.
const canQueue = me.action?.left && !active && me.queue_why[a.id] === null;
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;
const time = me.times[a.id]; const time = me.times[a.id];
@ -1717,13 +1741,15 @@ function actionCard(a) {
${me.action?.plot2 && (active || second) ? `<div class="small muted">Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}</div>` : ""} ${me.action?.plot2 && (active || second) ? `<div class="small muted">Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}</div>` : ""}
${growingNow && !a.enemy ? `<div class="bar thick"><span data-progress></span></div>` : ""} ${growingNow && !a.enemy ? `<div class="bar thick"><span data-progress></span></div>` : ""}
${active && a.enemy ? `<div><span class="tag gold">⚔️ Fighting${me.action.with?.length ? ` with ${me.action.with.map((uid) => esc(playerName(uid))).join(", ")}` : ""}</span></div>` : ""} ${active && a.enemy ? `<div><span class="tag gold">⚔️ Fighting${me.action.with?.length ? ` with ${me.action.with.map((uid) => esc(playerName(uid))).join(", ")}` : ""}</span></div>` : ""}
${active && me.action.left ? `<div class="small muted">${me.action.left === 1 ? "Just one" : `${fmt(me.action.left)} more`}${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}</div>` : ""} ${active && me.action.left ? `<div class="small muted">${me.action.left === 1 ? "Just one" : `${fmt(me.action.left)} more`}${me.queue.length ? `, then ${me.queue.length} queued` : ""}${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}</div>${queueRow()}` : ""}
${queued.length ? `<div><span class="tag" title="${esc(queueTip())}">⏳ Queued #${queued.join(", #")}</span></div>` : ""}
<div class="foot"> <div class="foot">
<span class="why">${!active && !second && why ? esc(why) : ""}</span> <span class="why">${!active && !second && why ? esc(why) : ""}</span>
${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 ? "disabled" : ""}></form>` ? `<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
? `<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" 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>`} <button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button></span>`}
@ -2814,6 +2840,7 @@ document.addEventListener("click", (e) => {
if (d.start) return act("/api/action", { id: d.start }); if (d.start) return act("/api/action", { id: d.start });
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.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 };
@ -2979,6 +3006,7 @@ document.addEventListener("submit", async (e) => {
const n = Math.floor(Number(value)); const n = Math.floor(Number(value));
if (!(n >= 1 && n <= C.make_max)) return toast(`1 to ${fmt(C.make_max)}`, "bad"); if (!(n >= 1 && n <= C.make_max)) return toast(`1 to ${fmt(C.make_max)}`, "bad");
field.value = "1"; field.value = "1";
if (e.submitter?.hasAttribute("data-q")) return act("/api/queue", { id: form.dataset.id, qty: n });
return act("/api/action", { id: form.dataset.id, once: n }); return act("/api/action", { id: form.dataset.id, once: n });
} }
if (form.dataset.form === "qty") { if (form.dataset.form === "qty") {

View file

@ -711,6 +711,8 @@ button.sheet-skill:hover { background: var(--panel-2); }
.make .btn { border-top-right-radius: 0; border-bottom-right-radius: 0; } .make .btn { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.make input { width: 60px; padding-inline: 8px; appearance: textfield; border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: -1px; } .make input { width: 60px; padding-inline: 8px; appearance: textfield; border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: -1px; }
.make.sm input { width: 70px; padding-block: 3px; font-size: 13px; } .make.sm input { width: 70px; padding-block: 3px; font-size: 13px; }
.make .btn.q { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: -1px; }
.make:has(.q) input { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.make input::-webkit-inner-spin-button, .make input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } .make input::-webkit-inner-spin-button, .make input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.reward-input { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } .reward-input { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.reward-input input { width: 110px; } .reward-input input { width: 110px; }
@ -848,3 +850,5 @@ button.swatch { width: 36px; height: 36px; }
.ach.held { border: 2px dashed var(--gold); } .ach.held { border: 2px dashed var(--gold); }
.ach.on { border-color: var(--text); } .ach.on { border-color: var(--text); }
.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 .x { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 0 0 0 4px; font-size: 11px; }

View file

@ -84,6 +84,74 @@ class ActionTest(unittest.TestCase):
self.assertGreater(p["bank"]["stone"], 100) self.assertGreater(p["bank"]["stone"], 100)
self.assertEqual(p["bank"]["stone"], 100 + summary["lucky"]) self.assertEqual(p["bank"]["stone"], 100 + summary["lucky"])
def _smith(self, bars):
world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
p["xp"]["smithing"] = game.XP_TABLE[10]
p["bank"] = {"copper_bar": bars}
game.start_action(world, p, "mine_stone", T0)
return world, p
def test_queue_runs_in_order_then_back_to_work(self):
world, p = self._smith(6)
self.assertIsNone(game.start_action(world, p, "smith_copper_helmet", T0, once=1))
self.assertIsNone(game.queue_add(world, p, "smith_copper_legs", 1, T0))
self.assertEqual(p["queue"], [{"id": "smith_copper_legs", "n": 1}])
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"], "mine_stone")
self.assertEqual(summary["resumed"], "mine_stone")
self.assertEqual(p["queue"], [])
def test_queue_skips_what_you_cant_make(self):
world, p = self._smith(4)
game.start_action(world, p, "smith_copper_helmet", T0, once=1)
game.queue_add(world, p, "smith_copper_legs", 1, T0) # 4 bars, only 2 left by then
game.queue_add(world, p, "smith_copper_sword", 1, T0)
summary = game.process_player(world, p, T0 + 500, random.Random(1))
self.assertEqual((p["bank"].get("copper_helmet"), p["bank"].get("copper_sword")), (1, 1))
self.assertNotIn("copper_legs", p["bank"])
self.assertEqual(summary["skipped"], ["smith_copper_legs"])
self.assertEqual(summary["resumed"], "mine_stone") # not the queued makes on the way
self.assertEqual(p["action"]["id"], "mine_stone")
def test_running_out_mid_make_moves_on_to_the_next(self):
world, p = self._smith(5)
game.start_action(world, p, "smith_copper_helmet", T0, once=3) # bars for 2
game.queue_add(world, p, "smith_copper_rivets", 1, T0)
summary = game.process_player(world, p, T0 + 500, random.Random(1))
self.assertEqual((p["bank"].get("copper_helmet"), p["bank"].get("copper_rivets")), (2, 5))
self.assertEqual(summary["skipped"], ["smith_copper_helmet"])
self.assertEqual(p["action"]["id"], "mine_stone")
def test_out_of_materials_with_nothing_queued_still_stops(self):
world, p = self._smith(4)
game.start_action(world, p, "smith_copper_helmet", T0, once=3)
summary = game.process_player(world, p, T0 + 500, random.Random(1))
self.assertIsNone(p["action"])
self.assertTrue(summary["stopped"].startswith("Out of"))
def test_queue_rules(self):
world, p = self._smith(4)
self.assertEqual(game.queue_add(world, p, "cut_logs", 1, T0), "Only crafting can be queued")
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"]["resume"], "mine_stone")
p["bank"] = {}
for _ in range(game.QUEUE_MAX):
self.assertIsNone(game.queue_add(world, p, "smith_copper_helmet", 1, T0)) # bars aren't checked yet
self.assertEqual(game.queue_add(world, p, "smith_copper_helmet", 1, T0), f"Queue full ({game.QUEUE_MAX})")
high = next(a["id"] for a in game.ACTIONS if game.queueable(a) and a["skill"] == "smithing" and a["level"] > 10)
self.assertTrue(game.queue_add(world, p, high, 1, T0).startswith("Needs level"))
self.assertIsNone(game.queue_remove(p, 0))
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)
self.assertEqual(p["queue"], [])
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"]