6223 lines
336 KiB
Python
6223 lines
336 KiB
Python
"""Guildhall rules: content tables and pure state transitions.
|
||
|
||
The whole world is one JSON-able dict. Every function takes `now` (unix
|
||
seconds) so the server, the tests and the dev clock drive it the same way.
|
||
Durations below are in game seconds; `speed` in the world config divides
|
||
them, so a dev world can run a week in an afternoon.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import random
|
||
from datetime import date
|
||
|
||
import combat
|
||
|
||
HOUR = 3600
|
||
DAY = 24 * HOUR
|
||
OFFLINE_CAP = 24 * HOUR
|
||
BASE_SLOTS = 12
|
||
BASE_STACK = 1000
|
||
CLASS_SPEED = 0.8
|
||
LUCKY_CHANCE = 0.1
|
||
OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose
|
||
MAX_LOG = 200
|
||
REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour
|
||
REST_RATE = 0.01 # share of max HP back per game second resting between fights...
|
||
BITE = 2.0 # ...and each bite of food takes this long, so food rests you faster
|
||
EAT_BELOW = 0.5 # unless you choose otherwise, you only eat while under this share of your HP...
|
||
EAT_CHOICES = (0, 0.25, 0.5, 0.75, 1.0) # ...and these are the choices (never, up to always)
|
||
KNOCKED_OUT_HP = 0.3 # share of max HP you come round with after being knocked out
|
||
KNOCKOUTS_TO_STOP = 3 # in a row, and you stop fighting
|
||
|
||
|
||
def _xp_table(max_level: int = 99) -> list[int]:
|
||
# RuneScape's curve, as Melvor uses: index = level, value = xp needed.
|
||
table = [0, 0]
|
||
points = 0
|
||
for level in range(1, max_level):
|
||
points += math.floor(level + 300 * 2 ** (level / 7))
|
||
table.append(points // 4)
|
||
return table
|
||
|
||
|
||
XP_TABLE = _xp_table()
|
||
MAX_LEVEL = len(XP_TABLE) - 1
|
||
|
||
|
||
def level_for(xp: float) -> int:
|
||
level = 1
|
||
while level < MAX_LEVEL and XP_TABLE[level + 1] <= xp:
|
||
level += 1
|
||
return level
|
||
|
||
|
||
ITEMS = {
|
||
"stone": {"name": "Stone", "icon": "🪨", "value": 1},
|
||
"wheat": {"name": "Wheat", "icon": "🌾", "value": 1},
|
||
"vegetables": {"name": "Vegetables", "icon": "🥕", "value": 2},
|
||
"feathers": {"name": "Feathers", "icon": "🪶", "value": 1},
|
||
"raw_meat": {"name": "Raw meat", "icon": "🥩", "value": 2},
|
||
"arrows": {"name": "Arrows", "icon": "🏹", "value": 1},
|
||
"bricks": {"name": "Bricks", "icon": "🧱", "value": 3},
|
||
"cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5},
|
||
"beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "🪜", "value": 25},
|
||
# `food` feeds that many townsfolk for two days; `heal` restores HP resting between fights.
|
||
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8},
|
||
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
|
||
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20},
|
||
"bones": {"name": "Bones", "icon": "🦴", "value": 1},
|
||
"big_bones": {"name": "Big bones", "icon": "🦴", "value": 4},
|
||
"dragon_bones": {"name": "Dragon bones", "icon": "🦴", "value": 15},
|
||
"studded_body": {"name": "Studded jerkin", "icon": "🧥", "value": 40, "slot": "body", "armour": 5, "ward": 5,
|
||
"weight": "medium", "req": ["combat", 10]},
|
||
# Building materials from every trade: thatch and flax from the fields, rope
|
||
# twisted from flax, glue boiled from hides, and provisions to feed the builders.
|
||
"thatch": {"name": "Thatch", "icon": "🌾", "value": 2},
|
||
"flax": {"name": "Flax", "icon": "🌿", "value": 2},
|
||
"rope": {"name": "Rope", "icon": "🪢", "value": 8},
|
||
"hide_glue": {"name": "Hide glue", "icon": "🍯", "value": 7},
|
||
"provisions": {"name": "Provisions", "icon": "🎒", "value": 10},
|
||
}
|
||
|
||
# --- tier ladders -----------------------------------------------------------
|
||
# Six tiers, RuneScape's metals with Melvor-ish level spacing. Each row of a
|
||
# ladder generates its items and recipes below; the copper and iron ids
|
||
# predate the ladders and are kept as they were.
|
||
|
||
TIER_LEVELS = [1, 15, 30, 45, 60, 75] # each tier's gathering / smelting level; also to wear and use it
|
||
|
||
# key, name, ore (item id, name, icon, value), coal per bar, bar icon, bar value, smithy level, handle wood
|
||
METALS = [
|
||
("copper", "Copper", ("copper_ore", "Copper ore", "🟤", 2), 0, "🟧", 5, 0, "logs"),
|
||
("iron", "Iron", ("iron_ore", "Iron ore", "⚫", 4), 0, "⬛", 10, 1, "oak_logs"),
|
||
("steel", "Steel", None, 2, "🔲", 20, 2, "willow_logs"),
|
||
("mithril", "Mithril", ("mithril_ore", "Mithril ore", "🔵", 15), 3, "🟦", 40, 3, "maple_logs"),
|
||
("adamant", "Adamant", ("adamantite_ore", "Adamantite ore", "🟢", 30), 4, "🟩", 70, 4, "yew_logs"),
|
||
("rune", "Rune", ("runite_ore", "Runite ore", "💠", 55), 5, "🟪", 120, 5, "magic_logs"),
|
||
]
|
||
# piece, slot, display noun, bars, smithing level above the tier, power by tier, armour by tier
|
||
PIECES = [
|
||
("sword", "weapon", "sword", 2, 7, [4, 9, 14, 20, 27, 35], None),
|
||
("shield", "shield", "shield", 3, 10, [3, 6, 8, 11, 14, 18], [2, 4, 6, 9, 12, 15]),
|
||
("helmet", "helmet", "helmet", 2, 5, None, [2, 4, 6, 8, 11, 14]),
|
||
("legs", "legs", "greaves", 4, 9, None, [3, 6, 9, 12, 16, 21]),
|
||
("body", "body", "cuirass", 5, 11, None, [4, 8, 12, 16, 22, 28]),
|
||
]
|
||
# Armour weights: a piece's rating, split into armour (against physical blows) and
|
||
# ward (against magic). Heavy plate from Smithing, medium leather from
|
||
# Leatherworking, light cloth from Tailoring; each adds up to about the same.
|
||
WEIGHTS = {
|
||
"heavy": {"name": "Heavy", "armour": 1.0, "ward": 0.2},
|
||
"medium": {"name": "Medium", "armour": 0.6, "ward": 0.6},
|
||
"light": {"name": "Light", "armour": 0.2, "ward": 1.0},
|
||
}
|
||
ARMOUR_RATING = {slot: rating for piece, slot, _noun, _bars, _offset, _power, rating in PIECES if piece != "shield" and rating}
|
||
|
||
|
||
def _armour_stats(weight: str, slot: str, tier: int) -> dict:
|
||
rating = ARMOUR_RATING[slot][tier]
|
||
return {"armour": round(rating * WEIGHTS[weight]["armour"]), "ward": round(rating * WEIGHTS[weight]["ward"]),
|
||
"weight": weight}
|
||
|
||
|
||
PLURALS = {"sword": "swords", "shield": "shields", "helmet": "helmets", "greaves": "greaves", "cuirass": "cuirasses"}
|
||
PIECE_ICONS = {"sword": "⚔️", "shield": "🛡️", "helmet": "🪖", "legs": "🦿", "body": "🦾"}
|
||
COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body": "🥋"}
|
||
# Tools: one slot per skill, `speed` cuts the action time. Smiths make every tool.
|
||
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", ""),
|
||
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪"), "construction": ("hammer", "🔨"),
|
||
"smithing": ("chisel", "🔧"), "jewelcrafting": ("gem drill", "🪛"), "cooking": ("cleaver", "🔪"),
|
||
"herblore": ("mortar and pestle", "🥣"), "leatherworking": ("awl", "📍"), "tailoring": ("shears", "✂️"),
|
||
"firekeeping": ("bellows", "🪭")}
|
||
TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60]
|
||
# Crafting stations: a second slot for every crafting skill, beside its tool, and as fast.
|
||
# Builders make every station, from the tier's planks and hardware and a material from
|
||
# another trade. A station speeds what's made at it, and a workbench the building site too.
|
||
# skill: (id suffix, noun, glyph, other material)
|
||
STATIONS = {
|
||
"construction": ("workbench", "workbench", "toolbox", "bar"),
|
||
"smithing": ("anvil_stand", "anvil stand", "anvil-impact", "leather"),
|
||
"jewelcrafting": ("jewellers_bench", "jeweller's bench", "gem-chain", "cloth"),
|
||
"cooking": ("hearth", "hearth", "campfire", "bar"),
|
||
"herblore": ("alembic", "alembic", "erlenmeyer", "bar"),
|
||
"leatherworking": ("tanning_rack", "tanning rack", "animal-hide", "cloth"),
|
||
"tailoring": ("loom", "loom", "spinning-wheel", "leather"),
|
||
}
|
||
# Storage: a builder's strongbox adds bank slots, a homesteader's bag a bigger stack.
|
||
STRONGBOX_SLOTS = [2, 4, 6, 8, 11, 14]
|
||
BAG_STACK = [250, 500, 750, 1000, 1500, 2000]
|
||
|
||
# item id, name, icon, value, action id, verb
|
||
TREES = [
|
||
("logs", "Logs", "🪵", 1, "cut_logs", "Cut trees"),
|
||
("oak_logs", "Oak logs", "🌳", 4, "cut_oak", "Cut oaks"),
|
||
("willow_logs", "Willow logs", "🌿", 8, "cut_willow", "Cut willows"),
|
||
("maple_logs", "Maple logs", "🍁", 15, "cut_maple", "Cut maples"),
|
||
("yew_logs", "Yew logs", "🌲", 30, "cut_yew", "Cut yews"),
|
||
("magic_logs", "Enchanted logs", "🔮", 55, "cut_magic", "Cut enchanted trees"),
|
||
]
|
||
FIREWOOD = tuple(tree[0] for tree in TREES)
|
||
|
||
# The guild's fires, shared by everyone. A craft at a fire takes one log of its tier as
|
||
# fuel, and a fire burning that tier's flame or hotter pays it (the forge a coal too).
|
||
# Firekeepers feed them: fuel burns hottest first, and a better log pushes cheaper fuel
|
||
# out of a full fire. Level gives more minutes per log and a bigger bank.
|
||
FIRES = {
|
||
"forge": {"name": "Forge", "icon": "⚒️", "skill": "smithing", "glyph": "anvil-impact"},
|
||
"kitchen": {"name": "Kitchen fire", "icon": "🍳", "skill": "cooking", "glyph": "cooking-pot"},
|
||
"kiln": {"name": "Kiln", "icon": "🧱", "skill": "construction", "glyph": "brick-pile"},
|
||
"still": {"name": "Still", "icon": "⚗️", "skill": "herblore", "glyph": "erlenmeyer"},
|
||
}
|
||
FLAMES = [("Red", "#ff6a2a"), ("Gold", "#ffcb3d"), ("White", "#eeeaf8"),
|
||
("Blue", "#4fa3ff"), ("Green", "#3ed37a"), ("Violet", "#b07cff")]
|
||
TEND_XP = [2, 3, 4.5, 5.5, 6.5, 7.5] # per game minute of fire fed, by the log's tier
|
||
WARMTH = 0.1 # of a craft's XP, shared by everyone whose pile is hot enough for it
|
||
FIRE_LOW = 30 * 60 # game seconds left when a fire counts as low
|
||
|
||
|
||
def fire_minutes(level: int) -> float:
|
||
"""Game minutes of fire one log gives at this Firekeeping level."""
|
||
return 3 + 0.12 * level
|
||
|
||
|
||
def fire_bank(level: int) -> float:
|
||
"""The most fuel, in game minutes, a keeper of this level can bank in a fire."""
|
||
return 60 + 9 * level
|
||
|
||
|
||
def _fuelled(inputs: dict, tier: int) -> dict:
|
||
"""A fire craft's inputs: its materials and one log of its tier to burn."""
|
||
return {**inputs, FIREWOOD[tier]: inputs.get(FIREWOOD[tier], 0) + 1}
|
||
|
||
# Every tier's hide comes from Hunting. raw hide (id, name, icon, value), leather (id, name, value),
|
||
# armour id prefix, armour name prefix
|
||
LEATHERS = [
|
||
(("hide", "Hide", "🟫", 3), ("leather", "Leather", 5), "leather", "Leather"),
|
||
(("boar_hide", "Boar hide", "🐗", 8), ("hard_leather", "Hard leather", 12), "hard_leather", "Hard leather"),
|
||
(("bear_pelt", "Bear pelt", "🐻", 16), ("bear_leather", "Bear leather", 24), "bear_leather", "Bear leather"),
|
||
(("elk_hide", "Elk hide", "🦌", 35), ("elk_leather", "Elk leather", 50), "elk_leather", "Elk leather"),
|
||
(("sabrecat_pelt", "Sabrecat pelt", "🐆", 60), ("sabrecat_leather", "Sabrecat leather", 85),
|
||
"sabrecat_leather", "Sabrecat leather"),
|
||
(("mammoth_hide", "Mammoth hide", "🦣", 100), ("mammoth_leather", "Mammoth leather", 140),
|
||
"mammoth_leather", "Mammoth leather"),
|
||
]
|
||
# piece suffix, slot, noun, leather used, level above the tier, icon
|
||
LEATHER_PIECES = [("cap", "helmet", "cap", 2, 2, "🧢"), ("legs", "legs", "trousers", 3, 7, "👖"),
|
||
("body", "body", "jerkin", 4, 11, "🦺")]
|
||
# Tailoring: a fibre from Farming, the cloth woven from it, and the light armour sewn from that.
|
||
# fibre (id, name, icon, value), farming level, seconds, xp, cloth (id, name, value), armour id prefix, armour name prefix
|
||
CLOTHS = [
|
||
(("flax", "Flax", "🌿", 2), 1, 35, 11, ("linen", "Linen", 5), "linen", "Linen"),
|
||
(("cotton", "Cotton", "☁️", 4), 15, 45, 20, ("cotton_cloth", "Cotton cloth", 12), "cotton", "Cotton"),
|
||
(("silk_cocoons", "Silk cocoons", "🐛", 8), 30, 55, 32, ("silk", "Silk", 24), "silk", "Silk"),
|
||
(("moonpetal", "Moonpetal", "🌙", 15), 45, 60, 45, ("moonweave", "Moonweave", 45), "moonweave", "Moonweave"),
|
||
(("starbloom", "Starbloom", "⭐", 28), 60, 70, 60, ("starweave", "Starweave", 80), "starweave", "Starweave"),
|
||
(("spiritvine", "Spiritvine", "🌀", 50), 75, 80, 80, ("spiritweave", "Spiritweave", 135), "spiritweave", "Spiritweave"),
|
||
]
|
||
FIBRE_VERBS = {"silk_cocoons": "Raise silkworms"}
|
||
CLOTH_PIECES = [("hood", "helmet", "hood", 2, 2, "🧢"), ("legs", "legs", "leggings", 3, 7, "👖"),
|
||
("body", "body", "robe", 4, 11, "👘")]
|
||
|
||
# raw (id, name, icon, value), cooked (id, name, icon, value, heal, food), fishing level, fish action, verb
|
||
FISH = [
|
||
(("raw_shrimp", "Raw shrimp", "🦐", 1), ("cooked_shrimp", "Cooked shrimp", "🍤", 4, 10, 1), 1, "fish_shrimp", "Net shrimp"),
|
||
(("raw_trout", "Raw trout", "🐟", 3), ("cooked_trout", "Grilled trout", "🍢", 8, 18, 1), 15, "fish_trout", "Fly-fish trout"),
|
||
(("raw_salmon", "Raw salmon", "🐠", 6), ("cooked_salmon", "Baked salmon", "🍣", 14, 28, 2), 30, "fish_salmon", "Catch salmon"),
|
||
(("raw_eel", "Raw eel", "🐍", 10), ("smoked_eel", "Smoked eel", "🥓", 22, 40, 2), 45, "fish_eel", "Trap eels"),
|
||
(("raw_lobster", "Raw lobster", "🦞", 18), ("cooked_lobster", "Cooked lobster", "🍜", 35, 55, 3), 60, "fish_lobster", "Pot lobsters"),
|
||
(("raw_swordfish", "Raw swordfish", "🐬", 30), ("cooked_swordfish", "Grilled swordfish", "🍱", 55, 70, 3), 75, "fish_swordfish", "Harpoon swordfish"),
|
||
]
|
||
COOK_IDS = {"raw_shrimp": "cook_shrimp", "raw_trout": "cook_trout", "raw_salmon": "cook_salmon", "raw_eel": "cook_eel",
|
||
"raw_lobster": "cook_lobster", "raw_swordfish": "cook_swordfish"}
|
||
|
||
ITEMS["coal"] = {"name": "Coal", "icon": "🌑", "value": 6}
|
||
for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in enumerate(METALS):
|
||
if _ore:
|
||
ITEMS[_ore[0]] = {"name": _ore[1], "icon": _ore[2], "value": _ore[3]}
|
||
ITEMS[f"{_key}_bar"] = {"name": f"{_name} bar", "icon": _bar_icon, "value": _bar_value, "tier": _tier}
|
||
for _piece, _slot, _noun, _bars, _offset, _power, _armour in PIECES:
|
||
_item = {"name": f"{_name} {_noun}", "plural": f"{_name} {PLURALS[_noun]}",
|
||
"icon": COPPER_ICONS.get(_piece, PIECE_ICONS[_piece]) if _key == "copper" else PIECE_ICONS[_piece],
|
||
"value": round(_bars * _bar_value * 1.2), "slot": _slot, "tier": _tier,
|
||
"req": ["combat", TIER_LEVELS[_tier]]}
|
||
if _power:
|
||
_item["power"] = _power[_tier]
|
||
if _armour and _piece == "shield":
|
||
_item["armour"] = _armour[_tier]
|
||
elif _armour:
|
||
_item.update(_armour_stats("heavy", _slot, _tier))
|
||
ITEMS[f"{_key}_{_piece}"] = {k: v for k, v in _item.items() if v is not None}
|
||
for _skill, (_tool, _icon) in TOOLS.items():
|
||
ITEMS[f"{_key}_{_tool.replace(' ', '_')}"] = {
|
||
"name": f"{_name} {_tool}", "icon": _icon, "value": round(2 * _bar_value * 1.4), "tier": _tier,
|
||
"slot": f"tool_{_skill}", "tool": _skill, "speed": TOOL_SPEED[_tier], "req": [_skill, TIER_LEVELS[_tier]],
|
||
}
|
||
# Jewelcrafting: a gem from each tier's rock, set in a band of copper, silver or gold.
|
||
# Silver and gold are soft metals the jeweller smelts, no smithy needed. Past the first
|
||
# tier, every piece also needs a shard from that tier's expedition area.
|
||
# gem id, name, value, tint, glyph
|
||
GEMS = [
|
||
("quartz", "Quartz", 8, "#e8e4f0", "crystal-bars"),
|
||
("garnet", "Garnet", 20, "#c8405a", "rupee"),
|
||
("amethyst", "Amethyst", 40, "#a070e0", "amethyst"),
|
||
("sapphire", "Sapphire", 70, "#4f7ff0", "saphir"),
|
||
("emerald", "Emerald", 110, "#3fc070", "emerald"),
|
||
("diamond", "Diamond", 160, "#d8f4ff", "diamond-hard"),
|
||
]
|
||
BANDS = ["copper_bar", "silver_bar", "silver_bar", "gold_bar", "gold_bar", "gold_bar"]
|
||
GEM_ODDS = 0.02 # a gem with each rock mined
|
||
PROSPECT_ORE = 5 # ore cracked open per prospecting action...
|
||
PROSPECT_ODDS = 0.15 # ...for this chance at its gem
|
||
# The gem each rock turns up when mined, and what's cracked open to prospect for each gem.
|
||
GEM_ROCKS = {"mine_stone": "quartz", "mine_copper": "quartz", "mine_iron": "garnet", "mine_coal": "amethyst",
|
||
"mine_mithril": "sapphire", "mine_adamantite": "emerald", "mine_runite": "diamond"}
|
||
PROSPECT = ["stone", "iron_ore", "coal", "mithril_ore", "adamantite_ore", "runite_ore"]
|
||
# Rings for offence, amulets for defence, trinkets for everything else; each its own tab.
|
||
JEWEL_GROUPS = {"ring": "Rings", "amulet": "Amulets", "trinket": "Trinkets"}
|
||
# key, slot, noun, stat, bonus by tier, bands, gems, shards, level above the tier, glyph
|
||
JEWELLERY = [
|
||
("haste", "ring", "ring of haste", "haste", [5, 7, 8, 9, 10, 11], 1, 1, 1, 2, "diamond-ring"),
|
||
("fury", "ring", "ring of fury", "crit", [2, 3, 5, 6, 8, 10], 1, 1, 1, 2, "big-diamond-ring"),
|
||
("might", "ring", "ring of might", "power", [2, 4, 6, 9, 12, 15], 1, 1, 1, 2, "swirl-ring"),
|
||
("vigour", "amulet", "amulet of vigour", "hp", [5, 10, 16, 23, 31, 40], 2, 1, 2, 8, "heart-necklace"),
|
||
("warding", "amulet", "amulet of warding", "ward", [3, 5, 7, 10, 12, 15], 2, 1, 2, 8, "gem-pendant"),
|
||
("mending", "amulet", "amulet of mending", "regen", [10, 20, 30, 40, 50, 60], 2, 1, 2, 8, "tribal-pendant"),
|
||
("hourglass", "trinket", "hourglass", "xp", [2, 3, 5, 6, 8, 10], 1, 2, 1, 5, "hourglass"),
|
||
("charm", "trinket", "lucky charm", "double", [2, 3, 4, 5, 6, 8], 1, 2, 1, 5, "clover"),
|
||
("loupe", "trinket", "loupe", "craft_double", [2, 3, 4, 4, 5, 6], 1, 2, 1, 5, "magnifying-glass"),
|
||
]
|
||
# How each jewellery bonus reads: name, unit, glyph, tint (and a sign, if it takes away).
|
||
JEWEL_STATS = {
|
||
"haste": {"name": "Swing time", "unit": "%", "glyph": "stopwatch", "tint": "#9fb3c8", "sign": "-"},
|
||
"crit": {"name": "Crit chance", "unit": "%", "glyph": "striking-diamonds", "tint": "#e05a5a"},
|
||
"power": {"name": "Power", "unit": "", "glyph": "biceps", "tint": "#e06a6a"},
|
||
"hp": {"name": "Max HP", "unit": "", "glyph": "hearts", "tint": "#e06a8a"},
|
||
"ward": {"name": "Ward", "unit": "", "glyph": "magic-shield", "tint": "#a77be8"},
|
||
"regen": {"name": "HP regen", "unit": "%", "glyph": "heart-plus", "tint": "#e06a8a"},
|
||
"xp": {"name": "XP", "unit": "%", "glyph": "sparkles", "tint": "#f0d060"},
|
||
"double": {"name": "Double gathering", "unit": "%", "glyph": "clover", "tint": "#5fae5a"},
|
||
"craft_double": {"name": "Double crafting", "unit": "%", "glyph": "anvil", "tint": "#d07a3c"},
|
||
}
|
||
ITEMS["silver_ore"] = {"name": "Silver ore", "icon": "⚪", "value": 6}
|
||
ITEMS["silver_bar"] = {"name": "Silver bar", "icon": "🥈", "value": 15, "tier": 1}
|
||
ITEMS["gold_ore"] = {"name": "Gold ore", "icon": "🟡", "value": 20}
|
||
ITEMS["gold_bar"] = {"name": "Gold bar", "icon": "🥇", "value": 45, "tier": 3}
|
||
for _tier, (_gem, _gem_name, _gem_value, _tint, _glyph) in enumerate(GEMS):
|
||
ITEMS[_gem] = {"name": _gem_name, "icon": "💎", "value": _gem_value, "tier": _tier}
|
||
for _key, _slot, _noun, _stat, _bonus, _bands, _gems, _shards, _offset, _piece_glyph in JEWELLERY:
|
||
ITEMS[f"{_gem}_{_key}"] = {
|
||
"name": f"{_gem_name} {_noun}", "icon": {"ring": "💍", "amulet": "📿", "trinket": "🔮"}[_slot],
|
||
"value": round((_bands * ITEMS[BANDS[_tier]]["value"] + _gems * _gem_value + _shards * 12 * _tier) * 1.3),
|
||
"slot": _slot, "tier": _tier, "jewel": _stat, _stat: _bonus[_tier],
|
||
**({"req": ["combat", TIER_LEVELS[_tier]]} if _slot != "trinket" else {})}
|
||
# Hardware: what the smith makes for builders, one kind per metal, used in
|
||
# fittings and in building levels in place of plain bars.
|
||
# id, singular, plural, bars used, pieces made
|
||
HARDWARE = [
|
||
("copper_rivets", "Copper rivet", "Copper rivets", 1, 5),
|
||
("iron_nails", "Iron nail", "Iron nails", 1, 5),
|
||
("steel_hinges", "Steel hinge", "Steel hinges", 1, 2),
|
||
("mithril_brackets", "Mithril bracket", "Mithril brackets", 1, 2),
|
||
("adamant_chain", "Adamant chain", "Adamant chains", 2, 1),
|
||
("rune_lock", "Rune lock", "Rune locks", 2, 1),
|
||
]
|
||
for (_id, _name, _plural, _bars, _made), _metal in zip(HARDWARE, METALS):
|
||
ITEMS[_id] = {"name": _name, "plural": _plural, "icon": "🔩", "value": max(1, round(_bars * _metal[5] * 1.2 / _made)),
|
||
"tier": METALS.index(_metal)}
|
||
PLANKS = [("plank", "Plank"), ("oak_plank", "Oak plank"), ("willow_plank", "Willow plank"),
|
||
("maple_plank", "Maple plank"), ("yew_plank", "Yew plank"), ("magic_plank", "Enchanted plank")]
|
||
for (_log, _name, _icon, _value, _action_id, _verb), (_plank, _plank_name) in zip(TREES, PLANKS):
|
||
ITEMS[_log] = {"name": _name, "icon": _icon, "value": _value}
|
||
ITEMS[_plank] = {"name": _plank_name, "icon": "🪚", "value": _value * 2 + 1}
|
||
# Weapons: four styles, one per trade. The sword pairs with a shield; the
|
||
# warhammer, staff and bow take both hands, so they carry the shield's power too.
|
||
SWORD_POWER = next(piece[5] for piece in PIECES if piece[0] == "sword")
|
||
SHIELD_POWER = next(piece[5] for piece in PIECES if piece[0] == "shield")
|
||
TWO_HANDED_POWER = [sword + shield for sword, shield in zip(SWORD_POWER, SHIELD_POWER)]
|
||
WOODS = ["wooden", "oak", "willow", "maple", "yew", "magic"]
|
||
STAVES = ["Wooden staff", "Oak staff", "Willow staff", "Maple staff", "Yew staff", "Enchanted staff"]
|
||
BOWS = ["Shortbow", "Oak bow", "Willow bow", "Maple bow", "Yew bow", "Enchanted bow"]
|
||
for _tier, _metal in enumerate(METALS):
|
||
ITEMS[f"{_metal[0]}_sword"]["style"] = "sword"
|
||
_weapon = {"slot": "weapon", "power": TWO_HANDED_POWER[_tier], "tier": _tier, "two_handed": True,
|
||
"req": ["combat", TIER_LEVELS[_tier]], "value": round(5 * _metal[5] * 1.2)}
|
||
ITEMS[f"{_metal[0]}_warhammer"] = {"name": f"{_metal[1]} warhammer", "icon": "🔨", "style": "hammer", **_weapon}
|
||
ITEMS[f"{WOODS[_tier]}_staff"] = {"name": STAVES[_tier], "plural": STAVES[_tier].replace("staff", "staves"),
|
||
"icon": "🪄", "style": "staff", **_weapon}
|
||
ITEMS[f"{WOODS[_tier]}_bow"] = {"name": BOWS[_tier], "icon": "🏹", "style": "bow", **_weapon}
|
||
WOOD_NAMES = ["Wooden", "Oak", "Willow", "Maple", "Yew", "Enchanted"]
|
||
|
||
|
||
def station_material(kind: str, tier: int) -> str:
|
||
"""The other trade's part of a station: the tier's bar, leather or cloth."""
|
||
return {"bar": f"{METALS[tier][0]}_bar", "leather": LEATHERS[tier][1][0], "cloth": CLOTHS[tier][4][0]}[kind]
|
||
|
||
|
||
for _tier, _wood in enumerate(WOODS):
|
||
for _skill, (_suffix, _noun, _glyph, _kind) in STATIONS.items():
|
||
ITEMS[f"{_wood}_{_suffix}"] = {
|
||
"name": f"{WOOD_NAMES[_tier]} {_noun}", "icon": "🪚", "tier": _tier, "slot": f"station_{_skill}", "station": _skill,
|
||
"speed": TOOL_SPEED[_tier], "req": [_skill, TIER_LEVELS[_tier]], "value": 0}
|
||
ITEMS[f"{_wood}_strongbox"] = {"name": f"{WOOD_NAMES[_tier]} strongbox", "plural": f"{WOOD_NAMES[_tier]} strongboxes",
|
||
"icon": "🧰", "tier": _tier, "slot": "storage", "slots": STRONGBOX_SLOTS[_tier], "value": 0}
|
||
for _tier, (_fibre, _level, _time, _xp, _cloth, _prefix, _label) in enumerate(CLOTHS):
|
||
ITEMS[f"{_prefix}_bag"] = {"name": f"{_label} bag", "icon": "👜", "tier": _tier, "slot": "bag",
|
||
"stack": BAG_STACK[_tier], "value": 0}
|
||
for _tier, (_raw, _leather, _prefix, _label) in enumerate(LEATHERS):
|
||
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
|
||
ITEMS[_leather[0]] = {"name": _leather[1], "icon": "🪢", "value": _leather[2]}
|
||
for _suffix, _slot, _noun, _used, _offset, _icon in LEATHER_PIECES:
|
||
ITEMS[f"{_prefix}_{_suffix}"] = {
|
||
"name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _leather[2] * 1.3),
|
||
"slot": _slot, "tier": _tier, "req": ["combat", TIER_LEVELS[_tier]], **_armour_stats("medium", _slot, _tier),
|
||
}
|
||
if _noun == "trousers":
|
||
ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"]
|
||
for _tier, (_fibre, _level, _time, _xp, _cloth, _prefix, _label) in enumerate(CLOTHS):
|
||
ITEMS.setdefault(_fibre[0], {"name": _fibre[1], "icon": _fibre[2], "value": _fibre[3]})
|
||
ITEMS[_cloth[0]] = {"name": _cloth[1], "icon": "🧶", "value": _cloth[2]}
|
||
for _suffix, _slot, _noun, _used, _offset, _icon in CLOTH_PIECES:
|
||
ITEMS[f"{_prefix}_{_suffix}"] = {
|
||
"name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _cloth[2] * 1.3),
|
||
"slot": _slot, "tier": _tier, "req": ["combat", TIER_LEVELS[_tier]], **_armour_stats("light", _slot, _tier),
|
||
}
|
||
if _noun == "leggings":
|
||
ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"]
|
||
for _raw, _cooked, _level, _action_id, _verb in FISH:
|
||
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
|
||
ITEMS[_cooked[0]] = {"name": _cooked[1], "icon": _cooked[2], "value": _cooked[3], "heal": _cooked[4], "food": _cooked[5]}
|
||
|
||
# --- herblore -------------------------------------------------------------------
|
||
# Herbs from Farming, brewed with a secondary into potions with charges. One
|
||
# potion per skill at a time, as in Melvor; each action in that skill uses a
|
||
# charge, and an empty potion is replaced from the bank if there's another.
|
||
|
||
# id, name, icon, farming level, seconds, xp, value
|
||
HERBS = [
|
||
("guam_leaf", "Guam leaf", "🍀", 1, 45, 12, 3),
|
||
("marrentill", "Marrentill", "🌼", 15, 50, 20, 5),
|
||
("tarromin", "Tarromin", "🌺", 25, 55, 30, 8),
|
||
("harralander", "Harralander", "🌻", 35, 60, 42, 12),
|
||
("ranarr", "Ranarr", "🪻", 45, 65, 55, 18),
|
||
("snapdragon", "Snapdragon", "🌸", 60, 70, 75, 28),
|
||
]
|
||
POTION_CHARGES = 50
|
||
# Potions come in tiers like everything else: tier n is brewed from the nth herb
|
||
# and the nth rung of the skill's own ladder, needs the tier's Herblore level
|
||
# (none for tier I), and is stronger the higher it goes. Ladders stop where the
|
||
# skill runs out of higher things to do.
|
||
ROMAN = ["I", "II", "III", "IV", "V", "VI"]
|
||
SPEEDS = [0.10, 0.15, 0.20, 0.25, 0.30, 0.35]
|
||
_ores = ["copper_ore", "iron_ore", "coal", "mithril_ore", "adamantite_ore", "runite_ore"]
|
||
# id, name, skill, effect, strength by tier, secondary by tier
|
||
POTION_LINES = [
|
||
("mining", "Mining potion", "mining", "speed", SPEEDS, _ores),
|
||
("woodcutting", "Woodcutting potion", "woodcutting", "speed", SPEEDS, [tree[0] for tree in TREES]),
|
||
("fishing", "Fishing potion", "fishing", "speed", SPEEDS, [fish[0][0] for fish in FISH]),
|
||
("farming", "Farming potion", "farming", "speed", SPEEDS,
|
||
["wheat", "vegetables", "guam_leaf", "marrentill", "tarromin", "harralander"]),
|
||
("hunting", "Hunting potion", "hunting", "speed", SPEEDS,
|
||
["feathers", "raw_meat", "boar_hide", "bear_pelt", "elk_hide", "sabrecat_pelt"]),
|
||
("construction", "Construction potion", "construction", "speed", SPEEDS, [plank for plank, _name in PLANKS]),
|
||
("smithing", "Smithing potion", "smithing", "keep", SPEEDS, [f"{metal[0]}_bar" for metal in METALS]),
|
||
("cooking", "Cooking potion", "cooking", "keep", SPEEDS, [fish[1][0] for fish in FISH]),
|
||
("leatherworking", "Leatherworking potion", "leatherworking", "keep", SPEEDS,
|
||
[leather[1][0] for leather in LEATHERS]),
|
||
("tailoring", "Tailoring potion", "tailoring", "keep", SPEEDS, [cloth[4][0] for cloth in CLOTHS]),
|
||
("prayer", "Prayer potion", "prayer", "bones", [0.25, 0.5, 0.75], ["bones", "big_bones", "dragon_bones"]),
|
||
("strength", "Strength potion", "combat", "power", [4, 7, 11, 15, 20, 26], [leather[0][0] for leather in LEATHERS]),
|
||
("defence", "Defence potion", "combat", "reduce", [0.10, 0.14, 0.18, 0.22, 0.26, 0.30],
|
||
[f"{metal[0]}_shield" for metal in METALS]),
|
||
]
|
||
# id, name, herblore level, herb, secondary, skill, effect
|
||
POTIONS = []
|
||
POTION_TIER = {}
|
||
for _line, _name, _skill, _key, _strengths, _seconds in POTION_LINES:
|
||
for _tier, (_strength, _second) in enumerate(zip(_strengths, _seconds)):
|
||
_id = f"{_line}_potion_{_tier + 1}"
|
||
POTIONS.append((_id, f"{_name} {ROMAN[_tier]}", TIER_LEVELS[_tier], HERBS[_tier][0], _second, _skill,
|
||
{_key: _strength}))
|
||
POTION_TIER[_id] = _tier
|
||
# Potions from before the tiers, and what they became.
|
||
OLD_POTIONS = {
|
||
"trackers_tincture": "hunting_potion_3", "lumberjacks_tonic": "woodcutting_potion_3",
|
||
"strength_potion": "strength_potion_2", "anglers_brew": "fishing_potion_3",
|
||
"prospectors_draught": "mining_potion_2", "growth_potion": "farming_potion_3",
|
||
"defence_potion": "defence_potion_2", "chefs_spice": "cooking_potion_2", "bonedust_tonic": "prayer_potion_2",
|
||
"smiths_focus": "smithing_potion_2", "masons_patience": "construction_potion_2", "tanners_oil": "leatherworking_potion_2",
|
||
"regeneration_potion": "defence_potion_3", "super_strength": "strength_potion_4", "super_defence": "defence_potion_5",
|
||
}
|
||
|
||
|
||
def describe_potion(skill: str, effect: dict) -> str:
|
||
name = "combat" if skill == "combat" else skill
|
||
if "speed" in effect:
|
||
return f"{name.title()} {round(effect['speed'] * 100)}% faster"
|
||
if "double" in effect:
|
||
return f"{name.title()}: {round(effect['double'] * 100)}% double yield"
|
||
if "keep" in effect:
|
||
return f"{name.title()}: {round(effect['keep'] * 100)}% save materials"
|
||
if "bones" in effect:
|
||
return f"+{round(effect['bones'] * 100)}% from bones"
|
||
if "power" in effect:
|
||
return f"+{effect['power']} power"
|
||
return f"-{round(effect['reduce'] * 100)}% damage taken"
|
||
|
||
|
||
for _id, _name, _icon, _level, _time, _xp, _value in HERBS:
|
||
ITEMS[_id] = {"name": _name, "icon": _icon, "value": _value}
|
||
for _id, _name, _level, _herb, _second, _skill, _effect in POTIONS:
|
||
ITEMS[_id] = {"name": _name, "icon": "⚗️" if _skill == "combat" else "🧪",
|
||
"value": ITEMS[_herb]["value"] * 3 + ITEMS[_second]["value"],
|
||
"potion": {"skill": _skill, **_effect, "text": describe_potion(_skill, _effect)}}
|
||
|
||
# Health potions: the one heal you take into a fight, drunk once a fight when you're low.
|
||
# Each tier is the tier's herb and its cooked fish.
|
||
HEALTH_HEAL = [20, 40, 65, 90, 115, 140]
|
||
HEALTH_POTIONS = [(f"health_potion_{_tier + 1}", f"Health potion {ROMAN[_tier]}", TIER_LEVELS[_tier], HERBS[_tier][0],
|
||
FISH[_tier][1][0], HEALTH_HEAL[_tier]) for _tier in range(len(HEALTH_HEAL))]
|
||
for _id, _name, _level, _herb, _fish, _heal in HEALTH_POTIONS:
|
||
ITEMS[_id] = {"name": _name, "icon": "❤️", "value": ITEMS[_herb]["value"] * 3 + ITEMS[_fish]["value"], "drink": _heal}
|
||
|
||
# What the city eats first: plain food before the good stuff.
|
||
FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"]))
|
||
EQUIP_SLOTS = (["weapon", "shield", "helmet", "body", "legs", "ring", "amulet", "trinket", "storage", "bag"]
|
||
+ [f"tool_{skill}" for skill in TOOLS] + [f"station_{skill}" for skill in STATIONS])
|
||
|
||
SKILLS = [
|
||
{"id": "mining", "name": "Mining", "icon": "⛏️", "group": "Gathering"},
|
||
{"id": "woodcutting", "name": "Woodcutting", "icon": "🪓", "group": "Gathering"},
|
||
{"id": "farming", "name": "Farming", "icon": "🌱", "group": "Gathering"},
|
||
{"id": "hunting", "name": "Hunting", "icon": "🦌", "group": "Gathering"},
|
||
{"id": "fishing", "name": "Fishing", "icon": "🎣", "group": "Gathering"},
|
||
{"id": "smithing", "name": "Smithing", "icon": "🔨", "group": "Crafting"},
|
||
{"id": "jewelcrafting", "name": "Jewelcrafting", "icon": "💍", "group": "Crafting"},
|
||
{"id": "construction", "name": "Construction", "icon": "🏗️", "group": "Crafting"},
|
||
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
|
||
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
|
||
{"id": "tailoring", "name": "Tailoring", "icon": "🧵", "group": "Crafting"},
|
||
{"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"},
|
||
{"id": "firekeeping", "name": "Firekeeping", "icon": "🔥", "group": "Crafting"},
|
||
{"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"},
|
||
{"id": "prayer", "name": "Prayer", "icon": "🙏", "group": "Combat"},
|
||
{"id": "reconnaissance", "name": "Reconnaissance", "icon": "🔭", "group": "Combat"},
|
||
]
|
||
SKILL_IDS = [skill["id"] for skill in SKILLS]
|
||
GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"}
|
||
CRAFTING = {"smithing", "jewelcrafting", "construction", "cooking", "leatherworking", "herblore", "tailoring"}
|
||
|
||
# A class is a whole trade, from gathering to the finished goods: its skills are
|
||
# 20% faster, 1 in 10 of its crafts make double, and it has one bonus of its own.
|
||
# Nothing is locked: every class can train every skill.
|
||
CLASSES = {
|
||
"miner": {
|
||
"name": "Miner", "icon": "⛏️", "fast": ["mining", "smithing", "jewelcrafting"],
|
||
"craft": ["smithing", "jewelcrafting"],
|
||
"bonus": "Mining, smithing and jewelcrafting 20% faster",
|
||
"masterwork": "Masterwork: 10% double smithing and jewelcrafting crafts",
|
||
"signature": "Prospector: 10% double ore",
|
||
"combat": "Warhammer: +15% damage",
|
||
},
|
||
"builder": {
|
||
"name": "Builder", "icon": "🏗️", "fast": ["woodcutting", "construction", "firekeeping"], "craft": ["construction"],
|
||
"bonus": "Woodcutting, construction and firekeeping 20% faster",
|
||
"masterwork": "Masterwork: 10% double construction crafts",
|
||
"signature": "Foreman: +1 labour per site action",
|
||
"combat": "Sword: +15% damage",
|
||
},
|
||
"homesteader": {
|
||
"name": "Homesteader", "icon": "🌾", "fast": ["cooking", "herblore", "tailoring"],
|
||
"craft": ["cooking", "herblore", "tailoring"],
|
||
"bonus": "Cooking, herblore and tailoring 20% faster, two farm plots",
|
||
"masterwork": "Masterwork: 10% double cooking, herblore and tailoring crafts",
|
||
"signature": "Steady hand: potions last 25% longer",
|
||
"combat": "Staff: +15% damage",
|
||
},
|
||
"ranger": {
|
||
"name": "Ranger", "icon": "🏹", "fast": ["hunting", "fishing", "leatherworking", "cooking"],
|
||
"craft": ["leatherworking", "cooking"],
|
||
"bonus": "Hunting, fishing, leatherworking and cooking 20% faster",
|
||
"masterwork": "Masterwork: 10% double leatherworking and cooking crafts",
|
||
"signature": "Pathfinder: +50% recon",
|
||
"combat": "Bow: +15% damage",
|
||
},
|
||
}
|
||
# Classes from before the trades, as a starting point until their player re-picks.
|
||
OLD_CLASSES = {"pioneer": "miner", "artisan": "builder", "scout": "ranger", "warrior": "builder"}
|
||
|
||
|
||
def _action(id, skill, name, level, time, xp, inputs=None, outputs=None, **extra):
|
||
return {"id": id, "skill": skill, "name": name, "level": level, "time": time, "xp": xp,
|
||
"inputs": inputs or {}, "outputs": outputs or {}, **extra}
|
||
|
||
|
||
def _tiered_actions() -> list[dict]:
|
||
"""Mining, woodcutting, smelting, smithing, tanning and leatherworking for every tier."""
|
||
actions = []
|
||
ore_xp = [7, 15, 30, 40, 55, 75]
|
||
ore_time = [25, 35, 50, 55, 65, 80]
|
||
bar_xp = [6, 14, 22, 32, 44, 58]
|
||
piece_xp = [6, 13, 20, 28, 37, 48] # per bar used
|
||
for tier, (key, name, ore, coal, _icon, _value, smithy, wood) in enumerate(METALS):
|
||
if ore:
|
||
actions.append(_action(f"mine_{key}" if key in ("copper", "iron") else f"mine_{ore[0].removesuffix('_ore')}",
|
||
"mining", f"Mine {ore[1].removesuffix(' ore').lower()}", TIER_LEVELS[tier],
|
||
ore_time[tier], ore_xp[tier], outputs={ore[0]: 1}))
|
||
if tier == 2:
|
||
actions.append(_action("mine_coal", "mining", "Mine coal", 30, 40, 22, outputs={"coal": 1}))
|
||
city = {"smithy": smithy} if smithy else {}
|
||
inputs = {"iron_ore" if key == "steel" else ore[0]: 1, **({"coal": coal} if coal else {})}
|
||
actions.append(_action(f"smith_{key}_bar", "smithing", f"{name} bar", TIER_LEVELS[tier], 20 + 4 * tier,
|
||
bar_xp[tier], _fuelled(inputs, tier), {f"{key}_bar": 1}, group="Bars",
|
||
city={"smithy": smithy} if tier >= 2 else {}, fire="forge", fuel=tier))
|
||
hardware, _name, _plural, bars, made = HARDWARE[tier]
|
||
actions.append(_action(f"smith_{hardware}", "smithing", _plural, TIER_LEVELS[tier] + 2, 25 + 2 * tier,
|
||
bars * piece_xp[tier], {f"{key}_bar": bars}, {hardware: made}, group="Hardware",
|
||
city=city))
|
||
for piece, _slot, noun, bars, offset, _power, _armour in PIECES:
|
||
if piece == "shield":
|
||
continue # builders make shields now, in Construction
|
||
inputs = {f"{key}_bar": bars}
|
||
if tier:
|
||
inputs.update({trophy("sword", tier): tier} if piece == "sword" else
|
||
{reagent("heavy", tier): REAGENT_COUNTS[_slot]})
|
||
actions.append(_action(f"smith_{key}_{piece}", "smithing", f"{name} {noun}", TIER_LEVELS[tier] + offset,
|
||
25 + 5 * bars + 2 * tier, bars * piece_xp[tier], inputs,
|
||
{f"{key}_{piece}": 1}, group=name, city=city))
|
||
actions.append(_action(f"smith_{key}_warhammer", "smithing", f"{name} warhammer", TIER_LEVELS[tier] + 6,
|
||
45 + 2 * tier, 4 * piece_xp[tier],
|
||
{f"{key}_bar": 3, PLANKS[tier][0]: 1, **({trophy("hammer", tier): 2 + tier} if tier else {})},
|
||
{f"{key}_warhammer": 1}, group=name, city=city))
|
||
for tool, _icon in TOOLS.values():
|
||
tool_id = f"{key}_{tool.replace(' ', '_')}"
|
||
actions.append(_action(f"smith_{tool_id}", "smithing", f"{name} {tool}", TIER_LEVELS[tier] + 3,
|
||
30 + 2 * tier, 2 * piece_xp[tier], {f"{key}_bar": 2, wood: 1}, {tool_id: 1},
|
||
group=name, city=city))
|
||
for tier, (log, _name, _icon, _value, action_id, verb) in enumerate(TREES):
|
||
actions.append(_action(action_id, "woodcutting", verb, TIER_LEVELS[tier], [20, 35, 40, 50, 60, 75][tier],
|
||
[5, 15, 24, 36, 52, 75][tier], outputs={log: 1}))
|
||
for tier, (raw, leather, prefix, label) in enumerate(LEATHERS):
|
||
actions.append(_action("tan_leather" if tier == 0 else f"tan_{leather[0]}", "leatherworking",
|
||
f"Tan {leather[1].lower()}", TIER_LEVELS[tier], 25 + 3 * tier, bar_xp[tier],
|
||
{raw[0]: 1}, {leather[0]: 1}, group="Tanning"))
|
||
for suffix, slot, noun, used, offset, _icon in LEATHER_PIECES:
|
||
actions.append(_action(f"lw_{suffix}" if tier == 0 else f"lw_{prefix}_{suffix}", "leatherworking",
|
||
f"{label} {noun}", TIER_LEVELS[tier] + offset,
|
||
25 + 5 * used + 2 * tier, used * piece_xp[tier],
|
||
{leather[0]: used, **({reagent("medium", tier): REAGENT_COUNTS[slot]} if tier else {})},
|
||
{f"{prefix}_{suffix}": 1}, group=label))
|
||
for tier, (fibre, level, seconds, fibre_xp, cloth, prefix, label) in enumerate(CLOTHS):
|
||
if tier: # flax is grown with the other building materials
|
||
actions.append(_action(f"grow_{fibre[0]}", "farming", FIBRE_VERBS.get(fibre[0], f"Grow {fibre[1].lower()}"),
|
||
level, seconds, fibre_xp, outputs={fibre[0]: 2}, group="Fibres"))
|
||
actions.append(_action(f"weave_{cloth[0]}", "tailoring", f"Weave {cloth[1].lower()}", TIER_LEVELS[tier],
|
||
25 + 3 * tier, bar_xp[tier], {fibre[0]: 2}, {cloth[0]: 1}, group="Weaving"))
|
||
for suffix, slot, noun, used, offset, _icon in CLOTH_PIECES:
|
||
actions.append(_action(f"sew_{prefix}_{suffix}", "tailoring", f"{label} {noun}", TIER_LEVELS[tier] + offset,
|
||
25 + 5 * used + 2 * tier, used * piece_xp[tier],
|
||
{cloth[0]: used, **({reagent("light", tier): REAGENT_COUNTS[slot]} if tier else {})},
|
||
{f"{prefix}_{suffix}": 1}, group=label))
|
||
for raw, cooked, level, fish_id, verb in FISH:
|
||
tier = TIER_LEVELS.index(level) if level in TIER_LEVELS else 3
|
||
actions.append(_action(fish_id, "fishing", verb, level, [25, 35, 45, 55, 65, 80][tier],
|
||
[6, 15, 28, 45, 62, 85][tier], outputs={raw[0]: 1}))
|
||
actions.append(_action(COOK_IDS[raw[0]], "cooking", cooked[1], level, [20, 30, 35, 40, 45, 50][tier],
|
||
[6, 15, 27, 42, 58, 80][tier], _fuelled({raw[0]: 1}, tier), {cooked[0]: 1}, group="Fish",
|
||
fire="kitchen", fuel=tier))
|
||
return actions
|
||
|
||
|
||
# --- combat -----------------------------------------------------------------
|
||
# A fight is played out swing by swing: you and the enemy each have HP, a swing
|
||
# timer, accuracy and a max hit, and every swing hits, misses or crits. Every
|
||
# blow has a damage type: weapons deal one, and each enemy is vulnerable to some
|
||
# types and resists others. Armour stops physical blows, ward stops magic.
|
||
# Combat is split into the Frontier (bandits and the city's troubles) and
|
||
# expeditions: one area per tier, each with four races (each feeding one weapon
|
||
# line with its trophy, and every armour line with the area's reagents) and a boss.
|
||
CRIT_CHANCE = 0.05
|
||
CRIT_MULT = combat.CRIT_MULT
|
||
RESPAWN = 2 # the least rest between one fight and the next
|
||
GROUP_SIZE = 2 # bosses and dragons are only fought this many together...
|
||
BOSS_HP = 0.75 # ...and grow by this much of their HP for each fighter...
|
||
BOSS_HITS = (1.3, 0.15) # ...and hit this hard against a pair, and this much harder for each fighter more
|
||
MAX_FIGHT = combat.MAX_FIGHT
|
||
PACK_EXTRA = 0.5 # chance a pack has one more enemy than there are of you...
|
||
OUTNUMBERED = (0.65, 0.65) # ...each of them then with this share of the HP and the hits
|
||
|
||
DAMAGE_TYPES = {
|
||
"slash": {"name": "Slashing", "glyph": "sword-slice", "tint": "#d4dae2"},
|
||
"blunt": {"name": "Blunt", "glyph": "hammer-drop", "tint": "#d1a46a"},
|
||
"pierce": {"name": "Piercing", "glyph": "arrowhead", "tint": "#5fae8a"},
|
||
"magic": {"name": "Magic", "glyph": "magic-swirl", "tint": "#a77be8"},
|
||
}
|
||
VULNERABLE = 1.25 # damage an enemy takes from a type it's vulnerable to
|
||
RESISTANT = 0.75 # ...and from one it resists
|
||
|
||
# How each kind of weapon fights. Two-handed weapons carry the shield's power themselves.
|
||
STYLES = {
|
||
"sword": {"name": "sword", "damage": "slash", "interval": 2.4, "hit": 1.0, "accuracy": 1.0, "crit": 0.05},
|
||
"hammer": {"name": "warhammer", "damage": "blunt", "interval": 3.6, "hit": 1.6, "accuracy": 0.95, "crit": 0.10},
|
||
"staff": {"name": "staff", "damage": "magic", "interval": 3.0, "hit": 1.2, "accuracy": 1.25, "crit": 0.05},
|
||
"bow": {"name": "bow", "damage": "pierce", "interval": 1.8, "hit": 0.75, "accuracy": 1.05, "crit": 0.05, "first": True},
|
||
}
|
||
CLASS_STYLE = {"miner": "hammer", "builder": "sword", "homesteader": "staff", "ranger": "bow"}
|
||
CLASS_STYLE_BONUS = 1.15 # damage with your own trade's weapon
|
||
BLOCK_CHANCE = 0.15 # a shield turns aside this share of hits
|
||
|
||
# Each armour piece past copper needs reagents from its tier's expedition area, and so
|
||
# does each piece of jewellery past quartz (its shards).
|
||
REAGENT_COUNTS = {"helmet": 1, "legs": 2, "body": 3}
|
||
REAGENT_NOUNS = {"heavy": "plating", "medium": "lacing", "light": "thread", "jewel": "shard"}
|
||
REAGENT_ODDS = 0.10 # each of the area's four reagents, from any of its races
|
||
TROPHY_ODDS = 0.10 # weapons arm the town as well as the hunter, so as common as reagents
|
||
BOSS_TROPHY_ODDS = 0.20 # each of the four lines, so a boss is worth the longer fight
|
||
|
||
|
||
def _slug(name: str) -> str:
|
||
return "".join(c if c.isalnum() else "_" for c in name.lower().replace("'", "")).strip("_")
|
||
|
||
|
||
TOUGHER = 1.2 # enemies have this much more HP since everyone has abilities, for the same XP
|
||
|
||
# Archetypes: HP, seconds between swings, accuracy, evasion, max hit (multipliers), how often it turns up.
|
||
ARCHETYPES = {
|
||
"brute": (1.3, 3.0, 1.0, 0.85, 1.2, 4),
|
||
"skirmisher": (0.85, 2.2, 1.1, 1.25, 0.85, 4),
|
||
"caster": (0.85, 2.6, 1.15, 1.0, 1.25, 3),
|
||
"elite": (1.7, 2.8, 1.1, 1.1, 1.3, 1),
|
||
"boss": (1.4, 3.0, 1.0, 1.0, 1.2, 1),
|
||
"dragon": (2.5, 3.0, 1.1, 1.0, 1.9, 1),
|
||
}
|
||
|
||
|
||
def _types(value) -> list:
|
||
return [value] if isinstance(value, str) else list(value)
|
||
|
||
|
||
def monster(name: str, archetype: str, level: int, attack, vulnerable=(), resists=(), boss: bool = False) -> dict:
|
||
hp, interval, accuracy, evasion, hit, weight = ARCHETYPES[archetype]
|
||
hp = round((20 + 6 * level) * hp * (2.5 if boss else 1))
|
||
return {"name": name, "archetype": archetype, "level": level, "boss": boss, "weight": weight,
|
||
"hp": round(hp * TOUGHER), "interval": interval, "xp": round(hp * 0.13 + level * 0.5),
|
||
"accuracy": round((12 + 2.2 * level) * accuracy), "evasion": round((10 + 2 * level) * evasion),
|
||
"max_hit": max(1, round((2 + level / 3) * hit * (1.2 if boss else 1))),
|
||
"attack": _types(attack), "vulnerable": _types(vulnerable), "resists": _types(resists)}
|
||
|
||
|
||
def _encounter(id: str, name: str, group: str, level: int, roster: list, gold: int = 0, drops=(),
|
||
boss: bool = False, req: int | None = None, **extra) -> dict:
|
||
"""A combat action: each kill is one of the roster's variants, picked by how often each turns up.
|
||
An elite is a couple of levels above the rest. `req` is the combat level to start, if not the enemy's own."""
|
||
variants = [monster(vname, archetype, level + (2 if archetype == "elite" else 0), attack, vulnerable, resists, boss)
|
||
for vname, archetype, attack, vulnerable, resists in roster]
|
||
total = sum(v["weight"] for v in variants)
|
||
xp = round(sum(v["xp"] * v["weight"] for v in variants) / total)
|
||
enemy = {"name": name, "level": level, "boss": boss, "variants": variants}
|
||
# Gold per kill is a range around the encounter's worth.
|
||
purse = [max(1, round(gold * 0.7)), max(2, round(gold * 1.3))] if gold else None
|
||
return _action(id, "combat", extra.pop("label", None) or name, req or level, RESPAWN, xp, gold=purse,
|
||
drops=[list(drop) for drop in drops], enemy=enemy, group=group, **extra)
|
||
|
||
|
||
def _boss(id: str, name: str, group: str, level: int, archetype: str, attack, vulnerable, resists, **extra) -> dict:
|
||
"""One big enemy, and a group fight: alone you wait for someone to tag in. Everyone who brings
|
||
it down gets their own loot."""
|
||
return _encounter(id, name, group, level, [(name, archetype, attack, vulnerable, resists)],
|
||
boss=extra.pop("is_boss", True), personal_loot=True, together=GROUP_SIZE, **extra)
|
||
|
||
|
||
# Rosters: (variant, archetype, attacks with, vulnerable to, resists).
|
||
BANDITS = [("Bandit brute", "brute", "blunt", "slash", "blunt"),
|
||
("Bandit archer", "skirmisher", "pierce", "blunt", "pierce"),
|
||
("Hedge mage", "caster", "magic", "pierce", "magic")]
|
||
WOLVES = [("Grey wolf", "skirmisher", "slash", "pierce", "blunt"),
|
||
("Timber wolf", "brute", "slash", "slash", "pierce"),
|
||
("Wolf alpha", "elite", "slash", "blunt", "slash")]
|
||
GOBLINS = [("Goblin brute", "brute", "blunt", "blunt", "slash"),
|
||
("Goblin rogue", "skirmisher", "pierce", "magic", "blunt"),
|
||
("Goblin warlock", "caster", "magic", "slash", "magic")]
|
||
SPIDERS = [("Cave spider", "skirmisher", "pierce", "blunt", "pierce"),
|
||
("Bloated spider", "brute", "magic", "slash", "blunt"),
|
||
("Spider matriarch", "elite", "pierce", "magic", "slash")]
|
||
SMUGGLERS = [("Smuggler", "skirmisher", "slash", "blunt", "slash"),
|
||
("Smuggler bruiser", "brute", "blunt", "pierce", "blunt"),
|
||
("Smuggler hexer", "caster", "magic", "slash", "pierce")]
|
||
|
||
# Expedition areas, tiers II to VI: name, level, the reagent prefix, and four races.
|
||
# Each race: (name, trophy, weapon line its trophy goes into, level offset, roster).
|
||
AREAS = [
|
||
("Old Watchtower", 15, "Watchtower", [
|
||
("Clay golems", "Clay core", "hammer", 0, [
|
||
("Clay brute", "brute", "blunt", "blunt", "pierce"),
|
||
("Mud shaper", "caster", "magic", "pierce", "magic"),
|
||
("Clay colossus", "elite", "blunt", "magic", "blunt")]),
|
||
("Cave trolls", "Cave troll hide", "sword", 2, [
|
||
("Troll bruiser", "brute", "blunt", "slash", "blunt"),
|
||
("Troll rockhurler", "skirmisher", "pierce", "blunt", "pierce"),
|
||
("Troll hexer", "caster", "magic", "pierce", "slash")]),
|
||
("Restless dead", "Grave dust", "staff", 1, [
|
||
("Skeleton warrior", "brute", "slash", "blunt", "pierce"),
|
||
("Wisp", "skirmisher", "magic", "magic", "slash"),
|
||
("Necromancer", "caster", "magic", "slash", "blunt")]),
|
||
("Harpies", "Harpy sinew", "bow", 0, [
|
||
("Harpy slasher", "skirmisher", "slash", "pierce", "blunt"),
|
||
("Harpy windcaster", "caster", "magic", ("slash", "pierce"), "magic"),
|
||
("Harpy matriarch", "elite", "slash", "magic", "pierce")]),
|
||
]),
|
||
("Sunken Barrows", 30, "Barrow", [
|
||
("Stone guardians", "Stone core", "hammer", 0, [
|
||
("Stone guardian", "brute", "blunt", "blunt", "slash"),
|
||
("Barrow sentry", "skirmisher", "pierce", "magic", "pierce"),
|
||
("Runebound guardian", "elite", "magic", "slash", "magic")]),
|
||
("Lizardfolk", "Lizard scale", "sword", 2, [
|
||
("Lizard warrior", "brute", "slash", "blunt", "slash"),
|
||
("Lizard spearman", "skirmisher", "pierce", "slash", "pierce"),
|
||
("Lizard shaman", "caster", "magic", "pierce", "magic")]),
|
||
("Bog wraiths", "Wraith essence", "staff", 1, [
|
||
("Bog wraith", "caster", "magic", "magic", "slash"),
|
||
("Drowned knight", "brute", "slash", "blunt", "pierce"),
|
||
("Marsh wisp", "skirmisher", "magic", "pierce", "blunt")]),
|
||
("Giant spiders", "Spider silk", "bow", 0, [
|
||
("Spider hunter", "skirmisher", "pierce", "slash", "pierce"),
|
||
("Venom weaver", "caster", "magic", "blunt", "magic"),
|
||
("Brood mother", "elite", "pierce", "magic", "blunt")]),
|
||
]),
|
||
("Iron Hills", 45, "Ironhill", [
|
||
("Iron golems", "Iron core", "hammer", 0, [
|
||
("Iron golem", "brute", "blunt", "blunt", "slash"),
|
||
("Forge golem", "caster", "magic", "pierce", "magic"),
|
||
("Iron colossus", "elite", "blunt", "magic", "pierce")]),
|
||
("Orcs", "Orc tusk", "sword", 2, [
|
||
("Orc berserker", "brute", "slash", "pierce", "blunt"),
|
||
("Orc archer", "skirmisher", "pierce", "slash", "pierce"),
|
||
("Orc shaman", "caster", "magic", "blunt", "magic")]),
|
||
("Mine ghosts", "Ghost essence", "staff", 1, [
|
||
("Lost miner", "brute", "blunt", "magic", "slash"),
|
||
("Lantern ghost", "caster", "magic", "blunt", "magic"),
|
||
("Shaft howler", "skirmisher", "magic", "pierce", "blunt")]),
|
||
("Wyverns", "Wyvern sinew", "bow", 0, [
|
||
("Wyvern", "brute", "slash", "pierce", "slash"),
|
||
("Wyvern hatchling", "skirmisher", "pierce", "blunt", "magic"),
|
||
("Venomspitter", "caster", "magic", "slash", "pierce")]),
|
||
]),
|
||
("Frostpeak", 60, "Frostpeak", [
|
||
("Ice elementals", "Frozen core", "hammer", 0, [
|
||
("Ice brute", "brute", "blunt", "blunt", "pierce"),
|
||
("Frost wisp", "skirmisher", "magic", "slash", "magic"),
|
||
("Glacier colossus", "elite", "blunt", "magic", "slash")]),
|
||
("Frost trolls", "Frost troll hide", "sword", 2, [
|
||
("Frost troll", "brute", "blunt", "slash", "blunt"),
|
||
("Troll icehurler", "skirmisher", "pierce", "magic", "pierce"),
|
||
("Troll rimecaller", "caster", "magic", "blunt", "magic")]),
|
||
("Banshees", "Banshee essence", "staff", 1, [
|
||
("Banshee", "caster", "magic", "magic", "slash"),
|
||
("Wailing knight", "brute", "slash", "blunt", "pierce"),
|
||
("Keening shade", "skirmisher", "magic", "pierce", "blunt")]),
|
||
("Griffins", "Griffin sinew", "bow", 0, [
|
||
("Griffin", "brute", "slash", "pierce", "blunt"),
|
||
("Griffin rider", "skirmisher", "pierce", "slash", "pierce"),
|
||
("Storm griffin", "caster", "magic", "blunt", "magic")]),
|
||
]),
|
||
("Dragon's Spine", 75, "Dragonspine", [
|
||
("Magma constructs", "Magma core", "hammer", 0, [
|
||
("Magma brute", "brute", "blunt", "blunt", "slash"),
|
||
("Cinder golem", "skirmisher", "pierce", "magic", "pierce"),
|
||
("Fire binder", "caster", "magic", "pierce", "magic")]),
|
||
("Dragonkin", "Dragonkin scale", "sword", 2, [
|
||
("Drakeguard", "brute", "slash", "blunt", "slash"),
|
||
("Drake lancer", "skirmisher", "pierce", "magic", "pierce"),
|
||
("Flame priest", "caster", "magic", "slash", "magic")]),
|
||
("Shades", "Shade essence", "staff", 1, [
|
||
("Shade", "caster", "magic", "magic", "pierce"),
|
||
("Shadow blade", "skirmisher", "slash", "blunt", "slash"),
|
||
("Void knight", "elite", "magic", "pierce", "blunt")]),
|
||
("Rocs", "Roc sinew", "bow", 0, [
|
||
("Roc", "brute", "slash", "pierce", "blunt"),
|
||
("Roc hatchling", "skirmisher", "pierce", "slash", "pierce"),
|
||
("Thunder roc", "caster", "magic", "blunt", "magic")]),
|
||
]),
|
||
]
|
||
AREA_BONES = ["bones", "big_bones", "big_bones", "dragon_bones", "dragon_bones"]
|
||
|
||
TROPHIES = {} # id: (name, weapon line, tier)
|
||
REAGENTS = {} # id: (weight, tier)
|
||
for _tier, (_area, _level, _prefix, _races) in enumerate(AREAS, start=1):
|
||
for _race, _trophy, _line, _offset, _roster in _races:
|
||
TROPHIES[_slug(_trophy)] = (_trophy, _line, _tier)
|
||
ITEMS[_slug(_trophy)] = {"name": _trophy, "icon": "🏆", "value": 15 * _tier, "trophy": _line, "tier": _tier}
|
||
for _weight, _noun in REAGENT_NOUNS.items():
|
||
_id = f"{_slug(_prefix)}_{_noun}"
|
||
REAGENTS[_id] = (_weight, _tier)
|
||
ITEMS[_id] = {"name": f"{_prefix} {_noun}", "icon": "✨", "value": 12 * _tier, "reagent": _weight, "tier": _tier}
|
||
|
||
|
||
def trophy(line: str, tier: int) -> str:
|
||
"""The trophy for a weapon line from a tier's area (tier 1 is the Old Watchtower)."""
|
||
return next(item for item, (_name, kind, t) in TROPHIES.items() if kind == line and t == tier)
|
||
|
||
|
||
def reagent(weight: str, tier: int) -> str:
|
||
"""The reagent an armour weight needs at a tier, from that tier's area."""
|
||
return next(item for item, (kind, t) in REAGENTS.items() if kind == weight and t == tier)
|
||
|
||
|
||
def _area_drops(tier: int, odds: float = REAGENT_ODDS) -> list[tuple]:
|
||
return [(reagent(kind, tier), 1, odds) for kind in REAGENT_NOUNS]
|
||
|
||
|
||
def _weapon_actions() -> list[dict]:
|
||
"""Shields (Construction), staves (Herblore) and bows (Leatherworking); trophies past the first tier."""
|
||
actions = []
|
||
for tier, (key, name, *_rest) in enumerate(METALS):
|
||
plank, leather, herb = PLANKS[tier][0], LEATHERS[tier][1][0], HERBS[tier][0]
|
||
extra = lambda line, count: {trophy(line, tier): count} if tier else {}
|
||
actions.append(_action(f"make_{key}_shield", "construction", f"{name} shield", TIER_LEVELS[tier] + 5,
|
||
40 + 2 * tier, [10, 22, 36, 52, 70, 92][tier],
|
||
{plank: 3, leather: 2, f"{key}_bar": 1, **extra("sword", 1 + tier)},
|
||
{f"{key}_shield": 1}, group="Shields"))
|
||
actions.append(_action(f"brew_{WOODS[tier]}_staff", "herblore", STAVES[tier], TIER_LEVELS[tier] + 5,
|
||
45 + 2 * tier, [12, 26, 42, 60, 80, 105][tier],
|
||
{plank: 2, herb: 3, **extra("staff", 2 + tier)}, {f"{WOODS[tier]}_staff": 1},
|
||
group="Staves"))
|
||
actions.append(_action(f"lw_{WOODS[tier]}_bow", "leatherworking", BOWS[tier], TIER_LEVELS[tier] + 4,
|
||
40 + 2 * tier, [12, 26, 42, 60, 80, 105][tier],
|
||
{plank: 2, leather: 2, **extra("bow", 2 + tier)}, {f"{WOODS[tier]}_bow": 1},
|
||
group="Bows"))
|
||
return actions
|
||
|
||
|
||
def _storage_actions() -> list[dict]:
|
||
"""Crafting stations and strongboxes (Construction), and bags (Tailoring)."""
|
||
actions = []
|
||
for tier, wood in enumerate(WOODS):
|
||
plank, (hardware, *_rest, made) = PLANKS[tier][0], HARDWARE[tier]
|
||
for skill, (suffix, _noun, _glyph, kind) in STATIONS.items():
|
||
inputs = {plank: 3, hardware: made, station_material(kind, tier): 2}
|
||
actions.append(_action(f"make_{wood}_{suffix}", "construction", ITEMS[f"{wood}_{suffix}"]["name"],
|
||
TIER_LEVELS[tier] + 4, 40 + 3 * tier, [14, 30, 48, 68, 90, 115][tier], inputs,
|
||
{f"{wood}_{suffix}": 1}, group="Stations"))
|
||
inputs = {plank: 4, hardware: 2 * made, LEATHERS[tier][1][0]: 2}
|
||
actions.append(_action(f"make_{wood}_strongbox", "construction", ITEMS[f"{wood}_strongbox"]["name"],
|
||
TIER_LEVELS[tier] + 8, 45 + 3 * tier, [18, 38, 60, 85, 112, 142][tier], inputs,
|
||
{f"{wood}_strongbox": 1}, group="Storage"))
|
||
for tier, (_fibre, _level, _time, _xp, cloth, prefix, label) in enumerate(CLOTHS):
|
||
actions.append(_action(f"sew_{prefix}_bag", "tailoring", f"{label} bag", TIER_LEVELS[tier] + 8, 45 + 3 * tier,
|
||
[18, 38, 60, 85, 112, 142][tier], {cloth[0]: 4, LEATHERS[tier][1][0]: 1},
|
||
{f"{prefix}_bag": 1}, group=label))
|
||
for action in actions:
|
||
made_item = ITEMS[next(iter(action["outputs"]))]
|
||
made_item["value"] = round(sum(ITEMS[item]["value"] * qty for item, qty in action["inputs"].items()) * 1.3)
|
||
return actions
|
||
|
||
|
||
def _jewel_actions() -> list[dict]:
|
||
"""Silver and gold, prospecting ore for gems, and every tier's jewellery."""
|
||
actions = [
|
||
_action("mine_silver", "mining", "Mine silver", 10, 35, 12, outputs={"silver_ore": 1}),
|
||
_action("mine_gold", "mining", "Mine gold", 40, 55, 36, outputs={"gold_ore": 1}),
|
||
]
|
||
for tier, (source, (gem, *_rest)) in enumerate(zip(PROSPECT, GEMS)):
|
||
actions.append(_action(f"prospect_{gem}", "jewelcrafting", f"Prospect {ITEMS[source]['name'].lower()}",
|
||
TIER_LEVELS[tier], 20 + 3 * tier, [8, 18, 28, 40, 54, 70][tier],
|
||
{source: PROSPECT_ORE}, finds=[(gem, 1, PROSPECT_ODDS)], group="Prospecting"))
|
||
actions += [
|
||
_action("jc_silver_bar", "jewelcrafting", "Silver bar", 10, 25, 12, {"silver_ore": 1, "logs": 1},
|
||
{"silver_bar": 1}, group="Bars"),
|
||
_action("jc_gold_bar", "jewelcrafting", "Gold bar", 40, 30, 32, {"gold_ore": 1, "coal": 1},
|
||
{"gold_bar": 1}, group="Bars"),
|
||
]
|
||
for tier, (gem, gem_name, *_rest) in enumerate(GEMS):
|
||
for key, slot, noun, _stat, _bonus, bands, gems, shards, offset, _glyph in JEWELLERY:
|
||
inputs = {BANDS[tier]: bands, gem: gems, **({reagent("jewel", tier): shards} if tier else {})}
|
||
actions.append(_action(f"jc_{gem}_{key}", "jewelcrafting", f"{gem_name} {noun}", TIER_LEVELS[tier] + offset,
|
||
35 + 5 * (bands + gems) + 2 * tier,
|
||
round([15, 32, 52, 75, 100, 130][tier] * (bands + gems) / 2),
|
||
inputs, {f"{gem}_{key}": 1}, group=JEWEL_GROUPS[slot]))
|
||
return actions
|
||
|
||
|
||
def _area_fights() -> list[dict]:
|
||
fights = []
|
||
for tier, (area, level, _prefix, races) in enumerate(AREAS, start=1):
|
||
for race, trophy_name, _line, offset, roster in races:
|
||
fights.append(_encounter(f"fight_{_slug(race)}", race, area, level + offset, roster, gold=level + offset,
|
||
drops=[(AREA_BONES[tier - 1], 1, 1.0), (_slug(trophy_name), 1, TROPHY_ODDS),
|
||
*_area_drops(tier)]))
|
||
return fights
|
||
|
||
|
||
def _boss_drops(tier: int, reagents: float = 0.3) -> list[tuple]:
|
||
return [*[(trophy(line, tier), 1, BOSS_TROPHY_ODDS) for line in STYLES], *_area_drops(tier, reagents)]
|
||
|
||
|
||
COMBAT_ACTIONS = [
|
||
# The Frontier: the city's own troubles.
|
||
_encounter("fight_scouts", "Bandit scouts", "Frontier", 10, BANDITS, gold=10,
|
||
drops=[("copper_bar", 1, 0.3), ("arrows", 5, 0.4), ("bones", 1, 1.0)]),
|
||
_encounter("sabotage", "Camp guards", "Frontier", 12, BANDITS, label="Sabotage the bandit camp", special="sabotage"),
|
||
_boss("fight_stronghold", "Bandit warlord", "Frontier", 40, "boss", ("slash", "magic"), ("blunt", "magic"),
|
||
("slash", "pierce"), label="Bandit stronghold", gold=60,
|
||
drops=[("steel_bar", 1, 0.4), ("coal", 3, 0.5), ("iron_shield", 1, 0.04), ("big_bones", 1, 1.0)]),
|
||
# Greenwood: where everyone starts.
|
||
_encounter("fight_wolves", "Wolves", "Greenwood", 1, WOLVES, gold=2, label="Wolves in the woods",
|
||
drops=[("raw_meat", 1, 0.6), ("hide", 1, 0.5), ("bones", 1, 1.0)]),
|
||
_encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4,
|
||
drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]),
|
||
*_area_fights(),
|
||
# Area bosses open at the area's level + 5, halfway through it. Bosses and dragons are group fights.
|
||
_boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 26, "boss", ("blunt", "magic"), ("slash", "magic"),
|
||
("blunt", "pierce"), gold=25, req=20,
|
||
drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_drops(1)]),
|
||
_boss("fight_barrow_king", "Barrow king", "Sunken Barrows", 41, "boss", ("slash", "magic"), ("blunt", "magic"),
|
||
("pierce", "slash"), gold=40, req=35,
|
||
drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_drops(2)]),
|
||
# Dragons: the prize hunt, dropping their area's reagents far more often than anything else there.
|
||
_boss("fight_green_dragon", "Green dragon", "Iron Hills", 56, "dragon", ("slash", "magic"), ("pierce", "magic"),
|
||
("slash", "blunt"), is_boss=False, gold=80, req=50,
|
||
drops=[("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(3, 0.6)]),
|
||
_boss("fight_blue_dragon", "Blue dragon", "Frostpeak", 71, "dragon", ("slash", "magic"), ("blunt", "pierce"),
|
||
("magic", "slash"), is_boss=False, gold=110, req=65,
|
||
drops=[("adamantite_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(4, 0.6)]),
|
||
_boss("fight_red_dragon", "Red dragon", "Dragon's Spine", 85, "dragon", ("slash", "magic"), ("slash", "magic"),
|
||
("pierce", "blunt"), is_boss=False, gold=150, req=80,
|
||
drops=[("runite_ore", 1, 0.25), ("dragon_bones", 1, 1.0), *_boss_drops(5, 0.6)]),
|
||
_boss("fight_black_dragon", "Black dragon", "Dragon's Spine", 95, "dragon", ("slash", "magic"), ("blunt", "magic"),
|
||
("slash", "pierce"), gold=220, req=90,
|
||
drops=[("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_drops(5, 0.8)]),
|
||
]
|
||
|
||
# --- dungeons ----------------------------------------------------------------
|
||
# A run of pulls for a group of two to four, in order, ending in the area's boss. Built for four:
|
||
# a smaller group meets lighter packs. Trash a group can clear on auto; the bosses it can't. They
|
||
# cast what has to be interrupted, hit what has to be braced for, call in help, and lose patience.
|
||
DUNGEON_SIZE = 4 # the most in a run
|
||
DUNGEON_WIPES = 3 # on one pull, and the run is over
|
||
DUNGEON_AWAY = 90 # real seconds without looking before a run waits for you
|
||
TRASH = (1.5, 1.5) # a pull's HP (and XP) and hits against the open world's
|
||
MINI_BOSS = 5.0 # a mini-boss's HP (and XP, and loot odds) against its race's elite
|
||
FINAL_BOSS = (2.5, 1.3) # the area boss's (and its adds') HP and hits against the open-world one
|
||
|
||
|
||
def dungeon_scale(size: int) -> tuple[float, float]:
|
||
"""HP and hits of a pull for a group this size."""
|
||
return 0.25 * size, 0.4 + 0.15 * size
|
||
|
||
|
||
def _dungeon_foe(action_id: str, variant: str, hp: float = 1.0, hits: float = 1.0, loot: float | None = None,
|
||
level: int | None = None, archetype: str | None = None, name: str | None = None, big: bool = False,
|
||
**extra) -> dict:
|
||
"""One of a race's variants, as a dungeon enemy: `hp` times as tough (and worth as much more XP),
|
||
hitting `hits` times as hard. It drops what its race does, `loot` (else `hp`) times as often. It can
|
||
stand at another `level`, as another `archetype` (`big` for a boss's size), under another `name`."""
|
||
action = next(a for a in COMBAT_ACTIONS if a["id"] == action_id)
|
||
foe = next(v for v in action["enemy"]["variants"] if v["name"] == variant)
|
||
if level or archetype or big:
|
||
foe = monster(name or foe["name"], archetype or foe["archetype"], level or foe["level"], foe["attack"],
|
||
foe["vulnerable"], foe["resists"], boss=big)
|
||
foe = {**foe, "name": name or foe["name"]}
|
||
return {**{key: foe[key] for key in ("name", "archetype", "level", "interval", "accuracy", "evasion",
|
||
"attack", "vulnerable", "resists")},
|
||
"boss": extra.pop("boss", False), "hp": round(foe["hp"] * hp), "max_hit": max(1, round(foe["max_hit"] * hits)),
|
||
"xp": foe["xp"] * hp, "share": hp if loot is None else loot, "loot": action_id, **extra}
|
||
|
||
|
||
DUNGEONS = {
|
||
"dungeon_watchtower": {
|
||
"name": "Watchtower Depths", "area": "Old Watchtower", "tier": 1, "level": 20, "bonus_gold": 100,
|
||
"pulls": [
|
||
("Gatehouse", [_dungeon_foe("fight_clay_golems", "Clay brute", *TRASH, 1), _dungeon_foe("fight_clay_golems", "Mud shaper", *TRASH, 1),
|
||
_dungeon_foe("fight_clay_golems", "Clay brute", *TRASH, 1)]),
|
||
("Broken stair", [_dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1), _dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1),
|
||
_dungeon_foe("fight_harpies", "Harpy windcaster", *TRASH, 1)]),
|
||
("Clay colossus", [_dungeon_foe("fight_clay_golems", "Clay colossus", MINI_BOSS, 3.0, boss=True,
|
||
abilities=["stone_curse", "shatter"], enrage_at=150)]),
|
||
("Barracks", [_dungeon_foe("fight_restless_dead", "Skeleton warrior", *TRASH, 1),
|
||
_dungeon_foe("fight_restless_dead", "Skeleton warrior", *TRASH, 1),
|
||
_dungeon_foe("fight_restless_dead", "Wisp", *TRASH, 1), _dungeon_foe("fight_restless_dead", "Necromancer", *TRASH, 1)]),
|
||
("Troll den", [_dungeon_foe("fight_cave_trolls", "Troll bruiser", *TRASH, 1), _dungeon_foe("fight_cave_trolls", "Troll rockhurler", *TRASH, 1),
|
||
_dungeon_foe("fight_cave_trolls", "Troll hexer", *TRASH, 1)]),
|
||
("Harpy matriarch", [_dungeon_foe("fight_harpies", "Harpy matriarch", MINI_BOSS, 5.5, boss=True,
|
||
abilities=["shriek", "lunge"], enrage_at=150,
|
||
phases=[{"at": 0.5, "say": "To me, sisters!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1, dormant=True),
|
||
_dungeon_foe("fight_harpies", "Harpy slasher", *TRASH, 1, dormant=True)]),
|
||
("Crypt", [_dungeon_foe("fight_restless_dead", "Necromancer", *TRASH, 1), _dungeon_foe("fight_restless_dead", "Necromancer", *TRASH, 1),
|
||
_dungeon_foe("fight_restless_dead", "Skeleton warrior", *TRASH, 1)]),
|
||
("Watchtower ogre", [_dungeon_foe("fight_ruins", "Watchtower ogre", FINAL_BOSS[0], FINAL_BOSS[1], 2,
|
||
boss=True, abilities=["stone_curse", "ground_pound", "heavy_slam"],
|
||
enrage_at=210, phases=[{"at": 0.5, "say": "Crush them!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_cave_trolls", "Troll bruiser", *FINAL_BOSS, 1, dormant=True),
|
||
_dungeon_foe("fight_cave_trolls", "Troll bruiser", *FINAL_BOSS, 1, dormant=True)]),
|
||
],
|
||
},
|
||
"dungeon_goblin_warren": {
|
||
"name": "Goblin Warren", "area": "Greenwood", "tier": 0, "level": 8, "bonus_gold": 30,
|
||
"pulls": [
|
||
("Wolf den", [_dungeon_foe("fight_wolves", v, *TRASH, 1, level=6) for v in ("Grey wolf", "Timber wolf", "Grey wolf")]),
|
||
("Tunnel mouth", [_dungeon_foe("fight_goblins", v, *TRASH, 1, level=6)
|
||
for v in ("Goblin brute", "Goblin rogue", "Goblin warlock")]),
|
||
("Old One-Eye", [_dungeon_foe("fight_wolves", "Wolf alpha", MINI_BOSS, 3.0, level=7, name="Old One-Eye", boss=True,
|
||
abilities=["crushing_decree", "lunge"], enrage_at=150,
|
||
phases=[{"at": 0.5, "say": "Aaaooo!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_wolves", "Grey wolf", *TRASH, 1, level=6, dormant=True),
|
||
_dungeon_foe("fight_wolves", "Grey wolf", *TRASH, 1, level=6, dormant=True)]),
|
||
("Fungus cave", [_dungeon_foe("fight_goblins", v, *TRASH, 1, level=6)
|
||
for v in ("Goblin rogue", "Goblin rogue", "Goblin warlock")]),
|
||
("Grizzik the Hexer", [_dungeon_foe("fight_goblins", "Goblin warlock", MINI_BOSS, 4.0, level=7, archetype="elite",
|
||
name="Grizzik the Hexer", boss=True, abilities=["hex_storm", "hex_bolt"],
|
||
enrage_at=150)]),
|
||
("Throne cave", [_dungeon_foe("fight_goblins", v, *TRASH, 1, level=6)
|
||
for v in ("Goblin brute", "Goblin brute", "Goblin warlock")]),
|
||
("Goblin king", [_dungeon_foe("fight_goblins", "Goblin brute", *FINAL_BOSS, 2, level=9, archetype="boss",
|
||
name="Goblin king", big=True, boss=True,
|
||
abilities=["crushing_decree", "royal_stomp", "heavy_slam"], enrage_at=210,
|
||
phases=[{"at": 0.5, "say": "Guards! Guards!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_goblins", "Goblin brute", *FINAL_BOSS, 1, level=6, dormant=True),
|
||
_dungeon_foe("fight_goblins", "Goblin brute", *FINAL_BOSS, 1, level=6, dormant=True)]),
|
||
],
|
||
},
|
||
"dungeon_bandit_stronghold": {
|
||
"name": "Bandit Stronghold", "area": "Frontier", "tier": 2, "level": 40, "bonus_gold": 300,
|
||
"pulls": [
|
||
("Palisade", [_dungeon_foe("fight_scouts", v, *TRASH, 1, level=35)
|
||
for v in ("Bandit brute", "Bandit archer", "Bandit archer")]),
|
||
("Gate", [_dungeon_foe("fight_scouts", v, *TRASH, 1, level=35) for v in ("Bandit brute", "Bandit brute", "Hedge mage")]),
|
||
("Bandit captain", [_dungeon_foe("fight_scouts", "Bandit brute", MINI_BOSS, 3.0, level=37, archetype="elite",
|
||
name="Bandit captain", boss=True, abilities=["cheap_shot", "heavy_slam"],
|
||
enrage_at=150)]),
|
||
("Barracks", [_dungeon_foe("fight_scouts", v, *TRASH, 1, level=35)
|
||
for v in ("Bandit brute", "Bandit archer", "Bandit archer", "Hedge mage")]),
|
||
("Hedge witch", [_dungeon_foe("fight_scouts", "Hedge mage", MINI_BOSS, 5.5, level=37, archetype="elite",
|
||
name="Hedge witch", boss=True, abilities=["witchfire", "hex_bolt"], enrage_at=150,
|
||
phases=[{"at": 0.5, "say": "Cut them down!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_scouts", "Bandit archer", *TRASH, 1, level=35, dormant=True),
|
||
_dungeon_foe("fight_scouts", "Bandit archer", *TRASH, 1, level=35, dormant=True)]),
|
||
("Armoury", [_dungeon_foe("fight_scouts", v, *TRASH, 1, level=35) for v in ("Bandit brute", "Hedge mage", "Hedge mage")]),
|
||
("Bandit warlord", [_dungeon_foe("fight_stronghold", "Bandit warlord", *FINAL_BOSS, 2, boss=True,
|
||
abilities=["execute", "whirlwind", "heavy_slam"], enrage_at=210,
|
||
phases=[{"at": 0.5, "say": "To arms!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_scouts", "Bandit brute", *FINAL_BOSS, 1, level=35, dormant=True),
|
||
_dungeon_foe("fight_scouts", "Bandit brute", *FINAL_BOSS, 1, level=35, dormant=True)]),
|
||
],
|
||
},
|
||
"dungeon_barrow_crypts": {
|
||
"name": "Barrow Crypts", "area": "Sunken Barrows", "tier": 2, "level": 35, "bonus_gold": 250,
|
||
"pulls": [
|
||
("Barrow mound", [_dungeon_foe("fight_stone_guardians", v, *TRASH, 1)
|
||
for v in ("Stone guardian", "Barrow sentry", "Barrow sentry")]),
|
||
("Flooded hall", [_dungeon_foe("fight_bog_wraiths", v, *TRASH, 1)
|
||
for v in ("Drowned knight", "Bog wraith", "Marsh wisp")]),
|
||
("Runebound guardian", [_dungeon_foe("fight_stone_guardians", "Runebound guardian", MINI_BOSS, 3.0, boss=True,
|
||
abilities=["rune_blast", "shatter"], enrage_at=150)]),
|
||
("Lizard warren", [_dungeon_foe("fight_lizardfolk", v, *TRASH, 1)
|
||
for v in ("Lizard warrior", "Lizard spearman", "Lizard spearman", "Lizard shaman")]),
|
||
("Brood mother", [_dungeon_foe("fight_giant_spiders", "Brood mother", MINI_BOSS, 5.5, boss=True,
|
||
abilities=["venom_spray", "lunge"], enrage_at=150,
|
||
phases=[{"at": 0.5, "say": "The eggs are hatching!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_giant_spiders", "Spider hunter", *TRASH, 1, dormant=True),
|
||
_dungeon_foe("fight_giant_spiders", "Spider hunter", *TRASH, 1, dormant=True)]),
|
||
("Bone gallery", [_dungeon_foe("fight_bog_wraiths", v, *TRASH, 1) for v in ("Bog wraith", "Bog wraith", "Drowned knight")]),
|
||
("Barrow king", [_dungeon_foe("fight_barrow_king", "Barrow king", *FINAL_BOSS, 2, boss=True,
|
||
abilities=["grave_chill", "soul_rend", "heavy_slam"], enrage_at=210,
|
||
phases=[{"at": 0.5, "say": "Rise, my knights!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_bog_wraiths", "Drowned knight", *FINAL_BOSS, 1, dormant=True),
|
||
_dungeon_foe("fight_bog_wraiths", "Drowned knight", *FINAL_BOSS, 1, dormant=True)]),
|
||
],
|
||
},
|
||
"dungeon_deep_forge": {
|
||
"name": "The Deep Forge", "area": "Iron Hills", "tier": 3, "level": 50, "bonus_gold": 400,
|
||
"pulls": [
|
||
("Mine shaft", [_dungeon_foe("fight_mine_ghosts", v, *TRASH, 1) for v in ("Lost miner", "Lantern ghost", "Shaft howler")]),
|
||
("Orc camp", [_dungeon_foe("fight_orcs", v, *TRASH, 1) for v in ("Orc berserker", "Orc archer", "Orc shaman")]),
|
||
("Iron colossus", [_dungeon_foe("fight_iron_golems", "Iron colossus", MINI_BOSS, 3.0, boss=True,
|
||
abilities=["forge_blast", "shatter"], enrage_at=150)]),
|
||
("Foundry", [_dungeon_foe("fight_iron_golems", v, *TRASH, 1) for v in ("Iron golem", "Forge golem", "Forge golem")]),
|
||
("Orc warchief", [_dungeon_foe("fight_orcs", "Orc berserker", MINI_BOSS, 5.5, archetype="elite", name="Orc warchief",
|
||
boss=True, abilities=["war_cry", "lunge"], enrage_at=150,
|
||
phases=[{"at": 0.5, "say": "Blood and iron!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_orcs", "Orc berserker", *TRASH, 1, dormant=True),
|
||
_dungeon_foe("fight_orcs", "Orc berserker", *TRASH, 1, dormant=True)]),
|
||
("Wyvern roost", [_dungeon_foe("fight_wyverns", v, *TRASH, 1) for v in ("Wyvern", "Wyvern hatchling", "Venomspitter")]),
|
||
("Green dragon", [_dungeon_foe("fight_green_dragon", "Green dragon", *FINAL_BOSS, 2, boss=True,
|
||
abilities=["acid_spit", "acid_breath", "tail_swipe"], enrage_at=210,
|
||
phases=[{"at": 0.5, "say": "The brood answers!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_wyverns", "Wyvern", *FINAL_BOSS, 1, dormant=True),
|
||
_dungeon_foe("fight_wyverns", "Wyvern", *FINAL_BOSS, 1, dormant=True)]),
|
||
],
|
||
},
|
||
"dungeon_frozen_hold": {
|
||
"name": "Frozen Hold", "area": "Frostpeak", "tier": 4, "level": 65, "bonus_gold": 550,
|
||
"pulls": [
|
||
("Ice gate", [_dungeon_foe("fight_ice_elementals", v, *TRASH, 1) for v in ("Ice brute", "Frost wisp", "Frost wisp")]),
|
||
("Troll cave", [_dungeon_foe("fight_frost_trolls", v, *TRASH, 1)
|
||
for v in ("Frost troll", "Troll icehurler", "Troll rimecaller")]),
|
||
("Glacier colossus", [_dungeon_foe("fight_ice_elementals", "Glacier colossus", MINI_BOSS, 3.0, boss=True,
|
||
abilities=["deep_freeze", "shatter"], enrage_at=150)]),
|
||
("Griffin eyrie", [_dungeon_foe("fight_griffins", v, *TRASH, 1) for v in ("Griffin", "Griffin rider", "Storm griffin")]),
|
||
("Banshee queen", [_dungeon_foe("fight_banshees", "Banshee", MINI_BOSS, 5.5, archetype="elite", name="Banshee queen",
|
||
boss=True, abilities=["wail", "lunge"], enrage_at=150,
|
||
phases=[{"at": 0.5, "say": "Sing with me!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_banshees", "Keening shade", *TRASH, 1, dormant=True),
|
||
_dungeon_foe("fight_banshees", "Keening shade", *TRASH, 1, dormant=True)]),
|
||
("Hall of echoes", [_dungeon_foe("fight_banshees", v, *TRASH, 1) for v in ("Wailing knight", "Banshee", "Banshee")]),
|
||
("Blue dragon", [_dungeon_foe("fight_blue_dragon", "Blue dragon", *FINAL_BOSS, 2, boss=True,
|
||
abilities=["frost_lance", "frost_breath", "tail_swipe"], enrage_at=210,
|
||
phases=[{"at": 0.5, "say": "Freeze!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_ice_elementals", "Ice brute", *FINAL_BOSS, 1, dormant=True),
|
||
_dungeon_foe("fight_ice_elementals", "Ice brute", *FINAL_BOSS, 1, dormant=True)]),
|
||
],
|
||
},
|
||
"dungeon_dragons_lair": {
|
||
"name": "Dragon's Lair", "area": "Dragon's Spine", "tier": 5, "level": 80, "bonus_gold": 750,
|
||
"pulls": [
|
||
("Lava tubes", [_dungeon_foe("fight_magma_constructs", v, *TRASH, 1)
|
||
for v in ("Magma brute", "Cinder golem", "Fire binder")]),
|
||
("Shadow pass", [_dungeon_foe("fight_shades", v, *TRASH, 1) for v in ("Shade", "Shadow blade", "Shadow blade")]),
|
||
("Void knight", [_dungeon_foe("fight_shades", "Void knight", MINI_BOSS, 3.0, boss=True,
|
||
abilities=["void_bolt", "shatter"], enrage_at=150)]),
|
||
("Roc nests", [_dungeon_foe("fight_rocs", v, *TRASH, 1) for v in ("Roc", "Roc hatchling", "Thunder roc")]),
|
||
("Flame high priest", [_dungeon_foe("fight_dragonkin", "Flame priest", MINI_BOSS, 5.5, archetype="elite",
|
||
name="Flame high priest", boss=True, abilities=["flame_wave", "hex_bolt"],
|
||
enrage_at=150, phases=[{"at": 0.5, "say": "Burn!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_dragonkin", "Flame priest", *TRASH, 1, dormant=True),
|
||
_dungeon_foe("fight_dragonkin", "Flame priest", *TRASH, 1, dormant=True)]),
|
||
("Drakeguard hall", [_dungeon_foe("fight_dragonkin", v, *TRASH, 1)
|
||
for v in ("Drakeguard", "Drake lancer", "Flame priest")]),
|
||
("Red dragon", [_dungeon_foe("fight_red_dragon", "Red dragon", *FINAL_BOSS, 2, boss=True,
|
||
abilities=["inferno", "fire_breath", "tail_swipe"], enrage_at=210,
|
||
phases=[{"at": 0.5, "say": "Guards!", "wake": ["e1", "e2"]}]),
|
||
_dungeon_foe("fight_dragonkin", "Drakeguard", *FINAL_BOSS, 1, dormant=True),
|
||
_dungeon_foe("fight_dragonkin", "Drakeguard", *FINAL_BOSS, 1, dormant=True)]),
|
||
],
|
||
},
|
||
}
|
||
|
||
# Each boss pull's last touch, HP and hits, so that a group on auto loses it and one that plays
|
||
# wins it (tools/balance.py dungeon, and dungeon perfect).
|
||
DUNGEON_TUNING = {
|
||
("dungeon_goblin_warren", "Old One-Eye"): (0.8, 0.8), ("dungeon_goblin_warren", "Grizzik the Hexer"): (1.5, 0.5),
|
||
("dungeon_goblin_warren", "Goblin king"): (0.8, 1.2),
|
||
("dungeon_bandit_stronghold", "Bandit captain"): (1.0, 1.2), ("dungeon_bandit_stronghold", "Hedge witch"): (1.25, 0.65),
|
||
("dungeon_bandit_stronghold", "Bandit warlord"): (1.5, 1.4),
|
||
("dungeon_barrow_crypts", "Runebound guardian"): (0.8, 1.2), ("dungeon_barrow_crypts", "Brood mother"): (1.0, 1.2),
|
||
("dungeon_barrow_crypts", "Barrow king"): (1.5, 1.0),
|
||
("dungeon_deep_forge", "Orc warchief"): (1.0, 1.3), ("dungeon_deep_forge", "Green dragon"): (1.25, 1.2),
|
||
("dungeon_frozen_hold", "Glacier colossus"): (1.0, 1.2), ("dungeon_frozen_hold", "Banshee queen"): (1.25, 1.0),
|
||
("dungeon_frozen_hold", "Blue dragon"): (1.5, 1.2),
|
||
("dungeon_dragons_lair", "Void knight"): (1.5, 1.0), ("dungeon_dragons_lair", "Flame high priest"): (0.8, 1.2),
|
||
("dungeon_dragons_lair", "Red dragon"): (1.5, 1.4),
|
||
}
|
||
|
||
|
||
def _tune_dungeons() -> None:
|
||
for key, spec in DUNGEONS.items():
|
||
for index, (name, pack) in enumerate(spec["pulls"]):
|
||
hp, hits = DUNGEON_TUNING.get((key, name), (1, 1))
|
||
spec["pulls"][index] = (name, [{**foe, "hp": round(foe["hp"] * hp), "max_hit": max(1, round(foe["max_hit"] * hits))}
|
||
for foe in pack])
|
||
|
||
|
||
_tune_dungeons()
|
||
|
||
|
||
def _dungeon_actions() -> list[dict]:
|
||
actions = []
|
||
for key, spec in DUNGEONS.items():
|
||
bosses = [foe for _name, pack in spec["pulls"] for foe in pack if foe["boss"]]
|
||
variants = [{**foe, "weight": 1} for foe in bosses]
|
||
actions.append(_action(key, "combat", spec["name"], spec["level"], RESPAWN,
|
||
round(sum(foe["xp"] for _name, pack in spec["pulls"] for foe in pack)),
|
||
enemy={"name": spec["name"], "level": spec["level"], "boss": True, "variants": variants},
|
||
group=spec["area"], dungeon=key, together=GROUP_SIZE, personal_loot=True,
|
||
gold=[spec["bonus_gold"], spec["bonus_gold"]],
|
||
drops=[list(d) for d in _boss_drops(spec["tier"])] if spec["tier"] else bosses[-1].get("drops", []),
|
||
pulls=[name for name, _pack in spec["pulls"]]))
|
||
return actions
|
||
|
||
|
||
COMBAT_ACTIONS += _dungeon_actions()
|
||
|
||
ACTIONS = [
|
||
*COMBAT_ACTIONS,
|
||
_action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}),
|
||
_action("farm_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}, group="Crops"),
|
||
_action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}, group="Crops"),
|
||
*[_action(f"grow_{herb}", "farming", f"Grow {name.lower()}", level, seconds, xp, outputs={herb: 1}, group="Herbs")
|
||
for herb, name, _icon, level, seconds, xp, _value in HERBS],
|
||
*[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6),
|
||
_fuelled({herb: 1, second: 1}, POTION_TIER[potion]), {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}",
|
||
fire="still", fuel=POTION_TIER[potion])
|
||
for potion, name, level, herb, second, skill, _effect in POTIONS],
|
||
*[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6),
|
||
_fuelled({herb: 1, fish: 1}, tier), {potion: 1}, group=f"Tier {ROMAN[tier]}", fire="still", fuel=tier)
|
||
for tier, (potion, name, level, herb, fish, _heal) in enumerate(HEALTH_POTIONS)],
|
||
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}),
|
||
_action("hunt_deer", "hunting", "Hunt deer", 1, 25, 6, outputs={"raw_meat": 2, "hide": 1}),
|
||
_action("hunt_boar", "hunting", "Hunt boar", 15, 35, 15, outputs={"raw_meat": 2, "boar_hide": 1}),
|
||
_action("hunt_bear", "hunting", "Hunt bears", 30, 45, 28, outputs={"raw_meat": 3, "bear_pelt": 1}),
|
||
_action("hunt_elk", "hunting", "Hunt elk", 45, 55, 45, outputs={"raw_meat": 3, "elk_hide": 1}),
|
||
_action("hunt_sabrecat", "hunting", "Track sabrecats", 60, 65, 62, outputs={"raw_meat": 3, "sabrecat_pelt": 1}),
|
||
_action("hunt_mammoth", "hunting", "Hunt mammoths", 75, 80, 85, outputs={"raw_meat": 5, "mammoth_hide": 1}),
|
||
*_tiered_actions(),
|
||
_action("smith_arrows", "smithing", "Arrows", 5, 30, 10,
|
||
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Hardware"),
|
||
_action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40,
|
||
{"leather": 4, "iron_bar": 1}, {"studded_body": 1}, group="Leather"),
|
||
_action("labour", "construction", "Work on the building site", 1, 30, 10, special="labour"),
|
||
_action("twist_rope", "construction", "Twist rope", 5, 30, 10, {"flax": 3}, {"rope": 1}, group="Supplies"),
|
||
_action("grow_thatch", "farming", "Cut thatch reeds", 3, 30, 7, outputs={"thatch": 3}, group="Materials"),
|
||
_action("grow_flax", "farming", "Grow flax", 1, 35, 11, outputs={"flax": 3}, group="Fibres"),
|
||
_action("boil_glue", "leatherworking", "Boil hide glue", 5, 30, 10, {"hide": 1, "logs": 1}, {"hide_glue": 2},
|
||
group="Tanning", fire="kitchen", fuel=0),
|
||
*[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier,
|
||
[6, 14, 22, 32, 44, 58][tier], {TREES[tier][0]: 2}, {plank: 1}, group="Planks")
|
||
for tier, (plank, name) in enumerate(PLANKS)],
|
||
_action("mason_bricks", "construction", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}, group="Masonry",
|
||
fire="kiln", fuel=0),
|
||
_action("mason_cut_stone", "construction", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}, group="Masonry"),
|
||
_action("mason_beam", "construction", "Reinforced beam", 20, 45, 30,
|
||
{"oak_logs": 2, "iron_nails": 5}, {"beam": 1}, group="Masonry"),
|
||
_action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}, group="Meals",
|
||
fire="kitchen", fuel=0),
|
||
_action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}, group="Meals",
|
||
fire="kitchen", fuel=0),
|
||
_action("cook_stew", "cooking", "Stew", 15, 35, 16,
|
||
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}, group="Meals", fire="kitchen", fuel=0),
|
||
_action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}, group="Rations"),
|
||
_action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}, group="Rations"),
|
||
_action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}, group="Rations"),
|
||
_action("drill_militia", "combat", "Drill the militia", 1, 60, 12, special="drill", group="Frontier"),
|
||
_action("bury_bones", "prayer", "Bury bones", 1, 10, 4, {"bones": 1}, special="bury", pp=2, group="Bury"),
|
||
_action("bury_big_bones", "prayer", "Bury big bones", 15, 10, 15, {"big_bones": 1}, special="bury", pp=6, group="Bury"),
|
||
_action("bury_dragon_bones", "prayer", "Bury dragon bones", 40, 12, 72, {"dragon_bones": 1}, special="bury", pp=20, group="Bury"),
|
||
_action("offer_bones", "prayer", "Offer bones at the temple", 1, 12, 7, {"bones": 1}, special="offer",
|
||
devotion=2, city={"temple": 1}, group="Offer at the temple"),
|
||
_action("offer_big_bones", "prayer", "Offer big bones", 15, 12, 25, {"big_bones": 1}, special="offer",
|
||
devotion=6, city={"temple": 1}, group="Offer at the temple"),
|
||
_action("offer_dragon_bones", "prayer", "Offer dragon bones", 40, 14, 110, {"dragon_bones": 1}, special="offer",
|
||
devotion=20, city={"temple": 2}, group="Offer at the temple"),
|
||
_action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1),
|
||
_action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2),
|
||
# Tending: one log a go into one fire; better logs need more Firekeeping.
|
||
*[_action(f"tend_{fire}_{log}", "firekeeping", f"Tend the {spec['name'].lower()}", TIER_LEVELS[tier], 30, 0,
|
||
{log: 1}, special="tend", tend=fire, tier=tier, group=spec["name"])
|
||
for fire, spec in FIRES.items() for tier, log in enumerate(FIREWOOD)],
|
||
]
|
||
|
||
# --- dungeon loot --------------------------------------------------------------
|
||
# Dungeons don't hand out finished gear: crafters still make it. Every boss in a dungeon drops its
|
||
# relic, and now and then a pattern: a crafter who learns it can reforge one of the tier's weapons
|
||
# or body pieces into a masterwork with relics. The final boss also has one unique, worn in a
|
||
# jewellery slot, that changes an ability.
|
||
MASTERWORK = 1.3 # a masterwork's power or armour and ward against the piece it's reforged from
|
||
PATTERN_ODDS = (0.15, 0.35) # each player's chance at one of the dungeon's patterns, from a mini-boss and the final boss
|
||
RELIC_ODDS = (0.5, 1.0) # ...and at its relic (two from the final boss)
|
||
UNIQUE_ODDS = 0.08 # each player's chance at the final boss's unique
|
||
MASTERWORK_RELICS = 3
|
||
|
||
# Per dungeon: what its masterworks are called, its relic's name, and its unique.
|
||
DUNGEON_LOOT = {
|
||
"dungeon_goblin_warren": ("Kingsguard", "Warren relic", "one_eyes_fang"),
|
||
"dungeon_watchtower": ("Ogre-forged", "Watchtower relic", "ogres_knuckle"),
|
||
"dungeon_barrow_crypts": ("Barrow-blessed", "Barrow relic", "barrow_kings_pendant"),
|
||
"dungeon_bandit_stronghold": ("Warlord's", "Stronghold relic", "warlords_whistle"),
|
||
"dungeon_deep_forge": ("Deepforged", "Deep Forge relic", "forgeheart"),
|
||
"dungeon_frozen_hold": ("Rimeforged", "Frozen Hold relic", "rimeglass_lens"),
|
||
"dungeon_dragons_lair": ("Dragonforged", "Dragon's Lair relic", "wyrmscale_quiver"),
|
||
}
|
||
# Uniques: (name, slot, glyph, tint, what it does, the ability changes).
|
||
UNIQUES = {
|
||
"one_eyes_fang": ("One-Eye's fang", "amulet", "fangs", "#e0e0d0", "Shield Bash also taunts for 3s.",
|
||
{"shield_bash": {"extra": [{"t": "taunt", "dur": 3}]}}),
|
||
"ogres_knuckle": ("Ogre's knuckle", "ring", "fist", "#d1a46a", "Crushing Blow winds up in 1s and stuns for 1s.",
|
||
{"crushing_blow": {"cast": 2 / 3, "extra": [{"t": "stun", "dur": 1}]}}),
|
||
"barrow_kings_pendant": ("Barrow king's pendant", "amulet", "gem-pendant", "#8fb8f0",
|
||
"Mend also heals the next most hurt ally for half.",
|
||
{"mend": {"extra": [{"t": "heal", "c": 1.0, "second": True}]}}),
|
||
"warlords_whistle": ("Warlord's whistle", "trinket", "whistle", "#a08060",
|
||
"Shield Bash, Concuss and Pinning Shot come back 30% sooner.",
|
||
{"shield_bash": {"cd": 0.7}, "concuss": {"cd": 0.7}, "pinning_shot": {"cd": 0.7}}),
|
||
"forgeheart": ("Forgeheart", "amulet", "heart-tower", "#ff8a3a", "Barricade lasts 12s and comes back 25% sooner.",
|
||
{"barricade": {"dur": 4, "cd": 0.75}}),
|
||
"rimeglass_lens": ("Rimeglass lens", "trinket", "crystal-ball", "#8fd0e0",
|
||
"Find the Seam and Mark Prey last twice as long; Ward also shields a second ally.",
|
||
{"find_the_seam": {"dur": 8}, "mark_prey": {"dur": 15},
|
||
"ward": {"extra": [{"t": "shield", "c": 1.0, "dur": 20, "second": True}]}}),
|
||
"wyrmscale_quiver": ("Wyrmscale quiver", "trinket", "quiver", "#e05a5a",
|
||
"Volley hits 40% harder; Aimed Shot aims 25% faster.",
|
||
{"volley": {"c": 1.4}, "aimed_shot": {"cast": 0.75}}),
|
||
}
|
||
# The pieces every dungeon's patterns reforge, by what they're made of at each tier.
|
||
MASTERWORK_PIECES = ["sword", "warhammer", "staff", "bow", "heavy", "medium", "light"]
|
||
MASTERWORK_NOUNS = {"sword": "sword", "warhammer": "warhammer", "staff": "staff", "bow": "bow",
|
||
"heavy": "plate", "medium": "jerkin", "light": "robe"}
|
||
MASTERWORK_TINT = "#f0c040"
|
||
|
||
|
||
def _tier_piece(kind: str, tier: int) -> str:
|
||
"""The tier's weapon of a style, or its body piece of an armour weight."""
|
||
level = ["combat", TIER_LEVELS[tier]]
|
||
for item, spec in ITEMS.items():
|
||
if spec.get("req") != level:
|
||
continue
|
||
if kind in ("heavy", "medium", "light") and spec.get("weight") == kind and spec.get("slot") == "body":
|
||
return item
|
||
if spec.get("style") == {"warhammer": "hammer"}.get(kind, kind):
|
||
return item
|
||
raise KeyError((kind, tier))
|
||
|
||
|
||
def _dungeon_loot() -> None:
|
||
"""Relics, patterns, masterworks and uniques, as items; the masterwork recipes, as actions; and each
|
||
dungeon boss's share of them, as its extra drops."""
|
||
made = {output: action for action in ACTIONS for output in action["outputs"]}
|
||
for key, (prefix, relic_name, unique) in DUNGEON_LOOT.items():
|
||
spec = DUNGEONS[key]
|
||
tier = spec["tier"]
|
||
relic = _slug(relic_name)
|
||
ITEMS[relic] = {"name": relic_name, "icon": "🏺", "value": 40 + 40 * tier, "relic": key}
|
||
patterns = []
|
||
for kind in MASTERWORK_PIECES:
|
||
base = _tier_piece(kind, tier)
|
||
item, recipe = f"{_slug(prefix)}_{kind}", made[base]
|
||
pattern = f"pattern_{item}"
|
||
name = f"{prefix} {MASTERWORK_NOUNS[kind]}"
|
||
stats = {stat: max(ITEMS[base][stat] + 1, round(ITEMS[base][stat] * MASTERWORK))
|
||
for stat in ("power", "armour", "ward") if ITEMS[base].get(stat)}
|
||
ITEMS[item] = {**{k: v for k, v in ITEMS[base].items() if k not in ("glyph", "tint", "plural")},
|
||
**stats, "name": name, "value": ITEMS[base]["value"] * 3 + ITEMS[relic]["value"] * MASTERWORK_RELICS,
|
||
"req": ["combat", spec["level"]], "masterwork": key, "base": base}
|
||
ITEMS[pattern] = {"name": f"Pattern: {name}", "icon": "📜", "value": 100 + 50 * tier, "pattern": item,
|
||
"skill": recipe["skill"], "level": recipe["level"] + 8}
|
||
reforge = _action(f"reforge_{item}", recipe["skill"], name, recipe["level"] + 8, round(recipe["time"] * 1.5),
|
||
recipe["xp"] * 3, {base: 1, relic: MASTERWORK_RELICS}, {item: 1}, group="Masterwork",
|
||
pattern=pattern)
|
||
ACTIONS.append(reforge)
|
||
ACTION_BY_ID[reforge["id"]] = reforge
|
||
patterns.append(pattern)
|
||
name, slot, _glyph, _tint, text, mods = UNIQUES[unique]
|
||
ITEMS[unique] = {"name": name, "icon": "💠", "value": 500 + 250 * tier, "slot": slot, "unique": text, "mods": mods,
|
||
"req": ["combat", spec["level"]]}
|
||
ACTION_BY_ID[key]["loot"] = {"relic": relic, "patterns": patterns, "unique": unique,
|
||
"odds": {"pattern": PATTERN_ODDS, "unique": UNIQUE_ODDS}}
|
||
bosses = [foe for _name, pack in spec["pulls"] for foe in pack if foe["boss"]]
|
||
for foe in bosses:
|
||
final = foe is bosses[-1]
|
||
odds = PATTERN_ODDS[final] / len(patterns)
|
||
foe["extra_drops"] = [(relic, 1 + final, RELIC_ODDS[final]), *[(p, 1, odds) for p in patterns],
|
||
*([(unique, 1, UNIQUE_ODDS)] if final else [])]
|
||
|
||
ACTION_BY_ID = {action["id"]: action for action in ACTIONS}
|
||
for _id, _gem in GEM_ROCKS.items():
|
||
ACTION_BY_ID[_id]["finds"] = [(_gem, 1, GEM_ODDS)]
|
||
|
||
BUILDINGS = {
|
||
"houses": {
|
||
"name": "Houses", "icon": "🏠", "max": 8, "gold": 200,
|
||
"base": {"logs": 300, "bricks": 150, "thatch": 300, "provisions": 80, "copper_rivets": 60},
|
||
"advanced": {"oak_logs": 100, "leather": 80, "rope": 40, "iron_nails": 150},
|
||
"late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 50, "steel_hinges": 20},
|
||
"final": {"maple_logs": 50, "willow_plank": 30, "hard_leather": 60, "mithril_brackets": 15},
|
||
"effect": "+20 homes per level",
|
||
},
|
||
"walls": {
|
||
"name": "Walls", "icon": "🏰", "max": 5, "gold": 300,
|
||
"base": {"stone": 1000, "bricks": 250, "provisions": 90},
|
||
"advanced": {"cut_stone": 150, "beam": 20, "rope": 50, "iron_nails": 200},
|
||
"late": {"steel_bar": 60, "oak_plank": 25, "hide_glue": 50, "steel_hinges": 30},
|
||
"final": {"mithril_brackets": 60, "maple_plank": 20, "hide_glue": 60, "adamant_chain": 10},
|
||
"effect": "+20 defence per level, less plunder lost",
|
||
},
|
||
"barracks": {
|
||
"name": "Barracks", "icon": "⛺", "max": 5, "gold": 300,
|
||
"base": {"logs": 300, "bricks": 200, "thatch": 200, "leather": 80, "copper_sword": 25, "provisions": 80, "copper_rivets": 60},
|
||
"advanced": {"iron_bar": 60, "hard_leather": 40},
|
||
"late": {"steel_bar": 40, "oak_plank": 30, "hide_glue": 40},
|
||
"final": {"mithril_bar": 25, "willow_plank": 25, "bear_leather": 40},
|
||
"effect": "More defence per townsperson",
|
||
},
|
||
"smithy": {
|
||
"name": "Smithy", "icon": "⚒️", "max": 5, "gold": 250,
|
||
"base": {"stone": 400, "logs": 250, "copper_bar": 180, "provisions": 60},
|
||
"advanced": {"iron_bar": 180, "leather": 70},
|
||
"late": {"steel_bar": 100, "oak_plank": 25, "hide_glue": 40},
|
||
"final": {"mithril_bar": 70, "maple_plank": 20, "bear_leather": 35},
|
||
"effect": "Smithing 8% faster and one more metal per level",
|
||
},
|
||
"temple": {
|
||
"name": "Temple", "icon": "⛪", "max": 3, "gold": 300,
|
||
"base": {"stone": 700, "bricks": 200, "cut_stone": 60, "provisions": 60},
|
||
"advanced": {"beam": 15, "rope": 40, "iron_nails": 150},
|
||
"late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 40, "steel_hinges": 20},
|
||
"effect": "Per level: +1 prayer point per bone, +25% devotion",
|
||
},
|
||
"granary": {
|
||
"name": "Granary", "icon": "🌾", "max": 3, "gold": 150,
|
||
"base": {"logs": 200, "bricks": 100, "plank": 40, "thatch": 300, "provisions": 50, "copper_rivets": 40},
|
||
"advanced": {"cut_stone": 40, "rope": 40, "iron_nails": 100},
|
||
"late": {"oak_plank": 35, "hide_glue": 35, "steel_hinges": 15},
|
||
"effect": "Guards 25% of food from pests per level",
|
||
},
|
||
"lumber_shed": {
|
||
"name": "Lumber shed", "icon": "🪵", "max": 3, "gold": 120,
|
||
"base": {"logs": 200, "stone": 250, "plank": 50, "thatch": 180, "provisions": 40, "copper_rivets": 40},
|
||
"advanced": {"oak_plank": 40, "rope": 40, "iron_nails": 100},
|
||
"late": {"willow_plank": 25, "hide_glue": 35},
|
||
"effect": "Guards 25% of wood from rot per level",
|
||
},
|
||
"well": {
|
||
"name": "Well", "icon": "🪣", "max": 3, "gold": 120,
|
||
"base": {"stone": 500, "bricks": 80, "rope": 50, "provisions": 40},
|
||
"advanced": {"cut_stone": 40, "leather": 60, "iron_nails": 125},
|
||
"late": {"oak_plank": 25, "hide_glue": 35, "steel_hinges": 15},
|
||
"effect": "Fire damage -25% per level",
|
||
},
|
||
"spymaster": {
|
||
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
|
||
"base": {"logs": 200, "bricks": 150, "hide": 120, "feathers": 300, "provisions": 60},
|
||
"advanced": {"cut_stone": 80, "hard_leather": 40, "iron_nails": 100},
|
||
"late": {"bear_leather": 50, "oak_plank": 25, "steel_hinges": 15},
|
||
"effect": "+25% recon per level, plus some each hour",
|
||
},
|
||
"storehouse": {
|
||
"name": "Storehouse", "icon": "📦", "max": 5, "gold": 200,
|
||
"base": {"logs": 400, "stone": 800, "thatch": 200, "provisions": 60, "copper_rivets": 60},
|
||
"advanced": {"oak_logs": 150, "cut_stone": 100, "rope": 50, "iron_nails": 150},
|
||
"late": {"willow_logs": 120, "oak_plank": 45, "hide_glue": 45, "steel_hinges": 25},
|
||
"final": {"yew_logs": 40, "yew_plank": 20, "bear_leather": 40, "rune_lock": 6},
|
||
"effect": "Per level: +3 bank slots, +250 stack size",
|
||
},
|
||
}
|
||
# Buildings rise by labour: players working the site, and a slow trickle from
|
||
# the townsfolk so nothing stalls completely while everyone is away.
|
||
LABOUR_PER_LEVEL = 360 # a level-1 building: about three hours of one player's work
|
||
TOWNSFOLK_LABOUR = 0.5 # per person per game hour
|
||
REPAIR_SHARE = 0.3
|
||
|
||
# Population promotes the settlement, permanently. Each tier lifts how high
|
||
# buildings may go (houses have their own ladder so the next tier is always
|
||
# reachable) and gives everyone more bank slots.
|
||
TIERS = [
|
||
{"name": "Hamlet", "pop": 0, "cap": 1, "houses": 2},
|
||
{"name": "Village", "pop": 40, "cap": 3, "houses": 4},
|
||
{"name": "Town", "pop": 90, "cap": 4, "houses": 7},
|
||
{"name": "City", "pop": 150, "cap": 99, "houses": 8},
|
||
]
|
||
TIER_SLOTS = 2
|
||
# Growth: empty homes draw settlers, fast while many stand empty and slower as
|
||
# they fill, and families grow a little on their own however full the city is.
|
||
SETTLERS_PER_EMPTY_HOME = 0.5
|
||
FAMILY_GROWTH = 0.05
|
||
|
||
RAID_GROUPS = ["the Red Hand", "the Hollow Crows", "the Blackwater gang", "the Ashen Wolves",
|
||
"the Gallows Men", "the Thornback raiders"]
|
||
DRAGONS = ["a green dragon", "a blue dragon", "an old red dragon", "a young black dragon"]
|
||
DRAGON_TRAITS = ["it hunts at dusk", "one wing is scarred and weak", "it breathes fire in long sweeps",
|
||
"it circles twice before it strikes"]
|
||
RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows",
|
||
"well armed and disciplined", "a ragged mob, many but poorly armed",
|
||
"led by a deserter knight in plate"]
|
||
|
||
|
||
# --- world ---------------------------------------------------------------
|
||
|
||
WORLD_VERSION = 31
|
||
|
||
|
||
def new_world(now: float, speed: float = 1.0) -> dict:
|
||
return {
|
||
"version": WORLD_VERSION,
|
||
"created": now,
|
||
"config": {"speed": speed, "clock_offset": 0},
|
||
"players": {},
|
||
"city": {
|
||
"population": 20.0,
|
||
"treasury": 0.0,
|
||
"stockpile": {"bread": 40},
|
||
"buildings": {key: {"level": 0, "damaged": False, "fund": {}, "fittings": []} for key in BUILDINGS},
|
||
"construction": None,
|
||
"queue": [],
|
||
"tier": 0,
|
||
"weather": {"kind": "clear", "until": now + 6 * HOUR / speed, "next": "rain"},
|
||
"effects": [], "problems": [], "hired": 0,
|
||
"last_tick": now,
|
||
"food_due": 0.0,
|
||
"starving": False,
|
||
"repelled": 0,
|
||
"fires": {key: new_fire(now) for key in FIRES},
|
||
"wages": 0, "monuments": {}, "built": [], "carving": None,
|
||
},
|
||
"events": [],
|
||
"next_event_at": now + 12 * HOUR / speed,
|
||
"next_minor_at": now + 30 * HOUR / speed,
|
||
"next_hardship_at": now + 24 * HOUR / speed,
|
||
"next_fortune_at": now + 6 * HOUR / speed,
|
||
"next_event_id": 1,
|
||
"ledger": [],
|
||
"board": [],
|
||
"next_message_id": 1,
|
||
"orders": [], "next_order_id": 1,
|
||
"parties": {}, "next_party_id": 1,
|
||
"caravan": None, "next_caravan_id": 1,
|
||
}
|
||
|
||
|
||
def migrate(world: dict, now: float | None = None) -> dict:
|
||
"""Bring a saved world up to the current shape. Never loses anything."""
|
||
now = now or world["city"]["last_tick"]
|
||
if world.get("version", 1) < 2:
|
||
city = world["city"]
|
||
for building in city["buildings"].values():
|
||
building.setdefault("fund", {})
|
||
city.setdefault("queue", [])
|
||
city.setdefault("tier", 0)
|
||
world["version"] = 2
|
||
if world["version"] < 3:
|
||
for player in world["players"].values():
|
||
for skill in SKILL_IDS:
|
||
player["xp"].setdefault(skill, 0)
|
||
for slot in EQUIP_SLOTS:
|
||
player["equip"].setdefault(slot, None)
|
||
player.setdefault("food", None)
|
||
player.setdefault("hp", max_hp(player))
|
||
player.setdefault("hp_at", now)
|
||
world["version"] = 3
|
||
if world["version"] < 4:
|
||
_sell_useless_stock(world, now)
|
||
world["version"] = 4
|
||
if world["version"] < 5:
|
||
for player in world["players"].values():
|
||
player["xp"].setdefault("prayer", 0)
|
||
player.setdefault("pp", 0)
|
||
player.setdefault("prayers", [])
|
||
world["city"]["buildings"].setdefault("temple", {"level": 0, "damaged": False, "fund": {}})
|
||
world["city"].setdefault("devotion", 0)
|
||
world["version"] = 5
|
||
if world["version"] < 6:
|
||
for player in world["players"].values():
|
||
player["xp"].setdefault("herblore", 0)
|
||
player.setdefault("potions", {})
|
||
world["version"] = 6
|
||
if world["version"] < 7:
|
||
for player in world["players"].values():
|
||
player["xp"].setdefault("construction", 0)
|
||
player["equip"].setdefault("tool_construction", None)
|
||
work = world["city"]["construction"]
|
||
if work and "done_at" in work:
|
||
# The old timer becomes labour, credited for the time already spent.
|
||
level = world["city"]["buildings"][work["building"]]["level"]
|
||
needed = labour_needed(level, work["repair"])
|
||
share = min(1.0, max(0.0, (now - work["start"]) / max(1.0, work["done_at"] - work["start"])))
|
||
del work["done_at"]
|
||
work.update(labour=needed * share, needed=needed)
|
||
world["version"] = 7
|
||
if world["version"] < 8:
|
||
city = world["city"]
|
||
for key in ("granary", "lumber_shed", "well"):
|
||
city["buildings"].setdefault(key, {"level": 0, "damaged": False, "fund": {}})
|
||
city.setdefault("weather", {"kind": "clear", "until": now + 4 * HOUR / speed(world), "next": "rain"})
|
||
city.setdefault("effects", [])
|
||
city.setdefault("problems", [])
|
||
city.setdefault("hired", 0)
|
||
world.setdefault("next_minor_at", now + 30 * HOUR / speed(world))
|
||
world.setdefault("next_hardship_at", now + 24 * HOUR / speed(world))
|
||
world.setdefault("next_fortune_at", now + 8 * HOUR / speed(world))
|
||
world["version"] = 8
|
||
if world["version"] < 9:
|
||
# Board messages get ids so their authors can delete them.
|
||
for number, message in enumerate(world["board"], 1):
|
||
message["id"] = number
|
||
world["next_message_id"] = len(world["board"]) + 1
|
||
world["version"] = 9
|
||
if world["version"] < 10:
|
||
for building in world["city"]["buildings"].values():
|
||
building.setdefault("fittings", [])
|
||
world["version"] = 10
|
||
if world["version"] < 11:
|
||
_migrate_potions(world)
|
||
world["version"] = 11
|
||
if world["version"] < 12:
|
||
# Classes became trades: everyone gets a stand-in and one free pick.
|
||
for player in world["players"].values():
|
||
player["cls"] = OLD_CLASSES.get(player["cls"], player["cls"])
|
||
player["repick"] = True
|
||
_return_overfunding(world)
|
||
world["version"] = 12
|
||
if world["version"] < 13:
|
||
_fold_masonry(world)
|
||
_return_overfunding(world)
|
||
world["version"] = 13
|
||
if world["version"] < 14:
|
||
# Shields moved from Smithing to Construction: anyone smithing them stops.
|
||
for player in world["players"].values():
|
||
action = player["action"]
|
||
if action and (action["id"] not in ACTION_BY_ID or action.get("resume", "") and action["resume"] not in ACTION_BY_ID):
|
||
stop(player, "Shields are made in Construction now")
|
||
_return_overfunding(world)
|
||
world["version"] = 14
|
||
if world["version"] < 15:
|
||
# Raid reports pop up once for everyone; ones from before that don't.
|
||
latest = last_threat(world)
|
||
for player in world["players"].values():
|
||
player["seen_threat"] = latest["id"] if latest else 0
|
||
world["version"] = 15
|
||
if world["version"] < 16:
|
||
_migrate_combat_rework(world)
|
||
world["version"] = 16
|
||
if world["version"] < 17:
|
||
world.setdefault("orders", [])
|
||
world.setdefault("next_order_id", 1)
|
||
world["version"] = 17
|
||
if world["version"] < 18:
|
||
# Jewelcrafting: a new skill and three new slots.
|
||
for player in world["players"].values():
|
||
player["xp"].setdefault("jewelcrafting", 0)
|
||
for slot in ("ring", "amulet", "trinket"):
|
||
player["equip"].setdefault(slot, None)
|
||
world["version"] = 18
|
||
if world["version"] < 19:
|
||
for player in world["players"].values():
|
||
player.setdefault("pinned", [])
|
||
world["version"] = 19
|
||
if world["version"] < 20:
|
||
_precision_to_haste(world)
|
||
world["version"] = 20
|
||
if world["version"] < 21:
|
||
# Crafting stations, strongboxes and bags: their slots.
|
||
for player in world["players"].values():
|
||
for slot in EQUIP_SLOTS:
|
||
player["equip"].setdefault(slot, None)
|
||
world["version"] = 21
|
||
if world["version"] < 22:
|
||
for player in world["players"].values():
|
||
player.setdefault("tracked", None)
|
||
world["version"] = 22
|
||
if world["version"] < 23:
|
||
# Stations got their own slot: crafting skills have a tool and a station now.
|
||
for player in world["players"].values():
|
||
equip = player["equip"]
|
||
for slot in EQUIP_SLOTS:
|
||
equip.setdefault(slot, None)
|
||
for skill in STATIONS:
|
||
item = equip.get(f"tool_{skill}")
|
||
if item and ITEMS[item].get("station"):
|
||
equip[f"tool_{skill}"], equip[f"station_{skill}"] = None, item
|
||
world["version"] = 23
|
||
if world["version"] < 24:
|
||
# Firekeeping: a new skill, its bellows slot, and the guild's fires.
|
||
world["city"].setdefault("fires", {})
|
||
for key in FIRES:
|
||
world["city"]["fires"].setdefault(key, new_fire(now))
|
||
for player in world["players"].values():
|
||
player["xp"].setdefault("firekeeping", 0)
|
||
player["equip"].setdefault("tool_firekeeping", None)
|
||
world["version"] = 24
|
||
if world["version"] < 25:
|
||
# Fighting together: fights are kept per fighter now, so the one in progress is drawn up again.
|
||
world.setdefault("parties", {})
|
||
world.setdefault("next_party_id", 1)
|
||
for player in world["players"].values():
|
||
if player["action"]:
|
||
player["action"].pop("fight", None)
|
||
world["version"] = 25
|
||
if world["version"] < 26:
|
||
# Packs and abilities: fights in progress are drawn up again.
|
||
for player in world["players"].values():
|
||
if player["action"]:
|
||
player["action"].pop("fight", None)
|
||
for party in world["parties"].values():
|
||
party["fight"] = None
|
||
world["version"] = 26
|
||
if world["version"] < 27:
|
||
# Livelier happenings: tracks waiting longer than their new cooldowns are brought forward,
|
||
# and a city with no threat coming hears rumours of the next one straight away.
|
||
for track, (_low, high) in TRACK_HOURS.items():
|
||
key = f"next_{track}_at"
|
||
if world.get(key):
|
||
world[key] = min(world[key], now + high * HOUR / speed(world))
|
||
if world["next_event_at"] and not upcoming_event(world):
|
||
world["next_event_at"] = min(world["next_event_at"], now)
|
||
world["version"] = 27
|
||
if world["version"] < 28:
|
||
# Abilities in your own hands.
|
||
for player in world["players"].values():
|
||
player.setdefault("manual", [])
|
||
world["version"] = 28
|
||
if world["version"] < 29:
|
||
# Guards are paid now. The council covers the coming threat once, so nobody is caught out.
|
||
world["city"]["wages"] = guard_posts(world) * GUARD_WAGE
|
||
# Gold to spend: rented slots, caravan wares, a look of your own, monuments.
|
||
for player in world["players"].values():
|
||
for key, value in (("rented", 0), ("boosts", []), ("bought", None), ("deeds", {}), ("owned", [])):
|
||
player.setdefault(key, value)
|
||
player.setdefault("look", dict(DEFAULT_LOOK))
|
||
world.setdefault("caravan", None)
|
||
world.setdefault("next_caravan_id", 1)
|
||
for key, value in (("monuments", {}), ("built", []), ("carving", None)):
|
||
world["city"].setdefault(key, value)
|
||
world["version"] = 29
|
||
if world["version"] < 30:
|
||
# Masterwork patterns, learned by crafters.
|
||
for player in world["players"].values():
|
||
player.setdefault("patterns", [])
|
||
world["version"] = 30
|
||
if world["version"] < 31:
|
||
# Uniques ever found, for achievements: whatever's held now.
|
||
for player in world["players"].values():
|
||
held = [*player["bank"], *player["equip"].values()]
|
||
player.setdefault("found", [item for item in UNIQUES if item in held])
|
||
world["version"] = 31
|
||
return world
|
||
|
||
|
||
def _precision_to_haste(world: dict) -> None:
|
||
"""Rings of precision became rings of haste: same gem, same slot."""
|
||
rename = lambda item: item.replace("_precision", "_haste") if item and item.endswith("_precision") else item
|
||
swap = lambda items: {rename(item): qty for item, qty in items.items()}
|
||
world["city"]["stockpile"] = swap(world["city"]["stockpile"])
|
||
for player in world["players"].values():
|
||
player["bank"] = swap(player["bank"])
|
||
player["equip"] = {slot: rename(item) for slot, item in player["equip"].items()}
|
||
action = player["action"]
|
||
if action:
|
||
for key in ("id", "resume"):
|
||
if key in action:
|
||
action[key] = rename(action[key])
|
||
for order in world.get("orders", []):
|
||
order["item"], order["action"] = rename(order["item"]), rename(order["action"])
|
||
|
||
|
||
# The combat rework: the old area monsters became races, their trophies carry over by
|
||
# weapon line and tier, dragonhide became the dragons' reagents, and the leathers past
|
||
# bear are hunted now.
|
||
RENAMED_ITEMS = {
|
||
"bat_sinew": "harpy_sinew", "harpy_sinew": "spider_silk", "wisp_essence": "grave_dust",
|
||
"hill_troll_hide": "lizard_scale", "rock_troll_hide": "orc_tusk", "elder_troll_hide": "dragonkin_scale",
|
||
"obsidian_core": "frozen_core", "runic_core": "magma_core",
|
||
"green_dragonhide": "ironhill_lacing", "blue_dragonhide": "frostpeak_lacing", "red_dragonhide": "dragonspine_lacing",
|
||
"green_dragon_leather": "elk_leather", "blue_dragon_leather": "sabrecat_leather", "red_dragon_leather": "mammoth_leather",
|
||
**{f"{old}_{suffix}": f"{new}_{suffix}" for old, new in (("green_dhide", "elk_leather"), ("blue_dhide", "sabrecat_leather"),
|
||
("red_dhide", "mammoth_leather")) for suffix in ("cap", "legs", "body")},
|
||
}
|
||
RENAMED_ACTIONS = {
|
||
**{f"fight_{_slug(old)}": f"fight_{_slug(new)}" for old, new in (
|
||
("Clay golem", "Clay golems"), ("Cave troll", "Cave trolls"), ("Will-o'-wisp", "Restless dead"),
|
||
("Giant bat", "Harpies"), ("Stone golem", "Stone guardians"), ("Hill troll", "Lizardfolk"),
|
||
("Bog wraith", "Bog wraiths"), ("Harpy", "Giant spiders"), ("Iron golem", "Iron golems"), ("Rock troll", "Orcs"),
|
||
("Mine ghost", "Mine ghosts"), ("Wyvern", "Wyverns"), ("Obsidian golem", "Ice elementals"),
|
||
("Frost troll", "Frost trolls"), ("Banshee", "Banshees"), ("Griffin", "Griffins"),
|
||
("Runic golem", "Magma constructs"), ("Elder troll", "Dragonkin"), ("Shade", "Shades"), ("Roc", "Rocs"))},
|
||
**{f"tan_{old}_dragon_leather": f"tan_{new}_leather" for old, new in
|
||
(("green", "elk"), ("blue", "sabrecat"), ("red", "mammoth"))},
|
||
**{f"lw_{old}_{suffix}": f"lw_{new}_{suffix}" for old, new in (("green_dhide", "elk_leather"),
|
||
("blue_dhide", "sabrecat_leather"), ("red_dhide", "mammoth_leather")) for suffix in ("cap", "legs", "body")},
|
||
}
|
||
|
||
|
||
def _renamed(stock: dict) -> dict:
|
||
out = {}
|
||
for item, qty in stock.items():
|
||
new = RENAMED_ITEMS.get(item, item)
|
||
out[new] = out.get(new, 0) + qty
|
||
return out
|
||
|
||
|
||
def _migrate_combat_rework(world: dict) -> None:
|
||
city = world["city"]
|
||
city["stockpile"] = _renamed(city["stockpile"])
|
||
for building in city["buildings"].values():
|
||
building["fund"] = _renamed(building["fund"])
|
||
for player in world["players"].values():
|
||
player["xp"].setdefault("tailoring", 0)
|
||
player["bank"] = _renamed(player["bank"])
|
||
for slot, item in list(player["equip"].items()):
|
||
if item:
|
||
player["equip"][slot] = RENAMED_ITEMS.get(item, item)
|
||
action = player["action"]
|
||
if action:
|
||
for key in ("id", "resume"):
|
||
if key in action:
|
||
action[key] = RENAMED_ACTIONS.get(action[key], action[key])
|
||
if action["id"] not in ACTION_BY_ID or action.get("resume", "") and action["resume"] not in ACTION_BY_ID:
|
||
stop(player, "Combat has changed: pick your fight again")
|
||
|
||
|
||
def last_threat(world: dict) -> dict | None:
|
||
"""The most recent raid or dragon attack that has played out."""
|
||
return next((e for e in reversed(world["events"]) if e["status"] == "done" and e["kind"] in THREATS
|
||
and e.get("report")), None)
|
||
|
||
|
||
def unseen_threat(world: dict, player: dict) -> dict | None:
|
||
"""The latest raid or dragon attack this player hasn't watched yet, if it happened while they were here."""
|
||
event = last_threat(world)
|
||
if event and event["id"] > player.get("seen_threat", 0) and event["resolved"] >= player["joined"]:
|
||
return event
|
||
return None
|
||
|
||
|
||
def _migrate_potions(world: dict) -> None:
|
||
"""Potions from before the tiers become their tiered equivalents, uses and all."""
|
||
def renamed(stock: dict) -> None:
|
||
for old, new in OLD_POTIONS.items():
|
||
if old in stock:
|
||
stock[new] = stock.get(new, 0) + stock.pop(old)
|
||
renamed(world["city"]["stockpile"])
|
||
for player in world["players"].values():
|
||
renamed(player["bank"])
|
||
for active in player["potions"].values(): # regeneration was combat too, so slots still match
|
||
if active["item"] in OLD_POTIONS:
|
||
active["item"] = OLD_POTIONS[active["item"]]
|
||
action = player["action"]
|
||
if action:
|
||
for key in ("id", "resume"):
|
||
old = (action.get(key) or "").removeprefix("brew_")
|
||
if old in OLD_POTIONS:
|
||
action[key] = f"brew_{OLD_POTIONS[old]}"
|
||
|
||
|
||
def _fold_masonry(world: dict) -> None:
|
||
"""Masonry became part of Construction: its XP, potions and brews carry over."""
|
||
moved = {f"masonry_potion_{tier}": f"construction_potion_{tier}" for tier in (1, 2, 3)}
|
||
def renamed(stock: dict) -> None:
|
||
for old, new in moved.items():
|
||
if old in stock:
|
||
stock[new] = stock.get(new, 0) + stock.pop(old)
|
||
renamed(world["city"]["stockpile"])
|
||
for player in world["players"].values():
|
||
player["xp"]["construction"] = player["xp"].get("construction", 0) + player["xp"].pop("masonry", 0)
|
||
renamed(player["bank"])
|
||
active = player["potions"].pop("masonry", None)
|
||
if active:
|
||
item = moved.get(active["item"], active["item"])
|
||
if "construction" in player["potions"]:
|
||
player["bank"][item] = player["bank"].get(item, 0) + 1 # a fresh one back, rather than lose it
|
||
else:
|
||
player["potions"]["construction"] = {"item": item, "charges": active["charges"]}
|
||
action = player["action"]
|
||
if action:
|
||
for key in ("id", "resume"):
|
||
brew = (action.get(key) or "").removeprefix("brew_")
|
||
if brew in moved:
|
||
action[key] = f"brew_{moved[brew]}"
|
||
|
||
|
||
def _return_overfunding(world: dict) -> None:
|
||
"""After building costs change, anything a fund holds beyond its new target goes back to the stockpile."""
|
||
city = world["city"]
|
||
for key, building in city["buildings"].items():
|
||
target = fund_target(world, key) or {}
|
||
for item, qty in list(building["fund"].items()):
|
||
extra = qty - target.get(item, 0)
|
||
if extra <= 0:
|
||
continue
|
||
building["fund"][item] = qty - extra
|
||
if not building["fund"][item]:
|
||
del building["fund"][item]
|
||
if item == "gold":
|
||
city["treasury"] += extra
|
||
else:
|
||
city["stockpile"][item] = city["stockpile"].get(item, 0) + extra
|
||
|
||
|
||
def _sell_useless_stock(world: dict, now: float) -> None:
|
||
"""The council sells what the city can't use, into the treasury."""
|
||
city = world["city"]
|
||
sold = {item: qty for item, qty in city["stockpile"].items() if item in ITEMS and not city_uses(item)}
|
||
if not sold:
|
||
return
|
||
gold = sum(qty * ITEMS[item]["value"] for item, qty in sold.items())
|
||
for item in sold:
|
||
del city["stockpile"][item]
|
||
city["treasury"] += gold
|
||
names = [amount(i, q) for i, q in sold.items()]
|
||
names = ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else names[0]
|
||
log(world, now, "city", f"The council sold {names} "
|
||
f"from the stockpile for {gold:,} gold")
|
||
|
||
|
||
def speed(world: dict) -> float:
|
||
return world["config"]["speed"]
|
||
|
||
|
||
def amount(item: str, qty: int) -> str:
|
||
"""'179 copper swords', '1 copper sword', '40 stone'."""
|
||
spec = ITEMS[item]
|
||
name = spec.get("plural", spec["name"]) if qty != 1 else spec["name"]
|
||
return f"{qty:,} {name.lower()}"
|
||
|
||
|
||
def upper_first(text: str) -> str:
|
||
return text[:1].upper() + text[1:]
|
||
|
||
|
||
def log(world: dict, now: float, kind: str, text: str, who: str | None = None) -> None:
|
||
world["ledger"].append({"at": now, "kind": kind, "text": text, "who": who})
|
||
del world["ledger"][:-MAX_LOG]
|
||
|
||
|
||
def building_level(world: dict, key: str) -> int:
|
||
"""Effective level: a damaged building does nothing until repaired."""
|
||
building = world["city"]["buildings"][key]
|
||
return 0 if building["damaged"] else building["level"]
|
||
|
||
|
||
def fitted(world: dict, fitting: str) -> bool:
|
||
"""Whether a fitting is installed and working (its building isn't damaged)."""
|
||
key = FITTING_BY_ID[fitting]["building"]
|
||
return fitting in world["city"]["buildings"][key]["fittings"] and building_level(world, key) > 0
|
||
|
||
|
||
def build_cost(key: str, level: int) -> dict:
|
||
spec = BUILDINGS[key]
|
||
cost = {item: qty * level for item, qty in spec["base"].items()}
|
||
# Later levels add materials from higher tiers: advanced from level 2, late from 3, final from 5.
|
||
for key, first in (("advanced", 2), ("late", 3), ("final", 5)):
|
||
for item, qty in spec.get(key, {}).items():
|
||
if level >= first:
|
||
cost[item] = cost.get(item, 0) + qty * (level - first + 1)
|
||
cost["gold"] = spec["gold"] * level
|
||
return cost
|
||
|
||
|
||
def repair_cost(key: str, level: int) -> dict:
|
||
return {item: max(1, math.ceil(qty * REPAIR_SHARE)) for item, qty in build_cost(key, level).items()}
|
||
|
||
|
||
def housing(world: dict) -> int:
|
||
return 25 + 20 * building_level(world, "houses")
|
||
|
||
|
||
def level_cap(world: dict, key: str) -> int:
|
||
"""The highest level the settlement's tier allows for this building."""
|
||
tier = TIERS[world["city"]["tier"]]
|
||
return min(BUILDINGS[key]["max"], tier["houses"] if key == "houses" else tier["cap"])
|
||
|
||
|
||
def fund_target(world: dict, key: str) -> dict | None:
|
||
"""What the building's fund is collecting for: a repair, the next level, or nothing."""
|
||
building = world["city"]["buildings"][key]
|
||
if building["damaged"]:
|
||
return repair_cost(key, building["level"])
|
||
if building["level"] >= level_cap(world, key):
|
||
return None
|
||
return build_cost(key, building["level"] + 1)
|
||
|
||
|
||
def fund_missing(world: dict, key: str) -> dict:
|
||
target = fund_target(world, key) or {}
|
||
fund = world["city"]["buildings"][key]["fund"]
|
||
return {item: qty - fund.get(item, 0) for item, qty in target.items() if fund.get(item, 0) < qty}
|
||
|
||
|
||
# --- players -------------------------------------------------------------
|
||
|
||
def new_player(uid: str, name: str, cls: str, now: float) -> dict:
|
||
return {
|
||
"id": uid, "name": name, "cls": cls, "joined": now,
|
||
"xp": {skill: 0 for skill in SKILL_IDS},
|
||
"bank": {}, "gold": 0, "favour": 0,
|
||
"equip": {slot: None for slot in EQUIP_SLOTS},
|
||
"food": None, # preferred food in fights; None eats the cheapest first
|
||
"pp": 0, "prayers": [], "potions": {},
|
||
"hp": 44, "hp_at": now,
|
||
"action": None, "stopped": None,
|
||
"last_active": now, "overflow": "city",
|
||
"perks": {"bank_slot": 0}, "manual": [], "patterns": [], "found": [], "eat_below": EAT_BELOW,
|
||
"rented": 0, "boosts": [], "bought": None, "deeds": {}, "owned": [], "look": dict(DEFAULT_LOOK),
|
||
"garrison": False,
|
||
"donated": 0,
|
||
"pinned": [], # skills pinned to the top of the sidebar
|
||
"tracked": None, # recipe shown under the top bar
|
||
}
|
||
|
||
|
||
def skill_level(player: dict, skill: str) -> int:
|
||
return level_for(player["xp"].get(skill, 0))
|
||
|
||
|
||
def bank_slots(world: dict, player: dict) -> int:
|
||
return (BASE_SLOTS + 3 * building_level(world, "storehouse") + TIER_SLOTS * world["city"]["tier"]
|
||
+ player["perks"]["bank_slot"] + player["rented"] + fitted(world, "shelving") + int(gear_bonus(player, "slots")))
|
||
|
||
|
||
def stack_size(world: dict, player: dict | None = None) -> int:
|
||
"""How many of one item fit in a bank slot: the storehouse's, plus your bag's."""
|
||
bag = int(gear_bonus(player, "stack")) if player else 0
|
||
return BASE_STACK + 250 * building_level(world, "storehouse") + 500 * fitted(world, "enchanted_chests") + bag
|
||
|
||
|
||
def overfull(world: dict, player: dict) -> int:
|
||
"""Bank slots in use past what the bank holds (after a smaller strongbox or bag, say)."""
|
||
return slots_used(world, player) - bank_slots(world, player)
|
||
|
||
|
||
def slots_used(world: dict, player: dict) -> int:
|
||
"""Bank slots taken: an item spills into another slot every full stack."""
|
||
stack = stack_size(world, player)
|
||
return sum(-(-qty // stack) for qty in player["bank"].values())
|
||
|
||
|
||
def bank_room(world: dict, player: dict, item: str) -> int:
|
||
"""How many more of `item` fit: the rest of its last stack, plus any free slots."""
|
||
stack = stack_size(world, player)
|
||
have = player["bank"].get(item, 0)
|
||
partial = -have % stack # room left in the item's last, part-filled stack
|
||
free = max(0, bank_slots(world, player) - slots_used(world, player))
|
||
return partial + free * stack
|
||
|
||
|
||
def city_uses(item: str) -> list[str]:
|
||
"""What the city does with an item: the buildings that need it, the granary, the armoury."""
|
||
uses = [spec["name"] for spec in BUILDINGS.values()
|
||
if any(item in spec.get(key, {}) for key in ("base", "advanced", "late", "final"))]
|
||
spec = ITEMS[item]
|
||
if "food" in spec:
|
||
uses.append("the granary")
|
||
if spec.get("slot") in ("weapon", "shield") or item == "arrows":
|
||
uses.append("the armoury")
|
||
if item == "water_barrel":
|
||
uses.append("the fire brigade")
|
||
if "fitting" in spec:
|
||
uses.append(BUILDINGS[spec["fitting"]]["name"])
|
||
return uses
|
||
|
||
|
||
def bank_add(world: dict, player: dict, item: str, qty: int, now: float, summary: dict) -> int:
|
||
"""Store what fits; the rest goes to the city if it has a use, else is sold. Returns qty kept."""
|
||
if ITEMS[item].get("unique") and item not in player["found"]:
|
||
player["found"].append(item) # for achievements: once found, always found
|
||
keep = min(qty, bank_room(world, player, item))
|
||
if keep:
|
||
player["bank"][item] = player["bank"].get(item, 0) + keep
|
||
spill = qty - keep
|
||
if spill and player["overflow"] == "city" and not city_uses(item):
|
||
gold = int(spill * sell_price(world, item, now))
|
||
player["gold"] += gold
|
||
summary["sold"][item] = summary["sold"].get(item, 0) + spill
|
||
summary["sold_gold"] += gold
|
||
elif spill and player["overflow"] == "city":
|
||
overflow = summary["overflow"]
|
||
stockpile = world["city"]["stockpile"]
|
||
stockpile[item] = stockpile.get(item, 0) + spill
|
||
gain = spill * ITEMS[item]["value"]
|
||
player["favour"] += gain * OVERFLOW_FAVOUR
|
||
player["donated"] += gain
|
||
overflow[item] = overflow.get(item, 0) + spill
|
||
return keep
|
||
|
||
|
||
def bank_take(player: dict, item: str, qty: int) -> None:
|
||
left = player["bank"][item] - qty
|
||
if left > 0:
|
||
player["bank"][item] = left
|
||
else:
|
||
del player["bank"][item]
|
||
|
||
|
||
def armour(player: dict) -> int:
|
||
"""Protection from physical blows: slashing, blunt and piercing."""
|
||
return sum(ITEMS[item].get("armour", 0) for item in player["equip"].values() if item)
|
||
|
||
|
||
def magic_ward(player: dict) -> int:
|
||
"""Protection from magic."""
|
||
return sum(ITEMS[item].get("ward", 0) for item in player["equip"].values() if item)
|
||
|
||
|
||
def reduction(points: float) -> float:
|
||
return points / (points + 25)
|
||
|
||
|
||
def damage_reduction(player: dict) -> float:
|
||
return reduction(armour(player))
|
||
|
||
|
||
def defence_bonus(player: dict) -> int:
|
||
"""What all your armour adds to power and evasion, whatever its weight."""
|
||
return round((armour(player) + magic_ward(player)) * 0.4)
|
||
|
||
|
||
def gear_bonus(player: dict, stat: str) -> float:
|
||
"""A bonus that comes from what you're wearing: jewellery's accuracy, crit, HP and so on."""
|
||
return sum(ITEMS[item].get(stat, 0) for item in player["equip"].values() if item)
|
||
|
||
|
||
def combat_power(player: dict) -> int:
|
||
power = skill_level(player, "combat") + defence_bonus(player)
|
||
for item in player["equip"].values():
|
||
if item:
|
||
power += ITEMS[item].get("power", 0)
|
||
return power
|
||
|
||
|
||
def max_hp(player: dict) -> int:
|
||
return 40 + 4 * skill_level(player, "combat") + int(gear_bonus(player, "hp"))
|
||
|
||
|
||
def regen_boost(world: dict | None, player: dict) -> float:
|
||
"""How much faster than usual you heal: the enchanted hearths, and amulets of regeneration."""
|
||
boost = 2 if world and fitted(world, "enchanted_hearths") else 1
|
||
return boost * (1 + gear_bonus(player, "regen") / 100)
|
||
|
||
|
||
def regen(world: dict, player: dict, now: float) -> None:
|
||
"""Heal for the time since HP was last settled."""
|
||
if now > player["hp_at"]:
|
||
top = max_hp(player)
|
||
per_second = top * REGEN_PER_HOUR * regen_boost(world, player) * speed(world) / HOUR
|
||
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
|
||
player["hp_at"] = now
|
||
|
||
|
||
def weapon_style(player: dict) -> str:
|
||
weapon = player["equip"].get("weapon")
|
||
return ITEMS[weapon].get("style", "sword") if weapon else "sword"
|
||
|
||
|
||
def affinity(enemy: dict, damage: str) -> float:
|
||
"""How hard a damage type lands on this enemy."""
|
||
return VULNERABLE if damage in enemy["vulnerable"] else RESISTANT if damage in enemy["resists"] else 1
|
||
|
||
|
||
def fighter(player: dict, blessed: dict, brew: dict, enemy: dict | None = None) -> dict:
|
||
"""Your side of a fight: power from level and gear (plus prayers and potions) aims and hits, shaped by
|
||
the weapon's style, your trade and how the enemy takes its damage type. Armour soaks physical blows,
|
||
ward soaks magic, both help you dodge, and a shield blocks. The weapon and your trade give your
|
||
abilities; a sword and shield hold the line, a staff heals."""
|
||
style_id = weapon_style(player)
|
||
style = STYLES[style_id]
|
||
power = combat_power(player) + blessed["power"] + brew.get("power", 0)
|
||
base = (2 + power / 3) * style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1)
|
||
lands = affinity(enemy, style["damage"]) if enemy else 1
|
||
taken = blessed["taken"] * (1 - brew.get("reduce", 0))
|
||
shield = bool(player["equip"].get("shield"))
|
||
mods = combat.combine_mods([ITEMS[item]["mods"] for item in player["equip"].values() if item and ITEMS[item].get("mods")])
|
||
return {"style": style_id, "damage": style["damage"], "role": combat.role(style_id, shield), "mods": mods,
|
||
"abilities": combat.known_abilities(style_id, player["cls"], skill_level(player, "combat"), shield),
|
||
"interval": round(style["interval"] * (1 - gear_bonus(player, "haste") / 100), 2),
|
||
"accuracy": (10 + 2 * power) * style["accuracy"],
|
||
"base": base, "max_hit": max(2, round(base * lands)),
|
||
"crit": style["crit"] + gear_bonus(player, "crit") / 100,
|
||
"first": style.get("first", False), "affinity": lands,
|
||
"block": BLOCK_CHANCE if shield else 0,
|
||
"evasion": 10 + 2 * (skill_level(player, "combat") + defence_bonus(player)),
|
||
"taken": {"physical": (1 - damage_reduction(player)) * taken, "magic": (1 - reduction(magic_ward(player))) * taken}}
|
||
|
||
|
||
def _taken(me: dict, damage: str) -> float:
|
||
return me["taken"]["magic" if damage == "magic" else "physical"]
|
||
|
||
|
||
hit_chance = combat.hit_chance
|
||
|
||
|
||
def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float:
|
||
return (1 - crit) * (max_hit + 1) / 2 + crit * max_hit * CRIT_MULT
|
||
|
||
|
||
def rest_plan(world: dict | None, player: dict, hp: float | None = None) -> tuple[float, dict]:
|
||
"""Resting back to full before a fight: how many game seconds, and the food eaten on the way.
|
||
You eat while you're under the share of your HP you chose, and only when a bite is quicker than
|
||
resting, so better food makes for shorter rests. The rest of the way you rest."""
|
||
top = max_hp(player)
|
||
missing = top - (player["hp"] if hp is None else hp)
|
||
rate = top * REST_RATE * regen_boost(world, player) / ware(player, "rest", "combat")
|
||
hungry = top * (1 - player.get("eat_below", EAT_BELOW)) # eat while more than this is missing
|
||
seconds, eaten = 0.0, {}
|
||
for food in edible(player):
|
||
heal, left = ITEMS[food]["heal"], player["bank"].get(food, 0)
|
||
while missing > hungry and missing > 0 and left and min(heal, missing) / rate > BITE:
|
||
eaten[food] = eaten.get(food, 0) + 1
|
||
left -= 1
|
||
missing -= heal
|
||
seconds += BITE
|
||
return max(RESPAWN, seconds + max(0.0, missing) / rate), eaten
|
||
|
||
|
||
def health_potion(player: dict) -> str | None:
|
||
"""The strongest health potion in the bank: the one you'd drink in a fight."""
|
||
potions = [item for item in player["bank"] if ITEMS[item].get("drink")]
|
||
return max(potions, key=lambda item: ITEMS[item]["drink"]) if potions else None
|
||
|
||
|
||
# How much abilities add to a weapon's swings, and an archetype's abilities to its blows, on
|
||
# average (from tools/balance.py). Only the encounter cards' estimates use them.
|
||
ABILITY_SHARE = {"sword": 1.15, "hammer": 1.45, "staff": 1.35, "bow": 1.3}
|
||
ENEMY_SHARE = {"brute": 1.4, "skirmisher": 1.3, "caster": 1.6, "elite": 1.5, "boss": 1.6, "dragon": 1.8}
|
||
GUARD_SHARE = {"sword": 0.73, "hammer": 0.85, "staff": 0.33, "bow": 0.72} # what your abilities keep off you
|
||
PACK_SHARE = 1 + PACK_EXTRA * (2 * OUTNUMBERED[0] - 1) # a pack's HP, for one of you, against a lone enemy's
|
||
|
||
|
||
def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict, pack: float = 1.0) -> dict:
|
||
me = fighter(player, blessed, brew, foe)
|
||
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"])
|
||
dps = hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"] * ABILITY_SHARE[me["style"]]
|
||
kill = min(foe["hp"] * pack / dps, MAX_FIGHT)
|
||
taken = sum(_taken(me, kind) for kind in foe["attack"]) / len(foe["attack"])
|
||
per_second = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * taken / foe["interval"]
|
||
per_second *= ENEMY_SHARE[foe["archetype"]] * GUARD_SHARE[me["style"]] * pack
|
||
per_second = max(0, per_second - blessed["mend"] * max_hp(player))
|
||
return {"hit": hit, "their_hit": their_hit, "max_hit": me["max_hit"], "affinity": me["affinity"],
|
||
"their_max": max(1, round(foe["max_hit"] * max(_taken(me, kind) for kind in foe["attack"]))),
|
||
"kill_time": kill, "damage": per_second * kill}
|
||
|
||
|
||
def fight_odds(player: dict, action: dict, world: dict | None = None) -> dict:
|
||
"""What a fight in this encounter looks like on average, for the cards, and against each variant:
|
||
the time to win it, the damage it costs, and the rest after it."""
|
||
blessed, brew = blessing(player), potion_effect(player, "combat")
|
||
variants = action["enemy"]["variants"]
|
||
pack = PACK_SHARE if len(variants) > 1 else BOSS_HP if action.get("together") else 1 # a boss shared by a pair
|
||
rows = [_variant_odds(player, foe, blessed, brew, pack) for foe in variants]
|
||
total = sum(foe["weight"] for foe in variants)
|
||
mean = lambda key: sum(row[key] * foe["weight"] for row, foe in zip(rows, variants)) / total
|
||
top = max_hp(player)
|
||
rest = rest_plan(world, player, top - min(top, mean("damage")))[0]
|
||
return {"hit": round(mean("hit"), 2), "their_hit": round(mean("their_hit"), 2), "style": weapon_style(player),
|
||
"max_hit": max(row["max_hit"] for row in rows), "their_max": max(row["their_max"] for row in rows),
|
||
"kill_time": round(mean("kill_time"), 1), "damage": round(mean("damage"), 1), "rest": round(rest, 1),
|
||
"variants": [{"affinity": row["affinity"], "kill_time": round(row["kill_time"], 1),
|
||
"damage": round(row["damage"], 1)} for row in rows]}
|
||
|
||
|
||
def fight_member(world: dict, player: dict, at: float = 0.0) -> dict:
|
||
"""One side of a fight as it squares up (or tags in `at` seconds of game time in): kept with the
|
||
fight, so playing it out again later goes the same way."""
|
||
blessed, brew = blessing(player), potion_effect(player, "combat")
|
||
me = fighter(player, blessed, brew)
|
||
potion = health_potion(player)
|
||
return {"id": player["id"], "name": player["name"], "at": at, "left": None,
|
||
"start_hp": round(player["hp"]), "hp_in": player["hp"], "top": max_hp(player), "style": me["style"],
|
||
"role": me["role"], "abilities": me["abilities"], "interval": me["interval"] / speed(world),
|
||
"fighter": me, "eaten": {}, "potion": ITEMS[potion]["drink"] if potion else 0, "potion_item": potion,
|
||
"mend": blessed["mend"],
|
||
"wind": blessed["wind"] and not (player["action"] or {}).get("wind_used"), "pp": blessed["cost"],
|
||
"manual": [a for a in player.get("manual", []) if a in me["abilities"]], "inputs": [], "mods": me["mods"],
|
||
"watch": at + combat.WATCH * speed(world)}
|
||
|
||
|
||
FIGHT_PRIVATE = ("fighter", "mend", "wind")
|
||
|
||
|
||
def fight_view(fight: dict) -> dict:
|
||
"""A fight as the page sees it: no one's bank or stats."""
|
||
return {**fight, "members": [{key: value for key, value in member.items() if key not in FIGHT_PRIVATE}
|
||
for member in fight["members"]]}
|
||
|
||
|
||
def member_of(fight: dict, uid: str) -> dict:
|
||
return next(member for member in fight["members"] if member["id"] == uid)
|
||
|
||
|
||
def draw_pack(action: dict, size: int, rng: random.Random) -> list[dict]:
|
||
"""Who you're up against: one of the encounter's variants, as many as there are of you and
|
||
often one more, weaker each when they outnumber you. A rare elite or a boss comes alone."""
|
||
variants = action["enemy"]["variants"]
|
||
weights = [foe["weight"] for foe in variants]
|
||
first = rng.choices(range(len(variants)), weights)[0]
|
||
picks, (hp, hits) = [first], (1.0, 1.0)
|
||
if action.get("together"):
|
||
hp, hits = BOSS_HP * size, BOSS_HITS[0] * (1 + BOSS_HITS[1] * (size - GROUP_SIZE))
|
||
if len(variants) > 1 and variants[first]["archetype"] != "elite":
|
||
count = size + (rng.random() < PACK_EXTRA)
|
||
common = [i for i, foe in enumerate(variants) if foe["archetype"] != "elite"]
|
||
picks += rng.choices(common, [weights[i] for i in common], k=count - 1)
|
||
if count > size:
|
||
hp, hits = OUTNUMBERED
|
||
share = 1.0 if action.get("together") else hp # a boss grown for its group still pays each of them one boss
|
||
return [{**{key: variants[i][key] for key in ("name", "archetype", "level", "boss", "interval", "accuracy",
|
||
"evasion", "attack", "vulnerable", "resists")},
|
||
"variant": i, "hp": max(1, round(variants[i]["hp"] * hp)), "max_hit": max(1, round(variants[i]["max_hit"] * hits)),
|
||
"xp": variants[i]["xp"] * share, "share": share} for i in picks]
|
||
|
||
|
||
def draw_fight(world: dict, players: list, action: dict, rng: random.Random, start: float | None = None) -> dict:
|
||
"""Draw up the pack, rest everyone back to full (the longest rest is everyone's), and play the
|
||
fight out. A fight starting at `start` (real time) holds each player's manual abilities for as
|
||
long as they were last seen watching."""
|
||
members, rest = [], 0.0
|
||
for player in players:
|
||
seconds, eaten = rest_plan(world, player)
|
||
member = fight_member(world, player)
|
||
member.update(eaten=eaten, hp_in=member["top"])
|
||
rest = max(rest, seconds)
|
||
members.append(member)
|
||
fight = {"seed": rng.random(), "pace": speed(world), "rest": rest, "enemy": action["enemy"]["name"],
|
||
"pack": draw_pack(action, len(players), rng), "members": members}
|
||
for member, player in zip(members, players):
|
||
member["watch"] = -1.0 if start is None else fight_time(fight, start, player["last_active"] + combat.WATCH)
|
||
return play_fight(fight)
|
||
|
||
|
||
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random, start: float | None = None) -> dict:
|
||
return draw_fight(world, [player], action, rng, start)
|
||
|
||
|
||
def fight_time(fight: dict, start: float, now: float) -> float:
|
||
"""A real moment as seconds into a fight (after its rest) that started at `start`."""
|
||
return (now - start) * fight["pace"] - fight["rest"]
|
||
|
||
|
||
def fight_clock(world: dict, player: dict) -> tuple[dict | None, float]:
|
||
"""The fight you're in and when it (its rest included) began."""
|
||
current = player["action"] or {}
|
||
party = world["parties"].get(current.get("party"))
|
||
if party:
|
||
return party["fight"], party["start"]
|
||
return current.get("fight"), current.get("start", 0)
|
||
|
||
|
||
def _my_fight(world: dict, player: dict, now: float) -> tuple[dict, dict, float] | str:
|
||
fight, start = fight_clock(world, player)
|
||
if not fight or now >= start + fight["duration"] or not any(m["id"] == player["id"] for m in fight["members"]):
|
||
return "You're not fighting"
|
||
member = member_of(fight, player["id"])
|
||
if member["result"] in ("down", "left"):
|
||
return "You're out of this fight"
|
||
return fight, member, fight_time(fight, start, now)
|
||
|
||
|
||
def watch_fight(world: dict, player: dict, now: float) -> None:
|
||
"""You're looking: your manual abilities hold a while longer, and the fight's future is played
|
||
again with them. Nothing that's already happened changes."""
|
||
mine = _my_fight(world, player, now)
|
||
if isinstance(mine, str):
|
||
return
|
||
fight, member, t = mine
|
||
if not member["manual"] and not member["inputs"]:
|
||
return
|
||
watch = t + combat.WATCH * fight["pace"]
|
||
if watch > member["watch"] + combat.STEP:
|
||
member["watch"] = watch
|
||
play_fight(fight)
|
||
|
||
|
||
def _input(world: dict, player: dict, now: float, kind: str, *args) -> str | None:
|
||
mine = _my_fight(world, player, now)
|
||
if isinstance(mine, str):
|
||
return mine
|
||
fight, member, t = mine
|
||
if t < 0:
|
||
return "Still resting"
|
||
at = math.ceil(t / combat.STEP - 1e-9) * combat.STEP # the next slice of the fight
|
||
member["inputs"].append([at, kind, *args])
|
||
member["watch"] = max(member["watch"], t + combat.WATCH * fight["pace"])
|
||
play_fight(fight)
|
||
return None
|
||
|
||
|
||
def set_eat_below(player: dict, share) -> str | None:
|
||
"""Below what share of your HP you eat between fights (0: never, 1: whenever it's quicker)."""
|
||
if share not in EAT_CHOICES:
|
||
return "Pick one of the choices"
|
||
player["eat_below"] = share
|
||
return None
|
||
|
||
|
||
def learn_pattern(player: dict, item: str) -> str | None:
|
||
"""Read a pattern: from then on you can reforge its masterwork. The pattern's used up."""
|
||
spec = ITEMS.get(item, {})
|
||
if not spec.get("pattern"):
|
||
return "That isn't a pattern"
|
||
if not player["bank"].get(item):
|
||
return "You don't have it"
|
||
known = player.setdefault("patterns", [])
|
||
if item in known:
|
||
return "You know it already"
|
||
if skill_level(player, spec["skill"]) < spec["level"]:
|
||
name = next(s["name"] for s in SKILLS if s["id"] == spec["skill"])
|
||
return f"Needs {name.lower()} level {spec['level']}"
|
||
bank_take(player, item, 1)
|
||
known.append(item)
|
||
return None
|
||
|
||
|
||
def press(world: dict, player: dict, ability: str, now: float, target: str | None = None) -> str | None:
|
||
"""Use one of your manual abilities: as soon as it's ready, if that's soon enough."""
|
||
if ability not in combat.ABILITIES:
|
||
return "No such ability"
|
||
if ability not in player["manual"]:
|
||
return "That one's on auto"
|
||
return _input(world, player, now, "press", ability, target)
|
||
|
||
|
||
def set_fight_target(world: dict, player: dict, target: str, now: float) -> str | None:
|
||
"""Tap an enemy to fight it, or someone on your side to aim your heals at them."""
|
||
return _input(world, player, now, "target", str(target))
|
||
|
||
|
||
def set_manual(world: dict, player: dict, ability: str, manual: bool, now: float) -> str | None:
|
||
"""Take an ability into your own hands (pressed, on its real cooldown, while you watch) or give it
|
||
back to auto."""
|
||
if ability not in combat.ABILITIES:
|
||
return "No such ability"
|
||
mine = player.setdefault("manual", [])
|
||
if manual and ability not in mine:
|
||
mine.append(ability)
|
||
elif not manual and ability in mine:
|
||
mine.remove(ability)
|
||
fight = _my_fight(world, player, now)
|
||
if isinstance(fight, str):
|
||
return None
|
||
fight, member, t = fight
|
||
if t < 0:
|
||
member["manual"] = list(mine)
|
||
play_fight(fight)
|
||
return None
|
||
return _input(world, player, now, "mode", ability, manual)
|
||
|
||
|
||
def play_fight(fight: dict) -> dict:
|
||
"""Play a fight out, slice by slice, from the start (see combat.py). Nothing changes until it's over:
|
||
the result is applied then. The dice come from the fight's own seed, so playing it again with
|
||
someone tagging in or stepping out changes nothing before then."""
|
||
return combat.run(fight)
|
||
|
||
|
||
def pack_xp(fight: dict, killed_only: bool = False) -> float:
|
||
"""The pack's XP: the enemies brought down, and on a loss a third of the rest (but never for an
|
||
add that didn't join)."""
|
||
kills = set(fight["kills"])
|
||
rest = 1 / 3 if killed_only else 0
|
||
return sum(foe["xp"] * (1 if f"e{i}" in kills else 0 if foe.get("dormant") else rest)
|
||
for i, foe in enumerate(fight["pack"]))
|
||
|
||
|
||
# --- prayer -------------------------------------------------------------------
|
||
# Melvor-style: bones become prayer points, active prayers cost points each
|
||
# fight and give boosts while they last, and praying in a fight trains Prayer.
|
||
|
||
MEND = 0.0004 # Rapid Heal: share of max HP mended per second of fighting
|
||
|
||
PRAYERS = [
|
||
{"id": "thick_skin", "name": "Thick Skin", "icon": "🪨", "level": 1, "cost": 1, "reduce": 0.10},
|
||
{"id": "burst_of_strength", "name": "Burst of Strength", "icon": "💪", "level": 5, "cost": 1, "power": 5},
|
||
{"id": "rapid_heal", "name": "Rapid Heal", "icon": "💗", "level": 10, "cost": 1, "mend": MEND},
|
||
{"id": "steel_skin", "name": "Steel Skin", "icon": "🛡️", "level": 20, "cost": 2, "reduce": 0.20},
|
||
{"id": "superhuman_strength", "name": "Superhuman Strength", "icon": "🦾", "level": 30, "cost": 2, "power": 12},
|
||
{"id": "second_wind", "name": "Second Wind", "icon": "🌬️", "level": 40, "cost": 1, "wind": True},
|
||
{"id": "protection", "name": "Protection", "icon": "✨", "level": 50, "cost": 3, "reduce": 0.35},
|
||
{"id": "piety", "name": "Piety", "icon": "😇", "level": 60, "cost": 4, "power": 20, "reduce": 0.20},
|
||
]
|
||
PRAYER_BY_ID = {prayer["id"]: prayer for prayer in PRAYERS}
|
||
MAX_PRAYERS = 2
|
||
PRAYER_XP_PER_POINT = 5
|
||
NO_BLESSING = {"cost": 0, "power": 0, "taken": 1.0, "mend": 0, "wind": False}
|
||
|
||
|
||
def describe_prayer(prayer: dict) -> str:
|
||
parts = []
|
||
if prayer.get("reduce"):
|
||
parts.append(f"-{round(prayer['reduce'] * 100)}% damage taken")
|
||
if prayer.get("power"):
|
||
parts.append(f"+{prayer['power']} power")
|
||
if prayer.get("mend"):
|
||
parts.append(f"+{prayer['mend'] * 6000:g}% HP / min in fights")
|
||
if prayer.get("wind"):
|
||
parts.append("once per outing, revive at half HP")
|
||
return ", ".join(parts)
|
||
|
||
|
||
def blessing(player: dict) -> dict:
|
||
"""What the active prayers give this fight, if there are points to pay for them."""
|
||
active = [PRAYER_BY_ID[p] for p in player["prayers"] if p in PRAYER_BY_ID]
|
||
cost = sum(p["cost"] for p in active)
|
||
if not active or player["pp"] < cost:
|
||
return NO_BLESSING
|
||
taken = 1.0
|
||
for prayer in active:
|
||
taken *= 1 - prayer.get("reduce", 0)
|
||
return {"cost": cost, "power": sum(p.get("power", 0) for p in active), "taken": taken,
|
||
"mend": sum(p.get("mend", 0) for p in active), "wind": any(p.get("wind") for p in active)}
|
||
|
||
|
||
def set_prayers(player: dict, ids: list) -> str | None:
|
||
ids = list(dict.fromkeys(str(i) for i in ids))
|
||
if len(ids) > MAX_PRAYERS:
|
||
return f"At most {MAX_PRAYERS} prayers at once"
|
||
for prayer_id in ids:
|
||
prayer = PRAYER_BY_ID.get(prayer_id)
|
||
if not prayer:
|
||
return "No such prayer"
|
||
if skill_level(player, "prayer") < prayer["level"]:
|
||
return f"{prayer['name']} needs prayer level {prayer['level']}"
|
||
player["prayers"] = ids
|
||
return None
|
||
|
||
|
||
def bone_points(world: dict, action: dict) -> int:
|
||
return action["pp"] + building_level(world, "temple") + fitted(world, "carved_altar")
|
||
|
||
|
||
def ward(world: dict) -> float:
|
||
"""Defence the temple's devotion adds to the next raid."""
|
||
cap = 15 * building_level(world, "temple") + 15 * fitted(world, "carved_altar")
|
||
return round(min(world["city"].get("devotion", 0) / 4, cap), 1)
|
||
|
||
|
||
def edible(player: dict) -> list[str]:
|
||
"""Food in the bank, in the order it gets eaten."""
|
||
foods = sorted((item for item in player["bank"] if "heal" in ITEMS[item]), key=lambda i: ITEMS[i]["heal"])
|
||
if player["food"] in foods:
|
||
foods.remove(player["food"])
|
||
foods.insert(0, player["food"])
|
||
return foods
|
||
|
||
|
||
def action_time(world: dict, player: dict, action: dict) -> float:
|
||
seconds = action["time"]
|
||
if action["skill"] in CLASSES[player["cls"]]["fast"]:
|
||
seconds *= CLASS_SPEED
|
||
if action["skill"] == "farming" and player["cls"] == "homesteader":
|
||
# Focus on one plot for 30% faster, or split the same 20% pace between two crops.
|
||
seconds *= PLOTS_SPEED if two_plots(player) else FOCUS_SPEED
|
||
tool = player["equip"].get(f"tool_{action['skill']}")
|
||
if tool:
|
||
seconds *= 1 - ITEMS[tool]["speed"]
|
||
station = player["equip"].get(f"station_{action['skill']}")
|
||
if station and (action["outputs"] or action.get("special") == "labour"):
|
||
seconds *= 1 - ITEMS[station]["speed"]
|
||
if action["skill"] == "smithing":
|
||
seconds *= 1 - 0.08 * building_level(world, "smithy")
|
||
seconds *= 0.9 if fitted(world, "bellows") else 1
|
||
if action["id"].startswith("saw_") and fitted(world, "saw_benches"):
|
||
seconds *= 0.8
|
||
seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0)
|
||
seconds *= skill_mods(world, action)["speed"]
|
||
seconds *= ware(player, "speed", action["skill"])
|
||
return seconds / speed(world)
|
||
|
||
|
||
FOCUS_SPEED = 0.7 # a homesteader tending one plot
|
||
PLOTS_SPEED = 0.8 # each harvest when tending two: together, one crop's worth at 20% faster
|
||
|
||
|
||
def two_plots(player: dict) -> bool:
|
||
current = player["action"]
|
||
return bool(current and current.get("plot2"))
|
||
|
||
|
||
def growing(player: dict) -> dict:
|
||
"""The action the player is on right now: with two plots, harvests alternate between them."""
|
||
current = player["action"]
|
||
if current.get("plot2") and current.get("turn"):
|
||
return ACTION_BY_ID[current["plot2"]]
|
||
return ACTION_BY_ID[current["id"]]
|
||
|
||
|
||
def plant_second_plot(world: dict, player: dict, action_id: str | None) -> str | None:
|
||
"""Let a homesteader's second plot grow another crop, or clear it."""
|
||
current = player["action"]
|
||
if not action_id:
|
||
if current:
|
||
current.pop("plot2", None)
|
||
current.pop("turn", None)
|
||
return None
|
||
if player["cls"] != "homesteader":
|
||
return "Only homesteaders tend two plots"
|
||
action = ACTION_BY_ID.get(action_id)
|
||
if not action or action["skill"] != "farming" or action.get("special"):
|
||
return "That isn't a crop"
|
||
if not current or ACTION_BY_ID[current["id"]]["skill"] != "farming" or "left" in current:
|
||
return "Plant your first plot first"
|
||
if action_id == current["id"]:
|
||
return "That's already growing in your first plot"
|
||
reason = can_start(world, player, action)
|
||
if reason:
|
||
return reason
|
||
current["plot2"] = action_id
|
||
current["turn"] = 0
|
||
return None
|
||
|
||
|
||
def potion_effect(player: dict, skill: str) -> dict:
|
||
active = player.get("potions", {}).get(skill)
|
||
return ITEMS[active["item"]]["potion"] if active else {}
|
||
|
||
|
||
def use_potion(player: dict, item: str) -> str | None:
|
||
"""Drink a potion from the bank into its skill's slot (topping up the same potion)."""
|
||
spec = ITEMS.get(item, {}).get("potion")
|
||
if not spec:
|
||
return "That isn't a potion"
|
||
if player["bank"].get(item, 0) < 1:
|
||
return "You don't have one"
|
||
bank_take(player, item, 1)
|
||
current = player["potions"].get(spec["skill"])
|
||
charges = current["charges"] if current and current["item"] == item else 0
|
||
fresh = round(POTION_CHARGES * (1.25 if player["cls"] == "homesteader" else 1))
|
||
player["potions"][spec["skill"]] = {"item": item, "charges": charges + fresh}
|
||
return None
|
||
|
||
|
||
def _use_charge(player: dict, skill: str, summary: dict) -> None:
|
||
active = player["potions"].get(skill)
|
||
if not active:
|
||
return
|
||
active["charges"] -= 1
|
||
if active["charges"] > 0:
|
||
return
|
||
del player["potions"][skill]
|
||
if player["bank"].get(active["item"], 0):
|
||
use_potion(player, active["item"])
|
||
summary["potions_drunk"][active["item"]] = summary["potions_drunk"].get(active["item"], 0) + 1
|
||
else:
|
||
summary["potion_out"].append(active["item"])
|
||
|
||
|
||
# --- fires -----------------------------------------------------------------
|
||
# Each fire is a list of fuel: {tier, left (game seconds), by (who fed it)}. Everyone who
|
||
# feeds a fire keeps their own pile in it, up to their own bank, so a full fire never
|
||
# shuts a beginner out. The fire burns a game minute a minute, shared evenly between the
|
||
# piles, so every log adds to how long it lasts; each pile burns its hottest fuel first,
|
||
# and the fire is as hot as its hottest pile.
|
||
|
||
def new_fire(now: float) -> dict:
|
||
return {"fuel": [], "at": now, "saved": 0}
|
||
|
||
|
||
def burn_fires(world: dict, now: float) -> None:
|
||
"""Burn every fire down to `now`. Never runs backwards."""
|
||
for fire in world["city"]["fires"].values():
|
||
if now <= fire["at"]:
|
||
continue
|
||
seconds = (now - fire["at"]) * speed(world)
|
||
fire["at"] = now
|
||
fuel = fire["fuel"]
|
||
while seconds > 1e-9 and fuel:
|
||
# Each pile burns its best fuel at 1/n of the pace, until the first of those runs out.
|
||
heads = [burning(fuel, by) for by in {chunk["by"] for chunk in fuel}]
|
||
step = min(seconds, min(chunk["left"] for chunk in heads) * len(heads))
|
||
for chunk in heads:
|
||
chunk["left"] -= step / len(heads)
|
||
if chunk["left"] <= 1e-6:
|
||
fuel.remove(chunk)
|
||
seconds -= step
|
||
|
||
|
||
def burning(fuel: list, by: str | None = None) -> dict | None:
|
||
"""The fuel burning hottest (in one pile, with `by`): the hottest, and the oldest of those."""
|
||
return max((chunk for chunk in fuel if by is None or chunk["by"] == by), key=lambda chunk: chunk["tier"], default=None)
|
||
|
||
|
||
def heat(world: dict, fire: str) -> int:
|
||
"""The tier of a fire's flame, or -1 when it's out."""
|
||
chunk = burning(world["city"]["fires"][fire]["fuel"])
|
||
return chunk["tier"] if chunk else -1
|
||
|
||
|
||
def _room(fire: dict, player: dict, tier: int) -> float:
|
||
"""Game seconds of the player's own cheaper fuel a log of `tier` would push out, or -1 if it won't fit."""
|
||
level = skill_level(player, "firekeeping")
|
||
mine = [chunk for chunk in fire["fuel"] if chunk["by"] == player["id"]]
|
||
short = sum(chunk["left"] for chunk in mine) + fire_minutes(level) * 60 - fire_bank(level) * 60
|
||
cheaper = sum(chunk["left"] for chunk in mine if chunk["tier"] < tier)
|
||
return -1 if short > cheaper + 1e-6 else max(0.0, short)
|
||
|
||
|
||
def feed_fire(world: dict, player: dict, fire_id: str, tier: int) -> float:
|
||
"""Put one log on your pile in a fire, pushing out your cheapest fuel if it's full. Returns the minutes added."""
|
||
fire, level = world["city"]["fires"][fire_id], skill_level(player, "firekeeping")
|
||
short = _room(fire, player, tier)
|
||
while short > 1e-6:
|
||
cheapest = min((chunk for chunk in fire["fuel"] if chunk["by"] == player["id"] and chunk["tier"] < tier),
|
||
key=lambda chunk: chunk["tier"])
|
||
out = min(short, cheapest["left"])
|
||
cheapest["left"] -= out
|
||
short -= out
|
||
if cheapest["left"] <= 1e-6:
|
||
fire["fuel"].remove(cheapest)
|
||
minutes = fire_minutes(level)
|
||
last = fire["fuel"][-1] if fire["fuel"] else None
|
||
if last and last["tier"] == tier and last["by"] == player["id"]:
|
||
last["left"] += minutes * 60
|
||
else:
|
||
fire["fuel"].append({"tier": tier, "left": minutes * 60, "by": player["id"]})
|
||
return minutes
|
||
|
||
|
||
def fire_inputs(world: dict, action: dict) -> dict:
|
||
"""What a craft takes right now: a hot enough fire pays its log, and the forge a coal (never the last)."""
|
||
fire = action.get("fire")
|
||
if not fire or heat(world, fire) < action["fuel"]:
|
||
return action["inputs"]
|
||
inputs = dict(action["inputs"])
|
||
log = FIREWOOD[action["fuel"]]
|
||
inputs[log] -= 1
|
||
if not inputs[log]:
|
||
del inputs[log]
|
||
if fire == "forge" and inputs.get("coal", 0) > 1:
|
||
inputs["coal"] -= 1
|
||
return inputs
|
||
|
||
|
||
def _warm(world: dict, crafter: dict, action: dict, xp: float, now: float) -> None:
|
||
"""A fire paid for a craft: count it, and share the warmth between everyone whose
|
||
pile alone was hot enough for it (a beginner's logs count for bricks and copper)."""
|
||
fire = world["city"]["fires"][action["fire"]]
|
||
fire["saved"] = fire.get("saved", 0) + 1
|
||
keepers = [world["players"].get(by) for by in {chunk["by"] for chunk in fire["fuel"]}
|
||
if burning(fire["fuel"], by)["tier"] >= action["fuel"]]
|
||
keepers = [keeper for keeper in keepers if keeper and keeper is not crafter]
|
||
for keeper in keepers:
|
||
gain_xp(world, keeper, "firekeeping", xp * WARMTH / len(keepers), now, keeper.setdefault("away", empty_summary()))
|
||
|
||
|
||
def fires_view(world: dict, now: float) -> dict:
|
||
burn_fires(world, now)
|
||
users = {}
|
||
for player in world["players"].values():
|
||
action = player["action"] and ACTION_BY_ID.get(player["action"]["id"])
|
||
if action and (action.get("fire") or action.get("tend")):
|
||
users.setdefault(action.get("fire") or action["tend"], []).append(
|
||
{"id": player["id"], "name": player["name"], "action": action["id"]})
|
||
return {key: {**fire, "heat": heat(world, key), "speed": speed(world), "users": users.get(key, [])}
|
||
for key, fire in world["city"]["fires"].items()}
|
||
|
||
|
||
def upcoming_raid(world: dict) -> dict | None:
|
||
"""The coming major threat: a bandit raid or a dragon."""
|
||
for event in world["events"]:
|
||
if event["status"] == "upcoming" and event["kind"] in ("raid", "dragon"):
|
||
return event
|
||
return None
|
||
|
||
|
||
def upcoming_event(world: dict) -> dict | None:
|
||
for event in world["events"]:
|
||
if event["status"] == "upcoming":
|
||
return event
|
||
return None
|
||
|
||
|
||
def can_start(world: dict, player: dict, action: dict) -> str | None:
|
||
"""Why the action can't run right now, or None."""
|
||
if skill_level(player, action["skill"]) < action["level"]:
|
||
return f"Needs level {action['level']}"
|
||
if action.get("pattern") and action["pattern"] not in player.get("patterns", []):
|
||
return "Learn its pattern first"
|
||
for key, level in action.get("city", {}).items():
|
||
if building_level(world, key) < level:
|
||
return f"Needs {BUILDINGS[key]['name']} level {level}"
|
||
order = working_order(world, player, action)
|
||
if order is not None or "order" in (player["action"] or {}) and player["action"]["id"] == action["id"]:
|
||
if not order or order["status"] != "open" or order["claimed"] != player["id"]:
|
||
return "Work order done" if order and order["status"] == "done" else "Work order closed"
|
||
for item, qty in fire_inputs(world, action).items():
|
||
pooled = order["provided"].get(item, 0) if order else 0
|
||
if pooled + player["bank"].get(item, 0) < qty:
|
||
return f"Out of {ITEMS[item]['name'].lower()}"
|
||
if action.get("special") == "tend" and _room(world["city"]["fires"][action["tend"]], player, action["tier"]) < 0:
|
||
return f"Your pile in the {FIRES[action['tend']]['name'].lower()} is full"
|
||
if action.get("special") == "sabotage":
|
||
raid = upcoming_raid(world)
|
||
if not raid or intel_level(world, raid) < 3:
|
||
return "No bandit camp has been located"
|
||
if raid["kind"] != "raid":
|
||
return "A dragon has no camp to sabotage"
|
||
if raid["sabotage"] >= 0.3:
|
||
return "The camp is already in ruins"
|
||
if action.get("special") == "labour" and not world["city"]["construction"]:
|
||
return "Nothing is being built"
|
||
if action.get("special") == "problem" and not active_problem(world, action["problem"]):
|
||
return "That's been dealt with"
|
||
blocked = skill_mods(world, action)["blocked"]
|
||
if blocked:
|
||
return blocked
|
||
if action.get("special") == "drill":
|
||
threat = upcoming_raid(world)
|
||
if not threat:
|
||
return "No raid to prepare for"
|
||
if threat.get("drilled", 0) >= drill_needed(threat):
|
||
return "The militia is as drilled as it can be"
|
||
return None
|
||
|
||
|
||
def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict,
|
||
fight: dict | None = None, loot: tuple | None = None) -> None:
|
||
"""Finish one action. A shared fight comes in as `fight`, with this member's share of its `loot`."""
|
||
brew = potion_effect(player, action["skill"])
|
||
order = working_order(world, player, action)
|
||
inputs = fire_inputs(world, action)
|
||
warmed = inputs is not action["inputs"]
|
||
own, used = dict(inputs), {}
|
||
if order:
|
||
used = _draw(order, inputs)
|
||
for share in used.values():
|
||
for item, qty in share.items():
|
||
own[item] -= qty
|
||
own = {item: qty for item, qty in own.items() if qty}
|
||
if own and rng.random() < brew.get("keep", 0):
|
||
summary["kept"] += 1
|
||
else:
|
||
for item, qty in own.items():
|
||
bank_take(player, item, qty)
|
||
xp = action["xp"]
|
||
cls = CLASSES[player["cls"]]
|
||
lucky = bool(action["outputs"]) and (
|
||
action["skill"] in cls["craft"] or (player["cls"] == "miner" and action["skill"] == "mining")
|
||
) and rng.random() < LUCKY_CHANCE
|
||
outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()}
|
||
if lucky:
|
||
summary["lucky"] += 1
|
||
if brew.get("double") and rng.random() < brew["double"]:
|
||
for item, qty in action["outputs"].items():
|
||
outputs[item] += qty
|
||
summary["lucky"] += 1
|
||
# Lucky charm doubles gathering, loupe doubles crafting, for any class.
|
||
jewel = "double" if action["skill"] in GATHERING else "craft_double" if action["skill"] in CRAFTING else None
|
||
if jewel and action["outputs"] and rng.random() < gear_bonus(player, jewel) / 100:
|
||
for item, qty in action["outputs"].items():
|
||
outputs[item] += qty
|
||
summary["lucky"] += 1
|
||
mods = skill_mods(world, action)
|
||
grow = mods["yield"] * (ware(player, "yield", action["skill"], spend=True) if action["outputs"] else 1)
|
||
if grow != 1:
|
||
for item, qty in list(outputs.items()):
|
||
exact = qty * grow
|
||
outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0)
|
||
for item, qty, odds in action.get("finds", []):
|
||
# A gem in the rock: a masterwork (a prospector's double ore) turns up two.
|
||
if rng.random() < odds:
|
||
outputs[item] = outputs.get(item, 0) + qty * (2 if lucky else 1)
|
||
xp *= mods["xp"]
|
||
|
||
if "enemy" in action:
|
||
outing = player["action"]
|
||
fight = fight or outing.pop("fight", None) or simulate_fight(world, player, action, rng, outing["start"])
|
||
mine = member_of(fight, player["id"])
|
||
ware(player, "rest", "combat", spend=True)
|
||
xp = pack_xp(fight, killed_only=mine["result"] != "won") * mods["xp"]
|
||
if player["prayers"] and not mine["pp"]:
|
||
summary["prayer_out"] = True
|
||
paid = min(player["pp"], mine["pp"])
|
||
if paid:
|
||
player["pp"] -= paid
|
||
summary["pp_spent"] += paid
|
||
gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary)
|
||
used = {mine["potion_item"]: 1} if mine["drank"] else {}
|
||
for key, spent in (("eaten", mine["eaten"]), ("used", used)):
|
||
for item, count in spent.items():
|
||
count = min(count, player["bank"].get(item, 0))
|
||
if count:
|
||
bank_take(player, item, count)
|
||
summary[key][item] = summary[key].get(item, 0) + count
|
||
player["hp"] = max_hp(player) * KNOCKED_OUT_HP if mine["down"] else mine["hp"]
|
||
player["hp_at"] = now
|
||
summary["damage"] += mine["damage"]
|
||
if mine["wind_used"]:
|
||
outing["wind_used"] = True
|
||
summary["second_wind"] += 1
|
||
if mine["result"] != "won":
|
||
# Knocked out, or the fight dragged on: nothing but some XP, and back to resting.
|
||
summary["losses"] += 1
|
||
gain_xp(world, player, "combat", xp, now, summary)
|
||
outing["downs"] = 0 if action.get("dungeon") else outing.get("downs", 0) + 1
|
||
reason = None if action.get("dungeon") else "Fell back: the fight dragged on" if mine["result"] == "fled" else \
|
||
f"Knocked out {KNOCKOUTS_TO_STOP} times in a row" if outing["downs"] >= KNOCKOUTS_TO_STOP else None
|
||
if reason:
|
||
drop_out(world, player, now, reason)
|
||
summary["stopped"] = reason
|
||
return
|
||
outing.pop("downs", None)
|
||
summary["wins"] += 1
|
||
gold, drops = loot or pack_loot(action, fight, rng)
|
||
if ware(player, "drops", "combat", spend=True) != 1:
|
||
# A whetstone's edge: another go at the pack's trophies and reagents.
|
||
drops = dict(drops)
|
||
for item, qty in pack_loot(action, fight, rng)[1].items():
|
||
if ITEMS[item].get("trophy") or ITEMS[item].get("reagent"):
|
||
drops[item] = drops.get(item, 0) + qty
|
||
if any(foe.get("boss") for foe in fight["pack"]):
|
||
_deed(player, "bosses")
|
||
emblem = f"boss_{action['id']}"
|
||
if emblem in COSMETICS and emblem not in player["owned"]:
|
||
player["owned"].append(emblem)
|
||
player["gold"] += gold
|
||
summary["gold"] += gold
|
||
for item, qty in drops.items():
|
||
outputs[item] = outputs.get(item, 0) + qty
|
||
if action.get("special") == "sabotage":
|
||
raid = upcoming_raid(world)
|
||
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.01)
|
||
if action.get("special") == "problem":
|
||
_work_problem(world, player, action, now)
|
||
if action.get("special") == "recon":
|
||
event = upcoming_raid(world)
|
||
# Once a threat is fully scouted, scouting only trains the skill (and keeps the informant's leads).
|
||
if event and event["recon"] < intel_costs(event)[-1]:
|
||
city = world["city"]
|
||
gain = recon_gain(world, player, action)
|
||
# An informant's leads double the reconnaissance (and the XP) of whoever follows them up.
|
||
boost = min(city.get("recon_boost", 0), gain)
|
||
city["recon_boost"] = city.get("recon_boost", 0) - boost
|
||
xp *= 1 + boost / gain
|
||
event["recon"] = min(intel_costs(event)[-1], event["recon"] + gain + boost)
|
||
if action.get("special") == "problem" and action["skill"] != "combat":
|
||
_work_problem(world, player, action, now)
|
||
threat = upcoming_raid(world) if action.get("special") == "drill" else None
|
||
if threat:
|
||
city = world["city"]
|
||
gain = drill_per_action(player)
|
||
# A drill sergeant doubles the drilling (and the XP) of whoever does it while the boost lasts.
|
||
boost = min(city.get("drill_boost", 0), gain)
|
||
city["drill_boost"] = city.get("drill_boost", 0) - boost
|
||
xp = DRILL_XP * (gain + boost) * mods["xp"]
|
||
threat["drilled"] = min(drill_needed(threat), threat.get("drilled", 0) + gain + boost)
|
||
if action.get("special") == "tend":
|
||
xp = TEND_XP[action["tier"]] * feed_fire(world, player, action["tend"], action["tier"]) * mods["xp"]
|
||
if warmed:
|
||
_warm(world, player, action, xp, now)
|
||
if action.get("special") == "labour":
|
||
city = world["city"]
|
||
site = city["construction"]
|
||
gain = labour_per_action(player)
|
||
# A master builder doubles the labour (and the XP) of whoever works while the boost lasts.
|
||
boost = min(city.get("site_boost", 0), gain)
|
||
city["site_boost"] = city.get("site_boost", 0) - boost
|
||
xp *= 1 + boost / gain
|
||
site["labour"] += gain + boost
|
||
summary["labour"] += gain + boost
|
||
if site["labour"] >= site["needed"]:
|
||
_finish_construction(world, now)
|
||
_start_next(world, now)
|
||
bonus = 1 + brew.get("bones", 0)
|
||
if action.get("special") == "bury":
|
||
player["pp"] += bone_points(world, action) * bonus
|
||
summary["pp_gained"] += bone_points(world, action) * bonus
|
||
if action.get("special") == "offer":
|
||
city = world["city"]
|
||
city["devotion"] = city.get("devotion", 0) + action["devotion"] * (1 + 0.25 * building_level(world, "temple")) * bonus
|
||
if action.get("special") != "drill":
|
||
_use_charge(player, action["skill"], summary)
|
||
|
||
if order and order["status"] == "open":
|
||
# The order's share goes to the requester; a masterwork's extra stays with the crafter.
|
||
sent = min(outputs.get(order["item"], 0), order["qty"] - order["delivered"])
|
||
if sent:
|
||
outputs[order["item"]] -= sent
|
||
_deliver(world, order, player, sent, now, used, inputs)
|
||
summary["delivered"] = summary.get("delivered", 0) + sent
|
||
for item, qty in outputs.items():
|
||
if not qty:
|
||
continue
|
||
kept = bank_add(world, player, item, qty, now, summary)
|
||
if kept:
|
||
summary["items"][item] = summary["items"].get(item, 0) + kept
|
||
xp *= ware(player, "xp", action["skill"], spend=True)
|
||
ware(player, "speed", action["skill"], spend=True)
|
||
gain_xp(world, player, action["skill"], xp, now, summary)
|
||
|
||
|
||
def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summary: dict | None = None) -> None:
|
||
before = skill_level(player, skill)
|
||
xp *= 1 + gear_bonus(player, "xp") / 100
|
||
player["xp"][skill] += xp
|
||
after = skill_level(player, skill)
|
||
if summary is not None:
|
||
summary["xp"][skill] = summary["xp"].get(skill, 0) + xp
|
||
if after > before:
|
||
if summary is not None:
|
||
summary["levels"] = [entry for entry in summary["levels"] if entry[0] != skill] + [[skill, after]]
|
||
if after % 10 == 0 or after == MAX_LEVEL:
|
||
name = next(s["name"] for s in SKILLS if s["id"] == skill)
|
||
log(world, now, "level", f"{player['name']} reached {name} level {after}", player["id"])
|
||
|
||
|
||
def empty_summary() -> dict:
|
||
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "used": {}, "damage": 0,
|
||
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None,
|
||
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0,
|
||
"kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 0}
|
||
|
||
|
||
def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict:
|
||
"""Run the player's action forward to `now` (capped at 24h past last_active)."""
|
||
rng = rng or random.Random()
|
||
summary = empty_summary()
|
||
collect_orders(world, player)
|
||
current = player["action"]
|
||
if current and current.get("party"):
|
||
if current["party"] in world["parties"]:
|
||
process_party(world, current["party"], now, rng)
|
||
else:
|
||
current.pop("party")
|
||
current = player["action"]
|
||
if current and current.get("party"):
|
||
return summary # a party's kills land in each member's away summary
|
||
if not current:
|
||
return summary
|
||
limit = min(now, offline_limit(world, player))
|
||
if ACTION_BY_ID[current["id"]].get("together"):
|
||
# A group fight with no group: wait at it for someone to tag in.
|
||
current.pop("fight", None)
|
||
current["start"] = max(current["start"], limit)
|
||
return summary
|
||
while player["action"]:
|
||
action = growing(player)
|
||
if current.get("plot2") and can_start(world, player, action):
|
||
# The second crop can't grow right now (a problem, too low a level): tend the first only.
|
||
current["turn"] = 0
|
||
action = growing(player)
|
||
if "enemy" in action and not can_start(world, player, action):
|
||
if not current.get("fight"):
|
||
regen(world, player, current["start"])
|
||
current["fight"] = simulate_fight(world, player, action, rng, current["start"])
|
||
duration = current["fight"]["duration"]
|
||
else:
|
||
duration = action_time(world, player, action)
|
||
if current["start"] + duration > limit:
|
||
break
|
||
burn_fires(world, current["start"] + duration)
|
||
reason = can_start(world, player, action)
|
||
if reason:
|
||
stop(player, reason)
|
||
summary["stopped"] = reason
|
||
break
|
||
if player["overflow"] == "stop" and any(bank_room(world, player, item) < qty
|
||
for item, qty in action["outputs"].items()):
|
||
stop(player, "Bank full")
|
||
summary["stopped"] = "Bank full"
|
||
break
|
||
_complete(world, player, action, current["start"] + duration, rng, summary)
|
||
summary["done"] += 1
|
||
current["start"] += duration
|
||
if current.get("plot2"):
|
||
current["turn"] = 1 - current.get("turn", 0)
|
||
if player["action"] is current and "left" in current:
|
||
current["left"] -= 1
|
||
if current["left"] <= 0:
|
||
back = current.get("resume")
|
||
if back:
|
||
plot2 = current.get("resume_plot2")
|
||
player["action"] = current = {"id": back, "start": current["start"]}
|
||
if plot2:
|
||
current.update(plot2=plot2, turn=0)
|
||
summary["resumed"] = back
|
||
else:
|
||
stop(player)
|
||
if player["action"] is current and "enemy" not in action and not current.get("plot2"):
|
||
# Out of materials: stop now, not once the next bar has filled.
|
||
reason = can_start(world, player, growing(player))
|
||
if reason:
|
||
stop(player, reason)
|
||
summary["stopped"] = reason
|
||
break
|
||
if player["action"] and limit < now and current["start"] < now:
|
||
current["start"] = now
|
||
return summary
|
||
|
||
|
||
def stop(player: dict, reason: str | None = None) -> None:
|
||
player["action"] = None
|
||
player["stopped"] = reason
|
||
|
||
|
||
def drop_out(world: dict, player: dict, now: float, reason: str | None = None) -> None:
|
||
"""Stop, stepping out of a shared fight if you're in one."""
|
||
current = player["action"]
|
||
if current and current.get("party") in world["parties"]:
|
||
leave_party(world, player, current["party"], now)
|
||
stop(player, reason)
|
||
|
||
|
||
def roll_loot(action: dict, rng: random.Random, share: float = 1.0) -> tuple[int, dict]:
|
||
"""A kill's gold and drops. A weaker enemy in a pack (`share`) carries less, and drops less often."""
|
||
gold = round(rng.randint(*action["gold"]) * share) if action.get("gold") else 0
|
||
drops = {}
|
||
for item, qty, odds in action.get("drops", []):
|
||
if rng.random() < odds * share:
|
||
drops[item] = drops.get(item, 0) + qty
|
||
return gold, drops
|
||
|
||
|
||
def pack_loot(action: dict, fight: dict, rng: random.Random) -> tuple[int, dict]:
|
||
"""Everything the pack carried."""
|
||
gold, drops = 0, {}
|
||
kills = set(fight["kills"])
|
||
for i, foe in enumerate(fight["pack"]):
|
||
if f"e{i}" not in kills:
|
||
continue
|
||
more, found = roll_loot(ACTION_BY_ID[foe["loot"]] if foe.get("loot") else action, rng, foe["share"])
|
||
for item, qty, odds in foe.get("extra_drops", []):
|
||
if rng.random() < odds:
|
||
found[item] = found.get(item, 0) + qty
|
||
gold += more
|
||
for item, qty in found.items():
|
||
drops[item] = drops.get(item, 0) + qty
|
||
return gold, drops
|
||
|
||
|
||
def share_loot(loot: tuple[int, dict], uids: list, rng: random.Random) -> dict:
|
||
"""Gold split evenly (the odd coins to whoever's lucky), each drop to someone at random."""
|
||
gold, drops = loot
|
||
each, odd = divmod(gold, len(uids))
|
||
lucky = set(rng.sample(uids, odd))
|
||
shares = {uid: (each + (uid in lucky), {}) for uid in uids}
|
||
for item, qty in drops.items():
|
||
for _ in range(qty):
|
||
got = shares[rng.choice(uids)][1]
|
||
got[item] = got.get(item, 0) + 1
|
||
return shares
|
||
|
||
|
||
# --- fighting together ------------------------------------------------------
|
||
# Anyone can tag into someone else's fight: from then on they're a party on that
|
||
# encounter, every enemy fought by all of them at once. Everyone gets the kill's full
|
||
# XP; its gold is split evenly and each drop goes to someone at random, except on
|
||
# bosses and dragons, where everyone rolls their own loot as if alone. The party
|
||
# lasts until all but one have left (stopped, retreated, or been away too long), and
|
||
# the last one carries on alone.
|
||
|
||
def offline_limit(world: dict, player: dict) -> float:
|
||
return player["last_active"] + OFFLINE_CAP / speed(world)
|
||
|
||
|
||
def joinable(world: dict, player: dict) -> bool:
|
||
"""Whether someone's fight is one others can tag into."""
|
||
current = player["action"]
|
||
if not current or "left" in current or "order" in current:
|
||
return False
|
||
action = ACTION_BY_ID[current["id"]]
|
||
if action.get("dungeon"):
|
||
party = world["parties"].get(current.get("party"))
|
||
return not party or ("run" not in party and len(party["members"]) < DUNGEON_SIZE)
|
||
return "enemy" in action and not action.get("special")
|
||
|
||
|
||
def current_fight(world: dict, player: dict) -> dict | None:
|
||
current = player["action"] or {}
|
||
party = world["parties"].get(current.get("party"))
|
||
return party["fight"] if party else current.get("fight")
|
||
|
||
|
||
def party_of(world: dict, player: dict) -> dict | None:
|
||
return world["parties"].get((player["action"] or {}).get("party"))
|
||
|
||
|
||
def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None:
|
||
"""Tag into someone's fight, swinging at the enemy they're on right now."""
|
||
host = world["players"].get(uid)
|
||
if not host or host is player:
|
||
return "No one to join"
|
||
run = party_of(world, host)
|
||
if run and ACTION_BY_ID[run["action"]].get("dungeon") and run is not party_of(world, player):
|
||
if "run" in run:
|
||
return "The run has started"
|
||
if len(run["members"]) >= DUNGEON_SIZE:
|
||
return "The group is full"
|
||
if not joinable(world, host):
|
||
return f"{host['name']} isn't fighting"
|
||
if offline_limit(world, host) <= now:
|
||
return f"{host['name']} is away"
|
||
party = party_of(world, host)
|
||
if party and party is party_of(world, player):
|
||
return "Already fighting together"
|
||
action = ACTION_BY_ID[host["action"]["id"]]
|
||
reason = can_start(world, player, action)
|
||
if reason:
|
||
return reason
|
||
if party_of(world, player):
|
||
leave_party(world, player, player["action"]["party"], now)
|
||
if not party:
|
||
# Someone fighting alone becomes a party of one, their fight as it stands.
|
||
pid = str(world["next_party_id"])
|
||
world["next_party_id"] += 1
|
||
solo = host["action"]
|
||
if action.get("together"):
|
||
solo["start"] = now # waiting for a group: it starts as you arrive
|
||
party = world["parties"][pid] = {"id": pid, "action": solo["id"], "members": [host["id"]],
|
||
"start": solo["start"], "fight": solo.pop("fight", None)}
|
||
solo["party"] = pid
|
||
party["members"].append(player["id"])
|
||
player["action"] = {"id": party["action"], "start": party["start"], "party": party["id"]}
|
||
player["stopped"] = None
|
||
fight = party["fight"]
|
||
if fight and now < party["start"] + fight["duration"]:
|
||
# Straight in, as you are: the others may still be resting, or already in the thick of it.
|
||
regen(world, player, now)
|
||
at = max(0.0, (now - party["start"]) * fight["pace"] - fight["rest"])
|
||
fight["members"].append(fight_member(world, player, at))
|
||
play_fight(fight)
|
||
log(world, now, "party", f"{player['name']} joined {host['name']}: {action['name']}", player["id"])
|
||
return None
|
||
|
||
|
||
def leave_party(world: dict, player: dict, pid: str, now: float) -> None:
|
||
"""Step out of a party at `now`. The fight goes on without you (you get nothing from it); whoever's
|
||
left alone carries on by themselves. Setting your own action is up to the caller."""
|
||
party = world["parties"][pid]
|
||
party["members"].remove(player["id"])
|
||
fight = party["fight"]
|
||
if fight and now < party["start"] + fight["duration"]:
|
||
member = member_of(fight, player["id"])
|
||
left = (now - party["start"]) * fight["pace"] - fight["rest"]
|
||
if left <= member["at"] or len(fight["members"]) == 1:
|
||
fight["members"].remove(member) # gone before it started: nothing eaten, nothing fought
|
||
else:
|
||
member["left"] = left
|
||
party["fight"] = play_fight(fight) if party["members"] and fight["members"] else None
|
||
if len(party["members"]) <= 1:
|
||
del world["parties"][pid]
|
||
for uid in party["members"]:
|
||
last = world["players"][uid]["action"]
|
||
last.pop("party", None)
|
||
last.update(start=party["start"], **({"fight": party["fight"]} if party["fight"] else {}))
|
||
|
||
|
||
def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None:
|
||
"""Run a party's fights forward to `now`: every kill lands for everyone in it at once. What
|
||
each member gets waits in their away summary."""
|
||
party = world["parties"][pid]
|
||
action = ACTION_BY_ID[party["action"]]
|
||
for uid in list(party["members"]):
|
||
if (world["players"][uid]["action"] or {}).get("party") != pid and world["parties"].get(pid) is party:
|
||
leave_party(world, world["players"][uid], pid, party["start"]) # moved on without saying
|
||
while world["parties"].get(pid) is party:
|
||
members = [world["players"][uid] for uid in party["members"]]
|
||
if not party["fight"]:
|
||
for player in members:
|
||
regen(world, player, party["start"])
|
||
reason = can_start(world, player, action)
|
||
if reason:
|
||
drop_out(world, player, party["start"], reason)
|
||
player.setdefault("away", empty_summary())["stopped"] = reason
|
||
if world["parties"].get(pid) is not party:
|
||
break
|
||
members = [world["players"][uid] for uid in party["members"]]
|
||
if action.get("dungeon"):
|
||
# A run waits for its leader to start it, then for anyone who's looked away: it's played together.
|
||
away = [p for p in members if p["last_active"] < party["start"] - DUNGEON_AWAY]
|
||
if "run" not in party or away:
|
||
party["start"] = max(party["start"], now)
|
||
for player in members:
|
||
player["action"]["start"] = party["start"]
|
||
break
|
||
party["fight"] = draw_pull(world, members, action, party["run"],
|
||
rng, party["start"])
|
||
else:
|
||
party["fight"] = draw_fight(world, members, action, rng, party["start"])
|
||
end = party["start"] + party["fight"]["duration"]
|
||
late = [player for player in members if offline_limit(world, player) < min(end, now)]
|
||
if late:
|
||
# Away too long: they step out where their time ran out, and pick up alone when they're back.
|
||
for player in late:
|
||
if world["parties"].get(pid) is not party:
|
||
break # the last of them carries on alone, and picks up when they're back
|
||
at = max(party["start"], offline_limit(world, player))
|
||
wind = {"wind_used": True} if player["action"].get("wind_used") else {}
|
||
leave_party(world, player, pid, at)
|
||
player["action"] = {"id": action["id"], "start": at, **wind}
|
||
continue
|
||
if end > now:
|
||
break
|
||
burn_fires(world, end)
|
||
fight, party["fight"] = party["fight"], None
|
||
party["start"] = end
|
||
for player in members:
|
||
player["action"]["start"] = party["start"]
|
||
results = {m["id"]: m["result"] for m in fight["members"]}
|
||
winners = [uid for uid in party["members"] if results.get(uid) == "won"]
|
||
if action.get("personal_loot") and (not action.get("dungeon") or any(foe["boss"] for foe in fight["pack"])):
|
||
shares = {uid: pack_loot(action, fight, rng) for uid in winners}
|
||
else:
|
||
shares = share_loot(pack_loot(action, fight, rng), winners, rng) if winners else {}
|
||
for player in members:
|
||
if results.get(player["id"], "left") == "left":
|
||
continue
|
||
summary = player.setdefault("away", empty_summary())
|
||
_complete(world, player, action, end, rng, summary, fight=fight, loot=shares.get(player["id"]))
|
||
summary["done"] += 1
|
||
together = summary.setdefault("with", {})
|
||
for other in members:
|
||
if other is not player:
|
||
together[other["name"]] = together.get(other["name"], 0) + 1
|
||
if action.get("dungeon") and world["parties"].get(pid) is party:
|
||
_advance_run(world, party, action, fight, members, end, rng)
|
||
|
||
|
||
def draw_pull(world: dict, players: list, action: dict, run: dict, rng: random.Random, start: float) -> dict:
|
||
"""The run's next pull, sized for the group, everyone rested first."""
|
||
spec = DUNGEONS[action["dungeon"]]
|
||
name, pack = spec["pulls"][run["pull"]]
|
||
hp, hits = dungeon_scale(len(players))
|
||
members, rest = [], 0.0
|
||
for player in players:
|
||
seconds, eaten = rest_plan(world, player)
|
||
member = fight_member(world, player)
|
||
member.update(eaten=eaten, hp_in=member["top"])
|
||
rest = max(rest, seconds)
|
||
members.append(member)
|
||
fight = {"seed": rng.random(), "pace": speed(world), "rest": rest, "enemy": name, "members": members,
|
||
"pack": [{**foe, "hp": max(1, round(foe["hp"] * hp)), "max_hit": max(1, round(foe["max_hit"] * hits)),
|
||
"phases": [dict(phase) for phase in foe.get("phases", [])]} for foe in pack],
|
||
"stage": {"dungeon": spec["name"], "pull": run["pull"] + 1, "of": len(spec["pulls"]), "wipes": run["wipes"]}}
|
||
for member, player in zip(members, players):
|
||
member["watch"] = fight_time(fight, start, player["last_active"] + combat.WATCH)
|
||
return play_fight(fight)
|
||
|
||
|
||
def _advance_run(world: dict, party: dict, action: dict, fight: dict, members: list, now: float, rng: random.Random) -> None:
|
||
"""After a pull: on to the next, or the same again after a wipe. Clearing it or wiping too often ends it."""
|
||
spec, run = DUNGEONS[action["dungeon"]], party["run"]
|
||
names = ", ".join(p["name"] for p in members)
|
||
if fight["result"] == "won":
|
||
run["pull"] += 1
|
||
run["wipes"] = 0
|
||
if run["pull"] < len(spec["pulls"]):
|
||
return
|
||
log(world, now, "party", f"{names} cleared {spec['name']}!")
|
||
for player in members:
|
||
_clear_bonus(world, player, spec, now, rng)
|
||
reason = f"Cleared {spec['name']}"
|
||
else:
|
||
run["wipes"] += 1
|
||
if run["wipes"] < DUNGEON_WIPES:
|
||
return
|
||
reason = f"Wiped {DUNGEON_WIPES} times at {spec['pulls'][run['pull']][0]}"
|
||
log(world, now, "party", f"{names}: {reason.lower()} in {spec['name']}")
|
||
del world["parties"][party["id"]]
|
||
for player in members:
|
||
stop(player, reason)
|
||
player.setdefault("away", empty_summary())["stopped"] = reason
|
||
|
||
|
||
def _clear_bonus(world: dict, player: dict, spec: dict, now: float, rng: random.Random) -> None:
|
||
"""What clearing a run pays everyone in it: gold, one of the area's trophies and two of its reagents."""
|
||
summary = player.setdefault("away", empty_summary())
|
||
player["gold"] += spec["bonus_gold"]
|
||
summary["gold"] += spec["bonus_gold"]
|
||
found = [trophy(rng.choice(list(STYLES)), spec["tier"]), *(reagent(rng.choice(list(REAGENT_NOUNS)), spec["tier"])
|
||
for _ in range(2))] if spec["tier"] else []
|
||
for item in found:
|
||
kept = bank_add(world, player, item, 1, now, summary)
|
||
if kept:
|
||
summary["items"][item] = summary["items"].get(item, 0) + kept
|
||
|
||
|
||
def run_of(world: dict, player: dict) -> dict | None:
|
||
"""Where your group's dungeon run stands, if you're in one."""
|
||
party = party_of(world, player)
|
||
if not party or not ACTION_BY_ID[party["action"]].get("dungeon"):
|
||
return None
|
||
spec = DUNGEONS[ACTION_BY_ID[party["action"]]["dungeon"]]
|
||
run = party.get("run", {"pull": 0, "wipes": 0})
|
||
away = [world["players"][uid]["name"] for uid in party["members"]
|
||
if world["players"][uid]["last_active"] < party["start"] - DUNGEON_AWAY]
|
||
return {"dungeon": spec["name"], "pull": run["pull"] + 1, "of": len(spec["pulls"]), "wipes": run["wipes"],
|
||
"next": spec["pulls"][min(run["pull"], len(spec["pulls"]) - 1)][0], "away": away,
|
||
"leader": party["members"][0], "size": len(party["members"]), "started": "run" in party}
|
||
|
||
|
||
def start_run(world: dict, player: dict, now: float) -> str | None:
|
||
"""The leader takes the group in: no one else can join from here on."""
|
||
party = party_of(world, player)
|
||
if not party or not ACTION_BY_ID[party["action"]].get("dungeon"):
|
||
return "Not in a dungeon"
|
||
if party["members"][0] != player["id"]:
|
||
return "Only the leader starts"
|
||
if "run" in party:
|
||
return "Already started"
|
||
if len(party["members"]) < GROUP_SIZE:
|
||
return f"Needs {GROUP_SIZE} or more"
|
||
party["run"] = {"pull": 0, "wipes": 0}
|
||
party["start"] = now
|
||
for uid in party["members"]:
|
||
world["players"][uid]["action"]["start"] = now
|
||
spec = DUNGEONS[ACTION_BY_ID[party["action"]]["dungeon"]]
|
||
log(world, now, "party", f"{player['name']} led {len(party['members'])} into {spec['name']}", player["id"])
|
||
return None
|
||
|
||
|
||
def pull_now(world: dict, player: dict, now: float) -> str | None:
|
||
"""The leader cuts the rest short: everyone goes in as healed as they are by now."""
|
||
party = party_of(world, player)
|
||
if not party or not ACTION_BY_ID[party["action"]].get("dungeon"):
|
||
return "Not in a dungeon"
|
||
if party["members"][0] != player["id"]:
|
||
return "Only the leader pulls"
|
||
fight = party["fight"]
|
||
if not fight:
|
||
return "Nothing to pull yet"
|
||
rested = (now - party["start"]) * fight["pace"]
|
||
if rested >= fight["rest"]:
|
||
return "Already fighting"
|
||
rested = math.ceil(rested / combat.STEP) * combat.STEP
|
||
share = rested / fight["rest"] if fight["rest"] else 1
|
||
for member in fight["members"]:
|
||
member["hp_in"] = member["start_hp"] + (member["hp_in"] - member["start_hp"]) * share
|
||
member["eaten"] = {food: int(count * share) for food, count in member["eaten"].items() if int(count * share)}
|
||
member["watch"] += fight["rest"] - rested
|
||
fight["rest"] = rested
|
||
play_fight(fight)
|
||
return None
|
||
|
||
|
||
def catch_up(world: dict, now: float, rng: random.Random | None = None) -> bool:
|
||
"""Run everyone's actions up to now, so the shared fires see feeds and crafts as they
|
||
happen. What each player missed waits in their `away` summary for their next visit."""
|
||
changed = False
|
||
parties = lambda: {pid: party["start"] for pid, party in world["parties"].items()}
|
||
before = parties()
|
||
for player in world["players"].values():
|
||
summary = process_player(world, player, now, rng)
|
||
if summary["done"] or summary["stopped"]:
|
||
merge_summary(player.setdefault("away", empty_summary()), summary)
|
||
changed = True
|
||
return changed or parties() != before
|
||
|
||
|
||
def merge_summary(into: dict, more: dict) -> dict:
|
||
"""Fold a later summary into an earlier one."""
|
||
for key, value in more.items():
|
||
if isinstance(value, bool):
|
||
into[key] = into.get(key) or value
|
||
elif isinstance(value, (int, float)):
|
||
into[key] = into.get(key, 0) + value
|
||
elif isinstance(value, dict):
|
||
for item, qty in value.items():
|
||
into.setdefault(key, {})[item] = into[key].get(item, 0) + qty
|
||
elif key == "levels":
|
||
into[key] = [entry for entry in into.get(key, []) if entry[0] not in {s for s, _ in value}] + value
|
||
elif isinstance(value, list):
|
||
into[key] = into.get(key, []) + value
|
||
elif value is not None:
|
||
into[key] = value
|
||
return into
|
||
|
||
|
||
def take_summary(player: dict, summary: dict) -> dict:
|
||
"""Everything that happened since the player last looked: the catch-up's and this request's."""
|
||
away = player.pop("away", None)
|
||
return merge_summary(away, summary) if away else summary
|
||
|
||
|
||
# --- work orders -----------------------------------------------------------
|
||
# A player asks the guild for something. A crafting order names a recipe: anyone
|
||
# can put its reagents into the order's pool, one crafter claims it and makes it
|
||
# with the normal action (pool first, their own materials for the rest), and each
|
||
# piece goes straight to the requester. A materials request is open to anyone who
|
||
# has the items. Reagents and the reward are held by the order, paid out as it's
|
||
# filled, and handed back to whoever put them in if it's cancelled, runs out of
|
||
# time, or finishes with some left over.
|
||
|
||
ORDER_LIMIT = 5 # open orders per player
|
||
ORDER_DAYS = 3 # before an order expires and is refunded
|
||
ORDER_MAX = {"craft": 500, "gather": 2000}
|
||
INPUT_SHARE = 0.5 # of each piece's reward, split by value among whoever's reagents went in
|
||
|
||
|
||
def _order_sources() -> tuple[dict, dict]:
|
||
craft, gather = {}, {}
|
||
for action in sorted(ACTIONS, key=lambda a: a["level"]):
|
||
if action.get("special") or "enemy" in action or not action["outputs"]:
|
||
continue
|
||
item = next(iter(action["outputs"]))
|
||
if action["skill"] in CRAFTING:
|
||
craft.setdefault(item, action["id"])
|
||
elif action["skill"] in GATHERING:
|
||
for output in [*action["outputs"], *(item for item, _qty, _odds in action.get("finds", []))]:
|
||
gather.setdefault(output, action["id"])
|
||
return craft, gather
|
||
|
||
|
||
|
||
def order_by_id(world: dict, order_id) -> dict | None:
|
||
return next((o for o in world["orders"] if o["id"] == order_id), None)
|
||
|
||
|
||
def _batches(action: dict, item: str, qty: int) -> int:
|
||
"""How many times the recipe runs to make `qty` of its item."""
|
||
return -(-qty // action["outputs"][item])
|
||
|
||
|
||
def post_order(world: dict, player: dict, kind: str, item: str, qty, provide, reward, now: float) -> str | None:
|
||
if kind not in ("craft", "gather"):
|
||
return "Bad order"
|
||
source = (ORDER_RECIPES if kind == "craft" else ORDER_RAW).get(item)
|
||
if not source:
|
||
return "Nobody can make that" if kind == "craft" else "Nobody gathers that"
|
||
if not isinstance(qty, int) or not 1 <= qty <= ORDER_MAX[kind]:
|
||
return f"1 to {ORDER_MAX[kind]}"
|
||
if not isinstance(reward, int) or reward < 0:
|
||
return "Bad reward"
|
||
if sum(o["by"] == player["id"] and o["status"] == "open" for o in world["orders"]) >= ORDER_LIMIT:
|
||
return f"At most {ORDER_LIMIT} open orders"
|
||
action = ACTION_BY_ID[source]
|
||
total = reward if kind == "craft" else reward * qty # a materials request pays per item
|
||
if player["gold"] < total:
|
||
return "Not enough gold"
|
||
provided = {}
|
||
if kind == "craft":
|
||
# The pool holds what a run takes right now: a lit fire's share stays with the fire.
|
||
runs, inputs = _batches(action, item, qty), fire_inputs(world, action)
|
||
for input_item in provide or []:
|
||
if input_item not in action["inputs"]:
|
||
return "That isn't in the recipe"
|
||
if input_item not in inputs:
|
||
return f"The {FIRES[action['fire']]['name'].lower()} pays that"
|
||
need = inputs[input_item] * runs
|
||
if player["bank"].get(input_item, 0) < need:
|
||
return f"Not enough {ITEMS[input_item]['name'].lower()}"
|
||
provided[input_item] = need
|
||
for input_item, need in provided.items():
|
||
bank_take(player, input_item, need)
|
||
player["gold"] -= total
|
||
order = {"id": world["next_order_id"], "kind": kind, "by": player["id"], "at": now,
|
||
"expires": now + ORDER_DAYS * DAY / speed(world), "item": item, "qty": qty, "action": source,
|
||
"skill": action["skill"], "level": action["level"], "provided": provided, "reward": total,
|
||
"paid": 0, "delivered": 0, "held": 0, "claimed": None, "from": {}, "status": "open",
|
||
"gave": {player["id"]: dict(provided)} if provided else {}, "earned": {}}
|
||
world["next_order_id"] += 1
|
||
world["orders"].append(order)
|
||
return None
|
||
|
||
|
||
def _gave(order: dict) -> dict:
|
||
"""Who has reagents in the pool: {player: {item: qty}}, oldest first. Older orders only had the requester's."""
|
||
return order.setdefault("gave", {order["by"]: dict(order["provided"])} if order["provided"] else {})
|
||
|
||
|
||
def provide_reagent(world: dict, player: dict, order_id, item: str) -> str | None:
|
||
"""Put a reagent into a crafting order's pool, up to what's still to be made."""
|
||
order = order_by_id(world, order_id)
|
||
if not order or order["status"] != "open" or order["kind"] != "craft":
|
||
return "No such order"
|
||
action = ACTION_BY_ID[order["action"]]
|
||
if item not in action["inputs"]:
|
||
return "That isn't in the recipe"
|
||
inputs = fire_inputs(world, action)
|
||
if item not in inputs:
|
||
return f"The {FIRES[action['fire']]['name'].lower()} pays that"
|
||
need = inputs[item] * _batches(action, order["item"], order["qty"] - order["delivered"])
|
||
short = need - order["provided"].get(item, 0)
|
||
if short <= 0:
|
||
return "Already provided"
|
||
qty = min(player["bank"].get(item, 0), short)
|
||
if not qty:
|
||
return f"No {ITEMS[item]['name'].lower()} in your bank"
|
||
bank_take(player, item, qty)
|
||
order["provided"][item] = order["provided"].get(item, 0) + qty
|
||
mine = _gave(order).setdefault(player["id"], {})
|
||
mine[item] = mine.get(item, 0) + qty
|
||
return None
|
||
|
||
|
||
def _draw(order: dict, inputs: dict) -> dict:
|
||
"""Take one run's reagents from the pool, oldest first: {player: {item: qty}} of whose went in."""
|
||
used = {}
|
||
for item, qty in inputs.items():
|
||
take = min(order["provided"].get(item, 0), qty)
|
||
if not take:
|
||
continue
|
||
order["provided"][item] -= take
|
||
for uid, items in _gave(order).items():
|
||
part = min(items.get(item, 0), take)
|
||
if part:
|
||
items[item] -= part
|
||
used.setdefault(uid, {})[item] = part
|
||
take -= part
|
||
if not take:
|
||
break
|
||
return used
|
||
|
||
|
||
def _refund(world: dict, order: dict, now: float) -> None:
|
||
"""Hand back what's left in the pool to whoever put it in, and the unpaid reward to the requester."""
|
||
for uid, items in _gave(order).items():
|
||
player = world["players"].get(uid)
|
||
for item, qty in items.items():
|
||
if qty and player:
|
||
bank_add(world, player, item, qty, now, empty_summary())
|
||
order["provided"], order["gave"] = {}, {}
|
||
owner = world["players"].get(order["by"])
|
||
if owner:
|
||
owner["gold"] += order["reward"] - order["paid"]
|
||
order["paid"] = order["reward"]
|
||
|
||
|
||
def cancel_order(world: dict, player: dict, order_id, now: float) -> str | None:
|
||
order = order_by_id(world, order_id)
|
||
if not order or order["by"] != player["id"] or order["status"] != "open":
|
||
return "No such order"
|
||
order["status"] = "cancelled"
|
||
_refund(world, order, now)
|
||
return None
|
||
|
||
|
||
def remove_orders(world: dict, player: dict, order_id=None) -> str | None:
|
||
"""Clear your finished orders: one, or all of them. Ones still holding items for your bank stay."""
|
||
gone = [o for o in world["orders"] if o["by"] == player["id"] and o["status"] != "open" and not o["held"]
|
||
and order_id in (None, o["id"])]
|
||
if not gone:
|
||
return "No such order"
|
||
world["orders"] = [o for o in world["orders"] if o not in gone]
|
||
return None
|
||
|
||
|
||
def claim_order(world: dict, player: dict, order_id, claim: bool = True) -> str | None:
|
||
order = order_by_id(world, order_id)
|
||
if not order or order["status"] != "open" or order["kind"] != "craft":
|
||
return "No such order"
|
||
if not claim:
|
||
if order["claimed"] == player["id"]:
|
||
order["claimed"] = None
|
||
if (player["action"] or {}).get("order") == order["id"]:
|
||
stop(player)
|
||
return None
|
||
if order["by"] == player["id"]:
|
||
return "That's your own order"
|
||
if order["claimed"] not in (None, player["id"]):
|
||
return "Someone else has it"
|
||
if skill_level(player, order["skill"]) < order["level"]:
|
||
return f"Needs {order['skill']} {order['level']}"
|
||
order["claimed"] = player["id"]
|
||
return None
|
||
|
||
|
||
def work_order(world: dict, player: dict, order_id, now: float) -> str | None:
|
||
"""Claim a crafting order and start making it."""
|
||
order = order_by_id(world, order_id)
|
||
had = order and order["claimed"] == player["id"]
|
||
error = claim_order(world, player, order_id)
|
||
if error:
|
||
return error
|
||
previous = player["action"]
|
||
player["action"] = {"id": order["action"], "start": now, "order": order["id"]}
|
||
reason = can_start(world, player, ACTION_BY_ID[order["action"]])
|
||
if reason:
|
||
player["action"] = previous
|
||
if not had:
|
||
order["claimed"] = None # couldn't start: don't sit on it
|
||
return reason
|
||
if previous and previous.get("party") in world["parties"]:
|
||
leave_party(world, player, previous["party"], now)
|
||
player["stopped"] = None
|
||
return None
|
||
|
||
|
||
def deliver_order(world: dict, player: dict, order_id, now: float) -> str | None:
|
||
"""Hand over what's asked for straight from your bank."""
|
||
order = order_by_id(world, order_id)
|
||
if not order or order["status"] != "open":
|
||
return "No such order"
|
||
if order["by"] == player["id"]:
|
||
return "That's your own order"
|
||
if order["kind"] == "craft":
|
||
error = claim_order(world, player, order_id)
|
||
if error:
|
||
return error
|
||
qty = min(player["bank"].get(order["item"], 0), order["qty"] - order["delivered"])
|
||
if not qty:
|
||
return f"No {ITEMS[order['item']]['name'].lower()} in your bank"
|
||
bank_take(player, order["item"], qty)
|
||
_deliver(world, order, player, qty, now)
|
||
return None
|
||
|
||
|
||
def _deliver(world: dict, order: dict, giver: dict, qty: int, now: float,
|
||
used: dict | None = None, inputs: dict | None = None) -> None:
|
||
"""Pay for `qty` pieces. Whoever's reagents went into them (`used` of the run's `inputs`) shares
|
||
INPUT_SHARE of the pay by value; the requester's own reagents leave theirs with the crafter."""
|
||
order["delivered"] += qty
|
||
order["from"][giver["id"]] = order["from"].get(giver["id"], 0) + qty
|
||
order["held"] += qty
|
||
pay = round(order["reward"] * order["delivered"] / order["qty"]) - order["paid"]
|
||
order["paid"] += pay
|
||
worth, base = sum(ITEMS[item]["value"] * n for item, n in (inputs or {}).items()), pay
|
||
for uid, items in (used or {}).items():
|
||
helper = world["players"].get(uid)
|
||
if uid == order["by"] or not helper or not worth:
|
||
continue
|
||
share = min(pay, round(base * INPUT_SHARE * sum(ITEMS[i]["value"] * n for i, n in items.items()) / worth))
|
||
if share:
|
||
helper["gold"] += share
|
||
earned = order.setdefault("earned", {})
|
||
earned[uid] = earned.get(uid, 0) + share
|
||
pay -= share
|
||
giver["gold"] += pay
|
||
owner = world["players"].get(order["by"])
|
||
if owner:
|
||
_hand_over(world, order, owner)
|
||
if order["delivered"] >= order["qty"]:
|
||
order["status"] = "done"
|
||
order["done"] = now
|
||
_refund(world, order, now) # leftover reagents
|
||
log(world, now, "order", f"{giver['name']} filled {owner['name'] if owner else 'someone'}'s order: "
|
||
f"{amount(order['item'], order['qty'])}")
|
||
|
||
|
||
def _hand_over(world: dict, order: dict, owner: dict) -> None:
|
||
"""Put delivered items in the requester's bank; what doesn't fit waits on the order."""
|
||
fits = min(order["held"], bank_room(world, owner, order["item"]))
|
||
if fits:
|
||
owner["bank"][order["item"]] = owner["bank"].get(order["item"], 0) + fits
|
||
order["held"] -= fits
|
||
|
||
|
||
def collect_orders(world: dict, player: dict) -> None:
|
||
for order in world["orders"]:
|
||
if order["by"] == player["id"] and order["held"]:
|
||
_hand_over(world, order, player)
|
||
|
||
|
||
def expire_orders(world: dict, now: float) -> None:
|
||
for order in world["orders"]:
|
||
if order["status"] == "open" and now >= order["expires"]:
|
||
order["status"] = "expired"
|
||
_refund(world, order, now)
|
||
# Forget finished orders after a while, once everything's been handed over.
|
||
world["orders"] = [o for o in world["orders"] if o["status"] == "open" or o["held"]
|
||
or now - o.get("done", o["expires"]) < DAY / speed(world)]
|
||
|
||
|
||
def working_order(world: dict, player: dict, action: dict) -> dict | None:
|
||
"""The work order this action is for, if the player is crafting one."""
|
||
current = player["action"] or {}
|
||
if "order" not in current or current["id"] != action["id"]:
|
||
return None
|
||
return order_by_id(world, current["order"])
|
||
|
||
|
||
def start_action(world: dict, player: dict, action_id: str, now: float, once: bool = False) -> str | None:
|
||
"""Start an action. `once` does it a single time, then goes back to what you were doing."""
|
||
action = ACTION_BY_ID.get(action_id)
|
||
if not action:
|
||
return "No such action"
|
||
reason = can_start(world, player, action)
|
||
if reason:
|
||
return reason
|
||
entry = {"id": action_id, "start": now}
|
||
current = player["action"]
|
||
if once:
|
||
back = current and (current.get("resume") or current["id"])
|
||
entry["left"] = 1
|
||
if back and back != action_id:
|
||
entry["resume"] = back
|
||
plot2 = current.get("resume_plot2") or current.get("plot2")
|
||
if plot2:
|
||
entry["resume_plot2"] = plot2
|
||
elif current and current.get("plot2") and current["plot2"] != action_id and action["skill"] == "farming":
|
||
# Replanting the first plot leaves the second one growing.
|
||
entry.update(plot2=current["plot2"], turn=0)
|
||
if current and current.get("party") in world["parties"]:
|
||
leave_party(world, player, current["party"], now)
|
||
player["action"] = entry
|
||
player["stopped"] = None
|
||
if action.get("together"):
|
||
log(world, now, "party", f"{player['name']} is looking for a group: {action['name']}", player["id"])
|
||
return None
|
||
|
||
|
||
# --- city ----------------------------------------------------------------
|
||
|
||
def stockpile_value(city: dict) -> float:
|
||
return sum(ITEMS[item]["value"] * qty for item, qty in city["stockpile"].items())
|
||
|
||
|
||
def donate(world: dict, player: dict, item: str, qty: int, now: float) -> str | None:
|
||
city = world["city"]
|
||
if qty <= 0:
|
||
return "Nothing to donate"
|
||
if item == "gold":
|
||
if player["gold"] < qty:
|
||
return "Not enough gold"
|
||
player["gold"] -= qty
|
||
city["treasury"] += qty
|
||
gain = qty
|
||
label = f"{qty:,} gold"
|
||
else:
|
||
if not city_uses(item):
|
||
return "The city has no use for that"
|
||
if player["bank"].get(item, 0) < qty:
|
||
return "You don't have that many"
|
||
bank_take(player, item, qty)
|
||
city["stockpile"][item] = city["stockpile"].get(item, 0) + qty
|
||
gain = qty * ITEMS[item]["value"]
|
||
label = amount(item, qty)
|
||
player["favour"] += gain * city_rate(world, "favour")
|
||
player["donated"] += gain
|
||
log(world, now, "donation", f"{player['name']} donated {label}", player["id"])
|
||
return None
|
||
|
||
|
||
def give(world: dict, giver: dict, receiver: dict, item: str, qty: int, now: float) -> str | None:
|
||
if receiver is giver:
|
||
return "You can't give to yourself"
|
||
if qty <= 0 or giver["bank"].get(item, 0) < qty:
|
||
return "You don't have that many"
|
||
room = bank_room(world, receiver, item)
|
||
if room < qty:
|
||
return f"{receiver['name']} only has room for {room:,}"
|
||
bank_take(giver, item, qty)
|
||
receiver["bank"][item] = receiver["bank"].get(item, 0) + qty
|
||
log(world, now, "gift", f"{giver['name']} gave {amount(item, qty)} to {receiver['name']}", giver["id"])
|
||
return None
|
||
|
||
|
||
def sell(world: dict, player: dict, item: str, qty: int, now: float) -> str | None:
|
||
if qty <= 0 or player["bank"].get(item, 0) < qty:
|
||
return "You don't have that many"
|
||
bank_take(player, item, qty)
|
||
player["gold"] += int(qty * sell_price(world, item, now))
|
||
return None
|
||
|
||
|
||
def sell_price(world: dict, item: str, now: float) -> float:
|
||
price = ITEMS[item]["value"]
|
||
if world["city"].get("merchant_until", 0) > now:
|
||
price *= 1.5
|
||
return price
|
||
|
||
|
||
def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source: str, now: float) -> str | None:
|
||
"""Put materials towards a building. Funds are safe from raiders.
|
||
|
||
`source` is "bank" (the player's own, earns favour) or "city" (the
|
||
stockpile, or the treasury for gold). `qty` may be "all": as much as
|
||
the source has, up to what the fund still needs.
|
||
"""
|
||
city = world["city"]
|
||
if key not in BUILDINGS:
|
||
return "No such building"
|
||
if item != "gold" and item not in ITEMS:
|
||
return "No such item"
|
||
need = fund_missing(world, key).get(item, 0)
|
||
if not need:
|
||
return "The fund doesn't need that"
|
||
if source == "bank":
|
||
have = player["gold"] if item == "gold" else player["bank"].get(item, 0)
|
||
elif source == "city":
|
||
have = int(city["treasury"]) if item == "gold" else city["stockpile"].get(item, 0)
|
||
else:
|
||
return "Bad source"
|
||
if qty == "all":
|
||
qty = min(have, need)
|
||
if not isinstance(qty, int) or qty <= 0:
|
||
return "Nothing to give"
|
||
if qty > have:
|
||
return "Not enough to give"
|
||
qty = min(qty, need)
|
||
if source == "bank":
|
||
if item == "gold":
|
||
player["gold"] -= qty
|
||
else:
|
||
bank_take(player, item, qty)
|
||
gain = qty * (1 if item == "gold" else ITEMS[item]["value"])
|
||
player["favour"] += gain * city_rate(world, "favour")
|
||
player["donated"] += gain
|
||
elif item == "gold":
|
||
city["treasury"] -= qty
|
||
else:
|
||
take_stock(city, item, qty)
|
||
building = city["buildings"][key]
|
||
building["fund"][item] = building["fund"].get(item, 0) + qty
|
||
label = f"{qty:,} gold" if item == "gold" else amount(item, qty)
|
||
where = "" if source == "bank" else " from the city's stores"
|
||
log(world, now, "fund", f"{player['name']} put {label}{where} towards the {BUILDINGS[key]['name'].lower()}", player["id"])
|
||
_queue_if_funded(world, key, now)
|
||
_start_next(world, now)
|
||
return None
|
||
|
||
|
||
def install(world: dict, player: dict, fitting: str, source: str, now: float) -> str | None:
|
||
"""Fit a building with a fitting from the player's bank (earns favour) or the stockpile."""
|
||
spec = FITTING_BY_ID.get(fitting)
|
||
if not spec:
|
||
return "No such fitting"
|
||
city = world["city"]
|
||
building = city["buildings"][spec["building"]]
|
||
name = BUILDINGS[spec["building"]]["name"].lower()
|
||
if fitting in building["fittings"]:
|
||
return f"The {name} already {'have' if name.endswith('s') else 'has'} that"
|
||
if building["level"] < spec["level"]:
|
||
return f"Needs {BUILDINGS[spec['building']]['name']} level {spec['level']}"
|
||
if source == "bank":
|
||
if not player["bank"].get(fitting):
|
||
return "You don't have one"
|
||
bank_take(player, fitting, 1)
|
||
gain = ITEMS[fitting]["value"]
|
||
player["favour"] += gain * city_rate(world, "favour")
|
||
player["donated"] += gain
|
||
elif source == "city":
|
||
if not city["stockpile"].get(fitting):
|
||
return "There isn't one in the stockpile"
|
||
take_stock(city, fitting, 1)
|
||
else:
|
||
return "Bad source"
|
||
building["fittings"].append(fitting)
|
||
log(world, now, "build", f"{player['name']} fitted the {name} with {spec['name'].lower()}", player["id"])
|
||
return None
|
||
|
||
|
||
def take_stock(city: dict, item: str, qty: int) -> None:
|
||
left = city["stockpile"][item] - qty
|
||
if left > 0:
|
||
city["stockpile"][item] = left
|
||
else:
|
||
del city["stockpile"][item]
|
||
|
||
|
||
def _queue_if_funded(world: dict, key: str, now: float) -> None:
|
||
city = world["city"]
|
||
if fund_target(world, key) is None or fund_missing(world, key):
|
||
return
|
||
busy = city["construction"] and city["construction"]["building"] == key
|
||
if key not in city["queue"] and not busy:
|
||
city["queue"].append(key)
|
||
log(world, now, "build", f"The {BUILDINGS[key]['name'].lower()} are fully funded"
|
||
if BUILDINGS[key]["name"].endswith("s") else f"The {BUILDINGS[key]['name'].lower()} is fully funded")
|
||
|
||
|
||
def _start_next(world: dict, now: float) -> None:
|
||
"""Start the next funded building, if the builders are free."""
|
||
city = world["city"]
|
||
while not city["construction"] and city["queue"]:
|
||
key = city["queue"].pop(0)
|
||
target = fund_target(world, key)
|
||
if target is None or fund_missing(world, key):
|
||
continue # its target changed while it waited (e.g. damaged); keep funding
|
||
building = city["buildings"][key]
|
||
for item, qty in target.items():
|
||
left = building["fund"][item] - qty
|
||
if left > 0:
|
||
building["fund"][item] = left
|
||
else:
|
||
del building["fund"][item]
|
||
repair = building["damaged"]
|
||
city["boosts_bought"] = 0 # master builders cost the base price again on a new site
|
||
city["construction"] = {"building": key, "repair": repair, "start": now, "labour": 0.0,
|
||
"needed": labour_needed(building["level"], repair)}
|
||
verb = "Repairs to" if repair else "Work on"
|
||
log(world, now, "build", f"{verb} the {BUILDINGS[key]['name'].lower()} began")
|
||
|
||
|
||
def growth_per_day(world: dict) -> float:
|
||
"""New townsfolk a day while there's room: settlers for the homes, plus families."""
|
||
pop = world["city"]["population"]
|
||
growth = SETTLERS_PER_EMPTY_HOME * max(0.0, housing(world) - pop) + FAMILY_GROWTH * pop
|
||
return growth * city_rate(world, "growth") * (1.25 if fitted(world, "furniture") else 1)
|
||
|
||
|
||
def population_view(world: dict) -> dict:
|
||
"""Population, food and tier, with the numbers that explain them."""
|
||
city = world["city"]
|
||
pop = city["population"]
|
||
room = housing(world)
|
||
if city["starving"]:
|
||
growth = -pop * 0.04
|
||
elif pop < room - 0.01:
|
||
growth = growth_per_day(world)
|
||
elif pop > room:
|
||
growth = -pop * 0.05
|
||
else:
|
||
growth = 0.0
|
||
food = sum(city["stockpile"].get(item, 0) * ITEMS[item]["food"] for item in FOOD_ORDER)
|
||
tier = city["tier"]
|
||
following = TIERS[tier + 1] if tier + 1 < len(TIERS) else None
|
||
return {
|
||
"population": int(pop), "housing": room, "growth": round(growth, 1),
|
||
"food": food, "food_days": round(food / (pop * 0.5), 1) if pop else None,
|
||
"tier": tier, "tier_name": TIERS[tier]["name"],
|
||
"next_tier": following and {"name": following["name"], "pop": following["pop"]},
|
||
}
|
||
|
||
|
||
def best_kit(stock: dict, slot: str, stat: str, people: int) -> float:
|
||
"""Total `stat` when the best `slot` items in the stockpile go to `people` defenders."""
|
||
total = 0
|
||
for item in sorted((i for i in stock if ITEMS[i].get("slot") == slot), key=lambda i: -ITEMS[i].get(stat, 0)):
|
||
take = min(people, stock[item])
|
||
total += take * ITEMS[item].get(stat, 0)
|
||
people -= take
|
||
if not people:
|
||
break
|
||
return total
|
||
|
||
|
||
def kit_count(stock: dict, slot: str) -> int:
|
||
return sum(qty for item, qty in stock.items() if ITEMS[item].get("slot") == slot)
|
||
|
||
|
||
def wear_share(ratio: float) -> float:
|
||
"""Share of the issued swords and shields a fight breaks: 10% in an easy win, 30% in a close one, 40% in defeat."""
|
||
if ratio < 1:
|
||
return 0.4
|
||
return 0.1 + 0.2 * max(0.0, 2 - ratio)
|
||
|
||
|
||
def wear_kit(world: dict, share: float, rng: random.Random) -> dict:
|
||
"""Break `share` of the swords and shields handed to defenders, worst first. Spares are untouched."""
|
||
stock = world["city"]["stockpile"]
|
||
broken = {}
|
||
for slot, stat in (("weapon", "power"), ("shield", "armour")):
|
||
people = guards_on_duty(world)
|
||
issued = []
|
||
if not people:
|
||
continue
|
||
for item in sorted((i for i in stock if ITEMS[i].get("slot") == slot), key=lambda i: -ITEMS[i].get(stat, 0)):
|
||
take = min(people, stock[item])
|
||
issued.append((item, take))
|
||
people -= take
|
||
if not people:
|
||
break
|
||
breaks = int(sum(take for _item, take in issued) * share + rng.random())
|
||
for item, take in reversed(issued):
|
||
lost = min(breaks, take)
|
||
if lost:
|
||
take_stock_qty(stock, item, lost)
|
||
broken[item] = lost
|
||
breaks -= lost
|
||
return broken
|
||
|
||
|
||
def _wear_lines(broken: dict, arrows: int) -> list[str]:
|
||
lines = []
|
||
if arrows:
|
||
lines.append(f"The defenders used {amount('arrows', arrows)}.")
|
||
if broken:
|
||
lines.append(f"{upper_first(_listing(broken))} {'was' if sum(broken.values()) == 1 else 'were'} broken in the fighting.")
|
||
return lines
|
||
|
||
|
||
def equip_problem(player: dict, item: str) -> str | None:
|
||
"""Why the player can't wear or use this yet, or None."""
|
||
weapon = player["equip"].get("weapon")
|
||
if ITEMS[item].get("slot") == "shield" and weapon and ITEMS[weapon].get("two_handed"):
|
||
return f"Your {ITEMS[weapon]['name'].lower()} takes both hands"
|
||
req = ITEMS[item].get("req")
|
||
if req and skill_level(player, req[0]) < req[1]:
|
||
name = next(s["name"] for s in SKILLS if s["id"] == req[0])
|
||
return f"Needs {name.lower()} level {req[1]}"
|
||
return None
|
||
|
||
|
||
def guard_posts(world: dict) -> int:
|
||
"""How many guards the barracks can field, never more than half the townsfolk."""
|
||
return min(GUARD_POSTS_BASE + GUARD_POSTS * building_level(world, "barracks"), int(world["city"]["population"] // 2))
|
||
|
||
|
||
def guards_on_duty(world: dict) -> int:
|
||
"""Guards whose wage for the coming threat is in the chest."""
|
||
return min(guard_posts(world), int(world["city"].get("wages", 0) // GUARD_WAGE))
|
||
|
||
|
||
def defence(world: dict, guards: int | None = None) -> dict:
|
||
"""The city's defence right now, part by part. `guards` overrides how many are on duty."""
|
||
city = world["city"]
|
||
pop = city["population"]
|
||
stock = city["stockpile"]
|
||
posts = guard_posts(world)
|
||
armed = guards_on_duty(world) if guards is None else min(guards, posts)
|
||
militia = 0.3 * building_level(world, "barracks") * pop * (1.5 if fitted(world, "weapon_racks") else 1)
|
||
parts = {
|
||
"townsfolk": 10,
|
||
"walls": 20 * building_level(world, "walls") + 25 * fitted(world, "hoardings"),
|
||
"militia": round(militia * armed / posts, 1) if posts else 0,
|
||
"weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1),
|
||
"shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1),
|
||
"arrows": min(100, stock.get("arrows", 0) // 10),
|
||
"drilled": round(drilled(world), 1),
|
||
"rallied": city.get("rally", 0), # favour bought before drilling existed; gone after the next raid
|
||
"ward": ward(world),
|
||
"mercenaries": city.get("hired", 0),
|
||
"garrison": 0,
|
||
}
|
||
if guards is not None:
|
||
return {"total": round(sum(parts.values()), 1), "parts": parts}
|
||
garrison = []
|
||
for player in world["players"].values():
|
||
if player["garrison"]:
|
||
power = combat_power(player)
|
||
garrison.append({"id": player["id"], "name": player["name"], "power": round(power, 1)})
|
||
parts["garrison"] += power
|
||
total = sum(parts.values())
|
||
if city["starving"]:
|
||
total *= 0.8
|
||
return {"parts": parts, "garrison": garrison, "total": round(total, 1), "starving": city["starving"],
|
||
"guards": armed, "posts": posts, "wages": int(city.get("wages", 0)), "wage": GUARD_WAGE}
|
||
|
||
|
||
def standing_defence(world: dict) -> float:
|
||
"""What the city holds with every guard post paid and no one preparing: walls, guards, kit, arrows."""
|
||
parts = defence(world, guards=guard_posts(world))["parts"]
|
||
return sum(v for k, v in parts.items() if k not in ("drilled", "rallied", "ward", "mercenaries", "garrison"))
|
||
|
||
|
||
def pay_wages(world: dict, player: dict, qty, source: str, now: float) -> str | None:
|
||
"""Put gold in the barracks' wage chest, from your purse (earns favour) or the treasury."""
|
||
city = world["city"]
|
||
room = guard_posts(world) * GUARD_WAGE - int(city.get("wages", 0))
|
||
if room <= 0:
|
||
return "Every post is paid for"
|
||
have = player["gold"] if source == "bank" else int(city["treasury"]) if source == "city" else None
|
||
if have is None:
|
||
return "Bad source"
|
||
qty = min(have, room) if qty == "all" else qty
|
||
if not isinstance(qty, int) or qty <= 0:
|
||
return "Nothing to pay"
|
||
if qty > have:
|
||
return "Not enough gold"
|
||
qty = min(qty, room)
|
||
if source == "bank":
|
||
player["gold"] -= qty
|
||
player["favour"] += qty * city_rate(world, "favour")
|
||
player["donated"] += qty
|
||
log(world, now, "donation", f"{player['name']} paid {qty:,} gold of guard wages", player["id"])
|
||
else:
|
||
city["treasury"] -= qty
|
||
log(world, now, "donation", f"{player['name']} paid {qty:,} gold of guard wages from the treasury", player["id"])
|
||
city["wages"] = city.get("wages", 0) + qty
|
||
return None
|
||
|
||
|
||
def _pay_guards(world: dict, report: dict) -> None:
|
||
"""The guards who stood are paid from the chest; what's left waits for the next threat."""
|
||
city = world["city"]
|
||
city["wages"] = city.get("wages", 0) - report["guards"] * GUARD_WAGE
|
||
|
||
|
||
def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
|
||
"""Advance the city and events to `now`. Returns True if anything changed."""
|
||
rng = rng or random.Random()
|
||
city = world["city"]
|
||
changed = False
|
||
burn_fires(world, now)
|
||
step = HOUR / speed(world)
|
||
# Never replay more than two weeks if the server was off.
|
||
city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world))
|
||
while city["last_tick"] + step <= now:
|
||
city["last_tick"] += step
|
||
_happenings_hour(world, city["last_tick"], rng)
|
||
expire_orders(world, city["last_tick"])
|
||
_city_hour(world, city["last_tick"])
|
||
changed = True
|
||
changed = advance_weather(world, now, rng) or changed
|
||
changed = _happenings_tracks(world, now, rng) or changed
|
||
changed = _caravan_tick(world, now, rng) or changed
|
||
construction = city["construction"]
|
||
if construction and construction["labour"] >= construction["needed"]:
|
||
_finish_construction(world, now)
|
||
_start_next(world, now)
|
||
changed = True
|
||
while city["tier"] + 1 < len(TIERS) and city["population"] >= TIERS[city["tier"] + 1]["pop"]:
|
||
city["tier"] += 1
|
||
tier = TIERS[city["tier"]]
|
||
log(world, now, "tier", f"The settlement has grown into a {tier['name'].lower()}! "
|
||
f"Buildings can go higher, and everyone gets {TIER_SLOTS} more bank slots.")
|
||
for key in BUILDINGS:
|
||
_queue_if_funded(world, key, now)
|
||
_start_next(world, now)
|
||
changed = True
|
||
for event in world["events"]:
|
||
if event["status"] == "upcoming" and event["fire_at"] <= now:
|
||
for player in world["players"].values():
|
||
process_player(world, player, event["fire_at"], rng)
|
||
_fire(world, event, now, rng)
|
||
# The next threat is rumoured as soon as this one is over.
|
||
world["next_event_at"] = now
|
||
changed = True
|
||
if world["next_event_at"] and world["next_event_at"] <= now and not upcoming_event(world):
|
||
_announce(world, now, rng)
|
||
changed = True
|
||
return changed
|
||
|
||
|
||
def _city_hour(world: dict, now: float) -> None:
|
||
city = world["city"]
|
||
pop = city["population"]
|
||
if city["construction"]:
|
||
city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR
|
||
threat = upcoming_raid(world)
|
||
if threat and building_level(world, "spymaster"):
|
||
threat["recon"] = min(intel_costs(threat)[-1], threat["recon"] + SPYMASTER_AGENTS * building_level(world, "spymaster"))
|
||
city["food_due"] += (pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1)
|
||
* (0.9 if fitted(world, "grain_bins") else 1))
|
||
stock = city["stockpile"]
|
||
for item in FOOD_ORDER:
|
||
while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0:
|
||
stock[item] -= 1
|
||
if not stock[item]:
|
||
del stock[item]
|
||
city["food_due"] -= ITEMS[item]["food"]
|
||
was_starving = city["starving"]
|
||
city["starving"] = city["food_due"] >= 1
|
||
if city["starving"]:
|
||
city["food_due"] = min(city["food_due"], pop)
|
||
city["population"] = max(5.0, pop * (1 - 0.04 / 24))
|
||
if not was_starving:
|
||
log(world, now, "city", "The granary is empty. People are going hungry and some are leaving.")
|
||
else:
|
||
room = housing(world)
|
||
if pop < room:
|
||
city["population"] = min(room, pop + growth_per_day(world) / 24)
|
||
elif pop > room:
|
||
city["population"] = max(room, pop - pop * 0.05 / 24)
|
||
city["treasury"] += pop * 0.5 / 24
|
||
|
||
|
||
def labour_needed(level: int, repair: bool) -> float:
|
||
"""Labour to raise a building from `level`, or to repair it at that level."""
|
||
if repair:
|
||
return LABOUR_PER_LEVEL * max(1, level) * 0.25
|
||
return LABOUR_PER_LEVEL * (level + 1)
|
||
|
||
|
||
def labour_per_action(player: dict) -> float:
|
||
# One more labour every 20 levels, and one more for a builder.
|
||
return 1 + skill_level(player, "construction") // 20 + (player["cls"] == "builder")
|
||
|
||
|
||
def _finish_construction(world: dict, now: float) -> None:
|
||
city = world["city"]
|
||
work = city["construction"]
|
||
building = city["buildings"][work["building"]]
|
||
name = BUILDINGS[work["building"]]["name"]
|
||
if work["repair"]:
|
||
building["damaged"] = False
|
||
log(world, now, "build", f"The {name.lower()} have been repaired" if name.endswith("s")
|
||
else f"The {name.lower()} has been repaired")
|
||
else:
|
||
building["level"] += 1
|
||
log(world, now, "build", f"{name} reached level {building['level']}")
|
||
city["construction"] = None
|
||
# Leftover materials may already cover the next step.
|
||
_queue_if_funded(world, work["building"], now)
|
||
|
||
|
||
# --- events --------------------------------------------------------------
|
||
|
||
# Threats keep a two-day rhythm: one lands every THREAT_EVERY game hours, give or
|
||
# take THREAT_WOBBLE, and the next is rumoured the moment it's over. The first day
|
||
# is for scouting, drilling and building; on the second the camp can be found.
|
||
THREAT_EVERY = 48
|
||
THREAT_WOBBLE = 2
|
||
|
||
|
||
def threat_slot(world: dict, now: float) -> float:
|
||
"""When the next threat is due: two days after the last one's slot, never sooner than about two days off."""
|
||
hours = HOUR / speed(world)
|
||
last = next((e["slot"] for e in reversed(world["events"]) if e["kind"] in THREATS and "slot" in e), None)
|
||
soonest = now + (THREAT_EVERY - THREAT_WOBBLE) * hours
|
||
return max(last + THREAT_EVERY * hours, soonest) if last else now + THREAT_EVERY * hours
|
||
|
||
|
||
def _announce(world: dict, now: float, rng: random.Random) -> None:
|
||
city = world["city"]
|
||
hours = HOUR / speed(world)
|
||
first = not any(e["kind"] in ("raid", "dragon") for e in world["events"])
|
||
slot = threat_slot(world, now)
|
||
day = (now - world["created"]) * speed(world) / DAY
|
||
# The major-threat track: mostly bandits, sometimes (after the first days) a dragon.
|
||
kind = "dragon" if not first and day >= 5 and rng.random() < 0.15 else "raid"
|
||
event = {
|
||
"id": world["next_event_id"], "kind": kind, "status": "upcoming",
|
||
"announced": now, "recon": 0, "sabotage": 0.0, "jitter": rng.uniform(-1, 1),
|
||
"slot": slot, "fire_at": slot + rng.uniform(-THREAT_WOBBLE, THREAT_WOBBLE) * hours,
|
||
}
|
||
world["next_event_id"] += 1
|
||
# Bigger, richer and older cities draw bigger threats; every win draws a bolder one.
|
||
wealth = city["treasury"] + stockpile_value(city)
|
||
# A bigger guild draws a bolder band: half the combat power of everyone active in the last two days.
|
||
fighters = sum(combat_power(p) for p in world["players"].values() if p["last_active"] > now - 2 * DAY / speed(world))
|
||
strength = ((15 + 5 * day + 1.5 * city["population"] + 1.6 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"])
|
||
+ 0.5 * fighters)
|
||
# How big it looks is measured against what the city holds without preparing.
|
||
event["standing"] = round(max(1.0, standing_defence(world)), 1)
|
||
if kind == "dragon":
|
||
event.update({
|
||
"strength": round(strength * 0.8 * rng.uniform(0.9, 1.15), 1),
|
||
"group": rng.choice(DRAGONS),
|
||
"trait": rng.choice(DRAGON_TRAITS),
|
||
})
|
||
log(world, now, "event", "Herders report scorched pastures in the hills")
|
||
elif kind == "raid":
|
||
event.update({
|
||
"strength": round(strength * rng.uniform(0.85, 1.2), 1),
|
||
"group": rng.choice(RAID_GROUPS),
|
||
"trait": rng.choice(RAID_TRAITS),
|
||
})
|
||
log(world, now, "event", "Rumours of bandits on the roads reach the council")
|
||
world["events"].append(event)
|
||
del world["events"][:-30]
|
||
_plan_caravan(world, now, rng)
|
||
|
||
|
||
# Intel: each level takes more reconnaissance than the last, a bigger band is
|
||
# harder to read (a dragon is hard to miss), and the sharper levels only become
|
||
# knowable as the threat draws near, however much scouting is done.
|
||
INTEL_POINTS = [15, 30, 45] # recon for intel levels 1, 2 and 3 against a small band
|
||
INTEL_SIZE = [1.0, 1.33, 1.67, 2.0] # small, medium, large, huge
|
||
INTEL_OPENS = [None, 36, 24] # game hours before arrival that levels 2 (±25% per threat) and 3 open
|
||
SPYMASTER_RECON = 0.25 # more recon per guild level
|
||
SPYMASTER_AGENTS = 0.5 # recon per guild level per game hour, with nobody scouting
|
||
INFORMANT_RECON = 30 # boosted recon per informant: doubled recon and XP for the scout who works it
|
||
|
||
|
||
def recon_gain(world: dict, player: dict, action: dict) -> float:
|
||
gain = (action["recon"] + fitted(world, "map_table")) * (1 + SPYMASTER_RECON * building_level(world, "spymaster"))
|
||
return gain * (1.5 if player["cls"] == "ranger" else 1) * ware(player, "recon", "reconnaissance")
|
||
|
||
|
||
def intel_costs(event: dict) -> list[int]:
|
||
"""Total recon needed for each intel level."""
|
||
size = threat_size(event, event["strength"])
|
||
scale = INTEL_SIZE[size] * (0.5 if event["kind"] == "dragon" else 1)
|
||
return [round(sum(INTEL_POINTS[:level]) * scale) for level in (1, 2, 3)]
|
||
|
||
|
||
def intel_opens(world: dict, event: dict, level: int) -> float:
|
||
"""When an intel level becomes knowable. Level 2's ±25% is fixed per threat, so it can't be timed;
|
||
the camp (level 3) can always be found a day before the threat lands."""
|
||
hours = INTEL_OPENS[level - 1]
|
||
if hours is None:
|
||
return 0
|
||
wobble = random.Random(f"{event['id']}-{level}").uniform(0.75, 1.25) if level < 3 else 1
|
||
return event["fire_at"] - hours * wobble * HOUR / speed(world)
|
||
|
||
|
||
def intel_level(world: dict, event: dict, now: float | None = None) -> int:
|
||
now = world["city"]["last_tick"] if now is None else now
|
||
level = 0
|
||
for next_level, need in enumerate(intel_costs(event), 1):
|
||
if event["recon"] < need or now < intel_opens(world, event, next_level):
|
||
break
|
||
level = next_level
|
||
return level
|
||
|
||
|
||
SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")] # by strength, before v28
|
||
SIZE_RATIOS = [1.4, 1.9, 2.5, math.inf] # strength / standing defence for small, medium, large, huge
|
||
|
||
|
||
def threat_size(event: dict, strength: float) -> int:
|
||
"""0 small … 3 huge, against what the city could hold when the threat was rumoured."""
|
||
if "standing" in event:
|
||
return next(i for i, limit in enumerate(SIZE_RATIOS) if strength / event["standing"] < limit)
|
||
return next(i for i, (limit, _label) in enumerate(SIZE_LABELS) if strength < limit)
|
||
WINDOW_HOURS = [4, 3, 2, 0]
|
||
|
||
|
||
THREATS = ("raid", "dragon")
|
||
GATES_WARNING = 300 # seconds of warning before a threat strikes
|
||
|
||
|
||
def event_view(world: dict, event: dict, now: float) -> dict:
|
||
"""What the city knows about an upcoming event, filtered by intel."""
|
||
level = intel_level(world, event, now)
|
||
half = WINDOW_HOURS[level] * HOUR / speed(world)
|
||
costs = intel_costs(event) if "strength" in event else [0, 0, 0]
|
||
centre = event["fire_at"] + event["jitter"] * half * 0.6
|
||
# In the last few minutes the sentries see them coming, whatever the intel.
|
||
gates = event["kind"] in THREATS and event["fire_at"] - now <= GATES_WARNING / speed(world)
|
||
view = {
|
||
"id": event["id"], "kind": event["kind"], "intel": level, "at_gates": gates,
|
||
"earliest": max(now, centre - half), "latest": max(now, centre + half),
|
||
"exact": event["fire_at"] if level >= 3 or gates else None,
|
||
"recon": int(min(event["recon"], costs[-1])), "recon_full": costs[-1],
|
||
"recon_next": max(0, round(costs[level] - event["recon"])) if level < 3 else 0,
|
||
}
|
||
if event["kind"] == "dragon":
|
||
strength = event["strength"]
|
||
view["headline"] = "Herders whisper of scorched pastures in the hills."
|
||
if level >= 1:
|
||
view["headline"] = "A dragon has been seen over the hills, and it's coming closer."
|
||
view["size"] = SIZE_LABELS[threat_size(event, strength)][1]
|
||
if level >= 2:
|
||
spread = 0.25 * strength
|
||
low = strength - spread * (1 + event["jitter"]) / 2
|
||
view["range"] = [round(low), round(low + spread)]
|
||
view["group"] = event["group"]
|
||
view["headline"] = f"{upper_first(event['group'])} is coming for the city."
|
||
if level >= 3:
|
||
view["strength"] = round(strength)
|
||
view["trait"] = event["trait"]
|
||
view["requests"] = council_requests(world, event)
|
||
if gates:
|
||
view["headline"] = "The dragon is overhead!"
|
||
elif event["kind"] == "raid":
|
||
strength = event["strength"] * (1 - event["sabotage"])
|
||
view["headline"] = "Travellers speak of trouble on the roads."
|
||
if level >= 1:
|
||
size = SIZE_LABELS[threat_size(event, strength)][1]
|
||
view["headline"] = f"A {size} bandit band is gathering in the hills."
|
||
view["size"] = size
|
||
if level >= 2:
|
||
spread = 0.25 * strength
|
||
low = strength - spread * (1 + event["jitter"]) / 2
|
||
view["range"] = [round(low), round(low + spread)]
|
||
view["group"] = event["group"]
|
||
view["headline"] = f"{upper_first(event["group"])} are gathering in the hills."
|
||
if level >= 3:
|
||
view["strength"] = round(strength)
|
||
view["trait"] = event["trait"]
|
||
view["camp"] = True
|
||
view["sabotage"] = round(event["sabotage"] * 100)
|
||
view["requests"] = council_requests(world, event)
|
||
if gates:
|
||
view["headline"] = "Raiders at the gates!"
|
||
elif event["kind"] == "merchant":
|
||
view["headline"] = "A merchant caravan is on its way. Sell prices +50% while it's here."
|
||
else:
|
||
view["headline"] = f"About {event['people']} refugees are walking towards the city, hoping for a roof."
|
||
return view
|
||
|
||
|
||
def council_requests(world: dict, event: dict) -> list[dict]:
|
||
city = world["city"]
|
||
stock = city["stockpile"]
|
||
strength = event["strength"]
|
||
armed = guard_posts(world)
|
||
wants = [
|
||
("arrows", min(1000, 50 * math.ceil(strength * 4 / 50))),
|
||
("copper_sword", min(armed, max(5, math.ceil(strength / 6)))),
|
||
("bread", math.ceil(city["population"] * 1.5)),
|
||
]
|
||
if building_level(world, "smithy") >= 1:
|
||
wants.append(("iron_shield", min(armed, math.ceil(strength / 10))))
|
||
requests = []
|
||
for item, qty in wants:
|
||
have = stock.get(item, 0)
|
||
if item in ("copper_sword", "iron_shield"):
|
||
have = kit_count(stock, ITEMS[item]["slot"])
|
||
if item == "bread":
|
||
have = sum(stock.get(food, 0) * ITEMS[food]["food"] for food in FOOD_ORDER)
|
||
label = {"copper_sword": "Swords (any)", "iron_shield": "Shields (any)", "bread": "Food (any)"}[item] \
|
||
if item in ("copper_sword", "iron_shield", "bread") else ITEMS[item]["name"]
|
||
request = {"item": item, "label": label, "want": qty, "have": have}
|
||
if item in ("copper_sword", "iron_shield"):
|
||
request["any"] = ITEMS[item]["slot"] # any item in this slot counts
|
||
if item == "bread":
|
||
request["any"] = "food" # counted in meals
|
||
requests.append(request)
|
||
return requests
|
||
|
||
|
||
def _fire(world: dict, event: dict, now: float, rng: random.Random) -> None:
|
||
event["status"] = "done"
|
||
event["resolved"] = now
|
||
if event["kind"] == "raid":
|
||
_resolve_raid(world, event, now, rng)
|
||
elif event["kind"] == "dragon":
|
||
_resolve_dragon(world, event, now, rng)
|
||
if event["kind"] in THREATS:
|
||
won = event["report"]["title"].startswith("Victory")
|
||
for entry in event["report"]["garrison"] if won else []:
|
||
_deed(world["players"][entry["id"]], "dragons" if event["kind"] == "dragon" else "defended")
|
||
# Wares bought against this threat are spent.
|
||
for player in world["players"].values():
|
||
player["boosts"] = [b for b in player["boosts"] if "threat" not in b]
|
||
elif event["kind"] == "merchant":
|
||
world["city"]["merchant_until"] = now + 12 * HOUR / speed(world)
|
||
event["report"] = {"title": "A merchant caravan has arrived",
|
||
"lines": ["For the next 12 hours, everything sells for 50% more."]}
|
||
log(world, now, "event", "The merchant caravan arrived. Prices are up for 12 hours")
|
||
else:
|
||
city = world["city"]
|
||
room = max(0, housing(world) - int(city["population"]))
|
||
taken = min(room, event["people"])
|
||
city["population"] += taken
|
||
lines = [f"{taken} of {event['people']} refugees found a home."]
|
||
if taken < event["people"]:
|
||
lines.append("The rest moved on. More houses would have kept them.")
|
||
event["report"] = {"title": "Refugees arrived", "lines": lines}
|
||
log(world, now, "event", f"{taken} refugees settled in the city")
|
||
|
||
|
||
def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> None:
|
||
city = world["city"]
|
||
stock = city["stockpile"]
|
||
attack = event["strength"] * (1 - event["sabotage"])
|
||
guard = defence(world)
|
||
ratio = guard["total"] / attack if attack else 9
|
||
garrison = guard["garrison"]
|
||
lines = []
|
||
if event["sabotage"]:
|
||
lines.append(f"Sabotage weakened the raiders by {round(event['sabotage'] * 100)}%.")
|
||
report = {
|
||
"group": event["group"], "attack": round(attack), "defence": guard["total"],
|
||
"parts": guard["parts"], "garrison": garrison, "guards": guard["guards"],
|
||
}
|
||
if ratio >= 1:
|
||
loot = round(attack)
|
||
city["treasury"] += loot
|
||
city["repelled"] += 1
|
||
broken = wear_kit(world, wear_share(ratio), rng)
|
||
arrows = _use_up(stock, "arrows", 0.3)
|
||
report["title"] = f"Victory! {upper_first(event["group"])} were driven off"
|
||
lines.append(f"The raiders fled and left {loot} gold worth of plunder behind.")
|
||
lines.extend(_wear_lines(broken, arrows))
|
||
for entry in garrison:
|
||
player = world["players"][entry["id"]]
|
||
player["favour"] += 25
|
||
gain_xp(world, player, "combat", round(attack * 2), now)
|
||
if garrison:
|
||
lines.append("Everyone in the garrison earned 25 favour and combat experience.")
|
||
log(world, now, "raid", f"{upper_first(event["group"])} were driven off")
|
||
else:
|
||
severity = min(1.0, 1 - ratio)
|
||
broken = wear_kit(world, wear_share(ratio), rng)
|
||
shelter = 1 - 0.08 * building_level(world, "walls")
|
||
lost_people = int(city["population"] * (0.1 + 0.3 * severity))
|
||
city["population"] = max(5.0, city["population"] - lost_people)
|
||
share = min(0.9, (0.2 + 0.5 * severity) * shelter)
|
||
taken = {}
|
||
for item in list(stock):
|
||
gone = int(stock[item] * share)
|
||
if gone:
|
||
taken[item] = gone
|
||
stock[item] -= gone
|
||
if not stock[item]:
|
||
del stock[item]
|
||
gold = int(city["treasury"] * share)
|
||
city["treasury"] -= gold
|
||
arrows = _use_up(stock, "arrows", 0.6)
|
||
report["title"] = f"Defeat. {upper_first(event["group"])} broke into the city"
|
||
lines.append(f"{lost_people} townsfolk were killed or fled.")
|
||
lines.extend(_wear_lines(broken, arrows))
|
||
if gold or taken:
|
||
stolen = ", ".join(amount(item, qty) for item, qty in taken.items())
|
||
loot = [f"{gold:,} gold"] if gold else []
|
||
lines.append("The raiders carried off " + " and ".join(loot + ([stolen] if stolen else [])) + ".")
|
||
built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]]
|
||
if severity > 0.35 and built:
|
||
key = rng.choice(built)
|
||
city["buildings"][key]["damaged"] = True
|
||
_queue_if_funded(world, key, now)
|
||
lines.append(f"The {BUILDINGS[key]['name'].lower()} were damaged and won't work until repaired."
|
||
if BUILDINGS[key]["name"].endswith("s") else
|
||
f"The {BUILDINGS[key]['name'].lower()} was damaged and won't work until repaired.")
|
||
lines.append("A smaller, poorer city will draw less attention for a while.")
|
||
for entry in garrison:
|
||
gain_xp(world, world["players"][entry["id"]], "combat", round(attack), now)
|
||
log(world, now, "raid", f"{upper_first(event["group"])} broke into the city")
|
||
report["lines"] = lines
|
||
event["report"] = report
|
||
_pay_guards(world, report)
|
||
for player in world["players"].values():
|
||
player["garrison"] = False
|
||
city["rally"] = city["rallies"] = 0
|
||
city["devotion"] = 0
|
||
city["hired"] = 0
|
||
|
||
|
||
def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> None:
|
||
city = world["city"]
|
||
stock = city["stockpile"]
|
||
attack = event["strength"]
|
||
guard = defence(world)
|
||
garrison = guard["garrison"]
|
||
report = {"group": event["group"], "attack": round(attack), "defence": guard["total"],
|
||
"parts": guard["parts"], "garrison": garrison, "guards": guard["guards"], "icon": "🐉"}
|
||
lines = []
|
||
if guard["total"] >= attack:
|
||
city["repelled"] += 1
|
||
city["treasury"] += round(attack)
|
||
broken = wear_kit(world, min(0.5, wear_share(guard["total"] / attack) * 1.25), rng)
|
||
arrows = _use_up(stock, "arrows", 0.5)
|
||
report["title"] = f"Victory! {upper_first(event['group'])} was driven off"
|
||
lines.append(f"It fled wounded, dropping a hoard worth {round(attack):,} gold.")
|
||
lines.extend(_wear_lines(broken, arrows))
|
||
for entry in garrison:
|
||
player = world["players"][entry["id"]]
|
||
player["favour"] += 40
|
||
gain_xp(world, player, "combat", round(attack * 2.5), now)
|
||
bank_add(world, player, "dragon_bones", 2, now, empty_summary())
|
||
for weight in WEIGHTS:
|
||
bank_add(world, player, reagent(weight, 3), 1, now, empty_summary())
|
||
if garrison:
|
||
lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and Ironhill reagents.")
|
||
log(world, now, "raid", report["title"])
|
||
else:
|
||
severity = min(1.0, 1 - guard["total"] / attack)
|
||
broken = wear_kit(world, 0.5, rng)
|
||
lost = int(city["population"] * (0.05 + 0.15 * severity))
|
||
city["population"] = max(5.0, city["population"] - lost)
|
||
share = min(0.8, 0.2 + 0.4 * severity)
|
||
burned = _take_share(stock, WOOD, share * (1 - 0.25 * building_level(world, "lumber_shed")))
|
||
burned.update(_take_share(stock, _food_items(stock), share * (1 - 0.25 * building_level(world, "granary"))))
|
||
name = _damage_building(world, now, rng)
|
||
arrows = _use_up(stock, "arrows", 0.7)
|
||
report["title"] = f"{upper_first(event['group'])} set the city ablaze"
|
||
lines.append(f"{lost} townsfolk were lost in the flames.")
|
||
lines.extend(_wear_lines(broken, arrows))
|
||
if burned:
|
||
lines.append(f"It burned {_listing(burned)}.")
|
||
if name:
|
||
lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.")
|
||
for entry in garrison:
|
||
gain_xp(world, world["players"][entry["id"]], "combat", round(attack), now)
|
||
log(world, now, "raid", report["title"])
|
||
report["lines"] = lines
|
||
event["report"] = report
|
||
_pay_guards(world, report)
|
||
for player in world["players"].values():
|
||
player["garrison"] = False
|
||
city["rally"] = city["rallies"] = 0
|
||
city["devotion"] = 0
|
||
city["hired"] = 0
|
||
|
||
|
||
def _work_problem(world: dict, player: dict, action: dict, now: float) -> None:
|
||
problem = active_problem(world, action["problem"])
|
||
if not problem:
|
||
return
|
||
problem["work"] += 1
|
||
if problem["work"] >= PROBLEMS[problem["kind"]]["work"]:
|
||
_solve(world, problem, now, player["name"])
|
||
|
||
|
||
def _use_up(stock: dict, item: str, share: float) -> int:
|
||
used = int(stock.get(item, 0) * share)
|
||
if used:
|
||
take_stock_qty(stock, item, used)
|
||
return used
|
||
|
||
|
||
# --- happenings: weather, seasons, hardships, fortunes, minor threats ---------
|
||
# Raids and dragons keep their own announced rhythm (the major-threat track
|
||
# above). Three more tracks run beside it on their own cooldowns, and the
|
||
# weather rolls every few hours, leaning towards whatever the season favours.
|
||
|
||
SEASONS = [("Spring", "🌱"), ("Summer", "☀️"), ("Autumn", "🍂"), ("Winter", "❄️")]
|
||
SEASON_DAYS = 7
|
||
|
||
# `speed` multiplies action time (below 1 is faster), `yield` multiplies output,
|
||
# `food` multiplies what the townsfolk eat, `firewood` is logs per person per hour.
|
||
WEATHER = {
|
||
"clear": {"name": "Clear skies", "icon": "🌤️", "text": "No effects"},
|
||
"rain": {"name": "Rain", "icon": "🌧️", "speed": {"farming": 0.9}, "text": "Farming 10% faster"},
|
||
"heat": {"name": "Heat wave", "icon": "🥵", "speed": {"farming": 0.85}, "food": 1.15,
|
||
"text": "Farming 15% faster, city eats 15% more"},
|
||
"storm": {"name": "Storm", "icon": "⛈️", "speed": {"fishing": 1.25}, "text": "Fishing 25% slower"},
|
||
"fog": {"name": "Fog", "icon": "🌫️", "speed": {"reconnaissance": 1.3, "hunting": 1.1},
|
||
"text": "Scouting 30% and hunting 10% slower"},
|
||
"snow": {"name": "Snow", "icon": "🌨️", "speed": {"hunting": 1.2}, "firewood": 0.05,
|
||
"text": "Hunting 20% slower, burns firewood"},
|
||
}
|
||
SEASON_WEATHER = [
|
||
{"clear": 35, "rain": 40, "storm": 10, "fog": 15},
|
||
{"clear": 45, "heat": 30, "rain": 15, "storm": 10},
|
||
{"clear": 30, "rain": 30, "storm": 25, "fog": 15},
|
||
{"clear": 30, "snow": 45, "fog": 15, "storm": 10},
|
||
]
|
||
WOOD = FIREWOOD + tuple(plank for plank, _name in PLANKS)
|
||
|
||
# Minor threats: they slow or block a skill until someone deals with them,
|
||
# either by working a special action (`work` completions) or by donating (`need`).
|
||
PROBLEMS = {
|
||
"collapse": {"name": "Mine collapse", "icon": "⛰️", "need": {"plank": 30},
|
||
"block": {"mining": "Mine collapsed"}, "allow": ["mine_stone"],
|
||
"text": "No ore until it's shored up with planks."},
|
||
"wolves": {"name": "Wolves at the farms", "icon": "🐺", "speed": {"farming": 2.0}, "work": 10, "action": "clear_wolves",
|
||
"text": "Farming twice as slow until fighters drive them off."},
|
||
"bear": {"name": "Bear in the woods", "icon": "🐻", "speed": {"woodcutting": 1.5}, "work": 8, "action": "drive_bear",
|
||
"text": "Woodcutting much slower until hunters drive it off."},
|
||
"goblins": {"name": "Goblin thieves", "icon": "👺", "steal": 0.01, "work": 10, "action": "catch_goblins",
|
||
"text": "Steal from the stockpile hourly until fighters catch them."},
|
||
"rockslide": {"name": "Rockslide on the quarry road", "icon": "🪨", "work": 25, "action": "clear_rockslide",
|
||
"block": {"mining": "Quarry road blocked"}, "only": ["mine_stone"],
|
||
"text": "No stone until builders clear it."},
|
||
"flood": {"name": "Flooded fishing grounds", "icon": "🌊", "speed": {"fishing": 1.5}, "need": {"plank": 20}, "lasts": 18,
|
||
"text": "Fishing slow for 18h, or until dammed with planks."},
|
||
"boars": {"name": "Boars trampling the crops", "icon": "🐗", "speed": {"farming": 1.5}, "work": 10, "action": "cull_boars",
|
||
"text": "Farming slower until hunters cull them."},
|
||
"rats": {"name": "Rats in the granary", "icon": "🐀", "steal_food": 0.03, "work": 12, "action": "catch_rats",
|
||
"text": "Eat city food hourly until hunters catch them."},
|
||
"spiders": {"name": "Spiders in the mine", "icon": "🕷️", "speed": {"mining": 1.5}, "work": 10, "action": "clear_spiders",
|
||
"text": "Mining much slower until fighters clear them out."},
|
||
"smugglers": {"name": "Smugglers in the caves", "icon": "🏴☠️", "steal_gold": 0.01, "work": 10, "action": "catch_smugglers",
|
||
"text": "Skim 1% of the treasury hourly until fighters catch them."},
|
||
"bridge": {"name": "Bridge washed out", "icon": "🌉", "need": {"plank": 25},
|
||
"block": {"hunting": "The bridge to the hunting grounds is out"},
|
||
"text": "No hunting until the bridge is rebuilt with planks."},
|
||
"hornets": {"name": "Hornets in the woods", "icon": "🐝", "speed": {"woodcutting": 1.4}, "work": 8, "action": "smoke_hornets",
|
||
"text": "Woodcutting slower until herbalists smoke them out."},
|
||
}
|
||
MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars",
|
||
"spiders", "smugglers", "bridge", "hornets")
|
||
PROBLEM_ACTIONS = [
|
||
_encounter("clear_wolves", "Wolf pack", "Frontier", 4, WOLVES, label="Drive off the wolves",
|
||
special="problem", problem="wolves"),
|
||
_encounter("catch_goblins", "Goblin thieves", "Frontier", 5, GOBLINS, label="Catch the goblin thieves",
|
||
special="problem", problem="goblins"),
|
||
_action("drive_bear", "hunting", "Drive off the bear", 1, 45, 14, special="problem", problem="bear"),
|
||
_action("cull_boars", "hunting", "Cull the boar herd", 1, 45, 14, special="problem", problem="boars"),
|
||
_action("catch_rats", "hunting", "Catch the rats", 1, 30, 8, special="problem", problem="rats"),
|
||
_action("clear_rockslide", "construction", "Clear the rockslide", 1, 30, 10, special="problem", problem="rockslide"),
|
||
_encounter("clear_spiders", "Mine spiders", "Frontier", 6, SPIDERS, label="Clear the spiders",
|
||
special="problem", problem="spiders"),
|
||
_encounter("catch_smugglers", "Smugglers", "Frontier", 8, SMUGGLERS, gold=6, label="Catch the smugglers",
|
||
special="problem", problem="smugglers"),
|
||
_action("smoke_hornets", "herblore", "Smoke out the hornets", 1, 40, 12, special="problem", problem="hornets"),
|
||
]
|
||
ACTIONS.extend(PROBLEM_ACTIONS)
|
||
ACTION_BY_ID.update({action["id"]: action for action in PROBLEM_ACTIONS})
|
||
ITEMS["water_barrel"] = {"name": "Water barrel", "icon": "🛢️", "value": 20}
|
||
_barrel = _action("cooper_barrel", "construction", "Water barrel", 10, 35, 18,
|
||
{"oak_plank": 2, "iron_nails": 4}, {"water_barrel": 1}, group="Supplies")
|
||
ACTIONS.append(_barrel)
|
||
ACTION_BY_ID[_barrel["id"]] = _barrel
|
||
|
||
# Fittings: Construction makes them, mostly from the better planks, and each
|
||
# building takes one of each once it's high enough. A fitting works while its
|
||
# building stands; damage pauses it like the rest of the building.
|
||
# id, name, building, building level, construction level, inputs, effect, glyph, tint
|
||
FITTINGS = [
|
||
("grain_bins", "Grain bins", "granary", 1, 15, {"oak_plank": 20, "iron_nails": 25},
|
||
"City eats 10% less", "grain-bundle", "#e3c060"),
|
||
("saw_benches", "Saw benches", "lumber_shed", 1, 15, {"oak_plank": 20, "iron_nails": 40},
|
||
"Planks 20% faster", "hand-saw", "#8f5e30"),
|
||
("shelving", "Shelving", "storehouse", 2, 20, {"oak_plank": 40, "iron_nails": 40},
|
||
"+1 bank slot", "bookshelf", "#8f5e30"),
|
||
("map_table", "Map table", "spymaster", 1, 30, {"willow_plank": 25, "hard_leather": 10, "steel_hinges": 4},
|
||
"+1 recon per scout", "treasure-map", "#a3b86c"),
|
||
("well_pump", "Well pump", "well", 2, 30, {"willow_plank": 25, "steel_hinges": 10},
|
||
"Stops half of all fires", "water-tank", "#5aa0d8"),
|
||
("furniture", "Furniture", "houses", 2, 35, {"willow_plank": 40, "leather": 30, "steel_hinges": 10},
|
||
"Population grows 25% faster", "sofa", "#a3b86c"),
|
||
("weapon_racks", "Weapon racks", "barracks", 2, 40, {"willow_plank": 30, "steel_hinges": 16},
|
||
"Militia defence +50%", "spears", "#d4dae2"),
|
||
("hoardings", "Hoardings", "walls", 3, 45, {"maple_plank": 40, "mithril_brackets": 20},
|
||
"+25 defence", "palisade", "#d0663a"),
|
||
("bellows", "Great bellows", "smithy", 3, 50, {"maple_plank": 30, "bear_leather": 15, "mithril_brackets": 6},
|
||
"Smithing 10% faster", "bellows", "#d0663a"),
|
||
("carved_altar", "Carved altar", "temple", 2, 60, {"yew_plank": 30, "cut_stone": 60, "adamant_chain": 4},
|
||
"+1 prayer point per bone, ward +15", "star-altar", "#6f9a4a"),
|
||
("enchanted_chests", "Enchanted chests", "storehouse", 4, 75, {"magic_plank": 40, "rune_lock": 6},
|
||
"+500 stack size", "open-treasure-chest", "#a77be8"),
|
||
("enchanted_hearths", "Enchanted hearths", "houses", 4, 80, {"magic_plank": 50, "mammoth_leather": 5, "adamant_chain": 6},
|
||
"HP recovers twice as fast", "fireplace", "#a77be8"),
|
||
]
|
||
# Builders' shields, homesteaders' staves and rangers' bows, after each skill's other tabs.
|
||
for _weapon in _weapon_actions():
|
||
ACTIONS.append(_weapon)
|
||
ACTION_BY_ID[_weapon["id"]] = _weapon
|
||
for _jewel in [*_jewel_actions(), *_storage_actions()]:
|
||
ACTIONS.append(_jewel)
|
||
ACTION_BY_ID[_jewel["id"]] = _jewel
|
||
_dungeon_loot() # masterworks are reforged from all of the above
|
||
FITTING_BY_ID = {}
|
||
for _id, _name, _building, _blevel, _level, _inputs, _effect, _glyph, _tint in FITTINGS:
|
||
FITTING_BY_ID[_id] = {"id": _id, "name": _name, "building": _building, "level": _blevel, "effect": _effect}
|
||
ITEMS[_id] = {"name": _name, "plural": _name, "icon": "🪚", "value": sum(ITEMS[i]["value"] * q for i, q in _inputs.items()),
|
||
"fitting": _building}
|
||
_fitting = _action(f"make_{_id}", "construction", _name, _level, 120 + _level * 2, 40 + _level * 4,
|
||
_inputs, {_id: 1}, group="Fittings")
|
||
ACTIONS.append(_fitting)
|
||
ACTION_BY_ID[_fitting["id"]] = _fitting
|
||
ITEMS[_id].update(glyph=_glyph, tint=_tint)
|
||
|
||
# (weights by season, spring to winter)
|
||
HARDSHIPS = {"sickness": [2, 1, 2, 3], "locusts": [1, 3, 2, 0], "rot": [3, 1, 3, 1], "storm": [1, 1, 3, 1],
|
||
"rats": [2, 2, 2, 2], "fire": [1, 2, 1, 2], "winter": [0, 0, 0, 4], "drought": [0, 4, 1, 0],
|
||
"blight": [2, 1, 3, 0], "tremors": [1, 1, 1, 1], "taxman": [1, 1, 2, 1]}
|
||
FORTUNES = {"merchant": [12] * 4, "refugees": [10] * 4, "master": [16] * 4, "harvest": [10, 10, 12, 0],
|
||
"rich_vein": [10] * 4, "salmon": [10, 8, 4, 0], "festival": [8] * 4, "noble": [8] * 4,
|
||
"pilgrims": [8] * 4, "mercenaries": [6] * 4, "cargo": [10] * 4,
|
||
"timber": [8, 6, 10, 8], "deer": [8, 10, 10, 4], "masons": [10, 10, 8, 6], "artisans": [8] * 4,
|
||
"trapper": [10] * 4}
|
||
TRACK_HOURS = {"minor": (16, 32), "hardship": (20, 40), "fortune": (10, 20)}
|
||
CRAFTS = ("smithing", "jewelcrafting", "cooking", "leatherworking", "tailoring", "herblore")
|
||
TRAPPER_RECON = 15 # boosted recon a trapper's leads are worth, like an informant's
|
||
|
||
|
||
def calendar_day(world: dict, now: float) -> int:
|
||
"""Days since the world began, counting midnights in the server's timezone (0 on the first day).
|
||
|
||
A sped-up dev world has no calendar to speak of, so it counts 24-hour stretches instead.
|
||
"""
|
||
if speed(world) != 1:
|
||
return int((now - world["created"]) * speed(world) // DAY)
|
||
return (date.fromtimestamp(now) - date.fromtimestamp(world["created"])).days
|
||
|
||
|
||
def season(world: dict, now: float) -> dict:
|
||
day = calendar_day(world, now)
|
||
index = day // SEASON_DAYS % len(SEASONS)
|
||
name, icon = SEASONS[index]
|
||
return {"index": index, "name": name, "icon": icon, "day": day % SEASON_DAYS + 1}
|
||
|
||
|
||
def _pick(weights: dict, rng: random.Random) -> str:
|
||
keys = [key for key, weight in weights.items() if weight > 0]
|
||
return rng.choices(keys, [weights[key] for key in keys])[0]
|
||
|
||
|
||
def _roll_weather(world: dict, now: float, rng: random.Random, current: str | None = None) -> str:
|
||
if current and rng.random() < 0.4:
|
||
return current
|
||
return _pick(SEASON_WEATHER[season(world, now)["index"]], rng)
|
||
|
||
|
||
def advance_weather(world: dict, now: float, rng: random.Random) -> bool:
|
||
"""Change the weather when its spell ends. Returns True if it changed."""
|
||
weather = world["city"]["weather"]
|
||
if now < weather["until"]:
|
||
return False
|
||
while now >= weather["until"]:
|
||
weather["kind"] = weather["next"]
|
||
_plan_spell(world, weather, rng)
|
||
return True
|
||
|
||
|
||
def _plan_spell(world: dict, weather: dict, rng: random.Random) -> None:
|
||
"""Set how long the current weather lasts and what follows. Rolls that keep the
|
||
same weather lengthen the spell, so `until` is when it really changes."""
|
||
for _ in range(3):
|
||
weather["until"] += rng.uniform(4, 8) * HOUR / speed(world)
|
||
weather["next"] = _roll_weather(world, weather["until"], rng, weather["kind"])
|
||
if weather["next"] != weather["kind"]:
|
||
return
|
||
# Three spells of the same is long enough: something else comes next.
|
||
weights = SEASON_WEATHER[season(world, weather["until"])["index"]]
|
||
weather["next"] = _pick({kind: w for kind, w in weights.items() if kind != weather["kind"]}, rng)
|
||
|
||
|
||
def _modifier_sources(world: dict) -> list[dict]:
|
||
city = world["city"]
|
||
sources = [WEATHER[city["weather"]["kind"]]] + city["effects"]
|
||
sources += [PROBLEMS[problem["kind"]] for problem in city["problems"]]
|
||
return sources
|
||
|
||
|
||
def skill_mods(world: dict, action: dict) -> dict:
|
||
"""How weather, happenings and problems change this action right now."""
|
||
skill = action["skill"]
|
||
mods = {"speed": 1.0, "yield": 1.0, "xp": 1.0, "blocked": None}
|
||
for source in _modifier_sources(world):
|
||
mods["speed"] *= source.get("speed", {}).get(skill, 1)
|
||
mods["yield"] *= source.get("yield", {}).get(skill, 1)
|
||
mods["xp"] *= source.get("xp", {}).get(skill, 1)
|
||
if action.get("special") != "problem":
|
||
for problem in world["city"]["problems"]:
|
||
spec = PROBLEMS[problem["kind"]]
|
||
reason = spec.get("block", {}).get(skill)
|
||
if reason and (action["id"] in spec.get("only", [action["id"]])) and action["id"] not in spec.get("allow", []):
|
||
mods["blocked"] = reason
|
||
return mods
|
||
|
||
|
||
def city_rate(world: dict, key: str) -> float:
|
||
"""The product of a city-wide factor (food, growth, favour) across everything active."""
|
||
rate = 1.0
|
||
for source in _modifier_sources(world):
|
||
rate *= source.get(key, 1)
|
||
return rate
|
||
|
||
|
||
def active_problem(world: dict, kind: str) -> dict | None:
|
||
return next((p for p in world["city"]["problems"] if p["kind"] == kind), None)
|
||
|
||
|
||
def _record(world: dict, now: float, track: str, kind: str, icon: str, title: str, lines: list[str]) -> None:
|
||
"""A happening for the history list and the ledger."""
|
||
world["events"].append({"id": world["next_event_id"], "kind": kind, "track": track, "status": "done",
|
||
"announced": now, "fire_at": now, "resolved": now,
|
||
"report": {"title": title, "icon": icon, "lines": lines}})
|
||
world["next_event_id"] += 1
|
||
del world["events"][:-60]
|
||
log(world, now, track, title)
|
||
|
||
|
||
def _add_effect(world: dict, now: float, hours: float, **effect) -> None:
|
||
world["city"]["effects"].append({**effect, "until": now + hours * HOUR / speed(world)})
|
||
|
||
|
||
def _take_share(stock: dict, items, share: float) -> dict:
|
||
taken = {}
|
||
for item in [i for i in items if stock.get(i)]:
|
||
gone = int(stock[item] * share)
|
||
if gone:
|
||
taken[item] = gone
|
||
take_stock_qty(stock, item, gone)
|
||
return taken
|
||
|
||
|
||
def take_stock_qty(stock: dict, item: str, qty: int) -> None:
|
||
stock[item] -= qty
|
||
if stock[item] <= 0:
|
||
del stock[item]
|
||
|
||
|
||
def _listing(taken: dict) -> str:
|
||
names = [amount(item, qty) for item, qty in taken.items()]
|
||
return ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else (names[0] if names else "nothing")
|
||
|
||
|
||
def _damage_building(world: dict, now: float, rng: random.Random) -> str | None:
|
||
city = world["city"]
|
||
built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]]
|
||
if not built:
|
||
return None
|
||
key = rng.choice(built)
|
||
city["buildings"][key]["damaged"] = True
|
||
_queue_if_funded(world, key, now)
|
||
return BUILDINGS[key]["name"]
|
||
|
||
|
||
def _food_items(stock: dict) -> list[str]:
|
||
return [item for item in stock if "food" in ITEMS[item] or item in ("wheat", "vegetables")]
|
||
|
||
|
||
def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None:
|
||
city = world["city"]
|
||
stock = city["stockpile"]
|
||
weights = {kind: w[season(world, now)["index"]] for kind, w in HARDSHIPS.items()}
|
||
if city["weather"]["kind"] == "storm":
|
||
weights["storm"] += 6
|
||
if city["weather"]["kind"] == "rain":
|
||
weights["rot"] += 3
|
||
if active_problem(world, "rats"):
|
||
weights["rats"] = 0
|
||
kind = kind or _pick(weights, rng)
|
||
granary, shed, well = (building_level(world, k) for k in ("granary", "lumber_shed", "well"))
|
||
if kind == "sickness":
|
||
hours = 36 * (1 - 0.2 * building_level(world, "temple"))
|
||
_add_effect(world, now, hours, kind="sickness", name="Sickness", icon="🤒", food=2.0,
|
||
text="City eats twice as much food")
|
||
_record(world, now, "hardship", kind, "🤒", "Sickness is spreading through the city",
|
||
[f"For about {round(hours)} hours the townsfolk eat twice as much food.",
|
||
"A bigger temple shortens it."])
|
||
elif kind == "locusts":
|
||
share = 0.4 * (1 - 0.25 * granary)
|
||
taken = _take_share(stock, _food_items(stock), share)
|
||
_record(world, now, "hardship", kind, "🦗", "A swarm of locusts descended on the stores",
|
||
[f"They ate {_listing(taken)}." if taken else "There was little for them to eat.",
|
||
*(["The granary kept part of it safe."] if granary else ["A granary would protect some of it."])])
|
||
elif kind == "rot":
|
||
taken = _take_share(stock, WOOD, 0.3 * (1 - 0.25 * shed))
|
||
_record(world, now, "hardship", kind, "🍄", "Damp got into the wood stores",
|
||
[f"Rot ruined {_listing(taken)}." if taken else "There was no wood in the stockpile to rot.",
|
||
*(["The lumber shed kept part of it dry."] if shed else ["A lumber shed would keep it dry."])])
|
||
elif kind == "storm":
|
||
name = _damage_building(world, now, rng)
|
||
taken = _take_share(stock, FIREWOOD, 0.1)
|
||
lines = [f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs." if name
|
||
else "Luckily there was nothing standing tall enough to break."]
|
||
if taken:
|
||
lines.append(f"The wind scattered {_listing(taken)}.")
|
||
_record(world, now, "hardship", kind, "🌪️", "A wild storm battered the city", lines)
|
||
elif kind == "rats":
|
||
city["problems"].append({"kind": "rats", "started": now, "progress": {}, "work": 0})
|
||
_record(world, now, "hardship", kind, "🐀", "Rats have got into the granary",
|
||
["They'll eat into the food every hour until hunters catch them."])
|
||
elif kind == "fire":
|
||
barrels = stock.get("water_barrel", 0)
|
||
if fitted(world, "well_pump") and rng.random() < 0.5:
|
||
_record(world, now, "hardship", kind, "🔥", "A fire broke out, and the well's pump put it out",
|
||
["Nothing was lost."])
|
||
elif barrels >= 5:
|
||
take_stock_qty(stock, "water_barrel", 5)
|
||
_record(world, now, "hardship", kind, "🔥", "A fire broke out, and the water barrels put it out",
|
||
["Five barrels were used up. Nothing else was lost."])
|
||
else:
|
||
share = 0.25 * (1 - 0.25 * well)
|
||
taken = _take_share(stock, WOOD + tuple(_food_items(stock)), share)
|
||
name = _damage_building(world, now, rng) if rng.random() > 0.25 * well else None
|
||
lines = [f"The flames took {_listing(taken)}." if taken else "Little was lost from the stores."]
|
||
if name:
|
||
lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.")
|
||
lines.append("Water barrels in the stockpile (five per fire) would have put it out.")
|
||
_record(world, now, "hardship", kind, "🔥", "Fire swept through part of the city", lines)
|
||
elif kind == "winter":
|
||
_add_effect(world, now, 24, kind="winter", name="Harsh winter", icon="🥶", firewood=0.1,
|
||
text="Burns stockpile logs hourly")
|
||
_record(world, now, "hardship", kind, "🥶", "A harsh cold snap has set in",
|
||
["For a day the city burns logs from the stockpile to keep warm.",
|
||
"Without firewood, the cold makes everyone eat 50% more food."])
|
||
elif kind == "blight":
|
||
_add_effect(world, now, 12, kind="blight", name="Blight", icon="🥀", **{"yield": {"farming": 0.5}},
|
||
text="Farming yield halved")
|
||
_record(world, now, "hardship", kind, "🥀", "Blight has struck the fields",
|
||
["For 12 hours, farming yields half."])
|
||
elif kind == "tremors":
|
||
_add_effect(world, now, 12, kind="tremors", name="Tremors", icon="🫨", speed={"mining": 1.3},
|
||
text="Mining 30% slower")
|
||
_record(world, now, "hardship", kind, "🫨", "The ground is shaking under the mines",
|
||
["For 12 hours, mining is 30% slower."])
|
||
elif kind == "taxman":
|
||
gold = int(city["treasury"] * 0.15)
|
||
city["treasury"] -= gold
|
||
_record(world, now, "hardship", kind, "📜", "The crown's tax collector came by",
|
||
[f"The collector took {gold:,} gold (15%) from the treasury." if gold else "The treasury was empty, so the collector left grumbling."])
|
||
else:
|
||
_add_effect(world, now, 12, kind="drought", name="Drought", icon="🏜️", speed={"farming": 1.3, "fishing": 1.2},
|
||
text="Farming 30% and fishing 20% slower")
|
||
_record(world, now, "hardship", kind, "🏜️", "Drought has dried the fields and streams",
|
||
["For 12 hours, farming is 30% and fishing 20% slower."])
|
||
|
||
|
||
def _fortune(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None:
|
||
city = world["city"]
|
||
weights = {kind: w[season(world, now)["index"]] for kind, w in FORTUNES.items()}
|
||
threat = upcoming_raid(world)
|
||
if not threat:
|
||
weights["mercenaries"] = 0
|
||
if not threat or threat["recon"] >= intel_costs(threat)[-1]:
|
||
weights["trapper"] = 0
|
||
kind = kind or _pick(weights, rng)
|
||
if kind == "merchant":
|
||
city["merchant_until"] = now + 6 * HOUR / speed(world)
|
||
_record(world, now, "fortune", kind, "🐪", "A merchant caravan has arrived",
|
||
["For the next 6 hours, everything sells for 50% more."])
|
||
elif kind == "refugees":
|
||
people = rng.randint(6, 12)
|
||
taken = min(max(0, housing(world) - int(city["population"])), people)
|
||
city["population"] += taken
|
||
lines = [f"{taken} of {people} refugees found a home."]
|
||
if taken < people:
|
||
lines.append("The rest moved on. More houses would have kept them.")
|
||
_record(world, now, "fortune", kind, "🧳", "Refugees arrived", lines)
|
||
elif kind == "master":
|
||
skill = rng.choice([s for s in SKILL_IDS if s != "reconnaissance"])
|
||
name = next(s["name"] for s in SKILLS if s["id"] == skill)
|
||
_add_effect(world, now, 6, kind="master", name=f"A master of {name.lower()}", icon="🎓",
|
||
xp={skill: 1.5}, text=f"{name} +50% XP")
|
||
_record(world, now, "fortune", kind, "🎓", f"A travelling master of {name.lower()} is visiting",
|
||
[f"For 6 hours, {name.lower()} earns 50% more XP. Learn while you can."])
|
||
elif kind == "harvest":
|
||
_add_effect(world, now, 6, kind="harvest", name="Bountiful harvest", icon="🌾", **{"yield": {"farming": 2}},
|
||
text="Farming yield doubled")
|
||
_record(world, now, "fortune", kind, "🌾", "The fields are bursting: a bountiful harvest",
|
||
["Farming yields are doubled for 6 hours."])
|
||
elif kind == "rich_vein":
|
||
_add_effect(world, now, 6, kind="rich_vein", name="Rich vein", icon="💎", **{"yield": {"mining": 1.5}},
|
||
text="Mining yield +50%")
|
||
_record(world, now, "fortune", kind, "💎", "Miners struck a rich vein",
|
||
["Mining yields 50% more for 6 hours."])
|
||
elif kind == "salmon":
|
||
_add_effect(world, now, 6, kind="salmon", name="Salmon run", icon="🐟", **{"yield": {"fishing": 2}},
|
||
text="Fishing yield doubled")
|
||
_record(world, now, "fortune", kind, "🐟", "The salmon are running",
|
||
["Fishing yields are doubled for 6 hours."])
|
||
elif kind == "festival":
|
||
_add_effect(world, now, 12, kind="festival", name="Festival", icon="🎉", growth=2, favour=1.5,
|
||
text="Growth doubled, +50% favour from donations")
|
||
_record(world, now, "fortune", kind, "🎉", "The city is holding a festival",
|
||
["For 12 hours, the population grows twice as fast and donations earn 50% more favour."])
|
||
elif kind == "noble":
|
||
levels = sum(b["level"] for b in city["buildings"].values())
|
||
gold = 100 + 40 * levels
|
||
city["treasury"] += gold
|
||
_record(world, now, "fortune", kind, "👑", "A noble visited and admired the city",
|
||
[f"Impressed by its {levels} building levels, they left {gold:,} gold for the treasury."])
|
||
elif kind == "pilgrims":
|
||
lines = []
|
||
if building_level(world, "temple"):
|
||
gift = 20 * building_level(world, "temple")
|
||
city["devotion"] = city.get("devotion", 0) + gift
|
||
lines.append(f"They prayed at the temple: +{gift} devotion.")
|
||
for player in world["players"].values():
|
||
bank_add(world, player, "bones", 10, now, empty_summary())
|
||
lines.append("They left 10 bones with every adventurer.")
|
||
_record(world, now, "fortune", kind, "🙏", "Pilgrims passed through the city", lines)
|
||
elif kind == "mercenaries":
|
||
cost = 150 + 30 * season(world, now)["index"] + int(city["population"]) * 5
|
||
_add_effect(world, now, 12, kind="mercenaries", name="Mercenaries for hire", icon="🗡️", offer=True,
|
||
cost=cost, defence=25, text="+25 defence for the coming threat")
|
||
_record(world, now, "fortune", kind, "🗡️", "A sellsword company is camped outside the walls",
|
||
[f"For 12 hours they'll join the next defence for {cost:,} gold from the treasury."])
|
||
elif kind == "timber":
|
||
_add_effect(world, now, 6, kind="timber", name="Storm-felled timber", icon="🪵", **{"yield": {"woodcutting": 1.5}},
|
||
text="Woodcutting yield +50%")
|
||
_record(world, now, "fortune", kind, "🪵", "A storm felled trees all over the woods",
|
||
["Woodcutting yields 50% more for 6 hours."])
|
||
elif kind == "deer":
|
||
_add_effect(world, now, 6, kind="deer", name="Deer migration", icon="🦌", **{"yield": {"hunting": 2}},
|
||
text="Hunting yield doubled")
|
||
_record(world, now, "fortune", kind, "🦌", "Herds are migrating past the city",
|
||
["Hunting yields are doubled for 6 hours."])
|
||
elif kind == "masons":
|
||
_add_effect(world, now, 6, kind="masons", name="Masons' guild", icon="🧱", speed={"construction": 0.75},
|
||
text="Construction 25% faster")
|
||
_record(world, now, "fortune", kind, "🧱", "A masons' guild is passing through",
|
||
["For 6 hours, construction is 25% faster."])
|
||
elif kind == "artisans":
|
||
_add_effect(world, now, 6, kind="artisans", name="Artisans' fair", icon="🎨", speed={s: 0.85 for s in CRAFTS},
|
||
text="Smithing, jewelcrafting, cooking, leatherworking, tailoring and herblore 15% faster")
|
||
_record(world, now, "fortune", kind, "🎨", "Artisans from afar opened a fair",
|
||
["For 6 hours, every craft but construction and firekeeping is 15% faster."])
|
||
elif kind == "trapper":
|
||
city["recon_boost"] = city.get("recon_boost", 0) + TRAPPER_RECON
|
||
_record(world, now, "fortune", kind, "🗺️", "A trapper brought word from the hills",
|
||
[f"The next {TRAPPER_RECON} recon scouts gather counts double."])
|
||
else:
|
||
goods = rng.sample([("stone", 200), ("logs", 200), ("bricks", 80), ("bread", 60), ("arrows", 150), ("plank", 40)], 2)
|
||
for item, qty in goods:
|
||
city["stockpile"][item] = city["stockpile"].get(item, 0) + qty
|
||
_record(world, now, "fortune", kind, "📦", f"An abandoned wagon brought {_listing(dict(goods))} to the stockpile",
|
||
["Townsfolk found a trader's wagon abandoned on the road, its owner nowhere to be seen.",
|
||
f"They hauled its load into the city stockpile: {_listing(dict(goods))}. "
|
||
"The city uses it straight away, and anyone can put it towards a building."])
|
||
|
||
|
||
def _minor_threat(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None:
|
||
city = world["city"]
|
||
options = [k for k in MINOR_THREATS if not active_problem(world, k)]
|
||
kind = kind or rng.choice(options)
|
||
problem = {"kind": kind, "started": now, "progress": {}, "work": 0}
|
||
if "lasts" in PROBLEMS[kind]:
|
||
problem["until"] = now + PROBLEMS[kind]["lasts"] * HOUR / speed(world)
|
||
city["problems"].append(problem)
|
||
spec = PROBLEMS[kind]
|
||
_record(world, now, "minor", kind, spec["icon"], spec["name"], [spec["text"]])
|
||
|
||
|
||
def _solve(world: dict, problem: dict, now: float, who: str | None = None) -> None:
|
||
world["city"]["problems"].remove(problem)
|
||
spec = PROBLEMS[problem["kind"]]
|
||
_record(world, now, "minor", problem["kind"], "✅", f"Solved: {spec['name'].lower()}",
|
||
[f"Thanks to {who}." if who else "It's over."])
|
||
|
||
|
||
def problem_missing(problem: dict) -> dict:
|
||
need = PROBLEMS[problem["kind"]].get("need", {})
|
||
return {item: qty - problem["progress"].get(item, 0) for item, qty in need.items() if problem["progress"].get(item, 0) < qty}
|
||
|
||
|
||
def aid_problem(world: dict, player: dict, kind: str, item: str, source: str, now: float) -> str | None:
|
||
"""Give planks (or whatever a problem needs) from the bank or the stockpile."""
|
||
problem = active_problem(world, kind)
|
||
if not problem:
|
||
return "That's not a problem right now"
|
||
need = problem_missing(problem).get(item, 0)
|
||
if not need:
|
||
return "It doesn't need that"
|
||
stock = world["city"]["stockpile"]
|
||
have = player["bank"].get(item, 0) if source == "bank" else stock.get(item, 0)
|
||
qty = min(have, need)
|
||
if qty <= 0:
|
||
return "Nothing to give"
|
||
if source == "bank":
|
||
bank_take(player, item, qty)
|
||
gain = qty * ITEMS[item]["value"] * city_rate(world, "favour")
|
||
player["favour"] += gain
|
||
player["donated"] += qty * ITEMS[item]["value"]
|
||
else:
|
||
take_stock_qty(stock, item, qty)
|
||
problem["progress"][item] = problem["progress"].get(item, 0) + qty
|
||
if not problem_missing(problem):
|
||
_solve(world, problem, now, player["name"])
|
||
return None
|
||
|
||
|
||
def hire_mercenaries(world: dict, player: dict, now: float) -> str | None:
|
||
city = world["city"]
|
||
offer = next((e for e in city["effects"] if e.get("offer")), None)
|
||
if not offer:
|
||
return "Nobody is offering"
|
||
if city["treasury"] < offer["cost"]:
|
||
return f"The treasury needs {offer['cost']:,} gold"
|
||
city["treasury"] -= offer["cost"]
|
||
city["hired"] = city.get("hired", 0) + offer["defence"]
|
||
city["effects"].remove(offer)
|
||
log(world, now, "favour", f"{player['name']} hired the sellswords for {offer['cost']:,} gold (+{offer['defence']} defence)", player["id"])
|
||
return None
|
||
|
||
|
||
def _happenings_hour(world: dict, now: float, rng: random.Random) -> None:
|
||
"""Weather, firewood, thieves and expiries, once per game hour."""
|
||
city = world["city"]
|
||
advance_weather(world, now, rng)
|
||
city["effects"] = [e for e in city["effects"] if e["until"] > now]
|
||
for problem in list(city["problems"]):
|
||
if problem.get("until") and problem["until"] <= now:
|
||
_solve(world, problem, now)
|
||
stock = city["stockpile"]
|
||
burn = city["population"] * sum(source.get("firewood", 0) for source in _modifier_sources(world))
|
||
city["firewood_due"] = city.get("firewood_due", 0) + burn
|
||
cold = False
|
||
while city["firewood_due"] >= 1:
|
||
log_item = next((item for item in FIREWOOD if stock.get(item)), None)
|
||
if not log_item:
|
||
cold = True
|
||
city["firewood_due"] = 0
|
||
break
|
||
take_stock_qty(stock, log_item, 1)
|
||
city["firewood_due"] -= 1
|
||
if cold and not city.get("cold"):
|
||
log(world, now, "city", "The firewood has run out. People are cold and eating more to stay warm.")
|
||
city["cold"] = cold
|
||
for problem in city["problems"]:
|
||
spec = PROBLEMS[problem["kind"]]
|
||
if spec.get("steal"):
|
||
_take_share(stock, [i for i in stock if i not in _food_items(stock)], spec["steal"])
|
||
if spec.get("steal_food"):
|
||
_take_share(stock, _food_items(stock), spec["steal_food"] * (1 - 0.25 * building_level(world, "granary")))
|
||
if spec.get("steal_gold"):
|
||
city["treasury"] *= 1 - spec["steal_gold"]
|
||
|
||
|
||
def _happenings_tracks(world: dict, now: float, rng: random.Random) -> bool:
|
||
changed = False
|
||
for track, (low, high) in TRACK_HOURS.items():
|
||
due = world.get(f"next_{track}_at")
|
||
if due is None or now < due:
|
||
continue
|
||
if track == "minor" and any(p["kind"] in MINOR_THREATS for p in world["city"]["problems"]):
|
||
world[f"next_{track}_at"] = now + 6 * HOUR / speed(world)
|
||
continue
|
||
{"minor": _minor_threat, "hardship": _hardship, "fortune": _fortune}[track](world, now, rng)
|
||
world[f"next_{track}_at"] = now + rng.uniform(low, high) * HOUR / speed(world)
|
||
changed = True
|
||
return changed
|
||
|
||
|
||
def happenings_view(world: dict, now: float) -> dict:
|
||
city = world["city"]
|
||
weather = city["weather"]
|
||
problems = []
|
||
for problem in city["problems"]:
|
||
spec = PROBLEMS[problem["kind"]]
|
||
problems.append({
|
||
"kind": problem["kind"], "name": spec["name"], "icon": spec["icon"], "text": spec["text"],
|
||
"until": problem.get("until"), "work": problem["work"], "needed": spec.get("work"),
|
||
"action": spec.get("action"), "need": spec.get("need"), "missing": problem_missing(problem),
|
||
})
|
||
return {
|
||
"season": season(world, now),
|
||
"weather": {**{k: v for k, v in WEATHER[weather["kind"]].items()}, "kind": weather["kind"], "until": weather["until"],
|
||
"next": weather["next"], "next_name": WEATHER[weather["next"]]["name"],
|
||
"next_icon": WEATHER[weather["next"]]["icon"], "next_text": WEATHER[weather["next"]]["text"]},
|
||
"effects": [{k: e[k] for k in ("kind", "name", "icon", "text", "until", "speed", "yield", "xp") if k in e}
|
||
| {"offer": e.get("offer", False), "cost": e.get("cost")} for e in city["effects"]],
|
||
"problems": problems,
|
||
"cold": city.get("cold", False),
|
||
"hired": city.get("hired", 0),
|
||
}
|
||
|
||
|
||
# --- gold ------------------------------------------------------------------
|
||
# Gold never buys results. It rents bank slots, pays the guards, buys caravan
|
||
# wares that boost your own work for a while, and buys looks.
|
||
|
||
RENT_BASE = 1000 # gold for the first rented bank slot...
|
||
RENT_GROWTH = 1.35 # ...and each one after costs this much more
|
||
|
||
|
||
def rent_cost(player: dict) -> int:
|
||
return int(round(RENT_BASE * RENT_GROWTH ** player["rented"] / 50) * 50)
|
||
|
||
|
||
def rent_slot(player: dict) -> str | None:
|
||
cost = rent_cost(player)
|
||
if player["gold"] < cost:
|
||
return f"Needs {cost:,} gold"
|
||
player["gold"] -= cost
|
||
player["rented"] += 1
|
||
return None
|
||
|
||
|
||
# Caravan wares: each multiplies your own work in one skill for its next `charges`
|
||
# actions, or against the coming threat. `skill` None is picked by the buyer.
|
||
WARES = {
|
||
"whetstone": {"name": "Whetstone of the north", "skill": "combat", "kind": "drops", "mult": 2, "charges": 100,
|
||
"price": 3000, "text": "Trophy and reagent odds ×2", "per": "kills"},
|
||
"tutor": {"name": "Tutor's notes", "skill": None, "kind": "xp", "mult": 1.5, "charges": 200, "price": 1500,
|
||
"text": "+50% XP", "per": "actions"},
|
||
"salts": {"name": "Ember salts", "skill": "firekeeping", "kind": "speed", "mult": 0.75, "charges": 100, "price": 1200,
|
||
"text": "Tending 25% faster", "per": "tends"},
|
||
"seeds": {"name": "Seed bag", "skill": "farming", "kind": "yield", "mult": 1.5, "charges": 150, "price": 1200,
|
||
"text": "+50% yield", "per": "harvests"},
|
||
"prospector": {"name": "Prospector's charm", "skill": "mining", "kind": "yield", "mult": 1.5, "charges": 300,
|
||
"price": 1500, "text": "+50% yield", "per": "ores"},
|
||
"lure": {"name": "Silver lure", "skill": "fishing", "kind": "yield", "mult": 1.5, "charges": 300, "price": 1200,
|
||
"text": "+50% yield", "per": "catches"},
|
||
"flux": {"name": "Fine flux", "skill": "smithing", "kind": "speed", "mult": 0.8, "charges": 100, "price": 1500,
|
||
"text": "Smithing 20% faster", "per": "crafts"},
|
||
"rations": {"name": "Trail rations", "skill": "combat", "kind": "rest", "mult": 0.5, "charges": 50, "price": 800,
|
||
"text": "Rest 50% faster", "per": "fights"},
|
||
"spyglass": {"name": "Spyglass lens", "skill": "reconnaissance", "kind": "recon", "mult": 1.5, "charges": None,
|
||
"price": 1000, "text": "Your recon ×1.5", "per": "threat"},
|
||
"manual": {"name": "Drill manual", "skill": "combat", "kind": "drill", "mult": 1.5, "charges": None,
|
||
"price": 1000, "text": "Your drilling ×1.5", "per": "threat"},
|
||
}
|
||
WARE_GLYPHS = {"whetstone": ("stone-sphere", "#a8b4c8"), "tutor": ("book-cover", "#4fa3e0"), "salts": ("powder", "#ff7a2a"),
|
||
"seeds": ("plant-seed", "#8fbf6a"), "prospector": ("gems", "#7fd0e0"), "lure": ("fishing-lure", "#d4dae2"),
|
||
"flux": ("powder-bag", "#d07a3c"), "rations": ("meat", "#c06a4a"), "spyglass": ("spyglass", "#6f8fec"),
|
||
"manual": ("scroll-unfurled", "#e0a050")}
|
||
CARAVAN_HOURS = 12 # how long the caravan stays
|
||
CARAVAN_WARES = 4 # wares it brings from the pool, plus one look
|
||
|
||
|
||
def ware(player: dict, kind: str, skill: str, spend: bool = False) -> float:
|
||
"""The multiplier a caravan ware gives this kind of work, using up a charge if `spend`."""
|
||
for boost in player["boosts"]:
|
||
spec = WARES[boost["ware"]]
|
||
if spec["kind"] == kind and boost["skill"] == skill:
|
||
if spend and "left" in boost:
|
||
boost["left"] -= 1
|
||
if boost["left"] <= 0:
|
||
player["boosts"].remove(boost)
|
||
return spec["mult"]
|
||
return 1
|
||
|
||
|
||
def ware_price(player: dict, ware_id: str, skill: str) -> int:
|
||
"""The list price, more for someone further along in the skill it helps."""
|
||
return int(round(WARES[ware_id]["price"] * (1 + skill_level(player, skill) / 30) / 50) * 50)
|
||
|
||
|
||
def _plan_caravan(world: dict, now: float, rng: random.Random) -> None:
|
||
"""The caravan comes once per threat, on the first day, for half a day."""
|
||
at = now + rng.uniform(2, 16) * HOUR / speed(world)
|
||
world["caravan"] = {"id": world["next_caravan_id"], "at": at, "until": at + CARAVAN_HOURS * HOUR / speed(world),
|
||
"wares": None, "look": None}
|
||
world["next_caravan_id"] += 1
|
||
|
||
|
||
def caravan_here(world: dict, now: float) -> dict | None:
|
||
caravan = world.get("caravan")
|
||
return caravan if caravan and caravan["wares"] is not None and caravan["at"] <= now < caravan["until"] else None
|
||
|
||
|
||
def _caravan_tick(world: dict, now: float, rng: random.Random) -> bool:
|
||
caravan = world.get("caravan")
|
||
if not caravan or caravan["wares"] is not None or now < caravan["at"] or now >= caravan["until"]:
|
||
return False
|
||
caravan["wares"] = rng.sample(sorted(WARES), CARAVAN_WARES)
|
||
caravan["look"] = rng.choice(sorted(k for k, c in COSMETICS.items() if c.get("price")))
|
||
names = [WARES[w]["name"].lower() for w in caravan["wares"]] + [COSMETICS[caravan["look"]]["name"].lower()]
|
||
_record(world, now, "fortune", "caravan", "🐫", "A caravan has arrived",
|
||
[f"For {CARAVAN_HOURS} hours it sells {', '.join(names[:-1])} and {names[-1]}."])
|
||
return True
|
||
|
||
|
||
def buy_ware(world: dict, player: dict, ware_id: str, skill: str | None, now: float) -> str | None:
|
||
caravan = caravan_here(world, now)
|
||
if not caravan:
|
||
return "The caravan isn't here"
|
||
bought = player["bought"] if player["bought"] and player["bought"]["visit"] == caravan["id"] else {"visit": caravan["id"], "items": []}
|
||
if ware_id in bought["items"]:
|
||
return "One of each per visit"
|
||
if ware_id == caravan["look"]:
|
||
spec = COSMETICS[ware_id]
|
||
if ware_id in player["owned"]:
|
||
return "You have that already"
|
||
if player["gold"] < spec["price"]:
|
||
return f"Needs {spec['price']:,} gold"
|
||
player["gold"] -= spec["price"]
|
||
player["owned"].append(ware_id)
|
||
else:
|
||
if ware_id not in caravan["wares"]:
|
||
return "Not for sale"
|
||
spec = WARES[ware_id]
|
||
skill = spec["skill"] or skill
|
||
if skill not in SKILL_IDS:
|
||
return "Pick a skill"
|
||
threat = upcoming_raid(world)
|
||
if spec["charges"] is None and not threat:
|
||
return "There's no threat coming"
|
||
price = ware_price(player, ware_id, skill)
|
||
if player["gold"] < price:
|
||
return f"Needs {price:,} gold"
|
||
player["gold"] -= price
|
||
held = next((b for b in player["boosts"] if b["ware"] == ware_id and b["skill"] == skill), None)
|
||
if spec["charges"] is None:
|
||
if held:
|
||
return "You have one for this threat"
|
||
player["boosts"].append({"ware": ware_id, "skill": skill, "threat": threat["id"]})
|
||
elif held:
|
||
held["left"] += spec["charges"]
|
||
else:
|
||
player["boosts"].append({"ware": ware_id, "skill": skill, "left": spec["charges"]})
|
||
bought["items"].append(ware_id)
|
||
player["bought"] = bought
|
||
return None
|
||
|
||
|
||
def caravan_view(world: dict, player: dict, now: float) -> dict | None:
|
||
caravan = caravan_here(world, now)
|
||
if not caravan:
|
||
return None
|
||
bought = player["bought"]["items"] if player["bought"] and player["bought"]["visit"] == caravan["id"] else []
|
||
wares = []
|
||
for ware_id in caravan["wares"]:
|
||
spec = WARES[ware_id]
|
||
skills = [spec["skill"]] if spec["skill"] else [s for s in SKILL_IDS]
|
||
wares.append({"id": ware_id, **spec, "glyph": WARE_GLYPHS[ware_id],
|
||
"prices": {s: ware_price(player, ware_id, s) for s in skills}, "bought": ware_id in bought})
|
||
look = COSMETICS[caravan["look"]]
|
||
return {"until": caravan["until"], "wares": wares,
|
||
"look": {"id": caravan["look"], **look, "bought": caravan["look"] in bought, "owned": caravan["look"] in player["owned"]}}
|
||
|
||
|
||
# Looks: a banner (field, trim, emblem), a title after your name, and your home on the roster.
|
||
# Free colours and earned titles cost nothing; the caravan sells one more look per visit.
|
||
DEFAULT_LOOK = {"field": "slate", "trim": "bone", "emblem": None, "title": None}
|
||
COSMETICS = {
|
||
# free
|
||
"slate": {"kind": "colour", "name": "Slate", "value": "#5a6478"},
|
||
"navy": {"kind": "colour", "name": "Navy", "value": "#2f4f8a"},
|
||
"oxblood": {"kind": "colour", "name": "Oxblood", "value": "#7a2f2f"},
|
||
"forest": {"kind": "colour", "name": "Forest", "value": "#2f6b3a"},
|
||
"bone": {"kind": "colour", "name": "Bone", "value": "#e7e9f0"},
|
||
"brass": {"kind": "colour", "name": "Brass", "value": "#e0b04a"},
|
||
# sold by the caravan
|
||
"royal_purple": {"kind": "colour", "name": "Royal purple dye", "value": "#6b3f8a", "price": 800},
|
||
"teal": {"kind": "colour", "name": "Deep teal dye", "value": "#1f6f6f", "price": 600},
|
||
"ember": {"kind": "colour", "name": "Ember dye", "value": "#ff7a2a", "price": 800},
|
||
"wine": {"kind": "colour", "name": "Wine dye", "value": "#8a2f5a", "price": 600},
|
||
"jet": {"kind": "colour", "name": "Jet dye", "value": "#111318", "price": 1000},
|
||
"sky": {"kind": "colour", "name": "Sky dye", "value": "#7fb6e8", "price": 600},
|
||
"e_swords": {"kind": "emblem", "name": "Crossed swords emblem", "value": "crossed-swords", "price": 1200},
|
||
"e_anvil": {"kind": "emblem", "name": "Anvil emblem", "value": "anvil", "price": 1200},
|
||
"e_castle": {"kind": "emblem", "name": "Tower emblem", "value": "castle", "price": 1200},
|
||
"e_wolf": {"kind": "emblem", "name": "Wolf emblem", "value": "wolf-head", "price": 1500},
|
||
"e_stag": {"kind": "emblem", "name": "Stag emblem", "value": "deer", "price": 1500},
|
||
"e_sun": {"kind": "emblem", "name": "Sun emblem", "value": "sun", "price": 1500},
|
||
"e_oak": {"kind": "emblem", "name": "Oak emblem", "value": "oak", "price": 1500},
|
||
"e_rose": {"kind": "emblem", "name": "Rose emblem", "value": "rose", "price": 2000},
|
||
"t_thrifty": {"kind": "title", "name": "Title: the Thrifty", "value": "the Thrifty", "price": 1000},
|
||
"t_bold": {"kind": "title", "name": "Title: the Bold", "value": "the Bold", "price": 2000},
|
||
"t_wanderer": {"kind": "title", "name": "Title: the Wanderer", "value": "the Wanderer", "price": 1500},
|
||
"t_lucky": {"kind": "title", "name": "Title: the Lucky", "value": "the Lucky", "price": 2500},
|
||
"t_unbowed": {"kind": "title", "name": "Title: the Unbowed", "value": "the Unbowed", "price": 3000},
|
||
}
|
||
FREE_LOOKS = [key for key, c in COSMETICS.items() if not c.get("price")]
|
||
# Boss emblems are only won, by beating the boss.
|
||
for _a in ACTIONS:
|
||
if "enemy" in _a and _a["enemy"].get("boss"):
|
||
COSMETICS[f"boss_{_a['id']}"] = {"kind": "emblem", "name": f"{_a['enemy']['name']} emblem", "boss": _a["id"]}
|
||
SKILL_TITLES = {"mining": "miner", "woodcutting": "woodcutter", "farming": "farmer", "hunting": "hunter", "fishing": "angler",
|
||
"smithing": "smith", "jewelcrafting": "jeweller", "construction": "builder", "cooking": "cook",
|
||
"leatherworking": "tanner", "tailoring": "tailor", "herblore": "herbalist", "firekeeping": "flamekeeper",
|
||
"combat": "warrior", "prayer": "priest", "reconnaissance": "scout"}
|
||
SKILL_EXTRA_TITLES = [("fishing", 50, "Master baiter")]
|
||
DEED_TITLES = [("defended", 5, "Defender of the City"), ("dragons", 1, "Dragonslayer"), ("bosses", 10, "Bossbane")]
|
||
HOMES = [("Hut", 0), ("Cottage", 2000), ("House", 8000), ("Hall", 25000), ("Manor", 75000)]
|
||
MONUMENTS = {
|
||
"gate_banner": {"name": "Gate banner", "cost": 10000, "glyph": "flying-flag", "tint": "#e0b04a", "once": True,
|
||
"text": "The guild's colours over the city gate."},
|
||
"fountain": {"name": "Fountain", "cost": 25000, "glyph": "fountain", "tint": "#5aa0d8", "once": True,
|
||
"text": "A fountain in the town square."},
|
||
"statue": {"name": "Statue of a guildmate", "cost": 50000, "glyph": "stone-bust", "tint": "#d4dae2", "once": False,
|
||
"text": "Carved in stone. The biggest donor picks who."},
|
||
}
|
||
|
||
|
||
def _deed(player: dict, key: str) -> None:
|
||
player["deeds"][key] = player["deeds"].get(key, 0) + 1
|
||
|
||
|
||
def earned_titles(player: dict) -> list[str]:
|
||
titles = []
|
||
for skill, noun in SKILL_TITLES.items():
|
||
level = skill_level(player, skill)
|
||
if level >= 99:
|
||
titles.append(f"Grandmaster {noun}")
|
||
elif level >= 50:
|
||
titles.append(f"Master {noun}")
|
||
titles += [title for skill, need, title in SKILL_EXTRA_TITLES if skill_level(player, skill) >= need]
|
||
titles += [title for key, need, title in DEED_TITLES if player["deeds"].get(key, 0) >= need]
|
||
if player["donated"] >= 100_000:
|
||
titles.append("Patron of the City")
|
||
return titles
|
||
|
||
|
||
def titles(player: dict) -> list[str]:
|
||
return earned_titles(player) + [COSMETICS[c]["value"] for c in player["owned"] if COSMETICS[c]["kind"] == "title"]
|
||
|
||
|
||
def can_wear(player: dict, key: str | None, kind: str) -> bool:
|
||
if key is None:
|
||
return kind == "emblem"
|
||
spec = COSMETICS.get(key)
|
||
return bool(spec) and spec["kind"] == kind and (key in FREE_LOOKS or key in player["owned"])
|
||
|
||
|
||
def set_look(player: dict, look: dict) -> str | None:
|
||
field, trim, emblem, title = (look.get(k) for k in ("field", "trim", "emblem", "title"))
|
||
if not (can_wear(player, field, "colour") and can_wear(player, trim, "colour") and can_wear(player, emblem, "emblem")):
|
||
return "You don't own that"
|
||
if title is not None and title not in titles(player):
|
||
return "You haven't earned that title"
|
||
player["look"] = {"field": field, "trim": trim, "emblem": emblem, "title": title}
|
||
return None
|
||
|
||
|
||
def look_view(player: dict) -> dict:
|
||
"""What others see: banner colours and emblem resolved, title, home."""
|
||
look = player["look"]
|
||
title = look["title"] if look["title"] in titles(player) else None
|
||
emblem = COSMETICS[look["emblem"]] if look["emblem"] else None
|
||
glyph = None
|
||
if emblem:
|
||
glyph = emblem.get("value") or ACTION_BY_ID[emblem["boss"]]["glyph"]
|
||
return {"field": COSMETICS[look["field"]]["value"], "trim": COSMETICS[look["trim"]]["value"], "emblem": glyph,
|
||
"title": title, "home": HOMES[player.get("home", 0)][0]}
|
||
|
||
|
||
def upgrade_home(player: dict) -> str | None:
|
||
level = player.get("home", 0)
|
||
if level + 1 >= len(HOMES):
|
||
return "Your home is as grand as it gets"
|
||
name, cost = HOMES[level + 1]
|
||
if player["gold"] < cost:
|
||
return f"Needs {cost:,} gold"
|
||
player["gold"] -= cost
|
||
player["home"] = level + 1
|
||
return None
|
||
|
||
|
||
def give_monument(world: dict, player: dict, key: str, qty, now: float) -> str | None:
|
||
"""Gold towards a monument. No favour: your name in the stone is the reward."""
|
||
city = world["city"]
|
||
spec = MONUMENTS.get(key)
|
||
if not spec:
|
||
return "No such monument"
|
||
if spec["once"] and any(b["kind"] == key for b in city["built"]):
|
||
return "It's already built"
|
||
if key == "statue" and city["carving"]:
|
||
return "A statue is being carved"
|
||
fund = city["monuments"].setdefault(key, {"gold": 0, "donors": {}})
|
||
room = spec["cost"] - fund["gold"]
|
||
qty = min(player["gold"], room) if qty == "all" else qty
|
||
if not isinstance(qty, int) or qty <= 0:
|
||
return "Nothing to give"
|
||
if qty > player["gold"]:
|
||
return "Not enough gold"
|
||
qty = min(qty, room)
|
||
player["gold"] -= qty
|
||
fund["gold"] += qty
|
||
fund["donors"][player["id"]] = fund["donors"].get(player["id"], 0) + qty
|
||
if fund["gold"] >= spec["cost"]:
|
||
del city["monuments"][key]
|
||
if key == "statue":
|
||
chooser = max(fund["donors"], key=lambda uid: fund["donors"][uid])
|
||
city["carving"] = {"by": chooser, "donors": fund["donors"]}
|
||
log(world, now, "build", f"The statue fund is full. {world['players'][chooser]['name']} picks who it honours.")
|
||
else:
|
||
city["built"].append({"kind": key, "at": now, "donors": fund["donors"], "of": None})
|
||
log(world, now, "build", f"The {spec['name'].lower()} is finished")
|
||
return None
|
||
|
||
|
||
def carve_statue(world: dict, player: dict, of: str, now: float) -> str | None:
|
||
city = world["city"]
|
||
carving = city["carving"]
|
||
if not carving or carving["by"] != player["id"]:
|
||
return "It isn't yours to pick"
|
||
if of not in world["players"]:
|
||
return "No such guildmate"
|
||
city["built"].append({"kind": "statue", "at": now, "donors": carving["donors"], "of": of})
|
||
city["carving"] = None
|
||
log(world, now, "build", f"A statue of {world['players'][of]['name']} now stands in the town square")
|
||
return None
|
||
|
||
|
||
def monuments_view(world: dict) -> dict:
|
||
city = world["city"]
|
||
return {"funds": city["monuments"], "built": city["built"], "carving": city["carving"]}
|
||
|
||
|
||
# --- 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
|
||
|
||
|
||
# --- 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}]},
|
||
]
|
||
|
||
|
||
def drill_per_action(player: dict) -> float:
|
||
return DRILL_PER_ACTION * (1 + 0.25 * (skill_level(player, "combat") // 20)) * ware(player, "drill", "combat")
|
||
|
||
|
||
def drill_needed(threat: dict) -> int:
|
||
return intel_costs(threat)[-1]
|
||
|
||
|
||
def drill_cap(world: dict) -> float:
|
||
return round(DRILL_SHARE * world["city"]["population"], 1)
|
||
|
||
|
||
def drilled(world: dict) -> float:
|
||
"""Defence the drilling is worth so far: a share of the cap, as far as the drill has got."""
|
||
threat = upcoming_raid(world)
|
||
if not threat:
|
||
return 0
|
||
return drill_cap(world) * min(1.0, threat.get("drilled", 0) / drill_needed(threat))
|
||
|
||
|
||
def favours(world: dict, now: float) -> dict:
|
||
"""What favour can buy for the city right now: cost, and why not if it can't."""
|
||
city = world["city"]
|
||
work = city["construction"]
|
||
raid = upcoming_raid(world)
|
||
return {
|
||
"builders": {
|
||
"cost": round(MASTER_BUILDER_FAVOUR * (1 + 0.5 * city.get("boosts_bought", 0))),
|
||
"why": None if work else "Nothing is being built",
|
||
"labour": MASTER_BUILDER_LABOUR,
|
||
"boost": round(city.get("site_boost", 0)),
|
||
},
|
||
"informant": {
|
||
"cost": round(INFORMANT_FAVOUR * (1 + 0.5 * (raid or {}).get("informants", 0))),
|
||
"why": "No news to dig into" if not raid else
|
||
"Nothing more to learn" if raid["recon"] >= intel_costs(raid)[-1] else None,
|
||
"recon": INFORMANT_RECON,
|
||
"boost": round(city.get("recon_boost", 0)),
|
||
},
|
||
"sergeant": {
|
||
"cost": round(DRILL_SERGEANT_FAVOUR * (1 + 0.5 * (raid or {}).get("sergeants", 0))),
|
||
"why": "No raid to prepare for" if not raid else
|
||
"The militia is as drilled as it can be" if raid.get("drilled", 0) >= drill_needed(raid) else None,
|
||
"drill": DRILL_SERGEANT_POINTS,
|
||
"boost": round(city.get("drill_boost", 0), 1),
|
||
"points": int((raid or {}).get("drilled", 0)), "needed": drill_needed(raid) if raid else 0,
|
||
"defence": round(drilled(world), 1), "cap": drill_cap(world),
|
||
},
|
||
}
|
||
|
||
|
||
def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None:
|
||
"""Turn a player's favour into help for the whole city."""
|
||
option = favours(world, now).get(kind)
|
||
if not option:
|
||
return "No such favour"
|
||
if option["why"]:
|
||
return option["why"]
|
||
if player["favour"] < option["cost"]:
|
||
return f"Needs {option['cost']:,} favour"
|
||
player["favour"] -= option["cost"]
|
||
city = world["city"]
|
||
if kind == "builders":
|
||
work = city["construction"]
|
||
city["site_boost"] = city.get("site_boost", 0) + MASTER_BUILDER_LABOUR
|
||
city["boosts_bought"] = city.get("boosts_bought", 0) + 1
|
||
name = BUILDINGS[work["building"]]["name"].lower()
|
||
log(world, now, "favour", f"{player['name']} hired a master builder for the {name}: "
|
||
f"the next {MASTER_BUILDER_LABOUR} labour counts double", player["id"])
|
||
elif kind == "informant":
|
||
raid = upcoming_raid(world)
|
||
raid["informants"] = raid.get("informants", 0) + 1
|
||
city["recon_boost"] = city.get("recon_boost", 0) + INFORMANT_RECON
|
||
log(world, now, "favour", f"{player['name']} paid an informant: the next {INFORMANT_RECON} "
|
||
f"reconnaissance counts double", player["id"])
|
||
else:
|
||
raid = upcoming_raid(world)
|
||
raid["sergeants"] = raid.get("sergeants", 0) + 1
|
||
city["drill_boost"] = city.get("drill_boost", 0) + DRILL_SERGEANT_POINTS
|
||
log(world, now, "favour", f"{player['name']} hired a drill sergeant: the next {DRILL_SERGEANT_POINTS} "
|
||
f"drill points count double", player["id"])
|
||
return None
|
||
|
||
|
||
def perk_cost(player: dict, perk: str) -> int:
|
||
if perk == "bank_slot":
|
||
return 500 * 2 ** player["perks"]["bank_slot"]
|
||
raise KeyError(perk)
|
||
|
||
|
||
def buy_perk(player: dict, perk: str) -> str | None:
|
||
if perk not in player["perks"]:
|
||
return "No such perk"
|
||
cost = perk_cost(player, perk)
|
||
if player["favour"] < cost:
|
||
return f"Needs {cost:,} favour"
|
||
player["favour"] -= cost
|
||
player["perks"][perk] += 1
|
||
return None
|
||
|
||
|
||
def toggle_pin(player: dict, skill: str) -> str | None:
|
||
if skill not in SKILL_IDS:
|
||
return "No such skill"
|
||
pinned = player["pinned"]
|
||
if skill in pinned:
|
||
pinned.remove(skill)
|
||
else:
|
||
pinned.append(skill)
|
||
return None
|
||
|
||
|
||
def toggle_track(player: dict, action_id: str) -> str | None:
|
||
action = ACTION_BY_ID.get(action_id)
|
||
if not action or not action["inputs"]:
|
||
return "No such recipe"
|
||
player["tracked"] = None if player["tracked"] == action_id else action_id
|
||
return None
|
||
|
||
|
||
# --- icons ------------------------------------------------------------------------
|
||
# Every item, skill, class, building, prayer and output-less action gets a
|
||
# game-icons.net glyph (CC BY 3.0) and a tint. Families share a shape and are
|
||
# told apart by colour: a rune sword is the copper sword in cyan. The sprite
|
||
# with just these glyphs is built by tools/build_icons.py into static/icons.svg.
|
||
|
||
METAL_TINTS = {"copper": "#d07a3c", "iron": "#9aa1ab", "steel": "#d4dae2",
|
||
"mithril": "#6f8fec", "adamant": "#4cb878", "rune": "#3fd0e0"}
|
||
WOOD_TINTS = ["#b9844e", "#8f5e30", "#a3b86c", "#d0663a", "#6f9a4a", "#a77be8"]
|
||
LEATHER_TINTS = ["#b07a4a", "#8a5a32", "#6b4a33", "#5fae5a", "#4f86d6", "#d0503c"]
|
||
HERB_TINTS = {"guam_leaf": "#7fbf5a", "marrentill": "#e7c84a", "tarromin": "#d05a8a",
|
||
"harralander": "#f0b030", "ranarr": "#8a6fd0", "snapdragon": "#e86a9a"}
|
||
PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "visored-helm",
|
||
"legs": "leg-armor", "body": "breastplate"}
|
||
TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole",
|
||
"hunting knife": "bowie-knife", "hammer": "claw-hammer", "chisel": "chisel", "gem drill": "drill",
|
||
"cleaver": "meat-cleaver", "mortar and pestle": "mortar", "awl": "needle-drill", "shears": "shears",
|
||
"bellows": "bellows"}
|
||
LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"}
|
||
CLOTH_GLYPHS = {"hood": "pointy-hat", "legs": "trousers", "body": "robe"}
|
||
FIBRE_GLYPHS = {"cotton": "cotton-flower", "silk_cocoons": "butterfly", "moonpetal": "lotus-flower",
|
||
"starbloom": "spiral-bloom", "spiritvine": "vine-flower"}
|
||
CLOTH_TINTS = ["#d8d0b8", "#f0f0f0", "#e8c8e0", "#a8c8f0", "#f0e080", "#9be0c8"]
|
||
REAGENT_GLYPHS = {"heavy": "chain-mail", "medium": "scale-mail", "light": "yarn", "jewel": "crystal-shine"}
|
||
WEAPON_GLYPHS = {"hammer": "warhammer", "staff": "wizard-staff", "bow": "pocket-bow"}
|
||
HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"]
|
||
GLYPHS = {
|
||
"stone": ("stone-block", "#a8a29e"), "wheat": ("wheat", "#e3c060"), "vegetables": ("carrot", "#f08a3c"),
|
||
"feathers": ("feather", "#e8e8ee"), "raw_meat": ("meat", "#e06a6a"), "arrows": ("arrow-cluster", "#c9b28a"),
|
||
"bricks": ("brick-pile", "#c66a45"), "cut_stone": ("stone-stack", "#b8b2aa"), "beam": ("wood-beam", "#9a6a3a"),
|
||
"bread": ("bread", "#deb064"), "cooked_meat": ("ham-shank", "#cf8f50"), "stew": ("hot-meal", "#c9884a"),
|
||
"bones": ("broken-bone", "#e9dfc6"), "big_bones": ("crossed-bones", "#e9dfc6"),
|
||
"dragon_bones": ("dinosaur-bones", "#a6d98f"), "studded_body": ("leather-armor", "#a3825a"),
|
||
"coal": ("ore", "#5d626b"), "water_barrel": ("barrel", "#5aa0d8"),
|
||
"silver_ore": ("ore", "#dfe4ec"), "silver_bar": ("metal-bar", "#dfe4ec"),
|
||
"gold_ore": ("ore", "#f0c040"), "gold_bar": ("gold-bar", "#f0c040"),
|
||
"thatch": ("high-grass", "#d8c070"), "flax": ("flax", "#8fb0d8"), "rope": ("rope-coil", "#c9a66b"),
|
||
"hide_glue": ("honey-jar", "#c08a40"), "provisions": ("knapsack", "#a08050"),
|
||
"raw_shrimp": ("shrimp", "#f39a82"), "raw_trout": ("flatfish", "#9fb5c8"), "raw_salmon": ("salmon", "#f08a6a"),
|
||
"raw_eel": ("eel", "#7f9a6a"), "raw_lobster": ("crab", "#e05a3a"), "raw_swordfish": ("angler-fish", "#7fa3c8"),
|
||
"cooked_shrimp": ("shrimp", "#f0b07a"), "cooked_trout": ("fish-cooked", "#d9a060"),
|
||
"cooked_salmon": ("fish-cooked", "#f09a6a"), "smoked_eel": ("eel", "#b07a4a"),
|
||
"cooked_lobster": ("crab", "#f07a4a"), "cooked_swordfish": ("fish-cooked", "#c8a070"),
|
||
}
|
||
SKILL_GLYPHS = {
|
||
"mining": ("mining", "#b8b2aa"), "woodcutting": ("wood-axe", "#c98a4a"), "farming": ("wheat", "#e3c060"),
|
||
"hunting": ("deer-head", "#c49a6c"), "fishing": ("fishing-pole", "#5aa0d8"), "smithing": ("anvil", "#d07a3c"),
|
||
"jewelcrafting": ("gems", "#e070c0"),
|
||
"construction": ("hammer-nails", "#d1a46a"),
|
||
"cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"),
|
||
"herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"),
|
||
"reconnaissance": ("spyglass", "#6f8fec"), "tailoring": ("sewing-machine", "#c8a0e0"),
|
||
"firekeeping": ("campfire", "#ff7a2a"),
|
||
}
|
||
CLASS_GLYPHS = {"miner": ("miner", "#d07a3c"), "builder": ("crane", "#d1a46a"),
|
||
"homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")}
|
||
BUILDING_GLYPHS = {
|
||
"houses": ("house", "#d0a060"), "walls": ("stone-wall", "#a8a29e"), "barracks": ("barracks-tent", "#c06a4a"),
|
||
"smithy": ("anvil", "#d07a3c"), "temple": ("temple-gate", "#e9dfc6"), "granary": ("granary", "#e3c060"),
|
||
"lumber_shed": ("wood-pile", "#b9844e"), "well": ("well", "#5aa0d8"), "spymaster": ("hooded-figure", "#8a7fd0"),
|
||
"storehouse": ("wooden-crate", "#b9844e"),
|
||
}
|
||
ACTION_GLYPHS = {
|
||
"fight_wolves": ("wolf-head", "#9aa1ab"), "fight_scouts": ("hooded-assassin", "#a08060"),
|
||
"fight_ruins": ("castle-ruins", "#a8a29e"), "fight_stronghold": ("castle", "#8a8f98"),
|
||
"dungeon_watchtower": ("watchtower", "#d1a46a"), "dungeon_goblin_warren": ("cave-entrance", "#7fbf5a"),
|
||
"dungeon_bandit_stronghold": ("guarded-tower", "#a08060"), "dungeon_barrow_crypts": ("tombstone", "#8fb8f0"),
|
||
"dungeon_deep_forge": ("anvil-impact", "#ff8a3a"), "dungeon_frozen_hold": ("frozen-orb", "#8fd0e0"),
|
||
"dungeon_dragons_lair": ("dragon-spiral", "#e05a5a"),
|
||
"fight_green_dragon": ("dragon-head", "#5fae5a"), "fight_blue_dragon": ("dragon-head", "#4f86d6"),
|
||
"fight_red_dragon": ("dragon-head", "#d0503c"), "fight_black_dragon": ("dragon-head", "#8a8f98"),
|
||
"sabotage": ("fire-bomb", "#e0703a"), "drill_militia": ("sword-brandish", "#e0a050"), "recon_roads": ("spyglass", "#6f8fec"), "recon_camps": ("spyglass", "#8a7fd0"),
|
||
"labour": ("hammer-nails", "#d1a46a"), "clear_wolves": ("wolf-head", "#9aa1ab"),
|
||
"catch_goblins": ("goblin-head", "#8fbf6a"), "drive_bear": ("bear-head", "#8a5a32"), "cull_boars": ("boar", "#a06a4a"),
|
||
"catch_rats": ("rat", "#9aa1ab"), "clear_rockslide": ("falling-rocks", "#a8a29e"),
|
||
"clear_spiders": ("spider-alt", "#9a7fb0"), "catch_smugglers": ("pirate-hat", "#a08060"),
|
||
"smoke_hornets": ("wasp-sting", "#e3c060"),
|
||
"hunt_elk": ("deer", "#a07a50"), "hunt_sabrecat": ("saber-toothed-cat-head", "#e0a050"),
|
||
"hunt_mammoth": ("mammoth", "#8a6a50"),
|
||
}
|
||
# Abilities: yours by weapon and trade, then the enemies' (shown as they cast).
|
||
ABILITY_GLYPHS = {
|
||
"shield_bash": ("shield-bash", "#d4dae2"), "challenge": ("shouting", "#e0a050"),
|
||
"sweeping_cut": ("sword-spin", "#d4dae2"), "shield_wall": ("stone-wall", "#a8a29e"),
|
||
"crushing_blow": ("hammer-break", "#d1a46a"), "concuss": ("knockout", "#f0d060"),
|
||
"earthshaker": ("quake-stomp", "#c08850"), "unstoppable": ("enrage", "#e06a6a"),
|
||
"bolt": ("bolt-spell-cast", "#a77be8"), "mend": ("healing", "#5fae5a"),
|
||
"ward": ("energy-shield", "#8fb8f0"), "thornbind": ("thorny-vine", "#6f9a4a"),
|
||
"aimed_shot": ("target-arrows", "#e0a050"), "pinning_shot": ("arrowed", "#8fd0e0"),
|
||
"barbed_arrow": ("barbed-arrow", "#e05a5a"), "volley": ("arrow-cluster", "#5fae8a"),
|
||
"find_the_seam": ("armor-downgrade", "#d07a3c"), "barricade": ("barricade", "#d1a46a"),
|
||
"hearty_meal": ("cooking-pot", "#e0b04a"), "mark_prey": ("targeted", "#e06a6a"),
|
||
"heavy_slam": ("punch-blast", "#e06a6a"), "lunge": ("pierced-body", "#e06a6a"),
|
||
"hex_bolt": ("fireball", "#a77be8"), "sweep": ("spinning-blades", "#e06a6a"),
|
||
"fire_breath": ("dragon-breath", "#ff8a3a"), "tail_swipe": ("spiked-tail", "#c08850"),
|
||
"stone_curse": ("evil-hand", "#a77be8"), "ground_pound": ("earth-crack", "#c08850"),
|
||
"shatter": ("shattered-glass", "#d1a46a"), "shriek": ("sonic-shout", "#a77be8"),
|
||
"hex_storm": ("lightning-storm", "#a77be8"), "crushing_decree": ("crown", "#e0b04a"),
|
||
"royal_stomp": ("crush", "#c08850"), "witchfire": ("witch-flight", "#5fae5a"),
|
||
"cheap_shot": ("thrown-daggers", "#d4dae2"), "execute": ("decapitation", "#e06a6a"),
|
||
"whirlwind": ("whirlwind", "#d4dae2"), "rune_blast": ("rune-stone", "#8fb8f0"),
|
||
"venom_spray": ("poison-cloud", "#5fae5a"), "grave_chill": ("tombstone", "#8fb8f0"),
|
||
"soul_rend": ("skull-bolt", "#a77be8"), "forge_blast": ("furnace", "#ff8a3a"),
|
||
"war_cry": ("shouting", "#e06a6a"), "acid_spit": ("acid-blob", "#5fae5a"),
|
||
"acid_breath": ("acid", "#5fae5a"), "deep_freeze": ("frozen-block", "#8fd0e0"),
|
||
"wail": ("screaming", "#a77be8"), "frost_lance": ("ice-spear", "#8fd0e0"),
|
||
"frost_breath": ("snowflake-2", "#8fd0e0"), "void_bolt": ("vortex", "#a77be8"),
|
||
"flame_wave": ("fire-wave", "#ff8a3a"), "inferno": ("fire-ring", "#ff6a2a"),
|
||
}
|
||
PRAYER_GLYPHS = {"thick_skin": ("shield", "#a8a29e"), "burst_of_strength": ("biceps", "#e06a6a"),
|
||
"rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"),
|
||
"superhuman_strength": ("biceps", "#ff8a3a"), "second_wind": ("wind-hole", "#8fd0e0"),
|
||
"protection": ("aura", "#f0d060"), "piety": ("angel-wings", "#f0e6c0")}
|
||
TROPHY_GLYPHS = {"hammer": "crystal-growth", "sword": "animal-hide", "staff": "spiral-bloom", "bow": "sewing-string"}
|
||
TIER_TINTS = ["#9aa1ab", "#d4dae2", "#6f8fec", "#4cb878", "#3fd0e0"] # trophies of areas II to VI
|
||
RACE_GLYPHS = {
|
||
"Clay golems": "rock-golem", "Cave trolls": "troll", "Restless dead": "skeleton", "Harpies": "harpy",
|
||
"Stone guardians": "golem-head", "Lizardfolk": "lizardman", "Bog wraiths": "spectre", "Giant spiders": "spider-face",
|
||
"Iron golems": "robot-golem", "Orcs": "orc-head", "Mine ghosts": "ghost", "Wyverns": "wyvern",
|
||
"Ice elementals": "ice-golem", "Frost trolls": "troll", "Banshees": "screaming", "Griffins": "griffin-symbol",
|
||
"Magma constructs": "lava", "Dragonkin": "dragon-spiral", "Shades": "ghost-ally", "Rocs": "eagle-head",
|
||
}
|
||
ACTION_GLYPHS.update({
|
||
"fight_goblins": ("goblin-head", "#8fbf6a"), "fight_ruins": ("ogre", "#a08060"),
|
||
"fight_barrow_king": ("crowned-skull", "#c8d0e0"), "drill_militia": ACTION_GLYPHS["drill_militia"],
|
||
})
|
||
for _index, (_area, _level, _prefix, _races) in enumerate(AREAS):
|
||
for _race, *_rest in _races:
|
||
ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index])
|
||
for _tend in ACTIONS:
|
||
if _tend.get("special") == "tend":
|
||
ACTION_GLYPHS[_tend["id"]] = ("campfire", FLAMES[_tend["tier"]][1])
|
||
UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", "stopwatch", "swordman", "biceps", "cancel", "scroll-unfurled", "striking-diamonds",
|
||
"archery-target", "dodging", "shield-reflect",
|
||
"cape", "gem-pendant", "gauntlet", "ring", "quiver", "leather-boot", "crystal-ball", "hot-meal", "knife-fork", "round-bottom-flask", "visored-helm", "breastplate",
|
||
"leg-armor", "pointy-sword", "round-shield",
|
||
*(kind["glyph"] for kind in DAMAGE_TYPES.values()), *(stat["glyph"] for stat in JEWEL_STATS.values())]
|
||
|
||
|
||
def _assign_glyphs() -> None:
|
||
for item, (glyph, tint) in GLYPHS.items():
|
||
ITEMS[item].update(glyph=glyph, tint=tint)
|
||
for tier, (key, _name, ore, *_rest) in enumerate(METALS):
|
||
tint = METAL_TINTS[key]
|
||
if ore:
|
||
ITEMS[ore[0]].update(glyph="ore", tint=tint)
|
||
ITEMS[f"{key}_bar"].update(glyph="metal-bar", tint=tint)
|
||
ITEMS[HARDWARE[tier][0]].update(glyph=HARDWARE_GLYPHS[tier], tint=tint)
|
||
ITEMS[f"{key}_warhammer"].update(glyph=WEAPON_GLYPHS["hammer"], tint=tint)
|
||
ITEMS[f"{WOODS[tier]}_staff"].update(glyph=WEAPON_GLYPHS["staff"], tint=WOOD_TINTS[tier])
|
||
ITEMS[f"{WOODS[tier]}_bow"].update(glyph=WEAPON_GLYPHS["bow"], tint=WOOD_TINTS[tier])
|
||
for piece, glyph in PIECE_GLYPHS.items():
|
||
ITEMS[f"{key}_{piece}"].update(glyph=glyph, tint=tint)
|
||
for tool, glyph in TOOL_GLYPHS.items():
|
||
ITEMS[f"{key}_{tool.replace(' ', '_')}"].update(glyph=glyph, tint=tint)
|
||
for (log, *_tree), (plank, _name), tint in zip(TREES, PLANKS, WOOD_TINTS):
|
||
ITEMS[log].update(glyph="log", tint=tint)
|
||
ITEMS[plank].update(glyph="planks", tint=tint)
|
||
for (raw, leather, prefix, *_rest), tint in zip(LEATHERS, LEATHER_TINTS):
|
||
ITEMS[raw[0]].update(glyph="animal-hide", tint=tint)
|
||
ITEMS[leather[0]].update(glyph="rolled-cloth", tint=tint)
|
||
for suffix, glyph in LEATHER_GLYPHS.items():
|
||
ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint)
|
||
for trophy, (_name, line, tier) in TROPHIES.items():
|
||
ITEMS[trophy].update(glyph=TROPHY_GLYPHS[line], tint=TIER_TINTS[tier - 1])
|
||
for item, (weight, tier) in REAGENTS.items():
|
||
ITEMS[item].update(glyph=REAGENT_GLYPHS[weight], tint=TIER_TINTS[tier - 1])
|
||
for (fibre, _level, _time, _xp, cloth, prefix, _label), tint in zip(CLOTHS, CLOTH_TINTS):
|
||
if fibre[0] in FIBRE_GLYPHS:
|
||
ITEMS[fibre[0]].update(glyph=FIBRE_GLYPHS[fibre[0]], tint=tint)
|
||
ITEMS[cloth[0]].update(glyph="rolled-cloth", tint=tint)
|
||
for suffix, glyph in CLOTH_GLYPHS.items():
|
||
ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint)
|
||
for herb, tint in HERB_TINTS.items():
|
||
ITEMS[herb].update(glyph="herbs-bundle", tint=tint)
|
||
for tier, (wood, tint) in enumerate(zip(WOODS, WOOD_TINTS)):
|
||
for suffix, _noun, glyph, _kind in STATIONS.values():
|
||
ITEMS[f"{wood}_{suffix}"].update(glyph=glyph, tint=tint)
|
||
ITEMS[f"{wood}_strongbox"].update(glyph="locked-chest", tint=tint)
|
||
for cloth, tint in zip(CLOTHS, CLOTH_TINTS):
|
||
ITEMS[f"{cloth[5]}_bag"].update(glyph="shoulder-bag", tint=tint)
|
||
for gem, _name, _value, tint, glyph in GEMS:
|
||
ITEMS[gem].update(glyph=glyph, tint=tint)
|
||
for key, *_rest, piece_glyph in JEWELLERY:
|
||
ITEMS[f"{gem}_{key}"].update(glyph=piece_glyph, tint=tint)
|
||
flasks = ["round-bottom-flask", "round-bottom-flask", "standing-potion", "standing-potion",
|
||
"bubbling-flask", "bubbling-flask"]
|
||
for potion, *_rest, skill, effect in POTIONS:
|
||
tint = "#e05a5a" if "power" in effect else "#8fa8c8" if "reduce" in effect else SKILL_GLYPHS[skill][1]
|
||
ITEMS[potion].update(glyph=flasks[POTION_TIER[potion]], tint=tint)
|
||
for tier, (potion, *_rest) in enumerate(HEALTH_POTIONS):
|
||
ITEMS[potion].update(glyph=flasks[tier], tint="#e0506e")
|
||
for skill in SKILLS:
|
||
skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]]
|
||
for key, spec in CLASSES.items():
|
||
spec["glyph"], spec["tint"] = CLASS_GLYPHS[key]
|
||
for key, spec in BUILDINGS.items():
|
||
spec["glyph"], spec["tint"] = BUILDING_GLYPHS[key]
|
||
for prayer in PRAYERS:
|
||
prayer["glyph"], prayer["tint"] = PRAYER_GLYPHS[prayer["id"]]
|
||
for key, ability in [*combat.ABILITIES.items(), *combat.ENEMY_ABILITIES.items()]:
|
||
ability["glyph"], ability["tint"] = ABILITY_GLYPHS[key]
|
||
# Dungeon loot: masterworks look like what they're reforged from, in gold.
|
||
for item, spec in ITEMS.items():
|
||
if spec.get("masterwork"):
|
||
spec.update(glyph=ITEMS[spec["base"]]["glyph"], tint=MASTERWORK_TINT)
|
||
elif spec.get("pattern"):
|
||
spec.update(glyph="scroll-quill", tint=MASTERWORK_TINT)
|
||
elif spec.get("relic"):
|
||
spec.update(glyph="relic-blade", tint="#c8b070")
|
||
for unique, (_name, _slot, glyph, tint, *_rest) in UNIQUES.items():
|
||
ITEMS[unique].update(glyph=glyph, tint=tint)
|
||
for action in ACTIONS:
|
||
if action["id"] in ACTION_GLYPHS:
|
||
action["glyph"], action["tint"] = ACTION_GLYPHS[action["id"]]
|
||
elif action["outputs"]:
|
||
first = ITEMS[next(iter(action["outputs"]))]
|
||
action["glyph"], action["tint"] = first["glyph"], first["tint"]
|
||
elif action.get("finds"):
|
||
first = ITEMS[action["finds"][0][0]]
|
||
action["glyph"], action["tint"] = first["glyph"], first["tint"]
|
||
elif action.get("special") in ("bury", "offer"):
|
||
bone = ITEMS[next(iter(action["inputs"]))]
|
||
action["glyph"] = "tombstone" if action["special"] == "bury" else "candle-holder"
|
||
action["tint"] = bone["tint"] if action["special"] == "bury" else "#f0c060"
|
||
else:
|
||
action["glyph"], action["tint"] = SKILL_GLYPHS[action["skill"]]
|
||
|
||
|
||
def all_glyphs() -> set[str]:
|
||
"""Every glyph the game draws, for building the sprite."""
|
||
names = set(UI_GLYPHS)
|
||
for collection in (ITEMS.values(), SKILLS, CLASSES.values(), BUILDINGS.values(), PRAYERS, ACTIONS,
|
||
combat.ABILITIES.values(), combat.ENEMY_ABILITIES.values()):
|
||
names.update(entry["glyph"] for entry in collection)
|
||
names.update(glyph for glyph, _tint in WARE_GLYPHS.values())
|
||
names.update(c["value"] for c in COSMETICS.values() if c["kind"] == "emblem" and "value" in c)
|
||
names.update(m["glyph"] for m in MONUMENTS.values())
|
||
return names
|
||
|
||
|
||
_assign_glyphs()
|
||
# Every recipe and gathering action exists by now: what can be put up as a work order.
|
||
ORDER_RECIPES, ORDER_RAW = _order_sources()
|
||
|
||
|
||
# What changed, shown on the Patch notes page. Newest first; every shipped change adds a
|
||
# line here (new / balance / fix), and a new release a new entry on top.
|
||
PATCH_NOTES = [
|
||
{"date": "2026-10-25", "title": "Titles", "notes": [
|
||
("new", "Fishing 50 also earns a second title: Master baiter."),
|
||
("balance", "Boiling hide glue now uses the kitchen fire: while it's lit, glue costs no logs."),
|
||
("fix", "Dungeons wait for the leader to press Start, so groups of 3 and 4 can form. No one can join once it starts."),
|
||
("fix", "Combat page shows your health potion: drunk on its own, no need to equip. The other card now reads \"No combat potion\"."),
|
||
("fix", "Combat page no longer jumps around: cast bars hold their space and the fight log stays 7 lines tall."),
|
||
("new", "Enemy casts that can't be interrupted show a 🔒 and a grey bar. Stuns still stop them, except on bosses."),
|
||
]},
|
||
{"date": "2026-10-24", "title": "Dungeon loot", "notes": [
|
||
("new", "Masterwork patterns: every dungeon boss can drop one of 7 (15% from a mini-boss, 35% from the last boss, "
|
||
"everyone rolls their own). A crafter learns it once, then reforges the tier's piece plus 3 relics into a "
|
||
"masterwork: about 30% more power or armour, wearable at the dungeon's level. Sword, warhammer, staff, bow, "
|
||
"plate, jerkin and robe in every dungeon."),
|
||
("new", "Relics: 50% from a mini-boss, 2 from every last boss."),
|
||
("new", "One unique per dungeon from its last boss (8% each), worn as a ring, amulet or trinket, that changes an "
|
||
"ability: One-Eye's fang, Ogre's knuckle, Barrow king's pendant, Warlord's whistle, Forgeheart, Rimeglass "
|
||
"lens, Wyrmscale quiver. Abilities it changes get a ✦."),
|
||
("new", "Choose when you eat between fights: below 25 / 50 / 75% of your HP, always, or never (the food slot, "
|
||
"under \"Eat below\"). Default 50%: above it you just rest, so a scratch doesn't cost food."),
|
||
("new", "🏆 Achievements (under You): 127 to collect. Masterwork patterns per dungeon (7 each), the 7 uniques, titles, "
|
||
"boss emblems, caravan looks, home. Tap an icon for what it is and how to get it. Nothing is awarded there, it only keeps track."),
|
||
("new", "Tapping an enemy or ally switches at once, and your next press goes to them: tap, then interrupt, "
|
||
"without waiting."),
|
||
("new", "HP bars show what was just lost, with each fighter's last hit or heal by their HP."),
|
||
("new", "An interrupted cast stays on the enemy's bar for a moment, struck through: ✋ interrupted."),
|
||
("balance", "Volley no longer takes arrows: abilities never cost anything. Wyrmscale quiver: Volley 40% harder "
|
||
"and Aimed Shot 25% faster."),
|
||
("fix", "Work orders on phones: the item picker and reagent slots show names, and picking an item scrolls to "
|
||
"the amount, price and Post."),
|
||
("fix", "Work orders show what a lit fire pays: its log is struck through instead of asked for, and the forge "
|
||
"pays a coal. Orders never needed that log from the crafter, they just asked for it."),
|
||
]},
|
||
{"date": "2026-10-24", "title": "A dungeon in every area", "notes": [
|
||
("new", "Six more dungeons for 2–4, seven pulls each with two mini-bosses and a final boss: Goblin Warren "
|
||
"(Greenwood, combat 8, best with 3–4), Barrow Crypts (Sunken Barrows, 35), Bandit Stronghold (Frontier, 40), "
|
||
"The Deep Forge (Iron Hills, 50), Frozen Hold (Frostpeak, 65) and Dragon's Lair (Dragon's Spine, 80)."),
|
||
("new", "Every boss has its own: curses to interrupt, shrieks, slams to brace for, adds at half health and an "
|
||
"enrage timer. As in the Watchtower, trash falls on auto and the bosses need your own hands."),
|
||
("new", "Clearing pays 30–750 gold by area, plus a trophy and 2 reagents (Goblin Warren: gold only)."),
|
||
]},
|
||
{"date": "2026-10-24", "title": "Gold", "notes": [
|
||
("balance", "Raids hit about twice as hard and grow with the guild: they add half the combat power of everyone "
|
||
"active in the last 2 days. Pay the guards, drill, stand guard and sabotage the camp, or expect losses. "
|
||
"Dragons are 0.8× a raid but can't be sabotaged."),
|
||
("balance", "Band size (small → huge) now compares the raid to what the city holds without preparing."),
|
||
("new", "Guard wages: 60 gold a guard per threat, paid ahead into the barracks' wage chest, from your gold (earns "
|
||
"favour) or the treasury. Only paid guards stand, and only they hold the armoury's swords and shields. "
|
||
"Posts: 10 + 20 per barracks level, up to half the townsfolk. The council paid for the coming threat."),
|
||
("balance", "Raid rewards: plunder 1× attack (was 2×); garrison XP 2× attack (was 5×), dragons 2.5× (was 6×)."),
|
||
("new", "Strongbox: rent bank slots for gold, 1,000 for the first and +35% each after."),
|
||
("new", "Caravan: comes once per threat, on the prep day, for 12h, with 4 of 10 wares and one look. Wares boost "
|
||
"your own work (trophy odds ×2 for 100 kills, +50% XP for 200 actions, …). One of each per visit; prices "
|
||
"rise with your level."),
|
||
("new", "Banner, title and home: pick colours and an emblem on your character sheet. Titles are earned by reaching "
|
||
"50 or 99 in a skill, defending the city 5 times, slaying a dragon or beating 10 bosses, or bought at the "
|
||
"caravan. Homes go from Cottage (2,000) to Manor (75,000). All of these show on the roster and the board."),
|
||
("new", "Monuments: gate banner 10,000, fountain 25,000, statues 50,000 (the biggest donor picks who). No favour; "
|
||
"donors' names are carved in."),
|
||
]},
|
||
{"date": "2026-10-24", "title": "Active play and dungeons", "notes": [
|
||
("new", "Active play: tap AUTO on an ability to take it yourself (YOU), then press it (tap, or keys 1–9) on its "
|
||
"real cooldown instead of auto's 1.5×. Look away for 12s and it's back on auto."),
|
||
("new", "Tap an enemy to fight it, or a friend to aim your heals at them."),
|
||
("new", "Dungeon: Watchtower Depths (combat 20, 2–4 players), 8 pulls in order. Trash falls on auto; the Clay "
|
||
"colossus, Harpy matriarch and Watchtower ogre need your own hands: interrupt Stone Curse and Shriek, "
|
||
"brace for Ground Pound, beat the enrage timer. Start it and wait for others to join before the first pull."),
|
||
("new", "A wipe repeats the pull, 3 on one pull ends the run. The run waits while anyone looks away for 90s. "
|
||
"The leader can pull early."),
|
||
("new", "Dungeon rewards: every boss gives everyone their own loot (mini-bosses 5× their race's odds). "
|
||
"Clearing pays 100 gold, a trophy and 2 reagents each."),
|
||
]},
|
||
{"date": "2026-10-24", "title": "Livelier frontier", "notes": [
|
||
("balance", "A threat lands every 2 days (±2h), and the next one is rumoured as soon as the last lands. "
|
||
"Day 1: scout, drill, build. Day 2: the camp is found (intel III opens 24h out), with the exact hour."),
|
||
("balance", "Sabotage: -1% raid strength per win (was -3%), still max 30%."),
|
||
("balance", "Happenings ~3× as often: fortunes every 10–20h (was 36–72h), hardships 20–40h (was 48–96h), "
|
||
"minor threats 16–32h (was 48–96h)."),
|
||
("balance", "Shorter happenings: 12h boosts → 6h; festival, sellswords, drought 24h → 12h; cold snap 48h → 24h; "
|
||
"sickness 72h → 36h; flood 36h → 18h."),
|
||
("new", "Fortunes: storm-felled timber (woodcutting yield +50%), deer migration (hunting yield ×2), masons' guild "
|
||
"(construction 25% faster), artisans' fair (crafts 15% faster), a trapper's leads (next 15 recon doubled)."),
|
||
("new", "Hardships: blight (farming yield ½), tremors (mining 30% slower), the tax collector (-15% treasury)."),
|
||
("new", "Minor threats: spiders in the mine and smugglers skimming 1% treasury/h (fighters), a washed-out "
|
||
"bridge that blocks hunting (25 planks), hornets slowing woodcutting (herbalists smoke them out)."),
|
||
]},
|
||
{"date": "2026-10-24", "title": "Combat rework", "notes": [
|
||
("new", "Fights are against packs: as many enemies as there are of you, often one more (each 35% weaker). "
|
||
"A rare elite still comes alone."),
|
||
("new", "Abilities: your weapon gives four (combat 1 / 15 / 30 / 45) and your trade one more. Sword and shield "
|
||
"tanks, warhammer hits hard and stuns, staff heals and holds, bow shoots from afar. All on auto for now: "
|
||
"hover them on the Combat page."),
|
||
("new", "Enemies fight back: brutes wind up Heavy Slams, skirmishers lunge at whoever isn't tanking, casters "
|
||
"cast Hex Bolts (which can be interrupted), elites and bosses enrage, dragons breathe fire. Enemies attack "
|
||
"whoever has the most threat, so a sword and shield draws them away from the rest."),
|
||
("new", "Food is only eaten resting between fights: you rest back to full before each one, and better food "
|
||
"rests you faster. No food just means longer rests."),
|
||
("new", "Health potions I–VI (Herblore 1–75, the tier's herb and cooked fish): the one heal you take into a "
|
||
"fight, drunk once a fight under 35% HP."),
|
||
("new", "Knocked out at 0 HP: you lose that fight and come round at 30% HP. Three in a row and you stop. "
|
||
"Retreating is gone."),
|
||
("new", "Bosses and dragons are group fights (2+). Start one alone and you wait there for someone to join. "
|
||
"They grow with the group: +75% HP for each fighter."),
|
||
("fix", "Abilities, enemy casts and fight statuses have proper icons instead of emoji."),
|
||
("balance", "Enemies have 20% more HP for the same XP, since everyone has abilities now."),
|
||
("balance", "Boss hits: area bosses 1.35 → 1.2× and dragons 1.5 → 1.9× their level's max hit, then ×1.3 "
|
||
"against a pair."),
|
||
]},
|
||
{"date": "2026-10-23", "title": "City needs", "notes": [
|
||
("new", "City needs are now indicated in profession menus."),
|
||
]},
|
||
{"date": "2026-10-22", "title": "Bosses", "notes": [
|
||
("new", "Bosses and dragons: everyone in the fight gets their own loot."),
|
||
("balance", "Level requirements for area boss reduced to be area level +5."),
|
||
]},
|
||
{"date": "2026-10-21", "title": "Beta", "notes": [
|
||
("new", "Beta: completely meaningless milestone reached; every class now makes a decent amount of valuable contributions to the guild."),
|
||
("new", "Patch notes menu."),
|
||
("new", "Firekeeping: tend the fire for your guild members' crafting stations. Builders get a bonus. Use tiers of wood to light "
|
||
"and maintain a fire, which saves resources on guild members' crafts of the same tier. For every craft completed while "
|
||
"the fire is lit, the Firekeeper gains 10% of the XP the craft gave the crafter, shared between all players keeping a "
|
||
"fire of that tier. Each player keeps their own \"pile\", which contributes an equal amount to the fire."),
|
||
("new", "Work orders: anyone can provide reagents towards an incomplete work order and gets paid based on the value of the items provided."),
|
||
("new", "Multiplayer Combat: tag into anyone's fight from the encounter card or the guild roster."),
|
||
("balance", "Gear: combat requirement adjusted to match the tier of crafting required to make it."),
|
||
("balance", "Combat XP reduced to keep it in line with professions. Eating now delays your next swing; eat higher tier food to reduce the impact."),
|
||
("balance", "Construction workbench now also affects labour on building sites."),
|
||
("balance", "Weapon trophies drop more often, since the town needs weapons too: 3% → 10% from races, 6% → 20% per weapon type from bosses."),
|
||
("balance", "Hunting and Leatherworking tiers now unlock at 1 / 15 / 30 / 45 / 60 / 75, like every other trade. "
|
||
"Hunting: deer 8 → 1, boar 25 → 15, bears 40 → 30, elk 50 → 45, sabrecats 65 → 60, mammoths 80 → 75. "
|
||
"Leatherworking: tanning, armour and bows 5 levels lower from hard leather up."),
|
||
("balance", "Hunting time and XP now match Fishing: deer 40s · 13 XP → 25s · 6 XP (same as birds), boar 50s · 30 → 35s · 15, "
|
||
"bears 60s · 48 → 45s · 28, elk 70s · 64 → 55s · 45, sabrecats 80s · 84 → 65s · 62, mammoths 90s · 108 → 80s · 85."),
|
||
]},
|
||
]
|
||
|
||
def content() -> dict:
|
||
"""The static tables the client renders from."""
|
||
buildings = {
|
||
key: {**spec, "costs": [build_cost(key, level) for level in range(1, spec["max"] + 1)]}
|
||
for key, spec in BUILDINGS.items()
|
||
}
|
||
return {
|
||
"items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS,
|
||
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
|
||
"city_uses": {item: city_uses(item) for item in ITEMS},
|
||
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS,
|
||
"potion_charges": POTION_CHARGES, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW,
|
||
"order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
|
||
"equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE, "eat_below": EAT_BELOW, "eat_choices": EAT_CHOICES,
|
||
"abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD,
|
||
"weapon_kits": combat.WEAPON_KITS, "group_size": GROUP_SIZE, "dungeon_size": DUNGEON_SIZE, "dungeon_wipes": DUNGEON_WIPES, "trade_ability": combat.TRADE_ABILITY, "potion_below": combat.POTION_BELOW,
|
||
"styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE,
|
||
"resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
|
||
"regen_per_hour": REGEN_PER_HOUR,
|
||
"fires": FIRES, "flames": FLAMES, "firewood": FIREWOOD, "tend_xp": TEND_XP, "fire_low": FIRE_LOW,
|
||
"patch_notes": PATCH_NOTES,
|
||
"wares": {key: {**spec, "glyph": WARE_GLYPHS[key]} for key, spec in WARES.items()}, "cosmetics": COSMETICS, "free_looks": FREE_LOOKS, "homes": HOMES, "monuments": MONUMENTS,
|
||
"guard_wage": GUARD_WAGE,
|
||
}
|