Construction: buildings rise by labour; planks and hammers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 09:24:45 +01:00
parent 4093245b8e
commit a4e4bd4385
6 changed files with 180 additions and 47 deletions

97
game.py
View file

@ -98,7 +98,7 @@ PIECE_ICONS = {"sword": "⚔️", "shield": "🛡️", "helmet": "🪖", "legs":
COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body": "🥋"}
# Gathering tools: one slot per skill, `speed` cuts the action time.
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", "🪏"),
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪")}
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪"), "construction": ("hammer", "🔨")}
TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60]
# item id, name, icon, value, action id, verb
@ -160,8 +160,11 @@ for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in
"name": f"{_name} {_tool}", "icon": _icon, "value": round(2 * _bar_value * 1.4), "tier": _tier,
"slot": f"tool_{_skill}", "tool": _skill, "speed": TOOL_SPEED[_tier], "req": [_skill, EQUIP_LEVELS[_tier]],
}
for _log, _name, _icon, _value, _action_id, _verb in TREES:
PLANKS = [("plank", "Plank"), ("oak_plank", "Oak plank"), ("willow_plank", "Willow plank"),
("maple_plank", "Maple plank"), ("yew_plank", "Yew plank"), ("magic_plank", "Magic plank")]
for (_log, _name, _icon, _value, _action_id, _verb), (_plank, _plank_name) in zip(TREES, PLANKS):
ITEMS[_log] = {"name": _name, "icon": _icon, "value": _value}
ITEMS[_plank] = {"name": _plank_name, "icon": "🪚", "value": _value * 2 + 1}
for _tier, (_raw, _leather, _prefix, _label, _level, _armour) in enumerate(LEATHERS):
ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]}
ITEMS[_leather[0]] = {"name": _leather[1], "icon": "🪢", "value": _leather[2]}
@ -247,6 +250,7 @@ SKILLS = [
{"id": "fishing", "name": "Fishing", "icon": "🎣", "group": "Gathering"},
{"id": "smithing", "name": "Smithing", "icon": "🔨", "group": "Crafting"},
{"id": "masonry", "name": "Masonry", "icon": "🧱", "group": "Crafting"},
{"id": "construction", "name": "Construction", "icon": "🏗️", "group": "Crafting"},
{"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"},
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
{"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"},
@ -276,8 +280,8 @@ CLASSES = {
"signature": "Masterwork: 1 in 10 crafts makes double",
},
"pioneer": {
"name": "Pioneer", "icon": "🧭", "fast": ["mining", "woodcutting", "farming"],
"bonus": "Mining, woodcutting and farming 20% faster",
"name": "Pioneer", "icon": "🧭", "fast": ["mining", "woodcutting", "farming", "construction"],
"bonus": "Mining, woodcutting, farming and construction 20% faster",
"signature": "Surveying: 1 in 10 gathers yields double",
},
}
@ -355,6 +359,10 @@ ACTIONS = [
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Bars"),
_action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40,
{"leather": 4, "iron_bar": 1}, {"studded_body": 1}, group="Leather"),
_action("labour", "construction", "Work on the building site", 1, 30, 10, special="labour"),
*[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier,
[6, 14, 22, 32, 44, 58][tier], {TREES[tier][0]: 2}, {plank: 1}, group="Planks")
for tier, (plank, name) in enumerate(PLANKS)],
_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,
@ -399,47 +407,50 @@ BUILDINGS = {
"houses": {
"name": "Houses", "icon": "🏠", "max": 8, "gold": 200,
"base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200},
"late": {"willow_logs": 150}, "final": {"maple_logs": 100},
"late": {"willow_logs": 150, "oak_plank": 60}, "final": {"maple_logs": 100, "willow_plank": 60},
"effect": "Room for 20 more people per level",
},
"walls": {
"name": "Walls", "icon": "🏰", "max": 5, "gold": 300,
"base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 40},
"late": {"steel_bar": 60}, "final": {"mithril_bar": 40},
"late": {"steel_bar": 60, "oak_plank": 40}, "final": {"mithril_bar": 40, "maple_plank": 40},
"effect": "+20 defence per level, and raiders carry off less",
},
"barracks": {
"name": "Barracks", "icon": "⛺", "max": 5, "gold": 300,
"base": {"logs": 600, "bricks": 400, "copper_sword": 20}, "advanced": {"iron_sword": 15},
"late": {"steel_sword": 10}, "final": {"mithril_sword": 6},
"late": {"steel_sword": 10, "oak_plank": 50}, "final": {"mithril_sword": 6, "willow_plank": 50},
"effect": "Trains townsfolk as militia: more defence per citizen",
},
"smithy": {
"name": "Smithy", "icon": "⚒️", "max": 5, "gold": 250,
"base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150},
"late": {"steel_bar": 80}, "final": {"mithril_bar": 60},
"late": {"steel_bar": 80, "oak_plank": 40}, "final": {"mithril_bar": 60, "maple_plank": 40},
"effect": "Smithing 8% faster per level; each level unlocks a metal: iron, steel, mithril, adamant, rune",
},
"temple": {
"name": "Temple", "icon": "⛪", "max": 3, "gold": 300,
"base": {"stone": 800, "bricks": 400, "cut_stone": 100}, "advanced": {"beam": 20},
"late": {"willow_logs": 150},
"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",
},
"spymaster": {
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
"base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150},
"late": {"bear_leather": 40},
"late": {"bear_leather": 40, "oak_plank": 40},
"effect": "One more level of intel on every event per level",
},
"storehouse": {
"name": "Storehouse", "icon": "📦", "max": 5, "gold": 200,
"base": {"logs": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 200},
"late": {"willow_logs": 200}, "final": {"yew_logs": 80},
"late": {"willow_logs": 200, "oak_plank": 80}, "final": {"yew_logs": 80, "yew_plank": 40},
"effect": "+3 bank slots and +250 per stack for everyone, per level",
},
}
BUILD_HOURS = 3
# Buildings rise by labour: players working the site, and a slow trickle from
# the townsfolk so nothing stalls completely while everyone is away.
LABOUR_PER_LEVEL = 360 # a level-1 building: about three hours of one player's work
TOWNSFOLK_LABOUR = 0.5 # per person per game hour
REPAIR_SHARE = 0.3
# Population promotes the settlement, permanently. Each tier lifts how high
@ -463,7 +474,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 6
WORLD_VERSION = 7
def new_world(now: float, speed: float = 1.0) -> dict:
@ -529,6 +540,19 @@ def migrate(world: dict, now: float | None = None) -> dict:
player["xp"].setdefault("herblore", 0)
player.setdefault("potions", {})
world["version"] = 6
if world["version"] < 7:
for player in world["players"].values():
player["xp"].setdefault("construction", 0)
player["equip"].setdefault("tool_construction", None)
work = world["city"]["construction"]
if work and "done_at" in work:
# The old timer becomes labour, credited for the time already spent.
level = world["city"]["buildings"][work["building"]]["level"]
needed = labour_needed(level, work["repair"])
share = min(1.0, max(0.0, (now - work["start"]) / max(1.0, work["done_at"] - work["start"])))
del work["done_at"]
work.update(labour=needed * share, needed=needed)
world["version"] = 7
return world
@ -910,6 +934,8 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
return "No bandit camp has been located"
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["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
@ -983,6 +1009,13 @@ 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") == "labour":
site = world["city"]["construction"]
site["labour"] += labour_per_action(player)
summary["labour"] += labour_per_action(player)
if site["labour"] >= site["needed"]:
_finish_construction(world, now)
_start_next(world, now)
bonus = 1 + brew.get("bones", 0)
if action.get("special") == "bury":
player["pp"] += bone_points(world, action) * bonus
@ -1017,7 +1050,7 @@ def empty_summary() -> dict:
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None,
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0,
"kept": 0, "potions_drunk": {}, "potion_out": []}
"kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 0}
def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict:
@ -1241,9 +1274,8 @@ def _start_next(world: dict, now: float) -> None:
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)}
city["construction"] = {"building": key, "repair": repair, "start": now, "labour": 0.0,
"needed": labour_needed(building["level"], repair)}
verb = "Repairs to" if repair else "Work on"
log(world, now, "build", f"{verb} the {BUILDINGS[key]['name'].lower()} began")
@ -1339,9 +1371,9 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
_city_hour(world, city["last_tick"])
changed = True
construction = city["construction"]
if construction and construction["done_at"] <= now:
_finish_construction(world, construction["done_at"])
_start_next(world, construction["done_at"])
if construction and construction["labour"] >= construction["needed"]:
_finish_construction(world, now)
_start_next(world, now)
changed = True
while city["tier"] + 1 < len(TIERS) and city["population"] >= TIERS[city["tier"] + 1]["pop"]:
city["tier"] += 1
@ -1368,6 +1400,8 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
def _city_hour(world: dict, now: float) -> None:
city = world["city"]
pop = city["population"]
if city["construction"]:
city["construction"]["labour"] += pop * TOWNSFOLK_LABOUR
city["food_due"] += pop * 0.5 / 24
stock = city["stockpile"]
for item in FOOD_ORDER:
@ -1392,6 +1426,17 @@ def _city_hour(world: dict, now: float) -> None:
city["treasury"] += pop * 0.5 / 24
def labour_needed(level: int, repair: bool) -> float:
"""Labour to raise a building from `level`, or to repair it at that level."""
if repair:
return LABOUR_PER_LEVEL * max(1, level) * 0.25
return LABOUR_PER_LEVEL * (level + 1)
def labour_per_action(player: dict) -> float:
return 1 + skill_level(player, "construction") // 20 # one more labour every 20 levels
def _finish_construction(world: dict, now: float) -> None:
city = world["city"]
work = city["construction"]
@ -1616,7 +1661,7 @@ def _use_up(stock: dict, item: str, share: float) -> None:
# --- favour --------------------------------------------------------------
BUILDER_FAVOUR_PER_HOUR = 40
BUILDER_FAVOUR_PER_LABOUR = 0.67
INFORMANT_FAVOUR = 250
RALLY_FAVOUR = 150
RALLY_DEFENCE = 10
@ -1626,14 +1671,14 @@ def favours(world: dict, now: float) -> dict:
"""What favour can buy for the city right now: cost, and why not if it can't."""
city = world["city"]
work = city["construction"]
hours = (work["done_at"] - now) * speed(world) / HOUR if work else 0
remaining = max(0.0, work["needed"] - work["labour"]) if work else 0
event = upcoming_event(world)
raid = upcoming_raid(world)
return {
"builders": {
"cost": max(25, math.ceil(hours * BUILDER_FAVOUR_PER_HOUR)),
"why": None if work and hours > 0.05 else "Nothing is being built",
"saves": hours / 2,
"cost": max(25, math.ceil(remaining / 2 * BUILDER_FAVOUR_PER_LABOUR)),
"why": None if work and remaining > 1 else "Nothing is being built",
"labour": round(remaining / 2),
},
"informant": {
"cost": INFORMANT_FAVOUR,
@ -1661,7 +1706,7 @@ def spend_favour(world: dict, player: dict, kind: str, now: float) -> str | None
city = world["city"]
if kind == "builders":
work = city["construction"]
work["done_at"] = now + (work["done_at"] - now) / 2
work["labour"] += (work["needed"] - work["labour"]) / 2
name = BUILDINGS[work["building"]]["name"].lower()
log(world, now, "favour", f"{player['name']} hired extra builders for the {name}", player["id"])
elif kind == "informant":