Combat HP, food and retreat; armour, leatherworking, fishing, tools

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 19:27:56 +01:00
parent 3716df7493
commit d2991ebf7a
6 changed files with 381 additions and 44 deletions

218
game.py
View file

@ -20,6 +20,9 @@ CLASS_SPEED = 0.8
LUCKY_CHANCE = 0.1
RECON_PER_INTEL = 8
MAX_LOG = 200
REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour
EAT_BELOW = 0.5 # auto-eat when HP drops under this share of max
TOO_HURT = 0.3 # won't start a fight below this share without food
def _xp_table(max_level: int = 99) -> list[int]:
@ -63,26 +66,62 @@ ITEMS = {
"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` feeds that many townsfolk for two days; `heal` restores HP in a fight.
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8},
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20},
"raw_shrimp": {"name": "Raw shrimp", "icon": "🦐", "value": 1},
"raw_trout": {"name": "Raw trout", "icon": "🐟", "value": 3},
"raw_salmon": {"name": "Raw salmon", "icon": "🐠", "value": 6},
"raw_eel": {"name": "Raw eel", "icon": "🐍", "value": 10},
"cooked_shrimp": {"name": "Cooked shrimp", "icon": "🍤", "value": 4, "food": 1, "heal": 10},
"cooked_trout": {"name": "Grilled trout", "icon": "🍢", "value": 8, "food": 1, "heal": 18},
"cooked_salmon": {"name": "Baked salmon", "icon": "🍣", "value": 14, "food": 2, "heal": 28},
"smoked_eel": {"name": "Smoked eel", "icon": "🥓", "value": 22, "food": 2, "heal": 40},
"leather": {"name": "Leather", "icon": "🪢", "value": 5},
# `armour` lowers the damage a fight deals; `power` adds to fighting strength.
"leather_cap": {"name": "Leather cap", "icon": "🧢", "value": 12, "slot": "helmet", "armour": 1},
"leather_legs": {"name": "Leather trousers", "plural": "Leather trousers", "icon": "👖", "value": 18, "slot": "legs", "armour": 2},
"leather_body": {"name": "Leather jerkin", "icon": "🦺", "value": 24, "slot": "body", "armour": 3},
"studded_body": {"name": "Studded jerkin", "icon": "🧥", "value": 40, "slot": "body", "armour": 6},
"copper_helmet": {"name": "Copper helmet", "icon": "⛑️", "value": 12, "slot": "helmet", "armour": 2},
"copper_legs": {"name": "Copper greaves", "plural": "Copper greaves", "icon": "🦵", "value": 22, "slot": "legs", "armour": 3},
"copper_body": {"name": "Copper cuirass", "icon": "🥋", "value": 28, "slot": "body", "armour": 4},
"iron_helmet": {"name": "Iron helmet", "icon": "🪖", "value": 25, "slot": "helmet", "armour": 4},
"iron_legs": {"name": "Iron greaves", "plural": "Iron greaves", "icon": "🦿", "value": 45, "slot": "legs", "armour": 6},
"iron_body": {"name": "Iron cuirass", "icon": "🦾", "value": 55, "slot": "body", "armour": 8},
}
FOOD_ORDER = ("bread", "cooked_meat", "stew")
# Gathering tools: one slot per skill, `speed` cuts the action time.
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", "🪏"),
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪")}
for _skill, (_tool, _icon) in TOOLS.items():
for _metal, _value, _speed in (("copper", 14, 0.15), ("iron", 30, 0.3)):
ITEMS[f"{_metal}_{_tool.replace(' ', '_')}"] = {
"name": f"{_metal.title()} {_tool}", "icon": _icon, "value": _value,
"slot": f"tool_{_skill}", "tool": _skill, "speed": _speed,
}
ITEMS["iron_shield"]["armour"] = 4
# What the city eats first: plain food before the good stuff.
FOOD_ORDER = ("bread", "cooked_meat", "cooked_shrimp", "cooked_trout", "stew", "cooked_salmon", "smoked_eel")
EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs"] + [f"tool_{skill}" for skill in TOOLS]
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": "fishing", "name": "Fishing", "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": "leatherworking", "name": "Leatherworking", "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"}
GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"}
CRAFTING = {"smithing", "masonry", "cooking", "leatherworking"}
SURVEYED = {"mining", "woodcutting", "farming"} # what the pioneer's double yield applies to
CLASSES = {
"warrior": {
@ -91,13 +130,13 @@ CLASSES = {
"signature": "Command: counts for half again as much in the garrison",
},
"scout": {
"name": "Scout", "icon": "🏹", "fast": ["hunting", "reconnaissance"],
"bonus": "Hunting 20% faster",
"name": "Scout", "icon": "🏹", "fast": ["hunting", "fishing", "reconnaissance"],
"bonus": "Hunting and fishing 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",
"name": "Artisan", "icon": "⚒️", "fast": ["smithing", "masonry", "cooking", "leatherworking"],
"bonus": "Smithing, masonry, cooking and leatherworking 20% faster",
"signature": "Masterwork: 1 in 10 crafts makes double",
},
"pioneer": {
@ -123,18 +162,45 @@ ACTIONS = [
_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("fish_shrimp", "fishing", "Net shrimp", 1, 25, 6, outputs={"raw_shrimp": 1}),
_action("fish_trout", "fishing", "Fly-fish trout", 15, 35, 15, outputs={"raw_trout": 1}),
_action("fish_salmon", "fishing", "Catch salmon", 30, 45, 28, outputs={"raw_salmon": 1}),
_action("fish_eel", "fishing", "Trap eels", 45, 55, 45, outputs={"raw_eel": 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}),
{"copper_ore": 1, "logs": 1}, {"copper_bar": 1}, group="Bars"),
_action("smith_iron_bar", "smithing", "Iron bar", 15, 30, 14,
{"iron_ore": 1, "logs": 1}, {"iron_bar": 1}),
{"iron_ore": 1, "logs": 1}, {"iron_bar": 1}, group="Bars"),
_action("smith_copper_sword", "smithing", "Copper sword", 8, 40, 18,
{"copper_bar": 3}, {"copper_sword": 1}, group="Weapons"),
_action("smith_iron_sword", "smithing", "Iron sword", 20, 45, 32,
{"iron_bar": 2}, {"iron_sword": 1}, city={"smithy": 1}),
{"iron_bar": 2}, {"iron_sword": 1}, city={"smithy": 1}, group="Weapons"),
_action("smith_iron_shield", "smithing", "Iron shield", 25, 50, 40,
{"iron_bar": 3}, {"iron_shield": 1}, city={"smithy": 2}),
{"iron_bar": 3}, {"iron_shield": 1}, city={"smithy": 2}, group="Weapons"),
_action("smith_copper_helmet", "smithing", "Copper helmet", 6, 35, 14,
{"copper_bar": 2}, {"copper_helmet": 1}, group="Armour"),
_action("smith_copper_legs", "smithing", "Copper greaves", 10, 40, 20,
{"copper_bar": 4}, {"copper_legs": 1}, group="Armour"),
_action("smith_copper_body", "smithing", "Copper cuirass", 12, 45, 26,
{"copper_bar": 5}, {"copper_body": 1}, group="Armour"),
_action("smith_iron_helmet", "smithing", "Iron helmet", 22, 40, 34,
{"iron_bar": 2}, {"iron_helmet": 1}, city={"smithy": 1}, group="Armour"),
_action("smith_iron_legs", "smithing", "Iron greaves", 27, 45, 46,
{"iron_bar": 4}, {"iron_legs": 1}, city={"smithy": 1}, group="Armour"),
_action("smith_iron_body", "smithing", "Iron cuirass", 30, 50, 56,
{"iron_bar": 5}, {"iron_body": 1}, city={"smithy": 2}, group="Armour"),
*[_action(f"smith_{metal}_{tool.replace(' ', '_')}", "smithing", f"{metal.title()} {tool}", level, time, xp,
{f"{metal}_bar": 2, wood: 1}, {f"{metal}_{tool.replace(' ', '_')}": 1}, group="Tools",
**({"city": {"smithy": 1}} if metal == "iron" else {}))
for metal, level, time, xp, wood in (("copper", 4, 30, 12, "logs"), ("iron", 20, 40, 30, "oak_logs"))
for tool, _icon in TOOLS.values()],
_action("smith_arrows", "smithing", "Arrows", 5, 30, 10,
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Supplies"),
_action("tan_leather", "leatherworking", "Tan leather", 1, 25, 6, {"hide": 1}, {"leather": 1}),
_action("lw_cap", "leatherworking", "Leather cap", 3, 30, 10, {"leather": 2}, {"leather_cap": 1}),
_action("lw_legs", "leatherworking", "Leather trousers", 8, 35, 16, {"leather": 3}, {"leather_legs": 1}),
_action("lw_body", "leatherworking", "Leather jerkin", 12, 40, 22, {"leather": 4}, {"leather_body": 1}),
_action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40,
{"leather": 4, "iron_bar": 1}, {"studded_body": 1}),
_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,
@ -143,15 +209,19 @@ ACTIONS = [
_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,
_action("cook_shrimp", "cooking", "Cooked shrimp", 1, 20, 6, {"raw_shrimp": 1, "logs": 1}, {"cooked_shrimp": 1}),
_action("cook_trout", "cooking", "Grilled trout", 15, 30, 15, {"raw_trout": 1, "logs": 1}, {"cooked_trout": 1}),
_action("cook_salmon", "cooking", "Baked salmon", 30, 35, 27, {"raw_salmon": 1, "logs": 1}, {"cooked_salmon": 1}),
_action("cook_eel", "cooking", "Smoked eel", 45, 40, 42, {"raw_eel": 1, "logs": 1}, {"smoked_eel": 1}),
_action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, damage=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,
_action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, 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,
_action("fight_ruins", "combat", "Watchtower ruins", 25, 90, 60, difficulty=30, damage=14, 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,
_action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, damage=25, 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,
_action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10,
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),
@ -214,7 +284,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 2
WORLD_VERSION = 3
def new_world(now: float, speed: float = 1.0) -> dict:
@ -253,6 +323,17 @@ def migrate(world: dict) -> dict:
city.setdefault("queue", [])
city.setdefault("tier", 0)
world["version"] = 2
if world["version"] < 3:
now = world["city"]["last_tick"]
for player in world["players"].values():
for skill in SKILL_IDS:
player["xp"].setdefault(skill, 0)
for slot in EQUIP_SLOTS:
player["equip"].setdefault(slot, None)
player.setdefault("food", None)
player.setdefault("hp", max_hp(player))
player.setdefault("hp_at", now)
world["version"] = 3
return world
@ -329,7 +410,9 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
"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},
"equip": {slot: None for slot in EQUIP_SLOTS},
"food": None, # preferred food in fights; None eats the cheapest first
"hp": 44, "hp_at": now,
"action": None, "stopped": None,
"last_active": now, "overflow": "city",
"perks": {"bank_slot": 0},
@ -383,19 +466,69 @@ def bank_take(player: dict, item: str, qty: int) -> None:
del player["bank"][item]
def armour(player: dict) -> int:
return sum(ITEMS[item].get("armour", 0) for item in player["equip"].values() if item)
def damage_reduction(player: dict) -> float:
points = armour(player)
return points / (points + 25)
def combat_power(player: dict) -> int:
power = skill_level(player, "combat")
for slot in ("weapon", "shield"):
item = player["equip"][slot]
power = skill_level(player, "combat") + armour(player) // 2
for item in player["equip"].values():
if item:
power += ITEMS[item]["power"]
power += ITEMS[item].get("power", 0)
return power
def max_hp(player: dict) -> int:
return 40 + 4 * skill_level(player, "combat")
def regen(world: dict, player: dict, now: float) -> None:
"""Heal for the time since HP was last settled."""
if now > player["hp_at"]:
top = max_hp(player)
per_second = top * REGEN_PER_HOUR * speed(world) / HOUR
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
player["hp_at"] = now
def fight_damage(player: dict, action: dict) -> float:
"""Average damage one fight deals: less when out-levelling the enemy or armoured."""
gap = combat_power(player) - action["difficulty"]
return action["damage"] * min(1.5, max(0.3, 1 - gap * 0.03)) * (1 - damage_reduction(player))
def edible(player: dict) -> list[str]:
"""Food in the bank, in the order it gets eaten."""
foods = sorted((item for item in player["bank"] if "heal" in ITEMS[item]), key=lambda i: ITEMS[i]["heal"])
if player["food"] in foods:
foods.remove(player["food"])
foods.insert(0, player["food"])
return foods
def auto_eat(player: dict, summary: dict) -> None:
top = max_hp(player)
while player["hp"] < top * EAT_BELOW:
foods = edible(player)
if not foods:
return
bank_take(player, foods[0], 1)
player["hp"] = min(top, player["hp"] + ITEMS[foods[0]]["heal"])
summary["eaten"][foods[0]] = summary["eaten"].get(foods[0], 0) + 1
def action_time(world: dict, player: dict, action: dict) -> float:
seconds = action["time"]
if action["skill"] in CLASSES[player["cls"]]["fast"]:
seconds *= CLASS_SPEED
tool = player["equip"].get(f"tool_{action['skill']}")
if tool:
seconds *= 1 - ITEMS[tool]["speed"]
if action["skill"] == "smithing":
seconds *= 1 - 0.08 * building_level(world, "smithy")
return seconds / speed(world)
@ -433,6 +566,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["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
@ -442,15 +577,31 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
xp = action["xp"]
lucky = (
(player["cls"] == "artisan" and action["skill"] in CRAFTING)
or (player["cls"] == "pioneer" and action["skill"] in GATHERING - {"hunting"})
or (player["cls"] == "pioneer" and action["skill"] in SURVEYED)
) 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":
regen(world, player, now)
auto_eat(player, summary)
chance = 0.5 + (combat_power(player) - action["difficulty"]) * 0.04
if rng.random() < min(0.97, max(0.15, chance)):
won = rng.random() < min(0.97, max(0.15, chance))
hurt = fight_damage(player, action) * rng.uniform(0.6, 1.4) * (1 if won else 1.3)
player["hp"] -= hurt
summary["damage"] += hurt
auto_eat(player, summary)
if player["hp"] <= 0:
# Out of food and out of luck: fall back with nothing but your life.
player["hp"] = 1
summary["losses"] += 1
reason = "Retreated: ran out of food" if not edible(player) else "Retreated"
stop(player, reason)
summary["stopped"] = reason
gain_xp(world, player, "combat", xp // 3, now, summary)
return
if won:
summary["wins"] += 1
player["gold"] += action.get("gold", 0)
summary["gold"] += action.get("gold", 0)
@ -490,7 +641,7 @@ def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summar
def empty_summary() -> dict:
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [],
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None}
@ -508,6 +659,8 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
if current["start"] + duration > limit:
break
reason = can_start(world, player, action)
if reason and reason.startswith("Too hurt"):
reason = "Retreated: hurt and out of food"
if reason:
stop(player, reason)
summary["stopped"] = reason
@ -1058,4 +1211,5 @@ def content() -> dict:
return {
"items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS,
"buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, "tiers": TIERS,
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "regen_per_hour": REGEN_PER_HOUR,
}