From d2991ebf7a986c29578e986cbf329f78f8fa254a Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 18 Oct 2026 19:27:56 +0100 Subject: [PATCH] Combat HP, food and retreat; armour, leatherworking, fishing, tools Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++- app.py | 20 ++++- game.py | 218 ++++++++++++++++++++++++++++++++++++++------- static/app.js | 66 ++++++++++++-- static/styles.css | 8 ++ tests/test_game.py | 103 +++++++++++++++++++++ 6 files changed, 381 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 58134fd..8c6fb6a 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,14 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. ## What's in it -- **Skills:** mining, woodcutting, farming, hunting, smithing, masonry, - cooking, combat, plus the scout's reconnaissance. +- **Skills:** mining, woodcutting, farming, hunting, fishing, smithing, + masonry, cooking, leatherworking, combat, plus the scout's reconnaissance. +- **Combat:** fights cost HP (less when out-levelled or armoured); below half + health you eat food from your bank, and out of food you retreat, losing + nothing but the fight. HP recovers 25% an hour. +- **Gear and tools:** smiths make weapons, copper and iron armour, and tools + (pickaxe, axe, hoe, fishing rod, hunting knife) that make gathering 15โ€“30% + faster; leatherworkers turn hides into leather armour. - **Classes:** warrior, scout, artisan, pioneer. Every class can train every skill; a class makes some skills 20% faster and has one signature ability. - **Bank:** 12 slots of up to 1,000 each to start. When something doesn't diff --git a/app.py b/app.py index 18347a2..f0fa9d5 100644 --- a/app.py +++ b/app.py @@ -91,7 +91,14 @@ class Store: def player_view(world: dict, player: dict, now: float) -> dict: - view = {key: value for key, value in player.items() if key != "last_active"} + game.regen(world, player, now) + view = {key: value for key, value in player.items() if key not in ("last_active", "hp_at")} + view["hp"] = int(player["hp"]) + view["max_hp"] = game.max_hp(player) + view["armour"] = game.armour(player) + view["reduction"] = round(game.damage_reduction(player), 3) + view["edible"] = game.edible(player) + view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a), 1) for a in game.ACTIONS if a["skill"] == "combat"} view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS} view["slots"] = game.bank_slots(world, player) view["stack"] = game.stack_size(world) @@ -224,6 +231,7 @@ class Handler(BaseHTTPRequestHandler): summary = None if player: summary = game.process_player(world, player, now, self.store.rng) + game.regen(world, player, now) player["last_active"] = now self.store.save() self.send_json(self.state(uid, summary)) @@ -253,6 +261,7 @@ class Handler(BaseHTTPRequestHandler): return self.fail("Create your adventurer first", HTTPStatus.CONFLICT) else: summary = game.process_player(world, player, now, self.store.rng) + game.regen(world, player, now) player["last_active"] = now error = self.player_route(path, player, body, now) if error is None: @@ -306,6 +315,12 @@ class Handler(BaseHTTPRequestHandler): return "There's no raid to stand guard for" player["garrison"] = bool(body.get("join")) return None + if path == "/api/food": + food = body.get("item") + if food is not None and "heal" not in game.ITEMS.get(str(food), {}): + return "That isn't food" + player["food"] = food + return None if path == "/api/perk": return game.buy_perk(player, str(body.get("perk"))) if path == "/api/settings": @@ -341,6 +356,9 @@ class Handler(BaseHTTPRequestHandler): game.bank_take(player, item, 1) current = player["equip"][spec["slot"]] if current: + if game.bank_room(self.store.world, player, current) < 1: + player["bank"][item] = player["bank"].get(item, 0) + 1 + return "No room in the bank for what you're wearing" player["bank"][current] = player["bank"].get(current, 0) + 1 player["equip"][spec["slot"]] = item return None diff --git a/game.py b/game.py index 2f4c77b..1b01e0c 100644 --- a/game.py +++ b/game.py @@ -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, } diff --git a/static/app.js b/static/app.js index e4c34b3..6c8b416 100644 --- a/static/app.js +++ b/static/app.js @@ -127,6 +127,8 @@ function showSummary(sum) { const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).icon} ${item(id).name}`); if (sum.gold) got.push(`+${fmt(sum.gold)} gold`); if (got.length) toast(got.join(" ยท "), "good"); + const eaten = Object.entries(sum.eaten || {}); + if (eaten.length) toast(`Ate ${eaten.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")}`); if (sum.lucky) toast(sum.lucky === 1 ? "โœจ Double yield!" : `โœจ ${sum.lucky} double yields!`, "level"); const spilled = Object.entries(sum.overflow); if (spilled.length) toast(`๐Ÿ“ฆ Bank full: ${spilled.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} went to the city`); @@ -142,6 +144,7 @@ function awayModal(sum) { openModal(`

While you were away

You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.

+ ${Object.keys(sum.eaten || {}).length ? `

Took ${fmt(sum.damage)} damage and ate ${Object.entries(sum.eaten).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.

` : ""} ${items ? `` : ""} ${sum.gold ? `

+${fmt(sum.gold)} gold

` : ""} ${xp ? `` : ""} @@ -205,7 +208,7 @@ function sidebar() {
Guildhall alpha
${esc(me.name)}
-
${cls.icon} ${cls.name}
+
${cls.icon} ${cls.name} ยท โค๏ธ ${fmt(me.hp)}/${me.max_hp}
๐Ÿช™ ${fmt(me.gold)}โœจ ${fmt(me.favour)}
@@ -305,20 +308,52 @@ function skillPage(id) { ${fast ? `${cls.icon} ${cls.name} bonus` : ""} `; if (id === "combat") html += combatPanel(); + if (`tool_${id}` in me.equip) html += toolPanel(id); if (id === "reconnaissance") html += `
Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.
`; - html += `
${C.actions.filter((a) => a.skill === id).map(actionCard).join("")}
`; - return html; + let group = null, cards = ""; + for (const a of C.actions.filter((a) => a.skill === id)) { + if (a.group && a.group !== group) { + group = a.group; + cards += `
${esc(group)}
`; + } + cards += actionCard(a); + } + return html + `
${cards}
`; +} + +function toolPanel(skill) { + const tool = S.me.equip[`tool_${skill}`]; + return `
${tool + ? `${item(tool).icon} ${esc(item(tool).name)} ${Math.round(item(tool).speed * 100)}% faster + ` + : `No tool. A smith can make you one that speeds this up.`}
`; } function combatPanel() { const me = S.me; + const stat = (id) => [item(id).power && `+${item(id).power} power`, item(id).armour && `+${item(id).armour} armour`].filter(Boolean).join(", "); const slot = (name, label) => { const id = me.equip[name]; - return `
${id ? `${item(id).icon} ${esc(item(id).name)} (+${item(id).power}) ` : `No ${label}`}
`; + return `
${id + ? `${item(id).icon} ${esc(item(id).name)} ${stat(id)}` + : `No ${label}`}
`; }; - return `

Equipment

-
${slot("weapon", "weapon")} ${slot("shield", "shield")}
-

Combat power ${me.power} = combat level + gear${me.cls === "warrior" ? ". As a warrior you count for ร—1.5 in the garrison" : ""}. Equip swords and shields from the bank.

`; + const foods = me.edible.map((id) => ``).join(""); + const hpPct = me.hp / me.max_hp * 100; + return `
+

Health

+
โค๏ธ ${fmt(me.hp)} / ${me.max_hp}recovers ${Math.round(C.regen_per_hour * 100)}% an hour
+
+
+

Below half health you eat from your bank automatically. + ${me.edible.length ? "" : `You have no food in your bank.`} + Out of food and badly hurt, you retreat: the fight is lost, but nothing else is.

+
+

Equipment

+
${slot("weapon", "weapon")}${slot("shield", "shield")}${slot("helmet", "helmet")}${slot("body", "body armour")}${slot("legs", "leg armour")}
+

Power ${me.power} (level + weapon + shield + half your armour) ยท armour ${me.armour} takes ${Math.round(me.reduction * 100)}% off the damage you take${me.cls === "warrior" ? " ยท as a warrior you count ร—1.5 in the garrison" : ""}. Equip gear from the bank.

+
`; } function actionCard(a) { @@ -337,7 +372,7 @@ function actionCard(a) { let extra = ""; if (a.skill === "combat") { const chance = Math.min(0.97, Math.max(0.15, 0.5 + (me.power - a.difficulty) * 0.04)); - extra = `
Win chance ${Math.round(chance * 100)}%${a.gold ? ` ยท ${a.gold} gold` : ""}
`; + extra = `
Win chance ${Math.round(chance * 100)}% ยท about ${me.fight_damage[a.id]} damage a fight${a.gold ? ` ยท ${a.gold} gold` : ""}
`; if (a.drops) io += a.drops.map(([id, q, odds]) => `${item(id).icon} ${q} ${Math.round(odds * 100)}%`).join(""); if (a.special === "sabotage") extra += `
Each win weakens the coming raid by 3%, up to 30%.
`; } @@ -397,7 +432,9 @@ function itemPanel(id) {
${it.icon}

${esc(it.name)}

You have ${fmt(have)} ยท worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}
- ${it.slot ? `
` : ""} + ${it.slot ? `
` : ""} + ${it.heal ? `
Heals ${it.heal} in fights.
+ ${S.me.food === id ? `Your fighting food` : ``}
` : ""} ${it.food ? `
Feeds ${it.food} townsfolk for two days in the city granary.
` : ""}
Donate to the city ยท +${it.value} favour each
${qtyBtns("data-donate")}
${others.length ? `
Give to
${qtyBtns("data-give")}
` : ""} @@ -406,6 +443,12 @@ function itemPanel(id) { ${goldPanel()}`; } +function equipLabel(it) { + if (it.tool) return `Use as ${skillOf(it.tool).name.toLowerCase()} tool (${Math.round(it.speed * 100)}% faster)`; + const stats = [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`].filter(Boolean).join(", "); + return `Equip ${it.slot === "body" ? "body armour" : it.slot === "legs" ? "leg armour" : it.slot} (${stats})`; +} + function goldPanel() { return `

Gold

๐Ÿช™ ${fmt(S.me.gold)}

@@ -658,12 +701,17 @@ document.addEventListener("click", (e) => { if (d.fund) return act("/api/fund", { building: d.fund, item: d.item, source: d.source, qty: "all" }); if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" }); if (d.perk) return act("/api/perk", { perk: d.perk }); + if (d.food) return act("/api/food", { item: d.food }); if (d.overflow) return act("/api/settings", { overflow: d.overflow }); if (d.class) { pickedClass = d.class; render(); return; } if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) }); if ("devEvent" in d) return act("/api/dev/event", {}); }); +document.addEventListener("change", (e) => { + if (e.target.matches("[data-food-pick]")) act("/api/food", { item: e.target.value || null }); +}); + $("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; }); document.addEventListener("submit", async (e) => { diff --git a/static/styles.css b/static/styles.css index 3b61292..48863d7 100644 --- a/static/styles.css +++ b/static/styles.css @@ -194,6 +194,14 @@ textarea { width: 100%; resize: vertical; min-height: 60px; } .item-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 6px; background: var(--panel-2); } .item-chip.short { color: var(--bad); } .action .why { font-size: 13px; color: var(--warn); } +.grid-head { grid-column: 1 / -1; margin: 10px 0 -4px; } +.tool-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.gear { display: flex; flex-direction: column; gap: 6px; } +.gear-slot { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; border-radius: 8px; background: var(--panel-2); } +.hp-line { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 6px; } +.bar.hp > span { background: var(--bad); } +.warn { color: var(--warn); } +select { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 5px 8px; margin-left: 6px; } .action .foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: auto; } /* --- bank ------------------------------------------------------------- */ diff --git a/tests/test_game.py b/tests/test_game.py index ac0a973..7403e25 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -98,6 +98,7 @@ class ActionTest(unittest.TestCase): def test_combat_pays_and_loses(self): world = world_with(("wil", "warrior")) p = world["players"]["wil"] + p["bank"] = {"bread": 500} game.start_action(world, p, "fight_wolves", T0) summary = game.process_player(world, p, T0 + 48 * 100, random.Random(5)) self.assertEqual(summary["done"], 100) @@ -329,6 +330,7 @@ class EventTest(unittest.TestCase): world = world_with(("wil", "warrior")) p = world["players"]["wil"] p["xp"]["combat"] = game.XP_TABLE[40] + p["hp"] = game.max_hp(p) raid = self.announce(world) self.assertEqual(game.start_action(world, p, "sabotage", T0), "No bandit camp has been located") raid["recon"] = 3 * game.RECON_PER_INTEL @@ -338,5 +340,106 @@ class EventTest(unittest.TestCase): self.assertLessEqual(raid["sabotage"], 0.3) + +class CombatTest(unittest.TestCase): + def fighter(self, level=10, **equip): + world = world_with(("wil", "warrior")) + p = world["players"]["wil"] + p["xp"]["combat"] = game.XP_TABLE[level] + p["hp"] = game.max_hp(p) + p["equip"].update(equip) + return world, p + + def test_fights_hurt_and_food_heals(self): + world, p = self.fighter() + p["bank"] = {"bread": 50} + game.start_action(world, p, "fight_scouts", T0) + summary = game.process_player(world, p, T0 + 60 * 60, random.Random(4)) + self.assertGreater(summary["damage"], 0) + self.assertGreater(summary["eaten"].get("bread", 0), 0) + self.assertEqual(p["bank"]["bread"], 50 - summary["eaten"]["bread"]) + self.assertGreaterEqual(p["hp"], 1) + + def test_out_of_food_means_retreat_and_nothing_lost(self): + world, p = self.fighter(level=10, weapon="copper_sword") + p["bank"] = {"stone": 10} + game.start_action(world, p, "fight_scouts", T0) + summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1)) + self.assertIsNone(p["action"]) + self.assertTrue(summary["stopped"].startswith("Retreated")) + self.assertEqual(p["equip"]["weapon"], "copper_sword") + self.assertEqual(p["bank"]["stone"], 10) + self.assertLess(p["hp"], game.max_hp(p) * game.TOO_HURT) + self.assertEqual(game.start_action(world, p, "fight_scouts", T0 + 6 * 3600), "Too hurt to fight. Rest, or bring food") + + def test_rest_heals(self): + world, p = self.fighter() + p["hp"] = 1 + game.regen(world, p, T0 + 4 * 3600) + self.assertEqual(p["hp"], game.max_hp(p)) + + def test_armour_and_levels_cut_damage(self): + world, bare = self.fighter(level=10) + _, armoured = self.fighter(level=10, helmet="iron_helmet", body="iron_body", legs="iron_legs") + _, veteran = self.fighter(level=60) + wolves = game.ACTION_BY_ID["fight_scouts"] + self.assertLess(game.fight_damage(armoured, wolves), game.fight_damage(bare, wolves) * 0.7) + self.assertLess(game.fight_damage(veteran, wolves), game.fight_damage(bare, wolves)) + + def test_preferred_food_is_eaten_first(self): + world, p = self.fighter() + p["bank"] = {"bread": 5, "cooked_salmon": 5} + self.assertEqual(game.edible(p), ["bread", "cooked_salmon"]) + p["food"] = "cooked_salmon" + self.assertEqual(game.edible(p), ["cooked_salmon", "bread"]) + + +class NewSkillsTest(unittest.TestCase): + def test_tools_speed_up_gathering(self): + world = world_with(("pia", "warrior")) + p = world["players"]["pia"] + plain = game.action_time(world, p, game.ACTION_BY_ID["mine_stone"]) + p["equip"]["tool_mining"] = "iron_pickaxe" + self.assertAlmostEqual(game.action_time(world, p, game.ACTION_BY_ID["mine_stone"]), plain * 0.7) + self.assertEqual(game.action_time(world, p, game.ACTION_BY_ID["cut_logs"]), plain) + + def test_fish_to_plate(self): + world = world_with(("sam", "scout")) + p = world["players"]["sam"] + game.start_action(world, p, "fish_shrimp", T0) + game.process_player(world, p, T0 + 200, random.Random(1)) + self.assertEqual(p["bank"]["raw_shrimp"], 10) # 20s with the scout bonus + p["bank"]["logs"] = 10 + game.start_action(world, p, "cook_shrimp", T0 + 200) + game.process_player(world, p, T0 + 1000, random.Random(1)) + self.assertEqual(p["bank"]["cooked_shrimp"], 10) + self.assertIn("cooked_shrimp", game.FOOD_ORDER) + + def test_hides_to_armour(self): + world = world_with(("kay", "artisan")) + p = world["players"]["kay"] + p["bank"] = {"hide": 4} + game.start_action(world, p, "tan_leather", T0) + game.process_player(world, p, T0 + 1000, random.Random(1)) + self.assertGreaterEqual(p["bank"]["leather"], 4) + p["xp"]["leatherworking"] = game.XP_TABLE[12] + self.assertIsNone(game.start_action(world, p, "lw_body", T0 + 1000)) + + def test_v2_worlds_gain_hp_and_slots(self): + world = world_with(("fred", "pioneer")) + p = world["players"]["fred"] + world["version"] = 2 + for key in ("hp", "hp_at", "food"): + del p[key] + p["equip"] = {"weapon": "copper_sword", "shield": None} + del p["xp"]["fishing"], p["xp"]["leatherworking"] + game.migrate(world) + self.assertEqual(world["version"], game.WORLD_VERSION) + self.assertEqual(p["hp"], game.max_hp(p)) + self.assertEqual(p["equip"]["weapon"], "copper_sword") + self.assertIsNone(p["equip"]["tool_fishing"]) + self.assertEqual(p["xp"]["fishing"], 0) + + if __name__ == "__main__": unittest.main()