"""Guildhall rules: content tables and pure state transitions. The whole world is one JSON-able dict. Every function takes `now` (unix seconds) so the server, the tests and the dev clock drive it the same way. Durations below are in game seconds; `speed` in the world config divides them, so a dev world can run a week in an afternoon. """ from __future__ import annotations import math import random HOUR = 3600 DAY = 24 * HOUR OFFLINE_CAP = 24 * HOUR BASE_SLOTS = 12 BASE_STACK = 1000 CLASS_SPEED = 0.8 LUCKY_CHANCE = 0.1 RECON_PER_INTEL = 8 MAX_LOG = 200 REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour EAT_BELOW = 0.5 # auto-eat when HP drops under this share of max TOO_HURT = 0.3 # won't start a fight below this share without food def _xp_table(max_level: int = 99) -> list[int]: # RuneScape's curve, as Melvor uses: index = level, value = xp needed. table = [0, 0] points = 0 for level in range(1, max_level): points += math.floor(level + 300 * 2 ** (level / 7)) table.append(points // 4) return table XP_TABLE = _xp_table() MAX_LEVEL = len(XP_TABLE) - 1 def level_for(xp: float) -> int: level = 1 while level < MAX_LEVEL and XP_TABLE[level + 1] <= xp: level += 1 return level ITEMS = { "stone": {"name": "Stone", "icon": "๐Ÿชจ", "value": 1}, "copper_ore": {"name": "Copper ore", "icon": "๐ŸŸค", "value": 2}, "iron_ore": {"name": "Iron ore", "icon": "โšซ", "value": 4}, "logs": {"name": "Logs", "icon": "๐Ÿชต", "value": 1}, "oak_logs": {"name": "Oak logs", "icon": "๐ŸŒณ", "value": 4}, "wheat": {"name": "Wheat", "icon": "๐ŸŒพ", "value": 1}, "vegetables": {"name": "Vegetables", "icon": "๐Ÿฅ•", "value": 2}, "feathers": {"name": "Feathers", "icon": "๐Ÿชถ", "value": 1}, "raw_meat": {"name": "Raw meat", "icon": "๐Ÿฅฉ", "value": 2}, "hide": {"name": "Hide", "icon": "๐ŸŸซ", "value": 3}, "copper_bar": {"name": "Copper bar", "icon": "๐ŸŸง", "value": 5}, "iron_bar": {"name": "Iron bar", "icon": "โฌ›", "value": 10}, "arrows": {"name": "Arrows", "icon": "๐Ÿน", "value": 1}, "copper_sword": {"name": "Copper sword", "plural": "Copper swords", "icon": "๐Ÿ—ก๏ธ", "value": 14, "slot": "weapon", "power": 4}, "iron_sword": {"name": "Iron sword", "plural": "Iron swords", "icon": "โš”๏ธ", "value": 30, "slot": "weapon", "power": 9}, "iron_shield": {"name": "Iron shield", "plural": "Iron shields", "icon": "๐Ÿ›ก๏ธ", "value": 40, "slot": "shield", "power": 6}, "bricks": {"name": "Bricks", "icon": "๐Ÿงฑ", "value": 3}, "cut_stone": {"name": "Cut stone", "icon": "๐Ÿ”ท", "value": 5}, "beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "๐Ÿชœ", "value": 25}, # `food` feeds that many townsfolk for two days; `heal` restores HP in a fight. "bread": {"name": "Bread", "icon": "๐Ÿž", "value": 4, "food": 1, "heal": 8}, "cooked_meat": {"name": "Cooked meat", "icon": "๐Ÿ–", "value": 5, "food": 1, "heal": 12}, "stew": {"name": "Stew", "icon": "๐Ÿฒ", "value": 10, "food": 2, "heal": 20}, "raw_shrimp": {"name": "Raw shrimp", "icon": "๐Ÿฆ", "value": 1}, "raw_trout": {"name": "Raw trout", "icon": "๐ŸŸ", "value": 3}, "raw_salmon": {"name": "Raw salmon", "icon": "๐Ÿ ", "value": 6}, "raw_eel": {"name": "Raw eel", "icon": "๐Ÿ", "value": 10}, "cooked_shrimp": {"name": "Cooked shrimp", "icon": "๐Ÿค", "value": 4, "food": 1, "heal": 10}, "cooked_trout": {"name": "Grilled trout", "icon": "๐Ÿข", "value": 8, "food": 1, "heal": 18}, "cooked_salmon": {"name": "Baked salmon", "icon": "๐Ÿฃ", "value": 14, "food": 2, "heal": 28}, "smoked_eel": {"name": "Smoked eel", "icon": "๐Ÿฅ“", "value": 22, "food": 2, "heal": 40}, "leather": {"name": "Leather", "icon": "๐Ÿชข", "value": 5}, # `armour` lowers the damage a fight deals; `power` adds to fighting strength. "leather_cap": {"name": "Leather cap", "icon": "๐Ÿงข", "value": 12, "slot": "helmet", "armour": 1}, "leather_legs": {"name": "Leather trousers", "plural": "Leather trousers", "icon": "๐Ÿ‘–", "value": 18, "slot": "legs", "armour": 2}, "leather_body": {"name": "Leather jerkin", "icon": "๐Ÿฆบ", "value": 24, "slot": "body", "armour": 3}, "studded_body": {"name": "Studded jerkin", "icon": "๐Ÿงฅ", "value": 40, "slot": "body", "armour": 6}, "copper_helmet": {"name": "Copper helmet", "icon": "โ›‘๏ธ", "value": 12, "slot": "helmet", "armour": 2}, "copper_legs": {"name": "Copper greaves", "plural": "Copper greaves", "icon": "๐Ÿฆต", "value": 22, "slot": "legs", "armour": 3}, "copper_body": {"name": "Copper cuirass", "icon": "๐Ÿฅ‹", "value": 28, "slot": "body", "armour": 4}, "iron_helmet": {"name": "Iron helmet", "icon": "๐Ÿช–", "value": 25, "slot": "helmet", "armour": 4}, "iron_legs": {"name": "Iron greaves", "plural": "Iron greaves", "icon": "๐Ÿฆฟ", "value": 45, "slot": "legs", "armour": 6}, "iron_body": {"name": "Iron cuirass", "icon": "๐Ÿฆพ", "value": 55, "slot": "body", "armour": 8}, } # Gathering tools: one slot per skill, `speed` cuts the action time. TOOLS = {"mining": ("pickaxe", "โ›๏ธ"), "woodcutting": ("axe", "๐Ÿช“"), "farming": ("hoe", "๐Ÿช"), "fishing": ("fishing rod", "๐ŸŽฃ"), "hunting": ("hunting knife", "๐Ÿ”ช")} for _skill, (_tool, _icon) in TOOLS.items(): for _metal, _value, _speed in (("copper", 14, 0.15), ("iron", 30, 0.3)): ITEMS[f"{_metal}_{_tool.replace(' ', '_')}"] = { "name": f"{_metal.title()} {_tool}", "icon": _icon, "value": _value, "slot": f"tool_{_skill}", "tool": _skill, "speed": _speed, } ITEMS["iron_shield"]["armour"] = 4 # What the city eats first: plain food before the good stuff. FOOD_ORDER = ("bread", "cooked_meat", "cooked_shrimp", "cooked_trout", "stew", "cooked_salmon", "smoked_eel") EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs"] + [f"tool_{skill}" for skill in TOOLS] SKILLS = [ {"id": "mining", "name": "Mining", "icon": "โ›๏ธ", "group": "Gathering"}, {"id": "woodcutting", "name": "Woodcutting", "icon": "๐Ÿช“", "group": "Gathering"}, {"id": "farming", "name": "Farming", "icon": "๐ŸŒฑ", "group": "Gathering"}, {"id": "hunting", "name": "Hunting", "icon": "๐ŸฆŒ", "group": "Gathering"}, {"id": "fishing", "name": "Fishing", "icon": "๐ŸŽฃ", "group": "Gathering"}, {"id": "smithing", "name": "Smithing", "icon": "๐Ÿ”จ", "group": "Crafting"}, {"id": "masonry", "name": "Masonry", "icon": "๐Ÿงฑ", "group": "Crafting"}, {"id": "cooking", "name": "Cooking", "icon": "๐Ÿณ", "group": "Crafting"}, {"id": "leatherworking", "name": "Leatherworking", "icon": "๐Ÿชก", "group": "Crafting"}, {"id": "combat", "name": "Combat", "icon": "โš”๏ธ", "group": "Combat"}, {"id": "reconnaissance", "name": "Reconnaissance", "icon": "๐Ÿ”ญ", "group": "Signature", "class": "scout"}, ] SKILL_IDS = [skill["id"] for skill in SKILLS] GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"} CRAFTING = {"smithing", "masonry", "cooking", "leatherworking"} SURVEYED = {"mining", "woodcutting", "farming"} # what the pioneer's double yield applies to CLASSES = { "warrior": { "name": "Warrior", "icon": "๐Ÿ›ก๏ธ", "fast": ["combat"], "bonus": "Combat actions 20% faster", "signature": "Command: counts for half again as much in the garrison", }, "scout": { "name": "Scout", "icon": "๐Ÿน", "fast": ["hunting", "fishing", "reconnaissance"], "bonus": "Hunting and fishing 20% faster", "signature": "Reconnaissance: scout the roads to sharpen intel on what's coming", }, "artisan": { "name": "Artisan", "icon": "โš’๏ธ", "fast": ["smithing", "masonry", "cooking", "leatherworking"], "bonus": "Smithing, masonry, cooking and leatherworking 20% faster", "signature": "Masterwork: 1 in 10 crafts makes double", }, "pioneer": { "name": "Pioneer", "icon": "๐Ÿงญ", "fast": ["mining", "woodcutting", "farming"], "bonus": "Mining, woodcutting and farming 20% faster", "signature": "Surveying: 1 in 10 gathers yields double", }, } def _action(id, skill, name, level, time, xp, inputs=None, outputs=None, **extra): return {"id": id, "skill": skill, "name": name, "level": level, "time": time, "xp": xp, "inputs": inputs or {}, "outputs": outputs or {}, **extra} ACTIONS = [ _action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}), _action("mine_copper", "mining", "Mine copper", 1, 25, 7, outputs={"copper_ore": 1}), _action("mine_iron", "mining", "Mine iron", 15, 35, 15, outputs={"iron_ore": 1}), _action("cut_logs", "woodcutting", "Cut trees", 1, 20, 5, outputs={"logs": 1}), _action("cut_oak", "woodcutting", "Cut oaks", 15, 35, 15, outputs={"oak_logs": 1}), _action("farm_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}), _action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}), _action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}), _action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}), _action("fish_shrimp", "fishing", "Net shrimp", 1, 25, 6, outputs={"raw_shrimp": 1}), _action("fish_trout", "fishing", "Fly-fish trout", 15, 35, 15, outputs={"raw_trout": 1}), _action("fish_salmon", "fishing", "Catch salmon", 30, 45, 28, outputs={"raw_salmon": 1}), _action("fish_eel", "fishing", "Trap eels", 45, 55, 45, outputs={"raw_eel": 1}), _action("smith_copper_bar", "smithing", "Copper bar", 1, 20, 6, {"copper_ore": 1, "logs": 1}, {"copper_bar": 1}, group="Bars"), _action("smith_iron_bar", "smithing", "Iron bar", 15, 30, 14, {"iron_ore": 1, "logs": 1}, {"iron_bar": 1}, group="Bars"), _action("smith_copper_sword", "smithing", "Copper sword", 8, 40, 18, {"copper_bar": 3}, {"copper_sword": 1}, group="Weapons"), _action("smith_iron_sword", "smithing", "Iron sword", 20, 45, 32, {"iron_bar": 2}, {"iron_sword": 1}, city={"smithy": 1}, group="Weapons"), _action("smith_iron_shield", "smithing", "Iron shield", 25, 50, 40, {"iron_bar": 3}, {"iron_shield": 1}, city={"smithy": 2}, group="Weapons"), _action("smith_copper_helmet", "smithing", "Copper helmet", 6, 35, 14, {"copper_bar": 2}, {"copper_helmet": 1}, group="Armour"), _action("smith_copper_legs", "smithing", "Copper greaves", 10, 40, 20, {"copper_bar": 4}, {"copper_legs": 1}, group="Armour"), _action("smith_copper_body", "smithing", "Copper cuirass", 12, 45, 26, {"copper_bar": 5}, {"copper_body": 1}, group="Armour"), _action("smith_iron_helmet", "smithing", "Iron helmet", 22, 40, 34, {"iron_bar": 2}, {"iron_helmet": 1}, city={"smithy": 1}, group="Armour"), _action("smith_iron_legs", "smithing", "Iron greaves", 27, 45, 46, {"iron_bar": 4}, {"iron_legs": 1}, city={"smithy": 1}, group="Armour"), _action("smith_iron_body", "smithing", "Iron cuirass", 30, 50, 56, {"iron_bar": 5}, {"iron_body": 1}, city={"smithy": 2}, group="Armour"), *[_action(f"smith_{metal}_{tool.replace(' ', '_')}", "smithing", f"{metal.title()} {tool}", level, time, xp, {f"{metal}_bar": 2, wood: 1}, {f"{metal}_{tool.replace(' ', '_')}": 1}, group="Tools", **({"city": {"smithy": 1}} if metal == "iron" else {})) for metal, level, time, xp, wood in (("copper", 4, 30, 12, "logs"), ("iron", 20, 40, 30, "oak_logs")) for tool, _icon in TOOLS.values()], _action("smith_arrows", "smithing", "Arrows", 5, 30, 10, {"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Supplies"), _action("tan_leather", "leatherworking", "Tan leather", 1, 25, 6, {"hide": 1}, {"leather": 1}), _action("lw_cap", "leatherworking", "Leather cap", 3, 30, 10, {"leather": 2}, {"leather_cap": 1}), _action("lw_legs", "leatherworking", "Leather trousers", 8, 35, 16, {"leather": 3}, {"leather_legs": 1}), _action("lw_body", "leatherworking", "Leather jerkin", 12, 40, 22, {"leather": 4}, {"leather_body": 1}), _action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40, {"leather": 4, "iron_bar": 1}, {"studded_body": 1}), _action("mason_bricks", "masonry", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}), _action("mason_cut_stone", "masonry", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}), _action("mason_beam", "masonry", "Reinforced beam", 20, 45, 30, {"oak_logs": 2, "iron_bar": 1}, {"beam": 1}), _action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}), _action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}), _action("cook_stew", "cooking", "Stew", 15, 35, 16, {"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}), _action("cook_shrimp", "cooking", "Cooked shrimp", 1, 20, 6, {"raw_shrimp": 1, "logs": 1}, {"cooked_shrimp": 1}), _action("cook_trout", "cooking", "Grilled trout", 15, 30, 15, {"raw_trout": 1, "logs": 1}, {"cooked_trout": 1}), _action("cook_salmon", "cooking", "Baked salmon", 30, 35, 27, {"raw_salmon": 1, "logs": 1}, {"cooked_salmon": 1}), _action("cook_eel", "cooking", "Smoked eel", 45, 40, 42, {"raw_eel": 1, "logs": 1}, {"smoked_eel": 1}), _action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, damage=2, gold=2, drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5]]), _action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, gold=10, drops=[["copper_bar", 1, 0.3], ["arrows", 5, 0.4]]), _action("fight_ruins", "combat", "Watchtower ruins", 25, 90, 60, difficulty=30, damage=14, gold=25, drops=[["iron_bar", 1, 0.4], ["iron_sword", 1, 0.03]]), _action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, damage=25, gold=60, drops=[["iron_bar", 2, 0.5], ["iron_shield", 1, 0.04]]), _action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10, special="sabotage"), _action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1), _action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2), ] ACTION_BY_ID = {action["id"]: action for action in ACTIONS} BUILDINGS = { "houses": { "name": "Houses", "icon": "๐Ÿ ", "max": 8, "gold": 200, "base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200}, "effect": "Room for 20 more people per level", }, "walls": { "name": "Walls", "icon": "๐Ÿฐ", "max": 5, "gold": 300, "base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 40}, "effect": "+20 defence per level, and raiders carry off less", }, "barracks": { "name": "Barracks", "icon": "โ›บ", "max": 5, "gold": 300, "base": {"logs": 600, "bricks": 400, "copper_sword": 20}, "advanced": {"iron_sword": 15}, "effect": "Trains townsfolk as militia: more defence per citizen", }, "smithy": { "name": "Smithy", "icon": "โš’๏ธ", "max": 3, "gold": 250, "base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150}, "effect": "Smithing 8% faster per level; unlocks iron swords (1) and shields (2)", }, "spymaster": { "name": "Spymaster's guild", "icon": "๐Ÿ•ต๏ธ", "max": 3, "gold": 400, "base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, "effect": "One more level of intel on every event per level", }, "storehouse": { "name": "Storehouse", "icon": "๐Ÿ“ฆ", "max": 5, "gold": 200, "base": {"logs": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 200}, "effect": "+3 bank slots and +250 per stack for everyone, per level", }, } BUILD_HOURS = 3 REPAIR_SHARE = 0.3 # Population promotes the settlement, permanently. Each tier lifts how high # buildings may go (houses have their own ladder so the next tier is always # reachable) and gives everyone more bank slots. TIERS = [ {"name": "Hamlet", "pop": 0, "cap": 1, "houses": 2}, {"name": "Village", "pop": 40, "cap": 3, "houses": 4}, {"name": "Town", "pop": 90, "cap": 4, "houses": 7}, {"name": "City", "pop": 150, "cap": 99, "houses": 8}, ] TIER_SLOTS = 2 GROWTH_PER_DAY = 0.12 RAID_GROUPS = ["the Red Hand", "the Hollow Crows", "the Blackwater gang", "the Ashen Wolves", "the Gallows Men", "the Thornback raiders"] RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows", "well armed and disciplined", "a ragged mob, many but poorly armed", "led by a deserter knight in plate"] # --- world --------------------------------------------------------------- WORLD_VERSION = 3 def new_world(now: float, speed: float = 1.0) -> dict: return { "version": WORLD_VERSION, "created": now, "config": {"speed": speed, "clock_offset": 0}, "players": {}, "city": { "population": 20.0, "treasury": 0.0, "stockpile": {"bread": 40}, "buildings": {key: {"level": 0, "damaged": False, "fund": {}} for key in BUILDINGS}, "construction": None, "queue": [], "tier": 0, "last_tick": now, "food_due": 0.0, "starving": False, "repelled": 0, }, "events": [], "next_event_at": now + 12 * HOUR / speed, "next_event_id": 1, "ledger": [], "board": [], } def migrate(world: dict) -> dict: """Bring a saved world up to the current shape. Never loses anything.""" if world.get("version", 1) < 2: city = world["city"] for building in city["buildings"].values(): building.setdefault("fund", {}) city.setdefault("queue", []) city.setdefault("tier", 0) world["version"] = 2 if world["version"] < 3: now = world["city"]["last_tick"] for player in world["players"].values(): for skill in SKILL_IDS: player["xp"].setdefault(skill, 0) for slot in EQUIP_SLOTS: player["equip"].setdefault(slot, None) player.setdefault("food", None) player.setdefault("hp", max_hp(player)) player.setdefault("hp_at", now) world["version"] = 3 return world def speed(world: dict) -> float: return world["config"]["speed"] def amount(item: str, qty: int) -> str: """'179 copper swords', '1 copper sword', '40 stone'.""" spec = ITEMS[item] name = spec.get("plural", spec["name"]) if qty != 1 else spec["name"] return f"{qty:,} {name.lower()}" def upper_first(text: str) -> str: return text[:1].upper() + text[1:] def log(world: dict, now: float, kind: str, text: str, who: str | None = None) -> None: world["ledger"].append({"at": now, "kind": kind, "text": text, "who": who}) del world["ledger"][:-MAX_LOG] def building_level(world: dict, key: str) -> int: """Effective level: a damaged building does nothing until repaired.""" building = world["city"]["buildings"][key] return 0 if building["damaged"] else building["level"] def build_cost(key: str, level: int) -> dict: spec = BUILDINGS[key] cost = {item: qty * level for item, qty in spec["base"].items()} for item, qty in spec["advanced"].items(): if level > 1: cost[item] = cost.get(item, 0) + qty * (level - 1) cost["gold"] = spec["gold"] * level return cost def repair_cost(key: str, level: int) -> dict: return {item: max(1, math.ceil(qty * REPAIR_SHARE)) for item, qty in build_cost(key, level).items()} def housing(world: dict) -> int: return 25 + 20 * building_level(world, "houses") def level_cap(world: dict, key: str) -> int: """The highest level the settlement's tier allows for this building.""" tier = TIERS[world["city"]["tier"]] return min(BUILDINGS[key]["max"], tier["houses"] if key == "houses" else tier["cap"]) def fund_target(world: dict, key: str) -> dict | None: """What the building's fund is collecting for: a repair, the next level, or nothing.""" building = world["city"]["buildings"][key] if building["damaged"]: return repair_cost(key, building["level"]) if building["level"] >= level_cap(world, key): return None return build_cost(key, building["level"] + 1) def fund_missing(world: dict, key: str) -> dict: target = fund_target(world, key) or {} fund = world["city"]["buildings"][key]["fund"] return {item: qty - fund.get(item, 0) for item, qty in target.items() if fund.get(item, 0) < qty} # --- players ------------------------------------------------------------- def new_player(uid: str, name: str, cls: str, now: float) -> dict: return { "id": uid, "name": name, "cls": cls, "joined": now, "xp": {skill: 0 for skill in SKILL_IDS}, "bank": {}, "gold": 0, "favour": 0, "equip": {slot: None for slot in EQUIP_SLOTS}, "food": None, # preferred food in fights; None eats the cheapest first "hp": 44, "hp_at": now, "action": None, "stopped": None, "last_active": now, "overflow": "city", "perks": {"bank_slot": 0}, "garrison": False, "donated": 0, } def skill_level(player: dict, skill: str) -> int: return level_for(player["xp"].get(skill, 0)) 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"]) def stack_size(world: dict) -> int: return BASE_STACK + 250 * building_level(world, "storehouse") def bank_room(world: dict, player: dict, item: str) -> int: """How many more of `item` fit in the bank.""" have = player["bank"].get(item, 0) if have == 0 and len(player["bank"]) >= bank_slots(world, player): return 0 return max(0, stack_size(world) - have) def bank_add(world: dict, player: dict, item: str, qty: int, now: float, overflow: dict) -> int: """Store what fits; the rest goes to the city (or is lost). Returns qty kept.""" keep = min(qty, bank_room(world, player, item)) if keep: player["bank"][item] = player["bank"].get(item, 0) + keep spill = qty - keep if spill and player["overflow"] == "city": stockpile = world["city"]["stockpile"] stockpile[item] = stockpile.get(item, 0) + spill gain = spill * ITEMS[item]["value"] player["favour"] += gain player["donated"] += gain overflow[item] = overflow.get(item, 0) + spill return keep def bank_take(player: dict, item: str, qty: int) -> None: left = player["bank"][item] - qty if left > 0: player["bank"][item] = left else: del player["bank"][item] def armour(player: dict) -> int: return sum(ITEMS[item].get("armour", 0) for item in player["equip"].values() if item) def damage_reduction(player: dict) -> float: points = armour(player) return points / (points + 25) def combat_power(player: dict) -> int: power = skill_level(player, "combat") + armour(player) // 2 for item in player["equip"].values(): if item: power += ITEMS[item].get("power", 0) return power def max_hp(player: dict) -> int: return 40 + 4 * skill_level(player, "combat") def regen(world: dict, player: dict, now: float) -> None: """Heal for the time since HP was last settled.""" if now > player["hp_at"]: top = max_hp(player) per_second = top * REGEN_PER_HOUR * speed(world) / HOUR player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second) player["hp_at"] = now def fight_damage(player: dict, action: dict) -> float: """Average damage one fight deals: less when out-levelling the enemy or armoured.""" gap = combat_power(player) - action["difficulty"] return action["damage"] * min(1.5, max(0.3, 1 - gap * 0.03)) * (1 - damage_reduction(player)) def edible(player: dict) -> list[str]: """Food in the bank, in the order it gets eaten.""" foods = sorted((item for item in player["bank"] if "heal" in ITEMS[item]), key=lambda i: ITEMS[i]["heal"]) if player["food"] in foods: foods.remove(player["food"]) foods.insert(0, player["food"]) return foods def auto_eat(player: dict, summary: dict) -> None: top = max_hp(player) while player["hp"] < top * EAT_BELOW: foods = edible(player) if not foods: return bank_take(player, foods[0], 1) player["hp"] = min(top, player["hp"] + ITEMS[foods[0]]["heal"]) summary["eaten"][foods[0]] = summary["eaten"].get(foods[0], 0) + 1 def action_time(world: dict, player: dict, action: dict) -> float: seconds = action["time"] if action["skill"] in CLASSES[player["cls"]]["fast"]: seconds *= CLASS_SPEED tool = player["equip"].get(f"tool_{action['skill']}") if tool: seconds *= 1 - ITEMS[tool]["speed"] if action["skill"] == "smithing": seconds *= 1 - 0.08 * building_level(world, "smithy") return seconds / speed(world) def upcoming_raid(world: dict) -> dict | None: for event in world["events"]: if event["status"] == "upcoming" and event["kind"] == "raid": return event return None def upcoming_event(world: dict) -> dict | None: for event in world["events"]: if event["status"] == "upcoming": return event return None def can_start(world: dict, player: dict, action: dict) -> str | None: """Why the action can't run right now, or None.""" if action["skill"] == "reconnaissance" and player["cls"] != "scout": return "Only scouts can do that" if skill_level(player, action["skill"]) < action["level"]: return f"Needs level {action['level']}" for key, level in action.get("city", {}).items(): if building_level(world, key) < level: return f"Needs {BUILDINGS[key]['name']} level {level}" for item, qty in action["inputs"].items(): if player["bank"].get(item, 0) < qty: return f"Out of {ITEMS[item]['name'].lower()}" if action.get("special") == "sabotage": raid = upcoming_raid(world) if not raid or intel_level(world, raid) < 3: return "No bandit camp has been located" if raid["sabotage"] >= 0.3: return "The camp is already in ruins" if action["skill"] == "combat" and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): return "Too hurt to fight. Rest, or bring food" return None def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None: for item, qty in action["inputs"].items(): bank_take(player, item, qty) xp = action["xp"] lucky = ( (player["cls"] == "artisan" and action["skill"] in CRAFTING) or (player["cls"] == "pioneer" and action["skill"] in SURVEYED) ) and rng.random() < LUCKY_CHANCE outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()} if lucky: summary["lucky"] += 1 if action["skill"] == "combat": regen(world, player, now) auto_eat(player, summary) chance = 0.5 + (combat_power(player) - action["difficulty"]) * 0.04 won = rng.random() < min(0.97, max(0.15, chance)) hurt = fight_damage(player, action) * rng.uniform(0.6, 1.4) * (1 if won else 1.3) player["hp"] -= hurt summary["damage"] += hurt auto_eat(player, summary) if player["hp"] <= 0: # Out of food and out of luck: fall back with nothing but your life. player["hp"] = 1 summary["losses"] += 1 reason = "Retreated: ran out of food" if not edible(player) else "Retreated" stop(player, reason) summary["stopped"] = reason gain_xp(world, player, "combat", xp // 3, now, summary) return if won: summary["wins"] += 1 player["gold"] += action.get("gold", 0) summary["gold"] += action.get("gold", 0) for item, qty, odds in action.get("drops", []): if rng.random() < odds: outputs[item] = outputs.get(item, 0) + qty if action.get("special") == "sabotage": raid = upcoming_raid(world) raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03) else: summary["losses"] += 1 xp = xp // 3 if action.get("special") == "recon": event = upcoming_event(world) if event: event["recon"] += action["recon"] for item, qty in outputs.items(): kept = bank_add(world, player, item, qty, now, summary["overflow"]) if kept: summary["items"][item] = summary["items"].get(item, 0) + kept gain_xp(world, player, action["skill"], xp, now, summary) def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summary: dict | None = None) -> None: before = skill_level(player, skill) player["xp"][skill] += xp after = skill_level(player, skill) if summary is not None: summary["xp"][skill] = summary["xp"].get(skill, 0) + xp if after > before: if summary is not None: summary["levels"] = [entry for entry in summary["levels"] if entry[0] != skill] + [[skill, after]] if after % 10 == 0 or after == MAX_LEVEL: name = next(s["name"] for s in SKILLS if s["id"] == skill) log(world, now, "level", f"{player['name']} reached {name} level {after}", player["id"]) def empty_summary() -> dict: return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0, "gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None} def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict: """Run the player's action forward to `now` (capped at 24h past last_active).""" rng = rng or random.Random() summary = empty_summary() current = player["action"] if not current: return summary action = ACTION_BY_ID[current["id"]] limit = min(now, player["last_active"] + OFFLINE_CAP / speed(world)) while player["action"]: duration = action_time(world, player, action) if current["start"] + duration > limit: break reason = can_start(world, player, action) if reason and reason.startswith("Too hurt"): reason = "Retreated: hurt and out of food" if reason: stop(player, reason) summary["stopped"] = reason break if player["overflow"] == "stop" and any(bank_room(world, player, item) < qty for item, qty in action["outputs"].items()): stop(player, "Bank full") summary["stopped"] = "Bank full" break _complete(world, player, action, current["start"] + duration, rng, summary) summary["done"] += 1 current["start"] += duration if player["action"] is current and "left" in current: current["left"] -= 1 if current["left"] <= 0: back = current.get("resume") if back: player["action"] = current = {"id": back, "start": current["start"]} action = ACTION_BY_ID[back] summary["resumed"] = back else: stop(player) if player["action"] and limit < now and current["start"] < now: current["start"] = now return summary def stop(player: dict, reason: str | None = None) -> None: player["action"] = None player["stopped"] = reason def start_action(world: dict, player: dict, action_id: str, now: float, once: bool = False) -> str | None: """Start an action. `once` does it a single time, then goes back to what you were doing.""" action = ACTION_BY_ID.get(action_id) if not action: return "No such action" reason = can_start(world, player, action) if reason: return reason entry = {"id": action_id, "start": now} if once: current = player["action"] back = current and (current.get("resume") or current["id"]) entry["left"] = 1 if back and back != action_id: entry["resume"] = back player["action"] = entry player["stopped"] = None return None # --- city ---------------------------------------------------------------- def stockpile_value(city: dict) -> float: return sum(ITEMS[item]["value"] * qty for item, qty in city["stockpile"].items()) def donate(world: dict, player: dict, item: str, qty: int, now: float) -> str | None: city = world["city"] if qty <= 0: return "Nothing to donate" if item == "gold": if player["gold"] < qty: return "Not enough gold" player["gold"] -= qty city["treasury"] += qty gain = qty label = f"{qty:,} gold" else: if player["bank"].get(item, 0) < qty: return "You don't have that many" bank_take(player, item, qty) city["stockpile"][item] = city["stockpile"].get(item, 0) + qty gain = qty * ITEMS[item]["value"] label = amount(item, qty) player["favour"] += gain player["donated"] += gain log(world, now, "donation", f"{player['name']} donated {label}", player["id"]) return None def give(world: dict, giver: dict, receiver: dict, item: str, qty: int, now: float) -> str | None: if receiver is giver: return "You can't give to yourself" if qty <= 0 or giver["bank"].get(item, 0) < qty: return "You don't have that many" room = bank_room(world, receiver, item) if room < qty: return f"{receiver['name']} only has room for {room:,}" bank_take(giver, item, qty) receiver["bank"][item] = receiver["bank"].get(item, 0) + qty log(world, now, "gift", f"{giver['name']} gave {amount(item, qty)} to {receiver['name']}", giver["id"]) return None def sell(world: dict, player: dict, item: str, qty: int, now: float) -> str | None: if qty <= 0 or player["bank"].get(item, 0) < qty: return "You don't have that many" bank_take(player, item, qty) player["gold"] += int(qty * sell_price(world, item, now)) return None def sell_price(world: dict, item: str, now: float) -> float: price = ITEMS[item]["value"] if world["city"].get("merchant_until", 0) > now: price *= 1.5 return price def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source: str, now: float) -> str | None: """Put materials towards a building. Funds are safe from raiders. `source` is "bank" (the player's own, earns favour) or "city" (the stockpile, or the treasury for gold). `qty` may be "all": as much as the source has, up to what the fund still needs. """ city = world["city"] if key not in BUILDINGS: return "No such building" if item != "gold" and item not in ITEMS: return "No such item" need = fund_missing(world, key).get(item, 0) if not need: return "The fund doesn't need that" if source == "bank": have = player["gold"] if item == "gold" else player["bank"].get(item, 0) elif source == "city": have = int(city["treasury"]) if item == "gold" else city["stockpile"].get(item, 0) else: return "Bad source" if qty == "all": qty = min(have, need) if not isinstance(qty, int) or qty <= 0: return "Nothing to give" if qty > have: return "Not enough to give" qty = min(qty, need) if source == "bank": if item == "gold": player["gold"] -= qty else: bank_take(player, item, qty) gain = qty * (1 if item == "gold" else ITEMS[item]["value"]) player["favour"] += gain player["donated"] += gain elif item == "gold": city["treasury"] -= qty else: take_stock(city, item, qty) building = city["buildings"][key] building["fund"][item] = building["fund"].get(item, 0) + qty label = f"{qty:,} gold" if item == "gold" else amount(item, qty) where = "" if source == "bank" else " from the city's stores" log(world, now, "fund", f"{player['name']} put {label}{where} towards the {BUILDINGS[key]['name'].lower()}", player["id"]) _queue_if_funded(world, key, now) _start_next(world, now) return None def take_stock(city: dict, item: str, qty: int) -> None: left = city["stockpile"][item] - qty if left > 0: city["stockpile"][item] = left else: del city["stockpile"][item] def _queue_if_funded(world: dict, key: str, now: float) -> None: city = world["city"] if fund_target(world, key) is None or fund_missing(world, key): return busy = city["construction"] and city["construction"]["building"] == key if key not in city["queue"] and not busy: city["queue"].append(key) log(world, now, "build", f"The {BUILDINGS[key]['name'].lower()} are fully funded" if BUILDINGS[key]["name"].endswith("s") else f"The {BUILDINGS[key]['name'].lower()} is fully funded") def _start_next(world: dict, now: float) -> None: """Start the next funded building, if the builders are free.""" city = world["city"] while not city["construction"] and city["queue"]: key = city["queue"].pop(0) target = fund_target(world, key) if target is None or fund_missing(world, key): continue # its target changed while it waited (e.g. damaged); keep funding building = city["buildings"][key] for item, qty in target.items(): left = building["fund"][item] - qty if left > 0: building["fund"][item] = left else: del building["fund"][item] repair = building["damaged"] hours = BUILD_HOURS * (building["level"] + 1) * (0.25 if repair else 1) city["construction"] = {"building": key, "repair": repair, "start": now, "done_at": now + hours * HOUR / speed(world)} verb = "Repairs to" if repair else "Work on" log(world, now, "build", f"{verb} the {BUILDINGS[key]['name'].lower()} began") def population_view(world: dict) -> dict: """Population, food and tier, with the numbers that explain them.""" city = world["city"] pop = city["population"] room = housing(world) if city["starving"]: growth = -pop * 0.04 elif pop < room - 0.01: growth = max(0.5, pop * GROWTH_PER_DAY) elif pop > room: growth = -pop * 0.05 else: growth = 0.0 food = sum(city["stockpile"].get(item, 0) * ITEMS[item]["food"] for item in FOOD_ORDER) tier = city["tier"] following = TIERS[tier + 1] if tier + 1 < len(TIERS) else None return { "population": int(pop), "housing": room, "growth": round(growth, 1), "food": food, "food_days": round(food / (pop * 0.5), 1) if pop else None, "tier": tier, "tier_name": TIERS[tier]["name"], "next_tier": following and {"name": following["name"], "pop": following["pop"]}, } def defence(world: dict) -> dict: """The city's defence right now, part by part.""" city = world["city"] pop = city["population"] stock = city["stockpile"] armed = int(pop // 2) swords = min(armed, stock.get("copper_sword", 0) + stock.get("iron_sword", 0)) iron = min(swords, stock.get("iron_sword", 0)) parts = { "townsfolk": 10, "walls": 20 * building_level(world, "walls"), "militia": round(0.3 * building_level(world, "barracks") * pop, 1), "weapons": round(3 * iron + 1.5 * (swords - iron), 1), "shields": 2 * min(armed, stock.get("iron_shield", 0)), "arrows": min(100, stock.get("arrows", 0) // 10), "garrison": 0, } garrison = [] for player in world["players"].values(): if player["garrison"]: power = combat_power(player) * (1.5 if player["cls"] == "warrior" else 1) garrison.append({"id": player["id"], "name": player["name"], "power": round(power, 1)}) parts["garrison"] += power total = sum(parts.values()) if city["starving"]: total *= 0.8 return {"parts": parts, "garrison": garrison, "total": round(total, 1), "starving": city["starving"]} def tick(world: dict, now: float, rng: random.Random | None = None) -> bool: """Advance the city and events to `now`. Returns True if anything changed.""" rng = rng or random.Random() city = world["city"] changed = False step = HOUR / speed(world) # Never replay more than two weeks if the server was off. city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world)) while city["last_tick"] + step <= now: city["last_tick"] += step _city_hour(world, city["last_tick"]) changed = True construction = city["construction"] if construction and construction["done_at"] <= now: _finish_construction(world, construction["done_at"]) _start_next(world, construction["done_at"]) changed = True while city["tier"] + 1 < len(TIERS) and city["population"] >= TIERS[city["tier"] + 1]["pop"]: city["tier"] += 1 tier = TIERS[city["tier"]] log(world, now, "tier", f"The settlement has grown into a {tier['name'].lower()}! " f"Buildings can go higher, and everyone gets {TIER_SLOTS} more bank slots.") for key in BUILDINGS: _queue_if_funded(world, key, now) _start_next(world, now) changed = True if world["next_event_at"] and world["next_event_at"] <= now and not upcoming_event(world): _announce(world, now, rng) changed = True for event in world["events"]: if event["status"] == "upcoming" and event["fire_at"] <= now: for player in world["players"].values(): process_player(world, player, event["fire_at"], rng) _fire(world, event, now, rng) world["next_event_at"] = now + rng.uniform(8, 24) * HOUR / speed(world) changed = True return changed def _city_hour(world: dict, now: float) -> None: city = world["city"] pop = city["population"] city["food_due"] += pop * 0.5 / 24 stock = city["stockpile"] for item in FOOD_ORDER: while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0: stock[item] -= 1 if not stock[item]: del stock[item] city["food_due"] -= ITEMS[item]["food"] was_starving = city["starving"] city["starving"] = city["food_due"] >= 1 if city["starving"]: city["food_due"] = min(city["food_due"], pop) city["population"] = max(5.0, pop * (1 - 0.04 / 24)) if not was_starving: log(world, now, "city", "The granary is empty. People are going hungry and some are leaving.") else: room = housing(world) if pop < room: city["population"] = min(room, pop + max(0.5, pop * GROWTH_PER_DAY) / 24) elif pop > room: city["population"] = max(room, pop - pop * 0.05 / 24) city["treasury"] += pop * 0.5 / 24 def _finish_construction(world: dict, now: float) -> None: city = world["city"] work = city["construction"] building = city["buildings"][work["building"]] name = BUILDINGS[work["building"]]["name"] if work["repair"]: building["damaged"] = False log(world, now, "build", f"The {name.lower()} have been repaired" if name.endswith("s") else f"The {name.lower()} has been repaired") else: building["level"] += 1 log(world, now, "build", f"{name} reached level {building['level']}") city["construction"] = None # Leftover materials may already cover the next step. _queue_if_funded(world, work["building"], now) # --- events -------------------------------------------------------------- def _announce(world: dict, now: float, rng: random.Random) -> None: city = world["city"] hours = HOUR / speed(world) first = not world["events"] roll = rng.random() kind = "raid" if first or roll < 0.7 else ("merchant" if roll < 0.85 else "refugees") event = { "id": world["next_event_id"], "kind": kind, "status": "upcoming", "announced": now, "recon": 0, "sabotage": 0.0, "jitter": rng.uniform(-1, 1), } world["next_event_id"] += 1 if kind == "raid": # Bigger, richer and older cities draw bigger bands; every win draws a bolder one. wealth = city["treasury"] + stockpile_value(city) day = (now - world["created"]) * speed(world) / DAY strength = (15 + 4 * day + 0.8 * city["population"] + 1.2 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"]) event.update({ "fire_at": now + rng.uniform(30, 54) * hours, "strength": round(strength * rng.uniform(0.85, 1.2), 1), "group": rng.choice(RAID_GROUPS), "trait": rng.choice(RAID_TRAITS), }) log(world, now, "event", "Rumours of bandits on the roads reach the council") elif kind == "merchant": event["fire_at"] = now + rng.uniform(12, 30) * hours log(world, now, "event", "Word arrives that a merchant caravan is headed our way") else: event["fire_at"] = now + rng.uniform(12, 36) * hours event["people"] = rng.randint(6, 12) log(world, now, "event", "Refugees from the borderlands are walking towards the city") world["events"].append(event) del world["events"][:-30] def intel_level(world: dict, event: dict) -> int: return min(3, building_level(world, "spymaster") + event["recon"] // RECON_PER_INTEL) SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")] WINDOW_HOURS = [24, 8, 2, 0] def event_view(world: dict, event: dict, now: float) -> dict: """What the city knows about an upcoming event, filtered by intel.""" level = intel_level(world, event) half = WINDOW_HOURS[level] * HOUR / speed(world) centre = event["fire_at"] + event["jitter"] * half * 0.6 view = { "id": event["id"], "kind": event["kind"], "intel": level, "earliest": max(now, centre - half), "latest": max(now, centre + half), "exact": event["fire_at"] if level >= 3 else None, "recon": event["recon"], "recon_next": RECON_PER_INTEL - event["recon"] % RECON_PER_INTEL, } if event["kind"] == "raid": 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) view["headline"] = f"A {size} bandit band is gathering in the hills." view["size"] = size if level >= 2: spread = 0.25 * strength low = strength - spread * (1 + event["jitter"]) / 2 view["range"] = [round(low), round(low + spread)] view["group"] = event["group"] view["headline"] = f"{upper_first(event["group"])} are gathering in the hills." if level >= 3: view["strength"] = round(strength) view["trait"] = event["trait"] view["camp"] = True view["sabotage"] = round(event["sabotage"] * 100) view["requests"] = council_requests(world, event) elif event["kind"] == "merchant": view["headline"] = "A merchant caravan is on its way. Everything sells for half again as much while it's here." else: view["headline"] = f"About {event['people']} refugees are walking towards the city, hoping for a roof." return view def council_requests(world: dict, event: dict) -> list[dict]: city = world["city"] stock = city["stockpile"] strength = event["strength"] armed = int(city["population"] // 2) wants = [ ("arrows", min(1000, 50 * math.ceil(strength * 4 / 50))), ("copper_sword", min(armed, max(5, math.ceil(strength / 6)))), ("bread", math.ceil(city["population"] * 1.5)), ] if building_level(world, "smithy") >= 2: wants.append(("iron_shield", min(armed, math.ceil(strength / 10)))) requests = [] for item, qty in wants: have = stock.get(item, 0) if item == "copper_sword": have += stock.get("iron_sword", 0) if item == "bread": have = sum(stock.get(food, 0) * ITEMS[food]["food"] for food in FOOD_ORDER) requests.append({"item": item, "want": qty, "have": have}) return requests def _fire(world: dict, event: dict, now: float, rng: random.Random) -> None: event["status"] = "done" event["resolved"] = now if event["kind"] == "raid": _resolve_raid(world, event, now, rng) elif event["kind"] == "merchant": world["city"]["merchant_until"] = now + 12 * HOUR / speed(world) event["report"] = {"title": "A merchant caravan has arrived", "lines": ["For the next 12 hours, everything sells for 50% more."]} log(world, now, "event", "The merchant caravan arrived. Prices are up for 12 hours") else: city = world["city"] room = max(0, housing(world) - int(city["population"])) taken = min(room, event["people"]) city["population"] += taken lines = [f"{taken} of {event['people']} refugees found a home."] if taken < event["people"]: lines.append("The rest moved on. More houses would have kept them.") event["report"] = {"title": "Refugees arrived", "lines": lines} log(world, now, "event", f"{taken} refugees settled in the city") def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> None: city = world["city"] stock = city["stockpile"] attack = event["strength"] * (1 - event["sabotage"]) guard = defence(world) ratio = guard["total"] / attack if attack else 9 garrison = guard["garrison"] lines = [] if event["sabotage"]: 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, } if ratio >= 1: loot = round(attack * 2) city["treasury"] += loot city["repelled"] += 1 _use_up(stock, "arrows", 0.3) report["title"] = f"Victory! {upper_first(event["group"])} were driven off" lines.append(f"The raiders fled and left {loot} gold worth of plunder behind.") for entry in garrison: player = world["players"][entry["id"]] player["favour"] += 25 gain_xp(world, player, "combat", round(attack * 5), 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") else: severity = min(1.0, 1 - ratio) shelter = 1 - 0.08 * building_level(world, "walls") lost_people = int(city["population"] * (0.1 + 0.3 * severity)) city["population"] = max(5.0, city["population"] - lost_people) share = min(0.9, (0.2 + 0.5 * severity) * shelter) taken = {} for item in list(stock): gone = int(stock[item] * share) if gone: taken[item] = gone stock[item] -= gone if not stock[item]: del stock[item] gold = int(city["treasury"] * share) city["treasury"] -= gold _use_up(stock, "arrows", 0.6) report["title"] = f"Defeat. {upper_first(event["group"])} broke into the city" lines.append(f"{lost_people} townsfolk were killed or fled.") if gold or taken: stolen = ", ".join(amount(item, qty) for item, qty in taken.items()) lines.append(f"The raiders carried off {gold:,} gold" + (f" and {stolen}." if stolen else ".")) built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]] if severity > 0.35 and built: key = rng.choice(built) city["buildings"][key]["damaged"] = True _queue_if_funded(world, key, now) lines.append(f"The {BUILDINGS[key]['name'].lower()} were damaged and won't work until repaired." if BUILDINGS[key]["name"].endswith("s") else 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) log(world, now, "raid", f"{upper_first(event["group"])} broke into the city") report["lines"] = lines event["report"] = report for player in world["players"].values(): player["garrison"] = False def _use_up(stock: dict, item: str, share: float) -> None: if stock.get(item): stock[item] -= int(stock[item] * share) if not stock[item]: del stock[item] # --- favour -------------------------------------------------------------- def perk_cost(player: dict, perk: str) -> int: if perk == "bank_slot": return 500 * 2 ** player["perks"]["bank_slot"] raise KeyError(perk) def buy_perk(player: dict, perk: str) -> str | None: if perk not in player["perks"]: return "No such perk" cost = perk_cost(player, perk) if player["favour"] < cost: return f"Needs {cost:,} favour" player["favour"] -= cost player["perks"][perk] += 1 return None def content() -> dict: """The static tables the client renders from.""" buildings = { key: {**spec, "costs": [build_cost(key, level) for level in range(1, spec["max"] + 1)]} for key, spec in BUILDINGS.items() } return { "items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS, "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS, "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR, }