Building funds safe from raids; settlement tiers; population and food on the town square

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 19:20:52 +01:00
parent e3b70c1e3a
commit 3716df7493
6 changed files with 358 additions and 91 deletions

218
game.py
View file

@ -160,9 +160,9 @@ ACTION_BY_ID = {action["id"]: action for action in ACTIONS}
BUILDINGS = {
"houses": {
"name": "Houses", "icon": "🏠", "max": 5, "gold": 200,
"name": "Houses", "icon": "🏠", "max": 8, "gold": 200,
"base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200},
"effect": "Room for 15 more people per level",
"effect": "Room for 20 more people per level",
},
"walls": {
"name": "Walls", "icon": "🏰", "max": 5, "gold": 300,
@ -193,6 +193,18 @@ BUILDINGS = {
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",
@ -202,9 +214,12 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 2
def new_world(now: float, speed: float = 1.0) -> dict:
return {
"version": 1,
"version": WORLD_VERSION,
"created": now,
"config": {"speed": speed, "clock_offset": 0},
"players": {},
@ -212,8 +227,10 @@ def new_world(now: float, speed: float = 1.0) -> dict:
"population": 20.0,
"treasury": 0.0,
"stockpile": {"bread": 40},
"buildings": {key: {"level": 0, "damaged": False} for key in BUILDINGS},
"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,
@ -227,6 +244,18 @@ def new_world(now: float, speed: float = 1.0) -> dict:
}
def migrate(world: dict) -> dict:
"""Bring a saved world up to the current shape. Never loses anything."""
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
return world
def speed(world: dict) -> float:
return world["config"]["speed"]
@ -268,7 +297,29 @@ def repair_cost(key: str, level: int) -> dict:
def housing(world: dict) -> int:
return 25 + 15 * building_level(world, "houses")
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 -------------------------------------------------------------
@ -292,7 +343,8 @@ def skill_level(player: dict, skill: str) -> int:
def bank_slots(world: dict, player: dict) -> int:
return BASE_SLOTS + 3 * building_level(world, "storehouse") + player["perks"]["bank_slot"]
return (BASE_SLOTS + 3 * building_level(world, "storehouse") + TIER_SLOTS * world["city"]["tier"]
+ player["perks"]["bank_slot"])
def stack_size(world: dict) -> int:
@ -549,45 +601,120 @@ def sell_price(world: dict, item: str, now: float) -> float:
return price
def commission(world: dict, player: dict, key: str, now: float) -> str | None:
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 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" 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":
city["treasury"] -= qty
player["gold"] -= 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"])
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 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 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 defence(world: dict) -> dict:
@ -634,6 +761,16 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
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)
@ -669,7 +806,7 @@ def _city_hour(world: dict, now: float) -> None:
else:
room = housing(world)
if pop < room:
city["population"] = min(room, pop + max(0.25, pop * 0.06) / 24)
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
@ -688,6 +825,8 @@ def _finish_construction(world: dict, now: float) -> None:
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 --------------------------------------------------------------
@ -870,6 +1009,7 @@ def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> N
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.")
@ -917,5 +1057,5 @@ def content() -> dict:
}
return {
"items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS,
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP,
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
}