1583 lines
72 KiB
Python
1583 lines
72 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
|
|
|
|
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
|
|
RECON_PER_INTEL = 8
|
|
MAX_LOG = 200
|
|
REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour
|
|
EAT_BELOW = 0.5 # auto-eat when HP drops under this share of max
|
|
TOO_HURT = 0.3 # won't start a fight below this share without food
|
|
|
|
|
|
def _xp_table(max_level: int = 99) -> list[int]:
|
|
# RuneScape's curve, as Melvor uses: index = level, value = xp needed.
|
|
table = [0, 0]
|
|
points = 0
|
|
for level in range(1, max_level):
|
|
points += math.floor(level + 300 * 2 ** (level / 7))
|
|
table.append(points // 4)
|
|
return table
|
|
|
|
|
|
XP_TABLE = _xp_table()
|
|
MAX_LEVEL = len(XP_TABLE) - 1
|
|
|
|
|
|
def level_for(xp: float) -> int:
|
|
level = 1
|
|
while level < MAX_LEVEL and XP_TABLE[level + 1] <= xp:
|
|
level += 1
|
|
return level
|
|
|
|
|
|
ITEMS = {
|
|
"stone": {"name": "Stone", "icon": "🪨", "value": 1},
|
|
"wheat": {"name": "Wheat", "icon": "🌾", "value": 1},
|
|
"vegetables": {"name": "Vegetables", "icon": "🥕", "value": 2},
|
|
"feathers": {"name": "Feathers", "icon": "🪶", "value": 1},
|
|
"raw_meat": {"name": "Raw meat", "icon": "🥩", "value": 2},
|
|
"arrows": {"name": "Arrows", "icon": "🏹", "value": 1},
|
|
"bricks": {"name": "Bricks", "icon": "🧱", "value": 3},
|
|
"cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5},
|
|
"beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "🪜", "value": 25},
|
|
# `food` feeds that many townsfolk for two days; `heal` restores HP in a fight.
|
|
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8},
|
|
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
|
|
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20},
|
|
"bones": {"name": "Bones", "icon": "🦴", "value": 1},
|
|
"big_bones": {"name": "Big bones", "icon": "🦴", "value": 4},
|
|
"dragon_bones": {"name": "Dragon bones", "icon": "🦴", "value": 15},
|
|
"studded_body": {"name": "Studded jerkin", "icon": "🧥", "value": 40, "slot": "body", "armour": 6,
|
|
"req": ["combat", 10]},
|
|
}
|
|
|
|
# --- tier ladders -----------------------------------------------------------
|
|
# Six tiers, RuneScape's metals with Melvor-ish level spacing. Each row of a
|
|
# ladder generates its items and recipes below; the copper and iron ids
|
|
# predate the ladders and are kept as they were.
|
|
|
|
TIER_LEVELS = [1, 15, 30, 45, 60, 75] # gathering / smelting level of each tier
|
|
EQUIP_LEVELS = [1, 10, 20, 30, 40, 50] # combat level to wear, skill level to use a tool
|
|
|
|
# key, name, ore (item id, name, icon, value), coal per bar, bar icon, bar value, smithy level, handle wood
|
|
METALS = [
|
|
("copper", "Copper", ("copper_ore", "Copper ore", "🟤", 2), 0, "🟧", 5, 0, "logs"),
|
|
("iron", "Iron", ("iron_ore", "Iron ore", "⚫", 4), 0, "⬛", 10, 1, "oak_logs"),
|
|
("steel", "Steel", None, 2, "🔲", 20, 2, "willow_logs"),
|
|
("mithril", "Mithril", ("mithril_ore", "Mithril ore", "🔵", 15), 3, "🟦", 40, 3, "maple_logs"),
|
|
("adamant", "Adamant", ("adamantite_ore", "Adamantite ore", "🟢", 30), 4, "🟩", 70, 4, "yew_logs"),
|
|
("rune", "Rune", ("runite_ore", "Runite ore", "💠", 55), 5, "🟪", 120, 5, "magic_logs"),
|
|
]
|
|
# piece, slot, display noun, bars, smithing level above the tier, power by tier, armour by tier
|
|
PIECES = [
|
|
("sword", "weapon", "sword", 2, 7, [4, 9, 14, 20, 27, 35], None),
|
|
("shield", "shield", "shield", 3, 10, [3, 6, 8, 11, 14, 18], [2, 4, 6, 9, 12, 15]),
|
|
("helmet", "helmet", "helmet", 2, 5, None, [2, 4, 6, 8, 11, 14]),
|
|
("legs", "legs", "greaves", 4, 9, None, [3, 6, 9, 12, 16, 21]),
|
|
("body", "body", "cuirass", 5, 11, None, [4, 8, 12, 16, 22, 28]),
|
|
]
|
|
PLURALS = {"sword": "swords", "shield": "shields", "helmet": "helmets", "greaves": "greaves", "cuirass": "cuirasses"}
|
|
PIECE_ICONS = {"sword": "⚔️", "shield": "🛡️", "helmet": "🪖", "legs": "🦿", "body": "🦾"}
|
|
COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body": "🥋"}
|
|
# Gathering tools: one slot per skill, `speed` cuts the action time.
|
|
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", ""),
|
|
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪")}
|
|
TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60]
|
|
|
|
# item id, name, icon, value, action id, verb
|
|
TREES = [
|
|
("logs", "Logs", "🪵", 1, "cut_logs", "Cut trees"),
|
|
("oak_logs", "Oak logs", "🌳", 4, "cut_oak", "Cut oaks"),
|
|
("willow_logs", "Willow logs", "🌿", 8, "cut_willow", "Cut willows"),
|
|
("maple_logs", "Maple logs", "🍁", 15, "cut_maple", "Cut maples"),
|
|
("yew_logs", "Yew logs", "🌲", 30, "cut_yew", "Cut yews"),
|
|
("magic_logs", "Magic logs", "🔮", 55, "cut_magic", "Cut magic trees"),
|
|
]
|
|
|
|
# raw hide (id, name, icon, value), leather (id, name, value), armour id prefix, armour name prefix,
|
|
# leatherworking level, armour by piece
|
|
LEATHERS = [
|
|
(("hide", "Hide", "🟫", 3), ("leather", "Leather", 5), "leather", "Leather", 1, (1, 2, 3)),
|
|
(("boar_hide", "Boar hide", "🐗", 8), ("hard_leather", "Hard leather", 12), "hard_leather", "Hard leather", 20, (2, 4, 6)),
|
|
(("bear_pelt", "Bear pelt", "🐻", 16), ("bear_leather", "Bear leather", 24), "bear_leather", "Bear leather", 35, (4, 6, 9)),
|
|
(("green_dragonhide", "Green dragonhide", "🐉", 35), ("green_dragon_leather", "Green dragon leather", 50),
|
|
"green_dhide", "Green dragonhide", 50, (6, 9, 13)),
|
|
(("blue_dragonhide", "Blue dragonhide", "🐲", 60), ("blue_dragon_leather", "Blue dragon leather", 85),
|
|
"blue_dhide", "Blue dragonhide", 65, (8, 12, 17)),
|
|
(("red_dragonhide", "Red dragonhide", "🔥", 100), ("red_dragon_leather", "Red dragon leather", 140),
|
|
"red_dhide", "Red dragonhide", 80, (10, 15, 21)),
|
|
]
|
|
# piece suffix, slot, noun, leather used, level above the tier, icon
|
|
LEATHER_PIECES = [("cap", "helmet", "cap", 2, 2, "🧢"), ("legs", "legs", "trousers", 3, 7, "👖"),
|
|
("body", "body", "jerkin", 4, 11, "🦺")]
|
|
|
|
# raw (id, name, icon, value), cooked (id, name, icon, value, heal, food), fishing level, fish action, verb
|
|
FISH = [
|
|
(("raw_shrimp", "Raw shrimp", "🦐", 1), ("cooked_shrimp", "Cooked shrimp", "🍤", 4, 10, 1), 1, "fish_shrimp", "Net shrimp"),
|
|
(("raw_trout", "Raw trout", "🐟", 3), ("cooked_trout", "Grilled trout", "🍢", 8, 18, 1), 15, "fish_trout", "Fly-fish trout"),
|
|
(("raw_salmon", "Raw salmon", "🐠", 6), ("cooked_salmon", "Baked salmon", "🍣", 14, 28, 2), 30, "fish_salmon", "Catch salmon"),
|
|
(("raw_eel", "Raw eel", "🐍", 10), ("smoked_eel", "Smoked eel", "🥓", 22, 40, 2), 45, "fish_eel", "Trap eels"),
|
|
(("raw_lobster", "Raw lobster", "🦞", 18), ("cooked_lobster", "Cooked lobster", "🍜", 35, 55, 3), 60, "fish_lobster", "Pot lobsters"),
|
|
(("raw_swordfish", "Raw swordfish", "🐬", 30), ("cooked_swordfish", "Grilled swordfish", "🍱", 55, 70, 3), 75, "fish_swordfish", "Harpoon swordfish"),
|
|
]
|
|
COOK_IDS = {"raw_shrimp": "cook_shrimp", "raw_trout": "cook_trout", "raw_salmon": "cook_salmon", "raw_eel": "cook_eel",
|
|
"raw_lobster": "cook_lobster", "raw_swordfish": "cook_swordfish"}
|
|
|
|
ITEMS["coal"] = {"name": "Coal", "icon": "🌑", "value": 6}
|
|
for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in enumerate(METALS):
|
|
if _ore:
|
|
ITEMS[_ore[0]] = {"name": _ore[1], "icon": _ore[2], "value": _ore[3]}
|
|
ITEMS[f"{_key}_bar"] = {"name": f"{_name} bar", "icon": _bar_icon, "value": _bar_value, "tier": _tier}
|
|
for _piece, _slot, _noun, _bars, _offset, _power, _armour in PIECES:
|
|
_item = {"name": f"{_name} {_noun}", "plural": f"{_name} {PLURALS[_noun]}",
|
|
"icon": COPPER_ICONS.get(_piece, PIECE_ICONS[_piece]) if _key == "copper" else PIECE_ICONS[_piece],
|
|
"value": round(_bars * _bar_value * 1.2), "slot": _slot, "tier": _tier,
|
|
"req": ["combat", EQUIP_LEVELS[_tier]]}
|
|
if _power:
|
|
_item["power"] = _power[_tier]
|
|
if _armour:
|
|
_item["armour"] = _armour[_tier]
|
|
ITEMS[f"{_key}_{_piece}"] = {k: v for k, v in _item.items() if v is not None}
|
|
for _skill, (_tool, _icon) in TOOLS.items():
|
|
ITEMS[f"{_key}_{_tool.replace(' ', '_')}"] = {
|
|
"name": f"{_name} {_tool}", "icon": _icon, "value": round(2 * _bar_value * 1.4), "tier": _tier,
|
|
"slot": f"tool_{_skill}", "tool": _skill, "speed": TOOL_SPEED[_tier], "req": [_skill, EQUIP_LEVELS[_tier]],
|
|
}
|
|
for _log, _name, _icon, _value, _action_id, _verb in TREES:
|
|
ITEMS[_log] = {"name": _name, "icon": _icon, "value": _value}
|
|
for _tier, (_raw, _leather, _prefix, _label, _level, _armour) in enumerate(LEATHERS):
|
|
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
|
|
ITEMS[_leather[0]] = {"name": _leather[1], "icon": "🪢", "value": _leather[2]}
|
|
for (_suffix, _slot, _noun, _used, _offset, _icon), _points in zip(LEATHER_PIECES, _armour):
|
|
ITEMS[f"{_prefix}_{_suffix}"] = {
|
|
"name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _leather[2] * 1.3),
|
|
"slot": _slot, "armour": _points, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]],
|
|
}
|
|
if _noun == "trousers":
|
|
ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"]
|
|
for _raw, _cooked, _level, _action_id, _verb in FISH:
|
|
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
|
|
ITEMS[_cooked[0]] = {"name": _cooked[1], "icon": _cooked[2], "value": _cooked[3], "heal": _cooked[4], "food": _cooked[5]}
|
|
|
|
# What the city eats first: plain food before the good stuff.
|
|
FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"]))
|
|
EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs"] + [f"tool_{skill}" for skill in TOOLS]
|
|
|
|
SKILLS = [
|
|
{"id": "mining", "name": "Mining", "icon": "⛏️", "group": "Gathering"},
|
|
{"id": "woodcutting", "name": "Woodcutting", "icon": "🪓", "group": "Gathering"},
|
|
{"id": "farming", "name": "Farming", "icon": "🌱", "group": "Gathering"},
|
|
{"id": "hunting", "name": "Hunting", "icon": "🦌", "group": "Gathering"},
|
|
{"id": "fishing", "name": "Fishing", "icon": "🎣", "group": "Gathering"},
|
|
{"id": "smithing", "name": "Smithing", "icon": "🔨", "group": "Crafting"},
|
|
{"id": "masonry", "name": "Masonry", "icon": "🧱", "group": "Crafting"},
|
|
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
|
|
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
|
|
{"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"},
|
|
{"id": "prayer", "name": "Prayer", "icon": "🙏", "group": "Combat"},
|
|
{"id": "reconnaissance", "name": "Reconnaissance", "icon": "🔭", "group": "Signature", "class": "scout"},
|
|
]
|
|
SKILL_IDS = [skill["id"] for skill in SKILLS]
|
|
GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"}
|
|
CRAFTING = {"smithing", "masonry", "cooking", "leatherworking"}
|
|
SURVEYED = {"mining", "woodcutting", "farming"} # what the pioneer's double yield applies to
|
|
|
|
CLASSES = {
|
|
"warrior": {
|
|
"name": "Warrior", "icon": "🛡️", "fast": ["combat"],
|
|
"bonus": "Combat actions 20% faster",
|
|
"signature": "Command: counts for half again as much in the garrison",
|
|
},
|
|
"scout": {
|
|
"name": "Scout", "icon": "🏹", "fast": ["hunting", "fishing", "reconnaissance"],
|
|
"bonus": "Hunting and fishing 20% faster",
|
|
"signature": "Reconnaissance: scout the roads to sharpen intel on what's coming",
|
|
},
|
|
"artisan": {
|
|
"name": "Artisan", "icon": "⚒️", "fast": ["smithing", "masonry", "cooking", "leatherworking"],
|
|
"bonus": "Smithing, masonry, cooking and leatherworking 20% faster",
|
|
"signature": "Masterwork: 1 in 10 crafts makes double",
|
|
},
|
|
"pioneer": {
|
|
"name": "Pioneer", "icon": "🧭", "fast": ["mining", "woodcutting", "farming"],
|
|
"bonus": "Mining, woodcutting and farming 20% faster",
|
|
"signature": "Surveying: 1 in 10 gathers yields double",
|
|
},
|
|
}
|
|
|
|
|
|
def _action(id, skill, name, level, time, xp, inputs=None, outputs=None, **extra):
|
|
return {"id": id, "skill": skill, "name": name, "level": level, "time": time, "xp": xp,
|
|
"inputs": inputs or {}, "outputs": outputs or {}, **extra}
|
|
|
|
|
|
def _tiered_actions() -> list[dict]:
|
|
"""Mining, woodcutting, smelting, smithing, tanning and leatherworking for every tier."""
|
|
actions = []
|
|
ore_xp = [7, 15, 30, 40, 55, 75]
|
|
ore_time = [25, 35, 50, 55, 65, 80]
|
|
bar_xp = [6, 14, 22, 32, 44, 58]
|
|
piece_xp = [6, 13, 20, 28, 37, 48] # per bar used
|
|
for tier, (key, name, ore, coal, _icon, _value, smithy, wood) in enumerate(METALS):
|
|
if ore:
|
|
actions.append(_action(f"mine_{key}" if key in ("copper", "iron") else f"mine_{ore[0].removesuffix('_ore')}",
|
|
"mining", f"Mine {ore[1].removesuffix(' ore').lower()}", TIER_LEVELS[tier],
|
|
ore_time[tier], ore_xp[tier], outputs={ore[0]: 1}))
|
|
if tier == 2:
|
|
actions.append(_action("mine_coal", "mining", "Mine coal", 30, 40, 22, outputs={"coal": 1}))
|
|
city = {"smithy": smithy} if smithy else {}
|
|
inputs = {"iron_ore" if key == "steel" else ore[0]: 1}
|
|
inputs.update({"coal": coal} if coal else {"logs": 1})
|
|
actions.append(_action(f"smith_{key}_bar", "smithing", f"{name} bar", TIER_LEVELS[tier], 20 + 4 * tier,
|
|
bar_xp[tier], inputs, {f"{key}_bar": 1}, group="Bars",
|
|
city={"smithy": smithy} if tier >= 2 else {}))
|
|
for piece, _slot, noun, bars, offset, _power, _armour in PIECES:
|
|
actions.append(_action(f"smith_{key}_{piece}", "smithing", f"{name} {noun}", TIER_LEVELS[tier] + offset,
|
|
25 + 5 * bars + 2 * tier, bars * piece_xp[tier], {f"{key}_bar": bars},
|
|
{f"{key}_{piece}": 1}, group=name, city=city))
|
|
for tool, _icon in TOOLS.values():
|
|
tool_id = f"{key}_{tool.replace(' ', '_')}"
|
|
actions.append(_action(f"smith_{tool_id}", "smithing", f"{name} {tool}", TIER_LEVELS[tier] + 3,
|
|
30 + 2 * tier, 2 * piece_xp[tier], {f"{key}_bar": 2, wood: 1}, {tool_id: 1},
|
|
group=name, city=city))
|
|
for tier, (log, _name, _icon, _value, action_id, verb) in enumerate(TREES):
|
|
actions.append(_action(action_id, "woodcutting", verb, TIER_LEVELS[tier], [20, 35, 40, 50, 60, 75][tier],
|
|
[5, 15, 24, 36, 52, 75][tier], outputs={log: 1}))
|
|
for tier, (raw, leather, prefix, label, level, _armour) in enumerate(LEATHERS):
|
|
actions.append(_action("tan_leather" if tier == 0 else f"tan_{leather[0]}", "leatherworking",
|
|
f"Tan {leather[1].lower()}", level, 25 + 3 * tier, bar_xp[tier],
|
|
{raw[0]: 1}, {leather[0]: 1}, group="Tanning"))
|
|
for suffix, _slot, noun, used, offset, _icon in LEATHER_PIECES:
|
|
actions.append(_action(f"lw_{suffix}" if tier == 0 else f"lw_{prefix}_{suffix}", "leatherworking",
|
|
f"{label} {noun}", level + offset, 25 + 5 * used + 2 * tier, used * piece_xp[tier],
|
|
{leather[0]: used}, {f"{prefix}_{suffix}": 1}, group=label))
|
|
for raw, cooked, level, fish_id, verb in FISH:
|
|
tier = TIER_LEVELS.index(level) if level in TIER_LEVELS else 3
|
|
actions.append(_action(fish_id, "fishing", verb, level, [25, 35, 45, 55, 65, 80][tier],
|
|
[6, 15, 28, 45, 62, 85][tier], outputs={raw[0]: 1}))
|
|
actions.append(_action(COOK_IDS[raw[0]], "cooking", cooked[1], level, [20, 30, 35, 40, 45, 50][tier],
|
|
[6, 15, 27, 42, 58, 80][tier], {raw[0]: 1, "logs": 1}, {cooked[0]: 1}))
|
|
return actions
|
|
|
|
|
|
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}),
|
|
_action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}),
|
|
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}),
|
|
_action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}),
|
|
_action("hunt_boar", "hunting", "Hunt boar", 25, 50, 30, outputs={"raw_meat": 2, "boar_hide": 1}),
|
|
_action("hunt_bear", "hunting", "Hunt bears", 40, 60, 48, outputs={"raw_meat": 3, "bear_pelt": 1}),
|
|
*_tiered_actions(),
|
|
_action("smith_arrows", "smithing", "Arrows", 5, 30, 10,
|
|
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Bars"),
|
|
_action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40,
|
|
{"leather": 4, "iron_bar": 1}, {"studded_body": 1}, group="Leather"),
|
|
_action("mason_bricks", "masonry", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}),
|
|
_action("mason_cut_stone", "masonry", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}),
|
|
_action("mason_beam", "masonry", "Reinforced beam", 20, 45, 30,
|
|
{"oak_logs": 2, "iron_bar": 1}, {"beam": 1}),
|
|
_action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}),
|
|
_action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}),
|
|
_action("cook_stew", "cooking", "Stew", 15, 35, 16,
|
|
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}),
|
|
_action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, damage=2, gold=2,
|
|
drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5], ["bones", 1, 1.0]]),
|
|
_action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, gold=10,
|
|
drops=[["copper_bar", 1, 0.3], ["arrows", 5, 0.4], ["bones", 1, 1.0]]),
|
|
_action("fight_ruins", "combat", "Watchtower ruins", 25, 90, 60, difficulty=30, damage=14, gold=25,
|
|
drops=[["iron_bar", 1, 0.4], ["iron_sword", 1, 0.03], ["big_bones", 1, 0.8]]),
|
|
_action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, damage=25, gold=60,
|
|
drops=[["steel_bar", 1, 0.4], ["coal", 3, 0.5], ["iron_shield", 1, 0.04], ["big_bones", 1, 1.0]]),
|
|
_action("fight_green_dragon", "combat", "Green dragon", 50, 120, 135, difficulty=90, damage=30, gold=80,
|
|
drops=[["green_dragonhide", 1, 0.9], ["mithril_ore", 2, 0.3], ["dragon_bones", 1, 1.0]]),
|
|
_action("fight_blue_dragon", "combat", "Blue dragon", 62, 130, 175, difficulty=115, damage=40, gold=110,
|
|
drops=[["blue_dragonhide", 1, 0.9], ["adamantite_ore", 2, 0.3], ["dragon_bones", 1, 1.0]]),
|
|
_action("fight_red_dragon", "combat", "Red dragon", 75, 140, 220, difficulty=140, damage=52, gold=150,
|
|
drops=[["red_dragonhide", 1, 0.9], ["runite_ore", 1, 0.25], ["dragon_bones", 1, 1.0]]),
|
|
_action("fight_black_dragon", "combat", "Black dragon", 85, 150, 280, difficulty=165, damage=65, gold=220,
|
|
drops=[["red_dragonhide", 2, 0.9], ["rune_bar", 1, 0.1], ["dragon_bones", 2, 1.0]]),
|
|
_action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10,
|
|
special="sabotage"),
|
|
_action("bury_bones", "prayer", "Bury bones", 1, 10, 4, {"bones": 1}, special="bury", pp=2),
|
|
_action("bury_big_bones", "prayer", "Bury big bones", 15, 10, 15, {"big_bones": 1}, special="bury", pp=6),
|
|
_action("bury_dragon_bones", "prayer", "Bury dragon bones", 40, 12, 72, {"dragon_bones": 1}, special="bury", pp=20),
|
|
_action("offer_bones", "prayer", "Offer bones at the temple", 1, 12, 7, {"bones": 1}, special="offer",
|
|
devotion=2, city={"temple": 1}),
|
|
_action("offer_big_bones", "prayer", "Offer big bones", 15, 12, 25, {"big_bones": 1}, special="offer",
|
|
devotion=6, city={"temple": 1}),
|
|
_action("offer_dragon_bones", "prayer", "Offer dragon bones", 40, 14, 110, {"dragon_bones": 1}, special="offer",
|
|
devotion=20, city={"temple": 2}),
|
|
_action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1),
|
|
_action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2),
|
|
]
|
|
ACTION_BY_ID = {action["id"]: action for action in ACTIONS}
|
|
|
|
BUILDINGS = {
|
|
"houses": {
|
|
"name": "Houses", "icon": "🏠", "max": 8, "gold": 200,
|
|
"base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200},
|
|
"late": {"willow_logs": 150}, "final": {"maple_logs": 100},
|
|
"effect": "Room for 20 more people per level",
|
|
},
|
|
"walls": {
|
|
"name": "Walls", "icon": "🏰", "max": 5, "gold": 300,
|
|
"base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 40},
|
|
"late": {"steel_bar": 60}, "final": {"mithril_bar": 40},
|
|
"effect": "+20 defence per level, and raiders carry off less",
|
|
},
|
|
"barracks": {
|
|
"name": "Barracks", "icon": "⛺", "max": 5, "gold": 300,
|
|
"base": {"logs": 600, "bricks": 400, "copper_sword": 20}, "advanced": {"iron_sword": 15},
|
|
"late": {"steel_sword": 10}, "final": {"mithril_sword": 6},
|
|
"effect": "Trains townsfolk as militia: more defence per citizen",
|
|
},
|
|
"smithy": {
|
|
"name": "Smithy", "icon": "⚒️", "max": 5, "gold": 250,
|
|
"base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150},
|
|
"late": {"steel_bar": 80}, "final": {"mithril_bar": 60},
|
|
"effect": "Smithing 8% faster per level; each level unlocks a metal: iron, steel, mithril, adamant, rune",
|
|
},
|
|
"temple": {
|
|
"name": "Temple", "icon": "⛪", "max": 3, "gold": 300,
|
|
"base": {"stone": 800, "bricks": 400, "cut_stone": 100}, "advanced": {"beam": 20},
|
|
"late": {"willow_logs": 150},
|
|
"effect": "Offerings ward the city in raids; +1 prayer point per bone buried and +25% devotion per level",
|
|
},
|
|
"spymaster": {
|
|
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
|
|
"base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150},
|
|
"late": {"bear_leather": 40},
|
|
"effect": "One more level of intel on every event per level",
|
|
},
|
|
"storehouse": {
|
|
"name": "Storehouse", "icon": "📦", "max": 5, "gold": 200,
|
|
"base": {"logs": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 200},
|
|
"late": {"willow_logs": 200}, "final": {"yew_logs": 80},
|
|
"effect": "+3 bank slots and +250 per stack for everyone, per level",
|
|
},
|
|
}
|
|
BUILD_HOURS = 3
|
|
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_PER_DAY = 0.12
|
|
|
|
RAID_GROUPS = ["the Red Hand", "the Hollow Crows", "the Blackwater gang", "the Ashen Wolves",
|
|
"the Gallows Men", "the Thornback raiders"]
|
|
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 = 5
|
|
|
|
|
|
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": {}} for key in BUILDINGS},
|
|
"construction": None,
|
|
"queue": [],
|
|
"tier": 0,
|
|
"last_tick": now,
|
|
"food_due": 0.0,
|
|
"starving": False,
|
|
"repelled": 0,
|
|
},
|
|
"events": [],
|
|
"next_event_at": now + 12 * HOUR / speed,
|
|
"next_event_id": 1,
|
|
"ledger": [],
|
|
"board": [],
|
|
}
|
|
|
|
|
|
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
|
|
return world
|
|
|
|
|
|
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 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": [],
|
|
"hp": 44, "hp_at": now,
|
|
"action": None, "stopped": None,
|
|
"last_active": now, "overflow": "city",
|
|
"perks": {"bank_slot": 0},
|
|
"garrison": False,
|
|
"donated": 0,
|
|
}
|
|
|
|
|
|
def skill_level(player: dict, skill: str) -> int:
|
|
return level_for(player["xp"].get(skill, 0))
|
|
|
|
|
|
def bank_slots(world: dict, player: dict) -> int:
|
|
return (BASE_SLOTS + 3 * building_level(world, "storehouse") + TIER_SLOTS * world["city"]["tier"]
|
|
+ player["perks"]["bank_slot"])
|
|
|
|
|
|
def stack_size(world: dict) -> int:
|
|
return BASE_STACK + 250 * building_level(world, "storehouse")
|
|
|
|
|
|
def bank_room(world: dict, player: dict, item: str) -> int:
|
|
"""How many more of `item` fit in the bank."""
|
|
have = player["bank"].get(item, 0)
|
|
if have == 0 and len(player["bank"]) >= bank_slots(world, player):
|
|
return 0
|
|
return max(0, stack_size(world) - have)
|
|
|
|
|
|
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")
|
|
return uses
|
|
|
|
|
|
def bank_add(world: dict, player: dict, item: str, qty: int, now: float, summary: dict) -> int:
|
|
"""Store what fits; the rest goes to the city if it has a use, else is sold. Returns qty kept."""
|
|
keep = min(qty, bank_room(world, player, item))
|
|
if keep:
|
|
player["bank"][item] = player["bank"].get(item, 0) + keep
|
|
spill = qty - keep
|
|
if spill and player["overflow"] == "city" and not city_uses(item):
|
|
gold = int(spill * sell_price(world, item, now))
|
|
player["gold"] += gold
|
|
summary["sold"][item] = summary["sold"].get(item, 0) + spill
|
|
summary["sold_gold"] += gold
|
|
elif spill and player["overflow"] == "city":
|
|
overflow = summary["overflow"]
|
|
stockpile = world["city"]["stockpile"]
|
|
stockpile[item] = stockpile.get(item, 0) + spill
|
|
gain = spill * ITEMS[item]["value"]
|
|
player["favour"] += gain * OVERFLOW_FAVOUR
|
|
player["donated"] += gain
|
|
overflow[item] = overflow.get(item, 0) + spill
|
|
return keep
|
|
|
|
|
|
def bank_take(player: dict, item: str, qty: int) -> None:
|
|
left = player["bank"][item] - qty
|
|
if left > 0:
|
|
player["bank"][item] = left
|
|
else:
|
|
del player["bank"][item]
|
|
|
|
|
|
def armour(player: dict) -> int:
|
|
return sum(ITEMS[item].get("armour", 0) for item in player["equip"].values() if item)
|
|
|
|
|
|
def damage_reduction(player: dict) -> float:
|
|
points = armour(player)
|
|
return points / (points + 25)
|
|
|
|
|
|
def combat_power(player: dict) -> int:
|
|
power = skill_level(player, "combat") + armour(player) // 2
|
|
for item in player["equip"].values():
|
|
if item:
|
|
power += ITEMS[item].get("power", 0)
|
|
return power
|
|
|
|
|
|
def max_hp(player: dict) -> int:
|
|
return 40 + 4 * skill_level(player, "combat")
|
|
|
|
|
|
def regen(world: dict, player: dict, now: float, boost: float = 1) -> None:
|
|
"""Heal for the time since HP was last settled."""
|
|
if now > player["hp_at"]:
|
|
top = max_hp(player)
|
|
per_second = top * REGEN_PER_HOUR * boost * speed(world) / HOUR
|
|
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
|
|
player["hp_at"] = now
|
|
|
|
|
|
def fight_damage(player: dict, action: dict, blessing: dict | None = None) -> float:
|
|
"""Average damage one fight deals: less when out-levelling the enemy, armoured or praying."""
|
|
blessing = blessing or NO_BLESSING
|
|
gap = combat_power(player) + blessing["power"] - action["difficulty"]
|
|
scale = min(1.5, max(0.3, 1 - gap * 0.03))
|
|
return action["damage"] * scale * (1 - damage_reduction(player)) * blessing["taken"]
|
|
|
|
|
|
# --- prayer -------------------------------------------------------------------
|
|
# Melvor-style: bones become prayer points, active prayers cost points each
|
|
# fight and give boosts while they last, and praying in a fight trains Prayer.
|
|
|
|
PRAYERS = [
|
|
{"id": "thick_skin", "name": "Thick Skin", "icon": "🪨", "level": 1, "cost": 1, "reduce": 0.10},
|
|
{"id": "burst_of_strength", "name": "Burst of Strength", "icon": "💪", "level": 5, "cost": 1, "power": 5},
|
|
{"id": "rapid_heal", "name": "Rapid Heal", "icon": "💗", "level": 10, "cost": 1, "regen": 2},
|
|
{"id": "steel_skin", "name": "Steel Skin", "icon": "🛡️", "level": 20, "cost": 2, "reduce": 0.20},
|
|
{"id": "superhuman_strength", "name": "Superhuman Strength", "icon": "🦾", "level": 30, "cost": 2, "power": 12},
|
|
{"id": "second_wind", "name": "Second Wind", "icon": "🌬️", "level": 40, "cost": 1, "wind": True},
|
|
{"id": "protection", "name": "Protection", "icon": "✨", "level": 50, "cost": 3, "reduce": 0.35},
|
|
{"id": "piety", "name": "Piety", "icon": "😇", "level": 60, "cost": 4, "power": 20, "reduce": 0.20},
|
|
]
|
|
PRAYER_BY_ID = {prayer["id"]: prayer for prayer in PRAYERS}
|
|
MAX_PRAYERS = 2
|
|
PRAYER_XP_PER_POINT = 5
|
|
NO_BLESSING = {"cost": 0, "power": 0, "taken": 1.0, "regen": 1, "wind": False}
|
|
|
|
|
|
def describe_prayer(prayer: dict) -> str:
|
|
parts = []
|
|
if prayer.get("reduce"):
|
|
parts.append(f"-{round(prayer['reduce'] * 100)}% damage taken")
|
|
if prayer.get("power"):
|
|
parts.append(f"+{prayer['power']} power")
|
|
if prayer.get("regen"):
|
|
parts.append("HP recovers twice as fast while fighting")
|
|
if prayer.get("wind"):
|
|
parts.append("once per outing, a retreat becomes a second wind: back to half health")
|
|
return ", ".join(parts)
|
|
|
|
|
|
def blessing(player: dict) -> dict:
|
|
"""What the active prayers give this fight, if there are points to pay for them."""
|
|
active = [PRAYER_BY_ID[p] for p in player["prayers"] if p in PRAYER_BY_ID]
|
|
cost = sum(p["cost"] for p in active)
|
|
if not active or player["pp"] < cost:
|
|
return NO_BLESSING
|
|
taken = 1.0
|
|
for prayer in active:
|
|
taken *= 1 - prayer.get("reduce", 0)
|
|
return {"cost": cost, "power": sum(p.get("power", 0) for p in active), "taken": taken,
|
|
"regen": max([p.get("regen", 1) for p in active]), "wind": any(p.get("wind") for p in active)}
|
|
|
|
|
|
def set_prayers(player: dict, ids: list) -> str | None:
|
|
ids = list(dict.fromkeys(str(i) for i in ids))
|
|
if len(ids) > MAX_PRAYERS:
|
|
return f"At most {MAX_PRAYERS} prayers at once"
|
|
for prayer_id in ids:
|
|
prayer = PRAYER_BY_ID.get(prayer_id)
|
|
if not prayer:
|
|
return "No such prayer"
|
|
if skill_level(player, "prayer") < prayer["level"]:
|
|
return f"{prayer['name']} needs prayer level {prayer['level']}"
|
|
player["prayers"] = ids
|
|
return None
|
|
|
|
|
|
def bone_points(world: dict, action: dict) -> int:
|
|
return action["pp"] + building_level(world, "temple")
|
|
|
|
|
|
def ward(world: dict) -> float:
|
|
"""Defence the temple's devotion adds to the next raid."""
|
|
return round(min(world["city"].get("devotion", 0) / 4, 15 * building_level(world, "temple")), 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 auto_eat(player: dict, summary: dict) -> None:
|
|
top = max_hp(player)
|
|
while player["hp"] < top * EAT_BELOW:
|
|
foods = edible(player)
|
|
if not foods:
|
|
return
|
|
bank_take(player, foods[0], 1)
|
|
player["hp"] = min(top, player["hp"] + ITEMS[foods[0]]["heal"])
|
|
summary["eaten"][foods[0]] = summary["eaten"].get(foods[0], 0) + 1
|
|
|
|
|
|
def action_time(world: dict, player: dict, action: dict) -> float:
|
|
seconds = action["time"]
|
|
if action["skill"] in CLASSES[player["cls"]]["fast"]:
|
|
seconds *= CLASS_SPEED
|
|
tool = player["equip"].get(f"tool_{action['skill']}")
|
|
if tool:
|
|
seconds *= 1 - ITEMS[tool]["speed"]
|
|
if action["skill"] == "smithing":
|
|
seconds *= 1 - 0.08 * building_level(world, "smithy")
|
|
return seconds / speed(world)
|
|
|
|
|
|
def upcoming_raid(world: dict) -> dict | None:
|
|
for event in world["events"]:
|
|
if event["status"] == "upcoming" and event["kind"] == "raid":
|
|
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 action["skill"] == "reconnaissance" and player["cls"] != "scout":
|
|
return "Only scouts can do that"
|
|
if skill_level(player, action["skill"]) < action["level"]:
|
|
return f"Needs level {action['level']}"
|
|
for key, level in action.get("city", {}).items():
|
|
if building_level(world, key) < level:
|
|
return f"Needs {BUILDINGS[key]['name']} level {level}"
|
|
for item, qty in action["inputs"].items():
|
|
if player["bank"].get(item, 0) < qty:
|
|
return f"Out of {ITEMS[item]['name'].lower()}"
|
|
if action.get("special") == "sabotage":
|
|
raid = upcoming_raid(world)
|
|
if not raid or intel_level(world, raid) < 3:
|
|
return "No bandit camp has been located"
|
|
if raid["sabotage"] >= 0.3:
|
|
return "The camp is already in ruins"
|
|
if action["skill"] == "combat" and player["hp"] < max_hp(player) * TOO_HURT and not edible(player):
|
|
return "Too hurt to fight. Rest, or bring food"
|
|
return None
|
|
|
|
|
|
def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None:
|
|
for item, qty in action["inputs"].items():
|
|
bank_take(player, item, qty)
|
|
xp = action["xp"]
|
|
lucky = (
|
|
(player["cls"] == "artisan" and action["skill"] in CRAFTING)
|
|
or (player["cls"] == "pioneer" and action["skill"] in SURVEYED)
|
|
) 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 action["skill"] == "combat":
|
|
blessed = blessing(player)
|
|
if player["prayers"] and not blessed["cost"]:
|
|
summary["prayer_out"] = True
|
|
if blessed["cost"]:
|
|
player["pp"] -= blessed["cost"]
|
|
summary["pp_spent"] += blessed["cost"]
|
|
gain_xp(world, player, "prayer", blessed["cost"] * PRAYER_XP_PER_POINT, now, summary)
|
|
regen(world, player, now, blessed["regen"])
|
|
auto_eat(player, summary)
|
|
chance = 0.5 + (combat_power(player) + blessed["power"] - action["difficulty"]) * 0.04
|
|
won = rng.random() < min(0.97, max(0.15, chance))
|
|
hurt = fight_damage(player, action, blessed) * rng.uniform(0.6, 1.4) * (1 if won else 1.3)
|
|
player["hp"] -= hurt
|
|
summary["damage"] += hurt
|
|
auto_eat(player, summary)
|
|
outing = player["action"]
|
|
if player["hp"] <= 0 and blessed["wind"] and outing and not outing.get("wind_used"):
|
|
outing["wind_used"] = True
|
|
player["hp"] = max_hp(player) / 2
|
|
summary["second_wind"] += 1
|
|
if player["hp"] <= 0:
|
|
# Out of food and out of luck: fall back with nothing but your life.
|
|
player["hp"] = 1
|
|
summary["losses"] += 1
|
|
reason = "Retreated: ran out of food" if not edible(player) else "Retreated"
|
|
stop(player, reason)
|
|
summary["stopped"] = reason
|
|
gain_xp(world, player, "combat", xp // 3, now, summary)
|
|
return
|
|
if won:
|
|
summary["wins"] += 1
|
|
player["gold"] += action.get("gold", 0)
|
|
summary["gold"] += action.get("gold", 0)
|
|
for item, qty, odds in action.get("drops", []):
|
|
if rng.random() < odds:
|
|
outputs[item] = outputs.get(item, 0) + qty
|
|
if action.get("special") == "sabotage":
|
|
raid = upcoming_raid(world)
|
|
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03)
|
|
else:
|
|
summary["losses"] += 1
|
|
xp = xp // 3
|
|
if action.get("special") == "recon":
|
|
event = upcoming_event(world)
|
|
if event:
|
|
event["recon"] += action["recon"]
|
|
if action.get("special") == "bury":
|
|
player["pp"] += bone_points(world, action)
|
|
summary["pp_gained"] += bone_points(world, action)
|
|
if action.get("special") == "offer":
|
|
city = world["city"]
|
|
city["devotion"] = city.get("devotion", 0) + action["devotion"] * (1 + 0.25 * building_level(world, "temple"))
|
|
|
|
for item, qty in outputs.items():
|
|
kept = bank_add(world, player, item, qty, now, summary)
|
|
if kept:
|
|
summary["items"][item] = summary["items"].get(item, 0) + kept
|
|
gain_xp(world, player, action["skill"], xp, now, summary)
|
|
|
|
|
|
def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summary: dict | None = None) -> None:
|
|
before = skill_level(player, skill)
|
|
player["xp"][skill] += xp
|
|
after = skill_level(player, skill)
|
|
if summary is not None:
|
|
summary["xp"][skill] = summary["xp"].get(skill, 0) + xp
|
|
if after > before:
|
|
if summary is not None:
|
|
summary["levels"] = [entry for entry in summary["levels"] if entry[0] != skill] + [[skill, after]]
|
|
if after % 10 == 0 or after == MAX_LEVEL:
|
|
name = next(s["name"] for s in SKILLS if s["id"] == skill)
|
|
log(world, now, "level", f"{player['name']} reached {name} level {after}", player["id"])
|
|
|
|
|
|
def empty_summary() -> dict:
|
|
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
|
|
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None,
|
|
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0}
|
|
|
|
|
|
def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict:
|
|
"""Run the player's action forward to `now` (capped at 24h past last_active)."""
|
|
rng = rng or random.Random()
|
|
summary = empty_summary()
|
|
current = player["action"]
|
|
if not current:
|
|
return summary
|
|
action = ACTION_BY_ID[current["id"]]
|
|
limit = min(now, player["last_active"] + OFFLINE_CAP / speed(world))
|
|
while player["action"]:
|
|
duration = action_time(world, player, action)
|
|
if current["start"] + duration > limit:
|
|
break
|
|
reason = can_start(world, player, action)
|
|
if reason and reason.startswith("Too hurt"):
|
|
if blessing(player)["wind"] and not current.get("wind_used"):
|
|
# Second Wind: the retreat that was coming turns into half health.
|
|
current["wind_used"] = True
|
|
player["hp"] = max_hp(player) / 2
|
|
summary["second_wind"] += 1
|
|
reason = None
|
|
else:
|
|
reason = "Retreated: hurt and out of food"
|
|
if reason:
|
|
stop(player, reason)
|
|
summary["stopped"] = reason
|
|
break
|
|
if player["overflow"] == "stop" and any(bank_room(world, player, item) < qty
|
|
for item, qty in action["outputs"].items()):
|
|
stop(player, "Bank full")
|
|
summary["stopped"] = "Bank full"
|
|
break
|
|
_complete(world, player, action, current["start"] + duration, rng, summary)
|
|
summary["done"] += 1
|
|
current["start"] += duration
|
|
if player["action"] is current and "left" in current:
|
|
current["left"] -= 1
|
|
if current["left"] <= 0:
|
|
back = current.get("resume")
|
|
if back:
|
|
player["action"] = current = {"id": back, "start": current["start"]}
|
|
action = ACTION_BY_ID[back]
|
|
summary["resumed"] = back
|
|
else:
|
|
stop(player)
|
|
if player["action"] and limit < now and current["start"] < now:
|
|
current["start"] = now
|
|
return summary
|
|
|
|
|
|
def stop(player: dict, reason: str | None = None) -> None:
|
|
player["action"] = None
|
|
player["stopped"] = reason
|
|
|
|
|
|
def start_action(world: dict, player: dict, action_id: str, now: float, once: bool = False) -> str | None:
|
|
"""Start an action. `once` does it a single time, then goes back to what you were doing."""
|
|
action = ACTION_BY_ID.get(action_id)
|
|
if not action:
|
|
return "No such action"
|
|
reason = can_start(world, player, action)
|
|
if reason:
|
|
return reason
|
|
entry = {"id": action_id, "start": now}
|
|
if once:
|
|
current = player["action"]
|
|
back = current and (current.get("resume") or current["id"])
|
|
entry["left"] = 1
|
|
if back and back != action_id:
|
|
entry["resume"] = back
|
|
player["action"] = entry
|
|
player["stopped"] = None
|
|
return None
|
|
|
|
|
|
# --- city ----------------------------------------------------------------
|
|
|
|
def stockpile_value(city: dict) -> float:
|
|
return sum(ITEMS[item]["value"] * qty for item, qty in city["stockpile"].items())
|
|
|
|
|
|
def donate(world: dict, player: dict, item: str, qty: int, now: float) -> str | None:
|
|
city = world["city"]
|
|
if qty <= 0:
|
|
return "Nothing to donate"
|
|
if item == "gold":
|
|
if player["gold"] < qty:
|
|
return "Not enough gold"
|
|
player["gold"] -= qty
|
|
city["treasury"] += qty
|
|
gain = qty
|
|
label = f"{qty:,} gold"
|
|
else:
|
|
if not city_uses(item):
|
|
return "The city has no use for that"
|
|
if player["bank"].get(item, 0) < qty:
|
|
return "You don't have that many"
|
|
bank_take(player, item, qty)
|
|
city["stockpile"][item] = city["stockpile"].get(item, 0) + qty
|
|
gain = qty * ITEMS[item]["value"]
|
|
label = amount(item, qty)
|
|
player["favour"] += gain
|
|
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
|
|
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 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"]
|
|
hours = BUILD_HOURS * (building["level"] + 1) * (0.25 if repair else 1)
|
|
city["construction"] = {"building": key, "repair": repair, "start": now,
|
|
"done_at": now + hours * HOUR / speed(world)}
|
|
verb = "Repairs to" if repair else "Work on"
|
|
log(world, now, "build", f"{verb} the {BUILDINGS[key]['name'].lower()} began")
|
|
|
|
|
|
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 = max(0.5, pop * GROWTH_PER_DAY)
|
|
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 equip_problem(player: dict, item: str) -> str | None:
|
|
"""Why the player can't wear or use this yet, or None."""
|
|
req = ITEMS[item].get("req")
|
|
if req and skill_level(player, req[0]) < req[1]:
|
|
name = next(s["name"] for s in SKILLS if s["id"] == req[0])
|
|
return f"Needs {name.lower()} level {req[1]}"
|
|
return None
|
|
|
|
|
|
def defence(world: dict) -> dict:
|
|
"""The city's defence right now, part by part."""
|
|
city = world["city"]
|
|
pop = city["population"]
|
|
stock = city["stockpile"]
|
|
armed = int(pop // 2)
|
|
parts = {
|
|
"townsfolk": 10,
|
|
"walls": 20 * building_level(world, "walls"),
|
|
"militia": round(0.3 * building_level(world, "barracks") * pop, 1),
|
|
"weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1),
|
|
"shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1),
|
|
"arrows": min(100, stock.get("arrows", 0) // 10),
|
|
"rallied": city.get("rally", 0),
|
|
"ward": ward(world),
|
|
"garrison": 0,
|
|
}
|
|
garrison = []
|
|
for player in world["players"].values():
|
|
if player["garrison"]:
|
|
power = combat_power(player) * (1.5 if player["cls"] == "warrior" else 1)
|
|
garrison.append({"id": player["id"], "name": player["name"], "power": round(power, 1)})
|
|
parts["garrison"] += power
|
|
total = sum(parts.values())
|
|
if city["starving"]:
|
|
total *= 0.8
|
|
return {"parts": parts, "garrison": garrison, "total": round(total, 1), "starving": city["starving"]}
|
|
|
|
|
|
def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
|
|
"""Advance the city and events to `now`. Returns True if anything changed."""
|
|
rng = rng or random.Random()
|
|
city = world["city"]
|
|
changed = False
|
|
step = HOUR / speed(world)
|
|
# Never replay more than two weeks if the server was off.
|
|
city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world))
|
|
while city["last_tick"] + step <= now:
|
|
city["last_tick"] += step
|
|
_city_hour(world, city["last_tick"])
|
|
changed = True
|
|
construction = city["construction"]
|
|
if construction and construction["done_at"] <= now:
|
|
_finish_construction(world, construction["done_at"])
|
|
_start_next(world, construction["done_at"])
|
|
changed = True
|
|
while city["tier"] + 1 < len(TIERS) and city["population"] >= TIERS[city["tier"] + 1]["pop"]:
|
|
city["tier"] += 1
|
|
tier = TIERS[city["tier"]]
|
|
log(world, now, "tier", f"The settlement has grown into a {tier['name'].lower()}! "
|
|
f"Buildings can go higher, and everyone gets {TIER_SLOTS} more bank slots.")
|
|
for key in BUILDINGS:
|
|
_queue_if_funded(world, key, now)
|
|
_start_next(world, now)
|
|
changed = True
|
|
if world["next_event_at"] and world["next_event_at"] <= now and not upcoming_event(world):
|
|
_announce(world, now, rng)
|
|
changed = True
|
|
for event in world["events"]:
|
|
if event["status"] == "upcoming" and event["fire_at"] <= now:
|
|
for player in world["players"].values():
|
|
process_player(world, player, event["fire_at"], rng)
|
|
_fire(world, event, now, rng)
|
|
world["next_event_at"] = now + rng.uniform(8, 24) * HOUR / speed(world)
|
|
changed = True
|
|
return changed
|
|
|
|
|
|
def _city_hour(world: dict, now: float) -> None:
|
|
city = world["city"]
|
|
pop = city["population"]
|
|
city["food_due"] += pop * 0.5 / 24
|
|
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 + max(0.5, pop * GROWTH_PER_DAY) / 24)
|
|
elif pop > room:
|
|
city["population"] = max(room, pop - pop * 0.05 / 24)
|
|
city["treasury"] += pop * 0.5 / 24
|
|
|
|
|
|
def _finish_construction(world: dict, now: float) -> None:
|
|
city = world["city"]
|
|
work = city["construction"]
|
|
building = city["buildings"][work["building"]]
|
|
name = BUILDINGS[work["building"]]["name"]
|
|
if work["repair"]:
|
|
building["damaged"] = False
|
|
log(world, now, "build", f"The {name.lower()} have been repaired" if name.endswith("s")
|
|
else f"The {name.lower()} has been repaired")
|
|
else:
|
|
building["level"] += 1
|
|
log(world, now, "build", f"{name} reached level {building['level']}")
|
|
city["construction"] = None
|
|
# Leftover materials may already cover the next step.
|
|
_queue_if_funded(world, work["building"], now)
|
|
|
|
|
|
# --- events --------------------------------------------------------------
|
|
|
|
def _announce(world: dict, now: float, rng: random.Random) -> None:
|
|
city = world["city"]
|
|
hours = HOUR / speed(world)
|
|
first = not world["events"]
|
|
roll = rng.random()
|
|
kind = "raid" if first or roll < 0.7 else ("merchant" if roll < 0.85 else "refugees")
|
|
event = {
|
|
"id": world["next_event_id"], "kind": kind, "status": "upcoming",
|
|
"announced": now, "recon": 0, "sabotage": 0.0, "jitter": rng.uniform(-1, 1),
|
|
}
|
|
world["next_event_id"] += 1
|
|
if kind == "raid":
|
|
# Bigger, richer and older cities draw bigger bands; every win draws a bolder one.
|
|
wealth = city["treasury"] + stockpile_value(city)
|
|
day = (now - world["created"]) * speed(world) / DAY
|
|
strength = (15 + 4 * day + 0.8 * city["population"] + 1.2 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"])
|
|
event.update({
|
|
"fire_at": now + rng.uniform(30, 54) * hours,
|
|
"strength": round(strength * rng.uniform(0.85, 1.2), 1),
|
|
"group": rng.choice(RAID_GROUPS),
|
|
"trait": rng.choice(RAID_TRAITS),
|
|
})
|
|
log(world, now, "event", "Rumours of bandits on the roads reach the council")
|
|
elif kind == "merchant":
|
|
event["fire_at"] = now + rng.uniform(12, 30) * hours
|
|
log(world, now, "event", "Word arrives that a merchant caravan is headed our way")
|
|
else:
|
|
event["fire_at"] = now + rng.uniform(12, 36) * hours
|
|
event["people"] = rng.randint(6, 12)
|
|
log(world, now, "event", "Refugees from the borderlands are walking towards the city")
|
|
world["events"].append(event)
|
|
del world["events"][:-30]
|
|
|
|
|
|
def intel_level(world: dict, event: dict) -> int:
|
|
return min(3, building_level(world, "spymaster") + event["recon"] // RECON_PER_INTEL)
|
|
|
|
|
|
SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")]
|
|
WINDOW_HOURS = [24, 8, 2, 0]
|
|
|
|
|
|
def event_view(world: dict, event: dict, now: float) -> dict:
|
|
"""What the city knows about an upcoming event, filtered by intel."""
|
|
level = intel_level(world, event)
|
|
half = WINDOW_HOURS[level] * HOUR / speed(world)
|
|
centre = event["fire_at"] + event["jitter"] * half * 0.6
|
|
view = {
|
|
"id": event["id"], "kind": event["kind"], "intel": level,
|
|
"earliest": max(now, centre - half), "latest": max(now, centre + half),
|
|
"exact": event["fire_at"] if level >= 3 else None,
|
|
"recon": event["recon"], "recon_next": RECON_PER_INTEL - event["recon"] % RECON_PER_INTEL,
|
|
}
|
|
if event["kind"] == "raid":
|
|
strength = event["strength"] * (1 - event["sabotage"])
|
|
view["headline"] = "Travellers speak of trouble on the roads."
|
|
if level >= 1:
|
|
size = next(label for limit, label in SIZE_LABELS if strength < limit)
|
|
view["headline"] = f"A {size} bandit band is gathering in the hills."
|
|
view["size"] = size
|
|
if level >= 2:
|
|
spread = 0.25 * strength
|
|
low = strength - spread * (1 + event["jitter"]) / 2
|
|
view["range"] = [round(low), round(low + spread)]
|
|
view["group"] = event["group"]
|
|
view["headline"] = f"{upper_first(event["group"])} are gathering in the hills."
|
|
if level >= 3:
|
|
view["strength"] = round(strength)
|
|
view["trait"] = event["trait"]
|
|
view["camp"] = True
|
|
view["sabotage"] = round(event["sabotage"] * 100)
|
|
view["requests"] = council_requests(world, event)
|
|
elif event["kind"] == "merchant":
|
|
view["headline"] = "A merchant caravan is on its way. Everything sells for half again as much while it's here."
|
|
else:
|
|
view["headline"] = f"About {event['people']} refugees are walking towards the city, hoping for a roof."
|
|
return view
|
|
|
|
|
|
def council_requests(world: dict, event: dict) -> list[dict]:
|
|
city = world["city"]
|
|
stock = city["stockpile"]
|
|
strength = event["strength"]
|
|
armed = int(city["population"] // 2)
|
|
wants = [
|
|
("arrows", min(1000, 50 * math.ceil(strength * 4 / 50))),
|
|
("copper_sword", min(armed, max(5, math.ceil(strength / 6)))),
|
|
("bread", math.ceil(city["population"] * 1.5)),
|
|
]
|
|
if building_level(world, "smithy") >= 1:
|
|
wants.append(("iron_shield", min(armed, math.ceil(strength / 10))))
|
|
requests = []
|
|
for item, qty in wants:
|
|
have = stock.get(item, 0)
|
|
if item in ("copper_sword", "iron_shield"):
|
|
have = kit_count(stock, ITEMS[item]["slot"])
|
|
if item == "bread":
|
|
have = sum(stock.get(food, 0) * ITEMS[food]["food"] for food in FOOD_ORDER)
|
|
label = {"copper_sword": "Swords (any)", "iron_shield": "Shields (any)", "bread": "Food (any)"}[item] \
|
|
if item in ("copper_sword", "iron_shield", "bread") else ITEMS[item]["name"]
|
|
requests.append({"item": item, "label": label, "want": qty, "have": have})
|
|
return requests
|
|
|
|
|
|
def _fire(world: dict, event: dict, now: float, rng: random.Random) -> None:
|
|
event["status"] = "done"
|
|
event["resolved"] = now
|
|
if event["kind"] == "raid":
|
|
_resolve_raid(world, event, now, rng)
|
|
elif event["kind"] == "merchant":
|
|
world["city"]["merchant_until"] = now + 12 * HOUR / speed(world)
|
|
event["report"] = {"title": "A merchant caravan has arrived",
|
|
"lines": ["For the next 12 hours, everything sells for 50% more."]}
|
|
log(world, now, "event", "The merchant caravan arrived. Prices are up for 12 hours")
|
|
else:
|
|
city = world["city"]
|
|
room = max(0, housing(world) - int(city["population"]))
|
|
taken = min(room, event["people"])
|
|
city["population"] += taken
|
|
lines = [f"{taken} of {event['people']} refugees found a home."]
|
|
if taken < event["people"]:
|
|
lines.append("The rest moved on. More houses would have kept them.")
|
|
event["report"] = {"title": "Refugees arrived", "lines": lines}
|
|
log(world, now, "event", f"{taken} refugees settled in the city")
|
|
|
|
|
|
def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> None:
|
|
city = world["city"]
|
|
stock = city["stockpile"]
|
|
attack = event["strength"] * (1 - event["sabotage"])
|
|
guard = defence(world)
|
|
ratio = guard["total"] / attack if attack else 9
|
|
garrison = guard["garrison"]
|
|
lines = []
|
|
if event["sabotage"]:
|
|
lines.append(f"Sabotage weakened the raiders by {round(event['sabotage'] * 100)}%.")
|
|
report = {
|
|
"group": event["group"], "attack": round(attack), "defence": guard["total"],
|
|
"parts": guard["parts"], "garrison": garrison,
|
|
}
|
|
if ratio >= 1:
|
|
loot = round(attack * 2)
|
|
city["treasury"] += loot
|
|
city["repelled"] += 1
|
|
_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.")
|
|
for entry in garrison:
|
|
player = world["players"][entry["id"]]
|
|
player["favour"] += 25
|
|
gain_xp(world, player, "combat", round(attack * 5), now)
|
|
if garrison:
|
|
lines.append("Everyone in the garrison earned 25 favour and combat experience.")
|
|
log(world, now, "raid", f"{upper_first(event["group"])} were driven off")
|
|
else:
|
|
severity = min(1.0, 1 - ratio)
|
|
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
|
|
_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.")
|
|
if gold or taken:
|
|
stolen = ", ".join(amount(item, qty) for item, qty in taken.items())
|
|
lines.append(f"The raiders carried off {gold:,} gold" + (f" and {stolen}." if stolen else "."))
|
|
built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]]
|
|
if severity > 0.35 and built:
|
|
key = rng.choice(built)
|
|
city["buildings"][key]["damaged"] = True
|
|
_queue_if_funded(world, key, now)
|
|
lines.append(f"The {BUILDINGS[key]['name'].lower()} were damaged and won't work until repaired."
|
|
if BUILDINGS[key]["name"].endswith("s") else
|
|
f"The {BUILDINGS[key]['name'].lower()} was damaged and won't work until repaired.")
|
|
lines.append("A smaller, poorer city will draw less attention for a while.")
|
|
for entry in garrison:
|
|
gain_xp(world, world["players"][entry["id"]], "combat", round(attack * 2), now)
|
|
log(world, now, "raid", f"{upper_first(event["group"])} broke into the city")
|
|
report["lines"] = lines
|
|
event["report"] = report
|
|
for player in world["players"].values():
|
|
player["garrison"] = False
|
|
city["rally"] = city["rallies"] = 0
|
|
city["devotion"] = 0
|
|
|
|
|
|
def _use_up(stock: dict, item: str, share: float) -> None:
|
|
if stock.get(item):
|
|
stock[item] -= int(stock[item] * share)
|
|
if not stock[item]:
|
|
del stock[item]
|
|
|
|
|
|
# --- favour --------------------------------------------------------------
|
|
|
|
BUILDER_FAVOUR_PER_HOUR = 40
|
|
INFORMANT_FAVOUR = 250
|
|
RALLY_FAVOUR = 150
|
|
RALLY_DEFENCE = 10
|
|
|
|
|
|
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"]
|
|
hours = (work["done_at"] - now) * speed(world) / HOUR if work else 0
|
|
event = upcoming_event(world)
|
|
raid = upcoming_raid(world)
|
|
return {
|
|
"builders": {
|
|
"cost": max(25, math.ceil(hours * BUILDER_FAVOUR_PER_HOUR)),
|
|
"why": None if work and hours > 0.05 else "Nothing is being built",
|
|
"saves": hours / 2,
|
|
},
|
|
"informant": {
|
|
"cost": INFORMANT_FAVOUR,
|
|
"why": "No news to dig into" if not event else
|
|
"Nothing more to learn" if intel_level(world, event) >= 3 else None,
|
|
},
|
|
"militia": {
|
|
"cost": RALLY_FAVOUR * (city.get("rallies", 0) + 1),
|
|
"why": None if raid else "No raid to prepare for",
|
|
"rallied": city.get("rally", 0),
|
|
},
|
|
}
|
|
|
|
|
|
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"]
|
|
work["done_at"] = now + (work["done_at"] - now) / 2
|
|
name = BUILDINGS[work["building"]]["name"].lower()
|
|
log(world, now, "favour", f"{player['name']} hired extra builders for the {name}", player["id"])
|
|
elif kind == "informant":
|
|
upcoming_event(world)["recon"] += RECON_PER_INTEL
|
|
log(world, now, "favour", f"{player['name']} paid an informant for news from the roads", player["id"])
|
|
else:
|
|
city["rally"] = city.get("rally", 0) + RALLY_DEFENCE
|
|
city["rallies"] = city.get("rallies", 0) + 1
|
|
log(world, now, "favour", f"{player['name']} rallied the militia (+{RALLY_DEFENCE} defence for the coming raid)", 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 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,
|
|
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR,
|
|
}
|