From cd44709d8000b59b12c5f117dda996eba4ca6267 Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 24 Oct 2026 13:34:59 +0100 Subject: [PATCH] Gold: guard wages, raid retune, strongbox, caravan wares, looks, monuments Co-Authored-By: Claude Opus 5.5 --- app.py | 22 ++ game.py | 515 ++++++++++++++++++++++++++++++++++++++++++--- static/app.js | 119 ++++++++++- static/icons.svg | 34 +++ static/styles.css | 16 ++ tests/test_game.py | 141 +++++++++++++ 6 files changed, 816 insertions(+), 31 deletions(-) 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.

${favourPanel()} + ${strongboxPanel()}
${selected ? itemPanel(selected) : goldPanel()}
`; @@ -1387,6 +1393,13 @@ function goldPanel() {

1 favour per gold.

`; } +function strongboxPanel() { + const me = S.me; + return `

Strongbox${me.rented ? ` · ${me.rented} rented` : ""}

+
+

Yours for good. Each costs 35% more.

`; +} + function favourPanel() { const me = S.me; return `

Favour with the council

@@ -1413,12 +1426,14 @@ function town() { ${weatherCard()}
`; html += happeningsPanel(); + html += caravanPanel(); if (city.starving) html += ``; if (city.merchant_until > serverNow()) html += ``; if (city.construction) html += siteBanner(); html += `
Buildings

Funds are safe from raiders. Building starts once a fund is full.

${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}
`; + html += monumentsPanel(); html += `
City stockpile
`; const stock = Object.entries(city.stockpile); html += stock.length ? `
${stock.map(([id, q]) => itemChip(id, q)).join("")}
` : `

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 `
${glyph(...w.glyph)}${esc(w.name)} + ${w.skill ? `${skillOf(w.skill).icon} ` : ""}${esc(w.text)} · ${per}${picker} +
`; + }); + const look = c.look, have = look.owned || look.bought; + rows.push(`
${lookChip(look.id)}${esc(look.name)}For your banner (character sheet) +
`); + return `

🐫 Caravan · leaves in ${countdown(c.until)}

${rows.join("")}
+

One of each per visit. Wares boost your own work. Pricier the higher your level.

`; +} + +function monumentsPanel() { + const m = S.city.monuments, me = S.me; + const rows = Object.entries(C.monuments).map(([key, spec]) => { + if (spec.once && m.built.some((b) => b.kind === key)) return ""; + const head = `${glyph(spec.glyph, spec.tint)}`; + if (key === "statue" && m.carving) { + const pick = m.carving.by === S.uid + ? ` + ` + : `${esc(playerName(m.carving.by))} picks who it honours.`; + return `
${head}${spec.name}Funded. Who should it honour?${pick}
`; + } + const fund = m.funds[key] || { gold: 0 }; + return `
${head}${spec.name}${esc(spec.text)} + ${COIN} ${fmt(fund.gold)} / ${fmt(spec.cost)} +
`; + }).join(""); + const built = m.built.map((b) => { + const spec = C.monuments[b.kind]; + const donors = Object.entries(b.donors).sort((x, y) => y[1] - x[1]).map(([uid]) => esc(playerName(uid))).join(", "); + return `
${glyph(spec.glyph, spec.tint)} ${b.of ? `Statue of ${esc(playerName(b.of))}` : spec.name} ${ago(b.at)}

Carved in: ${donors}

`; + }).join(""); + return `
Monuments
${rows}${built} +

Looks only. Gold here earns no favour: your name goes in the stone.

`; +} + // Progress towards a goal favour can boost. Favour doubles the next `boost` points players put // in, so the striped stretch behind the bar (twice the boost) is where work counts double. function boostBar(done, total, boost, tip) { @@ -1521,6 +1590,10 @@ function skillHappenings(skill) { : ``; banner("bad", p.icon, p.name, p.text, button); } + for (const b of S.me.boosts.filter((x) => x.skill === skill)) { + const w = C.wares[b.ware]; + banner("good", glyph(...w.glyph), w.name, `${w.text} · ${b.left ? `${fmt(b.left)} ${w.per} left` : "until the threat lands"}`); + } return banners.join(""); } @@ -1654,7 +1727,8 @@ function favourSpends() { function raidPrep(ev) { const city = S.city, me = S.me, d = city.defence; - const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" }; + const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Paid guards (barracks)", weapons: "Swords the guards hold", shields: "Shields the guards hold", arrows: "Arrows", garrison: "Garrison" }; + const full = d.posts * d.wage, owed = Math.max(0, full - d.wages); const reqs = (ev.requests || []).map((r) => { const mine = me.bank[r.item] || 0; return `
${item(r.item).icon}${esc(r.label)} ${fmt(r.have)} / ${fmt(r.want)} @@ -1664,7 +1738,14 @@ function raidPrep(ev) { return `

Council requests

${reqs}

Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.

-

Defence

+

Guards · ${d.guards} / ${d.posts} paid

+

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.

+

Defence

${Object.entries(d.parts).filter(([k, v]) => k !== "rallied" || v).map(([k, v]) => ``).join("")} ${d.starving ? `` : ""}
${names[k]}${fmt(v)}
Hungry (×0.8)

Garrison

@@ -1854,7 +1935,7 @@ function orders() { // --- board & roster ------------------------------------------------------- function board() { - const msgs = S.board.map((m) => `
${esc(playerName(m.who))}${ago(m.at)} + const msgs = S.board.map((m) => `
${crest(S.players.find((p) => p.id === m.who)?.look, 18)} ${esc(playerName(m.who))}${ago(m.at)} ${m.who === S.uid && m.id ? `` : ""}
${esc(m.text)}
`).join(""); const ledger = [...S.ledger].reverse().map((l) => `
  • ${esc(l.text)}${ago(l.at)}
  • `).join(""); @@ -1878,7 +1959,7 @@ function roster() { const cls = C.classes[p.cls]; const levels = C.skills.filter((s) => !s.class || s.class === p.cls).map((s) => `
    ${s.icon}${s.name}${p.levels[s.id]}
    `).join(""); return `
    ${cls.icon}
    - ${esc(p.name)} ${cls.name} · total level ${p.total} + ${titled(p)} ${cls.name} · total level ${p.total} · ${esc(p.look.home)}
    ${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle`}${p.garrison ? ` · On guard` : ""}
    ${FAVOUR} ${fmt(p.donated)} given to the city
    ${levels}
    @@ -1955,11 +2036,30 @@ function characterSheet(uid) {

    ${esc(cls.combat)}.

    ${equipmentPanel(p, mine)} + ${mine ? lookPanel(p) : ""}

    Skills

    ${skillRows}
    `; } +function lookPanel(p) { + const look = p.look, wear = (kind) => Object.keys(C.cosmetics).filter((k) => C.cosmetics[k].kind === kind && (C.free_looks.includes(k) || p.owned.includes(k))); + const swatches = (part) => wear("colour").map((k) => ``).join(""); + const emblems = [``, + ...wear("emblem").map((k) => ``)].join(""); + const home = C.homes.findIndex(([name]) => name === p.shown.home), next = C.homes[home + 1]; + return `

    Banner & title

    +
    ${crest(p.shown, 48)}${esc(p.name)}${p.shown.title ? ` ${esc(p.shown.title)}` : ""}
    +

    Field

    ${swatches("field")}
    +

    Trim

    ${swatches("trim")}
    +

    Emblem

    ${emblems}
    +

    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.

    `; +} + // The character, dressed: a compact grid of slots on a warrior's outline, Melvor-style, with // room for slots the game doesn't have yet. A two-handed weapon fills the off hand too, faded. // Picking a slot shows what's in it beside the grid. @@ -2242,6 +2342,12 @@ document.addEventListener("click", (e) => { } if (d.aid) return act("/api/aid", { kind: d.aid, item: d.item, source: d.source }); if ("hire" in d) return act("/api/hire", {}); + if (d.wages) return act("/api/wages", { source: d.wages, qty: "all" }); + if ("rent" in d) return act("/api/rent", {}); + if (d.buy) return act("/api/buy", { ware: d.buy, skill: $("[data-keep=tutor-skill]")?.value }); + if ("statue" in d) return act("/api/statue", { of: $("[data-keep=statue-of]").value }); + if ("home" in d) return act("/api/home", {}); + if (d.look) return act("/api/look", { look: { ...S.me.look, [d.look]: d.value || null } }); if (d.devHappen) return act("/api/dev/happen", { track: d.devHappen }); if (d.pray) { const on = S.me.prayers.includes(d.pray); @@ -2276,6 +2382,7 @@ document.addEventListener("input", (e) => { }); document.addEventListener("change", (e) => { + if ("lookTitle" in e.target.dataset) return act("/api/look", { look: { ...S.me.look, title: e.target.value || null } }); const key = e.target.dataset.draft; if (!key || !draft) return; if (key === "item") { @@ -2321,6 +2428,10 @@ document.addEventListener("submit", async (e) => { field.value = ""; return act("/api/board", { text: value }); } + if (form.dataset.form === "monument") { + field.value = ""; + return act("/api/monument", { id: form.dataset.id, qty: Number(value) }); + } if (form.dataset.form === "gold") { field.value = ""; return act("/api/donate", { item: "gold", qty: Number(value) }); diff --git a/static/icons.svg b/static/icons.svg index 2b18ef4..820950f 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -1,5 +1,7 @@ + + @@ -23,6 +25,7 @@ + @@ -39,6 +42,7 @@ + @@ -51,11 +55,14 @@ + + + @@ -79,12 +86,20 @@ + + + + + + + + @@ -97,6 +112,7 @@ + @@ -117,6 +133,7 @@ + @@ -125,6 +142,7 @@ + @@ -142,6 +160,7 @@ + @@ -151,9 +170,13 @@ + + + + @@ -165,8 +188,10 @@ + + @@ -188,6 +213,8 @@ + + @@ -203,10 +230,13 @@ + + + @@ -216,6 +246,7 @@ + @@ -225,6 +256,7 @@ + @@ -232,7 +264,9 @@ + + diff --git a/static/styles.css b/static/styles.css index 95955b9..29211c0 100644 --- a/static/styles.css +++ b/static/styles.css @@ -753,3 +753,19 @@ button.sheet-skill:hover { background: var(--panel-2); } @media (prefers-reduced-motion: reduce) { .pip.low, .fire-band.lit::before { animation: none; } } + +/* --- gold: banners, the caravan, monuments ----------------------------- */ + +.crest { vertical-align: middle; flex: none; } +.title { color: var(--gold); font-size: 13px; } +.wares { display: flex; flex-direction: column; gap: 8px; } +.ware { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: var(--panel-2); border-radius: 8px; } +.ware > .gi { width: 28px; height: 28px; flex: none; } +.ware .grow { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.ware select { margin-top: 4px; max-width: 220px; } +.swatches { display: flex; flex-wrap: wrap; gap: 6px; } +.swatch { display: inline-block; width: 28px; height: 28px; border-radius: 6px; border: 2px solid var(--line); padding: 0; flex: none; } +button.swatch { width: 36px; height: 36px; } +.swatch.on { border-color: var(--text); } +.look-preview { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } +.favour-option .bar { margin-top: 4px; } diff --git a/tests/test_game.py b/tests/test_game.py index 457a03c..6b88e48 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -435,6 +435,42 @@ class EventTest(unittest.TestCase): self.assertEqual(world["next_fortune_at"], T0 + 20 * game.HOUR) self.assertEqual(world["next_event_at"], T0) + def test_only_paid_guards_stand_and_hold_kit(self): + world = world_with(("sam", "commoner")) + city = world["city"] + city["population"] = 100 + city["buildings"]["barracks"]["level"] = 2 + city["stockpile"] = {"copper_sword": 100} + self.assertEqual(game.guard_posts(world), 50) + self.assertEqual(game.defence(world)["parts"]["weapons"], 0) + p = world["players"]["sam"] + p["gold"] = 1000 + self.assertIsNone(game.pay_wages(world, p, 600, "bank", T0)) + self.assertEqual(game.guards_on_duty(world), 10) + self.assertEqual(p["favour"], 600) + parts = game.defence(world)["parts"] + self.assertEqual(parts["weapons"], round(0.35 * 10 * 4, 1)) + self.assertEqual(parts["militia"], round(0.3 * 2 * 100 * 10 / 50, 1)) + city["treasury"] = 10_000 + self.assertIsNone(game.pay_wages(world, p, "all", "city", T0)) + self.assertEqual(city["wages"], 50 * game.GUARD_WAGE) # never more than every post + self.assertEqual(game.pay_wages(world, p, 1, "bank", T0), "Every post is paid for") + event = {"strength": 1, "sabotage": 0, "group": "the Red Hand"} + game._resolve_raid(world, event, T0, random.Random(1)) + self.assertEqual(event["report"]["guards"], 50) + self.assertEqual(city["wages"], 0) + + def test_threat_size_is_against_standing_defence(self): + self.assertEqual(game.threat_size({"standing": 100}, 110), 0) + self.assertEqual(game.threat_size({"standing": 100}, 260), 3) + self.assertEqual(game.threat_size({"strength": 20}, 20), 0) # rumoured before v29 + + def test_v28_worlds_pay_the_coming_threats_guards(self): + world = world_with() + world["version"] = 28 + game.migrate(world, T0) + self.assertEqual(game.guards_on_duty(world), game.guard_posts(world)) + def test_bigger_bands_take_more_scouting(self): small = {"kind": "raid", "strength": 20} huge = {"kind": "raid", "strength": 150} @@ -1098,6 +1134,7 @@ class TierTest(unittest.TestCase): def test_militia_takes_the_best_weapons_first(self): world = world_with() world["city"]["stockpile"] = {"copper_sword": 50, "rune_sword": 2, "iron_shield": 3} + world["city"]["wages"] = 10_000 parts = game.defence(world)["parts"] armed = int(world["city"]["population"] // 2) # 10 self.assertEqual(parts["weapons"], round(0.35 * (2 * 35 + (armed - 2) * 4), 1)) @@ -1592,6 +1629,8 @@ class WearTest(unittest.TestCase): def test_only_issued_gear_breaks_worst_first(self): self.city["stockpile"] = {"iron_sword": 10, "copper_sword": 30} + self.city["buildings"]["barracks"]["level"] = 1 + self.city["wages"] = 10_000 rng = random.Random() rng.random = lambda: 0.0 broken = game.wear_kit(self.world, 0.4, rng) @@ -1601,6 +1640,7 @@ class WearTest(unittest.TestCase): def test_a_won_raid_breaks_some_kit_and_reports_it(self): self.city["stockpile"] = {"copper_sword": 20, "arrows": 100} + self.city["wages"] = 10_000 event = {"strength": 1, "sabotage": 0, "group": "the Red Hand"} game._resolve_raid(self.world, event, T0, random.Random(1)) self.assertLess(self.city["stockpile"]["copper_sword"], 20) @@ -1610,6 +1650,107 @@ class WearTest(unittest.TestCase): self.assertIn("broken in the fighting", text) +class GoldTest(unittest.TestCase): + def setUp(self): + self.world = world_with(("ann", "commoner"), ("bob", "commoner")) + self.ann, self.bob = self.world["players"]["ann"], self.world["players"]["bob"] + self.ann["gold"] = self.bob["gold"] = 200_000 + + def caravan(self, wares): + self.world["caravan"] = {"id": 7, "at": T0 - 1, "until": T0 + 3600, "wares": wares, "look": "e_wolf"} + + def test_rented_slots_cost_more_each_time(self): + before = game.bank_slots(self.world, self.ann) + self.assertEqual([game.rent_cost({"rented": n}) for n in range(4)], [1000, 1350, 1800, 2450]) + self.assertIsNone(game.rent_slot(self.ann)) + self.assertIsNone(game.rent_slot(self.ann)) + self.assertEqual(game.bank_slots(self.world, self.ann), before + 2) + self.assertEqual(self.ann["gold"], 200_000 - 2350) + self.ann["gold"] = 0 + self.assertEqual(game.rent_slot(self.ann), "Needs 1,800 gold") + + def test_caravan_comes_on_the_prep_day_and_sells_once_per_visit(self): + self.world["next_event_at"] = T0 + game.tick(self.world, T0, random.Random(4)) + caravan = self.world["caravan"] + self.assertTrue(T0 + 2 * game.HOUR <= caravan["at"] <= T0 + 16 * game.HOUR) + self.assertIsNone(game.caravan_view(self.world, self.ann, T0)) + game.tick(self.world, caravan["at"] + 1, random.Random(4)) + view = game.caravan_view(self.world, self.ann, caravan["at"] + 1) + self.assertEqual(len(view["wares"]), game.CARAVAN_WARES) + self.assertIsNone(game.caravan_view(self.world, self.ann, caravan["until"] + 1)) + + def test_wares_boost_your_own_work_for_their_charges(self): + self.caravan(["prospector", "tutor"]) + self.assertIsNone(game.buy_ware(self.world, self.ann, "prospector", None, T0)) + self.assertEqual(game.buy_ware(self.world, self.ann, "prospector", None, T0), "One of each per visit") + self.assertEqual(self.ann["gold"], 200_000 - 1550) # mining 1: 1,500 × (1 + 1/30) + self.assertEqual(game.ware(self.ann, "yield", "mining"), 1.5) + self.assertEqual(game.ware(self.bob, "yield", "mining"), 1) + self.ann["boosts"][0]["left"] = 1 + game.ware(self.ann, "yield", "mining", spend=True) + self.assertEqual(self.ann["boosts"], []) + self.assertEqual(game.buy_ware(self.world, self.ann, "tutor", None, T0), "Pick a skill") + self.assertIsNone(game.buy_ware(self.world, self.ann, "tutor", "smithing", T0)) + self.assertEqual(game.ware(self.ann, "xp", "smithing"), 1.5) + + def test_ware_prices_rise_with_your_level(self): + self.ann["xp"]["mining"] = game.XP_TABLE[60] + self.assertEqual(game.ware_price(self.ann, "prospector", "mining"), 4500) + + def test_threat_wares_end_with_the_threat(self): + self.caravan(["spyglass"]) + self.assertEqual(game.buy_ware(self.world, self.ann, "spyglass", None, T0), "There's no threat coming") + self.world["next_event_at"] = T0 + game.tick(self.world, T0, random.Random(4)) + self.caravan(["spyglass"]) + self.assertIsNone(game.buy_ware(self.world, self.ann, "spyglass", None, T0)) + self.assertEqual(game.ware(self.ann, "recon", "reconnaissance"), 1.5) + raid = game.upcoming_raid(self.world) + raid["strength"] = 0 + game.tick(self.world, raid["fire_at"], random.Random(4)) + self.assertEqual(game.ware(self.ann, "recon", "reconnaissance"), 1) + + def test_looks_need_owning_or_earning(self): + look = {"field": "navy", "trim": "brass", "emblem": None, "title": None} + self.assertIsNone(game.set_look(self.ann, look)) + self.assertEqual(game.set_look(self.ann, {**look, "emblem": "e_wolf"}), "You don't own that") + self.caravan([]) + self.assertIsNone(game.buy_ware(self.world, self.ann, "e_wolf", None, T0)) + self.assertIsNone(game.set_look(self.ann, {**look, "emblem": "e_wolf"})) + self.assertEqual(game.look_view(self.ann)["emblem"], "wolf-head") + self.assertEqual(game.set_look(self.ann, {**look, "title": "Master smith"}), "You haven't earned that title") + self.ann["xp"]["smithing"] = game.XP_TABLE[50] + self.assertIsNone(game.set_look(self.ann, {**look, "title": "Master smith"})) + self.assertIsNone(game.upgrade_home(self.ann)) + self.assertEqual(game.look_view(self.ann)["home"], "Cottage") + + def test_monuments_take_gold_and_carve_the_donors_in(self): + city = self.world["city"] + self.assertIsNone(game.give_monument(self.world, self.ann, "fountain", 20_000, T0)) + self.assertIsNone(game.give_monument(self.world, self.bob, "fountain", "all", T0)) + self.assertEqual(self.bob["gold"], 200_000 - 5_000) + self.assertEqual(city["built"][0]["donors"], {"ann": 20_000, "bob": 5_000}) + self.assertEqual(game.give_monument(self.world, self.ann, "fountain", 1, T0), "It's already built") + self.assertEqual(self.ann["favour"], 0) + game.give_monument(self.world, self.ann, "statue", 30_000, T0) + game.give_monument(self.world, self.bob, "statue", 20_000, T0) + self.assertEqual(city["carving"]["by"], "ann") + self.assertEqual(game.carve_statue(self.world, self.bob, "bob", T0), "It isn't yours to pick") + self.assertIsNone(game.carve_statue(self.world, self.ann, "bob", T0)) + self.assertEqual(city["built"][-1]["of"], "bob") + + def test_v28_worlds_get_gold_sinks(self): + world = world_with(("ann", "commoner")) + world["version"] = 28 + for key in ("rented", "boosts", "bought", "deeds", "owned", "look"): + del world["players"]["ann"][key] + del world["caravan"] + game.migrate(world, T0) + self.assertEqual(world["players"]["ann"]["look"], game.DEFAULT_LOOK) + self.assertIsNone(world["caravan"]) + + class ClassTest(unittest.TestCase): def lucky_run(self, cls, action_id, bank): world = world_with(("pat", cls))