From 40e5274c4e516240d3f158f1f791af8460749d01 Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 26 Oct 2026 22:51:56 +0000 Subject: [PATCH] Crafting queue Co-Authored-By: Claude Opus 5.5 --- app.py | 10 +++++ game.py | 108 +++++++++++++++++++++++++++++++++++++++------ static/app.js | 34 ++++++++++++-- static/styles.css | 4 ++ tests/test_game.py | 68 ++++++++++++++++++++++++++++ 5 files changed, 207 insertions(+), 17 deletions(-) diff --git a/app.py b/app.py index 6cf6dc6..dad5bbf 100644 --- a/app.py +++ b/app.py @@ -146,6 +146,8 @@ def player_view(world: dict, player: dict, now: float) -> dict: / game.speed(world) 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["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["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS if action.get("special") == "recon"} @@ -363,6 +365,14 @@ class Handler(BaseHTTPRequestHandler): if not isinstance(once, int) or not 0 <= once <= game.MAKE_MAX: return f"1 to {game.MAKE_MAX:,}" 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": return game.join_fight(world, player, str(body.get("id")), now) if path == "/api/press": diff --git a/game.py b/game.py index 487a967..f2676cb 100644 --- a/game.py +++ b/game.py @@ -1524,7 +1524,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 31 +WORLD_VERSION = 32 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()] player.setdefault("found", [item for item in UNIQUES if item in held]) 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 @@ -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 "pp": 0, "prayers": [], "potions": {}, "hp": 44, "hp_at": now, - "action": None, "stopped": None, + "action": None, "stopped": None, "queue": [], "last_active": now, "overflow": "city", "perks": {"bank_slot": 0}, "manual": [], "patterns": [], "found": [], "eat_below": EAT_BELOW, "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: 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, "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) reason = can_start(world, player, action) if reason: + if next_queued(world, player, current["start"], summary, skipped=current): + current = player["action"] + continue stop(player, reason) summary["stopped"] = reason 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) if player["action"] is current and "left" in current: 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"] - summary["resumed"] = current["id"] 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. reason = can_start(world, player, growing(player)) if reason: + if next_queued(world, player, current["start"], summary, skipped=current): + current = player["action"] + continue stop(player, reason) summary["stopped"] = reason 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: player["action"] = None player["stopped"] = reason + player["queue"] = [] -def finish_once(player: dict, at: float, reason: str | None = None) -> bool: - """A one-off is done: back to what you were doing before (True), or stop.""" +def queueable(action: dict) -> bool: + """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"] back = current.get("resume") 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} if current.get("resume_plot2"): player["action"].update(plot2=current["resume_plot2"], turn=0) + if summary is not None: + summary["resumed"] = back return True @@ -3349,8 +3428,7 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None if current["left"] <= 0: if world["parties"].get(current.get("party")) is party: leave_party(world, player, pid, end) - if finish_once(player, end): - summary["resumed"] = player["action"]["id"] + finish_once(world, player, end, summary=summary) if action.get("dungeon") and world["parties"].get(pid) is party: _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"]] for player in members: summary = player.setdefault("away", empty_summary()) - if finish_once(player, now, reason): - summary["resumed"] = player["action"]["id"] - else: + if not finish_once(world, player, now, reason, summary): 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" +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: @@ -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") if 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. entry.update(plot2=current["plot2"], turn=0) 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)."), ("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", "Added crafting queue"), ]}, {"date": "2026-10-25", "title": "Titles", "notes": [ ("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}, "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, - "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, "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, diff --git a/static/app.js b/static/app.js index 39687b1..3a74514 100644 --- a/static/app.js +++ b/static/app.js @@ -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`); for (const [skill, level] of sum.levels) toast(`๐ŸŽ‰ ${skillOf(skill).name} level ${level}!`, "level"); 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 Object.keys(sum.potions_drunk || {})) toast(`๐Ÿงช Drank another ${item(id).name.toLowerCase()}`); 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()}`); } +// 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) { const items = Object.entries(sum.items).map(([id, q]) => `
  • ${item(id).icon} ${fmt(q)} ${esc(item(id).name)}
  • `).join(""); const xp = Object.entries(sum.xp).map(([id, q]) => `
  • ${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP
  • `).join(""); @@ -214,6 +220,7 @@ function awayModal(sum) { ${levels ? `` : ""} ${sold ? `

    Bank full, sold for ${fmt(sum.sold_gold)} gold:

    ` : ""} ${spilled ? `

    Bank full, sent to the stockpile:

    ` : ""} + ${sum.skipped?.length ? `

    โญ๏ธ Skipped: ${esc(skippedNames(sum))}

    ` : ""} ${sum.stopped ? `

    Stopped: ${esc(sum.stopped)}

    ` : ""}

    `); } @@ -761,7 +768,7 @@ 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)}` : ""}`; } @@ -777,6 +784,20 @@ function topbar() { return `

    ${esc(title)}

    ${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 `
    โณ ${q.map((e, i) => { + const a = C.actions.find((x) => x.id === e.id); + return `${a.icon} ร—${fmt(e.n)}`; + }).join(`โ†’`)}
    `; +} + // The recipe you're after, under the top bar on every page: have / need for each input. function trackedBar() { 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; const why = me.blocked[a.id]; 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 icon = a.icon; const time = me.times[a.id]; @@ -1717,13 +1741,15 @@ function actionCard(a) { ${me.action?.plot2 && (active || second) ? `
    Plot ${active ? 1 : 2}${growingNow ? " ยท growing now" : " ยท next"}
    ` : ""} ${growingNow && !a.enemy ? `
    ` : ""} ${active && a.enemy ? `
    โš”๏ธ Fighting${me.action.with?.length ? ` with ${me.action.with.map((uid) => esc(playerName(uid))).join(", ")}` : ""}
    ` : ""} - ${active && me.action.left ? `
    ${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())}` : ""}
    ` : ""} + ${active && me.action.left ? `
    ${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())}` : ""}
    ${queueRow()}` : ""} + ${queued.length ? `
    โณ Queued #${queued.join(", #")}
    ` : ""}
    ${!active && !second && why ? esc(why) : ""} ${second ? `` : active ? `` : `${!a.enemy && Object.keys(a.outputs).length - ? `
    ` + ? `
    ${canQueue + ? `` : ""}
    ` : ``} ${canPlot ? `` : ""}
    `} @@ -2814,6 +2840,7 @@ document.addEventListener("click", (e) => { if (d.start) return act("/api/action", { id: d.start }); 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 (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 }; @@ -2979,6 +3006,7 @@ document.addEventListener("submit", async (e) => { const n = Math.floor(Number(value)); if (!(n >= 1 && n <= C.make_max)) return toast(`1 to ${fmt(C.make_max)}`, "bad"); 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 }); } if (form.dataset.form === "qty") { diff --git a/static/styles.css b/static/styles.css index 0618d00..5543368 100644 --- a/static/styles.css +++ b/static/styles.css @@ -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 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 .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; } .reward-input { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } .reward-input input { width: 110px; } @@ -848,3 +850,5 @@ button.swatch { width: 36px; height: 36px; } .ach.held { border: 2px dashed var(--gold); } .ach.on { border-color: var(--text); } .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; } diff --git a/tests/test_game.py b/tests/test_game.py index 46079ee..5b16cac 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -84,6 +84,74 @@ class ActionTest(unittest.TestCase): self.assertGreater(p["bank"]["stone"], 100) 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): world = world_with(("fred", "commoner")) p = world["players"]["fred"]