From f735831a1e5848c29fe3d9413b3744c31005ec72 Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 26 Oct 2026 16:48:44 +0000 Subject: [PATCH] Make X: count for one-off crafts Co-Authored-By: Claude Opus 5.5 --- app.py | 5 ++++- game.py | 12 ++++++++---- static/app.js | 14 +++++++++++--- static/styles.css | 3 +++ tests/test_game.py | 11 +++++++++++ 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index 24dc624..ea42487 100644 --- a/app.py +++ b/app.py @@ -358,7 +358,10 @@ class Handler(BaseHTTPRequestHandler): if not isinstance(qty, int): return "Bad quantity" if path == "/api/action": - return game.start_action(world, player, str(body.get("id")), now, once=bool(body.get("once"))) + once = body.get("once") or 0 + 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/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 a9edc37..1fa80af 100644 --- a/game.py +++ b/game.py @@ -3801,8 +3801,11 @@ def working_order(world: dict, player: dict, action: dict) -> dict | None: return order_by_id(world, current["order"]) -def start_action(world: dict, player: dict, action_id: str, now: float, once: bool = False) -> str | None: - """Start an action. `once` does it a single time, then goes back to what you were doing.""" +MAKE_MAX = 1000 # most you can ask for in one "Make X" + + +def start_action(world: dict, player: dict, action_id: str, now: float, once: int = 0) -> str | None: + """Start an action. `once` does it that many times, then goes back to what you were doing.""" action = ACTION_BY_ID.get(action_id) if not action: return "No such action" @@ -3813,7 +3816,7 @@ def start_action(world: dict, player: dict, action_id: str, now: float, once: bo current = player["action"] if once: back = current and (current.get("resume") or current["id"]) - entry["left"] = 1 + entry["left"] = int(once) if back and back != action_id: entry["resume"] = back plot2 = current.get("resume_plot2") or current.get("plot2") @@ -6081,6 +6084,7 @@ ORDER_RECIPES, ORDER_RAW = _order_sources() # line here (new / balance / fix), and a new release a new entry on top. PATCH_NOTES = [ {"date": "2026-10-26", "title": "Fittings", "notes": [ + ("new", "Make X: \"Make one\" now takes a count (1 by default, up to 1,000), then you go back to what you were doing."), ("new", "Construction › Fittings: ✓ Fitted on the ones the town already has (✓ Paused while the building is damaged)."), ]}, {"date": "2026-10-25", "title": "Titles", "notes": [ @@ -6236,7 +6240,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, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), + "order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "make_max": MAKE_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 30495a3..e7e5c48 100644 --- a/static/app.js +++ b/static/app.js @@ -1712,12 +1712,14 @@ 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 ? `
Just one${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.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}
` : ""}
${!active && !second && why ? esc(why) : ""} ${second ? `` : active ? `` - : ` + : `${!a.enemy && Object.keys(a.outputs).length + ? `
` + : ``} ${canPlot ? `` : ""}
`}
@@ -2798,7 +2800,7 @@ document.addEventListener("click", (e) => { return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim }); } if (d.start) return act("/api/action", { id: d.start }); - if (d.once) return act("/api/action", { id: d.once, once: true }); + if (d.once) return act("/api/action", { id: d.once, once: 1 }); if ("stop" in d) return act("/api/stop", {}); if (d.join) { tagging = true; return act("/api/join", { id: d.join }); } if ("orderNew" in d) { @@ -2961,6 +2963,12 @@ document.addEventListener("submit", async (e) => { field.value = ""; return act("/api/monument", { id: form.dataset.id, qty: Number(value) }); } + if (form.dataset.form === "make") { + 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"; + return act("/api/action", { id: form.dataset.id, once: n }); + } if (form.dataset.form === "gold") { field.value = ""; return act("/api/donate", { item: "gold", qty: Number(value) }); diff --git a/static/styles.css b/static/styles.css index 522a5c4..8f04cc6 100644 --- a/static/styles.css +++ b/static/styles.css @@ -706,6 +706,9 @@ button.sheet-skill:hover { background: var(--panel-2); } .ro-pick > .doll-slot .gi { width: 34px; height: 34px; } .ro-pick .small .gi { width: 14px; height: 14px; } .stepper { display: flex; align-items: center; gap: 8px; margin-top: 10px; } .stepper input { width: 90px; } +.make { display: flex; } +.make .btn { border-top-right-radius: 0; border-bottom-right-radius: 0; } +.make input { width: 48px; padding-inline: 6px; border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: -1px; } .reward-input { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } .reward-input input { width: 110px; } @media (max-width: 900px) { .compose-grid { grid-template-columns: 1fr; gap: 14px; } } diff --git a/tests/test_game.py b/tests/test_game.py index 9dfd25f..46079ee 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -55,6 +55,17 @@ class ActionTest(unittest.TestCase): self.assertEqual(p["action"]["id"], "mine_stone") self.assertEqual(p["bank"]["stone"], 5 + 2) # mining carried on from when the pickaxe was done + def test_make_x_then_back_to_work(self): + world = world_with(("fred", "commoner")) + p = world["players"]["fred"] + game.start_action(world, p, "mine_stone", T0) + self.assertIsNone(game.start_action(world, p, "cut_logs", T0, once=3)) + self.assertEqual(p["action"]["left"], 3) + summary = game.process_player(world, p, T0 + 500, random.Random(1)) + self.assertEqual(p["bank"]["logs"], 3) + self.assertEqual(summary["resumed"], "mine_stone") + self.assertEqual(p["action"]["id"], "mine_stone") + def test_make_one_while_idle_just_stops(self): world = world_with(("fred", "commoner")) p = world["players"]["fred"]