guildhall/game.py
rope f60be605cf Sickness: city eats twice as much; plainer wording
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:46:18 +01:00

3297 lines
165 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": 6,
"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]),
]
PLURALS = {"sword": "swords", "shield": "shields", "helmet": "helmets", "greaves": "greaves", "cuirass": "cuirasses"}
PIECE_ICONS = {"sword": "⚔️", "shield": "🛡️", "helmet": "🪖", "legs": "🦿", "body": "🦾"}
COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body": "🥋"}
# Gathering tools: one slot per skill, `speed` cuts the action time.
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", "🪏"),
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪"), "construction": ("hammer", "🔨")}
TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60]
# item id, name, icon, value, action id, verb
TREES = [
("logs", "Logs", "🪵", 1, "cut_logs", "Cut trees"),
("oak_logs", "Oak logs", "🌳", 4, "cut_oak", "Cut oaks"),
("willow_logs", "Willow logs", "🌿", 8, "cut_willow", "Cut willows"),
("maple_logs", "Maple logs", "🍁", 15, "cut_maple", "Cut maples"),
("yew_logs", "Yew logs", "🌲", 30, "cut_yew", "Cut yews"),
("magic_logs", "Enchanted logs", "🔮", 55, "cut_magic", "Cut enchanted trees"),
]
# raw hide (id, name, icon, value), leather (id, name, value), armour id prefix, armour name prefix,
# leatherworking level, armour by piece
LEATHERS = [
(("hide", "Hide", "🟫", 3), ("leather", "Leather", 5), "leather", "Leather", 1, (1, 2, 3)),
(("boar_hide", "Boar hide", "🐗", 8), ("hard_leather", "Hard leather", 12), "hard_leather", "Hard leather", 20, (2, 4, 6)),
(("bear_pelt", "Bear pelt", "🐻", 16), ("bear_leather", "Bear leather", 24), "bear_leather", "Bear leather", 35, (4, 6, 9)),
(("green_dragonhide", "Green dragonhide", "🐉", 35), ("green_dragon_leather", "Green dragon leather", 50),
"green_dhide", "Green dragonhide", 50, (6, 9, 13)),
(("blue_dragonhide", "Blue dragonhide", "🐲", 60), ("blue_dragon_leather", "Blue dragon leather", 85),
"blue_dhide", "Blue dragonhide", 65, (8, 12, 17)),
(("red_dragonhide", "Red dragonhide", "🔥", 100), ("red_dragon_leather", "Red dragon leather", 140),
"red_dhide", "Red dragonhide", 80, (10, 15, 21)),
]
# piece suffix, slot, noun, leather used, level above the tier, icon
LEATHER_PIECES = [("cap", "helmet", "cap", 2, 2, "🧢"), ("legs", "legs", "trousers", 3, 7, "👖"),
("body", "body", "jerkin", 4, 11, "🦺")]
# raw (id, name, icon, value), cooked (id, name, icon, value, heal, food), fishing level, fish action, verb
FISH = [
(("raw_shrimp", "Raw shrimp", "🦐", 1), ("cooked_shrimp", "Cooked shrimp", "🍤", 4, 10, 1), 1, "fish_shrimp", "Net shrimp"),
(("raw_trout", "Raw trout", "🐟", 3), ("cooked_trout", "Grilled trout", "🍢", 8, 18, 1), 15, "fish_trout", "Fly-fish trout"),
(("raw_salmon", "Raw salmon", "🐠", 6), ("cooked_salmon", "Baked salmon", "🍣", 14, 28, 2), 30, "fish_salmon", "Catch salmon"),
(("raw_eel", "Raw eel", "🐍", 10), ("smoked_eel", "Smoked eel", "🥓", 22, 40, 2), 45, "fish_eel", "Trap eels"),
(("raw_lobster", "Raw lobster", "🦞", 18), ("cooked_lobster", "Cooked lobster", "🍜", 35, 55, 3), 60, "fish_lobster", "Pot lobsters"),
(("raw_swordfish", "Raw swordfish", "🐬", 30), ("cooked_swordfish", "Grilled swordfish", "🍱", 55, 70, 3), 75, "fish_swordfish", "Harpoon swordfish"),
]
COOK_IDS = {"raw_shrimp": "cook_shrimp", "raw_trout": "cook_trout", "raw_salmon": "cook_salmon", "raw_eel": "cook_eel",
"raw_lobster": "cook_lobster", "raw_swordfish": "cook_swordfish"}
ITEMS["coal"] = {"name": "Coal", "icon": "🌑", "value": 6}
for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in enumerate(METALS):
if _ore:
ITEMS[_ore[0]] = {"name": _ore[1], "icon": _ore[2], "value": _ore[3]}
ITEMS[f"{_key}_bar"] = {"name": f"{_name} bar", "icon": _bar_icon, "value": _bar_value, "tier": _tier}
for _piece, _slot, _noun, _bars, _offset, _power, _armour in PIECES:
_item = {"name": f"{_name} {_noun}", "plural": f"{_name} {PLURALS[_noun]}",
"icon": COPPER_ICONS.get(_piece, PIECE_ICONS[_piece]) if _key == "copper" else PIECE_ICONS[_piece],
"value": round(_bars * _bar_value * 1.2), "slot": _slot, "tier": _tier,
"req": ["combat", EQUIP_LEVELS[_tier]]}
if _power:
_item["power"] = _power[_tier]
if _armour:
_item["armour"] = _armour[_tier]
ITEMS[f"{_key}_{_piece}"] = {k: v for k, v in _item.items() if v is not None}
for _skill, (_tool, _icon) in TOOLS.items():
ITEMS[f"{_key}_{_tool.replace(' ', '_')}"] = {
"name": f"{_name} {_tool}", "icon": _icon, "value": round(2 * _bar_value * 1.4), "tier": _tier,
"slot": f"tool_{_skill}", "tool": _skill, "speed": TOOL_SPEED[_tier], "req": [_skill, EQUIP_LEVELS[_tier]],
}
# Hardware: what the smith makes for builders, one kind per metal, used in
# fittings and in building levels in place of plain bars.
# id, singular, plural, bars used, pieces made
HARDWARE = [
("copper_rivets", "Copper rivet", "Copper rivets", 1, 5),
("iron_nails", "Iron nail", "Iron nails", 1, 5),
("steel_hinges", "Steel hinge", "Steel hinges", 1, 2),
("mithril_brackets", "Mithril bracket", "Mithril brackets", 1, 2),
("adamant_chain", "Adamant chain", "Adamant chains", 2, 1),
("rune_lock", "Rune lock", "Rune locks", 2, 1),
]
for (_id, _name, _plural, _bars, _made), _metal in zip(HARDWARE, METALS):
ITEMS[_id] = {"name": _name, "plural": _plural, "icon": "🔩", "value": max(1, round(_bars * _metal[5] * 1.2 / _made)),
"tier": METALS.index(_metal)}
PLANKS = [("plank", "Plank"), ("oak_plank", "Oak plank"), ("willow_plank", "Willow plank"),
("maple_plank", "Maple plank"), ("yew_plank", "Yew plank"), ("magic_plank", "Enchanted plank")]
for (_log, _name, _icon, _value, _action_id, _verb), (_plank, _plank_name) in zip(TREES, PLANKS):
ITEMS[_log] = {"name": _name, "icon": _icon, "value": _value}
ITEMS[_plank] = {"name": _plank_name, "icon": "🪚", "value": _value * 2 + 1}
# Weapons: four styles, one per trade. The sword pairs with a shield; the
# warhammer, staff and bow take both hands, so they carry the shield's power too.
SWORD_POWER = next(piece[5] for piece in PIECES if piece[0] == "sword")
SHIELD_POWER = next(piece[5] for piece in PIECES if piece[0] == "shield")
TWO_HANDED_POWER = [sword + shield for sword, shield in zip(SWORD_POWER, SHIELD_POWER)]
WOODS = ["wooden", "oak", "willow", "maple", "yew", "magic"]
STAVES = ["Wooden staff", "Oak staff", "Willow staff", "Maple staff", "Yew staff", "Enchanted staff"]
BOWS = ["Shortbow", "Oak bow", "Willow bow", "Maple bow", "Yew bow", "Enchanted bow"]
for _tier, _metal in enumerate(METALS):
ITEMS[f"{_metal[0]}_sword"]["style"] = "sword"
_weapon = {"slot": "weapon", "power": TWO_HANDED_POWER[_tier], "tier": _tier, "two_handed": True,
"req": ["combat", EQUIP_LEVELS[_tier]], "value": round(5 * _metal[5] * 1.2)}
ITEMS[f"{_metal[0]}_warhammer"] = {"name": f"{_metal[1]} warhammer", "icon": "🔨", "style": "hammer", **_weapon}
ITEMS[f"{WOODS[_tier]}_staff"] = {"name": STAVES[_tier], "plural": STAVES[_tier].replace("staff", "staves"),
"icon": "🪄", "style": "staff", **_weapon}
ITEMS[f"{WOODS[_tier]}_bow"] = {"name": BOWS[_tier], "icon": "🏹", "style": "bow", **_weapon}
for _tier, (_raw, _leather, _prefix, _label, _level, _armour) in enumerate(LEATHERS):
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
ITEMS[_leather[0]] = {"name": _leather[1], "icon": "🪢", "value": _leather[2]}
for (_suffix, _slot, _noun, _used, _offset, _icon), _points in zip(LEATHER_PIECES, _armour):
ITEMS[f"{_prefix}_{_suffix}"] = {
"name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _leather[2] * 1.3),
"slot": _slot, "armour": _points, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]],
}
if _noun == "trousers":
ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"]
for _raw, _cooked, _level, _action_id, _verb in FISH:
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
ITEMS[_cooked[0]] = {"name": _cooked[1], "icon": _cooked[2], "value": _cooked[3], "heal": _cooked[4], "food": _cooked[5]}
# --- herblore -------------------------------------------------------------------
# Herbs from Farming, brewed with a secondary into potions with charges. One
# potion per skill at a time, as in Melvor; each action in that skill uses a
# charge, and an empty potion is replaced from the bank if there's another.
# id, name, icon, farming level, seconds, xp, value
HERBS = [
("guam_leaf", "Guam leaf", "🍀", 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[:4], ["feathers", "raw_meat", "boar_hide", "bear_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]),
("prayer", "Prayer potion", "prayer", "bones", [0.25, 0.5, 0.75], ["bones", "big_bones", "dragon_bones"]),
("strength", "Strength potion", "combat", "power", [4, 7, 11, 15, 20, 26], [leather[0][0] for leather in LEATHERS]),
("defence", "Defence potion", "combat", "reduce", [0.10, 0.14, 0.18, 0.22, 0.26, 0.30],
[f"{metal[0]}_shield" for metal in METALS]),
]
# id, name, herblore level, herb, secondary, skill, effect
POTIONS = []
POTION_TIER = {}
for _line, _name, _skill, _key, _strengths, _seconds in POTION_LINES:
for _tier, (_strength, _second) in enumerate(zip(_strengths, _seconds)):
_id = f"{_line}_potion_{_tier + 1}"
POTIONS.append((_id, f"{_name} {ROMAN[_tier]}", TIER_LEVELS[_tier], HERBS[_tier][0], _second, _skill,
{_key: _strength}))
POTION_TIER[_id] = _tier
# Potions from before the tiers, and what they became.
OLD_POTIONS = {
"trackers_tincture": "hunting_potion_3", "lumberjacks_tonic": "woodcutting_potion_3",
"strength_potion": "strength_potion_2", "anglers_brew": "fishing_potion_3",
"prospectors_draught": "mining_potion_2", "growth_potion": "farming_potion_3",
"defence_potion": "defence_potion_2", "chefs_spice": "cooking_potion_2", "bonedust_tonic": "prayer_potion_2",
"smiths_focus": "smithing_potion_2", "masons_patience": "construction_potion_2", "tanners_oil": "leatherworking_potion_2",
"regeneration_potion": "defence_potion_3", "super_strength": "strength_potion_4", "super_defence": "defence_potion_5",
}
def describe_potion(skill: str, effect: dict) -> str:
name = "combat" if skill == "combat" else skill
if "speed" in effect:
return f"{name.title()} {round(effect['speed'] * 100)}% faster"
if "double" in effect:
return f"{name.title()}: {round(effect['double'] * 100)}% double yield"
if "keep" in effect:
return f"{name.title()}: {round(effect['keep'] * 100)}% save materials"
if "bones" in effect:
return f"+{round(effect['bones'] * 100)}% from bones"
if "power" in effect:
return f"+{effect['power']} power"
return f"-{round(effect['reduce'] * 100)}% damage taken"
for _id, _name, _icon, _level, _time, _xp, _value in HERBS:
ITEMS[_id] = {"name": _name, "icon": _icon, "value": _value}
for _id, _name, _level, _herb, _second, _skill, _effect in POTIONS:
ITEMS[_id] = {"name": _name, "icon": "⚗️" if _skill == "combat" else "🧪",
"value": ITEMS[_herb]["value"] * 3 + ITEMS[_second]["value"],
"potion": {"skill": _skill, **_effect, "text": describe_potion(_skill, _effect)}}
# What the city eats first: plain food before the good stuff.
FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"]))
EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs"] + [f"tool_{skill}" for skill in TOOLS]
SKILLS = [
{"id": "mining", "name": "Mining", "icon": "⛏️", "group": "Gathering"},
{"id": "woodcutting", "name": "Woodcutting", "icon": "🪓", "group": "Gathering"},
{"id": "farming", "name": "Farming", "icon": "🌱", "group": "Gathering"},
{"id": "hunting", "name": "Hunting", "icon": "🦌", "group": "Gathering"},
{"id": "fishing", "name": "Fishing", "icon": "🎣", "group": "Gathering"},
{"id": "smithing", "name": "Smithing", "icon": "🔨", "group": "Crafting"},
{"id": "construction", "name": "Construction", "icon": "🏗️", "group": "Crafting"},
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
{"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"},
{"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"},
{"id": "prayer", "name": "Prayer", "icon": "🙏", "group": "Combat"},
{"id": "reconnaissance", "name": "Reconnaissance", "icon": "🔭", "group": "Combat"},
]
SKILL_IDS = [skill["id"] for skill in SKILLS]
GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"}
CRAFTING = {"smithing", "construction", "cooking", "leatherworking", "herblore"}
# A class is a whole trade, from gathering to the finished goods: its skills are
# 20% faster, 1 in 10 of its crafts make double, and it has one bonus of its own.
# Nothing is locked: every class can train every skill.
CLASSES = {
"miner": {
"name": "Miner", "icon": "⛏️", "fast": ["mining", "smithing"], "craft": ["smithing"],
"bonus": "Mining and smithing 20% faster",
"masterwork": "Masterwork: 10% double smithing crafts",
"signature": "Prospector: 10% double ore",
"combat": "Warhammer: +15% damage",
},
"builder": {
"name": "Builder", "icon": "🏗️", "fast": ["woodcutting", "construction"], "craft": ["construction"],
"bonus": "Woodcutting and construction 20% faster",
"masterwork": "Masterwork: 10% double construction crafts",
"signature": "Foreman: +1 labour per site action",
"combat": "Sword: +15% damage",
},
"homesteader": {
"name": "Homesteader", "icon": "🌾", "fast": ["cooking", "herblore"], "craft": ["cooking", "herblore"],
"bonus": "Cooking and herblore 20% faster, two farm plots",
"masterwork": "Masterwork: 10% double cooking and herblore 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, **({trophy("troll", tier): tier} if piece == "sword" and tier else {})}
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("golem", 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, _armour) in enumerate(LEATHERS):
actions.append(_action("tan_leather" if tier == 0 else f"tan_{leather[0]}", "leatherworking",
f"Tan {leather[1].lower()}", level, 25 + 3 * tier, bar_xp[tier],
{raw[0]: 1}, {leather[0]: 1}, group="Tanning"))
for suffix, _slot, noun, used, offset, _icon in LEATHER_PIECES:
actions.append(_action(f"lw_{suffix}" if tier == 0 else f"lw_{prefix}_{suffix}", "leatherworking",
f"{label} {noun}", level + offset, 25 + 5 * used + 2 * tier, used * piece_xp[tier],
{leather[0]: used}, {f"{prefix}_{suffix}": 1}, group=label))
for raw, cooked, level, fish_id, verb in FISH:
tier = TIER_LEVELS.index(level) if level in TIER_LEVELS else 3
actions.append(_action(fish_id, "fishing", verb, level, [25, 35, 45, 55, 65, 80][tier],
[6, 15, 28, 45, 62, 85][tier], outputs={raw[0]: 1}))
actions.append(_action(COOK_IDS[raw[0]], "cooking", cooked[1], level, [20, 30, 35, 40, 45, 50][tier],
[6, 15, 27, 42, 58, 80][tier], {raw[0]: 1, "logs": 1}, {cooked[0]: 1}, 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. Combat
# is split into the Frontier (bandits and the city's troubles) and expeditions:
# one area per tier, each with a golem, a troll, a spirit and a flyer that drop
# the trophies higher weapons need, and a boss.
CRIT_CHANCE = 0.05
CRIT_MULT = 1.5
RESPAWN = 1 # 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
# How each kind of weapon fights. Two-handed weapons carry the shield's power themselves.
STYLES = {
"sword": {"name": "sword", "interval": 2.4, "hit": 1.0, "accuracy": 1.0, "crit": 0.05},
"hammer": {"name": "warhammer", "interval": 3.6, "hit": 1.6, "accuracy": 0.95, "crit": 0.10},
"staff": {"name": "staff", "interval": 3.0, "hit": 1.15, "accuracy": 1.25, "crit": 0.05, "pierce": True},
"bow": {"name": "bow", "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
# Damage each style does to each kind of monster (1 when not listed).
WEAKNESS = {
"golem": {"hammer": 1.5, "bow": 0.5},
"troll": {"sword": 1.5},
"spirit": {"hammer": 0.5, "sword": 0.5, "bow": 0.5, "staff": 1.5},
"flyer": {"hammer": 0.5, "sword": 0.5, "bow": 1.5},
}
def trophy(kind: str, tier: int) -> str:
"""The trophy a tier's monster of this kind drops (tier 1 is the Old Watchtower)."""
return _slug(AREA_MONSTERS[kind][2][tier - 1])
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 kind, count: {trophy(kind, 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("troll", 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("spirit", 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("flyer", 2 + tier)}, {f"{WOODS[tier]}_bow": 1},
group="Bows"))
return actions
# kind: HP, seconds between swings, accuracy, evasion, max hit (multipliers), damage it shrugs off
MONSTER_KINDS = {
"human": (1.0, 2.6, 1.0, 1.0, 1.0, 0.0),
"beast": (0.9, 2.2, 1.0, 1.0, 0.9, 0.0),
"golem": (1.5, 3.2, 1.0, 0.7, 1.25, 0.2),
"troll": (1.4, 3.0, 0.9, 1.0, 1.4, 0.0),
"spirit": (0.8, 2.4, 1.0, 1.35, 0.9, 0.0),
"flyer": (0.8, 2.0, 1.0, 1.2, 0.8, 0.0),
"dragon": (2.5, 3.0, 1.1, 1.0, 1.5, 0.15),
}
def monster(name: str, kind: str, level: int, boss: bool = False) -> dict:
hp, interval, accuracy, evasion, hit, shrug = MONSTER_KINDS[kind]
return {"name": name, "kind": kind, "level": level, "boss": boss,
"hp": round((20 + 6 * level) * hp * (2.5 if boss else 1)), "interval": interval,
"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))), "reduction": shrug}
def _fight(id: str, enemy: dict, group: str, gold: int = 0, drops=(), name: str | None = None, **extra) -> dict:
"""A combat action: XP per kill, needs the enemy's level in combat."""
xp = round(enemy["hp"] * 0.4 + enemy["level"] * 1.5)
return _action(id, "combat", name or enemy["name"], enemy["level"], RESPAWN, xp, gold=gold,
drops=[list(drop) for drop in drops], enemy=enemy, group=group, **extra)
# Expedition areas, tiers II to VI: name, level, and the trophy each kind of monster drops there.
AREAS = ["Old Watchtower", "Sunken Barrows", "Iron Hills", "Frostpeak", "Dragon's Spine"]
AREA_LEVELS = [10, 20, 30, 40, 50]
AREA_MONSTERS = { # kind: (level offset, monsters, trophies)
"golem": (0, ["Clay golem", "Stone golem", "Iron golem", "Obsidian golem", "Runic golem"],
["Clay core", "Stone core", "Iron core", "Obsidian core", "Runic core"]),
"troll": (2, ["Cave troll", "Hill troll", "Rock troll", "Frost troll", "Elder troll"],
["Cave troll hide", "Hill troll hide", "Rock troll hide", "Frost troll hide", "Elder troll hide"]),
"spirit": (1, ["Will-o'-wisp", "Bog wraith", "Mine ghost", "Banshee", "Shade"],
["Wisp essence", "Wraith essence", "Ghost essence", "Banshee essence", "Shade essence"]),
"flyer": (0, ["Giant bat", "Harpy", "Wyvern", "Griffin", "Roc"],
["Bat sinew", "Harpy sinew", "Wyvern sinew", "Griffin sinew", "Roc sinew"]),
}
TROPHY_ODDS = 0.33
AREA_BONES = ["bones", "big_bones", "big_bones", "dragon_bones", "dragon_bones"]
def _slug(name: str) -> str:
return "".join(c if c.isalnum() else "_" for c in name.lower().replace("'", "")).strip("_")
TROPHIES = {} # id: (name, kind, tier)
for _kind, (_offset, _monsters, _trophies) in AREA_MONSTERS.items():
for _tier, _trophy in enumerate(_trophies, start=1):
TROPHIES[_slug(_trophy)] = (_trophy, _kind, _tier)
ITEMS[_slug(_trophy)] = {"name": _trophy, "icon": "🏆", "value": 15 * _tier, "trophy": _kind, "tier": _tier}
def _area_fights() -> list[dict]:
fights = []
for index, (area, level) in enumerate(zip(AREAS, AREA_LEVELS)):
for kind, (offset, monsters, trophies) in AREA_MONSTERS.items():
enemy = monster(monsters[index], kind, level + offset)
fights.append(_fight(f"fight_{_slug(monsters[index])}", enemy, area, gold=level + offset,
drops=[(AREA_BONES[index], 1, 1.0), (_slug(trophies[index]), 1, TROPHY_ODDS)]))
return fights
def _boss_trophies(tier: int) -> list[tuple]:
return [(_slug(trophies[tier - 1]), 1, 0.25) for _offset, _monsters, trophies in AREA_MONSTERS.values()]
COMBAT_ACTIONS = [
# The Frontier: the city's own troubles.
_fight("fight_scouts", monster("Bandit scouts", "human", 10), "Frontier", gold=10,
drops=[("copper_bar", 1, 0.3), ("arrows", 5, 0.4), ("bones", 1, 1.0)]),
_fight("sabotage", monster("Camp guards", "human", 12), "Frontier", name="Sabotage the bandit camp",
special="sabotage"),
_fight("fight_stronghold", monster("Bandit stronghold", "human", 40, boss=True), "Frontier", 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.
_fight("fight_wolves", monster("Wolves", "beast", 1), "Greenwood", gold=2, name="Wolves in the woods",
drops=[("raw_meat", 1, 0.6), ("hide", 1, 0.5), ("bones", 1, 1.0)]),
_fight("fight_goblins", monster("Cave goblins", "human", 5), "Greenwood", gold=4,
drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]),
*_area_fights(),
_fight("fight_ruins", monster("Watchtower ogre", "troll", 18, boss=True), "Old Watchtower", gold=25,
drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_trophies(1)]),
_fight("fight_barrow_king", monster("Barrow king", "spirit", 28, boss=True), "Sunken Barrows", gold=40,
drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_trophies(2)]),
_fight("fight_green_dragon", monster("Green dragon", "dragon", 38), "Iron Hills", gold=80,
drops=[("green_dragonhide", 1, 0.9), ("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_trophies(3)]),
_fight("fight_blue_dragon", monster("Blue dragon", "dragon", 48), "Frostpeak", gold=110,
drops=[("blue_dragonhide", 1, 0.9), ("adamantite_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_trophies(4)]),
_fight("fight_red_dragon", monster("Red dragon", "dragon", 60), "Dragon's Spine", gold=150,
drops=[("red_dragonhide", 1, 0.9), ("runite_ore", 1, 0.25), ("dragon_bones", 1, 1.0), *_boss_trophies(5)]),
_fight("fight_black_dragon", monster("Black dragon", "dragon", 75, boss=True), "Dragon's Spine", gold=220,
drops=[("red_dragonhide", 2, 0.9), ("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_trophies(5)]),
]
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}),
*_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", 10, 35, 11, outputs={"flax": 3}, group="Materials"),
_action("boil_glue", "leatherworking", "Boil hide glue", 5, 30, 10, {"hide": 1, "logs": 1}, {"hide_glue": 2},
group="Tanning"),
*[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier,
[6, 14, 22, 32, 44, 58][tier], {TREES[tier][0]: 2}, {plank: 1}, group="Planks")
for tier, (plank, name) in enumerate(PLANKS)],
_action("mason_bricks", "construction", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}, group="Masonry"),
_action("mason_cut_stone", "construction", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}, group="Masonry"),
_action("mason_beam", "construction", "Reinforced beam", 20, 45, 30,
{"oak_logs": 2, "iron_nails": 5}, {"beam": 1}, group="Masonry"),
_action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}, group="Meals"),
_action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}, group="Meals"),
_action("cook_stew", "cooking", "Stew", 15, 35, 16,
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}, group="Meals"),
_action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}, group="Rations"),
_action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}, group="Rations"),
_action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}, group="Rations"),
_action("drill_militia", "combat", "Drill the militia", 1, 60, 12, special="drill", group="Frontier"),
_action("bury_bones", "prayer", "Bury bones", 1, 10, 4, {"bones": 1}, special="bury", pp=2, group="Bury"),
_action("bury_big_bones", "prayer", "Bury big bones", 15, 10, 15, {"big_bones": 1}, special="bury", pp=6, group="Bury"),
_action("bury_dragon_bones", "prayer", "Bury dragon bones", 40, 12, 72, {"dragon_bones": 1}, special="bury", pp=20, group="Bury"),
_action("offer_bones", "prayer", "Offer bones at the temple", 1, 12, 7, {"bones": 1}, special="offer",
devotion=2, city={"temple": 1}, group="Offer at the temple"),
_action("offer_big_bones", "prayer", "Offer big bones", 15, 12, 25, {"big_bones": 1}, special="offer",
devotion=6, city={"temple": 1}, group="Offer at the temple"),
_action("offer_dragon_bones", "prayer", "Offer dragon bones", 40, 14, 110, {"dragon_bones": 1}, special="offer",
devotion=20, city={"temple": 2}, group="Offer at the temple"),
_action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1),
_action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2),
]
ACTION_BY_ID = {action["id"]: action for action in ACTIONS}
BUILDINGS = {
"houses": {
"name": "Houses", "icon": "🏠", "max": 8, "gold": 200,
"base": {"logs": 300, "bricks": 150, "thatch": 300, "provisions": 80, "copper_rivets": 60},
"advanced": {"oak_logs": 100, "leather": 80, "rope": 40, "iron_nails": 150},
"late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 50, "steel_hinges": 20},
"final": {"maple_logs": 50, "willow_plank": 30, "hard_leather": 60, "mithril_brackets": 15},
"effect": "+20 homes per level",
},
"walls": {
"name": "Walls", "icon": "🏰", "max": 5, "gold": 300,
"base": {"stone": 1000, "bricks": 250, "provisions": 90},
"advanced": {"cut_stone": 150, "beam": 20, "rope": 50, "iron_nails": 200},
"late": {"steel_bar": 60, "oak_plank": 25, "hide_glue": 50, "steel_hinges": 30},
"final": {"mithril_brackets": 60, "maple_plank": 20, "hide_glue": 60, "adamant_chain": 10},
"effect": "+20 defence per level, less plunder lost",
},
"barracks": {
"name": "Barracks", "icon": "⛺", "max": 5, "gold": 300,
"base": {"logs": 300, "bricks": 200, "thatch": 200, "leather": 80, "copper_sword": 25, "provisions": 80, "copper_rivets": 60},
"advanced": {"iron_bar": 60, "hard_leather": 40},
"late": {"steel_bar": 40, "oak_plank": 30, "hide_glue": 40},
"final": {"mithril_bar": 25, "willow_plank": 25, "bear_leather": 40},
"effect": "More defence per townsperson",
},
"smithy": {
"name": "Smithy", "icon": "⚒️", "max": 5, "gold": 250,
"base": {"stone": 400, "logs": 250, "copper_bar": 180, "provisions": 60},
"advanced": {"iron_bar": 180, "leather": 70},
"late": {"steel_bar": 100, "oak_plank": 25, "hide_glue": 40},
"final": {"mithril_bar": 70, "maple_plank": 20, "bear_leather": 35},
"effect": "Smithing 8% faster and one more metal per level",
},
"temple": {
"name": "Temple", "icon": "⛪", "max": 3, "gold": 300,
"base": {"stone": 700, "bricks": 200, "cut_stone": 60, "provisions": 60},
"advanced": {"beam": 15, "rope": 40, "iron_nails": 150},
"late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 40, "steel_hinges": 20},
"effect": "Per level: +1 prayer point per bone, +25% devotion",
},
"granary": {
"name": "Granary", "icon": "🌾", "max": 3, "gold": 150,
"base": {"logs": 200, "bricks": 100, "plank": 40, "thatch": 300, "provisions": 50, "copper_rivets": 40},
"advanced": {"cut_stone": 40, "rope": 40, "iron_nails": 100},
"late": {"oak_plank": 35, "hide_glue": 35, "steel_hinges": 15},
"effect": "Guards 25% of food from pests per level",
},
"lumber_shed": {
"name": "Lumber shed", "icon": "🪵", "max": 3, "gold": 120,
"base": {"logs": 200, "stone": 250, "plank": 50, "thatch": 180, "provisions": 40, "copper_rivets": 40},
"advanced": {"oak_plank": 40, "rope": 40, "iron_nails": 100},
"late": {"willow_plank": 25, "hide_glue": 35},
"effect": "Guards 25% of wood from rot per level",
},
"well": {
"name": "Well", "icon": "🪣", "max": 3, "gold": 120,
"base": {"stone": 500, "bricks": 80, "rope": 50, "provisions": 40},
"advanced": {"cut_stone": 40, "leather": 60, "iron_nails": 125},
"late": {"oak_plank": 25, "hide_glue": 35, "steel_hinges": 15},
"effect": "Fire damage -25% per level",
},
"spymaster": {
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
"base": {"logs": 200, "bricks": 150, "hide": 120, "feathers": 300, "provisions": 60},
"advanced": {"cut_stone": 80, "hard_leather": 40, "iron_nails": 100},
"late": {"bear_leather": 50, "oak_plank": 25, "steel_hinges": 15},
"effect": "+25% recon per level, plus some each hour",
},
"storehouse": {
"name": "Storehouse", "icon": "📦", "max": 5, "gold": 200,
"base": {"logs": 400, "stone": 800, "thatch": 200, "provisions": 60, "copper_rivets": 60},
"advanced": {"oak_logs": 150, "cut_stone": 100, "rope": 50, "iron_nails": 150},
"late": {"willow_logs": 120, "oak_plank": 45, "hide_glue": 45, "steel_hinges": 25},
"final": {"yew_logs": 40, "yew_plank": 20, "bear_leather": 40, "rune_lock": 6},
"effect": "Per level: +3 bank slots, +250 stack size",
},
}
# Buildings rise by labour: players working the site, and a slow trickle from
# the townsfolk so nothing stalls completely while everyone is away.
LABOUR_PER_LEVEL = 360 # a level-1 building: about three hours of one player's work
TOWNSFOLK_LABOUR = 0.5 # per person per game hour
REPAIR_SHARE = 0.3
# Population promotes the settlement, permanently. Each tier lifts how high
# buildings may go (houses have their own ladder so the next tier is always
# reachable) and gives everyone more bank slots.
TIERS = [
{"name": "Hamlet", "pop": 0, "cap": 1, "houses": 2},
{"name": "Village", "pop": 40, "cap": 3, "houses": 4},
{"name": "Town", "pop": 90, "cap": 4, "houses": 7},
{"name": "City", "pop": 150, "cap": 99, "houses": 8},
]
TIER_SLOTS = 2
# Growth: empty homes draw settlers, fast while many stand empty and slower as
# they fill, and families grow a little on their own however full the city is.
SETTLERS_PER_EMPTY_HOME = 0.5
FAMILY_GROWTH = 0.05
RAID_GROUPS = ["the Red Hand", "the Hollow Crows", "the Blackwater gang", "the Ashen Wolves",
"the Gallows Men", "the Thornback raiders"]
DRAGONS = ["a green dragon", "a blue dragon", "an old red dragon", "a young black dragon"]
DRAGON_TRAITS = ["it hunts at dusk", "one wing is scarred and weak", "it breathes fire in long sweeps",
"it circles twice before it strikes"]
RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows",
"well armed and disciplined", "a ragged mob, many but poorly armed",
"led by a deserter knight in plate"]
# --- world ---------------------------------------------------------------
WORLD_VERSION = 14
def new_world(now: float, speed: float = 1.0) -> dict:
return {
"version": WORLD_VERSION,
"created": now,
"config": {"speed": speed, "clock_offset": 0},
"players": {},
"city": {
"population": 20.0,
"treasury": 0.0,
"stockpile": {"bread": 40},
"buildings": {key: {"level": 0, "damaged": False, "fund": {}, "fittings": []} for key in BUILDINGS},
"construction": None,
"queue": [],
"tier": 0,
"weather": {"kind": "clear", "until": now + 6 * HOUR / speed, "next": "rain"},
"effects": [], "problems": [], "hired": 0,
"last_tick": now,
"food_due": 0.0,
"starving": False,
"repelled": 0,
},
"events": [],
"next_event_at": now + 12 * HOUR / speed,
"next_minor_at": now + 30 * HOUR / speed,
"next_hardship_at": now + 24 * HOUR / speed,
"next_fortune_at": now + 6 * HOUR / speed,
"next_event_id": 1,
"ledger": [],
"board": [],
"next_message_id": 1,
}
def migrate(world: dict, now: float | None = None) -> dict:
"""Bring a saved world up to the current shape. Never loses anything."""
now = now or world["city"]["last_tick"]
if world.get("version", 1) < 2:
city = world["city"]
for building in city["buildings"].values():
building.setdefault("fund", {})
city.setdefault("queue", [])
city.setdefault("tier", 0)
world["version"] = 2
if world["version"] < 3:
for player in world["players"].values():
for skill in SKILL_IDS:
player["xp"].setdefault(skill, 0)
for slot in EQUIP_SLOTS:
player["equip"].setdefault(slot, None)
player.setdefault("food", None)
player.setdefault("hp", max_hp(player))
player.setdefault("hp_at", now)
world["version"] = 3
if world["version"] < 4:
_sell_useless_stock(world, now)
world["version"] = 4
if world["version"] < 5:
for player in world["players"].values():
player["xp"].setdefault("prayer", 0)
player.setdefault("pp", 0)
player.setdefault("prayers", [])
world["city"]["buildings"].setdefault("temple", {"level": 0, "damaged": False, "fund": {}})
world["city"].setdefault("devotion", 0)
world["version"] = 5
if world["version"] < 6:
for player in world["players"].values():
player["xp"].setdefault("herblore", 0)
player.setdefault("potions", {})
world["version"] = 6
if world["version"] < 7:
for player in world["players"].values():
player["xp"].setdefault("construction", 0)
player["equip"].setdefault("tool_construction", None)
work = world["city"]["construction"]
if work and "done_at" in work:
# The old timer becomes labour, credited for the time already spent.
level = world["city"]["buildings"][work["building"]]["level"]
needed = labour_needed(level, work["repair"])
share = min(1.0, max(0.0, (now - work["start"]) / max(1.0, work["done_at"] - work["start"])))
del work["done_at"]
work.update(labour=needed * share, needed=needed)
world["version"] = 7
if world["version"] < 8:
city = world["city"]
for key in ("granary", "lumber_shed", "well"):
city["buildings"].setdefault(key, {"level": 0, "damaged": False, "fund": {}})
city.setdefault("weather", {"kind": "clear", "until": now + 4 * HOUR / speed(world), "next": "rain"})
city.setdefault("effects", [])
city.setdefault("problems", [])
city.setdefault("hired", 0)
world.setdefault("next_minor_at", now + 30 * HOUR / speed(world))
world.setdefault("next_hardship_at", now + 24 * HOUR / speed(world))
world.setdefault("next_fortune_at", now + 8 * HOUR / speed(world))
world["version"] = 8
if world["version"] < 9:
# Board messages get ids so their authors can delete them.
for number, message in enumerate(world["board"], 1):
message["id"] = number
world["next_message_id"] = len(world["board"]) + 1
world["version"] = 9
if world["version"] < 10:
for building in world["city"]["buildings"].values():
building.setdefault("fittings", [])
world["version"] = 10
if world["version"] < 11:
_migrate_potions(world)
world["version"] = 11
if world["version"] < 12:
# Classes became trades: everyone gets a stand-in and one free pick.
for player in world["players"].values():
player["cls"] = OLD_CLASSES.get(player["cls"], player["cls"])
player["repick"] = True
_return_overfunding(world)
world["version"] = 12
if world["version"] < 13:
_fold_masonry(world)
_return_overfunding(world)
world["version"] = 13
if world["version"] < 14:
# Shields moved from Smithing to Construction: anyone smithing them stops.
for player in world["players"].values():
action = player["action"]
if action and (action["id"] not in ACTION_BY_ID or action.get("resume", "") and action["resume"] not in ACTION_BY_ID):
stop(player, "Shields are made in Construction now")
_return_overfunding(world)
world["version"] = 14
return world
def _migrate_potions(world: dict) -> None:
"""Potions from before the tiers become their tiered equivalents, uses and all."""
def renamed(stock: dict) -> None:
for old, new in OLD_POTIONS.items():
if old in stock:
stock[new] = stock.get(new, 0) + stock.pop(old)
renamed(world["city"]["stockpile"])
for player in world["players"].values():
renamed(player["bank"])
for active in player["potions"].values(): # regeneration was combat too, so slots still match
if active["item"] in OLD_POTIONS:
active["item"] = OLD_POTIONS[active["item"]]
action = player["action"]
if action:
for key in ("id", "resume"):
old = (action.get(key) or "").removeprefix("brew_")
if old in OLD_POTIONS:
action[key] = f"brew_{OLD_POTIONS[old]}"
def _fold_masonry(world: dict) -> None:
"""Masonry became part of Construction: its XP, potions and brews carry over."""
moved = {f"masonry_potion_{tier}": f"construction_potion_{tier}" for tier in (1, 2, 3)}
def renamed(stock: dict) -> None:
for old, new in moved.items():
if old in stock:
stock[new] = stock.get(new, 0) + stock.pop(old)
renamed(world["city"]["stockpile"])
for player in world["players"].values():
player["xp"]["construction"] = player["xp"].get("construction", 0) + player["xp"].pop("masonry", 0)
renamed(player["bank"])
active = player["potions"].pop("masonry", None)
if active:
item = moved.get(active["item"], active["item"])
if "construction" in player["potions"]:
player["bank"][item] = player["bank"].get(item, 0) + 1 # a fresh one back, rather than lose it
else:
player["potions"]["construction"] = {"item": item, "charges": active["charges"]}
action = player["action"]
if action:
for key in ("id", "resume"):
brew = (action.get(key) or "").removeprefix("brew_")
if brew in moved:
action[key] = f"brew_{moved[brew]}"
def _return_overfunding(world: dict) -> None:
"""After building costs change, anything a fund holds beyond its new target goes back to the stockpile."""
city = world["city"]
for key, building in city["buildings"].items():
target = fund_target(world, key) or {}
for item, qty in list(building["fund"].items()):
extra = qty - target.get(item, 0)
if extra <= 0:
continue
building["fund"][item] = qty - extra
if not building["fund"][item]:
del building["fund"][item]
if item == "gold":
city["treasury"] += extra
else:
city["stockpile"][item] = city["stockpile"].get(item, 0) + extra
def _sell_useless_stock(world: dict, now: float) -> None:
"""The council sells what the city can't use, into the treasury."""
city = world["city"]
sold = {item: qty for item, qty in city["stockpile"].items() if item in ITEMS and not city_uses(item)}
if not sold:
return
gold = sum(qty * ITEMS[item]["value"] for item, qty in sold.items())
for item in sold:
del city["stockpile"][item]
city["treasury"] += gold
names = [amount(i, q) for i, q in sold.items()]
names = ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else names[0]
log(world, now, "city", f"The council sold {names} "
f"from the stockpile for {gold:,} gold")
def speed(world: dict) -> float:
return world["config"]["speed"]
def amount(item: str, qty: int) -> str:
"""'179 copper swords', '1 copper sword', '40 stone'."""
spec = ITEMS[item]
name = spec.get("plural", spec["name"]) if qty != 1 else spec["name"]
return f"{qty:,} {name.lower()}"
def upper_first(text: str) -> str:
return text[:1].upper() + text[1:]
def log(world: dict, now: float, kind: str, text: str, who: str | None = None) -> None:
world["ledger"].append({"at": now, "kind": kind, "text": text, "who": who})
del world["ledger"][:-MAX_LOG]
def building_level(world: dict, key: str) -> int:
"""Effective level: a damaged building does nothing until repaired."""
building = world["city"]["buildings"][key]
return 0 if building["damaged"] else building["level"]
def fitted(world: dict, fitting: str) -> bool:
"""Whether a fitting is installed and working (its building isn't damaged)."""
key = FITTING_BY_ID[fitting]["building"]
return fitting in world["city"]["buildings"][key]["fittings"] and building_level(world, key) > 0
def build_cost(key: str, level: int) -> dict:
spec = BUILDINGS[key]
cost = {item: qty * level for item, qty in spec["base"].items()}
# Later levels add materials from higher tiers: advanced from level 2, late from 3, final from 5.
for key, first in (("advanced", 2), ("late", 3), ("final", 5)):
for item, qty in spec.get(key, {}).items():
if level >= first:
cost[item] = cost.get(item, 0) + qty * (level - first + 1)
cost["gold"] = spec["gold"] * level
return cost
def repair_cost(key: str, level: int) -> dict:
return {item: max(1, math.ceil(qty * REPAIR_SHARE)) for item, qty in build_cost(key, level).items()}
def housing(world: dict) -> int:
return 25 + 20 * building_level(world, "houses")
def level_cap(world: dict, key: str) -> int:
"""The highest level the settlement's tier allows for this building."""
tier = TIERS[world["city"]["tier"]]
return min(BUILDINGS[key]["max"], tier["houses"] if key == "houses" else tier["cap"])
def fund_target(world: dict, key: str) -> dict | None:
"""What the building's fund is collecting for: a repair, the next level, or nothing."""
building = world["city"]["buildings"][key]
if building["damaged"]:
return repair_cost(key, building["level"])
if building["level"] >= level_cap(world, key):
return None
return build_cost(key, building["level"] + 1)
def fund_missing(world: dict, key: str) -> dict:
target = fund_target(world, key) or {}
fund = world["city"]["buildings"][key]["fund"]
return {item: qty - fund.get(item, 0) for item, qty in target.items() if fund.get(item, 0) < qty}
# --- players -------------------------------------------------------------
def new_player(uid: str, name: str, cls: str, now: float) -> dict:
return {
"id": uid, "name": name, "cls": cls, "joined": now,
"xp": {skill: 0 for skill in SKILL_IDS},
"bank": {}, "gold": 0, "favour": 0,
"equip": {slot: None for slot in EQUIP_SLOTS},
"food": None, # preferred food in fights; None eats the cheapest first
"pp": 0, "prayers": [], "potions": {},
"hp": 44, "hp_at": now,
"action": None, "stopped": None,
"last_active": now, "overflow": "city",
"perks": {"bank_slot": 0},
"garrison": False,
"donated": 0,
}
def skill_level(player: dict, skill: str) -> int:
return level_for(player["xp"].get(skill, 0))
def bank_slots(world: dict, player: dict) -> int:
return (BASE_SLOTS + 3 * building_level(world, "storehouse") + TIER_SLOTS * world["city"]["tier"]
+ player["perks"]["bank_slot"] + fitted(world, "shelving"))
def stack_size(world: dict) -> int:
return BASE_STACK + 250 * building_level(world, "storehouse") + 500 * fitted(world, "enchanted_chests")
def slots_used(world: dict, player: dict) -> int:
"""Bank slots taken: an item spills into another slot every full stack."""
stack = stack_size(world)
return sum(-(-qty // stack) for qty in player["bank"].values())
def bank_room(world: dict, player: dict, item: str) -> int:
"""How many more of `item` fit: the rest of its last stack, plus any free slots."""
stack = stack_size(world)
have = player["bank"].get(item, 0)
partial = -have % stack # room left in the item's last, part-filled stack
free = max(0, bank_slots(world, player) - slots_used(world, player))
return partial + free * stack
def city_uses(item: str) -> list[str]:
"""What the city does with an item: the buildings that need it, the granary, the armoury."""
uses = [spec["name"] for spec in BUILDINGS.values()
if any(item in spec.get(key, {}) for key in ("base", "advanced", "late", "final"))]
spec = ITEMS[item]
if "food" in spec:
uses.append("the granary")
if spec.get("slot") in ("weapon", "shield") or item == "arrows":
uses.append("the armoury")
if item == "water_barrel":
uses.append("the fire brigade")
if "fitting" in spec:
uses.append(BUILDINGS[spec["fitting"]]["name"])
return uses
def bank_add(world: dict, player: dict, item: str, qty: int, now: float, summary: dict) -> int:
"""Store what fits; the rest goes to the city if it has a use, else is sold. Returns qty kept."""
keep = min(qty, bank_room(world, player, item))
if keep:
player["bank"][item] = player["bank"].get(item, 0) + keep
spill = qty - keep
if spill and player["overflow"] == "city" and not city_uses(item):
gold = int(spill * sell_price(world, item, now))
player["gold"] += gold
summary["sold"][item] = summary["sold"].get(item, 0) + spill
summary["sold_gold"] += gold
elif spill and player["overflow"] == "city":
overflow = summary["overflow"]
stockpile = world["city"]["stockpile"]
stockpile[item] = stockpile.get(item, 0) + spill
gain = spill * ITEMS[item]["value"]
player["favour"] += gain * OVERFLOW_FAVOUR
player["donated"] += gain
overflow[item] = overflow.get(item, 0) + spill
return keep
def bank_take(player: dict, item: str, qty: int) -> None:
left = player["bank"][item] - qty
if left > 0:
player["bank"][item] = left
else:
del player["bank"][item]
def armour(player: dict) -> int:
return sum(ITEMS[item].get("armour", 0) for item in player["equip"].values() if item)
def damage_reduction(player: dict) -> float:
points = armour(player)
return points / (points + 25)
def combat_power(player: dict) -> int:
power = skill_level(player, "combat") + armour(player) // 2
for item in player["equip"].values():
if item:
power += ITEMS[item].get("power", 0)
return power
def max_hp(player: dict) -> int:
return 40 + 4 * skill_level(player, "combat")
def regen(world: dict, player: dict, now: float, boost: float = 1) -> None:
"""Heal for the time since HP was last settled."""
if now > player["hp_at"]:
top = max_hp(player)
boost *= 2 if fitted(world, "enchanted_hearths") else 1
per_second = top * REGEN_PER_HOUR * boost * speed(world) / HOUR
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
player["hp_at"] = now
def weapon_style(player: dict) -> str:
weapon = player["equip"].get("weapon")
return ITEMS[weapon].get("style", "sword") if weapon else "sword"
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 the enemy's weakness; armour dodges and soaks, 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)
weakness = WEAKNESS.get(enemy["kind"], {}).get(style_id, 1) if enemy else 1
return {"style": style_id, "interval": style["interval"], "accuracy": (10 + 2 * power) * style["accuracy"],
"max_hit": max(2, round((2 + power / 3) * damage * weakness)), "crit": style["crit"],
"pierce": style.get("pierce", False), "first": style.get("first", False), "weakness": weakness,
"block": BLOCK_CHANCE if player["equip"].get("shield") else 0,
"evasion": 10 + 2 * (skill_level(player, "combat") + armour(player) // 2),
"taken": (1 - damage_reduction(player)) * blessed["taken"] * (1 - brew.get("reduce", 0))}
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 fight_odds(player: dict, action: dict) -> dict:
"""What a fight against this enemy looks like on average, for the cards."""
foe = action["enemy"]
me = fighter(player, blessing(player), potion_effect(player, "combat"), foe)
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"])
shrug = 0 if me["pierce"] else foe["reduction"]
dps = hit * _average_hit(me["max_hit"], me["crit"]) * (1 - shrug) / me["interval"]
kill = foe["hp"] / dps
hurt = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * me["taken"] / foe["interval"] * kill
return {"hit": round(hit, 2), "their_hit": round(their_hit, 2), "max_hit": me["max_hit"], "style": me["style"],
"weakness": me["weakness"], "their_max": max(1, round(foe["max_hit"] * me["taken"])),
"kill_time": round(kill, 1), "damage": round(hurt, 1)}
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."""
foe, blessed, brew = action["enemy"], blessing(player), potion_effect(player, "combat")
me = fighter(player, blessed, brew, foe)
shrug = 0 if me["pierce"] else foe["reduction"]
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
while result is None:
t = min(mine, theirs)
if t > MAX_FIGHT:
result = "fled"
break
if mine <= theirs:
mine += me["interval"]
crit = rng.random() < me["crit"]
landed = rng.random() < hit
dealt = max(1, round((me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))
* (1 - shrug))) 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])
if not foe_hp:
result = "won"
continue
theirs += foe["interval"]
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"])) * me["taken"])) 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])
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"]),
"hp": hp, "result": result, "end": round(t / pace, 2), "duration": t / pace,
"interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, "style": me["style"],
"eaten": eaten, "damage": hurt, "pp": blessed["cost"],
"wind_used": blessed["wind"] and not wind and not (player["action"] or {}).get("wind_used")}
# --- prayer -------------------------------------------------------------------
# Melvor-style: bones become prayer points, active prayers cost points each
# fight and give boosts while they last, and praying in a fight trains Prayer.
PRAYERS = [
{"id": "thick_skin", "name": "Thick Skin", "icon": "🪨", "level": 1, "cost": 1, "reduce": 0.10},
{"id": "burst_of_strength", "name": "Burst of Strength", "icon": "💪", "level": 5, "cost": 1, "power": 5},
{"id": "rapid_heal", "name": "Rapid Heal", "icon": "💗", "level": 10, "cost": 1, "regen": 2},
{"id": "steel_skin", "name": "Steel Skin", "icon": "🛡️", "level": 20, "cost": 2, "reduce": 0.20},
{"id": "superhuman_strength", "name": "Superhuman Strength", "icon": "🦾", "level": 30, "cost": 2, "power": 12},
{"id": "second_wind", "name": "Second Wind", "icon": "🌬️", "level": 40, "cost": 1, "wind": True},
{"id": "protection", "name": "Protection", "icon": "✨", "level": 50, "cost": 3, "reduce": 0.35},
{"id": "piety", "name": "Piety", "icon": "😇", "level": 60, "cost": 4, "power": 20, "reduce": 0.20},
]
PRAYER_BY_ID = {prayer["id"]: prayer for prayer in PRAYERS}
MAX_PRAYERS = 2
PRAYER_XP_PER_POINT = 5
NO_BLESSING = {"cost": 0, "power": 0, "taken": 1.0, "regen": 1, "wind": False}
def describe_prayer(prayer: dict) -> str:
parts = []
if prayer.get("reduce"):
parts.append(f"-{round(prayer['reduce'] * 100)}% damage taken")
if prayer.get("power"):
parts.append(f"+{prayer['power']} power")
if prayer.get("regen"):
parts.append("HP recovers twice as fast")
if prayer.get("wind"):
parts.append("once per outing, revive at half HP")
return ", ".join(parts)
def blessing(player: dict) -> dict:
"""What the active prayers give this fight, if there are points to pay for them."""
active = [PRAYER_BY_ID[p] for p in player["prayers"] if p in PRAYER_BY_ID]
cost = sum(p["cost"] for p in active)
if not active or player["pp"] < cost:
return NO_BLESSING
taken = 1.0
for prayer in active:
taken *= 1 - prayer.get("reduce", 0)
return {"cost": cost, "power": sum(p.get("power", 0) for p in active), "taken": taken,
"regen": max([p.get("regen", 1) for p in active]), "wind": any(p.get("wind") for p in active)}
def set_prayers(player: dict, ids: list) -> str | None:
ids = list(dict.fromkeys(str(i) for i in ids))
if len(ids) > MAX_PRAYERS:
return f"At most {MAX_PRAYERS} prayers at once"
for prayer_id in ids:
prayer = PRAYER_BY_ID.get(prayer_id)
if not prayer:
return "No such prayer"
if skill_level(player, "prayer") < prayer["level"]:
return f"{prayer['name']} needs prayer level {prayer['level']}"
player["prayers"] = ids
return None
def bone_points(world: dict, action: dict) -> int:
return action["pp"] + building_level(world, "temple") + fitted(world, "carved_altar")
def ward(world: dict) -> float:
"""Defence the temple's devotion adds to the next raid."""
cap = 15 * building_level(world, "temple") + 15 * fitted(world, "carved_altar")
return round(min(world["city"].get("devotion", 0) / 4, cap), 1)
def edible(player: dict) -> list[str]:
"""Food in the bank, in the order it gets eaten."""
foods = sorted((item for item in player["bank"] if "heal" in ITEMS[item]), key=lambda i: ITEMS[i]["heal"])
if player["food"] in foods:
foods.remove(player["food"])
foods.insert(0, player["food"])
return foods
def action_time(world: dict, player: dict, action: dict) -> float:
seconds = action["time"]
if action["skill"] in CLASSES[player["cls"]]["fast"]:
seconds *= CLASS_SPEED
if action["skill"] == "farming" and player["cls"] == "homesteader":
# Focus on one plot for 30% faster, or split the same 20% pace between two crops.
seconds *= PLOTS_SPEED if two_plots(player) else FOCUS_SPEED
tool = player["equip"].get(f"tool_{action['skill']}")
if tool:
seconds *= 1 - ITEMS[tool]["speed"]
if action["skill"] == "smithing":
seconds *= 1 - 0.08 * building_level(world, "smithy")
seconds *= 0.9 if fitted(world, "bellows") else 1
if action["id"].startswith("saw_") and fitted(world, "saw_benches"):
seconds *= 0.8
seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0)
seconds *= skill_mods(world, action)["speed"]
return seconds / speed(world)
FOCUS_SPEED = 0.7 # a homesteader tending one plot
PLOTS_SPEED = 0.8 # each harvest when tending two: together, one crop's worth at 20% faster
def two_plots(player: dict) -> bool:
current = player["action"]
return bool(current and current.get("plot2"))
def growing(player: dict) -> dict:
"""The action the player is on right now: with two plots, harvests alternate between them."""
current = player["action"]
if current.get("plot2") and current.get("turn"):
return ACTION_BY_ID[current["plot2"]]
return ACTION_BY_ID[current["id"]]
def plant_second_plot(world: dict, player: dict, action_id: str | None) -> str | None:
"""Let a homesteader's second plot grow another crop, or clear it."""
current = player["action"]
if not action_id:
if current:
current.pop("plot2", None)
current.pop("turn", None)
return None
if player["cls"] != "homesteader":
return "Only homesteaders tend two plots"
action = ACTION_BY_ID.get(action_id)
if not action or action["skill"] != "farming" or action.get("special"):
return "That isn't a crop"
if not current or ACTION_BY_ID[current["id"]]["skill"] != "farming" or "left" in current:
return "Plant your first plot first"
if action_id == current["id"]:
return "That's already growing in your first plot"
reason = can_start(world, player, action)
if reason:
return reason
current["plot2"] = action_id
current["turn"] = 0
return None
def potion_effect(player: dict, skill: str) -> dict:
active = player.get("potions", {}).get(skill)
return ITEMS[active["item"]]["potion"] if active else {}
def use_potion(player: dict, item: str) -> str | None:
"""Drink a potion from the bank into its skill's slot (topping up the same potion)."""
spec = ITEMS.get(item, {}).get("potion")
if not spec:
return "That isn't a potion"
if player["bank"].get(item, 0) < 1:
return "You don't have one"
bank_take(player, item, 1)
current = player["potions"].get(spec["skill"])
charges = current["charges"] if current and current["item"] == item else 0
fresh = round(POTION_CHARGES * (1.25 if player["cls"] == "homesteader" else 1))
player["potions"][spec["skill"]] = {"item": item, "charges": charges + fresh}
return None
def _use_charge(player: dict, skill: str, summary: dict) -> None:
active = player["potions"].get(skill)
if not active:
return
active["charges"] -= 1
if active["charges"] > 0:
return
del player["potions"][skill]
if player["bank"].get(active["item"], 0):
use_potion(player, active["item"])
summary["potions_drunk"][active["item"]] = summary["potions_drunk"].get(active["item"], 0) + 1
else:
summary["potion_out"].append(active["item"])
def upcoming_raid(world: dict) -> dict | None:
"""The coming major threat: a bandit raid or a dragon."""
for event in world["events"]:
if event["status"] == "upcoming" and event["kind"] in ("raid", "dragon"):
return event
return None
def upcoming_event(world: dict) -> dict | None:
for event in world["events"]:
if event["status"] == "upcoming":
return event
return None
def can_start(world: dict, player: dict, action: dict) -> str | None:
"""Why the action can't run right now, or None."""
if skill_level(player, action["skill"]) < action["level"]:
return f"Needs level {action['level']}"
for key, level in action.get("city", {}).items():
if building_level(world, key) < level:
return f"Needs {BUILDINGS[key]['name']} level {level}"
for item, qty in action["inputs"].items():
if player["bank"].get(item, 0) < qty:
return f"Out of {ITEMS[item]['name'].lower()}"
if action.get("special") == "sabotage":
raid = upcoming_raid(world)
if not raid or intel_level(world, raid) < 3:
return "No bandit camp has been located"
if raid["kind"] != "raid":
return "A dragon has no camp to sabotage"
if raid["sabotage"] >= 0.3:
return "The camp is already in ruins"
if action.get("special") == "labour" and not world["city"]["construction"]:
return "Nothing is being built"
if action.get("special") == "problem" and not active_problem(world, action["problem"]):
return "That's been dealt with"
blocked = skill_mods(world, action)["blocked"]
if blocked:
return blocked
if action.get("special") == "drill":
threat = upcoming_raid(world)
if not threat:
return "No raid to prepare for"
if threat.get("drilled", 0) >= drill_needed(threat):
return "The militia is as drilled as it can be"
if "enemy" in action and player["hp"] < max_hp(player) * TOO_HURT and not edible(player):
return "Too hurt to fight. Rest, or bring food"
return None
def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None:
brew = potion_effect(player, action["skill"])
if action["inputs"] and rng.random() < brew.get("keep", 0):
summary["kept"] += 1
else:
for item, qty in action["inputs"].items():
bank_take(player, item, qty)
xp = action["xp"]
cls = CLASSES[player["cls"]]
lucky = bool(action["outputs"]) and (
action["skill"] in cls["craft"] or (player["cls"] == "miner" and action["skill"] == "mining")
) and rng.random() < LUCKY_CHANCE
outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()}
if lucky:
summary["lucky"] += 1
if brew.get("double") and rng.random() < brew["double"]:
for item, qty in action["outputs"].items():
outputs[item] += qty
summary["lucky"] += 1
mods = skill_mods(world, action)
if mods["yield"] != 1:
for item, qty in list(outputs.items()):
exact = qty * mods["yield"]
outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0)
xp *= mods["xp"]
if "enemy" in action:
outing = player["action"]
fight = outing.pop("fight", None) or simulate_fight(world, player, action, rng)
outing.pop("carry", None) # from a short-lived version that ran the swing timer on
if player["prayers"] and not fight["pp"]:
summary["prayer_out"] = True
paid = min(player["pp"], fight["pp"])
if paid:
player["pp"] -= paid
summary["pp_spent"] += paid
gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary)
for food, count in fight["eaten"].items():
count = min(count, player["bank"].get(food, 0))
if count:
bank_take(player, food, count)
summary["eaten"][food] = summary["eaten"].get(food, 0) + count
player["hp"], player["hp_at"] = fight["hp"], now
summary["damage"] += fight["damage"]
if fight["wind_used"]:
outing["wind_used"] = True
summary["second_wind"] += 1
if fight["result"] != "won":
# Beaten or out of food: fall back with nothing but your life.
summary["losses"] += 1
reason = "Retreated: ran out of food" if not edible(player) else "Retreated"
stop(player, reason)
summary["stopped"] = reason
gain_xp(world, player, "combat", xp // 3, now, summary)
return
summary["wins"] += 1
player["gold"] += action.get("gold", 0)
summary["gold"] += action.get("gold", 0)
for item, qty, odds in action.get("drops", []):
if rng.random() < odds:
outputs[item] = outputs.get(item, 0) + qty
if action.get("special") == "sabotage":
raid = upcoming_raid(world)
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03)
if action.get("special") == "problem":
_work_problem(world, player, action, now)
if action.get("special") == "recon":
event = upcoming_raid(world)
# Once a threat is fully scouted, scouting only trains the skill (and keeps the informant's leads).
if event and event["recon"] < intel_costs(event)[-1]:
city = world["city"]
gain = recon_gain(world, player, action)
# An informant's leads double the reconnaissance (and the XP) of whoever follows them up.
boost = min(city.get("recon_boost", 0), gain)
city["recon_boost"] = city.get("recon_boost", 0) - boost
xp *= 1 + boost / gain
event["recon"] = min(intel_costs(event)[-1], event["recon"] + gain + boost)
if action.get("special") == "problem" and action["skill"] != "combat":
_work_problem(world, player, action, now)
threat = upcoming_raid(world) if action.get("special") == "drill" else None
if threat:
city = world["city"]
gain = drill_per_action(player)
# A drill sergeant doubles the drilling (and the XP) of whoever does it while the boost lasts.
boost = min(city.get("drill_boost", 0), gain)
city["drill_boost"] = city.get("drill_boost", 0) - boost
xp = DRILL_XP * (gain + boost) * mods["xp"]
threat["drilled"] = min(drill_needed(threat), threat.get("drilled", 0) + gain + boost)
if action.get("special") == "labour":
city = world["city"]
site = city["construction"]
gain = labour_per_action(player)
# A master builder doubles the labour (and the XP) of whoever works while the boost lasts.
boost = min(city.get("site_boost", 0), gain)
city["site_boost"] = city.get("site_boost", 0) - boost
xp *= 1 + boost / gain
site["labour"] += gain + boost
summary["labour"] += gain + boost
if site["labour"] >= site["needed"]:
_finish_construction(world, now)
_start_next(world, now)
bonus = 1 + brew.get("bones", 0)
if action.get("special") == "bury":
player["pp"] += bone_points(world, action) * bonus
summary["pp_gained"] += bone_points(world, action) * bonus
if action.get("special") == "offer":
city = world["city"]
city["devotion"] = city.get("devotion", 0) + action["devotion"] * (1 + 0.25 * building_level(world, "temple")) * bonus
if action.get("special") != "drill":
_use_charge(player, action["skill"], summary)
for item, qty in outputs.items():
kept = bank_add(world, player, item, qty, now, summary)
if kept:
summary["items"][item] = summary["items"].get(item, 0) + kept
gain_xp(world, player, action["skill"], xp, now, summary)
def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summary: dict | None = None) -> None:
before = skill_level(player, skill)
player["xp"][skill] += xp
after = skill_level(player, skill)
if summary is not None:
summary["xp"][skill] = summary["xp"].get(skill, 0) + xp
if after > before:
if summary is not None:
summary["levels"] = [entry for entry in summary["levels"] if entry[0] != skill] + [[skill, after]]
if after % 10 == 0 or after == MAX_LEVEL:
name = next(s["name"] for s in SKILLS if s["id"] == skill)
log(world, now, "level", f"{player['name']} reached {name} level {after}", player["id"])
def empty_summary() -> dict:
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None,
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0,
"kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 0}
def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict:
"""Run the player's action forward to `now` (capped at 24h past last_active)."""
rng = rng or random.Random()
summary = empty_summary()
current = player["action"]
if not current:
return summary
limit = min(now, player["last_active"] + OFFLINE_CAP / speed(world))
while player["action"]:
action = growing(player)
if current.get("plot2") and can_start(world, player, action):
# The second crop can't grow right now (a problem, too low a level): tend the first only.
current["turn"] = 0
action = growing(player)
if "enemy" in action and not can_start(world, player, action):
if not current.get("fight"):
regen(world, player, current["start"], blessing(player)["regen"])
current["fight"] = simulate_fight(world, player, action, rng)
duration = current["fight"]["duration"]
else:
duration = action_time(world, player, action)
if current["start"] + duration > limit:
break
reason = can_start(world, player, action)
if reason and reason.startswith("Too hurt"):
if blessing(player)["wind"] and not current.get("wind_used"):
# Second Wind: the retreat that was coming turns into half health.
current["wind_used"] = True
player["hp"] = max_hp(player) / 2
summary["second_wind"] += 1
reason = None
else:
reason = "Retreated: hurt and out of food"
if reason:
stop(player, reason)
summary["stopped"] = reason
break
if player["overflow"] == "stop" and any(bank_room(world, player, item) < qty
for item, qty in action["outputs"].items()):
stop(player, "Bank full")
summary["stopped"] = "Bank full"
break
_complete(world, player, action, current["start"] + duration, rng, summary)
summary["done"] += 1
current["start"] += duration
if "enemy" in action and player["action"] is current:
current["start"] += RESPAWN / speed(world) # a moment before the next enemy turns up
if current.get("plot2"):
current["turn"] = 1 - current.get("turn", 0)
if player["action"] is current and "left" in current:
current["left"] -= 1
if current["left"] <= 0:
back = current.get("resume")
if back:
plot2 = current.get("resume_plot2")
player["action"] = current = {"id": back, "start": current["start"]}
if plot2:
current.update(plot2=plot2, turn=0)
summary["resumed"] = back
else:
stop(player)
if player["action"] and limit < now and current["start"] < now:
current["start"] = now
return summary
def stop(player: dict, reason: str | None = None) -> None:
player["action"] = None
player["stopped"] = reason
def start_action(world: dict, player: dict, action_id: str, now: float, once: bool = False) -> str | None:
"""Start an action. `once` does it a single time, then goes back to what you were doing."""
action = ACTION_BY_ID.get(action_id)
if not action:
return "No such action"
reason = can_start(world, player, action)
if reason:
return reason
entry = {"id": action_id, "start": now}
current = player["action"]
if once:
back = current and (current.get("resume") or current["id"])
entry["left"] = 1
if back and back != action_id:
entry["resume"] = back
plot2 = current.get("resume_plot2") or current.get("plot2")
if plot2:
entry["resume_plot2"] = plot2
elif current and current.get("plot2") and current["plot2"] != action_id and action["skill"] == "farming":
# Replanting the first plot leaves the second one growing.
entry.update(plot2=current["plot2"], turn=0)
player["action"] = entry
player["stopped"] = None
return None
# --- city ----------------------------------------------------------------
def stockpile_value(city: dict) -> float:
return sum(ITEMS[item]["value"] * qty for item, qty in city["stockpile"].items())
def donate(world: dict, player: dict, item: str, qty: int, now: float) -> str | None:
city = world["city"]
if qty <= 0:
return "Nothing to donate"
if item == "gold":
if player["gold"] < qty:
return "Not enough gold"
player["gold"] -= qty
city["treasury"] += qty
gain = qty
label = f"{qty:,} gold"
else:
if not city_uses(item):
return "The city has no use for that"
if player["bank"].get(item, 0) < qty:
return "You don't have that many"
bank_take(player, item, qty)
city["stockpile"][item] = city["stockpile"].get(item, 0) + qty
gain = qty * ITEMS[item]["value"]
label = amount(item, qty)
player["favour"] += gain * city_rate(world, "favour")
player["donated"] += gain
log(world, now, "donation", f"{player['name']} donated {label}", player["id"])
return None
def give(world: dict, giver: dict, receiver: dict, item: str, qty: int, now: float) -> str | None:
if receiver is giver:
return "You can't give to yourself"
if qty <= 0 or giver["bank"].get(item, 0) < qty:
return "You don't have that many"
room = bank_room(world, receiver, item)
if room < qty:
return f"{receiver['name']} only has room for {room:,}"
bank_take(giver, item, qty)
receiver["bank"][item] = receiver["bank"].get(item, 0) + qty
log(world, now, "gift", f"{giver['name']} gave {amount(item, qty)} to {receiver['name']}", giver["id"])
return None
def sell(world: dict, player: dict, item: str, qty: int, now: float) -> str | None:
if qty <= 0 or player["bank"].get(item, 0) < qty:
return "You don't have that many"
bank_take(player, item, qty)
player["gold"] += int(qty * sell_price(world, item, now))
return None
def sell_price(world: dict, item: str, now: float) -> float:
price = ITEMS[item]["value"]
if world["city"].get("merchant_until", 0) > now:
price *= 1.5
return price
def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source: str, now: float) -> str | None:
"""Put materials towards a building. Funds are safe from raiders.
`source` is "bank" (the player's own, earns favour) or "city" (the
stockpile, or the treasury for gold). `qty` may be "all": as much as
the source has, up to what the fund still needs.
"""
city = world["city"]
if key not in BUILDINGS:
return "No such building"
if item != "gold" and item not in ITEMS:
return "No such item"
need = fund_missing(world, key).get(item, 0)
if not need:
return "The fund doesn't need that"
if source == "bank":
have = player["gold"] if item == "gold" else player["bank"].get(item, 0)
elif source == "city":
have = int(city["treasury"]) if item == "gold" else city["stockpile"].get(item, 0)
else:
return "Bad source"
if qty == "all":
qty = min(have, need)
if not isinstance(qty, int) or qty <= 0:
return "Nothing to give"
if qty > have:
return "Not enough to give"
qty = min(qty, need)
if source == "bank":
if item == "gold":
player["gold"] -= qty
else:
bank_take(player, item, qty)
gain = qty * (1 if item == "gold" else ITEMS[item]["value"])
player["favour"] += gain * city_rate(world, "favour")
player["donated"] += gain
elif item == "gold":
city["treasury"] -= qty
else:
take_stock(city, item, qty)
building = city["buildings"][key]
building["fund"][item] = building["fund"].get(item, 0) + qty
label = f"{qty:,} gold" if item == "gold" else amount(item, qty)
where = "" if source == "bank" else " from the city's stores"
log(world, now, "fund", f"{player['name']} put {label}{where} towards the {BUILDINGS[key]['name'].lower()}", player["id"])
_queue_if_funded(world, key, now)
_start_next(world, now)
return None
def install(world: dict, player: dict, fitting: str, source: str, now: float) -> str | None:
"""Fit a building with a fitting from the player's bank (earns favour) or the stockpile."""
spec = FITTING_BY_ID.get(fitting)
if not spec:
return "No such fitting"
city = world["city"]
building = city["buildings"][spec["building"]]
name = BUILDINGS[spec["building"]]["name"].lower()
if fitting in building["fittings"]:
return f"The {name} already {'have' if name.endswith('s') else 'has'} that"
if building["level"] < spec["level"]:
return f"Needs {BUILDINGS[spec['building']]['name']} level {spec['level']}"
if source == "bank":
if not player["bank"].get(fitting):
return "You don't have one"
bank_take(player, fitting, 1)
gain = ITEMS[fitting]["value"]
player["favour"] += gain * city_rate(world, "favour")
player["donated"] += gain
elif source == "city":
if not city["stockpile"].get(fitting):
return "There isn't one in the stockpile"
take_stock(city, fitting, 1)
else:
return "Bad source"
building["fittings"].append(fitting)
log(world, now, "build", f"{player['name']} fitted the {name} with {spec['name'].lower()}", player["id"])
return None
def take_stock(city: dict, item: str, qty: int) -> None:
left = city["stockpile"][item] - qty
if left > 0:
city["stockpile"][item] = left
else:
del city["stockpile"][item]
def _queue_if_funded(world: dict, key: str, now: float) -> None:
city = world["city"]
if fund_target(world, key) is None or fund_missing(world, key):
return
busy = city["construction"] and city["construction"]["building"] == key
if key not in city["queue"] and not busy:
city["queue"].append(key)
log(world, now, "build", f"The {BUILDINGS[key]['name'].lower()} are fully funded"
if BUILDINGS[key]["name"].endswith("s") else f"The {BUILDINGS[key]['name'].lower()} is fully funded")
def _start_next(world: dict, now: float) -> None:
"""Start the next funded building, if the builders are free."""
city = world["city"]
while not city["construction"] and city["queue"]:
key = city["queue"].pop(0)
target = fund_target(world, key)
if target is None or fund_missing(world, key):
continue # its target changed while it waited (e.g. damaged); keep funding
building = city["buildings"][key]
for item, qty in target.items():
left = building["fund"][item] - qty
if left > 0:
building["fund"][item] = left
else:
del building["fund"][item]
repair = building["damaged"]
city["boosts_bought"] = 0 # master builders cost the base price again on a new site
city["construction"] = {"building": key, "repair": repair, "start": now, "labour": 0.0,
"needed": labour_needed(building["level"], repair)}
verb = "Repairs to" if repair else "Work on"
log(world, now, "build", f"{verb} the {BUILDINGS[key]['name'].lower()} began")
def growth_per_day(world: dict) -> float:
"""New townsfolk a day while there's room: settlers for the homes, plus families."""
pop = world["city"]["population"]
growth = SETTLERS_PER_EMPTY_HOME * max(0.0, housing(world) - pop) + FAMILY_GROWTH * pop
return growth * city_rate(world, "growth") * (1.25 if fitted(world, "furniture") else 1)
def population_view(world: dict) -> dict:
"""Population, food and tier, with the numbers that explain them."""
city = world["city"]
pop = city["population"]
room = housing(world)
if city["starving"]:
growth = -pop * 0.04
elif pop < room - 0.01:
growth = growth_per_day(world)
elif pop > room:
growth = -pop * 0.05
else:
growth = 0.0
food = sum(city["stockpile"].get(item, 0) * ITEMS[item]["food"] for item in FOOD_ORDER)
tier = city["tier"]
following = TIERS[tier + 1] if tier + 1 < len(TIERS) else None
return {
"population": int(pop), "housing": room, "growth": round(growth, 1),
"food": food, "food_days": round(food / (pop * 0.5), 1) if pop else None,
"tier": tier, "tier_name": TIERS[tier]["name"],
"next_tier": following and {"name": following["name"], "pop": following["pop"]},
}
def best_kit(stock: dict, slot: str, stat: str, people: int) -> float:
"""Total `stat` when the best `slot` items in the stockpile go to `people` defenders."""
total = 0
for item in sorted((i for i in stock if ITEMS[i].get("slot") == slot), key=lambda i: -ITEMS[i].get(stat, 0)):
take = min(people, stock[item])
total += take * ITEMS[item].get(stat, 0)
people -= take
if not people:
break
return total
def kit_count(stock: dict, slot: str) -> int:
return sum(qty for item, qty in stock.items() if ITEMS[item].get("slot") == slot)
def wear_share(ratio: float) -> float:
"""Share of the issued swords and shields a fight breaks: 10% in an easy win, 30% in a close one, 40% in defeat."""
if ratio < 1:
return 0.4
return 0.1 + 0.2 * max(0.0, 2 - ratio)
def wear_kit(world: dict, share: float, rng: random.Random) -> dict:
"""Break `share` of the swords and shields handed to defenders, worst first. Spares are untouched."""
stock = world["city"]["stockpile"]
broken = {}
for slot, stat in (("weapon", "power"), ("shield", "armour")):
people = int(world["city"]["population"] // 2)
issued = []
for item in sorted((i for i in stock if ITEMS[i].get("slot") == slot), key=lambda i: -ITEMS[i].get(stat, 0)):
take = min(people, stock[item])
issued.append((item, take))
people -= take
if not people:
break
breaks = int(sum(take for _item, take in issued) * share + rng.random())
for item, take in reversed(issued):
lost = min(breaks, take)
if lost:
take_stock_qty(stock, item, lost)
broken[item] = lost
breaks -= lost
return broken
def _wear_lines(broken: dict, arrows: int) -> list[str]:
lines = []
if arrows:
lines.append(f"The defenders used {amount('arrows', arrows)}.")
if broken:
lines.append(f"{upper_first(_listing(broken))} {'was' if sum(broken.values()) == 1 else 'were'} broken in the fighting.")
return lines
def equip_problem(player: dict, item: str) -> str | None:
"""Why the player can't wear or use this yet, or None."""
weapon = player["equip"].get("weapon")
if ITEMS[item].get("slot") == "shield" and weapon and ITEMS[weapon].get("two_handed"):
return f"Your {ITEMS[weapon]['name'].lower()} takes both hands"
req = ITEMS[item].get("req")
if req and skill_level(player, req[0]) < req[1]:
name = next(s["name"] for s in SKILLS if s["id"] == req[0])
return f"Needs {name.lower()} level {req[1]}"
return None
def defence(world: dict) -> dict:
"""The city's defence right now, part by part."""
city = world["city"]
pop = city["population"]
stock = city["stockpile"]
armed = int(pop // 2)
parts = {
"townsfolk": 10,
"walls": 20 * building_level(world, "walls") + 25 * fitted(world, "hoardings"),
"militia": round(0.3 * building_level(world, "barracks") * pop * (1.5 if fitted(world, "weapon_racks") else 1), 1),
"weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1),
"shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1),
"arrows": min(100, stock.get("arrows", 0) // 10),
"drilled": round(drilled(world), 1),
"rallied": city.get("rally", 0), # favour bought before drilling existed; gone after the next raid
"ward": ward(world),
"mercenaries": city.get("hired", 0),
"garrison": 0,
}
garrison = []
for player in world["players"].values():
if player["garrison"]:
power = combat_power(player)
garrison.append({"id": player["id"], "name": player["name"], "power": round(power, 1)})
parts["garrison"] += power
total = sum(parts.values())
if city["starving"]:
total *= 0.8
return {"parts": parts, "garrison": garrison, "total": round(total, 1), "starving": city["starving"]}
def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
"""Advance the city and events to `now`. Returns True if anything changed."""
rng = rng or random.Random()
city = world["city"]
changed = False
step = HOUR / speed(world)
# Never replay more than two weeks if the server was off.
city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world))
while city["last_tick"] + step <= now:
city["last_tick"] += step
_happenings_hour(world, city["last_tick"], rng)
_city_hour(world, city["last_tick"])
changed = True
changed = advance_weather(world, now, rng) or changed
changed = _happenings_tracks(world, now, rng) or changed
construction = city["construction"]
if construction and construction["labour"] >= construction["needed"]:
_finish_construction(world, now)
_start_next(world, now)
changed = True
while city["tier"] + 1 < len(TIERS) and city["population"] >= TIERS[city["tier"] + 1]["pop"]:
city["tier"] += 1
tier = TIERS[city["tier"]]
log(world, now, "tier", f"The settlement has grown into a {tier['name'].lower()}! "
f"Buildings can go higher, and everyone gets {TIER_SLOTS} more bank slots.")
for key in BUILDINGS:
_queue_if_funded(world, key, now)
_start_next(world, now)
changed = True
if world["next_event_at"] and world["next_event_at"] <= now and not upcoming_event(world):
_announce(world, now, rng)
changed = True
for event in world["events"]:
if event["status"] == "upcoming" and event["fire_at"] <= now:
for player in world["players"].values():
process_player(world, player, event["fire_at"], rng)
_fire(world, event, now, rng)
world["next_event_at"] = now + rng.uniform(8, 24) * HOUR / speed(world)
changed = True
return changed
def _city_hour(world: dict, now: float) -> None:
city = world["city"]
pop = city["population"]
if city["construction"]:
city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR
threat = upcoming_raid(world)
if threat and building_level(world, "spymaster"):
threat["recon"] = min(intel_costs(threat)[-1], threat["recon"] + SPYMASTER_AGENTS * building_level(world, "spymaster"))
city["food_due"] += (pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1)
* (0.9 if fitted(world, "grain_bins") else 1))
stock = city["stockpile"]
for item in FOOD_ORDER:
while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0:
stock[item] -= 1
if not stock[item]:
del stock[item]
city["food_due"] -= ITEMS[item]["food"]
was_starving = city["starving"]
city["starving"] = city["food_due"] >= 1
if city["starving"]:
city["food_due"] = min(city["food_due"], pop)
city["population"] = max(5.0, pop * (1 - 0.04 / 24))
if not was_starving:
log(world, now, "city", "The granary is empty. People are going hungry and some are leaving.")
else:
room = housing(world)
if pop < room:
city["population"] = min(room, pop + growth_per_day(world) / 24)
elif pop > room:
city["population"] = max(room, pop - pop * 0.05 / 24)
city["treasury"] += pop * 0.5 / 24
def labour_needed(level: int, repair: bool) -> float:
"""Labour to raise a building from `level`, or to repair it at that level."""
if repair:
return LABOUR_PER_LEVEL * max(1, level) * 0.25
return LABOUR_PER_LEVEL * (level + 1)
def labour_per_action(player: dict) -> float:
# One more labour every 20 levels, and one more for a builder.
return 1 + skill_level(player, "construction") // 20 + (player["cls"] == "builder")
def _finish_construction(world: dict, now: float) -> None:
city = world["city"]
work = city["construction"]
building = city["buildings"][work["building"]]
name = BUILDINGS[work["building"]]["name"]
if work["repair"]:
building["damaged"] = False
log(world, now, "build", f"The {name.lower()} have been repaired" if name.endswith("s")
else f"The {name.lower()} has been repaired")
else:
building["level"] += 1
log(world, now, "build", f"{name} reached level {building['level']}")
city["construction"] = None
# Leftover materials may already cover the next step.
_queue_if_funded(world, work["building"], now)
# --- events --------------------------------------------------------------
def _announce(world: dict, now: float, rng: random.Random) -> None:
city = world["city"]
hours = HOUR / speed(world)
first = not any(e["kind"] in ("raid", "dragon") for e in world["events"])
day = (now - world["created"]) * speed(world) / DAY
# The major-threat track: mostly bandits, sometimes (after the first days) a dragon.
kind = "dragon" if not first and day >= 5 and rng.random() < 0.15 else "raid"
event = {
"id": world["next_event_id"], "kind": kind, "status": "upcoming",
"announced": now, "recon": 0, "sabotage": 0.0, "jitter": rng.uniform(-1, 1),
}
world["next_event_id"] += 1
# Bigger, richer and older cities draw bigger threats; every win draws a bolder one.
wealth = city["treasury"] + stockpile_value(city)
strength = (15 + 4 * day + 0.8 * city["population"] + 1.2 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"])
if kind == "dragon":
event.update({
"fire_at": now + rng.uniform(30, 54) * hours,
"strength": round(strength * 1.5 * rng.uniform(0.9, 1.15), 1),
"group": rng.choice(DRAGONS),
"trait": rng.choice(DRAGON_TRAITS),
})
log(world, now, "event", "Herders report scorched pastures in the hills")
elif kind == "raid":
event.update({
"fire_at": now + rng.uniform(30, 54) * hours,
"strength": round(strength * rng.uniform(0.85, 1.2), 1),
"group": rng.choice(RAID_GROUPS),
"trait": rng.choice(RAID_TRAITS),
})
log(world, now, "event", "Rumours of bandits on the roads reach the council")
world["events"].append(event)
del world["events"][:-30]
# Intel: each level takes more reconnaissance than the last, a bigger band is
# harder to read (a dragon is hard to miss), and the sharper levels only become
# knowable as the threat draws near, however much scouting is done.
INTEL_POINTS = [15, 30, 45] # recon for intel levels 1, 2 and 3 against a small band
INTEL_SIZE = [1.0, 1.33, 1.67, 2.0] # small, medium, large, huge
INTEL_OPENS = [None, 24, 8] # game hours before arrival that levels 2 and 3 open, ±25% per threat
SPYMASTER_RECON = 0.25 # more recon per guild level
SPYMASTER_AGENTS = 0.5 # recon per guild level per game hour, with nobody scouting
INFORMANT_RECON = 30 # boosted recon per informant: doubled recon and XP for the scout who works it
def recon_gain(world: dict, player: dict, action: dict) -> float:
gain = (action["recon"] + fitted(world, "map_table")) * (1 + SPYMASTER_RECON * building_level(world, "spymaster"))
return gain * (1.5 if player["cls"] == "ranger" else 1)
def intel_costs(event: dict) -> list[int]:
"""Total recon needed for each intel level."""
size = next(i for i, (limit, _label) in enumerate(SIZE_LABELS) if event["strength"] < limit)
scale = INTEL_SIZE[size] * (0.5 if event["kind"] == "dragon" else 1)
return [round(sum(INTEL_POINTS[:level]) * scale) for level in (1, 2, 3)]
def intel_opens(world: dict, event: dict, level: int) -> float:
"""When an intel level becomes knowable. The ±25% is fixed per threat, so it can't be timed."""
hours = INTEL_OPENS[level - 1]
if hours is None:
return 0
wobble = random.Random(f"{event['id']}-{level}").uniform(0.75, 1.25)
return event["fire_at"] - hours * wobble * HOUR / speed(world)
def intel_level(world: dict, event: dict, now: float | None = None) -> int:
now = world["city"]["last_tick"] if now is None else now
level = 0
for next_level, need in enumerate(intel_costs(event), 1):
if event["recon"] < need or now < intel_opens(world, event, next_level):
break
level = next_level
return level
SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")]
WINDOW_HOURS = [24, 8, 2, 0]
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
view = {
"id": event["id"], "kind": event["kind"], "intel": level,
"earliest": max(now, centre - half), "latest": max(now, centre + half),
"exact": event["fire_at"] if level >= 3 else None,
"recon": int(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)
elif event["kind"] == "raid":
strength = event["strength"] * (1 - event["sabotage"])
view["headline"] = "Travellers speak of trouble on the roads."
if level >= 1:
size = next(label for limit, label in SIZE_LABELS if strength < limit)
view["headline"] = f"A {size} bandit band is gathering in the hills."
view["size"] = size
if level >= 2:
spread = 0.25 * strength
low = strength - spread * (1 + event["jitter"]) / 2
view["range"] = [round(low), round(low + spread)]
view["group"] = event["group"]
view["headline"] = f"{upper_first(event["group"])} are gathering in the hills."
if level >= 3:
view["strength"] = round(strength)
view["trait"] = event["trait"]
view["camp"] = True
view["sabotage"] = round(event["sabotage"] * 100)
view["requests"] = council_requests(world, event)
elif event["kind"] == "merchant":
view["headline"] = "A merchant caravan is on its way. 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())
lines.append(f"The raiders carried off {gold:,} gold" + (f" and {stolen}." if stolen else "."))
built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]]
if severity > 0.35 and built:
key = rng.choice(built)
city["buildings"][key]["damaged"] = True
_queue_if_funded(world, key, now)
lines.append(f"The {BUILDINGS[key]['name'].lower()} were damaged and won't work until repaired."
if BUILDINGS[key]["name"].endswith("s") else
f"The {BUILDINGS[key]['name'].lower()} was damaged and won't work until repaired.")
lines.append("A smaller, poorer city will draw less attention for a while.")
for entry in garrison:
gain_xp(world, world["players"][entry["id"]], "combat", round(attack * 2), now)
log(world, now, "raid", f"{upper_first(event["group"])} broke into the city")
report["lines"] = lines
event["report"] = report
for player in world["players"].values():
player["garrison"] = False
city["rally"] = city["rallies"] = 0
city["devotion"] = 0
city["hired"] = 0
def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> None:
city = world["city"]
stock = city["stockpile"]
attack = event["strength"]
guard = defence(world)
garrison = guard["garrison"]
report = {"group": event["group"], "attack": round(attack), "defence": guard["total"],
"parts": guard["parts"], "garrison": garrison, "icon": "🐉"}
lines = []
if guard["total"] >= attack:
city["repelled"] += 1
city["treasury"] += round(attack)
broken = wear_kit(world, min(0.5, wear_share(guard["total"] / attack) * 1.25), rng)
arrows = _use_up(stock, "arrows", 0.5)
report["title"] = f"Victory! {upper_first(event['group'])} was driven off"
lines.append(f"It fled wounded, dropping a hoard worth {round(attack):,} gold.")
lines.extend(_wear_lines(broken, arrows))
for entry in garrison:
player = world["players"][entry["id"]]
player["favour"] += 40
gain_xp(world, player, "combat", round(attack * 6), now)
bank_add(world, player, "dragon_bones", 2, now, empty_summary())
bank_add(world, player, "green_dragonhide", 1, now, empty_summary())
if garrison:
lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and a hide.")
log(world, now, "raid", report["title"])
else:
severity = min(1.0, 1 - guard["total"] / attack)
broken = wear_kit(world, 0.5, rng)
lost = int(city["population"] * (0.05 + 0.15 * severity))
city["population"] = max(5.0, city["population"] - lost)
share = min(0.8, 0.2 + 0.4 * severity)
burned = _take_share(stock, WOOD, share * (1 - 0.25 * building_level(world, "lumber_shed")))
burned.update(_take_share(stock, _food_items(stock), share * (1 - 0.25 * building_level(world, "granary"))))
name = _damage_building(world, now, rng)
arrows = _use_up(stock, "arrows", 0.7)
report["title"] = f"{upper_first(event['group'])} set the city ablaze"
lines.append(f"{lost} townsfolk were lost in the flames.")
lines.extend(_wear_lines(broken, arrows))
if burned:
lines.append(f"It burned {_listing(burned)}.")
if name:
lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.")
for entry in garrison:
gain_xp(world, world["players"][entry["id"]], "combat", round(attack * 2), now)
log(world, now, "raid", report["title"])
report["lines"] = lines
event["report"] = report
for player in world["players"].values():
player["garrison"] = False
city["rally"] = city["rallies"] = 0
city["devotion"] = 0
city["hired"] = 0
def _work_problem(world: dict, player: dict, action: dict, now: float) -> None:
problem = active_problem(world, action["problem"])
if not problem:
return
problem["work"] += 1
if problem["work"] >= PROBLEMS[problem["kind"]]["work"]:
_solve(world, problem, now, player["name"])
def _use_up(stock: dict, item: str, share: float) -> int:
used = int(stock.get(item, 0) * share)
if used:
take_stock_qty(stock, item, used)
return used
# --- happenings: weather, seasons, hardships, fortunes, minor threats ---------
# Raids and dragons keep their own announced rhythm (the major-threat track
# above). Three more tracks run beside it on their own cooldowns, and the
# weather rolls every few hours, leaning towards whatever the season favours.
SEASONS = [("Spring", "🌱"), ("Summer", "☀️"), ("Autumn", "🍂"), ("Winter", "❄️")]
SEASON_DAYS = 7
# `speed` multiplies action time (below 1 is faster), `yield` multiplies output,
# `food` multiplies what the townsfolk eat, `firewood` is logs per person per hour.
WEATHER = {
"clear": {"name": "Clear skies", "icon": "🌤️", "text": "No effects"},
"rain": {"name": "Rain", "icon": "🌧️", "speed": {"farming": 0.9}, "text": "Farming 10% faster"},
"heat": {"name": "Heat wave", "icon": "🥵", "speed": {"farming": 0.85}, "food": 1.15,
"text": "Farming 15% faster, city eats 15% more"},
"storm": {"name": "Storm", "icon": "⛈️", "speed": {"fishing": 1.25}, "text": "Fishing 25% slower"},
"fog": {"name": "Fog", "icon": "🌫️", "speed": {"reconnaissance": 1.3, "hunting": 1.1},
"text": "Scouting 30% and hunting 10% slower"},
"snow": {"name": "Snow", "icon": "🌨️", "speed": {"hunting": 1.2}, "firewood": 0.05,
"text": "Hunting 20% slower, burns firewood"},
}
SEASON_WEATHER = [
{"clear": 35, "rain": 40, "storm": 10, "fog": 15},
{"clear": 45, "heat": 30, "rain": 15, "storm": 10},
{"clear": 30, "rain": 30, "storm": 25, "fog": 15},
{"clear": 30, "snow": 45, "fog": 15, "storm": 10},
]
FIREWOOD = ("logs", "oak_logs", "willow_logs", "maple_logs", "yew_logs", "magic_logs")
WOOD = FIREWOOD + tuple(plank for plank, _name in PLANKS)
# Minor threats: they slow or block a skill until someone deals with them,
# either by working a special action (`work` completions) or by donating (`need`).
PROBLEMS = {
"collapse": {"name": "Mine collapse", "icon": "⛰️", "need": {"plank": 30},
"block": {"mining": "Mine collapsed"}, "allow": ["mine_stone"],
"text": "No ore until it's shored up with planks."},
"wolves": {"name": "Wolves at the farms", "icon": "🐺", "yield": {"farming": 0.5}, "work": 10, "action": "clear_wolves",
"text": "Farming yield halved until fighters drive them off."},
"bear": {"name": "Bear in the woods", "icon": "🐻", "speed": {"woodcutting": 1.5}, "work": 8, "action": "drive_bear",
"text": "Woodcutting much slower until hunters drive it off."},
"goblins": {"name": "Goblin thieves", "icon": "👺", "steal": 0.01, "work": 10, "action": "catch_goblins",
"text": "Steal from the stockpile hourly until fighters catch them."},
"rockslide": {"name": "Rockslide on the quarry road", "icon": "🪨", "work": 25, "action": "clear_rockslide",
"block": {"mining": "Quarry road blocked"}, "only": ["mine_stone"],
"text": "No stone until builders clear it."},
"flood": {"name": "Flooded fishing grounds", "icon": "🌊", "speed": {"fishing": 1.5}, "need": {"plank": 20}, "lasts": 36,
"text": "Fishing slow for 36h, or until dammed with planks."},
"boars": {"name": "Boars trampling the crops", "icon": "🐗", "yield": {"farming": 0.6}, "work": 10, "action": "cull_boars",
"text": "Farming yield down until hunters cull them."},
"rats": {"name": "Rats in the granary", "icon": "🐀", "steal_food": 0.03, "work": 12, "action": "catch_rats",
"text": "Eat city food hourly until hunters catch them."},
}
MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars")
PROBLEM_ACTIONS = [
_fight("clear_wolves", monster("Wolf pack", "beast", 4), "Frontier", name="Drive off the wolves",
special="problem", problem="wolves"),
_fight("catch_goblins", monster("Goblin thieves", "human", 5), "Frontier", name="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, "red_dragon_leather": 5, "adamant_chain": 6},
"HP recovers twice as fast", "fireplace", "#a77be8"),
]
# Builders' shields, homesteaders' staves and rangers' bows, after each skill's other tabs.
for _weapon in _weapon_actions():
ACTIONS.append(_weapon)
ACTION_BY_ID[_weapon["id"]] = _weapon
FITTING_BY_ID = {}
for _id, _name, _building, _blevel, _level, _inputs, _effect, _glyph, _tint in FITTINGS:
FITTING_BY_ID[_id] = {"id": _id, "name": _name, "building": _building, "level": _blevel, "effect": _effect}
ITEMS[_id] = {"name": _name, "plural": _name, "icon": "🪚", "value": sum(ITEMS[i]["value"] * q for i, q in _inputs.items()),
"fitting": _building}
_fitting = _action(f"make_{_id}", "construction", _name, _level, 120 + _level * 2, 40 + _level * 4,
_inputs, {_id: 1}, group="Fittings")
ACTIONS.append(_fitting)
ACTION_BY_ID[_fitting["id"]] = _fitting
ITEMS[_id].update(glyph=_glyph, tint=_tint)
# (weights by season, spring to winter)
HARDSHIPS = {"sickness": [2, 1, 2, 3], "locusts": [1, 3, 2, 0], "rot": [3, 1, 3, 1], "storm": [1, 1, 3, 1],
"rats": [2, 2, 2, 2], "fire": [1, 2, 1, 2], "winter": [0, 0, 0, 4], "drought": [0, 4, 1, 0]}
FORTUNES = {"merchant": [12] * 4, "refugees": [10] * 4, "master": [16] * 4, "harvest": [10, 10, 12, 0],
"rich_vein": [10] * 4, "salmon": [10, 8, 4, 0], "festival": [8] * 4, "noble": [8] * 4,
"pilgrims": [8] * 4, "mercenaries": [6] * 4, "cargo": [10] * 4}
TRACK_HOURS = {"minor": (48, 96), "hardship": (48, 96), "fortune": (36, 72)}
def calendar_day(world: dict, now: float) -> int:
"""Days since the world began, counting midnights in the server's timezone (0 on the first day).
A sped-up dev world has no calendar to speak of, so it counts 24-hour stretches instead.
"""
if speed(world) != 1:
return int((now - world["created"]) * speed(world) // DAY)
return (date.fromtimestamp(now) - date.fromtimestamp(world["created"])).days
def season(world: dict, now: float) -> dict:
day = calendar_day(world, now)
index = day // SEASON_DAYS % len(SEASONS)
name, icon = SEASONS[index]
return {"index": index, "name": name, "icon": icon, "day": day % SEASON_DAYS + 1}
def _pick(weights: dict, rng: random.Random) -> str:
keys = [key for key, weight in weights.items() if weight > 0]
return rng.choices(keys, [weights[key] for key in keys])[0]
def _roll_weather(world: dict, now: float, rng: random.Random, current: str | None = None) -> str:
if current and rng.random() < 0.4:
return current
return _pick(SEASON_WEATHER[season(world, now)["index"]], rng)
def advance_weather(world: dict, now: float, rng: random.Random) -> bool:
"""Change the weather when its spell ends. Returns True if it changed."""
weather = world["city"]["weather"]
if now < weather["until"]:
return False
while now >= weather["until"]:
weather["kind"] = weather["next"]
_plan_spell(world, weather, rng)
return True
def _plan_spell(world: dict, weather: dict, rng: random.Random) -> None:
"""Set how long the current weather lasts and what follows. Rolls that keep the
same weather lengthen the spell, so `until` is when it really changes."""
for _ in range(3):
weather["until"] += rng.uniform(4, 8) * HOUR / speed(world)
weather["next"] = _roll_weather(world, weather["until"], rng, weather["kind"])
if weather["next"] != weather["kind"]:
return
# Three spells of the same is long enough: something else comes next.
weights = SEASON_WEATHER[season(world, weather["until"])["index"]]
weather["next"] = _pick({kind: w for kind, w in weights.items() if kind != weather["kind"]}, rng)
def _modifier_sources(world: dict) -> list[dict]:
city = world["city"]
sources = [WEATHER[city["weather"]["kind"]]] + city["effects"]
sources += [PROBLEMS[problem["kind"]] for problem in city["problems"]]
return sources
def skill_mods(world: dict, action: dict) -> dict:
"""How weather, happenings and problems change this action right now."""
skill = action["skill"]
mods = {"speed": 1.0, "yield": 1.0, "xp": 1.0, "blocked": None}
for source in _modifier_sources(world):
mods["speed"] *= source.get("speed", {}).get(skill, 1)
mods["yield"] *= source.get("yield", {}).get(skill, 1)
mods["xp"] *= source.get("xp", {}).get(skill, 1)
if action.get("special") != "problem":
for problem in world["city"]["problems"]:
spec = PROBLEMS[problem["kind"]]
reason = spec.get("block", {}).get(skill)
if reason and (action["id"] in spec.get("only", [action["id"]])) and action["id"] not in spec.get("allow", []):
mods["blocked"] = reason
return mods
def city_rate(world: dict, key: str) -> float:
"""The product of a city-wide factor (food, growth, favour) across everything active."""
rate = 1.0
for source in _modifier_sources(world):
rate *= source.get(key, 1)
return rate
def active_problem(world: dict, kind: str) -> dict | None:
return next((p for p in world["city"]["problems"] if p["kind"] == kind), None)
def _record(world: dict, now: float, track: str, kind: str, icon: str, title: str, lines: list[str]) -> None:
"""A happening for the history list and the ledger."""
world["events"].append({"id": world["next_event_id"], "kind": kind, "track": track, "status": "done",
"announced": now, "fire_at": now, "resolved": now,
"report": {"title": title, "icon": icon, "lines": lines}})
world["next_event_id"] += 1
del world["events"][:-60]
log(world, now, track, title)
def _add_effect(world: dict, now: float, hours: float, **effect) -> None:
world["city"]["effects"].append({**effect, "until": now + hours * HOUR / speed(world)})
def _take_share(stock: dict, items, share: float) -> dict:
taken = {}
for item in [i for i in items if stock.get(i)]:
gone = int(stock[item] * share)
if gone:
taken[item] = gone
take_stock_qty(stock, item, gone)
return taken
def take_stock_qty(stock: dict, item: str, qty: int) -> None:
stock[item] -= qty
if stock[item] <= 0:
del stock[item]
def _listing(taken: dict) -> str:
names = [amount(item, qty) for item, qty in taken.items()]
return ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else (names[0] if names else "nothing")
def _damage_building(world: dict, now: float, rng: random.Random) -> str | None:
city = world["city"]
built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]]
if not built:
return None
key = rng.choice(built)
city["buildings"][key]["damaged"] = True
_queue_if_funded(world, key, now)
return BUILDINGS[key]["name"]
def _food_items(stock: dict) -> list[str]:
return [item for item in stock if "food" in ITEMS[item] or item in ("wheat", "vegetables")]
def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None:
city = world["city"]
stock = city["stockpile"]
weights = {kind: w[season(world, now)["index"]] for kind, w in HARDSHIPS.items()}
if city["weather"]["kind"] == "storm":
weights["storm"] += 6
if city["weather"]["kind"] == "rain":
weights["rot"] += 3
if active_problem(world, "rats"):
weights["rats"] = 0
kind = kind or _pick(weights, rng)
granary, shed, well = (building_level(world, k) for k in ("granary", "lumber_shed", "well"))
if kind == "sickness":
hours = 72 * (1 - 0.2 * building_level(world, "temple"))
_add_effect(world, now, hours, kind="sickness", name="Sickness", icon="🤒", food=2.0,
text="City eats twice as much food")
_record(world, now, "hardship", kind, "🤒", "Sickness is spreading through the city",
[f"For about {round(hours)} hours the townsfolk eat twice as much food.",
"A bigger temple shortens it."])
elif kind == "locusts":
share = 0.4 * (1 - 0.25 * granary)
taken = _take_share(stock, _food_items(stock), share)
_record(world, now, "hardship", kind, "🦗", "A swarm of locusts descended on the stores",
[f"They ate {_listing(taken)}." if taken else "There was little for them to eat.",
*(["The granary kept part of it safe."] if granary else ["A granary would protect some of it."])])
elif kind == "rot":
taken = _take_share(stock, WOOD, 0.3 * (1 - 0.25 * shed))
_record(world, now, "hardship", kind, "🍄", "Damp got into the wood stores",
[f"Rot ruined {_listing(taken)}." if taken else "There was no wood in the stockpile to rot.",
*(["The lumber shed kept part of it dry."] if shed else ["A lumber shed would keep it dry."])])
elif kind == "storm":
name = _damage_building(world, now, rng)
taken = _take_share(stock, FIREWOOD, 0.1)
lines = [f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs." if name
else "Luckily there was nothing standing tall enough to break."]
if taken:
lines.append(f"The wind scattered {_listing(taken)}.")
_record(world, now, "hardship", kind, "🌪️", "A wild storm battered the city", lines)
elif kind == "rats":
city["problems"].append({"kind": "rats", "started": now, "progress": {}, "work": 0})
_record(world, now, "hardship", kind, "🐀", "Rats have got into the granary",
["They'll eat into the food every hour until hunters catch them."])
elif kind == "fire":
barrels = stock.get("water_barrel", 0)
if fitted(world, "well_pump") and rng.random() < 0.5:
_record(world, now, "hardship", kind, "🔥", "A fire broke out, and the well's pump put it out",
["Nothing was lost."])
elif barrels >= 5:
take_stock_qty(stock, "water_barrel", 5)
_record(world, now, "hardship", kind, "🔥", "A fire broke out, and the water barrels put it out",
["Five barrels were used up. Nothing else was lost."])
else:
share = 0.25 * (1 - 0.25 * well)
taken = _take_share(stock, WOOD + tuple(_food_items(stock)), share)
name = _damage_building(world, now, rng) if rng.random() > 0.25 * well else None
lines = [f"The flames took {_listing(taken)}." if taken else "Little was lost from the stores."]
if name:
lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.")
lines.append("Water barrels in the stockpile (five per fire) would have put it out.")
_record(world, now, "hardship", kind, "🔥", "Fire swept through part of the city", lines)
elif kind == "winter":
_add_effect(world, now, 48, kind="winter", name="Harsh winter", icon="🥶", firewood=0.1,
text="Burns stockpile logs hourly")
_record(world, now, "hardship", kind, "🥶", "A harsh cold snap has set in",
["For two days the city burns logs from the stockpile to keep warm.",
"Without firewood, the cold makes everyone eat 50% more food."])
else:
_add_effect(world, now, 24, kind="drought", name="Drought", icon="🏜️", speed={"farming": 1.3, "fishing": 1.2},
text="Farming 30% and fishing 20% slower")
_record(world, now, "hardship", kind, "🏜️", "Drought has dried the fields and streams",
["For a day, farming is 30% and fishing 20% slower."])
def _fortune(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None:
city = world["city"]
weights = {kind: w[season(world, now)["index"]] for kind, w in FORTUNES.items()}
if not upcoming_raid(world):
weights["mercenaries"] = 0
kind = kind or _pick(weights, rng)
if kind == "merchant":
city["merchant_until"] = now + 12 * HOUR / speed(world)
_record(world, now, "fortune", kind, "🐪", "A merchant caravan has arrived",
["For the next 12 hours, everything sells for 50% more."])
elif kind == "refugees":
people = rng.randint(6, 12)
taken = min(max(0, housing(world) - int(city["population"])), people)
city["population"] += taken
lines = [f"{taken} of {people} refugees found a home."]
if taken < people:
lines.append("The rest moved on. More houses would have kept them.")
_record(world, now, "fortune", kind, "🧳", "Refugees arrived", lines)
elif kind == "master":
skill = rng.choice([s for s in SKILL_IDS if s != "reconnaissance"])
name = next(s["name"] for s in SKILLS if s["id"] == skill)
_add_effect(world, now, 12, kind="master", name=f"A master of {name.lower()}", icon="🎓",
xp={skill: 1.5}, text=f"{name} +50% XP")
_record(world, now, "fortune", kind, "🎓", f"A travelling master of {name.lower()} is visiting",
[f"For 12 hours, {name.lower()} earns 50% more XP. Learn while you can."])
elif kind == "harvest":
_add_effect(world, now, 12, kind="harvest", name="Bountiful harvest", icon="🌾", **{"yield": {"farming": 2}},
text="Farming yield doubled")
_record(world, now, "fortune", kind, "🌾", "The fields are bursting: a bountiful harvest",
["Farming yields are doubled for 12 hours."])
elif kind == "rich_vein":
_add_effect(world, now, 12, kind="rich_vein", name="Rich vein", icon="💎", **{"yield": {"mining": 1.5}},
text="Mining yield +50%")
_record(world, now, "fortune", kind, "💎", "Miners struck a rich vein",
["Mining yields 50% more for 12 hours."])
elif kind == "salmon":
_add_effect(world, now, 12, kind="salmon", name="Salmon run", icon="🐟", **{"yield": {"fishing": 2}},
text="Fishing yield doubled")
_record(world, now, "fortune", kind, "🐟", "The salmon are running",
["Fishing yields are doubled for 12 hours."])
elif kind == "festival":
_add_effect(world, now, 24, kind="festival", name="Festival", icon="🎉", growth=2, favour=1.5,
text="Growth doubled, +50% favour from donations")
_record(world, now, "fortune", kind, "🎉", "The city is holding a festival",
["For a day, the population grows twice as fast and donations earn 50% more favour."])
elif kind == "noble":
levels = sum(b["level"] for b in city["buildings"].values())
gold = 100 + 40 * levels
city["treasury"] += gold
_record(world, now, "fortune", kind, "👑", "A noble visited and admired the city",
[f"Impressed by its {levels} building levels, they left {gold:,} gold for the treasury."])
elif kind == "pilgrims":
lines = []
if building_level(world, "temple"):
gift = 20 * building_level(world, "temple")
city["devotion"] = city.get("devotion", 0) + gift
lines.append(f"They prayed at the temple: +{gift} devotion.")
for player in world["players"].values():
bank_add(world, player, "bones", 10, now, empty_summary())
lines.append("They left 10 bones with every adventurer.")
_record(world, now, "fortune", kind, "🙏", "Pilgrims passed through the city", lines)
elif kind == "mercenaries":
cost = 150 + 30 * season(world, now)["index"] + int(city["population"]) * 5
_add_effect(world, now, 24, kind="mercenaries", name="Mercenaries for hire", icon="🗡️", offer=True,
cost=cost, defence=25, text="+25 defence for the coming threat")
_record(world, now, "fortune", kind, "🗡️", "A sellsword company is camped outside the walls",
[f"For a day they'll join the next defence for {cost:,} gold from the treasury."])
else:
goods = rng.sample([("stone", 200), ("logs", 200), ("bricks", 80), ("bread", 60), ("arrows", 150), ("plank", 40)], 2)
for item, qty in goods:
city["stockpile"][item] = city["stockpile"].get(item, 0) + qty
_record(world, now, "fortune", kind, "📦", f"An abandoned wagon brought {_listing(dict(goods))} to the stockpile",
["Townsfolk found a trader's wagon abandoned on the road, its owner nowhere to be seen.",
f"They hauled its load into the city stockpile: {_listing(dict(goods))}. "
"The city uses it straight away, and anyone can put it towards a building."])
def _minor_threat(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None:
city = world["city"]
options = [k for k in MINOR_THREATS if not active_problem(world, k)]
kind = kind or rng.choice(options)
problem = {"kind": kind, "started": now, "progress": {}, "work": 0}
if "lasts" in PROBLEMS[kind]:
problem["until"] = now + PROBLEMS[kind]["lasts"] * HOUR / speed(world)
city["problems"].append(problem)
spec = PROBLEMS[kind]
_record(world, now, "minor", kind, spec["icon"], spec["name"], [spec["text"]])
def _solve(world: dict, problem: dict, now: float, who: str | None = None) -> None:
world["city"]["problems"].remove(problem)
spec = PROBLEMS[problem["kind"]]
_record(world, now, "minor", problem["kind"], "✅", f"Solved: {spec['name'].lower()}",
[f"Thanks to {who}." if who else "It's over."])
def problem_missing(problem: dict) -> dict:
need = PROBLEMS[problem["kind"]].get("need", {})
return {item: qty - problem["progress"].get(item, 0) for item, qty in need.items() if problem["progress"].get(item, 0) < qty}
def aid_problem(world: dict, player: dict, kind: str, item: str, source: str, now: float) -> str | None:
"""Give planks (or whatever a problem needs) from the bank or the stockpile."""
problem = active_problem(world, kind)
if not problem:
return "That's not a problem right now"
need = problem_missing(problem).get(item, 0)
if not need:
return "It doesn't need that"
stock = world["city"]["stockpile"]
have = player["bank"].get(item, 0) if source == "bank" else stock.get(item, 0)
qty = min(have, need)
if qty <= 0:
return "Nothing to give"
if source == "bank":
bank_take(player, item, qty)
gain = qty * ITEMS[item]["value"] * city_rate(world, "favour")
player["favour"] += gain
player["donated"] += qty * ITEMS[item]["value"]
else:
take_stock_qty(stock, item, qty)
problem["progress"][item] = problem["progress"].get(item, 0) + qty
if not problem_missing(problem):
_solve(world, problem, now, player["name"])
return None
def hire_mercenaries(world: dict, player: dict, now: float) -> str | None:
city = world["city"]
offer = next((e for e in city["effects"] if e.get("offer")), None)
if not offer:
return "Nobody is offering"
if city["treasury"] < offer["cost"]:
return f"The treasury needs {offer['cost']:,} gold"
city["treasury"] -= offer["cost"]
city["hired"] = city.get("hired", 0) + offer["defence"]
city["effects"].remove(offer)
log(world, now, "favour", f"{player['name']} hired the sellswords for {offer['cost']:,} gold (+{offer['defence']} defence)", player["id"])
return None
def _happenings_hour(world: dict, now: float, rng: random.Random) -> None:
"""Weather, firewood, thieves and expiries, once per game hour."""
city = world["city"]
advance_weather(world, now, rng)
city["effects"] = [e for e in city["effects"] if e["until"] > now]
for problem in list(city["problems"]):
if problem.get("until") and problem["until"] <= now:
_solve(world, problem, now)
stock = city["stockpile"]
burn = city["population"] * sum(source.get("firewood", 0) for source in _modifier_sources(world))
city["firewood_due"] = city.get("firewood_due", 0) + burn
cold = False
while city["firewood_due"] >= 1:
log_item = next((item for item in FIREWOOD if stock.get(item)), None)
if not log_item:
cold = True
city["firewood_due"] = 0
break
take_stock_qty(stock, log_item, 1)
city["firewood_due"] -= 1
if cold and not city.get("cold"):
log(world, now, "city", "The firewood has run out. People are cold and eating more to stay warm.")
city["cold"] = cold
for problem in city["problems"]:
spec = PROBLEMS[problem["kind"]]
if spec.get("steal"):
_take_share(stock, [i for i in stock if i not in _food_items(stock)], spec["steal"])
if spec.get("steal_food"):
_take_share(stock, _food_items(stock), spec["steal_food"] * (1 - 0.25 * building_level(world, "granary")))
def _happenings_tracks(world: dict, now: float, rng: random.Random) -> bool:
changed = False
for track, (low, high) in TRACK_HOURS.items():
due = world.get(f"next_{track}_at")
if due is None or now < due:
continue
if track == "minor" and any(p["kind"] in MINOR_THREATS for p in world["city"]["problems"]):
world[f"next_{track}_at"] = now + 6 * HOUR / speed(world)
continue
{"minor": _minor_threat, "hardship": _hardship, "fortune": _fortune}[track](world, now, rng)
world[f"next_{track}_at"] = now + rng.uniform(low, high) * HOUR / speed(world)
changed = True
return changed
def happenings_view(world: dict, now: float) -> dict:
city = world["city"]
weather = city["weather"]
problems = []
for problem in city["problems"]:
spec = PROBLEMS[problem["kind"]]
problems.append({
"kind": problem["kind"], "name": spec["name"], "icon": spec["icon"], "text": spec["text"],
"until": problem.get("until"), "work": problem["work"], "needed": spec.get("work"),
"action": spec.get("action"), "need": spec.get("need"), "missing": problem_missing(problem),
})
return {
"season": season(world, now),
"weather": {**{k: v for k, v in WEATHER[weather["kind"]].items()}, "kind": weather["kind"], "until": weather["until"],
"next": weather["next"], "next_name": WEATHER[weather["next"]]["name"],
"next_icon": WEATHER[weather["next"]]["icon"]},
"effects": [{k: e[k] for k in ("kind", "name", "icon", "text", "until", "speed", "yield", "xp") if k in e}
| {"offer": e.get("offer", False), "cost": e.get("cost")} for e in city["effects"]],
"problems": problems,
"cold": city.get("cold", False),
"hired": city.get("hired", 0),
}
# --- favour --------------------------------------------------------------
MASTER_BUILDER_LABOUR = 50 # boosted labour per hire: doubled labour and XP for whoever works it
MASTER_BUILDER_FAVOUR = 150 # +50% for every further hire on the same site
INFORMANT_FAVOUR = 250
# Drilling: fighters train the townsfolk before a raid. A full drill takes as
# many points as full intel on the same threat, so fighters and scouts put in
# about the same time; the defence it's worth grows with the population.
DRILL_PER_ACTION = 1.0 # drill points per session, +25% every 20 combat levels
DRILL_XP = 12 # combat XP per drill point
DRILL_SHARE = 1.0 # defence of a fully drilled militia, per townsperson
DRILL_SERGEANT_POINTS = 30 # boosted drilling per hire: doubled points and XP
DRILL_SERGEANT_FAVOUR = 150 # +50% for every further hire against the same threat
def drill_per_action(player: dict) -> float:
return DRILL_PER_ACTION * (1 + 0.25 * (skill_level(player, "combat") // 20))
def drill_needed(threat: dict) -> int:
return intel_costs(threat)[-1]
def drill_cap(world: dict) -> float:
return round(DRILL_SHARE * world["city"]["population"], 1)
def drilled(world: dict) -> float:
"""Defence the drilling is worth so far: a share of the cap, as far as the drill has got."""
threat = upcoming_raid(world)
if not threat:
return 0
return drill_cap(world) * min(1.0, threat.get("drilled", 0) / drill_needed(threat))
def favours(world: dict, now: float) -> dict:
"""What favour can buy for the city right now: cost, and why not if it can't."""
city = world["city"]
work = city["construction"]
raid = upcoming_raid(world)
return {
"builders": {
"cost": round(MASTER_BUILDER_FAVOUR * (1 + 0.5 * city.get("boosts_bought", 0))),
"why": None if work else "Nothing is being built",
"labour": MASTER_BUILDER_LABOUR,
"boost": round(city.get("site_boost", 0)),
},
"informant": {
"cost": round(INFORMANT_FAVOUR * (1 + 0.5 * (raid or {}).get("informants", 0))),
"why": "No news to dig into" if not raid else
"Nothing more to learn" if raid["recon"] >= intel_costs(raid)[-1] else None,
"recon": INFORMANT_RECON,
"boost": round(city.get("recon_boost", 0)),
},
"sergeant": {
"cost": round(DRILL_SERGEANT_FAVOUR * (1 + 0.5 * (raid or {}).get("sergeants", 0))),
"why": "No raid to prepare for" if not raid else
"The militia is as drilled as it can be" if raid.get("drilled", 0) >= drill_needed(raid) else None,
"drill": DRILL_SERGEANT_POINTS,
"boost": round(city.get("drill_boost", 0), 1),
"points": int((raid or {}).get("drilled", 0)), "needed": drill_needed(raid) if raid else 0,
"defence": round(drilled(world), 1), "cap": drill_cap(world),
},
}
def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None:
"""Turn a player's favour into help for the whole city."""
option = favours(world, now).get(kind)
if not option:
return "No such favour"
if option["why"]:
return option["why"]
if player["favour"] < option["cost"]:
return f"Needs {option['cost']:,} favour"
player["favour"] -= option["cost"]
city = world["city"]
if kind == "builders":
work = city["construction"]
city["site_boost"] = city.get("site_boost", 0) + MASTER_BUILDER_LABOUR
city["boosts_bought"] = city.get("boosts_bought", 0) + 1
name = BUILDINGS[work["building"]]["name"].lower()
log(world, now, "favour", f"{player['name']} hired a master builder for the {name}: "
f"the next {MASTER_BUILDER_LABOUR} labour counts double", player["id"])
elif kind == "informant":
raid = upcoming_raid(world)
raid["informants"] = raid.get("informants", 0) + 1
city["recon_boost"] = city.get("recon_boost", 0) + INFORMANT_RECON
log(world, now, "favour", f"{player['name']} paid an informant: the next {INFORMANT_RECON} "
f"reconnaissance counts double", player["id"])
else:
raid = upcoming_raid(world)
raid["sergeants"] = raid.get("sergeants", 0) + 1
city["drill_boost"] = city.get("drill_boost", 0) + DRILL_SERGEANT_POINTS
log(world, now, "favour", f"{player['name']} hired a drill sergeant: the next {DRILL_SERGEANT_POINTS} "
f"drill points count double", player["id"])
return None
def perk_cost(player: dict, perk: str) -> int:
if perk == "bank_slot":
return 500 * 2 ** player["perks"]["bank_slot"]
raise KeyError(perk)
def buy_perk(player: dict, perk: str) -> str | None:
if perk not in player["perks"]:
return "No such perk"
cost = perk_cost(player, perk)
if player["favour"] < cost:
return f"Needs {cost:,} favour"
player["favour"] -= cost
player["perks"][perk] += 1
return None
# --- icons ------------------------------------------------------------------------
# Every item, skill, class, building, prayer and output-less action gets a
# game-icons.net glyph (CC BY 3.0) and a tint. Families share a shape and are
# told apart by colour: a rune sword is the copper sword in cyan. The sprite
# with just these glyphs is built by tools/build_icons.py into static/icons.svg.
METAL_TINTS = {"copper": "#d07a3c", "iron": "#9aa1ab", "steel": "#d4dae2",
"mithril": "#6f8fec", "adamant": "#4cb878", "rune": "#3fd0e0"}
WOOD_TINTS = ["#b9844e", "#8f5e30", "#a3b86c", "#d0663a", "#6f9a4a", "#a77be8"]
LEATHER_TINTS = ["#b07a4a", "#8a5a32", "#6b4a33", "#5fae5a", "#4f86d6", "#d0503c"]
HERB_TINTS = {"guam_leaf": "#7fbf5a", "marrentill": "#e7c84a", "tarromin": "#d05a8a",
"harralander": "#f0b030", "ranarr": "#8a6fd0", "snapdragon": "#e86a9a"}
PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "visored-helm",
"legs": "leg-armor", "body": "breastplate"}
TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole",
"hunting knife": "bowie-knife", "hammer": "claw-hammer"}
LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"}
WEAPON_GLYPHS = {"hammer": "warhammer", "staff": "wizard-staff", "bow": "pocket-bow"}
HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"]
GLYPHS = {
"stone": ("stone-block", "#a8a29e"), "wheat": ("wheat", "#e3c060"), "vegetables": ("carrot", "#f08a3c"),
"feathers": ("feather", "#e8e8ee"), "raw_meat": ("meat", "#e06a6a"), "arrows": ("arrow-cluster", "#c9b28a"),
"bricks": ("brick-pile", "#c66a45"), "cut_stone": ("stone-stack", "#b8b2aa"), "beam": ("wood-beam", "#9a6a3a"),
"bread": ("bread", "#deb064"), "cooked_meat": ("ham-shank", "#cf8f50"), "stew": ("hot-meal", "#c9884a"),
"bones": ("broken-bone", "#e9dfc6"), "big_bones": ("crossed-bones", "#e9dfc6"),
"dragon_bones": ("dinosaur-bones", "#a6d98f"), "studded_body": ("leather-armor", "#a3825a"),
"coal": ("ore", "#5d626b"), "water_barrel": ("barrel", "#5aa0d8"),
"thatch": ("high-grass", "#d8c070"), "flax": ("flax", "#8fb0d8"), "rope": ("rope-coil", "#c9a66b"),
"hide_glue": ("honey-jar", "#c08a40"), "provisions": ("knapsack", "#a08050"),
"raw_shrimp": ("shrimp", "#f39a82"), "raw_trout": ("flatfish", "#9fb5c8"), "raw_salmon": ("salmon", "#f08a6a"),
"raw_eel": ("eel", "#7f9a6a"), "raw_lobster": ("crab", "#e05a3a"), "raw_swordfish": ("angler-fish", "#7fa3c8"),
"cooked_shrimp": ("shrimp", "#f0b07a"), "cooked_trout": ("fish-cooked", "#d9a060"),
"cooked_salmon": ("fish-cooked", "#f09a6a"), "smoked_eel": ("eel", "#b07a4a"),
"cooked_lobster": ("crab", "#f07a4a"), "cooked_swordfish": ("fish-cooked", "#c8a070"),
}
SKILL_GLYPHS = {
"mining": ("mining", "#b8b2aa"), "woodcutting": ("wood-axe", "#c98a4a"), "farming": ("wheat", "#e3c060"),
"hunting": ("deer-head", "#c49a6c"), "fishing": ("fishing-pole", "#5aa0d8"), "smithing": ("anvil", "#d07a3c"),
"construction": ("hammer-nails", "#d1a46a"),
"cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"),
"herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"),
"reconnaissance": ("spyglass", "#6f8fec"),
}
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"),
}
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 = {"golem": "crystal-growth", "troll": "animal-hide", "spirit": "magic-swirl", "flyer": "sewing-string"}
TIER_TINTS = ["#9aa1ab", "#d4dae2", "#6f8fec", "#4cb878", "#3fd0e0"] # trophies of areas II to VI
MONSTER_GLYPHS = {"golem": ["rock-golem"] * 5, "troll": ["troll"] * 5,
"spirit": ["fairy", "spectre", "ghost", "spectre", "ghost"],
"flyer": ["bat", "harpy", "wyvern", "griffin-symbol", "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 _kind, (_offset, _monsters, _trophies) in AREA_MONSTERS.items():
for _index, _name in enumerate(_monsters):
ACTION_GLYPHS[f"fight_{_slug(_name)}"] = (MONSTER_GLYPHS[_kind][_index], TIER_TINTS[_index])
UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield"]
def _assign_glyphs() -> None:
for item, (glyph, tint) in GLYPHS.items():
ITEMS[item].update(glyph=glyph, tint=tint)
for 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, kind, tier) in TROPHIES.items():
ITEMS[trophy].update(glyph=TROPHY_GLYPHS[kind], tint=TIER_TINTS[tier - 1])
for herb, tint in HERB_TINTS.items():
ITEMS[herb].update(glyph="herbs-bundle", tint=tint)
flasks = ["round-bottom-flask", "round-bottom-flask", "standing-potion", "standing-potion",
"bubbling-flask", "bubbling-flask"]
for potion, *_rest, skill, effect in POTIONS:
tint = "#e05a5a" if "power" in effect else "#8fa8c8" if "reduce" in effect else SKILL_GLYPHS[skill][1]
ITEMS[potion].update(glyph=flasks[POTION_TIER[potion]], tint=tint)
for skill in SKILLS:
skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]]
for key, spec in CLASSES.items():
spec["glyph"], spec["tint"] = CLASS_GLYPHS[key]
for key, spec in BUILDINGS.items():
spec["glyph"], spec["tint"] = BUILDING_GLYPHS[key]
for prayer in PRAYERS:
prayer["glyph"], prayer["tint"] = PRAYER_GLYPHS[prayer["id"]]
for action in ACTIONS:
if action["id"] in ACTION_GLYPHS:
action["glyph"], action["tint"] = ACTION_GLYPHS[action["id"]]
elif action["outputs"]:
first = ITEMS[next(iter(action["outputs"]))]
action["glyph"], action["tint"] = first["glyph"], first["tint"]
elif action.get("special") in ("bury", "offer"):
bone = ITEMS[next(iter(action["inputs"]))]
action["glyph"] = "tombstone" if action["special"] == "bury" else "candle-holder"
action["tint"] = bone["tint"] if action["special"] == "bury" else "#f0c060"
else:
action["glyph"], action["tint"] = SKILL_GLYPHS[action["skill"]]
def all_glyphs() -> set[str]:
"""Every glyph the game draws, for building the sprite."""
names = set(UI_GLYPHS)
for collection in (ITEMS.values(), SKILLS, CLASSES.values(), BUILDINGS.values(), PRAYERS, ACTIONS):
names.update(entry["glyph"] for entry in collection)
return names
_assign_glyphs()
def content() -> dict:
"""The static tables the client renders from."""
buildings = {
key: {**spec, "costs": [build_cost(key, level) for level in range(1, spec["max"] + 1)]}
for key, spec in BUILDINGS.items()
}
return {
"items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS,
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
"city_uses": {item: city_uses(item) for item in ITEMS},
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS,
"potion_charges": POTION_CHARGES, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED,
"styles": STYLES, "weakness": WEAKNESS, "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
"regen_per_hour": REGEN_PER_HOUR,
}