guildhall/game.py
rope 8d6d23261e Forecast line gets its own tooltip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:46:37 +01:00

4201 lines
215 KiB
Python

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