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

61
game.py
View file

@ -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: