From 19ccab4ac3615eb6fa24e942150e3ecb9caf5a6f Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 19 Oct 2026 23:32:07 +0100 Subject: [PATCH] Combat engine: swing-by-swing fights, Frontier and expeditions, trophies Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +- app.py | 13 +- game.py | 334 +++++++++++++++++++++++++++++++++------------ static/app.js | 79 +++++++++-- static/icons.svg | 16 ++- static/styles.css | 15 ++ tests/test_game.py | 40 ++++-- 7 files changed, 395 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index d4f363d..e51fea7 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,16 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. construction (with masonry), cooking, leatherworking, herblore, combat, prayer and reconnaissance. Smiths also make hardware for builders, one kind per metal (copper rivets to rune locks), which fittings and buildings need. -- **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. +- **Combat:** every fight is played out swing by swing: you and the enemy + each have HP, a swing timer, accuracy and a max hit, and swings hit, miss + or crit (power from level and gear aims and hits; armour dodges and soaks). + 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 tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, + Dragon's Spine), each with a golem, a troll, a spirit and a flyer that drop + trophies for stronger weapons, and a boss (the dragons at the top). - **Tiers:** six RuneScape-style tiers (copper, iron, steel, mithril, adamant, rune at levels 1–75) of ore, trees (up to enchanted) and hides (deer, boar and bear from hunting; green, blue and red dragonhide from fighting diff --git a/app.py b/app.py index f3540ae..0adebe8 100644 --- a/app.py +++ b/app.py @@ -100,9 +100,7 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["reduction"] = round(game.damage_reduction(player), 3) view["edible"] = game.edible(player) view["blessing"] = game.blessing(player) - blessed = game.with_potion(view["blessing"], game.potion_effect(player, "combat")) - view["fight_damage"] = {a["id"]: round(game.fight_damage(player, a, blessed), 1) - for a in game.ACTIONS if "difficulty" in a} + view["fight_odds"] = {a["id"]: game.fight_odds(player, a) for a in game.ACTIONS if "enemy" in a} view["pp"] = int(player["pp"]) view["labour_per_action"] = game.labour_per_action(player) view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS} @@ -112,9 +110,12 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["power"] = game.combat_power(player) view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]} if player["action"]: - action = game.growing(player) - view["action"] = {**player["action"], "growing": action["id"], "duration": game.action_time(world, player, action)} - view["times"] = {action["id"]: game.action_time(world, player, action) for action in game.ACTIONS} + action, fight = game.growing(player), player["action"].get("fight") + duration = fight["duration"] if fight else game.action_time(world, player, action) + view["action"] = {**player["action"], "growing": action["id"], "duration": duration} + # A fight takes as long as it takes: show the average time to a kill. + view["times"] = {action["id"]: (view["fight_odds"][action["id"]]["kill_time"] + game.RESPAWN) / game.speed(world) + if "enemy" in action else game.action_time(world, player, action) for action in game.ACTIONS} view["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS} view["drill_per_action"] = game.drill_per_action(player) view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS diff --git a/game.py b/game.py index 0080359..aa92c6c 100644 --- a/game.py +++ b/game.py @@ -397,7 +397,118 @@ def _tiered_actions() -> list[dict]: return actions +# --- combat ----------------------------------------------------------------- +# A fight is played out swing by swing: you and the enemy each have HP, a swing +# timer, accuracy and a max hit, and every swing hits, misses or crits. Combat +# is split into the Frontier (bandits and the city's troubles) and expeditions: +# one area per tier, each with a golem, a troll, a spirit and a flyer that drop +# the trophies higher weapons need, and a boss. +CRIT_CHANCE = 0.05 +CRIT_MULT = 1.5 +RESPAWN = 3 # seconds before the next enemy turns up +PLAYER_INTERVAL = 2.4 # seconds between your swings +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 + +# kind: HP, seconds between swings, accuracy, evasion, max hit (multipliers), damage it shrugs off +MONSTER_KINDS = { + "human": (1.0, 2.6, 1.0, 1.0, 1.0, 0.0), + "beast": (0.9, 2.2, 1.0, 1.0, 0.9, 0.0), + "golem": (1.5, 3.2, 1.0, 0.7, 1.25, 0.2), + "troll": (1.4, 3.0, 0.9, 1.0, 1.4, 0.0), + "spirit": (0.8, 2.4, 1.0, 1.35, 0.9, 0.0), + "flyer": (0.8, 2.0, 1.0, 1.2, 0.8, 0.0), + "dragon": (2.5, 3.0, 1.1, 1.0, 1.5, 0.15), +} + + +def monster(name: str, kind: str, level: int, boss: bool = False) -> dict: + hp, interval, accuracy, evasion, hit, shrug = MONSTER_KINDS[kind] + return {"name": name, "kind": kind, "level": level, "boss": boss, + "hp": round((20 + 6 * level) * hp * (2.5 if boss else 1)), "interval": interval, + "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))), "reduction": shrug} + + +def _fight(id: str, enemy: dict, group: str, gold: int = 0, drops=(), name: str | None = None, **extra) -> dict: + """A combat action: XP per kill, needs the enemy's level in combat.""" + xp = round(enemy["hp"] * 0.4 + enemy["level"] * 1.5) + return _action(id, "combat", name or enemy["name"], enemy["level"], RESPAWN, xp, gold=gold, + drops=[list(drop) for drop in drops], enemy=enemy, group=group, **extra) + + +# Expedition areas, tiers II to VI: name, level, and the trophy each kind of monster drops there. +AREAS = ["Old Watchtower", "Sunken Barrows", "Iron Hills", "Frostpeak", "Dragon's Spine"] +AREA_LEVELS = [10, 20, 30, 40, 50] +AREA_MONSTERS = { # kind: (level offset, monsters, trophies) + "golem": (0, ["Clay golem", "Stone golem", "Iron golem", "Obsidian golem", "Runic golem"], + ["Clay core", "Stone core", "Iron core", "Obsidian core", "Runic core"]), + "troll": (2, ["Cave troll", "Hill troll", "Rock troll", "Frost troll", "Elder troll"], + ["Cave troll hide", "Hill troll hide", "Rock troll hide", "Frost troll hide", "Elder troll hide"]), + "spirit": (1, ["Will-o'-wisp", "Bog wraith", "Mine ghost", "Banshee", "Shade"], + ["Wisp essence", "Wraith essence", "Ghost essence", "Banshee essence", "Shade essence"]), + "flyer": (0, ["Giant bat", "Harpy", "Wyvern", "Griffin", "Roc"], + ["Bat sinew", "Harpy sinew", "Wyvern sinew", "Griffin sinew", "Roc sinew"]), +} +TROPHY_ODDS = 0.33 +AREA_BONES = ["bones", "big_bones", "big_bones", "dragon_bones", "dragon_bones"] + + +def _slug(name: str) -> str: + return "".join(c if c.isalnum() else "_" for c in name.lower().replace("'", "")).strip("_") + + +TROPHIES = {} # id: (name, kind, tier) +for _kind, (_offset, _monsters, _trophies) in AREA_MONSTERS.items(): + for _tier, _trophy in enumerate(_trophies, start=1): + TROPHIES[_slug(_trophy)] = (_trophy, _kind, _tier) + ITEMS[_slug(_trophy)] = {"name": _trophy, "icon": "🏆", "value": 15 * _tier, "trophy": _kind, "tier": _tier} + + +def _area_fights() -> list[dict]: + fights = [] + for index, (area, level) in enumerate(zip(AREAS, AREA_LEVELS)): + for kind, (offset, monsters, trophies) in AREA_MONSTERS.items(): + enemy = monster(monsters[index], kind, level + offset) + fights.append(_fight(f"fight_{_slug(monsters[index])}", enemy, area, gold=level + offset, + drops=[(AREA_BONES[index], 1, 1.0), (_slug(trophies[index]), 1, TROPHY_ODDS)])) + return fights + + +def _boss_trophies(tier: int) -> list[tuple]: + return [(_slug(trophies[tier - 1]), 1, 0.25) for _offset, _monsters, trophies in AREA_MONSTERS.values()] + + +COMBAT_ACTIONS = [ + # The Frontier: the city's own troubles. + _fight("fight_scouts", monster("Bandit scouts", "human", 10), "Frontier", gold=10, + drops=[("copper_bar", 1, 0.3), ("arrows", 5, 0.4), ("bones", 1, 1.0)]), + _fight("sabotage", monster("Camp guards", "human", 12), "Frontier", name="Sabotage the bandit camp", + special="sabotage"), + _fight("fight_stronghold", monster("Bandit stronghold", "human", 40, boss=True), "Frontier", gold=60, + drops=[("steel_bar", 1, 0.4), ("coal", 3, 0.5), ("iron_shield", 1, 0.04), ("big_bones", 1, 1.0)]), + # Greenwood: where everyone starts. + _fight("fight_wolves", monster("Wolves", "beast", 1), "Greenwood", gold=2, name="Wolves in the woods", + drops=[("raw_meat", 1, 0.6), ("hide", 1, 0.5), ("bones", 1, 1.0)]), + _fight("fight_goblins", monster("Cave goblins", "human", 5), "Greenwood", gold=4, + drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]), + *_area_fights(), + _fight("fight_ruins", monster("Watchtower ogre", "troll", 18, boss=True), "Old Watchtower", gold=25, + drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_trophies(1)]), + _fight("fight_barrow_king", monster("Barrow king", "spirit", 28, boss=True), "Sunken Barrows", gold=40, + drops=[("steel_bar", 2, 0.5), ("coal", 4, 0.5), ("big_bones", 2, 1.0), *_boss_trophies(2)]), + _fight("fight_green_dragon", monster("Green dragon", "dragon", 38), "Iron Hills", gold=80, + drops=[("green_dragonhide", 1, 0.9), ("mithril_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_trophies(3)]), + _fight("fight_blue_dragon", monster("Blue dragon", "dragon", 48), "Frostpeak", gold=110, + drops=[("blue_dragonhide", 1, 0.9), ("adamantite_ore", 2, 0.3), ("dragon_bones", 1, 1.0), *_boss_trophies(4)]), + _fight("fight_red_dragon", monster("Red dragon", "dragon", 60), "Dragon's Spine", gold=150, + drops=[("red_dragonhide", 1, 0.9), ("runite_ore", 1, 0.25), ("dragon_bones", 1, 1.0), *_boss_trophies(5)]), + _fight("fight_black_dragon", monster("Black dragon", "dragon", 75, boss=True), "Dragon's Spine", gold=220, + drops=[("red_dragonhide", 2, 0.9), ("rune_bar", 1, 0.1), ("dragon_bones", 2, 1.0), *_boss_trophies(5)]), +] + ACTIONS = [ + *COMBAT_ACTIONS, _action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}), _action("farm_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}, group="Crops"), _action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}, group="Crops"), @@ -435,25 +546,7 @@ ACTIONS = [ _action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}, group="Rations"), _action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}, group="Rations"), _action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}, group="Rations"), - _action("drill_militia", "combat", "Drill the militia", 1, 60, 12, special="drill"), - _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], ["bones", 1, 1.0]]), - _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], ["bones", 1, 1.0]]), - _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], ["big_bones", 1, 0.8]]), - _action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, damage=25, gold=60, - drops=[["steel_bar", 1, 0.4], ["coal", 3, 0.5], ["iron_shield", 1, 0.04], ["big_bones", 1, 1.0]]), - _action("fight_green_dragon", "combat", "Green dragon", 50, 120, 135, difficulty=90, damage=30, gold=80, - drops=[["green_dragonhide", 1, 0.9], ["mithril_ore", 2, 0.3], ["dragon_bones", 1, 1.0]]), - _action("fight_blue_dragon", "combat", "Blue dragon", 62, 130, 175, difficulty=115, damage=40, gold=110, - drops=[["blue_dragonhide", 1, 0.9], ["adamantite_ore", 2, 0.3], ["dragon_bones", 1, 1.0]]), - _action("fight_red_dragon", "combat", "Red dragon", 75, 140, 220, difficulty=140, damage=52, gold=150, - drops=[["red_dragonhide", 1, 0.9], ["runite_ore", 1, 0.25], ["dragon_bones", 1, 1.0]]), - _action("fight_black_dragon", "combat", "Black dragon", 85, 150, 280, difficulty=165, damage=65, gold=220, - drops=[["red_dragonhide", 2, 0.9], ["rune_bar", 1, 0.1], ["dragon_bones", 2, 1.0]]), - _action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10, - special="sabotage"), + _action("drill_militia", "combat", "Drill the militia", 1, 60, 12, special="drill", group="Frontier"), _action("bury_bones", "prayer", "Bury bones", 1, 10, 4, {"bones": 1}, special="bury", pp=2, group="Bury"), _action("bury_big_bones", "prayer", "Bury big bones", 15, 10, 15, {"big_bones": 1}, special="bury", pp=6, group="Bury"), _action("bury_dragon_bones", "prayer", "Bury dragon bones", 40, 12, 72, {"dragon_bones": 1}, special="bury", pp=20, group="Bury"), @@ -980,12 +1073,87 @@ def regen(world: dict, player: dict, now: float, boost: float = 1) -> None: player["hp_at"] = now -def fight_damage(player: dict, action: dict, blessing: dict | None = None) -> float: - """Average damage one fight deals: less when out-levelling the enemy, armoured or praying.""" - blessing = blessing or NO_BLESSING - gap = combat_power(player) + blessing["power"] - action["difficulty"] - scale = min(1.5, max(0.3, 1 - gap * 0.03)) - return action["damage"] * scale * (1 - damage_reduction(player)) * blessing["taken"] +def fighter(player: dict, blessed: dict, brew: dict) -> dict: + """Your side of a fight: power from level and gear (plus prayers and potions) aims and hits; armour dodges and soaks.""" + power = combat_power(player) + blessed["power"] + brew.get("power", 0) + return {"interval": PLAYER_INTERVAL, "accuracy": 10 + 2 * power, "max_hit": max(2, round(2 + power / 3)), + "evasion": 10 + 2 * (skill_level(player, "combat") + armour(player) // 2), + "taken": (1 - damage_reduction(player)) * blessed["taken"] * (1 - brew.get("reduce", 0))} + + +def hit_chance(accuracy: float, evasion: float) -> float: + return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15)) + + +def _average_hit(max_hit: float) -> float: + return (1 - CRIT_CHANCE) * (max_hit + 1) / 2 + CRIT_CHANCE * max_hit * CRIT_MULT + + +def fight_odds(player: dict, action: dict) -> dict: + """What a fight against this enemy looks like on average, for the cards.""" + me, foe = fighter(player, blessing(player), potion_effect(player, "combat")), action["enemy"] + hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"]) + dps = hit * _average_hit(me["max_hit"]) * (1 - foe["reduction"]) / me["interval"] + kill = foe["hp"] / dps + hurt = their_hit * _average_hit(foe["max_hit"]) * me["taken"] / foe["interval"] * kill + return {"hit": round(hit, 2), "their_hit": round(their_hit, 2), "max_hit": me["max_hit"], + "their_max": max(1, round(foe["max_hit"] * me["taken"])), "kill_time": round(kill, 1), "damage": round(hurt, 1)} + + +def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict: + """Play out one fight swing by swing. Nothing changes until it's over: the result is applied then.""" + foe, blessed, brew = action["enemy"], blessing(player), potion_effect(player, "combat") + me = fighter(player, blessed, brew) + hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"]) + top, hp, foe_hp = max_hp(player), player["hp"], foe["hp"] + pantry = {item: player["bank"][item] for item in edible(player)} + eaten, events, hurt = {}, [], 0 + wind = blessed["wind"] and not (player["action"] or {}).get("wind_used") + pace = speed(world) + mine, theirs, result, t = me["interval"], foe["interval"], None, 0.0 + while result is None: + t = min(mine, theirs) + if t > MAX_FIGHT: + result = "fled" + break + crit = rng.random() < CRIT_CHANCE + if mine <= theirs: + mine += me["interval"] + landed = rng.random() < hit + dealt = max(1, round((me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"])) + * (1 - foe["reduction"]))) if landed else 0 + foe_hp = max(0, foe_hp - dealt) + events.append([round(t / pace, 2), "you", ("crit" if crit else "hit") if landed else "miss", dealt, round(hp), foe_hp]) + if not foe_hp: + result = "won" + continue + theirs += foe["interval"] + landed = rng.random() < their_hit + dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * me["taken"])) if landed else 0 + hp -= dealt + hurt += dealt + events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt, max(0, round(hp)), foe_hp]) + while hp < top * EAT_BELOW and any(pantry.values()): + food = next(item for item, left in pantry.items() if left) + pantry[food] -= 1 + eaten[food] = eaten.get(food, 0) + 1 + hp = min(top, hp + ITEMS[food]["heal"]) + 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: + # Second Wind: the retreat that was coming turns into half health, once per outing. + wind = False + hp = top / 2 + events.append([round(t / pace, 2), "you", "wind", 0, round(hp), foe_hp]) + elif beaten: + hp, result = 1, "lost" + elif starving: + result = "fled" + return {"enemy": foe["name"], "enemy_hp": foe["hp"], "top": top, "start_hp": round(player["hp"]), + "hp": hp, "result": result, "end": round(t / pace, 2), "duration": (t + RESPAWN) / pace, + "interval": me["interval"] / pace, "enemy_interval": foe["interval"] / pace, "events": events, + "eaten": eaten, "damage": hurt, "pp": blessed["cost"], + "wind_used": blessed["wind"] and not wind and not (player["action"] or {}).get("wind_used")} # --- prayer ------------------------------------------------------------------- @@ -1067,17 +1235,6 @@ def edible(player: dict) -> list[str]: 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"]: @@ -1140,12 +1297,6 @@ def plant_second_plot(world: dict, player: dict, action_id: str | None) -> str | return None -def with_potion(blessed: dict, brew: dict) -> dict: - """A fight's prayer boosts with a combat potion's added on top.""" - return {**blessed, "power": blessed["power"] + brew.get("power", 0), - "taken": blessed["taken"] * (1 - brew.get("reduce", 0))} - - def potion_effect(player: dict, skill: str) -> dict: active = player.get("potions", {}).get(skill) return ITEMS[active["item"]]["potion"] if active else {} @@ -1227,7 +1378,7 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: return "No raid to prepare for" if threat.get("drilled", 0) >= drill_needed(threat): return "The militia is as drilled as it can be" - if "difficulty" in action and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): + if "enemy" in action and player["hp"] < max_hp(player) * TOO_HURT and not edible(player): return "Too hurt to fight. Rest, or bring food" return None @@ -1258,52 +1409,45 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R outputs[item] = int(exact) + (1 if rng.random() < exact - int(exact) else 0) xp *= mods["xp"] - if "difficulty" in action: - blessed = blessing(player) - if player["prayers"] and not blessed["cost"]: - summary["prayer_out"] = True - if blessed["cost"]: - player["pp"] -= blessed["cost"] - summary["pp_spent"] += blessed["cost"] - gain_xp(world, player, "prayer", blessed["cost"] * PRAYER_XP_PER_POINT, now, summary) - regen(world, player, now, blessed["regen"]) - blessed = with_potion(blessed, brew) - auto_eat(player, summary) - chance = 0.5 + (combat_power(player) + blessed["power"] - action["difficulty"]) * 0.04 - won = rng.random() < min(0.97, max(0.15, chance)) - hurt = fight_damage(player, action, blessed) * rng.uniform(0.6, 1.4) * (1 if won else 1.3) - player["hp"] = min(max_hp(player), player["hp"] - hurt + brew.get("heal", 0)) - summary["damage"] += hurt - auto_eat(player, summary) + if "enemy" in action: outing = player["action"] - if player["hp"] <= 0 and blessed["wind"] and outing and not outing.get("wind_used"): + fight = outing.pop("fight", None) or simulate_fight(world, player, action, rng) + if player["prayers"] and not fight["pp"]: + summary["prayer_out"] = True + paid = min(player["pp"], fight["pp"]) + if paid: + player["pp"] -= paid + summary["pp_spent"] += paid + gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary) + for food, count in fight["eaten"].items(): + count = min(count, player["bank"].get(food, 0)) + if count: + bank_take(player, food, count) + summary["eaten"][food] = summary["eaten"].get(food, 0) + count + player["hp"], player["hp_at"] = fight["hp"], now + summary["damage"] += fight["damage"] + if fight["wind_used"]: outing["wind_used"] = True - player["hp"] = max_hp(player) / 2 summary["second_wind"] += 1 - if player["hp"] <= 0: - # Out of food and out of luck: fall back with nothing but your life. - player["hp"] = 1 + if fight["result"] != "won": + # Beaten or out of food: fall back with nothing but your life. 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) - for item, qty, odds in action.get("drops", []): - if rng.random() < odds: - outputs[item] = outputs.get(item, 0) + qty - if action.get("special") == "sabotage": - raid = upcoming_raid(world) - raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03) - if action.get("special") == "problem": - _work_problem(world, player, action, now) - else: - summary["losses"] += 1 - xp = xp // 3 + summary["wins"] += 1 + player["gold"] += action.get("gold", 0) + summary["gold"] += action.get("gold", 0) + for item, qty, odds in action.get("drops", []): + if rng.random() < odds: + outputs[item] = outputs.get(item, 0) + qty + if action.get("special") == "sabotage": + raid = upcoming_raid(world) + raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03) + if action.get("special") == "problem": + _work_problem(world, player, action, now) if action.get("special") == "recon": event = upcoming_raid(world) # Once a threat is fully scouted, scouting only trains the skill (and keeps the informant's leads). @@ -1391,7 +1535,13 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N # The second crop can't grow right now (a problem, too low a level): tend the first only. current["turn"] = 0 action = growing(player) - duration = action_time(world, player, action) + if "enemy" in action and not can_start(world, player, action): + if not current.get("fight"): + regen(world, player, current["start"], blessing(player)["regen"]) + current["fight"] = simulate_fight(world, player, action, rng) + duration = current["fight"]["duration"] + else: + duration = action_time(world, player, action) if current["start"] + duration > limit: break reason = can_start(world, player, action) @@ -2266,10 +2416,10 @@ PROBLEMS = { } MINOR_THREATS = ("collapse", "wolves", "bear", "goblins", "rockslide", "flood", "boars") PROBLEM_ACTIONS = [ - _action("clear_wolves", "combat", "Drive off the wolves", 1, 60, 16, difficulty=6, damage=3, - special="problem", problem="wolves"), - _action("catch_goblins", "combat", "Catch the goblin thieves", 1, 60, 16, difficulty=8, damage=3, - special="problem", problem="goblins"), + _fight("clear_wolves", monster("Wolf pack", "beast", 4), "Frontier", name="Drive off the wolves", + special="problem", problem="wolves"), + _fight("catch_goblins", monster("Goblin thieves", "human", 5), "Frontier", name="Catch the goblin thieves", + special="problem", problem="goblins"), _action("drive_bear", "hunting", "Drive off the bear", 1, 45, 14, special="problem", problem="bear"), _action("cull_boars", "hunting", "Cull the boar herd", 1, 45, 14, special="problem", problem="boars"), _action("catch_rats", "hunting", "Catch the rats", 1, 30, 8, special="problem", problem="rats"), @@ -2935,6 +3085,18 @@ PRAYER_GLYPHS = {"thick_skin": ("shield", "#a8a29e"), "burst_of_strength": ("bic "rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"), "superhuman_strength": ("biceps", "#ff8a3a"), "second_wind": ("wind-hole", "#8fd0e0"), "protection": ("aura", "#f0d060"), "piety": ("angel-wings", "#f0e6c0")} +TROPHY_GLYPHS = {"golem": "crystal-growth", "troll": "animal-hide", "spirit": "magic-swirl", "flyer": "sewing-string"} +TIER_TINTS = ["#9aa1ab", "#d4dae2", "#6f8fec", "#4cb878", "#3fd0e0"] # trophies of areas II to VI +MONSTER_GLYPHS = {"golem": ["rock-golem"] * 5, "troll": ["troll"] * 5, + "spirit": ["fairy", "spectre", "ghost", "spectre", "ghost"], + "flyer": ["bat", "harpy", "wyvern", "griffin-symbol", "eagle-head"]} +ACTION_GLYPHS.update({ + "fight_goblins": ("goblin-head", "#8fbf6a"), "fight_ruins": ("ogre", "#a08060"), + "fight_barrow_king": ("crowned-skull", "#c8d0e0"), "drill_militia": ACTION_GLYPHS["drill_militia"], +}) +for _kind, (_offset, _monsters, _trophies) in AREA_MONSTERS.items(): + for _index, _name in enumerate(_monsters): + ACTION_GLYPHS[f"fight_{_slug(_name)}"] = (MONSTER_GLYPHS[_kind][_index], TIER_TINTS[_index]) UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield"] @@ -2959,6 +3121,8 @@ def _assign_glyphs() -> None: ITEMS[leather[0]].update(glyph="rolled-cloth", tint=tint) for suffix, glyph in LEATHER_GLYPHS.items(): ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint) + for trophy, (_name, kind, tier) in TROPHIES.items(): + ITEMS[trophy].update(glyph=TROPHY_GLYPHS[kind], tint=TIER_TINTS[tier - 1]) for herb, tint in HERB_TINTS.items(): ITEMS[herb].update(glyph="herbs-bundle", tint=tint) flasks = ["round-bottom-flask", "round-bottom-flask", "standing-potion", "standing-potion", diff --git a/static/app.js b/static/app.js index a0c56a0..9a1442e 100644 --- a/static/app.js +++ b/static/app.js @@ -229,7 +229,7 @@ function sidebar() {
Guildhall alpha
${esc(me.name)}
-
${cls.icon} ${cls.name} · ${HEART} ${fmt(me.hp)}/${me.max_hp}
+
${cls.icon} ${cls.name} · ${HEART} ${fmt(me.hp)}/${me.max_hp}
${COIN} ${fmt(me.gold)}${FAVOUR} ${fmt(me.favour)}
@@ -339,7 +339,7 @@ function skillPage(id) { ${fast ? `${cls.icon} ${cls.name} bonus` : ""} `; - if (id === "combat") html += combatPanel(); + if (id === "combat") html += fightPanel() + combatPanel(); if (id === "prayer") html += prayerPanel(); if (id === "construction") html += S.city.construction ? siteBanner() : `
Nothing is being built right now. Fund a building on the Town square and its site opens here.
`; @@ -358,7 +358,7 @@ function skillPage(id) { const open = (g) => actions.some((a) => a.group === g && !isLocked(a)); let current = tabs[id] || remember(`tab.${id}`); // Tier ladders open on the highest tier you can work; categories open on the first. - const ladder = ["smithing", "leatherworking", "herblore"].includes(id); + const ladder = ["smithing", "leatherworking", "herblore", "combat"].includes(id); if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0]; const bar = groups.map((g) => ``).join(""); // Ungrouped actions (the building site) stay above the tabs. @@ -441,6 +441,63 @@ function toolPanel(skill) { : `No tool. A smith can make you one that speeds this up.`}`; } +// The fight in progress, replayed from the server's swing-by-swing record. +function fightPanel() { + const f = S.me.action?.fight; + if (!f) return ""; + const side = (who, name) => `
+
${esc(name)}
+
+
+
`; + return `
+
${side("you", S.me.name)}⚔️${side("foe", f.enemy)}
+
    +
    `; +} + +function fightLine(e, enemy) { + const [, who, kind, amount, , , food] = e; + const foe = esc(enemy); + if (kind === "eat") return `
  • You eat ${esc(item(food).name.toLowerCase())} (+${amount})
  • `; + if (kind === "wind") return `
  • Second wind! Back on your feet
  • `; + if (who === "you") return kind === "miss" ? `
  • You miss
  • ` : `
  • You ${kind === "crit" ? "crit" : "hit"} for ${amount}
  • `; + return kind === "miss" ? `
  • ${foe} misses
  • ` : `
  • ${foe} ${kind === "crit" ? "crits" : "hits"} you for ${amount}
  • `; +} + +function animateFight(now) { + const panel = $("[data-fight]"), a = S.me?.action, f = a?.fight; + if (!panel || !f) return; + const t = now - a.start; + let shown = -1; + while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++; + const last = f.events[shown]; + const hp = last ? last[4] : f.start_hp, foeHp = last ? last[5] : f.enemy_hp; + // Your HP everywhere follows the fight as it plays out. + for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(hp); + for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / f.top * 100)}%`; + const set = (key, fn) => { const el = panel.querySelector(`[data-f="${key}"]`); if (el) fn(el); }; + set("you-bar", (el) => { el.style.width = `${Math.max(0, hp / f.top * 100)}%`; }); + set("foe-bar", (el) => { el.style.width = `${Math.max(0, foeHp / f.enemy_hp * 100)}%`; }); + set("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(f.top)}`; }); + set("foe-hp", (el) => { el.textContent = `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; }); + const over = t >= f.end; + for (const [who, every] of [["you", f.interval], ["foe", f.enemy_interval]]) { + let swung = 0; + for (let i = shown; i >= 0; i--) if (f.events[i][1] === who && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") { swung = f.events[i][0]; break; } + set(`${who}-swing`, (el) => { el.style.width = over ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; }); + } + set("log", (el) => { + const key = `${a.start}:${shown}:${over}`; + if (el.dataset.key === key) return; + el.dataset.key = key; + const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy)); + if (over) lines.unshift(f.result === "won" ? `
  • ${esc(f.enemy)} is beaten. The next one is coming…
  • ` + : `
  • You fall back.
  • `); + el.innerHTML = lines.join("") || `
  • Squaring 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(", "); @@ -454,8 +511,8 @@ function combatPanel() { const hpPct = me.hp / me.max_hp * 100; return `

    Health

    -
    ${HEART} ${fmt(me.hp)} / ${me.max_hp}recovers ${Math.round(C.regen_per_hour * 100)}% an hour
    -
    +
    ${HEART} ${fmt(me.hp)} / ${me.max_hp}recovers ${Math.round(C.regen_per_hour * 100)}% an hour
    +

    ${skillOf("prayer").icon} ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` (not enough points: prayers are off)`}` : `No prayers active.`} @@ -481,6 +538,7 @@ function itemStats(id) { if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`); if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`); if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`); + if (it.trophy) parts.push(`An expedition trophy: stronger weapons will need these`); if (it.fitting) { const f = C.fittings.find((x) => x.id === id), done = S.city.buildings[f.building].fittings.includes(id); parts.push(`${C.buildings[f.building].icon} For the ${C.buildings[f.building].name.toLowerCase()} (Lv ${f.level}): ${f.effect}${done ? " · already fitted" : ""}`); @@ -507,9 +565,11 @@ function actionCard(a) { } io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join(""); let extra = ""; - if ("difficulty" in a) { - 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)}% · about ${me.fight_damage[a.id]} damage a fight${a.gold ? ` · ${a.gold} gold` : ""}
    `; + if (a.enemy) { + const o = me.fight_odds[a.id], e = a.enemy, pct = (x) => `${Math.round(x * 100)}%`; + extra = `
    ${HEART} ${fmt(e.hp)} HP · hits up to ${o.their_max} every ${e.interval}s${e.boss ? ` · Boss` : ""}
    +
    You land ${pct(o.hit)} of swings (up to ${o.max_hit}) · it lands ${pct(o.their_hit)}
    +
    About ${dur(o.kill_time)} a kill, ${Math.round(o.damage)} damage taken${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%.
    `; } @@ -549,7 +609,7 @@ function actionCard(a) { ${!active && !second && why ? esc(why) : ""} ${second ? `` : active ? `` - : ` + : ` ${canPlot ? `` : ""} `}
    @@ -1063,6 +1123,7 @@ function register() { function animate() { const now = serverNow(); const a = S?.me?.action; + animateFight(now); if (a) { const pct = Math.min(1, (now - a.start) / a.duration); for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%"; diff --git a/static/icons.svg b/static/icons.svg index 04c43d5..14c6248 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -9,6 +9,7 @@ + @@ -24,7 +25,6 @@ - @@ -34,11 +34,15 @@ + + + + @@ -48,12 +52,15 @@ + + + @@ -69,12 +76,14 @@ + + @@ -83,6 +92,7 @@ + @@ -90,12 +100,14 @@ + + @@ -106,6 +118,7 @@ + @@ -119,4 +132,5 @@ + diff --git a/static/styles.css b/static/styles.css index e35211b..2b5504f 100644 --- a/static/styles.css +++ b/static/styles.css @@ -229,6 +229,21 @@ select { background: var(--bg); border: 1px solid var(--line); border-radius: 8p .building.damaged { border-color: rgba(226, 100, 92, .6); } .cost { display: flex; flex-wrap: wrap; gap: 6px; } .fund { display: flex; flex-direction: column; gap: 6px; } +.fight .fighters { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: center; } +.fight .vs { font-size: 22px; } +.fighter { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.fighter-name { display: flex; justify-content: space-between; gap: 8px; } +.fighter-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fighter-name span { white-space: nowrap; } +@media (max-width: 500px) { .fight .fighters { gap: 8px; } .fighter-name { flex-direction: column; gap: 0; } } +.fight .bar.hp { height: 12px; } +.fight .bar.hp.you > span { background: var(--good); } +.fight .bar.hp.foe > span { background: var(--bad); } +.fight .bar.swing { height: 4px; } +.fight .bar.swing > span { background: var(--gold); transition: none; } +.fight-log { list-style: none; margin: 12px 0 0; padding: 0; font-size: 13px; display: flex; flex-direction: column; gap: 2px; min-height: 7.5em; } +.fight-log .good { color: var(--good); } +.fight-log .bad { color: var(--bad); } .fund-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; } .fittings { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--line); } .fitting-row { display: flex; align-items: center; gap: 8px; } diff --git a/tests/test_game.py b/tests/test_game.py index 3d2ecbc..820bd44 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -160,9 +160,9 @@ class ActionTest(unittest.TestCase): p = world["players"]["wil"] p["bank"] = {"bread": 500} game.start_action(world, p, "fight_wolves", T0) - summary = game.process_player(world, p, T0 + 60 * 100, random.Random(5)) - self.assertEqual(summary["done"], 100) - self.assertEqual(summary["wins"] + summary["losses"], 100) + summary = game.process_player(world, p, T0 + 3600, random.Random(5)) + self.assertGreater(summary["done"], 50) # a fight lasts as long as it takes + self.assertEqual(summary["wins"] + summary["losses"], summary["done"]) self.assertEqual(p["gold"], summary["wins"] * 2) @@ -490,7 +490,7 @@ class CombatTest(unittest.TestCase): 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"], 100 - summary["eaten"]["bread"]) + self.assertEqual(p["bank"].get("bread", 0), 100 - summary["eaten"]["bread"]) self.assertGreaterEqual(p["hp"], 1) def test_out_of_food_means_retreat_and_nothing_lost(self): @@ -515,9 +515,25 @@ class CombatTest(unittest.TestCase): 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)) + scouts = game.ACTION_BY_ID["fight_scouts"] + hurt = lambda p: game.fight_odds(p, scouts)["damage"] + self.assertLess(hurt(armoured), hurt(bare) * 0.7) + self.assertLess(hurt(veteran), hurt(bare)) + self.assertGreater(game.fight_odds(veteran, scouts)["hit"], game.fight_odds(bare, scouts)["hit"]) + + def test_a_fight_is_played_out_swing_by_swing(self): + world, p = self.fighter(level=10, weapon="iron_sword") + p["bank"] = {"bread": 50} + game.start_action(world, p, "fight_scouts", T0) + game.process_player(world, p, T0 + 1, random.Random(2)) + fight = p["action"]["fight"] # simulated, waiting to finish, for the page to replay + self.assertEqual(fight["result"], "won") + self.assertTrue(all(e[1] in ("you", "foe") and e[2] in ("hit", "crit", "miss", "eat", "wind") for e in fight["events"])) + self.assertEqual(fight["events"][-1][5], 0) # the last swing finished it + self.assertAlmostEqual(fight["duration"], fight["end"] + game.RESPAWN, places=1) + summary = game.process_player(world, p, T0 + fight["duration"] + 0.5, random.Random(2)) + self.assertEqual(summary["wins"], 1) + self.assertEqual(p["hp"], fight["hp"]) def test_preferred_food_is_eaten_first(self): world, p = self.fighter() @@ -764,12 +780,12 @@ class PrayerTest(unittest.TestCase): def test_prayers_cost_points_cut_damage_and_train_prayer(self): world, p = self.priest(level=10) scouts = game.ACTION_BY_ID["fight_scouts"] - plain = game.fight_damage(p, scouts) + plain = game.fight_odds(p, scouts)["damage"] game.set_prayers(p, ["thick_skin", "burst_of_strength"]) p["pp"] = 4 blessed = game.blessing(p) self.assertEqual(blessed["cost"], 2) - self.assertLess(game.fight_damage(p, scouts, blessed), plain * 0.9) + self.assertLess(game.fight_odds(p, scouts)["damage"], plain * 0.9) p["bank"] = {"bread": 200} game.start_action(world, p, "fight_scouts", T0) summary = game.process_player(world, p, T0 + 600, random.Random(1)) @@ -884,8 +900,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.with_potion(game.NO_BLESSING, game.potion_effect(p, "combat")) - self.assertAlmostEqual(game.fight_damage(p, scouts, brewed), game.fight_damage(p, scouts) * 0.70) + brewed = game.fight_odds(p, scouts)["damage"] + active = p["potions"].pop("combat") + self.assertAlmostEqual(brewed, game.fight_odds(p, scouts)["damage"] * 0.70, delta=0.2) + 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"])