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]) => `
Bank full, sold for ${fmt(sum.sold_gold)} gold:
Bank full, sent to the stockpile:
โญ๏ธ 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 `