From 0f2b29e67e83142c0dc51024479df23d6a672cae Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 21 Oct 2026 07:52:01 +0100 Subject: [PATCH] Combat on the tier ladder, less combat XP, eating costs a swing Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +++++---- game.py | 61 +++++++++++++++++++++++++++------------------- tests/test_game.py | 25 +++++++++++++++---- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 3ca1cce..c82a6d6 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. or crit. Every blow has a damage type (slashing, blunt, piercing, magic); each enemy is vulnerable to some (+25%) and resists others (-25%), shown as tinted icons. Armour stops physical blows, ward stops magic. Below half - health you eat from your bank; out of food you retreat, losing nothing but - the fight. HP recovers 25% an hour. The Combat page replays the fight - live. Fights are split into the Frontier (bandits, sabotage, drilling, the - city's troubles) and expeditions: Greenwood to start, then one area per + health you eat from your bank, and each bite holds back your next swing; + out of food you retreat, losing nothing but the fight. HP recovers 25% + an hour. The Combat page replays the fight live. Fights are split into + the Frontier (bandits, sabotage, drilling, the city's troubles) and + expeditions: Greenwood to start, then one area per tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, Dragon's Spine), each with four races and a boss (the dragons at the top). Every kill is one of the race's variants (a brute, a skirmisher, a caster, now @@ -69,7 +70,8 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. helmet, greaves, cuirass and five tools; every leather tier a cap, trousers and jerkin; every cloth tier a hood, leggings and robe. Higher bars need coal; each metal past copper needs a Smithy level (rune needs - level 5, so a city). Gear needs a combat level to wear, tools a skill + level 5, so a city). Gear needs the combat level of its tier (15, 30, 45, + 60, 75) to wear, and the areas open at the same levels; tools need a skill level to use. - **Classes are trades,** from gathering to finished goods, so each player has a quiet gathering side and a busier crafting side: miner (mining, smithing, diff --git a/game.py b/game.py index bb24700..4bf3fdf 100644 --- a/game.py +++ b/game.py @@ -80,8 +80,8 @@ ITEMS = { # ladder generates its items and recipes below; the copper and iron ids # predate the ladders and are kept as they were. -TIER_LEVELS = [1, 15, 30, 45, 60, 75] # gathering / smelting level of each tier -EQUIP_LEVELS = [1, 10, 20, 30, 40, 50] # combat level to wear, skill level to use a tool +TIER_LEVELS = [1, 15, 30, 45, 60, 75] # gathering / smelting level of each tier, and the combat level to wear it +TOOL_LEVELS = [1, 10, 20, 30, 40, 50] # skill level to use a tool or station # key, name, ore (item id, name, icon, value), coal per bar, bar icon, bar value, smithy level, handle wood METALS = [ @@ -203,7 +203,7 @@ for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in _item = {"name": f"{_name} {_noun}", "plural": f"{_name} {PLURALS[_noun]}", "icon": COPPER_ICONS.get(_piece, PIECE_ICONS[_piece]) if _key == "copper" else PIECE_ICONS[_piece], "value": round(_bars * _bar_value * 1.2), "slot": _slot, "tier": _tier, - "req": ["combat", EQUIP_LEVELS[_tier]]} + "req": ["combat", TIER_LEVELS[_tier]]} if _power: _item["power"] = _power[_tier] if _armour and _piece == "shield": @@ -214,7 +214,7 @@ for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in for _skill, (_tool, _icon) in TOOLS.items(): ITEMS[f"{_key}_{_tool.replace(' ', '_')}"] = { "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]], + "slot": f"tool_{_skill}", "tool": _skill, "speed": TOOL_SPEED[_tier], "req": [_skill, TOOL_LEVELS[_tier]], } # Jewelcrafting: a gem from each tier's rock, set in a band of copper, silver or gold. # Silver and gold are soft metals the jeweller smelts, no smithy needed. Past the first @@ -273,7 +273,7 @@ for _tier, (_gem, _gem_name, _gem_value, _tint, _glyph) in enumerate(GEMS): "name": f"{_gem_name} {_noun}", "icon": {"ring": "💍", "amulet": "📿", "trinket": "🔮"}[_slot], "value": round((_bands * ITEMS[BANDS[_tier]]["value"] + _gems * _gem_value + _shards * 12 * _tier) * 1.3), "slot": _slot, "tier": _tier, "jewel": _stat, _stat: _bonus[_tier], - **({"req": ["combat", EQUIP_LEVELS[_tier]]} if _slot != "trinket" else {})} + **({"req": ["combat", TIER_LEVELS[_tier]]} if _slot != "trinket" else {})} # Hardware: what the smith makes for builders, one kind per metal, used in # fittings and in building levels in place of plain bars. # id, singular, plural, bars used, pieces made @@ -304,7 +304,7 @@ BOWS = ["Shortbow", "Oak bow", "Willow bow", "Maple bow", "Yew bow", "Enchanted for _tier, _metal in enumerate(METALS): ITEMS[f"{_metal[0]}_sword"]["style"] = "sword" _weapon = {"slot": "weapon", "power": TWO_HANDED_POWER[_tier], "tier": _tier, "two_handed": True, - "req": ["combat", EQUIP_LEVELS[_tier]], "value": round(5 * _metal[5] * 1.2)} + "req": ["combat", TIER_LEVELS[_tier]], "value": round(5 * _metal[5] * 1.2)} ITEMS[f"{_metal[0]}_warhammer"] = {"name": f"{_metal[1]} warhammer", "icon": "🔨", "style": "hammer", **_weapon} ITEMS[f"{WOODS[_tier]}_staff"] = {"name": STAVES[_tier], "plural": STAVES[_tier].replace("staff", "staves"), "icon": "🪄", "style": "staff", **_weapon} @@ -321,7 +321,7 @@ for _tier, _wood in enumerate(WOODS): for _skill, (_suffix, _noun, _glyph, _kind) in STATIONS.items(): ITEMS[f"{_wood}_{_suffix}"] = { "name": f"{WOOD_NAMES[_tier]} {_noun}", "icon": "🪚", "tier": _tier, "slot": f"station_{_skill}", "station": _skill, - "speed": TOOL_SPEED[_tier], "req": [_skill, EQUIP_LEVELS[_tier]], "value": 0} + "speed": TOOL_SPEED[_tier], "req": [_skill, TOOL_LEVELS[_tier]], "value": 0} ITEMS[f"{_wood}_strongbox"] = {"name": f"{WOOD_NAMES[_tier]} strongbox", "plural": f"{WOOD_NAMES[_tier]} strongboxes", "icon": "🧰", "tier": _tier, "slot": "storage", "slots": STRONGBOX_SLOTS[_tier], "value": 0} for _tier, (_fibre, _level, _time, _xp, _cloth, _prefix, _label) in enumerate(CLOTHS): @@ -333,7 +333,7 @@ for _tier, (_raw, _leather, _prefix, _label, _level) in enumerate(LEATHERS): for _suffix, _slot, _noun, _used, _offset, _icon in LEATHER_PIECES: ITEMS[f"{_prefix}_{_suffix}"] = { "name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _leather[2] * 1.3), - "slot": _slot, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]], **_armour_stats("medium", _slot, _tier), + "slot": _slot, "tier": _tier, "req": ["combat", TIER_LEVELS[_tier]], **_armour_stats("medium", _slot, _tier), } if _noun == "trousers": ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"] @@ -343,7 +343,7 @@ for _tier, (_fibre, _level, _time, _xp, _cloth, _prefix, _label) in enumerate(CL for _suffix, _slot, _noun, _used, _offset, _icon in CLOTH_PIECES: ITEMS[f"{_prefix}_{_suffix}"] = { "name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _cloth[2] * 1.3), - "slot": _slot, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]], **_armour_stats("light", _slot, _tier), + "slot": _slot, "tier": _tier, "req": ["combat", TIER_LEVELS[_tier]], **_armour_stats("light", _slot, _tier), } if _noun == "leggings": ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"] @@ -594,6 +594,7 @@ CRIT_MULT = 1.5 RESPAWN = 2 # seconds after a kill before the next enemy turns up MAX_FIGHT = 600 # a fight that drags on longer is broken off FLEE_BELOW = 0.2 # out of food and under this share of HP: get out +EAT_DELAY = 1.5 # seconds each bite holds back your next swing DAMAGE_TYPES = { "slash": {"name": "Slashing", "glyph": "sword-slice", "tint": "#d4dae2"}, @@ -647,7 +648,7 @@ def monster(name: str, archetype: str, level: int, attack, vulnerable=(), resist hp, interval, accuracy, evasion, hit, weight = ARCHETYPES[archetype] hp = round((20 + 6 * level) * hp * (2.5 if boss else 1)) return {"name": name, "archetype": archetype, "level": level, "boss": boss, "weight": weight, - "hp": hp, "interval": interval, "xp": round(hp * 0.4 + level * 1.5), + "hp": hp, "interval": interval, "xp": round(hp * 0.13 + level * 0.5), "accuracy": round((12 + 2.2 * level) * accuracy), "evasion": round((10 + 2 * level) * evasion), "max_hit": max(1, round((2 + level / 3) * hit * (1.2 if boss else 1))), "attack": _types(attack), "vulnerable": _types(vulnerable), "resists": _types(resists)} @@ -687,7 +688,7 @@ GOBLINS = [("Goblin brute", "brute", "blunt", "blunt", "slash"), # Expedition areas, tiers II to VI: name, level, the reagent prefix, and four races. # Each race: (name, trophy, weapon line its trophy goes into, level offset, roster). AREAS = [ - ("Old Watchtower", 10, "Watchtower", [ + ("Old Watchtower", 15, "Watchtower", [ ("Clay golems", "Clay core", "hammer", 0, [ ("Clay brute", "brute", "blunt", "blunt", "pierce"), ("Mud shaper", "caster", "magic", "pierce", "magic"), @@ -705,7 +706,7 @@ AREAS = [ ("Harpy windcaster", "caster", "magic", ("slash", "pierce"), "magic"), ("Harpy matriarch", "elite", "slash", "magic", "pierce")]), ]), - ("Sunken Barrows", 20, "Barrow", [ + ("Sunken Barrows", 30, "Barrow", [ ("Stone guardians", "Stone core", "hammer", 0, [ ("Stone guardian", "brute", "blunt", "blunt", "slash"), ("Barrow sentry", "skirmisher", "pierce", "magic", "pierce"), @@ -723,7 +724,7 @@ AREAS = [ ("Venom weaver", "caster", "magic", "blunt", "magic"), ("Brood mother", "elite", "pierce", "magic", "blunt")]), ]), - ("Iron Hills", 30, "Ironhill", [ + ("Iron Hills", 45, "Ironhill", [ ("Iron golems", "Iron core", "hammer", 0, [ ("Iron golem", "brute", "blunt", "blunt", "slash"), ("Forge golem", "caster", "magic", "pierce", "magic"), @@ -741,7 +742,7 @@ AREAS = [ ("Wyvern hatchling", "skirmisher", "pierce", "blunt", "magic"), ("Venomspitter", "caster", "magic", "slash", "pierce")]), ]), - ("Frostpeak", 40, "Frostpeak", [ + ("Frostpeak", 60, "Frostpeak", [ ("Ice elementals", "Frozen core", "hammer", 0, [ ("Ice brute", "brute", "blunt", "blunt", "pierce"), ("Frost wisp", "skirmisher", "magic", "slash", "magic"), @@ -759,7 +760,7 @@ AREAS = [ ("Griffin rider", "skirmisher", "pierce", "slash", "pierce"), ("Storm griffin", "caster", "magic", "blunt", "magic")]), ]), - ("Dragon's Spine", 50, "Dragonspine", [ + ("Dragon's Spine", 75, "Dragonspine", [ ("Magma constructs", "Magma core", "hammer", 0, [ ("Magma brute", "brute", "blunt", "blunt", "slash"), ("Cinder golem", "skirmisher", "pierce", "magic", "pierce"), @@ -905,23 +906,23 @@ COMBAT_ACTIONS = [ _encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4, drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]), *_area_fights(), - _boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 18, "boss", ("blunt", "magic"), ("slash", "magic"), + _boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 26, "boss", ("blunt", "magic"), ("slash", "magic"), ("blunt", "pierce"), gold=25, drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_drops(1)]), - _boss("fight_barrow_king", "Barrow king", "Sunken Barrows", 28, "boss", ("slash", "magic"), ("blunt", "magic"), + _boss("fight_barrow_king", "Barrow king", "Sunken Barrows", 41, "boss", ("slash", "magic"), ("blunt", "magic"), ("pierce", "slash"), gold=40, drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_drops(2)]), # Dragons: the prize hunt, dropping their area's reagents far more often than anything else there. - _boss("fight_green_dragon", "Green dragon", "Iron Hills", 38, "dragon", ("slash", "magic"), ("pierce", "magic"), + _boss("fight_green_dragon", "Green dragon", "Iron Hills", 56, "dragon", ("slash", "magic"), ("pierce", "magic"), ("slash", "blunt"), is_boss=False, gold=80, drops=[("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(3, 0.6)]), - _boss("fight_blue_dragon", "Blue dragon", "Frostpeak", 48, "dragon", ("slash", "magic"), ("blunt", "pierce"), + _boss("fight_blue_dragon", "Blue dragon", "Frostpeak", 71, "dragon", ("slash", "magic"), ("blunt", "pierce"), ("magic", "slash"), is_boss=False, gold=110, drops=[("adamantite_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(4, 0.6)]), - _boss("fight_red_dragon", "Red dragon", "Dragon's Spine", 60, "dragon", ("slash", "magic"), ("slash", "magic"), + _boss("fight_red_dragon", "Red dragon", "Dragon's Spine", 85, "dragon", ("slash", "magic"), ("slash", "magic"), ("pierce", "blunt"), is_boss=False, gold=150, drops=[("runite_ore", 1, 0.25), ("dragon_bones", 1, 1.0), *_boss_drops(5, 0.6)]), - _boss("fight_black_dragon", "Black dragon", "Dragon's Spine", 75, "dragon", ("slash", "magic"), ("blunt", "magic"), + _boss("fight_black_dragon", "Black dragon", "Dragon's Spine", 95, "dragon", ("slash", "magic"), ("blunt", "magic"), ("slash", "pierce"), gold=220, drops=[("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_drops(5, 0.8)]), ] @@ -1712,13 +1713,20 @@ def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float: return (1 - crit) * (max_hit + 1) / 2 + crit * max_hit * CRIT_MULT -def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict) -> dict: +def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict, bite: int = 0) -> dict: + """`bite` is what the next food heals: eating holds back your swings, so a fight you have + to eat through runs longer.""" me = fighter(player, blessed, brew, foe) hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"]) kill = foe["hp"] / (hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"]) taken = sum(_taken(me, kind) for kind in foe["attack"]) / len(foe["attack"]) - hurt = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * taken / foe["interval"] * kill - hurt = max(0, hurt - blessed["mend"] * max_hp(player) * kill) + per_second = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * taken / foe["interval"] + per_second = max(0, per_second - blessed["mend"] * max_hp(player)) + if bite: + eating = per_second * EAT_DELAY / bite # seconds spent eating per second of fighting + kill = kill / (1 - eating) if eating < 1 else MAX_FIGHT + kill = min(kill, MAX_FIGHT) + hurt = per_second * kill return {"hit": hit, "their_hit": their_hit, "max_hit": me["max_hit"], "affinity": me["affinity"], "their_max": max(1, round(foe["max_hit"] * max(_taken(me, kind) for kind in foe["attack"]))), "kill_time": kill, "damage": hurt} @@ -1728,7 +1736,9 @@ def fight_odds(player: dict, action: dict) -> dict: """What a fight in this encounter looks like on average, for the cards, and against each variant.""" blessed, brew = blessing(player), potion_effect(player, "combat") variants = action["enemy"]["variants"] - rows = [_variant_odds(player, foe, blessed, brew) for foe in variants] + pantry = edible(player) + bite = ITEMS[pantry[0]]["heal"] if pantry else 0 + rows = [_variant_odds(player, foe, blessed, brew, bite) for foe in variants] total = sum(foe["weight"] for foe in variants) mean = lambda key: sum(row[key] * foe["weight"] for row, foe in zip(rows, variants)) / total return {"hit": round(mean("hit"), 2), "their_hit": round(mean("their_hit"), 2), "style": weapon_style(player), @@ -1790,6 +1800,7 @@ def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) pantry[food] -= 1 eaten[food] = eaten.get(food, 0) + 1 hp = min(top, hp + ITEMS[food]["heal"]) + mine += EAT_DELAY events.append([round(t / pace, 2), "you", "eat", ITEMS[food]["heal"], round(hp), foe_hp, food]) beaten, starving = hp <= 0, hp < top * FLEE_BELOW and not any(pantry.values()) if (beaten or starving) and wind: diff --git a/tests/test_game.py b/tests/test_game.py index 94dc2c2..ce10667 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -516,6 +516,20 @@ class CombatTest(unittest.TestCase): 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_eating_holds_back_your_swings(self): + world, p = self.fighter(level=15) + p["bank"] = {"cooked_shrimp": 100} + p["hp"] = 1 + fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(3)) + mine = [event for event in fight["events"] if event[1] == "you"] + bite = next(i for i, event in enumerate(mine) if event[2] == "eat") + swing = next(event for event in mine[bite:] if event[2] != "eat") + self.assertGreaterEqual(swing[0] - mine[bite][0], game.EAT_DELAY) + scouts = game.ACTION_BY_ID["fight_scouts"] + eating = game.fight_odds(p, scouts)["kill_time"] + p["bank"] = {} + self.assertGreater(eating, game.fight_odds(p, scouts)["kill_time"]) + def test_rest_heals(self): world, p = self.fighter() p["hp"] = 1 @@ -753,10 +767,10 @@ class TierTest(unittest.TestCase): def test_gear_needs_levels_to_wear(self): world = world_with(("wil", "commoner")) p = world["players"]["wil"] - self.assertEqual(game.equip_problem(p, "rune_sword"), "Needs combat level 50") + self.assertEqual(game.equip_problem(p, "rune_sword"), "Needs combat level 75") self.assertEqual(game.equip_problem(p, "steel_pickaxe"), "Needs mining level 20") self.assertIsNone(game.equip_problem(p, "copper_sword")) - p["xp"]["combat"] = game.XP_TABLE[50] + p["xp"]["combat"] = game.XP_TABLE[75] self.assertIsNone(game.equip_problem(p, "rune_sword")) def test_militia_takes_the_best_weapons_first(self): @@ -946,9 +960,10 @@ class HerbloreTest(unittest.TestCase): game.use_potion(p, "defence_potion_6") game.start_action(world, p, "fight_scouts", T0) scouts = game.ACTION_BY_ID["fight_scouts"] - brewed = game.fight_odds(p, scouts)["damage"] + per_second = lambda odds: odds["damage"] / odds["kill_time"] # less eating also shortens the fight + brewed = per_second(game.fight_odds(p, scouts)) active = p["potions"].pop("combat") - self.assertAlmostEqual(brewed, game.fight_odds(p, scouts)["damage"] * 0.70, delta=0.2) + self.assertAlmostEqual(brewed, per_second(game.fight_odds(p, scouts)) * 0.70, delta=0.02) p["potions"]["combat"] = active summary = game.process_player(world, p, T0 + 75 * 10, random.Random(1)) self.assertEqual(p["potions"]["combat"]["charges"], game.POTION_CHARGES - summary["done"]) @@ -1364,7 +1379,7 @@ class TradeSkillsTest(unittest.TestCase): class WeaponStyleTest(unittest.TestCase): - def armed(self, cls, weapon, shield=None, level=12): + def armed(self, cls, weapon, shield=None, level=15): world = world_with(("pat", cls)) p = world["players"]["pat"] p["xp"]["combat"] = game.XP_TABLE[level]