Combat on the tier ladder, less combat XP, eating costs a swing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-21 07:52:01 +01:00
parent 2816ca8606
commit 0f2b29e67e
3 changed files with 63 additions and 35 deletions

View file

@ -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); or crit. Every blow has a damage type (slashing, blunt, piercing, magic);
each enemy is vulnerable to some (+25%) and resists others (-25%), shown each enemy is vulnerable to some (+25%) and resists others (-25%), shown
as tinted icons. Armour stops physical blows, ward stops magic. Below half 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 health you eat from your bank, and each bite holds back your next swing;
the fight. HP recovers 25% an hour. The Combat page replays the fight out of food you retreat, losing nothing but the fight. HP recovers 25%
live. Fights are split into the Frontier (bandits, sabotage, drilling, the an hour. The Combat page replays the fight live. Fights are split into
city's troubles) and expeditions: Greenwood to start, then one area per 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 tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, Dragon's
Spine), each with four races and a boss (the dragons at the top). Every 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 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, helmet, greaves, cuirass and five tools; every leather tier a cap,
trousers and jerkin; every cloth tier a hood, leggings and robe. Higher 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 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. level to use.
- **Classes are trades,** from gathering to finished goods, so each player has - **Classes are trades,** from gathering to finished goods, so each player has
a quiet gathering side and a busier crafting side: miner (mining, smithing, a quiet gathering side and a busier crafting side: miner (mining, smithing,

61
game.py
View file

@ -80,8 +80,8 @@ ITEMS = {
# ladder generates its items and recipes below; the copper and iron ids # ladder generates its items and recipes below; the copper and iron ids
# predate the ladders and are kept as they were. # predate the ladders and are kept as they were.
TIER_LEVELS = [1, 15, 30, 45, 60, 75] # gathering / smelting level of each tier TIER_LEVELS = [1, 15, 30, 45, 60, 75] # gathering / smelting level of each tier, and the combat level to wear it
EQUIP_LEVELS = [1, 10, 20, 30, 40, 50] # combat level to wear, skill level to use a tool 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 # key, name, ore (item id, name, icon, value), coal per bar, bar icon, bar value, smithy level, handle wood
METALS = [ 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]}", _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], "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, "value": round(_bars * _bar_value * 1.2), "slot": _slot, "tier": _tier,
"req": ["combat", EQUIP_LEVELS[_tier]]} "req": ["combat", TIER_LEVELS[_tier]]}
if _power: if _power:
_item["power"] = _power[_tier] _item["power"] = _power[_tier]
if _armour and _piece == "shield": 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(): for _skill, (_tool, _icon) in TOOLS.items():
ITEMS[f"{_key}_{_tool.replace(' ', '_')}"] = { ITEMS[f"{_key}_{_tool.replace(' ', '_')}"] = {
"name": f"{_name} {_tool}", "icon": _icon, "value": round(2 * _bar_value * 1.4), "tier": _tier, "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. # 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 # 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], "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), "value": round((_bands * ITEMS[BANDS[_tier]]["value"] + _gems * _gem_value + _shards * 12 * _tier) * 1.3),
"slot": _slot, "tier": _tier, "jewel": _stat, _stat: _bonus[_tier], "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 # Hardware: what the smith makes for builders, one kind per metal, used in
# fittings and in building levels in place of plain bars. # fittings and in building levels in place of plain bars.
# id, singular, plural, bars used, pieces made # 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): for _tier, _metal in enumerate(METALS):
ITEMS[f"{_metal[0]}_sword"]["style"] = "sword" ITEMS[f"{_metal[0]}_sword"]["style"] = "sword"
_weapon = {"slot": "weapon", "power": TWO_HANDED_POWER[_tier], "tier": _tier, "two_handed": True, _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"{_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"), ITEMS[f"{WOODS[_tier]}_staff"] = {"name": STAVES[_tier], "plural": STAVES[_tier].replace("staff", "staves"),
"icon": "🪄", "style": "staff", **_weapon} "icon": "🪄", "style": "staff", **_weapon}
@ -321,7 +321,7 @@ for _tier, _wood in enumerate(WOODS):
for _skill, (_suffix, _noun, _glyph, _kind) in STATIONS.items(): for _skill, (_suffix, _noun, _glyph, _kind) in STATIONS.items():
ITEMS[f"{_wood}_{_suffix}"] = { ITEMS[f"{_wood}_{_suffix}"] = {
"name": f"{WOOD_NAMES[_tier]} {_noun}", "icon": "🪚", "tier": _tier, "slot": f"station_{_skill}", "station": _skill, "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", 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} "icon": "🧰", "tier": _tier, "slot": "storage", "slots": STRONGBOX_SLOTS[_tier], "value": 0}
for _tier, (_fibre, _level, _time, _xp, _cloth, _prefix, _label) in enumerate(CLOTHS): 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: for _suffix, _slot, _noun, _used, _offset, _icon in LEATHER_PIECES:
ITEMS[f"{_prefix}_{_suffix}"] = { ITEMS[f"{_prefix}_{_suffix}"] = {
"name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _leather[2] * 1.3), "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": if _noun == "trousers":
ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"] 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: for _suffix, _slot, _noun, _used, _offset, _icon in CLOTH_PIECES:
ITEMS[f"{_prefix}_{_suffix}"] = { ITEMS[f"{_prefix}_{_suffix}"] = {
"name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _cloth[2] * 1.3), "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": if _noun == "leggings":
ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"] 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 RESPAWN = 2 # seconds after a kill before the next enemy turns up
MAX_FIGHT = 600 # a fight that drags on longer is broken off 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 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 = { DAMAGE_TYPES = {
"slash": {"name": "Slashing", "glyph": "sword-slice", "tint": "#d4dae2"}, "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, interval, accuracy, evasion, hit, weight = ARCHETYPES[archetype]
hp = round((20 + 6 * level) * hp * (2.5 if boss else 1)) hp = round((20 + 6 * level) * hp * (2.5 if boss else 1))
return {"name": name, "archetype": archetype, "level": level, "boss": boss, "weight": weight, 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), "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))), "max_hit": max(1, round((2 + level / 3) * hit * (1.2 if boss else 1))),
"attack": _types(attack), "vulnerable": _types(vulnerable), "resists": _types(resists)} "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. # 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). # Each race: (name, trophy, weapon line its trophy goes into, level offset, roster).
AREAS = [ AREAS = [
("Old Watchtower", 10, "Watchtower", [ ("Old Watchtower", 15, "Watchtower", [
("Clay golems", "Clay core", "hammer", 0, [ ("Clay golems", "Clay core", "hammer", 0, [
("Clay brute", "brute", "blunt", "blunt", "pierce"), ("Clay brute", "brute", "blunt", "blunt", "pierce"),
("Mud shaper", "caster", "magic", "pierce", "magic"), ("Mud shaper", "caster", "magic", "pierce", "magic"),
@ -705,7 +706,7 @@ AREAS = [
("Harpy windcaster", "caster", "magic", ("slash", "pierce"), "magic"), ("Harpy windcaster", "caster", "magic", ("slash", "pierce"), "magic"),
("Harpy matriarch", "elite", "slash", "magic", "pierce")]), ("Harpy matriarch", "elite", "slash", "magic", "pierce")]),
]), ]),
("Sunken Barrows", 20, "Barrow", [ ("Sunken Barrows", 30, "Barrow", [
("Stone guardians", "Stone core", "hammer", 0, [ ("Stone guardians", "Stone core", "hammer", 0, [
("Stone guardian", "brute", "blunt", "blunt", "slash"), ("Stone guardian", "brute", "blunt", "blunt", "slash"),
("Barrow sentry", "skirmisher", "pierce", "magic", "pierce"), ("Barrow sentry", "skirmisher", "pierce", "magic", "pierce"),
@ -723,7 +724,7 @@ AREAS = [
("Venom weaver", "caster", "magic", "blunt", "magic"), ("Venom weaver", "caster", "magic", "blunt", "magic"),
("Brood mother", "elite", "pierce", "magic", "blunt")]), ("Brood mother", "elite", "pierce", "magic", "blunt")]),
]), ]),
("Iron Hills", 30, "Ironhill", [ ("Iron Hills", 45, "Ironhill", [
("Iron golems", "Iron core", "hammer", 0, [ ("Iron golems", "Iron core", "hammer", 0, [
("Iron golem", "brute", "blunt", "blunt", "slash"), ("Iron golem", "brute", "blunt", "blunt", "slash"),
("Forge golem", "caster", "magic", "pierce", "magic"), ("Forge golem", "caster", "magic", "pierce", "magic"),
@ -741,7 +742,7 @@ AREAS = [
("Wyvern hatchling", "skirmisher", "pierce", "blunt", "magic"), ("Wyvern hatchling", "skirmisher", "pierce", "blunt", "magic"),
("Venomspitter", "caster", "magic", "slash", "pierce")]), ("Venomspitter", "caster", "magic", "slash", "pierce")]),
]), ]),
("Frostpeak", 40, "Frostpeak", [ ("Frostpeak", 60, "Frostpeak", [
("Ice elementals", "Frozen core", "hammer", 0, [ ("Ice elementals", "Frozen core", "hammer", 0, [
("Ice brute", "brute", "blunt", "blunt", "pierce"), ("Ice brute", "brute", "blunt", "blunt", "pierce"),
("Frost wisp", "skirmisher", "magic", "slash", "magic"), ("Frost wisp", "skirmisher", "magic", "slash", "magic"),
@ -759,7 +760,7 @@ AREAS = [
("Griffin rider", "skirmisher", "pierce", "slash", "pierce"), ("Griffin rider", "skirmisher", "pierce", "slash", "pierce"),
("Storm griffin", "caster", "magic", "blunt", "magic")]), ("Storm griffin", "caster", "magic", "blunt", "magic")]),
]), ]),
("Dragon's Spine", 50, "Dragonspine", [ ("Dragon's Spine", 75, "Dragonspine", [
("Magma constructs", "Magma core", "hammer", 0, [ ("Magma constructs", "Magma core", "hammer", 0, [
("Magma brute", "brute", "blunt", "blunt", "slash"), ("Magma brute", "brute", "blunt", "blunt", "slash"),
("Cinder golem", "skirmisher", "pierce", "magic", "pierce"), ("Cinder golem", "skirmisher", "pierce", "magic", "pierce"),
@ -905,23 +906,23 @@ COMBAT_ACTIONS = [
_encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4, _encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4,
drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]), drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]),
*_area_fights(), *_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, ("blunt", "pierce"), gold=25,
drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_drops(1)]), 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, ("pierce", "slash"), gold=40,
drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_drops(2)]), 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. # 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, ("slash", "blunt"), is_boss=False, gold=80,
drops=[("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(3, 0.6)]), 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, ("magic", "slash"), is_boss=False, gold=110,
drops=[("adamantite_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_drops(4, 0.6)]), 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, ("pierce", "blunt"), is_boss=False, gold=150,
drops=[("runite_ore", 1, 0.25), ("dragon_bones", 1, 1.0), *_boss_drops(5, 0.6)]), 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, ("slash", "pierce"), gold=220,
drops=[("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_drops(5, 0.8)]), 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 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) me = fighter(player, blessed, brew, foe)
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"]) 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"]) 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"]) 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 per_second = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * taken / foe["interval"]
hurt = max(0, hurt - blessed["mend"] * max_hp(player) * kill) 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"], 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"]))), "their_max": max(1, round(foe["max_hit"] * max(_taken(me, kind) for kind in foe["attack"]))),
"kill_time": kill, "damage": hurt} "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.""" """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") blessed, brew = blessing(player), potion_effect(player, "combat")
variants = action["enemy"]["variants"] 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) total = sum(foe["weight"] for foe in variants)
mean = lambda key: sum(row[key] * foe["weight"] for row, foe in zip(rows, variants)) / total 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), 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 pantry[food] -= 1
eaten[food] = eaten.get(food, 0) + 1 eaten[food] = eaten.get(food, 0) + 1
hp = min(top, hp + ITEMS[food]["heal"]) 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]) 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()) beaten, starving = hp <= 0, hp < top * FLEE_BELOW and not any(pantry.values())
if (beaten or starving) and wind: if (beaten or starving) and wind:

View file

@ -516,6 +516,20 @@ class CombatTest(unittest.TestCase):
self.assertLess(p["hp"], game.max_hp(p) * game.TOO_HURT) 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") 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): def test_rest_heals(self):
world, p = self.fighter() world, p = self.fighter()
p["hp"] = 1 p["hp"] = 1
@ -753,10 +767,10 @@ class TierTest(unittest.TestCase):
def test_gear_needs_levels_to_wear(self): def test_gear_needs_levels_to_wear(self):
world = world_with(("wil", "commoner")) world = world_with(("wil", "commoner"))
p = world["players"]["wil"] 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.assertEqual(game.equip_problem(p, "steel_pickaxe"), "Needs mining level 20")
self.assertIsNone(game.equip_problem(p, "copper_sword")) 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")) self.assertIsNone(game.equip_problem(p, "rune_sword"))
def test_militia_takes_the_best_weapons_first(self): 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.use_potion(p, "defence_potion_6")
game.start_action(world, p, "fight_scouts", T0) game.start_action(world, p, "fight_scouts", T0)
scouts = game.ACTION_BY_ID["fight_scouts"] 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") 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 p["potions"]["combat"] = active
summary = game.process_player(world, p, T0 + 75 * 10, random.Random(1)) summary = game.process_player(world, p, T0 + 75 * 10, random.Random(1))
self.assertEqual(p["potions"]["combat"]["charges"], game.POTION_CHARGES - summary["done"]) self.assertEqual(p["potions"]["combat"]["charges"], game.POTION_CHARGES - summary["done"])
@ -1364,7 +1379,7 @@ class TradeSkillsTest(unittest.TestCase):
class WeaponStyleTest(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)) world = world_with(("pat", cls))
p = world["players"]["pat"] p = world["players"]["pat"]
p["xp"]["combat"] = game.XP_TABLE[level] p["xp"]["combat"] = game.XP_TABLE[level]