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:
parent
89fd9b3c40
commit
d03f0035eb
7 changed files with 538 additions and 8 deletions
247
game.py
247
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue