"""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 from datetime import date HOUR = 3600 DAY = 24 * HOUR OFFLINE_CAP = 24 * HOUR BASE_SLOTS = 12 BASE_STACK = 1000 CLASS_SPEED = 0.8 LUCKY_CHANCE = 0.1 OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose 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}, "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}, "arrows": {"name": "Arrows", "icon": "๐Ÿน", "value": 1}, "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}, "bones": {"name": "Bones", "icon": "๐Ÿฆด", "value": 1}, "big_bones": {"name": "Big bones", "icon": "๐Ÿฆด", "value": 4}, "dragon_bones": {"name": "Dragon bones", "icon": "๐Ÿฆด", "value": 15}, "studded_body": {"name": "Studded jerkin", "icon": "๐Ÿงฅ", "value": 40, "slot": "body", "armour": 5, "ward": 5, "weight": "medium", "req": ["combat", 10]}, # Building materials from every trade: thatch and flax from the fields, rope # twisted from flax, glue boiled from hides, and provisions to feed the builders. "thatch": {"name": "Thatch", "icon": "๐ŸŒพ", "value": 2}, "flax": {"name": "Flax", "icon": "๐ŸŒฟ", "value": 2}, "rope": {"name": "Rope", "icon": "๐Ÿชข", "value": 8}, "hide_glue": {"name": "Hide glue", "icon": "๐Ÿฏ", "value": 7}, "provisions": {"name": "Provisions", "icon": "๐ŸŽ’", "value": 10}, } # --- tier ladders ----------------------------------------------------------- # Six tiers, RuneScape's metals with Melvor-ish level spacing. Each row of a # ladder generates its items and recipes below; the copper and iron ids # predate the ladders and are kept as they were. TIER_LEVELS = [1, 15, 30, 45, 60, 75] # gathering / smelting level of each tier EQUIP_LEVELS = [1, 10, 20, 30, 40, 50] # combat level to wear, skill level to use a tool # key, name, ore (item id, name, icon, value), coal per bar, bar icon, bar value, smithy level, handle wood METALS = [ ("copper", "Copper", ("copper_ore", "Copper ore", "๐ŸŸค", 2), 0, "๐ŸŸง", 5, 0, "logs"), ("iron", "Iron", ("iron_ore", "Iron ore", "โšซ", 4), 0, "โฌ›", 10, 1, "oak_logs"), ("steel", "Steel", None, 2, "๐Ÿ”ฒ", 20, 2, "willow_logs"), ("mithril", "Mithril", ("mithril_ore", "Mithril ore", "๐Ÿ”ต", 15), 3, "๐ŸŸฆ", 40, 3, "maple_logs"), ("adamant", "Adamant", ("adamantite_ore", "Adamantite ore", "๐ŸŸข", 30), 4, "๐ŸŸฉ", 70, 4, "yew_logs"), ("rune", "Rune", ("runite_ore", "Runite ore", "๐Ÿ’ ", 55), 5, "๐ŸŸช", 120, 5, "magic_logs"), ] # piece, slot, display noun, bars, smithing level above the tier, power by tier, armour by tier PIECES = [ ("sword", "weapon", "sword", 2, 7, [4, 9, 14, 20, 27, 35], None), ("shield", "shield", "shield", 3, 10, [3, 6, 8, 11, 14, 18], [2, 4, 6, 9, 12, 15]), ("helmet", "helmet", "helmet", 2, 5, None, [2, 4, 6, 8, 11, 14]), ("legs", "legs", "greaves", 4, 9, None, [3, 6, 9, 12, 16, 21]), ("body", "body", "cuirass", 5, 11, None, [4, 8, 12, 16, 22, 28]), ] # Armour weights: a piece's rating, split into armour (against physical blows) and # ward (against magic). Heavy plate from Smithing, medium leather from # Leatherworking, light cloth from Tailoring; each adds up to about the same. WEIGHTS = { "heavy": {"name": "Heavy", "armour": 1.0, "ward": 0.2}, "medium": {"name": "Medium", "armour": 0.6, "ward": 0.6}, "light": {"name": "Light", "armour": 0.2, "ward": 1.0}, } ARMOUR_RATING = {slot: rating for piece, slot, _noun, _bars, _offset, _power, rating in PIECES if piece != "shield" and rating} def _armour_stats(weight: str, slot: str, tier: int) -> dict: rating = ARMOUR_RATING[slot][tier] return {"armour": round(rating * WEIGHTS[weight]["armour"]), "ward": round(rating * WEIGHTS[weight]["ward"]), "weight": weight} PLURALS = {"sword": "swords", "shield": "shields", "helmet": "helmets", "greaves": "greaves", "cuirass": "cuirasses"} PIECE_ICONS = {"sword": "โš”๏ธ", "shield": "๐Ÿ›ก๏ธ", "helmet": "๐Ÿช–", "legs": "๐Ÿฆฟ", "body": "๐Ÿฆพ"} COPPER_ICONS = {"sword": "๐Ÿ—ก๏ธ", "helmet": "โ›‘๏ธ", "legs": "๐Ÿฆต", "body": "๐Ÿฅ‹"} # Gathering tools: one slot per skill, `speed` cuts the action time. TOOLS = {"mining": ("pickaxe", "โ›๏ธ"), "woodcutting": ("axe", "๐Ÿช“"), "farming": ("hoe", "๐Ÿช"), "fishing": ("fishing rod", "๐ŸŽฃ"), "hunting": ("hunting knife", "๐Ÿ”ช"), "construction": ("hammer", "๐Ÿ”จ")} TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60] # item id, name, icon, value, action id, verb TREES = [ ("logs", "Logs", "๐Ÿชต", 1, "cut_logs", "Cut trees"), ("oak_logs", "Oak logs", "๐ŸŒณ", 4, "cut_oak", "Cut oaks"), ("willow_logs", "Willow logs", "๐ŸŒฟ", 8, "cut_willow", "Cut willows"), ("maple_logs", "Maple logs", "๐Ÿ", 15, "cut_maple", "Cut maples"), ("yew_logs", "Yew logs", "๐ŸŒฒ", 30, "cut_yew", "Cut yews"), ("magic_logs", "Enchanted logs", "๐Ÿ”ฎ", 55, "cut_magic", "Cut enchanted trees"), ] # Every tier's hide comes from Hunting. raw hide (id, name, icon, value), leather (id, name, value), # armour id prefix, armour name prefix, leatherworking level LEATHERS = [ (("hide", "Hide", "๐ŸŸซ", 3), ("leather", "Leather", 5), "leather", "Leather", 1), (("boar_hide", "Boar hide", "๐Ÿ—", 8), ("hard_leather", "Hard leather", 12), "hard_leather", "Hard leather", 20), (("bear_pelt", "Bear pelt", "๐Ÿป", 16), ("bear_leather", "Bear leather", 24), "bear_leather", "Bear leather", 35), (("elk_hide", "Elk hide", "๐ŸฆŒ", 35), ("elk_leather", "Elk leather", 50), "elk_leather", "Elk leather", 50), (("sabrecat_pelt", "Sabrecat pelt", "๐Ÿ†", 60), ("sabrecat_leather", "Sabrecat leather", 85), "sabrecat_leather", "Sabrecat leather", 65), (("mammoth_hide", "Mammoth hide", "๐Ÿฆฃ", 100), ("mammoth_leather", "Mammoth leather", 140), "mammoth_leather", "Mammoth leather", 80), ] # piece suffix, slot, noun, leather used, level above the tier, icon LEATHER_PIECES = [("cap", "helmet", "cap", 2, 2, "๐Ÿงข"), ("legs", "legs", "trousers", 3, 7, "๐Ÿ‘–"), ("body", "body", "jerkin", 4, 11, "๐Ÿฆบ")] # Tailoring: a fibre from Farming, the cloth woven from it, and the light armour sewn from that. # fibre (id, name, icon, value), farming level, seconds, xp, cloth (id, name, value), armour id prefix, armour name prefix CLOTHS = [ (("flax", "Flax", "๐ŸŒฟ", 2), 1, 35, 11, ("linen", "Linen", 5), "linen", "Linen"), (("cotton", "Cotton", "โ˜๏ธ", 4), 15, 45, 20, ("cotton_cloth", "Cotton cloth", 12), "cotton", "Cotton"), (("silk_cocoons", "Silk cocoons", "๐Ÿ›", 8), 30, 55, 32, ("silk", "Silk", 24), "silk", "Silk"), (("moonpetal", "Moonpetal", "๐ŸŒ™", 15), 45, 60, 45, ("moonweave", "Moonweave", 45), "moonweave", "Moonweave"), (("starbloom", "Starbloom", "โญ", 28), 60, 70, 60, ("starweave", "Starweave", 80), "starweave", "Starweave"), (("spiritvine", "Spiritvine", "๐ŸŒ€", 50), 75, 80, 80, ("spiritweave", "Spiritweave", 135), "spiritweave", "Spiritweave"), ] FIBRE_VERBS = {"silk_cocoons": "Raise silkworms"} CLOTH_PIECES = [("hood", "helmet", "hood", 2, 2, "๐Ÿงข"), ("legs", "legs", "leggings", 3, 7, "๐Ÿ‘–"), ("body", "body", "robe", 4, 11, "๐Ÿ‘˜")] # raw (id, name, icon, value), cooked (id, name, icon, value, heal, food), fishing level, fish action, verb FISH = [ (("raw_shrimp", "Raw shrimp", "๐Ÿฆ", 1), ("cooked_shrimp", "Cooked shrimp", "๐Ÿค", 4, 10, 1), 1, "fish_shrimp", "Net shrimp"), (("raw_trout", "Raw trout", "๐ŸŸ", 3), ("cooked_trout", "Grilled trout", "๐Ÿข", 8, 18, 1), 15, "fish_trout", "Fly-fish trout"), (("raw_salmon", "Raw salmon", "๐Ÿ ", 6), ("cooked_salmon", "Baked salmon", "๐Ÿฃ", 14, 28, 2), 30, "fish_salmon", "Catch salmon"), (("raw_eel", "Raw eel", "๐Ÿ", 10), ("smoked_eel", "Smoked eel", "๐Ÿฅ“", 22, 40, 2), 45, "fish_eel", "Trap eels"), (("raw_lobster", "Raw lobster", "๐Ÿฆž", 18), ("cooked_lobster", "Cooked lobster", "๐Ÿœ", 35, 55, 3), 60, "fish_lobster", "Pot lobsters"), (("raw_swordfish", "Raw swordfish", "๐Ÿฌ", 30), ("cooked_swordfish", "Grilled swordfish", "๐Ÿฑ", 55, 70, 3), 75, "fish_swordfish", "Harpoon swordfish"), ] COOK_IDS = {"raw_shrimp": "cook_shrimp", "raw_trout": "cook_trout", "raw_salmon": "cook_salmon", "raw_eel": "cook_eel", "raw_lobster": "cook_lobster", "raw_swordfish": "cook_swordfish"} ITEMS["coal"] = {"name": "Coal", "icon": "๐ŸŒ‘", "value": 6} for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in enumerate(METALS): if _ore: ITEMS[_ore[0]] = {"name": _ore[1], "icon": _ore[2], "value": _ore[3]} ITEMS[f"{_key}_bar"] = {"name": f"{_name} bar", "icon": _bar_icon, "value": _bar_value, "tier": _tier} for _piece, _slot, _noun, _bars, _offset, _power, _armour in PIECES: _item = {"name": f"{_name} {_noun}", "plural": f"{_name} {PLURALS[_noun]}", "icon": COPPER_ICONS.get(_piece, PIECE_ICONS[_piece]) if _key == "copper" else PIECE_ICONS[_piece], "value": round(_bars * _bar_value * 1.2), "slot": _slot, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]]} if _power: _item["power"] = _power[_tier] if _armour and _piece == "shield": _item["armour"] = _armour[_tier] elif _armour: _item.update(_armour_stats("heavy", _slot, _tier)) ITEMS[f"{_key}_{_piece}"] = {k: v for k, v in _item.items() if v is not None} for _skill, (_tool, _icon) in TOOLS.items(): ITEMS[f"{_key}_{_tool.replace(' ', '_')}"] = { "name": f"{_name} {_tool}", "icon": _icon, "value": round(2 * _bar_value * 1.4), "tier": _tier, "slot": f"tool_{_skill}", "tool": _skill, "speed": TOOL_SPEED[_tier], "req": [_skill, EQUIP_LEVELS[_tier]], } # Hardware: what the smith makes for builders, one kind per metal, used in # fittings and in building levels in place of plain bars. # id, singular, plural, bars used, pieces made HARDWARE = [ ("copper_rivets", "Copper rivet", "Copper rivets", 1, 5), ("iron_nails", "Iron nail", "Iron nails", 1, 5), ("steel_hinges", "Steel hinge", "Steel hinges", 1, 2), ("mithril_brackets", "Mithril bracket", "Mithril brackets", 1, 2), ("adamant_chain", "Adamant chain", "Adamant chains", 2, 1), ("rune_lock", "Rune lock", "Rune locks", 2, 1), ] for (_id, _name, _plural, _bars, _made), _metal in zip(HARDWARE, METALS): ITEMS[_id] = {"name": _name, "plural": _plural, "icon": "๐Ÿ”ฉ", "value": max(1, round(_bars * _metal[5] * 1.2 / _made)), "tier": METALS.index(_metal)} PLANKS = [("plank", "Plank"), ("oak_plank", "Oak plank"), ("willow_plank", "Willow plank"), ("maple_plank", "Maple plank"), ("yew_plank", "Yew plank"), ("magic_plank", "Enchanted plank")] for (_log, _name, _icon, _value, _action_id, _verb), (_plank, _plank_name) in zip(TREES, PLANKS): ITEMS[_log] = {"name": _name, "icon": _icon, "value": _value} ITEMS[_plank] = {"name": _plank_name, "icon": "๐Ÿชš", "value": _value * 2 + 1} # Weapons: four styles, one per trade. The sword pairs with a shield; the # warhammer, staff and bow take both hands, so they carry the shield's power too. SWORD_POWER = next(piece[5] for piece in PIECES if piece[0] == "sword") SHIELD_POWER = next(piece[5] for piece in PIECES if piece[0] == "shield") TWO_HANDED_POWER = [sword + shield for sword, shield in zip(SWORD_POWER, SHIELD_POWER)] WOODS = ["wooden", "oak", "willow", "maple", "yew", "magic"] STAVES = ["Wooden staff", "Oak staff", "Willow staff", "Maple staff", "Yew staff", "Enchanted staff"] BOWS = ["Shortbow", "Oak bow", "Willow bow", "Maple bow", "Yew bow", "Enchanted bow"] for _tier, _metal in enumerate(METALS): ITEMS[f"{_metal[0]}_sword"]["style"] = "sword" _weapon = {"slot": "weapon", "power": TWO_HANDED_POWER[_tier], "tier": _tier, "two_handed": True, "req": ["combat", EQUIP_LEVELS[_tier]], "value": round(5 * _metal[5] * 1.2)} ITEMS[f"{_metal[0]}_warhammer"] = {"name": f"{_metal[1]} warhammer", "icon": "๐Ÿ”จ", "style": "hammer", **_weapon} ITEMS[f"{WOODS[_tier]}_staff"] = {"name": STAVES[_tier], "plural": STAVES[_tier].replace("staff", "staves"), "icon": "๐Ÿช„", "style": "staff", **_weapon} ITEMS[f"{WOODS[_tier]}_bow"] = {"name": BOWS[_tier], "icon": "๐Ÿน", "style": "bow", **_weapon} for _tier, (_raw, _leather, _prefix, _label, _level) in enumerate(LEATHERS): ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]} ITEMS[_leather[0]] = {"name": _leather[1], "icon": "๐Ÿชข", "value": _leather[2]} for _suffix, _slot, _noun, _used, _offset, _icon in LEATHER_PIECES: ITEMS[f"{_prefix}_{_suffix}"] = { "name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _leather[2] * 1.3), "slot": _slot, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]], **_armour_stats("medium", _slot, _tier), } if _noun == "trousers": ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"] for _tier, (_fibre, _level, _time, _xp, _cloth, _prefix, _label) in enumerate(CLOTHS): ITEMS.setdefault(_fibre[0], {"name": _fibre[1], "icon": _fibre[2], "value": _fibre[3]}) ITEMS[_cloth[0]] = {"name": _cloth[1], "icon": "๐Ÿงถ", "value": _cloth[2]} for _suffix, _slot, _noun, _used, _offset, _icon in CLOTH_PIECES: ITEMS[f"{_prefix}_{_suffix}"] = { "name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _cloth[2] * 1.3), "slot": _slot, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]], **_armour_stats("light", _slot, _tier), } if _noun == "leggings": ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"] for _raw, _cooked, _level, _action_id, _verb in FISH: ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]} ITEMS[_cooked[0]] = {"name": _cooked[1], "icon": _cooked[2], "value": _cooked[3], "heal": _cooked[4], "food": _cooked[5]} # --- herblore ------------------------------------------------------------------- # Herbs from Farming, brewed with a secondary into potions with charges. One # potion per skill at a time, as in Melvor; each action in that skill uses a # charge, and an empty potion is replaced from the bank if there's another. # id, name, icon, farming level, seconds, xp, value HERBS = [ ("guam_leaf", "Guam leaf", "๐Ÿ€", 1, 45, 12, 3), ("marrentill", "Marrentill", "๐ŸŒผ", 15, 50, 20, 5), ("tarromin", "Tarromin", "๐ŸŒบ", 25, 55, 30, 8), ("harralander", "Harralander", "๐ŸŒป", 35, 60, 42, 12), ("ranarr", "Ranarr", "๐Ÿชป", 45, 65, 55, 18), ("snapdragon", "Snapdragon", "๐ŸŒธ", 60, 70, 75, 28), ] POTION_CHARGES = 50 # Potions come in tiers like everything else: tier n is brewed from the nth herb # and the nth rung of the skill's own ladder, needs the tier's Herblore level # (none for tier I), and is stronger the higher it goes. Ladders stop where the # skill runs out of higher things to do. ROMAN = ["I", "II", "III", "IV", "V", "VI"] SPEEDS = [0.10, 0.15, 0.20, 0.25, 0.30, 0.35] _ores = ["copper_ore", "iron_ore", "coal", "mithril_ore", "adamantite_ore", "runite_ore"] # id, name, skill, effect, strength by tier, secondary by tier POTION_LINES = [ ("mining", "Mining potion", "mining", "speed", SPEEDS, _ores), ("woodcutting", "Woodcutting potion", "woodcutting", "speed", SPEEDS, [tree[0] for tree in TREES]), ("fishing", "Fishing potion", "fishing", "speed", SPEEDS, [fish[0][0] for fish in FISH]), ("farming", "Farming potion", "farming", "speed", SPEEDS, ["wheat", "vegetables", "guam_leaf", "marrentill", "tarromin", "harralander"]), ("hunting", "Hunting potion", "hunting", "speed", SPEEDS, ["feathers", "raw_meat", "boar_hide", "bear_pelt", "elk_hide", "sabrecat_pelt"]), ("construction", "Construction potion", "construction", "speed", SPEEDS, [plank for plank, _name in PLANKS]), ("smithing", "Smithing potion", "smithing", "keep", SPEEDS, [f"{metal[0]}_bar" for metal in METALS]), ("cooking", "Cooking potion", "cooking", "keep", SPEEDS, [fish[1][0] for fish in FISH]), ("leatherworking", "Leatherworking potion", "leatherworking", "keep", SPEEDS, [leather[1][0] for leather in LEATHERS]), ("tailoring", "Tailoring potion", "tailoring", "keep", SPEEDS, [cloth[4][0] for cloth in CLOTHS]), ("prayer", "Prayer potion", "prayer", "bones", [0.25, 0.5, 0.75], ["bones", "big_bones", "dragon_bones"]), ("strength", "Strength potion", "combat", "power", [4, 7, 11, 15, 20, 26], [leather[0][0] for leather in LEATHERS]), ("defence", "Defence potion", "combat", "reduce", [0.10, 0.14, 0.18, 0.22, 0.26, 0.30], [f"{metal[0]}_shield" for metal in METALS]), ] # id, name, herblore level, herb, secondary, skill, effect POTIONS = [] POTION_TIER = {} for _line, _name, _skill, _key, _strengths, _seconds in POTION_LINES: for _tier, (_strength, _second) in enumerate(zip(_strengths, _seconds)): _id = f"{_line}_potion_{_tier + 1}" POTIONS.append((_id, f"{_name} {ROMAN[_tier]}", TIER_LEVELS[_tier], HERBS[_tier][0], _second, _skill, {_key: _strength})) POTION_TIER[_id] = _tier # Potions from before the tiers, and what they became. OLD_POTIONS = { "trackers_tincture": "hunting_potion_3", "lumberjacks_tonic": "woodcutting_potion_3", "strength_potion": "strength_potion_2", "anglers_brew": "fishing_potion_3", "prospectors_draught": "mining_potion_2", "growth_potion": "farming_potion_3", "defence_potion": "defence_potion_2", "chefs_spice": "cooking_potion_2", "bonedust_tonic": "prayer_potion_2", "smiths_focus": "smithing_potion_2", "masons_patience": "construction_potion_2", "tanners_oil": "leatherworking_potion_2", "regeneration_potion": "defence_potion_3", "super_strength": "strength_potion_4", "super_defence": "defence_potion_5", } def describe_potion(skill: str, effect: dict) -> str: name = "combat" if skill == "combat" else skill if "speed" in effect: return f"{name.title()} {round(effect['speed'] * 100)}% faster" if "double" in effect: return f"{name.title()}: {round(effect['double'] * 100)}% double yield" if "keep" in effect: return f"{name.title()}: {round(effect['keep'] * 100)}% save materials" if "bones" in effect: return f"+{round(effect['bones'] * 100)}% from bones" if "power" in effect: return f"+{effect['power']} power" return f"-{round(effect['reduce'] * 100)}% damage taken" for _id, _name, _icon, _level, _time, _xp, _value in HERBS: ITEMS[_id] = {"name": _name, "icon": _icon, "value": _value} for _id, _name, _level, _herb, _second, _skill, _effect in POTIONS: ITEMS[_id] = {"name": _name, "icon": "โš—๏ธ" if _skill == "combat" else "๐Ÿงช", "value": ITEMS[_herb]["value"] * 3 + ITEMS[_second]["value"], "potion": {"skill": _skill, **_effect, "text": describe_potion(_skill, _effect)}} # What the city eats first: plain food before the good stuff. FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"])) 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": "construction", "name": "Construction", "icon": "๐Ÿ—๏ธ", "group": "Crafting"}, {"id": "cooking", "name": "Cooking", "icon": "๐Ÿณ", "group": "Crafting"}, {"id": "leatherworking", "name": "Leatherworking", "icon": "๐Ÿชก", "group": "Crafting"}, {"id": "tailoring", "name": "Tailoring", "icon": "๐Ÿงต", "group": "Crafting"}, {"id": "herblore", "name": "Herblore", "icon": "๐Ÿงช", "group": "Crafting"}, {"id": "combat", "name": "Combat", "icon": "โš”๏ธ", "group": "Combat"}, {"id": "prayer", "name": "Prayer", "icon": "๐Ÿ™", "group": "Combat"}, {"id": "reconnaissance", "name": "Reconnaissance", "icon": "๐Ÿ”ญ", "group": "Combat"}, ] SKILL_IDS = [skill["id"] for skill in SKILLS] GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"} CRAFTING = {"smithing", "construction", "cooking", "leatherworking", "herblore", "tailoring"} # A class is a whole trade, from gathering to the finished goods: its skills are # 20% faster, 1 in 10 of its crafts make double, and it has one bonus of its own. # Nothing is locked: every class can train every skill. CLASSES = { "miner": { "name": "Miner", "icon": "โ›๏ธ", "fast": ["mining", "smithing"], "craft": ["smithing"], "bonus": "Mining and smithing 20% faster", "masterwork": "Masterwork: 10% double smithing crafts", "signature": "Prospector: 10% double ore", "combat": "Warhammer: +15% damage", }, "builder": { "name": "Builder", "icon": "๐Ÿ—๏ธ", "fast": ["woodcutting", "construction"], "craft": ["construction"], "bonus": "Woodcutting and construction 20% faster", "masterwork": "Masterwork: 10% double construction crafts", "signature": "Foreman: +1 labour per site action", "combat": "Sword: +15% damage", }, "homesteader": { "name": "Homesteader", "icon": "๐ŸŒพ", "fast": ["cooking", "herblore", "tailoring"], "craft": ["cooking", "herblore", "tailoring"], "bonus": "Cooking, herblore and tailoring 20% faster, two farm plots", "masterwork": "Masterwork: 10% double cooking, herblore and tailoring crafts", "signature": "Steady hand: potions last 25% longer", "combat": "Staff: +15% damage", }, "ranger": { "name": "Ranger", "icon": "๐Ÿน", "fast": ["hunting", "fishing", "leatherworking", "cooking"], "craft": ["leatherworking", "cooking"], "bonus": "Hunting, fishing, leatherworking and cooking 20% faster", "masterwork": "Masterwork: 10% double leatherworking and cooking crafts", "signature": "Pathfinder: +50% recon", "combat": "Bow: +15% damage", }, } # Classes from before the trades, as a starting point until their player re-picks. OLD_CLASSES = {"pioneer": "miner", "artisan": "builder", "scout": "ranger", "warrior": "builder"} 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} def _tiered_actions() -> list[dict]: """Mining, woodcutting, smelting, smithing, tanning and leatherworking for every tier.""" actions = [] ore_xp = [7, 15, 30, 40, 55, 75] ore_time = [25, 35, 50, 55, 65, 80] bar_xp = [6, 14, 22, 32, 44, 58] piece_xp = [6, 13, 20, 28, 37, 48] # per bar used for tier, (key, name, ore, coal, _icon, _value, smithy, wood) in enumerate(METALS): if ore: actions.append(_action(f"mine_{key}" if key in ("copper", "iron") else f"mine_{ore[0].removesuffix('_ore')}", "mining", f"Mine {ore[1].removesuffix(' ore').lower()}", TIER_LEVELS[tier], ore_time[tier], ore_xp[tier], outputs={ore[0]: 1})) if tier == 2: actions.append(_action("mine_coal", "mining", "Mine coal", 30, 40, 22, outputs={"coal": 1})) city = {"smithy": smithy} if smithy else {} inputs = {"iron_ore" if key == "steel" else ore[0]: 1} inputs.update({"coal": coal} if coal else {"logs": 1}) actions.append(_action(f"smith_{key}_bar", "smithing", f"{name} bar", TIER_LEVELS[tier], 20 + 4 * tier, bar_xp[tier], inputs, {f"{key}_bar": 1}, group="Bars", city={"smithy": smithy} if tier >= 2 else {})) hardware, _name, _plural, bars, made = HARDWARE[tier] actions.append(_action(f"smith_{hardware}", "smithing", _plural, TIER_LEVELS[tier] + 2, 25 + 2 * tier, bars * piece_xp[tier], {f"{key}_bar": bars}, {hardware: made}, group="Hardware", city=city)) for piece, _slot, noun, bars, offset, _power, _armour in PIECES: if piece == "shield": continue # builders make shields now, in Construction inputs = {f"{key}_bar": bars} if tier: inputs.update({trophy("sword", tier): tier} if piece == "sword" else {reagent("heavy", tier): REAGENT_COUNTS[_slot]}) actions.append(_action(f"smith_{key}_{piece}", "smithing", f"{name} {noun}", TIER_LEVELS[tier] + offset, 25 + 5 * bars + 2 * tier, bars * piece_xp[tier], inputs, {f"{key}_{piece}": 1}, group=name, city=city)) actions.append(_action(f"smith_{key}_warhammer", "smithing", f"{name} warhammer", TIER_LEVELS[tier] + 6, 45 + 2 * tier, 4 * piece_xp[tier], {f"{key}_bar": 3, PLANKS[tier][0]: 1, **({trophy("hammer", tier): 2 + tier} if tier else {})}, {f"{key}_warhammer": 1}, group=name, city=city)) for tool, _icon in TOOLS.values(): tool_id = f"{key}_{tool.replace(' ', '_')}" actions.append(_action(f"smith_{tool_id}", "smithing", f"{name} {tool}", TIER_LEVELS[tier] + 3, 30 + 2 * tier, 2 * piece_xp[tier], {f"{key}_bar": 2, wood: 1}, {tool_id: 1}, group=name, city=city)) for tier, (log, _name, _icon, _value, action_id, verb) in enumerate(TREES): actions.append(_action(action_id, "woodcutting", verb, TIER_LEVELS[tier], [20, 35, 40, 50, 60, 75][tier], [5, 15, 24, 36, 52, 75][tier], outputs={log: 1})) for tier, (raw, leather, prefix, label, level) in enumerate(LEATHERS): actions.append(_action("tan_leather" if tier == 0 else f"tan_{leather[0]}", "leatherworking", f"Tan {leather[1].lower()}", level, 25 + 3 * tier, bar_xp[tier], {raw[0]: 1}, {leather[0]: 1}, group="Tanning")) for suffix, slot, noun, used, offset, _icon in LEATHER_PIECES: actions.append(_action(f"lw_{suffix}" if tier == 0 else f"lw_{prefix}_{suffix}", "leatherworking", f"{label} {noun}", level + offset, 25 + 5 * used + 2 * tier, used * piece_xp[tier], {leather[0]: used, **({reagent("medium", tier): REAGENT_COUNTS[slot]} if tier else {})}, {f"{prefix}_{suffix}": 1}, group=label)) for tier, (fibre, level, seconds, fibre_xp, cloth, prefix, label) in enumerate(CLOTHS): if tier: # flax is grown with the other building materials actions.append(_action(f"grow_{fibre[0]}", "farming", FIBRE_VERBS.get(fibre[0], f"Grow {fibre[1].lower()}"), level, seconds, fibre_xp, outputs={fibre[0]: 2}, group="Fibres")) actions.append(_action(f"weave_{cloth[0]}", "tailoring", f"Weave {cloth[1].lower()}", TIER_LEVELS[tier], 25 + 3 * tier, bar_xp[tier], {fibre[0]: 2}, {cloth[0]: 1}, group="Weaving")) for suffix, slot, noun, used, offset, _icon in CLOTH_PIECES: actions.append(_action(f"sew_{prefix}_{suffix}", "tailoring", f"{label} {noun}", TIER_LEVELS[tier] + offset, 25 + 5 * used + 2 * tier, used * piece_xp[tier], {cloth[0]: used, **({reagent("light", tier): REAGENT_COUNTS[slot]} if tier else {})}, {f"{prefix}_{suffix}": 1}, group=label)) for raw, cooked, level, fish_id, verb in FISH: tier = TIER_LEVELS.index(level) if level in TIER_LEVELS else 3 actions.append(_action(fish_id, "fishing", verb, level, [25, 35, 45, 55, 65, 80][tier], [6, 15, 28, 45, 62, 85][tier], outputs={raw[0]: 1})) actions.append(_action(COOK_IDS[raw[0]], "cooking", cooked[1], level, [20, 30, 35, 40, 45, 50][tier], [6, 15, 27, 42, 58, 80][tier], {raw[0]: 1, "logs": 1}, {cooked[0]: 1}, group="Fish")) return actions # --- combat ----------------------------------------------------------------- # A fight is played out swing by swing: you and the enemy each have HP, a swing # timer, accuracy and a max hit, and every swing hits, misses or crits. Every # blow has a damage type: weapons deal one, and each enemy is vulnerable to some # types and resists others. Armour stops physical blows, ward stops magic. # Combat is split into the Frontier (bandits and the city's troubles) and # expeditions: one area per tier, each with four races (each feeding one weapon # line with its trophy, and every armour line with the area's reagents) and a boss. CRIT_CHANCE = 0.05 CRIT_MULT = 1.5 RESPAWN = 2 # seconds after a kill before the next enemy turns up MAX_FIGHT = 600 # a fight that drags on longer is broken off FLEE_BELOW = 0.2 # out of food and under this share of HP: get out DAMAGE_TYPES = { "slash": {"name": "Slashing", "glyph": "sword-slice", "tint": "#d4dae2"}, "blunt": {"name": "Blunt", "glyph": "hammer-drop", "tint": "#d1a46a"}, "pierce": {"name": "Piercing", "glyph": "arrowhead", "tint": "#5fae8a"}, "magic": {"name": "Magic", "glyph": "magic-swirl", "tint": "#a77be8"}, } VULNERABLE = 1.25 # damage an enemy takes from a type it's vulnerable to RESISTANT = 0.75 # ...and from one it resists # How each kind of weapon fights. Two-handed weapons carry the shield's power themselves. STYLES = { "sword": {"name": "sword", "damage": "slash", "interval": 2.4, "hit": 1.0, "accuracy": 1.0, "crit": 0.05}, "hammer": {"name": "warhammer", "damage": "blunt", "interval": 3.6, "hit": 1.6, "accuracy": 0.95, "crit": 0.10}, "staff": {"name": "staff", "damage": "magic", "interval": 3.0, "hit": 1.2, "accuracy": 1.25, "crit": 0.05}, "bow": {"name": "bow", "damage": "pierce", "interval": 1.8, "hit": 0.75, "accuracy": 1.05, "crit": 0.05, "first": True}, } CLASS_STYLE = {"miner": "hammer", "builder": "sword", "homesteader": "staff", "ranger": "bow"} CLASS_STYLE_BONUS = 1.15 # damage with your own trade's weapon BLOCK_CHANCE = 0.15 # a shield turns aside this share of hits # Each armour piece past copper needs reagents from its tier's expedition area. REAGENT_COUNTS = {"helmet": 1, "legs": 2, "body": 3} REAGENT_NOUNS = {"heavy": "plating", "medium": "lacing", "light": "thread"} REAGENT_ODDS = 0.10 # each of the area's three reagents, from any of its races TROPHY_ODDS = 0.03 # rare: higher-tier weapons are hunted for BOSS_TROPHY_ODDS = 0.06 # each of the four lines, so a boss is worth the longer fight def _slug(name: str) -> str: return "".join(c if c.isalnum() else "_" for c in name.lower().replace("'", "")).strip("_") # Archetypes: HP, seconds between swings, accuracy, evasion, max hit (multipliers), how often it turns up. ARCHETYPES = { "brute": (1.3, 3.0, 1.0, 0.85, 1.2, 4), "skirmisher": (0.85, 2.2, 1.1, 1.25, 0.85, 4), "caster": (0.85, 2.6, 1.15, 1.0, 1.25, 3), "elite": (1.7, 2.8, 1.1, 1.1, 1.3, 1), "boss": (1.4, 3.0, 1.0, 1.0, 1.35, 1), "dragon": (2.5, 3.0, 1.1, 1.0, 1.5, 1), } def _types(value) -> list: return [value] if isinstance(value, str) else list(value) def monster(name: str, archetype: str, level: int, attack, vulnerable=(), resists=(), boss: bool = False) -> dict: hp, interval, accuracy, evasion, hit, weight = ARCHETYPES[archetype] hp = round((20 + 6 * level) * hp * (2.5 if boss else 1)) return {"name": name, "archetype": archetype, "level": level, "boss": boss, "weight": weight, "hp": hp, "interval": interval, "xp": round(hp * 0.4 + level * 1.5), "accuracy": round((12 + 2.2 * level) * accuracy), "evasion": round((10 + 2 * level) * evasion), "max_hit": max(1, round((2 + level / 3) * hit * (1.2 if boss else 1))), "attack": _types(attack), "vulnerable": _types(vulnerable), "resists": _types(resists)} def _encounter(id: str, name: str, group: str, level: int, roster: list, gold: int = 0, drops=(), boss: bool = False, **extra) -> dict: """A combat action: each kill is one of the roster's variants, picked by how often each turns up. An elite is a couple of levels above the rest.""" variants = [monster(vname, archetype, level + (2 if archetype == "elite" else 0), attack, vulnerable, resists, boss) for vname, archetype, attack, vulnerable, resists in roster] total = sum(v["weight"] for v in variants) xp = round(sum(v["xp"] * v["weight"] for v in variants) / total) enemy = {"name": name, "level": level, "boss": boss, "variants": variants} return _action(id, "combat", extra.pop("label", None) or name, level, RESPAWN, xp, gold=gold, drops=[list(drop) for drop in drops], enemy=enemy, group=group, **extra) def _boss(id: str, name: str, group: str, level: int, archetype: str, attack, vulnerable, resists, **extra) -> dict: return _encounter(id, name, group, level, [(name, archetype, attack, vulnerable, resists)], boss=extra.pop("is_boss", True), **extra) # Rosters: (variant, archetype, attacks with, vulnerable to, resists). BANDITS = [("Bandit brute", "brute", "blunt", "slash", "blunt"), ("Bandit archer", "skirmisher", "pierce", "blunt", "pierce"), ("Hedge mage", "caster", "magic", "pierce", "magic")] WOLVES = [("Grey wolf", "skirmisher", "slash", "pierce", "blunt"), ("Timber wolf", "brute", "slash", "slash", "pierce"), ("Wolf alpha", "elite", "slash", "blunt", "slash")] GOBLINS = [("Goblin brute", "brute", "blunt", "blunt", "slash"), ("Goblin rogue", "skirmisher", "pierce", "magic", "blunt"), ("Goblin warlock", "caster", "magic", "slash", "magic")] # Expedition areas, tiers II to VI: name, level, the reagent prefix, and four races. # Each race: (name, trophy, weapon line its trophy goes into, level offset, roster). AREAS = [ ("Old Watchtower", 10, "Watchtower", [ ("Clay golems", "Clay core", "hammer", 0, [ ("Clay brute", "brute", "blunt", "blunt", "pierce"), ("Mud shaper", "caster", "magic", "pierce", "magic"), ("Clay colossus", "elite", "blunt", "magic", "blunt")]), ("Cave trolls", "Cave troll hide", "sword", 2, [ ("Troll bruiser", "brute", "blunt", "slash", "blunt"), ("Troll rockhurler", "skirmisher", "pierce", "blunt", "pierce"), ("Troll hexer", "caster", "magic", "pierce", "slash")]), ("Restless dead", "Grave dust", "staff", 1, [ ("Skeleton warrior", "brute", "slash", "blunt", "pierce"), ("Wisp", "skirmisher", "magic", "magic", "slash"), ("Necromancer", "caster", "magic", "slash", "blunt")]), ("Harpies", "Harpy sinew", "bow", 0, [ ("Harpy slasher", "skirmisher", "slash", "pierce", "blunt"), ("Harpy windcaster", "caster", "magic", ("slash", "pierce"), "magic"), ("Harpy matriarch", "elite", "slash", "magic", "pierce")]), ]), ("Sunken Barrows", 20, "Barrow", [ ("Stone guardians", "Stone core", "hammer", 0, [ ("Stone guardian", "brute", "blunt", "blunt", "slash"), ("Barrow sentry", "skirmisher", "pierce", "magic", "pierce"), ("Runebound guardian", "elite", "magic", "slash", "magic")]), ("Lizardfolk", "Lizard scale", "sword", 2, [ ("Lizard warrior", "brute", "slash", "blunt", "slash"), ("Lizard spearman", "skirmisher", "pierce", "slash", "pierce"), ("Lizard shaman", "caster", "magic", "pierce", "magic")]), ("Bog wraiths", "Wraith essence", "staff", 1, [ ("Bog wraith", "caster", "magic", "magic", "slash"), ("Drowned knight", "brute", "slash", "blunt", "pierce"), ("Marsh wisp", "skirmisher", "magic", "pierce", "blunt")]), ("Giant spiders", "Spider silk", "bow", 0, [ ("Spider hunter", "skirmisher", "pierce", "slash", "pierce"), ("Venom weaver", "caster", "magic", "blunt", "magic"), ("Brood mother", "elite", "pierce", "magic", "blunt")]), ]), ("Iron Hills", 30, "Ironhill", [ ("Iron golems", "Iron core", "hammer", 0, [ ("Iron golem", "brute", "blunt", "blunt", "slash"), ("Forge golem", "caster", "magic", "pierce", "magic"), ("Iron colossus", "elite", "blunt", "magic", "pierce")]), ("Orcs", "Orc tusk", "sword", 2, [ ("Orc berserker", "brute", "slash", "pierce", "blunt"), ("Orc archer", "skirmisher", "pierce", "slash", "pierce"), ("Orc shaman", "caster", "magic", "blunt", "magic")]), ("Mine ghosts", "Ghost essence", "staff", 1, [ ("Lost miner", "brute", "blunt", "magic", "slash"), ("Lantern ghost", "caster", "magic", "blunt", "magic"), ("Shaft howler", "skirmisher", "magic", "pierce", "blunt")]), ("Wyverns", "Wyvern sinew", "bow", 0, [ ("Wyvern", "brute", "slash", "pierce", "slash"), ("Wyvern hatchling", "skirmisher", "pierce", "blunt", "magic"), ("Venomspitter", "caster", "magic", "slash", "pierce")]), ]), ("Frostpeak", 40, "Frostpeak", [ ("Ice elementals", "Frozen core", "hammer", 0, [ ("Ice brute", "brute", "blunt", "blunt", "pierce"), ("Frost wisp", "skirmisher", "magic", "slash", "magic"), ("Glacier colossus", "elite", "blunt", "magic", "slash")]), ("Frost trolls", "Frost troll hide", "sword", 2, [ ("Frost troll", "brute", "blunt", "slash", "blunt"), ("Troll icehurler", "skirmisher", "pierce", "magic", "pierce"), ("Troll rimecaller", "caster", "magic", "blunt", "magic")]), ("Banshees", "Banshee essence", "staff", 1, [ ("Banshee", "caster", "magic", "magic", "slash"), ("Wailing knight", "brute", "slash", "blunt", "pierce"), ("Keening shade", "skirmisher", "magic", "pierce", "blunt")]), ("Griffins", "Griffin sinew", "bow", 0, [ ("Griffin", "brute", "slash", "pierce", "blunt"), ("Griffin rider", "skirmisher", "pierce", "slash", "pierce"), ("Storm griffin", "caster", "magic", "blunt", "magic")]), ]), ("Dragon's Spine", 50, "Dragonspine", [ ("Magma constructs", "Magma core", "hammer", 0, [ ("Magma brute", "brute", "blunt", "blunt", "slash"), ("Cinder golem", "skirmisher", "pierce", "magic", "pierce"), ("Fire binder", "caster", "magic", "pierce", "magic")]), ("Dragonkin", "Dragonkin scale", "sword", 2, [ ("Drakeguard", "brute", "slash", "blunt", "slash"), ("Drake lancer", "skirmisher", "pierce", "magic", "pierce"), ("Flame priest", "caster", "magic", "slash", "magic")]), ("Shades", "Shade essence", "staff", 1, [ ("Shade", "caster", "magic", "magic", "pierce"), ("Shadow blade", "skirmisher", "slash", "blunt", "slash"), ("Void knight", "elite", "magic", "pierce", "blunt")]), ("Rocs", "Roc sinew", "bow", 0, [ ("Roc", "brute", "slash", "pierce", "blunt"), ("Roc hatchling", "skirmisher", "pierce", "slash", "pierce"), ("Thunder roc", "caster", "magic", "blunt", "magic")]), ]), ] AREA_BONES = ["bones", "big_bones", "big_bones", "dragon_bones", "dragon_bones"] TROPHIES = {} # id: (name, weapon line, tier) REAGENTS = {} # id: (weight, tier) for _tier, (_area, _level, _prefix, _races) in enumerate(AREAS, start=1): for _race, _trophy, _line, _offset, _roster in _races: TROPHIES[_slug(_trophy)] = (_trophy, _line, _tier) ITEMS[_slug(_trophy)] = {"name": _trophy, "icon": "๐Ÿ†", "value": 15 * _tier, "trophy": _line, "tier": _tier} for _weight, _noun in REAGENT_NOUNS.items(): _id = f"{_slug(_prefix)}_{_noun}" REAGENTS[_id] = (_weight, _tier) ITEMS[_id] = {"name": f"{_prefix} {_noun}", "icon": "โœจ", "value": 12 * _tier, "reagent": _weight, "tier": _tier} def trophy(line: str, tier: int) -> str: """The trophy for a weapon line from a tier's area (tier 1 is the Old Watchtower).""" return next(item for item, (_name, kind, t) in TROPHIES.items() if kind == line and t == tier) def reagent(weight: str, tier: int) -> str: """The reagent an armour weight needs at a tier, from that tier's area.""" return next(item for item, (kind, t) in REAGENTS.items() if kind == weight and t == tier) def _area_drops(tier: int, odds: float = REAGENT_ODDS) -> list[tuple]: return [(reagent(weight, tier), 1, odds) for weight in WEIGHTS] def _weapon_actions() -> list[dict]: """Shields (Construction), staves (Herblore) and bows (Leatherworking); trophies past the first tier.""" actions = [] for tier, (key, name, *_rest) in enumerate(METALS): plank, leather, herb = PLANKS[tier][0], LEATHERS[tier][1][0], HERBS[tier][0] extra = lambda line, count: {trophy(line, tier): count} if tier else {} actions.append(_action(f"make_{key}_shield", "construction", f"{name} shield", TIER_LEVELS[tier] + 5, 40 + 2 * tier, [10, 22, 36, 52, 70, 92][tier], {plank: 3, leather: 2, f"{key}_bar": 1, **extra("sword", 1 + tier)}, {f"{key}_shield": 1}, group="Shields")) actions.append(_action(f"brew_{WOODS[tier]}_staff", "herblore", STAVES[tier], TIER_LEVELS[tier] + 5, 45 + 2 * tier, [12, 26, 42, 60, 80, 105][tier], {plank: 2, herb: 3, **extra("staff", 2 + tier)}, {f"{WOODS[tier]}_staff": 1}, group="Staves")) actions.append(_action(f"lw_{WOODS[tier]}_bow", "leatherworking", BOWS[tier], LEATHERS[tier][4] + 4, 40 + 2 * tier, [12, 26, 42, 60, 80, 105][tier], {plank: 2, leather: 2, **extra("bow", 2 + tier)}, {f"{WOODS[tier]}_bow": 1}, group="Bows")) return actions def _area_fights() -> list[dict]: fights = [] for tier, (area, level, _prefix, races) in enumerate(AREAS, start=1): for race, trophy_name, _line, offset, roster in races: fights.append(_encounter(f"fight_{_slug(race)}", race, area, level + offset, roster, gold=level + offset, drops=[(AREA_BONES[tier - 1], 1, 1.0), (_slug(trophy_name), 1, TROPHY_ODDS), *_area_drops(tier)])) return fights def _boss_drops(tier: int, reagents: float = 0.3) -> list[tuple]: return [*[(trophy(line, tier), 1, BOSS_TROPHY_ODDS) for line in STYLES], *_area_drops(tier, reagents)] COMBAT_ACTIONS = [ # The Frontier: the city's own troubles. _encounter("fight_scouts", "Bandit scouts", "Frontier", 10, BANDITS, gold=10, drops=[("copper_bar", 1, 0.3), ("arrows", 5, 0.4), ("bones", 1, 1.0)]), _encounter("sabotage", "Camp guards", "Frontier", 12, BANDITS, label="Sabotage the bandit camp", special="sabotage"), _boss("fight_stronghold", "Bandit warlord", "Frontier", 40, "boss", ("slash", "magic"), ("blunt", "magic"), ("slash", "pierce"), label="Bandit stronghold", gold=60, drops=[("steel_bar", 1, 0.4), ("coal", 3, 0.5), ("iron_shield", 1, 0.04), ("big_bones", 1, 1.0)]), # Greenwood: where everyone starts. _encounter("fight_wolves", "Wolves", "Greenwood", 1, WOLVES, gold=2, label="Wolves in the woods", drops=[("raw_meat", 1, 0.6), ("hide", 1, 0.5), ("bones", 1, 1.0)]), _encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4, drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]), *_area_fights(), _boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 18, "boss", ("blunt", "magic"), ("slash", "magic"), ("blunt", "pierce"), gold=25, drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_drops(1)]), _boss("fight_barrow_king", "Barrow king", "Sunken Barrows", 28, "boss", ("slash", "magic"), ("blunt", "magic"), ("pierce", "slash"), gold=40, drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_drops(2)]), # Dragons: the prize hunt, dropping their area's reagents far more often than anything else there. _boss("fight_green_dragon", "Green dragon", "Iron Hills", 38, "dragon", ("slash", "magic"), ("pierce", "magic"), ("slash", "blunt"), is_boss=False, gold=80, drops=[("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(3, 0.6)]), _boss("fight_blue_dragon", "Blue dragon", "Frostpeak", 48, "dragon", ("slash", "magic"), ("blunt", "pierce"), ("magic", "slash"), is_boss=False, gold=110, drops=[("adamantite_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(4, 0.6)]), _boss("fight_red_dragon", "Red dragon", "Dragon's Spine", 60, "dragon", ("slash", "magic"), ("slash", "magic"), ("pierce", "blunt"), is_boss=False, gold=150, drops=[("runite_ore", 1, 0.25), ("dragon_bones", 1, 1.0), *_boss_drops(5, 0.6)]), _boss("fight_black_dragon", "Black dragon", "Dragon's Spine", 75, "dragon", ("slash", "magic"), ("blunt", "magic"), ("slash", "pierce"), gold=220, drops=[("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_drops(5, 0.8)]), ] ACTIONS = [ *COMBAT_ACTIONS, _action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}), _action("farm_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}, group="Crops"), _action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}, group="Crops"), *[_action(f"grow_{herb}", "farming", f"Grow {name.lower()}", level, seconds, xp, outputs={herb: 1}, group="Herbs") for herb, name, _icon, level, seconds, xp, _value in HERBS], *[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6), {herb: 1, second: 1}, {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}") for potion, name, level, herb, second, skill, _effect in POTIONS], _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("hunt_boar", "hunting", "Hunt boar", 25, 50, 30, outputs={"raw_meat": 2, "boar_hide": 1}), _action("hunt_bear", "hunting", "Hunt bears", 40, 60, 48, outputs={"raw_meat": 3, "bear_pelt": 1}), _action("hunt_elk", "hunting", "Hunt elk", 50, 70, 64, outputs={"raw_meat": 3, "elk_hide": 1}), _action("hunt_sabrecat", "hunting", "Track sabrecats", 65, 80, 84, outputs={"raw_meat": 3, "sabrecat_pelt": 1}), _action("hunt_mammoth", "hunting", "Hunt mammoths", 80, 90, 108, outputs={"raw_meat": 5, "mammoth_hide": 1}), *_tiered_actions(), _action("smith_arrows", "smithing", "Arrows", 5, 30, 10, {"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Copper"), _action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40, {"leather": 4, "iron_bar": 1}, {"studded_body": 1}, group="Leather"), _action("labour", "construction", "Work on the building site", 1, 30, 10, special="labour"), _action("twist_rope", "construction", "Twist rope", 5, 30, 10, {"flax": 3}, {"rope": 1}, group="Supplies"), _action("grow_thatch", "farming", "Cut thatch reeds", 3, 30, 7, outputs={"thatch": 3}, group="Materials"), _action("grow_flax", "farming", "Grow flax", 1, 35, 11, outputs={"flax": 3}, group="Fibres"), _action("boil_glue", "leatherworking", "Boil hide glue", 5, 30, 10, {"hide": 1, "logs": 1}, {"hide_glue": 2}, group="Tanning"), *[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier, [6, 14, 22, 32, 44, 58][tier], {TREES[tier][0]: 2}, {plank: 1}, group="Planks") for tier, (plank, name) in enumerate(PLANKS)], _action("mason_bricks", "construction", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}, group="Masonry"), _action("mason_cut_stone", "construction", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}, group="Masonry"), _action("mason_beam", "construction", "Reinforced beam", 20, 45, 30, {"oak_logs": 2, "iron_nails": 5}, {"beam": 1}, group="Masonry"), _action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}, group="Meals"), _action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}, group="Meals"), _action("cook_stew", "cooking", "Stew", 15, 35, 16, {"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}, group="Meals"), _action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}, group="Rations"), _action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}, group="Rations"), _action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}, group="Rations"), _action("drill_militia", "combat", "Drill the militia", 1, 60, 12, special="drill", group="Frontier"), _action("bury_bones", "prayer", "Bury bones", 1, 10, 4, {"bones": 1}, special="bury", pp=2, group="Bury"), _action("bury_big_bones", "prayer", "Bury big bones", 15, 10, 15, {"big_bones": 1}, special="bury", pp=6, group="Bury"), _action("bury_dragon_bones", "prayer", "Bury dragon bones", 40, 12, 72, {"dragon_bones": 1}, special="bury", pp=20, group="Bury"), _action("offer_bones", "prayer", "Offer bones at the temple", 1, 12, 7, {"bones": 1}, special="offer", devotion=2, city={"temple": 1}, group="Offer at the temple"), _action("offer_big_bones", "prayer", "Offer big bones", 15, 12, 25, {"big_bones": 1}, special="offer", devotion=6, city={"temple": 1}, group="Offer at the temple"), _action("offer_dragon_bones", "prayer", "Offer dragon bones", 40, 14, 110, {"dragon_bones": 1}, special="offer", devotion=20, city={"temple": 2}, group="Offer at the temple"), _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": 300, "bricks": 150, "thatch": 300, "provisions": 80, "copper_rivets": 60}, "advanced": {"oak_logs": 100, "leather": 80, "rope": 40, "iron_nails": 150}, "late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 50, "steel_hinges": 20}, "final": {"maple_logs": 50, "willow_plank": 30, "hard_leather": 60, "mithril_brackets": 15}, "effect": "+20 homes per level", }, "walls": { "name": "Walls", "icon": "๐Ÿฐ", "max": 5, "gold": 300, "base": {"stone": 1000, "bricks": 250, "provisions": 90}, "advanced": {"cut_stone": 150, "beam": 20, "rope": 50, "iron_nails": 200}, "late": {"steel_bar": 60, "oak_plank": 25, "hide_glue": 50, "steel_hinges": 30}, "final": {"mithril_brackets": 60, "maple_plank": 20, "hide_glue": 60, "adamant_chain": 10}, "effect": "+20 defence per level, less plunder lost", }, "barracks": { "name": "Barracks", "icon": "โ›บ", "max": 5, "gold": 300, "base": {"logs": 300, "bricks": 200, "thatch": 200, "leather": 80, "copper_sword": 25, "provisions": 80, "copper_rivets": 60}, "advanced": {"iron_bar": 60, "hard_leather": 40}, "late": {"steel_bar": 40, "oak_plank": 30, "hide_glue": 40}, "final": {"mithril_bar": 25, "willow_plank": 25, "bear_leather": 40}, "effect": "More defence per townsperson", }, "smithy": { "name": "Smithy", "icon": "โš’๏ธ", "max": 5, "gold": 250, "base": {"stone": 400, "logs": 250, "copper_bar": 180, "provisions": 60}, "advanced": {"iron_bar": 180, "leather": 70}, "late": {"steel_bar": 100, "oak_plank": 25, "hide_glue": 40}, "final": {"mithril_bar": 70, "maple_plank": 20, "bear_leather": 35}, "effect": "Smithing 8% faster and one more metal per level", }, "temple": { "name": "Temple", "icon": "โ›ช", "max": 3, "gold": 300, "base": {"stone": 700, "bricks": 200, "cut_stone": 60, "provisions": 60}, "advanced": {"beam": 15, "rope": 40, "iron_nails": 150}, "late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 40, "steel_hinges": 20}, "effect": "Per level: +1 prayer point per bone, +25% devotion", }, "granary": { "name": "Granary", "icon": "๐ŸŒพ", "max": 3, "gold": 150, "base": {"logs": 200, "bricks": 100, "plank": 40, "thatch": 300, "provisions": 50, "copper_rivets": 40}, "advanced": {"cut_stone": 40, "rope": 40, "iron_nails": 100}, "late": {"oak_plank": 35, "hide_glue": 35, "steel_hinges": 15}, "effect": "Guards 25% of food from pests per level", }, "lumber_shed": { "name": "Lumber shed", "icon": "๐Ÿชต", "max": 3, "gold": 120, "base": {"logs": 200, "stone": 250, "plank": 50, "thatch": 180, "provisions": 40, "copper_rivets": 40}, "advanced": {"oak_plank": 40, "rope": 40, "iron_nails": 100}, "late": {"willow_plank": 25, "hide_glue": 35}, "effect": "Guards 25% of wood from rot per level", }, "well": { "name": "Well", "icon": "๐Ÿชฃ", "max": 3, "gold": 120, "base": {"stone": 500, "bricks": 80, "rope": 50, "provisions": 40}, "advanced": {"cut_stone": 40, "leather": 60, "iron_nails": 125}, "late": {"oak_plank": 25, "hide_glue": 35, "steel_hinges": 15}, "effect": "Fire damage -25% per level", }, "spymaster": { "name": "Spymaster's guild", "icon": "๐Ÿ•ต๏ธ", "max": 3, "gold": 400, "base": {"logs": 200, "bricks": 150, "hide": 120, "feathers": 300, "provisions": 60}, "advanced": {"cut_stone": 80, "hard_leather": 40, "iron_nails": 100}, "late": {"bear_leather": 50, "oak_plank": 25, "steel_hinges": 15}, "effect": "+25% recon per level, plus some each hour", }, "storehouse": { "name": "Storehouse", "icon": "๐Ÿ“ฆ", "max": 5, "gold": 200, "base": {"logs": 400, "stone": 800, "thatch": 200, "provisions": 60, "copper_rivets": 60}, "advanced": {"oak_logs": 150, "cut_stone": 100, "rope": 50, "iron_nails": 150}, "late": {"willow_logs": 120, "oak_plank": 45, "hide_glue": 45, "steel_hinges": 25}, "final": {"yew_logs": 40, "yew_plank": 20, "bear_leather": 40, "rune_lock": 6}, "effect": "Per level: +3 bank slots, +250 stack size", }, } # Buildings rise by labour: players working the site, and a slow trickle from # the townsfolk so nothing stalls completely while everyone is away. LABOUR_PER_LEVEL = 360 # a level-1 building: about three hours of one player's work TOWNSFOLK_LABOUR = 0.5 # per person per game hour 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: empty homes draw settlers, fast while many stand empty and slower as # they fill, and families grow a little on their own however full the city is. SETTLERS_PER_EMPTY_HOME = 0.5 FAMILY_GROWTH = 0.05 RAID_GROUPS = ["the Red Hand", "the Hollow Crows", "the Blackwater gang", "the Ashen Wolves", "the Gallows Men", "the Thornback raiders"] DRAGONS = ["a green dragon", "a blue dragon", "an old red dragon", "a young black dragon"] DRAGON_TRAITS = ["it hunts at dusk", "one wing is scarred and weak", "it breathes fire in long sweeps", "it circles twice before it strikes"] 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 = 16 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": {}, "fittings": []} for key in BUILDINGS}, "construction": None, "queue": [], "tier": 0, "weather": {"kind": "clear", "until": now + 6 * HOUR / speed, "next": "rain"}, "effects": [], "problems": [], "hired": 0, "last_tick": now, "food_due": 0.0, "starving": False, "repelled": 0, }, "events": [], "next_event_at": now + 12 * HOUR / speed, "next_minor_at": now + 30 * HOUR / speed, "next_hardship_at": now + 24 * HOUR / speed, "next_fortune_at": now + 6 * HOUR / speed, "next_event_id": 1, "ledger": [], "board": [], "next_message_id": 1, } def migrate(world: dict, now: float | None = None) -> dict: """Bring a saved world up to the current shape. Never loses anything.""" now = now or world["city"]["last_tick"] 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: 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 if world["version"] < 4: _sell_useless_stock(world, now) world["version"] = 4 if world["version"] < 5: for player in world["players"].values(): player["xp"].setdefault("prayer", 0) player.setdefault("pp", 0) player.setdefault("prayers", []) world["city"]["buildings"].setdefault("temple", {"level": 0, "damaged": False, "fund": {}}) world["city"].setdefault("devotion", 0) world["version"] = 5 if world["version"] < 6: for player in world["players"].values(): player["xp"].setdefault("herblore", 0) player.setdefault("potions", {}) world["version"] = 6 if world["version"] < 7: for player in world["players"].values(): player["xp"].setdefault("construction", 0) player["equip"].setdefault("tool_construction", None) work = world["city"]["construction"] if work and "done_at" in work: # The old timer becomes labour, credited for the time already spent. level = world["city"]["buildings"][work["building"]]["level"] needed = labour_needed(level, work["repair"]) share = min(1.0, max(0.0, (now - work["start"]) / max(1.0, work["done_at"] - work["start"]))) del work["done_at"] work.update(labour=needed * share, needed=needed) world["version"] = 7 if world["version"] < 8: city = world["city"] for key in ("granary", "lumber_shed", "well"): city["buildings"].setdefault(key, {"level": 0, "damaged": False, "fund": {}}) city.setdefault("weather", {"kind": "clear", "until": now + 4 * HOUR / speed(world), "next": "rain"}) city.setdefault("effects", []) city.setdefault("problems", []) city.setdefault("hired", 0) world.setdefault("next_minor_at", now + 30 * HOUR / speed(world)) world.setdefault("next_hardship_at", now + 24 * HOUR / speed(world)) world.setdefault("next_fortune_at", now + 8 * HOUR / speed(world)) world["version"] = 8 if world["version"] < 9: # Board messages get ids so their authors can delete them. for number, message in enumerate(world["board"], 1): message["id"] = number world["next_message_id"] = len(world["board"]) + 1 world["version"] = 9 if world["version"] < 10: for building in world["city"]["buildings"].values(): building.setdefault("fittings", []) world["version"] = 10 if world["version"] < 11: _migrate_potions(world) world["version"] = 11 if world["version"] < 12: # Classes became trades: everyone gets a stand-in and one free pick. for player in world["players"].values(): player["cls"] = OLD_CLASSES.get(player["cls"], player["cls"]) player["repick"] = True _return_overfunding(world) world["version"] = 12 if world["version"] < 13: _fold_masonry(world) _return_overfunding(world) world["version"] = 13 if world["version"] < 14: # Shields moved from Smithing to Construction: anyone smithing them stops. for player in world["players"].values(): action = player["action"] if action and (action["id"] not in ACTION_BY_ID or action.get("resume", "") and action["resume"] not in ACTION_BY_ID): stop(player, "Shields are made in Construction now") _return_overfunding(world) world["version"] = 14 if world["version"] < 15: # Raid reports pop up once for everyone; ones from before that don't. latest = last_threat(world) for player in world["players"].values(): player["seen_threat"] = latest["id"] if latest else 0 world["version"] = 15 if world["version"] < 16: _migrate_combat_rework(world) world["version"] = 16 return world # The combat rework: the old area monsters became races, their trophies carry over by # weapon line and tier, dragonhide became the dragons' reagents, and the leathers past # bear are hunted now. RENAMED_ITEMS = { "bat_sinew": "harpy_sinew", "harpy_sinew": "spider_silk", "wisp_essence": "grave_dust", "hill_troll_hide": "lizard_scale", "rock_troll_hide": "orc_tusk", "elder_troll_hide": "dragonkin_scale", "obsidian_core": "frozen_core", "runic_core": "magma_core", "green_dragonhide": "ironhill_lacing", "blue_dragonhide": "frostpeak_lacing", "red_dragonhide": "dragonspine_lacing", "green_dragon_leather": "elk_leather", "blue_dragon_leather": "sabrecat_leather", "red_dragon_leather": "mammoth_leather", **{f"{old}_{suffix}": f"{new}_{suffix}" for old, new in (("green_dhide", "elk_leather"), ("blue_dhide", "sabrecat_leather"), ("red_dhide", "mammoth_leather")) for suffix in ("cap", "legs", "body")}, } RENAMED_ACTIONS = { **{f"fight_{_slug(old)}": f"fight_{_slug(new)}" for old, new in ( ("Clay golem", "Clay golems"), ("Cave troll", "Cave trolls"), ("Will-o'-wisp", "Restless dead"), ("Giant bat", "Harpies"), ("Stone golem", "Stone guardians"), ("Hill troll", "Lizardfolk"), ("Bog wraith", "Bog wraiths"), ("Harpy", "Giant spiders"), ("Iron golem", "Iron golems"), ("Rock troll", "Orcs"), ("Mine ghost", "Mine ghosts"), ("Wyvern", "Wyverns"), ("Obsidian golem", "Ice elementals"), ("Frost troll", "Frost trolls"), ("Banshee", "Banshees"), ("Griffin", "Griffins"), ("Runic golem", "Magma constructs"), ("Elder troll", "Dragonkin"), ("Shade", "Shades"), ("Roc", "Rocs"))}, **{f"tan_{old}_dragon_leather": f"tan_{new}_leather" for old, new in (("green", "elk"), ("blue", "sabrecat"), ("red", "mammoth"))}, **{f"lw_{old}_{suffix}": f"lw_{new}_{suffix}" for old, new in (("green_dhide", "elk_leather"), ("blue_dhide", "sabrecat_leather"), ("red_dhide", "mammoth_leather")) for suffix in ("cap", "legs", "body")}, } def _renamed(stock: dict) -> dict: out = {} for item, qty in stock.items(): new = RENAMED_ITEMS.get(item, item) out[new] = out.get(new, 0) + qty return out def _migrate_combat_rework(world: dict) -> None: city = world["city"] city["stockpile"] = _renamed(city["stockpile"]) for building in city["buildings"].values(): building["fund"] = _renamed(building["fund"]) for player in world["players"].values(): player["xp"].setdefault("tailoring", 0) player["bank"] = _renamed(player["bank"]) for slot, item in list(player["equip"].items()): if item: player["equip"][slot] = RENAMED_ITEMS.get(item, item) action = player["action"] if action: for key in ("id", "resume"): if key in action: action[key] = RENAMED_ACTIONS.get(action[key], action[key]) if action["id"] not in ACTION_BY_ID or action.get("resume", "") and action["resume"] not in ACTION_BY_ID: stop(player, "Combat has changed: pick your fight again") def last_threat(world: dict) -> dict | None: """The most recent raid or dragon attack that has played out.""" return next((e for e in reversed(world["events"]) if e["status"] == "done" and e["kind"] in THREATS and e.get("report")), None) def unseen_threat(world: dict, player: dict) -> dict | None: """The latest raid or dragon attack this player hasn't watched yet, if it happened while they were here.""" event = last_threat(world) if event and event["id"] > player.get("seen_threat", 0) and event["resolved"] >= player["joined"]: return event return None def _migrate_potions(world: dict) -> None: """Potions from before the tiers become their tiered equivalents, uses and all.""" def renamed(stock: dict) -> None: for old, new in OLD_POTIONS.items(): if old in stock: stock[new] = stock.get(new, 0) + stock.pop(old) renamed(world["city"]["stockpile"]) for player in world["players"].values(): renamed(player["bank"]) for active in player["potions"].values(): # regeneration was combat too, so slots still match if active["item"] in OLD_POTIONS: active["item"] = OLD_POTIONS[active["item"]] action = player["action"] if action: for key in ("id", "resume"): old = (action.get(key) or "").removeprefix("brew_") if old in OLD_POTIONS: action[key] = f"brew_{OLD_POTIONS[old]}" def _fold_masonry(world: dict) -> None: """Masonry became part of Construction: its XP, potions and brews carry over.""" moved = {f"masonry_potion_{tier}": f"construction_potion_{tier}" for tier in (1, 2, 3)} def renamed(stock: dict) -> None: for old, new in moved.items(): if old in stock: stock[new] = stock.get(new, 0) + stock.pop(old) renamed(world["city"]["stockpile"]) for player in world["players"].values(): player["xp"]["construction"] = player["xp"].get("construction", 0) + player["xp"].pop("masonry", 0) renamed(player["bank"]) active = player["potions"].pop("masonry", None) if active: item = moved.get(active["item"], active["item"]) if "construction" in player["potions"]: player["bank"][item] = player["bank"].get(item, 0) + 1 # a fresh one back, rather than lose it else: player["potions"]["construction"] = {"item": item, "charges": active["charges"]} action = player["action"] if action: for key in ("id", "resume"): brew = (action.get(key) or "").removeprefix("brew_") if brew in moved: action[key] = f"brew_{moved[brew]}" def _return_overfunding(world: dict) -> None: """After building costs change, anything a fund holds beyond its new target goes back to the stockpile.""" city = world["city"] for key, building in city["buildings"].items(): target = fund_target(world, key) or {} for item, qty in list(building["fund"].items()): extra = qty - target.get(item, 0) if extra <= 0: continue building["fund"][item] = qty - extra if not building["fund"][item]: del building["fund"][item] if item == "gold": city["treasury"] += extra else: city["stockpile"][item] = city["stockpile"].get(item, 0) + extra def _sell_useless_stock(world: dict, now: float) -> None: """The council sells what the city can't use, into the treasury.""" city = world["city"] sold = {item: qty for item, qty in city["stockpile"].items() if item in ITEMS and not city_uses(item)} if not sold: return gold = sum(qty * ITEMS[item]["value"] for item, qty in sold.items()) for item in sold: del city["stockpile"][item] city["treasury"] += gold names = [amount(i, q) for i, q in sold.items()] names = ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else names[0] log(world, now, "city", f"The council sold {names} " f"from the stockpile for {gold:,} gold") 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 fitted(world: dict, fitting: str) -> bool: """Whether a fitting is installed and working (its building isn't damaged).""" key = FITTING_BY_ID[fitting]["building"] return fitting in world["city"]["buildings"][key]["fittings"] and building_level(world, key) > 0 def build_cost(key: str, level: int) -> dict: spec = BUILDINGS[key] cost = {item: qty * level for item, qty in spec["base"].items()} # Later levels add materials from higher tiers: advanced from level 2, late from 3, final from 5. for key, first in (("advanced", 2), ("late", 3), ("final", 5)): for item, qty in spec.get(key, {}).items(): if level >= first: cost[item] = cost.get(item, 0) + qty * (level - first + 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 "pp": 0, "prayers": [], "potions": {}, "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"] + fitted(world, "shelving")) def stack_size(world: dict) -> int: return BASE_STACK + 250 * building_level(world, "storehouse") + 500 * fitted(world, "enchanted_chests") def slots_used(world: dict, player: dict) -> int: """Bank slots taken: an item spills into another slot every full stack.""" stack = stack_size(world) return sum(-(-qty // stack) for qty in player["bank"].values()) def bank_room(world: dict, player: dict, item: str) -> int: """How many more of `item` fit: the rest of its last stack, plus any free slots.""" stack = stack_size(world) have = player["bank"].get(item, 0) partial = -have % stack # room left in the item's last, part-filled stack free = max(0, bank_slots(world, player) - slots_used(world, player)) return partial + free * stack def city_uses(item: str) -> list[str]: """What the city does with an item: the buildings that need it, the granary, the armoury.""" uses = [spec["name"] for spec in BUILDINGS.values() if any(item in spec.get(key, {}) for key in ("base", "advanced", "late", "final"))] spec = ITEMS[item] if "food" in spec: uses.append("the granary") if spec.get("slot") in ("weapon", "shield") or item == "arrows": uses.append("the armoury") if item == "water_barrel": uses.append("the fire brigade") if "fitting" in spec: uses.append(BUILDINGS[spec["fitting"]]["name"]) return uses def bank_add(world: dict, player: dict, item: str, qty: int, now: float, summary: dict) -> int: """Store what fits; the rest goes to the city if it has a use, else is sold. 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" and not city_uses(item): gold = int(spill * sell_price(world, item, now)) player["gold"] += gold summary["sold"][item] = summary["sold"].get(item, 0) + spill summary["sold_gold"] += gold elif spill and player["overflow"] == "city": overflow = summary["overflow"] stockpile = world["city"]["stockpile"] stockpile[item] = stockpile.get(item, 0) + spill gain = spill * ITEMS[item]["value"] player["favour"] += gain * OVERFLOW_FAVOUR 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: """Protection from physical blows: slashing, blunt and piercing.""" return sum(ITEMS[item].get("armour", 0) for item in player["equip"].values() if item) def magic_ward(player: dict) -> int: """Protection from magic.""" return sum(ITEMS[item].get("ward", 0) for item in player["equip"].values() if item) def reduction(points: float) -> float: return points / (points + 25) def damage_reduction(player: dict) -> float: return reduction(armour(player)) def defence_bonus(player: dict) -> int: """What all your armour adds to power and evasion, whatever its weight.""" return round((armour(player) + magic_ward(player)) * 0.4) def combat_power(player: dict) -> int: power = skill_level(player, "combat") + defence_bonus(player) 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, boost: float = 1) -> None: """Heal for the time since HP was last settled.""" if now > player["hp_at"]: top = max_hp(player) boost *= 2 if fitted(world, "enchanted_hearths") else 1 per_second = top * REGEN_PER_HOUR * boost * speed(world) / HOUR player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second) player["hp_at"] = now def weapon_style(player: dict) -> str: weapon = player["equip"].get("weapon") return ITEMS[weapon].get("style", "sword") if weapon else "sword" def affinity(enemy: dict, damage: str) -> float: """How hard a damage type lands on this enemy.""" return VULNERABLE if damage in enemy["vulnerable"] else RESISTANT if damage in enemy["resists"] else 1 def fighter(player: dict, blessed: dict, brew: dict, enemy: dict | None = None) -> dict: """Your side of a fight: power from level and gear (plus prayers and potions) aims and hits, shaped by the weapon's style, your trade and how the enemy takes its damage type. Armour soaks physical blows, ward soaks magic, both help you dodge, and a shield blocks.""" style_id = weapon_style(player) style = STYLES[style_id] power = combat_power(player) + blessed["power"] + brew.get("power", 0) damage = style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1) lands = affinity(enemy, style["damage"]) if enemy else 1 taken = blessed["taken"] * (1 - brew.get("reduce", 0)) return {"style": style_id, "damage": style["damage"], "interval": style["interval"], "accuracy": (10 + 2 * power) * style["accuracy"], "max_hit": max(2, round((2 + power / 3) * damage * lands)), "crit": style["crit"], "first": style.get("first", False), "affinity": lands, "block": BLOCK_CHANCE if player["equip"].get("shield") else 0, "evasion": 10 + 2 * (skill_level(player, "combat") + defence_bonus(player)), "taken": {"physical": (1 - damage_reduction(player)) * taken, "magic": (1 - reduction(magic_ward(player))) * taken}} def _taken(me: dict, damage: str) -> float: return me["taken"]["magic" if damage == "magic" else "physical"] def hit_chance(accuracy: float, evasion: float) -> float: return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15)) def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float: return (1 - crit) * (max_hit + 1) / 2 + crit * max_hit * CRIT_MULT def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict) -> dict: me = fighter(player, blessed, brew, foe) hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"]) kill = foe["hp"] / (hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"]) taken = sum(_taken(me, kind) for kind in foe["attack"]) / len(foe["attack"]) hurt = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * taken / foe["interval"] * kill return {"hit": hit, "their_hit": their_hit, "max_hit": me["max_hit"], "affinity": me["affinity"], "their_max": max(1, round(foe["max_hit"] * max(_taken(me, kind) for kind in foe["attack"]))), "kill_time": kill, "damage": hurt} def fight_odds(player: dict, action: dict) -> dict: """What a fight in this encounter looks like on average, for the cards, and against each variant.""" blessed, brew = blessing(player), potion_effect(player, "combat") variants = action["enemy"]["variants"] rows = [_variant_odds(player, foe, blessed, brew) for foe in variants] total = sum(foe["weight"] for foe in variants) mean = lambda key: sum(row[key] * foe["weight"] for row, foe in zip(rows, variants)) / total return {"hit": round(mean("hit"), 2), "their_hit": round(mean("their_hit"), 2), "style": weapon_style(player), "max_hit": max(row["max_hit"] for row in rows), "their_max": max(row["their_max"] for row in rows), "kill_time": round(mean("kill_time"), 1), "damage": round(mean("damage"), 1), "variants": [{"affinity": row["affinity"], "kill_time": round(row["kill_time"], 1), "damage": round(row["damage"], 1)} for row in rows]} def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict: """Play out one fight swing by swing, both swing timers starting empty. Nothing changes until it's over: the result is applied then.""" variants = action["enemy"]["variants"] foe = rng.choices(variants, [v["weight"] for v in variants])[0] blessed, brew = blessing(player), potion_effect(player, "combat") me = fighter(player, blessed, brew, foe) hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"]) top, hp, foe_hp = max_hp(player), player["hp"], foe["hp"] pantry = {item: player["bank"][item] for item in edible(player)} eaten, events, hurt = {}, [], 0 wind = blessed["wind"] and not (player["action"] or {}).get("wind_used") pace = speed(world) # A bow looses its first arrow before the enemy closes in. mine, theirs, result, t = (0.5 if me["first"] else me["interval"]), foe["interval"], None, 0.0 swings = 0 # an enemy with more than one attack takes turns with them while result is None: t = min(mine, theirs) if t > MAX_FIGHT: result = "fled" break if mine <= theirs: mine += me["interval"] crit = rng.random() < me["crit"] landed = rng.random() < hit dealt = max(1, round(me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))) if landed else 0 foe_hp = max(0, foe_hp - dealt) events.append([round(t / pace, 2), "you", ("crit" if crit else "hit") if landed else "miss", dealt, round(hp), foe_hp, me["damage"]]) if not foe_hp: result = "won" continue theirs += foe["interval"] kind = foe["attack"][swings % len(foe["attack"])] swings += 1 crit = rng.random() < CRIT_CHANCE landed = rng.random() < their_hit if landed and rng.random() < me["block"]: events.append([round(t / pace, 2), "foe", "block", 0, max(0, round(hp)), foe_hp]) continue dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * _taken(me, kind))) \ if landed else 0 hp -= dealt hurt += dealt events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt, max(0, round(hp)), foe_hp, kind]) while hp < top * EAT_BELOW and any(pantry.values()): food = next(item for item, left in pantry.items() if left) pantry[food] -= 1 eaten[food] = eaten.get(food, 0) + 1 hp = min(top, hp + ITEMS[food]["heal"]) events.append([round(t / pace, 2), "you", "eat", ITEMS[food]["heal"], round(hp), foe_hp, food]) beaten, starving = hp <= 0, hp < top * FLEE_BELOW and not any(pantry.values()) if (beaten or starving) and wind: # Second Wind: the retreat that was coming turns into half health, once per outing. wind = False hp = top / 2 events.append([round(t / pace, 2), "you", "wind", 0, round(hp), foe_hp]) elif beaten: hp, result = 1, "lost" elif starving: result = "fled" return {"enemy": foe["name"], "enemy_hp": foe["hp"], "top": top, "start_hp": round(player["hp"]), "xp": foe["xp"], "foe": {key: foe[key] for key in ("archetype", "attack", "vulnerable", "resists")}, "hp": hp, "result": result, "end": round(t / pace, 2), "duration": t / pace, "interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, "style": me["style"], "eaten": eaten, "damage": hurt, "pp": blessed["cost"], "wind_used": blessed["wind"] and not wind and not (player["action"] or {}).get("wind_used")} # --- prayer ------------------------------------------------------------------- # Melvor-style: bones become prayer points, active prayers cost points each # fight and give boosts while they last, and praying in a fight trains Prayer. PRAYERS = [ {"id": "thick_skin", "name": "Thick Skin", "icon": "๐Ÿชจ", "level": 1, "cost": 1, "reduce": 0.10}, {"id": "burst_of_strength", "name": "Burst of Strength", "icon": "๐Ÿ’ช", "level": 5, "cost": 1, "power": 5}, {"id": "rapid_heal", "name": "Rapid Heal", "icon": "๐Ÿ’—", "level": 10, "cost": 1, "regen": 2}, {"id": "steel_skin", "name": "Steel Skin", "icon": "๐Ÿ›ก๏ธ", "level": 20, "cost": 2, "reduce": 0.20}, {"id": "superhuman_strength", "name": "Superhuman Strength", "icon": "๐Ÿฆพ", "level": 30, "cost": 2, "power": 12}, {"id": "second_wind", "name": "Second Wind", "icon": "๐ŸŒฌ๏ธ", "level": 40, "cost": 1, "wind": True}, {"id": "protection", "name": "Protection", "icon": "โœจ", "level": 50, "cost": 3, "reduce": 0.35}, {"id": "piety", "name": "Piety", "icon": "๐Ÿ˜‡", "level": 60, "cost": 4, "power": 20, "reduce": 0.20}, ] PRAYER_BY_ID = {prayer["id"]: prayer for prayer in PRAYERS} MAX_PRAYERS = 2 PRAYER_XP_PER_POINT = 5 NO_BLESSING = {"cost": 0, "power": 0, "taken": 1.0, "regen": 1, "wind": False} def describe_prayer(prayer: dict) -> str: parts = [] if prayer.get("reduce"): parts.append(f"-{round(prayer['reduce'] * 100)}% damage taken") if prayer.get("power"): parts.append(f"+{prayer['power']} power") if prayer.get("regen"): parts.append("HP recovers twice as fast") if prayer.get("wind"): parts.append("once per outing, revive at half HP") return ", ".join(parts) def blessing(player: dict) -> dict: """What the active prayers give this fight, if there are points to pay for them.""" active = [PRAYER_BY_ID[p] for p in player["prayers"] if p in PRAYER_BY_ID] cost = sum(p["cost"] for p in active) if not active or player["pp"] < cost: return NO_BLESSING taken = 1.0 for prayer in active: taken *= 1 - prayer.get("reduce", 0) return {"cost": cost, "power": sum(p.get("power", 0) for p in active), "taken": taken, "regen": max([p.get("regen", 1) for p in active]), "wind": any(p.get("wind") for p in active)} def set_prayers(player: dict, ids: list) -> str | None: ids = list(dict.fromkeys(str(i) for i in ids)) if len(ids) > MAX_PRAYERS: return f"At most {MAX_PRAYERS} prayers at once" for prayer_id in ids: prayer = PRAYER_BY_ID.get(prayer_id) if not prayer: return "No such prayer" if skill_level(player, "prayer") < prayer["level"]: return f"{prayer['name']} needs prayer level {prayer['level']}" player["prayers"] = ids return None def bone_points(world: dict, action: dict) -> int: return action["pp"] + building_level(world, "temple") + fitted(world, "carved_altar") def ward(world: dict) -> float: """Defence the temple's devotion adds to the next raid.""" cap = 15 * building_level(world, "temple") + 15 * fitted(world, "carved_altar") return round(min(world["city"].get("devotion", 0) / 4, cap), 1) 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 action_time(world: dict, player: dict, action: dict) -> float: seconds = action["time"] if action["skill"] in CLASSES[player["cls"]]["fast"]: seconds *= CLASS_SPEED if action["skill"] == "farming" and player["cls"] == "homesteader": # Focus on one plot for 30% faster, or split the same 20% pace between two crops. seconds *= PLOTS_SPEED if two_plots(player) else FOCUS_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") seconds *= 0.9 if fitted(world, "bellows") else 1 if action["id"].startswith("saw_") and fitted(world, "saw_benches"): seconds *= 0.8 seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0) seconds *= skill_mods(world, action)["speed"] return seconds / speed(world) FOCUS_SPEED = 0.7 # a homesteader tending one plot PLOTS_SPEED = 0.8 # each harvest when tending two: together, one crop's worth at 20% faster def two_plots(player: dict) -> bool: current = player["action"] return bool(current and current.get("plot2")) def growing(player: dict) -> dict: """The action the player is on right now: with two plots, harvests alternate between them.""" current = player["action"] if current.get("plot2") and current.get("turn"): return ACTION_BY_ID[current["plot2"]] return ACTION_BY_ID[current["id"]] def plant_second_plot(world: dict, player: dict, action_id: str | None) -> str | None: """Let a homesteader's second plot grow another crop, or clear it.""" current = player["action"] if not action_id: if current: current.pop("plot2", None) current.pop("turn", None) return None if player["cls"] != "homesteader": return "Only homesteaders tend two plots" action = ACTION_BY_ID.get(action_id) if not action or action["skill"] != "farming" or action.get("special"): return "That isn't a crop" if not current or ACTION_BY_ID[current["id"]]["skill"] != "farming" or "left" in current: return "Plant your first plot first" if action_id == current["id"]: return "That's already growing in your first plot" reason = can_start(world, player, action) if reason: return reason current["plot2"] = action_id current["turn"] = 0 return None def potion_effect(player: dict, skill: str) -> dict: active = player.get("potions", {}).get(skill) return ITEMS[active["item"]]["potion"] if active else {} def use_potion(player: dict, item: str) -> str | None: """Drink a potion from the bank into its skill's slot (topping up the same potion).""" spec = ITEMS.get(item, {}).get("potion") if not spec: return "That isn't a potion" if player["bank"].get(item, 0) < 1: return "You don't have one" bank_take(player, item, 1) current = player["potions"].get(spec["skill"]) charges = current["charges"] if current and current["item"] == item else 0 fresh = round(POTION_CHARGES * (1.25 if player["cls"] == "homesteader" else 1)) player["potions"][spec["skill"]] = {"item": item, "charges": charges + fresh} return None def _use_charge(player: dict, skill: str, summary: dict) -> None: active = player["potions"].get(skill) if not active: return active["charges"] -= 1 if active["charges"] > 0: return del player["potions"][skill] if player["bank"].get(active["item"], 0): use_potion(player, active["item"]) summary["potions_drunk"][active["item"]] = summary["potions_drunk"].get(active["item"], 0) + 1 else: summary["potion_out"].append(active["item"]) def upcoming_raid(world: dict) -> dict | None: """The coming major threat: a bandit raid or a dragon.""" for event in world["events"]: if event["status"] == "upcoming" and event["kind"] in ("raid", "dragon"): 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 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["kind"] != "raid": return "A dragon has no camp to sabotage" if raid["sabotage"] >= 0.3: return "The camp is already in ruins" if action.get("special") == "labour" and not world["city"]["construction"]: return "Nothing is being built" if action.get("special") == "problem" and not active_problem(world, action["problem"]): return "That's been dealt with" blocked = skill_mods(world, action)["blocked"] if blocked: return blocked if action.get("special") == "drill": threat = upcoming_raid(world) if not threat: return "No raid to prepare for" if threat.get("drilled", 0) >= drill_needed(threat): return "The militia is as drilled as it can be" if "enemy" in action 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: brew = potion_effect(player, action["skill"]) if action["inputs"] and rng.random() < brew.get("keep", 0): summary["kept"] += 1 else: for item, qty in action["inputs"].items(): bank_take(player, item, qty) xp = action["xp"] cls = CLASSES[player["cls"]] lucky = bool(action["outputs"]) and ( action["skill"] in cls["craft"] or (player["cls"] == "miner" and action["skill"] == "mining") ) 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 brew.get("double") and rng.random() < brew["double"]: for item, qty in action["outputs"].items(): outputs[item] += qty summary["lucky"] += 1 mods = skill_mods(world, action) if mods["yield"] != 1: for item, qty in list(outputs.items()): exact = qty * mods["yield"] outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0) xp *= mods["xp"] if "enemy" in action: outing = player["action"] fight = outing.pop("fight", None) or simulate_fight(world, player, action, rng) outing.pop("carry", None) # from a short-lived version that ran the swing timer on xp = fight.get("xp", action["xp"]) * mods["xp"] # the variant you met if player["prayers"] and not fight["pp"]: summary["prayer_out"] = True paid = min(player["pp"], fight["pp"]) if paid: player["pp"] -= paid summary["pp_spent"] += paid gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary) for food, count in fight["eaten"].items(): count = min(count, player["bank"].get(food, 0)) if count: bank_take(player, food, count) summary["eaten"][food] = summary["eaten"].get(food, 0) + count player["hp"], player["hp_at"] = fight["hp"], now summary["damage"] += fight["damage"] if fight["wind_used"]: outing["wind_used"] = True summary["second_wind"] += 1 if fight["result"] != "won": # Beaten or out of food: fall back with nothing but your life. 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 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) if action.get("special") == "problem": _work_problem(world, player, action, now) if action.get("special") == "recon": event = upcoming_raid(world) # Once a threat is fully scouted, scouting only trains the skill (and keeps the informant's leads). if event and event["recon"] < intel_costs(event)[-1]: city = world["city"] gain = recon_gain(world, player, action) # An informant's leads double the reconnaissance (and the XP) of whoever follows them up. boost = min(city.get("recon_boost", 0), gain) city["recon_boost"] = city.get("recon_boost", 0) - boost xp *= 1 + boost / gain event["recon"] = min(intel_costs(event)[-1], event["recon"] + gain + boost) if action.get("special") == "problem" and action["skill"] != "combat": _work_problem(world, player, action, now) threat = upcoming_raid(world) if action.get("special") == "drill" else None if threat: city = world["city"] gain = drill_per_action(player) # A drill sergeant doubles the drilling (and the XP) of whoever does it while the boost lasts. boost = min(city.get("drill_boost", 0), gain) city["drill_boost"] = city.get("drill_boost", 0) - boost xp = DRILL_XP * (gain + boost) * mods["xp"] threat["drilled"] = min(drill_needed(threat), threat.get("drilled", 0) + gain + boost) if action.get("special") == "labour": city = world["city"] site = city["construction"] gain = labour_per_action(player) # A master builder doubles the labour (and the XP) of whoever works while the boost lasts. boost = min(city.get("site_boost", 0), gain) city["site_boost"] = city.get("site_boost", 0) - boost xp *= 1 + boost / gain site["labour"] += gain + boost summary["labour"] += gain + boost if site["labour"] >= site["needed"]: _finish_construction(world, now) _start_next(world, now) bonus = 1 + brew.get("bones", 0) if action.get("special") == "bury": player["pp"] += bone_points(world, action) * bonus summary["pp_gained"] += bone_points(world, action) * bonus if action.get("special") == "offer": city = world["city"] city["devotion"] = city.get("devotion", 0) + action["devotion"] * (1 + 0.25 * building_level(world, "temple")) * bonus if action.get("special") != "drill": _use_charge(player, action["skill"], summary) for item, qty in outputs.items(): kept = bank_add(world, player, item, qty, now, summary) 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, "sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0, "kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 0} 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 limit = min(now, player["last_active"] + OFFLINE_CAP / speed(world)) while player["action"]: action = growing(player) if current.get("plot2") and can_start(world, player, action): # The second crop can't grow right now (a problem, too low a level): tend the first only. current["turn"] = 0 action = growing(player) if "enemy" in action and not can_start(world, player, action): if not current.get("fight"): regen(world, player, current["start"], blessing(player)["regen"]) current["fight"] = simulate_fight(world, player, action, rng) duration = current["fight"]["duration"] else: 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"): if blessing(player)["wind"] and not current.get("wind_used"): # Second Wind: the retreat that was coming turns into half health. current["wind_used"] = True player["hp"] = max_hp(player) / 2 summary["second_wind"] += 1 reason = None else: 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 "enemy" in action and player["action"] is current: current["start"] += RESPAWN / speed(world) # a moment before the next enemy turns up if current.get("plot2"): current["turn"] = 1 - current.get("turn", 0) if player["action"] is current and "left" in current: current["left"] -= 1 if current["left"] <= 0: back = current.get("resume") if back: plot2 = current.get("resume_plot2") player["action"] = current = {"id": back, "start": current["start"]} if plot2: current.update(plot2=plot2, turn=0) 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} current = player["action"] if once: back = current and (current.get("resume") or current["id"]) entry["left"] = 1 if back and back != action_id: entry["resume"] = back plot2 = current.get("resume_plot2") or current.get("plot2") if plot2: entry["resume_plot2"] = plot2 elif current and current.get("plot2") and current["plot2"] != action_id and action["skill"] == "farming": # Replanting the first plot leaves the second one growing. entry.update(plot2=current["plot2"], turn=0) 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 not city_uses(item): return "The city has no use for that" 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 * city_rate(world, "favour") 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 * city_rate(world, "favour") 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 install(world: dict, player: dict, fitting: str, source: str, now: float) -> str | None: """Fit a building with a fitting from the player's bank (earns favour) or the stockpile.""" spec = FITTING_BY_ID.get(fitting) if not spec: return "No such fitting" city = world["city"] building = city["buildings"][spec["building"]] name = BUILDINGS[spec["building"]]["name"].lower() if fitting in building["fittings"]: return f"The {name} already {'have' if name.endswith('s') else 'has'} that" if building["level"] < spec["level"]: return f"Needs {BUILDINGS[spec['building']]['name']} level {spec['level']}" if source == "bank": if not player["bank"].get(fitting): return "You don't have one" bank_take(player, fitting, 1) gain = ITEMS[fitting]["value"] player["favour"] += gain * city_rate(world, "favour") player["donated"] += gain elif source == "city": if not city["stockpile"].get(fitting): return "There isn't one in the stockpile" take_stock(city, fitting, 1) else: return "Bad source" building["fittings"].append(fitting) log(world, now, "build", f"{player['name']} fitted the {name} with {spec['name'].lower()}", player["id"]) 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"] city["boosts_bought"] = 0 # master builders cost the base price again on a new site city["construction"] = {"building": key, "repair": repair, "start": now, "labour": 0.0, "needed": labour_needed(building["level"], repair)} verb = "Repairs to" if repair else "Work on" log(world, now, "build", f"{verb} the {BUILDINGS[key]['name'].lower()} began") def growth_per_day(world: dict) -> float: """New townsfolk a day while there's room: settlers for the homes, plus families.""" pop = world["city"]["population"] growth = SETTLERS_PER_EMPTY_HOME * max(0.0, housing(world) - pop) + FAMILY_GROWTH * pop return growth * city_rate(world, "growth") * (1.25 if fitted(world, "furniture") else 1) 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 = growth_per_day(world) 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 best_kit(stock: dict, slot: str, stat: str, people: int) -> float: """Total `stat` when the best `slot` items in the stockpile go to `people` defenders.""" total = 0 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]) total += take * ITEMS[item].get(stat, 0) people -= take if not people: break return total def kit_count(stock: dict, slot: str) -> int: return sum(qty for item, qty in stock.items() if ITEMS[item].get("slot") == slot) def wear_share(ratio: float) -> float: """Share of the issued swords and shields a fight breaks: 10% in an easy win, 30% in a close one, 40% in defeat.""" if ratio < 1: return 0.4 return 0.1 + 0.2 * max(0.0, 2 - ratio) def wear_kit(world: dict, share: float, rng: random.Random) -> dict: """Break `share` of the swords and shields handed to defenders, worst first. Spares are untouched.""" stock = world["city"]["stockpile"] broken = {} for slot, stat in (("weapon", "power"), ("shield", "armour")): people = int(world["city"]["population"] // 2) issued = [] 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)) people -= take if not people: break breaks = int(sum(take for _item, take in issued) * share + rng.random()) for item, take in reversed(issued): lost = min(breaks, take) if lost: take_stock_qty(stock, item, lost) broken[item] = lost breaks -= lost return broken def _wear_lines(broken: dict, arrows: int) -> list[str]: lines = [] if arrows: lines.append(f"The defenders used {amount('arrows', arrows)}.") if broken: lines.append(f"{upper_first(_listing(broken))} {'was' if sum(broken.values()) == 1 else 'were'} broken in the fighting.") return lines def equip_problem(player: dict, item: str) -> str | None: """Why the player can't wear or use this yet, or None.""" weapon = player["equip"].get("weapon") if ITEMS[item].get("slot") == "shield" and weapon and ITEMS[weapon].get("two_handed"): return f"Your {ITEMS[weapon]['name'].lower()} takes both hands" req = ITEMS[item].get("req") if req and skill_level(player, req[0]) < req[1]: name = next(s["name"] for s in SKILLS if s["id"] == req[0]) return f"Needs {name.lower()} level {req[1]}" return None 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) 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), "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), "drilled": round(drilled(world), 1), "rallied": city.get("rally", 0), # favour bought before drilling existed; gone after the next raid "ward": ward(world), "mercenaries": city.get("hired", 0), "garrison": 0, } garrison = [] for player in world["players"].values(): if player["garrison"]: power = combat_power(player) 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 _happenings_hour(world, city["last_tick"], rng) _city_hour(world, city["last_tick"]) changed = True changed = advance_weather(world, now, rng) or changed changed = _happenings_tracks(world, now, rng) or changed construction = city["construction"] if construction and construction["labour"] >= construction["needed"]: _finish_construction(world, now) _start_next(world, now) 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"] if city["construction"]: city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR threat = upcoming_raid(world) if threat and building_level(world, "spymaster"): threat["recon"] = min(intel_costs(threat)[-1], threat["recon"] + SPYMASTER_AGENTS * building_level(world, "spymaster")) city["food_due"] += (pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1) * (0.9 if fitted(world, "grain_bins") else 1)) 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 + growth_per_day(world) / 24) elif pop > room: city["population"] = max(room, pop - pop * 0.05 / 24) city["treasury"] += pop * 0.5 / 24 def labour_needed(level: int, repair: bool) -> float: """Labour to raise a building from `level`, or to repair it at that level.""" if repair: return LABOUR_PER_LEVEL * max(1, level) * 0.25 return LABOUR_PER_LEVEL * (level + 1) def labour_per_action(player: dict) -> float: # One more labour every 20 levels, and one more for a builder. return 1 + skill_level(player, "construction") // 20 + (player["cls"] == "builder") 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 any(e["kind"] in ("raid", "dragon") for e in world["events"]) day = (now - world["created"]) * speed(world) / DAY # The major-threat track: mostly bandits, sometimes (after the first days) a dragon. kind = "dragon" if not first and day >= 5 and rng.random() < 0.15 else "raid" 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 # 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"]) if kind == "dragon": event.update({ "fire_at": now + rng.uniform(30, 54) * hours, "strength": round(strength * 1.5 * rng.uniform(0.9, 1.15), 1), "group": rng.choice(DRAGONS), "trait": rng.choice(DRAGON_TRAITS), }) log(world, now, "event", "Herders report scorched pastures in the hills") elif kind == "raid": 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") world["events"].append(event) del world["events"][:-30] # Intel: each level takes more reconnaissance than the last, a bigger band is # harder to read (a dragon is hard to miss), and the sharper levels only become # knowable as the threat draws near, however much scouting is done. INTEL_POINTS = [15, 30, 45] # recon for intel levels 1, 2 and 3 against a small band INTEL_SIZE = [1.0, 1.33, 1.67, 2.0] # small, medium, large, huge INTEL_OPENS = [None, 24, 8] # game hours before arrival that levels 2 and 3 open, ยฑ25% per threat SPYMASTER_RECON = 0.25 # more recon per guild level SPYMASTER_AGENTS = 0.5 # recon per guild level per game hour, with nobody scouting INFORMANT_RECON = 30 # boosted recon per informant: doubled recon and XP for the scout who works it 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) 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) 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)] def intel_opens(world: dict, event: dict, level: int) -> float: """When an intel level becomes knowable. The ยฑ25% is fixed per threat, so it can't be timed.""" hours = INTEL_OPENS[level - 1] if hours is None: return 0 wobble = random.Random(f"{event['id']}-{level}").uniform(0.75, 1.25) return event["fire_at"] - hours * wobble * HOUR / speed(world) def intel_level(world: dict, event: dict, now: float | None = None) -> int: now = world["city"]["last_tick"] if now is None else now level = 0 for next_level, need in enumerate(intel_costs(event), 1): if event["recon"] < need or now < intel_opens(world, event, next_level): break level = next_level return level SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")] WINDOW_HOURS = [24, 8, 2, 0] THREATS = ("raid", "dragon") GATES_WARNING = 300 # seconds of warning before a threat strikes 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, now) half = WINDOW_HOURS[level] * HOUR / speed(world) costs = intel_costs(event) if "strength" in event else [0, 0, 0] centre = event["fire_at"] + event["jitter"] * half * 0.6 # In the last few minutes the sentries see them coming, whatever the intel. gates = event["kind"] in THREATS and event["fire_at"] - now <= GATES_WARNING / speed(world) view = { "id": event["id"], "kind": event["kind"], "intel": level, "at_gates": gates, "earliest": max(now, centre - half), "latest": max(now, centre + half), "exact": event["fire_at"] if level >= 3 or gates else None, "recon": int(min(event["recon"], costs[-1])), "recon_full": costs[-1], "recon_next": max(0, round(costs[level] - event["recon"])) if level < 3 else 0, } if event["kind"] == "dragon": strength = event["strength"] 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) 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'])} is coming for the city." if level >= 3: view["strength"] = round(strength) view["trait"] = event["trait"] view["requests"] = council_requests(world, event) if gates: view["headline"] = "The dragon is overhead!" elif 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) if gates: view["headline"] = "Raiders at the gates!" elif event["kind"] == "merchant": view["headline"] = "A merchant caravan is on its way. Sell prices +50% 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") >= 1: wants.append(("iron_shield", min(armed, math.ceil(strength / 10)))) requests = [] for item, qty in wants: have = stock.get(item, 0) if item in ("copper_sword", "iron_shield"): have = kit_count(stock, ITEMS[item]["slot"]) if item == "bread": have = sum(stock.get(food, 0) * ITEMS[food]["food"] for food in FOOD_ORDER) label = {"copper_sword": "Swords (any)", "iron_shield": "Shields (any)", "bread": "Food (any)"}[item] \ if item in ("copper_sword", "iron_shield", "bread") else ITEMS[item]["name"] requests.append({"item": item, "label": label, "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"] == "dragon": _resolve_dragon(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 broken = wear_kit(world, wear_share(ratio), rng) arrows = _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.") lines.extend(_wear_lines(broken, arrows)) 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) broken = wear_kit(world, wear_share(ratio), rng) 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 arrows = _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.") lines.extend(_wear_lines(broken, arrows)) if gold or taken: stolen = ", ".join(amount(item, qty) for item, qty in taken.items()) loot = [f"{gold:,} gold"] if gold else [] lines.append("The raiders carried off " + " and ".join(loot + ([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 city["rally"] = city["rallies"] = 0 city["devotion"] = 0 city["hired"] = 0 def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> None: city = world["city"] stock = city["stockpile"] attack = event["strength"] guard = defence(world) garrison = guard["garrison"] report = {"group": event["group"], "attack": round(attack), "defence": guard["total"], "parts": guard["parts"], "garrison": garrison, "icon": "๐Ÿ‰"} lines = [] if guard["total"] >= attack: city["repelled"] += 1 city["treasury"] += round(attack) broken = wear_kit(world, min(0.5, wear_share(guard["total"] / attack) * 1.25), rng) arrows = _use_up(stock, "arrows", 0.5) report["title"] = f"Victory! {upper_first(event['group'])} was driven off" lines.append(f"It fled wounded, dropping a hoard worth {round(attack):,} gold.") lines.extend(_wear_lines(broken, arrows)) for entry in garrison: player = world["players"][entry["id"]] player["favour"] += 40 gain_xp(world, player, "combat", round(attack * 6), 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()) if garrison: lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and Ironhill reagents.") log(world, now, "raid", report["title"]) else: severity = min(1.0, 1 - guard["total"] / attack) broken = wear_kit(world, 0.5, rng) lost = int(city["population"] * (0.05 + 0.15 * severity)) city["population"] = max(5.0, city["population"] - lost) share = min(0.8, 0.2 + 0.4 * severity) burned = _take_share(stock, WOOD, share * (1 - 0.25 * building_level(world, "lumber_shed"))) burned.update(_take_share(stock, _food_items(stock), share * (1 - 0.25 * building_level(world, "granary")))) name = _damage_building(world, now, rng) arrows = _use_up(stock, "arrows", 0.7) report["title"] = f"{upper_first(event['group'])} set the city ablaze" lines.append(f"{lost} townsfolk were lost in the flames.") lines.extend(_wear_lines(broken, arrows)) if burned: lines.append(f"It burned {_listing(burned)}.") 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) log(world, now, "raid", report["title"]) report["lines"] = lines event["report"] = report for player in world["players"].values(): player["garrison"] = False city["rally"] = city["rallies"] = 0 city["devotion"] = 0 city["hired"] = 0 def _work_problem(world: dict, player: dict, action: dict, now: float) -> None: problem = active_problem(world, action["problem"]) if not problem: return problem["work"] += 1 if problem["work"] >= PROBLEMS[problem["kind"]]["work"]: _solve(world, problem, now, player["name"]) def _use_up(stock: dict, item: str, share: float) -> int: used = int(stock.get(item, 0) * share) if used: take_stock_qty(stock, item, used) return used # --- happenings: weather, seasons, hardships, fortunes, minor threats --------- # Raids and dragons keep their own announced rhythm (the major-threat track # above). Three more tracks run beside it on their own cooldowns, and the # weather rolls every few hours, leaning towards whatever the season favours. SEASONS = [("Spring", "๐ŸŒฑ"), ("Summer", "โ˜€๏ธ"), ("Autumn", "๐Ÿ‚"), ("Winter", "โ„๏ธ")] SEASON_DAYS = 7 # `speed` multiplies action time (below 1 is faster), `yield` multiplies output, # `food` multiplies what the townsfolk eat, `firewood` is logs per person per hour. WEATHER = { "clear": {"name": "Clear skies", "icon": "๐ŸŒค๏ธ", "text": "No effects"}, "rain": {"name": "Rain", "icon": "๐ŸŒง๏ธ", "speed": {"farming": 0.9}, "text": "Farming 10% faster"}, "heat": {"name": "Heat wave", "icon": "๐Ÿฅต", "speed": {"farming": 0.85}, "food": 1.15, "text": "Farming 15% faster, city eats 15% more"}, "storm": {"name": "Storm", "icon": "โ›ˆ๏ธ", "speed": {"fishing": 1.25}, "text": "Fishing 25% slower"}, "fog": {"name": "Fog", "icon": "๐ŸŒซ๏ธ", "speed": {"reconnaissance": 1.3, "hunting": 1.1}, "text": "Scouting 30% and hunting 10% slower"}, "snow": {"name": "Snow", "icon": "๐ŸŒจ๏ธ", "speed": {"hunting": 1.2}, "firewood": 0.05, "text": "Hunting 20% slower, burns firewood"}, } SEASON_WEATHER = [ {"clear": 35, "rain": 40, "storm": 10, "fog": 15}, {"clear": 45, "heat": 30, "rain": 15, "storm": 10}, {"clear": 30, "rain": 30, "storm": 25, "fog": 15}, {"clear": 30, "snow": 45, "fog": 15, "storm": 10}, ] FIREWOOD = ("logs", "oak_logs", "willow_logs", "maple_logs", "yew_logs", "magic_logs") WOOD = FIREWOOD + tuple(plank for plank, _name in PLANKS) # Minor threats: they slow or block a skill until someone deals with them, # either by working a special action (`work` completions) or by donating (`need`). PROBLEMS = { "collapse": {"name": "Mine collapse", "icon": "โ›ฐ๏ธ", "need": {"plank": 30}, "block": {"mining": "Mine collapsed"}, "allow": ["mine_stone"], "text": "No ore until it's shored up with planks."}, "wolves": {"name": "Wolves at the farms", "icon": "๐Ÿบ", "yield": {"farming": 0.5}, "work": 10, "action": "clear_wolves", "text": "Farming yield halved until fighters drive them off."}, "bear": {"name": "Bear in the woods", "icon": "๐Ÿป", "speed": {"woodcutting": 1.5}, "work": 8, "action": "drive_bear", "text": "Woodcutting much slower until hunters drive it off."}, "goblins": {"name": "Goblin thieves", "icon": "๐Ÿ‘บ", "steal": 0.01, "work": 10, "action": "catch_goblins", "text": "Steal from the stockpile hourly until fighters catch them."}, "rockslide": {"name": "Rockslide on the quarry road", "icon": "๐Ÿชจ", "work": 25, "action": "clear_rockslide", "block": {"mining": "Quarry road blocked"}, "only": ["mine_stone"], "text": "No stone until builders clear it."}, "flood": {"name": "Flooded fishing grounds", "icon": "๐ŸŒŠ", "speed": {"fishing": 1.5}, "need": {"plank": 20}, "lasts": 36, "text": "Fishing slow for 36h, or until dammed with planks."}, "boars": {"name": "Boars trampling the crops", "icon": "๐Ÿ—", "yield": {"farming": 0.6}, "work": 10, "action": "cull_boars", "text": "Farming yield down until hunters cull them."}, "rats": {"name": "Rats in the granary", "icon": "๐Ÿ€", "steal_food": 0.03, "work": 12, "action": "catch_rats", "text": "Eat city food hourly until hunters catch them."}, } MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars") PROBLEM_ACTIONS = [ _encounter("clear_wolves", "Wolf pack", "Frontier", 4, WOLVES, label="Drive off the wolves", special="problem", problem="wolves"), _encounter("catch_goblins", "Goblin thieves", "Frontier", 5, GOBLINS, label="Catch the goblin thieves", special="problem", problem="goblins"), _action("drive_bear", "hunting", "Drive off the bear", 1, 45, 14, special="problem", problem="bear"), _action("cull_boars", "hunting", "Cull the boar herd", 1, 45, 14, special="problem", problem="boars"), _action("catch_rats", "hunting", "Catch the rats", 1, 30, 8, special="problem", problem="rats"), _action("clear_rockslide", "construction", "Clear the rockslide", 1, 30, 10, special="problem", problem="rockslide"), ] ACTIONS.extend(PROBLEM_ACTIONS) ACTION_BY_ID.update({action["id"]: action for action in PROBLEM_ACTIONS}) ITEMS["water_barrel"] = {"name": "Water barrel", "icon": "๐Ÿ›ข๏ธ", "value": 20} _barrel = _action("cooper_barrel", "construction", "Water barrel", 10, 35, 18, {"oak_plank": 2, "iron_nails": 4}, {"water_barrel": 1}, group="Supplies") ACTIONS.append(_barrel) ACTION_BY_ID[_barrel["id"]] = _barrel # Fittings: Construction makes them, mostly from the better planks, and each # building takes one of each once it's high enough. A fitting works while its # building stands; damage pauses it like the rest of the building. # id, name, building, building level, construction level, inputs, effect, glyph, tint FITTINGS = [ ("grain_bins", "Grain bins", "granary", 1, 15, {"oak_plank": 20, "iron_nails": 25}, "City eats 10% less", "grain-bundle", "#e3c060"), ("saw_benches", "Saw benches", "lumber_shed", 1, 15, {"oak_plank": 20, "iron_nails": 40}, "Planks 20% faster", "hand-saw", "#8f5e30"), ("shelving", "Shelving", "storehouse", 2, 20, {"oak_plank": 40, "iron_nails": 40}, "+1 bank slot", "bookshelf", "#8f5e30"), ("map_table", "Map table", "spymaster", 1, 30, {"willow_plank": 25, "hard_leather": 10, "steel_hinges": 4}, "+1 recon per scout", "treasure-map", "#a3b86c"), ("well_pump", "Well pump", "well", 2, 30, {"willow_plank": 25, "steel_hinges": 10}, "Stops half of all fires", "water-tank", "#5aa0d8"), ("furniture", "Furniture", "houses", 2, 35, {"willow_plank": 40, "leather": 30, "steel_hinges": 10}, "Population grows 25% faster", "sofa", "#a3b86c"), ("weapon_racks", "Weapon racks", "barracks", 2, 40, {"willow_plank": 30, "steel_hinges": 16}, "Militia defence +50%", "spears", "#d4dae2"), ("hoardings", "Hoardings", "walls", 3, 45, {"maple_plank": 40, "mithril_brackets": 20}, "+25 defence", "palisade", "#d0663a"), ("bellows", "Great bellows", "smithy", 3, 50, {"maple_plank": 30, "bear_leather": 15, "mithril_brackets": 6}, "Smithing 10% faster", "bellows", "#d0663a"), ("carved_altar", "Carved altar", "temple", 2, 60, {"yew_plank": 30, "cut_stone": 60, "adamant_chain": 4}, "+1 prayer point per bone, ward +15", "star-altar", "#6f9a4a"), ("enchanted_chests", "Enchanted chests", "storehouse", 4, 75, {"magic_plank": 40, "rune_lock": 6}, "+500 stack size", "open-treasure-chest", "#a77be8"), ("enchanted_hearths", "Enchanted hearths", "houses", 4, 80, {"magic_plank": 50, "mammoth_leather": 5, "adamant_chain": 6}, "HP recovers twice as fast", "fireplace", "#a77be8"), ] # Builders' shields, homesteaders' staves and rangers' bows, after each skill's other tabs. for _weapon in _weapon_actions(): ACTIONS.append(_weapon) ACTION_BY_ID[_weapon["id"]] = _weapon FITTING_BY_ID = {} for _id, _name, _building, _blevel, _level, _inputs, _effect, _glyph, _tint in FITTINGS: FITTING_BY_ID[_id] = {"id": _id, "name": _name, "building": _building, "level": _blevel, "effect": _effect} ITEMS[_id] = {"name": _name, "plural": _name, "icon": "๐Ÿชš", "value": sum(ITEMS[i]["value"] * q for i, q in _inputs.items()), "fitting": _building} _fitting = _action(f"make_{_id}", "construction", _name, _level, 120 + _level * 2, 40 + _level * 4, _inputs, {_id: 1}, group="Fittings") ACTIONS.append(_fitting) ACTION_BY_ID[_fitting["id"]] = _fitting ITEMS[_id].update(glyph=_glyph, tint=_tint) # (weights by season, spring to winter) HARDSHIPS = {"sickness": [2, 1, 2, 3], "locusts": [1, 3, 2, 0], "rot": [3, 1, 3, 1], "storm": [1, 1, 3, 1], "rats": [2, 2, 2, 2], "fire": [1, 2, 1, 2], "winter": [0, 0, 0, 4], "drought": [0, 4, 1, 0]} FORTUNES = {"merchant": [12] * 4, "refugees": [10] * 4, "master": [16] * 4, "harvest": [10, 10, 12, 0], "rich_vein": [10] * 4, "salmon": [10, 8, 4, 0], "festival": [8] * 4, "noble": [8] * 4, "pilgrims": [8] * 4, "mercenaries": [6] * 4, "cargo": [10] * 4} TRACK_HOURS = {"minor": (48, 96), "hardship": (48, 96), "fortune": (36, 72)} def calendar_day(world: dict, now: float) -> int: """Days since the world began, counting midnights in the server's timezone (0 on the first day). A sped-up dev world has no calendar to speak of, so it counts 24-hour stretches instead. """ if speed(world) != 1: return int((now - world["created"]) * speed(world) // DAY) return (date.fromtimestamp(now) - date.fromtimestamp(world["created"])).days def season(world: dict, now: float) -> dict: day = calendar_day(world, now) index = day // SEASON_DAYS % len(SEASONS) name, icon = SEASONS[index] return {"index": index, "name": name, "icon": icon, "day": day % SEASON_DAYS + 1} def _pick(weights: dict, rng: random.Random) -> str: keys = [key for key, weight in weights.items() if weight > 0] return rng.choices(keys, [weights[key] for key in keys])[0] def _roll_weather(world: dict, now: float, rng: random.Random, current: str | None = None) -> str: if current and rng.random() < 0.4: return current return _pick(SEASON_WEATHER[season(world, now)["index"]], rng) def advance_weather(world: dict, now: float, rng: random.Random) -> bool: """Change the weather when its spell ends. Returns True if it changed.""" weather = world["city"]["weather"] if now < weather["until"]: return False while now >= weather["until"]: weather["kind"] = weather["next"] _plan_spell(world, weather, rng) return True def _plan_spell(world: dict, weather: dict, rng: random.Random) -> None: """Set how long the current weather lasts and what follows. Rolls that keep the same weather lengthen the spell, so `until` is when it really changes.""" for _ in range(3): weather["until"] += rng.uniform(4, 8) * HOUR / speed(world) weather["next"] = _roll_weather(world, weather["until"], rng, weather["kind"]) if weather["next"] != weather["kind"]: return # Three spells of the same is long enough: something else comes next. weights = SEASON_WEATHER[season(world, weather["until"])["index"]] weather["next"] = _pick({kind: w for kind, w in weights.items() if kind != weather["kind"]}, rng) def _modifier_sources(world: dict) -> list[dict]: city = world["city"] sources = [WEATHER[city["weather"]["kind"]]] + city["effects"] sources += [PROBLEMS[problem["kind"]] for problem in city["problems"]] return sources def skill_mods(world: dict, action: dict) -> dict: """How weather, happenings and problems change this action right now.""" skill = action["skill"] mods = {"speed": 1.0, "yield": 1.0, "xp": 1.0, "blocked": None} for source in _modifier_sources(world): mods["speed"] *= source.get("speed", {}).get(skill, 1) mods["yield"] *= source.get("yield", {}).get(skill, 1) mods["xp"] *= source.get("xp", {}).get(skill, 1) if action.get("special") != "problem": for problem in world["city"]["problems"]: spec = PROBLEMS[problem["kind"]] reason = spec.get("block", {}).get(skill) if reason and (action["id"] in spec.get("only", [action["id"]])) and action["id"] not in spec.get("allow", []): mods["blocked"] = reason return mods def city_rate(world: dict, key: str) -> float: """The product of a city-wide factor (food, growth, favour) across everything active.""" rate = 1.0 for source in _modifier_sources(world): rate *= source.get(key, 1) return rate def active_problem(world: dict, kind: str) -> dict | None: return next((p for p in world["city"]["problems"] if p["kind"] == kind), None) def _record(world: dict, now: float, track: str, kind: str, icon: str, title: str, lines: list[str]) -> None: """A happening for the history list and the ledger.""" world["events"].append({"id": world["next_event_id"], "kind": kind, "track": track, "status": "done", "announced": now, "fire_at": now, "resolved": now, "report": {"title": title, "icon": icon, "lines": lines}}) world["next_event_id"] += 1 del world["events"][:-60] log(world, now, track, title) def _add_effect(world: dict, now: float, hours: float, **effect) -> None: world["city"]["effects"].append({**effect, "until": now + hours * HOUR / speed(world)}) def _take_share(stock: dict, items, share: float) -> dict: taken = {} for item in [i for i in items if stock.get(i)]: gone = int(stock[item] * share) if gone: taken[item] = gone take_stock_qty(stock, item, gone) return taken def take_stock_qty(stock: dict, item: str, qty: int) -> None: stock[item] -= qty if stock[item] <= 0: del stock[item] def _listing(taken: dict) -> str: names = [amount(item, qty) for item, qty in taken.items()] return ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else (names[0] if names else "nothing") def _damage_building(world: dict, now: float, rng: random.Random) -> str | None: city = world["city"] built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]] if not built: return None key = rng.choice(built) city["buildings"][key]["damaged"] = True _queue_if_funded(world, key, now) return BUILDINGS[key]["name"] def _food_items(stock: dict) -> list[str]: return [item for item in stock if "food" in ITEMS[item] or item in ("wheat", "vegetables")] def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None: city = world["city"] stock = city["stockpile"] weights = {kind: w[season(world, now)["index"]] for kind, w in HARDSHIPS.items()} if city["weather"]["kind"] == "storm": weights["storm"] += 6 if city["weather"]["kind"] == "rain": weights["rot"] += 3 if active_problem(world, "rats"): weights["rats"] = 0 kind = kind or _pick(weights, rng) granary, shed, well = (building_level(world, k) for k in ("granary", "lumber_shed", "well")) if kind == "sickness": hours = 72 * (1 - 0.2 * building_level(world, "temple")) _add_effect(world, now, hours, kind="sickness", name="Sickness", icon="๐Ÿค’", food=2.0, text="City eats twice as much food") _record(world, now, "hardship", kind, "๐Ÿค’", "Sickness is spreading through the city", [f"For about {round(hours)} hours the townsfolk eat twice as much food.", "A bigger temple shortens it."]) elif kind == "locusts": share = 0.4 * (1 - 0.25 * granary) taken = _take_share(stock, _food_items(stock), share) _record(world, now, "hardship", kind, "๐Ÿฆ—", "A swarm of locusts descended on the stores", [f"They ate {_listing(taken)}." if taken else "There was little for them to eat.", *(["The granary kept part of it safe."] if granary else ["A granary would protect some of it."])]) elif kind == "rot": taken = _take_share(stock, WOOD, 0.3 * (1 - 0.25 * shed)) _record(world, now, "hardship", kind, "๐Ÿ„", "Damp got into the wood stores", [f"Rot ruined {_listing(taken)}." if taken else "There was no wood in the stockpile to rot.", *(["The lumber shed kept part of it dry."] if shed else ["A lumber shed would keep it dry."])]) elif kind == "storm": name = _damage_building(world, now, rng) taken = _take_share(stock, FIREWOOD, 0.1) lines = [f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs." if name else "Luckily there was nothing standing tall enough to break."] if taken: lines.append(f"The wind scattered {_listing(taken)}.") _record(world, now, "hardship", kind, "๐ŸŒช๏ธ", "A wild storm battered the city", lines) elif kind == "rats": city["problems"].append({"kind": "rats", "started": now, "progress": {}, "work": 0}) _record(world, now, "hardship", kind, "๐Ÿ€", "Rats have got into the granary", ["They'll eat into the food every hour until hunters catch them."]) elif kind == "fire": barrels = stock.get("water_barrel", 0) if fitted(world, "well_pump") and rng.random() < 0.5: _record(world, now, "hardship", kind, "๐Ÿ”ฅ", "A fire broke out, and the well's pump put it out", ["Nothing was lost."]) elif barrels >= 5: take_stock_qty(stock, "water_barrel", 5) _record(world, now, "hardship", kind, "๐Ÿ”ฅ", "A fire broke out, and the water barrels put it out", ["Five barrels were used up. Nothing else was lost."]) else: share = 0.25 * (1 - 0.25 * well) taken = _take_share(stock, WOOD + tuple(_food_items(stock)), share) name = _damage_building(world, now, rng) if rng.random() > 0.25 * well else None lines = [f"The flames took {_listing(taken)}." if taken else "Little was lost from the stores."] if name: lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.") lines.append("Water barrels in the stockpile (five per fire) would have put it out.") _record(world, now, "hardship", kind, "๐Ÿ”ฅ", "Fire swept through part of the city", lines) elif kind == "winter": _add_effect(world, now, 48, kind="winter", name="Harsh winter", icon="๐Ÿฅถ", firewood=0.1, text="Burns stockpile logs hourly") _record(world, now, "hardship", kind, "๐Ÿฅถ", "A harsh cold snap has set in", ["For two days the city burns logs from the stockpile to keep warm.", "Without firewood, the cold makes everyone eat 50% more food."]) else: _add_effect(world, now, 24, kind="drought", name="Drought", icon="๐Ÿœ๏ธ", speed={"farming": 1.3, "fishing": 1.2}, text="Farming 30% and fishing 20% slower") _record(world, now, "hardship", kind, "๐Ÿœ๏ธ", "Drought has dried the fields and streams", ["For a day, farming is 30% and fishing 20% slower."]) def _fortune(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None: city = world["city"] weights = {kind: w[season(world, now)["index"]] for kind, w in FORTUNES.items()} if not upcoming_raid(world): weights["mercenaries"] = 0 kind = kind or _pick(weights, rng) if kind == "merchant": city["merchant_until"] = now + 12 * HOUR / speed(world) _record(world, now, "fortune", kind, "๐Ÿช", "A merchant caravan has arrived", ["For the next 12 hours, everything sells for 50% more."]) elif kind == "refugees": people = rng.randint(6, 12) taken = min(max(0, housing(world) - int(city["population"])), people) city["population"] += taken lines = [f"{taken} of {people} refugees found a home."] if taken < people: lines.append("The rest moved on. More houses would have kept them.") _record(world, now, "fortune", kind, "๐Ÿงณ", "Refugees arrived", lines) elif kind == "master": skill = rng.choice([s for s in SKILL_IDS if s != "reconnaissance"]) name = next(s["name"] for s in SKILLS if s["id"] == skill) _add_effect(world, now, 12, kind="master", name=f"A master of {name.lower()}", icon="๐ŸŽ“", xp={skill: 1.5}, text=f"{name} +50% XP") _record(world, now, "fortune", kind, "๐ŸŽ“", f"A travelling master of {name.lower()} is visiting", [f"For 12 hours, {name.lower()} earns 50% more XP. Learn while you can."]) elif kind == "harvest": _add_effect(world, now, 12, kind="harvest", name="Bountiful harvest", icon="๐ŸŒพ", **{"yield": {"farming": 2}}, text="Farming yield doubled") _record(world, now, "fortune", kind, "๐ŸŒพ", "The fields are bursting: a bountiful harvest", ["Farming yields are doubled for 12 hours."]) elif kind == "rich_vein": _add_effect(world, now, 12, kind="rich_vein", name="Rich vein", icon="๐Ÿ’Ž", **{"yield": {"mining": 1.5}}, text="Mining yield +50%") _record(world, now, "fortune", kind, "๐Ÿ’Ž", "Miners struck a rich vein", ["Mining yields 50% more for 12 hours."]) elif kind == "salmon": _add_effect(world, now, 12, kind="salmon", name="Salmon run", icon="๐ŸŸ", **{"yield": {"fishing": 2}}, text="Fishing yield doubled") _record(world, now, "fortune", kind, "๐ŸŸ", "The salmon are running", ["Fishing yields are doubled for 12 hours."]) elif kind == "festival": _add_effect(world, now, 24, kind="festival", name="Festival", icon="๐ŸŽ‰", growth=2, favour=1.5, text="Growth doubled, +50% favour from donations") _record(world, now, "fortune", kind, "๐ŸŽ‰", "The city is holding a festival", ["For a day, the population grows twice as fast and donations earn 50% more favour."]) elif kind == "noble": levels = sum(b["level"] for b in city["buildings"].values()) gold = 100 + 40 * levels city["treasury"] += gold _record(world, now, "fortune", kind, "๐Ÿ‘‘", "A noble visited and admired the city", [f"Impressed by its {levels} building levels, they left {gold:,} gold for the treasury."]) elif kind == "pilgrims": lines = [] if building_level(world, "temple"): gift = 20 * building_level(world, "temple") city["devotion"] = city.get("devotion", 0) + gift lines.append(f"They prayed at the temple: +{gift} devotion.") for player in world["players"].values(): bank_add(world, player, "bones", 10, now, empty_summary()) lines.append("They left 10 bones with every adventurer.") _record(world, now, "fortune", kind, "๐Ÿ™", "Pilgrims passed through the city", lines) elif kind == "mercenaries": cost = 150 + 30 * season(world, now)["index"] + int(city["population"]) * 5 _add_effect(world, now, 24, kind="mercenaries", name="Mercenaries for hire", icon="๐Ÿ—ก๏ธ", offer=True, cost=cost, defence=25, text="+25 defence for the coming threat") _record(world, now, "fortune", kind, "๐Ÿ—ก๏ธ", "A sellsword company is camped outside the walls", [f"For a day they'll join the next defence for {cost:,} gold from the treasury."]) else: goods = rng.sample([("stone", 200), ("logs", 200), ("bricks", 80), ("bread", 60), ("arrows", 150), ("plank", 40)], 2) for item, qty in goods: city["stockpile"][item] = city["stockpile"].get(item, 0) + qty _record(world, now, "fortune", kind, "๐Ÿ“ฆ", f"An abandoned wagon brought {_listing(dict(goods))} to the stockpile", ["Townsfolk found a trader's wagon abandoned on the road, its owner nowhere to be seen.", f"They hauled its load into the city stockpile: {_listing(dict(goods))}. " "The city uses it straight away, and anyone can put it towards a building."]) def _minor_threat(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None: city = world["city"] options = [k for k in MINOR_THREATS if not active_problem(world, k)] kind = kind or rng.choice(options) problem = {"kind": kind, "started": now, "progress": {}, "work": 0} if "lasts" in PROBLEMS[kind]: problem["until"] = now + PROBLEMS[kind]["lasts"] * HOUR / speed(world) city["problems"].append(problem) spec = PROBLEMS[kind] _record(world, now, "minor", kind, spec["icon"], spec["name"], [spec["text"]]) def _solve(world: dict, problem: dict, now: float, who: str | None = None) -> None: world["city"]["problems"].remove(problem) spec = PROBLEMS[problem["kind"]] _record(world, now, "minor", problem["kind"], "โœ…", f"Solved: {spec['name'].lower()}", [f"Thanks to {who}." if who else "It's over."]) def problem_missing(problem: dict) -> dict: need = PROBLEMS[problem["kind"]].get("need", {}) return {item: qty - problem["progress"].get(item, 0) for item, qty in need.items() if problem["progress"].get(item, 0) < qty} def aid_problem(world: dict, player: dict, kind: str, item: str, source: str, now: float) -> str | None: """Give planks (or whatever a problem needs) from the bank or the stockpile.""" problem = active_problem(world, kind) if not problem: return "That's not a problem right now" need = problem_missing(problem).get(item, 0) if not need: return "It doesn't need that" stock = world["city"]["stockpile"] have = player["bank"].get(item, 0) if source == "bank" else stock.get(item, 0) qty = min(have, need) if qty <= 0: return "Nothing to give" if source == "bank": bank_take(player, item, qty) gain = qty * ITEMS[item]["value"] * city_rate(world, "favour") player["favour"] += gain player["donated"] += qty * ITEMS[item]["value"] else: take_stock_qty(stock, item, qty) problem["progress"][item] = problem["progress"].get(item, 0) + qty if not problem_missing(problem): _solve(world, problem, now, player["name"]) return None def hire_mercenaries(world: dict, player: dict, now: float) -> str | None: city = world["city"] offer = next((e for e in city["effects"] if e.get("offer")), None) if not offer: return "Nobody is offering" if city["treasury"] < offer["cost"]: return f"The treasury needs {offer['cost']:,} gold" city["treasury"] -= offer["cost"] city["hired"] = city.get("hired", 0) + offer["defence"] city["effects"].remove(offer) log(world, now, "favour", f"{player['name']} hired the sellswords for {offer['cost']:,} gold (+{offer['defence']} defence)", player["id"]) return None def _happenings_hour(world: dict, now: float, rng: random.Random) -> None: """Weather, firewood, thieves and expiries, once per game hour.""" city = world["city"] advance_weather(world, now, rng) city["effects"] = [e for e in city["effects"] if e["until"] > now] for problem in list(city["problems"]): if problem.get("until") and problem["until"] <= now: _solve(world, problem, now) stock = city["stockpile"] burn = city["population"] * sum(source.get("firewood", 0) for source in _modifier_sources(world)) city["firewood_due"] = city.get("firewood_due", 0) + burn cold = False while city["firewood_due"] >= 1: log_item = next((item for item in FIREWOOD if stock.get(item)), None) if not log_item: cold = True city["firewood_due"] = 0 break take_stock_qty(stock, log_item, 1) city["firewood_due"] -= 1 if cold and not city.get("cold"): log(world, now, "city", "The firewood has run out. People are cold and eating more to stay warm.") city["cold"] = cold for problem in city["problems"]: spec = PROBLEMS[problem["kind"]] if spec.get("steal"): _take_share(stock, [i for i in stock if i not in _food_items(stock)], spec["steal"]) if spec.get("steal_food"): _take_share(stock, _food_items(stock), spec["steal_food"] * (1 - 0.25 * building_level(world, "granary"))) def _happenings_tracks(world: dict, now: float, rng: random.Random) -> bool: changed = False for track, (low, high) in TRACK_HOURS.items(): due = world.get(f"next_{track}_at") if due is None or now < due: continue if track == "minor" and any(p["kind"] in MINOR_THREATS for p in world["city"]["problems"]): world[f"next_{track}_at"] = now + 6 * HOUR / speed(world) continue {"minor": _minor_threat, "hardship": _hardship, "fortune": _fortune}[track](world, now, rng) world[f"next_{track}_at"] = now + rng.uniform(low, high) * HOUR / speed(world) changed = True return changed def happenings_view(world: dict, now: float) -> dict: city = world["city"] weather = city["weather"] problems = [] for problem in city["problems"]: spec = PROBLEMS[problem["kind"]] problems.append({ "kind": problem["kind"], "name": spec["name"], "icon": spec["icon"], "text": spec["text"], "until": problem.get("until"), "work": problem["work"], "needed": spec.get("work"), "action": spec.get("action"), "need": spec.get("need"), "missing": problem_missing(problem), }) return { "season": season(world, now), "weather": {**{k: v for k, v in WEATHER[weather["kind"]].items()}, "kind": weather["kind"], "until": weather["until"], "next": weather["next"], "next_name": WEATHER[weather["next"]]["name"], "next_icon": WEATHER[weather["next"]]["icon"]}, "effects": [{k: e[k] for k in ("kind", "name", "icon", "text", "until", "speed", "yield", "xp") if k in e} | {"offer": e.get("offer", False), "cost": e.get("cost")} for e in city["effects"]], "problems": problems, "cold": city.get("cold", False), "hired": city.get("hired", 0), } # --- favour -------------------------------------------------------------- MASTER_BUILDER_LABOUR = 50 # boosted labour per hire: doubled labour and XP for whoever works it MASTER_BUILDER_FAVOUR = 150 # +50% for every further hire on the same site 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. 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 DRILL_SERGEANT_POINTS = 30 # boosted drilling per hire: doubled points and XP DRILL_SERGEANT_FAVOUR = 150 # +50% for every further hire against the same threat def drill_per_action(player: dict) -> float: return DRILL_PER_ACTION * (1 + 0.25 * (skill_level(player, "combat") // 20)) def drill_needed(threat: dict) -> int: return intel_costs(threat)[-1] def drill_cap(world: dict) -> float: return round(DRILL_SHARE * world["city"]["population"], 1) def drilled(world: dict) -> float: """Defence the drilling is worth so far: a share of the cap, as far as the drill has got.""" threat = upcoming_raid(world) if not threat: return 0 return drill_cap(world) * min(1.0, threat.get("drilled", 0) / drill_needed(threat)) def favours(world: dict, now: float) -> dict: """What favour can buy for the city right now: cost, and why not if it can't.""" city = world["city"] work = city["construction"] raid = upcoming_raid(world) return { "builders": { "cost": round(MASTER_BUILDER_FAVOUR * (1 + 0.5 * city.get("boosts_bought", 0))), "why": None if work else "Nothing is being built", "labour": MASTER_BUILDER_LABOUR, "boost": round(city.get("site_boost", 0)), }, "informant": { "cost": round(INFORMANT_FAVOUR * (1 + 0.5 * (raid or {}).get("informants", 0))), "why": "No news to dig into" if not raid else "Nothing more to learn" if raid["recon"] >= intel_costs(raid)[-1] else None, "recon": INFORMANT_RECON, "boost": round(city.get("recon_boost", 0)), }, "sergeant": { "cost": round(DRILL_SERGEANT_FAVOUR * (1 + 0.5 * (raid or {}).get("sergeants", 0))), "why": "No raid to prepare for" if not raid else "The militia is as drilled as it can be" if raid.get("drilled", 0) >= drill_needed(raid) else None, "drill": DRILL_SERGEANT_POINTS, "boost": round(city.get("drill_boost", 0), 1), "points": int((raid or {}).get("drilled", 0)), "needed": drill_needed(raid) if raid else 0, "defence": round(drilled(world), 1), "cap": drill_cap(world), }, } def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None: """Turn a player's favour into help for the whole city.""" option = favours(world, now).get(kind) if not option: return "No such favour" if option["why"]: return option["why"] if player["favour"] < option["cost"]: return f"Needs {option['cost']:,} favour" player["favour"] -= option["cost"] city = world["city"] if kind == "builders": work = city["construction"] city["site_boost"] = city.get("site_boost", 0) + MASTER_BUILDER_LABOUR city["boosts_bought"] = city.get("boosts_bought", 0) + 1 name = BUILDINGS[work["building"]]["name"].lower() log(world, now, "favour", f"{player['name']} hired a master builder for the {name}: " f"the next {MASTER_BUILDER_LABOUR} labour counts double", player["id"]) elif kind == "informant": raid = upcoming_raid(world) raid["informants"] = raid.get("informants", 0) + 1 city["recon_boost"] = city.get("recon_boost", 0) + INFORMANT_RECON log(world, now, "favour", f"{player['name']} paid an informant: the next {INFORMANT_RECON} " f"reconnaissance counts double", player["id"]) else: raid = upcoming_raid(world) raid["sergeants"] = raid.get("sergeants", 0) + 1 city["drill_boost"] = city.get("drill_boost", 0) + DRILL_SERGEANT_POINTS log(world, now, "favour", f"{player['name']} hired a drill sergeant: the next {DRILL_SERGEANT_POINTS} " f"drill points count double", player["id"]) return None 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 # --- icons ------------------------------------------------------------------------ # Every item, skill, class, building, prayer and output-less action gets a # game-icons.net glyph (CC BY 3.0) and a tint. Families share a shape and are # told apart by colour: a rune sword is the copper sword in cyan. The sprite # with just these glyphs is built by tools/build_icons.py into static/icons.svg. METAL_TINTS = {"copper": "#d07a3c", "iron": "#9aa1ab", "steel": "#d4dae2", "mithril": "#6f8fec", "adamant": "#4cb878", "rune": "#3fd0e0"} WOOD_TINTS = ["#b9844e", "#8f5e30", "#a3b86c", "#d0663a", "#6f9a4a", "#a77be8"] LEATHER_TINTS = ["#b07a4a", "#8a5a32", "#6b4a33", "#5fae5a", "#4f86d6", "#d0503c"] HERB_TINTS = {"guam_leaf": "#7fbf5a", "marrentill": "#e7c84a", "tarromin": "#d05a8a", "harralander": "#f0b030", "ranarr": "#8a6fd0", "snapdragon": "#e86a9a"} PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "visored-helm", "legs": "leg-armor", "body": "breastplate"} TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole", "hunting knife": "bowie-knife", "hammer": "claw-hammer"} LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"} CLOTH_GLYPHS = {"hood": "pointy-hat", "legs": "trousers", "body": "robe"} FIBRE_GLYPHS = {"cotton": "cotton-flower", "silk_cocoons": "butterfly", "moonpetal": "lotus-flower", "starbloom": "spiral-bloom", "spiritvine": "vine-flower"} CLOTH_TINTS = ["#d8d0b8", "#f0f0f0", "#e8c8e0", "#a8c8f0", "#f0e080", "#9be0c8"] REAGENT_GLYPHS = {"heavy": "chain-mail", "medium": "scale-mail", "light": "yarn"} WEAPON_GLYPHS = {"hammer": "warhammer", "staff": "wizard-staff", "bow": "pocket-bow"} HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"] GLYPHS = { "stone": ("stone-block", "#a8a29e"), "wheat": ("wheat", "#e3c060"), "vegetables": ("carrot", "#f08a3c"), "feathers": ("feather", "#e8e8ee"), "raw_meat": ("meat", "#e06a6a"), "arrows": ("arrow-cluster", "#c9b28a"), "bricks": ("brick-pile", "#c66a45"), "cut_stone": ("stone-stack", "#b8b2aa"), "beam": ("wood-beam", "#9a6a3a"), "bread": ("bread", "#deb064"), "cooked_meat": ("ham-shank", "#cf8f50"), "stew": ("hot-meal", "#c9884a"), "bones": ("broken-bone", "#e9dfc6"), "big_bones": ("crossed-bones", "#e9dfc6"), "dragon_bones": ("dinosaur-bones", "#a6d98f"), "studded_body": ("leather-armor", "#a3825a"), "coal": ("ore", "#5d626b"), "water_barrel": ("barrel", "#5aa0d8"), "thatch": ("high-grass", "#d8c070"), "flax": ("flax", "#8fb0d8"), "rope": ("rope-coil", "#c9a66b"), "hide_glue": ("honey-jar", "#c08a40"), "provisions": ("knapsack", "#a08050"), "raw_shrimp": ("shrimp", "#f39a82"), "raw_trout": ("flatfish", "#9fb5c8"), "raw_salmon": ("salmon", "#f08a6a"), "raw_eel": ("eel", "#7f9a6a"), "raw_lobster": ("crab", "#e05a3a"), "raw_swordfish": ("angler-fish", "#7fa3c8"), "cooked_shrimp": ("shrimp", "#f0b07a"), "cooked_trout": ("fish-cooked", "#d9a060"), "cooked_salmon": ("fish-cooked", "#f09a6a"), "smoked_eel": ("eel", "#b07a4a"), "cooked_lobster": ("crab", "#f07a4a"), "cooked_swordfish": ("fish-cooked", "#c8a070"), } SKILL_GLYPHS = { "mining": ("mining", "#b8b2aa"), "woodcutting": ("wood-axe", "#c98a4a"), "farming": ("wheat", "#e3c060"), "hunting": ("deer-head", "#c49a6c"), "fishing": ("fishing-pole", "#5aa0d8"), "smithing": ("anvil", "#d07a3c"), "construction": ("hammer-nails", "#d1a46a"), "cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"), "herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"), "reconnaissance": ("spyglass", "#6f8fec"), "tailoring": ("sewing-machine", "#c8a0e0"), } CLASS_GLYPHS = {"miner": ("miner", "#d07a3c"), "builder": ("crane", "#d1a46a"), "homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")} BUILDING_GLYPHS = { "houses": ("house", "#d0a060"), "walls": ("stone-wall", "#a8a29e"), "barracks": ("barracks-tent", "#c06a4a"), "smithy": ("anvil", "#d07a3c"), "temple": ("temple-gate", "#e9dfc6"), "granary": ("granary", "#e3c060"), "lumber_shed": ("wood-pile", "#b9844e"), "well": ("well", "#5aa0d8"), "spymaster": ("hooded-figure", "#8a7fd0"), "storehouse": ("wooden-crate", "#b9844e"), } ACTION_GLYPHS = { "fight_wolves": ("wolf-head", "#9aa1ab"), "fight_scouts": ("hooded-assassin", "#a08060"), "fight_ruins": ("castle-ruins", "#a8a29e"), "fight_stronghold": ("castle", "#8a8f98"), "fight_green_dragon": ("dragon-head", "#5fae5a"), "fight_blue_dragon": ("dragon-head", "#4f86d6"), "fight_red_dragon": ("dragon-head", "#d0503c"), "fight_black_dragon": ("dragon-head", "#8a8f98"), "sabotage": ("fire-bomb", "#e0703a"), "drill_militia": ("sword-brandish", "#e0a050"), "recon_roads": ("spyglass", "#6f8fec"), "recon_camps": ("spyglass", "#8a7fd0"), "labour": ("hammer-nails", "#d1a46a"), "clear_wolves": ("wolf-head", "#9aa1ab"), "catch_goblins": ("goblin-head", "#8fbf6a"), "drive_bear": ("bear-head", "#8a5a32"), "cull_boars": ("boar", "#a06a4a"), "catch_rats": ("rat", "#9aa1ab"), "clear_rockslide": ("falling-rocks", "#a8a29e"), "hunt_elk": ("deer", "#a07a50"), "hunt_sabrecat": ("saber-toothed-cat-head", "#e0a050"), "hunt_mammoth": ("mammoth", "#8a6a50"), } PRAYER_GLYPHS = {"thick_skin": ("shield", "#a8a29e"), "burst_of_strength": ("biceps", "#e06a6a"), "rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"), "superhuman_strength": ("biceps", "#ff8a3a"), "second_wind": ("wind-hole", "#8fd0e0"), "protection": ("aura", "#f0d060"), "piety": ("angel-wings", "#f0e6c0")} TROPHY_GLYPHS = {"hammer": "crystal-growth", "sword": "animal-hide", "staff": "spiral-bloom", "bow": "sewing-string"} TIER_TINTS = ["#9aa1ab", "#d4dae2", "#6f8fec", "#4cb878", "#3fd0e0"] # trophies of areas II to VI RACE_GLYPHS = { "Clay golems": "rock-golem", "Cave trolls": "troll", "Restless dead": "skeleton", "Harpies": "harpy", "Stone guardians": "golem-head", "Lizardfolk": "lizardman", "Bog wraiths": "spectre", "Giant spiders": "spider-face", "Iron golems": "robot-golem", "Orcs": "orc-head", "Mine ghosts": "ghost", "Wyverns": "wyvern", "Ice elementals": "ice-golem", "Frost trolls": "troll", "Banshees": "screaming", "Griffins": "griffin-symbol", "Magma constructs": "lava", "Dragonkin": "dragon-spiral", "Shades": "ghost-ally", "Rocs": "eagle-head", } ACTION_GLYPHS.update({ "fight_goblins": ("goblin-head", "#8fbf6a"), "fight_ruins": ("ogre", "#a08060"), "fight_barrow_king": ("crowned-skull", "#c8d0e0"), "drill_militia": ACTION_GLYPHS["drill_militia"], }) for _index, (_area, _level, _prefix, _races) in enumerate(AREAS): for _race, *_rest in _races: ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index]) UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", *(kind["glyph"] for kind in DAMAGE_TYPES.values())] def _assign_glyphs() -> None: for item, (glyph, tint) in GLYPHS.items(): ITEMS[item].update(glyph=glyph, tint=tint) for tier, (key, _name, ore, *_rest) in enumerate(METALS): tint = METAL_TINTS[key] if ore: ITEMS[ore[0]].update(glyph="ore", tint=tint) ITEMS[f"{key}_bar"].update(glyph="metal-bar", tint=tint) ITEMS[HARDWARE[tier][0]].update(glyph=HARDWARE_GLYPHS[tier], tint=tint) ITEMS[f"{key}_warhammer"].update(glyph=WEAPON_GLYPHS["hammer"], tint=tint) ITEMS[f"{WOODS[tier]}_staff"].update(glyph=WEAPON_GLYPHS["staff"], tint=WOOD_TINTS[tier]) ITEMS[f"{WOODS[tier]}_bow"].update(glyph=WEAPON_GLYPHS["bow"], tint=WOOD_TINTS[tier]) for piece, glyph in PIECE_GLYPHS.items(): ITEMS[f"{key}_{piece}"].update(glyph=glyph, tint=tint) for tool, glyph in TOOL_GLYPHS.items(): ITEMS[f"{key}_{tool.replace(' ', '_')}"].update(glyph=glyph, tint=tint) for (log, *_tree), (plank, _name), tint in zip(TREES, PLANKS, WOOD_TINTS): ITEMS[log].update(glyph="log", tint=tint) ITEMS[plank].update(glyph="planks", tint=tint) for (raw, leather, prefix, *_rest), tint in zip(LEATHERS, LEATHER_TINTS): ITEMS[raw[0]].update(glyph="animal-hide", tint=tint) ITEMS[leather[0]].update(glyph="rolled-cloth", tint=tint) for suffix, glyph in LEATHER_GLYPHS.items(): ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint) for trophy, (_name, line, tier) in TROPHIES.items(): ITEMS[trophy].update(glyph=TROPHY_GLYPHS[line], tint=TIER_TINTS[tier - 1]) for item, (weight, tier) in REAGENTS.items(): ITEMS[item].update(glyph=REAGENT_GLYPHS[weight], tint=TIER_TINTS[tier - 1]) for (fibre, _level, _time, _xp, cloth, prefix, _label), tint in zip(CLOTHS, CLOTH_TINTS): if fibre[0] in FIBRE_GLYPHS: ITEMS[fibre[0]].update(glyph=FIBRE_GLYPHS[fibre[0]], tint=tint) ITEMS[cloth[0]].update(glyph="rolled-cloth", tint=tint) for suffix, glyph in CLOTH_GLYPHS.items(): ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint) for herb, tint in HERB_TINTS.items(): ITEMS[herb].update(glyph="herbs-bundle", tint=tint) flasks = ["round-bottom-flask", "round-bottom-flask", "standing-potion", "standing-potion", "bubbling-flask", "bubbling-flask"] for potion, *_rest, skill, effect in POTIONS: tint = "#e05a5a" if "power" in effect else "#8fa8c8" if "reduce" in effect else SKILL_GLYPHS[skill][1] ITEMS[potion].update(glyph=flasks[POTION_TIER[potion]], tint=tint) for skill in SKILLS: skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]] for key, spec in CLASSES.items(): spec["glyph"], spec["tint"] = CLASS_GLYPHS[key] for key, spec in BUILDINGS.items(): spec["glyph"], spec["tint"] = BUILDING_GLYPHS[key] for prayer in PRAYERS: prayer["glyph"], prayer["tint"] = PRAYER_GLYPHS[prayer["id"]] for action in ACTIONS: if action["id"] in ACTION_GLYPHS: action["glyph"], action["tint"] = ACTION_GLYPHS[action["id"]] elif action["outputs"]: first = ITEMS[next(iter(action["outputs"]))] action["glyph"], action["tint"] = first["glyph"], first["tint"] elif action.get("special") in ("bury", "offer"): bone = ITEMS[next(iter(action["inputs"]))] action["glyph"] = "tombstone" if action["special"] == "bury" else "candle-holder" action["tint"] = bone["tint"] if action["special"] == "bury" else "#f0c060" else: action["glyph"], action["tint"] = SKILL_GLYPHS[action["skill"]] def all_glyphs() -> set[str]: """Every glyph the game draws, for building the sprite.""" names = set(UI_GLYPHS) for collection in (ITEMS.values(), SKILLS, CLASSES.values(), BUILDINGS.values(), PRAYERS, ACTIONS): names.update(entry["glyph"] for entry in collection) return names _assign_glyphs() 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, "city_uses": {item: city_uses(item) for item in ITEMS}, "prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, "potion_charges": POTION_CHARGES, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), "equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED, "styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE, "resistant": RESISTANT, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE, "regen_per_hour": REGEN_PER_HOUR, }