Gold: guard wages, raid retune, strongbox, caravan wares, looks, monuments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 13:34:59 +01:00
parent 9d61de2012
commit cd44709d80
6 changed files with 816 additions and 31 deletions

515
game.py
View file

@ -1244,7 +1244,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 28
WORLD_VERSION = 29
def new_world(now: float, speed: float = 1.0) -> dict:
@ -1268,6 +1268,7 @@ def new_world(now: float, speed: float = 1.0) -> dict:
"starving": False,
"repelled": 0,
"fires": {key: new_fire(now) for key in FIRES},
"wages": 0, "monuments": {}, "built": [], "carving": None,
},
"events": [],
"next_event_at": now + 12 * HOUR / speed,
@ -1280,6 +1281,7 @@ def new_world(now: float, speed: float = 1.0) -> dict:
"next_message_id": 1,
"orders": [], "next_order_id": 1,
"parties": {}, "next_party_id": 1,
"caravan": None, "next_caravan_id": 1,
}
@ -1464,6 +1466,19 @@ def migrate(world: dict, now: float | None = None) -> dict:
for player in world["players"].values():
player.setdefault("manual", [])
world["version"] = 28
if world["version"] < 29:
# Guards are paid now. The council covers the coming threat once, so nobody is caught out.
world["city"]["wages"] = guard_posts(world) * GUARD_WAGE
# Gold to spend: rented slots, caravan wares, a look of your own, monuments.
for player in world["players"].values():
for key, value in (("rented", 0), ("boosts", []), ("bought", None), ("deeds", {}), ("owned", [])):
player.setdefault(key, value)
player.setdefault("look", dict(DEFAULT_LOOK))
world.setdefault("caravan", None)
world.setdefault("next_caravan_id", 1)
for key, value in (("monuments", {}), ("built", []), ("carving", None)):
world["city"].setdefault(key, value)
world["version"] = 29
return world
@ -1721,6 +1736,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
"action": None, "stopped": None,
"last_active": now, "overflow": "city",
"perks": {"bank_slot": 0}, "manual": [],
"rented": 0, "boosts": [], "bought": None, "deeds": {}, "owned": [], "look": dict(DEFAULT_LOOK),
"garrison": False,
"donated": 0,
"pinned": [], # skills pinned to the top of the sidebar
@ -1734,7 +1750,7 @@ def skill_level(player: dict, skill: str) -> int:
def bank_slots(world: dict, player: dict) -> int:
return (BASE_SLOTS + 3 * building_level(world, "storehouse") + TIER_SLOTS * world["city"]["tier"]
+ player["perks"]["bank_slot"] + fitted(world, "shelving") + int(gear_bonus(player, "slots")))
+ player["perks"]["bank_slot"] + player["rented"] + fitted(world, "shelving") + int(gear_bonus(player, "slots")))
def stack_size(world: dict, player: dict | None = None) -> int:
@ -1914,7 +1930,7 @@ def rest_plan(world: dict | None, player: dict, hp: float | None = None) -> tupl
A bite is only taken when it's quicker than resting, so better food makes for shorter rests."""
top = max_hp(player)
missing = top - (player["hp"] if hp is None else hp)
rate = top * REST_RATE * regen_boost(world, player)
rate = top * REST_RATE * regen_boost(world, player) / ware(player, "rest", "combat")
seconds, eaten = 0.0, {}
for food in edible(player):
heal, left = ITEMS[food]["heal"], player["bank"].get(food, 0)
@ -2250,6 +2266,7 @@ def action_time(world: dict, player: dict, action: dict) -> float:
seconds *= 0.8
seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0)
seconds *= skill_mods(world, action)["speed"]
seconds *= ware(player, "speed", action["skill"])
return seconds / speed(world)
@ -2534,9 +2551,10 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
outputs[item] += qty
summary["lucky"] += 1
mods = skill_mods(world, action)
if mods["yield"] != 1:
grow = mods["yield"] * (ware(player, "yield", action["skill"], spend=True) if action["outputs"] else 1)
if grow != 1:
for item, qty in list(outputs.items()):
exact = qty * mods["yield"]
exact = qty * grow
outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0)
for item, qty, odds in action.get("finds", []):
# A gem in the rock: a masterwork (a prospector's double ore) turns up two.
@ -2548,6 +2566,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
outing = player["action"]
fight = fight or outing.pop("fight", None) or simulate_fight(world, player, action, rng, outing["start"])
mine = member_of(fight, player["id"])
ware(player, "rest", "combat", spend=True)
xp = pack_xp(fight, killed_only=mine["result"] != "won") * mods["xp"]
if player["prayers"] and not mine["pp"]:
summary["prayer_out"] = True
@ -2583,6 +2602,17 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
outing.pop("downs", None)
summary["wins"] += 1
gold, drops = loot or pack_loot(action, fight, rng)
if ware(player, "drops", "combat", spend=True) != 1:
# A whetstone's edge: another go at the pack's trophies and reagents.
drops = dict(drops)
for item, qty in pack_loot(action, fight, rng)[1].items():
if ITEMS[item].get("trophy") or ITEMS[item].get("reagent"):
drops[item] = drops.get(item, 0) + qty
if any(foe.get("boss") for foe in fight["pack"]):
_deed(player, "bosses")
emblem = f"boss_{action['id']}"
if emblem in COSMETICS and emblem not in player["owned"]:
player["owned"].append(emblem)
player["gold"] += gold
summary["gold"] += gold
for item, qty in drops.items():
@ -2654,6 +2684,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
kept = bank_add(world, player, item, qty, now, summary)
if kept:
summary["items"][item] = summary["items"].get(item, 0) + kept
xp *= ware(player, "xp", action["skill"], spend=True)
ware(player, "speed", action["skill"], spend=True)
gain_xp(world, player, action["skill"], xp, now, summary)
@ -3670,8 +3702,10 @@ def wear_kit(world: dict, share: float, rng: random.Random) -> dict:
stock = world["city"]["stockpile"]
broken = {}
for slot, stat in (("weapon", "power"), ("shield", "armour")):
people = int(world["city"]["population"] // 2)
people = guards_on_duty(world)
issued = []
if not people:
continue
for item in sorted((i for i in stock if ITEMS[i].get("slot") == slot), key=lambda i: -ITEMS[i].get(stat, 0)):
take = min(people, stock[item])
issued.append((item, take))
@ -3709,16 +3743,28 @@ def equip_problem(player: dict, item: str) -> str | None:
return None
def defence(world: dict) -> dict:
"""The city's defence right now, part by part."""
def guard_posts(world: dict) -> int:
"""How many guards the barracks can field, never more than half the townsfolk."""
return min(GUARD_POSTS_BASE + GUARD_POSTS * building_level(world, "barracks"), int(world["city"]["population"] // 2))
def guards_on_duty(world: dict) -> int:
"""Guards whose wage for the coming threat is in the chest."""
return min(guard_posts(world), int(world["city"].get("wages", 0) // GUARD_WAGE))
def defence(world: dict, guards: int | None = None) -> dict:
"""The city's defence right now, part by part. `guards` overrides how many are on duty."""
city = world["city"]
pop = city["population"]
stock = city["stockpile"]
armed = int(pop // 2)
posts = guard_posts(world)
armed = guards_on_duty(world) if guards is None else min(guards, posts)
militia = 0.3 * building_level(world, "barracks") * pop * (1.5 if fitted(world, "weapon_racks") else 1)
parts = {
"townsfolk": 10,
"walls": 20 * building_level(world, "walls") + 25 * fitted(world, "hoardings"),
"militia": round(0.3 * building_level(world, "barracks") * pop * (1.5 if fitted(world, "weapon_racks") else 1), 1),
"militia": round(militia * armed / posts, 1) if posts else 0,
"weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1),
"shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1),
"arrows": min(100, stock.get("arrows", 0) // 10),
@ -3728,6 +3774,8 @@ def defence(world: dict) -> dict:
"mercenaries": city.get("hired", 0),
"garrison": 0,
}
if guards is not None:
return {"total": round(sum(parts.values()), 1), "parts": parts}
garrison = []
for player in world["players"].values():
if player["garrison"]:
@ -3737,7 +3785,47 @@ def defence(world: dict) -> dict:
total = sum(parts.values())
if city["starving"]:
total *= 0.8
return {"parts": parts, "garrison": garrison, "total": round(total, 1), "starving": city["starving"]}
return {"parts": parts, "garrison": garrison, "total": round(total, 1), "starving": city["starving"],
"guards": armed, "posts": posts, "wages": int(city.get("wages", 0)), "wage": GUARD_WAGE}
def standing_defence(world: dict) -> float:
"""What the city holds with every guard post paid and no one preparing: walls, guards, kit, arrows."""
parts = defence(world, guards=guard_posts(world))["parts"]
return sum(v for k, v in parts.items() if k not in ("drilled", "rallied", "ward", "mercenaries", "garrison"))
def pay_wages(world: dict, player: dict, qty, source: str, now: float) -> str | None:
"""Put gold in the barracks' wage chest, from your purse (earns favour) or the treasury."""
city = world["city"]
room = guard_posts(world) * GUARD_WAGE - int(city.get("wages", 0))
if room <= 0:
return "Every post is paid for"
have = player["gold"] if source == "bank" else int(city["treasury"]) if source == "city" else None
if have is None:
return "Bad source"
qty = min(have, room) if qty == "all" else qty
if not isinstance(qty, int) or qty <= 0:
return "Nothing to pay"
if qty > have:
return "Not enough gold"
qty = min(qty, room)
if source == "bank":
player["gold"] -= qty
player["favour"] += qty * city_rate(world, "favour")
player["donated"] += qty
log(world, now, "donation", f"{player['name']} paid {qty:,} gold of guard wages", player["id"])
else:
city["treasury"] -= qty
log(world, now, "donation", f"{player['name']} paid {qty:,} gold of guard wages from the treasury", player["id"])
city["wages"] = city.get("wages", 0) + qty
return None
def _pay_guards(world: dict, report: dict) -> None:
"""The guards who stood are paid from the chest; what's left waits for the next threat."""
city = world["city"]
city["wages"] = city.get("wages", 0) - report["guards"] * GUARD_WAGE
def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
@ -3757,6 +3845,7 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
changed = True
changed = advance_weather(world, now, rng) or changed
changed = _happenings_tracks(world, now, rng) or changed
changed = _caravan_tick(world, now, rng) or changed
construction = city["construction"]
if construction and construction["labour"] >= construction["needed"]:
_finish_construction(world, now)
@ -3880,10 +3969,15 @@ def _announce(world: dict, now: float, rng: random.Random) -> None:
world["next_event_id"] += 1
# Bigger, richer and older cities draw bigger threats; every win draws a bolder one.
wealth = city["treasury"] + stockpile_value(city)
strength = (15 + 4 * day + 0.8 * city["population"] + 1.2 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"])
# A bigger guild draws a bolder band: half the combat power of everyone active in the last two days.
fighters = sum(combat_power(p) for p in world["players"].values() if p["last_active"] > now - 2 * DAY / speed(world))
strength = ((15 + 5 * day + 1.5 * city["population"] + 1.6 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"])
+ 0.5 * fighters)
# How big it looks is measured against what the city holds without preparing.
event["standing"] = round(max(1.0, standing_defence(world)), 1)
if kind == "dragon":
event.update({
"strength": round(strength * 1.5 * rng.uniform(0.9, 1.15), 1),
"strength": round(strength * 0.8 * rng.uniform(0.9, 1.15), 1),
"group": rng.choice(DRAGONS),
"trait": rng.choice(DRAGON_TRAITS),
})
@ -3897,6 +3991,7 @@ def _announce(world: dict, now: float, rng: random.Random) -> None:
log(world, now, "event", "Rumours of bandits on the roads reach the council")
world["events"].append(event)
del world["events"][:-30]
_plan_caravan(world, now, rng)
# Intel: each level takes more reconnaissance than the last, a bigger band is
@ -3912,12 +4007,12 @@ INFORMANT_RECON = 30 # boosted recon per informant: doubled re
def recon_gain(world: dict, player: dict, action: dict) -> float:
gain = (action["recon"] + fitted(world, "map_table")) * (1 + SPYMASTER_RECON * building_level(world, "spymaster"))
return gain * (1.5 if player["cls"] == "ranger" else 1)
return gain * (1.5 if player["cls"] == "ranger" else 1) * ware(player, "recon", "reconnaissance")
def intel_costs(event: dict) -> list[int]:
"""Total recon needed for each intel level."""
size = next(i for i, (limit, _label) in enumerate(SIZE_LABELS) if event["strength"] < limit)
size = threat_size(event, event["strength"])
scale = INTEL_SIZE[size] * (0.5 if event["kind"] == "dragon" else 1)
return [round(sum(INTEL_POINTS[:level]) * scale) for level in (1, 2, 3)]
@ -3942,7 +4037,15 @@ def intel_level(world: dict, event: dict, now: float | None = None) -> int:
return level
SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")]
SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")] # by strength, before v28
SIZE_RATIOS = [1.4, 1.9, 2.5, math.inf] # strength / standing defence for small, medium, large, huge
def threat_size(event: dict, strength: float) -> int:
"""0 small … 3 huge, against what the city could hold when the threat was rumoured."""
if "standing" in event:
return next(i for i, limit in enumerate(SIZE_RATIOS) if strength / event["standing"] < limit)
return next(i for i, (limit, _label) in enumerate(SIZE_LABELS) if strength < limit)
WINDOW_HOURS = [4, 3, 2, 0]
@ -3970,7 +4073,7 @@ def event_view(world: dict, event: dict, now: float) -> dict:
view["headline"] = "Herders whisper of scorched pastures in the hills."
if level >= 1:
view["headline"] = "A dragon has been seen over the hills, and it's coming closer."
view["size"] = next(label for limit, label in SIZE_LABELS if strength < limit)
view["size"] = SIZE_LABELS[threat_size(event, strength)][1]
if level >= 2:
spread = 0.25 * strength
low = strength - spread * (1 + event["jitter"]) / 2
@ -3987,7 +4090,7 @@ def event_view(world: dict, event: dict, now: float) -> dict:
strength = event["strength"] * (1 - event["sabotage"])
view["headline"] = "Travellers speak of trouble on the roads."
if level >= 1:
size = next(label for limit, label in SIZE_LABELS if strength < limit)
size = SIZE_LABELS[threat_size(event, strength)][1]
view["headline"] = f"A {size} bandit band is gathering in the hills."
view["size"] = size
if level >= 2:
@ -4015,7 +4118,7 @@ def council_requests(world: dict, event: dict) -> list[dict]:
city = world["city"]
stock = city["stockpile"]
strength = event["strength"]
armed = int(city["population"] // 2)
armed = guard_posts(world)
wants = [
("arrows", min(1000, 50 * math.ceil(strength * 4 / 50))),
("copper_sword", min(armed, max(5, math.ceil(strength / 6)))),
@ -4048,6 +4151,13 @@ def _fire(world: dict, event: dict, now: float, rng: random.Random) -> None:
_resolve_raid(world, event, now, rng)
elif event["kind"] == "dragon":
_resolve_dragon(world, event, now, rng)
if event["kind"] in THREATS:
won = event["report"]["title"].startswith("Victory")
for entry in event["report"]["garrison"] if won else []:
_deed(world["players"][entry["id"]], "dragons" if event["kind"] == "dragon" else "defended")
# Wares bought against this threat are spent.
for player in world["players"].values():
player["boosts"] = [b for b in player["boosts"] if "threat" not in b]
elif event["kind"] == "merchant":
world["city"]["merchant_until"] = now + 12 * HOUR / speed(world)
event["report"] = {"title": "A merchant caravan has arrived",
@ -4077,10 +4187,10 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
lines.append(f"Sabotage weakened the raiders by {round(event['sabotage'] * 100)}%.")
report = {
"group": event["group"], "attack": round(attack), "defence": guard["total"],
"parts": guard["parts"], "garrison": garrison,
"parts": guard["parts"], "garrison": garrison, "guards": guard["guards"],
}
if ratio >= 1:
loot = round(attack * 2)
loot = round(attack)
city["treasury"] += loot
city["repelled"] += 1
broken = wear_kit(world, wear_share(ratio), rng)
@ -4091,7 +4201,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
for entry in garrison:
player = world["players"][entry["id"]]
player["favour"] += 25
gain_xp(world, player, "combat", round(attack * 5), now)
gain_xp(world, player, "combat", round(attack * 2), now)
if garrison:
lines.append("Everyone in the garrison earned 25 favour and combat experience.")
log(world, now, "raid", f"{upper_first(event["group"])} were driven off")
@ -4130,10 +4240,11 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
f"The {BUILDINGS[key]['name'].lower()} was damaged and won't work until repaired.")
lines.append("A smaller, poorer city will draw less attention for a while.")
for entry in garrison:
gain_xp(world, world["players"][entry["id"]], "combat", round(attack * 2), now)
gain_xp(world, world["players"][entry["id"]], "combat", round(attack), now)
log(world, now, "raid", f"{upper_first(event["group"])} broke into the city")
report["lines"] = lines
event["report"] = report
_pay_guards(world, report)
for player in world["players"].values():
player["garrison"] = False
city["rally"] = city["rallies"] = 0
@ -4148,7 +4259,7 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) ->
guard = defence(world)
garrison = guard["garrison"]
report = {"group": event["group"], "attack": round(attack), "defence": guard["total"],
"parts": guard["parts"], "garrison": garrison, "icon": "🐉"}
"parts": guard["parts"], "garrison": garrison, "guards": guard["guards"], "icon": "🐉"}
lines = []
if guard["total"] >= attack:
city["repelled"] += 1
@ -4161,7 +4272,7 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) ->
for entry in garrison:
player = world["players"][entry["id"]]
player["favour"] += 40
gain_xp(world, player, "combat", round(attack * 6), now)
gain_xp(world, player, "combat", round(attack * 2.5), now)
bank_add(world, player, "dragon_bones", 2, now, empty_summary())
for weight in WEIGHTS:
bank_add(world, player, reagent(weight, 3), 1, now, empty_summary())
@ -4186,10 +4297,11 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) ->
if name:
lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.")
for entry in garrison:
gain_xp(world, world["players"][entry["id"]], "combat", round(attack * 2), now)
gain_xp(world, world["players"][entry["id"]], "combat", round(attack), now)
log(world, now, "raid", report["title"])
report["lines"] = lines
event["report"] = report
_pay_guards(world, report)
for player in world["players"].values():
player["garrison"] = False
city["rally"] = city["rallies"] = 0
@ -4826,6 +4938,328 @@ def happenings_view(world: dict, now: float) -> dict:
}
# --- gold ------------------------------------------------------------------
# Gold never buys results. It rents bank slots, pays the guards, buys caravan
# wares that boost your own work for a while, and buys looks.
RENT_BASE = 1000 # gold for the first rented bank slot...
RENT_GROWTH = 1.35 # ...and each one after costs this much more
def rent_cost(player: dict) -> int:
return int(round(RENT_BASE * RENT_GROWTH ** player["rented"] / 50) * 50)
def rent_slot(player: dict) -> str | None:
cost = rent_cost(player)
if player["gold"] < cost:
return f"Needs {cost:,} gold"
player["gold"] -= cost
player["rented"] += 1
return None
# Caravan wares: each multiplies your own work in one skill for its next `charges`
# actions, or against the coming threat. `skill` None is picked by the buyer.
WARES = {
"whetstone": {"name": "Whetstone of the north", "skill": "combat", "kind": "drops", "mult": 2, "charges": 100,
"price": 3000, "text": "Trophy and reagent odds ×2", "per": "kills"},
"tutor": {"name": "Tutor's notes", "skill": None, "kind": "xp", "mult": 1.5, "charges": 200, "price": 1500,
"text": "+50% XP", "per": "actions"},
"salts": {"name": "Ember salts", "skill": "firekeeping", "kind": "speed", "mult": 0.75, "charges": 100, "price": 1200,
"text": "Tending 25% faster", "per": "tends"},
"seeds": {"name": "Seed bag", "skill": "farming", "kind": "yield", "mult": 1.5, "charges": 150, "price": 1200,
"text": "+50% yield", "per": "harvests"},
"prospector": {"name": "Prospector's charm", "skill": "mining", "kind": "yield", "mult": 1.5, "charges": 300,
"price": 1500, "text": "+50% yield", "per": "ores"},
"lure": {"name": "Silver lure", "skill": "fishing", "kind": "yield", "mult": 1.5, "charges": 300, "price": 1200,
"text": "+50% yield", "per": "catches"},
"flux": {"name": "Fine flux", "skill": "smithing", "kind": "speed", "mult": 0.8, "charges": 100, "price": 1500,
"text": "Smithing 20% faster", "per": "crafts"},
"rations": {"name": "Trail rations", "skill": "combat", "kind": "rest", "mult": 0.5, "charges": 50, "price": 800,
"text": "Rest 50% faster", "per": "fights"},
"spyglass": {"name": "Spyglass lens", "skill": "reconnaissance", "kind": "recon", "mult": 1.5, "charges": None,
"price": 1000, "text": "Your recon ×1.5", "per": "threat"},
"manual": {"name": "Drill manual", "skill": "combat", "kind": "drill", "mult": 1.5, "charges": None,
"price": 1000, "text": "Your drilling ×1.5", "per": "threat"},
}
WARE_GLYPHS = {"whetstone": ("stone-sphere", "#a8b4c8"), "tutor": ("book-cover", "#4fa3e0"), "salts": ("powder", "#ff7a2a"),
"seeds": ("plant-seed", "#8fbf6a"), "prospector": ("gems", "#7fd0e0"), "lure": ("fishing-lure", "#d4dae2"),
"flux": ("powder-bag", "#d07a3c"), "rations": ("meat", "#c06a4a"), "spyglass": ("spyglass", "#6f8fec"),
"manual": ("scroll-unfurled", "#e0a050")}
CARAVAN_HOURS = 12 # how long the caravan stays
CARAVAN_WARES = 4 # wares it brings from the pool, plus one look
def ware(player: dict, kind: str, skill: str, spend: bool = False) -> float:
"""The multiplier a caravan ware gives this kind of work, using up a charge if `spend`."""
for boost in player["boosts"]:
spec = WARES[boost["ware"]]
if spec["kind"] == kind and boost["skill"] == skill:
if spend and "left" in boost:
boost["left"] -= 1
if boost["left"] <= 0:
player["boosts"].remove(boost)
return spec["mult"]
return 1
def ware_price(player: dict, ware_id: str, skill: str) -> int:
"""The list price, more for someone further along in the skill it helps."""
return int(round(WARES[ware_id]["price"] * (1 + skill_level(player, skill) / 30) / 50) * 50)
def _plan_caravan(world: dict, now: float, rng: random.Random) -> None:
"""The caravan comes once per threat, on the first day, for half a day."""
at = now + rng.uniform(2, 16) * HOUR / speed(world)
world["caravan"] = {"id": world["next_caravan_id"], "at": at, "until": at + CARAVAN_HOURS * HOUR / speed(world),
"wares": None, "look": None}
world["next_caravan_id"] += 1
def caravan_here(world: dict, now: float) -> dict | None:
caravan = world.get("caravan")
return caravan if caravan and caravan["wares"] is not None and caravan["at"] <= now < caravan["until"] else None
def _caravan_tick(world: dict, now: float, rng: random.Random) -> bool:
caravan = world.get("caravan")
if not caravan or caravan["wares"] is not None or now < caravan["at"] or now >= caravan["until"]:
return False
caravan["wares"] = rng.sample(sorted(WARES), CARAVAN_WARES)
caravan["look"] = rng.choice(sorted(k for k, c in COSMETICS.items() if c.get("price")))
names = [WARES[w]["name"].lower() for w in caravan["wares"]] + [COSMETICS[caravan["look"]]["name"].lower()]
_record(world, now, "fortune", "caravan", "🐫", "A caravan has arrived",
[f"For {CARAVAN_HOURS} hours it sells {', '.join(names[:-1])} and {names[-1]}."])
return True
def buy_ware(world: dict, player: dict, ware_id: str, skill: str | None, now: float) -> str | None:
caravan = caravan_here(world, now)
if not caravan:
return "The caravan isn't here"
bought = player["bought"] if player["bought"] and player["bought"]["visit"] == caravan["id"] else {"visit": caravan["id"], "items": []}
if ware_id in bought["items"]:
return "One of each per visit"
if ware_id == caravan["look"]:
spec = COSMETICS[ware_id]
if ware_id in player["owned"]:
return "You have that already"
if player["gold"] < spec["price"]:
return f"Needs {spec['price']:,} gold"
player["gold"] -= spec["price"]
player["owned"].append(ware_id)
else:
if ware_id not in caravan["wares"]:
return "Not for sale"
spec = WARES[ware_id]
skill = spec["skill"] or skill
if skill not in SKILL_IDS:
return "Pick a skill"
threat = upcoming_raid(world)
if spec["charges"] is None and not threat:
return "There's no threat coming"
price = ware_price(player, ware_id, skill)
if player["gold"] < price:
return f"Needs {price:,} gold"
player["gold"] -= price
held = next((b for b in player["boosts"] if b["ware"] == ware_id and b["skill"] == skill), None)
if spec["charges"] is None:
if held:
return "You have one for this threat"
player["boosts"].append({"ware": ware_id, "skill": skill, "threat": threat["id"]})
elif held:
held["left"] += spec["charges"]
else:
player["boosts"].append({"ware": ware_id, "skill": skill, "left": spec["charges"]})
bought["items"].append(ware_id)
player["bought"] = bought
return None
def caravan_view(world: dict, player: dict, now: float) -> dict | None:
caravan = caravan_here(world, now)
if not caravan:
return None
bought = player["bought"]["items"] if player["bought"] and player["bought"]["visit"] == caravan["id"] else []
wares = []
for ware_id in caravan["wares"]:
spec = WARES[ware_id]
skills = [spec["skill"]] if spec["skill"] else [s for s in SKILL_IDS]
wares.append({"id": ware_id, **spec, "glyph": WARE_GLYPHS[ware_id],
"prices": {s: ware_price(player, ware_id, s) for s in skills}, "bought": ware_id in bought})
look = COSMETICS[caravan["look"]]
return {"until": caravan["until"], "wares": wares,
"look": {"id": caravan["look"], **look, "bought": caravan["look"] in bought, "owned": caravan["look"] in player["owned"]}}
# Looks: a banner (field, trim, emblem), a title after your name, and your home on the roster.
# Free colours and earned titles cost nothing; the caravan sells one more look per visit.
DEFAULT_LOOK = {"field": "slate", "trim": "bone", "emblem": None, "title": None}
COSMETICS = {
# free
"slate": {"kind": "colour", "name": "Slate", "value": "#5a6478"},
"navy": {"kind": "colour", "name": "Navy", "value": "#2f4f8a"},
"oxblood": {"kind": "colour", "name": "Oxblood", "value": "#7a2f2f"},
"forest": {"kind": "colour", "name": "Forest", "value": "#2f6b3a"},
"bone": {"kind": "colour", "name": "Bone", "value": "#e7e9f0"},
"brass": {"kind": "colour", "name": "Brass", "value": "#e0b04a"},
# sold by the caravan
"royal_purple": {"kind": "colour", "name": "Royal purple dye", "value": "#6b3f8a", "price": 800},
"teal": {"kind": "colour", "name": "Deep teal dye", "value": "#1f6f6f", "price": 600},
"ember": {"kind": "colour", "name": "Ember dye", "value": "#ff7a2a", "price": 800},
"wine": {"kind": "colour", "name": "Wine dye", "value": "#8a2f5a", "price": 600},
"jet": {"kind": "colour", "name": "Jet dye", "value": "#111318", "price": 1000},
"sky": {"kind": "colour", "name": "Sky dye", "value": "#7fb6e8", "price": 600},
"e_swords": {"kind": "emblem", "name": "Crossed swords emblem", "value": "crossed-swords", "price": 1200},
"e_anvil": {"kind": "emblem", "name": "Anvil emblem", "value": "anvil", "price": 1200},
"e_castle": {"kind": "emblem", "name": "Tower emblem", "value": "castle", "price": 1200},
"e_wolf": {"kind": "emblem", "name": "Wolf emblem", "value": "wolf-head", "price": 1500},
"e_stag": {"kind": "emblem", "name": "Stag emblem", "value": "deer", "price": 1500},
"e_sun": {"kind": "emblem", "name": "Sun emblem", "value": "sun", "price": 1500},
"e_oak": {"kind": "emblem", "name": "Oak emblem", "value": "oak", "price": 1500},
"e_rose": {"kind": "emblem", "name": "Rose emblem", "value": "rose", "price": 2000},
"t_thrifty": {"kind": "title", "name": "Title: the Thrifty", "value": "the Thrifty", "price": 1000},
"t_bold": {"kind": "title", "name": "Title: the Bold", "value": "the Bold", "price": 2000},
"t_wanderer": {"kind": "title", "name": "Title: the Wanderer", "value": "the Wanderer", "price": 1500},
"t_lucky": {"kind": "title", "name": "Title: the Lucky", "value": "the Lucky", "price": 2500},
"t_unbowed": {"kind": "title", "name": "Title: the Unbowed", "value": "the Unbowed", "price": 3000},
}
FREE_LOOKS = [key for key, c in COSMETICS.items() if not c.get("price")]
# Boss emblems are only won, by beating the boss.
for _a in ACTIONS:
if "enemy" in _a and _a["enemy"].get("boss"):
COSMETICS[f"boss_{_a['id']}"] = {"kind": "emblem", "name": f"{_a['enemy']['name']} emblem", "boss": _a["id"]}
SKILL_TITLES = {"mining": "miner", "woodcutting": "woodcutter", "farming": "farmer", "hunting": "hunter", "fishing": "angler",
"smithing": "smith", "jewelcrafting": "jeweller", "construction": "builder", "cooking": "cook",
"leatherworking": "tanner", "tailoring": "tailor", "herblore": "herbalist", "firekeeping": "flamekeeper",
"combat": "warrior", "prayer": "priest", "reconnaissance": "scout"}
DEED_TITLES = [("defended", 5, "Defender of the City"), ("dragons", 1, "Dragonslayer"), ("bosses", 10, "Bossbane")]
HOMES = [("Hut", 0), ("Cottage", 2000), ("House", 8000), ("Hall", 25000), ("Manor", 75000)]
MONUMENTS = {
"gate_banner": {"name": "Gate banner", "cost": 10000, "glyph": "flying-flag", "tint": "#e0b04a", "once": True,
"text": "The guild's colours over the city gate."},
"fountain": {"name": "Fountain", "cost": 25000, "glyph": "fountain", "tint": "#5aa0d8", "once": True,
"text": "A fountain in the town square."},
"statue": {"name": "Statue of a guildmate", "cost": 50000, "glyph": "stone-bust", "tint": "#d4dae2", "once": False,
"text": "Carved in stone. The biggest donor picks who."},
}
def _deed(player: dict, key: str) -> None:
player["deeds"][key] = player["deeds"].get(key, 0) + 1
def earned_titles(player: dict) -> list[str]:
titles = []
for skill, noun in SKILL_TITLES.items():
level = skill_level(player, skill)
if level >= 99:
titles.append(f"Grandmaster {noun}")
elif level >= 50:
titles.append(f"Master {noun}")
titles += [title for key, need, title in DEED_TITLES if player["deeds"].get(key, 0) >= need]
if player["donated"] >= 100_000:
titles.append("Patron of the City")
return titles
def titles(player: dict) -> list[str]:
return earned_titles(player) + [COSMETICS[c]["value"] for c in player["owned"] if COSMETICS[c]["kind"] == "title"]
def can_wear(player: dict, key: str | None, kind: str) -> bool:
if key is None:
return kind == "emblem"
spec = COSMETICS.get(key)
return bool(spec) and spec["kind"] == kind and (key in FREE_LOOKS or key in player["owned"])
def set_look(player: dict, look: dict) -> str | None:
field, trim, emblem, title = (look.get(k) for k in ("field", "trim", "emblem", "title"))
if not (can_wear(player, field, "colour") and can_wear(player, trim, "colour") and can_wear(player, emblem, "emblem")):
return "You don't own that"
if title is not None and title not in titles(player):
return "You haven't earned that title"
player["look"] = {"field": field, "trim": trim, "emblem": emblem, "title": title}
return None
def look_view(player: dict) -> dict:
"""What others see: banner colours and emblem resolved, title, home."""
look = player["look"]
title = look["title"] if look["title"] in titles(player) else None
emblem = COSMETICS[look["emblem"]] if look["emblem"] else None
glyph = None
if emblem:
glyph = emblem.get("value") or ACTION_BY_ID[emblem["boss"]]["glyph"]
return {"field": COSMETICS[look["field"]]["value"], "trim": COSMETICS[look["trim"]]["value"], "emblem": glyph,
"title": title, "home": HOMES[player.get("home", 0)][0]}
def upgrade_home(player: dict) -> str | None:
level = player.get("home", 0)
if level + 1 >= len(HOMES):
return "Your home is as grand as it gets"
name, cost = HOMES[level + 1]
if player["gold"] < cost:
return f"Needs {cost:,} gold"
player["gold"] -= cost
player["home"] = level + 1
return None
def give_monument(world: dict, player: dict, key: str, qty, now: float) -> str | None:
"""Gold towards a monument. No favour: your name in the stone is the reward."""
city = world["city"]
spec = MONUMENTS.get(key)
if not spec:
return "No such monument"
if spec["once"] and any(b["kind"] == key for b in city["built"]):
return "It's already built"
if key == "statue" and city["carving"]:
return "A statue is being carved"
fund = city["monuments"].setdefault(key, {"gold": 0, "donors": {}})
room = spec["cost"] - fund["gold"]
qty = min(player["gold"], room) if qty == "all" else qty
if not isinstance(qty, int) or qty <= 0:
return "Nothing to give"
if qty > player["gold"]:
return "Not enough gold"
qty = min(qty, room)
player["gold"] -= qty
fund["gold"] += qty
fund["donors"][player["id"]] = fund["donors"].get(player["id"], 0) + qty
if fund["gold"] >= spec["cost"]:
del city["monuments"][key]
if key == "statue":
chooser = max(fund["donors"], key=lambda uid: fund["donors"][uid])
city["carving"] = {"by": chooser, "donors": fund["donors"]}
log(world, now, "build", f"The statue fund is full. {world['players'][chooser]['name']} picks who it honours.")
else:
city["built"].append({"kind": key, "at": now, "donors": fund["donors"], "of": None})
log(world, now, "build", f"The {spec['name'].lower()} is finished")
return None
def carve_statue(world: dict, player: dict, of: str, now: float) -> str | None:
city = world["city"]
carving = city["carving"]
if not carving or carving["by"] != player["id"]:
return "It isn't yours to pick"
if of not in world["players"]:
return "No such guildmate"
city["built"].append({"kind": "statue", "at": now, "donors": carving["donors"], "of": of})
city["carving"] = None
log(world, now, "build", f"A statue of {world['players'][of]['name']} now stands in the town square")
return None
def monuments_view(world: dict) -> dict:
city = world["city"]
return {"funds": city["monuments"], "built": city["built"], "carving": city["carving"]}
# --- favour --------------------------------------------------------------
MASTER_BUILDER_LABOUR = 50 # boosted labour per hire: doubled labour and XP for whoever works it
@ -4834,6 +5268,9 @@ INFORMANT_FAVOUR = 250
# Drilling: fighters train the townsfolk before a raid. A full drill takes as
# many points as full intel on the same threat, so fighters and scouts put in
# about the same time; the defence it's worth grows with the population.
GUARD_POSTS_BASE = 10 # guard posts with no barracks...
GUARD_POSTS = 20 # ...and per barracks level, never more than half the townsfolk
GUARD_WAGE = 60 # gold per guard per threat, paid into the chest ahead of time
DRILL_PER_ACTION = 1.0 # drill points per session, +25% every 20 combat levels
DRILL_XP = 12 # combat XP per drill point
DRILL_SHARE = 1.0 # defence of a fully drilled militia, per townsperson
@ -4842,7 +5279,7 @@ DRILL_SERGEANT_FAVOUR = 150 # +50% for every further hire against the same t
def drill_per_action(player: dict) -> float:
return DRILL_PER_ACTION * (1 + 0.25 * (skill_level(player, "combat") // 20))
return DRILL_PER_ACTION * (1 + 0.25 * (skill_level(player, "combat") // 20)) * ware(player, "drill", "combat")
def drill_needed(threat: dict) -> int:
@ -5172,6 +5609,9 @@ def all_glyphs() -> set[str]:
for collection in (ITEMS.values(), SKILLS, CLASSES.values(), BUILDINGS.values(), PRAYERS, ACTIONS,
combat.ABILITIES.values(), combat.ENEMY_ABILITIES.values()):
names.update(entry["glyph"] for entry in collection)
names.update(glyph for glyph, _tint in WARE_GLYPHS.values())
names.update(c["value"] for c in COSMETICS.values() if c["kind"] == "emblem" and "value" in c)
names.update(m["glyph"] for m in MONUMENTS.values())
return names
@ -5183,6 +5623,25 @@ ORDER_RECIPES, ORDER_RAW = _order_sources()
# What changed, shown on the Patch notes page. Newest first; every shipped change adds a
# line here (new / balance / fix), and a new release a new entry on top.
PATCH_NOTES = [
{"date": "2026-10-24", "title": "Gold", "notes": [
("balance", "Raids hit about twice as hard and grow with the guild: they add half the combat power of everyone "
"active in the last 2 days. Pay the guards, drill, stand guard and sabotage the camp, or expect losses. "
"Dragons are 0.8× a raid but can't be sabotaged."),
("balance", "Band size (small → huge) now compares the raid to what the city holds without preparing."),
("new", "Guard wages: 60 gold a guard per threat, paid ahead into the barracks' wage chest, from your gold (earns "
"favour) or the treasury. Only paid guards stand, and only they hold the armoury's swords and shields. "
"Posts: 10 + 20 per barracks level, up to half the townsfolk. The council paid for the coming threat."),
("balance", "Raid rewards: plunder 1× attack (was 2×); garrison XP 2× attack (was 5×), dragons 2.5× (was 6×)."),
("new", "Strongbox: rent bank slots for gold, 1,000 for the first and +35% each after."),
("new", "Caravan: comes once per threat, on the prep day, for 12h, with 4 of 10 wares and one look. Wares boost "
"your own work (trophy odds ×2 for 100 kills, +50% XP for 200 actions, …). One of each per visit; prices "
"rise with your level."),
("new", "Banner, title and home: pick colours and an emblem on your character sheet. Titles are earned by reaching "
"50 or 99 in a skill, defending the city 5 times, slaying a dragon or beating 10 bosses, or bought at the "
"caravan. Homes go from Cottage (2,000) to Manor (75,000). All of these show on the roster and the board."),
("new", "Monuments: gate banner 10,000, fountain 25,000, statues 50,000 (the biggest donor picks who). No favour; "
"donors' names are carved in."),
]},
{"date": "2026-10-24", "title": "Active play and dungeons", "notes": [
("new", "Active play: tap AUTO on an ability to take it yourself (YOU), then press it (tap, or keys 1–9) on its "
"real cooldown instead of auto's 1.5×. Look away for 12s and it's back on auto."),
@ -5280,4 +5739,6 @@ def content() -> dict:
"regen_per_hour": REGEN_PER_HOUR,
"fires": FIRES, "flames": FLAMES, "firewood": FIREWOOD, "tend_xp": TEND_XP, "fire_low": FIRE_LOW,
"patch_notes": PATCH_NOTES,
"wares": {key: {**spec, "glyph": WARE_GLYPHS[key]} for key, spec in WARES.items()}, "cosmetics": COSMETICS, "free_looks": FREE_LOOKS, "homes": HOMES, "monuments": MONUMENTS,
"guard_wage": GUARD_WAGE,
}