Fittings: Construction-made building upgrades from planks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 11:15:19 +01:00
parent fb4b8bf7c8
commit cdb109e554
7 changed files with 218 additions and 14 deletions

119
game.py
View file

@ -496,7 +496,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 9
WORLD_VERSION = 10
def new_world(now: float, speed: float = 1.0) -> dict:
@ -509,7 +509,7 @@ 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, "fund": {}} for key in BUILDINGS},
"buildings": {key: {"level": 0, "damaged": False, "fund": {}, "fittings": []} for key in BUILDINGS},
"construction": None,
"queue": [],
"tier": 0,
@ -599,6 +599,10 @@ def migrate(world: dict, now: float | None = None) -> dict:
message["id"] = number
world["next_message_id"] = len(world["board"]) + 1
world["version"] = 9
if world["version"] < 10:
for building in world["city"]["buildings"].values():
building.setdefault("fittings", [])
world["version"] = 10
return world
@ -644,6 +648,12 @@ def building_level(world: dict, key: str) -> int:
return 0 if building["damaged"] else building["level"]
def fitted(world: dict, fitting: str) -> bool:
"""Whether a fitting is installed and working (its building isn't damaged)."""
key = FITTING_BY_ID[fitting]["building"]
return fitting in world["city"]["buildings"][key]["fittings"] and building_level(world, key) > 0
def build_cost(key: str, level: int) -> dict:
spec = BUILDINGS[key]
cost = {item: qty * level for item, qty in spec["base"].items()}
@ -711,11 +721,11 @@ def skill_level(player: dict, skill: str) -> int:
def bank_slots(world: dict, player: dict) -> int:
return (BASE_SLOTS + 3 * building_level(world, "storehouse") + TIER_SLOTS * world["city"]["tier"]
+ player["perks"]["bank_slot"])
+ player["perks"]["bank_slot"] + fitted(world, "shelving"))
def stack_size(world: dict) -> int:
return BASE_STACK + 250 * building_level(world, "storehouse")
return BASE_STACK + 250 * building_level(world, "storehouse") + 500 * fitted(world, "enchanted_chests")
def slots_used(world: dict, player: dict) -> int:
@ -744,6 +754,8 @@ def city_uses(item: str) -> list[str]:
uses.append("the armoury")
if item == "water_barrel":
uses.append("the fire brigade")
if "fitting" in spec:
uses.append(BUILDINGS[spec["fitting"]]["name"])
return uses
@ -802,6 +814,7 @@ 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)
boost *= 2 if fitted(world, "enchanted_hearths") else 1
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
@ -876,12 +889,13 @@ def set_prayers(player: dict, ids: list) -> str | None:
def bone_points(world: dict, action: dict) -> int:
return action["pp"] + building_level(world, "temple")
return action["pp"] + building_level(world, "temple") + fitted(world, "carved_altar")
def ward(world: dict) -> float:
"""Defence the temple's devotion adds to the next raid."""
return round(min(world["city"].get("devotion", 0) / 4, 15 * building_level(world, "temple")), 1)
cap = 15 * building_level(world, "temple") + 15 * fitted(world, "carved_altar")
return round(min(world["city"].get("devotion", 0) / 4, cap), 1)
def edible(player: dict) -> list[str]:
@ -913,6 +927,9 @@ def action_time(world: dict, player: dict, action: dict) -> float:
seconds *= 1 - ITEMS[tool]["speed"]
if action["skill"] == "smithing":
seconds *= 1 - 0.08 * building_level(world, "smithy")
seconds *= 0.9 if fitted(world, "bellows") else 1
if action["id"].startswith("saw_") and fitted(world, "saw_benches"):
seconds *= 0.8
seconds *= 1 - potion_effect(player, action["skill"]).get("speed", 0)
seconds *= skill_mods(world, action)["speed"]
return seconds / speed(world)
@ -1080,7 +1097,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
if action.get("special") == "recon":
event = upcoming_event(world)
if event:
event["recon"] += action["recon"]
event["recon"] += action["recon"] + fitted(world, "map_table")
if action.get("special") == "problem" and action["skill"] != "combat":
_work_problem(world, player, action, now)
if action.get("special") == "labour":
@ -1319,6 +1336,36 @@ def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source:
return None
def install(world: dict, player: dict, fitting: str, source: str, now: float) -> str | None:
"""Fit a building with a fitting from the player's bank (earns favour) or the stockpile."""
spec = FITTING_BY_ID.get(fitting)
if not spec:
return "No such fitting"
city = world["city"]
building = city["buildings"][spec["building"]]
name = BUILDINGS[spec["building"]]["name"].lower()
if fitting in building["fittings"]:
return f"The {name} already {'have' if name.endswith('s') else 'has'} that"
if building["level"] < spec["level"]:
return f"Needs {BUILDINGS[spec['building']]['name']} level {spec['level']}"
if source == "bank":
if not player["bank"].get(fitting):
return "You don't have one"
bank_take(player, fitting, 1)
gain = ITEMS[fitting]["value"]
player["favour"] += gain * city_rate(world, "favour")
player["donated"] += gain
elif source == "city":
if not city["stockpile"].get(fitting):
return "There isn't one in the stockpile"
take_stock(city, fitting, 1)
else:
return "Bad source"
building["fittings"].append(fitting)
log(world, now, "build", f"{player['name']} fitted the {name} with {spec['name'].lower()}", player["id"])
return None
def take_stock(city: dict, item: str, qty: int) -> None:
left = city["stockpile"][item] - qty
if left > 0:
@ -1418,8 +1465,8 @@ def defence(world: dict) -> dict:
armed = int(pop // 2)
parts = {
"townsfolk": 10,
"walls": 20 * building_level(world, "walls"),
"militia": round(0.3 * building_level(world, "barracks") * pop, 1),
"walls": 20 * building_level(world, "walls") + 25 * fitted(world, "hoardings"),
"militia": round(0.3 * building_level(world, "barracks") * pop * (1.5 if fitted(world, "weapon_racks") else 1), 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),
@ -1486,7 +1533,8 @@ 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_rate(world, "food") * (1.5 if city.get("cold") else 1)
city["food_due"] += (pop * 0.5 / 24 * city_rate(world, "food") * (1.5 if city.get("cold") else 1)
* (0.9 if fitted(world, "grain_bins") else 1))
stock = city["stockpile"]
for item in FOOD_ORDER:
while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0:
@ -1504,7 +1552,8 @@ 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) * city_rate(world, "growth") / 24)
growth = max(0.5, pop * GROWTH_PER_DAY) * city_rate(world, "growth") * (1.25 if fitted(world, "furniture") else 1)
city["population"] = min(room, pop + growth / 24)
elif pop > room:
city["population"] = max(room, pop - pop * 0.05 / 24)
city["treasury"] += pop * 0.5 / 24
@ -1894,6 +1943,47 @@ _barrel = _action("cooper_barrel", "construction", "Water barrel", 10, 35, 18,
ACTIONS.append(_barrel)
ACTION_BY_ID[_barrel["id"]] = _barrel
# Fittings: Construction makes them, mostly from the better planks, and each
# building takes one of each once it's high enough. A fitting works while its
# building stands; damage pauses it like the rest of the building.
# id, name, building, building level, construction level, inputs, effect, glyph, tint
FITTINGS = [
("grain_bins", "Grain bins", "granary", 1, 15, {"oak_plank": 20, "iron_bar": 5},
"The city eats 10% less food", "grain-bundle", "#e3c060"),
("saw_benches", "Saw benches", "lumber_shed", 1, 15, {"oak_plank": 20, "iron_bar": 10},
"Sawing planks is 20% faster", "hand-saw", "#8f5e30"),
("shelving", "Shelving", "storehouse", 2, 20, {"oak_plank": 40, "iron_bar": 10},
"+1 bank slot for everyone", "bookshelf", "#8f5e30"),
("map_table", "Map table", "spymaster", 1, 30, {"willow_plank": 25, "hard_leather": 10},
"Scouting gathers one more point of reconnaissance", "treasure-map", "#a3b86c"),
("well_pump", "Well pump", "well", 2, 30, {"willow_plank": 25, "steel_bar": 10},
"Half of all fires are put out before they spread", "water-tank", "#5aa0d8"),
("furniture", "Furniture", "houses", 2, 35, {"willow_plank": 40, "leather": 30},
"Population grows 25% faster", "sofa", "#a3b86c"),
("weapon_racks", "Weapon racks", "barracks", 2, 40, {"willow_plank": 30, "steel_bar": 20},
"Militia defence +50%", "spears", "#d4dae2"),
("hoardings", "Hoardings", "walls", 3, 45, {"maple_plank": 40, "mithril_bar": 10},
"+25 defence", "palisade", "#d0663a"),
("bellows", "Great bellows", "smithy", 3, 50, {"maple_plank": 30, "bear_leather": 15},
"Smithing another 10% faster", "bellows", "#d0663a"),
("carved_altar", "Carved altar", "temple", 2, 60, {"yew_plank": 30, "cut_stone": 60},
"+1 prayer point per bone buried, and offerings can ward 15 more", "star-altar", "#6f9a4a"),
("enchanted_chests", "Enchanted chests", "storehouse", 4, 75, {"magic_plank": 40, "rune_bar": 5},
"+500 per bank stack for everyone", "open-treasure-chest", "#a77be8"),
("enchanted_hearths", "Enchanted hearths", "houses", 4, 80, {"magic_plank": 50, "red_dragon_leather": 5},
"Everyone recovers HP twice as fast", "fireplace", "#a77be8"),
]
FITTING_BY_ID = {}
for _id, _name, _building, _blevel, _level, _inputs, _effect, _glyph, _tint in FITTINGS:
FITTING_BY_ID[_id] = {"id": _id, "name": _name, "building": _building, "level": _blevel, "effect": _effect}
ITEMS[_id] = {"name": _name, "plural": _name, "icon": "🪚", "value": sum(ITEMS[i]["value"] * q for i, q in _inputs.items()),
"fitting": _building}
_fitting = _action(f"make_{_id}", "construction", _name, _level, 120 + _level * 2, 40 + _level * 4,
_inputs, {_id: 1}, group="Fittings")
ACTIONS.append(_fitting)
ACTION_BY_ID[_fitting["id"]] = _fitting
ITEMS[_id].update(glyph=_glyph, tint=_tint)
# (weights by season, spring to winter)
HARDSHIPS = {"sickness": [2, 1, 2, 3], "locusts": [1, 3, 2, 0], "rot": [3, 1, 3, 1], "storm": [1, 1, 3, 1],
"rats": [2, 2, 2, 2], "fire": [1, 2, 1, 2], "winter": [0, 0, 0, 4], "drought": [0, 4, 1, 0]}
@ -2061,7 +2151,10 @@ def _hardship(world: dict, now: float, rng: random.Random, kind: str | None = No
["They'll eat into the food every hour until hunters catch them."])
elif kind == "fire":
barrels = stock.get("water_barrel", 0)
if barrels >= 5:
if fitted(world, "well_pump") and rng.random() < 0.5:
_record(world, now, "hardship", kind, "🔥", "A fire broke out, and the well's pump put it out",
["Nothing was lost."])
elif barrels >= 5:
take_stock_qty(stock, "water_barrel", 5)
_record(world, now, "hardship", kind, "🔥", "A fire broke out, and the water barrels put it out",
["Five barrels were used up. Nothing else was lost."])
@ -2515,6 +2608,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, "problems": PROBLEMS,
"potion_charges": POTION_CHARGES, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR,
}