City work orders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b0724e6e5
commit
e1d37ede87
4 changed files with 243 additions and 47 deletions
3
app.py
3
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":
|
||||
|
|
|
|||
100
game.py
100
game.py
|
|
@ -2298,12 +2298,14 @@ 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.
|
||||
"""
|
||||
if worth is None:
|
||||
worth = qty * (1 if item == "gold" else ITEMS[item]["value"])
|
||||
player["favour"] += worth * rate
|
||||
player["donated"] += worth
|
||||
|
|
@ -2311,6 +2313,7 @@ def credit(player: dict, item: str, qty: int, rate: float) -> None:
|
|||
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,12 +4086,13 @@ 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']}"
|
||||
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,
|
||||
|
|
|
|||
110
static/app.js
110
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 `<div class="fund-row">${chip}<span class="btns">
|
||||
${mine ? `<button class="btn sm gold" data-fund="${key}" data-item="${id}" data-source="bank" title="From your ${gold ? "purse" : "bank"}, earns favour">Give ${fmt(mine)}</button>` : ""}
|
||||
${cityOrderButton(id, missing, key)}
|
||||
${mine ?`<button class="btn sm gold" data-fund="${key}" data-item="${id}" data-source="bank" title="From your ${gold ? "purse" : "bank"}, earns favour">Give ${fmt(mine)}</button>` : ""}
|
||||
${stores ? `<button class="btn sm" data-fund="${key}" data-item="${id}" data-source="city" title="From the city's ${gold ? "treasury" : "stockpile"}">${gold ? "Treasury" : "Stockpile"} ${fmt(stores)}</button>` : ""}
|
||||
</span></div>`;
|
||||
}).join("")}</div>`;
|
||||
|
|
@ -2256,6 +2258,20 @@ function buildingCard(key, b) {
|
|||
</div></div>${body}</div>`;
|
||||
}
|
||||
|
||||
// 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 `<button class="btn sm" data-order-for="${id}" data-qty="${want}" data-for-fund="${key}"
|
||||
title="${esc(`City work order: ${item(id).name} ×${fmt(want)} for the ${C.buildings[key].name.toLowerCase()}`)}">${glyph("claw-hammer", "#e3c060")}</button>`;
|
||||
}
|
||||
return ordered ? `<button class="btn sm" data-order-show title="${esc(`On order: ${fmt(ordered)}`)}">${ORDER_ICON()} ${fmt(ordered)}</button>` : "";
|
||||
}
|
||||
|
||||
// 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 ? `<button class="btn sm gold" data-install="${f.id}" data-source="bank" title="From your bank, earns favour">Fit</button>` : ""}
|
||||
${stores ? `<button class="btn sm" data-install="${f.id}" data-source="city" title="From the city's stockpile">Fit from stockpile</button>` : ""}`
|
||||
: `<span class="muted small">Construction ${recipe.level}</span>`;
|
||||
: `<span class="muted small">Construction ${recipe.level}</span>${cityOrderButton(f.id, 1, key)}`;
|
||||
}
|
||||
return `<div class="fitting-row"><span class="fit-ico">${it.icon}</span>
|
||||
<span class="fitting-text"><b${goto(f.id)} title="${esc(gotoTip(f.id).trim())}">${esc(f.name)}</b> <span class="muted small">${esc(f.effect)}</span></span>
|
||||
|
|
@ -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 `<div class="ro-slot open"><button class="doll-slot empty-req" ${click} title="${esc(fill)}">${item(id).icon}</button>
|
||||
|
|
@ -2475,34 +2497,45 @@ function orderCard(o) {
|
|||
? ` · ${Object.entries(o.from).map(([uid, n]) => `${esc(playerName(uid))} ${fmt(n)}`).join(", ")}` : ""}</div>
|
||||
<div class="bar"><span style="width:${o.delivered / o.qty * 100}%"></span></div>` : "";
|
||||
let buttons = "", status = "";
|
||||
// Your city order is still yours to cancel, and anyone's to fill: you too.
|
||||
const cancel = mine && o.city ? `<button class="btn danger" data-order-cancel="${o.id}">Cancel</button>` : "";
|
||||
if (o.status !== "open") {
|
||||
status = `<span class="tag ${o.status === "done" ? "good" : ""}">${{ done: "Done", cancelled: "Cancelled", expired: "Expired" }[o.status]}</span>`;
|
||||
if (mine && !o.held) buttons = `<button class="btn" data-order-remove="${o.id}">Remove</button>`;
|
||||
} else if (mine) {
|
||||
} else if (mine && !o.city) {
|
||||
buttons = `<button class="btn danger" data-order-cancel="${o.id}">Cancel</button>`;
|
||||
if (o.claimed) status = `<span class="tag">${esc(playerName(o.claimed))}</span>`;
|
||||
} else if (o.kind === "gather") {
|
||||
buttons = `<button class="btn primary" data-order-deliver="${o.id}" ${have ? "" : "disabled"}>Deliver ${fmt(Math.min(have, left))}</button>`;
|
||||
buttons = `${cancel}<button class="btn primary" data-order-deliver="${o.id}" ${have ? "" : "disabled"}>Deliver ${fmt(Math.min(have, left))}</button>`;
|
||||
} else if (o.claimed && o.claimed !== S.uid) {
|
||||
status = `<span class="tag">${esc(playerName(o.claimed))}</span>`;
|
||||
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 ? `<button class="btn" data-order-drop="${o.id}">Drop</button>` : ""}
|
||||
buttons = `${cancel}${o.claimed === S.uid || o.city && working ? `<button class="btn" data-order-drop="${o.id}">Drop</button>` : ""}
|
||||
${have ? `<button class="btn" data-order-deliver="${o.id}">Deliver ${fmt(Math.min(have, left))}</button>` : ""}
|
||||
${working ? `<span class="tag gold">Crafting</span>` : `<button class="btn primary" data-order-work="${o.id}" ${why ? `disabled title="${esc(why)}"` : ""}>Craft</button>`}`;
|
||||
}
|
||||
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 ? `<span class="item-chip ro-reward" title="Favour, shared">${FAVOUR} ${fmt(it.value * o.qty)}</span>` : "";
|
||||
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 `<div class="action order">
|
||||
<div class="top"><div class="ico">${it.icon}</div><div>
|
||||
<div class="name">${o.kind === "gather" ? "Wanted: " : ""}${esc(it.name)}${o.qty > 1 ? ` ×${fmt(o.qty)}` : ""}</div>
|
||||
<div class="sub">${mine ? "Yours" : esc(playerName(o.by))} · ${ago(o.at)}${o.held ? ` · ${fmt(o.held)} waiting for bank space` : ""}</div></div>
|
||||
<span class="ro-req ${short && !mine ? "bad" : ""}" title="${esc(`${skill.name} ${o.level}`)}">${skill.icon} ${o.level}</span></div>
|
||||
<div class="sub">${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` : ""}</div></div>
|
||||
<span class="ro-req ${short && (!mine || o.city) ? "bad" : ""}" title="${esc(`${skill.name} ${o.level}`)}">${skill.icon} ${o.level}</span></div>
|
||||
${o.kind === "craft" ? `<div class="ro-slots">${orderSlots(o)}</div>` : ""}
|
||||
${Object.keys(o.earned || {}).length ? `<div class="small muted">🤝 ${Object.entries(o.earned).map(([uid, n]) => `${esc(playerName(uid))} ${fmt(n)}`).join(", ")}</div>` : ""}
|
||||
${favoured.length ? `<div class="small muted">${FAVOUR} ${favoured.map(([uid, n]) => `${esc(playerName(uid))} ${fmt(Math.round(n))}`).join(", ")}</div>` : ""}
|
||||
${crafting.length ? `<div class="small muted" title="Crafting now">${skill.icon} ${crafting.map((p) => esc(p.name)).join(", ")}</div>` : ""}
|
||||
${progress}
|
||||
<div class="foot"><span class="item-chip ro-reward">${COIN} ${reward}</span>${status}<span class="btns">${buttons}</span></div>
|
||||
<div class="foot">${o.reward || !o.city ? `<span class="item-chip ro-reward">${COIN} ${reward}</span>` : ""}${favour}${status}<span class="btns">${buttons}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
|||
<div class="swap-grid pick-grid">${tiles || `<span class="muted small">—</span>`}</div></div>`;
|
||||
}
|
||||
|
||||
// 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 = `<div class="small muted" title="${esc(`${skillOf(a.skill).name} ${a.level} · ${smiths} can make it`)}">${skillOf(a.skill).icon} ${a.level} · 👥 ${smiths}</div>`;
|
||||
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 && `<div class="small muted" title="Delivered into its fund">🏘️ ${esc(C.buildings[d.fund].name)}</div>`;
|
||||
return `<div class="panel compose">
|
||||
<div class="tabs" style="margin:0 0 14px"><button class="tab ${d.kind === "craft" ? "on" : ""}" data-draft-kind="craft">Craft</button>
|
||||
<button class="tab ${d.kind === "gather" ? "on" : ""}" data-draft-kind="gather">Materials</button></div>
|
||||
<button class="tab ${d.kind === "gather" ? "on" : ""}" data-draft-kind="gather">Materials</button>
|
||||
<button class="tab ${d.city ? "on" : ""}" data-draft-city title="For the city: goes to the stockpile, favour shared by whoever helps">🏘️ City</button></div>
|
||||
${orderPicker(list)}
|
||||
<div class="compose-grid">
|
||||
<div>${a ? `<div class="ro-pick"><span class="doll-slot on">${item(d.item).icon}</span><div><b>${esc(item(d.item).name)}</b>${maker}</div></div>`
|
||||
<div>${a ? `<div class="ro-pick"><span class="doll-slot on">${item(d.item).icon}</span><div><b>${esc(item(d.item).name)}</b>${maker}${fund || ""}</div></div>`
|
||||
: `<div class="ro-pick"><span class="doll-slot empty-req"></span><span class="muted">—</span></div>`}
|
||||
<div class="stepper">×<input type="number" min="1" max="${C.order_max[d.kind]}" value="${d.qty}" data-draft="qty">
|
||||
<span class="muted small">max ${fmt(C.order_max[d.kind])}</span></div></div>
|
||||
<div class="stepper">×<input type="number" min="1" value="${d.qty}" data-draft="qty"></div></div>
|
||||
<div>${slots ? `<div class="ro-slots big">${slots}</div>` : ""}</div>
|
||||
<div><div class="reward-input">${COIN}<input type="number" min="0" value="${d.reward}" data-draft="reward">
|
||||
${d.kind === "gather" ? `<span class="muted small">each · ${fmt(gold)}</span>` : ""}</div>
|
||||
${d.city && a ? `<div class="small muted" title="Favour, shared">${FAVOUR} ${fmt(item(d.item).value * d.qty)}</div>` : ""}
|
||||
<div class="btns"><button class="btn primary" data-order-post ${a && !why ? "" : "disabled"}>Post</button>
|
||||
${why ? `<span class="why">${esc(why)}</span>` : ""}</div></div>
|
||||
</div></div>`;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue