diff --git a/app.py b/app.py
index a3cdbff..f1fc3e4 100644
--- a/app.py
+++ b/app.py
@@ -120,6 +120,10 @@ def player_view(world: dict, player: dict, now: float) -> dict:
"role": me["role"], "abilities": me["abilities"], "style": me["style"]}
view["health_potion"] = game.health_potion(player)
view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]}
+ view["rent_cost"] = game.rent_cost(player)
+ view["caravan"] = game.caravan_view(world, player, now)
+ view["titles"] = game.titles(player)
+ view["shown"] = game.look_view(player)
if player["action"]:
action, fight = game.growing(player), game.current_fight(world, player)
duration = fight["duration"] if fight else game.action_time(world, player, action)
@@ -182,6 +186,7 @@ def city_view(world: dict, now: float) -> dict:
"defence": game.defence(world),
"day": game.calendar_day(world, now) + 1,
"fires": game.fires_view(world, now),
+ "monuments": game.monuments_view(world),
}
@@ -208,6 +213,7 @@ def roster(world: dict, now: float) -> list[dict]:
"fight": player["action"]["id"] if game.joinable(world, player) and game.offline_limit(world, player) > now else None,
"party": (player["action"] or {}).get("party"),
"garrison": player["garrison"], "donated": player["donated"],
+ "look": game.look_view(player),
})
return sorted(people, key=lambda p: -p["donated"])
@@ -423,6 +429,22 @@ class Handler(BaseHTTPRequestHandler):
return game.aid_problem(world, player, str(body.get("kind")), str(body.get("item")), str(body.get("source")), now)
if path == "/api/hire":
return game.hire_mercenaries(world, player, now)
+ if path == "/api/wages":
+ return game.pay_wages(world, player, qty if qty == "all" or isinstance(qty, int) else -1, str(body.get("source")), now)
+ if path == "/api/rent":
+ return game.rent_slot(player)
+ if path == "/api/buy":
+ skill = body.get("skill")
+ return game.buy_ware(world, player, str(body.get("ware")), str(skill) if skill else None, now)
+ if path == "/api/look":
+ look = body.get("look")
+ return game.set_look(player, look) if isinstance(look, dict) else "Bad request"
+ if path == "/api/home":
+ return game.upgrade_home(player)
+ if path == "/api/monument":
+ return game.give_monument(world, player, str(body.get("id")), qty if qty == "all" or isinstance(qty, int) else -1, now)
+ if path == "/api/statue":
+ return game.carve_statue(world, player, str(body.get("of")), now)
if path == "/api/potion":
return game.use_potion(player, str(body.get("item")))
if path == "/api/prayers":
diff --git a/game.py b/game.py
index 1b274eb..cd26056 100644
--- a/game.py
+++ b/game.py
@@ -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,
}
diff --git a/static/app.js b/static/app.js
index 6e12b75..635b977 100644
--- a/static/app.js
+++ b/static/app.js
@@ -34,6 +34,11 @@ const $ = (sel, root = document) => root.querySelector(sel);
// Icons are game-icons.net glyphs (CC BY 3.0) from one sprite, tinted per tier or material.
const glyph = (name, tint) => ``;
+// A player's banner: field and trim colours, with their emblem on it.
+const crest = (look, h = 26) => look ? `` : "";
+const titled = (p) => `${crest(p.look)} ${esc(p.name)}${p.look?.title ? ` ${esc(p.look.title)}` : ""}`;
const COIN = glyph("two-coins", "#e0b04a"), ANY_FOOD = glyph("knife-fork", "#d9c6a0"), FAVOUR = glyph("sparkles", "#b98ce8"), HEART = glyph("hearts", "#e2645c");
function iconise(content) {
@@ -1329,6 +1334,7 @@ function bank() {
Overflow earns half favour; what the city can't use is sold.
1 favour per gold.
`; } +function strongboxPanel() { + const me = S.me; + return `Yours for good. Each costs 35% more.
Funds are safe from raiders. Building starts once a fund is full.
Empty.
`; @@ -1426,6 +1441,60 @@ function town() { return html; } +function lookChip(id) { + const c = C.cosmetics[id]; + if (c.kind === "colour") return ``; + if (c.kind === "emblem") return glyph(emblemGlyph(id), "#e7e9f0"); + return `📜`; +} + +const emblemGlyph = (id) => C.cosmetics[id].value || C.actions.find((a) => a.id === C.cosmetics[id].boss).glyph; + +function caravanPanel() { + const c = S.me.caravan; + if (!c) return ""; + const rows = c.wares.map((w) => { + const price = w.skill ? ` · ${COIN} ${fmt(w.prices[w.skill])}` : ""; + const picker = w.skill ? "" : ``; + const per = w.charges ? `next ${fmt(w.charges)} ${w.per}` : "against the coming threat"; + return `One of each per visit. Wares boost your own work. Pricier the higher your level.
Carved in: ${donors}
Looks only. Gold here earns no favour: your name goes in the stone.
Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.
Wage chest ${COIN} ${fmt(d.wages)} / ${fmt(full)} · ${d.wage} gold a guard per threat
+ +Only paid guards stand, and only they hold the armoury's swords and shields. Paying earns favour.
+| ${names[k]} | ${fmt(v)} |
| Hungry (×0.8) |
${esc(cls.combat)}.
Field
Trim
Emblem
Title
+Home · ${esc(p.shown.home)}
+ ${next ? `` : `As grand as it gets.
`} +Dyes, emblems and titles come with the caravan. Titles are also earned: master a skill (50), defend the city, slay a dragon.