Combat rework: packs, abilities, rest, health potions, group bosses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 09:49:21 +01:00
parent 472609ac70
commit 57d11e30da
10 changed files with 1562 additions and 380 deletions

View file

@ -14,31 +14,48 @@ Beta. The core game is in place; changes from here are mostly balance and tuning
firekeeping, 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:** 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. Every blow has a damage type (slashing, blunt, piercing, magic);
each enemy is vulnerable to some (+25%) and resists others (-25%), shown
as tinted icons. Armour stops physical blows, ward stops magic. Below half
health you eat from your bank, and each bite holds back your next swing;
out of food you retreat, losing nothing but the fight. HP recovers 25%
an hour. The Combat page replays the fight live. Fights are split into
the Frontier (bandits, sabotage, drilling, the city's troubles) and
expeditions: Greenwood to start, then one area per
tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, Dragon's
Spine), each with four races and a boss (the dragons at the top). Every
kill is one of the race's variants (a brute, a skirmisher, a caster, now
and then a rare elite), so each encounter mixes damage types: spirit races
lean on magic, golems and trolls on blows. Races drop trophies (10%,
20% from bosses) for stronger weapons and the area's armour reagents (10%,
far more from dragons). Each kill is followed by a 2-second pause.
- **Combat:** a fight is a party against a pack, played out in quarter-second
slices (`combat.py`). Everyone swings on their weapon's timer, and swings hit,
miss or crit. Every blow has a damage type (slashing, blunt, piercing, magic);
each enemy is vulnerable to some (+25%) and resists others (-25%), shown as
tinted icons. Armour stops physical blows, ward stops magic. On top of the
swings, your weapon gives four abilities (learned at combat 1, 15, 30 and 45)
and your trade one more; for now they're all on auto. Enemies use their own:
brutes wind up a Heavy Slam, skirmishers lunge at whoever isn't tanking,
casters cast a Hex Bolt you can interrupt, elites and bosses enrage, dragons
breathe fire. Enemies pick targets by threat. A pack is as many enemies as
there are of you, often one more (each weaker); a rare elite comes alone.
Between fights you rest back to full: food is only eaten then, and better food
rests you faster. In a fight the only heal you carry is a health potion
(Herblore), drunk once a fight under 35% HP. At 0 HP you're knocked out: you
lose that fight and come round at 30%; three knockouts in a row and you stop.
HP recovers 25% an hour away from fights. The Combat page replays the fight
live. Fights are split into the Frontier (bandits, sabotage, drilling, the
city's troubles) and expeditions: Greenwood to start, then one area per tier
(Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, Dragon's Spine), each
with four races and a boss (the dragons at the top). Races drop trophies
(10%, 20% from bosses) for stronger weapons and the area's armour reagents
(10%, far more from dragons).
- **Bosses and dragons** are group fights: two or more. Alone you wait at it
for someone to tag in. They grow with the group, and everyone who brings one
down gets their own loot.
- **Multiplayer Combat:** tag into anyone's fight from the encounter card or the guild roster.
- **Weapons:** four styles, one per trade, and anyone can use any of them.
- **Weapons:** four styles, one per trade, and anyone can use any of them; the
weapon decides your abilities and your role.
Sword and shield (slashing, 2.4s swings, the shield blocks 15% of hits;
swords from Smithing, shields from Construction), warhammer (blunt,
Smithing; slow, heavy, twice the crits), staff (magic, Herblore;
accurate) and bow (piercing, Leatherworking; fast, strikes first). The
last three take both hands and carry a shield's power. Your trade's weapon
does 15% more damage. Past copper, each weapon needs its tier's trophies.
- **Abilities:** sword and shield tanks (Shield Bash, Challenge, Sweeping Cut,
Shield Wall; the shield ones need a shield), warhammer hits hard and stuns
(Crushing Blow, Concuss, Earthshaker, Unstoppable), staff heals and holds
(Bolt, Mend, Ward, Thornbind), bow shoots from afar (Aimed Shot, Pinning
Shot, Barbed Arrow, Volley, which takes 5 arrows). Trades: Find the Seam
(miner: a target's resistance stops counting), Barricade (builder: the group
takes less), Hearty Meal (homesteader: the group heals over time), Mark Prey
(ranger: a target takes more from everyone).
- **Armour:** three weights. Heavy plate (Smithing) is strong against blows
and weak to magic, light cloth (Tailoring) the other way round, medium
leather (Leatherworking) a bit of both. Every trade gathers its own base
@ -119,7 +136,8 @@ Beta. The core game is in place; changes from here are mostly balance and tuning
logs → enchanted logs, copper bar → rune bar…), at Herblore 1, 15, 30, 45,
60 and 75. Gathering and Construction potions are 10–35% faster, crafting
potions a 10–35% chance to keep materials, Prayer more points from bones,
and combat has Strength and Defence ladders. Potions last 50 uses, one per
and combat has Strength and Defence ladders. Health potions (I–VI, the tier's
herb and cooked fish) are the one heal you take into a fight. Potions last 50 uses, one per
skill at a time; an empty one is replaced from the bank automatically.
- **Firekeeping:** the guild shares four fires: the forge (bars), the kitchen
fire (cooking), the kiln (bricks) and the still (potions). Every craft at a
@ -178,6 +196,8 @@ snapshot and exits; the NixOS module runs it nightly when
## Layout
- `game.py`: content tables and the rules. Pure functions of `(world, now)`.
- `combat.py`: the fight engine and the abilities.
- `tools/balance.py`: an hour of fighting per trade and tier (`solo`, `party`, `bosses`).
- `app.py`: stdlib HTTP server, persistence and the background ticker.
- `static/`: the client (vanilla JS, no build step).

14
app.py
View file

@ -106,7 +106,7 @@ def player_view(world: dict, player: dict, now: float) -> dict:
view["ward_reduction"] = round(game.reduction(game.magic_ward(player)), 3)
view["edible"] = game.edible(player)
view["blessing"] = game.blessing(player)
view["fight_odds"] = {a["id"]: game.fight_odds(player, a) for a in game.ACTIONS if "enemy" in a}
view["fight_odds"] = {a["id"]: game.fight_odds(player, a, world) 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}
@ -116,20 +116,24 @@ def player_view(world: dict, player: dict, now: float) -> dict:
view["power"] = game.combat_power(player)
me = game.fighter(player, game.blessing(player), game.potion_effect(player, "combat"))
view["fighter"] = {"max_hit": me["max_hit"], "accuracy": round(me["accuracy"]), "evasion": me["evasion"],
"crit": me["crit"], "interval": me["interval"], "block": me["block"], "damage": me["damage"]}
"crit": me["crit"], "interval": me["interval"], "block": me["block"], "damage": me["damage"],
"role": me["role"], "abilities": me["abilities"], "style": me["style"]}
view["health_potion"] = game.health_potion(player)
view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]}
if player["action"]:
action, fight = game.growing(player), game.current_fight(world, player)
duration = fight["duration"] if fight else game.action_time(world, player, action)
view["action"] = {**player["action"], "growing": action["id"], "duration": duration}
if action.get("together") and not fight:
view["action"]["waiting"] = True # for a group: nothing to count down
if fight:
view["action"]["fight"] = game.fight_view(fight)
view["action"]["respawn"] = game.RESPAWN / game.speed(world)
party = game.party_of(world, player)
if party:
view["action"]["with"] = [uid for uid in party["members"] if uid != player["id"]]
# 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)
# A fight takes as long as it takes: show the average time to win one and rest after it.
view["times"] = {action["id"]: (view["fight_odds"][action["id"]]["kill_time"] + view["fight_odds"][action["id"]]["rest"])
/ 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)

729
combat.py Normal file
View file

@ -0,0 +1,729 @@
"""The fight engine: a party against a pack, stepped in fixed slices of game time.
A fight is played out whole as soon as it's drawn up, and again whenever someone tags in or
steps out, so the page can replay it. Each slice draws from a random seeded by the fight and
the slice number: playing it again with a latecomer changes nothing before they arrive.
Everyone still swings on their weapon's timer. On top of that, the weapon in your hands
gives four abilities (learned at combat 1, 15, 30 and 45) and your trade one more. For now
every ability is on auto: a rule decides when, and runs its cooldown half again as long.
"""
from __future__ import annotations
import random
STEP = 0.25 # game seconds per slice
GCD = 1.5
AUTO_DELAY = 0.5 # auto waits this long after it could act
AUTO_CD = 1.5 # ...and runs cooldowns this much slower
AUTO_REACT = 1.0 # seconds into an enemy's cast before auto stops it
CRIT_MULT = 1.5
ENEMY_CRIT = 0.05
SPREAD = 0.1 # abilities land within 10% of their size
HEAL_THREAT = 0.5
STICKY = 1.1 # an enemy only turns when someone has 10% more threat than its target
TANK_THREAT = 2.0
POTION_BELOW = 0.35 # a health potion is drunk under this share of HP, once a fight
MAX_FIGHT = 600 # a fight that drags on longer is broken off
UNLOCK = [1, 15, 30, 45]
def hit_chance(accuracy: float, evasion: float) -> float:
return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15))
# --- abilities ---------------------------------------------------------------
# `c` is a share of the user's average hit (healing too). Rules (`auto`) say when auto uses it.
def _dmg(c):
return {"t": "dmg", "c": c}
def _aura(kind, dur, on, **extra):
return {"t": "aura", "kind": kind, "dur": dur, "on": on, **extra}
ABILITIES = {
# Sword and shield: hold the line.
"shield_bash": {"name": "Shield Bash", "icon": "🛡️", "needs": "shield", "target": "enemy", "cd": 10,
"offgcd": True, "threat": 2, "effects": [_dmg(1.2), {"t": "stun", "dur": 2}],
"auto": {"r": "stop", "else": True},
"tip": "Hit, stun 2s, stop a cast. Bosses only lose the cast."},
"challenge": {"name": "Challenge", "icon": "😤", "target": "enemy", "cd": 8, "offgcd": True,
"effects": [{"t": "taunt", "dur": 3}], "auto": {"r": "taunt"},
"tip": "An enemy turns on you for 3s."},
"sweeping_cut": {"name": "Sweeping Cut", "icon": "🌀", "target": "aoe", "cd": 10, "threat": 2,
"effects": [{"t": "aoe", "c": 0.8}], "auto": {"r": "aoe", "n": 2},
"tip": "Hits every enemy. Double threat."},
"shield_wall": {"name": "Shield Wall", "icon": "🧱", "needs": "shield", "target": "self", "cd": 30, "offgcd": True,
"effects": [_aura("taken", 6, "self", mult=0.5)], "auto": {"r": "tanking", "pct": 0.6},
"tip": "Half damage taken for 6s."},
# Warhammer: slow, heavy, and hard to stand up to.
"crushing_blow": {"name": "Crushing Blow", "icon": "🔨", "target": "enemy", "cd": 16, "cast": 1.5,
"effects": [_dmg(1.5)], "auto": {"r": "always"}, "tip": "Wind up 1.5s, hit for 1.5×."},
"concuss": {"name": "Concuss", "icon": "💫", "target": "enemy", "cd": 15, "offgcd": True,
"effects": [{"t": "stun", "dur": 3}], "auto": {"r": "stop", "else": True},
"tip": "Stun 3s, stop a cast. Bosses only lose the cast."},
"earthshaker": {"name": "Earthshaker", "icon": "🌋", "target": "aoe", "cd": 12,
"effects": [{"t": "aoe", "c": 0.5}, {"t": "delay", "by": 1.5}], "auto": {"r": "aoe", "n": 2},
"tip": "Hits every enemy and puts their next swing back 1.5s."},
"unstoppable": {"name": "Unstoppable", "icon": "😡", "target": "self", "cd": 45, "offgcd": True,
"effects": [_aura("done", 12, "self", mult=1.2)], "auto": {"r": "big"},
"tip": "+20% damage for 12s."},
# Staff: magic, and keeping everyone standing.
"bolt": {"name": "Bolt", "icon": "✨", "target": "enemy", "cd": 10, "cast": 1.5,
"effects": [_dmg(1.1)], "auto": {"r": "always"}, "tip": "Cast 1.5s, magic hit for 1.1×."},
"mend": {"name": "Mend", "icon": "💚", "target": "ally", "cd": 10, "cast": 2,
"effects": [{"t": "heal", "c": 2.0}], "auto": {"r": "heal", "pct": 0.5},
"tip": "Cast 2s, heal someone for 2× your hit."},
"ward": {"name": "Ward", "icon": "🔮", "target": "ally", "cd": 20,
"effects": [{"t": "shield", "c": 2.0, "dur": 20}], "auto": {"r": "ward", "pct": 0.8},
"tip": "Soaks 2× your hit for someone, for up to 20s."},
"thornbind": {"name": "Thornbind", "icon": "🌿", "target": "enemy", "cd": 20,
"effects": [{"t": "cc", "dur": 15}], "auto": {"r": "cc"},
"tip": "Holds an enemy 15s. Damage breaks it. Not bosses."},
# Bow: fast, first, and from a distance.
"aimed_shot": {"name": "Aimed Shot", "icon": "🎯", "target": "enemy", "cd": 10, "cast": 2,
"effects": [_dmg(2.0)], "auto": {"r": "always"}, "tip": "Aim 2s, hit for 2×."},
"pinning_shot": {"name": "Pinning Shot", "icon": "📌", "target": "enemy", "cd": 12, "offgcd": True,
"effects": [_dmg(0.5), {"t": "interrupt"}], "auto": {"r": "stop"},
"tip": "Hit and stop a cast."},
"barbed_arrow": {"name": "Barbed Arrow", "icon": "🩸", "target": "enemy", "cd": 12,
"effects": [_dmg(0.4), {"t": "dot", "c": 0.25, "every": 2, "dur": 12}], "auto": {"r": "dot"},
"tip": "Hit, then bleeds 6× 0.25 over 12s."},
"volley": {"name": "Volley", "icon": "🏹", "target": "aoe", "cd": 10, "cost": 5,
"effects": [{"t": "aoe", "c": 0.9}], "auto": {"r": "aoe", "n": 2},
"tip": "Hits every enemy. 5 arrows."},
# Trades: one each, whatever the weapon.
"find_the_seam": {"name": "Find the Seam", "icon": "⛏️", "target": "enemy", "cd": 30, "offgcd": True,
"effects": [_aura("seam", 8, "target")], "auto": {"r": "debuff", "pct": 0.6},
"tip": "8s: its resistance doesn't count and its weakness counts double, for everyone."},
"barricade": {"name": "Barricade", "icon": "🚧", "target": "party", "cd": 40, "offgcd": True,
"effects": [_aura("taken", 8, "party", mult=0.75)], "auto": {"r": "danger", "pct": 0.6},
"tip": "Everyone takes 25% less damage for 8s."},
"hearty_meal": {"name": "Hearty Meal", "icon": "🍲", "target": "party", "cd": 30,
"effects": [{"t": "hot", "c": 0.3, "every": 2, "dur": 10}], "auto": {"r": "party_hot", "pct": 0.75},
"tip": "Everyone heals 5× 0.3 of your hit over 10s."},
"mark_prey": {"name": "Mark Prey", "icon": "🐾", "target": "enemy", "cd": 30, "offgcd": True,
"effects": [_aura("vuln", 15, "target", mult=1.15)], "auto": {"r": "debuff", "pct": 0.6},
"tip": "It takes 15% more damage from everyone for 15s."},
}
WEAPON_KITS = {
"sword": ["shield_bash", "challenge", "sweeping_cut", "shield_wall"],
"hammer": ["crushing_blow", "concuss", "earthshaker", "unstoppable"],
"staff": ["bolt", "mend", "ward", "thornbind"],
"bow": ["aimed_shot", "pinning_shot", "barbed_arrow", "volley"],
}
TRADE_ABILITY = {"miner": "find_the_seam", "builder": "barricade", "homesteader": "hearty_meal", "ranger": "mark_prey"}
# The order auto looks down: stopping casts and staying alive before damage.
PRIORITY = ["pinning_shot", "shield_bash", "concuss", "challenge", "shield_wall", "barricade", "unstoppable",
"mend", "ward", "hearty_meal", "thornbind", "find_the_seam", "mark_prey", "barbed_arrow",
"earthshaker", "sweeping_cut", "volley", "crushing_blow", "aimed_shot", "bolt"]
for _id, _ability in ABILITIES.items():
_ability["id"] = _id
for _style, _kit in WEAPON_KITS.items():
if _id in _kit:
_ability.update(style=_style, level=UNLOCK[_kit.index(_id)])
for _cls, _trade in TRADE_ABILITY.items():
if _trade == _id:
_ability.update(trade=_cls, level=1)
def known_abilities(style: str, cls: str, level: int, shield: bool) -> list[str]:
"""What you can use with this weapon at this combat level, in the order auto tries them."""
kit = [a for a in WEAPON_KITS[style] if ABILITIES[a]["level"] <= level and (shield or not ABILITIES[a].get("needs"))]
if cls in TRADE_ABILITY:
kit.append(TRADE_ABILITY[cls])
return sorted(kit, key=PRIORITY.index)
def role(style: str, shield: bool) -> str:
return "tank" if style == "sword" and shield else "healer" if style == "staff" else "damage"
# --- enemy abilities ---------------------------------------------------------
# Every archetype has its own, so every area's races use them. `effect` is (kind, share of
# the enemy's average hit, damage type: None for its own). Telegraphed casts always land.
ENEMY_ABILITIES = {
"heavy_slam": {"name": "Heavy Slam", "icon": "💥", "cast": 2.0, "every": 14, "first": 5, "interrupt": False,
"target": "target", "effect": ("hit", 1.6, "blunt")},
"lunge": {"name": "Lunge", "icon": "🗡️", "cast": 0, "every": 10, "first": 4, "interrupt": False,
"target": "soft", "effect": ("hit", 1.2, None)},
"hex_bolt": {"name": "Hex Bolt", "icon": "🔥", "cast": 2.5, "every": 12, "first": 3, "interrupt": True,
"target": "target", "effect": ("hit", 1.8, "magic")},
"sweep": {"name": "Sweep", "icon": "🌀", "cast": 1.5, "every": 16, "first": 8, "interrupt": False,
"target": "all", "effect": ("aoe", 0.8, "slash")},
"fire_breath": {"name": "Fire Breath", "icon": "🐉", "cast": 3.0, "every": 18, "first": 6, "interrupt": False,
"target": "all", "effect": ("aoe", 1.4, "magic")},
"tail_swipe": {"name": "Tail Swipe", "icon": "💢", "cast": 0, "every": 12, "first": 7, "interrupt": False,
"target": "target", "effect": ("hit", 1.2, "blunt")},
}
ARCHETYPE_ABILITIES = {"brute": ["heavy_slam"], "skirmisher": ["lunge"], "caster": ["hex_bolt"],
"elite": ["heavy_slam"], "boss": ["heavy_slam", "sweep"], "dragon": ["fire_breath", "tail_swipe"]}
ENRAGE = {"elite": (0.3, 1.4), "boss": (0.3, 1.4), "dragon": (0.25, 1.5)} # under this share of HP, hits this much harder
VULNERABLE, RESISTANT = 1.25, 0.75
# --- setting up --------------------------------------------------------------
def _member_unit(member: dict) -> dict:
me = member["fighter"]
return {"id": member["id"], "side": "party", "name": member["name"], "top": member["top"], "hp": member["hp_in"],
"at": member["at"], "left": member["left"], "in": False, "out": None, "role": me["role"],
"damage": me["damage"], "interval": me["interval"], "accuracy": me["accuracy"], "evasion": me["evasion"],
"crit": me["crit"], "block": me["block"], "taken": me["taken"], "base": me["base"],
"abilities": list(me["abilities"]), "cds": {}, "busy": 0.0, "cast": None, "auras": [],
"next": member["at"] + (0.5 if me["first"] else me["interval"]), "stun": 0.0, "target": None,
"mend": member["mend"], "wind": member["wind"], "wind_used": False,
"potion": member.get("potion") or 0, "drank": False, "arrows": member.get("arrows", 0), "shot": 0,
"dealt": 0, "hurt": 0, "down": False}
def _enemy_unit(index: int, foe: dict) -> dict:
return {"id": f"e{index}", "side": "enemy", "name": foe["name"], "archetype": foe["archetype"],
"boss": foe["archetype"] in ("boss", "dragon") or foe.get("boss", False),
"top": foe["hp"], "hp": foe["hp"], "interval": foe["interval"], "accuracy": foe["accuracy"],
"evasion": foe["evasion"], "max_hit": foe["max_hit"], "attack": foe["attack"],
"vulnerable": foe["vulnerable"], "resists": foe["resists"],
"next": foe["interval"] + 0.4 * index, "swings": 0, "cast": None, "auras": [], "threat": {},
"target": None, "taunt": None, "since": 0.0, "stun": 0.0, "cc": 0.0, "dead": False, "enraged": 1.0,
"abil": [{"id": a, "next": ENEMY_ABILITIES[a]["first"] + 0.5 * index}
for a in ARCHETYPE_ABILITIES.get(foe["archetype"], [])]}
def run(fight: dict) -> dict:
"""Play the fight out from the start. `fight` carries `seed`, `pace`, `rest` (game seconds before
it starts), `pack` (the enemies) and `members` (each with `fighter`, `at` and `left` in seconds of
the fight). Fills in `events`, the `kills`, each member's outcome, `result`, `end` and `duration`."""
units = {}
for member in fight["members"]:
units[member["id"]] = _member_unit(member)
for index, foe in enumerate(fight["pack"]):
unit = _enemy_unit(index, foe)
units[unit["id"]] = unit
state = {"units": units, "events": [], "kills": [], "pace": fight["pace"], "rest": fight["rest"]}
t, n, result = 0.0, 0, None
while result is None:
t = round(t + STEP, 4)
rng = random.Random(f"{fight['seed']}:{n}")
n += 1
result = _step(state, t, rng)
for member in fight["members"]:
unit = units[member["id"]]
won = result == "won" and unit["out"] in (None, "down") and unit["in"]
member.update(hp=max(0, unit["hp"]), damage=unit["hurt"], dealt=unit["dealt"], wind_used=unit["wind_used"],
drank=unit["drank"], shot=unit["shot"], down=unit["down"],
result="won" if won else unit["out"] or ("left" if not unit["in"] else result))
fight.update(events=state["events"], kills=state["kills"], result=result,
end=_real(state, t), duration=(fight["rest"] + t) / fight["pace"])
return fight
def _real(state: dict, t: float) -> float:
"""A moment of the fight in real seconds since its action started (rest included)."""
return round((state["rest"] + t) / state["pace"], 2)
def _event(state: dict, t: float, src: str | None, tgt: str | None, kind: str, amount: float = 0,
label: str = "") -> None:
target = state["units"].get(tgt or "")
hp = max(0, round(target["hp"])) if target else None
state["events"].append([_real(state, t), src, tgt, kind, round(amount), hp, label])
def _party(state: dict) -> list[dict]:
return [u for u in state["units"].values() if u["side"] == "party" and u["in"] and not u["out"]]
def _enemies(state: dict) -> list[dict]:
return [u for u in state["units"].values() if u["side"] == "enemy" and not u["dead"]]
# --- a slice -------------------------------------------------------------------
def _step(state: dict, t: float, rng: random.Random) -> str | None:
units = state["units"]
for unit in units.values():
if unit["side"] != "party" or unit["out"]:
continue
if not unit["in"] and unit["at"] <= t + 1e-9:
unit["in"] = True
for enemy in _enemies(state):
enemy["threat"].setdefault(unit["id"], 10.0 if unit["role"] == "tank" else 0.0)
if unit["in"] and unit["left"] is not None and unit["left"] <= t + 1e-9:
_out(state, unit, "left", t)
for unit in list(units.values()):
if _alive(unit):
_tick(state, unit, t, rng)
for unit in list(units.values()):
if _alive(unit) and unit["cast"] and unit["cast"]["end"] <= t + 1e-9:
_finish_cast(state, unit, t, rng)
for unit in _party(state):
_think(state, unit, t, rng)
for enemy in _enemies(state):
if enemy["cc"] and enemy["cc"] <= t + 1e-9:
enemy["cc"] = 0 # the hold wears off
_event(state, t, None, enemy["id"], "free")
_enemy_think(state, enemy, t, rng)
for unit in list(units.values()):
if _alive(unit):
_swing(state, unit, t, rng)
for enemy in _enemies(state):
rule = ENRAGE.get(enemy["archetype"])
if rule and enemy["enraged"] == 1.0 and enemy["hp"] < enemy["top"] * rule[0]:
enemy["enraged"] = rule[1]
_event(state, t, enemy["id"], enemy["id"], "enrage")
if not _enemies(state):
return "won"
if not _party(state) and not any(u["side"] == "party" and not u["in"] and not u["out"] for u in units.values()):
return "lost"
if t >= MAX_FIGHT:
for unit in _party(state):
unit["out"] = "fled"
return "fled"
return None
def _alive(unit: dict) -> bool:
return not unit["dead"] if unit["side"] == "enemy" else unit["in"] and not unit["out"]
def _tick(state: dict, unit: dict, t: float, rng: random.Random) -> None:
if unit["side"] == "party" and unit["mend"]:
unit["hp"] = min(unit["top"], unit["hp"] + unit["mend"] * unit["top"] * STEP) # Rapid Heal
for aura in list(unit["auras"]):
if aura["kind"] in ("dot", "hot") and t >= aura["next"] - 1e-9:
aura["next"] += aura["every"]
source = state["units"].get(aura["by"])
if aura["kind"] == "dot":
_hurt(state, source, unit, aura["amount"], aura["school"], t, rng, label=aura["id"], crit=False, tick=True)
else:
_heal(state, source, unit, aura["amount"], t, aura["id"], tick=True)
if not _alive(unit):
return
if t >= aura["until"] - 1e-9 and aura in unit["auras"]:
unit["auras"].remove(aura)
# --- your side -----------------------------------------------------------------
def _think(state: dict, unit: dict, t: float, rng: random.Random) -> None:
if unit["potion"] and not unit["drank"] and unit["hp"] < unit["top"] * POTION_BELOW:
unit["drank"] = True
_heal(state, None, unit, unit["potion"], t, "potion")
if unit["stun"] > t or unit["cast"]:
return
_pick_target(state, unit, t)
for ab_id in unit["abilities"]:
ab = ABILITIES[ab_id]
if unit["cds"].get(ab_id, 0) > t + 1e-9:
continue
if not ab.get("offgcd") and unit["busy"] + AUTO_DELAY > t + 1e-9:
continue
if ab.get("cost", 0) > unit["arrows"]:
continue
target = _rule(state, unit, ab, t)
if target is False:
continue
_use(state, unit, ab, target, t, rng)
if unit["cast"] or not ab.get("offgcd"):
return
def _use(state: dict, unit: dict, ab: dict, target, t: float, rng: random.Random) -> None:
unit["cds"][ab["id"]] = t + ab.get("cd", 0) * AUTO_CD
if ab.get("cost"):
unit["arrows"] -= ab["cost"]
unit["shot"] += ab["cost"]
if not ab.get("offgcd"):
unit["busy"] = t + max(GCD, ab.get("cast", 0))
if ab.get("cast"):
unit["cast"] = {"ab": ab["id"], "target": target, "start": t, "end": t + ab["cast"]}
_event(state, t, unit["id"], target, "cast", 0, ab["id"])
else:
_apply(state, unit, ab, target, t, rng)
def _finish_cast(state: dict, unit: dict, t: float, rng: random.Random) -> None:
cast, unit["cast"] = unit["cast"], None
if unit["side"] == "party":
_apply(state, unit, ABILITIES[cast["ab"]], cast["target"], t, rng)
else:
_enemy_effect(state, unit, cast["ab"], cast["target"], t, rng)
def _avg(unit: dict, enemy: dict | None = None) -> float:
"""The unit's average weapon hit, against this enemy if there is one."""
top = max(2, round(unit["base"] * (_affinity(enemy, unit["damage"]) if enemy else 1)))
return (top + 1) / 2
def _affinity(enemy: dict, damage: str) -> float:
seam = any(a["kind"] == "seam" for a in enemy["auras"])
if damage in enemy["vulnerable"]:
return 1 + (VULNERABLE - 1) * (2 if seam else 1)
if damage in enemy["resists"]:
return 1 if seam else RESISTANT
return 1
def _apply(state: dict, unit: dict, ab: dict, target_id, t: float, rng: random.Random) -> None:
units = state["units"]
target = units.get(target_id or "")
if ab["target"] in ("enemy", "ally") and (not target or not _alive(target)):
return
threat = ab.get("threat", 1)
if ab["target"] != "enemy" or not any(e["t"] == "dmg" for e in ab["effects"]):
_event(state, t, unit["id"], target_id, "use", 0, ab["id"])
spread = lambda: 1 + rng.uniform(-SPREAD, SPREAD)
for eff in ab["effects"]:
kind = eff["t"]
if kind == "dmg":
_hurt(state, unit, target, eff["c"] * _avg(unit, target) * spread(), unit["damage"], t, rng,
threat=threat, label=ab["id"])
elif kind == "aoe":
for enemy in _enemies(state):
if enemy["cc"] <= t:
_hurt(state, unit, enemy, eff["c"] * _avg(unit, enemy) * spread(), unit["damage"], t, rng,
threat=threat, label=ab["id"])
elif kind == "dot":
if target and _alive(target):
_add_aura(target, {"id": ab["id"], "kind": "dot", "by": unit["id"], "school": unit["damage"],
"amount": eff["c"] * _avg(unit, target), "every": eff["every"],
"next": t + eff["every"], "until": t + eff["dur"] + 1e-6})
elif kind == "heal":
_heal(state, unit, target, eff["c"] * _avg(unit) * spread(), t, ab["id"])
elif kind == "hot":
for ally in _party(state):
_add_aura(ally, {"id": ab["id"], "kind": "hot", "by": unit["id"], "amount": eff["c"] * _avg(unit),
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6})
elif kind == "shield":
_add_aura(target, {"id": ab["id"], "kind": "shield", "by": unit["id"], "amount": eff["c"] * _avg(unit),
"until": t + eff["dur"]})
_heal_threat(state, unit, eff["c"] * _avg(unit))
elif kind == "aura":
holders = _party(state) if eff["on"] == "party" else [target] if eff["on"] == "target" else [unit]
spec = {k: v for k, v in eff.items() if k not in ("t", "dur", "on")}
for holder in holders:
if holder and _alive(holder):
_add_aura(holder, {**spec, "id": ab["id"], "by": unit["id"], "until": t + eff["dur"]})
elif kind == "taunt":
top = max(target["threat"].values(), default=0)
target["threat"][unit["id"]] = top * STICKY + 1
target["taunt"] = {"by": unit["id"], "until": t + eff["dur"]}
target["target"] = unit["id"]
elif kind == "stun":
if not _alive(target):
continue
_stop_cast(state, target, unit, t, force=not target["boss"])
if not target["boss"]:
target["stun"] = max(target["stun"], t + eff["dur"])
target["next"] = max(target["next"], t + eff["dur"])
elif kind == "interrupt":
if _alive(target):
_stop_cast(state, target, unit, t)
elif kind == "cc":
if target["boss"]:
continue
_stop_cast(state, target, unit, t, force=True, quiet=True)
target["cc"] = t + eff["dur"]
_event(state, t, unit["id"], target["id"], "cc", 0, ab["id"])
for other in _party(state):
if other["target"] == target["id"]:
other["target"] = None
elif kind == "delay":
for enemy in _enemies(state):
enemy["next"] += eff["by"]
def _stop_cast(state: dict, enemy: dict, by: dict, t: float, force: bool = False, quiet: bool = False) -> None:
cast = enemy.get("cast")
if not cast or (not force and not ENEMY_ABILITIES[cast["ab"]]["interrupt"]):
return
enemy["cast"] = None
if not quiet:
_event(state, t, by["id"], enemy["id"], "stop", 0, cast["ab"])
def _add_aura(unit: dict, aura: dict) -> None:
unit["auras"] = [a for a in unit["auras"] if not (a["id"] == aura["id"] and a["by"] == aura["by"])]
unit["auras"].append(aura)
def _has(unit: dict, ab_id: str, by: str | None = None) -> bool:
return any(a["id"] == ab_id and (by is None or a["by"] == by) for a in unit["auras"])
def _share(unit: dict) -> float:
return unit["hp"] / unit["top"]
def _pick_target(state: dict, unit: dict, t: float) -> None:
current = state["units"].get(unit["target"] or "")
if current and not current["dead"] and current["cc"] <= t:
return
enemies = _enemies(state)
free = [e for e in enemies if e["cc"] <= t] or enemies
if not free:
unit["target"] = None
return
if unit["role"] == "tank":
loose = [e for e in free if e["target"] != unit["id"]]
unit["target"] = (loose or sorted(free, key=_share))[0]["id"]
return
tank = next((u for u in _party(state) if u["role"] == "tank" and u["target"]), None)
focus = state["units"].get(tank["target"]) if tank else None
if focus and not focus["dead"] and focus["cc"] <= t:
unit["target"] = focus["id"]
else:
unit["target"] = min(free, key=_share)["id"]
def _stoppable(cast: dict, ab: dict, enemy: dict) -> bool:
spec = ENEMY_ABILITIES[cast["ab"]]
stuns = any(e["t"] == "stun" for e in ab["effects"])
return spec["interrupt"] or (stuns and not enemy["boss"])
def _rule(state: dict, unit: dict, ab: dict, t: float):
"""Auto's decision: the target to use this on, None for no target, False for not now."""
rule = ab["auto"]
r = rule["r"]
enemies = _enemies(state)
party = _party(state)
target = state["units"].get(unit["target"] or "")
if target and target["dead"]:
target = None
on_me = [e for e in enemies if e["target"] == unit["id"] and e["cc"] <= t]
if r == "always":
return target["id"] if target else False
if r == "stop":
casting = [e for e in enemies if e["cast"] and t - e["cast"]["start"] >= AUTO_REACT - 1e-9
and _stoppable(e["cast"], ab, e)]
if casting:
return casting[0]["id"]
return target["id"] if rule.get("else") and target else False
if r == "taunt":
if unit["role"] != "tank":
return False
loose = [e for e in enemies if e["cc"] <= t and e["target"] and e["target"] != unit["id"]
and t - e["since"] >= AUTO_REACT]
return loose[0]["id"] if loose else False
if r == "aoe":
return None if sum(1 for e in enemies if e["cc"] <= t) >= rule["n"] else False
if r == "tanking":
big = any(e["boss"] or e["archetype"] == "elite" or e["cast"] and e["cast"]["target"] in (unit["id"], None)
for e in on_me)
return None if on_me and (big or _share(unit) < rule["pct"]) else False
if r == "big":
return None if target and (target["boss"] or target["archetype"] == "elite" or len(enemies) >= 2) else False
if r == "danger":
threatening = len([e for e in enemies if e["cc"] <= t]) >= 2 or any(e["boss"] for e in enemies)
return None if threatening or any(_share(u) < rule["pct"] for u in party) else False
if r == "heal":
low = min(party, key=_share, default=None)
return low["id"] if low and _share(low) < rule["pct"] else False
if r == "ward":
targeted = {e["target"] for e in enemies}
bare = [u for u in party if not any(a["kind"] == "shield" for a in u["auras"])
and (u["id"] in targeted or _share(u) < rule["pct"])]
return min(bare, key=_share)["id"] if bare else False
if r == "party_hot":
return None if any(_share(u) < rule["pct"] and not _has(u, ab["id"], unit["id"]) for u in party) else False
if r == "cc":
free = [e for e in enemies if e["cc"] <= t]
if len(free) < 2:
return False
focus = {u["target"] for u in party if u["role"] == "tank"} | {unit["target"]}
spare = [e for e in free if not e["boss"] and e["id"] not in focus and _share(e) > 0.5]
return max(spare, key=lambda e: e["hp"])["id"] if spare else False
if r == "dot":
return target["id"] if target and not _has(target, ab["id"], unit["id"]) and _share(target) > 0.3 else False
if r == "debuff":
return target["id"] if target and not _has(target, ab["id"]) and _share(target) > rule["pct"] else False
return False
# --- their side ----------------------------------------------------------------
def _enemy_think(state: dict, enemy: dict, t: float, rng: random.Random) -> None:
if enemy["stun"] > t or enemy["cc"] > t:
enemy["cast"] = None
return
_enemy_target(state, enemy, t)
if enemy["cast"] or not enemy["target"]:
return
for slot in enemy["abil"]:
if t < slot["next"]:
continue
spec = ENEMY_ABILITIES[slot["id"]]
slot["next"] = t + spec["every"]
target = enemy["target"]
if spec["target"] == "soft":
soft = [u for u in _party(state) if u["role"] != "tank"]
target = rng.choice(soft)["id"] if soft else target
elif spec["target"] == "all":
target = None
if spec["cast"]:
enemy["cast"] = {"ab": slot["id"], "target": target, "start": t, "end": t + spec["cast"]}
_event(state, t, enemy["id"], target, "cast", 0, slot["id"])
else:
_enemy_effect(state, enemy, slot["id"], target, t, rng)
return
def _enemy_target(state: dict, enemy: dict, t: float) -> None:
units = state["units"]
taunt = enemy["taunt"]
if taunt and taunt["until"] > t and _alive(units[taunt["by"]]):
enemy["target"] = taunt["by"]
return
table = {uid: v for uid, v in enemy["threat"].items() if _alive(units[uid])}
if not table:
party = _party(state)
enemy["target"] = party[0]["id"] if party else None
if party:
enemy["threat"][party[0]["id"]] = 1.0
enemy["since"] = t
return
top = max(table, key=table.get)
current = enemy["target"]
if current in table and table[top] <= table[current] * STICKY:
return
enemy["target"], enemy["since"] = top, t
def _enemy_effect(state: dict, enemy: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
spec = ENEMY_ABILITIES[ab_id]
kind, c, school = spec["effect"]
amount = c * (enemy["max_hit"] + 1) / 2 * enemy["enraged"]
if kind == "hit":
target = state["units"].get(target_id or "")
if target and _alive(target):
_hurt(state, enemy, target, amount * (1 + rng.uniform(-SPREAD, SPREAD)),
school or enemy["attack"][0], t, rng, label=ab_id, crit=False)
else:
_event(state, t, enemy["id"], None, "use", 0, ab_id)
for unit in _party(state):
_hurt(state, enemy, unit, amount * (1 + rng.uniform(-SPREAD, SPREAD)), school, t, rng,
label=ab_id, crit=False)
# --- blows ---------------------------------------------------------------------
def _swing(state: dict, unit: dict, t: float, rng: random.Random) -> None:
if t < unit["next"] - 1e-9 or unit["cast"] or unit["stun"] > t or unit.get("cc", 0) > t:
return
target = state["units"].get(unit["target"] or "")
if not target or not _alive(target):
return
unit["next"] = t + unit["interval"]
if unit["side"] == "party":
top = max(2, round(unit["base"] * _affinity(target, unit["damage"])))
if rng.random() >= hit_chance(unit["accuracy"], target["evasion"]):
_event(state, t, unit["id"], target["id"], "miss")
return
crit = rng.random() < unit["crit"]
amount = top * CRIT_MULT if crit else rng.randint(1, top)
_hurt(state, unit, target, amount, unit["damage"], t, rng, crit=False, critted=crit, swing=True)
return
attack = unit["attack"][unit["swings"] % len(unit["attack"])]
unit["swings"] += 1
if rng.random() >= hit_chance(unit["accuracy"], target["evasion"]):
_event(state, t, unit["id"], target["id"], "miss", 0, attack)
return
if rng.random() < target["block"]:
_event(state, t, unit["id"], target["id"], "block", 0, attack)
return
crit = rng.random() < ENEMY_CRIT
amount = (unit["max_hit"] * CRIT_MULT if crit else rng.randint(1, unit["max_hit"])) * unit["enraged"]
_hurt(state, unit, target, amount, attack, t, rng, crit=False, critted=crit, swing=True)
def _hurt(state: dict, source: dict | None, target: dict, amount: float, school: str, t: float,
rng: random.Random, threat: float = 1.0, crit: bool = True, critted: bool = False,
swing: bool = False, label: str = "", tick: bool = False) -> None:
if not _alive(target) or amount <= 0:
return
if crit and source and source["side"] == "party" and rng.random() < source["crit"]:
amount, critted = amount * CRIT_MULT, True
if source:
for aura in source["auras"]:
if aura["kind"] == "done":
amount *= aura["mult"]
for aura in target["auras"]:
if aura["kind"] in ("taken", "vuln"):
amount *= aura["mult"]
if target["side"] == "party":
amount *= target["taken"]["magic" if school == "magic" else "physical"]
amount = max(1, round(amount))
dealt = amount
for aura in list(target["auras"]):
if aura["kind"] == "shield" and amount:
soak = min(amount, round(aura["amount"]))
aura["amount"] -= soak
amount -= soak
if aura["amount"] < 1:
target["auras"].remove(aura)
target["hp"] -= amount
if source and source["side"] == "party":
source["dealt"] += dealt
if target["side"] == "party":
target["hurt"] += amount
kind = "absorb" if not amount else "tick" if tick else "crit" if critted else "hit"
_event(state, t, source["id"] if source else None, target["id"], kind, dealt, label or school)
if target["side"] == "enemy" and source:
boost = TANK_THREAT if source.get("role") == "tank" else 1
target["threat"][source["id"]] = target["threat"].get(source["id"], 0) + dealt * threat * boost
if target["cc"] > t:
target["cc"] = 0
_event(state, t, source["id"], target["id"], "free")
if target["hp"] <= 0:
_fall(state, target, t)
def _heal(state: dict, source: dict | None, target: dict, amount: float, t: float, label: str,
tick: bool = False) -> None:
if not target or not _alive(target):
return
gain = max(0, min(round(amount), target["top"] - target["hp"]))
target["hp"] += gain
_event(state, t, source["id"] if source else target["id"], target["id"], "mend" if tick else "heal", gain, label)
if source and gain:
_heal_threat(state, source, gain)
def _heal_threat(state: dict, source: dict, amount: float) -> None:
enemies = _enemies(state)
for enemy in enemies:
enemy["threat"][source["id"]] = enemy["threat"].get(source["id"], 0) + amount * HEAL_THREAT / len(enemies)
def _fall(state: dict, unit: dict, t: float) -> None:
if unit["side"] == "enemy":
unit["dead"], unit["hp"], unit["cast"], unit["auras"] = True, 0, None, []
state["kills"].append(unit["id"])
_event(state, t, None, unit["id"], "die")
for other in _party(state):
if other["target"] == unit["id"]:
other["target"] = None
return
if unit["wind"]:
# Second Wind: the knockout that was coming turns into half health, once per outing.
unit["wind"], unit["wind_used"] = False, True
unit["hp"] = unit["top"] / 2
_event(state, t, unit["id"], unit["id"], "wind")
return
unit["down"] = True
_out(state, unit, "down", t)
def _out(state: dict, unit: dict, why: str, t: float) -> None:
unit["out"], unit["cast"], unit["auras"] = why, None, []
_event(state, t, unit["id"], unit["id"], why)
for enemy in _enemies(state):
enemy["threat"].pop(unit["id"], None)
if enemy["target"] == unit["id"]:
enemy["target"] = None

View file

@ -27,7 +27,7 @@
chmod -R u+w source
cd source
python3 -m unittest discover -s tests -v
python3 -m py_compile app.py game.py
python3 -m py_compile app.py game.py combat.py
node --check static/app.js
touch $out
'';

451
game.py
View file

@ -12,6 +12,8 @@ import math
import random
from datetime import date
import combat
HOUR = 3600
DAY = 24 * HOUR
OFFLINE_CAP = 24 * HOUR
@ -22,8 +24,10 @@ LUCKY_CHANCE = 0.1
OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose
MAX_LOG = 200
REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour
EAT_BELOW = 0.5 # auto-eat when HP drops under this share of max
TOO_HURT = 0.3 # won't start a fight below this share without food
REST_RATE = 0.01 # share of max HP back per game second resting between fights...
BITE = 2.0 # ...and each bite of food takes this long, so food rests you faster
KNOCKED_OUT_HP = 0.3 # share of max HP you come round with after being knocked out
KNOCKOUTS_TO_STOP = 3 # in a row, and you stop fighting
def _xp_table(max_level: int = 99) -> list[int]:
@ -57,7 +61,7 @@ ITEMS = {
"bricks": {"name": "Bricks", "icon": "🧱", "value": 3},
"cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5},
"beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "🪜", "value": 25},
# `food` feeds that many townsfolk for two days; `heal` restores HP in a fight.
# `food` feeds that many townsfolk for two days; `heal` restores HP resting between fights.
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8},
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20},
@ -467,6 +471,14 @@ for _id, _name, _level, _herb, _second, _skill, _effect in POTIONS:
"value": ITEMS[_herb]["value"] * 3 + ITEMS[_second]["value"],
"potion": {"skill": _skill, **_effect, "text": describe_potion(_skill, _effect)}}
# Health potions: the one heal you take into a fight, drunk once a fight when you're low.
# Each tier is the tier's herb and its cooked fish.
HEALTH_HEAL = [20, 40, 65, 90, 115, 140]
HEALTH_POTIONS = [(f"health_potion_{_tier + 1}", f"Health potion {ROMAN[_tier]}", TIER_LEVELS[_tier], HERBS[_tier][0],
FISH[_tier][1][0], HEALTH_HEAL[_tier]) for _tier in range(len(HEALTH_HEAL))]
for _id, _name, _level, _herb, _fish, _heal in HEALTH_POTIONS:
ITEMS[_id] = {"name": _name, "icon": "❤️", "value": ITEMS[_herb]["value"] * 3 + ITEMS[_fish]["value"], "drink": _heal}
# What the city eats first: plain food before the good stuff.
FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"]))
EQUIP_SLOTS = (["weapon", "shield", "helmet", "body", "legs", "ring", "amulet", "trinket", "storage", "bag"]
@ -624,11 +636,14 @@ def _tiered_actions() -> list[dict]:
# expeditions: one area per tier, each with four races (each feeding one weapon
# line with its trophy, and every armour line with the area's reagents) and a boss.
CRIT_CHANCE = 0.05
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
CRIT_MULT = combat.CRIT_MULT
RESPAWN = 2 # the least rest between one fight and the next
GROUP_SIZE = 2 # bosses and dragons are only fought this many together...
BOSS_HP = 0.75 # ...and grow by this much of their HP for each fighter...
BOSS_HITS = (1.3, 0.15) # ...and hit this hard against a pair, and this much harder for each fighter more
MAX_FIGHT = combat.MAX_FIGHT
PACK_EXTRA = 0.5 # chance a pack has one more enemy than there are of you...
OUTNUMBERED = (0.65, 0.65) # ...each of them then with this share of the HP and the hits
DAMAGE_TYPES = {
"slash": {"name": "Slashing", "glyph": "sword-slice", "tint": "#d4dae2"},
@ -663,14 +678,16 @@ def _slug(name: str) -> str:
return "".join(c if c.isalnum() else "_" for c in name.lower().replace("'", "")).strip("_")
TOUGHER = 1.2 # enemies have this much more HP since everyone has abilities, for the same XP
# Archetypes: HP, seconds between swings, accuracy, evasion, max hit (multipliers), how often it turns up.
ARCHETYPES = {
"brute": (1.3, 3.0, 1.0, 0.85, 1.2, 4),
"skirmisher": (0.85, 2.2, 1.1, 1.25, 0.85, 4),
"caster": (0.85, 2.6, 1.15, 1.0, 1.25, 3),
"elite": (1.7, 2.8, 1.1, 1.1, 1.3, 1),
"boss": (1.4, 3.0, 1.0, 1.0, 1.35, 1),
"dragon": (2.5, 3.0, 1.1, 1.0, 1.5, 1),
"boss": (1.4, 3.0, 1.0, 1.0, 1.2, 1),
"dragon": (2.5, 3.0, 1.1, 1.0, 1.9, 1),
}
@ -682,7 +699,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.13 + level * 0.5),
"hp": round(hp * TOUGHER), "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)}
@ -704,9 +721,10 @@ def _encounter(id: str, name: str, group: str, level: int, roster: list, gold: i
def _boss(id: str, name: str, group: str, level: int, archetype: str, attack, vulnerable, resists, **extra) -> dict:
"""One big enemy: everyone who brings it down gets their own loot."""
"""One big enemy, and a group fight: alone you wait for someone to tag in. Everyone who brings
it down gets their own loot."""
return _encounter(id, name, group, level, [(name, archetype, attack, vulnerable, resists)],
boss=extra.pop("is_boss", True), personal_loot=True, **extra)
boss=extra.pop("is_boss", True), personal_loot=True, together=GROUP_SIZE, **extra)
# Rosters: (variant, archetype, attacks with, vulnerable to, resists).
@ -941,7 +959,7 @@ 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(),
# Area bosses open at the area's level + 5, halfway through it: hard alone, comfortable together.
# Area bosses open at the area's level + 5, halfway through it. Bosses and dragons are group fights.
_boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 26, "boss", ("blunt", "magic"), ("slash", "magic"),
("blunt", "pierce"), gold=25, req=20,
drops=[("iron_bar", 2, 0.5), ("iron_sword", 1, 0.05), ("big_bones", 1, 1.0), *_boss_drops(1)]),
@ -974,6 +992,9 @@ ACTIONS = [
_fuelled({herb: 1, second: 1}, POTION_TIER[potion]), {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}",
fire="still", fuel=POTION_TIER[potion])
for potion, name, level, herb, second, skill, _effect in POTIONS],
*[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6),
_fuelled({herb: 1, fish: 1}, tier), {potion: 1}, group=f"Tier {ROMAN[tier]}", fire="still", fuel=tier)
for tier, (potion, name, level, herb, fish, _heal) in enumerate(HEALTH_POTIONS)],
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}),
_action("hunt_deer", "hunting", "Hunt deer", 1, 25, 6, outputs={"raw_meat": 2, "hide": 1}),
_action("hunt_boar", "hunting", "Hunt boar", 15, 35, 15, outputs={"raw_meat": 2, "boar_hide": 1}),
@ -1140,7 +1161,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 25
WORLD_VERSION = 26
def new_world(now: float, speed: float = 1.0) -> dict:
@ -1337,6 +1358,14 @@ def migrate(world: dict, now: float | None = None) -> dict:
if player["action"]:
player["action"].pop("fight", None)
world["version"] = 25
if world["version"] < 26:
# Packs and abilities: fights in progress are drawn up again.
for player in world["players"].values():
if player["action"]:
player["action"].pop("fight", None)
for party in world["parties"].values():
party["fight"] = None
world["version"] = 26
return world
@ -1722,13 +1751,17 @@ def max_hp(player: dict) -> int:
return 40 + 4 * skill_level(player, "combat") + int(gear_bonus(player, "hp"))
def regen_boost(world: dict | None, player: dict) -> float:
"""How much faster than usual you heal: the enchanted hearths, and amulets of regeneration."""
boost = 2 if world and fitted(world, "enchanted_hearths") else 1
return boost * (1 + gear_bonus(player, "regen") / 100)
def regen(world: dict, player: dict, now: float) -> None:
"""Heal for the time since HP was last settled."""
if now > player["hp_at"]:
top = max_hp(player)
boost = 2 if fitted(world, "enchanted_hearths") else 1
boost *= 1 + gear_bonus(player, "regen") / 100
per_second = top * REGEN_PER_HOUR * boost * speed(world) / HOUR
per_second = top * REGEN_PER_HOUR * regen_boost(world, player) * speed(world) / HOUR
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
player["hp_at"] = now
@ -1746,20 +1779,23 @@ def affinity(enemy: dict, damage: str) -> float:
def fighter(player: dict, blessed: dict, brew: dict, enemy: dict | None = None) -> dict:
"""Your side of a fight: power from level and gear (plus prayers and potions) aims and hits, shaped by
the weapon's style, your trade and how the enemy takes its damage type. Armour soaks physical blows,
ward soaks magic, both help you dodge, and a shield blocks."""
ward soaks magic, both help you dodge, and a shield blocks. The weapon and your trade give your
abilities; a sword and shield hold the line, a staff heals."""
style_id = weapon_style(player)
style = STYLES[style_id]
power = combat_power(player) + blessed["power"] + brew.get("power", 0)
damage = style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1)
base = (2 + power / 3) * style["hit"] * (CLASS_STYLE_BONUS if CLASS_STYLE.get(player["cls"]) == style_id else 1)
lands = affinity(enemy, style["damage"]) if enemy else 1
taken = blessed["taken"] * (1 - brew.get("reduce", 0))
return {"style": style_id, "damage": style["damage"],
shield = bool(player["equip"].get("shield"))
return {"style": style_id, "damage": style["damage"], "role": combat.role(style_id, shield),
"abilities": combat.known_abilities(style_id, player["cls"], skill_level(player, "combat"), shield),
"interval": round(style["interval"] * (1 - gear_bonus(player, "haste") / 100), 2),
"accuracy": (10 + 2 * power) * style["accuracy"],
"max_hit": max(2, round((2 + power / 3) * damage * lands)),
"base": base, "max_hit": max(2, round(base * lands)),
"crit": style["crit"] + gear_bonus(player, "crit") / 100,
"first": style.get("first", False), "affinity": lands,
"block": BLOCK_CHANCE if player["equip"].get("shield") else 0,
"block": BLOCK_CHANCE if shield else 0,
"evasion": 10 + 2 * (skill_level(player, "combat") + defence_bonus(player)),
"taken": {"physical": (1 - damage_reduction(player)) * taken, "magic": (1 - reduction(magic_ward(player))) * taken}}
@ -1768,62 +1804,91 @@ def _taken(me: dict, damage: str) -> float:
return me["taken"]["magic" if damage == "magic" else "physical"]
def hit_chance(accuracy: float, evasion: float) -> float:
return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15))
hit_chance = combat.hit_chance
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, 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."""
def rest_plan(world: dict | None, player: dict, hp: float | None = None) -> tuple[float, dict]:
"""Resting back to full before a fight: how many game seconds, and the food eaten on the way.
A bite is only taken when it's quicker than resting, so better food makes for shorter rests."""
top = max_hp(player)
missing = top - (player["hp"] if hp is None else hp)
rate = top * REST_RATE * regen_boost(world, player)
seconds, eaten = 0.0, {}
for food in edible(player):
heal, left = ITEMS[food]["heal"], player["bank"].get(food, 0)
while missing > 0 and left and min(heal, missing) / rate > BITE:
eaten[food] = eaten.get(food, 0) + 1
left -= 1
missing -= heal
seconds += BITE
return max(RESPAWN, seconds + max(0.0, missing) / rate), eaten
def health_potion(player: dict) -> str | None:
"""The strongest health potion in the bank: the one you'd drink in a fight."""
potions = [item for item in player["bank"] if ITEMS[item].get("drink")]
return max(potions, key=lambda item: ITEMS[item]["drink"]) if potions else None
# How much abilities add to a weapon's swings, and an archetype's abilities to its blows, on
# average (from tools/balance.py). Only the encounter cards' estimates use them.
ABILITY_SHARE = {"sword": 1.15, "hammer": 1.45, "staff": 1.35, "bow": 1.3}
ENEMY_SHARE = {"brute": 1.4, "skirmisher": 1.3, "caster": 1.6, "elite": 1.5, "boss": 1.6, "dragon": 1.8}
GUARD_SHARE = {"sword": 0.73, "hammer": 0.85, "staff": 0.33, "bow": 0.72} # what your abilities keep off you
PACK_SHARE = 1 + PACK_EXTRA * (2 * OUTNUMBERED[0] - 1) # a pack's HP, for one of you, against a lone enemy's
def _variant_odds(player: dict, foe: dict, blessed: dict, brew: dict, pack: float = 1.0) -> dict:
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"])
dps = hit * _average_hit(me["max_hit"], me["crit"]) / me["interval"] * ABILITY_SHARE[me["style"]]
kill = min(foe["hp"] * pack / dps, MAX_FIGHT)
taken = sum(_taken(me, kind) for kind in foe["attack"]) / len(foe["attack"])
per_second = their_hit * (1 - me["block"]) * _average_hit(foe["max_hit"]) * taken / foe["interval"]
per_second *= ENEMY_SHARE[foe["archetype"]] * GUARD_SHARE[me["style"]] * pack
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}
"kill_time": kill, "damage": per_second * kill}
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."""
def fight_odds(player: dict, action: dict, world: dict | None = None) -> dict:
"""What a fight in this encounter looks like on average, for the cards, and against each variant:
the time to win it, the damage it costs, and the rest after it."""
blessed, brew = blessing(player), potion_effect(player, "combat")
variants = action["enemy"]["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]
pack = PACK_SHARE if len(variants) > 1 else BOSS_HP if action.get("together") else 1 # a boss shared by a pair
rows = [_variant_odds(player, foe, blessed, brew, pack) 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
top = max_hp(player)
rest = rest_plan(world, player, top - min(top, mean("damage")))[0]
return {"hit": round(mean("hit"), 2), "their_hit": round(mean("their_hit"), 2), "style": weapon_style(player),
"max_hit": max(row["max_hit"] for row in rows), "their_max": max(row["their_max"] for row in rows),
"kill_time": round(mean("kill_time"), 1), "damage": round(mean("damage"), 1),
"kill_time": round(mean("kill_time"), 1), "damage": round(mean("damage"), 1), "rest": round(rest, 1),
"variants": [{"affinity": row["affinity"], "kill_time": round(row["kill_time"], 1),
"damage": round(row["damage"], 1)} for row in rows]}
def fight_member(world: dict, player: dict, foe: dict, at: float = 0.0) -> dict:
def fight_member(world: dict, player: dict, at: float = 0.0) -> dict:
"""One side of a fight as it squares up (or tags in `at` seconds of game time in): kept with the
fight, so playing it out again later goes the same way."""
blessed, brew = blessing(player), potion_effect(player, "combat")
me = fighter(player, blessed, brew, foe)
me = fighter(player, blessed, brew)
potion = health_potion(player)
return {"id": player["id"], "name": player["name"], "at": at, "left": None,
"start_hp": round(player["hp"]), "top": max_hp(player), "style": me["style"],
"interval": me["interval"] / speed(world), "fighter": me, "hp_in": player["hp"],
"pantry": {item: player["bank"][item] for item in edible(player)}, "mend": blessed["mend"],
"start_hp": round(player["hp"]), "hp_in": player["hp"], "top": max_hp(player), "style": me["style"],
"role": me["role"], "abilities": me["abilities"], "interval": me["interval"] / speed(world),
"fighter": me, "eaten": {}, "potion": ITEMS[potion]["drink"] if potion else 0, "potion_item": potion,
"arrows": player["bank"].get("arrows", 0), "mend": blessed["mend"],
"wind": blessed["wind"] and not (player["action"] or {}).get("wind_used"), "pp": blessed["cost"]}
FIGHT_PRIVATE = ("fighter", "hp_in", "pantry", "mend", "wind")
FIGHT_PRIVATE = ("fighter", "mend", "wind", "arrows")
def fight_view(fight: dict) -> dict:
@ -1836,120 +1901,60 @@ def member_of(fight: dict, uid: str) -> dict:
return next(member for member in fight["members"] if member["id"] == uid)
def draw_fight(world: dict, players: list, action: dict, rng: random.Random) -> dict:
"""Pick the enemy and play the fight out for everyone in it."""
def draw_pack(action: dict, size: int, rng: random.Random) -> list[dict]:
"""Who you're up against: one of the encounter's variants, as many as there are of you and
often one more, weaker each when they outnumber you. A rare elite or a boss comes alone."""
variants = action["enemy"]["variants"]
variant = rng.choices(range(len(variants)), [v["weight"] for v in variants])[0]
foe = variants[variant]
pace = speed(world)
fight = {"seed": rng.random(), "variant": variant, "pace": pace, "enemy": foe["name"], "enemy_hp": foe["hp"],
"xp": foe["xp"], "enemy_interval": foe["interval"] / pace,
"foe": {key: foe[key] for key in ("archetype", "attack", "vulnerable", "resists")},
"members": [fight_member(world, player, foe) for player in players]}
return play_fight(action, fight)
weights = [foe["weight"] for foe in variants]
first = rng.choices(range(len(variants)), weights)[0]
picks, (hp, hits) = [first], (1.0, 1.0)
if action.get("together"):
hp, hits = BOSS_HP * size, BOSS_HITS[0] * (1 + BOSS_HITS[1] * (size - GROUP_SIZE))
if len(variants) > 1 and variants[first]["archetype"] != "elite":
count = size + (rng.random() < PACK_EXTRA)
common = [i for i, foe in enumerate(variants) if foe["archetype"] != "elite"]
picks += rng.choices(common, [weights[i] for i in common], k=count - 1)
if count > size:
hp, hits = OUTNUMBERED
share = 1.0 if action.get("together") else hp # a boss grown for its group still pays each of them one boss
return [{**{key: variants[i][key] for key in ("name", "archetype", "level", "boss", "interval", "accuracy",
"evasion", "attack", "vulnerable", "resists")},
"variant": i, "hp": max(1, round(variants[i]["hp"] * hp)), "max_hit": max(1, round(variants[i]["max_hit"] * hits)),
"xp": variants[i]["xp"] * share, "share": share} for i in picks]
def draw_fight(world: dict, players: list, action: dict, rng: random.Random) -> dict:
"""Draw up the pack, rest everyone back to full (the longest rest is everyone's), and play the
fight out."""
members, rest = [], 0.0
for player in players:
seconds, eaten = rest_plan(world, player)
member = fight_member(world, player)
member.update(eaten=eaten, hp_in=member["top"])
rest = max(rest, seconds)
members.append(member)
fight = {"seed": rng.random(), "pace": speed(world), "rest": rest, "enemy": action["enemy"]["name"],
"pack": draw_pack(action, len(players), rng), "members": members}
return play_fight(fight)
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict:
return draw_fight(world, [player], action, rng)
def play_fight(action: dict, fight: dict) -> dict:
"""Play out a fight swing by swing, every swing timer starting empty (a latecomer's from when they
tag in). Nothing changes until it's over: the result is applied then. The dice come from the fight's
own seed, so playing it again with someone tagging in or stepping out changes nothing before then."""
rng = random.Random(fight["seed"])
foe = action["enemy"]["variants"][fight["variant"]]
pace = fight["pace"]
sides = []
for member in fight["members"]:
me = member["fighter"]
# A bow looses its first arrow before the enemy closes in.
sides.append({"member": member, "me": me, "hp": member["hp_in"], "pantry": dict(member["pantry"]),
"next": member["at"] + (0.5 if me["first"] else me["interval"]), "eaten": {}, "hurt": 0,
"dealt": 0, "wind": member["wind"], "out": None,
"hit": hit_chance(me["accuracy"], foe["evasion"]), "their_hit": hit_chance(foe["accuracy"], me["evasion"])})
foe_hp, theirs, events, t, won = foe["hp"], foe["interval"], [], 0.0, False
swings = 0 # an enemy with more than one attack takes turns with them
while True:
# The next thing to happen: someone stepping out, someone's swing (in order of who came first), the enemy's.
moves = [(theirs, 2, -1)]
for i, side in enumerate(sides):
if not side["out"]:
if side["member"]["left"] is not None:
moves.append((side["member"]["left"], 0, i))
moves.append((side["next"], 1, i))
if len(moves) == 1:
break
t, kind, i = min(moves)
if t > MAX_FIGHT:
for side in sides:
side["out"] = side["out"] or "fled"
break
if kind == 0:
sides[i]["out"] = "left"
continue
if kind == 1:
side, me = sides[i], sides[i]["me"]
top = side["member"]["top"]
side["next"] += me["interval"]
side["hp"] = min(top, side["hp"] + side["member"]["mend"] * top * me["interval"]) # Rapid Heal mends a little each swing
crit = rng.random() < me["crit"]
landed = rng.random() < side["hit"]
dealt = max(1, round(me["max_hit"] * CRIT_MULT if crit else rng.randint(1, me["max_hit"]))) if landed else 0
foe_hp = max(0, foe_hp - dealt)
side["dealt"] += dealt
events.append([round(t / pace, 2), side["member"]["id"], ("crit" if crit else "hit") if landed else "miss",
dealt, round(side["hp"]), foe_hp, me["damage"]])
if not foe_hp:
won = True
break
continue
theirs += foe["interval"]
targets = [side for side in sides if not side["out"] and side["member"]["at"] <= t]
if not targets:
continue
side = targets[0] if len(targets) == 1 else rng.choice(targets)
me, uid, top = side["me"], side["member"]["id"], side["member"]["top"]
attack = foe["attack"][swings % len(foe["attack"])]
swings += 1
crit = rng.random() < CRIT_CHANCE
landed = rng.random() < side["their_hit"]
if landed and rng.random() < me["block"]:
events.append([round(t / pace, 2), "foe", "block", 0, max(0, round(side["hp"])), foe_hp, attack, uid])
continue
dealt = max(1, round((foe["max_hit"] * CRIT_MULT if crit else rng.randint(1, foe["max_hit"])) * _taken(me, attack))) \
if landed else 0
side["hp"] -= dealt
side["hurt"] += dealt
events.append([round(t / pace, 2), "foe", ("crit" if crit else "hit") if landed else "miss", dealt,
max(0, round(side["hp"])), foe_hp, attack, uid])
pantry = side["pantry"]
while side["hp"] < top * EAT_BELOW and any(pantry.values()):
food = next(item for item, left in pantry.items() if left)
pantry[food] -= 1
side["eaten"][food] = side["eaten"].get(food, 0) + 1
side["hp"] = min(top, side["hp"] + ITEMS[food]["heal"])
side["next"] += EAT_DELAY
events.append([round(t / pace, 2), uid, "eat", ITEMS[food]["heal"], round(side["hp"]), foe_hp, food])
beaten, starving = side["hp"] <= 0, side["hp"] < top * FLEE_BELOW and not any(pantry.values())
if (beaten or starving) and side["wind"]:
# Second Wind: the retreat that was coming turns into half health, once per outing.
side["wind"] = False
side["hp"] = top / 2
events.append([round(t / pace, 2), uid, "wind", 0, round(side["hp"]), foe_hp])
elif beaten:
side["hp"], side["out"] = 1, "lost"
elif starving:
side["out"] = "fled"
for side in sides:
member = side["member"]
member.update(hp=side["hp"], eaten=side["eaten"], damage=side["hurt"], dealt=side["dealt"],
wind_used=member["wind"] and not side["wind"],
result=side["out"] or ("won" if won and member["at"] <= t else "left"))
standing = [side for side in sides if side["out"] not in ("left", None)]
fight.update(events=events, end=round(t / pace, 2), duration=t / pace,
result="won" if won else standing[-1]["out"] if standing else "left")
return fight
def play_fight(fight: dict) -> dict:
"""Play a fight out, slice by slice, from the start (see combat.py). Nothing changes until it's over:
the result is applied then. The dice come from the fight's own seed, so playing it again with
someone tagging in or stepping out changes nothing before then."""
return combat.run(fight)
def pack_xp(fight: dict, killed_only: bool = False) -> float:
"""The pack's XP: on a loss, the enemies brought down and a third of the rest."""
if not killed_only:
return sum(foe["xp"] for foe in fight["pack"])
kills = set(fight["kills"])
return sum(foe["xp"] * (1 if f"e{i}" in kills else 1 / 3) for i, foe in enumerate(fight["pack"]))
# --- prayer -------------------------------------------------------------------
@ -2296,8 +2301,6 @@ 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 "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
@ -2352,9 +2355,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
if "enemy" in action:
outing = player["action"]
fight = fight or outing.pop("fight", None) or simulate_fight(world, player, action, rng)
outing.pop("carry", None) # from a short-lived version that ran the swing timer on
xp = fight.get("xp", action["xp"]) * mods["xp"] # the variant you met
mine = member_of(fight, player["id"])
xp = pack_xp(fight, killed_only=mine["result"] != "won") * mods["xp"]
if player["prayers"] and not mine["pp"]:
summary["prayer_out"] = True
paid = min(player["pp"], mine["pp"])
@ -2362,26 +2364,33 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
player["pp"] -= paid
summary["pp_spent"] += paid
gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary)
for food, count in mine["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"] = mine["hp"], now
used = {**({mine["potion_item"]: 1} if mine["drank"] else {}), **({"arrows": mine["shot"]} if mine["shot"] else {})}
for key, spent in (("eaten", mine["eaten"]), ("used", used)):
for item, count in spent.items():
count = min(count, player["bank"].get(item, 0))
if count:
bank_take(player, item, count)
summary[key][item] = summary[key].get(item, 0) + count
player["hp"] = max_hp(player) * KNOCKED_OUT_HP if mine["down"] else mine["hp"]
player["hp_at"] = now
summary["damage"] += mine["damage"]
if mine["wind_used"]:
outing["wind_used"] = True
summary["second_wind"] += 1
if mine["result"] != "won":
# Beaten or out of food: fall back with nothing but your life.
# Knocked out, or the fight dragged on: nothing but some XP, and back to resting.
summary["losses"] += 1
reason = "Retreated: ran out of food" if not edible(player) else "Retreated"
drop_out(world, player, now, reason)
summary["stopped"] = reason
gain_xp(world, player, "combat", xp // 3, now, summary)
gain_xp(world, player, "combat", xp, now, summary)
outing["downs"] = outing.get("downs", 0) + 1
reason = "Fell back: the fight dragged on" if mine["result"] == "fled" else \
f"Knocked out {KNOCKOUTS_TO_STOP} times in a row" if outing["downs"] >= KNOCKOUTS_TO_STOP else None
if reason:
drop_out(world, player, now, reason)
summary["stopped"] = reason
return
outing.pop("downs", None)
summary["wins"] += 1
gold, drops = loot or roll_loot(action, rng)
gold, drops = loot or pack_loot(action, fight, rng)
player["gold"] += gold
summary["gold"] += gold
for item, qty in drops.items():
@ -2472,7 +2481,7 @@ def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summar
def empty_summary() -> dict:
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "used": {}, "damage": 0,
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None,
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0,
"kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 0}
@ -2495,6 +2504,11 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
if not current:
return summary
limit = min(now, offline_limit(world, player))
if ACTION_BY_ID[current["id"]].get("together"):
# A group fight with no group: wait at it for someone to tag in.
current.pop("fight", None)
current["start"] = max(current["start"], limit)
return summary
while player["action"]:
action = growing(player)
if current.get("plot2") and can_start(world, player, action):
@ -2512,15 +2526,6 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
break
burn_fires(world, current["start"] + duration)
reason = can_start(world, player, action)
if reason and reason.startswith("Too hurt"):
if blessing(player)["wind"] and not current.get("wind_used"):
# Second Wind: the retreat that was coming turns into half health.
current["wind_used"] = True
player["hp"] = max_hp(player) / 2
summary["second_wind"] += 1
reason = None
else:
reason = "Retreated: hurt and out of food"
if reason:
stop(player, reason)
summary["stopped"] = reason
@ -2533,8 +2538,6 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
_complete(world, player, action, current["start"] + duration, rng, summary)
summary["done"] += 1
current["start"] += duration
if "enemy" in action and player["action"] is current:
current["start"] += RESPAWN / speed(world) # a moment before the next enemy turns up
if current.get("plot2"):
current["turn"] = 1 - current.get("turn", 0)
if player["action"] is current and "left" in current:
@ -2574,12 +2577,23 @@ def drop_out(world: dict, player: dict, now: float, reason: str | None = None) -
stop(player, reason)
def roll_loot(action: dict, rng: random.Random) -> tuple[int, dict]:
"""A kill's gold and drops."""
gold = rng.randint(*action["gold"]) if action.get("gold") else 0
def roll_loot(action: dict, rng: random.Random, share: float = 1.0) -> tuple[int, dict]:
"""A kill's gold and drops. A weaker enemy in a pack (`share`) carries less, and drops less often."""
gold = round(rng.randint(*action["gold"]) * share) if action.get("gold") else 0
drops = {}
for item, qty, odds in action.get("drops", []):
if rng.random() < odds:
if rng.random() < odds * share:
drops[item] = drops.get(item, 0) + qty
return gold, drops
def pack_loot(action: dict, fight: dict, rng: random.Random) -> tuple[int, dict]:
"""Everything the pack carried."""
gold, drops = 0, {}
for foe in fight["pack"]:
more, found = roll_loot(action, rng, foe["share"])
gold += more
for item, qty in found.items():
drops[item] = drops.get(item, 0) + qty
return gold, drops
@ -2651,6 +2665,8 @@ def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None:
pid = str(world["next_party_id"])
world["next_party_id"] += 1
solo = host["action"]
if action.get("together"):
solo["start"] = now # waiting for a group: it starts as you arrive
party = world["parties"][pid] = {"id": pid, "action": solo["id"], "members": [host["id"]],
"start": solo["start"], "fight": solo.pop("fight", None)}
solo["party"] = pid
@ -2659,10 +2675,11 @@ def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None:
player["stopped"] = None
fight = party["fight"]
if fight and now < party["start"] + fight["duration"]:
at = max(0.0, (now - party["start"]) * fight["pace"])
foe = action["enemy"]["variants"][fight["variant"]]
fight["members"].append(fight_member(world, player, foe, at))
play_fight(action, fight)
# Straight in, as you are: the others may still be resting, or already in the thick of it.
regen(world, player, now)
at = max(0.0, (now - party["start"]) * fight["pace"] - fight["rest"])
fight["members"].append(fight_member(world, player, at))
play_fight(fight)
log(world, now, "party", f"{player['name']} joined {host['name']}: {action['name']}", player["id"])
return None
@ -2674,13 +2691,13 @@ def leave_party(world: dict, player: dict, pid: str, now: float) -> None:
party["members"].remove(player["id"])
fight = party["fight"]
if fight and now < party["start"] + fight["duration"]:
action = ACTION_BY_ID[party["action"]]
member = member_of(fight, player["id"])
if now <= party["start"] or len(fight["members"]) == 1:
fight["members"].remove(member)
left = (now - party["start"]) * fight["pace"] - fight["rest"]
if left <= member["at"] or len(fight["members"]) == 1:
fight["members"].remove(member) # gone before it started: nothing eaten, nothing fought
else:
member["left"] = max(member["at"], (now - party["start"]) * fight["pace"])
party["fight"] = play_fight(action, fight) if party["members"] and fight["members"] else None
member["left"] = left
party["fight"] = play_fight(fight) if party["members"] and fight["members"] else None
if len(party["members"]) <= 1:
del world["parties"][pid]
for uid in party["members"]:
@ -2703,15 +2720,6 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
for player in members:
regen(world, player, party["start"])
reason = can_start(world, player, action)
if reason and reason.startswith("Too hurt"):
if blessing(player)["wind"] and not player["action"].get("wind_used"):
# Second Wind: the retreat that was coming turns into half health.
player["action"]["wind_used"] = True
player["hp"] = max_hp(player) / 2
player.setdefault("away", empty_summary())["second_wind"] += 1
reason = None
else:
reason = "Retreated: hurt and out of food"
if reason:
drop_out(world, player, party["start"], reason)
player.setdefault("away", empty_summary())["stopped"] = reason
@ -2735,15 +2743,15 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None
break
burn_fires(world, end)
fight, party["fight"] = party["fight"], None
party["start"] = end + RESPAWN / speed(world) # a moment before the next enemy turns up
party["start"] = end
for player in members:
player["action"]["start"] = party["start"]
results = {m["id"]: m["result"] for m in fight["members"]}
winners = [uid for uid in party["members"] if results.get(uid) == "won"]
if action.get("personal_loot"):
shares = {uid: roll_loot(action, rng) for uid in winners}
shares = {uid: pack_loot(action, fight, rng) for uid in winners}
else:
shares = share_loot(roll_loot(action, rng), winners, rng) if winners else {}
shares = share_loot(pack_loot(action, fight, rng), winners, rng) if winners else {}
for player in members:
if results.get(player["id"], "left") == "left":
continue
@ -3101,6 +3109,8 @@ def start_action(world: dict, player: dict, action_id: str, now: float, once: bo
leave_party(world, player, current["party"], now)
player["action"] = entry
player["stopped"] = None
if action.get("together"):
log(world, now, "party", f"{player['name']} is looking for a group: {action['name']}", player["id"])
return None
@ -4711,6 +4721,8 @@ def _assign_glyphs() -> None:
for potion, *_rest, skill, effect in POTIONS:
tint = "#e05a5a" if "power" in effect else "#8fa8c8" if "reduce" in effect else SKILL_GLYPHS[skill][1]
ITEMS[potion].update(glyph=flasks[POTION_TIER[potion]], tint=tint)
for tier, (potion, *_rest) in enumerate(HEALTH_POTIONS):
ITEMS[potion].update(glyph=flasks[tier], tint="#e0506e")
for skill in SKILLS:
skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]]
for key, spec in CLASSES.items():
@ -4752,6 +4764,27 @@ ORDER_RECIPES, ORDER_RAW = _order_sources()
# What changed, shown on the Patch notes page. Newest first; every shipped change adds a
# line here (new / balance / fix), and a new release a new entry on top.
PATCH_NOTES = [
{"date": "2026-10-24", "title": "Combat rework", "notes": [
("new", "Fights are against packs: as many enemies as there are of you, often one more (each 35% weaker). "
"A rare elite still comes alone."),
("new", "Abilities: your weapon gives four (combat 1 / 15 / 30 / 45) and your trade one more. Sword and shield "
"tanks, warhammer hits hard and stuns, staff heals and holds, bow shoots from afar. All on auto for now: "
"hover them on the Combat page."),
("new", "Enemies fight back: brutes wind up Heavy Slams, skirmishers lunge at whoever isn't tanking, casters "
"cast Hex Bolts (which can be interrupted), elites and bosses enrage, dragons breathe fire. Enemies attack "
"whoever has the most threat, so a sword and shield draws them away from the rest."),
("new", "Food is only eaten resting between fights: you rest back to full before each one, and better food "
"rests you faster. No food just means longer rests."),
("new", "Health potions I–VI (Herblore 1–75, the tier's herb and cooked fish): the one heal you take into a "
"fight, drunk once a fight under 35% HP."),
("new", "Knocked out at 0 HP: you lose that fight and come round at 30% HP. Three in a row and you stop. "
"Retreating is gone."),
("new", "Bosses and dragons are group fights (2+). Start one alone and you wait there for someone to join. "
"They grow with the group: +75% HP for each fighter."),
("balance", "Enemies have 20% more HP for the same XP, since everyone has abilities now."),
("balance", "Boss hits: area bosses 1.35 → 1.2× and dragons 1.5 → 1.9× their level's max hit, then ×1.3 "
"against a pair."),
]},
{"date": "2026-10-23", "title": "City needs", "notes": [
("new", "City needs are now indicated in profession menus."),
]},
@ -4793,7 +4826,9 @@ def content() -> dict:
"prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS,
"potion_charges": POTION_CHARGES, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW,
"order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()),
"equip_slots": EQUIP_SLOTS, "eat_below": EAT_BELOW, "class_speed": CLASS_SPEED,
"equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE,
"abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD,
"weapon_kits": combat.WEAPON_KITS, "group_size": GROUP_SIZE, "trade_ability": combat.TRADE_ABILITY, "potion_below": combat.POTION_BELOW,
"styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE,
"resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
"regen_per_hour": REGEN_PER_HOUR,

View file

@ -10,7 +10,7 @@ stdenvNoCC.mkDerivation {
installPhase = ''
runHook preInstall
mkdir -p $out/share/guildhall $out/bin
cp app.py game.py $out/share/guildhall/
cp app.py game.py combat.py $out/share/guildhall/
cp -r static $out/share/guildhall/static
makeWrapper ${python3}/bin/python3 $out/bin/guildhall \
--add-flags "$out/share/guildhall/app.py"

View file

@ -192,6 +192,7 @@ function awayModal(sum) {
<p class="muted">You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.</p>
${Object.keys(sum.with || {}).length ? `<p class="muted small">⚔️ ${Object.entries(sum.with).map(([name, n]) => `${fmt(n)} with ${esc(name)}`).join(" · ")}</p>` : ""}
${Object.keys(sum.eaten || {}).length ? `<p class="muted small">Took ${fmt(sum.damage)} damage and ate ${Object.entries(sum.eaten).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.</p>` : ""}
${Object.keys(sum.used || {}).length ? `<p class="muted small">Used ${Object.entries(sum.used).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.</p>` : ""}
${items ? `<ul>${items}</ul>` : ""}
${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""}
${xp ? `<ul>${xp}</ul>` : ""}
@ -888,38 +889,58 @@ function variantRows(e, o) {
}).join("")}</div>`;
}
// Everything about fighting in one frame. Left: you, your prayers (switch them mid-fight) and your
// gear (swap it mid-fight). Right: what you're fighting, what it attacks with, what it's weak to and
// resists (your weapon's type outlined), and the fight as it plays out.
// Everything about fighting in one frame. Left: you and whoever's with you, your abilities (on auto),
// your prayers (switch them mid-fight) and your gear (swap it mid-fight). Right: the pack, each with
// what it attacks with and how it takes each damage type (your weapon's outlined), and the fight as
// it plays out.
function combatFrame() {
const me = S.me, a = me.action, f = a?.fight;
const encounter = f && C.actions.find((x) => x.id === a.id);
const allies = f ? f.members.filter((m) => m.id !== S.uid) : [];
const bars = (who) => `<div class="bar hp ${who}"><span data-f="${who}-bar"${who === "you" ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div>
<div class="bar swing"><span data-f="${who}-swing"></span></div>`;
const you = `<div class="fighter">
<div class="fighter-name"><b>${esc(me.name)}</b><span class="small muted">${HEART} <span data-f="you-hp">${fmt(me.hp)} / ${me.max_hp}</span></span></div>
${bars("you")}
</div>${allies.map((m, i) => `<div class="fighter ally">
<div class="fighter-name"><b>${esc(m.name)}</b><span class="small muted">${HEART} <span data-f="ally${i}-hp"></span></span></div>
<div class="bar hp you"><span data-f="ally${i}-bar"></span></div><div class="bar swing"><span data-f="ally${i}-swing"></span></div>
</div>`).join("")}`;
let foe = `<div class="foe-idle muted">Not fighting.</div>`;
const encounter = a && C.actions.find((x) => x.id === a.id);
const waiting = a?.waiting;
const others = f ? f.members.filter((m) => m.id !== S.uid) : [];
const unit = (id, name, foe = false, extra = "") => `<div class="fighter ${foe ? "foe-unit" : id === S.uid ? "" : "ally"}" data-u="${esc(id)}">
<div class="fighter-name"><b>${name}${extra}</b><span class="small muted"><span data-u-status></span> ${foe ? "" : HEART} <span data-u-hp>${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}</span></span></div>
<div class="bar hp ${foe ? "foe" : "you"}"><span data-u-bar${id === S.uid ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div>
<div class="bar swing"><span data-u-swing></span></div>
<div class="cast small" data-u-cast></div>
</div>`;
const you = unit(S.uid, esc(me.name)) + others.map((m) => unit(m.id, esc(m.name))).join("");
let foe = `<div class="foe-idle muted">${waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}</div>`;
if (f) {
const mine = C.styles[(me.equip.weapon && item(me.equip.weapon).style) || "sword"].damage;
const pills = (label, kinds, how) => kinds?.length ? `<div class="trait"><span class="muted small">${label}</span>
${kinds.map((k) => `<span class="pill ${how} ${k === mine && how !== "attack" ? "yours" : ""}">${dmgIcon(k, how)}${C.damage_types[k].name}</span>`).join("")}</div>` : "";
foe = `<div class="foe-head"><span class="foe-ico">${encounter?.icon || ""}</span>
<div class="fighter"><div class="fighter-name"><b>${esc(f.enemy)}</b><span class="small muted" data-f="foe-hp"></span></div>${bars("foe")}</div></div>
${f.foe ? `<div class="traits">${pills("Attacks with", f.foe.attack, "attack")}${pills("Weak to", f.foe.vulnerable, "vuln")}${pills("Resists", f.foe.resists, "resist")}</div>` : ""}
const mine = C.styles[me.fighter.style || "sword"]?.damage || me.fighter.damage;
const traits = (v) => `<span class="v-attack">${v.attack.map((k) => dmgIcon(k, "attack")).join("")}</span>
<span class="v-res">${v.vulnerable.map((k) => `<span class="${k === mine ? "yours" : ""}">${dmgIcon(k, "vuln")}</span>`).join("")}${v.resists.map((k) => `<span class="${k === mine ? "yours" : ""}">${dmgIcon(k, "resist")}</span>`).join("")}</span>`;
foe = `<div class="foe-head"><span class="foe-ico">${encounter?.icon || ""}</span><b>${esc(f.enemy)}</b></div>
<div class="pack">${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" ? ` <span class="muted">· rare</span>` : ""}`, true,
` <span class="traits-inline">${traits(v)}</span>`)).join("")}</div>
<ul class="fight-log" data-f="log"></ul>`;
}
return `<div class="panel fight combat-frame" ${f ? `data-fight="${a.start}"` : ""}>
<div class="you-side">${you}${prayerBar(me)}${paperDoll(me, true)}</div>
<div class="you-side">${you}${abilityBar(me)}${prayerBar(me)}${paperDoll(me, true)}</div>
<div class="foe-side">${foe}</div>
</div>`;
}
// Your abilities, from your weapon and your trade, on auto: each lights up as it's used and shows its
// cooldown. The weapon's ones still to come are shown dimmed with the level they're learned at.
function abilityBar(me) {
const known = me.fighter.abilities, style = me.fighter.style || "sword";
const shield = !!me.equip.shield;
const tile = (id, on) => {
const ab = C.abilities[id];
const why = on ? "" : ab.needs === "shield" && !shield && me.levels.combat >= ab.level ? "needs a shield" : `combat ${ab.level}`;
const tip = `${ab.name}: ${ab.tip}${ab.cd ? ` · ${ab.cd * C.auto_cd}s on auto` : ""}${why ? ` · ${why}` : ""}`;
return `<span class="ab ${on ? "" : "locked"}" data-ab="${id}" title="${esc(tip)}">${ab.icon}<span class="ab-cd" data-ab-cd></span>
${on ? "" : `<span class="ab-lv">${why === "needs a shield" ? "🛡️" : ab.level}</span>`}</span>`;
};
const kit = C.weapon_kits[style].map((id) => tile(id, known.includes(id)));
const trade = C.trade_ability[me.cls];
const potion = me.health_potion;
return `<div class="ability-bar">${kit.join("")}${trade ? `<span class="ab-gap"></span>${tile(trade, true)}` : ""}
<span class="ab-gap"></span><span class="ab potion ${potion ? "" : "locked"}" data-ab="potion"
title="${esc(potion ? `${item(potion).name}: +${item(potion).drink} HP, once a fight under ${Math.round(C.potion_below * 100)}% · ${fmt(me.bank[potion])} left` : "No health potion")}">❤️${potion ? `<span class="swap-qty">${fmt(me.bank[potion])}</span>` : ""}</span></div>`;
}
// Prayers you can switch on and off between swings, and the points left to pay for them.
function prayerBar(me) {
const level = me.levels.prayer;
@ -930,72 +951,129 @@ function prayerBar(me) {
return `<div class="prayer-bar"><span class="pp ${me.prayers.length && !me.blessing.cost ? "warn" : ""}" title="Prayer points">${skillOf("prayer").icon} ${fmt(me.pp)}</span>${buttons}</div>`;
}
// A line of the fight: yours as "You", everyone else's by name.
function fightLine(e, enemy) {
const [, who, kind, amount, , , food, target] = e;
const type = kind === "hit" || kind === "crit" ? (C.damage_types[food] ? ` ${dmgIcon(food)}` : "") : "";
const foe = esc(enemy);
const subject = who === "foe" ? target : who, mine = subject === S.uid;
const name = mine ? "You" : esc(playerName(subject)), s = mine ? "" : "s";
if (kind === "eat") return `<li class="${mine ? "good" : "muted"}">${name} eat${s} ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
if (kind === "wind") return `<li class="good">Second wind! ${mine ? "Back on your feet" : `${name} is back up`}</li>`;
if (kind === "block") return `<li class="${mine ? "good" : "muted"}">${name} block${s} ${foe}'s blow</li>`;
if (who !== "foe") return kind === "miss" ? `<li class="muted">${name} miss${mine ? "" : "es"}</li>` : `<li${mine ? "" : ` class="ally"`}>${name} ${kind === "crit" ? `<b>crit${s}</b>` : `hit${s}`} for <b>${amount}</b>${type}</li>`;
return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="${mine ? "bad" : "muted"}">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} ${mine ? "you" : name} for <b>${amount}</b>${type}</li>`;
const SWINGS = new Set(["hit", "crit", "miss", "block", "absorb"]);
// A line of the fight, terse: who, to whom, how much, with what.
function fightLine(e, f) {
const [, src, tgt, kind, n, , label] = e;
const name = (id) => id === S.uid ? "You" : id?.[0] === "e" ? esc(f.pack[+id.slice(1)].name) : esc(playerName(id));
const ab = C.abilities[label] || C.enemy_abilities[label];
const what = ab ? ` ${ab.icon} ${esc(ab.name)}` : C.damage_types[label] ? ` ${dmgIcon(label)}` : "";
const hurtsMe = tgt === S.uid && src?.[0] === "e";
const tone = hurtsMe ? "bad" : src === S.uid ? "" : "muted";
switch (kind) {
case "hit": case "crit": case "tick":
return `<li class="${tone}">${name(src)} → ${name(tgt)} <b>${n}</b>${kind === "crit" ? " crit" : ""}${what}</li>`;
case "miss": return `<li class="muted">${name(src)} → ${name(tgt)} miss</li>`;
case "block": return `<li class="${tgt === S.uid ? "good" : "muted"}">${name(tgt)} block${tgt === S.uid ? "" : "s"}${what}</li>`;
case "absorb": return `<li class="${tgt === S.uid ? "good" : "muted"}">🔮 ${name(tgt)} soak${tgt === S.uid ? "" : "s"} ${n}</li>`;
case "cast": return `<li class="${src?.[0] === "e" ? "warn" : tone}">${name(src)}${what}…</li>`;
case "use": return `<li class="${tone}">${name(src)}${what}${tgt && tgt !== src ? ` → ${name(tgt)}` : ""}</li>`;
case "stop": return `<li class="good">${name(src)} ✋ ${name(tgt)}${what}</li>`;
case "heal": return `<li class="good">${label === "potion" ? `${name(tgt)} ❤️ potion` : `${name(src)}${what} → ${name(tgt)}`} +${n}</li>`;
case "mend": return `<li class="muted">${name(tgt)} +${n}${what}</li>`;
case "cc": return `<li class="good">${what} ${name(tgt)} held</li>`;
case "free": return `<li class="muted">${name(tgt)} breaks free</li>`;
case "die": return `<li class="good">${name(tgt)} falls</li>`;
case "enrage": return `<li class="bad">😡 ${name(src)} enrages</li>`;
case "wind": return `<li class="good">🌬️ Second wind! ${name(src)}</li>`;
case "down": return `<li class="bad"><b>${src === S.uid ? "You're knocked out" : `${name(src)} is knocked out`}</b></li>`;
case "left": return `<li class="muted">${name(src)} left</li>`;
}
return "";
}
// Where everyone in the fight stands at `t` seconds into it: HP, last swing, cast under way, out or not,
// and when each of your abilities was last used.
function fightState(f, t) {
const rest = f.rest / f.pace, st = {};
for (const m of f.members) st[m.id] = { hp: m.hp_in, top: m.top, from: m.at / f.pace + rest, every: m.interval, swung: null, cast: null, out: null, cds: {} };
f.pack.forEach((foe, i) => { st[`e${i}`] = { hp: foe.hp, top: foe.hp, from: rest, every: foe.interval / f.pace, swung: null, cast: null, out: null, held: false, enraged: false }; });
let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) {
const [time, src, tgt, kind, , hp, label] = f.events[++shown];
const who = st[src], them = st[tgt];
if (them && hp != null) them.hp = hp;
if (who && SWINGS.has(kind) && (label === "" || C.damage_types[label])) who.swung = time;
if (who && kind === "cast") {
const spec = src[0] === "e" ? C.enemy_abilities[label] : C.abilities[label];
who.cast = { label, from: time, until: time + spec.cast / f.pace };
}
const ab = C.abilities[label];
if (who?.cds && ab && (ab.cast ? kind === "cast" : kind === "use" || SWINGS.has(kind))) who.cds[label] = time;
if (who && kind === "heal" && label === "potion") who.drank = true;
if (them && (kind === "stop" || kind === "cc")) them.cast = null;
if (them && kind === "cc") them.held = true;
if (them && (kind === "free" || kind === "die")) them.held = false;
if (them && kind === "die") them.out = "dead";
if (who && (kind === "down" || kind === "left")) who.out = kind;
if (who && kind === "enrage") who.enraged = true;
}
return { st, shown };
}
function animateFight(now) {
const a = S.me?.action, f = a?.fight;
if (!f) return;
const t = now - a.start;
const t = now - a.start, rest = f.rest / f.pace;
const over = t >= f.end;
const resting = t < rest;
const { st, shown } = fightState(f, t);
const mine = f.members.find((m) => m.id === S.uid) || f.members[0];
// From the kill until the next enemy turns up (the next fight's start, once the server sends it),
// the panel is the same quiet countdown: no enemy, every swing timer empty.
const waiting = t < 0 || (over && f.result === "won" && mine.result === "won");
const respawnIn = t < 0 ? -t : f.end + (a.respawn || 1) - t;
let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++;
const last = f.events[shown];
// Each fighter's HP is in their own swings and bites, and in the enemy's blows at them.
const about = (e, m) => e[1] === m.id || (e[1] === "foe" && e[7] === m.id);
const hpOf = (m) => { for (let i = shown; i >= 0; i--) if (about(f.events[i], m)) return f.events[i][4]; return m.start_hp; };
const swungAt = (id) => { for (let i = shown; i >= 0; i--) if (f.events[i][1] === id && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") return f.events[i][0]; return null; };
const hp = hpOf(mine), foeHp = waiting ? 0 : last ? last[5] : f.enemy_hp;
let fell = Infinity; // beaten or out of food while the others fight on
if (mine.result === "lost" || mine.result === "fled") for (let i = f.events.length - 1; i >= 0; i--) if (about(f.events[i], mine)) { fell = f.events[i][0]; break; }
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${foeHp / f.enemy_hp * 100}%`;
const me = st[mine.id];
// Resting: everyone's HP climbs back to full while they eat.
if (resting) for (const m of f.members) st[m.id].hp = m.start_hp + (m.hp_in - m.start_hp) * Math.min(1, t / rest);
const enemies = f.pack.map((_, i) => st[`e${i}`]);
const left = enemies.reduce((sum, e) => sum + e.hp, 0), total = enemies.reduce((sum, e) => sum + e.top, 0);
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${(resting ? 1 : left / total) * 100}%`;
for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(me.hp);
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, me.hp / me.top * 100)}%`;
const panel = $("[data-fight]");
if (!panel) return;
panel.classList.toggle("waiting", waiting);
// 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 / mine.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 / mine.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(mine.top)}`; });
set("foe-hp", (el) => { el.textContent = waiting ? "" : `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; });
const swing = (key, swung, every, from = 0, gone = false) => set(`${key}-swing`, (el) => {
el.style.width = over || waiting || gone || t < from ? "0%" : `${Math.min(100, (t - (swung ?? from)) / every * 100)}%`;
});
swing("you", swungAt(mine.id), mine.interval, mine.at / f.pace, t >= fell);
swing("foe", swungAt("foe"), f.enemy_interval);
f.members.filter((m) => m.id !== S.uid).forEach((m, i) => {
const ally = hpOf(m), left = m.left != null && t >= m.left / f.pace;
set(`ally${i}-bar`, (el) => { el.style.width = `${Math.max(0, ally / m.top * 100)}%`; });
set(`ally${i}-hp`, (el) => { el.textContent = t < m.at / f.pace ? "joining…" : left ? "left" : `${fmt(ally)} / ${fmt(m.top)}`; });
swing(`ally${i}`, swungAt(m.id), m.interval, m.at / f.pace, left);
});
set("log", (el) => {
const key = `${a.start}:${shown}:${over}:${waiting ? Math.ceil(respawnIn * 10) : ""}`;
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 && !waiting) || t >= fell) lines.unshift(`<li class="bad"><b>You fall back.</b></li>`);
el.innerHTML = waiting ? `<li class="good"><b>Victory!</b> ${respawnIn > 0.05 ? `Next enemy in ${respawnIn.toFixed(1)}s` : "Next enemy coming…"}</li>`
: lines.join("") || `<li class="muted">Squaring up…</li>`;
});
panel.classList.toggle("waiting", resting);
for (const el of panel.querySelectorAll("[data-u]")) {
const u = st[el.dataset.u];
if (!u) continue;
const gone = !!u.out || over;
el.classList.toggle("out", !!u.out);
el.querySelector("[data-u-bar]").style.width = `${Math.max(0, u.hp / u.top * 100)}%`;
el.querySelector("[data-u-hp]").textContent = u.out === "left" ? "left" : t < u.from && !resting ? "joining…" : `${fmt(u.hp)} / ${fmt(u.top)}`;
el.querySelector("[data-u-status]").textContent = u.out === "dead" ? "💀" : u.out === "down" ? "💫" : u.held ? "🌿" : u.enraged ? "😡" : "";
el.querySelector("[data-u-swing]").style.width = gone || t < u.from ? "0%" : `${Math.min(100, (t - (u.swung ?? u.from)) / u.every * 100)}%`;
const cast = !gone && u.cast && t < u.cast.until ? u.cast : null;
const spec = cast && (el.dataset.u[0] === "e" ? C.enemy_abilities : C.abilities)[cast.label];
const castEl = el.querySelector("[data-u-cast]");
const key = cast ? `${cast.label}:${cast.from}` : "";
if (castEl.dataset.key !== key) {
castEl.dataset.key = key;
castEl.innerHTML = cast ? `<span>${spec.icon} ${esc(spec.name)}</span><span class="bar"><span></span></span>` : "";
}
if (cast) castEl.querySelector(".bar > span").style.width = `${Math.min(100, (t - cast.from) / (cast.until - cast.from) * 100)}%`;
}
// Your abilities: the shade is the cooldown left, a flash when one's just been used.
for (const el of panel.querySelectorAll("[data-ab]")) {
const id = el.dataset.ab;
if (id === "potion") { el.classList.toggle("spent", !!me.drank); continue; }
const ab = C.abilities[id], used = me.cds?.[id];
const cd = (ab.cd || 0) * C.auto_cd / f.pace;
const leftCd = used == null || over ? 0 : Math.max(0, used + cd - t);
el.querySelector("[data-ab-cd]").style.height = `${cd ? leftCd / cd * 100 : 0}%`;
el.classList.toggle("fired", used != null && t - used < 0.6);
}
const log = panel.querySelector('[data-f="log"]');
if (log) {
const key = `${a.start}:${shown}:${over}:${resting ? Math.ceil(rest - t) : ""}`;
if (log.dataset.key !== key) {
log.dataset.key = key;
const eaten = Object.entries(mine.eaten || {});
const lines = f.events.slice(Math.max(0, shown - 6), shown + 1).reverse().map((e) => fightLine(e, f)).filter(Boolean);
if (over) lines.unshift(f.result === "won" ? `<li class="good"><b>Victory!</b> Next fight coming…</li>`
: `<li class="bad"><b>${f.result === "fled" ? "It dragged on: you fall back." : "Beaten."}</b></li>`);
log.innerHTML = resting ? `<li class="muted">Resting · ${Math.ceil(rest - t)}s</li>${eaten.length
? `<li class="muted">${HEART} ${eaten.map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}</li>` : ""}`
: lines.join("") || `<li class="muted">Squaring up…</li>`;
}
}
}
// A piece of jewellery's bonus, e.g. "+8% accuracy".
@ -1052,6 +1130,7 @@ function itemStats(id) {
const it = item(id);
const parts = [];
if (it.heal) parts.push(`❤️ Heals ${it.heal}`);
if (it.drink) parts.push(`❤️ +${it.drink} in a fight`);
if (it.food) parts.push(`feeds ${it.food} for 2 days`);
if (it.power) parts.push(`+${it.power} power`);
if (it.style) {
@ -1096,7 +1175,8 @@ function actionCard(a) {
let extra = "";
if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy;
const own = a.personal_loot ? `<span class="tag good" title="Everyone in the fight gets their own loot">👥 Own loot</span>` : "";
const own = (a.together ? `<span class="tag warn" title="Fought by ${C.group_size} or more: alone, you wait for someone to join">👥 Group</span> ` : "")
+ (a.personal_loot ? `<span class="tag good" title="Everyone in the fight gets their own loot">🎁 Own loot</span>` : "");
extra = `${e.boss || own ? `<div>${e.boss ? `<span class="tag warn">Boss</span> ` : ""}${own}</div>` : ""}${variantRows(e, o)}${fightingHere(a, why)}`;
if (a.gold) io += `<span class="item-chip" title="Gold: ${a.gold[0]}–${a.gold[1]} a kill">${COIN} ${a.gold[0]}–${a.gold[1]}</span>`;
if (a.drops) io += a.drops.map(chance).join("");
@ -1228,8 +1308,9 @@ function itemPanel(id) {
${it.potion ? `<div class="row"><div class="small">${esc(it.potion.text)} for ${C.potion_charges} ${it.potion.skill === "combat" ? "fights" : "actions"}.</div>
<button class="btn gold" data-potion="${id}">Drink</button>
${S.me.potions[it.potion.skill] && S.me.potions[it.potion.skill].item !== id ? `<div class="small why">Replaces your ${esc(item(S.me.potions[it.potion.skill].item).name.toLowerCase())} (${S.me.potions[it.potion.skill].charges} uses left)</div>` : ""}</div>` : ""}
${it.heal ? `<div class="row"><div class="small muted">Heals ${it.heal} in fights.</div>
${S.me.food === id ? `<span class="tag good">Your fighting food</span>` : `<button class="btn sm" data-food="${id}">Eat this first in fights</button>`}</div>` : ""}
${it.heal ? `<div class="row"><div class="small muted">Heals ${it.heal} resting between fights.</div>
${S.me.food === id ? `<span class="tag good">Your fighting food</span>` : `<button class="btn sm" data-food="${id}">Eat this first</button>`}</div>` : ""}
${it.drink ? `<div class="row small muted">+${it.drink} HP in a fight, drunk once a fight under ${Math.round(C.potion_below * 100)}% HP.</div>` : ""}
${it.food ? `<div class="row small muted">Feeds ${it.food} townsfolk for 2 days.</div>` : ""}
${C.city_uses[id].length
? `<div class="row"><div class="k">Donate to the city · +${it.value} favour each</div><div class="btns">${qtyBtns("data-donate")}</div>
@ -1919,7 +2000,7 @@ function slot2(slot, p) {
return slot in p.equip || (slot === "food" && "food" in p) ? slot : null;
}
// Food for fights: one you picked to eat first, or the cheapest first. Only your own sheet shows it.
// Food for resting between fights: one you picked to eat first, or the cheapest first. Only your own sheet shows it.
function foodSlot(p, picked) {
if (!("food" in p)) return `<span class="doll-slot future" title="Food"></span>`;
const id = p.food && p.bank[p.food] ? p.food : null;
@ -2002,7 +2083,7 @@ function animate() {
const now = serverNow();
const a = S?.me?.action;
animateFight(now);
if (a) {
if (a && !a.waiting) {
const pct = Math.min(1, (now - a.start) / a.duration);
// In a fight the bar is the enemy's health (set by animateFight), not a timer.
if (!a.fight) for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%";
@ -2010,7 +2091,7 @@ function animate() {
if (pct >= 1 && refreshedFor !== key) {
refreshedFor = key;
setTimeout(refresh, 150);
} else if (a.fight && now - a.start > a.duration + (a.respawn || 1) && now - retriedAt > 2) {
} else if (a.fight && now - a.start > a.duration + 1 && now - retriedAt > 2) {
retriedAt = now; // the next enemy hasn't turned up yet: ask again
refresh();
}

View file

@ -553,9 +553,32 @@ button.sheet-skill:hover { background: var(--panel-2); }
@media (max-width: 900px) {
.combat-frame { display: flex; flex-direction: column; gap: 16px; }
.you-side { display: contents; }
.you-side > .fighter { order: 1; } .foe-side { order: 2; } .prayer-bar { order: 3; } .you-side > .doll-wrap { order: 4; }
.you-side > .fighter { order: 1; } .foe-side { order: 2; } .ability-bar { order: 3; } .prayer-bar { order: 3; } .you-side > .doll-wrap { order: 4; }
}
.bar.mini.foe > span { background: var(--bad); }
/* Packs, casts and abilities */
.pack { display: grid; gap: 12px; }
.fighter.out { opacity: .4; }
.traits-inline { display: inline-flex; gap: 2px; margin-left: 6px; vertical-align: middle; }
.traits-inline .v-res { display: inline-flex; gap: 2px; margin-left: 4px; }
.traits-inline .dmg .gi { width: 13px; height: 13px; }
.traits-inline .yours .dmg { box-shadow: 0 0 0 1.5px var(--gold); border-radius: 50%; }
.cast { display: grid; grid-template-columns: auto 1fr; gap: 8px; align-items: center; min-height: 16px; color: var(--gold); }
.cast:empty { display: none; }
.cast .bar { height: 4px; }
.cast .bar > span { background: var(--gold); transition: none; }
.foe-unit .cast { color: var(--bad); }
.foe-unit .cast .bar > span { background: var(--bad); }
.ability-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ab { position: relative; width: 38px; height: 38px; border-radius: 9px; display: grid; place-items: center; font-size: 20px;
background: var(--panel-2); border: 1px solid var(--line); overflow: hidden; }
.ab.locked { opacity: .35; filter: grayscale(1); }
.ab.fired { border-color: var(--gold); box-shadow: 0 0 0 2px var(--gold); }
.ab.spent { opacity: .35; }
.ab-cd { position: absolute; left: 0; right: 0; bottom: 0; height: 0; background: rgba(0, 0, 0, .55); pointer-events: none; }
.ab-lv { position: absolute; bottom: 0; right: 2px; font-size: 10px; font-weight: 700; color: var(--muted); filter: none; }
.ab-gap { width: 6px; }
.fight-log .warn { color: var(--gold); }
.pill { padding: 3px 10px 3px 3px; }
.pill .dmg { border-radius: 50%; }

View file

@ -170,6 +170,7 @@ class ActionTest(unittest.TestCase):
world = world_with(("wil", "commoner"))
p = world["players"]["wil"]
p["bank"] = {"bread": 500}
p["equip"].update(weapon="copper_sword", helmet="copper_helmet", body="copper_body", legs="copper_legs")
game.start_action(world, p, "fight_wolves", T0)
summary = game.process_player(world, p, T0 + 3600, random.Random(5))
self.assertGreater(summary["done"], 30) # a fight lasts as long as it takes
@ -505,31 +506,58 @@ class CombatTest(unittest.TestCase):
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):
def test_three_knockouts_in_a_row_stop_you_and_nothing_is_lost(self):
world, p = self.fighter(level=10, weapon="copper_sword")
p["bank"] = {"stone": 10}
game.start_action(world, p, "fight_scouts", T0)
summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1))
self.assertIsNone(p["action"])
self.assertTrue(summary["stopped"].startswith("Retreated"))
self.assertEqual(summary["stopped"], "Knocked out 3 times in a row")
self.assertEqual(summary["losses"], 3)
self.assertEqual(p["equip"]["weapon"], "copper_sword")
self.assertEqual(p["bank"]["stone"], 10)
self.assertLess(p["hp"], game.max_hp(p) * game.TOO_HURT)
self.assertEqual(game.start_action(world, p, "fight_scouts", T0 + 6 * 3600), "Too hurt to fight. Rest, or bring food")
self.assertAlmostEqual(p["hp"], game.max_hp(p) * game.KNOCKED_OUT_HP)
self.assertIsNone(game.start_action(world, p, "fight_scouts", T0 + 6 * 3600)) # you can always go again
def test_eating_holds_back_your_swings(self):
world, p = self.fighter(level=15)
p["bank"] = {"cooked_shrimp": 100}
p["hp"] = 1
fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(3))
mine = [event for event in fight["events"] if event[1] == p["id"]]
bite = next(i for i, event in enumerate(mine) if event[2] == "eat")
swing = next(event for event in mine[bite:] if event[2] != "eat")
self.assertGreaterEqual(swing[0] - mine[bite][0], game.EAT_DELAY)
scouts = game.ACTION_BY_ID["fight_scouts"]
eating = game.fight_odds(p, scouts)["kill_time"]
def test_a_win_resets_the_knockouts(self):
world, p = self.fighter(level=1, weapon="copper_sword")
game.start_action(world, p, "fight_wolves", T0)
summary = game.process_player(world, p, T0 + 3600, random.Random(1))
self.assertGreater(summary["losses"], 2) # knocked out now and then, never three in a row
self.assertEqual(p["action"]["id"], "fight_wolves")
self.assertGreater(summary["wins"], 20)
def test_food_shortens_the_rest_between_fights(self):
world, p = self.fighter(level=30)
top = game.max_hp(p)
p["bank"] = {}
self.assertGreater(eating, game.fight_odds(p, scouts)["kill_time"])
resting, eaten = game.rest_plan(world, p, top / 2)
self.assertEqual(eaten, {})
self.assertAlmostEqual(resting, top / 2 / (top * game.REST_RATE))
p["bank"] = {"bread": 100}
bread, eaten = game.rest_plan(world, p, top / 2)
self.assertLess(bread, resting)
self.assertEqual(eaten, {"bread": 10}) # 80 HP back in 8 HP bites
p["bank"] = {"cooked_salmon": 100}
salmon, eaten = game.rest_plan(world, p, top / 2)
self.assertLess(salmon, bread) # better food, shorter rest
self.assertEqual(eaten, {"cooked_salmon": 3})
self.assertEqual(game.rest_plan(world, p, top)[0], game.RESPAWN) # at full: a breather
p["xp"]["combat"] = game.XP_TABLE[99]
p["bank"] = {"bread": 100}
self.assertEqual(game.rest_plan(world, p, 100)[1], {}) # a bite of bread is no faster than resting
def test_food_is_eaten_resting_not_fighting(self):
world, p = self.fighter(level=15, weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs")
p["bank"] = {"cooked_trout": 100}
p["hp"] = 10
fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(3))
mine = game.member_of(fight, p["id"])
self.assertGreater(sum(mine["eaten"].values()), 0)
self.assertGreater(fight["rest"], game.RESPAWN)
self.assertEqual(mine["hp_in"], game.max_hp(p)) # every fight starts rested
self.assertTrue(all(e[0] >= fight["rest"] for e in fight["events"]))
self.assertFalse(any(e[3] == "eat" for e in fight["events"]))
def test_rest_heals(self):
world, p = self.fighter()
@ -547,20 +575,24 @@ class CombatTest(unittest.TestCase):
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")
def test_a_fight_is_played_out_slice_by_slice(self):
world, p = self.fighter(level=20, weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs")
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 (p["id"], "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
kinds = {"hit", "crit", "tick", "miss", "block", "absorb", "heal", "mend", "cast", "use", "stop", "cc", "free",
"die", "enrage", "wind", "down", "left"}
self.assertTrue(all(e[3] in kinds for e in fight["events"]))
self.assertTrue(all(e[1] in (p["id"], None) or e[1].startswith("e") for e in fight["events"]))
dead = [e[2] for e in fight["events"] if e[3] == "die"]
self.assertEqual(sorted(dead), sorted(f"e{i}" for i in range(len(fight["pack"])))) # the last of them finished it
self.assertAlmostEqual(fight["duration"], fight["end"], places=1) # rewards land on the kill
summary = game.process_player(world, p, T0 + fight["duration"] + 0.01, random.Random(2))
self.assertEqual(summary["wins"], 1)
self.assertAlmostEqual(p["hp"], game.member_of(fight, p["id"])["hp"], delta=0.1)
self.assertAlmostEqual(p["action"]["start"], T0 + fight["duration"] + game.RESPAWN) # then a short pause
self.assertAlmostEqual(p["action"]["start"], T0 + fight["duration"]) # straight on to the next rest
def test_outclassing_an_enemy_kills_faster(self):
kills = {}
@ -570,15 +602,17 @@ class CombatTest(unittest.TestCase):
kills[level] = game.process_player(world, p, T0 + 600, random.Random(1))["wins"]
self.assertGreater(kills[90], kills[20] * 1.3)
def test_each_enemy_starts_with_both_swing_timers_empty(self):
def test_each_fight_starts_with_every_swing_timer_empty(self):
world, p = self.fighter(level=60, weapon="rune_sword")
game.start_action(world, p, "fight_wolves", T0)
game.process_player(world, p, T0 + 0.1, random.Random(1))
first = p["action"]["fight"]
game.process_player(world, p, T0 + first["duration"] + 0.01, random.Random(1))
self.assertAlmostEqual(p["action"]["start"], T0 + first["duration"] + game.RESPAWN) # the pause starts at the kill
self.assertAlmostEqual(p["action"]["start"], T0 + first["duration"]) # the rest starts at the kill
second = p["action"]["fight"]
self.assertEqual(next(e[0] for e in second["events"] if e[1] == p["id"]), 2.4) # a full swing, from scratch
swing = next(e[0] for e in second["events"] if e[1] == p["id"] and e[6] in game.DAMAGE_TYPES)
self.assertAlmostEqual(swing, second["rest"] + 2.5) # a full swing from scratch, to the slice
def test_preferred_food_is_eaten_first(self):
world, p = self.fighter()
p["bank"] = {"bread": 5, "cooked_salmon": 5}
@ -587,6 +621,99 @@ class CombatTest(unittest.TestCase):
self.assertEqual(game.edible(p), ["cooked_salmon", "bread"])
class AbilityTest(unittest.TestCase):
def fighter(self, cls, level, tier=1, **equip):
world = world_with(("ash", cls))
p = world["players"]["ash"]
p["xp"]["combat"] = game.XP_TABLE[level]
p["equip"].update(equip)
p["hp"] = game.max_hp(p)
return world, p
def test_the_weapon_gives_four_abilities_and_the_trade_one(self):
known = lambda style, cls, level, shield=False: game.combat.known_abilities(style, cls, level, shield)
self.assertEqual(set(known("hammer", "miner", 1)), {"crushing_blow", "find_the_seam"})
self.assertEqual(set(known("hammer", "miner", 45)), {"crushing_blow", "concuss", "earthshaker", "unstoppable",
"find_the_seam"})
self.assertEqual(set(known("bow", "miner", 15)), {"aimed_shot", "pinning_shot", "find_the_seam"}) # any weapon
self.assertEqual(set(known("sword", "builder", 45)), {"challenge", "sweeping_cut", "barricade"}) # no shield
self.assertEqual(set(known("sword", "builder", 45, True)), {"shield_bash", "challenge", "sweeping_cut",
"shield_wall", "barricade"})
self.assertEqual(known("staff", "homesteader", 30), ["mend", "ward", "hearty_meal", "bolt"]) # auto's order
self.assertEqual(game.combat.role("sword", True), "tank")
self.assertEqual(game.combat.role("sword", False), "damage")
self.assertEqual(game.combat.role("staff", False), "healer")
def test_abilities_are_used_in_the_fight(self):
world, p = self.fighter("ranger", 30, weapon="willow_bow")
fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(1))
used = {e[6] for e in fight["events"] if e[1] == p["id"] and e[6] in game.combat.ABILITIES}
self.assertIn("aimed_shot", used)
casts = [e for e in fight["events"] if e[1] == p["id"] and e[3] == "cast"]
self.assertTrue(casts)
def test_casters_get_stopped(self):
world, p = self.fighter("ranger", 30, weapon="willow_bow")
harpies = game.ACTION_BY_ID["fight_harpies"]
stops = 0
for seed in range(20):
fight = game.simulate_fight(world, p, harpies, random.Random(seed))
stops += sum(1 for e in fight["events"] if e[3] == "stop" and e[6] == "hex_bolt")
self.assertGreater(stops, 0) # Pinning Shot stops a windcaster's hex
def test_a_health_potion_once_a_fight_when_low(self):
world, p = self.fighter("miner", 10, weapon="copper_warhammer")
p["bank"] = {"health_potion_1": 5, "health_potion_2": 5}
self.assertEqual(game.health_potion(p), "health_potion_2")
game.start_action(world, p, "fight_scouts", T0)
summary = game.process_player(world, p, T0 + 3600, random.Random(1))
drunk = summary["used"].get("health_potion_2", 0)
self.assertGreater(drunk, 0)
self.assertLessEqual(drunk, summary["done"])
self.assertEqual(p["bank"].get("health_potion_2", 0), 5 - drunk)
fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(3))
self.assertLessEqual(sum(1 for e in fight["events"] if e[3] == "heal" and e[6] == "potion"), 1)
def test_health_potions_are_brewed(self):
brew = game.ACTION_BY_ID["brew_health_potion_3"]
self.assertEqual((brew["skill"], brew["level"]), ("herblore", 30))
self.assertEqual(brew["outputs"], {"health_potion_3": 1})
self.assertIn("tarromin", brew["inputs"])
self.assertIn("cooked_salmon", brew["inputs"])
self.assertEqual(game.ITEMS["health_potion_3"]["drink"], game.HEALTH_HEAL[2])
self.assertNotIn("health_potion_3", game.edible({"bank": {"health_potion_3": 1}, "food": None}))
def test_volley_spends_arrows(self):
world, p = self.fighter("ranger", 45, weapon="maple_bow")
p["bank"] = {"arrows": 100}
game.start_action(world, p, "fight_goblins", T0)
summary = game.process_player(world, p, T0 + 1800, random.Random(2))
shot = summary["used"].get("arrows", 0)
self.assertGreater(shot, 0)
self.assertEqual(shot % 5, 0)
self.assertEqual(p["bank"].get("arrows", 0), 100 - shot)
def test_a_party_rests_as_long_as_its_slowest(self):
world = world_with(("wil", "builder"), ("kay", "builder"))
wil, kay = world["players"]["wil"], world["players"]["kay"]
for p in (wil, kay):
p["xp"]["combat"] = game.XP_TABLE[20]
wil["hp"], kay["hp"] = game.max_hp(wil), 10
fight = game.draw_fight(world, [wil, kay], game.ACTION_BY_ID["fight_scouts"], random.Random(1))
self.assertAlmostEqual(fight["rest"], game.rest_plan(world, kay)[0])
def test_fights_in_progress_are_drawn_again_after_the_update(self):
world = world_with(("wil", "builder"))
wil = world["players"]["wil"]
game.start_action(world, wil, "fight_wolves", T0)
wil["action"]["fight"] = {"old": True}
world["parties"]["1"] = {"id": "1", "action": "fight_wolves", "members": [], "start": T0, "fight": {"old": True}}
world["version"] = 25
game.migrate(world, T0)
self.assertNotIn("fight", wil["action"])
self.assertIsNone(world["parties"]["1"]["fight"])
class NewSkillsTest(unittest.TestCase):
def test_tools_speed_up_gathering(self):
world = world_with(("pia", "commoner"))
@ -789,14 +916,18 @@ class TierTest(unittest.TestCase):
self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180)
def test_dragons_drop_their_areas_reagents(self):
world = world_with(("wil", "commoner"))
world = world_with(("wil", "commoner"), ("kay", "commoner"))
for p in world["players"].values():
p["xp"]["combat"] = game.XP_TABLE[80]
p["equip"].update(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body",
legs="rune_legs")
p["hp"] = game.max_hp(p)
p["bank"] = {"cooked_swordfish": 200}
p["last_active"] = T0 + 3600 * 2
p = world["players"]["wil"]
p["xp"]["combat"] = game.XP_TABLE[80]
p["equip"].update(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body", legs="rune_legs")
p["hp"] = game.max_hp(p)
p["bank"] = {"cooked_swordfish": 200}
game.start_action(world, p, "fight_green_dragon", T0)
game.process_player(world, p, T0 + 3600 * 2, random.Random(3))
game.start_action(world, p, "fight_green_dragon", T0) # a group fight
game.join_fight(world, world["players"]["kay"], "wil", T0)
game.catch_up(world, T0 + 3600 * 2, random.Random(3))
self.assertGreater(sum(p["bank"].get(game.reagent(weight, 3), 0) for weight in game.WEIGHTS), 30)
@ -843,27 +974,26 @@ class PrayerTest(unittest.TestCase):
self.assertTrue(summary["prayer_out"])
self.assertEqual(p["xp"]["prayer"], game.XP_TABLE[10] + 4 * game.PRAYER_XP_PER_POINT)
def test_second_wind_saves_one_retreat(self):
def test_second_wind_saves_one_knockout(self):
world, p = self.priest(level=40, combat=10)
game.set_prayers(p, ["second_wind"])
p["pp"] = 1000
game.start_action(world, p, "fight_scouts", T0)
summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1))
self.assertEqual(summary["second_wind"], 1)
self.assertTrue(summary["stopped"].startswith("Retreated"))
self.assertEqual(summary["stopped"], "Knocked out 3 times in a row")
def test_rapid_heal_mends_during_fights(self):
world, p = self.priest(level=10, combat=10)
scouts = game.ACTION_BY_ID["fight_scouts"]
p["pp"], p["hp"] = 1000, 50
p["pp"] = 1000
p["equip"].update(weapon="copper_sword", helmet="copper_helmet", body="copper_body", legs="copper_legs")
plain = game.simulate_fight(world, p, scouts, random.Random(2))
game.set_prayers(p, ["rapid_heal"])
mended = game.simulate_fight(world, p, scouts, random.Random(2))
self.assertEqual(plain["end"], mended["end"]) # the same fight, only the mending differs
swings = sum(1 for e in plain["events"] if e[1] == p["id"])
top = game.max_hp(p)
plain, mended = game.member_of(plain, p["id"]), game.member_of(mended, p["id"])
self.assertAlmostEqual(mended["hp"] - plain["hp"], swings * game.MEND * top * plain["interval"], places=6)
self.assertEqual([e[:4] for e in plain["events"] if e[1] == p["id"]][:5],
[e[:4] for e in mended["events"] if e[1] == p["id"]][:5]) # the same fight
self.assertGreater(game.member_of(mended, p["id"])["hp"], game.member_of(plain, p["id"])["hp"])
self.assertLess(game.fight_odds(p, scouts)["damage"], game.fight_odds({**p, "prayers": []}, scouts)["damage"])
def test_offerings_ward_the_city_until_the_raid(self):
@ -1452,11 +1582,27 @@ class WeaponStyleTest(unittest.TestCase):
self.assertLess(hurt(cloth, shaper), hurt(plate, shaper))
self.assertLess(hurt(plate, brute), hurt(cloth, brute))
def test_each_kill_is_one_of_the_variants(self):
def test_each_fight_is_a_pack_of_the_variants(self):
world, p = self.armed("miner", "iron_warhammer", level=30)
seen = {game.simulate_fight(world, p, game.ACTION_BY_ID["fight_goblins"], random.Random(seed))["enemy"]
for seed in range(60)}
self.assertEqual(seen, {"Goblin brute", "Goblin rogue", "Goblin warlock"})
goblins = game.ACTION_BY_ID["fight_goblins"]
packs = [game.simulate_fight(world, p, goblins, random.Random(seed))["pack"] for seed in range(60)]
self.assertEqual({foe["name"] for pack in packs for foe in pack}, {"Goblin brute", "Goblin rogue", "Goblin warlock"})
self.assertEqual({len(pack) for pack in packs}, {1, 2})
for pack in packs:
share = game.OUTNUMBERED[0] if len(pack) == 2 else 1
self.assertTrue(all(foe["share"] == share for foe in pack)) # two of them, each weaker
scouts = game.ACTION_BY_ID["fight_scouts"]
self.assertEqual({len(game.draw_pack(scouts, 3, random.Random(seed))) for seed in range(60)}, {3, 4})
def test_a_boss_comes_alone(self):
ogre = game.ACTION_BY_ID["fight_ruins"]
foe = ogre["enemy"]["variants"][0]
pair, four = game.draw_pack(ogre, 2, random.Random(1)), game.draw_pack(ogre, 4, random.Random(1))
self.assertEqual((len(pair), len(four)), (1, 1))
self.assertEqual(pair[0]["hp"], round(foe["hp"] * game.BOSS_HP * 2)) # tougher the more of you there are
self.assertEqual(four[0]["hp"], round(foe["hp"] * game.BOSS_HP * 4))
self.assertEqual(pair[0]["max_hit"], round(foe["max_hit"] * game.BOSS_HITS[0]))
self.assertGreater(four[0]["max_hit"], pair[0]["max_hit"])
def test_shield_smiths_stop_after_the_move(self):
world = world_with(("kay", "miner"))
@ -2059,7 +2205,7 @@ class PartyTest(unittest.TestCase):
for p in world["players"].values():
p["xp"]["combat"] = game.XP_TABLE[level]
p["hp"] = game.max_hp(p)
p["equip"]["weapon"] = "iron_sword"
p["equip"].update(weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs")
p["bank"] = {"bread": 300}
return world, world["players"]["wil"], world["players"]["kay"]
@ -2096,14 +2242,12 @@ class PartyTest(unittest.TestCase):
self.assertEqual(together["done"], kay["away"]["done"])
self.assertEqual(together["with"], {"Kay": together["done"]})
self.assertEqual(wil["xp"]["combat"], kay["xp"]["combat"]) # the same kills, the full XP each
low, high = game.ACTION_BY_ID["fight_scouts"]["gold"]
self.assertTrue(together["wins"] * low <= wil["gold"] + kay["gold"] <= together["wins"] * high)
self.assertLessEqual(abs(wil["gold"] - kay["gold"]), together["wins"]) # split evenly, odd coins aside
self.assertEqual(wil["bank"].get("bones", 0) + kay["bank"].get("bones", 0), together["wins"]) # one each kill
self.assertGreater(wil["gold"] + kay["gold"], 0)
self.assertLessEqual(abs(wil["gold"] - kay["gold"]), together["wins"] * 4) # split evenly, odd coins aside
solo, alone = self.pair()[0:2]
game.start_action(solo, alone, "fight_scouts", T0)
self.online(solo, T0 + 3600, random.Random(2))
self.assertGreater(together["done"], alone["away"]["done"] * 1.5)
self.assertGreater(together["xp"]["combat"], alone["away"]["xp"]["combat"] * 1.3)
def test_leaving_lets_the_other_carry_on(self):
world, wil, kay = self.pair()
@ -2117,7 +2261,7 @@ class PartyTest(unittest.TestCase):
self.assertNotIn("party", wil["action"])
left = game.member_of(wil["action"]["fight"], "kay") # the fight goes on without her
self.assertEqual(left["result"], "left")
self.online(world, T0 + 600, rng)
self.online(world, T0 + 1200, rng)
self.assertGreater(wil["away"]["wins"], 3)
def test_away_too_long_steps_out(self):
@ -2134,17 +2278,42 @@ class PartyTest(unittest.TestCase):
self.assertNotIn("party", kay["action"])
self.assertGreater(wil["away"]["done"], kay["away"]["done"]) # wil carried on alone for the last hour
def test_a_retreat_leaves_the_rest_fighting(self):
def test_knocked_out_in_a_won_fight_still_shares_the_win(self):
world, wil, kay = self.pair()
kay["bank"] = {}
kay["hp"] = game.max_hp(kay) * 0.35
game.start_action(world, wil, "fight_scouts", T0)
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.online(world, T0 + 600, random.Random(5))
self.assertIsNone(kay["action"])
self.assertTrue(kay["stopped"].startswith("Retreated"))
self.assertEqual(wil["action"]["id"], "fight_scouts")
self.assertEqual(world["parties"], {})
self.online(world, T0 + 0.1, random.Random(0))
kay["hp"] = 1 # tags in as she is
self.assertIsNone(game.join_fight(world, kay, "wil", T0 + 0.1))
fight = game.current_fight(world, wil)
mine = game.member_of(fight, "kay")
self.assertTrue(mine["down"])
self.assertEqual((fight["result"], mine["result"]), ("won", "won"))
self.online(world, T0 + fight["duration"] + 0.01, random.Random(5))
self.assertEqual(kay["away"]["wins"], 1)
self.assertAlmostEqual(kay["hp"], game.max_hp(kay) * game.KNOCKED_OUT_HP)
self.assertEqual(kay["action"]["id"], "fight_scouts")
def test_a_boss_needs_a_group(self):
world, wil, kay = self.pair(level=30)
for p in (wil, kay):
p["equip"].update(weapon="steel_sword", shield="steel_shield", helmet="steel_helmet", body="steel_body",
legs="steel_legs")
self.assertIsNone(game.start_action(world, wil, "fight_ruins", T0))
self.assertIn("Wil is looking for a group", world["ledger"][-1]["text"])
self.online(world, T0 + 3600, random.Random(1))
self.assertNotIn("fight", wil["action"]) # alone, nothing happens
self.assertEqual(wil["away"]["done"], 0) if "away" in wil else None
self.assertEqual(wil["action"]["start"], T0 + 3600)
self.assertIsNone(game.join_fight(world, kay, "wil", T0 + 3600))
self.online(world, T0 + 7200, random.Random(1))
self.assertGreater(wil["away"]["wins"], 0)
self.assertEqual(wil["away"]["wins"], kay["away"]["wins"])
self.assertIsNone(game.start_action(world, kay, "mine_stone", T0 + 7200))
self.online(world, T0 + 9000, random.Random(1))
wins = wil["away"]["wins"]
self.online(world, T0 + 12600, random.Random(1))
self.assertEqual(wil["away"]["wins"], wins) # back to waiting
self.assertEqual(wil["action"]["id"], "fight_ruins")
def test_who_can_join(self):
world, wil, kay = self.pair()
@ -2162,7 +2331,8 @@ class PartyTest(unittest.TestCase):
def test_bosses_give_everyone_their_own_loot(self):
world, wil, kay = self.pair(level=30)
for p in (wil, kay):
p["equip"].update(weapon="steel_sword", helmet="steel_helmet", body="steel_body", legs="steel_legs")
p["equip"].update(weapon="steel_sword", shield="steel_shield", helmet="steel_helmet", body="steel_body",
legs="steel_legs")
p["bank"] = {"cooked_salmon": 5000}
game.start_action(world, wil, "fight_ruins", T0)
self.assertIsNone(game.join_fight(world, kay, "wil", T0))

120
tools/balance.py Normal file
View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Balance report: an hour of fighting for each trade with its own weapon, at each tier.
python3 tools/balance.py [solo|party] [seeds] [levels, e.g. 22,52] [potions]
Each row is one encounter: wins and losses in the hour, XP an hour, food eaten an hour,
and the share of a kill's time spent fighting (the rest is resting between fights).
"""
from __future__ import annotations
import random
import statistics
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import game # noqa: E402
T0 = 1_800_000_000.0
WEIGHT = {"miner": "heavy", "builder": "heavy", "homesteader": "light", "ranger": "medium"}
# (combat level, the tier of gear worn, the encounters to try)
CHECKPOINTS = [
(10, 0, ["fight_wolves", "fight_goblins", "fight_scouts"]),
(22, 1, ["fight_clay_golems", "fight_cave_trolls", "fight_restless_dead", "fight_harpies", "fight_ruins"]),
(37, 2, ["fight_stone_guardians", "fight_lizardfolk", "fight_bog_wraiths", "fight_giant_spiders", "fight_barrow_king"]),
(52, 3, ["fight_iron_golems", "fight_orcs", "fight_mine_ghosts", "fight_wyverns", "fight_green_dragon"]),
(67, 4, ["fight_ice_elementals", "fight_frost_trolls", "fight_banshees", "fight_griffins", "fight_blue_dragon"]),
(82, 5, ["fight_magma_constructs", "fight_dragonkin", "fight_shades", "fight_rocs", "fight_red_dragon"]),
]
FOODS = ["cooked_shrimp", "cooked_trout", "cooked_salmon", "smoked_eel", "cooked_lobster", "cooked_swordfish"]
def gear(cls: str, tier: int) -> dict:
"""The trade's own weapon and its usual armour weight, all of one tier (and a shield for a sword)."""
style = game.CLASS_STYLE[cls]
equip = {}
for item, spec in game.ITEMS.items():
if spec.get("tier") != tier or spec.get("req") != ["combat", game.TIER_LEVELS[tier]]:
continue
if spec.get("style") == style or (style == "sword" and spec.get("slot") == "shield") or \
spec.get("weight") == WEIGHT[cls] and spec["slot"] in ("helmet", "body", "legs"):
equip[spec["slot"]] = item
return equip
POTIONS = False
def fighter(world: dict, uid: str, cls: str, level: int, tier: int) -> dict:
player = game.new_player(uid, uid.title(), cls, T0)
world["players"][uid] = player
player["xp"]["combat"] = game.XP_TABLE[level]
player["equip"].update(gear(cls, tier))
player["hp"] = game.max_hp(player)
player["bank"] = {FOODS[tier]: 50_000, "arrows": 10_000}
if POTIONS:
player["bank"][f"health_potion_{tier + 1}"] = 10_000
return player
def hour(classes: list, level: int, tier: int, fight: str, seed: int) -> dict:
world = game.new_world(T0)
players = [fighter(world, f"p{i}", cls, level, tier) for i, cls in enumerate(classes)]
game.start_action(world, players[0], fight, T0)
for other in players[1:]:
game.join_fight(world, other, players[0]["id"], T0)
rng = random.Random(seed)
end = T0 + 3600
for now in range(int(T0) + 60, int(end) + 1, 60):
for player in players:
player["last_active"] = now
game.catch_up(world, now, rng)
away = players[0].get("away") or game.empty_summary()
eaten = sum(away["eaten"].values())
return {"wins": away["wins"], "losses": away["losses"], "xp": away["xp"].get("combat", 0), "eaten": eaten,
"stopped": away["stopped"]}
def report(groups: dict, seeds: int, levels: set | None = None) -> None:
print(f"{'who':24} {'lvl':>3} {'encounter':22} {'wins':>5} {'lost':>4} {'xp/h':>6} {'food/h':>6} {'stop':>4}")
for name, classes in groups.items():
for level, tier, fights in CHECKPOINTS:
if levels and level not in levels:
continue
for fight in fights:
rows = [hour(classes, level, tier, fight, seed) for seed in range(seeds)]
mean = lambda key: statistics.mean(row[key] for row in rows)
stops = sum(1 for row in rows if row["stopped"])
label = game.ACTION_BY_ID[fight]["name"][:22]
print(f"{name:24} {level:>3} {label:22} {mean('wins'):>5.0f} {mean('losses'):>4.1f} "
f"{mean('xp'):>6.0f} {mean('eaten'):>6.0f} {stops:>4}")
print()
BOSSES = ["fight_ruins", "fight_barrow_king", "fight_green_dragon", "fight_blue_dragon", "fight_red_dragon"]
def main() -> None:
mode = sys.argv[1] if len(sys.argv) > 1 else "solo"
seeds = int(sys.argv[2]) if len(sys.argv) > 2 else 3
levels = {int(level) for level in sys.argv[3].split(",")} if len(sys.argv) > 3 and sys.argv[3] != "all" else None
global POTIONS
POTIONS = "potions" in sys.argv[4:]
if mode == "bosses":
global CHECKPOINTS
CHECKPOINTS = [(level, tier, [f for f in fights if f in BOSSES]) for level, tier, fights in CHECKPOINTS]
report({"miner+ranger": ["miner", "ranger"], "builder+homesteader": ["builder", "homesteader"],
"homesteader+ranger": ["homesteader", "ranger"], "all four": ["builder", "homesteader", "miner", "ranger"]},
seeds, levels)
elif mode == "party":
report({"builder+homesteader": ["builder", "homesteader"],
"all four": ["builder", "homesteader", "miner", "ranger"]}, seeds, levels)
else:
report({cls: [cls] for cls in game.CLASS_STYLE}, seeds, levels)
if __name__ == "__main__":
main()