"""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": 6, "req": ["combat", 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]), ] 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"), ] # raw hide (id, name, icon, value), leather (id, name, value), armour id prefix, armour name prefix, # leatherworking level, armour by piece LEATHERS = [ (("hide", "Hide", "๐ŸŸซ", 3), ("leather", "Leather", 5), "leather", "Leather", 1, (1, 2, 3)), (("boar_hide", "Boar hide", "๐Ÿ—", 8), ("hard_leather", "Hard leather", 12), "hard_leather", "Hard leather", 20, (2, 4, 6)), (("bear_pelt", "Bear pelt", "๐Ÿป", 16), ("bear_leather", "Bear leather", 24), "bear_leather", "Bear leather", 35, (4, 6, 9)), (("green_dragonhide", "Green dragonhide", "๐Ÿ‰", 35), ("green_dragon_leather", "Green dragon leather", 50), "green_dhide", "Green dragonhide", 50, (6, 9, 13)), (("blue_dragonhide", "Blue dragonhide", "๐Ÿฒ", 60), ("blue_dragon_leather", "Blue dragon leather", 85), "blue_dhide", "Blue dragonhide", 65, (8, 12, 17)), (("red_dragonhide", "Red dragonhide", "๐Ÿ”ฅ", 100), ("red_dragon_leather", "Red dragon leather", 140), "red_dhide", "Red dragonhide", 80, (10, 15, 21)), ] # 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, "๐Ÿฆบ")] # 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: _item["armour"] = _armour[_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]], } 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} for _tier, (_raw, _leather, _prefix, _label, _level, _armour) 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), _points in zip(LEATHER_PIECES, _armour): ITEMS[f"{_prefix}_{_suffix}"] = { "name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _leather[2] * 1.3), "slot": _slot, "armour": _points, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]], } if _noun == "trousers": 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", "๐Ÿ€", 5, 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 # id, name, herblore level, herb, secondary, skill, effect POTIONS = [ ("trackers_tincture", "Tracker's tincture", 1, "guam_leaf", "feathers", "hunting", {"speed": 0.2}), ("lumberjacks_tonic", "Lumberjack's tonic", 6, "guam_leaf", "logs", "woodcutting", {"speed": 0.2}), ("strength_potion", "Strength potion", 10, "guam_leaf", "bones", "combat", {"power": 6}), ("anglers_brew", "Angler's brew", 15, "marrentill", "raw_shrimp", "fishing", {"speed": 0.2}), ("prospectors_draught", "Prospector's draught", 18, "marrentill", "copper_ore", "mining", {"double": 0.15}), ("growth_potion", "Growth potion", 20, "marrentill", "wheat", "farming", {"speed": 0.2}), ("defence_potion", "Defence potion", 25, "tarromin", "iron_ore", "combat", {"reduce": 0.15}), ("chefs_spice", "Chef's spice", 28, "tarromin", "vegetables", "cooking", {"keep": 0.15}), ("bonedust_tonic", "Bonedust tonic", 30, "tarromin", "big_bones", "prayer", {"bones": 0.5}), ("smiths_focus", "Smith's focus", 35, "harralander", "coal", "smithing", {"keep": 0.15}), ("masons_patience", "Mason's patience", 38, "harralander", "cut_stone", "masonry", {"keep": 0.15}), ("tanners_oil", "Tanner's oil", 40, "harralander", "raw_meat", "leatherworking", {"keep": 0.15}), ("regeneration_potion", "Regeneration potion", 45, "ranarr", "cooked_trout", "combat", {"heal": 4}), ("super_strength", "Super strength", 50, "ranarr", "dragon_bones", "combat", {"power": 14}), ("super_defence", "Super defence", 60, "snapdragon", "blue_dragonhide", "combat", {"reduce": 0.30}), ] 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)}% chance of double yield" if "keep" in effect: return f"{name.title()}: {round(effect['keep'] * 100)}% chance to keep your materials" if "bones" in effect: return f"+{round(effect['bones'] * 100)}% prayer points and devotion from bones" if "power" in effect: return f"+{effect['power']} combat power" if "reduce" in effect: return f"-{round(effect['reduce'] * 100)}% damage taken in fights" return f"Heals {effect['heal']} HP every fight" 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": "masonry", "name": "Masonry", "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": "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": "Signature", "class": "scout"}, ] SKILL_IDS = [skill["id"] for skill in SKILLS] GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"} CRAFTING = {"smithing", "masonry", "cooking", "leatherworking", "herblore"} SURVEYED = {"mining", "woodcutting", "farming"} # what the pioneer's double yield applies to CLASSES = { "warrior": { "name": "Warrior", "icon": "๐Ÿ›ก๏ธ", "fast": ["combat"], "bonus": "Combat actions 20% faster", "signature": "Command: counts for half again as much in the garrison", }, "scout": { "name": "Scout", "icon": "๐Ÿน", "fast": ["hunting", "fishing", "reconnaissance"], "bonus": "Hunting and fishing 20% faster", "signature": "Reconnaissance: scout the roads to sharpen intel on what's coming", }, "artisan": { "name": "Artisan", "icon": "โš’๏ธ", "fast": ["smithing", "masonry", "cooking", "leatherworking"], "bonus": "Smithing, masonry, cooking and leatherworking 20% faster", "signature": "Masterwork: 1 in 10 crafts makes double", }, "pioneer": { "name": "Pioneer", "icon": "๐Ÿงญ", "fast": ["mining", "woodcutting", "farming", "construction"], "bonus": "Mining, woodcutting, farming and construction 20% faster", "signature": "Surveying: 1 in 10 gathers yields double", }, } def _action(id, skill, name, level, time, xp, inputs=None, outputs=None, **extra): return {"id": id, "skill": skill, "name": name, "level": level, "time": time, "xp": xp, "inputs": inputs or {}, "outputs": outputs or {}, **extra} 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 {})) for piece, _slot, noun, bars, offset, _power, _armour in PIECES: actions.append(_action(f"smith_{key}_{piece}", "smithing", f"{name} {noun}", TIER_LEVELS[tier] + offset, 25 + 5 * bars + 2 * tier, bars * piece_xp[tier], {f"{key}_bar": bars}, {f"{key}_{piece}": 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, _armour) 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}, {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})) return actions 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}), _action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}), *[_action(f"grow_{herb}", "farming", f"Grow {name.lower()}", level, seconds, xp, outputs={herb: 1}) 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="Combat potions" if skill == "combat" else "Skill potions") 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}), *_tiered_actions(), _action("smith_arrows", "smithing", "Arrows", 5, 30, 10, {"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Bars"), _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(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", "masonry", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}), _action("mason_cut_stone", "masonry", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}), _action("mason_beam", "masonry", "Reinforced beam", 20, 45, 30, {"oak_logs": 2, "iron_bar": 1}, {"beam": 1}), _action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}), _action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}), _action("cook_stew", "cooking", "Stew", 15, 35, 16, {"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}), _action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, damage=2, gold=2, drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5], ["bones", 1, 1.0]]), _action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, gold=10, drops=[["copper_bar", 1, 0.3], ["arrows", 5, 0.4], ["bones", 1, 1.0]]), _action("fight_ruins", "combat", "Watchtower ruins", 25, 90, 60, difficulty=30, damage=14, gold=25, drops=[["iron_bar", 1, 0.4], ["iron_sword", 1, 0.03], ["big_bones", 1, 0.8]]), _action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, damage=25, gold=60, drops=[["steel_bar", 1, 0.4], ["coal", 3, 0.5], ["iron_shield", 1, 0.04], ["big_bones", 1, 1.0]]), _action("fight_green_dragon", "combat", "Green dragon", 50, 120, 135, difficulty=90, damage=30, gold=80, drops=[["green_dragonhide", 1, 0.9], ["mithril_ore", 2, 0.3], ["dragon_bones", 1, 1.0]]), _action("fight_blue_dragon", "combat", "Blue dragon", 62, 130, 175, difficulty=115, damage=40, gold=110, drops=[["blue_dragonhide", 1, 0.9], ["adamantite_ore", 2, 0.3], ["dragon_bones", 1, 1.0]]), _action("fight_red_dragon", "combat", "Red dragon", 75, 140, 220, difficulty=140, damage=52, gold=150, drops=[["red_dragonhide", 1, 0.9], ["runite_ore", 1, 0.25], ["dragon_bones", 1, 1.0]]), _action("fight_black_dragon", "combat", "Black dragon", 85, 150, 280, difficulty=165, damage=65, gold=220, drops=[["red_dragonhide", 2, 0.9], ["rune_bar", 1, 0.1], ["dragon_bones", 2, 1.0]]), _action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10, special="sabotage"), _action("bury_bones", "prayer", "Bury bones", 1, 10, 4, {"bones": 1}, special="bury", pp=2), _action("bury_big_bones", "prayer", "Bury big bones", 15, 10, 15, {"big_bones": 1}, special="bury", pp=6), _action("bury_dragon_bones", "prayer", "Bury dragon bones", 40, 12, 72, {"dragon_bones": 1}, special="bury", pp=20), _action("offer_bones", "prayer", "Offer bones at the temple", 1, 12, 7, {"bones": 1}, special="offer", devotion=2, city={"temple": 1}), _action("offer_big_bones", "prayer", "Offer big bones", 15, 12, 25, {"big_bones": 1}, special="offer", devotion=6, city={"temple": 1}), _action("offer_dragon_bones", "prayer", "Offer dragon bones", 40, 14, 110, {"dragon_bones": 1}, special="offer", devotion=20, city={"temple": 2}), _action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1), _action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2), ] ACTION_BY_ID = {action["id"]: action for action in ACTIONS} BUILDINGS = { "houses": { "name": "Houses", "icon": "๐Ÿ ", "max": 8, "gold": 200, "base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200}, "late": {"willow_logs": 150, "oak_plank": 60}, "final": {"maple_logs": 100, "willow_plank": 60}, "effect": "Room for 20 more people per level", }, "walls": { "name": "Walls", "icon": "๐Ÿฐ", "max": 5, "gold": 300, "base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 40}, "late": {"steel_bar": 60, "oak_plank": 40}, "final": {"mithril_bar": 40, "maple_plank": 40}, "effect": "+20 defence per level, and raiders carry off less", }, "barracks": { "name": "Barracks", "icon": "โ›บ", "max": 5, "gold": 300, "base": {"logs": 600, "bricks": 400, "copper_sword": 20}, "advanced": {"iron_sword": 15}, "late": {"steel_sword": 10, "oak_plank": 50}, "final": {"mithril_sword": 6, "willow_plank": 50}, "effect": "Trains townsfolk as militia: more defence per citizen", }, "smithy": { "name": "Smithy", "icon": "โš’๏ธ", "max": 5, "gold": 250, "base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150}, "late": {"steel_bar": 80, "oak_plank": 40}, "final": {"mithril_bar": 60, "maple_plank": 40}, "effect": "Smithing 8% faster per level; each level unlocks a metal: iron, steel, mithril, adamant, rune", }, "temple": { "name": "Temple", "icon": "โ›ช", "max": 3, "gold": 300, "base": {"stone": 800, "bricks": 400, "cut_stone": 100}, "advanced": {"beam": 20}, "late": {"willow_logs": 150, "oak_plank": 60}, "effect": "Offerings ward the city in raids; +1 prayer point per bone buried and +25% devotion per level", }, "granary": { "name": "Granary", "icon": "๐ŸŒพ", "max": 3, "gold": 150, "base": {"logs": 400, "bricks": 200, "plank": 60}, "advanced": {"cut_stone": 80}, "late": {"oak_plank": 60}, "effect": "Protects a quarter of the food from locusts and rats per level", }, "lumber_shed": { "name": "Lumber shed", "icon": "๐Ÿชต", "max": 3, "gold": 120, "base": {"logs": 300, "stone": 200, "plank": 80}, "advanced": {"oak_plank": 60}, "late": {"willow_plank": 40}, "effect": "Keeps a quarter of the stored wood safe from rot per level", }, "well": { "name": "Well", "icon": "๐Ÿชฃ", "max": 3, "gold": 120, "base": {"stone": 500, "bricks": 150}, "advanced": {"cut_stone": 80}, "late": {"oak_plank": 40}, "effect": "Each level cuts fire losses by a quarter and may save the building", }, "spymaster": { "name": "Spymaster's guild", "icon": "๐Ÿ•ต๏ธ", "max": 3, "gold": 400, "base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, "late": {"bear_leather": 40, "oak_plank": 40}, "effect": "+25% reconnaissance per level, and its agents gather some every hour on their own", }, "storehouse": { "name": "Storehouse", "icon": "๐Ÿ“ฆ", "max": 5, "gold": 200, "base": {"logs": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 200}, "late": {"willow_logs": 200, "oak_plank": 80}, "final": {"yew_logs": 80, "yew_plank": 40}, "effect": "+3 bank slots and +250 per stack for everyone, per level", }, } # 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_PER_DAY = 0.12 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 = 10 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 return world 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: return sum(ITEMS[item].get("armour", 0) for item in player["equip"].values() if item) def damage_reduction(player: dict) -> float: points = armour(player) return points / (points + 25) def combat_power(player: dict) -> int: power = skill_level(player, "combat") + armour(player) // 2 for item in player["equip"].values(): if item: power += ITEMS[item].get("power", 0) return power def max_hp(player: dict) -> int: return 40 + 4 * skill_level(player, "combat") def regen(world: dict, player: dict, now: float, 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 fight_damage(player: dict, action: dict, blessing: dict | None = None) -> float: """Average damage one fight deals: less when out-levelling the enemy, armoured or praying.""" blessing = blessing or NO_BLESSING gap = combat_power(player) + blessing["power"] - action["difficulty"] scale = min(1.5, max(0.3, 1 - gap * 0.03)) return action["damage"] * scale * (1 - damage_reduction(player)) * blessing["taken"] # --- 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 while fighting") if prayer.get("wind"): parts.append("once per outing, a retreat becomes a second wind: back to half health") 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 auto_eat(player: dict, summary: dict) -> None: top = max_hp(player) while player["hp"] < top * EAT_BELOW: foods = edible(player) if not foods: return bank_take(player, foods[0], 1) player["hp"] = min(top, player["hp"] + ITEMS[foods[0]]["heal"]) summary["eaten"][foods[0]] = summary["eaten"].get(foods[0], 0) + 1 def action_time(world: dict, player: dict, action: dict) -> float: seconds = action["time"] if action["skill"] in CLASSES[player["cls"]]["fast"]: seconds *= CLASS_SPEED tool = player["equip"].get(f"tool_{action['skill']}") if tool: seconds *= 1 - ITEMS[tool]["speed"] if action["skill"] == "smithing": seconds *= 1 - 0.08 * building_level(world, "smithy") 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) def with_potion(blessed: dict, brew: dict) -> dict: """A fight's prayer boosts with a combat potion's added on top.""" return {**blessed, "power": blessed["power"] + brew.get("power", 0), "taken": blessed["taken"] * (1 - brew.get("reduce", 0))} 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 player["potions"][spec["skill"]] = {"item": item, "charges": charges + POTION_CHARGES} 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 action["skill"] == "reconnaissance" and player["cls"] != "scout": return "Only scouts can do that" if skill_level(player, action["skill"]) < action["level"]: return f"Needs level {action['level']}" for key, level in action.get("city", {}).items(): if building_level(world, key) < level: return f"Needs {BUILDINGS[key]['name']} level {level}" for item, qty in action["inputs"].items(): if player["bank"].get(item, 0) < qty: return f"Out of {ITEMS[item]['name'].lower()}" if action.get("special") == "sabotage": raid = upcoming_raid(world) if not raid or intel_level(world, raid) < 3: return "No bandit camp has been located" if raid["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["skill"] == "combat" and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): return "Too hurt to fight. Rest, or bring food" return None def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None: 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"] lucky = ( (player["cls"] == "artisan" and action["skill"] in CRAFTING) or (player["cls"] == "pioneer" and action["skill"] in SURVEYED) ) and rng.random() < LUCKY_CHANCE outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()} if lucky: summary["lucky"] += 1 if 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 action["skill"] == "combat": blessed = blessing(player) if player["prayers"] and not blessed["cost"]: summary["prayer_out"] = True if blessed["cost"]: player["pp"] -= blessed["cost"] summary["pp_spent"] += blessed["cost"] gain_xp(world, player, "prayer", blessed["cost"] * PRAYER_XP_PER_POINT, now, summary) regen(world, player, now, blessed["regen"]) blessed = with_potion(blessed, brew) auto_eat(player, summary) chance = 0.5 + (combat_power(player) + blessed["power"] - action["difficulty"]) * 0.04 won = rng.random() < min(0.97, max(0.15, chance)) hurt = fight_damage(player, action, blessed) * rng.uniform(0.6, 1.4) * (1 if won else 1.3) player["hp"] = min(max_hp(player), player["hp"] - hurt + brew.get("heal", 0)) summary["damage"] += hurt auto_eat(player, summary) outing = player["action"] if player["hp"] <= 0 and blessed["wind"] and outing and not outing.get("wind_used"): outing["wind_used"] = True player["hp"] = max_hp(player) / 2 summary["second_wind"] += 1 if player["hp"] <= 0: # Out of food and out of luck: fall back with nothing but your life. player["hp"] = 1 summary["losses"] += 1 reason = "Retreated: ran out of food" if not edible(player) else "Retreated" stop(player, reason) summary["stopped"] = reason gain_xp(world, player, "combat", xp // 3, now, summary) return if won: summary["wins"] += 1 player["gold"] += action.get("gold", 0) summary["gold"] += action.get("gold", 0) for item, qty, odds in action.get("drops", []): if rng.random() < odds: outputs[item] = outputs.get(item, 0) + qty if action.get("special") == "sabotage": raid = upcoming_raid(world) raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03) if action.get("special") == "problem": _work_problem(world, player, action, now) else: summary["losses"] += 1 xp = xp // 3 if action.get("special") == "recon": event = upcoming_raid(world) if event: event["recon"] += recon_gain(world, action) if action.get("special") == "problem" and action["skill"] != "combat": _work_problem(world, player, action, now) 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 _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 action = ACTION_BY_ID[current["id"]] limit = min(now, player["last_active"] + OFFLINE_CAP / speed(world)) while player["action"]: duration = action_time(world, player, action) if current["start"] + duration > limit: break reason = can_start(world, player, action) if reason and reason.startswith("Too hurt"): 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 player["action"] is current and "left" in current: current["left"] -= 1 if current["left"] <= 0: back = current.get("resume") if back: player["action"] = current = {"id": back, "start": current["start"]} action = ACTION_BY_ID[back] summary["resumed"] = back else: stop(player) if player["action"] and limit < now and current["start"] < now: current["start"] = now return summary def stop(player: dict, reason: str | None = None) -> None: player["action"] = None player["stopped"] = reason def start_action(world: dict, player: dict, action_id: str, now: float, once: bool = False) -> str | None: """Start an action. `once` does it a single time, then goes back to what you were doing.""" action = ACTION_BY_ID.get(action_id) if not action: return "No such action" reason = can_start(world, player, action) if reason: return reason entry = {"id": action_id, "start": now} if once: current = player["action"] back = current and (current.get("resume") or current["id"]) entry["left"] = 1 if back and back != action_id: entry["resume"] = back player["action"] = entry player["stopped"] = None return None # --- city ---------------------------------------------------------------- def stockpile_value(city: dict) -> float: return sum(ITEMS[item]["value"] * qty for item, qty in city["stockpile"].items()) def donate(world: dict, player: dict, item: str, qty: int, now: float) -> str | None: city = world["city"] if qty <= 0: return "Nothing to donate" if item == "gold": if player["gold"] < qty: return "Not enough gold" player["gold"] -= qty city["treasury"] += qty gain = qty label = f"{qty:,} gold" else: if 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 population_view(world: dict) -> dict: """Population, food and tier, with the numbers that explain them.""" city = world["city"] pop = city["population"] room = housing(world) if city["starving"]: growth = -pop * 0.04 elif pop < room - 0.01: growth = max(0.5, pop * GROWTH_PER_DAY) elif pop > room: growth = -pop * 0.05 else: growth = 0.0 food = sum(city["stockpile"].get(item, 0) * ITEMS[item]["food"] for item in FOOD_ORDER) tier = city["tier"] following = TIERS[tier + 1] if tier + 1 < len(TIERS) else None return { "population": int(pop), "housing": room, "growth": round(growth, 1), "food": food, "food_days": round(food / (pop * 0.5), 1) if pop else None, "tier": tier, "tier_name": TIERS[tier]["name"], "next_tier": following and {"name": following["name"], "pop": following["pop"]}, } def 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.""" 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), "rallied": city.get("rally", 0), "ward": ward(world), "mercenaries": city.get("hired", 0), "garrison": 0, } garrison = [] for player in world["players"].values(): if player["garrison"]: power = combat_power(player) * (1.5 if player["cls"] == "warrior" else 1) garrison.append({"id": player["id"], "name": player["name"], "power": round(power, 1)}) parts["garrison"] += power total = sum(parts.values()) if city["starving"]: total *= 0.8 return {"parts": parts, "garrison": garrison, "total": round(total, 1), "starving": city["starving"]} def tick(world: dict, now: float, rng: random.Random | None = None) -> bool: """Advance the city and events to `now`. Returns True if anything changed.""" rng = rng or random.Random() city = world["city"] changed = False step = HOUR / speed(world) # Never replay more than two weeks if the server was off. city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world)) while city["last_tick"] + step <= now: city["last_tick"] += step _happenings_hour(world, city["last_tick"], rng) _city_hour(world, city["last_tick"]) changed = True 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"] += 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: growth = max(0.5, pop * GROWTH_PER_DAY) * city_rate(world, "growth") * (1.25 if fitted(world, "furniture") else 1) city["population"] = min(room, pop + growth / 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: return 1 + skill_level(player, "construction") // 20 # one more labour every 20 levels 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 = 15 def recon_gain(world: dict, action: dict) -> float: return (action["recon"] + fitted(world, "map_table")) * (1 + SPYMASTER_RECON * building_level(world, "spymaster")) 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] 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) centre = event["fire_at"] + event["jitter"] * half * 0.6 view = { "id": event["id"], "kind": event["kind"], "intel": level, "earliest": max(now, centre - half), "latest": max(now, centre + half), "exact": event["fire_at"] if level >= 3 else None, "recon": int(event["recon"]), "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) 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) elif event["kind"] == "merchant": view["headline"] = "A merchant caravan is on its way. Everything sells for half again as much while it's here." else: view["headline"] = f"About {event['people']} refugees are walking towards the city, hoping for a roof." return view def council_requests(world: dict, event: dict) -> list[dict]: city = world["city"] stock = city["stockpile"] strength = event["strength"] armed = int(city["population"] // 2) wants = [ ("arrows", min(1000, 50 * math.ceil(strength * 4 / 50))), ("copper_sword", min(armed, max(5, math.ceil(strength / 6)))), ("bread", math.ceil(city["population"] * 1.5)), ] if building_level(world, "smithy") >= 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()) lines.append(f"The raiders carried off {gold:,} gold" + (f" and {stolen}." if stolen else ".")) built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]] if severity > 0.35 and built: key = rng.choice(built) city["buildings"][key]["damaged"] = True _queue_if_funded(world, key, now) lines.append(f"The {BUILDINGS[key]['name'].lower()} were damaged and won't work until repaired." if BUILDINGS[key]["name"].endswith("s") else f"The {BUILDINGS[key]['name'].lower()} was damaged and won't work until repaired.") lines.append("A smaller, poorer city will draw less attention for a while.") for entry in garrison: gain_xp(world, world["players"][entry["id"]], "combat", round(attack * 2), now) log(world, now, "raid", f"{upper_first(event["group"])} broke into the city") report["lines"] = lines event["report"] = report for player in world["players"].values(): player["garrison"] = False 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()) bank_add(world, player, "green_dragonhide", 1, now, empty_summary()) if garrison: lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and a hide.") 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 effect on work or the city"}, "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, but food spoils: the 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, and the city 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": "The mine has collapsed: shore it up with planks"}, "allow": ["mine_stone"], "text": "A shaft caved in. Ore can't be mined until the mine is shored up with planks."}, "wolves": {"name": "Wolves at the farms", "icon": "๐Ÿบ", "yield": {"farming": 0.5}, "work": 10, "action": "clear_wolves", "text": "A pack is harrying the fields. Farming yields are halved until fighters drive them off."}, "bear": {"name": "Bear in the woods", "icon": "๐Ÿป", "speed": {"woodcutting": 1.5}, "work": 8, "action": "drive_bear", "text": "Woodcutters won't go near it. Woodcutting is much slower until hunters drive it off."}, "goblins": {"name": "Goblin thieves", "icon": "๐Ÿ‘บ", "steal": 0.01, "work": 10, "action": "catch_goblins", "text": "Goblins raid the stockpile every hour until fighters catch them."}, "rockslide": {"name": "Rockslide on the quarry road", "icon": "๐Ÿชจ", "work": 25, "action": "clear_rockslide", "block": {"mining": "The quarry road is blocked by a rockslide"}, "only": ["mine_stone"], "text": "No stone can be quarried until builders clear the road."}, "flood": {"name": "Flooded fishing grounds", "icon": "๐ŸŒŠ", "speed": {"fishing": 1.5}, "need": {"plank": 20}, "lasts": 36, "text": "The river burst its banks. Fishing is slow until it drops in a day and a half, or a dam of planks holds it back."}, "boars": {"name": "Boars trampling the crops", "icon": "๐Ÿ—", "yield": {"farming": 0.6}, "work": 10, "action": "cull_boars", "text": "A herd is trampling the fields. Farming yields are down until hunters cull them."}, "rats": {"name": "Rats in the granary", "icon": "๐Ÿ€", "steal_food": 0.03, "work": 12, "action": "catch_rats", "text": "Rats eat into the city's food every hour until someone catches them."}, } MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars") PROBLEM_ACTIONS = [ _action("clear_wolves", "combat", "Drive off the wolves", 1, 60, 16, difficulty=6, damage=3, special="problem", problem="wolves"), _action("catch_goblins", "combat", "Catch the goblin thieves", 1, 60, 16, difficulty=8, damage=3, 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_bar": 1}, {"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_bar": 5}, "The city eats 10% less food", "grain-bundle", "#e3c060"), ("saw_benches", "Saw benches", "lumber_shed", 1, 15, {"oak_plank": 20, "iron_bar": 10}, "Sawing planks is 20% faster", "hand-saw", "#8f5e30"), ("shelving", "Shelving", "storehouse", 2, 20, {"oak_plank": 40, "iron_bar": 10}, "+1 bank slot for everyone", "bookshelf", "#8f5e30"), ("map_table", "Map table", "spymaster", 1, 30, {"willow_plank": 25, "hard_leather": 10}, "Scouting gathers one more point of reconnaissance", "treasure-map", "#a3b86c"), ("well_pump", "Well pump", "well", 2, 30, {"willow_plank": 25, "steel_bar": 10}, "Half of all fires are put out before they spread", "water-tank", "#5aa0d8"), ("furniture", "Furniture", "houses", 2, 35, {"willow_plank": 40, "leather": 30}, "Population grows 25% faster", "sofa", "#a3b86c"), ("weapon_racks", "Weapon racks", "barracks", 2, 40, {"willow_plank": 30, "steel_bar": 20}, "Militia defence +50%", "spears", "#d4dae2"), ("hoardings", "Hoardings", "walls", 3, 45, {"maple_plank": 40, "mithril_bar": 10}, "+25 defence", "palisade", "#d0663a"), ("bellows", "Great bellows", "smithy", 3, 50, {"maple_plank": 30, "bear_leather": 15}, "Smithing another 10% faster", "bellows", "#d0663a"), ("carved_altar", "Carved altar", "temple", 2, 60, {"yew_plank": 30, "cut_stone": 60}, "+1 prayer point per bone buried, and offerings can ward 15 more", "star-altar", "#6f9a4a"), ("enchanted_chests", "Enchanted chests", "storehouse", 4, 75, {"magic_plank": 40, "rune_bar": 5}, "+500 per bank stack for everyone", "open-treasure-chest", "#a77be8"), ("enchanted_hearths", "Enchanted hearths", "houses", 4, 80, {"magic_plank": 50, "red_dragon_leather": 5}, "Everyone recovers HP twice as fast", "fireplace", "#a77be8"), ] 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 _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=1.5, text="The sick need more food: the city eats half again as much") _record(world, now, "hardship", kind, "๐Ÿค’", "Sickness is spreading through the city", [f"For about {round(hours)} hours the townsfolk eat half again as much.", "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="The city burns firewood from the stockpile every hour; without it, people go cold and hungry") _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 half again as much."]) 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} earns 50% more 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 yields 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 yields 50% more") _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 yields 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="The population grows twice as fast, and giving to the city earns 50% more favour") _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=f"A sellsword company offers +25 defence for the coming threat for {cost:,} gold from the treasury") _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, "๐Ÿ“ฆ", "Lost cargo was found on the road", [f"{_listing(dict(goods))} went into the stockpile."]) 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"] weather = city["weather"] if now >= weather["until"]: weather["kind"] = weather["next"] weather["until"] = now + rng.uniform(4, 8) * HOUR / speed(world) weather["next"] = _roll_weather(world, weather["until"], rng, weather["kind"]) 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 RALLY_FAVOUR = 150 RALLY_DEFENCE = 10 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": INFORMANT_FAVOUR, "why": "No news to dig into" if not raid else "Nothing more to learn" if raid["recon"] >= intel_costs(raid)[-1] else None, }, "militia": { "cost": RALLY_FAVOUR * (city.get("rallies", 0) + 1), "why": None if raid else "No raid to prepare for", "rallied": city.get("rally", 0), }, } 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": upcoming_raid(world)["recon"] += INFORMANT_RECON log(world, now, "favour", f"{player['name']} paid an informant for news from the roads", player["id"]) else: city["rally"] = city.get("rally", 0) + RALLY_DEFENCE city["rallies"] = city.get("rallies", 0) + 1 log(world, now, "favour", f"{player['name']} rallied the militia (+{RALLY_DEFENCE} defence for the coming raid)", 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"} 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"), "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"), "masonry": ("brick-wall", "#c66a45"), "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"), } CLASS_GLYPHS = {"warrior": ("black-knight-helm", "#c0c6d0"), "scout": ("bow-arrow", "#8fbf6a"), "artisan": ("anvil-impact", "#e0a050"), "pioneer": ("compass", "#4fb3d9")} 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"), "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"), } 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")} UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield"] def _assign_glyphs() -> None: for item, (glyph, tint) in GLYPHS.items(): ITEMS[item].update(glyph=glyph, tint=tint) for key, _name, ore, *_rest in 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) 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 herb, tint in HERB_TINTS.items(): ITEMS[herb].update(glyph="herbs-bundle", tint=tint) for potion, *_rest, skill, _effect in POTIONS: if skill == "combat": ITEMS[potion].update(glyph="potion-ball", tint="#e05a5a") else: ITEMS[potion].update(glyph="round-bottom-flask", tint=SKILL_GLYPHS[skill][1]) 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, "informant_recon": INFORMANT_RECON, "regen_per_hour": REGEN_PER_HOUR, }