From 9ff7a22a5da54b4d1e77cb3c0005d32f5ecc8f41 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 21 Oct 2026 13:40:54 +0100 Subject: [PATCH] Work orders: anyone can pool reagents, paid by value Co-Authored-By: Claude Opus 5.5 --- game.py | 116 ++++++++++++++++++++++++++++++++------------- static/app.js | 35 +++++++++----- tests/test_game.py | 54 +++++++++++++++++++++ 3 files changed, 161 insertions(+), 44 deletions(-) diff --git a/game.py b/game.py index e33e226..d494412 100644 --- a/game.py +++ b/game.py @@ -2189,10 +2189,8 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: if not order or order["status"] != "open" or order["claimed"] != player["id"]: return "Work order done" if order and order["status"] == "done" else "Work order closed" for item, qty in fire_inputs(world, action).items(): - if order and item in order["provided"]: - if order["provided"][item] < qty: - return f"Out of {ITEMS[item]['name'].lower()}" - elif player["bank"].get(item, 0) < qty: + pooled = order["provided"].get(item, 0) if order else 0 + if pooled + player["bank"].get(item, 0) < qty: return f"Out of {ITEMS[item]['name'].lower()}" if action.get("special") == "tend" and _room(world["city"]["fires"][action["tend"]], player, action["tier"]) < 0: return f"Your pile in the {FIRES[action['tend']]['name'].lower()} is full" @@ -2227,11 +2225,13 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R order = working_order(world, player, action) inputs = fire_inputs(world, action) warmed = inputs is not action["inputs"] - own = {item: qty for item, qty in inputs.items() if not order or item not in order["provided"]} + own, used = dict(inputs), {} if order: - for item, qty in inputs.items(): - if item in order["provided"]: - order["provided"][item] -= qty + used = _draw(order, inputs) + for share in used.values(): + for item, qty in share.items(): + own[item] -= qty + own = {item: qty for item, qty in own.items() if qty} if own and rng.random() < brew.get("keep", 0): summary["kept"] += 1 else: @@ -2362,7 +2362,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R sent = min(outputs.get(order["item"], 0), order["qty"] - order["delivered"]) if sent: outputs[order["item"]] -= sent - _deliver(world, order, player, sent, now) + _deliver(world, order, player, sent, now, used, inputs) summary["delivered"] = summary.get("delivered", 0) + sent for item, qty in outputs.items(): if not qty: @@ -2513,16 +2513,18 @@ def take_summary(player: dict, summary: dict) -> dict: # --- work orders ----------------------------------------------------------- -# A player asks the guild for something. A crafting order names a recipe: the -# requester can put in some of its reagents, one crafter claims it and makes it -# with the normal action (their own materials for the rest), and each piece goes -# straight to the requester. A materials request is open to anyone who has the -# items. Reagents and the reward are held by the order, paid out as it's filled, -# and handed back if it's cancelled or runs out of time. +# A player asks the guild for something. A crafting order names a recipe: anyone +# can put its reagents into the order's pool, one crafter claims it and makes it +# with the normal action (pool first, their own materials for the rest), and each +# piece goes straight to the requester. A materials request is open to anyone who +# has the items. Reagents and the reward are held by the order, paid out as it's +# filled, and handed back to whoever put them in if it's cancelled, runs out of +# time, or finishes with some left over. ORDER_LIMIT = 5 # open orders per player ORDER_DAYS = 3 # before an order expires and is refunded ORDER_MAX = {"craft": 500, "gather": 2000} +INPUT_SHARE = 0.5 # of each piece's reward, split by value among whoever's reagents went in def _order_sources() -> tuple[dict, dict]: @@ -2581,37 +2583,70 @@ def post_order(world: dict, player: dict, kind: str, item: str, qty, provide, re order = {"id": world["next_order_id"], "kind": kind, "by": player["id"], "at": now, "expires": now + ORDER_DAYS * DAY / speed(world), "item": item, "qty": qty, "action": source, "skill": action["skill"], "level": action["level"], "provided": provided, "reward": total, - "paid": 0, "delivered": 0, "held": 0, "claimed": None, "from": {}, "status": "open"} + "paid": 0, "delivered": 0, "held": 0, "claimed": None, "from": {}, "status": "open", + "gave": {player["id"]: dict(provided)} if provided else {}, "earned": {}} world["next_order_id"] += 1 world["orders"].append(order) return None +def _gave(order: dict) -> dict: + """Who has reagents in the pool: {player: {item: qty}}, oldest first. Older orders only had the requester's.""" + return order.setdefault("gave", {order["by"]: dict(order["provided"])} if order["provided"] else {}) + + def provide_reagent(world: dict, player: dict, order_id, item: str) -> str | None: - """Put a reagent into your own order after posting it, enough for what's still to be made.""" + """Put a reagent into a crafting order's pool, up to what's still to be made.""" order = order_by_id(world, order_id) - if not order or order["by"] != player["id"] or order["status"] != "open" or order["kind"] != "craft": + if not order or order["status"] != "open" or order["kind"] != "craft": return "No such order" action = ACTION_BY_ID[order["action"]] - if item not in action["inputs"] or item in order["provided"]: - return "Already provided" if item in order["provided"] else "That isn't in the recipe" + if item not in action["inputs"]: + return "That isn't in the recipe" need = action["inputs"][item] * _batches(action, order["item"], order["qty"] - order["delivered"]) - if player["bank"].get(item, 0) < need: - return f"{ITEMS[item]['name']}: {player['bank'].get(item, 0)} / {need}" - bank_take(player, item, need) - order["provided"][item] = need + short = need - order["provided"].get(item, 0) + if short <= 0: + return "Already provided" + qty = min(player["bank"].get(item, 0), short) + if not qty: + return f"No {ITEMS[item]['name'].lower()} in your bank" + bank_take(player, item, qty) + order["provided"][item] = order["provided"].get(item, 0) + qty + mine = _gave(order).setdefault(player["id"], {}) + mine[item] = mine.get(item, 0) + qty return None +def _draw(order: dict, inputs: dict) -> dict: + """Take one run's reagents from the pool, oldest first: {player: {item: qty}} of whose went in.""" + used = {} + for item, qty in inputs.items(): + take = min(order["provided"].get(item, 0), qty) + if not take: + continue + order["provided"][item] -= take + for uid, items in _gave(order).items(): + part = min(items.get(item, 0), take) + if part: + items[item] -= part + used.setdefault(uid, {})[item] = part + take -= part + if not take: + break + return used + + def _refund(world: dict, order: dict, now: float) -> None: + """Hand back what's left in the pool to whoever put it in, and the unpaid reward to the requester.""" + for uid, items in _gave(order).items(): + player = world["players"].get(uid) + for item, qty in items.items(): + if qty and player: + bank_add(world, player, item, qty, now, empty_summary()) + order["provided"], order["gave"] = {}, {} owner = world["players"].get(order["by"]) - if not owner: - return - for item, qty in order["provided"].items(): - if qty: - bank_add(world, owner, item, qty, now, empty_summary()) - order["provided"] = {} - owner["gold"] += order["reward"] - order["paid"] + if owner: + owner["gold"] += order["reward"] - order["paid"] order["paid"] = order["reward"] @@ -2692,12 +2727,26 @@ def deliver_order(world: dict, player: dict, order_id, now: float) -> str | None return None -def _deliver(world: dict, order: dict, giver: dict, qty: int, now: float) -> None: +def _deliver(world: dict, order: dict, giver: dict, qty: int, now: float, + used: dict | None = None, inputs: dict | None = None) -> None: + """Pay for `qty` pieces. Whoever's reagents went into them (`used` of the run's `inputs`) shares + INPUT_SHARE of the pay by value; the requester's own reagents leave theirs with the crafter.""" order["delivered"] += qty order["from"][giver["id"]] = order["from"].get(giver["id"], 0) + qty order["held"] += qty pay = round(order["reward"] * order["delivered"] / order["qty"]) - order["paid"] order["paid"] += pay + worth, base = sum(ITEMS[item]["value"] * n for item, n in (inputs or {}).items()), pay + for uid, items in (used or {}).items(): + helper = world["players"].get(uid) + if uid == order["by"] or not helper or not worth: + continue + share = min(pay, round(base * INPUT_SHARE * sum(ITEMS[i]["value"] * n for i, n in items.items()) / worth)) + if share: + helper["gold"] += share + earned = order.setdefault("earned", {}) + earned[uid] = earned.get(uid, 0) + share + pay -= share giver["gold"] += pay owner = world["players"].get(order["by"]) if owner: @@ -2705,6 +2754,7 @@ def _deliver(world: dict, order: dict, giver: dict, qty: int, now: float) -> Non if order["delivered"] >= order["qty"]: order["status"] = "done" order["done"] = now + _refund(world, order, now) # leftover reagents log(world, now, "order", f"{giver['name']} filled {owner['name'] if owner else 'someone'}'s order: " f"{amount(order['item'], order['qty'])}") @@ -4419,7 +4469,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, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), + "order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED, "styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE, "resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE, diff --git a/static/app.js b/static/app.js index c2e799e..63ccec5 100644 --- a/static/app.js +++ b/static/app.js @@ -16,6 +16,7 @@ let orderTab = "take"; // work orders: for you, all open, yours let draft = null; // a work order being written let seenOrders = null; // order ids already known, so only new ones toast let deliveredSeen = {}; // how much of each order had arrived +let earnedSeen = {}; // gold each order had paid you for reagents let pickedClass = null; // registration const tabs = {}; // skill -> selected recipe tab let refreshing = false; @@ -1478,28 +1479,39 @@ function orderNews(state) { } const was = deliveredSeen[o.id]; if (o.by === uid && was !== undefined && o.delivered > was) toast(`📦 ${item(o.item).name} ×${fmt(o.delivered - was)}`, "good"); + const got = (o.earned || {})[uid] || 0, had = earnedSeen[o.id]; + if (had !== undefined && got > had) toast(`🤝 ${item(o.item).name} · +${fmt(got - had)} gold`, "good"); } } seenOrders = new Set(orders.map((o) => o.id)); deliveredSeen = Object.fromEntries(orders.map((o) => [o.id, o.delivered])); + earnedSeen = Object.fromEntries(orders.map((o) => [o.id, (o.earned || {})[uid] || 0])); } +// Recipe slots on a crafting order. Anyone can put reagents in the pool; the crafter brings the rest. function orderSlots(o) { const a = C.actions.find((x) => x.id === o.action), out = a.outputs[o.item]; const left = Math.ceil((o.qty - o.delivered) / out), all = Math.ceil(o.qty / out); + const worth = Object.entries(a.inputs).reduce((sum, [id, per]) => sum + item(id).value * per, 0); return Object.entries(a.inputs).map(([id, per]) => { - const name = item(id).name; - if (id in o.provided) { - return `
${item(id).icon} - ${fmt(per * all)}✓
`; + const name = item(id).name, pool = o.provided[id] || 0, have = S.me.bank[id] || 0; + if (o.status !== "open") { + return `
${item(id).icon} + ×${fmt(per * all)}
`; } - const need = per * (o.status === "open" ? left : all), have = S.me.bank[id] || 0; - // On your own open order, an empty slot can still be filled from your bank. - const fill = o.by === S.uid && o.status === "open"; - return `
${fill - ? `` - : `${item(id).icon}`} - ${o.status !== "open" ? `×${fmt(need)}` : `${fmt(have)} / ${fmt(need)}`}
`; + const need = per * left; + const who = Object.entries(o.gave || {}).filter(([, items]) => items[id]).map(([uid, items]) => `${playerName(uid)} ${fmt(items[id])}`); + const tip = [`${name}: ${fmt(pool)} / ${fmt(need)}`, ...who].join(" · "); + if (pool >= need) { + return `
${item(id).icon} + ${fmt(need)}✓
`; + } + const give = Math.min(have, need - pool); + // What putting them in pays you once they're crafted: your value's slice of the reagents' share. + const share = o.by === S.uid ? 0 : Math.round(o.reward / o.qty * out * C.input_share * item(id).value / worth * give); + const fill = give ? `${tip} · +${fmt(give)}${share ? ` → ${fmt(share)} gold` : ""}` : `${tip} · 🎒 0`; + return `
+ ${fmt(pool)} / ${fmt(need)}
`; }).join(""); } @@ -1536,6 +1548,7 @@ function orderCard(o) {
${mine ? "Yours" : esc(playerName(o.by))} · ${ago(o.at)}${o.held ? ` · ${fmt(o.held)} waiting for bank space` : ""}
${skill.icon} ${o.level} ${o.kind === "craft" ? `
${orderSlots(o)}
` : ""} + ${Object.keys(o.earned || {}).length ? `
🤝 ${Object.entries(o.earned).map(([uid, n]) => `${esc(playerName(uid))} ${fmt(n)}`).join(", ")}
` : ""} ${progress}
${COIN} ${reward}${status}${buttons}
`; diff --git a/tests/test_game.py b/tests/test_game.py index 320e152..8954305 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1679,6 +1679,60 @@ class WorkOrderTest(unittest.TestCase): self.assertEqual(self.mike["bank"].get("iron_warhammer"), 1) self.assertEqual(self.fred["bank"], {}) # used his own plank and cores, never needed bars + def test_anyone_can_put_reagents_in_and_gets_a_share(self): + self.mike["gold"] = 200 + game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, [], 140, T0) + order = self.world["orders"][0] + self.dave["bank"] = {"iron_bar": 2} + self.mike["bank"] = {"iron_bar": 5} + self.assertIsNone(game.provide_reagent(self.world, self.dave, order["id"], "iron_bar")) + self.assertIsNone(game.provide_reagent(self.world, self.mike, order["id"], "iron_bar")) + self.assertEqual(order["provided"], {"iron_bar": 3}) # only what's needed + self.assertEqual(self.mike["bank"], {"iron_bar": 4}) + self.assertEqual(game.provide_reagent(self.world, self.dave, order["id"], "iron_bar"), "Already provided") + self.fred["bank"] = {"oak_plank": 1, "clay_core": 3} + self.assertIsNone(game.work_order(self.world, self.fred, order["id"], T0)) + game.process_player(self.world, self.fred, T0 + 600, random.Random(1)) + # Reagents are worth 84 a run; Dave's 2 bars (20) earn 20/84 of half the reward. + self.assertEqual(self.dave["gold"], 17) + self.assertEqual(self.fred["gold"], 123) # the requester's own bar leaves its share with the crafter + self.assertEqual(order["earned"], {"dave": 17}) + + def test_crafter_tops_up_a_short_pool(self): + self.mike["gold"] = 200 + game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, [], 140, T0) + order = self.world["orders"][0] + self.dave["bank"] = {"iron_bar": 1} + game.provide_reagent(self.world, self.dave, order["id"], "iron_bar") + self.fred["bank"] = {"iron_bar": 1, "oak_plank": 1, "clay_core": 3} + self.assertEqual(game.work_order(self.world, self.fred, order["id"], T0), "Out of iron bar") + self.fred["bank"]["iron_bar"] = 2 + self.assertIsNone(game.work_order(self.world, self.fred, order["id"], T0)) + game.process_player(self.world, self.fred, T0 + 600, random.Random(1)) + self.assertEqual(self.mike["bank"].get("iron_warhammer"), 1) + self.assertNotIn("iron_bar", self.fred["bank"]) + + def test_pooled_reagents_go_back_to_whoever_gave_them(self): + self.mike["gold"] = 200 + game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, [], 140, T0) + order = self.world["orders"][0] + self.dave["bank"] = {"clay_core": 3} + game.provide_reagent(self.world, self.dave, order["id"], "clay_core") + game.cancel_order(self.world, self.mike, order["id"], T0) + self.assertEqual(self.dave["bank"], {"clay_core": 3}) + self.assertEqual(self.mike["gold"], 200) + + def test_leftover_reagents_return_when_filled_from_a_bank(self): + self.mike["gold"] = 200 + game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, [], 140, T0) + order = self.world["orders"][0] + self.dave["bank"] = {"clay_core": 3} + game.provide_reagent(self.world, self.dave, order["id"], "clay_core") + self.fred["bank"] = {"iron_warhammer": 1} + self.assertIsNone(game.deliver_order(self.world, self.fred, order["id"], T0)) + self.assertEqual(order["status"], "done") + self.assertEqual(self.dave["bank"], {"clay_core": 3}) + class Always: """An rng where every roll comes up: a gem, a drop, whatever's on offer."""