guildhall/game.py

6227 lines
336 KiB
Python
Raw Normal View History

"""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
import combat
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
REST_RATE = 0.01 # share of max HP back per game second resting between fights...
BITE = 2.0 # ...and each bite of food takes this long, so food rests you faster
EAT_BELOW = 0.5 # unless you choose otherwise, you only eat while under this share of your HP...
EAT_CHOICES = (0, 0.25, 0.5, 0.75, 1.0) # ...and these are the choices (never, up to always)
KNOCKED_OUT_HP = 0.3 # share of max HP you come round with after being knocked out
KNOCKOUTS_TO_STOP = 3 # in a row, and you stop fighting
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 resting between fights.
"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, and a workbench the building site too.
# 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, shared by everyone whose pile is hot enough 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
LEATHERS = [
(("hide", "Hide", "🟫", 3), ("leather", "Leather", 5), "leather", "Leather"),
(("boar_hide", "Boar hide", "🐗", 8), ("hard_leather", "Hard leather", 12), "hard_leather", "Hard leather"),
(("bear_pelt", "Bear pelt", "🐻", 16), ("bear_leather", "Bear leather", 24), "bear_leather", "Bear leather"),
(("elk_hide", "Elk hide", "🦌", 35), ("elk_leather", "Elk leather", 50), "elk_leather", "Elk leather"),
(("sabrecat_pelt", "Sabrecat pelt", "🐆", 60), ("sabrecat_leather", "Sabrecat leather", 85),
"sabrecat_leather", "Sabrecat leather"),
(("mammoth_hide", "Mammoth hide", "🦣", 100), ("mammoth_leather", "Mammoth leather", 140),
"mammoth_leather", "Mammoth leather"),
]
# 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) 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)}}
# Health potions: the one heal you take into a fight, drunk once a fight when you're low.
# Each tier is the tier's herb and its cooked fish.
HEALTH_HEAL = [20, 40, 65, 90, 115, 140]
HEALTH_POTIONS = [(f"health_potion_{_tier + 1}", f"Health potion {ROMAN[_tier]}", TIER_LEVELS[_tier], HERBS[_tier][0],
FISH[_tier][1][0], HEALTH_HEAL[_tier]) for _tier in range(len(HEALTH_HEAL))]
for _id, _name, _level, _herb, _fish, _heal in HEALTH_POTIONS:
ITEMS[_id] = {"name": _name, "icon": "❤️", "value": ITEMS[_herb]["value"] * 3 + ITEMS[_fish]["value"], "drink": _heal}
# 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) in enumerate(LEATHERS):
actions.append(_action("tan_leather" if tier == 0 else f"tan_{leather[0]}", "leatherworking",
f"Tan {leather[1].lower()}", TIER_LEVELS[tier], 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}", TIER_LEVELS[tier] + 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 = combat.CRIT_MULT
RESPAWN = 2 # the least rest between one fight and the next
GROUP_SIZE = 2 # bosses and dragons are only fought this many together...
BOSS_HP = 0.75 # ...and grow by this much of their HP for each fighter...
BOSS_HITS = (1.3, 0.15) # ...and hit this hard against a pair, and this much harder for each fighter more
MAX_FIGHT = combat.MAX_FIGHT
PACK_EXTRA = 0.5 # chance a pack has one more enemy than there are of you...
OUTNUMBERED = (0.65, 0.65) # ...each of them then with this share of the HP and the hits
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.10 # weapons arm the town as well as the hunter, so as common as reagents
BOSS_TROPHY_ODDS = 0.20 # 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("_")
TOUGHER = 1.2 # enemies have this much more HP since everyone has abilities, for the same XP
# 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.2, 1),
"dragon": (2.5, 3.0, 1.1, 1.0, 1.9, 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": round(hp * TOUGHER), "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, req: int | None = None, **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. `req` is the combat level to start, if not the enemy's own."""
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, req or 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:
"""One big enemy, and a group fight: alone you wait for someone to tag in. Everyone who brings
it down gets their own loot."""
return _encounter(id, name, group, level, [(name, archetype, attack, vulnerable, resists)],
boss=extra.pop("is_boss", True), personal_loot=True, together=GROUP_SIZE, **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")]
SPIDERS = [("Cave spider", "skirmisher", "pierce", "blunt", "pierce"),
("Bloated spider", "brute", "magic", "slash", "blunt"),
("Spider matriarch", "elite", "pierce", "magic", "slash")]
SMUGGLERS = [("Smuggler", "skirmisher", "slash", "blunt", "slash"),
("Smuggler bruiser", "brute", "blunt", "pierce", "blunt"),
("Smuggler hexer", "caster", "magic", "slash", "pierce")]
# 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], TIER_LEVELS[tier] + 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(),
# Area bosses open at the area's level + 5, halfway through it. Bosses and dragons are group fights.
_boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 26, "boss", ("blunt", "magic"), ("slash", "magic"),
("blunt", "pierce"), gold=25, req=20,
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, req=35,
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, req=50,
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, req=65,
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, req=80,
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, req=90,
drops=[("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_drops(5, 0.8)]),
]
# --- dungeons ----------------------------------------------------------------
# A run of pulls for a group of two to four, in order, ending in the area's boss. Built for four:
# a smaller group meets lighter packs. Trash a group can clear on auto; the bosses it can't. They
# cast what has to be interrupted, hit what has to be braced for, call in help, and lose patience.
DUNGEON_SIZE = 4 # the most in a run
DUNGEON_WIPES = 3 # on one pull, and the run is over
DUNGEON_AWAY = 90 # real seconds without looking before a run waits for you
TRASH = (1.5, 1.5) # a pull's HP (and XP) and hits against the open world's
MINI_BOSS = 5.0 # a mini-boss's HP (and XP, and loot odds) against its race's elite
FINAL_BOSS = (2.5, 1.3) # the area boss's (and its adds') HP and hits against the open-world one
def dungeon_scale(size: int) -> tuple[float, float]:
"""HP and hits of a pull for a group this size."""
return 0.25 * size, 0.4 + 0.15 * size
def _dungeon_foe(action_id: str, variant: str, hp: float = 1.0, hits: float = 1.0, loot: float | None = None,
level: int | None = None, archetype: str | None = None, name: str | None = None, big: bool = False,
**extra) -> dict:
"""One of a race's variants, as a dungeon enemy: `hp` times as tough (and worth as much more XP),
hitting `hits` times as hard. It drops what its race does, `loot` (else `hp`) times as often. It can
stand at another `level`, as another `archetype` (`big` for a boss's size), under another `name`."""
action = next(a for a in COMBAT_ACTIONS if a["id"] == action_id)
foe = next(v for v in action["enemy"]["variants"] if v["name"] == variant)
if level or archetype or big:
foe = monster(name or foe["name"], archetype or foe["archetype"], level or foe["level"], foe["attack"],
foe["vulnerable"], foe["resists"], boss=big)
foe = {**foe, "name": name or foe["name"]}
return {**{key: foe[key] for key in ("name", "archetype", "level", "interval", "accuracy", "evasion",
"attack", "vulnerable", "resists")},
"boss": extra.pop("boss", False), "hp": round(foe["hp"] * hp), "max_hit": max(1, round(foe["max_hit"] * hits)),
"xp": foe["xp"] * hp, "share": hp if loot is None else loot, "loot": action_id, **extra}
DUNGEONS = {
"dungeon_watchtower": {
"name": "Watchtower Depths", "area": "Old Watchtower", "tier": 1, "level": 20, "bonus_gold": 100,
"pulls": [
("Gatehouse", [_dungeon_foe("fight_clay_golems", "Clay brute", *TRASH, 1), _dungeon_foe("fight_clay_golems", "Mud shaper", *TRASH, 1),
_dungeon_foe("fight_clay_golems", "Clay brute", *TRASH, 1)]),
("Broken stair", [_dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1), _dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1),
_dungeon_foe("fight_harpies", "Harpy windcaster", *TRASH, 1)]),
("Clay colossus", [_dungeon_foe("fight_clay_golems", "Clay colossus", MINI_BOSS, 3.0, boss=True,
abilities=["stone_curse", "shatter"], enrage_at=150)]),
("Barracks", [_dungeon_foe("fight_restless_dead", "Skeleton warrior", *TRASH, 1),
_dungeon_foe("fight_restless_dead", "Skeleton warrior", *TRASH, 1),
_dungeon_foe("fight_restless_dead", "Wisp", *TRASH, 1), _dungeon_foe("fight_restless_dead", "Necromancer", *TRASH, 1)]),
("Troll den", [_dungeon_foe("fight_cave_trolls", "Troll bruiser", *TRASH, 1), _dungeon_foe("fight_cave_trolls", "Troll rockhurler", *TRASH, 1),
_dungeon_foe("fight_cave_trolls", "Troll hexer", *TRASH, 1)]),
("Harpy matriarch", [_dungeon_foe("fight_harpies", "Harpy matriarch", MINI_BOSS, 5.5, boss=True,
abilities=["shriek", "lunge"], enrage_at=150,
phases=[{"at": 0.5, "say": "To me, sisters!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1, dormant=True),
_dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1, dormant=True)]),
("Crypt", [_dungeon_foe("fight_restless_dead", "Necromancer", *TRASH, 1), _dungeon_foe("fight_restless_dead", "Necromancer", *TRASH, 1),
_dungeon_foe("fight_restless_dead", "Skeleton warrior", *TRASH, 1)]),
("Watchtower ogre", [_dungeon_foe("fight_ruins", "Watchtower ogre", FINAL_BOSS[0], FINAL_BOSS[1], 2,
boss=True, abilities=["stone_curse", "ground_pound", "heavy_slam"],
enrage_at=210, phases=[{"at": 0.5, "say": "Crush them!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_cave_trolls", "Troll bruiser", *FINAL_BOSS, 1, dormant=True),
_dungeon_foe("fight_cave_trolls", "Troll bruiser", *FINAL_BOSS, 1, dormant=True)]),
],
},
"dungeon_goblin_warren": {
"name": "Goblin Warren", "area": "Greenwood", "tier": 0, "level": 8, "bonus_gold": 30,
"pulls": [
("Wolf den", [_dungeon_foe("fight_wolves", v, *TRASH, 1, level=6) for v in ("Grey wolf", "Timber wolf", "Grey wolf")]),
("Tunnel mouth", [_dungeon_foe("fight_goblins", v, *TRASH, 1, level=6)
for v in ("Goblin brute", "Goblin rogue", "Goblin warlock")]),
("Old One-Eye", [_dungeon_foe("fight_wolves", "Wolf alpha", MINI_BOSS, 3.0, level=7, name="Old One-Eye", boss=True,
abilities=["crushing_decree", "lunge"], enrage_at=150,
phases=[{"at": 0.5, "say": "Aaaooo!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_wolves", "Grey wolf", *TRASH, 1, level=6, dormant=True),
_dungeon_foe("fight_wolves", "Grey wolf", *TRASH, 1, level=6, dormant=True)]),
("Fungus cave", [_dungeon_foe("fight_goblins", v, *TRASH, 1, level=6)
for v in ("Goblin rogue", "Goblin rogue", "Goblin warlock")]),
("Grizzik the Hexer", [_dungeon_foe("fight_goblins", "Goblin warlock", MINI_BOSS, 4.0, level=7, archetype="elite",
name="Grizzik the Hexer", boss=True, abilities=["hex_storm", "hex_bolt"],
enrage_at=150)]),
("Throne cave", [_dungeon_foe("fight_goblins", v, *TRASH, 1, level=6)
for v in ("Goblin brute", "Goblin brute", "Goblin warlock")]),
("Goblin king", [_dungeon_foe("fight_goblins", "Goblin brute", *FINAL_BOSS, 2, level=9, archetype="boss",
name="Goblin king", big=True, boss=True,
abilities=["crushing_decree", "royal_stomp", "heavy_slam"], enrage_at=210,
phases=[{"at": 0.5, "say": "Guards! Guards!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_goblins", "Goblin brute", *FINAL_BOSS, 1, level=6, dormant=True),
_dungeon_foe("fight_goblins", "Goblin brute", *FINAL_BOSS, 1, level=6, dormant=True)]),
],
},
"dungeon_bandit_stronghold": {
"name": "Bandit Stronghold", "area": "Frontier", "tier": 2, "level": 40, "bonus_gold": 300,
"pulls": [
("Palisade", [_dungeon_foe("fight_scouts", v, *TRASH, 1, level=35)
for v in ("Bandit brute", "Bandit archer", "Bandit archer")]),
("Gate", [_dungeon_foe("fight_scouts", v, *TRASH, 1, level=35) for v in ("Bandit brute", "Bandit brute", "Hedge mage")]),
("Bandit captain", [_dungeon_foe("fight_scouts", "Bandit brute", MINI_BOSS, 3.0, level=37, archetype="elite",
name="Bandit captain", boss=True, abilities=["cheap_shot", "heavy_slam"],
enrage_at=150)]),
("Barracks", [_dungeon_foe("fight_scouts", v, *TRASH, 1, level=35)
for v in ("Bandit brute", "Bandit archer", "Bandit archer", "Hedge mage")]),
("Hedge witch", [_dungeon_foe("fight_scouts", "Hedge mage", MINI_BOSS, 5.5, level=37, archetype="elite",
name="Hedge witch", boss=True, abilities=["witchfire", "hex_bolt"], enrage_at=150,
phases=[{"at": 0.5, "say": "Cut them down!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_scouts", "Bandit archer", *TRASH, 1, level=35, dormant=True),
_dungeon_foe("fight_scouts", "Bandit archer", *TRASH, 1, level=35, dormant=True)]),
("Armoury", [_dungeon_foe("fight_scouts", v, *TRASH, 1, level=35) for v in ("Bandit brute", "Hedge mage", "Hedge mage")]),
("Bandit warlord", [_dungeon_foe("fight_stronghold", "Bandit warlord", *FINAL_BOSS, 2, boss=True,
abilities=["execute", "whirlwind", "heavy_slam"], enrage_at=210,
phases=[{"at": 0.5, "say": "To arms!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_scouts", "Bandit brute", *FINAL_BOSS, 1, level=35, dormant=True),
_dungeon_foe("fight_scouts", "Bandit brute", *FINAL_BOSS, 1, level=35, dormant=True)]),
],
},
"dungeon_barrow_crypts": {
"name": "Barrow Crypts", "area": "Sunken Barrows", "tier": 2, "level": 35, "bonus_gold": 250,
"pulls": [
("Barrow mound", [_dungeon_foe("fight_stone_guardians", v, *TRASH, 1)
for v in ("Stone guardian", "Barrow sentry", "Barrow sentry")]),
("Flooded hall", [_dungeon_foe("fight_bog_wraiths", v, *TRASH, 1)
for v in ("Drowned knight", "Bog wraith", "Marsh wisp")]),
("Runebound guardian", [_dungeon_foe("fight_stone_guardians", "Runebound guardian", MINI_BOSS, 3.0, boss=True,
abilities=["rune_blast", "shatter"], enrage_at=150)]),
("Lizard warren", [_dungeon_foe("fight_lizardfolk", v, *TRASH, 1)
for v in ("Lizard warrior", "Lizard spearman", "Lizard spearman", "Lizard shaman")]),
("Brood mother", [_dungeon_foe("fight_giant_spiders", "Brood mother", MINI_BOSS, 5.5, boss=True,
abilities=["venom_spray", "lunge"], enrage_at=150,
phases=[{"at": 0.5, "say": "The eggs are hatching!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_giant_spiders", "Spider hunter", *TRASH, 1, dormant=True),
_dungeon_foe("fight_giant_spiders", "Spider hunter", *TRASH, 1, dormant=True)]),
("Bone gallery", [_dungeon_foe("fight_bog_wraiths", v, *TRASH, 1) for v in ("Bog wraith", "Bog wraith", "Drowned knight")]),
("Barrow king", [_dungeon_foe("fight_barrow_king", "Barrow king", *FINAL_BOSS, 2, boss=True,
abilities=["grave_chill", "soul_rend", "heavy_slam"], enrage_at=210,
phases=[{"at": 0.5, "say": "Rise, my knights!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_bog_wraiths", "Drowned knight", *FINAL_BOSS, 1, dormant=True),
_dungeon_foe("fight_bog_wraiths", "Drowned knight", *FINAL_BOSS, 1, dormant=True)]),
],
},
"dungeon_deep_forge": {
"name": "The Deep Forge", "area": "Iron Hills", "tier": 3, "level": 50, "bonus_gold": 400,
"pulls": [
("Mine shaft", [_dungeon_foe("fight_mine_ghosts", v, *TRASH, 1) for v in ("Lost miner", "Lantern ghost", "Shaft howler")]),
("Orc camp", [_dungeon_foe("fight_orcs", v, *TRASH, 1) for v in ("Orc berserker", "Orc archer", "Orc shaman")]),
("Iron colossus", [_dungeon_foe("fight_iron_golems", "Iron colossus", MINI_BOSS, 3.0, boss=True,
abilities=["forge_blast", "shatter"], enrage_at=150)]),
("Foundry", [_dungeon_foe("fight_iron_golems", v, *TRASH, 1) for v in ("Iron golem", "Forge golem", "Forge golem")]),
("Orc warchief", [_dungeon_foe("fight_orcs", "Orc berserker", MINI_BOSS, 5.5, archetype="elite", name="Orc warchief",
boss=True, abilities=["war_cry", "lunge"], enrage_at=150,
phases=[{"at": 0.5, "say": "Blood and iron!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_orcs", "Orc berserker", *TRASH, 1, dormant=True),
_dungeon_foe("fight_orcs", "Orc berserker", *TRASH, 1, dormant=True)]),
("Wyvern roost", [_dungeon_foe("fight_wyverns", v, *TRASH, 1) for v in ("Wyvern", "Wyvern hatchling", "Venomspitter")]),
("Green dragon", [_dungeon_foe("fight_green_dragon", "Green dragon", *FINAL_BOSS, 2, boss=True,
abilities=["acid_spit", "acid_breath", "tail_swipe"], enrage_at=210,
phases=[{"at": 0.5, "say": "The brood answers!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_wyverns", "Wyvern", *FINAL_BOSS, 1, dormant=True),
_dungeon_foe("fight_wyverns", "Wyvern", *FINAL_BOSS, 1, dormant=True)]),
],
},
"dungeon_frozen_hold": {
"name": "Frozen Hold", "area": "Frostpeak", "tier": 4, "level": 65, "bonus_gold": 550,
"pulls": [
("Ice gate", [_dungeon_foe("fight_ice_elementals", v, *TRASH, 1) for v in ("Ice brute", "Frost wisp", "Frost wisp")]),
("Troll cave", [_dungeon_foe("fight_frost_trolls", v, *TRASH, 1)
for v in ("Frost troll", "Troll icehurler", "Troll rimecaller")]),
("Glacier colossus", [_dungeon_foe("fight_ice_elementals", "Glacier colossus", MINI_BOSS, 3.0, boss=True,
abilities=["deep_freeze", "shatter"], enrage_at=150)]),
("Griffin eyrie", [_dungeon_foe("fight_griffins", v, *TRASH, 1) for v in ("Griffin", "Griffin rider", "Storm griffin")]),
("Banshee queen", [_dungeon_foe("fight_banshees", "Banshee", MINI_BOSS, 5.5, archetype="elite", name="Banshee queen",
boss=True, abilities=["wail", "lunge"], enrage_at=150,
phases=[{"at": 0.5, "say": "Sing with me!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_banshees", "Keening shade", *TRASH, 1, dormant=True),
_dungeon_foe("fight_banshees", "Keening shade", *TRASH, 1, dormant=True)]),
("Hall of echoes", [_dungeon_foe("fight_banshees", v, *TRASH, 1) for v in ("Wailing knight", "Banshee", "Banshee")]),
("Blue dragon", [_dungeon_foe("fight_blue_dragon", "Blue dragon", *FINAL_BOSS, 2, boss=True,
abilities=["frost_lance", "frost_breath", "tail_swipe"], enrage_at=210,
phases=[{"at": 0.5, "say": "Freeze!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_ice_elementals", "Ice brute", *FINAL_BOSS, 1, dormant=True),
_dungeon_foe("fight_ice_elementals", "Ice brute", *FINAL_BOSS, 1, dormant=True)]),
],
},
"dungeon_dragons_lair": {
"name": "Dragon's Lair", "area": "Dragon's Spine", "tier": 5, "level": 80, "bonus_gold": 750,
"pulls": [
("Lava tubes", [_dungeon_foe("fight_magma_constructs", v, *TRASH, 1)
for v in ("Magma brute", "Cinder golem", "Fire binder")]),
("Shadow pass", [_dungeon_foe("fight_shades", v, *TRASH, 1) for v in ("Shade", "Shadow blade", "Shadow blade")]),
("Void knight", [_dungeon_foe("fight_shades", "Void knight", MINI_BOSS, 3.0, boss=True,
abilities=["void_bolt", "shatter"], enrage_at=150)]),
("Roc nests", [_dungeon_foe("fight_rocs", v, *TRASH, 1) for v in ("Roc", "Roc hatchling", "Thunder roc")]),
("Flame high priest", [_dungeon_foe("fight_dragonkin", "Flame priest", MINI_BOSS, 5.5, archetype="elite",
name="Flame high priest", boss=True, abilities=["flame_wave", "hex_bolt"],
enrage_at=150, phases=[{"at": 0.5, "say": "Burn!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_dragonkin", "Flame priest", *TRASH, 1, dormant=True),
_dungeon_foe("fight_dragonkin", "Flame priest", *TRASH, 1, dormant=True)]),
("Drakeguard hall", [_dungeon_foe("fight_dragonkin", v, *TRASH, 1)
for v in ("Drakeguard", "Drake lancer", "Flame priest")]),
("Red dragon", [_dungeon_foe("fight_red_dragon", "Red dragon", *FINAL_BOSS, 2, boss=True,
abilities=["inferno", "fire_breath", "tail_swipe"], enrage_at=210,
phases=[{"at": 0.5, "say": "Guards!", "wake": ["e1", "e2"]}]),
_dungeon_foe("fight_dragonkin", "Drakeguard", *FINAL_BOSS, 1, dormant=True),
_dungeon_foe("fight_dragonkin", "Drakeguard", *FINAL_BOSS, 1, dormant=True)]),
],
},
}
# Each boss pull's last touch, HP and hits, so that a group on auto loses it and one that plays
# wins it (tools/balance.py dungeon, and dungeon perfect).
DUNGEON_TUNING = {
("dungeon_goblin_warren", "Old One-Eye"): (0.8, 0.8), ("dungeon_goblin_warren", "Grizzik the Hexer"): (1.5, 0.5),
("dungeon_goblin_warren", "Goblin king"): (0.8, 1.2),
("dungeon_bandit_stronghold", "Bandit captain"): (1.0, 1.2), ("dungeon_bandit_stronghold", "Hedge witch"): (1.25, 0.65),
("dungeon_bandit_stronghold", "Bandit warlord"): (1.5, 1.4),
("dungeon_barrow_crypts", "Runebound guardian"): (0.8, 1.2), ("dungeon_barrow_crypts", "Brood mother"): (1.0, 1.2),
("dungeon_barrow_crypts", "Barrow king"): (1.5, 1.0),
("dungeon_deep_forge", "Orc warchief"): (1.0, 1.3), ("dungeon_deep_forge", "Green dragon"): (1.25, 1.2),
("dungeon_frozen_hold", "Glacier colossus"): (1.0, 1.2), ("dungeon_frozen_hold", "Banshee queen"): (1.25, 1.0),
("dungeon_frozen_hold", "Blue dragon"): (1.5, 1.2),
("dungeon_dragons_lair", "Void knight"): (1.5, 1.0), ("dungeon_dragons_lair", "Flame high priest"): (0.8, 1.2),
("dungeon_dragons_lair", "Red dragon"): (1.5, 1.4),
}
def _tune_dungeons() -> None:
for key, spec in DUNGEONS.items():
for index, (name, pack) in enumerate(spec["pulls"]):
hp, hits = DUNGEON_TUNING.get((key, name), (1, 1))
spec["pulls"][index] = (name, [{**foe, "hp": round(foe["hp"] * hp), "max_hit": max(1, round(foe["max_hit"] * hits))}
for foe in pack])
_tune_dungeons()
def _dungeon_actions() -> list[dict]:
actions = []
for key, spec in DUNGEONS.items():
bosses = [foe for _name, pack in spec["pulls"] for foe in pack if foe["boss"]]
variants = [{**foe, "weight": 1} for foe in bosses]
actions.append(_action(key, "combat", spec["name"], spec["level"], RESPAWN,
round(sum(foe["xp"] for _name, pack in spec["pulls"] for foe in pack)),
enemy={"name": spec["name"], "level": spec["level"], "boss": True, "variants": variants},
group=spec["area"], dungeon=key, together=GROUP_SIZE, personal_loot=True,
gold=[spec["bonus_gold"], spec["bonus_gold"]],
drops=[list(d) for d in _boss_drops(spec["tier"])] if spec["tier"] else bosses[-1].get("drops", []),
pulls=[name for name, _pack in spec["pulls"]]))
return actions
COMBAT_ACTIONS += _dungeon_actions()
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(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6),
_fuelled({herb: 1, fish: 1}, tier), {potion: 1}, group=f"Tier {ROMAN[tier]}", fire="still", fuel=tier)
for tier, (potion, name, level, herb, fish, _heal) in enumerate(HEALTH_POTIONS)],
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}),
_action("hunt_deer", "hunting", "Hunt deer", 1, 25, 6, outputs={"raw_meat": 2, "hide": 1}),
_action("hunt_boar", "hunting", "Hunt boar", 15, 35, 15, outputs={"raw_meat": 2, "boar_hide": 1}),
_action("hunt_bear", "hunting", "Hunt bears", 30, 45, 28, outputs={"raw_meat": 3, "bear_pelt": 1}),
_action("hunt_elk", "hunting", "Hunt elk", 45, 55, 45, outputs={"raw_meat": 3, "elk_hide": 1}),
_action("hunt_sabrecat", "hunting", "Track sabrecats", 60, 65, 62, outputs={"raw_meat": 3, "sabrecat_pelt": 1}),
_action("hunt_mammoth", "hunting", "Hunt mammoths", 75, 80, 85, 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", fire="kitchen", fuel=0),
*[_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)],
]
# --- dungeon loot --------------------------------------------------------------
# Dungeons don't hand out finished gear: crafters still make it. Every boss in a dungeon drops its
# relic, and now and then a pattern: a crafter who learns it can reforge one of the tier's weapons
# or body pieces into a masterwork with relics. The final boss also has one unique, worn in a
# jewellery slot, that changes an ability.
MASTERWORK = 1.3 # a masterwork's power or armour and ward against the piece it's reforged from
PATTERN_ODDS = (0.15, 0.35) # each player's chance at one of the dungeon's patterns, from a mini-boss and the final boss
RELIC_ODDS = (0.5, 1.0) # ...and at its relic (two from the final boss)
UNIQUE_ODDS = 0.08 # each player's chance at the final boss's unique
MASTERWORK_RELICS = 3
# Per dungeon: what its masterworks are called, its relic's name, and its unique.
DUNGEON_LOOT = {
"dungeon_goblin_warren": ("Kingsguard", "Warren relic", "one_eyes_fang"),
"dungeon_watchtower": ("Ogre-forged", "Watchtower relic", "ogres_knuckle"),
"dungeon_barrow_crypts": ("Barrow-blessed", "Barrow relic", "barrow_kings_pendant"),
"dungeon_bandit_stronghold": ("Warlord's", "Stronghold relic", "warlords_whistle"),
"dungeon_deep_forge": ("Deepforged", "Deep Forge relic", "forgeheart"),
"dungeon_frozen_hold": ("Rimeforged", "Frozen Hold relic", "rimeglass_lens"),
"dungeon_dragons_lair": ("Dragonforged", "Dragon's Lair relic", "wyrmscale_quiver"),
}
# Uniques: (name, slot, glyph, tint, what it does, the ability changes).
UNIQUES = {
"one_eyes_fang": ("One-Eye's fang", "amulet", "fangs", "#e0e0d0", "Shield Bash also taunts for 3s.",
{"shield_bash": {"extra": [{"t": "taunt", "dur": 3}]}}),
"ogres_knuckle": ("Ogre's knuckle", "ring", "fist", "#d1a46a", "Crushing Blow winds up in 1s and stuns for 1s.",
{"crushing_blow": {"cast": 2 / 3, "extra": [{"t": "stun", "dur": 1}]}}),
"barrow_kings_pendant": ("Barrow king's pendant", "amulet", "gem-pendant", "#8fb8f0",
"Mend also heals the next most hurt ally for half.",
{"mend": {"extra": [{"t": "heal", "c": 1.0, "second": True}]}}),
"warlords_whistle": ("Warlord's whistle", "trinket", "whistle", "#a08060",
"Shield Bash, Concuss and Pinning Shot come back 30% sooner.",
{"shield_bash": {"cd": 0.7}, "concuss": {"cd": 0.7}, "pinning_shot": {"cd": 0.7}}),
"forgeheart": ("Forgeheart", "amulet", "heart-tower", "#ff8a3a", "Barricade lasts 12s and comes back 25% sooner.",
{"barricade": {"dur": 4, "cd": 0.75}}),
"rimeglass_lens": ("Rimeglass lens", "trinket", "crystal-ball", "#8fd0e0",
"Find the Seam and Mark Prey last twice as long; Ward also shields a second ally.",
{"find_the_seam": {"dur": 8}, "mark_prey": {"dur": 15},
"ward": {"extra": [{"t": "shield", "c": 1.0, "dur": 20, "second": True}]}}),
"wyrmscale_quiver": ("Wyrmscale quiver", "trinket", "quiver", "#e05a5a",
"Volley hits 40% harder; Aimed Shot aims 25% faster.",
{"volley": {"c": 1.4}, "aimed_shot": {"cast": 0.75}}),
}
# The pieces every dungeon's patterns reforge, by what they're made of at each tier.
MASTERWORK_PIECES = ["sword", "warhammer", "staff", "bow", "heavy", "medium", "light"]
MASTERWORK_NOUNS = {"sword": "sword", "warhammer": "warhammer", "staff": "staff", "bow": "bow",
"heavy": "plate", "medium": "jerkin", "light": "robe"}
MASTERWORK_TINT = "#f0c040"
def _tier_piece(kind: str, tier: int) -> str:
"""The tier's weapon of a style, or its body piece of an armour weight."""
level = ["combat", TIER_LEVELS[tier]]
for item, spec in ITEMS.items():
if spec.get("req") != level:
continue
if kind in ("heavy", "medium", "light") and spec.get("weight") == kind and spec.get("slot") == "body":
return item
if spec.get("style") == {"warhammer": "hammer"}.get(kind, kind):
return item
raise KeyError((kind, tier))
def _dungeon_loot() -> None:
"""Relics, patterns, masterworks and uniques, as items; the masterwork recipes, as actions; and each
dungeon boss's share of them, as its extra drops."""
made = {output: action for action in ACTIONS for output in action["outputs"]}
for key, (prefix, relic_name, unique) in DUNGEON_LOOT.items():
spec = DUNGEONS[key]
tier = spec["tier"]
relic = _slug(relic_name)
ITEMS[relic] = {"name": relic_name, "icon": "🏺", "value": 40 + 40 * tier, "relic": key}
patterns = []
for kind in MASTERWORK_PIECES:
base = _tier_piece(kind, tier)
item, recipe = f"{_slug(prefix)}_{kind}", made[base]
pattern = f"pattern_{item}"
name = f"{prefix} {MASTERWORK_NOUNS[kind]}"
stats = {stat: max(ITEMS[base][stat] + 1, round(ITEMS[base][stat] * MASTERWORK))
for stat in ("power", "armour", "ward") if ITEMS[base].get(stat)}
ITEMS[item] = {**{k: v for k, v in ITEMS[base].items() if k not in ("glyph", "tint", "plural")},
**stats, "name": name, "value": ITEMS[base]["value"] * 3 + ITEMS[relic]["value"] * MASTERWORK_RELICS,
"req": ["combat", spec["level"]], "masterwork": key, "base": base}
ITEMS[pattern] = {"name": f"Pattern: {name}", "icon": "📜", "value": 100 + 50 * tier, "pattern": item,
"skill": recipe["skill"], "level": recipe["level"] + 8}
reforge = _action(f"reforge_{item}", recipe["skill"], name, recipe["level"] + 8, round(recipe["time"] * 1.5),
recipe["xp"] * 3, {base: 1, relic: MASTERWORK_RELICS}, {item: 1}, group="Masterwork",
pattern=pattern)
ACTIONS.append(reforge)
ACTION_BY_ID[reforge["id"]] = reforge
patterns.append(pattern)
name, slot, _glyph, _tint, text, mods = UNIQUES[unique]
ITEMS[unique] = {"name": name, "icon": "💠", "value": 500 + 250 * tier, "slot": slot, "unique": text, "mods": mods,
"req": ["combat", spec["level"]]}
ACTION_BY_ID[key]["loot"] = {"relic": relic, "patterns": patterns, "unique": unique,
"odds": {"pattern": PATTERN_ODDS, "unique": UNIQUE_ODDS}}
bosses = [foe for _name, pack in spec["pulls"] for foe in pack if foe["boss"]]
for foe in bosses:
final = foe is bosses[-1]
odds = PATTERN_ODDS[final] / len(patterns)
foe["extra_drops"] = [(relic, 1 + final, RELIC_ODDS[final]), *[(p, 1, odds) for p in patterns],
*([(unique, 1, UNIQUE_ODDS)] if final else [])]
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 = 31
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},
"wages": 0, "monuments": {}, "built": [], "carving": None,
},
"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,
"parties": {}, "next_party_id": 1,
"caravan": None, "next_caravan_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
if world["version"] < 25:
# Fighting together: fights are kept per fighter now, so the one in progress is drawn up again.
world.setdefault("parties", {})
world.setdefault("next_party_id", 1)
for player in world["players"].values():
if player["action"]:
player["action"].pop("fight", None)
world["version"] = 25
if world["version"] < 26:
# Packs and abilities: fights in progress are drawn up again.
for player in world["players"].values():
if player["action"]:
player["action"].pop("fight", None)
for party in world["parties"].values():
party["fight"] = None
world["version"] = 26
if world["version"] < 27:
# Livelier happenings: tracks waiting longer than their new cooldowns are brought forward,
# and a city with no threat coming hears rumours of the next one straight away.
for track, (_low, high) in TRACK_HOURS.items():
key = f"next_{track}_at"
if world.get(key):
world[key] = min(world[key], now + high * HOUR / speed(world))
if world["next_event_at"] and not upcoming_event(world):
world["next_event_at"] = min(world["next_event_at"], now)
world["version"] = 27
if world["version"] < 28:
# Abilities in your own hands.
for player in world["players"].values():
player.setdefault("manual", [])
world["version"] = 28
if world["version"] < 29:
# Guards are paid now. The council covers the coming threat once, so nobody is caught out.
world["city"]["wages"] = guard_posts(world) * GUARD_WAGE
# Gold to spend: rented slots, caravan wares, a look of your own, monuments.
for player in world["players"].values():
for key, value in (("rented", 0), ("boosts", []), ("bought", None), ("deeds", {}), ("owned", [])):
player.setdefault(key, value)
player.setdefault("look", dict(DEFAULT_LOOK))
world.setdefault("caravan", None)
world.setdefault("next_caravan_id", 1)
for key, value in (("monuments", {}), ("built", []), ("carving", None)):
world["city"].setdefault(key, value)
world["version"] = 29
if world["version"] < 30:
# Masterwork patterns, learned by crafters.
for player in world["players"].values():
player.setdefault("patterns", [])
world["version"] = 30
if world["version"] < 31:
# Uniques ever found, for achievements: whatever's held now.
for player in world["players"].values():
held = [*player["bank"], *player["equip"].values()]
player.setdefault("found", [item for item in UNIQUES if item in held])
world["version"] = 31
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}, "manual": [], "patterns": [], "found": [], "eat_below": EAT_BELOW,
"rented": 0, "boosts": [], "bought": None, "deeds": {}, "owned": [], "look": dict(DEFAULT_LOOK),
"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"] + player["rented"] + 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."""
if ITEMS[item].get("unique") and item not in player["found"]:
player["found"].append(item) # for achievements: once found, always found
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_boost(world: dict | None, player: dict) -> float:
"""How much faster than usual you heal: the enchanted hearths, and amulets of regeneration."""
boost = 2 if world and fitted(world, "enchanted_hearths") else 1
return boost * (1 + gear_bonus(player, "regen") / 100)
def regen(world: dict, player: dict, now: float) -> None:
"""Heal for the time since HP was last settled."""
if now > player["hp_at"]:
top = max_hp(player)
per_second = top * REGEN_PER_HOUR * regen_boost(world, player) * 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. The weapon and your trade give your
abilities; a sword and shield hold the line, a staff heals."""
style_id = weapon_style(player)
style = STYLES[style_id]
power = combat_power(player) + blessed["power"] + brew.get("power", 0)
base = (2 + power / 3) * 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))
shield = bool(player["equip"].get("shield"))
mods = combat.combine_mods([ITEMS[item]["mods"] for item in player["equip"].values() if item and ITEMS[item].get("mods")])
return {"style": style_id, "damage": style["damage"], "role": combat.role(style_id, shield), "mods": mods,
"abilities": combat.known_abilities(style_id, player["cls"], skill_level(player, "combat"), shield),
"interval": round(style["interval"] * (1 - gear_bonus(player, "haste") / 100), 2),
"accuracy": (10 + 2 * power) * style["accuracy"],
"base": base, "max_hit": max(2, round(base * lands)),
"crit": style["crit"] + gear_bonus(player, "crit") / 100,
"first": style.get("first", False), "affinity": lands,
"block": BLOCK_CHANCE if 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"]
hit_chance = combat.hit_chance
def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float:
return (1 - crit) * (max_hit + 1) / 2 + crit * max_hit * CRIT_MULT
def rest_plan(world: dict | None, player: dict, hp: float | None = None) -> tuple[float, dict]:
"""Resting back to full before a fight: how many game seconds, and the food eaten on the way.
You eat while you're under the share of your HP you chose, and only when a bite is quicker than
resting, so better food makes for shorter rests. The rest of the way you rest."""
top = max_hp(player)
missing = top - (player["hp"] if hp is None else hp)
rate = top * REST_RATE * regen_boost(world, player) / ware(player, "rest", "combat")
hungry = top * (1 - player.get("eat_below", EAT_BELOW)) # eat while more than this is missing
seconds, eaten = 0.0, {}
for food in edible(player):
heal, left = ITEMS[food]["heal"], player["bank"].get(food, 0)
while missing > hungry and missing > 0 and left and min(heal, missing) / rate > BITE:
eaten[food] = eaten.get(food, 0) + 1
left -= 1
missing -= heal
seconds += BITE
return max(RESPAWN, seconds + max(0.0, missing) / rate), eaten
def health_potion(player: dict) -> str | None:
"""The strongest health potion in the bank: the one you'd drink in a fight."""
potions = [item for item in player["bank"] if ITEMS[item].get("drink")]
return max(potions, key=lambda item: ITEMS[item]["drink"]) if potions else None
# How much abilities add to a weapon's swings, and an archetype's abilities to its blows, on
# average (from tools/balance.py). Only the encounter cards' estimates use them.
ABILITY_SHARE = {"sword": 1.15, "hammer": 1.45, "staff": 1.35, "bow": 1.3}
ENEMY_SHARE = {"brute": 1.4, "skirmisher": 1.3, "caster": 1.6, "elite": 1.5, "boss": 1.6, "dragon": 1.8}
GUARD_SHARE = {"sword": 0.73, "hammer": 0.85, "staff": 0.33, "bow": 0.72} # what your abilities keep off you
PACK_SHARE = 1 + PACK_EXTRA * (2 * OUTNUMBERED[0] - 1) # a pack's HP, for one of you, against a lone enemy's
def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict, pack: float = 1.0) -> dict:
me = fighter(player, blessed, brew, foe)
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"])
dps = hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"] * ABILITY_SHARE[me["style"]]
kill = min(foe["hp"] * pack / dps, MAX_FIGHT)
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 *= ENEMY_SHARE[foe["archetype"]] * GUARD_SHARE[me["style"]] * pack
per_second = max(0, per_second - blessed["mend"] * max_hp(player))
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": per_second * kill}
def fight_odds(player: dict, action: dict, world: dict | None = None) -> dict:
"""What a fight in this encounter looks like on average, for the cards, and against each variant:
the time to win it, the damage it costs, and the rest after it."""
blessed, brew = blessing(player), potion_effect(player, "combat")
variants = action["enemy"]["variants"]
pack = PACK_SHARE if len(variants) > 1 else BOSS_HP if action.get("together") else 1 # a boss shared by a pair
rows = [_variant_odds(player, foe, blessed, brew, pack) 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
top = max_hp(player)
rest = rest_plan(world, player, top - min(top, mean("damage")))[0]
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), "rest": round(rest, 1),
"variants": [{"affinity": row["affinity"], "kill_time": round(row["kill_time"], 1),
"damage": round(row["damage"], 1)} for row in rows]}
def fight_member(world: dict, player: dict, at: float = 0.0) -> dict:
"""One side of a fight as it squares up (or tags in `at` seconds of game time in): kept with the
fight, so playing it out again later goes the same way."""
blessed, brew = blessing(player), potion_effect(player, "combat")
me = fighter(player, blessed, brew)
potion = health_potion(player)
return {"id": player["id"], "name": player["name"], "at": at, "left": None,
"start_hp": round(player["hp"]), "hp_in": player["hp"], "top": max_hp(player), "style": me["style"],
"role": me["role"], "abilities": me["abilities"], "interval": me["interval"] / speed(world),
"fighter": me, "eaten": {}, "potion": ITEMS[potion]["drink"] if potion else 0, "potion_item": potion,
"mend": blessed["mend"],
"wind": blessed["wind"] and not (player["action"] or {}).get("wind_used"), "pp": blessed["cost"],
"manual": [a for a in player.get("manual", []) if a in me["abilities"]], "inputs": [], "mods": me["mods"],
"watch": at + combat.WATCH * speed(world)}
FIGHT_PRIVATE = ("fighter", "mend", "wind")
def fight_view(fight: dict) -> dict:
"""A fight as the page sees it: no one's bank or stats."""
return {**fight, "members": [{key: value for key, value in member.items() if key not in FIGHT_PRIVATE}
for member in fight["members"]]}
def member_of(fight: dict, uid: str) -> dict:
return next(member for member in fight["members"] if member["id"] == uid)
def draw_pack(action: dict, size: int, rng: random.Random) -> list[dict]:
"""Who you're up against: one of the encounter's variants, as many as there are of you and
often one more, weaker each when they outnumber you. A rare elite or a boss comes alone."""
variants = action["enemy"]["variants"]
weights = [foe["weight"] for foe in variants]
first = rng.choices(range(len(variants)), weights)[0]
picks, (hp, hits) = [first], (1.0, 1.0)
if action.get("together"):
hp, hits = BOSS_HP * size, BOSS_HITS[0] * (1 + BOSS_HITS[1] * (size - GROUP_SIZE))
if len(variants) > 1 and variants[first]["archetype"] != "elite":
count = size + (rng.random() < PACK_EXTRA)
common = [i for i, foe in enumerate(variants) if foe["archetype"] != "elite"]
picks += rng.choices(common, [weights[i] for i in common], k=count - 1)
if count > size:
hp, hits = OUTNUMBERED
share = 1.0 if action.get("together") else hp # a boss grown for its group still pays each of them one boss
return [{**{key: variants[i][key] for key in ("name", "archetype", "level", "boss", "interval", "accuracy",
"evasion", "attack", "vulnerable", "resists")},
"variant": i, "hp": max(1, round(variants[i]["hp"] * hp)), "max_hit": max(1, round(variants[i]["max_hit"] * hits)),
"xp": variants[i]["xp"] * share, "share": share} for i in picks]
def draw_fight(world: dict, players: list, action: dict, rng: random.Random, start: float | None = None) -> dict:
"""Draw up the pack, rest everyone back to full (the longest rest is everyone's), and play the
fight out. A fight starting at `start` (real time) holds each player's manual abilities for as
long as they were last seen watching."""
members, rest = [], 0.0
for player in players:
seconds, eaten = rest_plan(world, player)
member = fight_member(world, player)
member.update(eaten=eaten, hp_in=member["top"], rest=seconds) # full after your own rest, then wait
rest = max(rest, seconds)
members.append(member)
fight = {"seed": rng.random(), "pace": speed(world), "rest": rest, "enemy": action["enemy"]["name"],
"pack": draw_pack(action, len(players), rng), "members": members}
for member, player in zip(members, players):
member["watch"] = -1.0 if start is None else fight_time(fight, start, player["last_active"] + combat.WATCH)
return play_fight(fight)
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random, start: float | None = None) -> dict:
return draw_fight(world, [player], action, rng, start)
def fight_time(fight: dict, start: float, now: float) -> float:
"""A real moment as seconds into a fight (after its rest) that started at `start`."""
return (now - start) * fight["pace"] - fight["rest"]
def fight_clock(world: dict, player: dict) -> tuple[dict | None, float]:
"""The fight you're in and when it (its rest included) began."""
current = player["action"] or {}
party = world["parties"].get(current.get("party"))
if party:
return party["fight"], party["start"]
return current.get("fight"), current.get("start", 0)
def _my_fight(world: dict, player: dict, now: float) -> tuple[dict, dict, float] | str:
fight, start = fight_clock(world, player)
if not fight or now >= start + fight["duration"] or not any(m["id"] == player["id"] for m in fight["members"]):
return "You're not fighting"
member = member_of(fight, player["id"])
if member["result"] in ("down", "left"):
return "You're out of this fight"
return fight, member, fight_time(fight, start, now)
def watch_fight(world: dict, player: dict, now: float) -> None:
"""You're looking: your manual abilities hold a while longer, and the fight's future is played
again with them. Nothing that's already happened changes."""
mine = _my_fight(world, player, now)
if isinstance(mine, str):
return
fight, member, t = mine
if not member["manual"] and not member["inputs"]:
return
watch = t + combat.WATCH * fight["pace"]
if watch > member["watch"] + combat.STEP:
member["watch"] = watch
play_fight(fight)
def _input(world: dict, player: dict, now: float, kind: str, *args) -> str | None:
mine = _my_fight(world, player, now)
if isinstance(mine, str):
return mine
fight, member, t = mine
if t < 0:
return "Still resting"
at = math.ceil(t / combat.STEP - 1e-9) * combat.STEP # the next slice of the fight
member["inputs"].append([at, kind, *args])
member["watch"] = max(member["watch"], t + combat.WATCH * fight["pace"])
play_fight(fight)
return None
def set_eat_below(player: dict, share) -> str | None:
"""Below what share of your HP you eat between fights (0: never, 1: whenever it's quicker)."""
if share not in EAT_CHOICES:
return "Pick one of the choices"
player["eat_below"] = share
return None
def learn_pattern(player: dict, item: str) -> str | None:
"""Read a pattern: from then on you can reforge its masterwork. The pattern's used up."""
spec = ITEMS.get(item, {})
if not spec.get("pattern"):
return "That isn't a pattern"
if not player["bank"].get(item):
return "You don't have it"
known = player.setdefault("patterns", [])
if item in known:
return "You know it already"
if skill_level(player, spec["skill"]) < spec["level"]:
name = next(s["name"] for s in SKILLS if s["id"] == spec["skill"])
return f"Needs {name.lower()} level {spec['level']}"
bank_take(player, item, 1)
known.append(item)
return None
def press(world: dict, player: dict, ability: str, now: float, target: str | None = None) -> str | None:
"""Use one of your manual abilities: as soon as it's ready, if that's soon enough."""
if ability not in combat.ABILITIES:
return "No such ability"
if ability not in player["manual"]:
return "That one's on auto"
return _input(world, player, now, "press", ability, target)
def set_fight_target(world: dict, player: dict, target: str, now: float) -> str | None:
"""Tap an enemy to fight it, or someone on your side to aim your heals at them."""
return _input(world, player, now, "target", str(target))
def set_manual(world: dict, player: dict, ability: str, manual: bool, now: float) -> str | None:
"""Take an ability into your own hands (pressed, on its real cooldown, while you watch) or give it
back to auto."""
if ability not in combat.ABILITIES:
return "No such ability"
mine = player.setdefault("manual", [])
if manual and ability not in mine:
mine.append(ability)
elif not manual and ability in mine:
mine.remove(ability)
fight = _my_fight(world, player, now)
if isinstance(fight, str):
return None
fight, member, t = fight
if t < 0:
member["manual"] = list(mine)
play_fight(fight)
return None
return _input(world, player, now, "mode", ability, manual)
def play_fight(fight: dict) -> dict:
"""Play a fight out, slice by slice, from the start (see combat.py). Nothing changes until it's over:
the result is applied then. The dice come from the fight's own seed, so playing it again with
someone tagging in or stepping out changes nothing before then."""
return combat.run(fight)
def pack_xp(fight: dict, killed_only: bool = False) -> float:
"""The pack's XP: the enemies brought down, and on a loss a third of the rest (but never for an
add that didn't join)."""
kills = set(fight["kills"])
rest = 1 / 3 if killed_only else 0
return sum(foe["xp"] * (1 if f"e{i}" in kills else 0 if foe.get("dormant") else rest)
for i, foe in enumerate(fight["pack"]))
# --- 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"] or action.get("special") == "labour"):
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"]
seconds *= ware(player, "speed", action["skill"])
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 share the warmth between everyone whose
pile alone was hot enough for it (a beginner's logs count for bricks and copper)."""
fire = world["city"]["fires"][action["fire"]]
fire["saved"] = fire.get("saved", 0) + 1
keepers = [world["players"].get(by) for by in {chunk["by"] for chunk in fire["fuel"]}
if burning(fire["fuel"], by)["tier"] >= action["fuel"]]
keepers = [keeper for keeper in keepers if keeper and keeper is not crafter]
for keeper in keepers:
gain_xp(world, keeper, "firekeeping", xp * WARMTH / len(keepers), 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']}"
if action.get("pattern") and action["pattern"] not in player.get("patterns", []):
return "Learn its pattern first"
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():
pooled = order["provided"].get(item, 0) if order else 0
if pooled + 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"
return None
def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict,
fight: dict | None = None, loot: tuple | None = None) -> None:
"""Finish one action. A shared fight comes in as `fight`, with this member's share of its `loot`."""
brew = potion_effect(player, action["skill"])
order = working_order(world, player, action)
inputs = fire_inputs(world, action)
warmed = inputs is not action["inputs"]
own, used = dict(inputs), {}
if order:
used = _draw(order, inputs)
for share in used.values():
for item, qty in share.items():
own[item] -= qty
own = {item: qty for item, qty in own.items() if 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)
grow = mods["yield"] * (ware(player, "yield", action["skill"], spend=True) if action["outputs"] else 1)
if grow != 1:
for item, qty in list(outputs.items()):
exact = qty * grow
outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0)
for item, qty, odds in action.get("finds", []):
# A gem in the rock: a masterwork (a prospector's double ore) turns up two.
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 = fight or outing.pop("fight", None) or simulate_fight(world, player, action, rng, outing["start"])
mine = member_of(fight, player["id"])
ware(player, "rest", "combat", spend=True)
xp = pack_xp(fight, killed_only=mine["result"] != "won") * mods["xp"]
if player["prayers"] and not mine["pp"]:
summary["prayer_out"] = True
paid = min(player["pp"], mine["pp"])
if paid:
player["pp"] -= paid
summary["pp_spent"] += paid
gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary)
used = {mine["potion_item"]: 1} if mine["drank"] else {}
for key, spent in (("eaten", mine["eaten"]), ("used", used)):
for item, count in spent.items():
count = min(count, player["bank"].get(item, 0))
if count:
bank_take(player, item, count)
summary[key][item] = summary[key].get(item, 0) + count
player["hp"] = max_hp(player) * KNOCKED_OUT_HP if mine["down"] else mine["hp"]
player["hp_at"] = now
summary["damage"] += mine["damage"]
if mine["wind_used"]:
outing["wind_used"] = True
summary["second_wind"] += 1
if mine["result"] != "won":
# Knocked out, or the fight dragged on: nothing but some XP, and back to resting.
summary["losses"] += 1
gain_xp(world, player, "combat", xp, now, summary)
outing["downs"] = 0 if action.get("dungeon") else outing.get("downs", 0) + 1
reason = None if action.get("dungeon") else "Fell back: the fight dragged on" if mine["result"] == "fled" else \
f"Knocked out {KNOCKOUTS_TO_STOP} times in a row" if outing["downs"] >= KNOCKOUTS_TO_STOP else None
if reason:
drop_out(world, player, now, reason)
summary["stopped"] = reason
return
outing.pop("downs", None)
summary["wins"] += 1
gold, drops = loot or pack_loot(action, fight, rng)
if ware(player, "drops", "combat", spend=True) != 1:
# A whetstone's edge: another go at the pack's trophies and reagents.
drops = dict(drops)
for item, qty in pack_loot(action, fight, rng)[1].items():
if ITEMS[item].get("trophy") or ITEMS[item].get("reagent"):
drops[item] = drops.get(item, 0) + qty
if any(foe.get("boss") for foe in fight["pack"]):
_deed(player, "bosses")
emblem = f"boss_{action['id']}"
if emblem in COSMETICS and emblem not in player["owned"]:
player["owned"].append(emblem)
player["gold"] += gold
summary["gold"] += gold
for item, qty in drops.items():
outputs[item] = outputs.get(item, 0) + qty
if action.get("special") == "sabotage":
raid = upcoming_raid(world)
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.01)
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, used, inputs)
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
xp *= ware(player, "xp", action["skill"], spend=True)
ware(player, "speed", action["skill"], spend=True)
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": {}, "used": {}, "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 current and current.get("party"):
if current["party"] in world["parties"]:
process_party(world, current["party"], now, rng)
else:
current.pop("party")
current = player["action"]
if current and current.get("party"):
return summary # a party's kills land in each member's away summary
if not current:
return summary
limit = min(now, offline_limit(world, player))
if ACTION_BY_ID[current["id"]].get("together"):
# A group fight with no group: wait at it for someone to tag in.
current.pop("fight", None)
current["start"] = max(current["start"], limit)
return summary
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, current["start"])
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:
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 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 drop_out(world: dict, player: dict, now: float, reason: str | None = None) -> None:
"""Stop, stepping out of a shared fight if you're in one."""
current = player["action"]
if current and current.get("party") in world["parties"]:
leave_party(world, player, current["party"], now)
stop(player, reason)
def roll_loot(action: dict, rng: random.Random, share: float = 1.0) -> tuple[int, dict]:
"""A kill's gold and drops. A weaker enemy in a pack (`share`) carries less, and drops less often."""
gold = round(rng.randint(*action["gold"]) * share) if action.get("gold") else 0
drops = {}
for item, qty, odds in action.get("drops", []):
if rng.random() < odds * share:
drops[item] = drops.get(item, 0) + qty
return gold, drops
def pack_loot(action: dict, fight: dict, rng: random.Random) -> tuple[int, dict]:
"""Everything the pack carried."""
gold, drops = 0, {}
kills = set(fight["kills"])
for i, foe in enumerate(fight["pack"]):
if f"e{i}" not in kills:
continue
more, found = roll_loot(ACTION_BY_ID[foe["loot"]] if foe.get("loot") else action, rng, foe["share"])
for item, qty, odds in foe.get("extra_drops", []):
if rng.random() < odds:
found[item] = found.get(item, 0) + qty
gold += more
for item, qty in found.items():
drops[item] = drops.get(item, 0) + qty
return gold, drops
def share_loot(loot: tuple[int, dict], uids: list, rng: random.Random) -> dict:
"""Gold split evenly (the odd coins to whoever's lucky), each drop to someone at random."""
gold, drops = loot
each, odd = divmod(gold, len(uids))
lucky = set(rng.sample(uids, odd))
shares = {uid: (each + (uid in lucky), {}) for uid in uids}
for item, qty in drops.items():
for _ in range(qty):
got = shares[rng.choice(uids)][1]
got[item] = got.get(item, 0) + 1
return shares
# --- fighting together ------------------------------------------------------
# Anyone can tag into someone else's fight: from then on they're a party on that
# encounter, every enemy fought by all of them at once. Everyone gets the kill's full
# XP; its gold is split evenly and each drop goes to someone at random, except on
# bosses and dragons, where everyone rolls their own loot as if alone. The party
# lasts until all but one have left (stopped, retreated, or been away too long), and
# the last one carries on alone.
def offline_limit(world: dict, player: dict) -> float:
return player["last_active"] + OFFLINE_CAP / speed(world)
def joinable(world: dict, player: dict) -> bool:
"""Whether someone's fight is one others can tag into."""
current = player["action"]
if not current or "left" in current or "order" in current:
return False
action = ACTION_BY_ID[current["id"]]
if action.get("dungeon"):
party = world["parties"].get(current.get("party"))
return not party or ("run" not in party and len(party["members"]) < DUNGEON_SIZE)
return "enemy" in action and not action.get("special")
def current_fight(world: dict, player: dict) -> dict | None:
current = player["action"] or {}
party = world["parties"].get(current.get("party"))
return party["fight"] if party else current.get("fight")
def party_of(world: dict, player: dict) -> dict | None:
return world["parties"].get((player["action"] or {}).get("party"))
def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None:
"""Tag into someone's fight, swinging at the enemy they're on right now."""
host = world["players"].get(uid)
if not host or host is player:
return "No one to join"
run = party_of(world, host)
if run and ACTION_BY_ID[run["action"]].get("dungeon") and run is not party_of(world, player):
if "run" in run:
return "The run has started"
if len(run["members"]) >= DUNGEON_SIZE:
return "The group is full"
if not joinable(world, host):
return f"{host['name']} isn't fighting"
if offline_limit(world, host) <= now:
return f"{host['name']} is away"
party = party_of(world, host)
if party and party is party_of(world, player):
return "Already fighting together"
action = ACTION_BY_ID[host["action"]["id"]]
reason = can_start(world, player, action)
if reason:
return reason
if party_of(world, player):
leave_party(world, player, player["action"]["party"], now)
if not party:
# Someone fighting alone becomes a party of one, their fight as it stands.
pid = str(world["next_party_id"])
world["next_party_id"] += 1
solo = host["action"]
if action.get("together"):
solo["start"] = now # waiting for a group: it starts as you arrive
party = world["parties"][pid] = {"id": pid, "action": solo["id"], "members": [host["id"]],
"start": solo["start"], "fight": solo.pop("fight", None)}
solo["party"] = pid
party["members"].append(player["id"])
player["action"] = {"id": party["action"], "start": party["start"], "party": party["id"]}
player["stopped"] = None
fight = party["fight"]
if fight and now < party["start"] + fight["duration"]:
# Straight in, as you are: the others may still be resting, or already in the thick of it.
regen(world, player, now)
at = max(0.0, (now - party["start"]) * fight["pace"] - fight["rest"])
fight["members"].append(fight_member(world, player, at))
play_fight(fight)
log(world, now, "party", f"{player['name']} joined {host['name']}: {action['name']}", player["id"])
return None
def leave_party(world: dict, player: dict, pid: str, now: float) -> None:
"""Step out of a party at `now`. The fight goes on without you (you get nothing from it); whoever's
left alone carries on by themselves. Setting your own action is up to the caller."""
party = world["parties"][pid]
party["members"].remove(player["id"])
fight = party["fight"]
if fight and now < party["start"] + fight["duration"]:
member = member_of(fight, player["id"])
left = (now - party["start"]) * fight["pace"] - fight["rest"]
if left <= member["at"] or len(fight["members"]) == 1:
fight["members"].remove(member) # gone before it started: nothing eaten, nothing fought
else:
member["left"] = left
party["fight"] = play_fight(fight) if party["members"] and fight["members"] else None
if len(party["members"]) <= 1:
del world["parties"][pid]
for uid in party["members"]:
last = world["players"][uid]["action"]
last.pop("party", None)
last.update(start=party["start"], **({"fight": party["fight"]} if party["fight"] else {}))
def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None:
"""Run a party's fights forward to `now`: every kill lands for everyone in it at once. What
each member gets waits in their away summary."""
party = world["parties"][pid]
action = ACTION_BY_ID[party["action"]]
for uid in list(party["members"]):
if (world["players"][uid]["action"] or {}).get("party") != pid and world["parties"].get(pid) is party:
leave_party(world, world["players"][uid], pid, party["start"]) # moved on without saying
while world["parties"].get(pid) is party:
members = [world["players"][uid] for uid in party["members"]]
if not party["fight"]:
for player in members:
regen(world, player, party["start"])
reason = can_start(world, player, action)
if reason:
drop_out(world, player, party["start"], reason)
player.setdefault("away", empty_summary())["stopped"] = reason
if world["parties"].get(pid) is not party:
break
members = [world["players"][uid] for uid in party["members"]]
if action.get("dungeon"):
# A run waits for its leader to start it, then for anyone who's looked away: it's played together.
away = [p for p in members if p["last_active"] < party["start"] - DUNGEON_AWAY]
if "run" not in party or away:
party["start"] = max(party["start"], now)
for player in members:
player["action"]["start"] = party["start"]
break
party["fight"] = draw_pull(world, members, action, party["run"],
rng, party["start"])
else:
party["fight"] = draw_fight(world, members, action, rng, party["start"])
end = party["start"] + party["fight"]["duration"]
late = [player for player in members if offline_limit(world, player) < min(end, now)]
if late:
# Away too long: they step out where their time ran out, and pick up alone when they're back.
for player in late:
if world["parties"].get(pid) is not party:
break # the last of them carries on alone, and picks up when they're back
at = max(party["start"], offline_limit(world, player))
wind = {"wind_used": True} if player["action"].get("wind_used") else {}
leave_party(world, player, pid, at)
player["action"] = {"id": action["id"], "start": at, **wind}
continue
if end > now:
break
burn_fires(world, end)
fight, party["fight"] = party["fight"], None
party["start"] = end
for player in members:
player["action"]["start"] = party["start"]
results = {m["id"]: m["result"] for m in fight["members"]}
winners = [uid for uid in party["members"] if results.get(uid) == "won"]
if action.get("personal_loot") and (not action.get("dungeon") or any(foe["boss"] for foe in fight["pack"])):
shares = {uid: pack_loot(action, fight, rng) for uid in winners}
else:
shares = share_loot(pack_loot(action, fight, rng), winners, rng) if winners else {}
for player in members:
if results.get(player["id"], "left") == "left":
continue
summary = player.setdefault("away", empty_summary())
_complete(world, player, action, end, rng, summary, fight=fight, loot=shares.get(player["id"]))
summary["done"] += 1
together = summary.setdefault("with", {})
for other in members:
if other is not player:
together[other["name"]] = together.get(other["name"], 0) + 1
if action.get("dungeon") and world["parties"].get(pid) is party:
_advance_run(world, party, action, fight, members, end, rng)
def draw_pull(world: dict, players: list, action: dict, run: dict, rng: random.Random, start: float) -> dict:
"""The run's next pull, sized for the group, everyone rested first."""
spec = DUNGEONS[action["dungeon"]]
name, pack = spec["pulls"][run["pull"]]
hp, hits = dungeon_scale(len(players))
members, rest = [], 0.0
for player in players:
seconds, eaten = rest_plan(world, player)
member = fight_member(world, player)
member.update(eaten=eaten, hp_in=member["top"], rest=seconds) # full after your own rest, then wait
rest = max(rest, seconds)
members.append(member)
fight = {"seed": rng.random(), "pace": speed(world), "rest": rest, "enemy": name, "members": members,
"pack": [{**foe, "hp": max(1, round(foe["hp"] * hp)), "max_hit": max(1, round(foe["max_hit"] * hits)),
"phases": [dict(phase) for phase in foe.get("phases", [])]} for foe in pack],
"stage": {"dungeon": spec["name"], "pull": run["pull"] + 1, "of": len(spec["pulls"]), "wipes": run["wipes"]}}
for member, player in zip(members, players):
member["watch"] = fight_time(fight, start, player["last_active"] + combat.WATCH)
return play_fight(fight)
def _advance_run(world: dict, party: dict, action: dict, fight: dict, members: list, now: float, rng: random.Random) -> None:
"""After a pull: on to the next, or the same again after a wipe. Clearing it or wiping too often ends it."""
spec, run = DUNGEONS[action["dungeon"]], party["run"]
names = ", ".join(p["name"] for p in members)
if fight["result"] == "won":
run["pull"] += 1
run["wipes"] = 0
if run["pull"] < len(spec["pulls"]):
return
log(world, now, "party", f"{names} cleared {spec['name']}!")
for player in members:
_clear_bonus(world, player, spec, now, rng)
reason = f"Cleared {spec['name']}"
else:
run["wipes"] += 1
if run["wipes"] < DUNGEON_WIPES:
return
reason = f"Wiped {DUNGEON_WIPES} times at {spec['pulls'][run['pull']][0]}"
log(world, now, "party", f"{names}: {reason.lower()} in {spec['name']}")
del world["parties"][party["id"]]
for player in members:
stop(player, reason)
player.setdefault("away", empty_summary())["stopped"] = reason
def _clear_bonus(world: dict, player: dict, spec: dict, now: float, rng: random.Random) -> None:
"""What clearing a run pays everyone in it: gold, one of the area's trophies and two of its reagents."""
summary = player.setdefault("away", empty_summary())
player["gold"] += spec["bonus_gold"]
summary["gold"] += spec["bonus_gold"]
found = [trophy(rng.choice(list(STYLES)), spec["tier"]), *(reagent(rng.choice(list(REAGENT_NOUNS)), spec["tier"])
for _ in range(2))] if spec["tier"] else []
for item in found:
kept = bank_add(world, player, item, 1, now, summary)
if kept:
summary["items"][item] = summary["items"].get(item, 0) + kept
def run_of(world: dict, player: dict) -> dict | None:
"""Where your group's dungeon run stands, if you're in one."""
party = party_of(world, player)
if not party or not ACTION_BY_ID[party["action"]].get("dungeon"):
return None
spec = DUNGEONS[ACTION_BY_ID[party["action"]]["dungeon"]]
run = party.get("run", {"pull": 0, "wipes": 0})
away = [world["players"][uid]["name"] for uid in party["members"]
if world["players"][uid]["last_active"] < party["start"] - DUNGEON_AWAY]
return {"dungeon": spec["name"], "pull": run["pull"] + 1, "of": len(spec["pulls"]), "wipes": run["wipes"],
"next": spec["pulls"][min(run["pull"], len(spec["pulls"]) - 1)][0], "away": away,
"leader": party["members"][0], "size": len(party["members"]), "started": "run" in party}
def start_run(world: dict, player: dict, now: float) -> str | None:
"""The leader takes the group in: no one else can join from here on."""
party = party_of(world, player)
if not party or not ACTION_BY_ID[party["action"]].get("dungeon"):
return "Not in a dungeon"
if party["members"][0] != player["id"]:
return "Only the leader starts"
if "run" in party:
return "Already started"
if len(party["members"]) < GROUP_SIZE:
return f"Needs {GROUP_SIZE} or more"
party["run"] = {"pull": 0, "wipes": 0}
party["start"] = now
for uid in party["members"]:
world["players"][uid]["action"]["start"] = now
spec = DUNGEONS[ACTION_BY_ID[party["action"]]["dungeon"]]
log(world, now, "party", f"{player['name']} led {len(party['members'])} into {spec['name']}", player["id"])
return None
def pull_now(world: dict, player: dict, now: float) -> str | None:
"""The leader cuts the rest short: everyone goes in as healed as they are by now."""
party = party_of(world, player)
if not party or not ACTION_BY_ID[party["action"]].get("dungeon"):
return "Not in a dungeon"
if party["members"][0] != player["id"]:
return "Only the leader pulls"
fight = party["fight"]
if not fight:
return "Nothing to pull yet"
rested = (now - party["start"]) * fight["pace"]
if rested >= fight["rest"]:
return "Already fighting"
rested = math.ceil(rested / combat.STEP) * combat.STEP
for member in fight["members"]:
share = min(1, rested / member["rest"]) if member.get("rest") else 1
member["hp_in"] = member["start_hp"] + (member["hp_in"] - member["start_hp"]) * share
member["eaten"] = {food: int(count * share) for food, count in member["eaten"].items() if int(count * share)}
member["watch"] += fight["rest"] - rested
fight["rest"] = rested
play_fight(fight)
return None
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
parties = lambda: {pid: party["start"] for pid, party in world["parties"].items()}
before = parties()
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 or parties() != before
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: anyone
# can put its reagents into the order's pool, one crafter claims it and makes it
# with the normal action (pool first, 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 to whoever put them in if it's cancelled, runs out of
# time, or finishes with some left over.
ORDER_LIMIT = 5 # open orders per player
ORDER_DAYS = 3 # before an order expires and is refunded
ORDER_MAX = {"craft": 500, "gather": 2000}
INPUT_SHARE = 0.5 # of each piece's reward, split by value among whoever's reagents went in
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":
# The pool holds what a run takes right now: a lit fire's share stays with the fire.
runs, inputs = _batches(action, item, qty), fire_inputs(world, action)
for input_item in provide or []:
if input_item not in action["inputs"]:
return "That isn't in the recipe"
if input_item not in inputs:
return f"The {FIRES[action['fire']]['name'].lower()} pays that"
need = 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",
"gave": {player["id"]: dict(provided)} if provided else {}, "earned": {}}
world["next_order_id"] += 1
world["orders"].append(order)
return None
def _gave(order: dict) -> dict:
"""Who has reagents in the pool: {player: {item: qty}}, oldest first. Older orders only had the requester's."""
return order.setdefault("gave", {order["by"]: dict(order["provided"])} if order["provided"] else {})
def provide_reagent(world: dict, player: dict, order_id, item: str) -> str | None:
"""Put a reagent into a crafting order's pool, up to what's still to be made."""
order = order_by_id(world, order_id)
if not order or order["status"] != "open" or order["kind"] != "craft":
return "No such order"
action = ACTION_BY_ID[order["action"]]
if item not in action["inputs"]:
return "That isn't in the recipe"
inputs = fire_inputs(world, action)
if item not in inputs:
return f"The {FIRES[action['fire']]['name'].lower()} pays that"
need = inputs[item] * _batches(action, order["item"], order["qty"] - order["delivered"])
short = need - order["provided"].get(item, 0)
if short <= 0:
return "Already provided"
qty = min(player["bank"].get(item, 0), short)
if not qty:
return f"No {ITEMS[item]['name'].lower()} in your bank"
bank_take(player, item, qty)
order["provided"][item] = order["provided"].get(item, 0) + qty
mine = _gave(order).setdefault(player["id"], {})
mine[item] = mine.get(item, 0) + qty
return None
def _draw(order: dict, inputs: dict) -> dict:
"""Take one run's reagents from the pool, oldest first: {player: {item: qty}} of whose went in."""
used = {}
for item, qty in inputs.items():
take = min(order["provided"].get(item, 0), qty)
if not take:
continue
order["provided"][item] -= take
for uid, items in _gave(order).items():
part = min(items.get(item, 0), take)
if part:
items[item] -= part
used.setdefault(uid, {})[item] = part
take -= part
if not take:
break
return used
def _refund(world: dict, order: dict, now: float) -> None:
"""Hand back what's left in the pool to whoever put it in, and the unpaid reward to the requester."""
for uid, items in _gave(order).items():
player = world["players"].get(uid)
for item, qty in items.items():
if qty and player:
bank_add(world, player, item, qty, now, empty_summary())
order["provided"], order["gave"] = {}, {}
owner = world["players"].get(order["by"])
if owner:
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
if previous and previous.get("party") in world["parties"]:
leave_party(world, player, previous["party"], now)
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,
used: dict | None = None, inputs: dict | None = None) -> None:
"""Pay for `qty` pieces. Whoever's reagents went into them (`used` of the run's `inputs`) shares
INPUT_SHARE of the pay by value; the requester's own reagents leave theirs with the crafter."""
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
worth, base = sum(ITEMS[item]["value"] * n for item, n in (inputs or {}).items()), pay
for uid, items in (used or {}).items():
helper = world["players"].get(uid)
if uid == order["by"] or not helper or not worth:
continue
share = min(pay, round(base * INPUT_SHARE * sum(ITEMS[i]["value"] * n for i, n in items.items()) / worth))
if share:
helper["gold"] += share
earned = order.setdefault("earned", {})
earned[uid] = earned.get(uid, 0) + share
pay -= share
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
_refund(world, order, now) # leftover reagents
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)
if current and current.get("party") in world["parties"]:
leave_party(world, player, current["party"], now)
player["action"] = entry
player["stopped"] = None
if action.get("together"):
log(world, now, "party", f"{player['name']} is looking for a group: {action['name']}", player["id"])
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 = guards_on_duty(world)
issued = []
if not people:
continue
for item in sorted((i for i in stock if ITEMS[i].get("slot") == slot), key=lambda i: -ITEMS[i].get(stat, 0)):
take = min(people, stock[item])
issued.append((item, take))
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 guard_posts(world: dict) -> int:
"""How many guards the barracks can field, never more than half the townsfolk."""
return min(GUARD_POSTS_BASE + GUARD_POSTS * building_level(world, "barracks"), int(world["city"]["population"] // 2))
def guards_on_duty(world: dict) -> int:
"""Guards whose wage for the coming threat is in the chest."""
return min(guard_posts(world), int(world["city"].get("wages", 0) // GUARD_WAGE))
def defence(world: dict, guards: int | None = None) -> dict:
"""The city's defence right now, part by part. `guards` overrides how many are on duty."""
city = world["city"]
pop = city["population"]
stock = city["stockpile"]
posts = guard_posts(world)
armed = guards_on_duty(world) if guards is None else min(guards, posts)
militia = 0.3 * building_level(world, "barracks") * pop * (1.5 if fitted(world, "weapon_racks") else 1)
parts = {
"townsfolk": 10,
"walls": 20 * building_level(world, "walls") + 25 * fitted(world, "hoardings"),
"militia": round(militia * armed / posts, 1) if posts else 0,
"weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1),
"shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1),
"arrows": min(100, stock.get("arrows", 0) // 10),
"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,
}
if guards is not None:
return {"total": round(sum(parts.values()), 1), "parts": parts}
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"],
"guards": armed, "posts": posts, "wages": int(city.get("wages", 0)), "wage": GUARD_WAGE}
def standing_defence(world: dict) -> float:
"""What the city holds with every guard post paid and no one preparing: walls, guards, kit, arrows."""
parts = defence(world, guards=guard_posts(world))["parts"]
return sum(v for k, v in parts.items() if k not in ("drilled", "rallied", "ward", "mercenaries", "garrison"))
def pay_wages(world: dict, player: dict, qty, source: str, now: float) -> str | None:
"""Put gold in the barracks' wage chest, from your purse (earns favour) or the treasury."""
city = world["city"]
room = guard_posts(world) * GUARD_WAGE - int(city.get("wages", 0))
if room <= 0:
return "Every post is paid for"
have = player["gold"] if source == "bank" else int(city["treasury"]) if source == "city" else None
if have is None:
return "Bad source"
qty = min(have, room) if qty == "all" else qty
if not isinstance(qty, int) or qty <= 0:
return "Nothing to pay"
if qty > have:
return "Not enough gold"
qty = min(qty, room)
if source == "bank":
player["gold"] -= qty
player["favour"] += qty * city_rate(world, "favour")
player["donated"] += qty
log(world, now, "donation", f"{player['name']} paid {qty:,} gold of guard wages", player["id"])
else:
city["treasury"] -= qty
log(world, now, "donation", f"{player['name']} paid {qty:,} gold of guard wages from the treasury", player["id"])
city["wages"] = city.get("wages", 0) + qty
return None
def _pay_guards(world: dict, report: dict) -> None:
"""The guards who stood are paid from the chest; what's left waits for the next threat."""
city = world["city"]
city["wages"] = city.get("wages", 0) - report["guards"] * GUARD_WAGE
def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
"""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
changed = _caravan_tick(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
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)
# The next threat is rumoured as soon as this one is over.
world["next_event_at"] = 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
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 --------------------------------------------------------------
# Threats keep a two-day rhythm: one lands every THREAT_EVERY game hours, give or
# take THREAT_WOBBLE, and the next is rumoured the moment it's over. The first day
# is for scouting, drilling and building; on the second the camp can be found.
THREAT_EVERY = 48
THREAT_WOBBLE = 2
def threat_slot(world: dict, now: float) -> float:
"""When the next threat is due: two days after the last one's slot, never sooner than about two days off."""
hours = HOUR / speed(world)
last = next((e["slot"] for e in reversed(world["events"]) if e["kind"] in THREATS and "slot" in e), None)
soonest = now + (THREAT_EVERY - THREAT_WOBBLE) * hours
return max(last + THREAT_EVERY * hours, soonest) if last else now + THREAT_EVERY * hours
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"])
slot = threat_slot(world, now)
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),
"slot": slot, "fire_at": slot + rng.uniform(-THREAT_WOBBLE, THREAT_WOBBLE) * hours,
}
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)
# A bigger guild draws a bolder band: half the combat power of everyone active in the last two days.
fighters = sum(combat_power(p) for p in world["players"].values() if p["last_active"] > now - 2 * DAY / speed(world))
strength = ((15 + 5 * day + 1.5 * city["population"] + 1.6 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"])
+ 0.5 * fighters)
# How big it looks is measured against what the city holds without preparing.
event["standing"] = round(max(1.0, standing_defence(world)), 1)
if kind == "dragon":
event.update({
"strength": round(strength * 0.8 * 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({
"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]
_plan_caravan(world, now, rng)
# 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, 36, 24] # game hours before arrival that levels 2 (±25% per threat) and 3 open
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) * ware(player, "recon", "reconnaissance")
def intel_costs(event: dict) -> list[int]:
"""Total recon needed for each intel level."""
size = threat_size(event, event["strength"])
scale = INTEL_SIZE[size] * (0.5 if event["kind"] == "dragon" else 1)
return [round(sum(INTEL_POINTS[:level]) * scale) for level in (1, 2, 3)]
def intel_opens(world: dict, event: dict, level: int) -> float:
"""When an intel level becomes knowable. Level 2's ±25% is fixed per threat, so it can't be timed;
the camp (level 3) can always be found a day before the threat lands."""
hours = INTEL_OPENS[level - 1]
if hours is None:
return 0
wobble = random.Random(f"{event['id']}-{level}").uniform(0.75, 1.25) if level < 3 else 1
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")] # by strength, before v28
SIZE_RATIOS = [1.4, 1.9, 2.5, math.inf] # strength / standing defence for small, medium, large, huge
def threat_size(event: dict, strength: float) -> int:
"""0 small … 3 huge, against what the city could hold when the threat was rumoured."""
if "standing" in event:
return next(i for i, limit in enumerate(SIZE_RATIOS) if strength / event["standing"] < limit)
return next(i for i, (limit, _label) in enumerate(SIZE_LABELS) if strength < limit)
WINDOW_HOURS = [4, 3, 2, 0]
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"] = SIZE_LABELS[threat_size(event, strength)][1]
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 = SIZE_LABELS[threat_size(event, strength)][1]
view["headline"] = f"A {size} bandit band is gathering in the hills."
view["size"] = size
if level >= 2:
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 = guard_posts(world)
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"]
request = {"item": item, "label": label, "want": qty, "have": have}
if item in ("copper_sword", "iron_shield"):
request["any"] = ITEMS[item]["slot"] # any item in this slot counts
if item == "bread":
request["any"] = "food" # counted in meals
requests.append(request)
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)
if event["kind"] in THREATS:
won = event["report"]["title"].startswith("Victory")
for entry in event["report"]["garrison"] if won else []:
_deed(world["players"][entry["id"]], "dragons" if event["kind"] == "dragon" else "defended")
# Wares bought against this threat are spent.
for player in world["players"].values():
player["boosts"] = [b for b in player["boosts"] if "threat" not in b]
elif event["kind"] == "merchant":
world["city"]["merchant_until"] = now + 12 * HOUR / speed(world)
event["report"] = {"title": "A merchant caravan has arrived",
"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, "guards": guard["guards"],
}
if ratio >= 1:
loot = round(attack)
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 * 2), now)
if garrison:
lines.append("Everyone in the garrison earned 25 favour and combat experience.")
log(world, now, "raid", f"{upper_first(event["group"])} were driven off")
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), now)
log(world, now, "raid", f"{upper_first(event["group"])} broke into the city")
report["lines"] = lines
event["report"] = report
_pay_guards(world, report)
for player in world["players"].values():
player["garrison"] = False
city["rally"] = city["rallies"] = 0
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, "guards": guard["guards"], "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 * 2.5), now)
bank_add(world, player, "dragon_bones", 2, now, empty_summary())
for weight in WEIGHTS:
bank_add(world, player, reagent(weight, 3), 1, now, empty_summary())
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), now)
log(world, now, "raid", report["title"])
report["lines"] = lines
event["report"] = report
_pay_guards(world, report)
for player in world["players"].values():
player["garrison"] = False
city["rally"] = city["rallies"] = 0
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": 18,
"text": "Fishing slow for 18h, 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."},
"spiders": {"name": "Spiders in the mine", "icon": "🕷️", "speed": {"mining": 1.5}, "work": 10, "action": "clear_spiders",
"text": "Mining much slower until fighters clear them out."},
"smugglers": {"name": "Smugglers in the caves", "icon": "🏴‍☠️", "steal_gold": 0.01, "work": 10, "action": "catch_smugglers",
"text": "Skim 1% of the treasury hourly until fighters catch them."},
"bridge": {"name": "Bridge washed out", "icon": "🌉", "need": {"plank": 25},
"block": {"hunting": "The bridge to the hunting grounds is out"},
"text": "No hunting until the bridge is rebuilt with planks."},
"hornets": {"name": "Hornets in the woods", "icon": "🐝", "speed": {"woodcutting": 1.4}, "work": 8, "action": "smoke_hornets",
"text": "Woodcutting slower until herbalists smoke them out."},
}
MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars",
"spiders", "smugglers", "bridge", "hornets")
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"),
_encounter("clear_spiders", "Mine spiders", "Frontier", 6, SPIDERS, label="Clear the spiders",
special="problem", problem="spiders"),
_encounter("catch_smugglers", "Smugglers", "Frontier", 8, SMUGGLERS, gold=6, label="Catch the smugglers",
special="problem", problem="smugglers"),
_action("smoke_hornets", "herblore", "Smoke out the hornets", 1, 40, 12, special="problem", problem="hornets"),
]
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
_dungeon_loot() # masterworks are reforged from all of the above
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],
"blight": [2, 1, 3, 0], "tremors": [1, 1, 1, 1], "taxman": [1, 1, 2, 1]}
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,
"timber": [8, 6, 10, 8], "deer": [8, 10, 10, 4], "masons": [10, 10, 8, 6], "artisans": [8] * 4,
"trapper": [10] * 4}
TRACK_HOURS = {"minor": (16, 32), "hardship": (20, 40), "fortune": (10, 20)}
CRAFTS = ("smithing", "jewelcrafting", "cooking", "leatherworking", "tailoring", "herblore")
TRAPPER_RECON = 15 # boosted recon a trapper's leads are worth, like an informant's
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 = 36 * (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, 24, 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 a day the city burns logs from the stockpile to keep warm.",
"Without firewood, the cold makes everyone eat 50% more food."])
elif kind == "blight":
_add_effect(world, now, 12, kind="blight", name="Blight", icon="🥀", **{"yield": {"farming": 0.5}},
text="Farming yield halved")
_record(world, now, "hardship", kind, "🥀", "Blight has struck the fields",
["For 12 hours, farming yields half."])
elif kind == "tremors":
_add_effect(world, now, 12, kind="tremors", name="Tremors", icon="🫨", speed={"mining": 1.3},
text="Mining 30% slower")
_record(world, now, "hardship", kind, "🫨", "The ground is shaking under the mines",
["For 12 hours, mining is 30% slower."])
elif kind == "taxman":
gold = int(city["treasury"] * 0.15)
city["treasury"] -= gold
_record(world, now, "hardship", kind, "📜", "The crown's tax collector came by",
[f"The collector took {gold:,} gold (15%) from the treasury." if gold else "The treasury was empty, so the collector left grumbling."])
else:
_add_effect(world, now, 12, 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 12 hours, 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()}
threat = upcoming_raid(world)
if not threat:
weights["mercenaries"] = 0
if not threat or threat["recon"] >= intel_costs(threat)[-1]:
weights["trapper"] = 0
kind = kind or _pick(weights, rng)
if kind == "merchant":
city["merchant_until"] = now + 6 * HOUR / speed(world)
_record(world, now, "fortune", kind, "🐪", "A merchant caravan has arrived",
["For the next 6 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, 6, 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 6 hours, {name.lower()} earns 50% more XP. Learn while you can."])
elif kind == "harvest":
_add_effect(world, now, 6, 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 6 hours."])
elif kind == "rich_vein":
_add_effect(world, now, 6, 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 6 hours."])
elif kind == "salmon":
_add_effect(world, now, 6, 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 6 hours."])
elif kind == "festival":
_add_effect(world, now, 12, 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 12 hours, 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, 12, 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 12 hours they'll join the next defence for {cost:,} gold from the treasury."])
elif kind == "timber":
_add_effect(world, now, 6, kind="timber", name="Storm-felled timber", icon="🪵", **{"yield": {"woodcutting": 1.5}},
text="Woodcutting yield +50%")
_record(world, now, "fortune", kind, "🪵", "A storm felled trees all over the woods",
["Woodcutting yields 50% more for 6 hours."])
elif kind == "deer":
_add_effect(world, now, 6, kind="deer", name="Deer migration", icon="🦌", **{"yield": {"hunting": 2}},
text="Hunting yield doubled")
_record(world, now, "fortune", kind, "🦌", "Herds are migrating past the city",
["Hunting yields are doubled for 6 hours."])
elif kind == "masons":
_add_effect(world, now, 6, kind="masons", name="Masons' guild", icon="🧱", speed={"construction": 0.75},
text="Construction 25% faster")
_record(world, now, "fortune", kind, "🧱", "A masons' guild is passing through",
["For 6 hours, construction is 25% faster."])
elif kind == "artisans":
_add_effect(world, now, 6, kind="artisans", name="Artisans' fair", icon="🎨", speed={s: 0.85 for s in CRAFTS},
text="Smithing, jewelcrafting, cooking, leatherworking, tailoring and herblore 15% faster")
_record(world, now, "fortune", kind, "🎨", "Artisans from afar opened a fair",
["For 6 hours, every craft but construction and firekeeping is 15% faster."])
elif kind == "trapper":
city["recon_boost"] = city.get("recon_boost", 0) + TRAPPER_RECON
_record(world, now, "fortune", kind, "🗺️", "A trapper brought word from the hills",
[f"The next {TRAPPER_RECON} recon scouts gather counts double."])
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")))
if spec.get("steal_gold"):
city["treasury"] *= 1 - spec["steal_gold"]
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),
}
# --- gold ------------------------------------------------------------------
# Gold never buys results. It rents bank slots, pays the guards, buys caravan
# wares that boost your own work for a while, and buys looks.
RENT_BASE = 1000 # gold for the first rented bank slot...
RENT_GROWTH = 1.35 # ...and each one after costs this much more
def rent_cost(player: dict) -> int:
return int(round(RENT_BASE * RENT_GROWTH ** player["rented"] / 50) * 50)
def rent_slot(player: dict) -> str | None:
cost = rent_cost(player)
if player["gold"] < cost:
return f"Needs {cost:,} gold"
player["gold"] -= cost
player["rented"] += 1
return None
# Caravan wares: each multiplies your own work in one skill for its next `charges`
# actions, or against the coming threat. `skill` None is picked by the buyer.
WARES = {
"whetstone": {"name": "Whetstone of the north", "skill": "combat", "kind": "drops", "mult": 2, "charges": 100,
"price": 3000, "text": "Trophy and reagent odds ×2", "per": "kills"},
"tutor": {"name": "Tutor's notes", "skill": None, "kind": "xp", "mult": 1.5, "charges": 200, "price": 1500,
"text": "+50% XP", "per": "actions"},
"salts": {"name": "Ember salts", "skill": "firekeeping", "kind": "speed", "mult": 0.75, "charges": 100, "price": 1200,
"text": "Tending 25% faster", "per": "tends"},
"seeds": {"name": "Seed bag", "skill": "farming", "kind": "yield", "mult": 1.5, "charges": 150, "price": 1200,
"text": "+50% yield", "per": "harvests"},
"prospector": {"name": "Prospector's charm", "skill": "mining", "kind": "yield", "mult": 1.5, "charges": 300,
"price": 1500, "text": "+50% yield", "per": "ores"},
"lure": {"name": "Silver lure", "skill": "fishing", "kind": "yield", "mult": 1.5, "charges": 300, "price": 1200,
"text": "+50% yield", "per": "catches"},
"flux": {"name": "Fine flux", "skill": "smithing", "kind": "speed", "mult": 0.8, "charges": 100, "price": 1500,
"text": "Smithing 20% faster", "per": "crafts"},
"rations": {"name": "Trail rations", "skill": "combat", "kind": "rest", "mult": 0.5, "charges": 50, "price": 800,
"text": "Rest 50% faster", "per": "fights"},
"spyglass": {"name": "Spyglass lens", "skill": "reconnaissance", "kind": "recon", "mult": 1.5, "charges": None,
"price": 1000, "text": "Your recon ×1.5", "per": "threat"},
"manual": {"name": "Drill manual", "skill": "combat", "kind": "drill", "mult": 1.5, "charges": None,
"price": 1000, "text": "Your drilling ×1.5", "per": "threat"},
}
WARE_GLYPHS = {"whetstone": ("stone-sphere", "#a8b4c8"), "tutor": ("book-cover", "#4fa3e0"), "salts": ("powder", "#ff7a2a"),
"seeds": ("plant-seed", "#8fbf6a"), "prospector": ("gems", "#7fd0e0"), "lure": ("fishing-lure", "#d4dae2"),
"flux": ("powder-bag", "#d07a3c"), "rations": ("meat", "#c06a4a"), "spyglass": ("spyglass", "#6f8fec"),
"manual": ("scroll-unfurled", "#e0a050")}
CARAVAN_HOURS = 12 # how long the caravan stays
CARAVAN_WARES = 4 # wares it brings from the pool, plus one look
def ware(player: dict, kind: str, skill: str, spend: bool = False) -> float:
"""The multiplier a caravan ware gives this kind of work, using up a charge if `spend`."""
for boost in player["boosts"]:
spec = WARES[boost["ware"]]
if spec["kind"] == kind and boost["skill"] == skill:
if spend and "left" in boost:
boost["left"] -= 1
if boost["left"] <= 0:
player["boosts"].remove(boost)
return spec["mult"]
return 1
def ware_price(player: dict, ware_id: str, skill: str) -> int:
"""The list price, more for someone further along in the skill it helps."""
return int(round(WARES[ware_id]["price"] * (1 + skill_level(player, skill) / 30) / 50) * 50)
def _plan_caravan(world: dict, now: float, rng: random.Random) -> None:
"""The caravan comes once per threat, on the first day, for half a day."""
at = now + rng.uniform(2, 16) * HOUR / speed(world)
world["caravan"] = {"id": world["next_caravan_id"], "at": at, "until": at + CARAVAN_HOURS * HOUR / speed(world),
"wares": None, "look": None}
world["next_caravan_id"] += 1
def caravan_here(world: dict, now: float) -> dict | None:
caravan = world.get("caravan")
return caravan if caravan and caravan["wares"] is not None and caravan["at"] <= now < caravan["until"] else None
def _caravan_tick(world: dict, now: float, rng: random.Random) -> bool:
caravan = world.get("caravan")
if not caravan or caravan["wares"] is not None or now < caravan["at"] or now >= caravan["until"]:
return False
caravan["wares"] = rng.sample(sorted(WARES), CARAVAN_WARES)
caravan["look"] = rng.choice(sorted(k for k, c in COSMETICS.items() if c.get("price")))
names = [WARES[w]["name"].lower() for w in caravan["wares"]] + [COSMETICS[caravan["look"]]["name"].lower()]
_record(world, now, "fortune", "caravan", "🐫", "A caravan has arrived",
[f"For {CARAVAN_HOURS} hours it sells {', '.join(names[:-1])} and {names[-1]}."])
return True
def buy_ware(world: dict, player: dict, ware_id: str, skill: str | None, now: float) -> str | None:
caravan = caravan_here(world, now)
if not caravan:
return "The caravan isn't here"
bought = player["bought"] if player["bought"] and player["bought"]["visit"] == caravan["id"] else {"visit": caravan["id"], "items": []}
if ware_id in bought["items"]:
return "One of each per visit"
if ware_id == caravan["look"]:
spec = COSMETICS[ware_id]
if ware_id in player["owned"]:
return "You have that already"
if player["gold"] < spec["price"]:
return f"Needs {spec['price']:,} gold"
player["gold"] -= spec["price"]
player["owned"].append(ware_id)
else:
if ware_id not in caravan["wares"]:
return "Not for sale"
spec = WARES[ware_id]
skill = spec["skill"] or skill
if skill not in SKILL_IDS:
return "Pick a skill"
threat = upcoming_raid(world)
if spec["charges"] is None and not threat:
return "There's no threat coming"
price = ware_price(player, ware_id, skill)
if player["gold"] < price:
return f"Needs {price:,} gold"
player["gold"] -= price
held = next((b for b in player["boosts"] if b["ware"] == ware_id and b["skill"] == skill), None)
if spec["charges"] is None:
if held:
return "You have one for this threat"
player["boosts"].append({"ware": ware_id, "skill": skill, "threat": threat["id"]})
elif held:
held["left"] += spec["charges"]
else:
player["boosts"].append({"ware": ware_id, "skill": skill, "left": spec["charges"]})
bought["items"].append(ware_id)
player["bought"] = bought
return None
def caravan_view(world: dict, player: dict, now: float) -> dict | None:
caravan = caravan_here(world, now)
if not caravan:
return None
bought = player["bought"]["items"] if player["bought"] and player["bought"]["visit"] == caravan["id"] else []
wares = []
for ware_id in caravan["wares"]:
spec = WARES[ware_id]
skills = [spec["skill"]] if spec["skill"] else [s for s in SKILL_IDS]
wares.append({"id": ware_id, **spec, "glyph": WARE_GLYPHS[ware_id],
"prices": {s: ware_price(player, ware_id, s) for s in skills}, "bought": ware_id in bought})
look = COSMETICS[caravan["look"]]
return {"until": caravan["until"], "wares": wares,
"look": {"id": caravan["look"], **look, "bought": caravan["look"] in bought, "owned": caravan["look"] in player["owned"]}}
# Looks: a banner (field, trim, emblem), a title after your name, and your home on the roster.
# Free colours and earned titles cost nothing; the caravan sells one more look per visit.
DEFAULT_LOOK = {"field": "slate", "trim": "bone", "emblem": None, "title": None}
COSMETICS = {
# free
"slate": {"kind": "colour", "name": "Slate", "value": "#5a6478"},
"navy": {"kind": "colour", "name": "Navy", "value": "#2f4f8a"},
"oxblood": {"kind": "colour", "name": "Oxblood", "value": "#7a2f2f"},
"forest": {"kind": "colour", "name": "Forest", "value": "#2f6b3a"},
"bone": {"kind": "colour", "name": "Bone", "value": "#e7e9f0"},
"brass": {"kind": "colour", "name": "Brass", "value": "#e0b04a"},
# sold by the caravan
"royal_purple": {"kind": "colour", "name": "Royal purple dye", "value": "#6b3f8a", "price": 800},
"teal": {"kind": "colour", "name": "Deep teal dye", "value": "#1f6f6f", "price": 600},
"ember": {"kind": "colour", "name": "Ember dye", "value": "#ff7a2a", "price": 800},
"wine": {"kind": "colour", "name": "Wine dye", "value": "#8a2f5a", "price": 600},
"jet": {"kind": "colour", "name": "Jet dye", "value": "#111318", "price": 1000},
"sky": {"kind": "colour", "name": "Sky dye", "value": "#7fb6e8", "price": 600},
"e_swords": {"kind": "emblem", "name": "Crossed swords emblem", "value": "crossed-swords", "price": 1200},
"e_anvil": {"kind": "emblem", "name": "Anvil emblem", "value": "anvil", "price": 1200},
"e_castle": {"kind": "emblem", "name": "Tower emblem", "value": "castle", "price": 1200},
"e_wolf": {"kind": "emblem", "name": "Wolf emblem", "value": "wolf-head", "price": 1500},
"e_stag": {"kind": "emblem", "name": "Stag emblem", "value": "deer", "price": 1500},
"e_sun": {"kind": "emblem", "name": "Sun emblem", "value": "sun", "price": 1500},
"e_oak": {"kind": "emblem", "name": "Oak emblem", "value": "oak", "price": 1500},
"e_rose": {"kind": "emblem", "name": "Rose emblem", "value": "rose", "price": 2000},
"t_thrifty": {"kind": "title", "name": "Title: the Thrifty", "value": "the Thrifty", "price": 1000},
"t_bold": {"kind": "title", "name": "Title: the Bold", "value": "the Bold", "price": 2000},
"t_wanderer": {"kind": "title", "name": "Title: the Wanderer", "value": "the Wanderer", "price": 1500},
"t_lucky": {"kind": "title", "name": "Title: the Lucky", "value": "the Lucky", "price": 2500},
"t_unbowed": {"kind": "title", "name": "Title: the Unbowed", "value": "the Unbowed", "price": 3000},
}
FREE_LOOKS = [key for key, c in COSMETICS.items() if not c.get("price")]
# Boss emblems are only won, by beating the boss.
for _a in ACTIONS:
if "enemy" in _a and _a["enemy"].get("boss"):
COSMETICS[f"boss_{_a['id']}"] = {"kind": "emblem", "name": f"{_a['enemy']['name']} emblem", "boss": _a["id"]}
SKILL_TITLES = {"mining": "miner", "woodcutting": "woodcutter", "farming": "farmer", "hunting": "hunter", "fishing": "angler",
"smithing": "smith", "jewelcrafting": "jeweller", "construction": "builder", "cooking": "cook",
"leatherworking": "tanner", "tailoring": "tailor", "herblore": "herbalist", "firekeeping": "flamekeeper",
"combat": "warrior", "prayer": "priest", "reconnaissance": "scout"}
SKILL_EXTRA_TITLES = [("fishing", 50, "Master baiter")]
DEED_TITLES = [("defended", 5, "Defender of the City"), ("dragons", 1, "Dragonslayer"), ("bosses", 10, "Bossbane")]
HOMES = [("Hut", 0), ("Cottage", 2000), ("House", 8000), ("Hall", 25000), ("Manor", 75000)]
MONUMENTS = {
"gate_banner": {"name": "Gate banner", "cost": 10000, "glyph": "flying-flag", "tint": "#e0b04a", "once": True,
"text": "The guild's colours over the city gate."},
"fountain": {"name": "Fountain", "cost": 25000, "glyph": "fountain", "tint": "#5aa0d8", "once": True,
"text": "A fountain in the town square."},
"statue": {"name": "Statue of a guildmate", "cost": 50000, "glyph": "stone-bust", "tint": "#d4dae2", "once": False,
"text": "Carved in stone. The biggest donor picks who."},
}
def _deed(player: dict, key: str) -> None:
player["deeds"][key] = player["deeds"].get(key, 0) + 1
def earned_titles(player: dict) -> list[str]:
titles = []
for skill, noun in SKILL_TITLES.items():
level = skill_level(player, skill)
if level >= 99:
titles.append(f"Grandmaster {noun}")
elif level >= 50:
titles.append(f"Master {noun}")
titles += [title for skill, need, title in SKILL_EXTRA_TITLES if skill_level(player, skill) >= need]
titles += [title for key, need, title in DEED_TITLES if player["deeds"].get(key, 0) >= need]
if player["donated"] >= 100_000:
titles.append("Patron of the City")
return titles
def titles(player: dict) -> list[str]:
return earned_titles(player) + [COSMETICS[c]["value"] for c in player["owned"] if COSMETICS[c]["kind"] == "title"]
def can_wear(player: dict, key: str | None, kind: str) -> bool:
if key is None:
return kind == "emblem"
spec = COSMETICS.get(key)
return bool(spec) and spec["kind"] == kind and (key in FREE_LOOKS or key in player["owned"])
def set_look(player: dict, look: dict) -> str | None:
field, trim, emblem, title = (look.get(k) for k in ("field", "trim", "emblem", "title"))
if not (can_wear(player, field, "colour") and can_wear(player, trim, "colour") and can_wear(player, emblem, "emblem")):
return "You don't own that"
if title is not None and title not in titles(player):
return "You haven't earned that title"
player["look"] = {"field": field, "trim": trim, "emblem": emblem, "title": title}
return None
def look_view(player: dict) -> dict:
"""What others see: banner colours and emblem resolved, title, home."""
look = player["look"]
title = look["title"] if look["title"] in titles(player) else None
emblem = COSMETICS[look["emblem"]] if look["emblem"] else None
glyph = None
if emblem:
glyph = emblem.get("value") or ACTION_BY_ID[emblem["boss"]]["glyph"]
return {"field": COSMETICS[look["field"]]["value"], "trim": COSMETICS[look["trim"]]["value"], "emblem": glyph,
"title": title, "home": HOMES[player.get("home", 0)][0]}
def upgrade_home(player: dict) -> str | None:
level = player.get("home", 0)
if level + 1 >= len(HOMES):
return "Your home is as grand as it gets"
name, cost = HOMES[level + 1]
if player["gold"] < cost:
return f"Needs {cost:,} gold"
player["gold"] -= cost
player["home"] = level + 1
return None
def give_monument(world: dict, player: dict, key: str, qty, now: float) -> str | None:
"""Gold towards a monument. No favour: your name in the stone is the reward."""
city = world["city"]
spec = MONUMENTS.get(key)
if not spec:
return "No such monument"
if spec["once"] and any(b["kind"] == key for b in city["built"]):
return "It's already built"
if key == "statue" and city["carving"]:
return "A statue is being carved"
fund = city["monuments"].setdefault(key, {"gold": 0, "donors": {}})
room = spec["cost"] - fund["gold"]
qty = min(player["gold"], room) if qty == "all" else qty
if not isinstance(qty, int) or qty <= 0:
return "Nothing to give"
if qty > player["gold"]:
return "Not enough gold"
qty = min(qty, room)
player["gold"] -= qty
fund["gold"] += qty
fund["donors"][player["id"]] = fund["donors"].get(player["id"], 0) + qty
if fund["gold"] >= spec["cost"]:
del city["monuments"][key]
if key == "statue":
chooser = max(fund["donors"], key=lambda uid: fund["donors"][uid])
city["carving"] = {"by": chooser, "donors": fund["donors"]}
log(world, now, "build", f"The statue fund is full. {world['players'][chooser]['name']} picks who it honours.")
else:
city["built"].append({"kind": key, "at": now, "donors": fund["donors"], "of": None})
log(world, now, "build", f"The {spec['name'].lower()} is finished")
return None
def carve_statue(world: dict, player: dict, of: str, now: float) -> str | None:
city = world["city"]
carving = city["carving"]
if not carving or carving["by"] != player["id"]:
return "It isn't yours to pick"
if of not in world["players"]:
return "No such guildmate"
city["built"].append({"kind": "statue", "at": now, "donors": carving["donors"], "of": of})
city["carving"] = None
log(world, now, "build", f"A statue of {world['players'][of]['name']} now stands in the town square")
return None
def monuments_view(world: dict) -> dict:
city = world["city"]
return {"funds": city["monuments"], "built": city["built"], "carving": city["carving"]}
# --- achievements ----------------------------------------------------------------
# What's left to unlock. Read off what you already have, never a source of anything:
# patterns learned, uniques found, titles, looks, your home.
def _got(name: str, done, glyph: str, tint: str, tip: str = "", colour: str | None = None) -> dict:
return {"name": name, "done": bool(done), "glyph": glyph, "tint": tint, "tip": tip, **({"colour": colour} if colour else {})}
def achievements(player: dict) -> list[dict]:
"""Sections of cards, each card a set of things to collect."""
skill_name = {s["id"]: s["name"] for s in SKILLS}
dungeons = sorted(DUNGEON_LOOT, key=lambda key: DUNGEONS[key]["level"])
patterns = []
for key in dungeons:
cards = []
for pattern in ACTION_BY_ID[key]["loot"]["patterns"]:
spec, made = ITEMS[pattern], ITEMS[ITEMS[pattern]["pattern"]]
held = pattern not in player["patterns"] and player["bank"].get(pattern)
tip = f"{skill_name[spec['skill']]} {spec['level']}" + (" · 📜 in bank" if held else "")
cards.append({**_got(made["name"], pattern in player["patterns"], made["glyph"], made["tint"], tip), "held": bool(held)})
patterns.append({"name": DUNGEONS[key]["name"], "entries": cards})
found = player.get("found", [])
uniques = [_got(ITEMS[unique]["name"], unique in found, ITEMS[unique]["glyph"], ITEMS[unique]["tint"],
DUNGEONS[key]["name"]) for key in dungeons for unique in [DUNGEON_LOOT[key][2]]]
def level_title(need: int, rank: str) -> list[dict]:
return [_got(f"{rank} {noun}", skill_level(player, skill) >= need, *SKILL_GLYPHS[skill],
f"{skill_name[skill]} {skill_level(player, skill)} / {need}") for skill, noun in SKILL_TITLES.items()]
deeds = {"defended": ("checked-shield", "#d4dae2", "Raids won on guard"), "dragons": ("dragon-head", "#e05a5a", "Dragons slain on guard"),
"bosses": ("crossed-swords", "#e0b04a", "Bosses beaten")}
feats = [_got(title, skill_level(player, skill) >= need, *SKILL_GLYPHS[skill], f"{skill_name[skill]} {skill_level(player, skill)} / {need}")
for skill, need, title in SKILL_EXTRA_TITLES]
feats += [_got(title, player["deeds"].get(key, 0) >= need, *deeds[key][:2], f"{deeds[key][2]} {player['deeds'].get(key, 0)} / {need}")
for key, need, title in DEED_TITLES]
feats.append(_got("Patron of the City", player["donated"] >= 100_000, "sparkles", "#b98ce8",
f"Given {player['donated']:,} / 100,000"))
def bought(kind: str) -> list[dict]:
return [_got(spec["value"] if kind == "title" else spec["name"], key in player["owned"],
"scroll-unfurled" if kind == "title" else spec.get("value", ""), "#e0b04a",
f"Caravan · {spec['price']:,} gold", spec["value"] if kind == "colour" else None)
for key, spec in COSMETICS.items() if spec["kind"] == kind and spec.get("price")]
bosses = [_got(spec["name"], key in player["owned"], ACTION_BY_ID[spec["boss"]]["glyph"], ACTION_BY_ID[spec["boss"]]["tint"],
ACTION_BY_ID[spec["boss"]]["name"]) for key, spec in COSMETICS.items() if spec.get("boss")]
homes = [_got(name, player.get("home", 0) >= level, "castle", "#d4dae2", f"{cost:,} gold")
for level, (name, cost) in enumerate(HOMES) if level]
return [
{"name": "Masterwork patterns", "cards": patterns},
{"name": "Dungeon loot", "cards": [{"name": "Uniques", "entries": uniques}]},
{"name": "Titles", "cards": [{"name": "Masters", "entries": level_title(50, "Master")},
{"name": "Grandmasters", "entries": level_title(99, "Grandmaster")},
{"name": "Deeds", "entries": feats}, {"name": "Caravan titles", "entries": bought("title")}]},
{"name": "Looks", "cards": [{"name": "Boss emblems", "entries": bosses}, {"name": "Caravan emblems", "entries": bought("emblem")},
{"name": "Dyes", "entries": bought("colour")}, {"name": "Home", "entries": homes}]},
]
# --- 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.
GUARD_POSTS_BASE = 10 # guard posts with no barracks...
GUARD_POSTS = 20 # ...and per barracks level, never more than half the townsfolk
GUARD_WAGE = 60 # gold per guard per threat, paid into the chest ahead of time
DRILL_PER_ACTION = 1.0 # drill points per session, +25% every 20 combat levels
DRILL_XP = 12 # combat XP per drill point
DRILL_SHARE = 1.0 # defence of a fully drilled militia, per townsperson
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)) * ware(player, "drill", "combat")
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"),
"dungeon_watchtower": ("watchtower", "#d1a46a"), "dungeon_goblin_warren": ("cave-entrance", "#7fbf5a"),
"dungeon_bandit_stronghold": ("guarded-tower", "#a08060"), "dungeon_barrow_crypts": ("tombstone", "#8fb8f0"),
"dungeon_deep_forge": ("anvil-impact", "#ff8a3a"), "dungeon_frozen_hold": ("frozen-orb", "#8fd0e0"),
"dungeon_dragons_lair": ("dragon-spiral", "#e05a5a"),
"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"),
"clear_spiders": ("spider-alt", "#9a7fb0"), "catch_smugglers": ("pirate-hat", "#a08060"),
"smoke_hornets": ("wasp-sting", "#e3c060"),
"hunt_elk": ("deer", "#a07a50"), "hunt_sabrecat": ("saber-toothed-cat-head", "#e0a050"),
"hunt_mammoth": ("mammoth", "#8a6a50"),
}
# Abilities: yours by weapon and trade, then the enemies' (shown as they cast).
ABILITY_GLYPHS = {
"shield_bash": ("shield-bash", "#d4dae2"), "challenge": ("shouting", "#e0a050"),
"sweeping_cut": ("sword-spin", "#d4dae2"), "shield_wall": ("stone-wall", "#a8a29e"),
"crushing_blow": ("hammer-break", "#d1a46a"), "concuss": ("knockout", "#f0d060"),
"earthshaker": ("quake-stomp", "#c08850"), "unstoppable": ("enrage", "#e06a6a"),
"bolt": ("bolt-spell-cast", "#a77be8"), "mend": ("healing", "#5fae5a"),
"ward": ("energy-shield", "#8fb8f0"), "thornbind": ("thorny-vine", "#6f9a4a"),
"aimed_shot": ("target-arrows", "#e0a050"), "pinning_shot": ("arrowed", "#8fd0e0"),
"barbed_arrow": ("barbed-arrow", "#e05a5a"), "volley": ("arrow-cluster", "#5fae8a"),
"find_the_seam": ("armor-downgrade", "#d07a3c"), "barricade": ("barricade", "#d1a46a"),
"hearty_meal": ("cooking-pot", "#e0b04a"), "mark_prey": ("targeted", "#e06a6a"),
"heavy_slam": ("punch-blast", "#e06a6a"), "lunge": ("pierced-body", "#e06a6a"),
"hex_bolt": ("fireball", "#a77be8"), "sweep": ("spinning-blades", "#e06a6a"),
"fire_breath": ("dragon-breath", "#ff8a3a"), "tail_swipe": ("spiked-tail", "#c08850"),
"stone_curse": ("evil-hand", "#a77be8"), "ground_pound": ("earth-crack", "#c08850"),
"shatter": ("shattered-glass", "#d1a46a"), "shriek": ("sonic-shout", "#a77be8"),
"hex_storm": ("lightning-storm", "#a77be8"), "crushing_decree": ("crown", "#e0b04a"),
"royal_stomp": ("crush", "#c08850"), "witchfire": ("witch-flight", "#5fae5a"),
"cheap_shot": ("thrown-daggers", "#d4dae2"), "execute": ("decapitation", "#e06a6a"),
"whirlwind": ("whirlwind", "#d4dae2"), "rune_blast": ("rune-stone", "#8fb8f0"),
"venom_spray": ("poison-cloud", "#5fae5a"), "grave_chill": ("tombstone", "#8fb8f0"),
"soul_rend": ("skull-bolt", "#a77be8"), "forge_blast": ("furnace", "#ff8a3a"),
"war_cry": ("shouting", "#e06a6a"), "acid_spit": ("acid-blob", "#5fae5a"),
"acid_breath": ("acid", "#5fae5a"), "deep_freeze": ("frozen-block", "#8fd0e0"),
"wail": ("screaming", "#a77be8"), "frost_lance": ("ice-spear", "#8fd0e0"),
"frost_breath": ("snowflake-2", "#8fd0e0"), "void_bolt": ("vortex", "#a77be8"),
"flame_wave": ("fire-wave", "#ff8a3a"), "inferno": ("fire-ring", "#ff6a2a"),
}
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 tier, (potion, *_rest) in enumerate(HEALTH_POTIONS):
ITEMS[potion].update(glyph=flasks[tier], tint="#e0506e")
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 key, ability in [*combat.ABILITIES.items(), *combat.ENEMY_ABILITIES.items()]:
ability["glyph"], ability["tint"] = ABILITY_GLYPHS[key]
# Dungeon loot: masterworks look like what they're reforged from, in gold.
for item, spec in ITEMS.items():
if spec.get("masterwork"):
spec.update(glyph=ITEMS[spec["base"]]["glyph"], tint=MASTERWORK_TINT)
elif spec.get("pattern"):
spec.update(glyph="scroll-quill", tint=MASTERWORK_TINT)
elif spec.get("relic"):
spec.update(glyph="relic-blade", tint="#c8b070")
for unique, (_name, _slot, glyph, tint, *_rest) in UNIQUES.items():
ITEMS[unique].update(glyph=glyph, tint=tint)
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,
combat.ABILITIES.values(), combat.ENEMY_ABILITIES.values()):
names.update(entry["glyph"] for entry in collection)
names.update(glyph for glyph, _tint in WARE_GLYPHS.values())
names.update(c["value"] for c in COSMETICS.values() if c["kind"] == "emblem" and "value" in c)
names.update(m["glyph"] for m in MONUMENTS.values())
return names
_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()
# What changed, shown on the Patch notes page. Newest first; every shipped change adds a
# line here (new / balance / fix), and a new release a new entry on top.
PATCH_NOTES = [
{"date": "2026-10-25", "title": "Titles", "notes": [
("new", "🏆 Achievements (under You): 127 to collect. Masterwork patterns per dungeon (7 each), the 7 uniques, titles, "
"boss emblems, caravan looks, home. Tap an icon for what it is and how to get it. Nothing is awarded there, it only keeps track."),
("new", "Fishing 50 also earns a second title: Master baiter."),
("balance", "Boiling hide glue now uses the kitchen fire: while it's lit, glue costs no logs."),
("fix", "Dungeons wait for the leader to press Start, so groups of 3 and 4 can form. No one can join once it starts."),
("fix", "Combat page shows your health potion: drunk on its own, no need to equip. The other card now reads \"No combat potion\"."),
("fix", "Combat page no longer jumps around: cast bars hold their space and the fight log stays 7 lines tall."),
("new", "Enemy casts that can't be interrupted show a 🔒 and a grey bar. Stuns still stop them, except on bosses."),
("fix", "Resting in a group: everyone heals at their own pace, so good food tops you up first. The pull still "
"waits for the slowest, and Pull now sends each in as healed as they really are."),
("new", "DPS meter under the combat log: damage per second and total for each fighter, live. Shows the last fight while resting."),
]},
{"date": "2026-10-24", "title": "Dungeon loot", "notes": [
("new", "Masterwork patterns: every dungeon boss can drop one of 7 (15% from a mini-boss, 35% from the last boss, "
"everyone rolls their own). A crafter learns it once, then reforges the tier's piece plus 3 relics into a "
"masterwork: about 30% more power or armour, wearable at the dungeon's level. Sword, warhammer, staff, bow, "
"plate, jerkin and robe in every dungeon."),
("new", "Relics: 50% from a mini-boss, 2 from every last boss."),
("new", "One unique per dungeon from its last boss (8% each), worn as a ring, amulet or trinket, that changes an "
"ability: One-Eye's fang, Ogre's knuckle, Barrow king's pendant, Warlord's whistle, Forgeheart, Rimeglass "
"lens, Wyrmscale quiver. Abilities it changes get a ✦."),
("new", "Choose when you eat between fights: below 25 / 50 / 75% of your HP, always, or never (the food slot, "
"under \"Eat below\"). Default 50%: above it you just rest, so a scratch doesn't cost food."),
("new", "Tapping an enemy or ally switches at once, and your next press goes to them: tap, then interrupt, "
"without waiting."),
("new", "HP bars show what was just lost, with each fighter's last hit or heal by their HP."),
("new", "An interrupted cast stays on the enemy's bar for a moment, struck through: ✋ interrupted."),
("balance", "Volley no longer takes arrows: abilities never cost anything. Wyrmscale quiver: Volley 40% harder "
"and Aimed Shot 25% faster."),
("fix", "Work orders on phones: the item picker and reagent slots show names, and picking an item scrolls to "
"the amount, price and Post."),
("fix", "Work orders show what a lit fire pays: its log is struck through instead of asked for, and the forge "
"pays a coal. Orders never needed that log from the crafter, they just asked for it."),
]},
{"date": "2026-10-24", "title": "A dungeon in every area", "notes": [
("new", "Six more dungeons for 2–4, seven pulls each with two mini-bosses and a final boss: Goblin Warren "
"(Greenwood, combat 8, best with 3–4), Barrow Crypts (Sunken Barrows, 35), Bandit Stronghold (Frontier, 40), "
"The Deep Forge (Iron Hills, 50), Frozen Hold (Frostpeak, 65) and Dragon's Lair (Dragon's Spine, 80)."),
("new", "Every boss has its own: curses to interrupt, shrieks, slams to brace for, adds at half health and an "
"enrage timer. As in the Watchtower, trash falls on auto and the bosses need your own hands."),
("new", "Clearing pays 30–750 gold by area, plus a trophy and 2 reagents (Goblin Warren: gold only)."),
]},
{"date": "2026-10-24", "title": "Gold", "notes": [
("balance", "Raids hit about twice as hard and grow with the guild: they add half the combat power of everyone "
"active in the last 2 days. Pay the guards, drill, stand guard and sabotage the camp, or expect losses. "
"Dragons are 0.8× a raid but can't be sabotaged."),
("balance", "Band size (small → huge) now compares the raid to what the city holds without preparing."),
("new", "Guard wages: 60 gold a guard per threat, paid ahead into the barracks' wage chest, from your gold (earns "
"favour) or the treasury. Only paid guards stand, and only they hold the armoury's swords and shields. "
"Posts: 10 + 20 per barracks level, up to half the townsfolk. The council paid for the coming threat."),
("balance", "Raid rewards: plunder 1× attack (was 2×); garrison XP 2× attack (was 5×), dragons 2.5× (was 6×)."),
("new", "Strongbox: rent bank slots for gold, 1,000 for the first and +35% each after."),
("new", "Caravan: comes once per threat, on the prep day, for 12h, with 4 of 10 wares and one look. Wares boost "
"your own work (trophy odds ×2 for 100 kills, +50% XP for 200 actions, …). One of each per visit; prices "
"rise with your level."),
("new", "Banner, title and home: pick colours and an emblem on your character sheet. Titles are earned by reaching "
"50 or 99 in a skill, defending the city 5 times, slaying a dragon or beating 10 bosses, or bought at the "
"caravan. Homes go from Cottage (2,000) to Manor (75,000). All of these show on the roster and the board."),
("new", "Monuments: gate banner 10,000, fountain 25,000, statues 50,000 (the biggest donor picks who). No favour; "
"donors' names are carved in."),
]},
{"date": "2026-10-24", "title": "Active play and dungeons", "notes": [
("new", "Active play: tap AUTO on an ability to take it yourself (YOU), then press it (tap, or keys 1–9) on its "
"real cooldown instead of auto's 1.5×. Look away for 12s and it's back on auto."),
("new", "Tap an enemy to fight it, or a friend to aim your heals at them."),
("new", "Dungeon: Watchtower Depths (combat 20, 2–4 players), 8 pulls in order. Trash falls on auto; the Clay "
"colossus, Harpy matriarch and Watchtower ogre need your own hands: interrupt Stone Curse and Shriek, "
"brace for Ground Pound, beat the enrage timer. Start it and wait for others to join before the first pull."),
("new", "A wipe repeats the pull, 3 on one pull ends the run. The run waits while anyone looks away for 90s. "
"The leader can pull early."),
("new", "Dungeon rewards: every boss gives everyone their own loot (mini-bosses 5× their race's odds). "
"Clearing pays 100 gold, a trophy and 2 reagents each."),
]},
{"date": "2026-10-24", "title": "Livelier frontier", "notes": [
("balance", "A threat lands every 2 days (±2h), and the next one is rumoured as soon as the last lands. "
"Day 1: scout, drill, build. Day 2: the camp is found (intel III opens 24h out), with the exact hour."),
("balance", "Sabotage: -1% raid strength per win (was -3%), still max 30%."),
("balance", "Happenings ~3× as often: fortunes every 10–20h (was 36–72h), hardships 20–40h (was 48–96h), "
"minor threats 16–32h (was 48–96h)."),
("balance", "Shorter happenings: 12h boosts → 6h; festival, sellswords, drought 24h → 12h; cold snap 48h → 24h; "
"sickness 72h → 36h; flood 36h → 18h."),
("new", "Fortunes: storm-felled timber (woodcutting yield +50%), deer migration (hunting yield ×2), masons' guild "
"(construction 25% faster), artisans' fair (crafts 15% faster), a trapper's leads (next 15 recon doubled)."),
("new", "Hardships: blight (farming yield ½), tremors (mining 30% slower), the tax collector (-15% treasury)."),
("new", "Minor threats: spiders in the mine and smugglers skimming 1% treasury/h (fighters), a washed-out "
"bridge that blocks hunting (25 planks), hornets slowing woodcutting (herbalists smoke them out)."),
]},
{"date": "2026-10-24", "title": "Combat rework", "notes": [
("new", "Fights are against packs: as many enemies as there are of you, often one more (each 35% weaker). "
"A rare elite still comes alone."),
("new", "Abilities: your weapon gives four (combat 1 / 15 / 30 / 45) and your trade one more. Sword and shield "
"tanks, warhammer hits hard and stuns, staff heals and holds, bow shoots from afar. All on auto for now: "
"hover them on the Combat page."),
("new", "Enemies fight back: brutes wind up Heavy Slams, skirmishers lunge at whoever isn't tanking, casters "
"cast Hex Bolts (which can be interrupted), elites and bosses enrage, dragons breathe fire. Enemies attack "
"whoever has the most threat, so a sword and shield draws them away from the rest."),
("new", "Food is only eaten resting between fights: you rest back to full before each one, and better food "
"rests you faster. No food just means longer rests."),
("new", "Health potions I–VI (Herblore 1–75, the tier's herb and cooked fish): the one heal you take into a "
"fight, drunk once a fight under 35% HP."),
("new", "Knocked out at 0 HP: you lose that fight and come round at 30% HP. Three in a row and you stop. "
"Retreating is gone."),
("new", "Bosses and dragons are group fights (2+). Start one alone and you wait there for someone to join. "
"They grow with the group: +75% HP for each fighter."),
("fix", "Abilities, enemy casts and fight statuses have proper icons instead of emoji."),
("balance", "Enemies have 20% more HP for the same XP, since everyone has abilities now."),
("balance", "Boss hits: area bosses 1.35 → 1.2× and dragons 1.5 → 1.9× their level's max hit, then ×1.3 "
"against a pair."),
]},
{"date": "2026-10-23", "title": "City needs", "notes": [
("new", "City needs are now indicated in profession menus."),
]},
{"date": "2026-10-22", "title": "Bosses", "notes": [
("new", "Bosses and dragons: everyone in the fight gets their own loot."),
("balance", "Level requirements for area boss reduced to be area level +5."),
]},
{"date": "2026-10-21", "title": "Beta", "notes": [
("new", "Beta: completely meaningless milestone reached; every class now makes a decent amount of valuable contributions to the guild."),
("new", "Patch notes menu."),
("new", "Firekeeping: tend the fire for your guild members' crafting stations. Builders get a bonus. Use tiers of wood to light "
"and maintain a fire, which saves resources on guild members' crafts of the same tier. For every craft completed while "
"the fire is lit, the Firekeeper gains 10% of the XP the craft gave the crafter, shared between all players keeping a "
"fire of that tier. Each player keeps their own \"pile\", which contributes an equal amount to the fire."),
("new", "Work orders: anyone can provide reagents towards an incomplete work order and gets paid based on the value of the items provided."),
("new", "Multiplayer Combat: tag into anyone's fight from the encounter card or the guild roster."),
("balance", "Gear: combat requirement adjusted to match the tier of crafting required to make it."),
("balance", "Combat XP reduced to keep it in line with professions. Eating now delays your next swing; eat higher tier food to reduce the impact."),
("balance", "Construction workbench now also affects labour on building sites."),
("balance", "Weapon trophies drop more often, since the town needs weapons too: 3% → 10% from races, 6% → 20% per weapon type from bosses."),
("balance", "Hunting and Leatherworking tiers now unlock at 1 / 15 / 30 / 45 / 60 / 75, like every other trade. "
"Hunting: deer 8 → 1, boar 25 → 15, bears 40 → 30, elk 50 → 45, sabrecats 65 → 60, mammoths 80 → 75. "
"Leatherworking: tanning, armour and bows 5 levels lower from hard leather up."),
("balance", "Hunting time and XP now match Fishing: deer 40s · 13 XP → 25s · 6 XP (same as birds), boar 50s · 30 → 35s · 15, "
"bears 60s · 48 → 45s · 28, elk 70s · 64 → 55s · 45, sabrecats 80s · 84 → 65s · 62, mammoths 90s · 108 → 80s · 85."),
]},
]
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, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
"equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE, "eat_below": EAT_BELOW, "eat_choices": EAT_CHOICES,
"abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD,
"weapon_kits": combat.WEAPON_KITS, "group_size": GROUP_SIZE, "dungeon_size": DUNGEON_SIZE, "dungeon_wipes": DUNGEON_WIPES, "trade_ability": combat.TRADE_ABILITY, "potion_below": combat.POTION_BELOW,
"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,
"patch_notes": PATCH_NOTES,
"wares": {key: {**spec, "glyph": WARE_GLYPHS[key]} for key, spec in WARES.items()}, "cosmetics": COSMETICS, "free_looks": FREE_LOOKS, "homes": HOMES, "monuments": MONUMENTS,
"guard_wage": GUARD_WAGE,
}