Work orders: crafting orders claimed by one crafter, open materials requests, escrow, refunds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 18:01:02 +01:00
parent 89fd9b3c40
commit d03f0035eb
7 changed files with 538 additions and 8 deletions

View file

@ -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. faster pace. Every class can train every skill, scout and drill.
Building costs draw on all four trades: thatch, flax for rope, hide glue, Building costs draw on all four trades: thatch, flax for rope, hide glue,
leather and provisions as well as wood, stone and metal. 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 - **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 spills into another slot. When something doesn't fit it goes to the city
stockpile (or the action stops, if you prefer). stockpile (or the action stops, if you prefer).

12
app.py
View file

@ -323,6 +323,17 @@ class Handler(BaseHTTPRequestHandler):
return "Bad quantity" return "Bad quantity"
if path == "/api/action": if path == "/api/action":
return game.start_action(world, player, str(body.get("id")), now, once=bool(body.get("once"))) 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": if path == "/api/seen":
event_id = body.get("id") event_id = body.get("id")
if not isinstance(event_id, int): if not isinstance(event_id, int):
@ -502,6 +513,7 @@ class Handler(BaseHTTPRequestHandler):
"favours": game.favours(world, now), "favours": game.favours(world, now),
"happenings": game.happenings_view(world, now), "happenings": game.happenings_view(world, now),
"history": history, "history": history,
"orders": world["orders"],
"threat_report": unseen and {"id": unseen["id"], "kind": unseen["kind"], "at": unseen["resolved"], "threat_report": unseen and {"id": unseen["id"], "kind": unseen["kind"], "at": unseen["resolved"],
"report": unseen["report"]}, "report": unseen["report"]},
"players": roster(world, now), "players": roster(world, now),

247
game.py
View file

@ -945,7 +945,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world --------------------------------------------------------------- # --- world ---------------------------------------------------------------
WORLD_VERSION = 16 WORLD_VERSION = 17
def new_world(now: float, speed: float = 1.0) -> dict: 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": [], "ledger": [],
"board": [], "board": [],
"next_message_id": 1, "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: if world["version"] < 16:
_migrate_combat_rework(world) _migrate_combat_rework(world)
world["version"] = 16 world["version"] = 16
if world["version"] < 17:
world.setdefault("orders", [])
world.setdefault("next_order_id", 1)
world["version"] = 17
return world 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(): for key, level in action.get("city", {}).items():
if building_level(world, key) < level: if building_level(world, key) < level:
return f"Needs {BUILDINGS[key]['name']} level {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(): 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()}" return f"Out of {ITEMS[item]['name'].lower()}"
if action.get("special") == "sabotage": if action.get("special") == "sabotage":
raid = upcoming_raid(world) 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: def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None:
brew = potion_effect(player, action["skill"]) 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 summary["kept"] += 1
else: else:
for item, qty in action["inputs"].items(): for item, qty in own.items():
bank_take(player, item, qty) bank_take(player, item, qty)
xp = action["xp"] xp = action["xp"]
cls = CLASSES[player["cls"]] 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": if action.get("special") != "drill":
_use_charge(player, action["skill"], summary) _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(): for item, qty in outputs.items():
if not qty:
continue
kept = bank_add(world, player, item, qty, now, summary) kept = bank_add(world, player, item, qty, now, summary)
if kept: if kept:
summary["items"][item] = summary["items"].get(item, 0) + 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).""" """Run the player's action forward to `now` (capped at 24h past last_active)."""
rng = rng or random.Random() rng = rng or random.Random()
summary = empty_summary() summary = empty_summary()
collect_orders(world, player)
current = player["action"] current = player["action"]
if not current: if not current:
return summary return summary
@ -2022,6 +2050,209 @@ def stop(player: dict, reason: str | None = None) -> None:
player["stopped"] = reason 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: 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.""" """Start an action. `once` does it a single time, then goes back to what you were doing."""
action = ACTION_BY_ID.get(action_id) 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: while city["last_tick"] + step <= now:
city["last_tick"] += step city["last_tick"] += step
_happenings_hour(world, city["last_tick"], rng) _happenings_hour(world, city["last_tick"], rng)
expire_orders(world, city["last_tick"])
_city_hour(world, city["last_tick"]) _city_hour(world, city["last_tick"])
changed = True changed = True
changed = advance_weather(world, now, rng) or changed 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 _index, (_area, _level, _prefix, _races) in enumerate(AREAS):
for _race, *_rest in _races: for _race, *_rest in _races:
ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index]) 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", "archery-target", "dodging", "shield-reflect",
"cape", "gem-pendant", "gauntlet", "ring", "quiver", "leather-boot", "crystal-ball", "hot-meal", "round-bottom-flask", "visored-helm", "breastplate", "cape", "gem-pendant", "gauntlet", "ring", "quiver", "leather-boot", "crystal-ball", "hot-meal", "round-bottom-flask", "visored-helm", "breastplate",
"leg-armor", "pointy-sword", "round-shield", "leg-armor", "pointy-sword", "round-shield",
@ -3639,6 +3871,8 @@ def all_glyphs() -> set[str]:
_assign_glyphs() _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: def content() -> dict:
@ -3652,7 +3886,8 @@ def content() -> dict:
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS, "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
"city_uses": {item: city_uses(item) for item in ITEMS}, "city_uses": {item: city_uses(item) for item in ITEMS},
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, "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, "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED,
"styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE, "styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE,
"resistant": RESISTANT, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE, "resistant": RESISTANT, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,

View file

@ -12,6 +12,10 @@ let skew = 0; // server clock minus client clock, seconds
let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town"; let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town";
let selected = null; // bank item let selected = null; // bank item
let dollPick = null; // equipment slot shown on the character sheet 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 let pickedClass = null; // registration
const tabs = {}; // skill -> selected recipe tab const tabs = {}; // skill -> selected recipe tab
let refreshing = false; let refreshing = false;
@ -93,6 +97,7 @@ function accept(state) {
S = state; S = state;
skew = state.now - Date.now() / 1000; skew = state.now - Date.now() / 1000;
if (state.summary) showSummary(state.summary); if (state.summary) showSummary(state.summary);
orderNews(state);
const ev = state.upcoming; const ev = state.upcoming;
if (ev?.at_gates && warned !== ev.id) { if (ev?.at_gates && warned !== ev.id) {
warned = ev.id; warned = ev.id;
@ -307,6 +312,7 @@ function sidebar() {
const me = S.me, cls = C.classes[me.cls]; const me = S.me, cls = C.classes[me.cls];
const doing = me.action && C.actions.find((a) => a.id === me.action.id); const doing = me.action && C.actions.find((a) => a.id === me.action.id);
const raid = S.upcoming?.kind === "raid"; const raid = S.upcoming?.kind === "raid";
const takeable = (S.orders || []).filter(canTake).length;
let html = ` let html = `
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div> <div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>
<div class="me"> <div class="me">
@ -317,6 +323,7 @@ function sidebar() {
<div class="navhead">City</div> <div class="navhead">City</div>
${nav("town", "🏘️", "Town square", `${S.city.population}`, "", `Population ${S.city.population}`)} ${nav("town", "🏘️", "Town square", `${S.city.population}`, "", `Population ${S.city.population}`)}
${nav("council", "📜", "Council & threats", "", raid ? `<span class="alert"></span>` : "", raid ? "A threat is coming" : "")} ${nav("council", "📜", "Council & threats", "", raid ? `<span class="alert"></span>` : "", raid ? "A threat is coming" : "")}
${nav("orders", ORDER_ICON(), "Work orders", "", takeable ? `<span class="badge">${takeable}</span>` : "", takeable ? `${takeable} for you` : "")}
${nav("board", "💬", "Town board")} ${nav("board", "💬", "Town board")}
${nav("roster", "👥", "Guild roster", `${S.players.length}`, "", `${S.players.length} ${S.players.length === 1 ? "adventurer" : "adventurers"}`)} ${nav("roster", "👥", "Guild roster", `${S.players.length}`, "", `${S.players.length} ${S.players.length === 1 ? "adventurer" : "adventurers"}`)}
<div class="navhead">You</div> <div class="navhead">You</div>
@ -350,7 +357,7 @@ function devbar() {
</div></div>`; </div></div>`;
} }
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() { function topbar() {
let title = TITLES[page] || "Guildhall"; let title = TITLES[page] || "Guildhall";
@ -390,7 +397,7 @@ function pageHtml() {
} }
if (page.startsWith("character:")) return characterSheet(page.slice(10)); if (page.startsWith("character:")) return characterSheet(page.slice(10));
if (page === "character") return characterSheet(S.uid); 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) { function itemChip(id, qty, have) {
@ -429,6 +436,11 @@ function skillPage(id) {
html += skillGear(id); html += skillGear(id);
if (id === "reconnaissance") html += `<div class="panel small muted">Raises intel on the coming threat. Higher levels open as it nears.</div>`; if (id === "reconnaissance") html += `<div class="panel small muted">Raises intel on the coming threat. Higher levels open as it nears.</div>`;
html += skillHappenings(id); html += skillHappenings(id);
const wanted = (S.orders || []).filter((o) => o.skill === id && canTake(o));
if (wanted.length) html += `<div class="banner good happening-banner"><span class="banner-ico">${ORDER_ICON()}</span>
<div class="grow"><b>${wanted.length} work order${wanted.length > 1 ? "s" : ""}</b>
<div class="small">${wanted.map((o) => esc(item(o.item).name)).join(" · ")}</div></div>
<button class="btn" data-nav="orders">Work orders</button></div>`;
const live = new Set(S.happenings.problems.map((p) => p.kind)); 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))); 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. // 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) {
</div></div>`; </div></div>`;
} }
// --- 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 `<div class="ro-slot filled" title="${esc(`${name}: provided`)}"><span class="doll-slot">${item(id).icon}
<span class="swap-qty">${fmt(per * all)}</span><span class="tick">✓</span></span></div>`;
}
const need = per * (o.status === "open" ? left : all), have = S.me.bank[id] || 0;
return `<div class="ro-slot open" title="${esc(`${name}: crafter brings`)}"><span class="doll-slot empty-req">${item(id).icon}</span>
<span class="ro-have ${o.by === S.uid ? "muted" : have < need ? "bad" : "good"}">${o.by === S.uid ? `×${fmt(need)}` : `${fmt(have)} / ${fmt(need)}`}</span></div>`;
}).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"
? `<div class="small muted">${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(", ")}` : ""}</div>
<div class="bar"><span style="width:${o.delivered / o.qty * 100}%"></span></div>` : "";
let buttons = "", status = "";
if (o.status !== "open") {
status = `<span class="tag ${o.status === "done" ? "good" : ""}">${{ done: "Done", cancelled: "Cancelled", expired: "Expired" }[o.status]}</span>`;
} else if (mine) {
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>`;
} else if (o.claimed && o.claimed !== S.uid) {
status = `<span class="tag">${esc(playerName(o.claimed))}</span>`;
} 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>` : ""}
${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 it</button>`}`;
}
const reward = o.kind === "gather" ? `${fmt(Math.round(o.reward / o.qty))} each` : fmt(o.reward);
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>
${o.kind === "craft" ? `<div class="ro-slots">${orderSlots(o)}</div>` : ""}
${progress}
<div class="foot"><span class="item-chip ro-reward">${COIN} ${reward}</span>${status}<span class="btns">${buttons}</span></div>
</div>`;
}
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]) => `<optgroup label="${esc(skillOf(skill).name)}">${items
.sort((x, y) => x[1] - y[1]).map(([id, level]) => `<option value="${id}" ${d.item === id ? "selected" : ""}>${esc(item(id).name)} (${level})</option>`).join("")}</optgroup>`).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 = `<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]);
slots = Object.entries(a.inputs).map(([id, per]) => {
const need = per * runs, have = S.me.bank[id] || 0, on = d.provide.includes(id);
return `<div class="ro-slot ${on ? "filled" : "open"}"><button class="doll-slot ${on ? "on" : "empty-req"}" data-provide="${id}"
${!on && have < need ? `disabled title="${esc(`${item(id).name}: ${fmt(have)} / ${fmt(need)}`)}"` : `title="${esc(item(id).name)}"`}>
${item(id).icon}<span class="swap-qty">${fmt(need)}</span>${on ? `<span class="tick">✓</span>` : ""}</button>
<span class="ro-have ${have < need ? "bad" : "muted"}">${fmt(have)}</span></div>`;
}).join("");
}
}
const gold = d.kind === "craft" ? d.reward : d.reward * d.qty;
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>
<div class="compose-grid">
<div><select data-draft="item"><option value="">Item…</option>${options}</select>
${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 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>
<button class="btn primary" data-order-post ${a && gold <= S.me.gold ? "" : "disabled"}>Post</button></div>
</div></div>`;
}
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) => `<button class="tab ${orderTab === key ? "on" : ""}" data-otab="${key}">${label}${n ? ` <b class="badge">${n}</b>` : ""}</button>`;
return `<div class="panel"><div class="skill-head"><div class="big">${ORDER_ICON()}</div>
<div class="info"><h2>Work orders</h2></div>
<button class="btn ${draft ? "" : "primary"}" data-order-new>${draft ? "Close" : "New order"}</button></div></div>
${draft ? orderComposer() : ""}
<div class="tabs">${tab("take", "For you", takeable.length)}${tab("open", "All open", 0)}${tab("yours", "Yours", 0)}</div>
${shown.length ? `<div class="grid wide">${shown.map(orderCard).join("")}</div>` : `<p class="muted">None.</p>`}`;
}
// --- board & roster ------------------------------------------------------- // --- board & roster -------------------------------------------------------
function board() { function board() {
@ -1446,6 +1592,24 @@ document.addEventListener("click", (e) => {
if (d.start) return act("/api/action", { id: d.start }); if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true }); if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {}); 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) { if (d.doll) {
dollPick = dollPick === d.doll ? null : d.doll; dollPick = dollPick === d.doll ? null : d.doll;
return render(); return render();
@ -1492,6 +1656,24 @@ document.addEventListener("click", (e) => {
}); });
document.addEventListener("change", (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; }); $("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; });

View file

@ -130,6 +130,7 @@
<symbol id="scale-mail" viewBox="0 0 512 512"><path fill="currentColor" d="M174.688 61.094L144.155 78.72c60.143 71.885 171.856 69.547 222.78 0l-30.53-17.626c-11.46 23.49-43.57 40.625-81.156 40.625s-69.1-17.137-80.563-40.626zm208.5 27c-5.07 7.136-10.657 13.675-16.72 19.594l18.97 34.343l22.03-39.905l-24.28-14.03zm-255.438.094L53.937 130.81l66.876 112.375l33.874-19.656l-2.78 62.595l12.53 22.656l20.75-37.56l-15.375-27.814l16.344-9.03l22.78 41.218l22.75-41.22l16.345 9.032l-30.936 56l-8.156 14.813l-8.188-14.814l-4.906-8.875l-23.25 42.064l-8.188 14.812l-8.156-14.812l-5.938-10.72l-3.5 78.938h81.97l-28.532-51.687l16.375-9.03l22.75 41.217l22.75-41.218l.53.28l-13.874-25.093l16.345-9.03l22.75 41.22l22.78-41.22L312 307l-14.72-26.656l16.376-9.03l22.75 41.186l22.72-41.125L357 223.53l33 19.157l66.72-112.156l-33.033-19.06l-30.062 54.405l-8.188 14.813l-8.187-14.813l-6.438-11.656l-25.718 46.593l-8.188 14.812l-8.156-14.813l-6.75-12.218l-25.438 46.062l-8.156 14.813l-8.187-14.814l-30.94-56l16.376-9.03l22.75 41.186l22.75-41.187l.563.313l-13.564-24.563c-29.667 7.446-61.705 6.438-91.687-3.188l-25.814 46.75l-8.187 14.813l-8.19-14.813l-5.624-10.218l-20.25 36.624l-8.187 14.812l-8.157-14.812l-30.938-56l16.344-9.03l22.78 41.217l17.72-32.124l-14.595-26.47l16.344-9.03l19.56 35.406l.064.032l-.03.03l3.155 5.72l16.374-29.656c-22.918-10.325-44.015-26.047-61.094-47.25zm224.22 31.874c-10.98 8.186-22.995 14.723-35.658 19.594l20.625 37.313l22.75-41.22l1.344.75l-9.06-16.438zm120.936 19.813l-66.75 112.188l22.906 13.28L493.875 152l-20.97-12.125zm-435.156.28L17.22 152l65.405 113.344l22-12.78l-66.875-112.41zm322.97 167.032l-16.126 29.188l-8.188 14.813l-8.187-14.813l-6.44-11.656l-25.717 46.56l-8.188 14.814l-8.156-14.813l-6.75-12.217l-23.033 41.75h114.938l-4.156-93.625zM146 419.5l-1.22 27.25h222.126l-1.22-27.25h-126.06l-.25.47l-.25-.47z"/></symbol> <symbol id="scale-mail" viewBox="0 0 512 512"><path fill="currentColor" d="M174.688 61.094L144.155 78.72c60.143 71.885 171.856 69.547 222.78 0l-30.53-17.626c-11.46 23.49-43.57 40.625-81.156 40.625s-69.1-17.137-80.563-40.626zm208.5 27c-5.07 7.136-10.657 13.675-16.72 19.594l18.97 34.343l22.03-39.905l-24.28-14.03zm-255.438.094L53.937 130.81l66.876 112.375l33.874-19.656l-2.78 62.595l12.53 22.656l20.75-37.56l-15.375-27.814l16.344-9.03l22.78 41.218l22.75-41.22l16.345 9.032l-30.936 56l-8.156 14.813l-8.188-14.814l-4.906-8.875l-23.25 42.064l-8.188 14.812l-8.156-14.812l-5.938-10.72l-3.5 78.938h81.97l-28.532-51.687l16.375-9.03l22.75 41.217l22.75-41.218l.53.28l-13.874-25.093l16.345-9.03l22.75 41.22l22.78-41.22L312 307l-14.72-26.656l16.376-9.03l22.75 41.186l22.72-41.125L357 223.53l33 19.157l66.72-112.156l-33.033-19.06l-30.062 54.405l-8.188 14.813l-8.187-14.813l-6.438-11.656l-25.718 46.593l-8.188 14.812l-8.156-14.813l-6.75-12.218l-25.438 46.062l-8.156 14.813l-8.187-14.814l-30.94-56l16.376-9.03l22.75 41.186l22.75-41.187l.563.313l-13.564-24.563c-29.667 7.446-61.705 6.438-91.687-3.188l-25.814 46.75l-8.187 14.813l-8.19-14.813l-5.624-10.218l-20.25 36.624l-8.187 14.812l-8.157-14.812l-30.938-56l16.344-9.03l22.78 41.217l17.72-32.124l-14.595-26.47l16.344-9.03l19.56 35.406l.064.032l-.03.03l3.155 5.72l16.374-29.656c-22.918-10.325-44.015-26.047-61.094-47.25zm224.22 31.874c-10.98 8.186-22.995 14.723-35.658 19.594l20.625 37.313l22.75-41.22l1.344.75l-9.06-16.438zm120.936 19.813l-66.75 112.188l22.906 13.28L493.875 152l-20.97-12.125zm-435.156.28L17.22 152l65.405 113.344l22-12.78l-66.875-112.41zm322.97 167.032l-16.126 29.188l-8.188 14.813l-8.187-14.813l-6.44-11.656l-25.717 46.56l-8.188 14.814l-8.156-14.813l-6.75-12.217l-23.033 41.75h114.938l-4.156-93.625zM146 419.5l-1.22 27.25h222.126l-1.22-27.25h-126.06l-.25.47l-.25-.47z"/></symbol>
<symbol id="screaming" viewBox="0 0 512 512"><path fill="currentColor" d="M16.406 17.53v14.595l140.75 132.563L105.75 17.53zm123.22 0l68.343 124.407l-2.907-124.406h-65.438zm100.468 0l22.78 118.564L298.5 17.53zm93.437 0L317.19 147.75l90.53-130.22h-74.187zm106.283 0l-74.157 158.282L495.876 76.5V17.53h-56.063zM16.406 71.25v84.094l99.28 46.72zm479.47 46.156l-93.438 99.28l93.437-32.717v-66.564zm-312.188 59.47c-10.92.086-25.162 10.363-36.125 27.374c-6.48 10.054-10.708 20.766-12.5 30.406c-49.04 22.678-113.907 63.906-113.907 63.906l148.438 126l15 67.782h144.28l11.313-64.656l.094.093l152.25-129.217s-66.033-41.99-115.217-64.532c-1.858-9.475-6.037-19.945-12.375-29.78c-10.964-17.01-25.237-27.288-36.157-27.375c-3.64-.03-6.906 1.073-9.56 3.438c-2.42 2.154-4.117 5.17-5.126 8.812c1.253 1.576 2.484 3.176 3.656 4.844c11.65 16.57 19.578 37.626 22.47 61c8.607 7.78 17.563 11.763 24.717 10.78l54.594 35.938L314.94 359.5c-4.07 5.263-8.572 10.022-13.438 14.156c-5.576-4.344-11.033-7.95-16.438-10.812c6.327-4.49 12.208-10.48 17.407-17.875c12.3-17.496 20.28-42.414 20.28-70.126s-7.98-52.63-20.28-70.125c-12.3-17.496-28.3-27.314-45.44-27.314c-17.138 0-33.136 9.818-45.436 27.313c-12.3 17.493-20.28 42.412-20.28 70.124s7.98 52.63 20.28 70.125c4.54 6.456 9.58 11.866 15 16.124c-5.51 2.618-11.07 6.023-16.688 10.22a96 96 0 0 1-10.5-11.44l-105.25-58.187l54.438-35.843c7.49.513 16.76-4.09 25.5-12.594c3.06-22.683 10.856-43.12 22.22-59.28a98 98 0 0 1 2.436-3.314c-.934-4.326-2.752-7.896-5.5-10.344c-2.655-2.364-5.923-3.466-9.563-3.437zm61.093 58.468a73.4 73.4 0 0 0-1.03 12.312c0 2.244.067 4.41.25 6.53c-11.27.942-22.108 3.384-32.063 7.345l-6.906-17.342c12.538-4.99 25.953-7.912 39.75-8.844zm28.22.78c11.567 1.376 23.15 4.035 34.47 7.907l-6.033 17.657a148.5 148.5 0 0 0-27.906-6.625a75 75 0 0 0 .345-7.406c0-4.045-.303-7.93-.875-11.53zm-16.5 41.314c20.266 0 36.72 16.404 36.72 36.656c0 20.255-16.454 36.687-36.72 36.687s-36.688-16.434-36.688-36.686s16.422-36.656 36.688-36.656z"/></symbol> <symbol id="screaming" viewBox="0 0 512 512"><path fill="currentColor" d="M16.406 17.53v14.595l140.75 132.563L105.75 17.53zm123.22 0l68.343 124.407l-2.907-124.406h-65.438zm100.468 0l22.78 118.564L298.5 17.53zm93.437 0L317.19 147.75l90.53-130.22h-74.187zm106.283 0l-74.157 158.282L495.876 76.5V17.53h-56.063zM16.406 71.25v84.094l99.28 46.72zm479.47 46.156l-93.438 99.28l93.437-32.717v-66.564zm-312.188 59.47c-10.92.086-25.162 10.363-36.125 27.374c-6.48 10.054-10.708 20.766-12.5 30.406c-49.04 22.678-113.907 63.906-113.907 63.906l148.438 126l15 67.782h144.28l11.313-64.656l.094.093l152.25-129.217s-66.033-41.99-115.217-64.532c-1.858-9.475-6.037-19.945-12.375-29.78c-10.964-17.01-25.237-27.288-36.157-27.375c-3.64-.03-6.906 1.073-9.56 3.438c-2.42 2.154-4.117 5.17-5.126 8.812c1.253 1.576 2.484 3.176 3.656 4.844c11.65 16.57 19.578 37.626 22.47 61c8.607 7.78 17.563 11.763 24.717 10.78l54.594 35.938L314.94 359.5c-4.07 5.263-8.572 10.022-13.438 14.156c-5.576-4.344-11.033-7.95-16.438-10.812c6.327-4.49 12.208-10.48 17.407-17.875c12.3-17.496 20.28-42.414 20.28-70.126s-7.98-52.63-20.28-70.125c-12.3-17.496-28.3-27.314-45.44-27.314c-17.138 0-33.136 9.818-45.436 27.313c-12.3 17.493-20.28 42.412-20.28 70.124s7.98 52.63 20.28 70.125c4.54 6.456 9.58 11.866 15 16.124c-5.51 2.618-11.07 6.023-16.688 10.22a96 96 0 0 1-10.5-11.44l-105.25-58.187l54.438-35.843c7.49.513 16.76-4.09 25.5-12.594c3.06-22.683 10.856-43.12 22.22-59.28a98 98 0 0 1 2.436-3.314c-.934-4.326-2.752-7.896-5.5-10.344c-2.655-2.364-5.923-3.466-9.563-3.437zm61.093 58.468a73.4 73.4 0 0 0-1.03 12.312c0 2.244.067 4.41.25 6.53c-11.27.942-22.108 3.384-32.063 7.345l-6.906-17.342c12.538-4.99 25.953-7.912 39.75-8.844zm28.22.78c11.567 1.376 23.15 4.035 34.47 7.907l-6.033 17.657a148.5 148.5 0 0 0-27.906-6.625a75 75 0 0 0 .345-7.406c0-4.045-.303-7.93-.875-11.53zm-16.5 41.314c20.266 0 36.72 16.404 36.72 36.656c0 20.255-16.454 36.687-36.72 36.687s-36.688-16.434-36.688-36.686s16.422-36.656 36.688-36.656z"/></symbol>
<symbol id="screw" viewBox="0 0 512 512"><path fill="currentColor" d="M259.2 20.25c-24.2.16-49.4 5.94-73 16.17c-26.5 40.57-38.4 73.68-30.9 118.98c-26.9-15.8-43.1-31.6-48.5-58.83c-8.74 11.03-16 22.83-21.41 35.13c-14.06 32-15.85 67.5 1.15 104.1L368.2 77.3c-22.6-34.19-53.9-51.23-88.5-55.81c-4.7-.62-9.4-1-14.2-1.16c-2.1-.1-4.2-.1-6.3-.1zm67.6 101.85l-40.9 23l-61.5 93.9l-41.3 60.6l15.4 10.6l22-32.3c8.3 12.2 16.8 24.2 25.7 36.1l86.7-120.9q-11.85-22.05-24.9-43.5zm-76.1 42.8l-74.4 41.8c7.1 12.6 14.6 25.1 22.4 37.6zm108.2 22.4L239.3 355.5l15.2 10.8l15.4-21.6c11 13.6 22.4 26.9 34.1 39.9l69.4-103.7c-6.7-17.2-14.1-34.2-22-50.9l22.7-31.9zm37.3 93.7l-14.6 21.6s-.1-.1-.1-.2l-24.9 37.3l-61.3 90.8l15.4 10.4l19.4-28.7c8 8.2 16.2 16.2 24.6 23.9l47.5-66.9c-3.9-14.9-8.3-29.8-13.1-44.5l22.5-33.3zm26.3 93.1L408.2 394c-.1-.3-.1-.5-.2-.8l-39.4 55.5l.1.1l-13.5 18.8l15.2 11l12.6-17.5c12.9 10.8 26.2 21 39.6 30.7c-1.8-24.3-4.9-48.6-9.3-72.7l24.4-34z"/></symbol> <symbol id="screw" viewBox="0 0 512 512"><path fill="currentColor" d="M259.2 20.25c-24.2.16-49.4 5.94-73 16.17c-26.5 40.57-38.4 73.68-30.9 118.98c-26.9-15.8-43.1-31.6-48.5-58.83c-8.74 11.03-16 22.83-21.41 35.13c-14.06 32-15.85 67.5 1.15 104.1L368.2 77.3c-22.6-34.19-53.9-51.23-88.5-55.81c-4.7-.62-9.4-1-14.2-1.16c-2.1-.1-4.2-.1-6.3-.1zm67.6 101.85l-40.9 23l-61.5 93.9l-41.3 60.6l15.4 10.6l22-32.3c8.3 12.2 16.8 24.2 25.7 36.1l86.7-120.9q-11.85-22.05-24.9-43.5zm-76.1 42.8l-74.4 41.8c7.1 12.6 14.6 25.1 22.4 37.6zm108.2 22.4L239.3 355.5l15.2 10.8l15.4-21.6c11 13.6 22.4 26.9 34.1 39.9l69.4-103.7c-6.7-17.2-14.1-34.2-22-50.9l22.7-31.9zm37.3 93.7l-14.6 21.6s-.1-.1-.1-.2l-24.9 37.3l-61.3 90.8l15.4 10.4l19.4-28.7c8 8.2 16.2 16.2 24.6 23.9l47.5-66.9c-3.9-14.9-8.3-29.8-13.1-44.5l22.5-33.3zm26.3 93.1L408.2 394c-.1-.3-.1-.5-.2-.8l-39.4 55.5l.1.1l-13.5 18.8l15.2 11l12.6-17.5c12.9 10.8 26.2 21 39.6 30.7c-1.8-24.3-4.9-48.6-9.3-72.7l24.4-34z"/></symbol>
<symbol id="scroll-unfurled" viewBox="0 0 512 512"><path fill="currentColor" d="M103.432 17.844a87 87 0 0 0-3.348.08q-3.822.163-7.604.678c-20.167 2.747-39.158 13.667-52.324 33.67c-24.613 37.4 2.194 98.025 56.625 98.025c.536 0 1.058-.012 1.583-.022v.704h60.565c-10.758 31.994-30.298 66.596-52.448 101.43a283 283 0 0 0-6.29 10.406l34.878 35.733l-56.263 9.423c-32.728 85.966-27.42 182.074 48.277 182.074v-.002l9.31.066c23.83-.57 46.732-4.298 61.325-12.887c4.174-2.458 7.63-5.237 10.467-8.42h-32.446c-20.33 5.95-40.8-6.94-47.396-25.922c-8.956-25.77 7.52-52.36 31.867-60.452a55.6 55.6 0 0 1 17.565-2.834v-.406h178.33c-.57-44.403 16.35-90.125 49.184-126c23.955-26.176 42.03-60.624 51.3-94.846l-41.225-24.932l38.272-6.906l-43.37-25.807h-.005l.002-.002l.002.002l52.127-8.85c-5.232-39.134-28.84-68.113-77.37-68.113C341.14 32.26 222.11 35.29 149.34 28.496c-14.888-6.763-30.547-10.723-45.908-10.652m.464 18.703c13.137.043 27.407 3.804 41.247 10.63l.033-.07c4.667 4.735 8.542 9.737 11.68 14.985H82.92l10.574 14.78c10.608 14.83 19.803 31.99 21.09 42.024c.643 5.017-.11 7.167-1.814 8.836c-1.705 1.67-6.228 3.875-15.99 3.875c-40.587 0-56.878-44.952-41.012-69.06C66.238 46.64 79.582 39.22 95.002 37.12a64 64 0 0 1 8.894-.573M118.5 80.78h46.28c4.275 15.734 3.656 33.07-.544 51.51H131.52c1.9-5.027 2.268-10.574 1.6-15.77c-1.527-11.913-7.405-24.065-14.62-35.74m101.553 317.095c6.44 6.84 11.192 15.31 13.37 24.914c3.797 16.736 3.092 31.208-1.767 43.204c-4.526 11.175-12.576 19.79-22.29 26h237.19c14.448 0 24.887-5.678 32.2-14.318c7.312-8.64 11.2-20.514 10.705-32.352a47.7 47.7 0 0 0-2.407-13.18l-69.91-8.205l42.017-20.528c-8.32-3.442-18.64-5.537-31.375-5.537H220.053zm-42.668.506a37 37 0 0 0-3.457.153a34.8 34.8 0 0 0-7.824 1.63c-15.11 5.02-25.338 21.54-20.11 36.583c3.673 10.57 15.347 17.71 25.654 13.938l1.555-.57h43.354c.946-6.36.754-13.882-1.358-23.192c-3.71-16.358-20.543-28.483-37.815-28.54z"/></symbol>
<symbol id="sewing-machine" viewBox="0 0 512 512"><path fill="currentColor" d="m61.67 119.8l-12.69 68.3c-5.47 29.6-1.02 60.1 12.69 86.8h51.03V119.8zm68.63 0v115.6h2.9c6.2 0 11.2-5.1 11.2-11.3V119.8zm31.8 0v104.3c0 15.9-12.9 28.8-28.9 28.8h-2.9v81.7h14.9v-59.7h19.6s7.6-15.3 13.6-27.7c4.7-9.2 14.1-15.1 24.5-15.1h80.2c15 0 27.2 12.1 27.2 27.1v91.5H25.95l10.57 68.5H455.1l10.5-63.1l-21.1-236.3zm310.1 23.4c-4.9.4-8.5 4.7-8 9.5l4.3 47.4c.4 4.8 4.7 8.4 9.5 8c4.9-.4 8.4-4.8 8-9.6l-4.2-47.3c-.4-4.5-4.2-8-8.7-8zm-86.8.1c21.7 0 39.2 17.5 39.2 39.2s-17.5 39.2-39.2 39.2s-39.2-17.5-39.2-39.2s17.5-39.2 39.2-39.2m0 17.5c-11.9 0-21.7 9.8-21.7 21.7c0 12 9.8 21.7 21.7 21.7s21.7-9.7 21.7-21.7c0-11.9-9.8-21.7-21.7-21.7m0 89.9c26.6 0 48.2 21.7 48.2 48.2c0 26.6-21.6 48.2-48.2 48.2s-48.2-21.6-48.2-48.2c0-26.5 21.6-48.2 48.2-48.2m0 17.6c-16.9 0-30.6 13.7-30.6 30.6s13.7 30.5 30.6 30.5s30.5-13.6 30.5-30.5s-13.6-30.6-30.5-30.6M92.71 287.8v17.6h20.09v-17.6zM36.52 438.9v26.5H454.9v-26.5z"/></symbol> <symbol id="sewing-machine" viewBox="0 0 512 512"><path fill="currentColor" d="m61.67 119.8l-12.69 68.3c-5.47 29.6-1.02 60.1 12.69 86.8h51.03V119.8zm68.63 0v115.6h2.9c6.2 0 11.2-5.1 11.2-11.3V119.8zm31.8 0v104.3c0 15.9-12.9 28.8-28.9 28.8h-2.9v81.7h14.9v-59.7h19.6s7.6-15.3 13.6-27.7c4.7-9.2 14.1-15.1 24.5-15.1h80.2c15 0 27.2 12.1 27.2 27.1v91.5H25.95l10.57 68.5H455.1l10.5-63.1l-21.1-236.3zm310.1 23.4c-4.9.4-8.5 4.7-8 9.5l4.3 47.4c.4 4.8 4.7 8.4 9.5 8c4.9-.4 8.4-4.8 8-9.6l-4.2-47.3c-.4-4.5-4.2-8-8.7-8zm-86.8.1c21.7 0 39.2 17.5 39.2 39.2s-17.5 39.2-39.2 39.2s-39.2-17.5-39.2-39.2s17.5-39.2 39.2-39.2m0 17.5c-11.9 0-21.7 9.8-21.7 21.7c0 12 9.8 21.7 21.7 21.7s21.7-9.7 21.7-21.7c0-11.9-9.8-21.7-21.7-21.7m0 89.9c26.6 0 48.2 21.7 48.2 48.2c0 26.6-21.6 48.2-48.2 48.2s-48.2-21.6-48.2-48.2c0-26.5 21.6-48.2 48.2-48.2m0 17.6c-16.9 0-30.6 13.7-30.6 30.6s13.7 30.5 30.6 30.5s30.5-13.6 30.5-30.5s-13.6-30.6-30.5-30.6M92.71 287.8v17.6h20.09v-17.6zM36.52 438.9v26.5H454.9v-26.5z"/></symbol>
<symbol id="sewing-needle" viewBox="0 0 512 512"><path fill="currentColor" d="M56.813 18.438c-.47-.005-.938.014-1.407.03q-.939.035-1.875.126c-7.468.72-14.682 4.148-22.31 11.125c-9.985 9.128-12.863 17.66-11.407 27.718c1.455 10.056 8.353 22.236 21.125 34.906c2.895 2.872 6.126 5.737 9.593 8.625c1.27 1.055 2.565 2.13 3.907 3.186a201 201 0 0 0 3.438 2.656c.232.175.454.358.688.532C74.71 119.38 95.558 131.28 120.25 142.22c159.723 161.31 288.815 297.553 374.594 352.124c-51.11-85.624-187.432-214.62-350-375.531c-10.23-23.802-21.115-43.71-32.094-59.282c35.46-10.19 82.517-2.294 125.75 40.94c40.243 40.242 52.642 90.776 41.375 128.343c4.89 4.914 9.883 9.913 14.688 14.75c19.07-46.1 4.573-108.895-42.844-156.313c-35.694-35.693-75.48-50.324-111.032-50.22c-1.147.005-2.3.03-3.438.064c-21.844.65-41.957 6.898-58.344 17.187a102 102 0 0 0-8.312 5.814c1.33 3.094 2.758 5.884 4.406 8.218c1.92 2.72 4.004 4.936 6.906 6.688c2.202-1.717 4.498-3.375 6.938-4.906a89 89 0 0 1 3.25-1.97c8.196 10.088 15.465 24.624 25.47 47.25c-23.907-10.568-38.822-18.04-49.033-27c.024-.03.04-.063.064-.093c-.433-.33-.867-.655-1.28-1q-.018-.013-.033-.03c-11.215-10.375-16.532-22.934-22.31-44.5c5.988 1.605 11.256 3.19 16 4.844a137 137 0 0 1 26.81-15.875c-9.5-7.738-18.552-11.93-26.686-12.97c-1.443-.185-2.87-.3-4.282-.313zM493.28 64.374c-77.985 0-125.587 28.886-146.124 69.406s-13.994 90.39 9.688 131.407c11.385 19.72 17.807 40.687 19.187 61.22a4330 4330 0 0 1 18.47 19.187c2.31-29.955-5.072-61.35-21.47-89.75c-21.13-36.602-26.155-80.112-9.186-113.594s55.94-59.188 129.437-59.188V64.376zM37.907 114.938c-2.894 23.925 6.18 50.21 29.532 73.562l13.218-13.188c-15.97-15.97-23.188-31.833-24.437-46.593c-6.646-4.51-12.754-9.102-18.314-13.782zM124.062 277c-24.466-.028-45.04 4.92-61.343 13.656c-24.842 13.31-39.363 35.865-40.75 60.47c-2.78 49.207 44.38 102.65 140.405 118.28c101.63 16.544 170.303-9.184 205.406-54.062q1.513-1.933 2.94-3.906c-4.552-4.232-9.148-8.52-13.783-12.875c-1.233 1.79-2.52 3.55-3.875 5.28c-29.97 38.315-90.355 62.97-187.687 47.126c-90.394-14.715-126.796-62.527-124.75-98.783c1.023-18.127 10.982-34.37 30.938-45.062c19.955-10.692 50.267-15.364 90.5-7.47c34.077 6.69 62.938 3.47 85.437-6.936c-4.72-4.65-9.49-9.33-14.28-14.064c-17.545 6.452-39.89 8.088-67.564 2.656c-14.895-2.923-28.778-4.297-41.594-4.312z"/></symbol> <symbol id="sewing-needle" viewBox="0 0 512 512"><path fill="currentColor" d="M56.813 18.438c-.47-.005-.938.014-1.407.03q-.939.035-1.875.126c-7.468.72-14.682 4.148-22.31 11.125c-9.985 9.128-12.863 17.66-11.407 27.718c1.455 10.056 8.353 22.236 21.125 34.906c2.895 2.872 6.126 5.737 9.593 8.625c1.27 1.055 2.565 2.13 3.907 3.186a201 201 0 0 0 3.438 2.656c.232.175.454.358.688.532C74.71 119.38 95.558 131.28 120.25 142.22c159.723 161.31 288.815 297.553 374.594 352.124c-51.11-85.624-187.432-214.62-350-375.531c-10.23-23.802-21.115-43.71-32.094-59.282c35.46-10.19 82.517-2.294 125.75 40.94c40.243 40.242 52.642 90.776 41.375 128.343c4.89 4.914 9.883 9.913 14.688 14.75c19.07-46.1 4.573-108.895-42.844-156.313c-35.694-35.693-75.48-50.324-111.032-50.22c-1.147.005-2.3.03-3.438.064c-21.844.65-41.957 6.898-58.344 17.187a102 102 0 0 0-8.312 5.814c1.33 3.094 2.758 5.884 4.406 8.218c1.92 2.72 4.004 4.936 6.906 6.688c2.202-1.717 4.498-3.375 6.938-4.906a89 89 0 0 1 3.25-1.97c8.196 10.088 15.465 24.624 25.47 47.25c-23.907-10.568-38.822-18.04-49.033-27c.024-.03.04-.063.064-.093c-.433-.33-.867-.655-1.28-1q-.018-.013-.033-.03c-11.215-10.375-16.532-22.934-22.31-44.5c5.988 1.605 11.256 3.19 16 4.844a137 137 0 0 1 26.81-15.875c-9.5-7.738-18.552-11.93-26.686-12.97c-1.443-.185-2.87-.3-4.282-.313zM493.28 64.374c-77.985 0-125.587 28.886-146.124 69.406s-13.994 90.39 9.688 131.407c11.385 19.72 17.807 40.687 19.187 61.22a4330 4330 0 0 1 18.47 19.187c2.31-29.955-5.072-61.35-21.47-89.75c-21.13-36.602-26.155-80.112-9.186-113.594s55.94-59.188 129.437-59.188V64.376zM37.907 114.938c-2.894 23.925 6.18 50.21 29.532 73.562l13.218-13.188c-15.97-15.97-23.188-31.833-24.437-46.593c-6.646-4.51-12.754-9.102-18.314-13.782zM124.062 277c-24.466-.028-45.04 4.92-61.343 13.656c-24.842 13.31-39.363 35.865-40.75 60.47c-2.78 49.207 44.38 102.65 140.405 118.28c101.63 16.544 170.303-9.184 205.406-54.062q1.513-1.933 2.94-3.906c-4.552-4.232-9.148-8.52-13.783-12.875c-1.233 1.79-2.52 3.55-3.875 5.28c-29.97 38.315-90.355 62.97-187.687 47.126c-90.394-14.715-126.796-62.527-124.75-98.783c1.023-18.127 10.982-34.37 30.938-45.062c19.955-10.692 50.267-15.364 90.5-7.47c34.077 6.69 62.938 3.47 85.437-6.936c-4.72-4.65-9.49-9.33-14.28-14.064c-17.545 6.452-39.89 8.088-67.564 2.656c-14.895-2.923-28.778-4.297-41.594-4.312z"/></symbol>
<symbol id="sewing-string" viewBox="0 0 512 512"><path fill="currentColor" d="M153.9 51.5c-34.6 0-65.87 4.45-87.79 11.3c-10.96 3.43-19.55 7.51-24.76 11.37c-5.22 3.85-6.41 6.7-6.41 8.33s1.19 4.48 6.41 8.33c5.21 3.86 13.8 7.94 24.76 11.37c21.92 6.8 53.19 11.3 87.79 11.3s65.9-4.5 87.8-11.3c11-3.43 19.6-7.51 24.8-11.37c5.2-3.85 6.4-6.7 6.4-8.33s-1.2-4.48-6.4-8.33c-5.2-3.86-13.8-7.94-24.8-11.37c-21.9-6.85-53.2-11.3-87.8-11.3M388 70.48c-2.5 0-5 0-7.5.16c-20.2 1.15-42.3 7.56-63.1 15.43c-12.5 4.72-24.5 9.98-35.3 15.03c-1.5 1.5-3.2 2.9-4.9 4.2q-7.65 5.55-18.3 9.9v17.9c13.5-7.4 38.1-20.1 64.8-30.2c19.9-7.5 40.8-13.32 57.8-14.29c17-.98 28.9 2.5 35.4 11.69c6 8.5 5 14.1-.7 23.4c-5.6 9.3-16.8 20.2-29.5 31.6c-12.6 11.3-26.5 23.2-37.4 36.4s-19.2 28.2-18.2 45.2c1.5 24.8 21.4 48.3 39.9 67.4c18.4 19 36.5 32.9 36.5 32.9l11-14.2s-17.2-13.3-34.6-31.2c-17.4-18-33.9-41.4-34.8-56v-.1c-.7-10.3 4.6-21.1 14.1-32.5c9.5-11.5 22.8-23 35.6-34.6c12.8-11.5 25.1-22.8 32.9-35.6c7.7-12.8 9.7-29.2-.2-43.11c-9.8-13.91-26.1-19.1-43.5-19.41M153.9 74.5a48 8 0 0 1 48 8a48 8 0 0 1-48 8a48 8 0 0 1-48-8a48 8 0 0 1 48-8m-87 46.7v10.6c58.3 14 116 13 174 6.9v-17.5c-23.6 6.5-53.9 10.3-87 10.3c-33.2 0-63.38-3.8-87-10.3m0 29.1v24.4c62.9 2.9 119.7-3.4 174-13.9v-4.1c-57.1 6-115 7-174-6.4m174 28.7c-54 10.3-111.1 16.5-174 13.8v18.7c64 1.5 123.2-2.5 174-15.9zm0 35.3c-52 12.9-111.1 16.6-174 15.2v3.2c61.5 8.4 120.8 13.8 174 10.8zm-174 36.5v8.9c53.3 17 112.9 17.8 174 16.9v-15.1c-54.1 2.9-113.2-2.3-174-10.7m0 27.7v31.8c52.7 3.4 102.8-3.5 152.2-15.5c-52 .4-103.9-1.7-152.2-16.3m174 29.4c-55.7 14.7-113.1 24.3-174 20.5v16c57.6.2 114.8-1 174-14.2zm221.7 19a20.5 40.99 51.48 0 0-32.8 14.1a20.5 40.99 51.48 0 0-.2.1l-.6.6a20.5 40.99 51.48 0 0-8.9 8.5L265.9 497.5l176.3-115.9a20.5 40.99 51.48 0 0 13.1-8.5a20.5 40.99 51.48 0 0 19.3-41.5a20.5 40.99 51.48 0 0-12-4.7M452.5 342a10.25 20.5 51.48 0 1 .1 0a10.25 20.5 51.48 0 1 6 2.3a10.25 20.5 51.48 0 1-9.7 20.8a10.25 20.5 51.48 0 1-22.4 4.8a10.25 20.5 51.48 0 1 9.6-20.8a10.25 20.5 51.48 0 1 16.4-7.1m-211.6 6.6c-59.9 12.8-117.3 14-173.99 13.8c0 0 .41.9 3.71 2.8c3.91 2.2 10.75 4.6 19.24 6.5c16.94 3.8 40.54 5.8 64.04 5.8s47.1-2 64-5.8c8.5-1.9 15.4-4.3 19.3-6.5c3.4-2 3.7-3 3.7-2.8zM49.17 365.5c-3.1 1.6-5.74 3.1-7.82 4.7c-5.22 3.8-6.41 6.7-6.41 8.3s1.19 4.5 6.41 8.3c5.21 3.9 13.8 8 24.76 11.4c21.92 6.8 53.19 11.3 87.79 11.3s65.9-4.5 87.8-11.3c11-3.4 19.6-7.5 24.8-11.4c5.2-3.8 6.4-6.7 6.4-8.3s-1.2-4.5-6.4-8.3c-2.1-1.6-4.7-3.2-7.9-4.7c-1.2 6.9-6.7 12-12.5 15.3c-6.6 3.8-14.7 6.4-24.3 8.5c-19 4.2-43.4 6.2-67.9 6.2s-48.9-2-67.95-6.2c-9.51-2.1-17.68-4.7-24.26-8.5c-5.79-3.2-11.27-8.4-12.52-15.3"/></symbol> <symbol id="sewing-string" viewBox="0 0 512 512"><path fill="currentColor" d="M153.9 51.5c-34.6 0-65.87 4.45-87.79 11.3c-10.96 3.43-19.55 7.51-24.76 11.37c-5.22 3.85-6.41 6.7-6.41 8.33s1.19 4.48 6.41 8.33c5.21 3.86 13.8 7.94 24.76 11.37c21.92 6.8 53.19 11.3 87.79 11.3s65.9-4.5 87.8-11.3c11-3.43 19.6-7.51 24.8-11.37c5.2-3.85 6.4-6.7 6.4-8.33s-1.2-4.48-6.4-8.33c-5.2-3.86-13.8-7.94-24.8-11.37c-21.9-6.85-53.2-11.3-87.8-11.3M388 70.48c-2.5 0-5 0-7.5.16c-20.2 1.15-42.3 7.56-63.1 15.43c-12.5 4.72-24.5 9.98-35.3 15.03c-1.5 1.5-3.2 2.9-4.9 4.2q-7.65 5.55-18.3 9.9v17.9c13.5-7.4 38.1-20.1 64.8-30.2c19.9-7.5 40.8-13.32 57.8-14.29c17-.98 28.9 2.5 35.4 11.69c6 8.5 5 14.1-.7 23.4c-5.6 9.3-16.8 20.2-29.5 31.6c-12.6 11.3-26.5 23.2-37.4 36.4s-19.2 28.2-18.2 45.2c1.5 24.8 21.4 48.3 39.9 67.4c18.4 19 36.5 32.9 36.5 32.9l11-14.2s-17.2-13.3-34.6-31.2c-17.4-18-33.9-41.4-34.8-56v-.1c-.7-10.3 4.6-21.1 14.1-32.5c9.5-11.5 22.8-23 35.6-34.6c12.8-11.5 25.1-22.8 32.9-35.6c7.7-12.8 9.7-29.2-.2-43.11c-9.8-13.91-26.1-19.1-43.5-19.41M153.9 74.5a48 8 0 0 1 48 8a48 8 0 0 1-48 8a48 8 0 0 1-48-8a48 8 0 0 1 48-8m-87 46.7v10.6c58.3 14 116 13 174 6.9v-17.5c-23.6 6.5-53.9 10.3-87 10.3c-33.2 0-63.38-3.8-87-10.3m0 29.1v24.4c62.9 2.9 119.7-3.4 174-13.9v-4.1c-57.1 6-115 7-174-6.4m174 28.7c-54 10.3-111.1 16.5-174 13.8v18.7c64 1.5 123.2-2.5 174-15.9zm0 35.3c-52 12.9-111.1 16.6-174 15.2v3.2c61.5 8.4 120.8 13.8 174 10.8zm-174 36.5v8.9c53.3 17 112.9 17.8 174 16.9v-15.1c-54.1 2.9-113.2-2.3-174-10.7m0 27.7v31.8c52.7 3.4 102.8-3.5 152.2-15.5c-52 .4-103.9-1.7-152.2-16.3m174 29.4c-55.7 14.7-113.1 24.3-174 20.5v16c57.6.2 114.8-1 174-14.2zm221.7 19a20.5 40.99 51.48 0 0-32.8 14.1a20.5 40.99 51.48 0 0-.2.1l-.6.6a20.5 40.99 51.48 0 0-8.9 8.5L265.9 497.5l176.3-115.9a20.5 40.99 51.48 0 0 13.1-8.5a20.5 40.99 51.48 0 0 19.3-41.5a20.5 40.99 51.48 0 0-12-4.7M452.5 342a10.25 20.5 51.48 0 1 .1 0a10.25 20.5 51.48 0 1 6 2.3a10.25 20.5 51.48 0 1-9.7 20.8a10.25 20.5 51.48 0 1-22.4 4.8a10.25 20.5 51.48 0 1 9.6-20.8a10.25 20.5 51.48 0 1 16.4-7.1m-211.6 6.6c-59.9 12.8-117.3 14-173.99 13.8c0 0 .41.9 3.71 2.8c3.91 2.2 10.75 4.6 19.24 6.5c16.94 3.8 40.54 5.8 64.04 5.8s47.1-2 64-5.8c8.5-1.9 15.4-4.3 19.3-6.5c3.4-2 3.7-3 3.7-2.8zM49.17 365.5c-3.1 1.6-5.74 3.1-7.82 4.7c-5.22 3.8-6.41 6.7-6.41 8.3s1.19 4.5 6.41 8.3c5.21 3.9 13.8 8 24.76 11.4c21.92 6.8 53.19 11.3 87.79 11.3s65.9-4.5 87.8-11.3c11-3.4 19.6-7.5 24.8-11.4c5.2-3.8 6.4-6.7 6.4-8.3s-1.2-4.5-6.4-8.3c-2.1-1.6-4.7-3.2-7.9-4.7c-1.2 6.9-6.7 12-12.5 15.3c-6.6 3.8-14.7 6.4-24.3 8.5c-19 4.2-43.4 6.2-67.9 6.2s-48.9-2-67.95-6.2c-9.51-2.1-17.68-4.7-24.26-8.5c-5.79-3.2-11.27-8.4-12.52-15.3"/></symbol>

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Before After
Before After

View file

@ -571,3 +571,34 @@ button.sheet-skill:hover { background: var(--panel-2); }
.happening-banner { flex-wrap: wrap; } .happening-banner { flex-wrap: wrap; }
.happening-banner .grow { min-width: min(260px, 70%); } .happening-banner .grow { min-width: min(260px, 70%); }
.happening-banner > .btn { margin-left: auto; } .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; } }

View file

@ -1518,3 +1518,65 @@ class CombatReworkMigrationTest(unittest.TestCase):
def test_homesteaders_tailor(self): def test_homesteaders_tailor(self):
self.assertIn("tailoring", game.CLASSES["homesteader"]["fast"]) self.assertIn("tailoring", game.CLASSES["homesteader"]["fast"])
self.assertIn("tailoring", game.CLASSES["homesteader"]["craft"]) 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)