Combat engine: swing-by-swing fights, Frontier and expeditions, trophies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 23:32:07 +01:00
parent 2ca90c43c0
commit 19ccab4ac3
7 changed files with 395 additions and 115 deletions

View file

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

13
app.py
View file

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

334
game.py
View file

@ -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",

View file

@ -229,7 +229,7 @@ function sidebar() {
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>
<div class="me">
<div class="who">${esc(me.name)}</div>
<div class="cls">${cls.icon} ${cls.name} · ${HEART} ${fmt(me.hp)}/${me.max_hp}</div>
<div class="cls">${cls.icon} ${cls.name} · ${HEART} <span data-live-hp>${fmt(me.hp)}</span>/${me.max_hp}</div>
<div class="purse"><span class="gold" title="Gold: from fights and selling. Fund buildings with it or give it to the treasury">${COIN} ${fmt(me.gold)}</span><span class="favour" title="Favour: earned by giving to the city. Spend it on the Council page, or on extra bank slots">${FAVOUR} ${fmt(me.favour)}</span></div>
</div>
<div class="navhead">City</div>
@ -339,7 +339,7 @@ function skillPage(id) {
</div>
${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""}
</div></div>`;
if (id === "combat") html += combatPanel();
if (id === "combat") html += fightPanel() + combatPanel();
if (id === "prayer") html += prayerPanel();
if (id === "construction") html += S.city.construction ? siteBanner()
: `<div class="panel muted">Nothing is being built right now. Fund a building on the Town square and its site opens here.</div>`;
@ -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) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</button>`).join("");
// Ungrouped actions (the building site) stay above the tabs.
@ -441,6 +441,63 @@ function toolPanel(skill) {
: `<span class="muted">No tool. A smith can make you one that speeds this up.</span>`}</div>`;
}
// 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) => `<div class="fighter">
<div class="fighter-name"><b>${esc(name)}</b><span class="small muted" data-f="${who}-hp"></span></div>
<div class="bar hp ${who}"><span data-f="${who}-bar"></span></div>
<div class="bar swing"><span data-f="${who}-swing"></span></div>
</div>`;
return `<div class="panel fight" data-fight="${S.me.action.start}">
<div class="fighters">${side("you", S.me.name)}<span class="vs">⚔️</span>${side("foe", f.enemy)}</div>
<ul class="fight-log" data-f="log"></ul>
</div>`;
}
function fightLine(e, enemy) {
const [, who, kind, amount, , , food] = e;
const foe = esc(enemy);
if (kind === "eat") return `<li class="good">You eat ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
if (kind === "wind") return `<li class="good">Second wind! Back on your feet</li>`;
if (who === "you") return kind === "miss" ? `<li class="muted">You miss</li>` : `<li>You ${kind === "crit" ? "<b>crit</b>" : "hit"} for <b>${amount}</b></li>`;
return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="bad">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} you for <b>${amount}</b></li>`;
}
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" ? `<li class="good"><b>${esc(f.enemy)} is beaten.</b> The next one is coming…</li>`
: `<li class="bad"><b>You fall back.</b></li>`);
el.innerHTML = lines.join("") || `<li class="muted">Squaring up…</li>`;
});
}
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 `<div class="cols">
<div class="panel"><h2>Health</h2>
<div class="hp-line"><b>${HEART} ${fmt(me.hp)} / ${me.max_hp}</b><span class="muted small">recovers ${Math.round(C.regen_per_hour * 100)}% an hour</span></div>
<div class="bar thick hp"><span style="width:${hpPct}%"></span></div>
<div class="hp-line"><b>${HEART} <span data-live-hp>${fmt(me.hp)}</span> / ${me.max_hp}</b><span class="muted small">recovers ${Math.round(C.regen_per_hour * 100)}% an hour</span></div>
<div class="bar thick hp"><span data-live-hpbar style="width:${hpPct}%"></span></div>
<div class="row" style="margin-top:12px"><label class="small muted">Food in fights
<select data-food-pick>${`<option value="">Cheapest first</option>` + foods}</select></label></div>
<p class="small">${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 ? "" : ` <b class="warn">(not enough points: prayers are off)</b>`}` : `<span class="muted">No prayers active.</span>`}
@ -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 = `<div class="small">Win chance <b>${Math.round(chance * 100)}%</b> · about <b>${me.fight_damage[a.id]}</b> damage a fight${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy, pct = (x) => `${Math.round(x * 100)}%`;
extra = `<div class="small">${HEART} ${fmt(e.hp)} HP · hits up to <b>${o.their_max}</b> every ${e.interval}s${e.boss ? ` · <span class="tag warn">Boss</span>` : ""}</div>
<div class="small">You land <b>${pct(o.hit)}</b> of swings (up to ${o.max_hit}) · it lands <b>${pct(o.their_hit)}</b></div>
<div class="small">About <b>${dur(o.kill_time)}</b> a kill, <b>${Math.round(o.damage)}</b> damage taken${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
if (a.drops) io += a.drops.map(([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${q} <span class="have">${Math.round(odds * 100)}%</span></span>`).join("");
if (a.special === "sabotage") extra += `<div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div>`;
}
@ -549,7 +609,7 @@ function actionCard(a) {
<span class="why">${!active && !second && why ? esc(why) : ""}</span>
${second ? `<button class="btn" data-plot="">Clear plot 2</button>`
: active ? `<button class="btn danger" data-stop>Stop</button>`
: `<span class="btns"><button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Do this once, then go back to what you were doing">${"difficulty" in a ? "Fight once" : Object.keys(a.outputs).length ? "Make one" : "Once"}</button>
: `<span class="btns"><button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Do this once, then go back to what you were doing">${a.enemy ? "Fight once" : Object.keys(a.outputs).length ? "Make one" : "Once"}</button>
${canPlot ? `<button class="btn" data-plot="${a.id}" ${why ? "disabled" : ""} title="Grow this in your second plot as well">Plot 2</button>` : ""}
<button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button></span>`}
</div>
@ -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) + "%";

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Before After
Before After

View file

@ -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; }

View file

@ -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"])