diff --git a/README.md b/README.md index cc7e2a2..b691596 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,13 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. faster pace. Every class can train every skill, scout and drill. Building costs draw on all four trades: thatch, flax for rope, hide glue, leather and provisions as well as wood, stone and metal. +- **Work orders:** players post requests on a board. A crafting order names + the item and lets the requester put in any of its reagents; one crafter + claims it and makes it with their own materials for the rest, and each piece + goes straight to the requester. A materials request is open to anyone with + the items. Reagents and the reward are held by the order, paid out as it's + filled and refunded if it's cancelled or expires (3 days, 5 open each). + Players who can take an order see a badge, a toast and a banner on the skill. - **Bank:** 12 slots of 1,000 each to start; more than a stack of one item spills into another slot. When something doesn't fit it goes to the city stockpile (or the action stops, if you prefer). diff --git a/app.py b/app.py index aa9796a..3fb0120 100644 --- a/app.py +++ b/app.py @@ -323,6 +323,17 @@ class Handler(BaseHTTPRequestHandler): return "Bad quantity" if path == "/api/action": return game.start_action(world, player, str(body.get("id")), now, once=bool(body.get("once"))) + if path == "/api/order": + return game.post_order(world, player, body.get("kind"), str(item), qty, body.get("provide"), + body.get("reward"), now) + if path == "/api/order/cancel": + return game.cancel_order(world, player, body.get("id"), now) + if path == "/api/order/work": + return game.work_order(world, player, body.get("id"), now) + if path == "/api/order/drop": + return game.claim_order(world, player, body.get("id"), claim=False) + if path == "/api/order/deliver": + return game.deliver_order(world, player, body.get("id"), now) if path == "/api/seen": event_id = body.get("id") if not isinstance(event_id, int): @@ -502,6 +513,7 @@ class Handler(BaseHTTPRequestHandler): "favours": game.favours(world, now), "happenings": game.happenings_view(world, now), "history": history, + "orders": world["orders"], "threat_report": unseen and {"id": unseen["id"], "kind": unseen["kind"], "at": unseen["resolved"], "report": unseen["report"]}, "players": roster(world, now), diff --git a/game.py b/game.py index deb8f73..f323ef4 100644 --- a/game.py +++ b/game.py @@ -945,7 +945,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 16 +WORLD_VERSION = 17 def new_world(now: float, speed: float = 1.0) -> dict: @@ -978,6 +978,7 @@ def new_world(now: float, speed: float = 1.0) -> dict: "ledger": [], "board": [], "next_message_id": 1, + "orders": [], "next_order_id": 1, } @@ -1083,6 +1084,10 @@ def migrate(world: dict, now: float | None = None) -> dict: if world["version"] < 16: _migrate_combat_rework(world) world["version"] = 16 + if world["version"] < 17: + world.setdefault("orders", []) + world.setdefault("next_order_id", 1) + world["version"] = 17 return world @@ -1781,8 +1786,15 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: for key, level in action.get("city", {}).items(): if building_level(world, key) < level: 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"]: + return "Work order done" if order and order["status"] == "done" else "Work order closed" for item, qty in action["inputs"].items(): - if player["bank"].get(item, 0) < qty: + 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: return f"Out of {ITEMS[item]['name'].lower()}" if action.get("special") == "sabotage": raid = upcoming_raid(world) @@ -1812,10 +1824,16 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None: brew = potion_effect(player, action["skill"]) - if action["inputs"] and rng.random() < brew.get("keep", 0): + order = working_order(world, player, action) + own = {item: qty for item, qty in action["inputs"].items() if not order or item not in order["provided"]} + if order: + for item, qty in action["inputs"].items(): + if item in order["provided"]: + order["provided"][item] -= qty + if own and rng.random() < brew.get("keep", 0): summary["kept"] += 1 else: - for item, qty in action["inputs"].items(): + for item, qty in own.items(): bank_take(player, item, qty) xp = action["xp"] cls = CLASSES[player["cls"]] @@ -1923,7 +1941,16 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R if action.get("special") != "drill": _use_charge(player, action["skill"], summary) + if order and order["status"] == "open": + # The order's share goes to the requester; a masterwork's extra stays with the crafter. + sent = min(outputs.get(order["item"], 0), order["qty"] - order["delivered"]) + if sent: + outputs[order["item"]] -= sent + _deliver(world, order, player, sent, now) + summary["delivered"] = summary.get("delivered", 0) + sent for item, qty in outputs.items(): + if not qty: + continue kept = bank_add(world, player, item, qty, now, summary) if kept: summary["items"][item] = summary["items"].get(item, 0) + kept @@ -1955,6 +1982,7 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N """Run the player's action forward to `now` (capped at 24h past last_active).""" rng = rng or random.Random() summary = empty_summary() + collect_orders(world, player) current = player["action"] if not current: return summary @@ -2022,6 +2050,209 @@ def stop(player: dict, reason: str | None = None) -> None: player["stopped"] = reason +# --- 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. + +ORDER_LIMIT = 5 # open orders per player +ORDER_DAYS = 3 # before an order expires and is refunded +ORDER_MAX = {"craft": 50, "gather": 2000} + + +def _order_sources() -> tuple[dict, dict]: + craft, gather = {}, {} + for action in sorted(ACTIONS, key=lambda a: a["level"]): + if action.get("special") or "enemy" in action or not action["outputs"]: + continue + item = next(iter(action["outputs"])) + if action["skill"] in CRAFTING: + craft.setdefault(item, action["id"]) + elif action["skill"] in GATHERING: + for output in action["outputs"]: + gather.setdefault(output, action["id"]) + return craft, gather + + + +def order_by_id(world: dict, order_id) -> dict | None: + return next((o for o in world["orders"] if o["id"] == order_id), None) + + +def _batches(action: dict, item: str, qty: int) -> int: + """How many times the recipe runs to make `qty` of its item.""" + return -(-qty // action["outputs"][item]) + + +def post_order(world: dict, player: dict, kind: str, item: str, qty, provide, reward, now: float) -> 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 "Bad quantity" + 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: + return f"At most {ORDER_LIMIT} open orders" + action = ACTION_BY_ID[source] + total = reward if kind == "craft" else reward * qty # a materials request pays per item + if player["gold"] < total: + return "Not enough gold" + provided = {} + if kind == "craft": + runs = _batches(action, item, qty) + for input_item in provide or []: + if input_item not in action["inputs"]: + return "That isn't in the recipe" + need = action["inputs"][input_item] * runs + if player["bank"].get(input_item, 0) < need: + return f"Not enough {ITEMS[input_item]['name'].lower()}" + provided[input_item] = need + for input_item, need in provided.items(): + bank_take(player, input_item, need) + player["gold"] -= total + 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"} + world["next_order_id"] += 1 + world["orders"].append(order) + return None + + +def _refund(world: dict, order: dict, now: float) -> None: + 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"] + order["paid"] = order["reward"] + + +def cancel_order(world: dict, player: dict, order_id, now: float) -> str | None: + order = order_by_id(world, order_id) + if not order or order["by"] != player["id"] or order["status"] != "open": + return "No such order" + order["status"] = "cancelled" + _refund(world, order, now) + return None + + +def claim_order(world: dict, player: dict, order_id, claim: bool = True) -> 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 not claim: + if order["claimed"] == player["id"]: + order["claimed"] = None + if (player["action"] or {}).get("order") == order["id"]: + stop(player) + return None + if order["by"] == player["id"]: + 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"] + return None + + +def work_order(world: dict, player: dict, order_id, now: float) -> str | None: + """Claim a crafting order and start making it.""" + order = order_by_id(world, order_id) + had = order and order["claimed"] == player["id"] + error = claim_order(world, player, order_id) + if error: + return error + previous = player["action"] + player["action"] = {"id": order["action"], "start": now, "order": order["id"]} + reason = can_start(world, player, ACTION_BY_ID[order["action"]]) + if reason: + player["action"] = previous + if not had: + order["claimed"] = None # couldn't start: don't sit on it + return reason + player["stopped"] = None + return None + + +def deliver_order(world: dict, player: dict, order_id, now: float) -> str | None: + """Hand over what's asked for straight from your bank.""" + order = order_by_id(world, order_id) + if not order or order["status"] != "open": + return "No such order" + if order["by"] == player["id"]: + return "That's your own order" + if order["kind"] == "craft": + error = claim_order(world, player, order_id) + if error: + return error + qty = min(player["bank"].get(order["item"], 0), order["qty"] - order["delivered"]) + if not qty: + return f"No {ITEMS[order['item']]['name'].lower()} in your bank" + bank_take(player, order["item"], qty) + _deliver(world, order, player, qty, now) + return None + + +def _deliver(world: dict, order: dict, giver: dict, qty: int, now: float) -> None: + 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 + giver["gold"] += pay + owner = world["players"].get(order["by"]) + if owner: + _hand_over(world, order, owner) + if order["delivered"] >= order["qty"]: + order["status"] = "done" + order["done"] = now + log(world, now, "order", f"{giver['name']} filled {owner['name'] if owner else 'someone'}'s order: " + f"{amount(order['item'], order['qty'])}") + + +def _hand_over(world: dict, order: dict, owner: dict) -> None: + """Put delivered items in the requester's bank; what doesn't fit waits on the order.""" + fits = min(order["held"], bank_room(world, owner, order["item"])) + if fits: + owner["bank"][order["item"]] = owner["bank"].get(order["item"], 0) + fits + order["held"] -= fits + + +def collect_orders(world: dict, player: dict) -> None: + for order in world["orders"]: + if order["by"] == player["id"] and order["held"]: + _hand_over(world, order, player) + + +def expire_orders(world: dict, now: float) -> None: + for order in world["orders"]: + if order["status"] == "open" and now >= order["expires"]: + order["status"] = "expired" + _refund(world, order, now) + # Forget finished orders after a while, once everything's been handed over. + world["orders"] = [o for o in world["orders"] if o["status"] == "open" or o["held"] + or now - o.get("done", o["expires"]) < DAY / speed(world)] + + +def working_order(world: dict, player: dict, action: dict) -> dict | None: + """The work order this action is for, if the player is crafting one.""" + current = player["action"] or {} + if "order" not in current or current["id"] != action["id"]: + return None + return order_by_id(world, current["order"]) + + def start_action(world: dict, player: dict, action_id: str, now: float, once: bool = False) -> str | None: """Start an action. `once` does it a single time, then goes back to what you were doing.""" action = ACTION_BY_ID.get(action_id) @@ -2371,6 +2602,7 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool: while city["last_tick"] + step <= now: city["last_tick"] += step _happenings_hour(world, city["last_tick"], rng) + expire_orders(world, city["last_tick"]) _city_hour(world, city["last_tick"]) changed = True changed = advance_weather(world, now, rng) or changed @@ -3560,7 +3792,7 @@ ACTION_GLYPHS.update({ for _index, (_area, _level, _prefix, _races) in enumerate(AREAS): for _race, *_rest in _races: ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index]) -UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", "stopwatch", "swordman", "biceps", "cancel", "striking-diamonds", +UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", "stopwatch", "swordman", "biceps", "cancel", "scroll-unfurled", "striking-diamonds", "archery-target", "dodging", "shield-reflect", "cape", "gem-pendant", "gauntlet", "ring", "quiver", "leather-boot", "crystal-ball", "hot-meal", "round-bottom-flask", "visored-helm", "breastplate", "leg-armor", "pointy-sword", "round-shield", @@ -3639,6 +3871,8 @@ def all_glyphs() -> set[str]: _assign_glyphs() +# Every recipe and gathering action exists by now: what can be put up as a work order. +ORDER_RECIPES, ORDER_RAW = _order_sources() def content() -> dict: @@ -3652,7 +3886,8 @@ def content() -> dict: "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS, "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, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), + "potion_charges": POTION_CHARGES, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW, + "order_limit": ORDER_LIMIT, "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, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE, diff --git a/static/app.js b/static/app.js index 8f9bb76..1ef21fe 100644 --- a/static/app.js +++ b/static/app.js @@ -12,6 +12,10 @@ let skew = 0; // server clock minus client clock, seconds let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town"; let selected = null; // bank item let dollPick = null; // equipment slot shown on the character sheet +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 pickedClass = null; // registration const tabs = {}; // skill -> selected recipe tab let refreshing = false; @@ -93,6 +97,7 @@ function accept(state) { S = state; skew = state.now - Date.now() / 1000; if (state.summary) showSummary(state.summary); + orderNews(state); const ev = state.upcoming; if (ev?.at_gates && warned !== ev.id) { warned = ev.id; @@ -307,6 +312,7 @@ function sidebar() { const me = S.me, cls = C.classes[me.cls]; const doing = me.action && C.actions.find((a) => a.id === me.action.id); const raid = S.upcoming?.kind === "raid"; + const takeable = (S.orders || []).filter(canTake).length; let html = `
Guildhall alpha
@@ -317,6 +323,7 @@ function sidebar() { ${nav("town", "🏘️", "Town square", `${S.city.population}`, "", `Population ${S.city.population}`)} ${nav("council", "📜", "Council & threats", "", raid ? `` : "", raid ? "A threat is coming" : "")} + ${nav("orders", ORDER_ICON(), "Work orders", "", takeable ? `${takeable}` : "", takeable ? `${takeable} for you` : "")} ${nav("board", "💬", "Town board")} ${nav("roster", "👥", "Guild roster", `${S.players.length}`, "", `${S.players.length} ${S.players.length === 1 ? "adventurer" : "adventurers"}`)} @@ -350,7 +357,7 @@ function devbar() {
`; } -const TITLES = { town: "Town square", council: "Council & threats", board: "Town board", roster: "Guild roster", bank: "Bank", character: "Character" }; +const TITLES = { town: "Town square", council: "Council & threats", orders: "Work orders", board: "Town board", roster: "Guild roster", bank: "Bank", character: "Character" }; function topbar() { let title = TITLES[page] || "Guildhall"; @@ -390,7 +397,7 @@ function pageHtml() { } if (page.startsWith("character:")) return characterSheet(page.slice(10)); if (page === "character") return characterSheet(S.uid); - return ({ town, council, board, roster, bank }[page] || town)(); + return ({ town, council, orders, board, roster, bank }[page] || town)(); } function itemChip(id, qty, have) { @@ -429,6 +436,11 @@ function skillPage(id) { html += skillGear(id); if (id === "reconnaissance") html += `
Raises intel on the coming threat. Higher levels open as it nears.
`; html += skillHappenings(id); + const wanted = (S.orders || []).filter((o) => o.skill === id && canTake(o)); + if (wanted.length) html += ``; const live = new Set(S.happenings.problems.map((p) => p.kind)); const listed = C.actions.filter((a) => a.skill === id && (a.special !== "problem" || live.has(a.problem))); // Tabs and sections keep their designed order; what's inside each is sorted by the level it needs. @@ -1148,6 +1160,140 @@ function raidPrep(ev) { `; } +// --- work orders ------------------------------------------------------------ + +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. +function canTake(o) { + if (o.status !== "open" || o.by === S.uid) return false; + if (o.kind === "craft" && o.claimed && o.claimed !== S.uid) return false; + return S.me.levels[o.skill] >= o.level; +} + +// Toasts for new orders you can take, and for things delivered to you. Nothing on the first load. +function orderNews(state) { + const orders = state.orders || [], uid = state.uid; + 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"); + } + const was = deliveredSeen[o.id]; + if (o.by === uid && was !== undefined && o.delivered > was) toast(`📦 ${item(o.item).name} ×${fmt(o.delivered - was)}`, "good"); + } + } + seenOrders = new Set(orders.map((o) => o.id)); + deliveredSeen = Object.fromEntries(orders.map((o) => [o.id, o.delivered])); +} + +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); + 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 need = per * (o.status === "open" ? left : all), have = S.me.bank[id] || 0; + return `
${item(id).icon} + ${o.by === S.uid ? `×${fmt(need)}` : `${fmt(have)} / ${fmt(need)}`}
`; + }).join(""); +} + +function orderCard(o) { + const it = item(o.item), mine = o.by === S.uid, skill = skillOf(o.skill); + const short = S.me.levels[o.skill] < o.level, have = S.me.bank[o.item] || 0, left = o.qty - o.delivered; + const progress = o.qty > 1 || o.kind === "gather" + ? `
${fmt(o.delivered)} / ${fmt(o.qty)}${o.kind === "gather" && Object.keys(o.from).length + ? ` · ${Object.entries(o.from).map(([uid, n]) => `${esc(playerName(uid))} ${fmt(n)}`).join(", ")}` : ""}
+
` : ""; + let buttons = "", status = ""; + if (o.status !== "open") { + status = `${{ done: "Done", cancelled: "Cancelled", expired: "Expired" }[o.status]}`; + } else if (mine) { + buttons = ``; + if (o.claimed) status = `${esc(playerName(o.claimed))}`; + } else if (o.kind === "gather") { + buttons = ``; + } else if (o.claimed && o.claimed !== S.uid) { + status = `${esc(playerName(o.claimed))}`; + } 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 ? `` : ""} + ${have ? `` : ""} + ${working ? `Crafting` : ``}`; + } + const reward = o.kind === "gather" ? `${fmt(Math.round(o.reward / o.qty))} each` : fmt(o.reward); + 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.kind === "craft" ? `
${orderSlots(o)}
` : ""} + ${progress} +
${COIN} ${reward}${status}${buttons}
+
`; +} + +function orderComposer() { + const d = draft, list = d.kind === "craft" ? C.order_recipes : C.order_raw; + const bySkill = {}; + for (const [id, action] of Object.entries(list)) { + const a = C.actions.find((x) => x.id === action); + (bySkill[a.skill] ||= []).push([id, a.level]); + } + const options = Object.entries(bySkill).map(([skill, items]) => `${items + .sort((x, y) => x[1] - y[1]).map(([id, level]) => ``).join("")}`).join(""); + 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; + maker = `
${skillOf(a.skill).icon} ${a.level} · 👥 ${smiths}
`; + if (d.kind === "craft") { + const runs = Math.ceil(d.qty / a.outputs[d.item]); + slots = Object.entries(a.inputs).map(([id, per]) => { + const need = per * runs, have = S.me.bank[id] || 0, on = d.provide.includes(id); + return `
+ ${fmt(have)}
`; + }).join(""); + } + } + const gold = d.kind === "craft" ? d.reward : d.reward * d.qty; + return `
+
+
+
+
+ ${a ? `
${item(d.item).icon}
${esc(item(d.item).name)}${maker}
` : ""} +
×
+
${slots ? `
${slots}
` : ""}
+
${COIN} + ${d.kind === "gather" ? `each · ${fmt(gold)}` : ""}
+
+
`; +} + +function orders() { + const all = S.orders || [], open = all.filter((o) => o.status === "open"); + const takeable = open.filter(canTake), yours = all.filter((o) => o.by === S.uid); + const lists = { take: takeable, open, yours }; + if (!lists[orderTab]) orderTab = "take"; + const shown = lists[orderTab]; + const tab = (key, label, n) => ``; + return `
${ORDER_ICON()}
+

Work orders

+
+ ${draft ? orderComposer() : ""} +
${tab("take", "For you", takeable.length)}${tab("open", "All open", 0)}${tab("yours", "Yours", 0)}
+ ${shown.length ? `
${shown.map(orderCard).join("")}
` : `

None.

`}`; +} + // --- board & roster ------------------------------------------------------- function board() { @@ -1446,6 +1592,24 @@ document.addEventListener("click", (e) => { if (d.start) return act("/api/action", { id: d.start }); if (d.once) return act("/api/action", { id: d.once, once: true }); if ("stop" in d) return act("/api/stop", {}); + 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 }; return render(); } + if (d.provide) { + draft.provide = draft.provide.includes(d.provide) ? draft.provide.filter((x) => x !== d.provide) : [...draft.provide, d.provide]; + return render(); + } + if ("orderPost" in d) { + const body = { kind: draft.kind, item: draft.item, qty: draft.qty, provide: draft.provide, reward: draft.reward }; + 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(); } + if (d.orderWork) return act("/api/order/work", { id: Number(d.orderWork) }); + if (d.orderDrop) return act("/api/order/drop", { id: Number(d.orderDrop) }); + if (d.orderDeliver) return act("/api/order/deliver", { id: Number(d.orderDeliver) }); + if (d.orderCancel) return act("/api/order/cancel", { id: Number(d.orderCancel) }); if (d.doll) { dollPick = dollPick === d.doll ? null : d.doll; return render(); @@ -1492,6 +1656,24 @@ document.addEventListener("click", (e) => { }); document.addEventListener("change", (e) => { + const key = e.target.dataset.draft; + if (!key || !draft) return; + if (key === "item") { + const list = draft.kind === "craft" ? C.order_recipes : C.order_raw; + draft.item = e.target.value; + 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; + if (!list[draft.item]) draft.item = ""; + } else { + draft[key] = Math.max(key === "qty" ? 1 : 0, Math.floor(Number(e.target.value) || 0)); + if (key === "qty" && draft.kind === "craft" && draft.item) { + // Drop provided reagents the bank can no longer cover at the new quantity. + const a = C.actions.find((x) => x.id === C.order_recipes[draft.item]), runs = Math.ceil(draft.qty / a.outputs[draft.item]); + draft.provide = draft.provide.filter((id) => (S.me.bank[id] || 0) >= a.inputs[id] * runs); + } + } + render(); }); $("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; }); diff --git a/static/icons.svg b/static/icons.svg index 368fc32..c0c6034 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -130,6 +130,7 @@ + diff --git a/static/styles.css b/static/styles.css index 42f9fd2..13c0ace 100644 --- a/static/styles.css +++ b/static/styles.css @@ -571,3 +571,34 @@ button.sheet-skill:hover { background: var(--panel-2); } .happening-banner { flex-wrap: wrap; } .happening-banner .grow { min-width: min(260px, 70%); } .happening-banner > .btn { margin-left: auto; } + +/* Work orders */ +.badge { background: var(--gold); color: var(--bg); border-radius: 99px; font-size: 11px; font-weight: 800; padding: 1px 7px; } +.order .top { align-items: flex-start; } +.ro-req { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-weight: 700; font-size: 13px; } +.ro-req.bad { color: var(--bad); } +.ro-req .gi { width: 16px; height: 16px; } +.ro-slots { display: flex; gap: 10px; flex-wrap: wrap; margin: 4px 0; } +.ro-slot { display: grid; justify-items: center; gap: 3px; font-size: 11px; } +.ro-slot .doll-slot { width: 46px; height: 46px; } +.ro-slot .doll-slot .gi { width: 30px; height: 30px; } +.ro-slot.filled .doll-slot { border-color: rgba(95, 174, 90, .7); background: rgba(95, 174, 90, .1); } +.doll-slot.empty-req { border-style: dashed; background: transparent; } +.doll-slot.empty-req .gi { opacity: .45; } +.tick { position: absolute; bottom: -5px; right: -5px; background: var(--good); color: var(--bg); border-radius: 50%; width: 15px; height: 15px; + font-size: 10px; display: grid; place-items: center; font-weight: 900; } +.ro-have { font-weight: 700; } +.ro-have.good { color: var(--good); } .ro-have.bad { color: var(--bad); } +.ro-reward { font-weight: 700; } +.order .foot { gap: 8px; } +.ro-slots.big .doll-slot { width: 56px; height: 56px; } .ro-slots.big .doll-slot .gi { width: 36px; height: 36px; } +.compose-grid { display: grid; grid-template-columns: 1fr 1.3fr .8fr; gap: 26px; align-items: start; } +.compose select { width: 100%; } +.ro-pick { display: flex; align-items: center; gap: 10px; margin-top: 10px; } +.ro-pick .doll-slot { cursor: default; } +.ro-pick > .doll-slot .gi { width: 34px; height: 34px; } .ro-pick .small .gi { width: 14px; height: 14px; } +.stepper { display: flex; align-items: center; gap: 8px; margin-top: 10px; } +.stepper input { width: 90px; } +.reward-input { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } +.reward-input input { width: 110px; } +@media (max-width: 900px) { .compose-grid { grid-template-columns: 1fr; gap: 14px; } } diff --git a/tests/test_game.py b/tests/test_game.py index ded1f30..367d8d9 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1518,3 +1518,65 @@ class CombatReworkMigrationTest(unittest.TestCase): def test_homesteaders_tailor(self): self.assertIn("tailoring", game.CLASSES["homesteader"]["fast"]) self.assertIn("tailoring", game.CLASSES["homesteader"]["craft"]) + + +class WorkOrderTest(unittest.TestCase): + def setUp(self): + self.world = world_with(("mike", "ranger"), ("fred", "miner"), ("dave", "builder")) + self.mike, self.fred, self.dave = (self.world["players"][uid] for uid in ("mike", "fred", "dave")) + self.fred["xp"]["smithing"] = game.XP_TABLE[25] + self.dave["xp"]["smithing"] = game.XP_TABLE[25] + self.world["city"]["buildings"]["smithy"]["level"] = 1 + + def post_hammer(self): + self.mike["gold"] = 200 + self.mike["bank"] = {"iron_bar": 3, "clay_core": 3} + return game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, ["iron_bar", "clay_core"], 140, T0) + + def test_posting_holds_the_reagents_and_reward(self): + self.assertIsNone(self.post_hammer()) + order = self.world["orders"][0] + self.assertEqual(order["provided"], {"iron_bar": 3, "clay_core": 3}) + self.assertEqual(self.mike["bank"], {}) + self.assertEqual(self.mike["gold"], 60) + + def test_a_claimed_order_is_crafted_and_sent_to_the_requester(self): + self.post_hammer() + order = self.world["orders"][0] + self.fred["bank"] = {"oak_plank": 1} + self.assertIsNone(game.work_order(self.world, self.fred, order["id"], T0)) + self.assertEqual(game.claim_order(self.world, self.dave, order["id"]), "Someone else has it") + game.process_player(self.world, self.fred, T0 + 600, random.Random(1)) + self.assertEqual(self.mike["bank"].get("iron_warhammer"), 1) + self.assertEqual(self.fred["gold"], 140) + self.assertEqual(order["status"], "done") + self.assertNotIn("oak_plank", self.fred["bank"]) # the crafter brings what wasn't provided + self.assertIsNone(self.fred["action"]) + + def test_cancelling_refunds_what_is_left(self): + self.post_hammer() + order = self.world["orders"][0] + self.assertEqual(game.claim_order(self.world, self.mike, order["id"]), "That's your own order") + self.assertIsNone(game.cancel_order(self.world, self.mike, order["id"], T0)) + self.assertEqual(self.mike["bank"], {"iron_bar": 3, "clay_core": 3}) + self.assertEqual(self.mike["gold"], 200) + + def test_materials_requests_are_open_and_pay_per_item(self): + self.mike["gold"] = 400 + self.assertIsNone(game.post_order(self.world, self.mike, "gather", "oak_logs", 200, None, 2, T0)) + order = self.world["orders"][0] + self.fred["bank"] = {"oak_logs": 40} + self.dave["bank"] = {"oak_logs": 500} + self.assertIsNone(game.deliver_order(self.world, self.fred, order["id"], T0)) + self.assertIsNone(game.deliver_order(self.world, self.dave, order["id"], T0)) + self.assertEqual(self.fred["gold"], 80) + self.assertEqual(self.dave["gold"], 320) + self.assertEqual(self.dave["bank"]["oak_logs"], 340) + self.assertEqual(self.mike["bank"]["oak_logs"], 200) + self.assertEqual(order["status"], "done") + + def test_orders_expire_and_refund(self): + self.post_hammer() + game.expire_orders(self.world, T0 + 4 * game.DAY) + self.assertEqual(self.mike["gold"], 200) + self.assertEqual(self.mike["bank"]["iron_bar"], 3)