diff --git a/app.py b/app.py index 433d3a2..d397e1b 100644 --- a/app.py +++ b/app.py @@ -218,6 +218,7 @@ def roster(world: dict, now: float) -> list[dict]: # A fight others can tag into, and who's in it together. "fight": player["action"]["id"] if game.joinable(world, player) and game.offline_limit(world, player) > now else None, "party": (player["action"] or {}).get("party"), + "order": (player["action"] or {}).get("order"), # the work order they're crafting "garrison": player["garrison"], "donated": player["donated"], "look": game.look_view(player), "seen": int(player["last_active"]), @@ -409,7 +410,7 @@ class Handler(BaseHTTPRequestHandler): return game.set_manual(world, player, str(body.get("id")), bool(body.get("on")), now) if path == "/api/order": return game.post_order(world, player, body.get("kind"), str(item), qty, body.get("provide"), - body.get("reward"), now) + body.get("reward"), now, bool(body.get("city")), str(body["fund"]) if body.get("fund") else None) if path == "/api/order/provide": return game.provide_reagent(world, player, body.get("id"), str(item)) if path == "/api/order/cancel": diff --git a/game.py b/game.py index 42829f7..5f47472 100644 --- a/game.py +++ b/game.py @@ -2298,19 +2298,22 @@ def bank_add(world: dict, player: dict, item: str, qty: int, now: float, summary return keep -def credit(player: dict, item: str, qty: int, rate: float) -> None: +def credit(player: dict, item: str, qty: int, rate: float, worth: float | None = None) -> float: """Favour for giving `qty` of an item (or gold) to the city, at `rate` per point of worth. + `worth` overrides what they count as (a city order shares a piece's worth). Returns the favour. Every gift is tallied per item in `gave`: how many, their worth at the time, and the favour paid for them, so a later revalue can recompute favour exactly. """ - worth = qty * (1 if item == "gold" else ITEMS[item]["value"]) + if worth is None: + worth = qty * (1 if item == "gold" else ITEMS[item]["value"]) player["favour"] += worth * rate player["donated"] += worth tally = player["gave"].setdefault(item, {"qty": 0, "worth": 0, "favour": 0}) tally["qty"] += qty tally["worth"] += worth tally["favour"] += worth * rate + return worth * rate def bank_take(player: dict, item: str, qty: int) -> None: @@ -3048,7 +3051,7 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: return f"Needs {BUILDINGS[key]['name']} level {level}" order = working_order(world, player, action) if order is not None or "order" in (player["action"] or {}) and player["action"]["id"] == action["id"]: - if not order or order["status"] != "open" or order["claimed"] != player["id"]: + if not order or order["status"] != "open" or order["claimed"] != player["id"] and not order.get("city"): return "Work order done" if order and order["status"] == "done" else "Work order closed" for item, qty in fire_inputs(world, action).items(): pooled = order["provided"].get(item, 0) if order else 0 @@ -3907,10 +3910,15 @@ def take_summary(player: dict, summary: dict) -> dict: # 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. +# +# A city order asks on the city's behalf: what's made goes to the stockpile, and its +# favour is shared. Pooled reagents earn their worth for whoever gave them, as if +# donated; whoever makes or delivers it earns the rest of the piece's worth. Anyone +# can make it, the poster too, several at once. One posted for a building goes into +# its fund (or fits it) while it still needs it, the rest to the stockpile. 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 @@ -3938,14 +3946,22 @@ def _batches(action: dict, item: str, qty: int) -> int: return -(-qty // action["outputs"][item]) -def post_order(world: dict, player: dict, kind: str, item: str, qty, provide, reward, now: float) -> str | None: +def post_order(world: dict, player: dict, kind: str, item: str, qty, provide, reward, now: float, + city: bool = False, fund: str | None = None) -> str | None: if kind not in ("craft", "gather"): return "Bad order" source = (ORDER_RECIPES if kind == "craft" else ORDER_RAW).get(item) if not source: return "Nobody can make that" if kind == "craft" else "Nobody gathers that" - if not isinstance(qty, int) or not 1 <= qty <= ORDER_MAX[kind]: - return f"1 to {ORDER_MAX[kind]}" + if city and not city_uses(item): + return "The city has no use for that" + if fund is not None: + if not city or fund not in BUILDINGS: + return "Bad order" + if not fund_missing(world, fund).get(item) and FITTING_BY_ID.get(item, {}).get("building") != fund: + return f"The {BUILDINGS[fund]['name'].lower()} doesn't need that" + if not isinstance(qty, int) or qty < 1: + return "Bad amount" if not isinstance(reward, int) or reward < 0: return "Bad reward" if sum(o["by"] == player["id"] and o["status"] == "open" for o in world["orders"]) >= ORDER_LIMIT: @@ -3975,6 +3991,10 @@ def post_order(world: dict, player: dict, kind: str, item: str, qty, provide, re "skill": action["skill"], "level": action["level"], "provided": provided, "reward": total, "paid": 0, "delivered": 0, "held": 0, "claimed": None, "from": {}, "status": "open", "gave": {player["id"]: dict(provided)} if provided else {}, "earned": {}} + if city: + order.update(city=True, favour={}) + if fund: + order["fund"] = fund world["next_order_id"] += 1 world["orders"].append(order) return None @@ -4066,13 +4086,14 @@ def claim_order(world: dict, player: dict, order_id) -> str | None: order = order_by_id(world, order_id) if not order or order["status"] != "open" or order["kind"] != "craft": return "No such order" - if order["by"] == player["id"]: + if order["by"] == player["id"] and not order.get("city"): return "That's your own order" if order["claimed"] not in (None, player["id"]): return "Someone else has it" if skill_level(player, order["skill"]) < order["level"]: return f"Needs {order['skill']} {order['level']}" - order["claimed"] = player["id"] + if not order.get("city"): # a city order is never claimed: anyone can work it alongside the rest + order["claimed"] = player["id"] return None @@ -4081,7 +4102,7 @@ def drop_order(world: dict, player: dict, order_id, now: float) -> str | None: order = order_by_id(world, order_id) if not order or order["status"] != "open" or order["kind"] != "craft": return "No such order" - if order["claimed"] == player["id"]: + if order["claimed"] == player["id"] or order.get("city"): order["claimed"] = None if (player["action"] or {}).get("order") == order["id"]: finish_once(world, player, now) @@ -4121,9 +4142,9 @@ def deliver_order(world: dict, player: dict, order_id, now: float) -> str | None order = order_by_id(world, order_id) if not order or order["status"] != "open": return "No such order" - if order["by"] == player["id"]: + if order["by"] == player["id"] and not order.get("city"): return "That's your own order" - if order["kind"] == "craft": + if order["kind"] == "craft" and not order.get("city"): error = claim_order(world, player, order_id) if error: return error @@ -4141,8 +4162,8 @@ def _deliver(world: dict, order: dict, giver: dict, qty: int, now: float, 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["held"] += 0 if order.get("city") else 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(): @@ -4160,14 +4181,59 @@ def _deliver(world: dict, order: dict, giver: dict, qty: int, now: float, if pay: note(world, giver, now, "Earned", "gold", pay, who=order["by"]) owner = world["players"].get(order["by"]) - if owner: + if order.get("city"): + _to_city(world, order, giver, qty, now, used or {}) + elif owner: _hand_over(world, order, owner, now) 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'])}") + whose = "the city's" if order.get("city") else f"{owner['name']}'s" if owner else "someone's" + log(world, now, "order", f"{giver['name']} filled {whose} order: {amount(order['item'], order['qty'])}") + + +def _to_city(world: dict, order: dict, giver: dict, qty: int, now: float, used: dict) -> None: + """Put a city order's pieces where they go and share their favour: pooled reagents earn + their worth for whoever gave them (never more than the pieces are worth), the giver the rest.""" + item, rate = order["item"], city_rate(world, "favour") + label = BUILDINGS[order["fund"]]["name"] if order.get("fund") else "City order" + _store(world, order, qty, now) + worth = qty * ITEMS[item]["value"] + pooled = sum(ITEMS[i]["value"] * n for items in used.values() for i, n in items.items()) + scale = min(1, worth / pooled) if pooled else 0 + favour = order.setdefault("favour", {}) + for uid, items in used.items(): + helper = world["players"].get(uid) + if not helper: + continue + for reagent, n in items.items(): + favour[uid] = favour.get(uid, 0) + credit(helper, reagent, n, rate, ITEMS[reagent]["value"] * n * scale) + note(world, helper, now, "Donated", reagent, n, label=label) + favour[giver["id"]] = favour.get(giver["id"], 0) + credit(giver, item, qty, rate, worth - pooled * scale) + note(world, giver, now, "Donated", item, qty, label=label) + + +def _store(world: dict, order: dict, qty: int, now: float) -> None: + """A city order's pieces go to its building while it needs them, funds being safe from + raiders (a fitting is fitted), and the rest to the stockpile.""" + city, item, key = world["city"], order["item"], order.get("fund") + if key: + building, spec = city["buildings"][key], FITTING_BY_ID.get(item) + if spec and spec["building"] == key: + if item not in building["fittings"] and building["level"] >= spec["level"]: + building["fittings"].append(item) + qty -= 1 + log(world, now, "build", f"The {BUILDINGS[key]['name'].lower()} got {spec['name'].lower()} from a work order") + else: + into = min(qty, fund_missing(world, key).get(item, 0)) + if into: + building["fund"][item] = building["fund"].get(item, 0) + into + qty -= into + _queue_if_funded(world, key, now) + _start_next(world, now) + if qty: + city["stockpile"][item] = city["stockpile"].get(item, 0) + qty def _hand_over(world: dict, order: dict, owner: dict, now: float) -> None: @@ -6684,6 +6750,8 @@ 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-28", "title": "Queue", "notes": [ + ("new", "City work orders: 🏘️ City when posting, or 🔨 on anything a building needs (goes straight into its fund). Anyone can craft them, several at once. Reagents earn their worth in favour, the maker the rest."), + ("new", "Work orders: no size cap (was 500 crafted / 2,000 materials)."), ("new", "Added a personal ledger to character sheet"), ("new", "Work orders no longer erase your crafting queue"), ("new", "Items link to where they're made: click to jump to the recipe."), @@ -6832,7 +6900,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, "kit_defence": KIT_DEFENCE, "salve_cap": SALVE_CAP, "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, "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 8c4d217..460a8f0 100644 --- a/static/app.js +++ b/static/app.js @@ -19,6 +19,7 @@ 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 favourSeen = {}; // favour each city order had earned you let pickedClass = null; // registration let queueOpen = false; // the queue panel under the top bar const tabs = {}; // skill -> selected recipe tab @@ -2242,7 +2243,8 @@ function buildingCard(key, b) { const stores = Math.min(missing, gold ? city.treasury : city.stockpile[id] || 0); const chip = costChip(gold ? COIN : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing, !gold && id); return `
${chip} - ${mine ? `` : ""} + ${cityOrderButton(id, missing, key)} + ${mine ?`` : ""} ${stores ? `` : ""}
`; }).join("")}`; @@ -2256,6 +2258,20 @@ function buildingCard(key, b) { ${body}`; } +// A city work order for what a building still needs of an item, delivered into its fund (a fitting +// fitted): what's not in the stockpile or on order yet. Once it's all on order, the button shows those orders. +function cityOrderButton(id, missing, key) { + if (!C.order_recipes[id] && !C.order_raw[id]) return ""; + const ordered = (S.orders || []).filter((o) => o.city && o.status === "open" && o.item === id) + .reduce((n, o) => n + o.qty - o.delivered, 0); + const want = Math.min(missing, (fundNeeds()[id]?.qty || 0) - ordered); + if (want > 0) { + return ``; + } + return ordered ? `` : ""; +} + // Fittings Construction makes for this building: fitted, ready to fit, or waiting on a level. function fittingRows(key, b) { const rows = C.fittings.filter((f) => f.building === key).map((f) => { @@ -2270,7 +2286,7 @@ function fittingRows(key, b) { state = mine || stores ? `${mine ? `` : ""} ${stores ? `` : ""}` - : `Construction ${recipe.level}`; + : `Construction ${recipe.level}${cityOrderButton(f.id, 1, key)}`; } return `
${it.icon} ${esc(f.name)} ${esc(f.effect)} @@ -2401,9 +2417,9 @@ function raidPrep(ev) { const ORDER_ICON = () => glyph("scroll-unfurled", "#e3c060"); -// An order you could pick up: open, not yours, not claimed by someone else, and your level is high enough. +// An order you could pick up: open, not yours (a city order is anyone's), not claimed by someone else, and your level is high enough. function canTake(o) { - if (o.status !== "open" || o.by === S.uid) return false; + if (o.status !== "open" || o.by === S.uid && !o.city) return false; if (o.kind === "craft" && o.claimed && o.claimed !== S.uid) return false; return S.me.levels[o.skill] >= o.level; } @@ -2414,17 +2430,21 @@ function orderNews(state) { if (seenOrders) { for (const o of orders) { if (!seenOrders.has(o.id) && o.by !== uid && state.me && state.me.levels[o.skill] >= o.level && o.status === "open") { - toast(`📜 ${item(o.item).name}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""} · ${fmt(o.reward)} gold`, "level"); + toast(`📜 ${o.city ? "🏘️ " : ""}${item(o.item).name}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""}${o.reward || !o.city ? ` · ${fmt(o.reward)} gold` : ""}`, "level"); } const was = deliveredSeen[o.id]; - if (o.by === uid && was !== undefined && o.delivered > was) toast(`📦 ${item(o.item).name} ×${fmt(o.delivered - was)}`, "good"); + if (o.by === uid && !o.city && 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"); + // Favour from your reagents in someone else's make: your own makes show in the summary. + const fav = Math.round((o.favour || {})[uid] || 0), favHad = favourSeen[o.id]; + if (favHad !== undefined && fav > favHad && o.claimed !== uid) toast(`🏘️ ${item(o.item).name} · +${fmt(fav - favHad)} favour`, "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])); + favourSeen = Object.fromEntries(orders.map((o) => [o.id, Math.round((o.favour || {})[uid] || 0)])); } // What one run of a recipe takes right now: a lit fire pays its log (the forge a coal too). @@ -2459,7 +2479,9 @@ function orderSlots(o) { 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${gotoTip(id)}`; + // On a city order they count as donated: their worth in favour. + const pays = [share ? `${fmt(share)} gold` : "", o.city ? `${fmt(item(id).value * give)} favour` : ""].filter(Boolean).join(" + "); + const fill = give ? `${tip} · +${fmt(give)}${pays ? ` → ${pays}` : ""}` : `${tip} · 🎒 0${gotoTip(id)}`; // None to give: the slot links to where it's made instead. const click = give ? `data-order-fill="${o.id}" data-item="${id}"` : sourceOf(id) ? goto(id) : "disabled"; return `
@@ -2475,34 +2497,45 @@ function orderCard(o) { ? ` · ${Object.entries(o.from).map(([uid, n]) => `${esc(playerName(uid))} ${fmt(n)}`).join(", ")}` : ""}
` : ""; let buttons = "", status = ""; + // Your city order is still yours to cancel, and anyone's to fill: you too. + const cancel = mine && o.city ? `` : ""; if (o.status !== "open") { status = `${{ done: "Done", cancelled: "Cancelled", expired: "Expired" }[o.status]}`; if (mine && !o.held) buttons = ``; - } else if (mine) { + } else if (mine && !o.city) { buttons = ``; if (o.claimed) status = `${esc(playerName(o.claimed))}`; } else if (o.kind === "gather") { - buttons = ``; + buttons = `${cancel}`; } else if (o.claimed && o.claimed !== S.uid) { status = `${esc(playerName(o.claimed))}`; + buttons = cancel; } else { const working = S.me.action?.order === o.id; const why = short ? `Needs ${skill.name.toLowerCase()} ${o.level}` : S.me.blocked[o.action] && !working && !S.me.blocked[o.action].startsWith("Out of") ? S.me.blocked[o.action] : ""; - buttons = `${o.claimed === S.uid ? `` : ""} + buttons = `${cancel}${o.claimed === S.uid || o.city && working ? `` : ""} ${have ? `` : ""} ${working ? `Crafting` : ``}`; } const reward = o.kind === "gather" ? `${fmt(Math.round(o.reward / o.qty))} each` : fmt(o.reward); + // A city order pays its worth in favour, shared by whoever's reagents and work went in. + const favour = o.city ? `${FAVOUR} ${fmt(it.value * o.qty)}` : ""; + const favoured = Object.entries(o.favour || {}).filter(([, n]) => Math.round(n)); + // A city order is worked by anyone, several at once: who's at it right now. + const crafting = o.city && o.status === "open" ? S.players.filter((p) => p.order === o.id) : []; + const city = o.fund ? C.buildings[o.fund].name : "City"; return `
${it.icon}
${o.kind === "gather" ? "Wanted: " : ""}${esc(it.name)}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""}
-
${mine ? "Yours" : esc(playerName(o.by))} · ${ago(o.at)}${o.held ? ` · ${fmt(o.held)} waiting for bank space` : ""}
- ${skill.icon} ${o.level}
+
${o.city ? `🏘️ ${esc(city)} · ${mine ? "yours" : esc(playerName(o.by))}` : 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(", ")}
` : ""} + ${favoured.length ? `
${FAVOUR} ${favoured.map(([uid, n]) => `${esc(playerName(uid))} ${fmt(Math.round(n))}`).join(", ")}
` : ""} + ${crafting.length ? `
${skill.icon} ${crafting.map((p) => esc(p.name)).join(", ")}
` : ""} ${progress} -
${COIN} ${reward}${status}${buttons}
+
${o.reward || !o.city ? `${COIN} ${reward}` : ""}${favour}${status}${buttons}
`; } @@ -2517,7 +2550,7 @@ function orderPicker(list) { const q = (d.search || "").trim().toLowerCase(); const shown = q ? actions.filter(([id]) => item(id).name.toLowerCase().includes(q)) : inSkill.filter(([, a]) => !groups.length || a.group === d.group); - const others = S.players.filter((p) => p.id !== S.uid); + const others = S.players.filter((p) => p.id !== S.uid || d.city); // a city order you can make yourself const tiles = shown.sort((x, y) => x[1].level - y[1].level).map(([id, a]) => { const makers = others.filter((p) => p.levels[a.skill] >= a.level).length; const tip = `${item(id).name} · ${skillOf(a.skill).name} ${a.level}${d.kind === "craft" ? ` · 👥 ${makers}` : ""}`; @@ -2531,12 +2564,18 @@ function orderPicker(list) {
${tiles || `—`}
`; } +// What the draft can ask for: a city order only what the city has a use for. +function orderList() { + const list = draft.kind === "craft" ? C.order_recipes : C.order_raw; + return draft.city ? Object.fromEntries(Object.entries(list).filter(([id]) => C.city_uses[id].length)) : list; +} + function orderComposer() { - const d = draft, list = d.kind === "craft" ? C.order_recipes : C.order_raw; + const d = draft, list = orderList(); let slots = "", maker = ""; const a = d.item && C.actions.find((x) => x.id === list[d.item]); if (a) { - const smiths = S.players.filter((p) => p.id !== S.uid && p.levels[a.skill] >= a.level).length; + const smiths = S.players.filter((p) => (p.id !== S.uid || d.city) && p.levels[a.skill] >= a.level).length; maker = `
${skillOf(a.skill).icon} ${a.level} · 👥 ${smiths}
`; if (d.kind === "craft") { const runs = Math.ceil(d.qty / a.outputs[d.item]), ins = runInputs(a); @@ -2551,20 +2590,21 @@ function orderComposer() { } } const gold = d.kind === "craft" ? d.reward : d.reward * d.qty; - const why = !a ? "" : d.qty > C.order_max[d.kind] ? `Max ${fmt(C.order_max[d.kind])}` - : gold > S.me.gold ? `Gold ${fmt(S.me.gold)} / ${fmt(gold)}` : ""; + const why = !a ? "" : gold > S.me.gold ? `Gold ${fmt(S.me.gold)} / ${fmt(gold)}` : ""; + const fund = d.fund && `
🏘️ ${esc(C.buildings[d.fund].name)}
`; return `
-
+ +
${orderPicker(list)}
-
${a ? `
${item(d.item).icon}
${esc(item(d.item).name)}${maker}
` +
${a ? `
${item(d.item).icon}
${esc(item(d.item).name)}${maker}${fund || ""}
` : `
—
`} -
× - max ${fmt(C.order_max[d.kind])}
+
×
${slots ? `
${slots}
` : ""}
${COIN} ${d.kind === "gather" ? `each · ${fmt(gold)}` : ""}
+ ${d.city && a ? `
${FAVOUR} ${fmt(item(d.item).value * d.qty)}
` : ""}
${why ? `${esc(why)}` : ""}
`; @@ -3045,11 +3085,25 @@ document.addEventListener("click", (e) => { if ("queueSkip" in d) return act("/api/queue/skip", {}); if ("queueClear" in d) return act("/api/queue/clear", {}); if (d.join) { tagging = true; return act("/api/join", { id: d.join }); } + if (d.orderFor) { + // From a town square shortfall: a city order for that item and amount, ready to post. + draft = { kind: C.order_recipes[d.orderFor] ? "craft" : "gather", item: "", qty: Number(d.qty), provide: [], reward: 0, city: true }; + pickOrderItem(d.orderFor); + draft.fund = d.forFund; + return go("orders"); + } + if ("orderShow" in d) { orderTab = "open"; return go("orders"); } if ("orderNew" in d) { draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 }; return render(); } - if (d.draftKind) { draft = { kind: d.draftKind, item: "", qty: 1, provide: [], reward: 0 }; clearSearch(); return render(); } + if (d.draftKind) { draft = { kind: d.draftKind, item: "", qty: 1, provide: [], reward: 0, city: draft.city }; clearSearch(); return render(); } + if ("draftCity" in d) { + draft.city = !draft.city; + if (!draft.city) draft.fund = null; + pickOrderItem(draft.item); // keeps it if the city can use it; a city order starts at no gold + return render(); + } if (d.draftSkill) { draft.skill = d.draftSkill; draft.search = ""; clearSearch(); return render(); } if (d.draftGroup) { draft.group = d.draftGroup; return render(); } if (d.draftItem) { @@ -3067,7 +3121,7 @@ document.addEventListener("click", (e) => { // A fire lit since picking may now pay a reagent that was ticked. const a = draft.kind === "craft" && C.actions.find((x) => x.id === C.order_recipes[draft.item]); const provide = a ? draft.provide.filter((id) => runInputs(a)[id]) : draft.provide; - const body = { kind: draft.kind, item: draft.item, qty: draft.qty, provide, reward: draft.reward }; + const body = { kind: draft.kind, item: draft.item, qty: draft.qty, provide, reward: draft.reward, city: !!draft.city, fund: draft.fund || null }; return api("/api/order", body).then((state) => { draft = null; orderTab = "yours"; accept(state); }).catch((err) => toast(err.message, "bad")); } if (d.otab) { orderTab = d.otab; return render(); } @@ -3150,11 +3204,12 @@ function clearSearch() { } function pickOrderItem(id) { - const list = draft.kind === "craft" ? C.order_recipes : C.order_raw; + const list = orderList(); + if (id !== draft.item) draft.fund = null; // the building asked for that item, not this one draft.item = list[id] ? id : ""; draft.provide = []; - // A starting price: what the item's worth, each. - draft.reward = draft.item ? item(draft.item).value * (draft.kind === "craft" ? draft.qty : 1) : 0; + // A starting price: what the item's worth, each. The city pays in favour instead. + draft.reward = draft.item && !draft.city ? item(draft.item).value * (draft.kind === "craft" ? draft.qty : 1) : 0; } document.addEventListener("input", (e) => { @@ -3182,7 +3237,6 @@ document.addEventListener("change", (e) => { } else { const before = draft.qty; draft[key] = Math.max(key === "qty" ? 1 : 0, Math.floor(Number(e.target.value) || 0)); - if (key === "qty") draft.qty = Math.min(draft.qty, C.order_max[draft.kind]); // A crafting order's reward is the total: it follows the quantity at the same price each. if (key === "qty" && draft.kind === "craft") draft.reward = Math.round(draft.reward / before * draft.qty); if (key === "qty" && draft.kind === "craft" && draft.item) { diff --git a/tests/test_game.py b/tests/test_game.py index 43de35b..93d686f 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -2737,6 +2737,79 @@ class WorkOrderTest(unittest.TestCase): self.assertEqual(order["status"], "done") self.assertEqual(self.dave["bank"], {"clay_core": 3}) + def test_city_orders_fill_the_stockpile_and_share_the_favour(self): + self.assertIsNone(game.post_order(self.world, self.fred, "craft", "iron_warhammer", 1, [], 0, T0, city=True)) + order = self.world["orders"][0] + self.dave["bank"] = {"iron_bar": 2} + self.mike["bank"] = {"clay_core": 3} + game.provide_reagent(self.world, self.dave, order["id"], "iron_bar") + game.provide_reagent(self.world, self.mike, order["id"], "clay_core") + self.fred["bank"] = {"iron_bar": 1, "oak_plank": 1} + self.assertIsNone(game.work_order(self.world, self.fred, order["id"], T0)) # the poster can make it + game.process_player(self.world, self.fred, T0 + 600, random.Random(1)) + self.assertEqual(order["status"], "done") + self.assertEqual(self.world["city"]["stockpile"].get("iron_warhammer"), 1) + self.assertEqual(self.fred["bank"], {}) + # Hammer 101: Dave's bars 20 and Mike's cores 45 as if donated, Fred the other 36. + self.assertEqual(order["favour"], {"dave": 20, "mike": 45, "fred": 36}) + self.assertEqual((self.dave["favour"], self.mike["favour"], self.fred["favour"]), (20, 45, 36)) + line = self.mike["ledger"][-1] + self.assertEqual((line["verb"], line["item"], line["qty"], line["label"]), ("Donated", "clay_core", 3, "City order")) + + def test_city_materials_orders_are_delivered_to_the_stockpile(self): + self.assertEqual(game.post_order(self.world, self.mike, "gather", "copper_ore", 10, None, 0, T0, city=True), + "The city has no use for that") + self.assertIsNone(game.post_order(self.world, self.mike, "gather", "oak_logs", 100, None, 0, T0, city=True)) + order = self.world["orders"][0] + self.mike["bank"] = {"oak_logs": 30} + self.dave["bank"] = {"oak_logs": 80} + self.assertIsNone(game.deliver_order(self.world, self.mike, order["id"], T0)) + self.assertIsNone(game.deliver_order(self.world, self.dave, order["id"], T0)) + self.assertEqual(self.world["city"]["stockpile"].get("oak_logs"), 100) + self.assertEqual(order["favour"], {"mike": 120, "dave": 280}) + self.assertEqual((order["status"], order["held"], self.dave["bank"]), ("done", 0, {"oak_logs": 10})) + + def test_several_crafters_work_a_city_order_at_once(self): + game.post_order(self.world, self.mike, "craft", "iron_warhammer", 2, [], 0, T0, city=True) + order = self.world["orders"][0] + self.fred["bank"] = {"iron_bar": 3, "oak_plank": 1, "clay_core": 3} + self.dave["bank"] = {"iron_bar": 3, "oak_plank": 1, "clay_core": 3} + self.assertIsNone(game.work_order(self.world, self.fred, order["id"], T0)) + self.assertIsNone(game.work_order(self.world, self.dave, order["id"], T0)) # nobody holds a claim + game.process_player(self.world, self.fred, T0 + 600, random.Random(1)) + game.process_player(self.world, self.dave, T0 + 600, random.Random(1)) + self.assertEqual((order["status"], order["from"]), ("done", {"fred": 1, "dave": 1})) + self.assertEqual(self.world["city"]["stockpile"].get("iron_warhammer"), 2) + + def test_a_building_order_fills_its_fund_then_the_stockpile(self): + self.world["city"]["buildings"]["houses"]["fund"] = {} + need = game.fund_missing(self.world, "houses") + item = next(i for i in need if i in game.ORDER_RAW) + self.assertEqual(game.post_order(self.world, self.mike, "gather", "copper_ore", 5, None, 0, T0, city=True, fund="houses"), + "The city has no use for that") + self.assertIsNone(game.post_order(self.world, self.mike, "gather", item, need[item] + 5, None, 0, T0, city=True, fund="houses")) + order = self.world["orders"][0] + self.fred["bank"] = {item: need[item] + 5} + stock = self.world["city"]["stockpile"].get(item, 0) + self.assertIsNone(game.deliver_order(self.world, self.fred, order["id"], T0)) + self.assertNotIn(item, game.fund_missing(self.world, "houses")) + self.assertEqual(self.world["city"]["stockpile"].get(item, 0), stock + 5) # the fund was full + self.assertEqual(self.fred["ledger"][-1]["label"], "Houses") + + def test_a_building_order_fits_its_fitting(self): + spec = next(f for f in game.FITTING_BY_ID.values() if f["id"] in game.ORDER_RECIPES) + building = self.world["city"]["buildings"][spec["building"]] + building["level"] = spec["level"] + self.assertIsNone(game.post_order(self.world, self.mike, "craft", spec["id"], 2, [], 0, T0, city=True, fund=spec["building"])) + self.fred["bank"] = {spec["id"]: 2} + self.assertIsNone(game.deliver_order(self.world, self.fred, self.world["orders"][0]["id"], T0)) + self.assertIn(spec["id"], building["fittings"]) + self.assertEqual(self.world["city"]["stockpile"].get(spec["id"]), 1) # one fits, the spare is stored + + def test_orders_have_no_size_cap(self): + self.assertIsNone(game.post_order(self.world, self.mike, "gather", "oak_logs", 50_000, None, 0, T0, city=True)) + self.assertEqual(game.post_order(self.world, self.mike, "gather", "oak_logs", 0, None, 0, T0), "Bad amount") + class Always: """An rng where every roll comes up: a gem, a drop, whatever's on offer."""