2026-10-18 13:48:56 +01:00
""" 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
2026-10-19 09:42:36 +01:00
from datetime import date
2026-10-18 13:48:56 +01:00
2026-10-24 09:49:21 +01:00
import combat
2026-10-18 13:48:56 +01:00
HOUR = 3600
DAY = 24 * HOUR
OFFLINE_CAP = 24 * HOUR
BASE_SLOTS = 12
BASE_STACK = 1000
CLASS_SPEED = 0.8
LUCKY_CHANCE = 0.1
2026-10-18 19:54:08 +01:00
OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose
2026-10-18 13:48:56 +01:00
MAX_LOG = 200
2026-10-18 19:27:56 +01:00
REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour
2026-10-24 09:49:21 +01:00
REST_RATE = 0.01 # share of max HP back per game second resting between fights...
BITE = 2.0 # ...and each bite of food takes this long, so food rests you faster
2026-10-24 14:02:24 +01:00
EAT_BELOW = 0.5 # unless you choose otherwise, you only eat while under this share of your HP...
EAT_CHOICES = ( 0 , 0.25 , 0.5 , 0.75 , 1.0 ) # ...and these are the choices (never, up to always)
2026-10-24 09:49:21 +01:00
KNOCKED_OUT_HP = 0.3 # share of max HP you come round with after being knocked out
KNOCKOUTS_TO_STOP = 3 # in a row, and you stop fighting
2026-10-18 13:48:56 +01:00
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 } ,
2026-10-24 09:49:21 +01:00
# `food` feeds that many townsfolk for two days; `heal` restores HP resting between fights.
2026-10-18 19:27:56 +01:00
" 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 } ,
2026-10-18 23:09:17 +01:00
" bones " : { " name " : " Bones " , " icon " : " 🦴 " , " value " : 1 } ,
" big_bones " : { " name " : " Big bones " , " icon " : " 🦴 " , " value " : 4 } ,
" dragon_bones " : { " name " : " Dragon bones " , " icon " : " 🦴 " , " value " : 15 } ,
2026-10-20 14:16:12 +01:00
" studded_body " : { " name " : " Studded jerkin " , " icon " : " 🧥 " , " value " : 40 , " slot " : " body " , " armour " : 5 , " ward " : 5 ,
" weight " : " medium " , " req " : [ " combat " , 10 ] } ,
2026-10-19 22:45:32 +01:00
# 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 } ,
2026-10-18 13:48:56 +01:00
}
2026-10-18 22:13:50 +01:00
# --- 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.
2026-10-21 07:59:22 +01:00
TIER_LEVELS = [ 1 , 15 , 30 , 45 , 60 , 75 ] # each tier's gathering / smelting level; also to wear and use it
2026-10-18 22:13:50 +01:00
# 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 ] ) ,
]
2026-10-20 14:16:12 +01:00
# Armour weights: a piece's rating, split into armour (against physical blows) and
# ward (against magic). Heavy plate from Smithing, medium leather from
# Leatherworking, light cloth from Tailoring; each adds up to about the same.
WEIGHTS = {
" heavy " : { " name " : " Heavy " , " armour " : 1.0 , " ward " : 0.2 } ,
" medium " : { " name " : " Medium " , " armour " : 0.6 , " ward " : 0.6 } ,
" light " : { " name " : " Light " , " armour " : 0.2 , " ward " : 1.0 } ,
}
ARMOUR_RATING = { slot : rating for piece , slot , _noun , _bars , _offset , _power , rating in PIECES if piece != " shield " and rating }
def _armour_stats ( weight : str , slot : str , tier : int ) - > dict :
rating = ARMOUR_RATING [ slot ] [ tier ]
return { " armour " : round ( rating * WEIGHTS [ weight ] [ " armour " ] ) , " ward " : round ( rating * WEIGHTS [ weight ] [ " ward " ] ) ,
" weight " : weight }
2026-10-18 22:13:50 +01:00
PLURALS = { " sword " : " swords " , " shield " : " shields " , " helmet " : " helmets " , " greaves " : " greaves " , " cuirass " : " cuirasses " }
PIECE_ICONS = { " sword " : " ⚔️ " , " shield " : " 🛡️ " , " helmet " : " 🪖 " , " legs " : " 🦿 " , " body " : " 🦾 " }
COPPER_ICONS = { " sword " : " 🗡️ " , " helmet " : " ⛑️ " , " legs " : " 🦵 " , " body " : " 🥋 " }
2026-10-20 21:20:25 +01:00
# Tools: one slot per skill, `speed` cuts the action time. Smiths make every tool.
2026-10-18 19:27:56 +01:00
TOOLS = { " mining " : ( " pickaxe " , " ⛏️ " ) , " woodcutting " : ( " axe " , " 🪓 " ) , " farming " : ( " hoe " , " " ) ,
2026-10-20 21:20:25 +01:00
" fishing " : ( " fishing rod " , " 🎣 " ) , " hunting " : ( " hunting knife " , " 🔪 " ) , " construction " : ( " hammer " , " 🔨 " ) ,
" smithing " : ( " chisel " , " 🔧 " ) , " jewelcrafting " : ( " gem drill " , " 🪛 " ) , " cooking " : ( " cleaver " , " 🔪 " ) ,
2026-10-21 12:16:22 +01:00
" herblore " : ( " mortar and pestle " , " 🥣 " ) , " leatherworking " : ( " awl " , " 📍 " ) , " tailoring " : ( " shears " , " ✂️ " ) ,
" firekeeping " : ( " bellows " , " 🪭 " ) }
2026-10-18 22:13:50 +01:00
TOOL_SPEED = [ 0.15 , 0.30 , 0.38 , 0.45 , 0.52 , 0.60 ]
2026-10-20 21:20:25 +01:00
# Crafting stations: a second slot for every crafting skill, beside its tool, and as fast.
# Builders make every station, from the tier's planks and hardware and a material from
2026-10-21 14:37:52 +01:00
# another trade. A station speeds what's made at it, and a workbench the building site too.
2026-10-20 21:20:25 +01:00
# skill: (id suffix, noun, glyph, other material)
2026-10-20 21:00:31 +01:00
STATIONS = {
2026-10-20 21:20:25 +01:00
" construction " : ( " workbench " , " workbench " , " toolbox " , " bar " ) ,
2026-10-20 21:00:31 +01:00
" smithing " : ( " anvil_stand " , " anvil stand " , " anvil-impact " , " leather " ) ,
" jewelcrafting " : ( " jewellers_bench " , " jeweller ' s bench " , " gem-chain " , " cloth " ) ,
" cooking " : ( " hearth " , " hearth " , " campfire " , " bar " ) ,
" herblore " : ( " alembic " , " alembic " , " erlenmeyer " , " bar " ) ,
" leatherworking " : ( " tanning_rack " , " tanning rack " , " animal-hide " , " cloth " ) ,
" tailoring " : ( " loom " , " loom " , " spinning-wheel " , " leather " ) ,
}
# Storage: a builder's strongbox adds bank slots, a homesteader's bag a bigger stack.
STRONGBOX_SLOTS = [ 2 , 4 , 6 , 8 , 11 , 14 ]
BAG_STACK = [ 250 , 500 , 750 , 1000 , 1500 , 2000 ]
2026-10-18 22:13:50 +01:00
# 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 " ) ,
2026-10-19 10:19:38 +01:00
( " magic_logs " , " Enchanted logs " , " 🔮 " , 55 , " cut_magic " , " Cut enchanted trees " ) ,
2026-10-18 22:13:50 +01:00
]
2026-10-21 12:16:22 +01:00
FIREWOOD = tuple ( tree [ 0 ] for tree in TREES )
# The guild's fires, shared by everyone. A craft at a fire takes one log of its tier as
# fuel, and a fire burning that tier's flame or hotter pays it (the forge a coal too).
# Firekeepers feed them: fuel burns hottest first, and a better log pushes cheaper fuel
# out of a full fire. Level gives more minutes per log and a bigger bank.
FIRES = {
" forge " : { " name " : " Forge " , " icon " : " ⚒️ " , " skill " : " smithing " , " glyph " : " anvil-impact " } ,
" kitchen " : { " name " : " Kitchen fire " , " icon " : " 🍳 " , " skill " : " cooking " , " glyph " : " cooking-pot " } ,
" kiln " : { " name " : " Kiln " , " icon " : " 🧱 " , " skill " : " construction " , " glyph " : " brick-pile " } ,
" still " : { " name " : " Still " , " icon " : " ⚗️ " , " skill " : " herblore " , " glyph " : " erlenmeyer " } ,
}
FLAMES = [ ( " Red " , " #ff6a2a " ) , ( " Gold " , " #ffcb3d " ) , ( " White " , " #eeeaf8 " ) ,
( " Blue " , " #4fa3ff " ) , ( " Green " , " #3ed37a " ) , ( " Violet " , " #b07cff " ) ]
TEND_XP = [ 2 , 3 , 4.5 , 5.5 , 6.5 , 7.5 ] # per game minute of fire fed, by the log's tier
2026-10-21 13:22:20 +01:00
WARMTH = 0.1 # of a craft's XP, shared by everyone whose pile is hot enough for it
2026-10-21 12:16:22 +01:00
FIRE_LOW = 30 * 60 # game seconds left when a fire counts as low
def fire_minutes ( level : int ) - > float :
""" Game minutes of fire one log gives at this Firekeeping level. """
return 3 + 0.12 * level
def fire_bank ( level : int ) - > float :
""" The most fuel, in game minutes, a keeper of this level can bank in a fire. """
return 60 + 9 * level
def _fuelled ( inputs : dict , tier : int ) - > dict :
""" A fire craft ' s inputs: its materials and one log of its tier to burn. """
return { * * inputs , FIREWOOD [ tier ] : inputs . get ( FIREWOOD [ tier ] , 0 ) + 1 }
2026-10-18 22:13:50 +01:00
2026-10-20 14:16:12 +01:00
# Every tier's hide comes from Hunting. raw hide (id, name, icon, value), leather (id, name, value),
2026-10-21 20:56:39 +01:00
# armour id prefix, armour name prefix
2026-10-18 22:13:50 +01:00
LEATHERS = [
2026-10-21 20:56:39 +01:00
( ( " hide " , " Hide " , " 🟫 " , 3 ) , ( " leather " , " Leather " , 5 ) , " leather " , " Leather " ) ,
( ( " boar_hide " , " Boar hide " , " 🐗 " , 8 ) , ( " hard_leather " , " Hard leather " , 12 ) , " hard_leather " , " Hard leather " ) ,
( ( " bear_pelt " , " Bear pelt " , " 🐻 " , 16 ) , ( " bear_leather " , " Bear leather " , 24 ) , " bear_leather " , " Bear leather " ) ,
( ( " elk_hide " , " Elk hide " , " 🦌 " , 35 ) , ( " elk_leather " , " Elk leather " , 50 ) , " elk_leather " , " Elk leather " ) ,
2026-10-20 14:16:12 +01:00
( ( " sabrecat_pelt " , " Sabrecat pelt " , " 🐆 " , 60 ) , ( " sabrecat_leather " , " Sabrecat leather " , 85 ) ,
2026-10-21 20:56:39 +01:00
" sabrecat_leather " , " Sabrecat leather " ) ,
2026-10-20 14:16:12 +01:00
( ( " mammoth_hide " , " Mammoth hide " , " 🦣 " , 100 ) , ( " mammoth_leather " , " Mammoth leather " , 140 ) ,
2026-10-21 20:56:39 +01:00
" mammoth_leather " , " Mammoth leather " ) ,
2026-10-18 22:13:50 +01:00
]
# 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 , " 🦺 " ) ]
2026-10-20 14:16:12 +01:00
# Tailoring: a fibre from Farming, the cloth woven from it, and the light armour sewn from that.
# fibre (id, name, icon, value), farming level, seconds, xp, cloth (id, name, value), armour id prefix, armour name prefix
CLOTHS = [
( ( " flax " , " Flax " , " 🌿 " , 2 ) , 1 , 35 , 11 , ( " linen " , " Linen " , 5 ) , " linen " , " Linen " ) ,
( ( " cotton " , " Cotton " , " ☁️ " , 4 ) , 15 , 45 , 20 , ( " cotton_cloth " , " Cotton cloth " , 12 ) , " cotton " , " Cotton " ) ,
( ( " silk_cocoons " , " Silk cocoons " , " 🐛 " , 8 ) , 30 , 55 , 32 , ( " silk " , " Silk " , 24 ) , " silk " , " Silk " ) ,
( ( " moonpetal " , " Moonpetal " , " 🌙 " , 15 ) , 45 , 60 , 45 , ( " moonweave " , " Moonweave " , 45 ) , " moonweave " , " Moonweave " ) ,
( ( " starbloom " , " Starbloom " , " ⭐ " , 28 ) , 60 , 70 , 60 , ( " starweave " , " Starweave " , 80 ) , " starweave " , " Starweave " ) ,
( ( " spiritvine " , " Spiritvine " , " 🌀 " , 50 ) , 75 , 80 , 80 , ( " spiritweave " , " Spiritweave " , 135 ) , " spiritweave " , " Spiritweave " ) ,
]
FIBRE_VERBS = { " silk_cocoons " : " Raise silkworms " }
CLOTH_PIECES = [ ( " hood " , " helmet " , " hood " , 2 , 2 , " 🧢 " ) , ( " legs " , " legs " , " leggings " , 3 , 7 , " 👖 " ) ,
( " body " , " body " , " robe " , 4 , 11 , " 👘 " ) ]
2026-10-18 22:13:50 +01:00
# 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 ,
2026-10-21 07:52:01 +01:00
" req " : [ " combat " , TIER_LEVELS [ _tier ] ] }
2026-10-18 22:13:50 +01:00
if _power :
_item [ " power " ] = _power [ _tier ]
2026-10-20 14:16:12 +01:00
if _armour and _piece == " shield " :
2026-10-18 22:13:50 +01:00
_item [ " armour " ] = _armour [ _tier ]
2026-10-20 14:16:12 +01:00
elif _armour :
_item . update ( _armour_stats ( " heavy " , _slot , _tier ) )
2026-10-18 22:13:50 +01:00
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 ,
2026-10-21 07:59:22 +01:00
" slot " : f " tool_ { _skill } " , " tool " : _skill , " speed " : TOOL_SPEED [ _tier ] , " req " : [ _skill , TIER_LEVELS [ _tier ] ] ,
2026-10-18 22:13:50 +01:00
}
2026-10-20 19:59:45 +01:00
# Jewelcrafting: a gem from each tier's rock, set in a band of copper, silver or gold.
# Silver and gold are soft metals the jeweller smelts, no smithy needed. Past the first
# tier, every piece also needs a shard from that tier's expedition area.
# gem id, name, value, tint, glyph
GEMS = [
( " quartz " , " Quartz " , 8 , " #e8e4f0 " , " crystal-bars " ) ,
( " garnet " , " Garnet " , 20 , " #c8405a " , " rupee " ) ,
( " amethyst " , " Amethyst " , 40 , " #a070e0 " , " amethyst " ) ,
( " sapphire " , " Sapphire " , 70 , " #4f7ff0 " , " saphir " ) ,
( " emerald " , " Emerald " , 110 , " #3fc070 " , " emerald " ) ,
( " diamond " , " Diamond " , 160 , " #d8f4ff " , " diamond-hard " ) ,
]
BANDS = [ " copper_bar " , " silver_bar " , " silver_bar " , " gold_bar " , " gold_bar " , " gold_bar " ]
GEM_ODDS = 0.02 # a gem with each rock mined
PROSPECT_ORE = 5 # ore cracked open per prospecting action...
PROSPECT_ODDS = 0.15 # ...for this chance at its gem
# The gem each rock turns up when mined, and what's cracked open to prospect for each gem.
GEM_ROCKS = { " mine_stone " : " quartz " , " mine_copper " : " quartz " , " mine_iron " : " garnet " , " mine_coal " : " amethyst " ,
" mine_mithril " : " sapphire " , " mine_adamantite " : " emerald " , " mine_runite " : " diamond " }
PROSPECT = [ " stone " , " iron_ore " , " coal " , " mithril_ore " , " adamantite_ore " , " runite_ore " ]
2026-10-20 22:15:57 +01:00
# Rings for offence, amulets for defence, trinkets for everything else; each its own tab.
JEWEL_GROUPS = { " ring " : " Rings " , " amulet " : " Amulets " , " trinket " : " Trinkets " }
2026-10-20 19:59:45 +01:00
# key, slot, noun, stat, bonus by tier, bands, gems, shards, level above the tier, glyph
JEWELLERY = [
2026-10-20 20:26:54 +01:00
( " haste " , " ring " , " ring of haste " , " haste " , [ 5 , 7 , 8 , 9 , 10 , 11 ] , 1 , 1 , 1 , 2 , " diamond-ring " ) ,
2026-10-20 19:59:45 +01:00
( " fury " , " ring " , " ring of fury " , " crit " , [ 2 , 3 , 5 , 6 , 8 , 10 ] , 1 , 1 , 1 , 2 , " big-diamond-ring " ) ,
( " might " , " ring " , " ring of might " , " power " , [ 2 , 4 , 6 , 9 , 12 , 15 ] , 1 , 1 , 1 , 2 , " swirl-ring " ) ,
( " vigour " , " amulet " , " amulet of vigour " , " hp " , [ 5 , 10 , 16 , 23 , 31 , 40 ] , 2 , 1 , 2 , 8 , " heart-necklace " ) ,
( " warding " , " amulet " , " amulet of warding " , " ward " , [ 3 , 5 , 7 , 10 , 12 , 15 ] , 2 , 1 , 2 , 8 , " gem-pendant " ) ,
( " mending " , " amulet " , " amulet of mending " , " regen " , [ 10 , 20 , 30 , 40 , 50 , 60 ] , 2 , 1 , 2 , 8 , " tribal-pendant " ) ,
( " hourglass " , " trinket " , " hourglass " , " xp " , [ 2 , 3 , 5 , 6 , 8 , 10 ] , 1 , 2 , 1 , 5 , " hourglass " ) ,
( " charm " , " trinket " , " lucky charm " , " double " , [ 2 , 3 , 4 , 5 , 6 , 8 ] , 1 , 2 , 1 , 5 , " clover " ) ,
2026-10-20 22:15:57 +01:00
( " loupe " , " trinket " , " loupe " , " craft_double " , [ 2 , 3 , 4 , 4 , 5 , 6 ] , 1 , 2 , 1 , 5 , " magnifying-glass " ) ,
2026-10-20 19:59:45 +01:00
]
2026-10-20 20:26:54 +01:00
# How each jewellery bonus reads: name, unit, glyph, tint (and a sign, if it takes away).
2026-10-20 19:59:45 +01:00
JEWEL_STATS = {
2026-10-20 20:26:54 +01:00
" haste " : { " name " : " Swing time " , " unit " : " % " , " glyph " : " stopwatch " , " tint " : " #9fb3c8 " , " sign " : " - " } ,
2026-10-20 19:59:45 +01:00
" crit " : { " name " : " Crit chance " , " unit " : " % " , " glyph " : " striking-diamonds " , " tint " : " #e05a5a " } ,
" power " : { " name " : " Power " , " unit " : " " , " glyph " : " biceps " , " tint " : " #e06a6a " } ,
" hp " : { " name " : " Max HP " , " unit " : " " , " glyph " : " hearts " , " tint " : " #e06a8a " } ,
" ward " : { " name " : " Ward " , " unit " : " " , " glyph " : " magic-shield " , " tint " : " #a77be8 " } ,
" regen " : { " name " : " HP regen " , " unit " : " % " , " glyph " : " heart-plus " , " tint " : " #e06a8a " } ,
" xp " : { " name " : " XP " , " unit " : " % " , " glyph " : " sparkles " , " tint " : " #f0d060 " } ,
" double " : { " name " : " Double gathering " , " unit " : " % " , " glyph " : " clover " , " tint " : " #5fae5a " } ,
2026-10-20 22:15:57 +01:00
" craft_double " : { " name " : " Double crafting " , " unit " : " % " , " glyph " : " anvil " , " tint " : " #d07a3c " } ,
2026-10-20 19:59:45 +01:00
}
ITEMS [ " silver_ore " ] = { " name " : " Silver ore " , " icon " : " ⚪ " , " value " : 6 }
ITEMS [ " silver_bar " ] = { " name " : " Silver bar " , " icon " : " 🥈 " , " value " : 15 , " tier " : 1 }
ITEMS [ " gold_ore " ] = { " name " : " Gold ore " , " icon " : " 🟡 " , " value " : 20 }
ITEMS [ " gold_bar " ] = { " name " : " Gold bar " , " icon " : " 🥇 " , " value " : 45 , " tier " : 3 }
for _tier , ( _gem , _gem_name , _gem_value , _tint , _glyph ) in enumerate ( GEMS ) :
ITEMS [ _gem ] = { " name " : _gem_name , " icon " : " 💎 " , " value " : _gem_value , " tier " : _tier }
for _key , _slot , _noun , _stat , _bonus , _bands , _gems , _shards , _offset , _piece_glyph in JEWELLERY :
ITEMS [ f " { _gem } _ { _key } " ] = {
" name " : f " { _gem_name } { _noun } " , " icon " : { " ring " : " 💍 " , " amulet " : " 📿 " , " trinket " : " 🔮 " } [ _slot ] ,
" value " : round ( ( _bands * ITEMS [ BANDS [ _tier ] ] [ " value " ] + _gems * _gem_value + _shards * 12 * _tier ) * 1.3 ) ,
" slot " : _slot , " tier " : _tier , " jewel " : _stat , _stat : _bonus [ _tier ] ,
2026-10-21 07:52:01 +01:00
* * ( { " req " : [ " combat " , TIER_LEVELS [ _tier ] ] } if _slot != " trinket " else { } ) }
2026-10-19 23:17:23 +01:00
# 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 ) }
2026-10-19 09:24:45 +01:00
PLANKS = [ ( " plank " , " Plank " ) , ( " oak_plank " , " Oak plank " ) , ( " willow_plank " , " Willow plank " ) ,
2026-10-19 10:19:38 +01:00
( " maple_plank " , " Maple plank " ) , ( " yew_plank " , " Yew plank " ) , ( " magic_plank " , " Enchanted plank " ) ]
2026-10-19 09:24:45 +01:00
for ( _log , _name , _icon , _value , _action_id , _verb ) , ( _plank , _plank_name ) in zip ( TREES , PLANKS ) :
2026-10-18 22:13:50 +01:00
ITEMS [ _log ] = { " name " : _name , " icon " : _icon , " value " : _value }
2026-10-19 09:24:45 +01:00
ITEMS [ _plank ] = { " name " : _plank_name , " icon " : " 🪚 " , " value " : _value * 2 + 1 }
2026-10-19 23:38:34 +01:00
# 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 ,
2026-10-21 07:52:01 +01:00
" req " : [ " combat " , TIER_LEVELS [ _tier ] ] , " value " : round ( 5 * _metal [ 5 ] * 1.2 ) }
2026-10-19 23:38:34 +01:00
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 }
2026-10-20 21:00:31 +01:00
WOOD_NAMES = [ " Wooden " , " Oak " , " Willow " , " Maple " , " Yew " , " Enchanted " ]
def station_material ( kind : str , tier : int ) - > str :
""" The other trade ' s part of a station: the tier ' s bar, leather or cloth. """
return { " bar " : f " { METALS [ tier ] [ 0 ] } _bar " , " leather " : LEATHERS [ tier ] [ 1 ] [ 0 ] , " cloth " : CLOTHS [ tier ] [ 4 ] [ 0 ] } [ kind ]
for _tier , _wood in enumerate ( WOODS ) :
for _skill , ( _suffix , _noun , _glyph , _kind ) in STATIONS . items ( ) :
ITEMS [ f " { _wood } _ { _suffix } " ] = {
2026-10-20 21:20:25 +01:00
" name " : f " { WOOD_NAMES [ _tier ] } { _noun } " , " icon " : " 🪚 " , " tier " : _tier , " slot " : f " station_ { _skill } " , " station " : _skill ,
2026-10-21 07:59:22 +01:00
" speed " : TOOL_SPEED [ _tier ] , " req " : [ _skill , TIER_LEVELS [ _tier ] ] , " value " : 0 }
2026-10-20 21:00:31 +01:00
ITEMS [ f " { _wood } _strongbox " ] = { " name " : f " { WOOD_NAMES [ _tier ] } strongbox " , " plural " : f " { WOOD_NAMES [ _tier ] } strongboxes " ,
" icon " : " 🧰 " , " tier " : _tier , " slot " : " storage " , " slots " : STRONGBOX_SLOTS [ _tier ] , " value " : 0 }
for _tier , ( _fibre , _level , _time , _xp , _cloth , _prefix , _label ) in enumerate ( CLOTHS ) :
ITEMS [ f " { _prefix } _bag " ] = { " name " : f " { _label } bag " , " icon " : " 👜 " , " tier " : _tier , " slot " : " bag " ,
" stack " : BAG_STACK [ _tier ] , " value " : 0 }
2026-10-21 20:56:39 +01:00
for _tier , ( _raw , _leather , _prefix , _label ) in enumerate ( LEATHERS ) :
2026-10-18 22:13:50 +01:00
ITEMS [ _raw [ 0 ] ] = { " name " : _raw [ 1 ] , " icon " : _raw [ 2 ] , " value " : _raw [ 3 ] }
ITEMS [ _leather [ 0 ] ] = { " name " : _leather [ 1 ] , " icon " : " 🪢 " , " value " : _leather [ 2 ] }
2026-10-20 14:16:12 +01:00
for _suffix , _slot , _noun , _used , _offset , _icon in LEATHER_PIECES :
2026-10-18 22:13:50 +01:00
ITEMS [ f " { _prefix } _ { _suffix } " ] = {
" name " : f " { _label } { _noun } " , " icon " : _icon , " value " : round ( _used * _leather [ 2 ] * 1.3 ) ,
2026-10-21 07:52:01 +01:00
" slot " : _slot , " tier " : _tier , " req " : [ " combat " , TIER_LEVELS [ _tier ] ] , * * _armour_stats ( " medium " , _slot , _tier ) ,
2026-10-18 19:27:56 +01:00
}
2026-10-18 22:13:50 +01:00
if _noun == " trousers " :
ITEMS [ f " { _prefix } _ { _suffix } " ] [ " plural " ] = ITEMS [ f " { _prefix } _ { _suffix } " ] [ " name " ]
2026-10-20 14:16:12 +01:00
for _tier , ( _fibre , _level , _time , _xp , _cloth , _prefix , _label ) in enumerate ( CLOTHS ) :
ITEMS . setdefault ( _fibre [ 0 ] , { " name " : _fibre [ 1 ] , " icon " : _fibre [ 2 ] , " value " : _fibre [ 3 ] } )
ITEMS [ _cloth [ 0 ] ] = { " name " : _cloth [ 1 ] , " icon " : " 🧶 " , " value " : _cloth [ 2 ] }
for _suffix , _slot , _noun , _used , _offset , _icon in CLOTH_PIECES :
ITEMS [ f " { _prefix } _ { _suffix } " ] = {
" name " : f " { _label } { _noun } " , " icon " : _icon , " value " : round ( _used * _cloth [ 2 ] * 1.3 ) ,
2026-10-21 07:52:01 +01:00
" slot " : _slot , " tier " : _tier , " req " : [ " combat " , TIER_LEVELS [ _tier ] ] , * * _armour_stats ( " light " , _slot , _tier ) ,
2026-10-20 14:16:12 +01:00
}
if _noun == " leggings " :
ITEMS [ f " { _prefix } _ { _suffix } " ] [ " plural " ] = ITEMS [ f " { _prefix } _ { _suffix } " ] [ " name " ]
2026-10-18 22:13:50 +01:00
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 ] }
2026-10-18 23:12:57 +01:00
# --- 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 = [
2026-10-19 21:28:00 +01:00
( " guam_leaf " , " Guam leaf " , " 🍀 " , 1 , 45 , 12 , 3 ) ,
2026-10-18 23:12:57 +01:00
( " 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
2026-10-19 21:28:00 +01:00
# 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 " ] ) ,
2026-10-20 14:16:12 +01:00
( " hunting " , " Hunting potion " , " hunting " , " speed " , SPEEDS ,
[ " feathers " , " raw_meat " , " boar_hide " , " bear_pelt " , " elk_hide " , " sabrecat_pelt " ] ) ,
2026-10-19 21:28:00 +01:00
( " 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 ] ) ,
2026-10-20 14:16:12 +01:00
( " tailoring " , " Tailoring potion " , " tailoring " , " keep " , SPEEDS , [ cloth [ 4 ] [ 0 ] for cloth in CLOTHS ] ) ,
2026-10-19 21:28:00 +01:00
( " 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 ] ) ,
2026-10-18 23:12:57 +01:00
]
2026-10-19 21:28:00 +01:00
# 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 " ,
2026-10-19 23:17:23 +01:00
" smiths_focus " : " smithing_potion_2 " , " masons_patience " : " construction_potion_2 " , " tanners_oil " : " leatherworking_potion_2 " ,
2026-10-19 21:28:00 +01:00
" regeneration_potion " : " defence_potion_3 " , " super_strength " : " strength_potion_4 " , " super_defence " : " defence_potion_5 " ,
}
2026-10-18 23:12:57 +01:00
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 :
2026-10-20 08:01:57 +01:00
return f " { name . title ( ) } : { round ( effect [ ' double ' ] * 100 ) } % double yield "
2026-10-18 23:12:57 +01:00
if " keep " in effect :
2026-10-20 08:01:57 +01:00
return f " { name . title ( ) } : { round ( effect [ ' keep ' ] * 100 ) } % save materials "
2026-10-18 23:12:57 +01:00
if " bones " in effect :
2026-10-20 08:01:57 +01:00
return f " + { round ( effect [ ' bones ' ] * 100 ) } % from bones "
2026-10-18 23:12:57 +01:00
if " power " in effect :
2026-10-20 08:01:57 +01:00
return f " + { effect [ ' power ' ] } power "
return f " - { round ( effect [ ' reduce ' ] * 100 ) } % damage taken "
2026-10-18 23:12:57 +01:00
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 ) } }
2026-10-24 09:49:21 +01:00
# Health potions: the one heal you take into a fight, drunk once a fight when you're low.
# Each tier is the tier's herb and its cooked fish.
HEALTH_HEAL = [ 20 , 40 , 65 , 90 , 115 , 140 ]
HEALTH_POTIONS = [ ( f " health_potion_ { _tier + 1 } " , f " Health potion { ROMAN [ _tier ] } " , TIER_LEVELS [ _tier ] , HERBS [ _tier ] [ 0 ] ,
FISH [ _tier ] [ 1 ] [ 0 ] , HEALTH_HEAL [ _tier ] ) for _tier in range ( len ( HEALTH_HEAL ) ) ]
for _id , _name , _level , _herb , _fish , _heal in HEALTH_POTIONS :
ITEMS [ _id ] = { " name " : _name , " icon " : " ❤️ " , " value " : ITEMS [ _herb ] [ " value " ] * 3 + ITEMS [ _fish ] [ " value " ] , " drink " : _heal }
2026-10-18 19:27:56 +01:00
# What the city eats first: plain food before the good stuff.
2026-10-18 22:13:50 +01:00
FOOD_ORDER = tuple ( sorted ( ( item for item , spec in ITEMS . items ( ) if " food " in spec ) , key = lambda i : ITEMS [ i ] [ " heal " ] ) )
2026-10-20 21:00:31 +01:00
EQUIP_SLOTS = ( [ " weapon " , " shield " , " helmet " , " body " , " legs " , " ring " , " amulet " , " trinket " , " storage " , " bag " ]
2026-10-20 21:20:25 +01:00
+ [ f " tool_ { skill } " for skill in TOOLS ] + [ f " station_ { skill } " for skill in STATIONS ] )
2026-10-18 13:48:56 +01:00
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 " } ,
2026-10-18 19:27:56 +01:00
{ " id " : " fishing " , " name " : " Fishing " , " icon " : " 🎣 " , " group " : " Gathering " } ,
2026-10-18 13:48:56 +01:00
{ " id " : " smithing " , " name " : " Smithing " , " icon " : " 🔨 " , " group " : " Crafting " } ,
2026-10-20 19:59:45 +01:00
{ " id " : " jewelcrafting " , " name " : " Jewelcrafting " , " icon " : " 💍 " , " group " : " Crafting " } ,
2026-10-19 09:24:45 +01:00
{ " id " : " construction " , " name " : " Construction " , " icon " : " 🏗️ " , " group " : " Crafting " } ,
2026-10-18 13:48:56 +01:00
{ " id " : " cooking " , " name " : " Cooking " , " icon " : " 🍳 " , " group " : " Crafting " } ,
2026-10-18 19:27:56 +01:00
{ " id " : " leatherworking " , " name " : " Leatherworking " , " icon " : " 🪡 " , " group " : " Crafting " } ,
2026-10-20 14:16:12 +01:00
{ " id " : " tailoring " , " name " : " Tailoring " , " icon " : " 🧵 " , " group " : " Crafting " } ,
2026-10-18 23:12:57 +01:00
{ " id " : " herblore " , " name " : " Herblore " , " icon " : " 🧪 " , " group " : " Crafting " } ,
2026-10-21 12:16:22 +01:00
{ " id " : " firekeeping " , " name " : " Firekeeping " , " icon " : " 🔥 " , " group " : " Crafting " } ,
2026-10-18 13:48:56 +01:00
{ " id " : " combat " , " name " : " Combat " , " icon " : " ⚔️ " , " group " : " Combat " } ,
2026-10-18 23:09:17 +01:00
{ " id " : " prayer " , " name " : " Prayer " , " icon " : " 🙏 " , " group " : " Combat " } ,
2026-10-19 22:45:32 +01:00
{ " id " : " reconnaissance " , " name " : " Reconnaissance " , " icon " : " 🔭 " , " group " : " Combat " } ,
2026-10-18 13:48:56 +01:00
]
SKILL_IDS = [ skill [ " id " ] for skill in SKILLS ]
2026-10-18 19:27:56 +01:00
GATHERING = { " mining " , " woodcutting " , " farming " , " hunting " , " fishing " }
2026-10-20 19:59:45 +01:00
CRAFTING = { " smithing " , " jewelcrafting " , " construction " , " cooking " , " leatherworking " , " herblore " , " tailoring " }
2026-10-18 13:48:56 +01:00
2026-10-19 22:45:32 +01:00
# 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.
2026-10-18 13:48:56 +01:00
CLASSES = {
2026-10-19 22:45:32 +01:00
" miner " : {
2026-10-20 19:59:45 +01:00
" name " : " Miner " , " icon " : " ⛏️ " , " fast " : [ " mining " , " smithing " , " jewelcrafting " ] ,
" craft " : [ " smithing " , " jewelcrafting " ] ,
" bonus " : " Mining, smithing and jewelcrafting 20 % f aster " ,
" masterwork " : " Masterwork: 10 % d ouble smithing and jewelcrafting crafts " ,
2026-10-20 08:01:57 +01:00
" signature " : " Prospector: 10 % d ouble ore " ,
" combat " : " Warhammer: +15 % d amage " ,
2026-10-18 13:48:56 +01:00
} ,
2026-10-19 22:45:32 +01:00
" builder " : {
2026-10-21 12:16:22 +01:00
" name " : " Builder " , " icon " : " 🏗️ " , " fast " : [ " woodcutting " , " construction " , " firekeeping " ] , " craft " : [ " construction " ] ,
" bonus " : " Woodcutting, construction and firekeeping 20 % f aster " ,
2026-10-20 08:01:57 +01:00
" masterwork " : " Masterwork: 10 % d ouble construction crafts " ,
" signature " : " Foreman: +1 labour per site action " ,
" combat " : " Sword: +15 % d amage " ,
2026-10-18 13:48:56 +01:00
} ,
2026-10-19 22:45:32 +01:00
" homesteader " : {
2026-10-20 14:16:12 +01:00
" name " : " Homesteader " , " icon " : " 🌾 " , " fast " : [ " cooking " , " herblore " , " tailoring " ] ,
" craft " : [ " cooking " , " herblore " , " tailoring " ] ,
" bonus " : " Cooking, herblore and tailoring 20 % f aster, two farm plots " ,
" masterwork " : " Masterwork: 10 % d ouble cooking, herblore and tailoring crafts " ,
2026-10-20 08:01:57 +01:00
" signature " : " Steady hand: potions last 25 % lo nger " ,
" combat " : " Staff: +15 % d amage " ,
2026-10-18 13:48:56 +01:00
} ,
2026-10-19 22:45:32 +01:00
" ranger " : {
" name " : " Ranger " , " icon " : " 🏹 " , " fast " : [ " hunting " , " fishing " , " leatherworking " , " cooking " ] ,
" craft " : [ " leatherworking " , " cooking " ] ,
" bonus " : " Hunting, fishing, leatherworking and cooking 20 % f aster " ,
2026-10-20 08:01:57 +01:00
" masterwork " : " Masterwork: 10 % d ouble leatherworking and cooking crafts " ,
" signature " : " Pathfinder: +50 % r econ " ,
" combat " : " Bow: +15 % d amage " ,
2026-10-18 13:48:56 +01:00
} ,
}
2026-10-19 22:45:32 +01:00
# Classes from before the trades, as a starting point until their player re-picks.
OLD_CLASSES = { " pioneer " : " miner " , " artisan " : " builder " , " scout " : " ranger " , " warrior " : " builder " }
2026-10-18 13:48:56 +01:00
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 }
2026-10-18 22:13:50 +01:00
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 { }
2026-10-21 12:16:22 +01:00
inputs = { " iron_ore " if key == " steel " else ore [ 0 ] : 1 , * * ( { " coal " : coal } if coal else { } ) }
2026-10-18 22:13:50 +01:00
actions . append ( _action ( f " smith_ { key } _bar " , " smithing " , f " { name } bar " , TIER_LEVELS [ tier ] , 20 + 4 * tier ,
2026-10-21 12:16:22 +01:00
bar_xp [ tier ] , _fuelled ( inputs , tier ) , { f " { key } _bar " : 1 } , group = " Bars " ,
city = { " smithy " : smithy } if tier > = 2 else { } , fire = " forge " , fuel = tier ) )
2026-10-19 23:17:23 +01:00
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 ) )
2026-10-18 22:13:50 +01:00
for piece , _slot , noun , bars , offset , _power , _armour in PIECES :
2026-10-19 23:38:34 +01:00
if piece == " shield " :
continue # builders make shields now, in Construction
2026-10-20 14:16:12 +01:00
inputs = { f " { key } _bar " : bars }
if tier :
inputs . update ( { trophy ( " sword " , tier ) : tier } if piece == " sword " else
{ reagent ( " heavy " , tier ) : REAGENT_COUNTS [ _slot ] } )
2026-10-18 22:13:50 +01:00
actions . append ( _action ( f " smith_ { key } _ { piece } " , " smithing " , f " { name } { noun } " , TIER_LEVELS [ tier ] + offset ,
2026-10-19 23:38:34 +01:00
25 + 5 * bars + 2 * tier , bars * piece_xp [ tier ] , inputs ,
2026-10-18 22:13:50 +01:00
{ f " { key } _ { piece } " : 1 } , group = name , city = city ) )
2026-10-19 23:38:34 +01:00
actions . append ( _action ( f " smith_ { key } _warhammer " , " smithing " , f " { name } warhammer " , TIER_LEVELS [ tier ] + 6 ,
45 + 2 * tier , 4 * piece_xp [ tier ] ,
2026-10-20 14:16:12 +01:00
{ f " { key } _bar " : 3 , PLANKS [ tier ] [ 0 ] : 1 , * * ( { trophy ( " hammer " , tier ) : 2 + tier } if tier else { } ) } ,
2026-10-19 23:38:34 +01:00
{ f " { key } _warhammer " : 1 } , group = name , city = city ) )
2026-10-18 22:13:50 +01:00
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 } ) )
2026-10-21 20:56:39 +01:00
for tier , ( raw , leather , prefix , label ) in enumerate ( LEATHERS ) :
2026-10-18 22:13:50 +01:00
actions . append ( _action ( " tan_leather " if tier == 0 else f " tan_ { leather [ 0 ] } " , " leatherworking " ,
2026-10-21 20:56:39 +01:00
f " Tan { leather [ 1 ] . lower ( ) } " , TIER_LEVELS [ tier ] , 25 + 3 * tier , bar_xp [ tier ] ,
2026-10-18 22:13:50 +01:00
{ raw [ 0 ] : 1 } , { leather [ 0 ] : 1 } , group = " Tanning " ) )
2026-10-20 14:16:12 +01:00
for suffix , slot , noun , used , offset , _icon in LEATHER_PIECES :
2026-10-18 22:13:50 +01:00
actions . append ( _action ( f " lw_ { suffix } " if tier == 0 else f " lw_ { prefix } _ { suffix } " , " leatherworking " ,
2026-10-21 20:56:39 +01:00
f " { label } { noun } " , TIER_LEVELS [ tier ] + offset ,
25 + 5 * used + 2 * tier , used * piece_xp [ tier ] ,
2026-10-20 14:16:12 +01:00
{ leather [ 0 ] : used , * * ( { reagent ( " medium " , tier ) : REAGENT_COUNTS [ slot ] } if tier else { } ) } ,
{ f " { prefix } _ { suffix } " : 1 } , group = label ) )
for tier , ( fibre , level , seconds , fibre_xp , cloth , prefix , label ) in enumerate ( CLOTHS ) :
if tier : # flax is grown with the other building materials
actions . append ( _action ( f " grow_ { fibre [ 0 ] } " , " farming " , FIBRE_VERBS . get ( fibre [ 0 ] , f " Grow { fibre [ 1 ] . lower ( ) } " ) ,
level , seconds , fibre_xp , outputs = { fibre [ 0 ] : 2 } , group = " Fibres " ) )
actions . append ( _action ( f " weave_ { cloth [ 0 ] } " , " tailoring " , f " Weave { cloth [ 1 ] . lower ( ) } " , TIER_LEVELS [ tier ] ,
25 + 3 * tier , bar_xp [ tier ] , { fibre [ 0 ] : 2 } , { cloth [ 0 ] : 1 } , group = " Weaving " ) )
for suffix , slot , noun , used , offset , _icon in CLOTH_PIECES :
actions . append ( _action ( f " sew_ { prefix } _ { suffix } " , " tailoring " , f " { label } { noun } " , TIER_LEVELS [ tier ] + offset ,
25 + 5 * used + 2 * tier , used * piece_xp [ tier ] ,
{ cloth [ 0 ] : used , * * ( { reagent ( " light " , tier ) : REAGENT_COUNTS [ slot ] } if tier else { } ) } ,
{ f " { prefix } _ { suffix } " : 1 } , group = label ) )
2026-10-18 22:13:50 +01:00
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 ] ,
2026-10-21 12:16:22 +01:00
[ 6 , 15 , 27 , 42 , 58 , 80 ] [ tier ] , _fuelled ( { raw [ 0 ] : 1 } , tier ) , { cooked [ 0 ] : 1 } , group = " Fish " ,
fire = " kitchen " , fuel = tier ) )
2026-10-18 22:13:50 +01:00
return actions
2026-10-19 23:32:07 +01:00
# --- combat -----------------------------------------------------------------
# A fight is played out swing by swing: you and the enemy each have HP, a swing
2026-10-20 14:16:12 +01:00
# timer, accuracy and a max hit, and every swing hits, misses or crits. Every
# blow has a damage type: weapons deal one, and each enemy is vulnerable to some
# types and resists others. Armour stops physical blows, ward stops magic.
# Combat is split into the Frontier (bandits and the city's troubles) and
# expeditions: one area per tier, each with four races (each feeding one weapon
# line with its trophy, and every armour line with the area's reagents) and a boss.
2026-10-19 23:32:07 +01:00
CRIT_CHANCE = 0.05
2026-10-24 09:49:21 +01:00
CRIT_MULT = combat . CRIT_MULT
RESPAWN = 2 # the least rest between one fight and the next
GROUP_SIZE = 2 # bosses and dragons are only fought this many together...
BOSS_HP = 0.75 # ...and grow by this much of their HP for each fighter...
BOSS_HITS = ( 1.3 , 0.15 ) # ...and hit this hard against a pair, and this much harder for each fighter more
MAX_FIGHT = combat . MAX_FIGHT
PACK_EXTRA = 0.5 # chance a pack has one more enemy than there are of you...
OUTNUMBERED = ( 0.65 , 0.65 ) # ...each of them then with this share of the HP and the hits
2026-10-19 23:32:07 +01:00
2026-10-20 14:16:12 +01:00
DAMAGE_TYPES = {
" slash " : { " name " : " Slashing " , " glyph " : " sword-slice " , " tint " : " #d4dae2 " } ,
" blunt " : { " name " : " Blunt " , " glyph " : " hammer-drop " , " tint " : " #d1a46a " } ,
" pierce " : { " name " : " Piercing " , " glyph " : " arrowhead " , " tint " : " #5fae8a " } ,
" magic " : { " name " : " Magic " , " glyph " : " magic-swirl " , " tint " : " #a77be8 " } ,
}
VULNERABLE = 1.25 # damage an enemy takes from a type it's vulnerable to
RESISTANT = 0.75 # ...and from one it resists
2026-10-19 23:38:34 +01:00
# How each kind of weapon fights. Two-handed weapons carry the shield's power themselves.
STYLES = {
2026-10-20 14:16:12 +01:00
" sword " : { " name " : " sword " , " damage " : " slash " , " interval " : 2.4 , " hit " : 1.0 , " accuracy " : 1.0 , " crit " : 0.05 } ,
" hammer " : { " name " : " warhammer " , " damage " : " blunt " , " interval " : 3.6 , " hit " : 1.6 , " accuracy " : 0.95 , " crit " : 0.10 } ,
" staff " : { " name " : " staff " , " damage " : " magic " , " interval " : 3.0 , " hit " : 1.2 , " accuracy " : 1.25 , " crit " : 0.05 } ,
" bow " : { " name " : " bow " , " damage " : " pierce " , " interval " : 1.8 , " hit " : 0.75 , " accuracy " : 1.05 , " crit " : 0.05 , " first " : True } ,
2026-10-19 23:38:34 +01:00
}
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
2026-10-20 14:16:12 +01:00
2026-10-20 19:59:45 +01:00
# Each armour piece past copper needs reagents from its tier's expedition area, and so
# does each piece of jewellery past quartz (its shards).
2026-10-20 14:16:12 +01:00
REAGENT_COUNTS = { " helmet " : 1 , " legs " : 2 , " body " : 3 }
2026-10-20 19:59:45 +01:00
REAGENT_NOUNS = { " heavy " : " plating " , " medium " : " lacing " , " light " : " thread " , " jewel " : " shard " }
REAGENT_ODDS = 0.10 # each of the area's four reagents, from any of its races
2026-10-21 20:30:47 +01:00
TROPHY_ODDS = 0.10 # weapons arm the town as well as the hunter, so as common as reagents
BOSS_TROPHY_ODDS = 0.20 # each of the four lines, so a boss is worth the longer fight
2026-10-20 14:16:12 +01:00
def _slug ( name : str ) - > str :
return " " . join ( c if c . isalnum ( ) else " _ " for c in name . lower ( ) . replace ( " ' " , " " ) ) . strip ( " _ " )
2026-10-24 09:49:21 +01:00
TOUGHER = 1.2 # enemies have this much more HP since everyone has abilities, for the same XP
2026-10-20 14:16:12 +01:00
# Archetypes: HP, seconds between swings, accuracy, evasion, max hit (multipliers), how often it turns up.
ARCHETYPES = {
" brute " : ( 1.3 , 3.0 , 1.0 , 0.85 , 1.2 , 4 ) ,
" skirmisher " : ( 0.85 , 2.2 , 1.1 , 1.25 , 0.85 , 4 ) ,
" caster " : ( 0.85 , 2.6 , 1.15 , 1.0 , 1.25 , 3 ) ,
" elite " : ( 1.7 , 2.8 , 1.1 , 1.1 , 1.3 , 1 ) ,
2026-10-24 09:49:21 +01:00
" boss " : ( 1.4 , 3.0 , 1.0 , 1.0 , 1.2 , 1 ) ,
" dragon " : ( 2.5 , 3.0 , 1.1 , 1.0 , 1.9 , 1 ) ,
2026-10-19 23:38:34 +01:00
}
2026-10-20 14:16:12 +01:00
def _types ( value ) - > list :
return [ value ] if isinstance ( value , str ) else list ( value )
def monster ( name : str , archetype : str , level : int , attack , vulnerable = ( ) , resists = ( ) , boss : bool = False ) - > dict :
hp , interval , accuracy , evasion , hit , weight = ARCHETYPES [ archetype ]
hp = round ( ( 20 + 6 * level ) * hp * ( 2.5 if boss else 1 ) )
return { " name " : name , " archetype " : archetype , " level " : level , " boss " : boss , " weight " : weight ,
2026-10-24 09:49:21 +01:00
" hp " : round ( hp * TOUGHER ) , " interval " : interval , " xp " : round ( hp * 0.13 + level * 0.5 ) ,
2026-10-20 14:16:12 +01:00
" accuracy " : round ( ( 12 + 2.2 * level ) * accuracy ) , " evasion " : round ( ( 10 + 2 * level ) * evasion ) ,
" max_hit " : max ( 1 , round ( ( 2 + level / 3 ) * hit * ( 1.2 if boss else 1 ) ) ) ,
" attack " : _types ( attack ) , " vulnerable " : _types ( vulnerable ) , " resists " : _types ( resists ) }
def _encounter ( id : str , name : str , group : str , level : int , roster : list , gold : int = 0 , drops = ( ) ,
2026-10-22 14:08:41 +01:00
boss : bool = False , req : int | None = None , * * extra ) - > dict :
2026-10-20 14:16:12 +01:00
""" A combat action: each kill is one of the roster ' s variants, picked by how often each turns up.
2026-10-22 14:08:41 +01:00
An elite is a couple of levels above the rest . ` req ` is the combat level to start , if not the enemy ' s own. " " "
2026-10-20 14:16:12 +01:00
variants = [ monster ( vname , archetype , level + ( 2 if archetype == " elite " else 0 ) , attack , vulnerable , resists , boss )
for vname , archetype , attack , vulnerable , resists in roster ]
total = sum ( v [ " weight " ] for v in variants )
xp = round ( sum ( v [ " xp " ] * v [ " weight " ] for v in variants ) / total )
enemy = { " name " : name , " level " : level , " boss " : boss , " variants " : variants }
2026-10-20 14:21:34 +01:00
# Gold per kill is a range around the encounter's worth.
purse = [ max ( 1 , round ( gold * 0.7 ) ) , max ( 2 , round ( gold * 1.3 ) ) ] if gold else None
2026-10-22 14:08:41 +01:00
return _action ( id , " combat " , extra . pop ( " label " , None ) or name , req or level , RESPAWN , xp , gold = purse ,
2026-10-20 14:16:12 +01:00
drops = [ list ( drop ) for drop in drops ] , enemy = enemy , group = group , * * extra )
def _boss ( id : str , name : str , group : str , level : int , archetype : str , attack , vulnerable , resists , * * extra ) - > dict :
2026-10-24 09:49:21 +01:00
""" One big enemy, and a group fight: alone you wait for someone to tag in. Everyone who brings
it down gets their own loot . """
2026-10-20 14:16:12 +01:00
return _encounter ( id , name , group , level , [ ( name , archetype , attack , vulnerable , resists ) ] ,
2026-10-24 09:49:21 +01:00
boss = extra . pop ( " is_boss " , True ) , personal_loot = True , together = GROUP_SIZE , * * extra )
2026-10-20 14:16:12 +01:00
# Rosters: (variant, archetype, attacks with, vulnerable to, resists).
BANDITS = [ ( " Bandit brute " , " brute " , " blunt " , " slash " , " blunt " ) ,
( " Bandit archer " , " skirmisher " , " pierce " , " blunt " , " pierce " ) ,
( " Hedge mage " , " caster " , " magic " , " pierce " , " magic " ) ]
WOLVES = [ ( " Grey wolf " , " skirmisher " , " slash " , " pierce " , " blunt " ) ,
( " Timber wolf " , " brute " , " slash " , " slash " , " pierce " ) ,
( " Wolf alpha " , " elite " , " slash " , " blunt " , " slash " ) ]
GOBLINS = [ ( " Goblin brute " , " brute " , " blunt " , " blunt " , " slash " ) ,
( " Goblin rogue " , " skirmisher " , " pierce " , " magic " , " blunt " ) ,
( " Goblin warlock " , " caster " , " magic " , " slash " , " magic " ) ]
2026-10-24 11:43:56 +01:00
SPIDERS = [ ( " Cave spider " , " skirmisher " , " pierce " , " blunt " , " pierce " ) ,
( " Bloated spider " , " brute " , " magic " , " slash " , " blunt " ) ,
( " Spider matriarch " , " elite " , " pierce " , " magic " , " slash " ) ]
SMUGGLERS = [ ( " Smuggler " , " skirmisher " , " slash " , " blunt " , " slash " ) ,
( " Smuggler bruiser " , " brute " , " blunt " , " pierce " , " blunt " ) ,
( " Smuggler hexer " , " caster " , " magic " , " slash " , " pierce " ) ]
2026-10-20 14:16:12 +01:00
# Expedition areas, tiers II to VI: name, level, the reagent prefix, and four races.
# Each race: (name, trophy, weapon line its trophy goes into, level offset, roster).
AREAS = [
2026-10-21 07:52:01 +01:00
( " Old Watchtower " , 15 , " Watchtower " , [
2026-10-20 14:16:12 +01:00
( " Clay golems " , " Clay core " , " hammer " , 0 , [
( " Clay brute " , " brute " , " blunt " , " blunt " , " pierce " ) ,
( " Mud shaper " , " caster " , " magic " , " pierce " , " magic " ) ,
( " Clay colossus " , " elite " , " blunt " , " magic " , " blunt " ) ] ) ,
( " Cave trolls " , " Cave troll hide " , " sword " , 2 , [
( " Troll bruiser " , " brute " , " blunt " , " slash " , " blunt " ) ,
( " Troll rockhurler " , " skirmisher " , " pierce " , " blunt " , " pierce " ) ,
( " Troll hexer " , " caster " , " magic " , " pierce " , " slash " ) ] ) ,
( " Restless dead " , " Grave dust " , " staff " , 1 , [
( " Skeleton warrior " , " brute " , " slash " , " blunt " , " pierce " ) ,
( " Wisp " , " skirmisher " , " magic " , " magic " , " slash " ) ,
( " Necromancer " , " caster " , " magic " , " slash " , " blunt " ) ] ) ,
( " Harpies " , " Harpy sinew " , " bow " , 0 , [
( " Harpy slasher " , " skirmisher " , " slash " , " pierce " , " blunt " ) ,
( " Harpy windcaster " , " caster " , " magic " , ( " slash " , " pierce " ) , " magic " ) ,
( " Harpy matriarch " , " elite " , " slash " , " magic " , " pierce " ) ] ) ,
] ) ,
2026-10-21 07:52:01 +01:00
( " Sunken Barrows " , 30 , " Barrow " , [
2026-10-20 14:16:12 +01:00
( " Stone guardians " , " Stone core " , " hammer " , 0 , [
( " Stone guardian " , " brute " , " blunt " , " blunt " , " slash " ) ,
( " Barrow sentry " , " skirmisher " , " pierce " , " magic " , " pierce " ) ,
( " Runebound guardian " , " elite " , " magic " , " slash " , " magic " ) ] ) ,
( " Lizardfolk " , " Lizard scale " , " sword " , 2 , [
( " Lizard warrior " , " brute " , " slash " , " blunt " , " slash " ) ,
( " Lizard spearman " , " skirmisher " , " pierce " , " slash " , " pierce " ) ,
( " Lizard shaman " , " caster " , " magic " , " pierce " , " magic " ) ] ) ,
( " Bog wraiths " , " Wraith essence " , " staff " , 1 , [
( " Bog wraith " , " caster " , " magic " , " magic " , " slash " ) ,
( " Drowned knight " , " brute " , " slash " , " blunt " , " pierce " ) ,
( " Marsh wisp " , " skirmisher " , " magic " , " pierce " , " blunt " ) ] ) ,
( " Giant spiders " , " Spider silk " , " bow " , 0 , [
( " Spider hunter " , " skirmisher " , " pierce " , " slash " , " pierce " ) ,
( " Venom weaver " , " caster " , " magic " , " blunt " , " magic " ) ,
( " Brood mother " , " elite " , " pierce " , " magic " , " blunt " ) ] ) ,
] ) ,
2026-10-21 07:52:01 +01:00
( " Iron Hills " , 45 , " Ironhill " , [
2026-10-20 14:16:12 +01:00
( " Iron golems " , " Iron core " , " hammer " , 0 , [
( " Iron golem " , " brute " , " blunt " , " blunt " , " slash " ) ,
( " Forge golem " , " caster " , " magic " , " pierce " , " magic " ) ,
( " Iron colossus " , " elite " , " blunt " , " magic " , " pierce " ) ] ) ,
( " Orcs " , " Orc tusk " , " sword " , 2 , [
( " Orc berserker " , " brute " , " slash " , " pierce " , " blunt " ) ,
( " Orc archer " , " skirmisher " , " pierce " , " slash " , " pierce " ) ,
( " Orc shaman " , " caster " , " magic " , " blunt " , " magic " ) ] ) ,
( " Mine ghosts " , " Ghost essence " , " staff " , 1 , [
( " Lost miner " , " brute " , " blunt " , " magic " , " slash " ) ,
( " Lantern ghost " , " caster " , " magic " , " blunt " , " magic " ) ,
( " Shaft howler " , " skirmisher " , " magic " , " pierce " , " blunt " ) ] ) ,
( " Wyverns " , " Wyvern sinew " , " bow " , 0 , [
( " Wyvern " , " brute " , " slash " , " pierce " , " slash " ) ,
( " Wyvern hatchling " , " skirmisher " , " pierce " , " blunt " , " magic " ) ,
( " Venomspitter " , " caster " , " magic " , " slash " , " pierce " ) ] ) ,
] ) ,
2026-10-21 07:52:01 +01:00
( " Frostpeak " , 60 , " Frostpeak " , [
2026-10-20 14:16:12 +01:00
( " Ice elementals " , " Frozen core " , " hammer " , 0 , [
( " Ice brute " , " brute " , " blunt " , " blunt " , " pierce " ) ,
( " Frost wisp " , " skirmisher " , " magic " , " slash " , " magic " ) ,
( " Glacier colossus " , " elite " , " blunt " , " magic " , " slash " ) ] ) ,
( " Frost trolls " , " Frost troll hide " , " sword " , 2 , [
( " Frost troll " , " brute " , " blunt " , " slash " , " blunt " ) ,
( " Troll icehurler " , " skirmisher " , " pierce " , " magic " , " pierce " ) ,
( " Troll rimecaller " , " caster " , " magic " , " blunt " , " magic " ) ] ) ,
( " Banshees " , " Banshee essence " , " staff " , 1 , [
( " Banshee " , " caster " , " magic " , " magic " , " slash " ) ,
( " Wailing knight " , " brute " , " slash " , " blunt " , " pierce " ) ,
( " Keening shade " , " skirmisher " , " magic " , " pierce " , " blunt " ) ] ) ,
( " Griffins " , " Griffin sinew " , " bow " , 0 , [
( " Griffin " , " brute " , " slash " , " pierce " , " blunt " ) ,
( " Griffin rider " , " skirmisher " , " pierce " , " slash " , " pierce " ) ,
( " Storm griffin " , " caster " , " magic " , " blunt " , " magic " ) ] ) ,
] ) ,
2026-10-21 07:52:01 +01:00
( " Dragon ' s Spine " , 75 , " Dragonspine " , [
2026-10-20 14:16:12 +01:00
( " Magma constructs " , " Magma core " , " hammer " , 0 , [
( " Magma brute " , " brute " , " blunt " , " blunt " , " slash " ) ,
( " Cinder golem " , " skirmisher " , " pierce " , " magic " , " pierce " ) ,
( " Fire binder " , " caster " , " magic " , " pierce " , " magic " ) ] ) ,
( " Dragonkin " , " Dragonkin scale " , " sword " , 2 , [
( " Drakeguard " , " brute " , " slash " , " blunt " , " slash " ) ,
( " Drake lancer " , " skirmisher " , " pierce " , " magic " , " pierce " ) ,
( " Flame priest " , " caster " , " magic " , " slash " , " magic " ) ] ) ,
( " Shades " , " Shade essence " , " staff " , 1 , [
( " Shade " , " caster " , " magic " , " magic " , " pierce " ) ,
( " Shadow blade " , " skirmisher " , " slash " , " blunt " , " slash " ) ,
( " Void knight " , " elite " , " magic " , " pierce " , " blunt " ) ] ) ,
( " Rocs " , " Roc sinew " , " bow " , 0 , [
( " Roc " , " brute " , " slash " , " pierce " , " blunt " ) ,
( " Roc hatchling " , " skirmisher " , " pierce " , " slash " , " pierce " ) ,
( " Thunder roc " , " caster " , " magic " , " blunt " , " magic " ) ] ) ,
] ) ,
]
AREA_BONES = [ " bones " , " big_bones " , " big_bones " , " dragon_bones " , " dragon_bones " ]
TROPHIES = { } # id: (name, weapon line, tier)
REAGENTS = { } # id: (weight, tier)
for _tier , ( _area , _level , _prefix , _races ) in enumerate ( AREAS , start = 1 ) :
for _race , _trophy , _line , _offset , _roster in _races :
TROPHIES [ _slug ( _trophy ) ] = ( _trophy , _line , _tier )
ITEMS [ _slug ( _trophy ) ] = { " name " : _trophy , " icon " : " 🏆 " , " value " : 15 * _tier , " trophy " : _line , " tier " : _tier }
for _weight , _noun in REAGENT_NOUNS . items ( ) :
_id = f " { _slug ( _prefix ) } _ { _noun } "
REAGENTS [ _id ] = ( _weight , _tier )
ITEMS [ _id ] = { " name " : f " { _prefix } { _noun } " , " icon " : " ✨ " , " value " : 12 * _tier , " reagent " : _weight , " tier " : _tier }
def trophy ( line : str , tier : int ) - > str :
""" The trophy for a weapon line from a tier ' s area (tier 1 is the Old Watchtower). """
return next ( item for item , ( _name , kind , t ) in TROPHIES . items ( ) if kind == line and t == tier )
def reagent ( weight : str , tier : int ) - > str :
""" The reagent an armour weight needs at a tier, from that tier ' s area. """
return next ( item for item , ( kind , t ) in REAGENTS . items ( ) if kind == weight and t == tier )
def _area_drops ( tier : int , odds : float = REAGENT_ODDS ) - > list [ tuple ] :
2026-10-20 19:59:45 +01:00
return [ ( reagent ( kind , tier ) , 1 , odds ) for kind in REAGENT_NOUNS ]
2026-10-19 23:38:34 +01:00
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 ]
2026-10-20 14:16:12 +01:00
extra = lambda line , count : { trophy ( line , tier ) : count } if tier else { }
2026-10-19 23:38:34 +01:00
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 ] ,
2026-10-20 14:16:12 +01:00
{ plank : 3 , leather : 2 , f " { key } _bar " : 1 , * * extra ( " sword " , 1 + tier ) } ,
2026-10-19 23:38:34 +01:00
{ 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 ] ,
2026-10-20 14:16:12 +01:00
{ plank : 2 , herb : 3 , * * extra ( " staff " , 2 + tier ) } , { f " { WOODS [ tier ] } _staff " : 1 } ,
2026-10-19 23:38:34 +01:00
group = " Staves " ) )
2026-10-21 20:56:39 +01:00
actions . append ( _action ( f " lw_ { WOODS [ tier ] } _bow " , " leatherworking " , BOWS [ tier ] , TIER_LEVELS [ tier ] + 4 ,
2026-10-19 23:38:34 +01:00
40 + 2 * tier , [ 12 , 26 , 42 , 60 , 80 , 105 ] [ tier ] ,
2026-10-20 14:16:12 +01:00
{ plank : 2 , leather : 2 , * * extra ( " bow " , 2 + tier ) } , { f " { WOODS [ tier ] } _bow " : 1 } ,
2026-10-19 23:38:34 +01:00
group = " Bows " ) )
return actions
2026-10-20 21:00:31 +01:00
def _storage_actions ( ) - > list [ dict ] :
""" Crafting stations and strongboxes (Construction), and bags (Tailoring). """
actions = [ ]
for tier , wood in enumerate ( WOODS ) :
plank , ( hardware , * _rest , made ) = PLANKS [ tier ] [ 0 ] , HARDWARE [ tier ]
for skill , ( suffix , _noun , _glyph , kind ) in STATIONS . items ( ) :
inputs = { plank : 3 , hardware : made , station_material ( kind , tier ) : 2 }
actions . append ( _action ( f " make_ { wood } _ { suffix } " , " construction " , ITEMS [ f " { wood } _ { suffix } " ] [ " name " ] ,
TIER_LEVELS [ tier ] + 4 , 40 + 3 * tier , [ 14 , 30 , 48 , 68 , 90 , 115 ] [ tier ] , inputs ,
{ f " { wood } _ { suffix } " : 1 } , group = " Stations " ) )
inputs = { plank : 4 , hardware : 2 * made , LEATHERS [ tier ] [ 1 ] [ 0 ] : 2 }
actions . append ( _action ( f " make_ { wood } _strongbox " , " construction " , ITEMS [ f " { wood } _strongbox " ] [ " name " ] ,
TIER_LEVELS [ tier ] + 8 , 45 + 3 * tier , [ 18 , 38 , 60 , 85 , 112 , 142 ] [ tier ] , inputs ,
{ f " { wood } _strongbox " : 1 } , group = " Storage " ) )
for tier , ( _fibre , _level , _time , _xp , cloth , prefix , label ) in enumerate ( CLOTHS ) :
actions . append ( _action ( f " sew_ { prefix } _bag " , " tailoring " , f " { label } bag " , TIER_LEVELS [ tier ] + 8 , 45 + 3 * tier ,
[ 18 , 38 , 60 , 85 , 112 , 142 ] [ tier ] , { cloth [ 0 ] : 4 , LEATHERS [ tier ] [ 1 ] [ 0 ] : 1 } ,
{ f " { prefix } _bag " : 1 } , group = label ) )
for action in actions :
made_item = ITEMS [ next ( iter ( action [ " outputs " ] ) ) ]
made_item [ " value " ] = round ( sum ( ITEMS [ item ] [ " value " ] * qty for item , qty in action [ " inputs " ] . items ( ) ) * 1.3 )
return actions
2026-10-20 19:59:45 +01:00
def _jewel_actions ( ) - > list [ dict ] :
""" Silver and gold, prospecting ore for gems, and every tier ' s jewellery. """
actions = [
_action ( " mine_silver " , " mining " , " Mine silver " , 10 , 35 , 12 , outputs = { " silver_ore " : 1 } ) ,
_action ( " mine_gold " , " mining " , " Mine gold " , 40 , 55 , 36 , outputs = { " gold_ore " : 1 } ) ,
]
for tier , ( source , ( gem , * _rest ) ) in enumerate ( zip ( PROSPECT , GEMS ) ) :
actions . append ( _action ( f " prospect_ { gem } " , " jewelcrafting " , f " Prospect { ITEMS [ source ] [ ' name ' ] . lower ( ) } " ,
TIER_LEVELS [ tier ] , 20 + 3 * tier , [ 8 , 18 , 28 , 40 , 54 , 70 ] [ tier ] ,
{ source : PROSPECT_ORE } , finds = [ ( gem , 1 , PROSPECT_ODDS ) ] , group = " Prospecting " ) )
actions + = [
_action ( " jc_silver_bar " , " jewelcrafting " , " Silver bar " , 10 , 25 , 12 , { " silver_ore " : 1 , " logs " : 1 } ,
{ " silver_bar " : 1 } , group = " Bars " ) ,
_action ( " jc_gold_bar " , " jewelcrafting " , " Gold bar " , 40 , 30 , 32 , { " gold_ore " : 1 , " coal " : 1 } ,
{ " gold_bar " : 1 } , group = " Bars " ) ,
]
for tier , ( gem , gem_name , * _rest ) in enumerate ( GEMS ) :
2026-10-20 22:15:57 +01:00
for key , slot , noun , _stat , _bonus , bands , gems , shards , offset , _glyph in JEWELLERY :
2026-10-20 19:59:45 +01:00
inputs = { BANDS [ tier ] : bands , gem : gems , * * ( { reagent ( " jewel " , tier ) : shards } if tier else { } ) }
actions . append ( _action ( f " jc_ { gem } _ { key } " , " jewelcrafting " , f " { gem_name } { noun } " , TIER_LEVELS [ tier ] + offset ,
35 + 5 * ( bands + gems ) + 2 * tier ,
round ( [ 15 , 32 , 52 , 75 , 100 , 130 ] [ tier ] * ( bands + gems ) / 2 ) ,
2026-10-20 22:15:57 +01:00
inputs , { f " { gem } _ { key } " : 1 } , group = JEWEL_GROUPS [ slot ] ) )
2026-10-20 19:59:45 +01:00
return actions
2026-10-19 23:32:07 +01:00
def _area_fights ( ) - > list [ dict ] :
fights = [ ]
2026-10-20 14:16:12 +01:00
for tier , ( area , level , _prefix , races ) in enumerate ( AREAS , start = 1 ) :
for race , trophy_name , _line , offset , roster in races :
fights . append ( _encounter ( f " fight_ { _slug ( race ) } " , race , area , level + offset , roster , gold = level + offset ,
drops = [ ( AREA_BONES [ tier - 1 ] , 1 , 1.0 ) , ( _slug ( trophy_name ) , 1 , TROPHY_ODDS ) ,
* _area_drops ( tier ) ] ) )
2026-10-19 23:32:07 +01:00
return fights
2026-10-20 14:16:12 +01:00
def _boss_drops ( tier : int , reagents : float = 0.3 ) - > list [ tuple ] :
return [ * [ ( trophy ( line , tier ) , 1 , BOSS_TROPHY_ODDS ) for line in STYLES ] , * _area_drops ( tier , reagents ) ]
2026-10-19 23:32:07 +01:00
COMBAT_ACTIONS = [
# The Frontier: the city's own troubles.
2026-10-20 14:16:12 +01:00
_encounter ( " fight_scouts " , " Bandit scouts " , " Frontier " , 10 , BANDITS , gold = 10 ,
drops = [ ( " copper_bar " , 1 , 0.3 ) , ( " arrows " , 5 , 0.4 ) , ( " bones " , 1 , 1.0 ) ] ) ,
_encounter ( " sabotage " , " Camp guards " , " Frontier " , 12 , BANDITS , label = " Sabotage the bandit camp " , special = " sabotage " ) ,
_boss ( " fight_stronghold " , " Bandit warlord " , " Frontier " , 40 , " boss " , ( " slash " , " magic " ) , ( " blunt " , " magic " ) ,
( " slash " , " pierce " ) , label = " Bandit stronghold " , gold = 60 ,
drops = [ ( " steel_bar " , 1 , 0.4 ) , ( " coal " , 3 , 0.5 ) , ( " iron_shield " , 1 , 0.04 ) , ( " big_bones " , 1 , 1.0 ) ] ) ,
2026-10-19 23:32:07 +01:00
# Greenwood: where everyone starts.
2026-10-20 14:16:12 +01:00
_encounter ( " fight_wolves " , " Wolves " , " Greenwood " , 1 , WOLVES , gold = 2 , label = " Wolves in the woods " ,
drops = [ ( " raw_meat " , 1 , 0.6 ) , ( " hide " , 1 , 0.5 ) , ( " bones " , 1 , 1.0 ) ] ) ,
_encounter ( " fight_goblins " , " Cave goblins " , " Greenwood " , 5 , GOBLINS , gold = 4 ,
drops = [ ( " bones " , 1 , 1.0 ) , ( " copper_ore " , 2 , 0.4 ) , ( " feathers " , 5 , 0.4 ) ] ) ,
2026-10-19 23:32:07 +01:00
* _area_fights ( ) ,
2026-10-24 09:49:21 +01:00
# Area bosses open at the area's level + 5, halfway through it. Bosses and dragons are group fights.
2026-10-21 07:52:01 +01:00
_boss ( " fight_ruins " , " Watchtower ogre " , " Old Watchtower " , 26 , " boss " , ( " blunt " , " magic " ) , ( " slash " , " magic " ) ,
2026-10-22 14:08:41 +01:00
( " blunt " , " pierce " ) , gold = 25 , req = 20 ,
2026-10-20 14:16:12 +01:00
drops = [ ( " iron_bar " , 2 , 0.5 ) , ( " iron_sword " , 1 , 0.05 ) , ( " big_bones " , 1 , 1.0 ) , * _boss_drops ( 1 ) ] ) ,
2026-10-21 07:52:01 +01:00
_boss ( " fight_barrow_king " , " Barrow king " , " Sunken Barrows " , 41 , " boss " , ( " slash " , " magic " ) , ( " blunt " , " magic " ) ,
2026-10-22 14:08:41 +01:00
( " pierce " , " slash " ) , gold = 40 , req = 35 ,
2026-10-20 14:16:12 +01:00
drops = [ ( " steel_bar " , 2 , 0.5 ) , ( " coal " , 4 , 0.5 ) , ( " big_bones " , 2 , 1.0 ) , * _boss_drops ( 2 ) ] ) ,
# Dragons: the prize hunt, dropping their area's reagents far more often than anything else there.
2026-10-21 07:52:01 +01:00
_boss ( " fight_green_dragon " , " Green dragon " , " Iron Hills " , 56 , " dragon " , ( " slash " , " magic " ) , ( " pierce " , " magic " ) ,
2026-10-22 14:08:41 +01:00
( " slash " , " blunt " ) , is_boss = False , gold = 80 , req = 50 ,
2026-10-20 14:16:12 +01:00
drops = [ ( " mithril_ore " , 2 , 0.3 ) , ( " dragon_bones " , 1 , 1.0 ) , * _boss_drops ( 3 , 0.6 ) ] ) ,
2026-10-21 07:52:01 +01:00
_boss ( " fight_blue_dragon " , " Blue dragon " , " Frostpeak " , 71 , " dragon " , ( " slash " , " magic " ) , ( " blunt " , " pierce " ) ,
2026-10-22 14:08:41 +01:00
( " magic " , " slash " ) , is_boss = False , gold = 110 , req = 65 ,
2026-10-20 14:16:12 +01:00
drops = [ ( " adamantite_ore " , 2 , 0.3 ) , ( " dragon_bones " , 1 , 1.0 ) , * _boss_drops ( 4 , 0.6 ) ] ) ,
2026-10-21 07:52:01 +01:00
_boss ( " fight_red_dragon " , " Red dragon " , " Dragon ' s Spine " , 85 , " dragon " , ( " slash " , " magic " ) , ( " slash " , " magic " ) ,
2026-10-22 14:08:41 +01:00
( " pierce " , " blunt " ) , is_boss = False , gold = 150 , req = 80 ,
2026-10-20 14:16:12 +01:00
drops = [ ( " runite_ore " , 1 , 0.25 ) , ( " dragon_bones " , 1 , 1.0 ) , * _boss_drops ( 5 , 0.6 ) ] ) ,
2026-10-21 07:52:01 +01:00
_boss ( " fight_black_dragon " , " Black dragon " , " Dragon ' s Spine " , 95 , " dragon " , ( " slash " , " magic " ) , ( " blunt " , " magic " ) ,
2026-10-22 14:08:41 +01:00
( " slash " , " pierce " ) , gold = 220 , req = 90 ,
2026-10-20 14:16:12 +01:00
drops = [ ( " rune_bar " , 1 , 0.1 ) , ( " dragon_bones " , 2 , 1.0 ) , * _boss_drops ( 5 , 0.8 ) ] ) ,
2026-10-19 23:32:07 +01:00
]
2026-10-24 12:05:23 +01:00
# --- dungeons ----------------------------------------------------------------
# A run of pulls for a group of two to four, in order, ending in the area's boss. Built for four:
# a smaller group meets lighter packs. Trash a group can clear on auto; the bosses it can't. They
# cast what has to be interrupted, hit what has to be braced for, call in help, and lose patience.
DUNGEON_SIZE = 4 # the most in a run
DUNGEON_WIPES = 3 # on one pull, and the run is over
DUNGEON_AWAY = 90 # real seconds without looking before a run waits for you
TRASH = ( 1.5 , 1.5 ) # a pull's HP (and XP) and hits against the open world's
MINI_BOSS = 5.0 # a mini-boss's HP (and XP, and loot odds) against its race's elite
FINAL_BOSS = ( 2.5 , 1.3 ) # the area boss's (and its adds') HP and hits against the open-world one
def dungeon_scale ( size : int ) - > tuple [ float , float ] :
""" HP and hits of a pull for a group this size. """
return 0.25 * size , 0.4 + 0.15 * size
def _dungeon_foe ( action_id : str , variant : str , hp : float = 1.0 , hits : float = 1.0 , loot : float | None = None ,
2026-10-24 13:37:34 +01:00
level : int | None = None , archetype : str | None = None , name : str | None = None , big : bool = False ,
2026-10-24 12:05:23 +01:00
* * extra ) - > dict :
""" One of a race ' s variants, as a dungeon enemy: `hp` times as tough (and worth as much more XP),
2026-10-24 13:37:34 +01:00
hitting ` hits ` times as hard . It drops what its race does , ` loot ` ( else ` hp ` ) times as often . It can
stand at another ` level ` , as another ` archetype ` ( ` big ` for a boss ' s size), under another `name`. " " "
2026-10-24 12:05:23 +01:00
action = next ( a for a in COMBAT_ACTIONS if a [ " id " ] == action_id )
foe = next ( v for v in action [ " enemy " ] [ " variants " ] if v [ " name " ] == variant )
2026-10-24 13:37:34 +01:00
if level or archetype or big :
foe = monster ( name or foe [ " name " ] , archetype or foe [ " archetype " ] , level or foe [ " level " ] , foe [ " attack " ] ,
foe [ " vulnerable " ] , foe [ " resists " ] , boss = big )
foe = { * * foe , " name " : name or foe [ " name " ] }
2026-10-24 12:05:23 +01:00
return { * * { key : foe [ key ] for key in ( " name " , " archetype " , " level " , " interval " , " accuracy " , " evasion " ,
" attack " , " vulnerable " , " resists " ) } ,
" boss " : extra . pop ( " boss " , False ) , " hp " : round ( foe [ " hp " ] * hp ) , " max_hit " : max ( 1 , round ( foe [ " max_hit " ] * hits ) ) ,
" xp " : foe [ " xp " ] * hp , " share " : hp if loot is None else loot , " loot " : action_id , * * extra }
DUNGEONS = {
" dungeon_watchtower " : {
" name " : " Watchtower Depths " , " area " : " Old Watchtower " , " tier " : 1 , " level " : 20 , " bonus_gold " : 100 ,
" pulls " : [
( " Gatehouse " , [ _dungeon_foe ( " fight_clay_golems " , " Clay brute " , * TRASH , 1 ) , _dungeon_foe ( " fight_clay_golems " , " Mud shaper " , * TRASH , 1 ) ,
_dungeon_foe ( " fight_clay_golems " , " Clay brute " , * TRASH , 1 ) ] ) ,
( " Broken stair " , [ _dungeon_foe ( " fight_harpies " , " Harpy slasher " , * TRASH , 1 ) , _dungeon_foe ( " fight_harpies " , " Harpy slasher " , * TRASH , 1 ) ,
_dungeon_foe ( " fight_harpies " , " Harpy windcaster " , * TRASH , 1 ) ] ) ,
( " Clay colossus " , [ _dungeon_foe ( " fight_clay_golems " , " Clay colossus " , MINI_BOSS , 3.0 , boss = True ,
abilities = [ " stone_curse " , " shatter " ] , enrage_at = 150 ) ] ) ,
( " Barracks " , [ _dungeon_foe ( " fight_restless_dead " , " Skeleton warrior " , * TRASH , 1 ) ,
_dungeon_foe ( " fight_restless_dead " , " Skeleton warrior " , * TRASH , 1 ) ,
_dungeon_foe ( " fight_restless_dead " , " Wisp " , * TRASH , 1 ) , _dungeon_foe ( " fight_restless_dead " , " Necromancer " , * TRASH , 1 ) ] ) ,
( " Troll den " , [ _dungeon_foe ( " fight_cave_trolls " , " Troll bruiser " , * TRASH , 1 ) , _dungeon_foe ( " fight_cave_trolls " , " Troll rockhurler " , * TRASH , 1 ) ,
_dungeon_foe ( " fight_cave_trolls " , " Troll hexer " , * TRASH , 1 ) ] ) ,
( " Harpy matriarch " , [ _dungeon_foe ( " fight_harpies " , " Harpy matriarch " , MINI_BOSS , 5.5 , boss = True ,
abilities = [ " shriek " , " lunge " ] , enrage_at = 150 ,
phases = [ { " at " : 0.5 , " say " : " To me, sisters! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_harpies " , " Harpy slasher " , * TRASH , 1 , dormant = True ) ,
_dungeon_foe ( " fight_harpies " , " Harpy slasher " , * TRASH , 1 , dormant = True ) ] ) ,
( " Crypt " , [ _dungeon_foe ( " fight_restless_dead " , " Necromancer " , * TRASH , 1 ) , _dungeon_foe ( " fight_restless_dead " , " Necromancer " , * TRASH , 1 ) ,
_dungeon_foe ( " fight_restless_dead " , " Skeleton warrior " , * TRASH , 1 ) ] ) ,
( " Watchtower ogre " , [ _dungeon_foe ( " fight_ruins " , " Watchtower ogre " , FINAL_BOSS [ 0 ] , FINAL_BOSS [ 1 ] , 2 ,
boss = True , abilities = [ " stone_curse " , " ground_pound " , " heavy_slam " ] ,
enrage_at = 210 , phases = [ { " at " : 0.5 , " say " : " Crush them! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_cave_trolls " , " Troll bruiser " , * FINAL_BOSS , 1 , dormant = True ) ,
_dungeon_foe ( " fight_cave_trolls " , " Troll bruiser " , * FINAL_BOSS , 1 , dormant = True ) ] ) ,
] ,
} ,
2026-10-24 13:37:34 +01:00
" dungeon_goblin_warren " : {
" name " : " Goblin Warren " , " area " : " Greenwood " , " tier " : 0 , " level " : 8 , " bonus_gold " : 30 ,
" pulls " : [
( " Wolf den " , [ _dungeon_foe ( " fight_wolves " , v , * TRASH , 1 , level = 6 ) for v in ( " Grey wolf " , " Timber wolf " , " Grey wolf " ) ] ) ,
( " Tunnel mouth " , [ _dungeon_foe ( " fight_goblins " , v , * TRASH , 1 , level = 6 )
for v in ( " Goblin brute " , " Goblin rogue " , " Goblin warlock " ) ] ) ,
( " Old One-Eye " , [ _dungeon_foe ( " fight_wolves " , " Wolf alpha " , MINI_BOSS , 3.0 , level = 7 , name = " Old One-Eye " , boss = True ,
abilities = [ " crushing_decree " , " lunge " ] , enrage_at = 150 ,
phases = [ { " at " : 0.5 , " say " : " Aaaooo! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_wolves " , " Grey wolf " , * TRASH , 1 , level = 6 , dormant = True ) ,
_dungeon_foe ( " fight_wolves " , " Grey wolf " , * TRASH , 1 , level = 6 , dormant = True ) ] ) ,
( " Fungus cave " , [ _dungeon_foe ( " fight_goblins " , v , * TRASH , 1 , level = 6 )
for v in ( " Goblin rogue " , " Goblin rogue " , " Goblin warlock " ) ] ) ,
( " Grizzik the Hexer " , [ _dungeon_foe ( " fight_goblins " , " Goblin warlock " , MINI_BOSS , 4.0 , level = 7 , archetype = " elite " ,
name = " Grizzik the Hexer " , boss = True , abilities = [ " hex_storm " , " hex_bolt " ] ,
enrage_at = 150 ) ] ) ,
( " Throne cave " , [ _dungeon_foe ( " fight_goblins " , v , * TRASH , 1 , level = 6 )
for v in ( " Goblin brute " , " Goblin brute " , " Goblin warlock " ) ] ) ,
( " Goblin king " , [ _dungeon_foe ( " fight_goblins " , " Goblin brute " , * FINAL_BOSS , 2 , level = 9 , archetype = " boss " ,
name = " Goblin king " , big = True , boss = True ,
abilities = [ " crushing_decree " , " royal_stomp " , " heavy_slam " ] , enrage_at = 210 ,
phases = [ { " at " : 0.5 , " say " : " Guards! Guards! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_goblins " , " Goblin brute " , * FINAL_BOSS , 1 , level = 6 , dormant = True ) ,
_dungeon_foe ( " fight_goblins " , " Goblin brute " , * FINAL_BOSS , 1 , level = 6 , dormant = True ) ] ) ,
] ,
} ,
" dungeon_bandit_stronghold " : {
" name " : " Bandit Stronghold " , " area " : " Frontier " , " tier " : 2 , " level " : 40 , " bonus_gold " : 300 ,
" pulls " : [
( " Palisade " , [ _dungeon_foe ( " fight_scouts " , v , * TRASH , 1 , level = 35 )
for v in ( " Bandit brute " , " Bandit archer " , " Bandit archer " ) ] ) ,
( " Gate " , [ _dungeon_foe ( " fight_scouts " , v , * TRASH , 1 , level = 35 ) for v in ( " Bandit brute " , " Bandit brute " , " Hedge mage " ) ] ) ,
( " Bandit captain " , [ _dungeon_foe ( " fight_scouts " , " Bandit brute " , MINI_BOSS , 3.0 , level = 37 , archetype = " elite " ,
name = " Bandit captain " , boss = True , abilities = [ " cheap_shot " , " heavy_slam " ] ,
enrage_at = 150 ) ] ) ,
( " Barracks " , [ _dungeon_foe ( " fight_scouts " , v , * TRASH , 1 , level = 35 )
for v in ( " Bandit brute " , " Bandit archer " , " Bandit archer " , " Hedge mage " ) ] ) ,
( " Hedge witch " , [ _dungeon_foe ( " fight_scouts " , " Hedge mage " , MINI_BOSS , 5.5 , level = 37 , archetype = " elite " ,
name = " Hedge witch " , boss = True , abilities = [ " witchfire " , " hex_bolt " ] , enrage_at = 150 ,
phases = [ { " at " : 0.5 , " say " : " Cut them down! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_scouts " , " Bandit archer " , * TRASH , 1 , level = 35 , dormant = True ) ,
_dungeon_foe ( " fight_scouts " , " Bandit archer " , * TRASH , 1 , level = 35 , dormant = True ) ] ) ,
( " Armoury " , [ _dungeon_foe ( " fight_scouts " , v , * TRASH , 1 , level = 35 ) for v in ( " Bandit brute " , " Hedge mage " , " Hedge mage " ) ] ) ,
( " Bandit warlord " , [ _dungeon_foe ( " fight_stronghold " , " Bandit warlord " , * FINAL_BOSS , 2 , boss = True ,
abilities = [ " execute " , " whirlwind " , " heavy_slam " ] , enrage_at = 210 ,
phases = [ { " at " : 0.5 , " say " : " To arms! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_scouts " , " Bandit brute " , * FINAL_BOSS , 1 , level = 35 , dormant = True ) ,
_dungeon_foe ( " fight_scouts " , " Bandit brute " , * FINAL_BOSS , 1 , level = 35 , dormant = True ) ] ) ,
] ,
} ,
" dungeon_barrow_crypts " : {
" name " : " Barrow Crypts " , " area " : " Sunken Barrows " , " tier " : 2 , " level " : 35 , " bonus_gold " : 250 ,
" pulls " : [
( " Barrow mound " , [ _dungeon_foe ( " fight_stone_guardians " , v , * TRASH , 1 )
for v in ( " Stone guardian " , " Barrow sentry " , " Barrow sentry " ) ] ) ,
( " Flooded hall " , [ _dungeon_foe ( " fight_bog_wraiths " , v , * TRASH , 1 )
for v in ( " Drowned knight " , " Bog wraith " , " Marsh wisp " ) ] ) ,
( " Runebound guardian " , [ _dungeon_foe ( " fight_stone_guardians " , " Runebound guardian " , MINI_BOSS , 3.0 , boss = True ,
abilities = [ " rune_blast " , " shatter " ] , enrage_at = 150 ) ] ) ,
( " Lizard warren " , [ _dungeon_foe ( " fight_lizardfolk " , v , * TRASH , 1 )
for v in ( " Lizard warrior " , " Lizard spearman " , " Lizard spearman " , " Lizard shaman " ) ] ) ,
( " Brood mother " , [ _dungeon_foe ( " fight_giant_spiders " , " Brood mother " , MINI_BOSS , 5.5 , boss = True ,
abilities = [ " venom_spray " , " lunge " ] , enrage_at = 150 ,
phases = [ { " at " : 0.5 , " say " : " The eggs are hatching! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_giant_spiders " , " Spider hunter " , * TRASH , 1 , dormant = True ) ,
_dungeon_foe ( " fight_giant_spiders " , " Spider hunter " , * TRASH , 1 , dormant = True ) ] ) ,
( " Bone gallery " , [ _dungeon_foe ( " fight_bog_wraiths " , v , * TRASH , 1 ) for v in ( " Bog wraith " , " Bog wraith " , " Drowned knight " ) ] ) ,
( " Barrow king " , [ _dungeon_foe ( " fight_barrow_king " , " Barrow king " , * FINAL_BOSS , 2 , boss = True ,
abilities = [ " grave_chill " , " soul_rend " , " heavy_slam " ] , enrage_at = 210 ,
phases = [ { " at " : 0.5 , " say " : " Rise, my knights! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_bog_wraiths " , " Drowned knight " , * FINAL_BOSS , 1 , dormant = True ) ,
_dungeon_foe ( " fight_bog_wraiths " , " Drowned knight " , * FINAL_BOSS , 1 , dormant = True ) ] ) ,
] ,
} ,
" dungeon_deep_forge " : {
" name " : " The Deep Forge " , " area " : " Iron Hills " , " tier " : 3 , " level " : 50 , " bonus_gold " : 400 ,
" pulls " : [
( " Mine shaft " , [ _dungeon_foe ( " fight_mine_ghosts " , v , * TRASH , 1 ) for v in ( " Lost miner " , " Lantern ghost " , " Shaft howler " ) ] ) ,
( " Orc camp " , [ _dungeon_foe ( " fight_orcs " , v , * TRASH , 1 ) for v in ( " Orc berserker " , " Orc archer " , " Orc shaman " ) ] ) ,
( " Iron colossus " , [ _dungeon_foe ( " fight_iron_golems " , " Iron colossus " , MINI_BOSS , 3.0 , boss = True ,
abilities = [ " forge_blast " , " shatter " ] , enrage_at = 150 ) ] ) ,
( " Foundry " , [ _dungeon_foe ( " fight_iron_golems " , v , * TRASH , 1 ) for v in ( " Iron golem " , " Forge golem " , " Forge golem " ) ] ) ,
( " Orc warchief " , [ _dungeon_foe ( " fight_orcs " , " Orc berserker " , MINI_BOSS , 5.5 , archetype = " elite " , name = " Orc warchief " ,
boss = True , abilities = [ " war_cry " , " lunge " ] , enrage_at = 150 ,
phases = [ { " at " : 0.5 , " say " : " Blood and iron! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_orcs " , " Orc berserker " , * TRASH , 1 , dormant = True ) ,
_dungeon_foe ( " fight_orcs " , " Orc berserker " , * TRASH , 1 , dormant = True ) ] ) ,
( " Wyvern roost " , [ _dungeon_foe ( " fight_wyverns " , v , * TRASH , 1 ) for v in ( " Wyvern " , " Wyvern hatchling " , " Venomspitter " ) ] ) ,
( " Green dragon " , [ _dungeon_foe ( " fight_green_dragon " , " Green dragon " , * FINAL_BOSS , 2 , boss = True ,
abilities = [ " acid_spit " , " acid_breath " , " tail_swipe " ] , enrage_at = 210 ,
phases = [ { " at " : 0.5 , " say " : " The brood answers! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_wyverns " , " Wyvern " , * FINAL_BOSS , 1 , dormant = True ) ,
_dungeon_foe ( " fight_wyverns " , " Wyvern " , * FINAL_BOSS , 1 , dormant = True ) ] ) ,
] ,
} ,
" dungeon_frozen_hold " : {
" name " : " Frozen Hold " , " area " : " Frostpeak " , " tier " : 4 , " level " : 65 , " bonus_gold " : 550 ,
" pulls " : [
( " Ice gate " , [ _dungeon_foe ( " fight_ice_elementals " , v , * TRASH , 1 ) for v in ( " Ice brute " , " Frost wisp " , " Frost wisp " ) ] ) ,
( " Troll cave " , [ _dungeon_foe ( " fight_frost_trolls " , v , * TRASH , 1 )
for v in ( " Frost troll " , " Troll icehurler " , " Troll rimecaller " ) ] ) ,
( " Glacier colossus " , [ _dungeon_foe ( " fight_ice_elementals " , " Glacier colossus " , MINI_BOSS , 3.0 , boss = True ,
abilities = [ " deep_freeze " , " shatter " ] , enrage_at = 150 ) ] ) ,
( " Griffin eyrie " , [ _dungeon_foe ( " fight_griffins " , v , * TRASH , 1 ) for v in ( " Griffin " , " Griffin rider " , " Storm griffin " ) ] ) ,
( " Banshee queen " , [ _dungeon_foe ( " fight_banshees " , " Banshee " , MINI_BOSS , 5.5 , archetype = " elite " , name = " Banshee queen " ,
boss = True , abilities = [ " wail " , " lunge " ] , enrage_at = 150 ,
phases = [ { " at " : 0.5 , " say " : " Sing with me! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_banshees " , " Keening shade " , * TRASH , 1 , dormant = True ) ,
_dungeon_foe ( " fight_banshees " , " Keening shade " , * TRASH , 1 , dormant = True ) ] ) ,
( " Hall of echoes " , [ _dungeon_foe ( " fight_banshees " , v , * TRASH , 1 ) for v in ( " Wailing knight " , " Banshee " , " Banshee " ) ] ) ,
( " Blue dragon " , [ _dungeon_foe ( " fight_blue_dragon " , " Blue dragon " , * FINAL_BOSS , 2 , boss = True ,
abilities = [ " frost_lance " , " frost_breath " , " tail_swipe " ] , enrage_at = 210 ,
phases = [ { " at " : 0.5 , " say " : " Freeze! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_ice_elementals " , " Ice brute " , * FINAL_BOSS , 1 , dormant = True ) ,
_dungeon_foe ( " fight_ice_elementals " , " Ice brute " , * FINAL_BOSS , 1 , dormant = True ) ] ) ,
] ,
} ,
" dungeon_dragons_lair " : {
" name " : " Dragon ' s Lair " , " area " : " Dragon ' s Spine " , " tier " : 5 , " level " : 80 , " bonus_gold " : 750 ,
" pulls " : [
( " Lava tubes " , [ _dungeon_foe ( " fight_magma_constructs " , v , * TRASH , 1 )
for v in ( " Magma brute " , " Cinder golem " , " Fire binder " ) ] ) ,
( " Shadow pass " , [ _dungeon_foe ( " fight_shades " , v , * TRASH , 1 ) for v in ( " Shade " , " Shadow blade " , " Shadow blade " ) ] ) ,
( " Void knight " , [ _dungeon_foe ( " fight_shades " , " Void knight " , MINI_BOSS , 3.0 , boss = True ,
abilities = [ " void_bolt " , " shatter " ] , enrage_at = 150 ) ] ) ,
( " Roc nests " , [ _dungeon_foe ( " fight_rocs " , v , * TRASH , 1 ) for v in ( " Roc " , " Roc hatchling " , " Thunder roc " ) ] ) ,
( " Flame high priest " , [ _dungeon_foe ( " fight_dragonkin " , " Flame priest " , MINI_BOSS , 5.5 , archetype = " elite " ,
name = " Flame high priest " , boss = True , abilities = [ " flame_wave " , " hex_bolt " ] ,
enrage_at = 150 , phases = [ { " at " : 0.5 , " say " : " Burn! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_dragonkin " , " Flame priest " , * TRASH , 1 , dormant = True ) ,
_dungeon_foe ( " fight_dragonkin " , " Flame priest " , * TRASH , 1 , dormant = True ) ] ) ,
( " Drakeguard hall " , [ _dungeon_foe ( " fight_dragonkin " , v , * TRASH , 1 )
for v in ( " Drakeguard " , " Drake lancer " , " Flame priest " ) ] ) ,
( " Red dragon " , [ _dungeon_foe ( " fight_red_dragon " , " Red dragon " , * FINAL_BOSS , 2 , boss = True ,
abilities = [ " inferno " , " fire_breath " , " tail_swipe " ] , enrage_at = 210 ,
phases = [ { " at " : 0.5 , " say " : " Guards! " , " wake " : [ " e1 " , " e2 " ] } ] ) ,
_dungeon_foe ( " fight_dragonkin " , " Drakeguard " , * FINAL_BOSS , 1 , dormant = True ) ,
_dungeon_foe ( " fight_dragonkin " , " Drakeguard " , * FINAL_BOSS , 1 , dormant = True ) ] ) ,
] ,
} ,
}
# Each boss pull's last touch, HP and hits, so that a group on auto loses it and one that plays
# wins it (tools/balance.py dungeon, and dungeon perfect).
DUNGEON_TUNING = {
( " dungeon_goblin_warren " , " Old One-Eye " ) : ( 0.8 , 0.8 ) , ( " dungeon_goblin_warren " , " Grizzik the Hexer " ) : ( 1.5 , 0.5 ) ,
( " dungeon_goblin_warren " , " Goblin king " ) : ( 0.8 , 1.2 ) ,
( " dungeon_bandit_stronghold " , " Bandit captain " ) : ( 1.0 , 1.2 ) , ( " dungeon_bandit_stronghold " , " Hedge witch " ) : ( 1.25 , 0.65 ) ,
( " dungeon_bandit_stronghold " , " Bandit warlord " ) : ( 1.5 , 1.4 ) ,
( " dungeon_barrow_crypts " , " Runebound guardian " ) : ( 0.8 , 1.2 ) , ( " dungeon_barrow_crypts " , " Brood mother " ) : ( 1.0 , 1.2 ) ,
( " dungeon_barrow_crypts " , " Barrow king " ) : ( 1.5 , 1.0 ) ,
( " dungeon_deep_forge " , " Orc warchief " ) : ( 1.0 , 1.3 ) , ( " dungeon_deep_forge " , " Green dragon " ) : ( 1.25 , 1.2 ) ,
( " dungeon_frozen_hold " , " Glacier colossus " ) : ( 1.0 , 1.2 ) , ( " dungeon_frozen_hold " , " Banshee queen " ) : ( 1.25 , 1.0 ) ,
( " dungeon_frozen_hold " , " Blue dragon " ) : ( 1.5 , 1.2 ) ,
( " dungeon_dragons_lair " , " Void knight " ) : ( 1.5 , 1.0 ) , ( " dungeon_dragons_lair " , " Flame high priest " ) : ( 0.8 , 1.2 ) ,
( " dungeon_dragons_lair " , " Red dragon " ) : ( 1.5 , 1.4 ) ,
2026-10-24 12:05:23 +01:00
}
2026-10-24 13:37:34 +01:00
def _tune_dungeons ( ) - > None :
for key , spec in DUNGEONS . items ( ) :
for index , ( name , pack ) in enumerate ( spec [ " pulls " ] ) :
hp , hits = DUNGEON_TUNING . get ( ( key , name ) , ( 1 , 1 ) )
spec [ " pulls " ] [ index ] = ( name , [ { * * foe , " hp " : round ( foe [ " hp " ] * hp ) , " max_hit " : max ( 1 , round ( foe [ " max_hit " ] * hits ) ) }
for foe in pack ] )
_tune_dungeons ( )
2026-10-24 12:05:23 +01:00
def _dungeon_actions ( ) - > list [ dict ] :
actions = [ ]
for key , spec in DUNGEONS . items ( ) :
bosses = [ foe for _name , pack in spec [ " pulls " ] for foe in pack if foe [ " boss " ] ]
variants = [ { * * foe , " weight " : 1 } for foe in bosses ]
actions . append ( _action ( key , " combat " , spec [ " name " ] , spec [ " level " ] , RESPAWN ,
round ( sum ( foe [ " xp " ] for _name , pack in spec [ " pulls " ] for foe in pack ) ) ,
enemy = { " name " : spec [ " name " ] , " level " : spec [ " level " ] , " boss " : True , " variants " : variants } ,
group = spec [ " area " ] , dungeon = key , together = GROUP_SIZE , personal_loot = True ,
2026-10-24 13:37:34 +01:00
gold = [ spec [ " bonus_gold " ] , spec [ " bonus_gold " ] ] ,
drops = [ list ( d ) for d in _boss_drops ( spec [ " tier " ] ) ] if spec [ " tier " ] else bosses [ - 1 ] . get ( " drops " , [ ] ) ,
2026-10-24 12:05:23 +01:00
pulls = [ name for name , _pack in spec [ " pulls " ] ] ) )
return actions
COMBAT_ACTIONS + = _dungeon_actions ( )
2026-10-18 13:48:56 +01:00
ACTIONS = [
2026-10-19 23:32:07 +01:00
* COMBAT_ACTIONS ,
2026-10-18 13:48:56 +01:00
_action ( " mine_stone " , " mining " , " Quarry stone " , 1 , 20 , 5 , outputs = { " stone " : 1 } ) ,
2026-10-19 23:23:26 +01:00
_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 " )
2026-10-18 23:12:57 +01:00
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 ) ,
2026-10-21 12:16:22 +01:00
_fuelled ( { herb : 1 , second : 1 } , POTION_TIER [ potion ] ) , { potion : 1 } , group = f " Tier { ROMAN [ POTION_TIER [ potion ] ] } " ,
fire = " still " , fuel = POTION_TIER [ potion ] )
2026-10-18 23:12:57 +01:00
for potion , name , level , herb , second , skill , _effect in POTIONS ] ,
2026-10-24 09:49:21 +01:00
* [ _action ( f " brew_ { potion } " , " herblore " , name , level , 30 + level / / 3 , round ( 4 + level * 1.6 ) ,
_fuelled ( { herb : 1 , fish : 1 } , tier ) , { potion : 1 } , group = f " Tier { ROMAN [ tier ] } " , fire = " still " , fuel = tier )
for tier , ( potion , name , level , herb , fish , _heal ) in enumerate ( HEALTH_POTIONS ) ] ,
2026-10-18 13:48:56 +01:00
_action ( " hunt_birds " , " hunting " , " Snare birds " , 1 , 25 , 6 , outputs = { " feathers " : 3 } ) ,
2026-10-21 20:56:39 +01:00
_action ( " hunt_deer " , " hunting " , " Hunt deer " , 1 , 25 , 6 , outputs = { " raw_meat " : 2 , " hide " : 1 } ) ,
_action ( " hunt_boar " , " hunting " , " Hunt boar " , 15 , 35 , 15 , outputs = { " raw_meat " : 2 , " boar_hide " : 1 } ) ,
_action ( " hunt_bear " , " hunting " , " Hunt bears " , 30 , 45 , 28 , outputs = { " raw_meat " : 3 , " bear_pelt " : 1 } ) ,
_action ( " hunt_elk " , " hunting " , " Hunt elk " , 45 , 55 , 45 , outputs = { " raw_meat " : 3 , " elk_hide " : 1 } ) ,
_action ( " hunt_sabrecat " , " hunting " , " Track sabrecats " , 60 , 65 , 62 , outputs = { " raw_meat " : 3 , " sabrecat_pelt " : 1 } ) ,
_action ( " hunt_mammoth " , " hunting " , " Hunt mammoths " , 75 , 80 , 85 , outputs = { " raw_meat " : 5 , " mammoth_hide " : 1 } ) ,
2026-10-18 22:13:50 +01:00
* _tiered_actions ( ) ,
2026-10-18 19:27:56 +01:00
_action ( " smith_arrows " , " smithing " , " Arrows " , 5 , 30 , 10 ,
2026-10-21 09:28:23 +01:00
{ " copper_bar " : 1 , " logs " : 1 , " feathers " : 2 } , { " arrows " : 15 } , group = " Hardware " ) ,
2026-10-18 19:27:56 +01:00
_action ( " lw_studded " , " leatherworking " , " Studded jerkin " , 25 , 50 , 40 ,
2026-10-18 22:13:50 +01:00
{ " leather " : 4 , " iron_bar " : 1 } , { " studded_body " : 1 } , group = " Leather " ) ,
2026-10-19 09:24:45 +01:00
_action ( " labour " , " construction " , " Work on the building site " , 1 , 30 , 10 , special = " labour " ) ,
2026-10-19 22:45:32 +01:00
_action ( " twist_rope " , " construction " , " Twist rope " , 5 , 30 , 10 , { " flax " : 3 } , { " rope " : 1 } , group = " Supplies " ) ,
2026-10-19 23:23:26 +01:00
_action ( " grow_thatch " , " farming " , " Cut thatch reeds " , 3 , 30 , 7 , outputs = { " thatch " : 3 } , group = " Materials " ) ,
2026-10-20 14:16:12 +01:00
_action ( " grow_flax " , " farming " , " Grow flax " , 1 , 35 , 11 , outputs = { " flax " : 3 } , group = " Fibres " ) ,
2026-10-19 22:45:32 +01:00
_action ( " boil_glue " , " leatherworking " , " Boil hide glue " , 5 , 30 , 10 , { " hide " : 1 , " logs " : 1 } , { " hide_glue " : 2 } ,
2026-10-25 09:42:31 +00:00
group = " Tanning " , fire = " kitchen " , fuel = 0 ) ,
2026-10-19 09:24:45 +01:00
* [ _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 ) ] ,
2026-10-21 12:16:22 +01:00
_action ( " mason_bricks " , " construction " , " Bricks " , 1 , 25 , 6 , { " stone " : 2 , " logs " : 1 } , { " bricks " : 2 } , group = " Masonry " ,
fire = " kiln " , fuel = 0 ) ,
2026-10-19 23:17:23 +01:00
_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 " ) ,
2026-10-21 12:16:22 +01:00
_action ( " cook_bread " , " cooking " , " Bread " , 1 , 20 , 5 , { " wheat " : 2 , " logs " : 1 } , { " bread " : 1 } , group = " Meals " ,
fire = " kitchen " , fuel = 0 ) ,
_action ( " cook_meat " , " cooking " , " Cooked meat " , 5 , 20 , 7 , { " raw_meat " : 1 , " logs " : 1 } , { " cooked_meat " : 1 } , group = " Meals " ,
fire = " kitchen " , fuel = 0 ) ,
2026-10-18 13:48:56 +01:00
_action ( " cook_stew " , " cooking " , " Stew " , 15 , 35 , 16 ,
2026-10-21 12:16:22 +01:00
{ " raw_meat " : 1 , " vegetables " : 2 , " logs " : 1 } , { " stew " : 2 } , group = " Meals " , fire = " kitchen " , fuel = 0 ) ,
2026-10-19 23:23:26 +01:00
_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 " ) ,
2026-10-19 23:32:07 +01:00
_action ( " drill_militia " , " combat " , " Drill the militia " , 1 , 60 , 12 , special = " drill " , group = " Frontier " ) ,
2026-10-19 23:23:26 +01:00
_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 " ) ,
2026-10-18 23:09:17 +01:00
_action ( " offer_bones " , " prayer " , " Offer bones at the temple " , 1 , 12 , 7 , { " bones " : 1 } , special = " offer " ,
2026-10-19 23:23:26 +01:00
devotion = 2 , city = { " temple " : 1 } , group = " Offer at the temple " ) ,
2026-10-18 23:09:17 +01:00
_action ( " offer_big_bones " , " prayer " , " Offer big bones " , 15 , 12 , 25 , { " big_bones " : 1 } , special = " offer " ,
2026-10-19 23:23:26 +01:00
devotion = 6 , city = { " temple " : 1 } , group = " Offer at the temple " ) ,
2026-10-18 23:09:17 +01:00
_action ( " offer_dragon_bones " , " prayer " , " Offer dragon bones " , 40 , 14 , 110 , { " dragon_bones " : 1 } , special = " offer " ,
2026-10-19 23:23:26 +01:00
devotion = 20 , city = { " temple " : 2 } , group = " Offer at the temple " ) ,
2026-10-18 13:48:56 +01:00
_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 ) ,
2026-10-21 12:16:22 +01:00
# Tending: one log a go into one fire; better logs need more Firekeeping.
* [ _action ( f " tend_ { fire } _ { log } " , " firekeeping " , f " Tend the { spec [ ' name ' ] . lower ( ) } " , TIER_LEVELS [ tier ] , 30 , 0 ,
{ log : 1 } , special = " tend " , tend = fire , tier = tier , group = spec [ " name " ] )
for fire , spec in FIRES . items ( ) for tier , log in enumerate ( FIREWOOD ) ] ,
2026-10-18 13:48:56 +01:00
]
2026-10-24 13:48:16 +01:00
# --- dungeon loot --------------------------------------------------------------
# Dungeons don't hand out finished gear: crafters still make it. Every boss in a dungeon drops its
# relic, and now and then a pattern: a crafter who learns it can reforge one of the tier's weapons
# or body pieces into a masterwork with relics. The final boss also has one unique, worn in a
# jewellery slot, that changes an ability.
MASTERWORK = 1.3 # a masterwork's power or armour and ward against the piece it's reforged from
PATTERN_ODDS = ( 0.15 , 0.35 ) # each player's chance at one of the dungeon's patterns, from a mini-boss and the final boss
RELIC_ODDS = ( 0.5 , 1.0 ) # ...and at its relic (two from the final boss)
UNIQUE_ODDS = 0.08 # each player's chance at the final boss's unique
MASTERWORK_RELICS = 3
# Per dungeon: what its masterworks are called, its relic's name, and its unique.
DUNGEON_LOOT = {
" dungeon_goblin_warren " : ( " Kingsguard " , " Warren relic " , " one_eyes_fang " ) ,
" dungeon_watchtower " : ( " Ogre-forged " , " Watchtower relic " , " ogres_knuckle " ) ,
" dungeon_barrow_crypts " : ( " Barrow-blessed " , " Barrow relic " , " barrow_kings_pendant " ) ,
" dungeon_bandit_stronghold " : ( " Warlord ' s " , " Stronghold relic " , " warlords_whistle " ) ,
" dungeon_deep_forge " : ( " Deepforged " , " Deep Forge relic " , " forgeheart " ) ,
" dungeon_frozen_hold " : ( " Rimeforged " , " Frozen Hold relic " , " rimeglass_lens " ) ,
" dungeon_dragons_lair " : ( " Dragonforged " , " Dragon ' s Lair relic " , " wyrmscale_quiver " ) ,
}
# Uniques: (name, slot, glyph, tint, what it does, the ability changes).
UNIQUES = {
" one_eyes_fang " : ( " One-Eye ' s fang " , " amulet " , " fangs " , " #e0e0d0 " , " Shield Bash also taunts for 3s. " ,
{ " shield_bash " : { " extra " : [ { " t " : " taunt " , " dur " : 3 } ] } } ) ,
" ogres_knuckle " : ( " Ogre ' s knuckle " , " ring " , " fist " , " #d1a46a " , " Crushing Blow winds up in 1s and stuns for 1s. " ,
{ " crushing_blow " : { " cast " : 2 / 3 , " extra " : [ { " t " : " stun " , " dur " : 1 } ] } } ) ,
" barrow_kings_pendant " : ( " Barrow king ' s pendant " , " amulet " , " gem-pendant " , " #8fb8f0 " ,
" Mend also heals the next most hurt ally for half. " ,
{ " mend " : { " extra " : [ { " t " : " heal " , " c " : 1.0 , " second " : True } ] } } ) ,
" warlords_whistle " : ( " Warlord ' s whistle " , " trinket " , " whistle " , " #a08060 " ,
" Shield Bash, Concuss and Pinning Shot come back 30 % s ooner. " ,
{ " shield_bash " : { " cd " : 0.7 } , " concuss " : { " cd " : 0.7 } , " pinning_shot " : { " cd " : 0.7 } } ) ,
" forgeheart " : ( " Forgeheart " , " amulet " , " heart-tower " , " #ff8a3a " , " Barricade lasts 12s and comes back 25 % s ooner. " ,
{ " barricade " : { " dur " : 4 , " cd " : 0.75 } } ) ,
" rimeglass_lens " : ( " Rimeglass lens " , " trinket " , " crystal-ball " , " #8fd0e0 " ,
" Find the Seam and Mark Prey last twice as long; Ward also shields a second ally. " ,
{ " find_the_seam " : { " dur " : 8 } , " mark_prey " : { " dur " : 15 } ,
" ward " : { " extra " : [ { " t " : " shield " , " c " : 1.0 , " dur " : 20 , " second " : True } ] } } ) ,
" wyrmscale_quiver " : ( " Wyrmscale quiver " , " trinket " , " quiver " , " #e05a5a " ,
2026-10-24 14:21:08 +01:00
" Volley hits 40 % ha rder; Aimed Shot aims 25 % f aster. " ,
{ " volley " : { " c " : 1.4 } , " aimed_shot " : { " cast " : 0.75 } } ) ,
2026-10-24 13:48:16 +01:00
}
# The pieces every dungeon's patterns reforge, by what they're made of at each tier.
MASTERWORK_PIECES = [ " sword " , " warhammer " , " staff " , " bow " , " heavy " , " medium " , " light " ]
MASTERWORK_NOUNS = { " sword " : " sword " , " warhammer " : " warhammer " , " staff " : " staff " , " bow " : " bow " ,
" heavy " : " plate " , " medium " : " jerkin " , " light " : " robe " }
MASTERWORK_TINT = " #f0c040 "
def _tier_piece ( kind : str , tier : int ) - > str :
""" The tier ' s weapon of a style, or its body piece of an armour weight. """
level = [ " combat " , TIER_LEVELS [ tier ] ]
for item , spec in ITEMS . items ( ) :
if spec . get ( " req " ) != level :
continue
if kind in ( " heavy " , " medium " , " light " ) and spec . get ( " weight " ) == kind and spec . get ( " slot " ) == " body " :
return item
if spec . get ( " style " ) == { " warhammer " : " hammer " } . get ( kind , kind ) :
return item
raise KeyError ( ( kind , tier ) )
def _dungeon_loot ( ) - > None :
""" Relics, patterns, masterworks and uniques, as items; the masterwork recipes, as actions; and each
dungeon boss ' s share of them, as its extra drops. " " "
made = { output : action for action in ACTIONS for output in action [ " outputs " ] }
for key , ( prefix , relic_name , unique ) in DUNGEON_LOOT . items ( ) :
spec = DUNGEONS [ key ]
tier = spec [ " tier " ]
relic = _slug ( relic_name )
ITEMS [ relic ] = { " name " : relic_name , " icon " : " 🏺 " , " value " : 40 + 40 * tier , " relic " : key }
patterns = [ ]
for kind in MASTERWORK_PIECES :
base = _tier_piece ( kind , tier )
item , recipe = f " { _slug ( prefix ) } _ { kind } " , made [ base ]
pattern = f " pattern_ { item } "
name = f " { prefix } { MASTERWORK_NOUNS [ kind ] } "
stats = { stat : max ( ITEMS [ base ] [ stat ] + 1 , round ( ITEMS [ base ] [ stat ] * MASTERWORK ) )
for stat in ( " power " , " armour " , " ward " ) if ITEMS [ base ] . get ( stat ) }
ITEMS [ item ] = { * * { k : v for k , v in ITEMS [ base ] . items ( ) if k not in ( " glyph " , " tint " , " plural " ) } ,
* * stats , " name " : name , " value " : ITEMS [ base ] [ " value " ] * 3 + ITEMS [ relic ] [ " value " ] * MASTERWORK_RELICS ,
" req " : [ " combat " , spec [ " level " ] ] , " masterwork " : key , " base " : base }
ITEMS [ pattern ] = { " name " : f " Pattern: { name } " , " icon " : " 📜 " , " value " : 100 + 50 * tier , " pattern " : item ,
" skill " : recipe [ " skill " ] , " level " : recipe [ " level " ] + 8 }
reforge = _action ( f " reforge_ { item } " , recipe [ " skill " ] , name , recipe [ " level " ] + 8 , round ( recipe [ " time " ] * 1.5 ) ,
recipe [ " xp " ] * 3 , { base : 1 , relic : MASTERWORK_RELICS } , { item : 1 } , group = " Masterwork " ,
pattern = pattern )
ACTIONS . append ( reforge )
ACTION_BY_ID [ reforge [ " id " ] ] = reforge
patterns . append ( pattern )
name , slot , _glyph , _tint , text , mods = UNIQUES [ unique ]
ITEMS [ unique ] = { " name " : name , " icon " : " 💠 " , " value " : 500 + 250 * tier , " slot " : slot , " unique " : text , " mods " : mods ,
" req " : [ " combat " , spec [ " level " ] ] }
ACTION_BY_ID [ key ] [ " loot " ] = { " relic " : relic , " patterns " : patterns , " unique " : unique ,
" odds " : { " pattern " : PATTERN_ODDS , " unique " : UNIQUE_ODDS } }
bosses = [ foe for _name , pack in spec [ " pulls " ] for foe in pack if foe [ " boss " ] ]
for foe in bosses :
final = foe is bosses [ - 1 ]
odds = PATTERN_ODDS [ final ] / len ( patterns )
foe [ " extra_drops " ] = [ ( relic , 1 + final , RELIC_ODDS [ final ] ) , * [ ( p , 1 , odds ) for p in patterns ] ,
* ( [ ( unique , 1 , UNIQUE_ODDS ) ] if final else [ ] ) ]
2026-10-18 13:48:56 +01:00
ACTION_BY_ID = { action [ " id " ] : action for action in ACTIONS }
2026-10-20 19:59:45 +01:00
for _id , _gem in GEM_ROCKS . items ( ) :
ACTION_BY_ID [ _id ] [ " finds " ] = [ ( _gem , 1 , GEM_ODDS ) ]
2026-10-18 13:48:56 +01:00
BUILDINGS = {
" houses " : {
2026-10-18 19:20:52 +01:00
" name " : " Houses " , " icon " : " 🏠 " , " max " : 8 , " gold " : 200 ,
2026-10-19 23:17:23 +01:00
" 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 } ,
2026-10-20 08:01:57 +01:00
" effect " : " +20 homes per level " ,
2026-10-18 13:48:56 +01:00
} ,
" walls " : {
" name " : " Walls " , " icon " : " 🏰 " , " max " : 5 , " gold " : 300 ,
2026-10-19 22:45:32 +01:00
" base " : { " stone " : 1000 , " bricks " : 250 , " provisions " : 90 } ,
2026-10-19 23:17:23 +01:00
" 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 } ,
2026-10-20 08:01:57 +01:00
" effect " : " +20 defence per level, less plunder lost " ,
2026-10-18 13:48:56 +01:00
} ,
" barracks " : {
" name " : " Barracks " , " icon " : " ⛺ " , " max " : 5 , " gold " : 300 ,
2026-10-19 23:17:23 +01:00
" base " : { " logs " : 300 , " bricks " : 200 , " thatch " : 200 , " leather " : 80 , " copper_sword " : 25 , " provisions " : 80 , " copper_rivets " : 60 } ,
2026-10-19 23:38:34 +01:00
" 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 } ,
2026-10-20 08:01:57 +01:00
" effect " : " More defence per townsperson " ,
2026-10-18 13:48:56 +01:00
} ,
" smithy " : {
2026-10-18 22:13:50 +01:00
" name " : " Smithy " , " icon " : " ⚒️ " , " max " : 5 , " gold " : 250 ,
2026-10-19 22:45:32 +01:00
" 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 } ,
2026-10-20 08:01:57 +01:00
" effect " : " Smithing 8 % f aster and one more metal per level " ,
2026-10-18 13:48:56 +01:00
} ,
2026-10-18 23:09:17 +01:00
" temple " : {
" name " : " Temple " , " icon " : " ⛪ " , " max " : 3 , " gold " : 300 ,
2026-10-19 22:45:32 +01:00
" base " : { " stone " : 700 , " bricks " : 200 , " cut_stone " : 60 , " provisions " : 60 } ,
2026-10-19 23:17:23 +01:00
" advanced " : { " beam " : 15 , " rope " : 40 , " iron_nails " : 150 } ,
" late " : { " willow_logs " : 80 , " oak_plank " : 35 , " hide_glue " : 40 , " steel_hinges " : 20 } ,
2026-10-20 08:01:57 +01:00
" effect " : " Per level: +1 prayer point per bone, +25 % d evotion " ,
2026-10-18 23:09:17 +01:00
} ,
2026-10-19 09:34:18 +01:00
" granary " : {
" name " : " Granary " , " icon " : " 🌾 " , " max " : 3 , " gold " : 150 ,
2026-10-19 23:17:23 +01:00
" 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 } ,
2026-10-20 08:01:57 +01:00
" effect " : " Guards 25 % o f food from pests per level " ,
2026-10-19 09:34:18 +01:00
} ,
" lumber_shed " : {
" name " : " Lumber shed " , " icon " : " 🪵 " , " max " : 3 , " gold " : 120 ,
2026-10-19 23:17:23 +01:00
" base " : { " logs " : 200 , " stone " : 250 , " plank " : 50 , " thatch " : 180 , " provisions " : 40 , " copper_rivets " : 40 } ,
" advanced " : { " oak_plank " : 40 , " rope " : 40 , " iron_nails " : 100 } ,
2026-10-19 22:45:32 +01:00
" late " : { " willow_plank " : 25 , " hide_glue " : 35 } ,
2026-10-20 08:01:57 +01:00
" effect " : " Guards 25 % o f wood from rot per level " ,
2026-10-19 09:34:18 +01:00
} ,
" well " : {
" name " : " Well " , " icon " : " 🪣 " , " max " : 3 , " gold " : 120 ,
2026-10-19 22:45:32 +01:00
" base " : { " stone " : 500 , " bricks " : 80 , " rope " : 50 , " provisions " : 40 } ,
2026-10-19 23:17:23 +01:00
" advanced " : { " cut_stone " : 40 , " leather " : 60 , " iron_nails " : 125 } ,
" late " : { " oak_plank " : 25 , " hide_glue " : 35 , " steel_hinges " : 15 } ,
2026-10-20 08:01:57 +01:00
" effect " : " Fire damage -25 % per level " ,
2026-10-19 09:34:18 +01:00
} ,
2026-10-18 13:48:56 +01:00
" spymaster " : {
" name " : " Spymaster ' s guild " , " icon " : " 🕵️ " , " max " : 3 , " gold " : 400 ,
2026-10-19 22:45:32 +01:00
" base " : { " logs " : 200 , " bricks " : 150 , " hide " : 120 , " feathers " : 300 , " provisions " : 60 } ,
2026-10-19 23:17:23 +01:00
" advanced " : { " cut_stone " : 80 , " hard_leather " : 40 , " iron_nails " : 100 } ,
" late " : { " bear_leather " : 50 , " oak_plank " : 25 , " steel_hinges " : 15 } ,
2026-10-20 08:01:57 +01:00
" effect " : " +25 % r econ per level, plus some each hour " ,
2026-10-18 13:48:56 +01:00
} ,
" storehouse " : {
" name " : " Storehouse " , " icon " : " 📦 " , " max " : 5 , " gold " : 200 ,
2026-10-19 23:17:23 +01:00
" 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 } ,
2026-10-20 08:01:57 +01:00
" effect " : " Per level: +3 bank slots, +250 stack size " ,
2026-10-18 13:48:56 +01:00
} ,
}
2026-10-19 09:24:45 +01:00
# 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
2026-10-18 13:48:56 +01:00
REPAIR_SHARE = 0.3
2026-10-18 19:20:52 +01:00
# 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
2026-10-19 20:35:15 +01:00
# 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
2026-10-19 20:31:25 +01:00
FAMILY_GROWTH = 0.05
2026-10-18 19:20:52 +01:00
2026-10-18 13:48:56 +01:00
RAID_GROUPS = [ " the Red Hand " , " the Hollow Crows " , " the Blackwater gang " , " the Ashen Wolves " ,
" the Gallows Men " , " the Thornback raiders " ]
2026-10-19 09:34:18 +01:00
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 " ]
2026-10-18 13:48:56 +01:00
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 ---------------------------------------------------------------
2026-10-25 20:01:33 +00:00
WORLD_VERSION = 31
2026-10-18 19:20:52 +01:00
2026-10-18 13:48:56 +01:00
def new_world ( now : float , speed : float = 1.0 ) - > dict :
return {
2026-10-18 19:20:52 +01:00
" version " : WORLD_VERSION ,
2026-10-18 13:48:56 +01:00
" created " : now ,
" config " : { " speed " : speed , " clock_offset " : 0 } ,
" players " : { } ,
" city " : {
" population " : 20.0 ,
" treasury " : 0.0 ,
" stockpile " : { " bread " : 40 } ,
2026-10-19 11:15:19 +01:00
" buildings " : { key : { " level " : 0 , " damaged " : False , " fund " : { } , " fittings " : [ ] } for key in BUILDINGS } ,
2026-10-18 13:48:56 +01:00
" construction " : None ,
2026-10-18 19:20:52 +01:00
" queue " : [ ] ,
" tier " : 0 ,
2026-10-19 09:34:18 +01:00
" weather " : { " kind " : " clear " , " until " : now + 6 * HOUR / speed , " next " : " rain " } ,
" effects " : [ ] , " problems " : [ ] , " hired " : 0 ,
2026-10-18 13:48:56 +01:00
" last_tick " : now ,
" food_due " : 0.0 ,
" starving " : False ,
" repelled " : 0 ,
2026-10-21 12:16:22 +01:00
" fires " : { key : new_fire ( now ) for key in FIRES } ,
2026-10-24 13:34:59 +01:00
" wages " : 0 , " monuments " : { } , " built " : [ ] , " carving " : None ,
2026-10-18 13:48:56 +01:00
} ,
" events " : [ ] ,
" next_event_at " : now + 12 * HOUR / speed ,
2026-10-19 09:34:18 +01:00
" next_minor_at " : now + 30 * HOUR / speed ,
" next_hardship_at " : now + 24 * HOUR / speed ,
" next_fortune_at " : now + 6 * HOUR / speed ,
2026-10-18 13:48:56 +01:00
" next_event_id " : 1 ,
" ledger " : [ ] ,
" board " : [ ] ,
2026-10-19 09:56:11 +01:00
" next_message_id " : 1 ,
2026-10-20 18:01:02 +01:00
" orders " : [ ] , " next_order_id " : 1 ,
2026-10-21 22:06:49 +01:00
" parties " : { } , " next_party_id " : 1 ,
2026-10-24 13:34:59 +01:00
" caravan " : None , " next_caravan_id " : 1 ,
2026-10-18 13:48:56 +01:00
}
2026-10-18 22:49:45 +01:00
def migrate ( world : dict , now : float | None = None ) - > dict :
2026-10-18 19:20:52 +01:00
""" Bring a saved world up to the current shape. Never loses anything. """
2026-10-18 22:49:45 +01:00
now = now or world [ " city " ] [ " last_tick " ]
2026-10-18 19:20:52 +01:00
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
2026-10-18 19:27:56 +01:00
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
2026-10-18 22:41:11 +01:00
if world [ " version " ] < 4 :
2026-10-18 22:49:45 +01:00
_sell_useless_stock ( world , now )
2026-10-18 22:41:11 +01:00
world [ " version " ] = 4
2026-10-18 23:09:17 +01:00
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
2026-10-18 23:12:57 +01:00
if world [ " version " ] < 6 :
for player in world [ " players " ] . values ( ) :
player [ " xp " ] . setdefault ( " herblore " , 0 )
player . setdefault ( " potions " , { } )
world [ " version " ] = 6
2026-10-19 09:24:45 +01:00
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
2026-10-19 09:34:18 +01:00
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
2026-10-19 09:56:11 +01:00
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
2026-10-19 11:15:19 +01:00
if world [ " version " ] < 10 :
for building in world [ " city " ] [ " buildings " ] . values ( ) :
building . setdefault ( " fittings " , [ ] )
world [ " version " ] = 10
2026-10-19 21:28:00 +01:00
if world [ " version " ] < 11 :
_migrate_potions ( world )
world [ " version " ] = 11
2026-10-19 22:45:32 +01:00
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
2026-10-19 23:17:23 +01:00
if world [ " version " ] < 13 :
_fold_masonry ( world )
_return_overfunding ( world )
world [ " version " ] = 13
2026-10-19 23:38:34 +01:00
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
2026-10-20 13:12:12 +01:00
if world [ " version " ] < 15 :
# Raid reports pop up once for everyone; ones from before that don't.
latest = last_threat ( world )
for player in world [ " players " ] . values ( ) :
player [ " seen_threat " ] = latest [ " id " ] if latest else 0
world [ " version " ] = 15
2026-10-20 14:16:12 +01:00
if world [ " version " ] < 16 :
_migrate_combat_rework ( world )
world [ " version " ] = 16
2026-10-20 18:01:02 +01:00
if world [ " version " ] < 17 :
world . setdefault ( " orders " , [ ] )
world . setdefault ( " next_order_id " , 1 )
world [ " version " ] = 17
2026-10-20 19:59:45 +01:00
if world [ " version " ] < 18 :
# Jewelcrafting: a new skill and three new slots.
for player in world [ " players " ] . values ( ) :
player [ " xp " ] . setdefault ( " jewelcrafting " , 0 )
for slot in ( " ring " , " amulet " , " trinket " ) :
player [ " equip " ] . setdefault ( slot , None )
world [ " version " ] = 18
2026-10-20 20:06:16 +01:00
if world [ " version " ] < 19 :
for player in world [ " players " ] . values ( ) :
player . setdefault ( " pinned " , [ ] )
world [ " version " ] = 19
2026-10-20 20:26:54 +01:00
if world [ " version " ] < 20 :
_precision_to_haste ( world )
world [ " version " ] = 20
2026-10-20 21:00:31 +01:00
if world [ " version " ] < 21 :
# Crafting stations, strongboxes and bags: their slots.
for player in world [ " players " ] . values ( ) :
for slot in EQUIP_SLOTS :
player [ " equip " ] . setdefault ( slot , None )
world [ " version " ] = 21
2026-10-20 21:13:46 +01:00
if world [ " version " ] < 22 :
for player in world [ " players " ] . values ( ) :
player . setdefault ( " tracked " , None )
world [ " version " ] = 22
2026-10-20 21:20:25 +01:00
if world [ " version " ] < 23 :
# Stations got their own slot: crafting skills have a tool and a station now.
for player in world [ " players " ] . values ( ) :
equip = player [ " equip " ]
for slot in EQUIP_SLOTS :
equip . setdefault ( slot , None )
for skill in STATIONS :
item = equip . get ( f " tool_ { skill } " )
if item and ITEMS [ item ] . get ( " station " ) :
equip [ f " tool_ { skill } " ] , equip [ f " station_ { skill } " ] = None , item
world [ " version " ] = 23
2026-10-21 12:16:22 +01:00
if world [ " version " ] < 24 :
# Firekeeping: a new skill, its bellows slot, and the guild's fires.
world [ " city " ] . setdefault ( " fires " , { } )
for key in FIRES :
world [ " city " ] [ " fires " ] . setdefault ( key , new_fire ( now ) )
for player in world [ " players " ] . values ( ) :
player [ " xp " ] . setdefault ( " firekeeping " , 0 )
player [ " equip " ] . setdefault ( " tool_firekeeping " , None )
world [ " version " ] = 24
2026-10-21 22:06:49 +01:00
if world [ " version " ] < 25 :
# Fighting together: fights are kept per fighter now, so the one in progress is drawn up again.
world . setdefault ( " parties " , { } )
world . setdefault ( " next_party_id " , 1 )
for player in world [ " players " ] . values ( ) :
if player [ " action " ] :
player [ " action " ] . pop ( " fight " , None )
world [ " version " ] = 25
2026-10-24 09:49:21 +01:00
if world [ " version " ] < 26 :
# Packs and abilities: fights in progress are drawn up again.
for player in world [ " players " ] . values ( ) :
if player [ " action " ] :
player [ " action " ] . pop ( " fight " , None )
for party in world [ " parties " ] . values ( ) :
party [ " fight " ] = None
world [ " version " ] = 26
2026-10-24 11:43:56 +01:00
if world [ " version " ] < 27 :
# Livelier happenings: tracks waiting longer than their new cooldowns are brought forward,
# and a city with no threat coming hears rumours of the next one straight away.
for track , ( _low , high ) in TRACK_HOURS . items ( ) :
key = f " next_ { track } _at "
if world . get ( key ) :
world [ key ] = min ( world [ key ] , now + high * HOUR / speed ( world ) )
if world [ " next_event_at " ] and not upcoming_event ( world ) :
world [ " next_event_at " ] = min ( world [ " next_event_at " ] , now )
world [ " version " ] = 27
2026-10-24 12:05:23 +01:00
if world [ " version " ] < 28 :
# Abilities in your own hands.
for player in world [ " players " ] . values ( ) :
player . setdefault ( " manual " , [ ] )
world [ " version " ] = 28
2026-10-24 13:34:59 +01:00
if world [ " version " ] < 29 :
# Guards are paid now. The council covers the coming threat once, so nobody is caught out.
world [ " city " ] [ " wages " ] = guard_posts ( world ) * GUARD_WAGE
# Gold to spend: rented slots, caravan wares, a look of your own, monuments.
for player in world [ " players " ] . values ( ) :
for key , value in ( ( " rented " , 0 ) , ( " boosts " , [ ] ) , ( " bought " , None ) , ( " deeds " , { } ) , ( " owned " , [ ] ) ) :
player . setdefault ( key , value )
player . setdefault ( " look " , dict ( DEFAULT_LOOK ) )
world . setdefault ( " caravan " , None )
world . setdefault ( " next_caravan_id " , 1 )
for key , value in ( ( " monuments " , { } ) , ( " built " , [ ] ) , ( " carving " , None ) ) :
world [ " city " ] . setdefault ( key , value )
world [ " version " ] = 29
2026-10-24 13:48:16 +01:00
if world [ " version " ] < 30 :
# Masterwork patterns, learned by crafters.
for player in world [ " players " ] . values ( ) :
player . setdefault ( " patterns " , [ ] )
world [ " version " ] = 30
2026-10-25 20:01:33 +00:00
if world [ " version " ] < 31 :
# Uniques ever found, for achievements: whatever's held now.
for player in world [ " players " ] . values ( ) :
held = [ * player [ " bank " ] , * player [ " equip " ] . values ( ) ]
player . setdefault ( " found " , [ item for item in UNIQUES if item in held ] )
world [ " version " ] = 31
2026-10-18 19:20:52 +01:00
return world
2026-10-20 20:26:54 +01:00
def _precision_to_haste ( world : dict ) - > None :
""" Rings of precision became rings of haste: same gem, same slot. """
rename = lambda item : item . replace ( " _precision " , " _haste " ) if item and item . endswith ( " _precision " ) else item
swap = lambda items : { rename ( item ) : qty for item , qty in items . items ( ) }
world [ " city " ] [ " stockpile " ] = swap ( world [ " city " ] [ " stockpile " ] )
for player in world [ " players " ] . values ( ) :
player [ " bank " ] = swap ( player [ " bank " ] )
player [ " equip " ] = { slot : rename ( item ) for slot , item in player [ " equip " ] . items ( ) }
action = player [ " action " ]
if action :
for key in ( " id " , " resume " ) :
if key in action :
action [ key ] = rename ( action [ key ] )
for order in world . get ( " orders " , [ ] ) :
order [ " item " ] , order [ " action " ] = rename ( order [ " item " ] ) , rename ( order [ " action " ] )
2026-10-20 14:16:12 +01:00
# The combat rework: the old area monsters became races, their trophies carry over by
# weapon line and tier, dragonhide became the dragons' reagents, and the leathers past
# bear are hunted now.
RENAMED_ITEMS = {
" bat_sinew " : " harpy_sinew " , " harpy_sinew " : " spider_silk " , " wisp_essence " : " grave_dust " ,
" hill_troll_hide " : " lizard_scale " , " rock_troll_hide " : " orc_tusk " , " elder_troll_hide " : " dragonkin_scale " ,
" obsidian_core " : " frozen_core " , " runic_core " : " magma_core " ,
" green_dragonhide " : " ironhill_lacing " , " blue_dragonhide " : " frostpeak_lacing " , " red_dragonhide " : " dragonspine_lacing " ,
" green_dragon_leather " : " elk_leather " , " blue_dragon_leather " : " sabrecat_leather " , " red_dragon_leather " : " mammoth_leather " ,
* * { f " { old } _ { suffix } " : f " { new } _ { suffix } " for old , new in ( ( " green_dhide " , " elk_leather " ) , ( " blue_dhide " , " sabrecat_leather " ) ,
( " red_dhide " , " mammoth_leather " ) ) for suffix in ( " cap " , " legs " , " body " ) } ,
}
RENAMED_ACTIONS = {
* * { f " fight_ { _slug ( old ) } " : f " fight_ { _slug ( new ) } " for old , new in (
( " Clay golem " , " Clay golems " ) , ( " Cave troll " , " Cave trolls " ) , ( " Will-o ' -wisp " , " Restless dead " ) ,
( " Giant bat " , " Harpies " ) , ( " Stone golem " , " Stone guardians " ) , ( " Hill troll " , " Lizardfolk " ) ,
( " Bog wraith " , " Bog wraiths " ) , ( " Harpy " , " Giant spiders " ) , ( " Iron golem " , " Iron golems " ) , ( " Rock troll " , " Orcs " ) ,
( " Mine ghost " , " Mine ghosts " ) , ( " Wyvern " , " Wyverns " ) , ( " Obsidian golem " , " Ice elementals " ) ,
( " Frost troll " , " Frost trolls " ) , ( " Banshee " , " Banshees " ) , ( " Griffin " , " Griffins " ) ,
( " Runic golem " , " Magma constructs " ) , ( " Elder troll " , " Dragonkin " ) , ( " Shade " , " Shades " ) , ( " Roc " , " Rocs " ) ) } ,
* * { f " tan_ { old } _dragon_leather " : f " tan_ { new } _leather " for old , new in
( ( " green " , " elk " ) , ( " blue " , " sabrecat " ) , ( " red " , " mammoth " ) ) } ,
* * { f " lw_ { old } _ { suffix } " : f " lw_ { new } _ { suffix } " for old , new in ( ( " green_dhide " , " elk_leather " ) ,
( " blue_dhide " , " sabrecat_leather " ) , ( " red_dhide " , " mammoth_leather " ) ) for suffix in ( " cap " , " legs " , " body " ) } ,
}
def _renamed ( stock : dict ) - > dict :
out = { }
for item , qty in stock . items ( ) :
new = RENAMED_ITEMS . get ( item , item )
out [ new ] = out . get ( new , 0 ) + qty
return out
def _migrate_combat_rework ( world : dict ) - > None :
city = world [ " city " ]
city [ " stockpile " ] = _renamed ( city [ " stockpile " ] )
for building in city [ " buildings " ] . values ( ) :
building [ " fund " ] = _renamed ( building [ " fund " ] )
for player in world [ " players " ] . values ( ) :
player [ " xp " ] . setdefault ( " tailoring " , 0 )
player [ " bank " ] = _renamed ( player [ " bank " ] )
for slot , item in list ( player [ " equip " ] . items ( ) ) :
if item :
player [ " equip " ] [ slot ] = RENAMED_ITEMS . get ( item , item )
action = player [ " action " ]
if action :
for key in ( " id " , " resume " ) :
if key in action :
action [ key ] = RENAMED_ACTIONS . get ( action [ key ] , action [ key ] )
if action [ " id " ] not in ACTION_BY_ID or action . get ( " resume " , " " ) and action [ " resume " ] not in ACTION_BY_ID :
stop ( player , " Combat has changed: pick your fight again " )
2026-10-20 13:12:12 +01:00
def last_threat ( world : dict ) - > dict | None :
""" The most recent raid or dragon attack that has played out. """
return next ( ( e for e in reversed ( world [ " events " ] ) if e [ " status " ] == " done " and e [ " kind " ] in THREATS
and e . get ( " report " ) ) , None )
def unseen_threat ( world : dict , player : dict ) - > dict | None :
""" The latest raid or dragon attack this player hasn ' t watched yet, if it happened while they were here. """
event = last_threat ( world )
if event and event [ " id " ] > player . get ( " seen_threat " , 0 ) and event [ " resolved " ] > = player [ " joined " ] :
return event
return None
2026-10-19 21:28:00 +01:00
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 ] } "
2026-10-19 23:17:23 +01:00
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 ] } "
2026-10-19 22:45:32 +01:00
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
2026-10-18 22:41:11 +01:00
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 " )
2026-10-18 13:48:56 +01:00
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 " ]
2026-10-19 11:15:19 +01:00
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
2026-10-18 13:48:56 +01:00
def build_cost ( key : str , level : int ) - > dict :
spec = BUILDINGS [ key ]
cost = { item : qty * level for item , qty in spec [ " base " ] . items ( ) }
2026-10-18 22:13:50 +01:00
# 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 )
2026-10-18 13:48:56 +01:00
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 :
2026-10-18 19:20:52 +01:00
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 }
2026-10-18 13:48:56 +01:00
# --- 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 ,
2026-10-18 19:27:56 +01:00
" equip " : { slot : None for slot in EQUIP_SLOTS } ,
" food " : None , # preferred food in fights; None eats the cheapest first
2026-10-18 23:12:57 +01:00
" pp " : 0 , " prayers " : [ ] , " potions " : { } ,
2026-10-18 19:27:56 +01:00
" hp " : 44 , " hp_at " : now ,
2026-10-18 13:48:56 +01:00
" action " : None , " stopped " : None ,
" last_active " : now , " overflow " : " city " ,
2026-10-25 20:01:33 +00:00
" perks " : { " bank_slot " : 0 } , " manual " : [ ] , " patterns " : [ ] , " found " : [ ] , " eat_below " : EAT_BELOW ,
2026-10-24 13:34:59 +01:00
" rented " : 0 , " boosts " : [ ] , " bought " : None , " deeds " : { } , " owned " : [ ] , " look " : dict ( DEFAULT_LOOK ) ,
2026-10-18 13:48:56 +01:00
" garrison " : False ,
" donated " : 0 ,
2026-10-20 20:06:16 +01:00
" pinned " : [ ] , # skills pinned to the top of the sidebar
2026-10-20 21:13:46 +01:00
" tracked " : None , # recipe shown under the top bar
2026-10-18 13:48:56 +01:00
}
def skill_level ( player : dict , skill : str ) - > int :
return level_for ( player [ " xp " ] . get ( skill , 0 ) )
def bank_slots ( world : dict , player : dict ) - > int :
2026-10-18 19:20:52 +01:00
return ( BASE_SLOTS + 3 * building_level ( world , " storehouse " ) + TIER_SLOTS * world [ " city " ] [ " tier " ]
2026-10-24 13:34:59 +01:00
+ player [ " perks " ] [ " bank_slot " ] + player [ " rented " ] + fitted ( world , " shelving " ) + int ( gear_bonus ( player , " slots " ) ) )
2026-10-20 21:00:31 +01:00
def stack_size ( world : dict , player : dict | None = None ) - > int :
""" How many of one item fit in a bank slot: the storehouse ' s, plus your bag ' s. """
bag = int ( gear_bonus ( player , " stack " ) ) if player else 0
return BASE_STACK + 250 * building_level ( world , " storehouse " ) + 500 * fitted ( world , " enchanted_chests " ) + bag
2026-10-18 13:48:56 +01:00
2026-10-20 21:00:31 +01:00
def overfull ( world : dict , player : dict ) - > int :
""" Bank slots in use past what the bank holds (after a smaller strongbox or bag, say). """
return slots_used ( world , player ) - bank_slots ( world , player )
2026-10-18 13:48:56 +01:00
2026-10-19 10:52:07 +01:00
def slots_used ( world : dict , player : dict ) - > int :
""" Bank slots taken: an item spills into another slot every full stack. """
2026-10-20 21:00:31 +01:00
stack = stack_size ( world , player )
2026-10-19 10:52:07 +01:00
return sum ( - ( - qty / / stack ) for qty in player [ " bank " ] . values ( ) )
2026-10-18 13:48:56 +01:00
def bank_room ( world : dict , player : dict , item : str ) - > int :
2026-10-19 10:52:07 +01:00
""" How many more of `item` fit: the rest of its last stack, plus any free slots. """
2026-10-20 21:00:31 +01:00
stack = stack_size ( world , player )
2026-10-18 13:48:56 +01:00
have = player [ " bank " ] . get ( item , 0 )
2026-10-19 10:52:07 +01:00
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
2026-10-18 13:48:56 +01:00
2026-10-18 22:40:36 +01:00
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 " )
2026-10-19 09:34:18 +01:00
if item == " water_barrel " :
uses . append ( " the fire brigade " )
2026-10-19 11:15:19 +01:00
if " fitting " in spec :
uses . append ( BUILDINGS [ spec [ " fitting " ] ] [ " name " ] )
2026-10-18 22:40:36 +01:00
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. """
2026-10-25 20:01:33 +00:00
if ITEMS [ item ] . get ( " unique " ) and item not in player [ " found " ] :
player [ " found " ] . append ( item ) # for achievements: once found, always found
2026-10-18 13:48:56 +01:00
keep = min ( qty , bank_room ( world , player , item ) )
if keep :
player [ " bank " ] [ item ] = player [ " bank " ] . get ( item , 0 ) + keep
spill = qty - keep
2026-10-18 22:40:36 +01:00
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 " ]
2026-10-18 13:48:56 +01:00
stockpile = world [ " city " ] [ " stockpile " ]
stockpile [ item ] = stockpile . get ( item , 0 ) + spill
gain = spill * ITEMS [ item ] [ " value " ]
2026-10-18 19:54:08 +01:00
player [ " favour " ] + = gain * OVERFLOW_FAVOUR
2026-10-18 13:48:56 +01:00
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 ]
2026-10-18 19:27:56 +01:00
def armour ( player : dict ) - > int :
2026-10-20 14:16:12 +01:00
""" Protection from physical blows: slashing, blunt and piercing. """
2026-10-18 19:27:56 +01:00
return sum ( ITEMS [ item ] . get ( " armour " , 0 ) for item in player [ " equip " ] . values ( ) if item )
2026-10-20 14:16:12 +01:00
def magic_ward ( player : dict ) - > int :
""" Protection from magic. """
return sum ( ITEMS [ item ] . get ( " ward " , 0 ) for item in player [ " equip " ] . values ( ) if item )
def reduction ( points : float ) - > float :
2026-10-18 19:27:56 +01:00
return points / ( points + 25 )
2026-10-20 14:16:12 +01:00
def damage_reduction ( player : dict ) - > float :
return reduction ( armour ( player ) )
def defence_bonus ( player : dict ) - > int :
""" What all your armour adds to power and evasion, whatever its weight. """
return round ( ( armour ( player ) + magic_ward ( player ) ) * 0.4 )
2026-10-20 19:59:45 +01:00
def gear_bonus ( player : dict , stat : str ) - > float :
""" A bonus that comes from what you ' re wearing: jewellery ' s accuracy, crit, HP and so on. """
return sum ( ITEMS [ item ] . get ( stat , 0 ) for item in player [ " equip " ] . values ( ) if item )
2026-10-18 13:48:56 +01:00
def combat_power ( player : dict ) - > int :
2026-10-20 14:16:12 +01:00
power = skill_level ( player , " combat " ) + defence_bonus ( player )
2026-10-18 19:27:56 +01:00
for item in player [ " equip " ] . values ( ) :
2026-10-18 13:48:56 +01:00
if item :
2026-10-18 19:27:56 +01:00
power + = ITEMS [ item ] . get ( " power " , 0 )
2026-10-18 13:48:56 +01:00
return power
2026-10-18 19:27:56 +01:00
def max_hp ( player : dict ) - > int :
2026-10-20 19:59:45 +01:00
return 40 + 4 * skill_level ( player , " combat " ) + int ( gear_bonus ( player , " hp " ) )
2026-10-18 19:27:56 +01:00
2026-10-24 09:49:21 +01:00
def regen_boost ( world : dict | None , player : dict ) - > float :
""" How much faster than usual you heal: the enchanted hearths, and amulets of regeneration. """
boost = 2 if world and fitted ( world , " enchanted_hearths " ) else 1
return boost * ( 1 + gear_bonus ( player , " regen " ) / 100 )
2026-10-20 20:44:20 +01:00
def regen ( world : dict , player : dict , now : float ) - > None :
2026-10-18 19:27:56 +01:00
""" Heal for the time since HP was last settled. """
if now > player [ " hp_at " ] :
top = max_hp ( player )
2026-10-24 09:49:21 +01:00
per_second = top * REGEN_PER_HOUR * regen_boost ( world , player ) * speed ( world ) / HOUR
2026-10-18 19:27:56 +01:00
player [ " hp " ] = min ( top , player [ " hp " ] + ( now - player [ " hp_at " ] ) * per_second )
player [ " hp_at " ] = now
2026-10-19 23:38:34 +01:00
def weapon_style ( player : dict ) - > str :
weapon = player [ " equip " ] . get ( " weapon " )
return ITEMS [ weapon ] . get ( " style " , " sword " ) if weapon else " sword "
2026-10-20 14:16:12 +01:00
def affinity ( enemy : dict , damage : str ) - > float :
""" How hard a damage type lands on this enemy. """
return VULNERABLE if damage in enemy [ " vulnerable " ] else RESISTANT if damage in enemy [ " resists " ] else 1
2026-10-19 23:38:34 +01:00
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
2026-10-20 14:16:12 +01:00
the weapon ' s style, your trade and how the enemy takes its damage type. Armour soaks physical blows,
2026-10-24 09:49:21 +01:00
ward soaks magic , both help you dodge , and a shield blocks . The weapon and your trade give your
abilities ; a sword and shield hold the line , a staff heals . """
2026-10-19 23:38:34 +01:00
style_id = weapon_style ( player )
style = STYLES [ style_id ]
2026-10-19 23:32:07 +01:00
power = combat_power ( player ) + blessed [ " power " ] + brew . get ( " power " , 0 )
2026-10-24 09:49:21 +01:00
base = ( 2 + power / 3 ) * style [ " hit " ] * ( CLASS_STYLE_BONUS if CLASS_STYLE . get ( player [ " cls " ] ) == style_id else 1 )
2026-10-20 14:16:12 +01:00
lands = affinity ( enemy , style [ " damage " ] ) if enemy else 1
taken = blessed [ " taken " ] * ( 1 - brew . get ( " reduce " , 0 ) )
2026-10-24 09:49:21 +01:00
shield = bool ( player [ " equip " ] . get ( " shield " ) )
2026-10-24 13:48:16 +01:00
mods = combat . combine_mods ( [ ITEMS [ item ] [ " mods " ] for item in player [ " equip " ] . values ( ) if item and ITEMS [ item ] . get ( " mods " ) ] )
return { " style " : style_id , " damage " : style [ " damage " ] , " role " : combat . role ( style_id , shield ) , " mods " : mods ,
2026-10-24 09:49:21 +01:00
" abilities " : combat . known_abilities ( style_id , player [ " cls " ] , skill_level ( player , " combat " ) , shield ) ,
2026-10-20 20:26:54 +01:00
" interval " : round ( style [ " interval " ] * ( 1 - gear_bonus ( player , " haste " ) / 100 ) , 2 ) ,
" accuracy " : ( 10 + 2 * power ) * style [ " accuracy " ] ,
2026-10-24 09:49:21 +01:00
" base " : base , " max_hit " : max ( 2 , round ( base * lands ) ) ,
2026-10-20 19:59:45 +01:00
" crit " : style [ " crit " ] + gear_bonus ( player , " crit " ) / 100 ,
2026-10-20 14:16:12 +01:00
" first " : style . get ( " first " , False ) , " affinity " : lands ,
2026-10-24 09:49:21 +01:00
" block " : BLOCK_CHANCE if shield else 0 ,
2026-10-20 14:16:12 +01:00
" evasion " : 10 + 2 * ( skill_level ( player , " combat " ) + defence_bonus ( player ) ) ,
" taken " : { " physical " : ( 1 - damage_reduction ( player ) ) * taken , " magic " : ( 1 - reduction ( magic_ward ( player ) ) ) * taken } }
def _taken ( me : dict , damage : str ) - > float :
return me [ " taken " ] [ " magic " if damage == " magic " else " physical " ]
2026-10-19 23:32:07 +01:00
2026-10-24 09:49:21 +01:00
hit_chance = combat . hit_chance
2026-10-19 23:32:07 +01:00
2026-10-19 23:38:34 +01:00
def _average_hit ( max_hit : float , crit : float = CRIT_CHANCE ) - > float :
return ( 1 - crit ) * ( max_hit + 1 ) / 2 + crit * max_hit * CRIT_MULT
2026-10-19 23:32:07 +01:00
2026-10-24 09:49:21 +01:00
def rest_plan ( world : dict | None , player : dict , hp : float | None = None ) - > tuple [ float , dict ] :
""" Resting back to full before a fight: how many game seconds, and the food eaten on the way.
2026-10-24 14:02:24 +01:00
You eat while you ' re under the share of your HP you chose, and only when a bite is quicker than
resting , so better food makes for shorter rests . The rest of the way you rest . """
2026-10-24 09:49:21 +01:00
top = max_hp ( player )
missing = top - ( player [ " hp " ] if hp is None else hp )
2026-10-24 13:34:59 +01:00
rate = top * REST_RATE * regen_boost ( world , player ) / ware ( player , " rest " , " combat " )
2026-10-24 14:02:24 +01:00
hungry = top * ( 1 - player . get ( " eat_below " , EAT_BELOW ) ) # eat while more than this is missing
2026-10-24 09:49:21 +01:00
seconds , eaten = 0.0 , { }
for food in edible ( player ) :
heal , left = ITEMS [ food ] [ " heal " ] , player [ " bank " ] . get ( food , 0 )
2026-10-24 14:02:24 +01:00
while missing > hungry and missing > 0 and left and min ( heal , missing ) / rate > BITE :
2026-10-24 09:49:21 +01:00
eaten [ food ] = eaten . get ( food , 0 ) + 1
left - = 1
missing - = heal
seconds + = BITE
return max ( RESPAWN , seconds + max ( 0.0 , missing ) / rate ) , eaten
def health_potion ( player : dict ) - > str | None :
""" The strongest health potion in the bank: the one you ' d drink in a fight. """
potions = [ item for item in player [ " bank " ] if ITEMS [ item ] . get ( " drink " ) ]
return max ( potions , key = lambda item : ITEMS [ item ] [ " drink " ] ) if potions else None
# How much abilities add to a weapon's swings, and an archetype's abilities to its blows, on
# average (from tools/balance.py). Only the encounter cards' estimates use them.
ABILITY_SHARE = { " sword " : 1.15 , " hammer " : 1.45 , " staff " : 1.35 , " bow " : 1.3 }
ENEMY_SHARE = { " brute " : 1.4 , " skirmisher " : 1.3 , " caster " : 1.6 , " elite " : 1.5 , " boss " : 1.6 , " dragon " : 1.8 }
GUARD_SHARE = { " sword " : 0.73 , " hammer " : 0.85 , " staff " : 0.33 , " bow " : 0.72 } # what your abilities keep off you
PACK_SHARE = 1 + PACK_EXTRA * ( 2 * OUTNUMBERED [ 0 ] - 1 ) # a pack's HP, for one of you, against a lone enemy's
def _variant_odds ( player : dict , foe : dict , blessed : dict , brew : dict , pack : float = 1.0 ) - > dict :
2026-10-20 14:16:12 +01:00
me = fighter ( player , blessed , brew , foe )
2026-10-19 23:32:07 +01:00
hit , their_hit = hit_chance ( me [ " accuracy " ] , foe [ " evasion " ] ) , hit_chance ( foe [ " accuracy " ] , me [ " evasion " ] )
2026-10-24 09:49:21 +01:00
dps = hit * _average_hit ( me [ " max_hit " ] , me [ " crit " ] ) / me [ " interval " ] * ABILITY_SHARE [ me [ " style " ] ]
kill = min ( foe [ " hp " ] * pack / dps , MAX_FIGHT )
2026-10-20 14:16:12 +01:00
taken = sum ( _taken ( me , kind ) for kind in foe [ " attack " ] ) / len ( foe [ " attack " ] )
2026-10-21 07:52:01 +01:00
per_second = their_hit * ( 1 - me [ " block " ] ) * _average_hit ( foe [ " max_hit " ] ) * taken / foe [ " interval " ]
2026-10-24 09:49:21 +01:00
per_second * = ENEMY_SHARE [ foe [ " archetype " ] ] * GUARD_SHARE [ me [ " style " ] ] * pack
2026-10-21 07:52:01 +01:00
per_second = max ( 0 , per_second - blessed [ " mend " ] * max_hp ( player ) )
2026-10-20 14:16:12 +01:00
return { " hit " : hit , " their_hit " : their_hit , " max_hit " : me [ " max_hit " ] , " affinity " : me [ " affinity " ] ,
" their_max " : max ( 1 , round ( foe [ " max_hit " ] * max ( _taken ( me , kind ) for kind in foe [ " attack " ] ) ) ) ,
2026-10-24 09:49:21 +01:00
" kill_time " : kill , " damage " : per_second * kill }
2026-10-20 14:16:12 +01:00
2026-10-24 09:49:21 +01:00
def fight_odds ( player : dict , action : dict , world : dict | None = None ) - > dict :
""" What a fight in this encounter looks like on average, for the cards, and against each variant:
the time to win it , the damage it costs , and the rest after it . """
2026-10-20 14:16:12 +01:00
blessed , brew = blessing ( player ) , potion_effect ( player , " combat " )
variants = action [ " enemy " ] [ " variants " ]
2026-10-24 09:49:21 +01:00
pack = PACK_SHARE if len ( variants ) > 1 else BOSS_HP if action . get ( " together " ) else 1 # a boss shared by a pair
rows = [ _variant_odds ( player , foe , blessed , brew , pack ) for foe in variants ]
2026-10-20 14:16:12 +01:00
total = sum ( foe [ " weight " ] for foe in variants )
mean = lambda key : sum ( row [ key ] * foe [ " weight " ] for row , foe in zip ( rows , variants ) ) / total
2026-10-24 09:49:21 +01:00
top = max_hp ( player )
rest = rest_plan ( world , player , top - min ( top , mean ( " damage " ) ) ) [ 0 ]
2026-10-20 14:16:12 +01:00
return { " hit " : round ( mean ( " hit " ) , 2 ) , " their_hit " : round ( mean ( " their_hit " ) , 2 ) , " style " : weapon_style ( player ) ,
" max_hit " : max ( row [ " max_hit " ] for row in rows ) , " their_max " : max ( row [ " their_max " ] for row in rows ) ,
2026-10-24 09:49:21 +01:00
" kill_time " : round ( mean ( " kill_time " ) , 1 ) , " damage " : round ( mean ( " damage " ) , 1 ) , " rest " : round ( rest , 1 ) ,
2026-10-20 14:16:12 +01:00
" variants " : [ { " affinity " : row [ " affinity " ] , " kill_time " : round ( row [ " kill_time " ] , 1 ) ,
" damage " : round ( row [ " damage " ] , 1 ) } for row in rows ] }
2026-10-19 23:32:07 +01:00
2026-10-24 09:49:21 +01:00
def fight_member ( world : dict , player : dict , at : float = 0.0 ) - > dict :
2026-10-21 22:06:49 +01:00
""" One side of a fight as it squares up (or tags in `at` seconds of game time in): kept with the
fight , so playing it out again later goes the same way . """
2026-10-20 14:16:12 +01:00
blessed , brew = blessing ( player ) , potion_effect ( player , " combat " )
2026-10-24 09:49:21 +01:00
me = fighter ( player , blessed , brew )
potion = health_potion ( player )
2026-10-21 22:06:49 +01:00
return { " id " : player [ " id " ] , " name " : player [ " name " ] , " at " : at , " left " : None ,
2026-10-24 09:49:21 +01:00
" start_hp " : round ( player [ " hp " ] ) , " hp_in " : player [ " hp " ] , " top " : max_hp ( player ) , " style " : me [ " style " ] ,
" role " : me [ " role " ] , " abilities " : me [ " abilities " ] , " interval " : me [ " interval " ] / speed ( world ) ,
" fighter " : me , " eaten " : { } , " potion " : ITEMS [ potion ] [ " drink " ] if potion else 0 , " potion_item " : potion ,
2026-10-24 14:21:08 +01:00
" mend " : blessed [ " mend " ] ,
2026-10-24 12:05:23 +01:00
" wind " : blessed [ " wind " ] and not ( player [ " action " ] or { } ) . get ( " wind_used " ) , " pp " : blessed [ " cost " ] ,
2026-10-24 13:48:16 +01:00
" manual " : [ a for a in player . get ( " manual " , [ ] ) if a in me [ " abilities " ] ] , " inputs " : [ ] , " mods " : me [ " mods " ] ,
2026-10-24 12:05:23 +01:00
" watch " : at + combat . WATCH * speed ( world ) }
2026-10-21 22:06:49 +01:00
2026-10-24 14:21:08 +01:00
FIGHT_PRIVATE = ( " fighter " , " mend " , " wind " )
2026-10-21 22:06:49 +01:00
def fight_view ( fight : dict ) - > dict :
""" A fight as the page sees it: no one ' s bank or stats. """
return { * * fight , " members " : [ { key : value for key , value in member . items ( ) if key not in FIGHT_PRIVATE }
for member in fight [ " members " ] ] }
def member_of ( fight : dict , uid : str ) - > dict :
return next ( member for member in fight [ " members " ] if member [ " id " ] == uid )
2026-10-24 09:49:21 +01:00
def draw_pack ( action : dict , size : int , rng : random . Random ) - > list [ dict ] :
""" Who you ' re up against: one of the encounter ' s variants, as many as there are of you and
often one more , weaker each when they outnumber you . A rare elite or a boss comes alone . """
2026-10-21 22:06:49 +01:00
variants = action [ " enemy " ] [ " variants " ]
2026-10-24 09:49:21 +01:00
weights = [ foe [ " weight " ] for foe in variants ]
first = rng . choices ( range ( len ( variants ) ) , weights ) [ 0 ]
picks , ( hp , hits ) = [ first ] , ( 1.0 , 1.0 )
if action . get ( " together " ) :
hp , hits = BOSS_HP * size , BOSS_HITS [ 0 ] * ( 1 + BOSS_HITS [ 1 ] * ( size - GROUP_SIZE ) )
if len ( variants ) > 1 and variants [ first ] [ " archetype " ] != " elite " :
count = size + ( rng . random ( ) < PACK_EXTRA )
common = [ i for i , foe in enumerate ( variants ) if foe [ " archetype " ] != " elite " ]
picks + = rng . choices ( common , [ weights [ i ] for i in common ] , k = count - 1 )
if count > size :
hp , hits = OUTNUMBERED
share = 1.0 if action . get ( " together " ) else hp # a boss grown for its group still pays each of them one boss
return [ { * * { key : variants [ i ] [ key ] for key in ( " name " , " archetype " , " level " , " boss " , " interval " , " accuracy " ,
" evasion " , " attack " , " vulnerable " , " resists " ) } ,
" variant " : i , " hp " : max ( 1 , round ( variants [ i ] [ " hp " ] * hp ) ) , " max_hit " : max ( 1 , round ( variants [ i ] [ " max_hit " ] * hits ) ) ,
" xp " : variants [ i ] [ " xp " ] * share , " share " : share } for i in picks ]
2026-10-24 12:05:23 +01:00
def draw_fight ( world : dict , players : list , action : dict , rng : random . Random , start : float | None = None ) - > dict :
2026-10-24 09:49:21 +01:00
""" Draw up the pack, rest everyone back to full (the longest rest is everyone ' s), and play the
2026-10-24 12:05:23 +01:00
fight out . A fight starting at ` start ` ( real time ) holds each player ' s manual abilities for as
long as they were last seen watching . """
2026-10-24 09:49:21 +01:00
members , rest = [ ] , 0.0
for player in players :
seconds , eaten = rest_plan ( world , player )
member = fight_member ( world , player )
2026-10-25 20:21:08 +00:00
member . update ( eaten = eaten , hp_in = member [ " top " ] , rest = seconds ) # full after your own rest, then wait
2026-10-24 09:49:21 +01:00
rest = max ( rest , seconds )
members . append ( member )
fight = { " seed " : rng . random ( ) , " pace " : speed ( world ) , " rest " : rest , " enemy " : action [ " enemy " ] [ " name " ] ,
" pack " : draw_pack ( action , len ( players ) , rng ) , " members " : members }
2026-10-24 12:05:23 +01:00
for member , player in zip ( members , players ) :
member [ " watch " ] = - 1.0 if start is None else fight_time ( fight , start , player [ " last_active " ] + combat . WATCH )
2026-10-24 09:49:21 +01:00
return play_fight ( fight )
2026-10-21 22:06:49 +01:00
2026-10-24 12:05:23 +01:00
def simulate_fight ( world : dict , player : dict , action : dict , rng : random . Random , start : float | None = None ) - > dict :
return draw_fight ( world , [ player ] , action , rng , start )
def fight_time ( fight : dict , start : float , now : float ) - > float :
""" A real moment as seconds into a fight (after its rest) that started at `start`. """
return ( now - start ) * fight [ " pace " ] - fight [ " rest " ]
def fight_clock ( world : dict , player : dict ) - > tuple [ dict | None , float ] :
""" The fight you ' re in and when it (its rest included) began. """
current = player [ " action " ] or { }
party = world [ " parties " ] . get ( current . get ( " party " ) )
if party :
return party [ " fight " ] , party [ " start " ]
return current . get ( " fight " ) , current . get ( " start " , 0 )
def _my_fight ( world : dict , player : dict , now : float ) - > tuple [ dict , dict , float ] | str :
fight , start = fight_clock ( world , player )
if not fight or now > = start + fight [ " duration " ] or not any ( m [ " id " ] == player [ " id " ] for m in fight [ " members " ] ) :
return " You ' re not fighting "
2026-10-25 22:29:42 +00:00
member , t = member_of ( fight , player [ " id " ] ) , fight_time ( fight , start , now )
if member . get ( " out_at " ) is not None and t > = member [ " out_at " ] : # out by now, not just forecast to be
2026-10-24 12:05:23 +01:00
return " You ' re out of this fight "
2026-10-25 22:29:42 +00:00
return fight , member , t
2026-10-24 12:05:23 +01:00
def watch_fight ( world : dict , player : dict , now : float ) - > None :
""" You ' re looking: your manual abilities hold a while longer, and the fight ' s future is played
again with them . Nothing that ' s already happened changes. " " "
mine = _my_fight ( world , player , now )
if isinstance ( mine , str ) :
return
fight , member , t = mine
if not member [ " manual " ] and not member [ " inputs " ] :
return
watch = t + combat . WATCH * fight [ " pace " ]
if watch > member [ " watch " ] + combat . STEP :
member [ " watch " ] = watch
play_fight ( fight )
def _input ( world : dict , player : dict , now : float , kind : str , * args ) - > str | None :
mine = _my_fight ( world , player , now )
if isinstance ( mine , str ) :
return mine
fight , member , t = mine
if t < 0 :
return " Still resting "
at = math . ceil ( t / combat . STEP - 1e-9 ) * combat . STEP # the next slice of the fight
member [ " inputs " ] . append ( [ at , kind , * args ] )
member [ " watch " ] = max ( member [ " watch " ] , t + combat . WATCH * fight [ " pace " ] )
play_fight ( fight )
return None
2026-10-24 14:02:24 +01:00
def set_eat_below ( player : dict , share ) - > str | None :
""" Below what share of your HP you eat between fights (0: never, 1: whenever it ' s quicker). """
if share not in EAT_CHOICES :
return " Pick one of the choices "
player [ " eat_below " ] = share
return None
2026-10-24 13:48:16 +01:00
def learn_pattern ( player : dict , item : str ) - > str | None :
""" Read a pattern: from then on you can reforge its masterwork. The pattern ' s used up. """
spec = ITEMS . get ( item , { } )
if not spec . get ( " pattern " ) :
return " That isn ' t a pattern "
if not player [ " bank " ] . get ( item ) :
return " You don ' t have it "
known = player . setdefault ( " patterns " , [ ] )
if item in known :
return " You know it already "
if skill_level ( player , spec [ " skill " ] ) < spec [ " level " ] :
name = next ( s [ " name " ] for s in SKILLS if s [ " id " ] == spec [ " skill " ] )
return f " Needs { name . lower ( ) } level { spec [ ' level ' ] } "
bank_take ( player , item , 1 )
known . append ( item )
return None
2026-10-24 12:05:23 +01:00
def press ( world : dict , player : dict , ability : str , now : float , target : str | None = None ) - > str | None :
""" Use one of your manual abilities: as soon as it ' s ready, if that ' s soon enough. """
if ability not in combat . ABILITIES :
return " No such ability "
if ability not in player [ " manual " ] :
return " That one ' s on auto "
return _input ( world , player , now , " press " , ability , target )
def set_fight_target ( world : dict , player : dict , target : str , now : float ) - > str | None :
""" Tap an enemy to fight it, or someone on your side to aim your heals at them. """
return _input ( world , player , now , " target " , str ( target ) )
def set_manual ( world : dict , player : dict , ability : str , manual : bool , now : float ) - > str | None :
""" Take an ability into your own hands (pressed, on its real cooldown, while you watch) or give it
back to auto . """
if ability not in combat . ABILITIES :
return " No such ability "
mine = player . setdefault ( " manual " , [ ] )
if manual and ability not in mine :
mine . append ( ability )
elif not manual and ability in mine :
mine . remove ( ability )
fight = _my_fight ( world , player , now )
if isinstance ( fight , str ) :
return None
fight , member , t = fight
if t < 0 :
member [ " manual " ] = list ( mine )
play_fight ( fight )
return None
return _input ( world , player , now , " mode " , ability , manual )
2026-10-21 22:06:49 +01:00
2026-10-24 09:49:21 +01:00
def play_fight ( fight : dict ) - > dict :
""" Play a fight out, slice by slice, from the start (see combat.py). Nothing changes until it ' s over:
the result is applied then . The dice come from the fight ' s own seed, so playing it again with
someone tagging in or stepping out changes nothing before then . """
return combat . run ( fight )
def pack_xp ( fight : dict , killed_only : bool = False ) - > float :
2026-10-24 12:05:23 +01:00
""" The pack ' s XP: the enemies brought down, and on a loss a third of the rest (but never for an
add that didn ' t join). " " "
2026-10-24 09:49:21 +01:00
kills = set ( fight [ " kills " ] )
2026-10-24 12:05:23 +01:00
rest = 1 / 3 if killed_only else 0
return sum ( foe [ " xp " ] * ( 1 if f " e { i } " in kills else 0 if foe . get ( " dormant " ) else rest )
for i , foe in enumerate ( fight [ " pack " ] ) )
2026-10-18 23:09:17 +01:00
# --- 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.
2026-10-20 20:44:20 +01:00
MEND = 0.0004 # Rapid Heal: share of max HP mended per second of fighting
2026-10-18 23:09:17 +01:00
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 } ,
2026-10-20 20:44:20 +01:00
{ " id " : " rapid_heal " , " name " : " Rapid Heal " , " icon " : " 💗 " , " level " : 10 , " cost " : 1 , " mend " : MEND } ,
2026-10-18 23:09:17 +01:00
{ " 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
2026-10-20 20:44:20 +01:00
NO_BLESSING = { " cost " : 0 , " power " : 0 , " taken " : 1.0 , " mend " : 0 , " wind " : False }
2026-10-18 23:09:17 +01:00
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 " )
2026-10-20 20:44:20 +01:00
if prayer . get ( " mend " ) :
parts . append ( f " + { prayer [ ' mend ' ] * 6000 : g } % HP / min in fights " )
2026-10-18 23:09:17 +01:00
if prayer . get ( " wind " ) :
2026-10-20 08:01:57 +01:00
parts . append ( " once per outing, revive at half HP " )
2026-10-18 23:09:17 +01:00
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 ,
2026-10-20 20:44:20 +01:00
" mend " : sum ( p . get ( " mend " , 0 ) for p in active ) , " wind " : any ( p . get ( " wind " ) for p in active ) }
2026-10-18 23:09:17 +01:00
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 :
2026-10-19 11:15:19 +01:00
return action [ " pp " ] + building_level ( world , " temple " ) + fitted ( world , " carved_altar " )
2026-10-18 23:09:17 +01:00
def ward ( world : dict ) - > float :
""" Defence the temple ' s devotion adds to the next raid. """
2026-10-19 11:15:19 +01:00
cap = 15 * building_level ( world , " temple " ) + 15 * fitted ( world , " carved_altar " )
return round ( min ( world [ " city " ] . get ( " devotion " , 0 ) / 4 , cap ) , 1 )
2026-10-18 19:27:56 +01:00
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
2026-10-18 13:48:56 +01:00
def action_time ( world : dict , player : dict , action : dict ) - > float :
seconds = action [ " time " ]
if action [ " skill " ] in CLASSES [ player [ " cls " ] ] [ " fast " ] :
seconds * = CLASS_SPEED
2026-10-19 23:17:23 +01:00
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
2026-10-18 19:27:56 +01:00
tool = player [ " equip " ] . get ( f " tool_ { action [ ' skill ' ] } " )
if tool :
seconds * = 1 - ITEMS [ tool ] [ " speed " ]
2026-10-20 21:20:25 +01:00
station = player [ " equip " ] . get ( f " station_ { action [ ' skill ' ] } " )
2026-10-21 14:37:52 +01:00
if station and ( action [ " outputs " ] or action . get ( " special " ) == " labour " ) :
2026-10-20 21:20:25 +01:00
seconds * = 1 - ITEMS [ station ] [ " speed " ]
2026-10-18 13:48:56 +01:00
if action [ " skill " ] == " smithing " :
seconds * = 1 - 0.08 * building_level ( world , " smithy " )
2026-10-19 11:15:19 +01:00
seconds * = 0.9 if fitted ( world , " bellows " ) else 1
if action [ " id " ] . startswith ( " saw_ " ) and fitted ( world , " saw_benches " ) :
seconds * = 0.8
2026-10-18 23:12:57 +01:00
seconds * = 1 - potion_effect ( player , action [ " skill " ] ) . get ( " speed " , 0 )
2026-10-19 09:34:18 +01:00
seconds * = skill_mods ( world , action ) [ " speed " ]
2026-10-24 13:34:59 +01:00
seconds * = ware ( player , " speed " , action [ " skill " ] )
2026-10-18 13:48:56 +01:00
return seconds / speed ( world )
2026-10-19 23:17:23 +01:00
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
2026-10-18 23:12:57 +01:00
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
2026-10-19 22:45:32 +01:00
fresh = round ( POTION_CHARGES * ( 1.25 if player [ " cls " ] == " homesteader " else 1 ) )
player [ " potions " ] [ spec [ " skill " ] ] = { " item " : item , " charges " : charges + fresh }
2026-10-18 23:12:57 +01:00
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 " ] )
2026-10-21 12:16:22 +01:00
# --- fires -----------------------------------------------------------------
2026-10-21 13:14:03 +01:00
# Each fire is a list of fuel: {tier, left (game seconds), by (who fed it)}. Everyone who
# feeds a fire keeps their own pile in it, up to their own bank, so a full fire never
# shuts a beginner out. The fire burns a game minute a minute, shared evenly between the
# piles, so every log adds to how long it lasts; each pile burns its hottest fuel first,
# and the fire is as hot as its hottest pile.
2026-10-21 12:16:22 +01:00
def new_fire ( now : float ) - > dict :
return { " fuel " : [ ] , " at " : now , " saved " : 0 }
def burn_fires ( world : dict , now : float ) - > None :
""" Burn every fire down to `now`. Never runs backwards. """
for fire in world [ " city " ] [ " fires " ] . values ( ) :
if now < = fire [ " at " ] :
continue
seconds = ( now - fire [ " at " ] ) * speed ( world )
fire [ " at " ] = now
fuel = fire [ " fuel " ]
2026-10-21 13:14:03 +01:00
while seconds > 1e-9 and fuel :
# Each pile burns its best fuel at 1/n of the pace, until the first of those runs out.
heads = [ burning ( fuel , by ) for by in { chunk [ " by " ] for chunk in fuel } ]
step = min ( seconds , min ( chunk [ " left " ] for chunk in heads ) * len ( heads ) )
for chunk in heads :
chunk [ " left " ] - = step / len ( heads )
if chunk [ " left " ] < = 1e-6 :
fuel . remove ( chunk )
seconds - = step
2026-10-21 12:16:22 +01:00
2026-10-21 13:14:03 +01:00
def burning ( fuel : list , by : str | None = None ) - > dict | None :
""" The fuel burning hottest (in one pile, with `by`): the hottest, and the oldest of those. """
return max ( ( chunk for chunk in fuel if by is None or chunk [ " by " ] == by ) , key = lambda chunk : chunk [ " tier " ] , default = None )
2026-10-21 12:16:22 +01:00
def heat ( world : dict , fire : str ) - > int :
""" The tier of a fire ' s flame, or -1 when it ' s out. """
2026-10-21 13:14:03 +01:00
chunk = burning ( world [ " city " ] [ " fires " ] [ fire ] [ " fuel " ] )
2026-10-21 12:16:22 +01:00
return chunk [ " tier " ] if chunk else - 1
2026-10-21 13:14:03 +01:00
def _room ( fire : dict , player : dict , tier : int ) - > float :
""" Game seconds of the player ' s own cheaper fuel a log of `tier` would push out, or -1 if it won ' t fit. """
level = skill_level ( player , " firekeeping " )
mine = [ chunk for chunk in fire [ " fuel " ] if chunk [ " by " ] == player [ " id " ] ]
short = sum ( chunk [ " left " ] for chunk in mine ) + fire_minutes ( level ) * 60 - fire_bank ( level ) * 60
cheaper = sum ( chunk [ " left " ] for chunk in mine if chunk [ " tier " ] < tier )
2026-10-21 12:16:22 +01:00
return - 1 if short > cheaper + 1e-6 else max ( 0.0 , short )
def feed_fire ( world : dict , player : dict , fire_id : str , tier : int ) - > float :
2026-10-21 13:14:03 +01:00
""" Put one log on your pile in a fire, pushing out your cheapest fuel if it ' s full. Returns the minutes added. """
2026-10-21 12:16:22 +01:00
fire , level = world [ " city " ] [ " fires " ] [ fire_id ] , skill_level ( player , " firekeeping " )
2026-10-21 13:14:03 +01:00
short = _room ( fire , player , tier )
2026-10-21 12:16:22 +01:00
while short > 1e-6 :
2026-10-21 13:14:03 +01:00
cheapest = min ( ( chunk for chunk in fire [ " fuel " ] if chunk [ " by " ] == player [ " id " ] and chunk [ " tier " ] < tier ) ,
key = lambda chunk : chunk [ " tier " ] )
2026-10-21 12:16:22 +01:00
out = min ( short , cheapest [ " left " ] )
cheapest [ " left " ] - = out
short - = out
if cheapest [ " left " ] < = 1e-6 :
fire [ " fuel " ] . remove ( cheapest )
minutes = fire_minutes ( level )
last = fire [ " fuel " ] [ - 1 ] if fire [ " fuel " ] else None
if last and last [ " tier " ] == tier and last [ " by " ] == player [ " id " ] :
last [ " left " ] + = minutes * 60
else :
fire [ " fuel " ] . append ( { " tier " : tier , " left " : minutes * 60 , " by " : player [ " id " ] } )
return minutes
def fire_inputs ( world : dict , action : dict ) - > dict :
""" What a craft takes right now: a hot enough fire pays its log, and the forge a coal (never the last). """
fire = action . get ( " fire " )
if not fire or heat ( world , fire ) < action [ " fuel " ] :
return action [ " inputs " ]
inputs = dict ( action [ " inputs " ] )
log = FIREWOOD [ action [ " fuel " ] ]
inputs [ log ] - = 1
if not inputs [ log ] :
del inputs [ log ]
if fire == " forge " and inputs . get ( " coal " , 0 ) > 1 :
inputs [ " coal " ] - = 1
return inputs
def _warm ( world : dict , crafter : dict , action : dict , xp : float , now : float ) - > None :
2026-10-21 13:22:20 +01:00
""" A fire paid for a craft: count it, and share the warmth between everyone whose
pile alone was hot enough for it ( a beginner ' s logs count for bricks and copper). " " "
2026-10-21 12:16:22 +01:00
fire = world [ " city " ] [ " fires " ] [ action [ " fire " ] ]
fire [ " saved " ] = fire . get ( " saved " , 0 ) + 1
2026-10-21 13:22:20 +01:00
keepers = [ world [ " players " ] . get ( by ) for by in { chunk [ " by " ] for chunk in fire [ " fuel " ] }
if burning ( fire [ " fuel " ] , by ) [ " tier " ] > = action [ " fuel " ] ]
keepers = [ keeper for keeper in keepers if keeper and keeper is not crafter ]
for keeper in keepers :
gain_xp ( world , keeper , " firekeeping " , xp * WARMTH / len ( keepers ) , now , keeper . setdefault ( " away " , empty_summary ( ) ) )
2026-10-21 12:16:22 +01:00
def fires_view ( world : dict , now : float ) - > dict :
burn_fires ( world , now )
users = { }
for player in world [ " players " ] . values ( ) :
action = player [ " action " ] and ACTION_BY_ID . get ( player [ " action " ] [ " id " ] )
if action and ( action . get ( " fire " ) or action . get ( " tend " ) ) :
users . setdefault ( action . get ( " fire " ) or action [ " tend " ] , [ ] ) . append (
{ " id " : player [ " id " ] , " name " : player [ " name " ] , " action " : action [ " id " ] } )
return { key : { * * fire , " heat " : heat ( world , key ) , " speed " : speed ( world ) , " users " : users . get ( key , [ ] ) }
for key , fire in world [ " city " ] [ " fires " ] . items ( ) }
2026-10-18 13:48:56 +01:00
def upcoming_raid ( world : dict ) - > dict | None :
2026-10-19 09:34:18 +01:00
""" The coming major threat: a bandit raid or a dragon. """
2026-10-18 13:48:56 +01:00
for event in world [ " events " ] :
2026-10-19 09:34:18 +01:00
if event [ " status " ] == " upcoming " and event [ " kind " ] in ( " raid " , " dragon " ) :
2026-10-18 13:48:56 +01:00
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 ' ] } "
2026-10-24 13:48:16 +01:00
if action . get ( " pattern " ) and action [ " pattern " ] not in player . get ( " patterns " , [ ] ) :
return " Learn its pattern first "
2026-10-18 13:48:56 +01:00
for key , level in action . get ( " city " , { } ) . items ( ) :
if building_level ( world , key ) < level :
return f " Needs { BUILDINGS [ key ] [ ' name ' ] } level { level } "
2026-10-20 18:01:02 +01:00
order = working_order ( world , player , action )
if order is not None or " order " in ( player [ " action " ] or { } ) and player [ " action " ] [ " id " ] == action [ " id " ] :
if not order or order [ " status " ] != " open " or order [ " claimed " ] != player [ " id " ] :
return " Work order done " if order and order [ " status " ] == " done " else " Work order closed "
2026-10-21 12:16:22 +01:00
for item , qty in fire_inputs ( world , action ) . items ( ) :
2026-10-21 13:40:54 +01:00
pooled = order [ " provided " ] . get ( item , 0 ) if order else 0
if pooled + player [ " bank " ] . get ( item , 0 ) < qty :
2026-10-18 13:48:56 +01:00
return f " Out of { ITEMS [ item ] [ ' name ' ] . lower ( ) } "
2026-10-21 13:14:03 +01:00
if action . get ( " special " ) == " tend " and _room ( world [ " city " ] [ " fires " ] [ action [ " tend " ] ] , player , action [ " tier " ] ) < 0 :
return f " Your pile in the { FIRES [ action [ ' tend ' ] ] [ ' name ' ] . lower ( ) } is full "
2026-10-18 13:48:56 +01:00
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 "
2026-10-19 09:34:18 +01:00
if raid [ " kind " ] != " raid " :
return " A dragon has no camp to sabotage "
2026-10-18 13:48:56 +01:00
if raid [ " sabotage " ] > = 0.3 :
return " The camp is already in ruins "
2026-10-19 09:24:45 +01:00
if action . get ( " special " ) == " labour " and not world [ " city " ] [ " construction " ] :
return " Nothing is being built "
2026-10-19 09:34:18 +01:00
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
2026-10-19 15:21:49 +01:00
if action . get ( " special " ) == " drill " :
threat = upcoming_raid ( world )
if not threat :
return " No raid to prepare for "
2026-10-19 15:35:35 +01:00
if threat . get ( " drilled " , 0 ) > = drill_needed ( threat ) :
2026-10-19 15:21:49 +01:00
return " The militia is as drilled as it can be "
2026-10-18 13:48:56 +01:00
return None
2026-10-21 22:06:49 +01:00
def _complete ( world : dict , player : dict , action : dict , now : float , rng : random . Random , summary : dict ,
fight : dict | None = None , loot : tuple | None = None ) - > None :
""" Finish one action. A shared fight comes in as `fight`, with this member ' s share of its `loot`. """
2026-10-18 23:12:57 +01:00
brew = potion_effect ( player , action [ " skill " ] )
2026-10-20 18:01:02 +01:00
order = working_order ( world , player , action )
2026-10-21 12:16:22 +01:00
inputs = fire_inputs ( world , action )
warmed = inputs is not action [ " inputs " ]
2026-10-21 13:40:54 +01:00
own , used = dict ( inputs ) , { }
2026-10-20 18:01:02 +01:00
if order :
2026-10-21 13:40:54 +01:00
used = _draw ( order , inputs )
for share in used . values ( ) :
for item , qty in share . items ( ) :
own [ item ] - = qty
own = { item : qty for item , qty in own . items ( ) if qty }
2026-10-20 18:01:02 +01:00
if own and rng . random ( ) < brew . get ( " keep " , 0 ) :
2026-10-18 23:12:57 +01:00
summary [ " kept " ] + = 1
else :
2026-10-20 18:01:02 +01:00
for item , qty in own . items ( ) :
2026-10-18 23:12:57 +01:00
bank_take ( player , item , qty )
2026-10-18 13:48:56 +01:00
xp = action [ " xp " ]
2026-10-19 22:45:32 +01:00
cls = CLASSES [ player [ " cls " ] ]
lucky = bool ( action [ " outputs " ] ) and (
action [ " skill " ] in cls [ " craft " ] or ( player [ " cls " ] == " miner " and action [ " skill " ] == " mining " )
2026-10-20 22:15:57 +01:00
) and rng . random ( ) < LUCKY_CHANCE
2026-10-18 13:48:56 +01:00
outputs = { item : qty * ( 2 if lucky else 1 ) for item , qty in action [ " outputs " ] . items ( ) }
if lucky :
summary [ " lucky " ] + = 1
2026-10-18 23:12:57 +01:00
if brew . get ( " double " ) and rng . random ( ) < brew [ " double " ] :
for item , qty in action [ " outputs " ] . items ( ) :
outputs [ item ] + = qty
summary [ " lucky " ] + = 1
2026-10-20 22:15:57 +01:00
# Lucky charm doubles gathering, loupe doubles crafting, for any class.
jewel = " double " if action [ " skill " ] in GATHERING else " craft_double " if action [ " skill " ] in CRAFTING else None
if jewel and action [ " outputs " ] and rng . random ( ) < gear_bonus ( player , jewel ) / 100 :
2026-10-20 19:59:45 +01:00
for item , qty in action [ " outputs " ] . items ( ) :
outputs [ item ] + = qty
summary [ " lucky " ] + = 1
2026-10-19 09:34:18 +01:00
mods = skill_mods ( world , action )
2026-10-24 13:34:59 +01:00
grow = mods [ " yield " ] * ( ware ( player , " yield " , action [ " skill " ] , spend = True ) if action [ " outputs " ] else 1 )
if grow != 1 :
2026-10-19 09:34:18 +01:00
for item , qty in list ( outputs . items ( ) ) :
2026-10-24 13:34:59 +01:00
exact = qty * grow
2026-10-19 09:34:18 +01:00
outputs [ item ] = int ( exact ) + ( 1 if rng . random ( ) < exact - int ( exact ) else 0 )
2026-10-20 19:59:45 +01:00
for item , qty , odds in action . get ( " finds " , [ ] ) :
# A gem in the rock: a masterwork (a prospector's double ore) turns up two.
if rng . random ( ) < odds :
outputs [ item ] = outputs . get ( item , 0 ) + qty * ( 2 if lucky else 1 )
2026-10-19 09:34:18 +01:00
xp * = mods [ " xp " ]
2026-10-18 13:48:56 +01:00
2026-10-19 23:32:07 +01:00
if " enemy " in action :
2026-10-18 23:09:17 +01:00
outing = player [ " action " ]
2026-10-24 12:05:23 +01:00
fight = fight or outing . pop ( " fight " , None ) or simulate_fight ( world , player , action , rng , outing [ " start " ] )
2026-10-21 22:06:49 +01:00
mine = member_of ( fight , player [ " id " ] )
2026-10-24 13:34:59 +01:00
ware ( player , " rest " , " combat " , spend = True )
2026-10-24 09:49:21 +01:00
xp = pack_xp ( fight , killed_only = mine [ " result " ] != " won " ) * mods [ " xp " ]
2026-10-21 22:06:49 +01:00
if player [ " prayers " ] and not mine [ " pp " ] :
2026-10-19 23:32:07 +01:00
summary [ " prayer_out " ] = True
2026-10-21 22:06:49 +01:00
paid = min ( player [ " pp " ] , mine [ " pp " ] )
2026-10-19 23:32:07 +01:00
if paid :
player [ " pp " ] - = paid
summary [ " pp_spent " ] + = paid
gain_xp ( world , player , " prayer " , paid * PRAYER_XP_PER_POINT , now , summary )
2026-10-24 14:21:08 +01:00
used = { mine [ " potion_item " ] : 1 } if mine [ " drank " ] else { }
2026-10-24 09:49:21 +01:00
for key , spent in ( ( " eaten " , mine [ " eaten " ] ) , ( " used " , used ) ) :
for item , count in spent . items ( ) :
count = min ( count , player [ " bank " ] . get ( item , 0 ) )
if count :
bank_take ( player , item , count )
summary [ key ] [ item ] = summary [ key ] . get ( item , 0 ) + count
player [ " hp " ] = max_hp ( player ) * KNOCKED_OUT_HP if mine [ " down " ] else mine [ " hp " ]
player [ " hp_at " ] = now
2026-10-21 22:06:49 +01:00
summary [ " damage " ] + = mine [ " damage " ]
if mine [ " wind_used " ] :
2026-10-18 23:09:17 +01:00
outing [ " wind_used " ] = True
summary [ " second_wind " ] + = 1
2026-10-21 22:06:49 +01:00
if mine [ " result " ] != " won " :
2026-10-24 09:49:21 +01:00
# Knocked out, or the fight dragged on: nothing but some XP, and back to resting.
2026-10-18 19:27:56 +01:00
summary [ " losses " ] + = 1
2026-10-24 09:49:21 +01:00
gain_xp ( world , player , " combat " , xp , now , summary )
2026-10-24 12:05:23 +01:00
outing [ " downs " ] = 0 if action . get ( " dungeon " ) else outing . get ( " downs " , 0 ) + 1
reason = None if action . get ( " dungeon " ) else " Fell back: the fight dragged on " if mine [ " result " ] == " fled " else \
2026-10-24 09:49:21 +01:00
f " Knocked out { KNOCKOUTS_TO_STOP } times in a row " if outing [ " downs " ] > = KNOCKOUTS_TO_STOP else None
if reason :
drop_out ( world , player , now , reason )
summary [ " stopped " ] = reason
2026-10-18 19:27:56 +01:00
return
2026-10-24 09:49:21 +01:00
outing . pop ( " downs " , None )
2026-10-19 23:32:07 +01:00
summary [ " wins " ] + = 1
2026-10-24 09:49:21 +01:00
gold , drops = loot or pack_loot ( action , fight , rng )
2026-10-24 13:34:59 +01:00
if ware ( player , " drops " , " combat " , spend = True ) != 1 :
# A whetstone's edge: another go at the pack's trophies and reagents.
drops = dict ( drops )
for item , qty in pack_loot ( action , fight , rng ) [ 1 ] . items ( ) :
if ITEMS [ item ] . get ( " trophy " ) or ITEMS [ item ] . get ( " reagent " ) :
drops [ item ] = drops . get ( item , 0 ) + qty
if any ( foe . get ( " boss " ) for foe in fight [ " pack " ] ) :
_deed ( player , " bosses " )
emblem = f " boss_ { action [ ' id ' ] } "
if emblem in COSMETICS and emblem not in player [ " owned " ] :
player [ " owned " ] . append ( emblem )
2026-10-20 14:21:34 +01:00
player [ " gold " ] + = gold
summary [ " gold " ] + = gold
2026-10-21 22:06:49 +01:00
for item , qty in drops . items ( ) :
outputs [ item ] = outputs . get ( item , 0 ) + qty
2026-10-19 23:32:07 +01:00
if action . get ( " special " ) == " sabotage " :
raid = upcoming_raid ( world )
2026-10-24 11:43:56 +01:00
raid [ " sabotage " ] = min ( 0.3 , raid [ " sabotage " ] + 0.01 )
2026-10-19 23:32:07 +01:00
if action . get ( " special " ) == " problem " :
_work_problem ( world , player , action , now )
2026-10-18 13:48:56 +01:00
if action . get ( " special " ) == " recon " :
2026-10-19 15:09:51 +01:00
event = upcoming_raid ( world )
2026-10-19 15:39:02 +01:00
# 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 ] :
2026-10-19 15:14:00 +01:00
city = world [ " city " ]
2026-10-19 22:45:32 +01:00
gain = recon_gain ( world , player , action )
2026-10-19 15:14:00 +01:00
# 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
2026-10-19 15:39:02 +01:00
event [ " recon " ] = min ( intel_costs ( event ) [ - 1 ] , event [ " recon " ] + gain + boost )
2026-10-19 09:34:18 +01:00
if action . get ( " special " ) == " problem " and action [ " skill " ] != " combat " :
_work_problem ( world , player , action , now )
2026-10-19 15:21:49 +01:00
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 " ]
2026-10-19 15:35:35 +01:00
threat [ " drilled " ] = min ( drill_needed ( threat ) , threat . get ( " drilled " , 0 ) + gain + boost )
2026-10-21 12:16:22 +01:00
if action . get ( " special " ) == " tend " :
xp = TEND_XP [ action [ " tier " ] ] * feed_fire ( world , player , action [ " tend " ] , action [ " tier " ] ) * mods [ " xp " ]
if warmed :
_warm ( world , player , action , xp , now )
2026-10-19 09:24:45 +01:00
if action . get ( " special " ) == " labour " :
2026-10-19 11:01:29 +01:00
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
2026-10-19 09:24:45 +01:00
if site [ " labour " ] > = site [ " needed " ] :
_finish_construction ( world , now )
_start_next ( world , now )
2026-10-18 23:12:57 +01:00
bonus = 1 + brew . get ( " bones " , 0 )
2026-10-18 23:09:17 +01:00
if action . get ( " special " ) == " bury " :
2026-10-18 23:12:57 +01:00
player [ " pp " ] + = bone_points ( world , action ) * bonus
summary [ " pp_gained " ] + = bone_points ( world , action ) * bonus
2026-10-18 23:09:17 +01:00
if action . get ( " special " ) == " offer " :
city = world [ " city " ]
2026-10-18 23:12:57 +01:00
city [ " devotion " ] = city . get ( " devotion " , 0 ) + action [ " devotion " ] * ( 1 + 0.25 * building_level ( world , " temple " ) ) * bonus
2026-10-19 15:21:49 +01:00
if action . get ( " special " ) != " drill " :
_use_charge ( player , action [ " skill " ] , summary )
2026-10-18 13:48:56 +01:00
2026-10-20 18:01:02 +01:00
if order and order [ " status " ] == " open " :
# The order's share goes to the requester; a masterwork's extra stays with the crafter.
sent = min ( outputs . get ( order [ " item " ] , 0 ) , order [ " qty " ] - order [ " delivered " ] )
if sent :
outputs [ order [ " item " ] ] - = sent
2026-10-21 13:40:54 +01:00
_deliver ( world , order , player , sent , now , used , inputs )
2026-10-20 18:01:02 +01:00
summary [ " delivered " ] = summary . get ( " delivered " , 0 ) + sent
2026-10-18 13:48:56 +01:00
for item , qty in outputs . items ( ) :
2026-10-20 18:01:02 +01:00
if not qty :
continue
2026-10-18 22:40:36 +01:00
kept = bank_add ( world , player , item , qty , now , summary )
2026-10-18 13:48:56 +01:00
if kept :
summary [ " items " ] [ item ] = summary [ " items " ] . get ( item , 0 ) + kept
2026-10-24 13:34:59 +01:00
xp * = ware ( player , " xp " , action [ " skill " ] , spend = True )
ware ( player , " speed " , action [ " skill " ] , spend = True )
2026-10-18 13:48:56 +01:00
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 )
2026-10-20 19:59:45 +01:00
xp * = 1 + gear_bonus ( player , " xp " ) / 100
2026-10-18 13:48:56 +01:00
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 :
2026-10-24 09:49:21 +01:00
return { " done " : 0 , " items " : { } , " overflow " : { } , " xp " : { } , " levels " : [ ] , " eaten " : { } , " used " : { } , " damage " : 0 ,
2026-10-18 22:40:36 +01:00
" gold " : 0 , " wins " : 0 , " losses " : 0 , " lucky " : 0 , " stopped " : None , " resumed " : None ,
2026-10-18 23:12:57 +01:00
" sold " : { } , " sold_gold " : 0 , " pp_spent " : 0 , " pp_gained " : 0 , " prayer_out " : False , " second_wind " : 0 ,
2026-10-19 09:24:45 +01:00
" kept " : 0 , " potions_drunk " : { } , " potion_out " : [ ] , " labour " : 0 }
2026-10-18 13:48:56 +01:00
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 ( )
2026-10-20 18:01:02 +01:00
collect_orders ( world , player )
2026-10-18 13:48:56 +01:00
current = player [ " action " ]
2026-10-21 22:06:49 +01:00
if current and current . get ( " party " ) :
if current [ " party " ] in world [ " parties " ] :
process_party ( world , current [ " party " ] , now , rng )
else :
current . pop ( " party " )
current = player [ " action " ]
if current and current . get ( " party " ) :
return summary # a party's kills land in each member's away summary
2026-10-18 13:48:56 +01:00
if not current :
return summary
2026-10-21 22:06:49 +01:00
limit = min ( now , offline_limit ( world , player ) )
2026-10-24 09:49:21 +01:00
if ACTION_BY_ID [ current [ " id " ] ] . get ( " together " ) :
# A group fight with no group: wait at it for someone to tag in.
current . pop ( " fight " , None )
current [ " start " ] = max ( current [ " start " ] , limit )
return summary
2026-10-18 13:48:56 +01:00
while player [ " action " ] :
2026-10-19 23:17:23 +01:00
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 )
2026-10-19 23:32:07 +01:00
if " enemy " in action and not can_start ( world , player , action ) :
if not current . get ( " fight " ) :
2026-10-20 20:44:20 +01:00
regen ( world , player , current [ " start " ] )
2026-10-24 12:05:23 +01:00
current [ " fight " ] = simulate_fight ( world , player , action , rng , current [ " start " ] )
2026-10-19 23:32:07 +01:00
duration = current [ " fight " ] [ " duration " ]
else :
duration = action_time ( world , player , action )
2026-10-18 13:48:56 +01:00
if current [ " start " ] + duration > limit :
break
2026-10-21 12:16:22 +01:00
burn_fires ( world , current [ " start " ] + duration )
2026-10-18 13:48:56 +01:00
reason = can_start ( world , player , action )
if reason :
stop ( player , reason )
summary [ " stopped " ] = reason
break
if player [ " overflow " ] == " stop " and any ( bank_room ( world , player , item ) < qty
for item , qty in action [ " outputs " ] . items ( ) ) :
stop ( player , " Bank full " )
summary [ " stopped " ] = " Bank full "
break
_complete ( world , player , action , current [ " start " ] + duration , rng , summary )
summary [ " done " ] + = 1
current [ " start " ] + = duration
2026-10-19 23:17:23 +01:00
if current . get ( " plot2 " ) :
current [ " turn " ] = 1 - current . get ( " turn " , 0 )
2026-10-18 19:35:21 +01:00
if player [ " action " ] is current and " left " in current :
current [ " left " ] - = 1
2026-10-25 20:54:37 +00:00
if current [ " left " ] < = 0 and finish_once ( player , current [ " start " ] ) :
current = player [ " action " ]
summary [ " resumed " ] = current [ " id " ]
2026-10-20 19:43:45 +01:00
if player [ " action " ] is current and " enemy " not in action and not current . get ( " plot2 " ) :
# Out of materials: stop now, not once the next bar has filled.
reason = can_start ( world , player , growing ( player ) )
if reason :
stop ( player , reason )
summary [ " stopped " ] = reason
break
2026-10-18 13:48:56 +01:00
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
2026-10-25 20:54:37 +00:00
def finish_once ( player : dict , at : float , reason : str | None = None ) - > bool :
""" A one-off is done: back to what you were doing before (True), or stop. """
current = player [ " action " ]
back = current . get ( " resume " )
if not back :
stop ( player , reason )
return False
player [ " action " ] = { " id " : back , " start " : at }
if current . get ( " resume_plot2 " ) :
player [ " action " ] . update ( plot2 = current [ " resume_plot2 " ] , turn = 0 )
return True
2026-10-21 22:06:49 +01:00
def drop_out ( world : dict , player : dict , now : float , reason : str | None = None ) - > None :
""" Stop, stepping out of a shared fight if you ' re in one. """
current = player [ " action " ]
if current and current . get ( " party " ) in world [ " parties " ] :
leave_party ( world , player , current [ " party " ] , now )
stop ( player , reason )
2026-10-24 09:49:21 +01:00
def roll_loot ( action : dict , rng : random . Random , share : float = 1.0 ) - > tuple [ int , dict ] :
""" A kill ' s gold and drops. A weaker enemy in a pack (`share`) carries less, and drops less often. """
gold = round ( rng . randint ( * action [ " gold " ] ) * share ) if action . get ( " gold " ) else 0
2026-10-21 22:06:49 +01:00
drops = { }
for item , qty , odds in action . get ( " drops " , [ ] ) :
2026-10-24 09:49:21 +01:00
if rng . random ( ) < odds * share :
drops [ item ] = drops . get ( item , 0 ) + qty
return gold , drops
def pack_loot ( action : dict , fight : dict , rng : random . Random ) - > tuple [ int , dict ] :
""" Everything the pack carried. """
gold , drops = 0 , { }
2026-10-24 12:05:23 +01:00
kills = set ( fight [ " kills " ] )
for i , foe in enumerate ( fight [ " pack " ] ) :
if f " e { i } " not in kills :
continue
more , found = roll_loot ( ACTION_BY_ID [ foe [ " loot " ] ] if foe . get ( " loot " ) else action , rng , foe [ " share " ] )
2026-10-24 13:48:16 +01:00
for item , qty , odds in foe . get ( " extra_drops " , [ ] ) :
if rng . random ( ) < odds :
found [ item ] = found . get ( item , 0 ) + qty
2026-10-24 09:49:21 +01:00
gold + = more
for item , qty in found . items ( ) :
2026-10-21 22:06:49 +01:00
drops [ item ] = drops . get ( item , 0 ) + qty
return gold , drops
def share_loot ( loot : tuple [ int , dict ] , uids : list , rng : random . Random ) - > dict :
""" Gold split evenly (the odd coins to whoever ' s lucky), each drop to someone at random. """
gold , drops = loot
each , odd = divmod ( gold , len ( uids ) )
lucky = set ( rng . sample ( uids , odd ) )
shares = { uid : ( each + ( uid in lucky ) , { } ) for uid in uids }
for item , qty in drops . items ( ) :
for _ in range ( qty ) :
got = shares [ rng . choice ( uids ) ] [ 1 ]
got [ item ] = got . get ( item , 0 ) + 1
return shares
# --- fighting together ------------------------------------------------------
# Anyone can tag into someone else's fight: from then on they're a party on that
# encounter, every enemy fought by all of them at once. Everyone gets the kill's full
2026-10-22 14:08:41 +01:00
# XP; its gold is split evenly and each drop goes to someone at random, except on
# bosses and dragons, where everyone rolls their own loot as if alone. The party
2026-10-21 22:06:49 +01:00
# lasts until all but one have left (stopped, retreated, or been away too long), and
# the last one carries on alone.
def offline_limit ( world : dict , player : dict ) - > float :
return player [ " last_active " ] + OFFLINE_CAP / speed ( world )
def joinable ( world : dict , player : dict ) - > bool :
""" Whether someone ' s fight is one others can tag into. """
current = player [ " action " ]
2026-10-25 20:54:37 +00:00
if not current or " order " in current :
2026-10-21 22:06:49 +01:00
return False
action = ACTION_BY_ID [ current [ " id " ] ]
2026-10-24 12:05:23 +01:00
if action . get ( " dungeon " ) :
party = world [ " parties " ] . get ( current . get ( " party " ) )
2026-10-25 19:21:37 +00:00
return not party or ( " run " not in party and len ( party [ " members " ] ) < DUNGEON_SIZE )
2026-10-21 22:06:49 +01:00
return " enemy " in action and not action . get ( " special " )
def current_fight ( world : dict , player : dict ) - > dict | None :
current = player [ " action " ] or { }
party = world [ " parties " ] . get ( current . get ( " party " ) )
return party [ " fight " ] if party else current . get ( " fight " )
def party_of ( world : dict , player : dict ) - > dict | None :
return world [ " parties " ] . get ( ( player [ " action " ] or { } ) . get ( " party " ) )
def join_fight ( world : dict , player : dict , uid : str , now : float ) - > str | None :
""" Tag into someone ' s fight, swinging at the enemy they ' re on right now. """
host = world [ " players " ] . get ( uid )
if not host or host is player :
return " No one to join "
2026-10-24 12:05:23 +01:00
run = party_of ( world , host )
if run and ACTION_BY_ID [ run [ " action " ] ] . get ( " dungeon " ) and run is not party_of ( world , player ) :
2026-10-25 19:21:37 +00:00
if " run " in run :
2026-10-24 12:05:23 +01:00
return " The run has started "
if len ( run [ " members " ] ) > = DUNGEON_SIZE :
return " The group is full "
2026-10-21 22:06:49 +01:00
if not joinable ( world , host ) :
return f " { host [ ' name ' ] } isn ' t fighting "
if offline_limit ( world , host ) < = now :
return f " { host [ ' name ' ] } is away "
party = party_of ( world , host )
if party and party is party_of ( world , player ) :
return " Already fighting together "
action = ACTION_BY_ID [ host [ " action " ] [ " id " ] ]
reason = can_start ( world , player , action )
if reason :
return reason
if party_of ( world , player ) :
leave_party ( world , player , player [ " action " ] [ " party " ] , now )
if not party :
# Someone fighting alone becomes a party of one, their fight as it stands.
pid = str ( world [ " next_party_id " ] )
world [ " next_party_id " ] + = 1
solo = host [ " action " ]
2026-10-24 09:49:21 +01:00
if action . get ( " together " ) :
solo [ " start " ] = now # waiting for a group: it starts as you arrive
2026-10-21 22:06:49 +01:00
party = world [ " parties " ] [ pid ] = { " id " : pid , " action " : solo [ " id " ] , " members " : [ host [ " id " ] ] ,
" start " : solo [ " start " ] , " fight " : solo . pop ( " fight " , None ) }
solo [ " party " ] = pid
party [ " members " ] . append ( player [ " id " ] )
player [ " action " ] = { " id " : party [ " action " ] , " start " : party [ " start " ] , " party " : party [ " id " ] }
player [ " stopped " ] = None
fight = party [ " fight " ]
if fight and now < party [ " start " ] + fight [ " duration " ] :
2026-10-24 09:49:21 +01:00
# Straight in, as you are: the others may still be resting, or already in the thick of it.
regen ( world , player , now )
at = max ( 0.0 , ( now - party [ " start " ] ) * fight [ " pace " ] - fight [ " rest " ] )
fight [ " members " ] . append ( fight_member ( world , player , at ) )
play_fight ( fight )
2026-10-21 22:06:49 +01:00
log ( world , now , " party " , f " { player [ ' name ' ] } joined { host [ ' name ' ] } : { action [ ' name ' ] } " , player [ " id " ] )
return None
def leave_party ( world : dict , player : dict , pid : str , now : float ) - > None :
""" Step out of a party at `now`. The fight goes on without you (you get nothing from it); whoever ' s
left alone carries on by themselves . Setting your own action is up to the caller . """
party = world [ " parties " ] [ pid ]
party [ " members " ] . remove ( player [ " id " ] )
fight = party [ " fight " ]
if fight and now < party [ " start " ] + fight [ " duration " ] :
member = member_of ( fight , player [ " id " ] )
2026-10-24 09:49:21 +01:00
left = ( now - party [ " start " ] ) * fight [ " pace " ] - fight [ " rest " ]
if left < = member [ " at " ] or len ( fight [ " members " ] ) == 1 :
fight [ " members " ] . remove ( member ) # gone before it started: nothing eaten, nothing fought
2026-10-21 22:06:49 +01:00
else :
2026-10-24 09:49:21 +01:00
member [ " left " ] = left
party [ " fight " ] = play_fight ( fight ) if party [ " members " ] and fight [ " members " ] else None
2026-10-21 22:06:49 +01:00
if len ( party [ " members " ] ) < = 1 :
del world [ " parties " ] [ pid ]
for uid in party [ " members " ] :
last = world [ " players " ] [ uid ] [ " action " ]
last . pop ( " party " , None )
last . update ( start = party [ " start " ] , * * ( { " fight " : party [ " fight " ] } if party [ " fight " ] else { } ) )
def process_party ( world : dict , pid : str , now : float , rng : random . Random ) - > None :
""" Run a party ' s fights forward to `now`: every kill lands for everyone in it at once. What
each member gets waits in their away summary . """
party = world [ " parties " ] [ pid ]
action = ACTION_BY_ID [ party [ " action " ] ]
for uid in list ( party [ " members " ] ) :
if ( world [ " players " ] [ uid ] [ " action " ] or { } ) . get ( " party " ) != pid and world [ " parties " ] . get ( pid ) is party :
leave_party ( world , world [ " players " ] [ uid ] , pid , party [ " start " ] ) # moved on without saying
while world [ " parties " ] . get ( pid ) is party :
members = [ world [ " players " ] [ uid ] for uid in party [ " members " ] ]
if not party [ " fight " ] :
for player in members :
regen ( world , player , party [ " start " ] )
reason = can_start ( world , player , action )
if reason :
drop_out ( world , player , party [ " start " ] , reason )
player . setdefault ( " away " , empty_summary ( ) ) [ " stopped " ] = reason
if world [ " parties " ] . get ( pid ) is not party :
break
members = [ world [ " players " ] [ uid ] for uid in party [ " members " ] ]
2026-10-24 12:05:23 +01:00
if action . get ( " dungeon " ) :
2026-10-25 19:21:37 +00:00
# A run waits for its leader to start it, then for anyone who's looked away: it's played together.
2026-10-24 12:05:23 +01:00
away = [ p for p in members if p [ " last_active " ] < party [ " start " ] - DUNGEON_AWAY ]
2026-10-25 19:21:37 +00:00
if " run " not in party or away :
2026-10-24 12:05:23 +01:00
party [ " start " ] = max ( party [ " start " ] , now )
for player in members :
player [ " action " ] [ " start " ] = party [ " start " ]
break
2026-10-25 19:21:37 +00:00
party [ " fight " ] = draw_pull ( world , members , action , party [ " run " ] ,
2026-10-24 12:05:23 +01:00
rng , party [ " start " ] )
else :
party [ " fight " ] = draw_fight ( world , members , action , rng , party [ " start " ] )
2026-10-21 22:06:49 +01:00
end = party [ " start " ] + party [ " fight " ] [ " duration " ]
late = [ player for player in members if offline_limit ( world , player ) < min ( end , now ) ]
if late :
# Away too long: they step out where their time ran out, and pick up alone when they're back.
for player in late :
if world [ " parties " ] . get ( pid ) is not party :
break # the last of them carries on alone, and picks up when they're back
at = max ( party [ " start " ] , offline_limit ( world , player ) )
2026-10-25 20:54:37 +00:00
keep = { k : v for k , v in player [ " action " ] . items ( ) if k in ( " wind_used " , " left " , " resume " , " resume_plot2 " ) }
2026-10-21 22:06:49 +01:00
leave_party ( world , player , pid , at )
2026-10-25 20:54:37 +00:00
player [ " action " ] = { " id " : action [ " id " ] , " start " : at , * * keep }
2026-10-21 22:06:49 +01:00
continue
if end > now :
break
burn_fires ( world , end )
fight , party [ " fight " ] = party [ " fight " ] , None
2026-10-24 09:49:21 +01:00
party [ " start " ] = end
2026-10-21 22:06:49 +01:00
for player in members :
player [ " action " ] [ " start " ] = party [ " start " ]
results = { m [ " id " ] : m [ " result " ] for m in fight [ " members " ] }
winners = [ uid for uid in party [ " members " ] if results . get ( uid ) == " won " ]
2026-10-24 12:05:23 +01:00
if action . get ( " personal_loot " ) and ( not action . get ( " dungeon " ) or any ( foe [ " boss " ] for foe in fight [ " pack " ] ) ) :
2026-10-24 09:49:21 +01:00
shares = { uid : pack_loot ( action , fight , rng ) for uid in winners }
2026-10-22 14:08:41 +01:00
else :
2026-10-24 09:49:21 +01:00
shares = share_loot ( pack_loot ( action , fight , rng ) , winners , rng ) if winners else { }
2026-10-21 22:06:49 +01:00
for player in members :
if results . get ( player [ " id " ] , " left " ) == " left " :
continue
summary = player . setdefault ( " away " , empty_summary ( ) )
_complete ( world , player , action , end , rng , summary , fight = fight , loot = shares . get ( player [ " id " ] ) )
summary [ " done " ] + = 1
together = summary . setdefault ( " with " , { } )
for other in members :
if other is not player :
together [ other [ " name " ] ] = together . get ( other [ " name " ] , 0 ) + 1
2026-10-25 20:54:37 +00:00
current = player [ " action " ]
if not action . get ( " dungeon " ) and " left " in current :
# Fought once: step out, and back to what you were doing. The rest carry on.
current [ " left " ] - = 1
if current [ " left " ] < = 0 :
if world [ " parties " ] . get ( current . get ( " party " ) ) is party :
leave_party ( world , player , pid , end )
if finish_once ( player , end ) :
summary [ " resumed " ] = player [ " action " ] [ " id " ]
2026-10-24 12:05:23 +01:00
if action . get ( " dungeon " ) and world [ " parties " ] . get ( pid ) is party :
_advance_run ( world , party , action , fight , members , end , rng )
def draw_pull ( world : dict , players : list , action : dict , run : dict , rng : random . Random , start : float ) - > dict :
""" The run ' s next pull, sized for the group, everyone rested first. """
spec = DUNGEONS [ action [ " dungeon " ] ]
name , pack = spec [ " pulls " ] [ run [ " pull " ] ]
hp , hits = dungeon_scale ( len ( players ) )
members , rest = [ ] , 0.0
for player in players :
seconds , eaten = rest_plan ( world , player )
member = fight_member ( world , player )
2026-10-25 20:21:08 +00:00
member . update ( eaten = eaten , hp_in = member [ " top " ] , rest = seconds ) # full after your own rest, then wait
2026-10-24 12:05:23 +01:00
rest = max ( rest , seconds )
members . append ( member )
fight = { " seed " : rng . random ( ) , " pace " : speed ( world ) , " rest " : rest , " enemy " : name , " members " : members ,
" pack " : [ { * * foe , " hp " : max ( 1 , round ( foe [ " hp " ] * hp ) ) , " max_hit " : max ( 1 , round ( foe [ " max_hit " ] * hits ) ) ,
" phases " : [ dict ( phase ) for phase in foe . get ( " phases " , [ ] ) ] } for foe in pack ] ,
" stage " : { " dungeon " : spec [ " name " ] , " pull " : run [ " pull " ] + 1 , " of " : len ( spec [ " pulls " ] ) , " wipes " : run [ " wipes " ] } }
for member , player in zip ( members , players ) :
member [ " watch " ] = fight_time ( fight , start , player [ " last_active " ] + combat . WATCH )
return play_fight ( fight )
def _advance_run ( world : dict , party : dict , action : dict , fight : dict , members : list , now : float , rng : random . Random ) - > None :
""" After a pull: on to the next, or the same again after a wipe. Clearing it or wiping too often ends it. """
spec , run = DUNGEONS [ action [ " dungeon " ] ] , party [ " run " ]
names = " , " . join ( p [ " name " ] for p in members )
if fight [ " result " ] == " won " :
run [ " pull " ] + = 1
run [ " wipes " ] = 0
if run [ " pull " ] < len ( spec [ " pulls " ] ) :
return
log ( world , now , " party " , f " { names } cleared { spec [ ' name ' ] } ! " )
for player in members :
_clear_bonus ( world , player , spec , now , rng )
reason = f " Cleared { spec [ ' name ' ] } "
else :
run [ " wipes " ] + = 1
if run [ " wipes " ] < DUNGEON_WIPES :
return
reason = f " Wiped { DUNGEON_WIPES } times at { spec [ ' pulls ' ] [ run [ ' pull ' ] ] [ 0 ] } "
log ( world , now , " party " , f " { names } : { reason . lower ( ) } in { spec [ ' name ' ] } " )
del world [ " parties " ] [ party [ " id " ] ]
for player in members :
2026-10-25 20:54:37 +00:00
summary = player . setdefault ( " away " , empty_summary ( ) )
if finish_once ( player , now , reason ) :
summary [ " resumed " ] = player [ " action " ] [ " id " ]
else :
summary [ " stopped " ] = reason
2026-10-24 12:05:23 +01:00
def _clear_bonus ( world : dict , player : dict , spec : dict , now : float , rng : random . Random ) - > None :
""" What clearing a run pays everyone in it: gold, one of the area ' s trophies and two of its reagents. """
summary = player . setdefault ( " away " , empty_summary ( ) )
player [ " gold " ] + = spec [ " bonus_gold " ]
summary [ " gold " ] + = spec [ " bonus_gold " ]
found = [ trophy ( rng . choice ( list ( STYLES ) ) , spec [ " tier " ] ) , * ( reagent ( rng . choice ( list ( REAGENT_NOUNS ) ) , spec [ " tier " ] )
2026-10-24 13:37:34 +01:00
for _ in range ( 2 ) ) ] if spec [ " tier " ] else [ ]
2026-10-24 12:05:23 +01:00
for item in found :
kept = bank_add ( world , player , item , 1 , now , summary )
if kept :
summary [ " items " ] [ item ] = summary [ " items " ] . get ( item , 0 ) + kept
def run_of ( world : dict , player : dict ) - > dict | None :
""" Where your group ' s dungeon run stands, if you ' re in one. """
party = party_of ( world , player )
if not party or not ACTION_BY_ID [ party [ " action " ] ] . get ( " dungeon " ) :
return None
spec = DUNGEONS [ ACTION_BY_ID [ party [ " action " ] ] [ " dungeon " ] ]
run = party . get ( " run " , { " pull " : 0 , " wipes " : 0 } )
away = [ world [ " players " ] [ uid ] [ " name " ] for uid in party [ " members " ]
if world [ " players " ] [ uid ] [ " last_active " ] < party [ " start " ] - DUNGEON_AWAY ]
return { " dungeon " : spec [ " name " ] , " pull " : run [ " pull " ] + 1 , " of " : len ( spec [ " pulls " ] ) , " wipes " : run [ " wipes " ] ,
" next " : spec [ " pulls " ] [ min ( run [ " pull " ] , len ( spec [ " pulls " ] ) - 1 ) ] [ 0 ] , " away " : away ,
2026-10-25 19:21:37 +00:00
" leader " : party [ " members " ] [ 0 ] , " size " : len ( party [ " members " ] ) , " started " : " run " in party }
def start_run ( world : dict , player : dict , now : float ) - > str | None :
""" The leader takes the group in: no one else can join from here on. """
party = party_of ( world , player )
if not party or not ACTION_BY_ID [ party [ " action " ] ] . get ( " dungeon " ) :
return " Not in a dungeon "
if party [ " members " ] [ 0 ] != player [ " id " ] :
return " Only the leader starts "
if " run " in party :
return " Already started "
if len ( party [ " members " ] ) < GROUP_SIZE :
return f " Needs { GROUP_SIZE } or more "
party [ " run " ] = { " pull " : 0 , " wipes " : 0 }
party [ " start " ] = now
for uid in party [ " members " ] :
world [ " players " ] [ uid ] [ " action " ] [ " start " ] = now
spec = DUNGEONS [ ACTION_BY_ID [ party [ " action " ] ] [ " dungeon " ] ]
log ( world , now , " party " , f " { player [ ' name ' ] } led { len ( party [ ' members ' ] ) } into { spec [ ' name ' ] } " , player [ " id " ] )
return None
2026-10-24 12:05:23 +01:00
def pull_now ( world : dict , player : dict , now : float ) - > str | None :
""" The leader cuts the rest short: everyone goes in as healed as they are by now. """
party = party_of ( world , player )
if not party or not ACTION_BY_ID [ party [ " action " ] ] . get ( " dungeon " ) :
return " Not in a dungeon "
if party [ " members " ] [ 0 ] != player [ " id " ] :
return " Only the leader pulls "
fight = party [ " fight " ]
if not fight :
return " Nothing to pull yet "
rested = ( now - party [ " start " ] ) * fight [ " pace " ]
if rested > = fight [ " rest " ] :
return " Already fighting "
rested = math . ceil ( rested / combat . STEP ) * combat . STEP
for member in fight [ " members " ] :
2026-10-25 20:21:08 +00:00
share = min ( 1 , rested / member [ " rest " ] ) if member . get ( " rest " ) else 1
2026-10-24 12:05:23 +01:00
member [ " hp_in " ] = member [ " start_hp " ] + ( member [ " hp_in " ] - member [ " start_hp " ] ) * share
member [ " eaten " ] = { food : int ( count * share ) for food , count in member [ " eaten " ] . items ( ) if int ( count * share ) }
member [ " watch " ] + = fight [ " rest " ] - rested
fight [ " rest " ] = rested
play_fight ( fight )
return None
2026-10-21 22:06:49 +01:00
2026-10-21 12:16:22 +01:00
def catch_up ( world : dict , now : float , rng : random . Random | None = None ) - > bool :
""" Run everyone ' s actions up to now, so the shared fires see feeds and crafts as they
happen . What each player missed waits in their ` away ` summary for their next visit . """
changed = False
2026-10-21 22:06:49 +01:00
parties = lambda : { pid : party [ " start " ] for pid , party in world [ " parties " ] . items ( ) }
before = parties ( )
2026-10-21 12:16:22 +01:00
for player in world [ " players " ] . values ( ) :
summary = process_player ( world , player , now , rng )
if summary [ " done " ] or summary [ " stopped " ] :
merge_summary ( player . setdefault ( " away " , empty_summary ( ) ) , summary )
changed = True
2026-10-21 22:06:49 +01:00
return changed or parties ( ) != before
2026-10-21 12:16:22 +01:00
def merge_summary ( into : dict , more : dict ) - > dict :
""" Fold a later summary into an earlier one. """
for key , value in more . items ( ) :
if isinstance ( value , bool ) :
into [ key ] = into . get ( key ) or value
elif isinstance ( value , ( int , float ) ) :
into [ key ] = into . get ( key , 0 ) + value
elif isinstance ( value , dict ) :
for item , qty in value . items ( ) :
into . setdefault ( key , { } ) [ item ] = into [ key ] . get ( item , 0 ) + qty
elif key == " levels " :
into [ key ] = [ entry for entry in into . get ( key , [ ] ) if entry [ 0 ] not in { s for s , _ in value } ] + value
elif isinstance ( value , list ) :
into [ key ] = into . get ( key , [ ] ) + value
elif value is not None :
into [ key ] = value
return into
def take_summary ( player : dict , summary : dict ) - > dict :
""" Everything that happened since the player last looked: the catch-up ' s and this request ' s. """
away = player . pop ( " away " , None )
return merge_summary ( away , summary ) if away else summary
2026-10-20 18:01:02 +01:00
# --- work orders -----------------------------------------------------------
2026-10-21 13:40:54 +01:00
# A player asks the guild for something. A crafting order names a recipe: anyone
# can put its reagents into the order's pool, one crafter claims it and makes it
# with the normal action (pool first, their own materials for the rest), and each
# piece goes straight to the requester. A materials request is open to anyone who
# has the items. Reagents and the reward are held by the order, paid out as it's
# filled, and handed back to whoever put them in if it's cancelled, runs out of
# time, or finishes with some left over.
2026-10-20 18:01:02 +01:00
ORDER_LIMIT = 5 # open orders per player
ORDER_DAYS = 3 # before an order expires and is refunded
2026-10-20 18:19:15 +01:00
ORDER_MAX = { " craft " : 500 , " gather " : 2000 }
2026-10-21 13:40:54 +01:00
INPUT_SHARE = 0.5 # of each piece's reward, split by value among whoever's reagents went in
2026-10-20 18:01:02 +01:00
def _order_sources ( ) - > tuple [ dict , dict ] :
craft , gather = { } , { }
for action in sorted ( ACTIONS , key = lambda a : a [ " level " ] ) :
if action . get ( " special " ) or " enemy " in action or not action [ " outputs " ] :
continue
item = next ( iter ( action [ " outputs " ] ) )
if action [ " skill " ] in CRAFTING :
craft . setdefault ( item , action [ " id " ] )
elif action [ " skill " ] in GATHERING :
2026-10-20 19:59:45 +01:00
for output in [ * action [ " outputs " ] , * ( item for item , _qty , _odds in action . get ( " finds " , [ ] ) ) ] :
2026-10-20 18:01:02 +01:00
gather . setdefault ( output , action [ " id " ] )
return craft , gather
def order_by_id ( world : dict , order_id ) - > dict | None :
return next ( ( o for o in world [ " orders " ] if o [ " id " ] == order_id ) , None )
def _batches ( action : dict , item : str , qty : int ) - > int :
""" How many times the recipe runs to make `qty` of its item. """
return - ( - qty / / action [ " outputs " ] [ item ] )
def post_order ( world : dict , player : dict , kind : str , item : str , qty , provide , reward , now : float ) - > str | None :
if kind not in ( " craft " , " gather " ) :
return " Bad order "
source = ( ORDER_RECIPES if kind == " craft " else ORDER_RAW ) . get ( item )
if not source :
return " Nobody can make that " if kind == " craft " else " Nobody gathers that "
if not isinstance ( qty , int ) or not 1 < = qty < = ORDER_MAX [ kind ] :
2026-10-20 18:19:15 +01:00
return f " 1 to { ORDER_MAX [ kind ] } "
2026-10-20 18:01:02 +01:00
if not isinstance ( reward , int ) or reward < 0 :
return " Bad reward "
if sum ( o [ " by " ] == player [ " id " ] and o [ " status " ] == " open " for o in world [ " orders " ] ) > = ORDER_LIMIT :
return f " At most { ORDER_LIMIT } open orders "
action = ACTION_BY_ID [ source ]
total = reward if kind == " craft " else reward * qty # a materials request pays per item
if player [ " gold " ] < total :
return " Not enough gold "
provided = { }
if kind == " craft " :
2026-10-24 18:56:15 +01:00
# The pool holds what a run takes right now: a lit fire's share stays with the fire.
runs , inputs = _batches ( action , item , qty ) , fire_inputs ( world , action )
2026-10-20 18:01:02 +01:00
for input_item in provide or [ ] :
if input_item not in action [ " inputs " ] :
return " That isn ' t in the recipe "
2026-10-24 18:56:15 +01:00
if input_item not in inputs :
return f " The { FIRES [ action [ ' fire ' ] ] [ ' name ' ] . lower ( ) } pays that "
need = inputs [ input_item ] * runs
2026-10-20 18:01:02 +01:00
if player [ " bank " ] . get ( input_item , 0 ) < need :
return f " Not enough { ITEMS [ input_item ] [ ' name ' ] . lower ( ) } "
provided [ input_item ] = need
for input_item , need in provided . items ( ) :
bank_take ( player , input_item , need )
player [ " gold " ] - = total
order = { " id " : world [ " next_order_id " ] , " kind " : kind , " by " : player [ " id " ] , " at " : now ,
" expires " : now + ORDER_DAYS * DAY / speed ( world ) , " item " : item , " qty " : qty , " action " : source ,
" skill " : action [ " skill " ] , " level " : action [ " level " ] , " provided " : provided , " reward " : total ,
2026-10-21 13:40:54 +01:00
" paid " : 0 , " delivered " : 0 , " held " : 0 , " claimed " : None , " from " : { } , " status " : " open " ,
" gave " : { player [ " id " ] : dict ( provided ) } if provided else { } , " earned " : { } }
2026-10-20 18:01:02 +01:00
world [ " next_order_id " ] + = 1
world [ " orders " ] . append ( order )
return None
2026-10-21 13:40:54 +01:00
def _gave ( order : dict ) - > dict :
""" Who has reagents in the pool: { player: { item: qty}}, oldest first. Older orders only had the requester ' s. """
return order . setdefault ( " gave " , { order [ " by " ] : dict ( order [ " provided " ] ) } if order [ " provided " ] else { } )
2026-10-20 18:46:39 +01:00
def provide_reagent ( world : dict , player : dict , order_id , item : str ) - > str | None :
2026-10-21 13:40:54 +01:00
""" Put a reagent into a crafting order ' s pool, up to what ' s still to be made. """
2026-10-20 18:46:39 +01:00
order = order_by_id ( world , order_id )
2026-10-21 13:40:54 +01:00
if not order or order [ " status " ] != " open " or order [ " kind " ] != " craft " :
2026-10-20 18:46:39 +01:00
return " No such order "
action = ACTION_BY_ID [ order [ " action " ] ]
2026-10-21 13:40:54 +01:00
if item not in action [ " inputs " ] :
return " That isn ' t in the recipe "
2026-10-24 18:56:15 +01:00
inputs = fire_inputs ( world , action )
if item not in inputs :
return f " The { FIRES [ action [ ' fire ' ] ] [ ' name ' ] . lower ( ) } pays that "
need = inputs [ item ] * _batches ( action , order [ " item " ] , order [ " qty " ] - order [ " delivered " ] )
2026-10-21 13:40:54 +01:00
short = need - order [ " provided " ] . get ( item , 0 )
if short < = 0 :
return " Already provided "
qty = min ( player [ " bank " ] . get ( item , 0 ) , short )
if not qty :
return f " No { ITEMS [ item ] [ ' name ' ] . lower ( ) } in your bank "
bank_take ( player , item , qty )
order [ " provided " ] [ item ] = order [ " provided " ] . get ( item , 0 ) + qty
mine = _gave ( order ) . setdefault ( player [ " id " ] , { } )
mine [ item ] = mine . get ( item , 0 ) + qty
2026-10-20 18:46:39 +01:00
return None
2026-10-21 13:40:54 +01:00
def _draw ( order : dict , inputs : dict ) - > dict :
""" Take one run ' s reagents from the pool, oldest first: { player: { item: qty}} of whose went in. """
used = { }
for item , qty in inputs . items ( ) :
take = min ( order [ " provided " ] . get ( item , 0 ) , qty )
if not take :
continue
order [ " provided " ] [ item ] - = take
for uid , items in _gave ( order ) . items ( ) :
part = min ( items . get ( item , 0 ) , take )
if part :
items [ item ] - = part
used . setdefault ( uid , { } ) [ item ] = part
take - = part
if not take :
break
return used
2026-10-20 18:01:02 +01:00
def _refund ( world : dict , order : dict , now : float ) - > None :
2026-10-21 13:40:54 +01:00
""" Hand back what ' s left in the pool to whoever put it in, and the unpaid reward to the requester. """
for uid , items in _gave ( order ) . items ( ) :
player = world [ " players " ] . get ( uid )
for item , qty in items . items ( ) :
if qty and player :
bank_add ( world , player , item , qty , now , empty_summary ( ) )
order [ " provided " ] , order [ " gave " ] = { } , { }
2026-10-20 18:01:02 +01:00
owner = world [ " players " ] . get ( order [ " by " ] )
2026-10-21 13:40:54 +01:00
if owner :
owner [ " gold " ] + = order [ " reward " ] - order [ " paid " ]
2026-10-20 18:01:02 +01:00
order [ " paid " ] = order [ " reward " ]
def cancel_order ( world : dict , player : dict , order_id , now : float ) - > str | None :
order = order_by_id ( world , order_id )
if not order or order [ " by " ] != player [ " id " ] or order [ " status " ] != " open " :
return " No such order "
order [ " status " ] = " cancelled "
_refund ( world , order , now )
return None
2026-10-20 20:11:11 +01:00
def remove_orders ( world : dict , player : dict , order_id = None ) - > str | None :
""" Clear your finished orders: one, or all of them. Ones still holding items for your bank stay. """
gone = [ o for o in world [ " orders " ] if o [ " by " ] == player [ " id " ] and o [ " status " ] != " open " and not o [ " held " ]
and order_id in ( None , o [ " id " ] ) ]
if not gone :
return " No such order "
world [ " orders " ] = [ o for o in world [ " orders " ] if o not in gone ]
return None
2026-10-20 18:01:02 +01:00
def claim_order ( world : dict , player : dict , order_id , claim : bool = True ) - > str | None :
order = order_by_id ( world , order_id )
if not order or order [ " status " ] != " open " or order [ " kind " ] != " craft " :
return " No such order "
if not claim :
if order [ " claimed " ] == player [ " id " ] :
order [ " claimed " ] = None
if ( player [ " action " ] or { } ) . get ( " order " ) == order [ " id " ] :
stop ( player )
return None
if order [ " by " ] == player [ " id " ] :
return " That ' s your own order "
if order [ " claimed " ] not in ( None , player [ " id " ] ) :
return " Someone else has it "
if skill_level ( player , order [ " skill " ] ) < order [ " level " ] :
return f " Needs { order [ ' skill ' ] } { order [ ' level ' ] } "
order [ " claimed " ] = player [ " id " ]
return None
def work_order ( world : dict , player : dict , order_id , now : float ) - > str | None :
""" Claim a crafting order and start making it. """
order = order_by_id ( world , order_id )
had = order and order [ " claimed " ] == player [ " id " ]
error = claim_order ( world , player , order_id )
if error :
return error
previous = player [ " action " ]
player [ " action " ] = { " id " : order [ " action " ] , " start " : now , " order " : order [ " id " ] }
reason = can_start ( world , player , ACTION_BY_ID [ order [ " action " ] ] )
if reason :
player [ " action " ] = previous
if not had :
order [ " claimed " ] = None # couldn't start: don't sit on it
return reason
2026-10-21 22:06:49 +01:00
if previous and previous . get ( " party " ) in world [ " parties " ] :
leave_party ( world , player , previous [ " party " ] , now )
2026-10-20 18:01:02 +01:00
player [ " stopped " ] = None
return None
def deliver_order ( world : dict , player : dict , order_id , now : float ) - > str | None :
""" Hand over what ' s asked for straight from your bank. """
order = order_by_id ( world , order_id )
if not order or order [ " status " ] != " open " :
return " No such order "
if order [ " by " ] == player [ " id " ] :
return " That ' s your own order "
if order [ " kind " ] == " craft " :
error = claim_order ( world , player , order_id )
if error :
return error
qty = min ( player [ " bank " ] . get ( order [ " item " ] , 0 ) , order [ " qty " ] - order [ " delivered " ] )
if not qty :
return f " No { ITEMS [ order [ ' item ' ] ] [ ' name ' ] . lower ( ) } in your bank "
bank_take ( player , order [ " item " ] , qty )
_deliver ( world , order , player , qty , now )
return None
2026-10-21 13:40:54 +01:00
def _deliver ( world : dict , order : dict , giver : dict , qty : int , now : float ,
used : dict | None = None , inputs : dict | None = None ) - > None :
""" Pay for `qty` pieces. Whoever ' s reagents went into them (`used` of the run ' s `inputs`) shares
INPUT_SHARE of the pay by value ; the requester ' s own reagents leave theirs with the crafter. " " "
2026-10-20 18:01:02 +01:00
order [ " delivered " ] + = qty
order [ " from " ] [ giver [ " id " ] ] = order [ " from " ] . get ( giver [ " id " ] , 0 ) + qty
order [ " held " ] + = qty
pay = round ( order [ " reward " ] * order [ " delivered " ] / order [ " qty " ] ) - order [ " paid " ]
order [ " paid " ] + = pay
2026-10-21 13:40:54 +01:00
worth , base = sum ( ITEMS [ item ] [ " value " ] * n for item , n in ( inputs or { } ) . items ( ) ) , pay
for uid , items in ( used or { } ) . items ( ) :
helper = world [ " players " ] . get ( uid )
if uid == order [ " by " ] or not helper or not worth :
continue
share = min ( pay , round ( base * INPUT_SHARE * sum ( ITEMS [ i ] [ " value " ] * n for i , n in items . items ( ) ) / worth ) )
if share :
helper [ " gold " ] + = share
earned = order . setdefault ( " earned " , { } )
earned [ uid ] = earned . get ( uid , 0 ) + share
pay - = share
2026-10-20 18:01:02 +01:00
giver [ " gold " ] + = pay
owner = world [ " players " ] . get ( order [ " by " ] )
if owner :
_hand_over ( world , order , owner )
if order [ " delivered " ] > = order [ " qty " ] :
order [ " status " ] = " done "
order [ " done " ] = now
2026-10-21 13:40:54 +01:00
_refund ( world , order , now ) # leftover reagents
2026-10-20 18:01:02 +01:00
log ( world , now , " order " , f " { giver [ ' name ' ] } filled { owner [ ' name ' ] if owner else ' someone ' } ' s order: "
f " { amount ( order [ ' item ' ] , order [ ' qty ' ] ) } " )
def _hand_over ( world : dict , order : dict , owner : dict ) - > None :
""" Put delivered items in the requester ' s bank; what doesn ' t fit waits on the order. """
fits = min ( order [ " held " ] , bank_room ( world , owner , order [ " item " ] ) )
if fits :
owner [ " bank " ] [ order [ " item " ] ] = owner [ " bank " ] . get ( order [ " item " ] , 0 ) + fits
order [ " held " ] - = fits
def collect_orders ( world : dict , player : dict ) - > None :
for order in world [ " orders " ] :
if order [ " by " ] == player [ " id " ] and order [ " held " ] :
_hand_over ( world , order , player )
def expire_orders ( world : dict , now : float ) - > None :
for order in world [ " orders " ] :
if order [ " status " ] == " open " and now > = order [ " expires " ] :
order [ " status " ] = " expired "
_refund ( world , order , now )
# Forget finished orders after a while, once everything's been handed over.
world [ " orders " ] = [ o for o in world [ " orders " ] if o [ " status " ] == " open " or o [ " held " ]
or now - o . get ( " done " , o [ " expires " ] ) < DAY / speed ( world ) ]
def working_order ( world : dict , player : dict , action : dict ) - > dict | None :
""" The work order this action is for, if the player is crafting one. """
current = player [ " action " ] or { }
if " order " not in current or current [ " id " ] != action [ " id " ] :
return None
return order_by_id ( world , current [ " order " ] )
2026-10-26 16:48:44 +00:00
MAKE_MAX = 1000 # most you can ask for in one "Make X"
def start_action ( world : dict , player : dict , action_id : str , now : float , once : int = 0 ) - > str | None :
""" Start an action. `once` does it that many times, then goes back to what you were doing. """
2026-10-18 13:48:56 +01:00
action = ACTION_BY_ID . get ( action_id )
if not action :
return " No such action "
reason = can_start ( world , player , action )
if reason :
return reason
2026-10-18 19:35:21 +01:00
entry = { " id " : action_id , " start " : now }
2026-10-19 23:17:23 +01:00
current = player [ " action " ]
2026-10-18 19:35:21 +01:00
if once :
back = current and ( current . get ( " resume " ) or current [ " id " ] )
2026-10-26 16:48:44 +00:00
entry [ " left " ] = int ( once )
2026-10-18 19:35:21 +01:00
if back and back != action_id :
entry [ " resume " ] = back
2026-10-19 23:17:23 +01:00
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 )
2026-10-21 22:06:49 +01:00
if current and current . get ( " party " ) in world [ " parties " ] :
leave_party ( world , player , current [ " party " ] , now )
2026-10-18 19:35:21 +01:00
player [ " action " ] = entry
2026-10-18 13:48:56 +01:00
player [ " stopped " ] = None
2026-10-24 09:49:21 +01:00
if action . get ( " together " ) :
log ( world , now , " party " , f " { player [ ' name ' ] } is looking for a group: { action [ ' name ' ] } " , player [ " id " ] )
2026-10-18 13:48:56 +01:00
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 :
2026-10-18 22:40:36 +01:00
if not city_uses ( item ) :
return " The city has no use for that "
2026-10-18 13:48:56 +01:00
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 )
2026-10-19 09:34:18 +01:00
player [ " favour " ] + = gain * city_rate ( world , " favour " )
2026-10-18 13:48:56 +01:00
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
2026-10-18 19:20:52 +01:00
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 .
"""
2026-10-18 13:48:56 +01:00
city = world [ " city " ]
if key not in BUILDINGS :
return " No such building "
2026-10-18 19:20:52 +01:00
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 " :
2026-10-18 13:48:56 +01:00
if item == " gold " :
2026-10-18 19:20:52 +01:00
player [ " gold " ] - = qty
2026-10-18 13:48:56 +01:00
else :
2026-10-18 19:20:52 +01:00
bank_take ( player , item , qty )
gain = qty * ( 1 if item == " gold " else ITEMS [ item ] [ " value " ] )
2026-10-19 09:34:18 +01:00
player [ " favour " ] + = gain * city_rate ( world , " favour " )
2026-10-18 19:20:52 +01:00
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 )
2026-10-18 13:48:56 +01:00
return None
2026-10-19 11:15:19 +01:00
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
2026-10-18 19:20:52 +01:00
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 " ]
2026-10-19 11:01:29 +01:00
city [ " boosts_bought " ] = 0 # master builders cost the base price again on a new site
2026-10-19 09:24:45 +01:00
city [ " construction " ] = { " building " : key , " repair " : repair , " start " : now , " labour " : 0.0 ,
" needed " : labour_needed ( building [ " level " ] , repair ) }
2026-10-18 19:20:52 +01:00
verb = " Repairs to " if repair else " Work on "
log ( world , now , " build " , f " { verb } the { BUILDINGS [ key ] [ ' name ' ] . lower ( ) } began " )
2026-10-19 20:31:25 +01:00
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 " ]
2026-10-19 20:35:15 +01:00
growth = SETTLERS_PER_EMPTY_HOME * max ( 0.0 , housing ( world ) - pop ) + FAMILY_GROWTH * pop
2026-10-19 20:31:25 +01:00
return growth * city_rate ( world , " growth " ) * ( 1.25 if fitted ( world , " furniture " ) else 1 )
2026-10-18 19:20:52 +01:00
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 :
2026-10-19 20:31:25 +01:00
growth = growth_per_day ( world )
2026-10-18 19:20:52 +01:00
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 " ] } ,
}
2026-10-18 13:48:56 +01:00
2026-10-18 22:13:50 +01:00
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 )
2026-10-19 14:51:05 +01:00
def wear_share ( ratio : float ) - > float :
""" Share of the issued swords and shields a fight breaks: 10 % i n an easy win, 30 % i n a close one, 40 % i n 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 " ) ) :
2026-10-24 13:34:59 +01:00
people = guards_on_duty ( world )
2026-10-19 14:51:05 +01:00
issued = [ ]
2026-10-24 13:34:59 +01:00
if not people :
continue
2026-10-19 14:51:05 +01:00
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
2026-10-18 22:13:50 +01:00
def equip_problem ( player : dict , item : str ) - > str | None :
""" Why the player can ' t wear or use this yet, or None. """
2026-10-19 23:38:34 +01:00
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 "
2026-10-18 22:13:50 +01:00
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
2026-10-24 13:34:59 +01:00
def guard_posts ( world : dict ) - > int :
""" How many guards the barracks can field, never more than half the townsfolk. """
return min ( GUARD_POSTS_BASE + GUARD_POSTS * building_level ( world , " barracks " ) , int ( world [ " city " ] [ " population " ] / / 2 ) )
def guards_on_duty ( world : dict ) - > int :
""" Guards whose wage for the coming threat is in the chest. """
return min ( guard_posts ( world ) , int ( world [ " city " ] . get ( " wages " , 0 ) / / GUARD_WAGE ) )
def defence ( world : dict , guards : int | None = None ) - > dict :
""" The city ' s defence right now, part by part. `guards` overrides how many are on duty. """
2026-10-18 13:48:56 +01:00
city = world [ " city " ]
pop = city [ " population " ]
stock = city [ " stockpile " ]
2026-10-24 13:34:59 +01:00
posts = guard_posts ( world )
armed = guards_on_duty ( world ) if guards is None else min ( guards , posts )
militia = 0.3 * building_level ( world , " barracks " ) * pop * ( 1.5 if fitted ( world , " weapon_racks " ) else 1 )
2026-10-18 13:48:56 +01:00
parts = {
" townsfolk " : 10 ,
2026-10-19 11:15:19 +01:00
" walls " : 20 * building_level ( world , " walls " ) + 25 * fitted ( world , " hoardings " ) ,
2026-10-24 13:34:59 +01:00
" militia " : round ( militia * armed / posts , 1 ) if posts else 0 ,
2026-10-18 22:13:50 +01:00
" weapons " : round ( 0.35 * best_kit ( stock , " weapon " , " power " , armed ) , 1 ) ,
" shields " : round ( 0.5 * best_kit ( stock , " shield " , " armour " , armed ) , 1 ) ,
2026-10-18 13:48:56 +01:00
" arrows " : min ( 100 , stock . get ( " arrows " , 0 ) / / 10 ) ,
2026-10-19 15:21:49 +01:00
" drilled " : round ( drilled ( world ) , 1 ) ,
" rallied " : city . get ( " rally " , 0 ) , # favour bought before drilling existed; gone after the next raid
2026-10-18 23:09:17 +01:00
" ward " : ward ( world ) ,
2026-10-19 09:34:18 +01:00
" mercenaries " : city . get ( " hired " , 0 ) ,
2026-10-18 13:48:56 +01:00
" garrison " : 0 ,
}
2026-10-24 13:34:59 +01:00
if guards is not None :
return { " total " : round ( sum ( parts . values ( ) ) , 1 ) , " parts " : parts }
2026-10-18 13:48:56 +01:00
garrison = [ ]
for player in world [ " players " ] . values ( ) :
if player [ " garrison " ] :
2026-10-19 22:45:32 +01:00
power = combat_power ( player )
2026-10-18 13:48:56 +01:00
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
2026-10-24 13:34:59 +01:00
return { " parts " : parts , " garrison " : garrison , " total " : round ( total , 1 ) , " starving " : city [ " starving " ] ,
" guards " : armed , " posts " : posts , " wages " : int ( city . get ( " wages " , 0 ) ) , " wage " : GUARD_WAGE }
def standing_defence ( world : dict ) - > float :
""" What the city holds with every guard post paid and no one preparing: walls, guards, kit, arrows. """
parts = defence ( world , guards = guard_posts ( world ) ) [ " parts " ]
return sum ( v for k , v in parts . items ( ) if k not in ( " drilled " , " rallied " , " ward " , " mercenaries " , " garrison " ) )
def pay_wages ( world : dict , player : dict , qty , source : str , now : float ) - > str | None :
""" Put gold in the barracks ' wage chest, from your purse (earns favour) or the treasury. """
city = world [ " city " ]
room = guard_posts ( world ) * GUARD_WAGE - int ( city . get ( " wages " , 0 ) )
if room < = 0 :
return " Every post is paid for "
have = player [ " gold " ] if source == " bank " else int ( city [ " treasury " ] ) if source == " city " else None
if have is None :
return " Bad source "
qty = min ( have , room ) if qty == " all " else qty
if not isinstance ( qty , int ) or qty < = 0 :
return " Nothing to pay "
if qty > have :
return " Not enough gold "
qty = min ( qty , room )
if source == " bank " :
player [ " gold " ] - = qty
player [ " favour " ] + = qty * city_rate ( world , " favour " )
player [ " donated " ] + = qty
log ( world , now , " donation " , f " { player [ ' name ' ] } paid { qty : , } gold of guard wages " , player [ " id " ] )
else :
city [ " treasury " ] - = qty
log ( world , now , " donation " , f " { player [ ' name ' ] } paid { qty : , } gold of guard wages from the treasury " , player [ " id " ] )
city [ " wages " ] = city . get ( " wages " , 0 ) + qty
return None
def _pay_guards ( world : dict , report : dict ) - > None :
""" The guards who stood are paid from the chest; what ' s left waits for the next threat. """
city = world [ " city " ]
city [ " wages " ] = city . get ( " wages " , 0 ) - report [ " guards " ] * GUARD_WAGE
2026-10-18 13:48:56 +01:00
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
2026-10-21 12:16:22 +01:00
burn_fires ( world , now )
2026-10-18 13:48:56 +01:00
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
2026-10-19 09:34:18 +01:00
_happenings_hour ( world , city [ " last_tick " ] , rng )
2026-10-20 18:01:02 +01:00
expire_orders ( world , city [ " last_tick " ] )
2026-10-18 13:48:56 +01:00
_city_hour ( world , city [ " last_tick " ] )
changed = True
2026-10-19 18:33:50 +01:00
changed = advance_weather ( world , now , rng ) or changed
2026-10-19 09:34:18 +01:00
changed = _happenings_tracks ( world , now , rng ) or changed
2026-10-24 13:34:59 +01:00
changed = _caravan_tick ( world , now , rng ) or changed
2026-10-18 13:48:56 +01:00
construction = city [ " construction " ]
2026-10-19 09:24:45 +01:00
if construction and construction [ " labour " ] > = construction [ " needed " ] :
_finish_construction ( world , now )
_start_next ( world , now )
2026-10-18 19:20:52 +01:00
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 )
2026-10-18 13:48:56 +01:00
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 )
2026-10-24 11:43:56 +01:00
# The next threat is rumoured as soon as this one is over.
world [ " next_event_at " ] = now
2026-10-18 13:48:56 +01:00
changed = True
2026-10-24 11:43:56 +01:00
if world [ " next_event_at " ] and world [ " next_event_at " ] < = now and not upcoming_event ( world ) :
_announce ( world , now , rng )
changed = True
2026-10-18 13:48:56 +01:00
return changed
def _city_hour ( world : dict , now : float ) - > None :
city = world [ " city " ]
pop = city [ " population " ]
2026-10-19 09:24:45 +01:00
if city [ " construction " ] :
city [ " construction " ] [ " labour " ] + = pop * TOWNSFOLK_LABOUR
2026-10-19 15:09:51 +01:00
threat = upcoming_raid ( world )
if threat and building_level ( world , " spymaster " ) :
2026-10-19 15:39:02 +01:00
threat [ " recon " ] = min ( intel_costs ( threat ) [ - 1 ] , threat [ " recon " ] + SPYMASTER_AGENTS * building_level ( world , " spymaster " ) )
2026-10-19 11:15:19 +01:00
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 ) )
2026-10-18 13:48:56 +01:00
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 :
2026-10-19 20:31:25 +01:00
city [ " population " ] = min ( room , pop + growth_per_day ( world ) / 24 )
2026-10-18 13:48:56 +01:00
elif pop > room :
city [ " population " ] = max ( room , pop - pop * 0.05 / 24 )
city [ " treasury " ] + = pop * 0.5 / 24
2026-10-19 09:24:45 +01:00
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 :
2026-10-19 22:45:32 +01:00
# One more labour every 20 levels, and one more for a builder.
return 1 + skill_level ( player , " construction " ) / / 20 + ( player [ " cls " ] == " builder " )
2026-10-19 09:24:45 +01:00
2026-10-18 13:48:56 +01:00
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
2026-10-18 19:20:52 +01:00
# Leftover materials may already cover the next step.
_queue_if_funded ( world , work [ " building " ] , now )
2026-10-18 13:48:56 +01:00
# --- events --------------------------------------------------------------
2026-10-24 11:43:56 +01:00
# Threats keep a two-day rhythm: one lands every THREAT_EVERY game hours, give or
# take THREAT_WOBBLE, and the next is rumoured the moment it's over. The first day
# is for scouting, drilling and building; on the second the camp can be found.
THREAT_EVERY = 48
THREAT_WOBBLE = 2
def threat_slot ( world : dict , now : float ) - > float :
""" When the next threat is due: two days after the last one ' s slot, never sooner than about two days off. """
hours = HOUR / speed ( world )
last = next ( ( e [ " slot " ] for e in reversed ( world [ " events " ] ) if e [ " kind " ] in THREATS and " slot " in e ) , None )
soonest = now + ( THREAT_EVERY - THREAT_WOBBLE ) * hours
return max ( last + THREAT_EVERY * hours , soonest ) if last else now + THREAT_EVERY * hours
2026-10-18 13:48:56 +01:00
def _announce ( world : dict , now : float , rng : random . Random ) - > None :
city = world [ " city " ]
hours = HOUR / speed ( world )
2026-10-19 09:34:18 +01:00
first = not any ( e [ " kind " ] in ( " raid " , " dragon " ) for e in world [ " events " ] )
2026-10-24 11:43:56 +01:00
slot = threat_slot ( world , now )
2026-10-19 09:34:18 +01:00
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 "
2026-10-18 13:48:56 +01:00
event = {
" id " : world [ " next_event_id " ] , " kind " : kind , " status " : " upcoming " ,
" announced " : now , " recon " : 0 , " sabotage " : 0.0 , " jitter " : rng . uniform ( - 1 , 1 ) ,
2026-10-24 11:43:56 +01:00
" slot " : slot , " fire_at " : slot + rng . uniform ( - THREAT_WOBBLE , THREAT_WOBBLE ) * hours ,
2026-10-18 13:48:56 +01:00
}
world [ " next_event_id " ] + = 1
2026-10-19 09:34:18 +01:00
# Bigger, richer and older cities draw bigger threats; every win draws a bolder one.
wealth = city [ " treasury " ] + stockpile_value ( city )
2026-10-24 13:34:59 +01:00
# A bigger guild draws a bolder band: half the combat power of everyone active in the last two days.
fighters = sum ( combat_power ( p ) for p in world [ " players " ] . values ( ) if p [ " last_active " ] > now - 2 * DAY / speed ( world ) )
strength = ( ( 15 + 5 * day + 1.5 * city [ " population " ] + 1.6 * math . sqrt ( wealth ) ) * ( 1 + 0.08 * city [ " repelled " ] )
+ 0.5 * fighters )
# How big it looks is measured against what the city holds without preparing.
event [ " standing " ] = round ( max ( 1.0 , standing_defence ( world ) ) , 1 )
2026-10-19 09:34:18 +01:00
if kind == " dragon " :
event . update ( {
2026-10-24 13:34:59 +01:00
" strength " : round ( strength * 0.8 * rng . uniform ( 0.9 , 1.15 ) , 1 ) ,
2026-10-19 09:34:18 +01:00
" group " : rng . choice ( DRAGONS ) ,
" trait " : rng . choice ( DRAGON_TRAITS ) ,
} )
log ( world , now , " event " , " Herders report scorched pastures in the hills " )
elif kind == " raid " :
2026-10-18 13:48:56 +01:00
event . update ( {
" strength " : round ( strength * rng . uniform ( 0.85 , 1.2 ) , 1 ) ,
" group " : rng . choice ( RAID_GROUPS ) ,
" trait " : rng . choice ( RAID_TRAITS ) ,
} )
log ( world , now , " event " , " Rumours of bandits on the roads reach the council " )
world [ " events " ] . append ( event )
del world [ " events " ] [ : - 30 ]
2026-10-24 13:34:59 +01:00
_plan_caravan ( world , now , rng )
2026-10-18 13:48:56 +01:00
2026-10-19 15:09:51 +01:00
# 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
2026-10-24 11:43:56 +01:00
INTEL_OPENS = [ None , 36 , 24 ] # game hours before arrival that levels 2 (±25% per threat) and 3 open
2026-10-19 15:09:51 +01:00
SPYMASTER_RECON = 0.25 # more recon per guild level
SPYMASTER_AGENTS = 0.5 # recon per guild level per game hour, with nobody scouting
2026-10-19 15:14:00 +01:00
INFORMANT_RECON = 30 # boosted recon per informant: doubled recon and XP for the scout who works it
2026-10-19 15:09:51 +01:00
2026-10-19 22:45:32 +01:00
def recon_gain ( world : dict , player : dict , action : dict ) - > float :
gain = ( action [ " recon " ] + fitted ( world , " map_table " ) ) * ( 1 + SPYMASTER_RECON * building_level ( world , " spymaster " ) )
2026-10-24 13:34:59 +01:00
return gain * ( 1.5 if player [ " cls " ] == " ranger " else 1 ) * ware ( player , " recon " , " reconnaissance " )
2026-10-19 15:09:51 +01:00
def intel_costs ( event : dict ) - > list [ int ] :
""" Total recon needed for each intel level. """
2026-10-24 13:34:59 +01:00
size = threat_size ( event , event [ " strength " ] )
2026-10-19 15:09:51 +01:00
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 :
2026-10-24 11:43:56 +01:00
""" When an intel level becomes knowable. Level 2 ' s ±25 % i s fixed per threat, so it can ' t be timed;
the camp ( level 3 ) can always be found a day before the threat lands . """
2026-10-19 15:09:51 +01:00
hours = INTEL_OPENS [ level - 1 ]
if hours is None :
return 0
2026-10-24 11:43:56 +01:00
wobble = random . Random ( f " { event [ ' id ' ] } - { level } " ) . uniform ( 0.75 , 1.25 ) if level < 3 else 1
2026-10-19 15:09:51 +01:00
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
2026-10-18 13:48:56 +01:00
2026-10-24 13:34:59 +01:00
SIZE_LABELS = [ ( 30 , " small " ) , ( 60 , " medium " ) , ( 100 , " large " ) , ( math . inf , " huge " ) ] # by strength, before v28
SIZE_RATIOS = [ 1.4 , 1.9 , 2.5 , math . inf ] # strength / standing defence for small, medium, large, huge
def threat_size ( event : dict , strength : float ) - > int :
""" 0 small … 3 huge, against what the city could hold when the threat was rumoured. """
if " standing " in event :
return next ( i for i , limit in enumerate ( SIZE_RATIOS ) if strength / event [ " standing " ] < limit )
return next ( i for i , ( limit , _label ) in enumerate ( SIZE_LABELS ) if strength < limit )
2026-10-24 11:43:56 +01:00
WINDOW_HOURS = [ 4 , 3 , 2 , 0 ]
2026-10-18 13:48:56 +01:00
2026-10-20 13:12:12 +01:00
THREATS = ( " raid " , " dragon " )
GATES_WARNING = 300 # seconds of warning before a threat strikes
2026-10-18 13:48:56 +01:00
def event_view ( world : dict , event : dict , now : float ) - > dict :
""" What the city knows about an upcoming event, filtered by intel. """
2026-10-19 15:09:51 +01:00
level = intel_level ( world , event , now )
2026-10-18 13:48:56 +01:00
half = WINDOW_HOURS [ level ] * HOUR / speed ( world )
2026-10-19 15:39:02 +01:00
costs = intel_costs ( event ) if " strength " in event else [ 0 , 0 , 0 ]
2026-10-18 13:48:56 +01:00
centre = event [ " fire_at " ] + event [ " jitter " ] * half * 0.6
2026-10-20 13:12:12 +01:00
# In the last few minutes the sentries see them coming, whatever the intel.
gates = event [ " kind " ] in THREATS and event [ " fire_at " ] - now < = GATES_WARNING / speed ( world )
2026-10-18 13:48:56 +01:00
view = {
2026-10-20 13:12:12 +01:00
" id " : event [ " id " ] , " kind " : event [ " kind " ] , " intel " : level , " at_gates " : gates ,
2026-10-18 13:48:56 +01:00
" earliest " : max ( now , centre - half ) , " latest " : max ( now , centre + half ) ,
2026-10-20 13:12:12 +01:00
" exact " : event [ " fire_at " ] if level > = 3 or gates else None ,
2026-10-19 15:39:02 +01:00
" recon " : int ( min ( event [ " recon " ] , costs [ - 1 ] ) ) , " recon_full " : costs [ - 1 ] ,
2026-10-19 15:09:51 +01:00
" recon_next " : max ( 0 , round ( costs [ level ] - event [ " recon " ] ) ) if level < 3 else 0 ,
2026-10-18 13:48:56 +01:00
}
2026-10-19 09:34:18 +01:00
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. "
2026-10-24 13:34:59 +01:00
view [ " size " ] = SIZE_LABELS [ threat_size ( event , strength ) ] [ 1 ]
2026-10-19 09:34:18 +01:00
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 )
2026-10-20 13:12:12 +01:00
if gates :
view [ " headline " ] = " The dragon is overhead! "
2026-10-19 09:34:18 +01:00
elif event [ " kind " ] == " raid " :
2026-10-18 13:48:56 +01:00
strength = event [ " strength " ] * ( 1 - event [ " sabotage " ] )
view [ " headline " ] = " Travellers speak of trouble on the roads. "
if level > = 1 :
2026-10-24 13:34:59 +01:00
size = SIZE_LABELS [ threat_size ( event , strength ) ] [ 1 ]
2026-10-18 13:48:56 +01:00
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 )
2026-10-20 13:12:12 +01:00
if gates :
view [ " headline " ] = " Raiders at the gates! "
2026-10-18 13:48:56 +01:00
elif event [ " kind " ] == " merchant " :
2026-10-20 08:01:57 +01:00
view [ " headline " ] = " A merchant caravan is on its way. Sell prices +50 % while it ' s here. "
2026-10-18 13:48:56 +01:00
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 " ]
2026-10-24 13:34:59 +01:00
armed = guard_posts ( world )
2026-10-18 13:48:56 +01:00
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 ) ) ,
]
2026-10-18 22:13:50 +01:00
if building_level ( world , " smithy " ) > = 1 :
2026-10-18 13:48:56 +01:00
wants . append ( ( " iron_shield " , min ( armed , math . ceil ( strength / 10 ) ) ) )
requests = [ ]
for item , qty in wants :
have = stock . get ( item , 0 )
2026-10-18 22:13:50 +01:00
if item in ( " copper_sword " , " iron_shield " ) :
have = kit_count ( stock , ITEMS [ item ] [ " slot " ] )
2026-10-18 13:48:56 +01:00
if item == " bread " :
have = sum ( stock . get ( food , 0 ) * ITEMS [ food ] [ " food " ] for food in FOOD_ORDER )
2026-10-18 22:13:50 +01:00
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 " ]
2026-10-23 22:13:32 +01:00
request = { " item " : item , " label " : label , " want " : qty , " have " : have }
if item in ( " copper_sword " , " iron_shield " ) :
request [ " any " ] = ITEMS [ item ] [ " slot " ] # any item in this slot counts
if item == " bread " :
request [ " any " ] = " food " # counted in meals
requests . append ( request )
2026-10-18 13:48:56 +01:00
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 )
2026-10-19 09:34:18 +01:00
elif event [ " kind " ] == " dragon " :
_resolve_dragon ( world , event , now , rng )
2026-10-24 13:34:59 +01:00
if event [ " kind " ] in THREATS :
won = event [ " report " ] [ " title " ] . startswith ( " Victory " )
for entry in event [ " report " ] [ " garrison " ] if won else [ ] :
_deed ( world [ " players " ] [ entry [ " id " ] ] , " dragons " if event [ " kind " ] == " dragon " else " defended " )
# Wares bought against this threat are spent.
for player in world [ " players " ] . values ( ) :
player [ " boosts " ] = [ b for b in player [ " boosts " ] if " threat " not in b ]
2026-10-18 13:48:56 +01:00
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 " ] ,
2026-10-24 13:34:59 +01:00
" parts " : guard [ " parts " ] , " garrison " : garrison , " guards " : guard [ " guards " ] ,
2026-10-18 13:48:56 +01:00
}
if ratio > = 1 :
2026-10-24 13:34:59 +01:00
loot = round ( attack )
2026-10-18 13:48:56 +01:00
city [ " treasury " ] + = loot
city [ " repelled " ] + = 1
2026-10-19 14:51:05 +01:00
broken = wear_kit ( world , wear_share ( ratio ) , rng )
arrows = _use_up ( stock , " arrows " , 0.3 )
2026-10-18 13:48:56 +01:00
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. " )
2026-10-19 14:51:05 +01:00
lines . extend ( _wear_lines ( broken , arrows ) )
2026-10-18 13:48:56 +01:00
for entry in garrison :
player = world [ " players " ] [ entry [ " id " ] ]
player [ " favour " ] + = 25
2026-10-24 13:34:59 +01:00
gain_xp ( world , player , " combat " , round ( attack * 2 ) , now )
2026-10-18 13:48:56 +01:00
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 )
2026-10-19 14:51:05 +01:00
broken = wear_kit ( world , wear_share ( ratio ) , rng )
2026-10-18 13:48:56 +01:00
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
2026-10-19 14:51:05 +01:00
arrows = _use_up ( stock , " arrows " , 0.6 )
2026-10-18 13:48:56 +01:00
report [ " title " ] = f " Defeat. { upper_first ( event [ " group " ] ) } broke into the city "
lines . append ( f " { lost_people } townsfolk were killed or fled. " )
2026-10-19 14:51:05 +01:00
lines . extend ( _wear_lines ( broken , arrows ) )
2026-10-18 13:48:56 +01:00
if gold or taken :
stolen = " , " . join ( amount ( item , qty ) for item , qty in taken . items ( ) )
2026-10-20 13:12:12 +01:00
loot = [ f " { gold : , } gold " ] if gold else [ ]
lines . append ( " The raiders carried off " + " and " . join ( loot + ( [ stolen ] if stolen else [ ] ) ) + " . " )
2026-10-18 13:48:56 +01:00
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
2026-10-18 19:20:52 +01:00
_queue_if_funded ( world , key , now )
2026-10-18 13:48:56 +01:00
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 :
2026-10-24 13:34:59 +01:00
gain_xp ( world , world [ " players " ] [ entry [ " id " ] ] , " combat " , round ( attack ) , now )
2026-10-18 13:48:56 +01:00
log ( world , now , " raid " , f " { upper_first ( event [ " group " ] ) } broke into the city " )
report [ " lines " ] = lines
event [ " report " ] = report
2026-10-24 13:34:59 +01:00
_pay_guards ( world , report )
2026-10-18 13:48:56 +01:00
for player in world [ " players " ] . values ( ) :
player [ " garrison " ] = False
2026-10-18 19:48:22 +01:00
city [ " rally " ] = city [ " rallies " ] = 0
2026-10-18 23:09:17 +01:00
city [ " devotion " ] = 0
2026-10-19 09:34:18 +01:00
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 " ] ,
2026-10-24 13:34:59 +01:00
" parts " : guard [ " parts " ] , " garrison " : garrison , " guards " : guard [ " guards " ] , " icon " : " 🐉 " }
2026-10-19 09:34:18 +01:00
lines = [ ]
if guard [ " total " ] > = attack :
city [ " repelled " ] + = 1
city [ " treasury " ] + = round ( attack )
2026-10-19 14:51:05 +01:00
broken = wear_kit ( world , min ( 0.5 , wear_share ( guard [ " total " ] / attack ) * 1.25 ) , rng )
arrows = _use_up ( stock , " arrows " , 0.5 )
2026-10-19 09:34:18 +01:00
report [ " title " ] = f " Victory! { upper_first ( event [ ' group ' ] ) } was driven off "
lines . append ( f " It fled wounded, dropping a hoard worth { round ( attack ) : , } gold. " )
2026-10-19 14:51:05 +01:00
lines . extend ( _wear_lines ( broken , arrows ) )
2026-10-19 09:34:18 +01:00
for entry in garrison :
player = world [ " players " ] [ entry [ " id " ] ]
player [ " favour " ] + = 40
2026-10-24 13:34:59 +01:00
gain_xp ( world , player , " combat " , round ( attack * 2.5 ) , now )
2026-10-19 09:34:18 +01:00
bank_add ( world , player , " dragon_bones " , 2 , now , empty_summary ( ) )
2026-10-20 14:16:12 +01:00
for weight in WEIGHTS :
bank_add ( world , player , reagent ( weight , 3 ) , 1 , now , empty_summary ( ) )
2026-10-19 09:34:18 +01:00
if garrison :
2026-10-20 14:16:12 +01:00
lines . append ( " Everyone in the garrison earned 40 favour, combat experience, dragon bones and Ironhill reagents. " )
2026-10-19 09:34:18 +01:00
log ( world , now , " raid " , report [ " title " ] )
else :
severity = min ( 1.0 , 1 - guard [ " total " ] / attack )
2026-10-19 14:51:05 +01:00
broken = wear_kit ( world , 0.5 , rng )
2026-10-19 09:34:18 +01:00
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 )
2026-10-19 14:51:05 +01:00
arrows = _use_up ( stock , " arrows " , 0.7 )
2026-10-19 09:34:18 +01:00
report [ " title " ] = f " { upper_first ( event [ ' group ' ] ) } set the city ablaze "
lines . append ( f " { lost } townsfolk were lost in the flames. " )
2026-10-19 14:51:05 +01:00
lines . extend ( _wear_lines ( broken , arrows ) )
2026-10-19 09:34:18 +01:00
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 :
2026-10-24 13:34:59 +01:00
gain_xp ( world , world [ " players " ] [ entry [ " id " ] ] , " combat " , round ( attack ) , now )
2026-10-19 09:34:18 +01:00
log ( world , now , " raid " , report [ " title " ] )
report [ " lines " ] = lines
event [ " report " ] = report
2026-10-24 13:34:59 +01:00
_pay_guards ( world , report )
2026-10-19 09:34:18 +01:00
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 " ] )
2026-10-18 13:48:56 +01:00
2026-10-19 14:51:05 +01:00
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
2026-10-18 13:48:56 +01:00
2026-10-19 09:34:18 +01:00
# --- 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 = {
2026-10-20 08:01:57 +01:00
" clear " : { " name " : " Clear skies " , " icon " : " 🌤️ " , " text " : " No effects " } ,
2026-10-19 09:34:18 +01:00
" rain " : { " name " : " Rain " , " icon " : " 🌧️ " , " speed " : { " farming " : 0.9 } , " text " : " Farming 10 % f aster " } ,
" heat " : { " name " : " Heat wave " , " icon " : " 🥵 " , " speed " : { " farming " : 0.85 } , " food " : 1.15 ,
2026-10-20 08:01:57 +01:00
" text " : " Farming 15 % f aster, city eats 15 % more " } ,
2026-10-19 09:34:18 +01:00
" storm " : { " name " : " Storm " , " icon " : " ⛈️ " , " speed " : { " fishing " : 1.25 } , " text " : " Fishing 25 % s lower " } ,
" fog " : { " name " : " Fog " , " icon " : " 🌫️ " , " speed " : { " reconnaissance " : 1.3 , " hunting " : 1.1 } ,
" text " : " Scouting 30 % a nd hunting 10 % s lower " } ,
" snow " : { " name " : " Snow " , " icon " : " 🌨️ " , " speed " : { " hunting " : 1.2 } , " firewood " : 0.05 ,
2026-10-20 08:01:57 +01:00
" text " : " Hunting 20 % s lower, burns firewood " } ,
2026-10-19 09:34:18 +01:00
}
SEASON_WEATHER = [
{ " clear " : 35 , " rain " : 40 , " storm " : 10 , " fog " : 15 } ,
{ " clear " : 45 , " heat " : 30 , " rain " : 15 , " storm " : 10 } ,
{ " clear " : 30 , " rain " : 30 , " storm " : 25 , " fog " : 15 } ,
{ " clear " : 30 , " snow " : 45 , " fog " : 15 , " storm " : 10 } ,
]
WOOD = FIREWOOD + tuple ( plank for plank , _name in PLANKS )
# Minor threats: they slow or block a skill until someone deals with them,
# either by working a special action (`work` completions) or by donating (`need`).
PROBLEMS = {
" collapse " : { " name " : " Mine collapse " , " icon " : " ⛰️ " , " need " : { " plank " : 30 } ,
2026-10-20 08:01:57 +01:00
" block " : { " mining " : " Mine collapsed " } , " allow " : [ " mine_stone " ] ,
" text " : " No ore until it ' s shored up with planks. " } ,
2026-10-20 16:01:28 +01:00
" wolves " : { " name " : " Wolves at the farms " , " icon " : " 🐺 " , " speed " : { " farming " : 2.0 } , " work " : 10 , " action " : " clear_wolves " ,
" text " : " Farming twice as slow until fighters drive them off. " } ,
2026-10-19 09:34:18 +01:00
" bear " : { " name " : " Bear in the woods " , " icon " : " 🐻 " , " speed " : { " woodcutting " : 1.5 } , " work " : 8 , " action " : " drive_bear " ,
2026-10-20 08:01:57 +01:00
" text " : " Woodcutting much slower until hunters drive it off. " } ,
2026-10-19 09:34:18 +01:00
" goblins " : { " name " : " Goblin thieves " , " icon " : " 👺 " , " steal " : 0.01 , " work " : 10 , " action " : " catch_goblins " ,
2026-10-20 08:01:57 +01:00
" text " : " Steal from the stockpile hourly until fighters catch them. " } ,
2026-10-19 09:34:18 +01:00
" rockslide " : { " name " : " Rockslide on the quarry road " , " icon " : " 🪨 " , " work " : 25 , " action " : " clear_rockslide " ,
2026-10-20 08:01:57 +01:00
" block " : { " mining " : " Quarry road blocked " } , " only " : [ " mine_stone " ] ,
" text " : " No stone until builders clear it. " } ,
2026-10-24 11:43:56 +01:00
" flood " : { " name " : " Flooded fishing grounds " , " icon " : " 🌊 " , " speed " : { " fishing " : 1.5 } , " need " : { " plank " : 20 } , " lasts " : 18 ,
" text " : " Fishing slow for 18h, or until dammed with planks. " } ,
2026-10-20 16:01:28 +01:00
" boars " : { " name " : " Boars trampling the crops " , " icon " : " 🐗 " , " speed " : { " farming " : 1.5 } , " work " : 10 , " action " : " cull_boars " ,
" text " : " Farming slower until hunters cull them. " } ,
2026-10-19 09:34:18 +01:00
" rats " : { " name " : " Rats in the granary " , " icon " : " 🐀 " , " steal_food " : 0.03 , " work " : 12 , " action " : " catch_rats " ,
2026-10-20 08:01:57 +01:00
" text " : " Eat city food hourly until hunters catch them. " } ,
2026-10-24 11:43:56 +01:00
" spiders " : { " name " : " Spiders in the mine " , " icon " : " 🕷️ " , " speed " : { " mining " : 1.5 } , " work " : 10 , " action " : " clear_spiders " ,
" text " : " Mining much slower until fighters clear them out. " } ,
" smugglers " : { " name " : " Smugglers in the caves " , " icon " : " 🏴☠️ " , " steal_gold " : 0.01 , " work " : 10 , " action " : " catch_smugglers " ,
" text " : " Skim 1 % o f the treasury hourly until fighters catch them. " } ,
" bridge " : { " name " : " Bridge washed out " , " icon " : " 🌉 " , " need " : { " plank " : 25 } ,
" block " : { " hunting " : " The bridge to the hunting grounds is out " } ,
" text " : " No hunting until the bridge is rebuilt with planks. " } ,
" hornets " : { " name " : " Hornets in the woods " , " icon " : " 🐝 " , " speed " : { " woodcutting " : 1.4 } , " work " : 8 , " action " : " smoke_hornets " ,
" text " : " Woodcutting slower until herbalists smoke them out. " } ,
2026-10-19 09:34:18 +01:00
}
2026-10-24 11:43:56 +01:00
MINOR_THREATS = ( " collapse " , " wolves " , " bear " , " goblins " , " rockslide " , " flood " , " boars " ,
" spiders " , " smugglers " , " bridge " , " hornets " )
2026-10-19 09:34:18 +01:00
PROBLEM_ACTIONS = [
2026-10-20 14:16:12 +01:00
_encounter ( " clear_wolves " , " Wolf pack " , " Frontier " , 4 , WOLVES , label = " Drive off the wolves " ,
special = " problem " , problem = " wolves " ) ,
_encounter ( " catch_goblins " , " Goblin thieves " , " Frontier " , 5 , GOBLINS , label = " Catch the goblin thieves " ,
special = " problem " , problem = " goblins " ) ,
2026-10-19 09:34:18 +01:00
_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 " ) ,
2026-10-24 11:43:56 +01:00
_encounter ( " clear_spiders " , " Mine spiders " , " Frontier " , 6 , SPIDERS , label = " Clear the spiders " ,
special = " problem " , problem = " spiders " ) ,
_encounter ( " catch_smugglers " , " Smugglers " , " Frontier " , 8 , SMUGGLERS , gold = 6 , label = " Catch the smugglers " ,
special = " problem " , problem = " smugglers " ) ,
_action ( " smoke_hornets " , " herblore " , " Smoke out the hornets " , 1 , 40 , 12 , special = " problem " , problem = " hornets " ) ,
2026-10-19 09:34:18 +01:00
]
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 ,
2026-10-19 23:17:23 +01:00
{ " oak_plank " : 2 , " iron_nails " : 4 } , { " water_barrel " : 1 } , group = " Supplies " )
2026-10-19 09:34:18 +01:00
ACTIONS . append ( _barrel )
ACTION_BY_ID [ _barrel [ " id " ] ] = _barrel
2026-10-19 11:15:19 +01:00
# 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 = [
2026-10-19 23:17:23 +01:00
( " grain_bins " , " Grain bins " , " granary " , 1 , 15 , { " oak_plank " : 20 , " iron_nails " : 25 } ,
2026-10-20 08:01:57 +01:00
" City eats 10 % le ss " , " grain-bundle " , " #e3c060 " ) ,
2026-10-19 23:17:23 +01:00
( " saw_benches " , " Saw benches " , " lumber_shed " , 1 , 15 , { " oak_plank " : 20 , " iron_nails " : 40 } ,
2026-10-20 08:01:57 +01:00
" Planks 20 % f aster " , " hand-saw " , " #8f5e30 " ) ,
2026-10-19 23:17:23 +01:00
( " shelving " , " Shelving " , " storehouse " , 2 , 20 , { " oak_plank " : 40 , " iron_nails " : 40 } ,
2026-10-20 08:01:57 +01:00
" +1 bank slot " , " bookshelf " , " #8f5e30 " ) ,
2026-10-19 23:17:23 +01:00
( " map_table " , " Map table " , " spymaster " , 1 , 30 , { " willow_plank " : 25 , " hard_leather " : 10 , " steel_hinges " : 4 } ,
2026-10-20 08:01:57 +01:00
" +1 recon per scout " , " treasure-map " , " #a3b86c " ) ,
2026-10-19 23:17:23 +01:00
( " well_pump " , " Well pump " , " well " , 2 , 30 , { " willow_plank " : 25 , " steel_hinges " : 10 } ,
2026-10-20 08:01:57 +01:00
" Stops half of all fires " , " water-tank " , " #5aa0d8 " ) ,
2026-10-19 23:17:23 +01:00
( " furniture " , " Furniture " , " houses " , 2 , 35 , { " willow_plank " : 40 , " leather " : 30 , " steel_hinges " : 10 } ,
2026-10-19 11:15:19 +01:00
" Population grows 25 % f aster " , " sofa " , " #a3b86c " ) ,
2026-10-19 23:17:23 +01:00
( " weapon_racks " , " Weapon racks " , " barracks " , 2 , 40 , { " willow_plank " : 30 , " steel_hinges " : 16 } ,
2026-10-19 11:15:19 +01:00
" Militia defence +50 % " , " spears " , " #d4dae2 " ) ,
2026-10-19 23:17:23 +01:00
( " hoardings " , " Hoardings " , " walls " , 3 , 45 , { " maple_plank " : 40 , " mithril_brackets " : 20 } ,
2026-10-19 11:15:19 +01:00
" +25 defence " , " palisade " , " #d0663a " ) ,
2026-10-19 23:17:23 +01:00
( " bellows " , " Great bellows " , " smithy " , 3 , 50 , { " maple_plank " : 30 , " bear_leather " : 15 , " mithril_brackets " : 6 } ,
2026-10-20 08:01:57 +01:00
" Smithing 10 % f aster " , " bellows " , " #d0663a " ) ,
2026-10-19 23:17:23 +01:00
( " carved_altar " , " Carved altar " , " temple " , 2 , 60 , { " yew_plank " : 30 , " cut_stone " : 60 , " adamant_chain " : 4 } ,
2026-10-20 08:01:57 +01:00
" +1 prayer point per bone, ward +15 " , " star-altar " , " #6f9a4a " ) ,
2026-10-19 23:17:23 +01:00
( " enchanted_chests " , " Enchanted chests " , " storehouse " , 4 , 75 , { " magic_plank " : 40 , " rune_lock " : 6 } ,
2026-10-20 08:01:57 +01:00
" +500 stack size " , " open-treasure-chest " , " #a77be8 " ) ,
2026-10-20 14:16:12 +01:00
( " enchanted_hearths " , " Enchanted hearths " , " houses " , 4 , 80 , { " magic_plank " : 50 , " mammoth_leather " : 5 , " adamant_chain " : 6 } ,
2026-10-20 08:01:57 +01:00
" HP recovers twice as fast " , " fireplace " , " #a77be8 " ) ,
2026-10-19 11:15:19 +01:00
]
2026-10-19 23:38:34 +01:00
# 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
2026-10-20 21:00:31 +01:00
for _jewel in [ * _jewel_actions ( ) , * _storage_actions ( ) ] :
2026-10-20 19:59:45 +01:00
ACTIONS . append ( _jewel )
ACTION_BY_ID [ _jewel [ " id " ] ] = _jewel
2026-10-24 13:48:16 +01:00
_dungeon_loot ( ) # masterworks are reforged from all of the above
2026-10-19 11:15:19 +01:00
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 )
2026-10-19 09:34:18 +01:00
# (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 ] ,
2026-10-24 11:43:56 +01:00
" rats " : [ 2 , 2 , 2 , 2 ] , " fire " : [ 1 , 2 , 1 , 2 ] , " winter " : [ 0 , 0 , 0 , 4 ] , " drought " : [ 0 , 4 , 1 , 0 ] ,
" blight " : [ 2 , 1 , 3 , 0 ] , " tremors " : [ 1 , 1 , 1 , 1 ] , " taxman " : [ 1 , 1 , 2 , 1 ] }
2026-10-19 09:34:18 +01:00
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 ,
2026-10-24 11:43:56 +01:00
" pilgrims " : [ 8 ] * 4 , " mercenaries " : [ 6 ] * 4 , " cargo " : [ 10 ] * 4 ,
" timber " : [ 8 , 6 , 10 , 8 ] , " deer " : [ 8 , 10 , 10 , 4 ] , " masons " : [ 10 , 10 , 8 , 6 ] , " artisans " : [ 8 ] * 4 ,
" trapper " : [ 10 ] * 4 }
TRACK_HOURS = { " minor " : ( 16 , 32 ) , " hardship " : ( 20 , 40 ) , " fortune " : ( 10 , 20 ) }
CRAFTS = ( " smithing " , " jewelcrafting " , " cooking " , " leatherworking " , " tailoring " , " herblore " )
TRAPPER_RECON = 15 # boosted recon a trapper's leads are worth, like an informant's
2026-10-19 09:34:18 +01:00
2026-10-19 09:42:36 +01:00
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
2026-10-19 09:34:18 +01:00
def season ( world : dict , now : float ) - > dict :
2026-10-19 09:42:36 +01:00
day = calendar_day ( world , now )
index = day / / SEASON_DAYS % len ( SEASONS )
2026-10-19 09:34:18 +01:00
name , icon = SEASONS [ index ]
2026-10-19 09:42:36 +01:00
return { " index " : index , " name " : name , " icon " : icon , " day " : day % SEASON_DAYS + 1 }
2026-10-19 09:34:18 +01:00
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 )
2026-10-19 18:33:50 +01:00
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 )
2026-10-19 09:34:18 +01:00
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 " :
2026-10-24 11:43:56 +01:00
hours = 36 * ( 1 - 0.2 * building_level ( world , " temple " ) )
2026-10-20 10:46:18 +01:00
_add_effect ( world , now , hours , kind = " sickness " , name = " Sickness " , icon = " 🤒 " , food = 2.0 ,
text = " City eats twice as much food " )
2026-10-19 09:34:18 +01:00
_record ( world , now , " hardship " , kind , " 🤒 " , " Sickness is spreading through the city " ,
2026-10-20 10:46:18 +01:00
[ f " For about { round ( hours ) } hours the townsfolk eat twice as much food. " ,
2026-10-19 09:34:18 +01:00
" 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 )
2026-10-19 11:15:19 +01:00
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 :
2026-10-19 09:34:18 +01:00
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 " :
2026-10-24 11:43:56 +01:00
_add_effect ( world , now , 24 , kind = " winter " , name = " Harsh winter " , icon = " 🥶 " , firewood = 0.1 ,
2026-10-20 08:01:57 +01:00
text = " Burns stockpile logs hourly " )
2026-10-19 09:34:18 +01:00
_record ( world , now , " hardship " , kind , " 🥶 " , " A harsh cold snap has set in " ,
2026-10-24 11:43:56 +01:00
[ " For a day the city burns logs from the stockpile to keep warm. " ,
2026-10-20 10:46:18 +01:00
" Without firewood, the cold makes everyone eat 50 % more food. " ] )
2026-10-24 11:43:56 +01:00
elif kind == " blight " :
_add_effect ( world , now , 12 , kind = " blight " , name = " Blight " , icon = " 🥀 " , * * { " yield " : { " farming " : 0.5 } } ,
text = " Farming yield halved " )
_record ( world , now , " hardship " , kind , " 🥀 " , " Blight has struck the fields " ,
[ " For 12 hours, farming yields half. " ] )
elif kind == " tremors " :
_add_effect ( world , now , 12 , kind = " tremors " , name = " Tremors " , icon = " 🫨 " , speed = { " mining " : 1.3 } ,
text = " Mining 30 % s lower " )
_record ( world , now , " hardship " , kind , " 🫨 " , " The ground is shaking under the mines " ,
[ " For 12 hours, mining is 30 % s lower. " ] )
elif kind == " taxman " :
gold = int ( city [ " treasury " ] * 0.15 )
city [ " treasury " ] - = gold
_record ( world , now , " hardship " , kind , " 📜 " , " The crown ' s tax collector came by " ,
[ f " The collector took { gold : , } gold (15%) from the treasury. " if gold else " The treasury was empty, so the collector left grumbling. " ] )
2026-10-19 09:34:18 +01:00
else :
2026-10-24 11:43:56 +01:00
_add_effect ( world , now , 12 , kind = " drought " , name = " Drought " , icon = " 🏜️ " , speed = { " farming " : 1.3 , " fishing " : 1.2 } ,
2026-10-19 09:34:18 +01:00
text = " Farming 30 % a nd fishing 20 % s lower " )
_record ( world , now , " hardship " , kind , " 🏜️ " , " Drought has dried the fields and streams " ,
2026-10-24 11:43:56 +01:00
[ " For 12 hours, farming is 30 % a nd fishing 20 % s lower. " ] )
2026-10-19 09:34:18 +01:00
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 ( ) }
2026-10-24 11:43:56 +01:00
threat = upcoming_raid ( world )
if not threat :
2026-10-19 09:34:18 +01:00
weights [ " mercenaries " ] = 0
2026-10-24 11:43:56 +01:00
if not threat or threat [ " recon " ] > = intel_costs ( threat ) [ - 1 ] :
weights [ " trapper " ] = 0
2026-10-19 09:34:18 +01:00
kind = kind or _pick ( weights , rng )
if kind == " merchant " :
2026-10-24 11:43:56 +01:00
city [ " merchant_until " ] = now + 6 * HOUR / speed ( world )
2026-10-19 09:34:18 +01:00
_record ( world , now , " fortune " , kind , " 🐪 " , " A merchant caravan has arrived " ,
2026-10-24 11:43:56 +01:00
[ " For the next 6 hours, everything sells for 50 % more. " ] )
2026-10-19 09:34:18 +01:00
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 )
2026-10-24 11:43:56 +01:00
_add_effect ( world , now , 6 , kind = " master " , name = f " A master of { name . lower ( ) } " , icon = " 🎓 " ,
2026-10-20 08:01:57 +01:00
xp = { skill : 1.5 } , text = f " { name } +50% XP " )
2026-10-19 09:34:18 +01:00
_record ( world , now , " fortune " , kind , " 🎓 " , f " A travelling master of { name . lower ( ) } is visiting " ,
2026-10-24 11:43:56 +01:00
[ f " For 6 hours, { name . lower ( ) } earns 50% more XP. Learn while you can. " ] )
2026-10-19 09:34:18 +01:00
elif kind == " harvest " :
2026-10-24 11:43:56 +01:00
_add_effect ( world , now , 6 , kind = " harvest " , name = " Bountiful harvest " , icon = " 🌾 " , * * { " yield " : { " farming " : 2 } } ,
2026-10-20 08:01:57 +01:00
text = " Farming yield doubled " )
2026-10-19 09:34:18 +01:00
_record ( world , now , " fortune " , kind , " 🌾 " , " The fields are bursting: a bountiful harvest " ,
2026-10-24 11:43:56 +01:00
[ " Farming yields are doubled for 6 hours. " ] )
2026-10-19 09:34:18 +01:00
elif kind == " rich_vein " :
2026-10-24 11:43:56 +01:00
_add_effect ( world , now , 6 , kind = " rich_vein " , name = " Rich vein " , icon = " 💎 " , * * { " yield " : { " mining " : 1.5 } } ,
2026-10-20 08:01:57 +01:00
text = " Mining yield +50 % " )
2026-10-19 09:34:18 +01:00
_record ( world , now , " fortune " , kind , " 💎 " , " Miners struck a rich vein " ,
2026-10-24 11:43:56 +01:00
[ " Mining yields 50 % more for 6 hours. " ] )
2026-10-19 09:34:18 +01:00
elif kind == " salmon " :
2026-10-24 11:43:56 +01:00
_add_effect ( world , now , 6 , kind = " salmon " , name = " Salmon run " , icon = " 🐟 " , * * { " yield " : { " fishing " : 2 } } ,
2026-10-20 08:01:57 +01:00
text = " Fishing yield doubled " )
2026-10-19 09:34:18 +01:00
_record ( world , now , " fortune " , kind , " 🐟 " , " The salmon are running " ,
2026-10-24 11:43:56 +01:00
[ " Fishing yields are doubled for 6 hours. " ] )
2026-10-19 09:34:18 +01:00
elif kind == " festival " :
2026-10-24 11:43:56 +01:00
_add_effect ( world , now , 12 , kind = " festival " , name = " Festival " , icon = " 🎉 " , growth = 2 , favour = 1.5 ,
2026-10-20 08:01:57 +01:00
text = " Growth doubled, +50 % f avour from donations " )
2026-10-19 09:34:18 +01:00
_record ( world , now , " fortune " , kind , " 🎉 " , " The city is holding a festival " ,
2026-10-24 11:43:56 +01:00
[ " For 12 hours, the population grows twice as fast and donations earn 50 % more favour. " ] )
2026-10-19 09:34:18 +01:00
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
2026-10-24 11:43:56 +01:00
_add_effect ( world , now , 12 , kind = " mercenaries " , name = " Mercenaries for hire " , icon = " 🗡️ " , offer = True ,
2026-10-20 08:01:57 +01:00
cost = cost , defence = 25 , text = " +25 defence for the coming threat " )
2026-10-19 09:34:18 +01:00
_record ( world , now , " fortune " , kind , " 🗡️ " , " A sellsword company is camped outside the walls " ,
2026-10-24 11:43:56 +01:00
[ f " For 12 hours they ' ll join the next defence for { cost : , } gold from the treasury. " ] )
elif kind == " timber " :
_add_effect ( world , now , 6 , kind = " timber " , name = " Storm-felled timber " , icon = " 🪵 " , * * { " yield " : { " woodcutting " : 1.5 } } ,
text = " Woodcutting yield +50 % " )
_record ( world , now , " fortune " , kind , " 🪵 " , " A storm felled trees all over the woods " ,
[ " Woodcutting yields 50 % more for 6 hours. " ] )
elif kind == " deer " :
_add_effect ( world , now , 6 , kind = " deer " , name = " Deer migration " , icon = " 🦌 " , * * { " yield " : { " hunting " : 2 } } ,
text = " Hunting yield doubled " )
_record ( world , now , " fortune " , kind , " 🦌 " , " Herds are migrating past the city " ,
[ " Hunting yields are doubled for 6 hours. " ] )
elif kind == " masons " :
_add_effect ( world , now , 6 , kind = " masons " , name = " Masons ' guild " , icon = " 🧱 " , speed = { " construction " : 0.75 } ,
text = " Construction 25 % f aster " )
_record ( world , now , " fortune " , kind , " 🧱 " , " A masons ' guild is passing through " ,
[ " For 6 hours, construction is 25 % f aster. " ] )
elif kind == " artisans " :
_add_effect ( world , now , 6 , kind = " artisans " , name = " Artisans ' fair " , icon = " 🎨 " , speed = { s : 0.85 for s in CRAFTS } ,
text = " Smithing, jewelcrafting, cooking, leatherworking, tailoring and herblore 15 % f aster " )
_record ( world , now , " fortune " , kind , " 🎨 " , " Artisans from afar opened a fair " ,
[ " For 6 hours, every craft but construction and firekeeping is 15 % f aster. " ] )
elif kind == " trapper " :
city [ " recon_boost " ] = city . get ( " recon_boost " , 0 ) + TRAPPER_RECON
_record ( world , now , " fortune " , kind , " 🗺️ " , " A trapper brought word from the hills " ,
[ f " The next { TRAPPER_RECON } recon scouts gather counts double. " ] )
2026-10-19 09:34:18 +01:00
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
2026-10-19 17:58:46 +01:00
_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. " ] )
2026-10-19 09:34:18 +01:00
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 " ]
2026-10-19 18:33:50 +01:00
advance_weather ( world , now , rng )
2026-10-19 09:34:18 +01:00
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 " ) ) )
2026-10-24 11:43:56 +01:00
if spec . get ( " steal_gold " ) :
city [ " treasury " ] * = 1 - spec [ " steal_gold " ]
2026-10-19 09:34:18 +01:00
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 " ] ,
2026-10-20 21:46:37 +01:00
" next_icon " : WEATHER [ weather [ " next " ] ] [ " icon " ] , " next_text " : WEATHER [ weather [ " next " ] ] [ " text " ] } ,
2026-10-19 09:34:18 +01:00
" 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 ) ,
}
2026-10-24 13:34:59 +01:00
# --- gold ------------------------------------------------------------------
# Gold never buys results. It rents bank slots, pays the guards, buys caravan
# wares that boost your own work for a while, and buys looks.
RENT_BASE = 1000 # gold for the first rented bank slot...
RENT_GROWTH = 1.35 # ...and each one after costs this much more
def rent_cost ( player : dict ) - > int :
return int ( round ( RENT_BASE * RENT_GROWTH * * player [ " rented " ] / 50 ) * 50 )
def rent_slot ( player : dict ) - > str | None :
cost = rent_cost ( player )
if player [ " gold " ] < cost :
return f " Needs { cost : , } gold "
player [ " gold " ] - = cost
player [ " rented " ] + = 1
return None
# Caravan wares: each multiplies your own work in one skill for its next `charges`
# actions, or against the coming threat. `skill` None is picked by the buyer.
WARES = {
" whetstone " : { " name " : " Whetstone of the north " , " skill " : " combat " , " kind " : " drops " , " mult " : 2 , " charges " : 100 ,
" price " : 3000 , " text " : " Trophy and reagent odds × 2 " , " per " : " kills " } ,
" tutor " : { " name " : " Tutor ' s notes " , " skill " : None , " kind " : " xp " , " mult " : 1.5 , " charges " : 200 , " price " : 1500 ,
" text " : " +50 % X P " , " per " : " actions " } ,
" salts " : { " name " : " Ember salts " , " skill " : " firekeeping " , " kind " : " speed " , " mult " : 0.75 , " charges " : 100 , " price " : 1200 ,
" text " : " Tending 25 % f aster " , " per " : " tends " } ,
" seeds " : { " name " : " Seed bag " , " skill " : " farming " , " kind " : " yield " , " mult " : 1.5 , " charges " : 150 , " price " : 1200 ,
" text " : " +50 % yield " , " per " : " harvests " } ,
" prospector " : { " name " : " Prospector ' s charm " , " skill " : " mining " , " kind " : " yield " , " mult " : 1.5 , " charges " : 300 ,
" price " : 1500 , " text " : " +50 % yield " , " per " : " ores " } ,
" lure " : { " name " : " Silver lure " , " skill " : " fishing " , " kind " : " yield " , " mult " : 1.5 , " charges " : 300 , " price " : 1200 ,
" text " : " +50 % yield " , " per " : " catches " } ,
" flux " : { " name " : " Fine flux " , " skill " : " smithing " , " kind " : " speed " , " mult " : 0.8 , " charges " : 100 , " price " : 1500 ,
" text " : " Smithing 20 % f aster " , " per " : " crafts " } ,
" rations " : { " name " : " Trail rations " , " skill " : " combat " , " kind " : " rest " , " mult " : 0.5 , " charges " : 50 , " price " : 800 ,
" text " : " Rest 50 % f aster " , " per " : " fights " } ,
" spyglass " : { " name " : " Spyglass lens " , " skill " : " reconnaissance " , " kind " : " recon " , " mult " : 1.5 , " charges " : None ,
" price " : 1000 , " text " : " Your recon × 1.5 " , " per " : " threat " } ,
" manual " : { " name " : " Drill manual " , " skill " : " combat " , " kind " : " drill " , " mult " : 1.5 , " charges " : None ,
" price " : 1000 , " text " : " Your drilling × 1.5 " , " per " : " threat " } ,
}
WARE_GLYPHS = { " whetstone " : ( " stone-sphere " , " #a8b4c8 " ) , " tutor " : ( " book-cover " , " #4fa3e0 " ) , " salts " : ( " powder " , " #ff7a2a " ) ,
" seeds " : ( " plant-seed " , " #8fbf6a " ) , " prospector " : ( " gems " , " #7fd0e0 " ) , " lure " : ( " fishing-lure " , " #d4dae2 " ) ,
" flux " : ( " powder-bag " , " #d07a3c " ) , " rations " : ( " meat " , " #c06a4a " ) , " spyglass " : ( " spyglass " , " #6f8fec " ) ,
" manual " : ( " scroll-unfurled " , " #e0a050 " ) }
CARAVAN_HOURS = 12 # how long the caravan stays
CARAVAN_WARES = 4 # wares it brings from the pool, plus one look
def ware ( player : dict , kind : str , skill : str , spend : bool = False ) - > float :
""" The multiplier a caravan ware gives this kind of work, using up a charge if `spend`. """
for boost in player [ " boosts " ] :
spec = WARES [ boost [ " ware " ] ]
if spec [ " kind " ] == kind and boost [ " skill " ] == skill :
if spend and " left " in boost :
boost [ " left " ] - = 1
if boost [ " left " ] < = 0 :
player [ " boosts " ] . remove ( boost )
return spec [ " mult " ]
return 1
def ware_price ( player : dict , ware_id : str , skill : str ) - > int :
""" The list price, more for someone further along in the skill it helps. """
return int ( round ( WARES [ ware_id ] [ " price " ] * ( 1 + skill_level ( player , skill ) / 30 ) / 50 ) * 50 )
def _plan_caravan ( world : dict , now : float , rng : random . Random ) - > None :
""" The caravan comes once per threat, on the first day, for half a day. """
at = now + rng . uniform ( 2 , 16 ) * HOUR / speed ( world )
world [ " caravan " ] = { " id " : world [ " next_caravan_id " ] , " at " : at , " until " : at + CARAVAN_HOURS * HOUR / speed ( world ) ,
" wares " : None , " look " : None }
world [ " next_caravan_id " ] + = 1
def caravan_here ( world : dict , now : float ) - > dict | None :
caravan = world . get ( " caravan " )
return caravan if caravan and caravan [ " wares " ] is not None and caravan [ " at " ] < = now < caravan [ " until " ] else None
def _caravan_tick ( world : dict , now : float , rng : random . Random ) - > bool :
caravan = world . get ( " caravan " )
if not caravan or caravan [ " wares " ] is not None or now < caravan [ " at " ] or now > = caravan [ " until " ] :
return False
caravan [ " wares " ] = rng . sample ( sorted ( WARES ) , CARAVAN_WARES )
caravan [ " look " ] = rng . choice ( sorted ( k for k , c in COSMETICS . items ( ) if c . get ( " price " ) ) )
names = [ WARES [ w ] [ " name " ] . lower ( ) for w in caravan [ " wares " ] ] + [ COSMETICS [ caravan [ " look " ] ] [ " name " ] . lower ( ) ]
_record ( world , now , " fortune " , " caravan " , " 🐫 " , " A caravan has arrived " ,
[ f " For { CARAVAN_HOURS } hours it sells { ' , ' . join ( names [ : - 1 ] ) } and { names [ - 1 ] } . " ] )
return True
def buy_ware ( world : dict , player : dict , ware_id : str , skill : str | None , now : float ) - > str | None :
caravan = caravan_here ( world , now )
if not caravan :
return " The caravan isn ' t here "
bought = player [ " bought " ] if player [ " bought " ] and player [ " bought " ] [ " visit " ] == caravan [ " id " ] else { " visit " : caravan [ " id " ] , " items " : [ ] }
if ware_id in bought [ " items " ] :
return " One of each per visit "
if ware_id == caravan [ " look " ] :
spec = COSMETICS [ ware_id ]
if ware_id in player [ " owned " ] :
return " You have that already "
if player [ " gold " ] < spec [ " price " ] :
return f " Needs { spec [ ' price ' ] : , } gold "
player [ " gold " ] - = spec [ " price " ]
player [ " owned " ] . append ( ware_id )
else :
if ware_id not in caravan [ " wares " ] :
return " Not for sale "
spec = WARES [ ware_id ]
skill = spec [ " skill " ] or skill
if skill not in SKILL_IDS :
return " Pick a skill "
threat = upcoming_raid ( world )
if spec [ " charges " ] is None and not threat :
return " There ' s no threat coming "
price = ware_price ( player , ware_id , skill )
if player [ " gold " ] < price :
return f " Needs { price : , } gold "
player [ " gold " ] - = price
held = next ( ( b for b in player [ " boosts " ] if b [ " ware " ] == ware_id and b [ " skill " ] == skill ) , None )
if spec [ " charges " ] is None :
if held :
return " You have one for this threat "
player [ " boosts " ] . append ( { " ware " : ware_id , " skill " : skill , " threat " : threat [ " id " ] } )
elif held :
held [ " left " ] + = spec [ " charges " ]
else :
player [ " boosts " ] . append ( { " ware " : ware_id , " skill " : skill , " left " : spec [ " charges " ] } )
bought [ " items " ] . append ( ware_id )
player [ " bought " ] = bought
return None
def caravan_view ( world : dict , player : dict , now : float ) - > dict | None :
caravan = caravan_here ( world , now )
if not caravan :
return None
bought = player [ " bought " ] [ " items " ] if player [ " bought " ] and player [ " bought " ] [ " visit " ] == caravan [ " id " ] else [ ]
wares = [ ]
for ware_id in caravan [ " wares " ] :
spec = WARES [ ware_id ]
skills = [ spec [ " skill " ] ] if spec [ " skill " ] else [ s for s in SKILL_IDS ]
wares . append ( { " id " : ware_id , * * spec , " glyph " : WARE_GLYPHS [ ware_id ] ,
" prices " : { s : ware_price ( player , ware_id , s ) for s in skills } , " bought " : ware_id in bought } )
look = COSMETICS [ caravan [ " look " ] ]
return { " until " : caravan [ " until " ] , " wares " : wares ,
" look " : { " id " : caravan [ " look " ] , * * look , " bought " : caravan [ " look " ] in bought , " owned " : caravan [ " look " ] in player [ " owned " ] } }
# Looks: a banner (field, trim, emblem), a title after your name, and your home on the roster.
# Free colours and earned titles cost nothing; the caravan sells one more look per visit.
DEFAULT_LOOK = { " field " : " slate " , " trim " : " bone " , " emblem " : None , " title " : None }
COSMETICS = {
# free
" slate " : { " kind " : " colour " , " name " : " Slate " , " value " : " #5a6478 " } ,
" navy " : { " kind " : " colour " , " name " : " Navy " , " value " : " #2f4f8a " } ,
" oxblood " : { " kind " : " colour " , " name " : " Oxblood " , " value " : " #7a2f2f " } ,
" forest " : { " kind " : " colour " , " name " : " Forest " , " value " : " #2f6b3a " } ,
" bone " : { " kind " : " colour " , " name " : " Bone " , " value " : " #e7e9f0 " } ,
" brass " : { " kind " : " colour " , " name " : " Brass " , " value " : " #e0b04a " } ,
# sold by the caravan
" royal_purple " : { " kind " : " colour " , " name " : " Royal purple dye " , " value " : " #6b3f8a " , " price " : 800 } ,
" teal " : { " kind " : " colour " , " name " : " Deep teal dye " , " value " : " #1f6f6f " , " price " : 600 } ,
" ember " : { " kind " : " colour " , " name " : " Ember dye " , " value " : " #ff7a2a " , " price " : 800 } ,
" wine " : { " kind " : " colour " , " name " : " Wine dye " , " value " : " #8a2f5a " , " price " : 600 } ,
" jet " : { " kind " : " colour " , " name " : " Jet dye " , " value " : " #111318 " , " price " : 1000 } ,
" sky " : { " kind " : " colour " , " name " : " Sky dye " , " value " : " #7fb6e8 " , " price " : 600 } ,
" e_swords " : { " kind " : " emblem " , " name " : " Crossed swords emblem " , " value " : " crossed-swords " , " price " : 1200 } ,
" e_anvil " : { " kind " : " emblem " , " name " : " Anvil emblem " , " value " : " anvil " , " price " : 1200 } ,
" e_castle " : { " kind " : " emblem " , " name " : " Tower emblem " , " value " : " castle " , " price " : 1200 } ,
" e_wolf " : { " kind " : " emblem " , " name " : " Wolf emblem " , " value " : " wolf-head " , " price " : 1500 } ,
" e_stag " : { " kind " : " emblem " , " name " : " Stag emblem " , " value " : " deer " , " price " : 1500 } ,
" e_sun " : { " kind " : " emblem " , " name " : " Sun emblem " , " value " : " sun " , " price " : 1500 } ,
" e_oak " : { " kind " : " emblem " , " name " : " Oak emblem " , " value " : " oak " , " price " : 1500 } ,
" e_rose " : { " kind " : " emblem " , " name " : " Rose emblem " , " value " : " rose " , " price " : 2000 } ,
" t_thrifty " : { " kind " : " title " , " name " : " Title: the Thrifty " , " value " : " the Thrifty " , " price " : 1000 } ,
" t_bold " : { " kind " : " title " , " name " : " Title: the Bold " , " value " : " the Bold " , " price " : 2000 } ,
" t_wanderer " : { " kind " : " title " , " name " : " Title: the Wanderer " , " value " : " the Wanderer " , " price " : 1500 } ,
" t_lucky " : { " kind " : " title " , " name " : " Title: the Lucky " , " value " : " the Lucky " , " price " : 2500 } ,
" t_unbowed " : { " kind " : " title " , " name " : " Title: the Unbowed " , " value " : " the Unbowed " , " price " : 3000 } ,
}
FREE_LOOKS = [ key for key , c in COSMETICS . items ( ) if not c . get ( " price " ) ]
# Boss emblems are only won, by beating the boss.
for _a in ACTIONS :
if " enemy " in _a and _a [ " enemy " ] . get ( " boss " ) :
COSMETICS [ f " boss_ { _a [ ' id ' ] } " ] = { " kind " : " emblem " , " name " : f " { _a [ ' enemy ' ] [ ' name ' ] } emblem " , " boss " : _a [ " id " ] }
SKILL_TITLES = { " mining " : " miner " , " woodcutting " : " woodcutter " , " farming " : " farmer " , " hunting " : " hunter " , " fishing " : " angler " ,
" smithing " : " smith " , " jewelcrafting " : " jeweller " , " construction " : " builder " , " cooking " : " cook " ,
" leatherworking " : " tanner " , " tailoring " : " tailor " , " herblore " : " herbalist " , " firekeeping " : " flamekeeper " ,
" combat " : " warrior " , " prayer " : " priest " , " reconnaissance " : " scout " }
2026-10-25 08:34:24 +00:00
SKILL_EXTRA_TITLES = [ ( " fishing " , 50 , " Master baiter " ) ]
2026-10-24 13:34:59 +01:00
DEED_TITLES = [ ( " defended " , 5 , " Defender of the City " ) , ( " dragons " , 1 , " Dragonslayer " ) , ( " bosses " , 10 , " Bossbane " ) ]
HOMES = [ ( " Hut " , 0 ) , ( " Cottage " , 2000 ) , ( " House " , 8000 ) , ( " Hall " , 25000 ) , ( " Manor " , 75000 ) ]
MONUMENTS = {
" gate_banner " : { " name " : " Gate banner " , " cost " : 10000 , " glyph " : " flying-flag " , " tint " : " #e0b04a " , " once " : True ,
" text " : " The guild ' s colours over the city gate. " } ,
" fountain " : { " name " : " Fountain " , " cost " : 25000 , " glyph " : " fountain " , " tint " : " #5aa0d8 " , " once " : True ,
" text " : " A fountain in the town square. " } ,
" statue " : { " name " : " Statue of a guildmate " , " cost " : 50000 , " glyph " : " stone-bust " , " tint " : " #d4dae2 " , " once " : False ,
" text " : " Carved in stone. The biggest donor picks who. " } ,
}
def _deed ( player : dict , key : str ) - > None :
player [ " deeds " ] [ key ] = player [ " deeds " ] . get ( key , 0 ) + 1
def earned_titles ( player : dict ) - > list [ str ] :
titles = [ ]
for skill , noun in SKILL_TITLES . items ( ) :
level = skill_level ( player , skill )
if level > = 99 :
titles . append ( f " Grandmaster { noun } " )
elif level > = 50 :
titles . append ( f " Master { noun } " )
2026-10-25 08:34:24 +00:00
titles + = [ title for skill , need , title in SKILL_EXTRA_TITLES if skill_level ( player , skill ) > = need ]
2026-10-24 13:34:59 +01:00
titles + = [ title for key , need , title in DEED_TITLES if player [ " deeds " ] . get ( key , 0 ) > = need ]
if player [ " donated " ] > = 100_000 :
titles . append ( " Patron of the City " )
return titles
def titles ( player : dict ) - > list [ str ] :
return earned_titles ( player ) + [ COSMETICS [ c ] [ " value " ] for c in player [ " owned " ] if COSMETICS [ c ] [ " kind " ] == " title " ]
def can_wear ( player : dict , key : str | None , kind : str ) - > bool :
if key is None :
return kind == " emblem "
spec = COSMETICS . get ( key )
return bool ( spec ) and spec [ " kind " ] == kind and ( key in FREE_LOOKS or key in player [ " owned " ] )
def set_look ( player : dict , look : dict ) - > str | None :
field , trim , emblem , title = ( look . get ( k ) for k in ( " field " , " trim " , " emblem " , " title " ) )
if not ( can_wear ( player , field , " colour " ) and can_wear ( player , trim , " colour " ) and can_wear ( player , emblem , " emblem " ) ) :
return " You don ' t own that "
if title is not None and title not in titles ( player ) :
return " You haven ' t earned that title "
player [ " look " ] = { " field " : field , " trim " : trim , " emblem " : emblem , " title " : title }
return None
def look_view ( player : dict ) - > dict :
""" What others see: banner colours and emblem resolved, title, home. """
look = player [ " look " ]
title = look [ " title " ] if look [ " title " ] in titles ( player ) else None
emblem = COSMETICS [ look [ " emblem " ] ] if look [ " emblem " ] else None
glyph = None
if emblem :
glyph = emblem . get ( " value " ) or ACTION_BY_ID [ emblem [ " boss " ] ] [ " glyph " ]
return { " field " : COSMETICS [ look [ " field " ] ] [ " value " ] , " trim " : COSMETICS [ look [ " trim " ] ] [ " value " ] , " emblem " : glyph ,
" title " : title , " home " : HOMES [ player . get ( " home " , 0 ) ] [ 0 ] }
def upgrade_home ( player : dict ) - > str | None :
level = player . get ( " home " , 0 )
if level + 1 > = len ( HOMES ) :
return " Your home is as grand as it gets "
name , cost = HOMES [ level + 1 ]
if player [ " gold " ] < cost :
return f " Needs { cost : , } gold "
player [ " gold " ] - = cost
player [ " home " ] = level + 1
return None
def give_monument ( world : dict , player : dict , key : str , qty , now : float ) - > str | None :
""" Gold towards a monument. No favour: your name in the stone is the reward. """
city = world [ " city " ]
spec = MONUMENTS . get ( key )
if not spec :
return " No such monument "
if spec [ " once " ] and any ( b [ " kind " ] == key for b in city [ " built " ] ) :
return " It ' s already built "
if key == " statue " and city [ " carving " ] :
return " A statue is being carved "
fund = city [ " monuments " ] . setdefault ( key , { " gold " : 0 , " donors " : { } } )
room = spec [ " cost " ] - fund [ " gold " ]
qty = min ( player [ " gold " ] , room ) if qty == " all " else qty
if not isinstance ( qty , int ) or qty < = 0 :
return " Nothing to give "
if qty > player [ " gold " ] :
return " Not enough gold "
qty = min ( qty , room )
player [ " gold " ] - = qty
fund [ " gold " ] + = qty
fund [ " donors " ] [ player [ " id " ] ] = fund [ " donors " ] . get ( player [ " id " ] , 0 ) + qty
if fund [ " gold " ] > = spec [ " cost " ] :
del city [ " monuments " ] [ key ]
if key == " statue " :
chooser = max ( fund [ " donors " ] , key = lambda uid : fund [ " donors " ] [ uid ] )
city [ " carving " ] = { " by " : chooser , " donors " : fund [ " donors " ] }
log ( world , now , " build " , f " The statue fund is full. { world [ ' players ' ] [ chooser ] [ ' name ' ] } picks who it honours. " )
else :
city [ " built " ] . append ( { " kind " : key , " at " : now , " donors " : fund [ " donors " ] , " of " : None } )
log ( world , now , " build " , f " The { spec [ ' name ' ] . lower ( ) } is finished " )
return None
def carve_statue ( world : dict , player : dict , of : str , now : float ) - > str | None :
city = world [ " city " ]
carving = city [ " carving " ]
if not carving or carving [ " by " ] != player [ " id " ] :
return " It isn ' t yours to pick "
if of not in world [ " players " ] :
return " No such guildmate "
city [ " built " ] . append ( { " kind " : " statue " , " at " : now , " donors " : carving [ " donors " ] , " of " : of } )
city [ " carving " ] = None
log ( world , now , " build " , f " A statue of { world [ ' players ' ] [ of ] [ ' name ' ] } now stands in the town square " )
return None
def monuments_view ( world : dict ) - > dict :
city = world [ " city " ]
return { " funds " : city [ " monuments " ] , " built " : city [ " built " ] , " carving " : city [ " carving " ] }
2026-10-25 20:01:33 +00:00
# --- achievements ----------------------------------------------------------------
# What's left to unlock. Read off what you already have, never a source of anything:
# patterns learned, uniques found, titles, looks, your home.
def _got ( name : str , done , glyph : str , tint : str , tip : str = " " , colour : str | None = None ) - > dict :
return { " name " : name , " done " : bool ( done ) , " glyph " : glyph , " tint " : tint , " tip " : tip , * * ( { " colour " : colour } if colour else { } ) }
def achievements ( player : dict ) - > list [ dict ] :
""" Sections of cards, each card a set of things to collect. """
skill_name = { s [ " id " ] : s [ " name " ] for s in SKILLS }
dungeons = sorted ( DUNGEON_LOOT , key = lambda key : DUNGEONS [ key ] [ " level " ] )
patterns = [ ]
for key in dungeons :
cards = [ ]
for pattern in ACTION_BY_ID [ key ] [ " loot " ] [ " patterns " ] :
spec , made = ITEMS [ pattern ] , ITEMS [ ITEMS [ pattern ] [ " pattern " ] ]
held = pattern not in player [ " patterns " ] and player [ " bank " ] . get ( pattern )
tip = f " { skill_name [ spec [ ' skill ' ] ] } { spec [ ' level ' ] } " + ( " · 📜 in bank " if held else " " )
cards . append ( { * * _got ( made [ " name " ] , pattern in player [ " patterns " ] , made [ " glyph " ] , made [ " tint " ] , tip ) , " held " : bool ( held ) } )
patterns . append ( { " name " : DUNGEONS [ key ] [ " name " ] , " entries " : cards } )
found = player . get ( " found " , [ ] )
uniques = [ _got ( ITEMS [ unique ] [ " name " ] , unique in found , ITEMS [ unique ] [ " glyph " ] , ITEMS [ unique ] [ " tint " ] ,
DUNGEONS [ key ] [ " name " ] ) for key in dungeons for unique in [ DUNGEON_LOOT [ key ] [ 2 ] ] ]
def level_title ( need : int , rank : str ) - > list [ dict ] :
return [ _got ( f " { rank } { noun } " , skill_level ( player , skill ) > = need , * SKILL_GLYPHS [ skill ] ,
f " { skill_name [ skill ] } { skill_level ( player , skill ) } / { need } " ) for skill , noun in SKILL_TITLES . items ( ) ]
deeds = { " defended " : ( " checked-shield " , " #d4dae2 " , " Raids won on guard " ) , " dragons " : ( " dragon-head " , " #e05a5a " , " Dragons slain on guard " ) ,
" bosses " : ( " crossed-swords " , " #e0b04a " , " Bosses beaten " ) }
feats = [ _got ( title , skill_level ( player , skill ) > = need , * SKILL_GLYPHS [ skill ] , f " { skill_name [ skill ] } { skill_level ( player , skill ) } / { need } " )
for skill , need , title in SKILL_EXTRA_TITLES ]
feats + = [ _got ( title , player [ " deeds " ] . get ( key , 0 ) > = need , * deeds [ key ] [ : 2 ] , f " { deeds [ key ] [ 2 ] } { player [ ' deeds ' ] . get ( key , 0 ) } / { need } " )
for key , need , title in DEED_TITLES ]
feats . append ( _got ( " Patron of the City " , player [ " donated " ] > = 100_000 , " sparkles " , " #b98ce8 " ,
f " Given { player [ ' donated ' ] : , } / 100,000 " ) )
def bought ( kind : str ) - > list [ dict ] :
return [ _got ( spec [ " value " ] if kind == " title " else spec [ " name " ] , key in player [ " owned " ] ,
" scroll-unfurled " if kind == " title " else spec . get ( " value " , " " ) , " #e0b04a " ,
f " Caravan · { spec [ ' price ' ] : , } gold " , spec [ " value " ] if kind == " colour " else None )
for key , spec in COSMETICS . items ( ) if spec [ " kind " ] == kind and spec . get ( " price " ) ]
bosses = [ _got ( spec [ " name " ] , key in player [ " owned " ] , ACTION_BY_ID [ spec [ " boss " ] ] [ " glyph " ] , ACTION_BY_ID [ spec [ " boss " ] ] [ " tint " ] ,
ACTION_BY_ID [ spec [ " boss " ] ] [ " name " ] ) for key , spec in COSMETICS . items ( ) if spec . get ( " boss " ) ]
homes = [ _got ( name , player . get ( " home " , 0 ) > = level , " castle " , " #d4dae2 " , f " { cost : , } gold " )
for level , ( name , cost ) in enumerate ( HOMES ) if level ]
return [
{ " name " : " Masterwork patterns " , " cards " : patterns } ,
{ " name " : " Dungeon loot " , " cards " : [ { " name " : " Uniques " , " entries " : uniques } ] } ,
{ " name " : " Titles " , " cards " : [ { " name " : " Masters " , " entries " : level_title ( 50 , " Master " ) } ,
{ " name " : " Grandmasters " , " entries " : level_title ( 99 , " Grandmaster " ) } ,
{ " name " : " Deeds " , " entries " : feats } , { " name " : " Caravan titles " , " entries " : bought ( " title " ) } ] } ,
{ " name " : " Looks " , " cards " : [ { " name " : " Boss emblems " , " entries " : bosses } , { " name " : " Caravan emblems " , " entries " : bought ( " emblem " ) } ,
{ " name " : " Dyes " , " entries " : bought ( " colour " ) } , { " name " : " Home " , " entries " : homes } ] } ,
]
2026-10-25 20:07:50 +00:00
# --- favour --------------------------------------------------------------
MASTER_BUILDER_LABOUR = 50 # boosted labour per hire: doubled labour and XP for whoever works it
MASTER_BUILDER_FAVOUR = 150 # +50% for every further hire on the same site
INFORMANT_FAVOUR = 250
# Drilling: fighters train the townsfolk before a raid. A full drill takes as
# many points as full intel on the same threat, so fighters and scouts put in
# about the same time; the defence it's worth grows with the population.
GUARD_POSTS_BASE = 10 # guard posts with no barracks...
GUARD_POSTS = 20 # ...and per barracks level, never more than half the townsfolk
GUARD_WAGE = 60 # gold per guard per threat, paid into the chest ahead of time
DRILL_PER_ACTION = 1.0 # drill points per session, +25% every 20 combat levels
DRILL_XP = 12 # combat XP per drill point
DRILL_SHARE = 1.0 # defence of a fully drilled militia, per townsperson
DRILL_SERGEANT_POINTS = 30 # boosted drilling per hire: doubled points and XP
DRILL_SERGEANT_FAVOUR = 150 # +50% for every further hire against the same threat
2026-10-19 15:21:49 +01:00
def drill_per_action ( player : dict ) - > float :
2026-10-24 13:34:59 +01:00
return DRILL_PER_ACTION * ( 1 + 0.25 * ( skill_level ( player , " combat " ) / / 20 ) ) * ware ( player , " drill " , " combat " )
2026-10-19 15:35:35 +01:00
def drill_needed ( threat : dict ) - > int :
return intel_costs ( threat ) [ - 1 ]
2026-10-19 15:21:49 +01:00
def drill_cap ( world : dict ) - > float :
return round ( DRILL_SHARE * world [ " city " ] [ " population " ] , 1 )
def drilled ( world : dict ) - > float :
2026-10-19 15:35:35 +01:00
""" Defence the drilling is worth so far: a share of the cap, as far as the drill has got. """
2026-10-19 15:21:49 +01:00
threat = upcoming_raid ( world )
2026-10-19 15:35:35 +01:00
if not threat :
return 0
return drill_cap ( world ) * min ( 1.0 , threat . get ( " drilled " , 0 ) / drill_needed ( threat ) )
2026-10-18 19:48:22 +01:00
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 " : {
2026-10-19 11:01:29 +01:00
" 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 ) ) ,
2026-10-18 19:48:22 +01:00
} ,
" informant " : {
2026-10-19 15:14:00 +01:00
" cost " : round ( INFORMANT_FAVOUR * ( 1 + 0.5 * ( raid or { } ) . get ( " informants " , 0 ) ) ) ,
2026-10-19 15:09:51 +01:00
" why " : " No news to dig into " if not raid else
2026-10-19 22:45:32 +01:00
" Nothing more to learn " if raid [ " recon " ] > = intel_costs ( raid ) [ - 1 ] else None ,
2026-10-19 15:14:00 +01:00
" recon " : INFORMANT_RECON ,
" boost " : round ( city . get ( " recon_boost " , 0 ) ) ,
2026-10-18 19:48:22 +01:00
} ,
2026-10-19 15:21:49 +01:00
" sergeant " : {
" cost " : round ( DRILL_SERGEANT_FAVOUR * ( 1 + 0.5 * ( raid or { } ) . get ( " sergeants " , 0 ) ) ) ,
" why " : " No raid to prepare for " if not raid else
2026-10-19 15:35:35 +01:00
" The militia is as drilled as it can be " if raid . get ( " drilled " , 0 ) > = drill_needed ( raid ) else None ,
2026-10-19 15:21:49 +01:00
" drill " : DRILL_SERGEANT_POINTS ,
" boost " : round ( city . get ( " drill_boost " , 0 ) , 1 ) ,
2026-10-19 15:35:35 +01:00
" points " : int ( ( raid or { } ) . get ( " drilled " , 0 ) ) , " needed " : drill_needed ( raid ) if raid else 0 ,
" defence " : round ( drilled ( world ) , 1 ) , " cap " : drill_cap ( world ) ,
2026-10-18 19:48:22 +01:00
} ,
}
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 " ]
2026-10-19 11:01:29 +01:00
city [ " site_boost " ] = city . get ( " site_boost " , 0 ) + MASTER_BUILDER_LABOUR
city [ " boosts_bought " ] = city . get ( " boosts_bought " , 0 ) + 1
2026-10-18 19:48:22 +01:00
name = BUILDINGS [ work [ " building " ] ] [ " name " ] . lower ( )
2026-10-19 11:01:29 +01:00
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 " ] )
2026-10-18 19:48:22 +01:00
elif kind == " informant " :
2026-10-19 15:14:00 +01:00
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 " ] )
2026-10-18 19:48:22 +01:00
else :
2026-10-19 15:21:49 +01:00
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 } "
2026-10-19 15:35:35 +01:00
f " drill points count double " , player [ " id " ] )
2026-10-18 19:48:22 +01:00
return None
2026-10-18 13:48:56 +01:00
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
2026-10-20 20:06:16 +01:00
def toggle_pin ( player : dict , skill : str ) - > str | None :
if skill not in SKILL_IDS :
return " No such skill "
pinned = player [ " pinned " ]
if skill in pinned :
pinned . remove ( skill )
else :
pinned . append ( skill )
return None
2026-10-20 21:13:46 +01:00
def toggle_track ( player : dict , action_id : str ) - > str | None :
action = ACTION_BY_ID . get ( action_id )
if not action or not action [ " inputs " ] :
return " No such recipe "
player [ " tracked " ] = None if player [ " tracked " ] == action_id else action_id
return None
2026-10-19 10:04:38 +01:00
# --- 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 " ,
2026-10-20 21:20:25 +01:00
" hunting knife " : " bowie-knife " , " hammer " : " claw-hammer " , " chisel " : " chisel " , " gem drill " : " drill " ,
2026-10-21 12:16:22 +01:00
" cleaver " : " meat-cleaver " , " mortar and pestle " : " mortar " , " awl " : " needle-drill " , " shears " : " shears " ,
" bellows " : " bellows " }
2026-10-19 10:04:38 +01:00
LEATHER_GLYPHS = { " cap " : " hood " , " legs " : " trousers " , " body " : " leather-vest " }
2026-10-20 14:16:12 +01:00
CLOTH_GLYPHS = { " hood " : " pointy-hat " , " legs " : " trousers " , " body " : " robe " }
FIBRE_GLYPHS = { " cotton " : " cotton-flower " , " silk_cocoons " : " butterfly " , " moonpetal " : " lotus-flower " ,
" starbloom " : " spiral-bloom " , " spiritvine " : " vine-flower " }
CLOTH_TINTS = [ " #d8d0b8 " , " #f0f0f0 " , " #e8c8e0 " , " #a8c8f0 " , " #f0e080 " , " #9be0c8 " ]
2026-10-20 19:59:45 +01:00
REAGENT_GLYPHS = { " heavy " : " chain-mail " , " medium " : " scale-mail " , " light " : " yarn " , " jewel " : " crystal-shine " }
2026-10-19 23:38:34 +01:00
WEAPON_GLYPHS = { " hammer " : " warhammer " , " staff " : " wizard-staff " , " bow " : " pocket-bow " }
2026-10-19 23:17:23 +01:00
HARDWARE_GLYPHS = [ " screw " , " nails " , " door-handle " , " metal-plate " , " crossed-chains " , " padlock " ]
2026-10-19 10:04:38 +01:00
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 " ) ,
2026-10-20 19:59:45 +01:00
" silver_ore " : ( " ore " , " #dfe4ec " ) , " silver_bar " : ( " metal-bar " , " #dfe4ec " ) ,
" gold_ore " : ( " ore " , " #f0c040 " ) , " gold_bar " : ( " gold-bar " , " #f0c040 " ) ,
2026-10-19 22:45:32 +01:00
" thatch " : ( " high-grass " , " #d8c070 " ) , " flax " : ( " flax " , " #8fb0d8 " ) , " rope " : ( " rope-coil " , " #c9a66b " ) ,
" hide_glue " : ( " honey-jar " , " #c08a40 " ) , " provisions " : ( " knapsack " , " #a08050 " ) ,
2026-10-19 10:04:38 +01:00
" 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 " ) ,
2026-10-20 19:59:45 +01:00
" jewelcrafting " : ( " gems " , " #e070c0 " ) ,
2026-10-19 23:17:23 +01:00
" construction " : ( " hammer-nails " , " #d1a46a " ) ,
2026-10-19 10:04:38 +01:00
" cooking " : ( " cooking-pot " , " #e0703a " ) , " leatherworking " : ( " sewing-needle " , " #d6a26e " ) ,
" herblore " : ( " cauldron " , " #7fbf5a " ) , " combat " : ( " crossed-swords " , " #e05a5a " ) , " prayer " : ( " prayer " , " #e9dfc6 " ) ,
2026-10-20 14:16:12 +01:00
" reconnaissance " : ( " spyglass " , " #6f8fec " ) , " tailoring " : ( " sewing-machine " , " #c8a0e0 " ) ,
2026-10-21 12:16:22 +01:00
" firekeeping " : ( " campfire " , " #ff7a2a " ) ,
2026-10-19 10:04:38 +01:00
}
2026-10-19 22:45:32 +01:00
CLASS_GLYPHS = { " miner " : ( " miner " , " #d07a3c " ) , " builder " : ( " crane " , " #d1a46a " ) ,
" homesteader " : ( " farmer " , " #8fbf6a " ) , " ranger " : ( " bow-arrow " , " #5fae8a " ) }
2026-10-19 10:04:38 +01:00
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 " ) ,
2026-10-24 13:37:34 +01:00
" dungeon_watchtower " : ( " watchtower " , " #d1a46a " ) , " dungeon_goblin_warren " : ( " cave-entrance " , " #7fbf5a " ) ,
" dungeon_bandit_stronghold " : ( " guarded-tower " , " #a08060 " ) , " dungeon_barrow_crypts " : ( " tombstone " , " #8fb8f0 " ) ,
" dungeon_deep_forge " : ( " anvil-impact " , " #ff8a3a " ) , " dungeon_frozen_hold " : ( " frozen-orb " , " #8fd0e0 " ) ,
" dungeon_dragons_lair " : ( " dragon-spiral " , " #e05a5a " ) ,
2026-10-19 10:04:38 +01:00
" fight_green_dragon " : ( " dragon-head " , " #5fae5a " ) , " fight_blue_dragon " : ( " dragon-head " , " #4f86d6 " ) ,
" fight_red_dragon " : ( " dragon-head " , " #d0503c " ) , " fight_black_dragon " : ( " dragon-head " , " #8a8f98 " ) ,
2026-10-19 15:21:49 +01:00
" sabotage " : ( " fire-bomb " , " #e0703a " ) , " drill_militia " : ( " sword-brandish " , " #e0a050 " ) , " recon_roads " : ( " spyglass " , " #6f8fec " ) , " recon_camps " : ( " spyglass " , " #8a7fd0 " ) ,
2026-10-19 10:04:38 +01:00
" 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 " ) ,
2026-10-24 11:43:56 +01:00
" clear_spiders " : ( " spider-alt " , " #9a7fb0 " ) , " catch_smugglers " : ( " pirate-hat " , " #a08060 " ) ,
" smoke_hornets " : ( " wasp-sting " , " #e3c060 " ) ,
2026-10-20 14:16:12 +01:00
" hunt_elk " : ( " deer " , " #a07a50 " ) , " hunt_sabrecat " : ( " saber-toothed-cat-head " , " #e0a050 " ) ,
" hunt_mammoth " : ( " mammoth " , " #8a6a50 " ) ,
2026-10-19 10:04:38 +01:00
}
2026-10-24 10:01:41 +01:00
# Abilities: yours by weapon and trade, then the enemies' (shown as they cast).
ABILITY_GLYPHS = {
" shield_bash " : ( " shield-bash " , " #d4dae2 " ) , " challenge " : ( " shouting " , " #e0a050 " ) ,
" sweeping_cut " : ( " sword-spin " , " #d4dae2 " ) , " shield_wall " : ( " stone-wall " , " #a8a29e " ) ,
" crushing_blow " : ( " hammer-break " , " #d1a46a " ) , " concuss " : ( " knockout " , " #f0d060 " ) ,
" earthshaker " : ( " quake-stomp " , " #c08850 " ) , " unstoppable " : ( " enrage " , " #e06a6a " ) ,
" bolt " : ( " bolt-spell-cast " , " #a77be8 " ) , " mend " : ( " healing " , " #5fae5a " ) ,
" ward " : ( " energy-shield " , " #8fb8f0 " ) , " thornbind " : ( " thorny-vine " , " #6f9a4a " ) ,
" aimed_shot " : ( " target-arrows " , " #e0a050 " ) , " pinning_shot " : ( " arrowed " , " #8fd0e0 " ) ,
" barbed_arrow " : ( " barbed-arrow " , " #e05a5a " ) , " volley " : ( " arrow-cluster " , " #5fae8a " ) ,
" find_the_seam " : ( " armor-downgrade " , " #d07a3c " ) , " barricade " : ( " barricade " , " #d1a46a " ) ,
" hearty_meal " : ( " cooking-pot " , " #e0b04a " ) , " mark_prey " : ( " targeted " , " #e06a6a " ) ,
" heavy_slam " : ( " punch-blast " , " #e06a6a " ) , " lunge " : ( " pierced-body " , " #e06a6a " ) ,
" hex_bolt " : ( " fireball " , " #a77be8 " ) , " sweep " : ( " spinning-blades " , " #e06a6a " ) ,
" fire_breath " : ( " dragon-breath " , " #ff8a3a " ) , " tail_swipe " : ( " spiked-tail " , " #c08850 " ) ,
2026-10-24 12:05:23 +01:00
" stone_curse " : ( " evil-hand " , " #a77be8 " ) , " ground_pound " : ( " earth-crack " , " #c08850 " ) ,
" shatter " : ( " shattered-glass " , " #d1a46a " ) , " shriek " : ( " sonic-shout " , " #a77be8 " ) ,
2026-10-24 13:37:34 +01:00
" hex_storm " : ( " lightning-storm " , " #a77be8 " ) , " crushing_decree " : ( " crown " , " #e0b04a " ) ,
" royal_stomp " : ( " crush " , " #c08850 " ) , " witchfire " : ( " witch-flight " , " #5fae5a " ) ,
" cheap_shot " : ( " thrown-daggers " , " #d4dae2 " ) , " execute " : ( " decapitation " , " #e06a6a " ) ,
" whirlwind " : ( " whirlwind " , " #d4dae2 " ) , " rune_blast " : ( " rune-stone " , " #8fb8f0 " ) ,
" venom_spray " : ( " poison-cloud " , " #5fae5a " ) , " grave_chill " : ( " tombstone " , " #8fb8f0 " ) ,
" soul_rend " : ( " skull-bolt " , " #a77be8 " ) , " forge_blast " : ( " furnace " , " #ff8a3a " ) ,
" war_cry " : ( " shouting " , " #e06a6a " ) , " acid_spit " : ( " acid-blob " , " #5fae5a " ) ,
" acid_breath " : ( " acid " , " #5fae5a " ) , " deep_freeze " : ( " frozen-block " , " #8fd0e0 " ) ,
" wail " : ( " screaming " , " #a77be8 " ) , " frost_lance " : ( " ice-spear " , " #8fd0e0 " ) ,
" frost_breath " : ( " snowflake-2 " , " #8fd0e0 " ) , " void_bolt " : ( " vortex " , " #a77be8 " ) ,
" flame_wave " : ( " fire-wave " , " #ff8a3a " ) , " inferno " : ( " fire-ring " , " #ff6a2a " ) ,
2026-10-24 10:01:41 +01:00
}
2026-10-19 10:04:38 +01:00
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 " ) }
2026-10-20 14:16:12 +01:00
TROPHY_GLYPHS = { " hammer " : " crystal-growth " , " sword " : " animal-hide " , " staff " : " spiral-bloom " , " bow " : " sewing-string " }
2026-10-19 23:32:07 +01:00
TIER_TINTS = [ " #9aa1ab " , " #d4dae2 " , " #6f8fec " , " #4cb878 " , " #3fd0e0 " ] # trophies of areas II to VI
2026-10-20 14:16:12 +01:00
RACE_GLYPHS = {
" Clay golems " : " rock-golem " , " Cave trolls " : " troll " , " Restless dead " : " skeleton " , " Harpies " : " harpy " ,
" Stone guardians " : " golem-head " , " Lizardfolk " : " lizardman " , " Bog wraiths " : " spectre " , " Giant spiders " : " spider-face " ,
" Iron golems " : " robot-golem " , " Orcs " : " orc-head " , " Mine ghosts " : " ghost " , " Wyverns " : " wyvern " ,
" Ice elementals " : " ice-golem " , " Frost trolls " : " troll " , " Banshees " : " screaming " , " Griffins " : " griffin-symbol " ,
" Magma constructs " : " lava " , " Dragonkin " : " dragon-spiral " , " Shades " : " ghost-ally " , " Rocs " : " eagle-head " ,
}
2026-10-19 23:32:07 +01:00
ACTION_GLYPHS . update ( {
" fight_goblins " : ( " goblin-head " , " #8fbf6a " ) , " fight_ruins " : ( " ogre " , " #a08060 " ) ,
" fight_barrow_king " : ( " crowned-skull " , " #c8d0e0 " ) , " drill_militia " : ACTION_GLYPHS [ " drill_militia " ] ,
} )
2026-10-20 14:16:12 +01:00
for _index , ( _area , _level , _prefix , _races ) in enumerate ( AREAS ) :
for _race , * _rest in _races :
ACTION_GLYPHS [ f " fight_ { _slug ( _race ) } " ] = ( RACE_GLYPHS [ _race ] , TIER_TINTS [ _index ] )
2026-10-21 12:16:22 +01:00
for _tend in ACTIONS :
if _tend . get ( " special " ) == " tend " :
ACTION_GLYPHS [ _tend [ " id " ] ] = ( " campfire " , FLAMES [ _tend [ " tier " ] ] [ 1 ] )
2026-10-20 18:01:02 +01:00
UI_GLYPHS = [ " two-coins " , " sparkles " , " hearts " , " prayer " , " checked-shield " , " magic-shield " , " stopwatch " , " swordman " , " biceps " , " cancel " , " scroll-unfurled " , " striking-diamonds " ,
2026-10-20 15:45:33 +01:00
" archery-target " , " dodging " , " shield-reflect " ,
2026-10-21 09:23:19 +01:00
" cape " , " gem-pendant " , " gauntlet " , " ring " , " quiver " , " leather-boot " , " crystal-ball " , " hot-meal " , " knife-fork " , " round-bottom-flask " , " visored-helm " , " breastplate " ,
2026-10-20 15:03:22 +01:00
" leg-armor " , " pointy-sword " , " round-shield " ,
2026-10-20 19:59:45 +01:00
* ( kind [ " glyph " ] for kind in DAMAGE_TYPES . values ( ) ) , * ( stat [ " glyph " ] for stat in JEWEL_STATS . values ( ) ) ]
2026-10-19 10:04:38 +01:00
def _assign_glyphs ( ) - > None :
for item , ( glyph , tint ) in GLYPHS . items ( ) :
ITEMS [ item ] . update ( glyph = glyph , tint = tint )
2026-10-19 23:17:23 +01:00
for tier , ( key , _name , ore , * _rest ) in enumerate ( METALS ) :
2026-10-19 10:04:38 +01:00
tint = METAL_TINTS [ key ]
if ore :
ITEMS [ ore [ 0 ] ] . update ( glyph = " ore " , tint = tint )
ITEMS [ f " { key } _bar " ] . update ( glyph = " metal-bar " , tint = tint )
2026-10-19 23:17:23 +01:00
ITEMS [ HARDWARE [ tier ] [ 0 ] ] . update ( glyph = HARDWARE_GLYPHS [ tier ] , tint = tint )
2026-10-19 23:38:34 +01:00
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 ] )
2026-10-19 10:04:38 +01:00
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 )
2026-10-20 14:16:12 +01:00
for trophy , ( _name , line , tier ) in TROPHIES . items ( ) :
ITEMS [ trophy ] . update ( glyph = TROPHY_GLYPHS [ line ] , tint = TIER_TINTS [ tier - 1 ] )
for item , ( weight , tier ) in REAGENTS . items ( ) :
ITEMS [ item ] . update ( glyph = REAGENT_GLYPHS [ weight ] , tint = TIER_TINTS [ tier - 1 ] )
for ( fibre , _level , _time , _xp , cloth , prefix , _label ) , tint in zip ( CLOTHS , CLOTH_TINTS ) :
if fibre [ 0 ] in FIBRE_GLYPHS :
ITEMS [ fibre [ 0 ] ] . update ( glyph = FIBRE_GLYPHS [ fibre [ 0 ] ] , tint = tint )
ITEMS [ cloth [ 0 ] ] . update ( glyph = " rolled-cloth " , tint = tint )
for suffix , glyph in CLOTH_GLYPHS . items ( ) :
ITEMS [ f " { prefix } _ { suffix } " ] . update ( glyph = glyph , tint = tint )
2026-10-19 10:04:38 +01:00
for herb , tint in HERB_TINTS . items ( ) :
ITEMS [ herb ] . update ( glyph = " herbs-bundle " , tint = tint )
2026-10-20 21:00:31 +01:00
for tier , ( wood , tint ) in enumerate ( zip ( WOODS , WOOD_TINTS ) ) :
for suffix , _noun , glyph , _kind in STATIONS . values ( ) :
ITEMS [ f " { wood } _ { suffix } " ] . update ( glyph = glyph , tint = tint )
ITEMS [ f " { wood } _strongbox " ] . update ( glyph = " locked-chest " , tint = tint )
for cloth , tint in zip ( CLOTHS , CLOTH_TINTS ) :
ITEMS [ f " { cloth [ 5 ] } _bag " ] . update ( glyph = " shoulder-bag " , tint = tint )
2026-10-20 19:59:45 +01:00
for gem , _name , _value , tint , glyph in GEMS :
ITEMS [ gem ] . update ( glyph = glyph , tint = tint )
for key , * _rest , piece_glyph in JEWELLERY :
ITEMS [ f " { gem } _ { key } " ] . update ( glyph = piece_glyph , tint = tint )
2026-10-19 21:28:00 +01:00
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 )
2026-10-24 09:49:21 +01:00
for tier , ( potion , * _rest ) in enumerate ( HEALTH_POTIONS ) :
ITEMS [ potion ] . update ( glyph = flasks [ tier ] , tint = " #e0506e " )
2026-10-19 10:04:38 +01:00
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 " ] ]
2026-10-24 10:01:41 +01:00
for key , ability in [ * combat . ABILITIES . items ( ) , * combat . ENEMY_ABILITIES . items ( ) ] :
ability [ " glyph " ] , ability [ " tint " ] = ABILITY_GLYPHS [ key ]
2026-10-24 13:48:16 +01:00
# Dungeon loot: masterworks look like what they're reforged from, in gold.
for item , spec in ITEMS . items ( ) :
if spec . get ( " masterwork " ) :
spec . update ( glyph = ITEMS [ spec [ " base " ] ] [ " glyph " ] , tint = MASTERWORK_TINT )
elif spec . get ( " pattern " ) :
spec . update ( glyph = " scroll-quill " , tint = MASTERWORK_TINT )
elif spec . get ( " relic " ) :
spec . update ( glyph = " relic-blade " , tint = " #c8b070 " )
for unique , ( _name , _slot , glyph , tint , * _rest ) in UNIQUES . items ( ) :
ITEMS [ unique ] . update ( glyph = glyph , tint = tint )
2026-10-19 10:04:38 +01:00
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 " ]
2026-10-20 19:59:45 +01:00
elif action . get ( " finds " ) :
first = ITEMS [ action [ " finds " ] [ 0 ] [ 0 ] ]
action [ " glyph " ] , action [ " tint " ] = first [ " glyph " ] , first [ " tint " ]
2026-10-19 10:04:38 +01:00
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 )
2026-10-24 10:01:41 +01:00
for collection in ( ITEMS . values ( ) , SKILLS , CLASSES . values ( ) , BUILDINGS . values ( ) , PRAYERS , ACTIONS ,
combat . ABILITIES . values ( ) , combat . ENEMY_ABILITIES . values ( ) ) :
2026-10-19 10:04:38 +01:00
names . update ( entry [ " glyph " ] for entry in collection )
2026-10-24 13:34:59 +01:00
names . update ( glyph for glyph , _tint in WARE_GLYPHS . values ( ) )
names . update ( c [ " value " ] for c in COSMETICS . values ( ) if c [ " kind " ] == " emblem " and " value " in c )
names . update ( m [ " glyph " ] for m in MONUMENTS . values ( ) )
2026-10-19 10:04:38 +01:00
return names
_assign_glyphs ( )
2026-10-20 18:01:02 +01:00
# Every recipe and gathering action exists by now: what can be put up as a work order.
ORDER_RECIPES , ORDER_RAW = _order_sources ( )
2026-10-19 10:04:38 +01:00
2026-10-21 15:36:16 +01:00
# What changed, shown on the Patch notes page. Newest first; every shipped change adds a
# line here (new / balance / fix), and a new release a new entry on top.
PATCH_NOTES = [
2026-10-26 08:27:50 +00:00
{ " date " : " 2026-10-26 " , " title " : " Fittings " , " notes " : [
2026-10-26 16:48:44 +00:00
( " new " , " Make X: \" Make one \" now takes a count (1 by default, up to 1,000), then you go back to what you were doing. " ) ,
2026-10-26 08:27:50 +00:00
( " new " , " Construction › Fittings: ✓ Fitted on the ones the town already has (✓ Paused while the building is damaged). " ) ,
] } ,
2026-10-25 08:34:24 +00:00
{ " date " : " 2026-10-25 " , " title " : " Titles " , " notes " : [
2026-10-25 20:07:50 +00:00
( " new " , " 🏆 Achievements (under You): 127 to collect. Masterwork patterns per dungeon (7 each), the 7 uniques, titles, "
" boss emblems, caravan looks, home. Tap an icon for what it is and how to get it. Nothing is awarded there, it only keeps track. " ) ,
2026-10-25 08:34:24 +00:00
( " new " , " Fishing 50 also earns a second title: Master baiter. " ) ,
2026-10-25 09:42:31 +00:00
( " balance " , " Boiling hide glue now uses the kitchen fire: while it ' s lit, glue costs no logs. " ) ,
2026-10-25 19:21:37 +00:00
( " fix " , " Dungeons wait for the leader to press Start, so groups of 3 and 4 can form. No one can join once it starts. " ) ,
2026-10-25 19:22:03 +00:00
( " fix " , " Combat page shows your health potion: drunk on its own, no need to equip. The other card now reads \" No combat potion \" . " ) ,
2026-10-25 19:44:30 +00:00
( " fix " , " Combat page no longer jumps around: cast bars hold their space and the fight log stays 7 lines tall. " ) ,
2026-10-25 19:44:58 +00:00
( " new " , " Enemy casts that can ' t be interrupted show a 🔒 and a grey bar. Stuns still stop them, except on bosses. " ) ,
2026-10-25 20:21:08 +00:00
( " fix " , " Resting in a group: everyone heals at their own pace, so good food tops you up first. The pull still "
" waits for the slowest, and Pull now sends each in as healed as they really are. " ) ,
2026-10-25 20:30:15 +00:00
( " new " , " DPS meter under the combat log: damage per second and total for each fighter, live. Shows the last fight while resting. " ) ,
2026-10-25 20:54:37 +00:00
( " new " , " Improved raid UI " ) ,
( " fix " , " Bugfix: Combats started using \" Fight once \" can now be joined. " ) ,
2026-10-25 22:29:42 +00:00
( " fix " , " Your buttons work until you ' re knocked out. A fight heading for a loss no longer locks you out early with \" You ' re out of this fight \" while you ' re still standing. " ) ,
2026-10-25 08:34:24 +00:00
] } ,
2026-10-24 13:48:16 +01:00
{ " date " : " 2026-10-24 " , " title " : " Dungeon loot " , " notes " : [
( " new " , " Masterwork patterns: every dungeon boss can drop one of 7 (15 % f rom a mini-boss, 35 % f rom the last boss, "
" everyone rolls their own). A crafter learns it once, then reforges the tier ' s piece plus 3 relics into a "
" masterwork: about 30 % more power or armour, wearable at the dungeon ' s level. Sword, warhammer, staff, bow, "
" plate, jerkin and robe in every dungeon. " ) ,
( " new " , " Relics: 50 % f rom a mini-boss, 2 from every last boss. " ) ,
( " new " , " One unique per dungeon from its last boss (8 % e ach), worn as a ring, amulet or trinket, that changes an "
" ability: One-Eye ' s fang, Ogre ' s knuckle, Barrow king ' s pendant, Warlord ' s whistle, Forgeheart, Rimeglass "
" lens, Wyrmscale quiver. Abilities it changes get a ✦. " ) ,
2026-10-24 14:02:24 +01:00
( " new " , " Choose when you eat between fights: below 25 / 50 / 75 % o f your HP, always, or never (the food slot, "
" under \" Eat below \" ). Default 50 % : above it you just rest, so a scratch doesn ' t cost food. " ) ,
2026-10-24 14:21:08 +01:00
( " new " , " Tapping an enemy or ally switches at once, and your next press goes to them: tap, then interrupt, "
" without waiting. " ) ,
( " new " , " HP bars show what was just lost, with each fighter ' s last hit or heal by their HP. " ) ,
( " new " , " An interrupted cast stays on the enemy ' s bar for a moment, struck through: ✋ interrupted. " ) ,
( " balance " , " Volley no longer takes arrows: abilities never cost anything. Wyrmscale quiver: Volley 40 % ha rder "
" and Aimed Shot 25 % f aster. " ) ,
2026-10-24 18:13:44 +01:00
( " fix " , " Work orders on phones: the item picker and reagent slots show names, and picking an item scrolls to "
" the amount, price and Post. " ) ,
2026-10-24 18:56:15 +01:00
( " fix " , " Work orders show what a lit fire pays: its log is struck through instead of asked for, and the forge "
" pays a coal. Orders never needed that log from the crafter, they just asked for it. " ) ,
2026-10-24 13:48:16 +01:00
] } ,
2026-10-24 13:37:34 +01:00
{ " date " : " 2026-10-24 " , " title " : " A dungeon in every area " , " notes " : [
( " new " , " Six more dungeons for 2– 4, seven pulls each with two mini-bosses and a final boss: Goblin Warren "
" (Greenwood, combat 8, best with 3– 4), Barrow Crypts (Sunken Barrows, 35), Bandit Stronghold (Frontier, 40), "
" The Deep Forge (Iron Hills, 50), Frozen Hold (Frostpeak, 65) and Dragon ' s Lair (Dragon ' s Spine, 80). " ) ,
( " new " , " Every boss has its own: curses to interrupt, shrieks, slams to brace for, adds at half health and an "
" enrage timer. As in the Watchtower, trash falls on auto and the bosses need your own hands. " ) ,
( " new " , " Clearing pays 30– 750 gold by area, plus a trophy and 2 reagents (Goblin Warren: gold only). " ) ,
] } ,
2026-10-24 13:34:59 +01:00
{ " date " : " 2026-10-24 " , " title " : " Gold " , " notes " : [
( " balance " , " Raids hit about twice as hard and grow with the guild: they add half the combat power of everyone "
" active in the last 2 days. Pay the guards, drill, stand guard and sabotage the camp, or expect losses. "
" Dragons are 0.8× a raid but can ' t be sabotaged. " ) ,
( " balance " , " Band size (small → huge) now compares the raid to what the city holds without preparing. " ) ,
( " new " , " Guard wages: 60 gold a guard per threat, paid ahead into the barracks ' wage chest, from your gold (earns "
" favour) or the treasury. Only paid guards stand, and only they hold the armoury ' s swords and shields. "
" Posts: 10 + 20 per barracks level, up to half the townsfolk. The council paid for the coming threat. " ) ,
( " balance " , " Raid rewards: plunder 1× attack (was 2× ); garrison XP 2× attack (was 5× ), dragons 2.5× (was 6× ). " ) ,
( " new " , " Strongbox: rent bank slots for gold, 1,000 for the first and +35 % e ach after. " ) ,
( " new " , " Caravan: comes once per threat, on the prep day, for 12h, with 4 of 10 wares and one look. Wares boost "
" your own work (trophy odds × 2 for 100 kills, +50 % X P for 200 actions, …). One of each per visit; prices "
" rise with your level. " ) ,
( " new " , " Banner, title and home: pick colours and an emblem on your character sheet. Titles are earned by reaching "
" 50 or 99 in a skill, defending the city 5 times, slaying a dragon or beating 10 bosses, or bought at the "
" caravan. Homes go from Cottage (2,000) to Manor (75,000). All of these show on the roster and the board. " ) ,
( " new " , " Monuments: gate banner 10,000, fountain 25,000, statues 50,000 (the biggest donor picks who). No favour; "
" donors ' names are carved in. " ) ,
] } ,
2026-10-24 12:05:23 +01:00
{ " date " : " 2026-10-24 " , " title " : " Active play and dungeons " , " notes " : [
( " new " , " Active play: tap AUTO on an ability to take it yourself (YOU), then press it (tap, or keys 1– 9) on its "
" real cooldown instead of auto ' s 1.5× . Look away for 12s and it ' s back on auto. " ) ,
( " new " , " Tap an enemy to fight it, or a friend to aim your heals at them. " ) ,
( " new " , " Dungeon: Watchtower Depths (combat 20, 2– 4 players), 8 pulls in order. Trash falls on auto; the Clay "
" colossus, Harpy matriarch and Watchtower ogre need your own hands: interrupt Stone Curse and Shriek, "
" brace for Ground Pound, beat the enrage timer. Start it and wait for others to join before the first pull. " ) ,
( " new " , " A wipe repeats the pull, 3 on one pull ends the run. The run waits while anyone looks away for 90s. "
" The leader can pull early. " ) ,
( " new " , " Dungeon rewards: every boss gives everyone their own loot (mini-bosses 5× their race ' s odds). "
" Clearing pays 100 gold, a trophy and 2 reagents each. " ) ,
] } ,
2026-10-24 11:43:56 +01:00
{ " date " : " 2026-10-24 " , " title " : " Livelier frontier " , " notes " : [
( " balance " , " A threat lands every 2 days (±2h), and the next one is rumoured as soon as the last lands. "
" Day 1: scout, drill, build. Day 2: the camp is found (intel III opens 24h out), with the exact hour. " ) ,
( " balance " , " Sabotage: -1 % r aid strength per win (was -3 % ), still max 30 % . " ) ,
( " balance " , " Happenings ~3× as often: fortunes every 10– 20h (was 36– 72h), hardships 20– 40h (was 48– 96h), "
" minor threats 16– 32h (was 48– 96h). " ) ,
( " balance " , " Shorter happenings: 12h boosts → 6h; festival, sellswords, drought 24h → 12h; cold snap 48h → 24h; "
" sickness 72h → 36h; flood 36h → 18h. " ) ,
( " new " , " Fortunes: storm-felled timber (woodcutting yield +50 % ), deer migration (hunting yield × 2), masons ' guild "
" (construction 25 % f aster), artisans ' fair (crafts 15 % f aster), a trapper ' s leads (next 15 recon doubled). " ) ,
( " new " , " Hardships: blight (farming yield ½), tremors (mining 30 % s lower), the tax collector (-15 % treasury). " ) ,
( " new " , " Minor threats: spiders in the mine and smugglers skimming 1 % treasury/h (fighters), a washed-out "
" bridge that blocks hunting (25 planks), hornets slowing woodcutting (herbalists smoke them out). " ) ,
] } ,
2026-10-24 09:49:21 +01:00
{ " date " : " 2026-10-24 " , " title " : " Combat rework " , " notes " : [
( " new " , " Fights are against packs: as many enemies as there are of you, often one more (each 35 % weaker). "
" A rare elite still comes alone. " ) ,
( " new " , " Abilities: your weapon gives four (combat 1 / 15 / 30 / 45) and your trade one more. Sword and shield "
" tanks, warhammer hits hard and stuns, staff heals and holds, bow shoots from afar. All on auto for now: "
" hover them on the Combat page. " ) ,
( " new " , " Enemies fight back: brutes wind up Heavy Slams, skirmishers lunge at whoever isn ' t tanking, casters "
" cast Hex Bolts (which can be interrupted), elites and bosses enrage, dragons breathe fire. Enemies attack "
" whoever has the most threat, so a sword and shield draws them away from the rest. " ) ,
( " new " , " Food is only eaten resting between fights: you rest back to full before each one, and better food "
" rests you faster. No food just means longer rests. " ) ,
( " new " , " Health potions I– VI (Herblore 1– 75, the tier ' s herb and cooked fish): the one heal you take into a "
" fight, drunk once a fight under 35 % HP. " ) ,
( " new " , " Knocked out at 0 HP: you lose that fight and come round at 30 % HP. Three in a row and you stop. "
" Retreating is gone. " ) ,
( " new " , " Bosses and dragons are group fights (2+). Start one alone and you wait there for someone to join. "
" They grow with the group: +75 % HP for each fighter. " ) ,
2026-10-24 10:01:41 +01:00
( " fix " , " Abilities, enemy casts and fight statuses have proper icons instead of emoji. " ) ,
2026-10-24 09:49:21 +01:00
( " balance " , " Enemies have 20 % more HP for the same XP, since everyone has abilities now. " ) ,
( " balance " , " Boss hits: area bosses 1.35 → 1.2× and dragons 1.5 → 1.9× their level ' s max hit, then × 1.3 "
" against a pair. " ) ,
] } ,
2026-10-23 22:13:32 +01:00
{ " date " : " 2026-10-23 " , " title " : " City needs " , " notes " : [
( " new " , " City needs are now indicated in profession menus. " ) ,
] } ,
2026-10-22 14:08:41 +01:00
{ " date " : " 2026-10-22 " , " title " : " Bosses " , " notes " : [
( " new " , " Bosses and dragons: everyone in the fight gets their own loot. " ) ,
( " balance " , " Level requirements for area boss reduced to be area level +5. " ) ,
] } ,
2026-10-21 15:36:16 +01:00
{ " date " : " 2026-10-21 " , " title " : " Beta " , " notes " : [
( " new " , " Beta: completely meaningless milestone reached; every class now makes a decent amount of valuable contributions to the guild. " ) ,
( " new " , " Patch notes menu. " ) ,
( " new " , " Firekeeping: tend the fire for your guild members ' crafting stations. Builders get a bonus. Use tiers of wood to light "
" and maintain a fire, which saves resources on guild members ' crafts of the same tier. For every craft completed while "
" the fire is lit, the Firekeeper gains 10 % o f the XP the craft gave the crafter, shared between all players keeping a "
" fire of that tier. Each player keeps their own \" pile \" , which contributes an equal amount to the fire. " ) ,
( " new " , " Work orders: anyone can provide reagents towards an incomplete work order and gets paid based on the value of the items provided. " ) ,
2026-10-21 22:18:07 +01:00
( " new " , " Multiplayer Combat: tag into anyone ' s fight from the encounter card or the guild roster. " ) ,
2026-10-21 15:36:16 +01:00
( " balance " , " Gear: combat requirement adjusted to match the tier of crafting required to make it. " ) ,
( " balance " , " Combat XP reduced to keep it in line with professions. Eating now delays your next swing; eat higher tier food to reduce the impact. " ) ,
( " balance " , " Construction workbench now also affects labour on building sites. " ) ,
2026-10-21 20:30:47 +01:00
( " balance " , " Weapon trophies drop more often, since the town needs weapons too: 3 % → 10 % f rom races, 6 % → 20 % per weapon type from bosses. " ) ,
2026-10-21 20:56:39 +01:00
( " balance " , " Hunting and Leatherworking tiers now unlock at 1 / 15 / 30 / 45 / 60 / 75, like every other trade. "
" Hunting: deer 8 → 1, boar 25 → 15, bears 40 → 30, elk 50 → 45, sabrecats 65 → 60, mammoths 80 → 75. "
" Leatherworking: tanning, armour and bows 5 levels lower from hard leather up. " ) ,
( " balance " , " Hunting time and XP now match Fishing: deer 40s · 13 XP → 25s · 6 XP (same as birds), boar 50s · 30 → 35s · 15, "
" bears 60s · 48 → 45s · 28, elk 70s · 64 → 55s · 45, sabrecats 80s · 84 → 65s · 62, mammoths 90s · 108 → 80s · 85. " ) ,
2026-10-21 15:36:16 +01:00
] } ,
]
2026-10-18 13:48:56 +01:00
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 ,
2026-10-18 19:20:52 +01:00
" buildings " : buildings , " xp_table " : XP_TABLE , " offline_cap " : OFFLINE_CAP , " tiers " : TIERS ,
2026-10-18 22:40:36 +01:00
" city_uses " : { item : city_uses ( item ) for item in ITEMS } ,
2026-10-18 23:09:17 +01:00
" prayers " : [ { * * prayer , " text " : describe_prayer ( prayer ) } for prayer in PRAYERS ] , " max_prayers " : MAX_PRAYERS ,
2026-10-20 18:01:02 +01:00
" potion_charges " : POTION_CHARGES , " order_recipes " : ORDER_RECIPES , " order_raw " : ORDER_RAW ,
2026-10-26 16:48:44 +00:00
" order_limit " : ORDER_LIMIT , " order_max " : ORDER_MAX , " make_max " : MAKE_MAX , " input_share " : INPUT_SHARE , " problems " : PROBLEMS , " fittings " : list ( FITTING_BY_ID . values ( ) ) ,
2026-10-24 14:02:24 +01:00
" equip_slots " : EQUIP_SLOTS , " class_speed " : CLASS_SPEED , " rest_rate " : REST_RATE , " bite " : BITE , " eat_below " : EAT_BELOW , " eat_choices " : EAT_CHOICES ,
2026-10-24 12:05:23 +01:00
" abilities " : combat . ABILITIES , " enemy_abilities " : combat . ENEMY_ABILITIES , " unlock " : combat . UNLOCK , " auto_cd " : combat . AUTO_CD , " watch " : combat . WATCH , " gcd " : combat . GCD ,
" weapon_kits " : combat . WEAPON_KITS , " group_size " : GROUP_SIZE , " dungeon_size " : DUNGEON_SIZE , " dungeon_wipes " : DUNGEON_WIPES , " trade_ability " : combat . TRADE_ABILITY , " potion_below " : combat . POTION_BELOW ,
2026-10-20 14:16:12 +01:00
" styles " : STYLES , " damage_types " : DAMAGE_TYPES , " weights " : WEIGHTS , " vulnerable " : VULNERABLE ,
2026-10-20 21:00:31 +01:00
" resistant " : RESISTANT , " jewel_stats " : JEWEL_STATS , " stations " : list ( STATIONS ) , " class_style " : CLASS_STYLE , " block_chance " : BLOCK_CHANCE ,
2026-10-19 15:14:00 +01:00
" regen_per_hour " : REGEN_PER_HOUR ,
2026-10-21 12:16:22 +01:00
" fires " : FIRES , " flames " : FLAMES , " firewood " : FIREWOOD , " tend_xp " : TEND_XP , " fire_low " : FIRE_LOW ,
2026-10-21 15:36:16 +01:00
" patch_notes " : PATCH_NOTES ,
2026-10-24 13:34:59 +01:00
" wares " : { key : { * * spec , " glyph " : WARE_GLYPHS [ key ] } for key , spec in WARES . items ( ) } , " cosmetics " : COSMETICS , " free_looks " : FREE_LOOKS , " homes " : HOMES , " monuments " : MONUMENTS ,
" guard_wage " : GUARD_WAGE ,
2026-10-18 13:48:56 +01:00
}