Combat rework: packs, abilities, rest, health potions, group bosses
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
472609ac70
commit
57d11e30da
10 changed files with 1562 additions and 380 deletions
58
README.md
58
README.md
|
|
@ -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
14
app.py
|
|
@ -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
729
combat.py
Normal 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
|
||||
|
|
@ -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
|
||||
'';
|
||||
|
|
|
|||
445
game.py
445
game.py
|
|
@ -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))
|
||||
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, food, count)
|
||||
summary["eaten"][food] = summary["eaten"].get(food, 0) + count
|
||||
player["hp"], player["hp_at"] = mine["hp"], now
|
||||
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"
|
||||
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
|
||||
gain_xp(world, player, "combat", xp // 3, now, summary)
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
249
static/app.js
249
static/app.js
|
|
@ -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>`
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
p = world["players"]["wil"]
|
||||
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["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))
|
||||
p["last_active"] = T0 + 3600 * 2
|
||||
p = world["players"]["wil"]
|
||||
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
120
tools/balance.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue