Guildhall alpha: co-op idle game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9058039eb7
14 changed files with 2815 additions and 0 deletions
921
game.py
Normal file
921
game.py
Normal file
|
|
@ -0,0 +1,921 @@
|
|||
"""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
|
||||
RECON_PER_INTEL = 8
|
||||
MAX_LOG = 200
|
||||
|
||||
|
||||
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},
|
||||
"copper_ore": {"name": "Copper ore", "icon": "🟤", "value": 2},
|
||||
"iron_ore": {"name": "Iron ore", "icon": "⚫", "value": 4},
|
||||
"logs": {"name": "Logs", "icon": "🪵", "value": 1},
|
||||
"oak_logs": {"name": "Oak logs", "icon": "🌳", "value": 4},
|
||||
"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},
|
||||
"hide": {"name": "Hide", "icon": "🟫", "value": 3},
|
||||
"copper_bar": {"name": "Copper bar", "icon": "🟧", "value": 5},
|
||||
"iron_bar": {"name": "Iron bar", "icon": "⬛", "value": 10},
|
||||
"arrows": {"name": "Arrows", "icon": "🏹", "value": 1},
|
||||
"copper_sword": {"name": "Copper sword", "plural": "Copper swords", "icon": "🗡️", "value": 14, "slot": "weapon", "power": 4},
|
||||
"iron_sword": {"name": "Iron sword", "plural": "Iron swords", "icon": "⚔️", "value": 30, "slot": "weapon", "power": 9},
|
||||
"iron_shield": {"name": "Iron shield", "plural": "Iron shields", "icon": "🛡️", "value": 40, "slot": "shield", "power": 6},
|
||||
"bricks": {"name": "Bricks", "icon": "🧱", "value": 3},
|
||||
"cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5},
|
||||
"beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "🪜", "value": 25},
|
||||
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1},
|
||||
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1},
|
||||
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2},
|
||||
}
|
||||
FOOD_ORDER = ("bread", "cooked_meat", "stew")
|
||||
|
||||
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": "smithing", "name": "Smithing", "icon": "🔨", "group": "Crafting"},
|
||||
{"id": "masonry", "name": "Masonry", "icon": "🧱", "group": "Crafting"},
|
||||
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
|
||||
{"id": "combat", "name": "Combat", "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"}
|
||||
CRAFTING = {"smithing", "masonry", "cooking"}
|
||||
|
||||
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", "reconnaissance"],
|
||||
"bonus": "Hunting 20% faster",
|
||||
"signature": "Reconnaissance: scout the roads to sharpen intel on what's coming",
|
||||
},
|
||||
"artisan": {
|
||||
"name": "Artisan", "icon": "⚒️", "fast": ["smithing", "masonry", "cooking"],
|
||||
"bonus": "Smithing, masonry and cooking 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}
|
||||
|
||||
|
||||
ACTIONS = [
|
||||
_action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}),
|
||||
_action("mine_copper", "mining", "Mine copper", 1, 25, 7, outputs={"copper_ore": 1}),
|
||||
_action("mine_iron", "mining", "Mine iron", 15, 35, 15, outputs={"iron_ore": 1}),
|
||||
_action("cut_logs", "woodcutting", "Cut trees", 1, 20, 5, outputs={"logs": 1}),
|
||||
_action("cut_oak", "woodcutting", "Cut oaks", 15, 35, 15, outputs={"oak_logs": 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("smith_copper_bar", "smithing", "Copper bar", 1, 20, 6,
|
||||
{"copper_ore": 1, "logs": 1}, {"copper_bar": 1}),
|
||||
_action("smith_arrows", "smithing", "Arrows", 5, 30, 10,
|
||||
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}),
|
||||
_action("smith_copper_sword", "smithing", "Copper sword", 8, 40, 18,
|
||||
{"copper_bar": 3}, {"copper_sword": 1}),
|
||||
_action("smith_iron_bar", "smithing", "Iron bar", 15, 30, 14,
|
||||
{"iron_ore": 1, "logs": 1}, {"iron_bar": 1}),
|
||||
_action("smith_iron_sword", "smithing", "Iron sword", 20, 45, 32,
|
||||
{"iron_bar": 2}, {"iron_sword": 1}, city={"smithy": 1}),
|
||||
_action("smith_iron_shield", "smithing", "Iron shield", 25, 50, 40,
|
||||
{"iron_bar": 3}, {"iron_shield": 1}, city={"smithy": 2}),
|
||||
_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, gold=2,
|
||||
drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5]]),
|
||||
_action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, gold=10,
|
||||
drops=[["copper_bar", 1, 0.3], ["arrows", 5, 0.4]]),
|
||||
_action("fight_ruins", "combat", "Watchtower ruins", 25, 90, 60, difficulty=30, gold=25,
|
||||
drops=[["iron_bar", 1, 0.4], ["iron_sword", 1, 0.03]]),
|
||||
_action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, gold=60,
|
||||
drops=[["iron_bar", 2, 0.5], ["iron_shield", 1, 0.04]]),
|
||||
_action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12,
|
||||
special="sabotage"),
|
||||
_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": 5, "gold": 200,
|
||||
"base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200},
|
||||
"effect": "Room for 15 more people per level",
|
||||
},
|
||||
"walls": {
|
||||
"name": "Walls", "icon": "🏰", "max": 5, "gold": 300,
|
||||
"base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 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},
|
||||
"effect": "Trains townsfolk as militia: more defence per citizen",
|
||||
},
|
||||
"smithy": {
|
||||
"name": "Smithy", "icon": "⚒️", "max": 3, "gold": 250,
|
||||
"base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150},
|
||||
"effect": "Smithing 8% faster per level; unlocks iron swords (1) and shields (2)",
|
||||
},
|
||||
"spymaster": {
|
||||
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
|
||||
"base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150},
|
||||
"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},
|
||||
"effect": "+3 bank slots and +250 per stack for everyone, per level",
|
||||
},
|
||||
}
|
||||
BUILD_HOURS = 3
|
||||
REPAIR_SHARE = 0.3
|
||||
|
||||
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 ---------------------------------------------------------------
|
||||
|
||||
def new_world(now: float, speed: float = 1.0) -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"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} for key in BUILDINGS},
|
||||
"construction": None,
|
||||
"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 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()}
|
||||
for item, qty in spec["advanced"].items():
|
||||
if level > 1:
|
||||
cost[item] = cost.get(item, 0) + qty * (level - 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 + 15 * building_level(world, "houses")
|
||||
|
||||
|
||||
# --- 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": {"weapon": None, "shield": None},
|
||||
"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") + 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 bank_add(world: dict, player: dict, item: str, qty: int, now: float, overflow: dict) -> int:
|
||||
"""Store what fits; the rest goes to the city (or is lost). 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":
|
||||
stockpile = world["city"]["stockpile"]
|
||||
stockpile[item] = stockpile.get(item, 0) + spill
|
||||
gain = spill * ITEMS[item]["value"]
|
||||
player["favour"] += gain
|
||||
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 combat_power(player: dict) -> int:
|
||||
power = skill_level(player, "combat")
|
||||
for slot in ("weapon", "shield"):
|
||||
item = player["equip"][slot]
|
||||
if item:
|
||||
power += ITEMS[item]["power"]
|
||||
return power
|
||||
|
||||
|
||||
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"] == "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"
|
||||
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 GATHERING - {"hunting"})
|
||||
) 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":
|
||||
chance = 0.5 + (combat_power(player) - action["difficulty"]) * 0.04
|
||||
if rng.random() < min(0.97, max(0.15, chance)):
|
||||
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"]
|
||||
|
||||
for item, qty in outputs.items():
|
||||
kept = bank_add(world, player, item, qty, now, summary["overflow"])
|
||||
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": [],
|
||||
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None}
|
||||
|
||||
|
||||
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:
|
||||
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"] 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) -> str | None:
|
||||
action = ACTION_BY_ID.get(action_id)
|
||||
if not action:
|
||||
return "No such action"
|
||||
reason = can_start(world, player, action)
|
||||
if reason:
|
||||
return reason
|
||||
player["action"] = {"id": action_id, "start": now}
|
||||
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 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 commission(world: dict, player: dict, key: str, now: float) -> str | None:
|
||||
city = world["city"]
|
||||
if key not in BUILDINGS:
|
||||
return "No such building"
|
||||
if city["construction"]:
|
||||
return "The builders are busy"
|
||||
building = city["buildings"][key]
|
||||
repair = building["damaged"]
|
||||
if not repair and building["level"] >= BUILDINGS[key]["max"]:
|
||||
return "Already at its highest level"
|
||||
cost = repair_cost(key, building["level"]) if repair else build_cost(key, building["level"] + 1)
|
||||
missing = missing_for(city, cost)
|
||||
if missing:
|
||||
return "The stockpile is short: " + ", ".join(missing)
|
||||
for item, qty in cost.items():
|
||||
if item == "gold":
|
||||
city["treasury"] -= qty
|
||||
else:
|
||||
left = city["stockpile"][item] - qty
|
||||
if left:
|
||||
city["stockpile"][item] = left
|
||||
else:
|
||||
del city["stockpile"][item]
|
||||
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"{player['name']} started {verb} the {BUILDINGS[key]['name'].lower()}", player["id"])
|
||||
return None
|
||||
|
||||
|
||||
def missing_for(city: dict, cost: dict) -> list[str]:
|
||||
missing = []
|
||||
for item, qty in cost.items():
|
||||
have = city["treasury"] if item == "gold" else city["stockpile"].get(item, 0)
|
||||
if have < qty:
|
||||
name = "gold" if item == "gold" else ITEMS[item]["name"].lower()
|
||||
missing.append(f"{qty - int(have):,} {name}")
|
||||
return missing
|
||||
|
||||
|
||||
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)
|
||||
swords = min(armed, stock.get("copper_sword", 0) + stock.get("iron_sword", 0))
|
||||
iron = min(swords, stock.get("iron_sword", 0))
|
||||
parts = {
|
||||
"townsfolk": 10,
|
||||
"walls": 20 * building_level(world, "walls"),
|
||||
"militia": round(0.3 * building_level(world, "barracks") * pop, 1),
|
||||
"weapons": round(3 * iron + 1.5 * (swords - iron), 1),
|
||||
"shields": 2 * min(armed, stock.get("iron_shield", 0)),
|
||||
"arrows": min(100, stock.get("arrows", 0) // 10),
|
||||
"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"])
|
||||
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.25, pop * 0.06) / 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
|
||||
|
||||
|
||||
# --- 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") >= 2:
|
||||
wants.append(("iron_shield", min(armed, math.ceil(strength / 10))))
|
||||
requests = []
|
||||
for item, qty in wants:
|
||||
have = stock.get(item, 0)
|
||||
if item == "copper_sword":
|
||||
have += stock.get("iron_sword", 0)
|
||||
if item == "bread":
|
||||
have = sum(stock.get(food, 0) * ITEMS[food]["food"] for food in FOOD_ORDER)
|
||||
requests.append({"item": item, "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
|
||||
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
|
||||
|
||||
|
||||
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 --------------------------------------------------------------
|
||||
|
||||
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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue