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