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 `
`;
}).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 `