Happenings: weather and seasons, minor threats, hardships, fortunes, dragons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 09:34:18 +01:00
parent a4e4bd4385
commit 75327f30e0
6 changed files with 903 additions and 38 deletions

655
game.py
View file

@ -434,6 +434,24 @@ BUILDINGS = {
"late": {"willow_logs": 150, "oak_plank": 60},
"effect": "Offerings ward the city in raids; +1 prayer point per bone buried and +25% devotion per level",
},
"granary": {
"name": "Granary", "icon": "🌾", "max": 3, "gold": 150,
"base": {"logs": 400, "bricks": 200, "plank": 60}, "advanced": {"cut_stone": 80},
"late": {"oak_plank": 60},
"effect": "Protects a quarter of the food from locusts and rats per level",
},
"lumber_shed": {
"name": "Lumber shed", "icon": "🪵", "max": 3, "gold": 120,
"base": {"logs": 300, "stone": 200, "plank": 80}, "advanced": {"oak_plank": 60},
"late": {"willow_plank": 40},
"effect": "Keeps a quarter of the stored wood safe from rot per level",
},
"well": {
"name": "Well", "icon": "🪣", "max": 3, "gold": 120,
"base": {"stone": 500, "bricks": 150}, "advanced": {"cut_stone": 80},
"late": {"oak_plank": 40},
"effect": "Each level cuts fire losses by a quarter and may save the building",
},
"spymaster": {
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
"base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150},
@ -467,6 +485,9 @@ 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"]
DRAGONS = ["a green dragon", "a blue dragon", "an old red dragon", "a young black dragon"]
DRAGON_TRAITS = ["it hunts at dusk", "one wing is scarred and weak", "it breathes fire in long sweeps",
"it circles twice before it strikes"]
RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows",
"well armed and disciplined", "a ragged mob, many but poorly armed",
"led by a deserter knight in plate"]
@ -474,7 +495,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 7
WORLD_VERSION = 8
def new_world(now: float, speed: float = 1.0) -> dict:
@ -491,6 +512,8 @@ def new_world(now: float, speed: float = 1.0) -> dict:
"construction": None,
"queue": [],
"tier": 0,
"weather": {"kind": "clear", "until": now + 6 * HOUR / speed, "next": "rain"},
"effects": [], "problems": [], "hired": 0,
"last_tick": now,
"food_due": 0.0,
"starving": False,
@ -498,6 +521,9 @@ def new_world(now: float, speed: float = 1.0) -> dict:
},
"events": [],
"next_event_at": now + 12 * HOUR / speed,
"next_minor_at": now + 30 * HOUR / speed,
"next_hardship_at": now + 24 * HOUR / speed,
"next_fortune_at": now + 6 * HOUR / speed,
"next_event_id": 1,
"ledger": [],
"board": [],
@ -553,6 +579,18 @@ def migrate(world: dict, now: float | None = None) -> dict:
del work["done_at"]
work.update(labour=needed * share, needed=needed)
world["version"] = 7
if world["version"] < 8:
city = world["city"]
for key in ("granary", "lumber_shed", "well"):
city["buildings"].setdefault(key, {"level": 0, "damaged": False, "fund": {}})
city.setdefault("weather", {"kind": "clear", "until": now + 4 * HOUR / speed(world), "next": "rain"})
city.setdefault("effects", [])
city.setdefault("problems", [])
city.setdefault("hired", 0)
world.setdefault("next_minor_at", now + 30 * HOUR / speed(world))
world.setdefault("next_hardship_at", now + 24 * HOUR / speed(world))
world.setdefault("next_fortune_at", now + 8 * HOUR / speed(world))
world["version"] = 8
return world
@ -689,6 +727,8 @@ def city_uses(item: str) -> list[str]:
uses.append("the granary")
if spec.get("slot") in ("weapon", "shield") or item == "arrows":
uses.append("the armoury")
if item == "water_barrel":
uses.append("the fire brigade")
return uses
@ -859,6 +899,7 @@ def action_time(world: dict, player: dict, action: dict) -> float:
if action["skill"] == "smithing":
seconds *= 1 - 0.08 * building_level(world, "smithy")
seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0)
seconds *= skill_mods(world, action)["speed"]
return seconds / speed(world)
@ -903,8 +944,9 @@ def _use_charge(player: dict, skill: str, summary: dict) -> None:
def upcoming_raid(world: dict) -> dict | None:
"""The coming major threat: a bandit raid or a dragon."""
for event in world["events"]:
if event["status"] == "upcoming" and event["kind"] == "raid":
if event["status"] == "upcoming" and event["kind"] in ("raid", "dragon"):
return event
return None
@ -932,10 +974,17 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
raid = upcoming_raid(world)
if not raid or intel_level(world, raid) < 3:
return "No bandit camp has been located"
if raid["kind"] != "raid":
return "A dragon has no camp to sabotage"
if raid["sabotage"] >= 0.3:
return "The camp is already in ruins"
if action.get("special") == "labour" and not world["city"]["construction"]:
return "Nothing is being built"
if action.get("special") == "problem" and not active_problem(world, action["problem"]):
return "That's been dealt with"
blocked = skill_mods(world, action)["blocked"]
if blocked:
return blocked
if action["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
@ -960,6 +1009,12 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
for item, qty in action["outputs"].items():
outputs[item] += qty
summary["lucky"] += 1
mods = skill_mods(world, action)
if mods["yield"] != 1:
for item, qty in list(outputs.items()):
exact = qty * mods["yield"]
outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0)
xp *= mods["xp"]
if action["skill"] == "combat":
blessed = blessing(player)
@ -1002,6 +1057,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
if action.get("special") == "sabotage":
raid = upcoming_raid(world)
raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03)
if action.get("special") == "problem":
_work_problem(world, player, action, now)
else:
summary["losses"] += 1
xp = xp // 3
@ -1009,6 +1066,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
event = upcoming_event(world)
if event:
event["recon"] += action["recon"]
if action.get("special") == "problem" and action["skill"] != "combat":
_work_problem(world, player, action, now)
if action.get("special") == "labour":
site = world["city"]["construction"]
site["labour"] += labour_per_action(player)
@ -1154,7 +1213,7 @@ def donate(world: dict, player: dict, item: str, qty: int, now: float) -> str |
city["stockpile"][item] = city["stockpile"].get(item, 0) + qty
gain = qty * ITEMS[item]["value"]
label = amount(item, qty)
player["favour"] += gain
player["favour"] += gain * city_rate(world, "favour")
player["donated"] += gain
log(world, now, "donation", f"{player['name']} donated {label}", player["id"])
return None
@ -1223,7 +1282,7 @@ def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source:
else:
bank_take(player, item, qty)
gain = qty * (1 if item == "gold" else ITEMS[item]["value"])
player["favour"] += gain
player["favour"] += gain * city_rate(world, "favour")
player["donated"] += gain
elif item == "gold":
city["treasury"] -= qty
@ -1344,6 +1403,7 @@ def defence(world: dict) -> dict:
"arrows": min(100, stock.get("arrows", 0) // 10),
"rallied": city.get("rally", 0),
"ward": ward(world),
"mercenaries": city.get("hired", 0),
"garrison": 0,
}
garrison = []
@ -1368,8 +1428,10 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world))
while city["last_tick"] + step <= now:
city["last_tick"] += step
_happenings_hour(world, city["last_tick"], rng)
_city_hour(world, city["last_tick"])
changed = True
changed = _happenings_tracks(world, now, rng) or changed
construction = city["construction"]
if construction and construction["labour"] >= construction["needed"]:
_finish_construction(world, now)
@ -1402,7 +1464,7 @@ def _city_hour(world: dict, now: float) -> None:
pop = city["population"]
if city["construction"]:
city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR
city["food_due"] += pop * 0.5 / 24
city["food_due"] += pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1)
stock = city["stockpile"]
for item in FOOD_ORDER:
while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0:
@ -1420,7 +1482,7 @@ def _city_hour(world: dict, now: float) -> None:
else:
room = housing(world)
if pop < room:
city["population"] = min(room, pop + max(0.5, pop * GROWTH_PER_DAY) / 24)
city["population"] = min(room, pop + max(0.5, pop * GROWTH_PER_DAY) * city_rate(world, "growth") / 24)
elif pop > room:
city["population"] = max(room, pop - pop * 0.05 / 24)
city["treasury"] += pop * 0.5 / 24
@ -1459,19 +1521,27 @@ def _finish_construction(world: dict, now: float) -> None:
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")
first = not any(e["kind"] in ("raid", "dragon") for e in world["events"])
day = (now - world["created"]) * speed(world) / DAY
# The major-threat track: mostly bandits, sometimes (after the first days) a dragon.
kind = "dragon" if not first and day >= 5 and rng.random() < 0.15 else "raid"
event = {
"id": world["next_event_id"], "kind": kind, "status": "upcoming",
"announced": now, "recon": 0, "sabotage": 0.0, "jitter": rng.uniform(-1, 1),
}
world["next_event_id"] += 1
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"])
# Bigger, richer and older cities draw bigger threats; every win draws a bolder one.
wealth = city["treasury"] + stockpile_value(city)
strength = (15 + 4 * day + 0.8 * city["population"] + 1.2 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"])
if kind == "dragon":
event.update({
"fire_at": now + rng.uniform(30, 54) * hours,
"strength": round(strength * 1.5 * rng.uniform(0.9, 1.15), 1),
"group": rng.choice(DRAGONS),
"trait": rng.choice(DRAGON_TRAITS),
})
log(world, now, "event", "Herders report scorched pastures in the hills")
elif kind == "raid":
event.update({
"fire_at": now + rng.uniform(30, 54) * hours,
"strength": round(strength * rng.uniform(0.85, 1.2), 1),
@ -1479,13 +1549,6 @@ def _announce(world: dict, now: float, rng: random.Random) -> None:
"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]
@ -1509,7 +1572,23 @@ def event_view(world: dict, event: dict, now: float) -> dict:
"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":
if event["kind"] == "dragon":
strength = event["strength"]
view["headline"] = "Herders whisper of scorched pastures in the hills."
if level >= 1:
view["headline"] = "A dragon has been seen over the hills, and it's coming closer."
view["size"] = next(label for limit, label in SIZE_LABELS if strength < limit)
if level >= 2:
spread = 0.25 * strength
low = strength - spread * (1 + event["jitter"]) / 2
view["range"] = [round(low), round(low + spread)]
view["group"] = event["group"]
view["headline"] = f"{upper_first(event['group'])} is coming for the city."
if level >= 3:
view["strength"] = round(strength)
view["trait"] = event["trait"]
view["requests"] = council_requests(world, event)
elif event["kind"] == "raid":
strength = event["strength"] * (1 - event["sabotage"])
view["headline"] = "Travellers speak of trouble on the roads."
if level >= 1:
@ -1565,6 +1644,8 @@ def _fire(world: dict, event: dict, now: float, rng: random.Random) -> None:
event["resolved"] = now
if event["kind"] == "raid":
_resolve_raid(world, event, now, rng)
elif event["kind"] == "dragon":
_resolve_dragon(world, event, now, rng)
elif event["kind"] == "merchant":
world["city"]["merchant_until"] = now + 12 * HOUR / speed(world)
event["report"] = {"title": "A merchant caravan has arrived",
@ -1650,6 +1731,67 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
player["garrison"] = False
city["rally"] = city["rallies"] = 0
city["devotion"] = 0
city["hired"] = 0
def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> None:
city = world["city"]
stock = city["stockpile"]
attack = event["strength"]
guard = defence(world)
garrison = guard["garrison"]
report = {"group": event["group"], "attack": round(attack), "defence": guard["total"],
"parts": guard["parts"], "garrison": garrison, "icon": "🐉"}
lines = []
if guard["total"] >= attack:
city["repelled"] += 1
city["treasury"] += round(attack)
_use_up(stock, "arrows", 0.5)
report["title"] = f"Victory! {upper_first(event['group'])} was driven off"
lines.append(f"It fled wounded, dropping a hoard worth {round(attack):,} gold.")
for entry in garrison:
player = world["players"][entry["id"]]
player["favour"] += 40
gain_xp(world, player, "combat", round(attack * 6), now)
bank_add(world, player, "dragon_bones", 2, now, empty_summary())
bank_add(world, player, "green_dragonhide", 1, now, empty_summary())
if garrison:
lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and a hide.")
log(world, now, "raid", report["title"])
else:
severity = min(1.0, 1 - guard["total"] / attack)
lost = int(city["population"] * (0.05 + 0.15 * severity))
city["population"] = max(5.0, city["population"] - lost)
share = min(0.8, 0.2 + 0.4 * severity)
burned = _take_share(stock, WOOD, share * (1 - 0.25 * building_level(world, "lumber_shed")))
burned.update(_take_share(stock, _food_items(stock), share * (1 - 0.25 * building_level(world, "granary"))))
name = _damage_building(world, now, rng)
_use_up(stock, "arrows", 0.7)
report["title"] = f"{upper_first(event['group'])} set the city ablaze"
lines.append(f"{lost} townsfolk were lost in the flames.")
if burned:
lines.append(f"It burned {_listing(burned)}.")
if name:
lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.")
for entry in garrison:
gain_xp(world, world["players"][entry["id"]], "combat", round(attack * 2), now)
log(world, now, "raid", report["title"])
report["lines"] = lines
event["report"] = report
for player in world["players"].values():
player["garrison"] = False
city["rally"] = city["rallies"] = 0
city["devotion"] = 0
city["hired"] = 0
def _work_problem(world: dict, player: dict, action: dict, now: float) -> None:
problem = active_problem(world, action["problem"])
if not problem:
return
problem["work"] += 1
if problem["work"] >= PROBLEMS[problem["kind"]]["work"]:
_solve(world, problem, now, player["name"])
def _use_up(stock: dict, item: str, share: float) -> None:
@ -1659,6 +1801,473 @@ def _use_up(stock: dict, item: str, share: float) -> None:
del stock[item]
# --- happenings: weather, seasons, hardships, fortunes, minor threats ---------
# Raids and dragons keep their own announced rhythm (the major-threat track
# above). Three more tracks run beside it on their own cooldowns, and the
# weather rolls every few hours, leaning towards whatever the season favours.
SEASONS = [("Spring", "🌱"), ("Summer", "☀️"), ("Autumn", "🍂"), ("Winter", "❄️")]
SEASON_DAYS = 7
# `speed` multiplies action time (below 1 is faster), `yield` multiplies output,
# `food` multiplies what the townsfolk eat, `firewood` is logs per person per hour.
WEATHER = {
"clear": {"name": "Clear skies", "icon": "🌤️", "text": "Nothing out of the ordinary"},
"rain": {"name": "Rain", "icon": "🌧️", "speed": {"farming": 0.9}, "text": "Farming 10% faster"},
"heat": {"name": "Heat wave", "icon": "🥵", "speed": {"farming": 0.85}, "food": 1.15,
"text": "Farming 15% faster, but food spoils faster"},
"storm": {"name": "Storm", "icon": "⛈️", "speed": {"fishing": 1.25}, "text": "Fishing 25% slower"},
"fog": {"name": "Fog", "icon": "🌫️", "speed": {"reconnaissance": 1.3, "hunting": 1.1},
"text": "Scouting 30% and hunting 10% slower"},
"snow": {"name": "Snow", "icon": "🌨️", "speed": {"hunting": 1.2}, "firewood": 0.05,
"text": "Hunting 20% slower, and the city burns firewood"},
}
SEASON_WEATHER = [
{"clear": 35, "rain": 40, "storm": 10, "fog": 15},
{"clear": 45, "heat": 30, "rain": 15, "storm": 10},
{"clear": 30, "rain": 30, "storm": 25, "fog": 15},
{"clear": 30, "snow": 45, "fog": 15, "storm": 10},
]
FIREWOOD = ("logs", "oak_logs", "willow_logs", "maple_logs", "yew_logs", "magic_logs")
WOOD = FIREWOOD + tuple(plank for plank, _name in PLANKS)
# Minor threats: they slow or block a skill until someone deals with them,
# either by working a special action (`work` completions) or by donating (`need`).
PROBLEMS = {
"collapse": {"name": "Mine collapse", "icon": "⛰️", "need": {"plank": 30},
"block": {"mining": "The mine has collapsed: shore it up with planks"}, "allow": ["mine_stone"],
"text": "A shaft caved in. Ore can't be mined until the mine is shored up with planks."},
"wolves": {"name": "Wolves at the farms", "icon": "🐺", "yield": {"farming": 0.5}, "work": 10, "action": "clear_wolves",
"text": "A pack is harrying the fields. Farming yields are halved until fighters drive them off."},
"bear": {"name": "Bear in the woods", "icon": "🐻", "speed": {"woodcutting": 1.5}, "work": 8, "action": "drive_bear",
"text": "Woodcutters won't go near it. Woodcutting is much slower until hunters drive it off."},
"goblins": {"name": "Goblin thieves", "icon": "👺", "steal": 0.01, "work": 10, "action": "catch_goblins",
"text": "Goblins raid the stockpile every hour until fighters catch them."},
"rockslide": {"name": "Rockslide on the quarry road", "icon": "🪨", "work": 25, "action": "clear_rockslide",
"block": {"mining": "The quarry road is blocked by a rockslide"}, "only": ["mine_stone"],
"text": "No stone can be quarried until builders clear the road."},
"flood": {"name": "Flooded fishing grounds", "icon": "🌊", "speed": {"fishing": 1.5}, "need": {"plank": 20}, "lasts": 36,
"text": "The river burst its banks. Fishing is slow until it drops in a day and a half, or a dam of planks holds it back."},
"boars": {"name": "Boars trampling the crops", "icon": "🐗", "yield": {"farming": 0.6}, "work": 10, "action": "cull_boars",
"text": "A herd is trampling the fields. Farming yields are down until hunters cull them."},
"rats": {"name": "Rats in the granary", "icon": "🐀", "steal_food": 0.03, "work": 12, "action": "catch_rats",
"text": "Rats eat into the city's food every hour until someone catches them."},
}
MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars")
PROBLEM_ACTIONS = [
_action("clear_wolves", "combat", "Drive off the wolves", 1, 60, 16, difficulty=6, damage=3,
special="problem", problem="wolves"),
_action("catch_goblins", "combat", "Catch the goblin thieves", 1, 60, 16, difficulty=8, damage=3,
special="problem", problem="goblins"),
_action("drive_bear", "hunting", "Drive off the bear", 1, 45, 14, special="problem", problem="bear"),
_action("cull_boars", "hunting", "Cull the boar herd", 1, 45, 14, special="problem", problem="boars"),
_action("catch_rats", "hunting", "Catch the rats", 1, 30, 8, special="problem", problem="rats"),
_action("clear_rockslide", "construction", "Clear the rockslide", 1, 30, 10, special="problem", problem="rockslide"),
]
ACTIONS.extend(PROBLEM_ACTIONS)
ACTION_BY_ID.update({action["id"]: action for action in PROBLEM_ACTIONS})
ITEMS["water_barrel"] = {"name": "Water barrel", "icon": "🛢️", "value": 20}
_barrel = _action("cooper_barrel", "construction", "Water barrel", 10, 35, 18,
{"oak_plank": 2, "iron_bar": 1}, {"water_barrel": 1}, group="Supplies")
ACTIONS.append(_barrel)
ACTION_BY_ID[_barrel["id"]] = _barrel
# (weights by season, spring to winter)
HARDSHIPS = {"sickness": [2, 1, 2, 3], "locusts": [1, 3, 2, 0], "rot": [3, 1, 3, 1], "storm": [1, 1, 3, 1],
"rats": [2, 2, 2, 2], "fire": [1, 2, 1, 2], "winter": [0, 0, 0, 4], "drought": [0, 4, 1, 0]}
FORTUNES = {"merchant": [12] * 4, "refugees": [10] * 4, "master": [16] * 4, "harvest": [10, 10, 12, 0],
"rich_vein": [10] * 4, "salmon": [10, 8, 4, 0], "festival": [8] * 4, "noble": [8] * 4,
"pilgrims": [8] * 4, "mercenaries": [6] * 4, "cargo": [10] * 4}
TRACK_HOURS = {"minor": (48, 96), "hardship": (48, 96), "fortune": (36, 72)}
def season(world: dict, now: float) -> dict:
day = (now - world["created"]) * speed(world) / DAY
index = int(day // SEASON_DAYS) % len(SEASONS)
name, icon = SEASONS[index]
return {"index": index, "name": name, "icon": icon, "day": int(day % SEASON_DAYS) + 1}
def _pick(weights: dict, rng: random.Random) -> str:
keys = [key for key, weight in weights.items() if weight > 0]
return rng.choices(keys, [weights[key] for key in keys])[0]
def _roll_weather(world: dict, now: float, rng: random.Random, current: str | None = None) -> str:
if current and rng.random() < 0.4:
return current
return _pick(SEASON_WEATHER[season(world, now)["index"]], rng)
def _modifier_sources(world: dict) -> list[dict]:
city = world["city"]
sources = [WEATHER[city["weather"]["kind"]]] + city["effects"]
sources += [PROBLEMS[problem["kind"]] for problem in city["problems"]]
return sources
def skill_mods(world: dict, action: dict) -> dict:
"""How weather, happenings and problems change this action right now."""
skill = action["skill"]
mods = {"speed": 1.0, "yield": 1.0, "xp": 1.0, "blocked": None}
for source in _modifier_sources(world):
mods["speed"] *= source.get("speed", {}).get(skill, 1)
mods["yield"] *= source.get("yield", {}).get(skill, 1)
mods["xp"] *= source.get("xp", {}).get(skill, 1)
if action.get("special") != "problem":
for problem in world["city"]["problems"]:
spec = PROBLEMS[problem["kind"]]
reason = spec.get("block", {}).get(skill)
if reason and (action["id"] in spec.get("only", [action["id"]])) and action["id"] not in spec.get("allow", []):
mods["blocked"] = reason
return mods
def city_rate(world: dict, key: str) -> float:
"""The product of a city-wide factor (food, growth, favour) across everything active."""
rate = 1.0
for source in _modifier_sources(world):
rate *= source.get(key, 1)
return rate
def active_problem(world: dict, kind: str) -> dict | None:
return next((p for p in world["city"]["problems"] if p["kind"] == kind), None)
def _record(world: dict, now: float, track: str, kind: str, icon: str, title: str, lines: list[str]) -> None:
"""A happening for the history list and the ledger."""
world["events"].append({"id": world["next_event_id"], "kind": kind, "track": track, "status": "done",
"announced": now, "fire_at": now, "resolved": now,
"report": {"title": title, "icon": icon, "lines": lines}})
world["next_event_id"] += 1
del world["events"][:-60]
log(world, now, track, title)
def _add_effect(world: dict, now: float, hours: float, **effect) -> None:
world["city"]["effects"].append({**effect, "until": now + hours * HOUR / speed(world)})
def _take_share(stock: dict, items, share: float) -> dict:
taken = {}
for item in [i for i in items if stock.get(i)]:
gone = int(stock[item] * share)
if gone:
taken[item] = gone
take_stock_qty(stock, item, gone)
return taken
def take_stock_qty(stock: dict, item: str, qty: int) -> None:
stock[item] -= qty
if stock[item] <= 0:
del stock[item]
def _listing(taken: dict) -> str:
names = [amount(item, qty) for item, qty in taken.items()]
return ", ".join(names[:-1]) + " and " + names[-1] if len(names) > 1 else (names[0] if names else "nothing")
def _damage_building(world: dict, now: float, rng: random.Random) -> str | None:
city = world["city"]
built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]]
if not built:
return None
key = rng.choice(built)
city["buildings"][key]["damaged"] = True
_queue_if_funded(world, key, now)
return BUILDINGS[key]["name"]
def _food_items(stock: dict) -> list[str]:
return [item for item in stock if "food" in ITEMS[item] or item in ("wheat", "vegetables")]
def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None:
city = world["city"]
stock = city["stockpile"]
weights = {kind: w[season(world, now)["index"]] for kind, w in HARDSHIPS.items()}
if city["weather"]["kind"] == "storm":
weights["storm"] += 6
if city["weather"]["kind"] == "rain":
weights["rot"] += 3
if active_problem(world, "rats"):
weights["rats"] = 0
kind = kind or _pick(weights, rng)
granary, shed, well = (building_level(world, k) for k in ("granary", "lumber_shed", "well"))
if kind == "sickness":
hours = 72 * (1 - 0.2 * building_level(world, "temple"))
_add_effect(world, now, hours, kind="sickness", name="Sickness", icon="🤒", food=1.5,
text="The sick need more food: the city eats half again as much")
_record(world, now, "hardship", kind, "🤒", "Sickness is spreading through the city",
[f"For about {round(hours)} hours the townsfolk eat half again as much.",
"A bigger temple shortens it."])
elif kind == "locusts":
share = 0.4 * (1 - 0.25 * granary)
taken = _take_share(stock, _food_items(stock), share)
_record(world, now, "hardship", kind, "🦗", "A swarm of locusts descended on the stores",
[f"They ate {_listing(taken)}." if taken else "There was little for them to eat.",
*(["The granary kept part of it safe."] if granary else ["A granary would protect some of it."])])
elif kind == "rot":
taken = _take_share(stock, WOOD, 0.3 * (1 - 0.25 * shed))
_record(world, now, "hardship", kind, "🍄", "Damp got into the wood stores",
[f"Rot ruined {_listing(taken)}." if taken else "There was no wood in the stockpile to rot.",
*(["The lumber shed kept part of it dry."] if shed else ["A lumber shed would keep it dry."])])
elif kind == "storm":
name = _damage_building(world, now, rng)
taken = _take_share(stock, FIREWOOD, 0.1)
lines = [f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs." if name
else "Luckily there was nothing standing tall enough to break."]
if taken:
lines.append(f"The wind scattered {_listing(taken)}.")
_record(world, now, "hardship", kind, "🌪️", "A wild storm battered the city", lines)
elif kind == "rats":
city["problems"].append({"kind": "rats", "started": now, "progress": {}, "work": 0})
_record(world, now, "hardship", kind, "🐀", "Rats have got into the granary",
["They'll eat into the food every hour until hunters catch them."])
elif kind == "fire":
barrels = stock.get("water_barrel", 0)
if barrels >= 5:
take_stock_qty(stock, "water_barrel", 5)
_record(world, now, "hardship", kind, "🔥", "A fire broke out, and the water barrels put it out",
["Five barrels were used up. Nothing else was lost."])
else:
share = 0.25 * (1 - 0.25 * well)
taken = _take_share(stock, WOOD + tuple(_food_items(stock)), share)
name = _damage_building(world, now, rng) if rng.random() > 0.25 * well else None
lines = [f"The flames took {_listing(taken)}." if taken else "Little was lost from the stores."]
if name:
lines.append(f"The {name.lower()} {'were' if name.endswith('s') else 'was'} damaged and needs repairs.")
lines.append("Water barrels in the stockpile (five per fire) would have put it out.")
_record(world, now, "hardship", kind, "🔥", "Fire swept through part of the city", lines)
elif kind == "winter":
_add_effect(world, now, 48, kind="winter", name="Harsh winter", icon="🥶", firewood=0.1,
text="The city burns firewood from the stockpile every hour; without it, people go cold and hungry")
_record(world, now, "hardship", kind, "🥶", "A harsh cold snap has set in",
["For two days the city burns logs from the stockpile to keep warm.",
"Without firewood, the cold makes everyone eat half again as much."])
else:
_add_effect(world, now, 24, kind="drought", name="Drought", icon="🏜️", speed={"farming": 1.3, "fishing": 1.2},
text="Farming 30% and fishing 20% slower")
_record(world, now, "hardship", kind, "🏜️", "Drought has dried the fields and streams",
["For a day, farming is 30% and fishing 20% slower."])
def _fortune(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None:
city = world["city"]
weights = {kind: w[season(world, now)["index"]] for kind, w in FORTUNES.items()}
if not upcoming_raid(world):
weights["mercenaries"] = 0
kind = kind or _pick(weights, rng)
if kind == "merchant":
city["merchant_until"] = now + 12 * HOUR / speed(world)
_record(world, now, "fortune", kind, "🐪", "A merchant caravan has arrived",
["For the next 12 hours, everything sells for 50% more."])
elif kind == "refugees":
people = rng.randint(6, 12)
taken = min(max(0, housing(world) - int(city["population"])), people)
city["population"] += taken
lines = [f"{taken} of {people} refugees found a home."]
if taken < people:
lines.append("The rest moved on. More houses would have kept them.")
_record(world, now, "fortune", kind, "🧳", "Refugees arrived", lines)
elif kind == "master":
skill = rng.choice([s for s in SKILL_IDS if s != "reconnaissance"])
name = next(s["name"] for s in SKILLS if s["id"] == skill)
_add_effect(world, now, 12, kind="master", name=f"A master of {name.lower()}", icon="🎓",
xp={skill: 1.5}, text=f"{name} earns 50% more XP")
_record(world, now, "fortune", kind, "🎓", f"A travelling master of {name.lower()} is visiting",
[f"For 12 hours, {name.lower()} earns 50% more XP. Learn while you can."])
elif kind == "harvest":
_add_effect(world, now, 12, kind="harvest", name="Bountiful harvest", icon="🌾", **{"yield": {"farming": 2}},
text="Farming yields doubled")
_record(world, now, "fortune", kind, "🌾", "The fields are bursting: a bountiful harvest",
["Farming yields are doubled for 12 hours."])
elif kind == "rich_vein":
_add_effect(world, now, 12, kind="rich_vein", name="Rich vein", icon="💎", **{"yield": {"mining": 1.5}},
text="Mining yields 50% more")
_record(world, now, "fortune", kind, "💎", "Miners struck a rich vein",
["Mining yields 50% more for 12 hours."])
elif kind == "salmon":
_add_effect(world, now, 12, kind="salmon", name="Salmon run", icon="🐟", **{"yield": {"fishing": 2}},
text="Fishing yields doubled")
_record(world, now, "fortune", kind, "🐟", "The salmon are running",
["Fishing yields are doubled for 12 hours."])
elif kind == "festival":
_add_effect(world, now, 24, kind="festival", name="Festival", icon="🎉", growth=2, favour=1.5,
text="The population grows twice as fast, and giving to the city earns 50% more favour")
_record(world, now, "fortune", kind, "🎉", "The city is holding a festival",
["For a day, the population grows twice as fast and donations earn 50% more favour."])
elif kind == "noble":
levels = sum(b["level"] for b in city["buildings"].values())
gold = 100 + 40 * levels
city["treasury"] += gold
_record(world, now, "fortune", kind, "👑", "A noble visited and admired the city",
[f"Impressed by its {levels} building levels, they left {gold:,} gold for the treasury."])
elif kind == "pilgrims":
lines = []
if building_level(world, "temple"):
gift = 20 * building_level(world, "temple")
city["devotion"] = city.get("devotion", 0) + gift
lines.append(f"They prayed at the temple: +{gift} devotion.")
for player in world["players"].values():
bank_add(world, player, "bones", 10, now, empty_summary())
lines.append("They left 10 bones with every adventurer.")
_record(world, now, "fortune", kind, "🙏", "Pilgrims passed through the city", lines)
elif kind == "mercenaries":
cost = 150 + 30 * season(world, now)["index"] + int(city["population"]) * 5
_add_effect(world, now, 24, kind="mercenaries", name="Mercenaries for hire", icon="🗡️", offer=True,
cost=cost, defence=25, text=f"A sellsword company offers +25 defence for the coming threat for {cost:,} gold from the treasury")
_record(world, now, "fortune", kind, "🗡️", "A sellsword company is camped outside the walls",
[f"For a day they'll join the next defence for {cost:,} gold from the treasury."])
else:
goods = rng.sample([("stone", 200), ("logs", 200), ("bricks", 80), ("bread", 60), ("arrows", 150), ("plank", 40)], 2)
for item, qty in goods:
city["stockpile"][item] = city["stockpile"].get(item, 0) + qty
_record(world, now, "fortune", kind, "📦", "Lost cargo was found on the road",
[f"{_listing(dict(goods))} went into the stockpile."])
def _minor_threat(world: dict, now: float, rng: random.Random, kind: str | None = None) -> None:
city = world["city"]
options = [k for k in MINOR_THREATS if not active_problem(world, k)]
kind = kind or rng.choice(options)
problem = {"kind": kind, "started": now, "progress": {}, "work": 0}
if "lasts" in PROBLEMS[kind]:
problem["until"] = now + PROBLEMS[kind]["lasts"] * HOUR / speed(world)
city["problems"].append(problem)
spec = PROBLEMS[kind]
_record(world, now, "minor", kind, spec["icon"], spec["name"], [spec["text"]])
def _solve(world: dict, problem: dict, now: float, who: str | None = None) -> None:
world["city"]["problems"].remove(problem)
spec = PROBLEMS[problem["kind"]]
_record(world, now, "minor", problem["kind"], "✅", f"Solved: {spec['name'].lower()}",
[f"Thanks to {who}." if who else "It's over."])
def problem_missing(problem: dict) -> dict:
need = PROBLEMS[problem["kind"]].get("need", {})
return {item: qty - problem["progress"].get(item, 0) for item, qty in need.items() if problem["progress"].get(item, 0) < qty}
def aid_problem(world: dict, player: dict, kind: str, item: str, source: str, now: float) -> str | None:
"""Give planks (or whatever a problem needs) from the bank or the stockpile."""
problem = active_problem(world, kind)
if not problem:
return "That's not a problem right now"
need = problem_missing(problem).get(item, 0)
if not need:
return "It doesn't need that"
stock = world["city"]["stockpile"]
have = player["bank"].get(item, 0) if source == "bank" else stock.get(item, 0)
qty = min(have, need)
if qty <= 0:
return "Nothing to give"
if source == "bank":
bank_take(player, item, qty)
gain = qty * ITEMS[item]["value"] * city_rate(world, "favour")
player["favour"] += gain
player["donated"] += qty * ITEMS[item]["value"]
else:
take_stock_qty(stock, item, qty)
problem["progress"][item] = problem["progress"].get(item, 0) + qty
if not problem_missing(problem):
_solve(world, problem, now, player["name"])
return None
def hire_mercenaries(world: dict, player: dict, now: float) -> str | None:
city = world["city"]
offer = next((e for e in city["effects"] if e.get("offer")), None)
if not offer:
return "Nobody is offering"
if city["treasury"] < offer["cost"]:
return f"The treasury needs {offer['cost']:,} gold"
city["treasury"] -= offer["cost"]
city["hired"] = city.get("hired", 0) + offer["defence"]
city["effects"].remove(offer)
log(world, now, "favour", f"{player['name']} hired the sellswords for {offer['cost']:,} gold (+{offer['defence']} defence)", player["id"])
return None
def _happenings_hour(world: dict, now: float, rng: random.Random) -> None:
"""Weather, firewood, thieves and expiries, once per game hour."""
city = world["city"]
weather = city["weather"]
if now >= weather["until"]:
weather["kind"] = weather["next"]
weather["until"] = now + rng.uniform(4, 8) * HOUR / speed(world)
weather["next"] = _roll_weather(world, weather["until"], rng, weather["kind"])
city["effects"] = [e for e in city["effects"] if e["until"] > now]
for problem in list(city["problems"]):
if problem.get("until") and problem["until"] <= now:
_solve(world, problem, now)
stock = city["stockpile"]
burn = city["population"] * sum(source.get("firewood", 0) for source in _modifier_sources(world))
city["firewood_due"] = city.get("firewood_due", 0) + burn
cold = False
while city["firewood_due"] >= 1:
log_item = next((item for item in FIREWOOD if stock.get(item)), None)
if not log_item:
cold = True
city["firewood_due"] = 0
break
take_stock_qty(stock, log_item, 1)
city["firewood_due"] -= 1
if cold and not city.get("cold"):
log(world, now, "city", "The firewood has run out. People are cold and eating more to stay warm.")
city["cold"] = cold
for problem in city["problems"]:
spec = PROBLEMS[problem["kind"]]
if spec.get("steal"):
_take_share(stock, [i for i in stock if i not in _food_items(stock)], spec["steal"])
if spec.get("steal_food"):
_take_share(stock, _food_items(stock), spec["steal_food"] * (1 - 0.25 * building_level(world, "granary")))
def _happenings_tracks(world: dict, now: float, rng: random.Random) -> bool:
changed = False
for track, (low, high) in TRACK_HOURS.items():
due = world.get(f"next_{track}_at")
if due is None or now < due:
continue
if track == "minor" and any(p["kind"] in MINOR_THREATS for p in world["city"]["problems"]):
world[f"next_{track}_at"] = now + 6 * HOUR / speed(world)
continue
{"minor": _minor_threat, "hardship": _hardship, "fortune": _fortune}[track](world, now, rng)
world[f"next_{track}_at"] = now + rng.uniform(low, high) * HOUR / speed(world)
changed = True
return changed
def happenings_view(world: dict, now: float) -> dict:
city = world["city"]
weather = city["weather"]
problems = []
for problem in city["problems"]:
spec = PROBLEMS[problem["kind"]]
problems.append({
"kind": problem["kind"], "name": spec["name"], "icon": spec["icon"], "text": spec["text"],
"until": problem.get("until"), "work": problem["work"], "needed": spec.get("work"),
"action": spec.get("action"), "need": spec.get("need"), "missing": problem_missing(problem),
})
return {
"season": season(world, now),
"weather": {**{k: v for k, v in WEATHER[weather["kind"]].items()}, "kind": weather["kind"], "until": weather["until"],
"next": weather["next"], "next_name": WEATHER[weather["next"]]["name"],
"next_icon": WEATHER[weather["next"]]["icon"]},
"effects": [{k: e[k] for k in ("kind", "name", "icon", "text", "until", "speed", "yield", "xp") if k in e}
| {"offer": e.get("offer", False), "cost": e.get("cost")} for e in city["effects"]],
"problems": problems,
"cold": city.get("cold", False),
"hired": city.get("hired", 0),
}
# --- favour --------------------------------------------------------------
BUILDER_FAVOUR_PER_LABOUR = 0.67
@ -1747,6 +2356,6 @@ def content() -> dict:
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
"city_uses": {item: city_uses(item) for item in ITEMS},
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS,
"potion_charges": POTION_CHARGES,
"potion_charges": POTION_CHARGES, "problems": PROBLEMS,
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR,
}