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,
|
firekeeping, combat, prayer and reconnaissance. Smiths also make hardware for builders,
|
||||||
one kind per metal (copper rivets to rune locks), which fittings and
|
one kind per metal (copper rivets to rune locks), which fittings and
|
||||||
buildings need.
|
buildings need.
|
||||||
- **Combat:** every fight is played out swing by swing: you and the enemy
|
- **Combat:** a fight is a party against a pack, played out in quarter-second
|
||||||
each have HP, a swing timer, accuracy and a max hit, and swings hit, miss
|
slices (`combat.py`). Everyone swings on their weapon's timer, and swings hit,
|
||||||
or crit. Every blow has a damage type (slashing, blunt, piercing, magic);
|
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
|
each enemy is vulnerable to some (+25%) and resists others (-25%), shown as
|
||||||
as tinted icons. Armour stops physical blows, ward stops magic. Below half
|
tinted icons. Armour stops physical blows, ward stops magic. On top of the
|
||||||
health you eat from your bank, and each bite holds back your next swing;
|
swings, your weapon gives four abilities (learned at combat 1, 15, 30 and 45)
|
||||||
out of food you retreat, losing nothing but the fight. HP recovers 25%
|
and your trade one more; for now they're all on auto. Enemies use their own:
|
||||||
an hour. The Combat page replays the fight live. Fights are split into
|
brutes wind up a Heavy Slam, skirmishers lunge at whoever isn't tanking,
|
||||||
the Frontier (bandits, sabotage, drilling, the city's troubles) and
|
casters cast a Hex Bolt you can interrupt, elites and bosses enrage, dragons
|
||||||
expeditions: Greenwood to start, then one area per
|
breathe fire. Enemies pick targets by threat. A pack is as many enemies as
|
||||||
tier (Old Watchtower, Sunken Barrows, Iron Hills, Frostpeak, Dragon's
|
there are of you, often one more (each weaker); a rare elite comes alone.
|
||||||
Spine), each with four races and a boss (the dragons at the top). Every
|
Between fights you rest back to full: food is only eaten then, and better food
|
||||||
kill is one of the race's variants (a brute, a skirmisher, a caster, now
|
rests you faster. In a fight the only heal you carry is a health potion
|
||||||
and then a rare elite), so each encounter mixes damage types: spirit races
|
(Herblore), drunk once a fight under 35% HP. At 0 HP you're knocked out: you
|
||||||
lean on magic, golems and trolls on blows. Races drop trophies (10%,
|
lose that fight and come round at 30%; three knockouts in a row and you stop.
|
||||||
20% from bosses) for stronger weapons and the area's armour reagents (10%,
|
HP recovers 25% an hour away from fights. The Combat page replays the fight
|
||||||
far more from dragons). Each kill is followed by a 2-second pause.
|
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.
|
- **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;
|
Sword and shield (slashing, 2.4s swings, the shield blocks 15% of hits;
|
||||||
swords from Smithing, shields from Construction), warhammer (blunt,
|
swords from Smithing, shields from Construction), warhammer (blunt,
|
||||||
Smithing; slow, heavy, twice the crits), staff (magic, Herblore;
|
Smithing; slow, heavy, twice the crits), staff (magic, Herblore;
|
||||||
accurate) and bow (piercing, Leatherworking; fast, strikes first). The
|
accurate) and bow (piercing, Leatherworking; fast, strikes first). The
|
||||||
last three take both hands and carry a shield's power. Your trade's weapon
|
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.
|
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
|
- **Armour:** three weights. Heavy plate (Smithing) is strong against blows
|
||||||
and weak to magic, light cloth (Tailoring) the other way round, medium
|
and weak to magic, light cloth (Tailoring) the other way round, medium
|
||||||
leather (Leatherworking) a bit of both. Every trade gathers its own base
|
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,
|
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
|
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,
|
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.
|
skill at a time; an empty one is replaced from the bank automatically.
|
||||||
- **Firekeeping:** the guild shares four fires: the forge (bars), the kitchen
|
- **Firekeeping:** the guild shares four fires: the forge (bars), the kitchen
|
||||||
fire (cooking), the kiln (bricks) and the still (potions). Every craft at a
|
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
|
## Layout
|
||||||
|
|
||||||
- `game.py`: content tables and the rules. Pure functions of `(world, now)`.
|
- `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.
|
- `app.py`: stdlib HTTP server, persistence and the background ticker.
|
||||||
- `static/`: the client (vanilla JS, no build step).
|
- `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["ward_reduction"] = round(game.reduction(game.magic_ward(player)), 3)
|
||||||
view["edible"] = game.edible(player)
|
view["edible"] = game.edible(player)
|
||||||
view["blessing"] = game.blessing(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["pp"] = int(player["pp"])
|
||||||
view["labour_per_action"] = game.labour_per_action(player)
|
view["labour_per_action"] = game.labour_per_action(player)
|
||||||
view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS}
|
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)
|
view["power"] = game.combat_power(player)
|
||||||
me = game.fighter(player, game.blessing(player), game.potion_effect(player, "combat"))
|
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"],
|
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"]}
|
view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]}
|
||||||
if player["action"]:
|
if player["action"]:
|
||||||
action, fight = game.growing(player), game.current_fight(world, player)
|
action, fight = game.growing(player), game.current_fight(world, player)
|
||||||
duration = fight["duration"] if fight else game.action_time(world, player, action)
|
duration = fight["duration"] if fight else game.action_time(world, player, action)
|
||||||
view["action"] = {**player["action"], "growing": action["id"], "duration": duration}
|
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:
|
if fight:
|
||||||
view["action"]["fight"] = game.fight_view(fight)
|
view["action"]["fight"] = game.fight_view(fight)
|
||||||
view["action"]["respawn"] = game.RESPAWN / game.speed(world)
|
|
||||||
party = game.party_of(world, player)
|
party = game.party_of(world, player)
|
||||||
if party:
|
if party:
|
||||||
view["action"]["with"] = [uid for uid in party["members"] if uid != player["id"]]
|
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.
|
# 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"] + game.RESPAWN) / game.speed(world)
|
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}
|
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["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS}
|
||||||
view["drill_per_action"] = game.drill_per_action(player)
|
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
|
chmod -R u+w source
|
||||||
cd source
|
cd source
|
||||||
python3 -m unittest discover -s tests -v
|
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
|
node --check static/app.js
|
||||||
touch $out
|
touch $out
|
||||||
'';
|
'';
|
||||||
|
|
|
||||||
451
game.py
451
game.py
|
|
@ -12,6 +12,8 @@ import math
|
||||||
import random
|
import random
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
import combat
|
||||||
|
|
||||||
HOUR = 3600
|
HOUR = 3600
|
||||||
DAY = 24 * HOUR
|
DAY = 24 * HOUR
|
||||||
OFFLINE_CAP = 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
|
OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose
|
||||||
MAX_LOG = 200
|
MAX_LOG = 200
|
||||||
REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour
|
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
|
REST_RATE = 0.01 # share of max HP back per game second resting between fights...
|
||||||
TOO_HURT = 0.3 # won't start a fight below this share without food
|
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]:
|
def _xp_table(max_level: int = 99) -> list[int]:
|
||||||
|
|
@ -57,7 +61,7 @@ ITEMS = {
|
||||||
"bricks": {"name": "Bricks", "icon": "🧱", "value": 3},
|
"bricks": {"name": "Bricks", "icon": "🧱", "value": 3},
|
||||||
"cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5},
|
"cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5},
|
||||||
"beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "🪜", "value": 25},
|
"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},
|
"bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8},
|
||||||
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
|
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
|
||||||
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20},
|
"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"],
|
"value": ITEMS[_herb]["value"] * 3 + ITEMS[_second]["value"],
|
||||||
"potion": {"skill": _skill, **_effect, "text": describe_potion(_skill, _effect)}}
|
"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.
|
# 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"]))
|
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"]
|
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
|
# 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.
|
# line with its trophy, and every armour line with the area's reagents) and a boss.
|
||||||
CRIT_CHANCE = 0.05
|
CRIT_CHANCE = 0.05
|
||||||
CRIT_MULT = 1.5
|
CRIT_MULT = combat.CRIT_MULT
|
||||||
RESPAWN = 2 # seconds after a kill before the next enemy turns up
|
RESPAWN = 2 # the least rest between one fight and the next
|
||||||
MAX_FIGHT = 600 # a fight that drags on longer is broken off
|
GROUP_SIZE = 2 # bosses and dragons are only fought this many together...
|
||||||
FLEE_BELOW = 0.2 # out of food and under this share of HP: get out
|
BOSS_HP = 0.75 # ...and grow by this much of their HP for each fighter...
|
||||||
EAT_DELAY = 1.5 # seconds each bite holds back your next swing
|
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 = {
|
DAMAGE_TYPES = {
|
||||||
"slash": {"name": "Slashing", "glyph": "sword-slice", "tint": "#d4dae2"},
|
"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("_")
|
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: HP, seconds between swings, accuracy, evasion, max hit (multipliers), how often it turns up.
|
||||||
ARCHETYPES = {
|
ARCHETYPES = {
|
||||||
"brute": (1.3, 3.0, 1.0, 0.85, 1.2, 4),
|
"brute": (1.3, 3.0, 1.0, 0.85, 1.2, 4),
|
||||||
"skirmisher": (0.85, 2.2, 1.1, 1.25, 0.85, 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),
|
"caster": (0.85, 2.6, 1.15, 1.0, 1.25, 3),
|
||||||
"elite": (1.7, 2.8, 1.1, 1.1, 1.3, 1),
|
"elite": (1.7, 2.8, 1.1, 1.1, 1.3, 1),
|
||||||
"boss": (1.4, 3.0, 1.0, 1.0, 1.35, 1),
|
"boss": (1.4, 3.0, 1.0, 1.0, 1.2, 1),
|
||||||
"dragon": (2.5, 3.0, 1.1, 1.0, 1.5, 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, interval, accuracy, evasion, hit, weight = ARCHETYPES[archetype]
|
||||||
hp = round((20 + 6 * level) * hp * (2.5 if boss else 1))
|
hp = round((20 + 6 * level) * hp * (2.5 if boss else 1))
|
||||||
return {"name": name, "archetype": archetype, "level": level, "boss": boss, "weight": weight,
|
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),
|
"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))),
|
"max_hit": max(1, round((2 + level / 3) * hit * (1.2 if boss else 1))),
|
||||||
"attack": _types(attack), "vulnerable": _types(vulnerable), "resists": _types(resists)}
|
"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:
|
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)],
|
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).
|
# Rosters: (variant, archetype, attacks with, vulnerable to, resists).
|
||||||
|
|
@ -941,7 +959,7 @@ COMBAT_ACTIONS = [
|
||||||
_encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4,
|
_encounter("fight_goblins", "Cave goblins", "Greenwood", 5, GOBLINS, gold=4,
|
||||||
drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]),
|
drops=[("bones", 1, 1.0), ("copper_ore", 2, 0.4), ("feathers", 5, 0.4)]),
|
||||||
*_area_fights(),
|
*_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"),
|
_boss("fight_ruins", "Watchtower ogre", "Old Watchtower", 26, "boss", ("blunt", "magic"), ("slash", "magic"),
|
||||||
("blunt", "pierce"), gold=25, req=20,
|
("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)]),
|
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]]}",
|
_fuelled({herb: 1, second: 1}, POTION_TIER[potion]), {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}",
|
||||||
fire="still", fuel=POTION_TIER[potion])
|
fire="still", fuel=POTION_TIER[potion])
|
||||||
for potion, name, level, herb, second, skill, _effect in POTIONS],
|
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_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_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}),
|
_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 ---------------------------------------------------------------
|
||||||
|
|
||||||
WORLD_VERSION = 25
|
WORLD_VERSION = 26
|
||||||
|
|
||||||
|
|
||||||
def new_world(now: float, speed: float = 1.0) -> dict:
|
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"]:
|
if player["action"]:
|
||||||
player["action"].pop("fight", None)
|
player["action"].pop("fight", None)
|
||||||
world["version"] = 25
|
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
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1722,13 +1751,17 @@ def max_hp(player: dict) -> int:
|
||||||
return 40 + 4 * skill_level(player, "combat") + int(gear_bonus(player, "hp"))
|
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:
|
def regen(world: dict, player: dict, now: float) -> None:
|
||||||
"""Heal for the time since HP was last settled."""
|
"""Heal for the time since HP was last settled."""
|
||||||
if now > player["hp_at"]:
|
if now > player["hp_at"]:
|
||||||
top = max_hp(player)
|
top = max_hp(player)
|
||||||
boost = 2 if fitted(world, "enchanted_hearths") else 1
|
per_second = top * REGEN_PER_HOUR * regen_boost(world, player) * speed(world) / HOUR
|
||||||
boost *= 1 + gear_bonus(player, "regen") / 100
|
|
||||||
per_second = top * REGEN_PER_HOUR * boost * speed(world) / HOUR
|
|
||||||
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
|
player["hp"] = min(top, player["hp"] + (now - player["hp_at"]) * per_second)
|
||||||
player["hp_at"] = now
|
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:
|
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
|
"""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,
|
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_id = weapon_style(player)
|
||||||
style = STYLES[style_id]
|
style = STYLES[style_id]
|
||||||
power = combat_power(player) + blessed["power"] + brew.get("power", 0)
|
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
|
lands = affinity(enemy, style["damage"]) if enemy else 1
|
||||||
taken = blessed["taken"] * (1 - brew.get("reduce", 0))
|
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),
|
"interval": round(style["interval"] * (1 - gear_bonus(player, "haste") / 100), 2),
|
||||||
"accuracy": (10 + 2 * power) * style["accuracy"],
|
"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,
|
"crit": style["crit"] + gear_bonus(player, "crit") / 100,
|
||||||
"first": style.get("first", False), "affinity": lands,
|
"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)),
|
"evasion": 10 + 2 * (skill_level(player, "combat") + defence_bonus(player)),
|
||||||
"taken": {"physical": (1 - damage_reduction(player)) * taken, "magic": (1 - reduction(magic_ward(player))) * taken}}
|
"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"]
|
return me["taken"]["magic" if damage == "magic" else "physical"]
|
||||||
|
|
||||||
|
|
||||||
def hit_chance(accuracy: float, evasion: float) -> float:
|
hit_chance = combat.hit_chance
|
||||||
return min(0.95, max(0.05, accuracy / (accuracy + evasion) + 0.15))
|
|
||||||
|
|
||||||
|
|
||||||
def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float:
|
def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float:
|
||||||
return (1 - crit) * (max_hit + 1) / 2 + crit * max_hit * CRIT_MULT
|
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:
|
def rest_plan(world: dict | None, player: dict, hp: float | None = None) -> tuple[float, dict]:
|
||||||
"""`bite` is what the next food heals: eating holds back your swings, so a fight you have
|
"""Resting back to full before a fight: how many game seconds, and the food eaten on the way.
|
||||||
to eat through runs longer."""
|
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)
|
me = fighter(player, blessed, brew, foe)
|
||||||
hit, their_hit = hit_chance(me["accuracy"], foe["evasion"]), hit_chance(foe["accuracy"], me["evasion"])
|
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"])
|
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 = 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))
|
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"],
|
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"]))),
|
"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:
|
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."""
|
"""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")
|
blessed, brew = blessing(player), potion_effect(player, "combat")
|
||||||
variants = action["enemy"]["variants"]
|
variants = action["enemy"]["variants"]
|
||||||
pantry = edible(player)
|
pack = PACK_SHARE if len(variants) > 1 else BOSS_HP if action.get("together") else 1 # a boss shared by a pair
|
||||||
bite = ITEMS[pantry[0]]["heal"] if pantry else 0
|
rows = [_variant_odds(player, foe, blessed, brew, pack) for foe in variants]
|
||||||
rows = [_variant_odds(player, foe, blessed, brew, bite) for foe in variants]
|
|
||||||
total = sum(foe["weight"] 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
|
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),
|
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),
|
"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),
|
"variants": [{"affinity": row["affinity"], "kill_time": round(row["kill_time"], 1),
|
||||||
"damage": round(row["damage"], 1)} for row in rows]}
|
"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
|
"""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."""
|
fight, so playing it out again later goes the same way."""
|
||||||
blessed, brew = blessing(player), potion_effect(player, "combat")
|
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,
|
return {"id": player["id"], "name": player["name"], "at": at, "left": None,
|
||||||
"start_hp": round(player["hp"]), "top": max_hp(player), "style": me["style"],
|
"start_hp": round(player["hp"]), "hp_in": player["hp"], "top": max_hp(player), "style": me["style"],
|
||||||
"interval": me["interval"] / speed(world), "fighter": me, "hp_in": player["hp"],
|
"role": me["role"], "abilities": me["abilities"], "interval": me["interval"] / speed(world),
|
||||||
"pantry": {item: player["bank"][item] for item in edible(player)}, "mend": blessed["mend"],
|
"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"]}
|
"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:
|
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)
|
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:
|
def draw_pack(action: dict, size: int, rng: random.Random) -> list[dict]:
|
||||||
"""Pick the enemy and play the fight out for everyone in it."""
|
"""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"]
|
variants = action["enemy"]["variants"]
|
||||||
variant = rng.choices(range(len(variants)), [v["weight"] for v in variants])[0]
|
weights = [foe["weight"] for foe in variants]
|
||||||
foe = variants[variant]
|
first = rng.choices(range(len(variants)), weights)[0]
|
||||||
pace = speed(world)
|
picks, (hp, hits) = [first], (1.0, 1.0)
|
||||||
fight = {"seed": rng.random(), "variant": variant, "pace": pace, "enemy": foe["name"], "enemy_hp": foe["hp"],
|
if action.get("together"):
|
||||||
"xp": foe["xp"], "enemy_interval": foe["interval"] / pace,
|
hp, hits = BOSS_HP * size, BOSS_HITS[0] * (1 + BOSS_HITS[1] * (size - GROUP_SIZE))
|
||||||
"foe": {key: foe[key] for key in ("archetype", "attack", "vulnerable", "resists")},
|
if len(variants) > 1 and variants[first]["archetype"] != "elite":
|
||||||
"members": [fight_member(world, player, foe) for player in players]}
|
count = size + (rng.random() < PACK_EXTRA)
|
||||||
return play_fight(action, fight)
|
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:
|
def simulate_fight(world: dict, player: dict, action: dict, rng: random.Random) -> dict:
|
||||||
return draw_fight(world, [player], action, rng)
|
return draw_fight(world, [player], action, rng)
|
||||||
|
|
||||||
|
|
||||||
def play_fight(action: dict, fight: dict) -> dict:
|
def play_fight(fight: dict) -> dict:
|
||||||
"""Play out a fight swing by swing, every swing timer starting empty (a latecomer's from when they
|
"""Play a fight out, slice by slice, from the start (see combat.py). Nothing changes until it's over:
|
||||||
tag in). Nothing changes until it's over: the result is applied then. The dice come from the fight's
|
the result is applied then. The dice come from the fight's own seed, so playing it again with
|
||||||
own seed, so playing it again with someone tagging in or stepping out changes nothing before then."""
|
someone tagging in or stepping out changes nothing before then."""
|
||||||
rng = random.Random(fight["seed"])
|
return combat.run(fight)
|
||||||
foe = action["enemy"]["variants"][fight["variant"]]
|
|
||||||
pace = fight["pace"]
|
|
||||||
sides = []
|
def pack_xp(fight: dict, killed_only: bool = False) -> float:
|
||||||
for member in fight["members"]:
|
"""The pack's XP: on a loss, the enemies brought down and a third of the rest."""
|
||||||
me = member["fighter"]
|
if not killed_only:
|
||||||
# A bow looses its first arrow before the enemy closes in.
|
return sum(foe["xp"] for foe in fight["pack"])
|
||||||
sides.append({"member": member, "me": me, "hp": member["hp_in"], "pantry": dict(member["pantry"]),
|
kills = set(fight["kills"])
|
||||||
"next": member["at"] + (0.5 if me["first"] else me["interval"]), "eaten": {}, "hurt": 0,
|
return sum(foe["xp"] * (1 if f"e{i}" in kills else 1 / 3) for i, foe in enumerate(fight["pack"]))
|
||||||
"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
|
|
||||||
|
|
||||||
|
|
||||||
# --- prayer -------------------------------------------------------------------
|
# --- prayer -------------------------------------------------------------------
|
||||||
|
|
@ -2296,8 +2301,6 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
|
||||||
return "No raid to prepare for"
|
return "No raid to prepare for"
|
||||||
if threat.get("drilled", 0) >= drill_needed(threat):
|
if threat.get("drilled", 0) >= drill_needed(threat):
|
||||||
return "The militia is as drilled as it can be"
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2352,9 +2355,8 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
|
||||||
if "enemy" in action:
|
if "enemy" in action:
|
||||||
outing = player["action"]
|
outing = player["action"]
|
||||||
fight = fight or outing.pop("fight", None) or simulate_fight(world, player, action, rng)
|
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"])
|
mine = member_of(fight, player["id"])
|
||||||
|
xp = pack_xp(fight, killed_only=mine["result"] != "won") * mods["xp"]
|
||||||
if player["prayers"] and not mine["pp"]:
|
if player["prayers"] and not mine["pp"]:
|
||||||
summary["prayer_out"] = True
|
summary["prayer_out"] = True
|
||||||
paid = min(player["pp"], mine["pp"])
|
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
|
player["pp"] -= paid
|
||||||
summary["pp_spent"] += paid
|
summary["pp_spent"] += paid
|
||||||
gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary)
|
gain_xp(world, player, "prayer", paid * PRAYER_XP_PER_POINT, now, summary)
|
||||||
for food, count in mine["eaten"].items():
|
used = {**({mine["potion_item"]: 1} if mine["drank"] else {}), **({"arrows": mine["shot"]} if mine["shot"] else {})}
|
||||||
count = min(count, player["bank"].get(food, 0))
|
for key, spent in (("eaten", mine["eaten"]), ("used", used)):
|
||||||
if count:
|
for item, count in spent.items():
|
||||||
bank_take(player, food, count)
|
count = min(count, player["bank"].get(item, 0))
|
||||||
summary["eaten"][food] = summary["eaten"].get(food, 0) + count
|
if 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"]
|
summary["damage"] += mine["damage"]
|
||||||
if mine["wind_used"]:
|
if mine["wind_used"]:
|
||||||
outing["wind_used"] = True
|
outing["wind_used"] = True
|
||||||
summary["second_wind"] += 1
|
summary["second_wind"] += 1
|
||||||
if mine["result"] != "won":
|
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
|
summary["losses"] += 1
|
||||||
reason = "Retreated: ran out of food" if not edible(player) else "Retreated"
|
gain_xp(world, player, "combat", xp, now, summary)
|
||||||
drop_out(world, player, now, reason)
|
outing["downs"] = outing.get("downs", 0) + 1
|
||||||
summary["stopped"] = reason
|
reason = "Fell back: the fight dragged on" if mine["result"] == "fled" else \
|
||||||
gain_xp(world, player, "combat", xp // 3, now, summary)
|
f"Knocked out {KNOCKOUTS_TO_STOP} times in a row" if outing["downs"] >= KNOCKOUTS_TO_STOP else None
|
||||||
|
if reason:
|
||||||
|
drop_out(world, player, now, reason)
|
||||||
|
summary["stopped"] = reason
|
||||||
return
|
return
|
||||||
|
outing.pop("downs", None)
|
||||||
summary["wins"] += 1
|
summary["wins"] += 1
|
||||||
gold, drops = loot or roll_loot(action, rng)
|
gold, drops = loot or pack_loot(action, fight, rng)
|
||||||
player["gold"] += gold
|
player["gold"] += gold
|
||||||
summary["gold"] += gold
|
summary["gold"] += gold
|
||||||
for item, qty in drops.items():
|
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:
|
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,
|
"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,
|
"sold": {}, "sold_gold": 0, "pp_spent": 0, "pp_gained": 0, "prayer_out": False, "second_wind": 0,
|
||||||
"kept": 0, "potions_drunk": {}, "potion_out": [], "labour": 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:
|
if not current:
|
||||||
return summary
|
return summary
|
||||||
limit = min(now, offline_limit(world, player))
|
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"]:
|
while player["action"]:
|
||||||
action = growing(player)
|
action = growing(player)
|
||||||
if current.get("plot2") and can_start(world, player, action):
|
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
|
break
|
||||||
burn_fires(world, current["start"] + duration)
|
burn_fires(world, current["start"] + duration)
|
||||||
reason = can_start(world, player, action)
|
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:
|
if reason:
|
||||||
stop(player, reason)
|
stop(player, reason)
|
||||||
summary["stopped"] = 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)
|
_complete(world, player, action, current["start"] + duration, rng, summary)
|
||||||
summary["done"] += 1
|
summary["done"] += 1
|
||||||
current["start"] += duration
|
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"):
|
if current.get("plot2"):
|
||||||
current["turn"] = 1 - current.get("turn", 0)
|
current["turn"] = 1 - current.get("turn", 0)
|
||||||
if player["action"] is current and "left" in current:
|
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)
|
stop(player, reason)
|
||||||
|
|
||||||
|
|
||||||
def roll_loot(action: dict, rng: random.Random) -> tuple[int, dict]:
|
def roll_loot(action: dict, rng: random.Random, share: float = 1.0) -> tuple[int, dict]:
|
||||||
"""A kill's gold and drops."""
|
"""A kill's gold and drops. A weaker enemy in a pack (`share`) carries less, and drops less often."""
|
||||||
gold = rng.randint(*action["gold"]) if action.get("gold") else 0
|
gold = round(rng.randint(*action["gold"]) * share) if action.get("gold") else 0
|
||||||
drops = {}
|
drops = {}
|
||||||
for item, qty, odds in action.get("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
|
drops[item] = drops.get(item, 0) + qty
|
||||||
return gold, drops
|
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"])
|
pid = str(world["next_party_id"])
|
||||||
world["next_party_id"] += 1
|
world["next_party_id"] += 1
|
||||||
solo = host["action"]
|
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"]],
|
party = world["parties"][pid] = {"id": pid, "action": solo["id"], "members": [host["id"]],
|
||||||
"start": solo["start"], "fight": solo.pop("fight", None)}
|
"start": solo["start"], "fight": solo.pop("fight", None)}
|
||||||
solo["party"] = pid
|
solo["party"] = pid
|
||||||
|
|
@ -2659,10 +2675,11 @@ def join_fight(world: dict, player: dict, uid: str, now: float) -> str | None:
|
||||||
player["stopped"] = None
|
player["stopped"] = None
|
||||||
fight = party["fight"]
|
fight = party["fight"]
|
||||||
if fight and now < party["start"] + fight["duration"]:
|
if fight and now < party["start"] + fight["duration"]:
|
||||||
at = max(0.0, (now - party["start"]) * fight["pace"])
|
# Straight in, as you are: the others may still be resting, or already in the thick of it.
|
||||||
foe = action["enemy"]["variants"][fight["variant"]]
|
regen(world, player, now)
|
||||||
fight["members"].append(fight_member(world, player, foe, at))
|
at = max(0.0, (now - party["start"]) * fight["pace"] - fight["rest"])
|
||||||
play_fight(action, fight)
|
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"])
|
log(world, now, "party", f"{player['name']} joined {host['name']}: {action['name']}", player["id"])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -2674,13 +2691,13 @@ def leave_party(world: dict, player: dict, pid: str, now: float) -> None:
|
||||||
party["members"].remove(player["id"])
|
party["members"].remove(player["id"])
|
||||||
fight = party["fight"]
|
fight = party["fight"]
|
||||||
if fight and now < party["start"] + fight["duration"]:
|
if fight and now < party["start"] + fight["duration"]:
|
||||||
action = ACTION_BY_ID[party["action"]]
|
|
||||||
member = member_of(fight, player["id"])
|
member = member_of(fight, player["id"])
|
||||||
if now <= party["start"] or len(fight["members"]) == 1:
|
left = (now - party["start"]) * fight["pace"] - fight["rest"]
|
||||||
fight["members"].remove(member)
|
if left <= member["at"] or len(fight["members"]) == 1:
|
||||||
|
fight["members"].remove(member) # gone before it started: nothing eaten, nothing fought
|
||||||
else:
|
else:
|
||||||
member["left"] = max(member["at"], (now - party["start"]) * fight["pace"])
|
member["left"] = left
|
||||||
party["fight"] = play_fight(action, fight) if party["members"] and fight["members"] else None
|
party["fight"] = play_fight(fight) if party["members"] and fight["members"] else None
|
||||||
if len(party["members"]) <= 1:
|
if len(party["members"]) <= 1:
|
||||||
del world["parties"][pid]
|
del world["parties"][pid]
|
||||||
for uid in party["members"]:
|
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:
|
for player in members:
|
||||||
regen(world, player, party["start"])
|
regen(world, player, party["start"])
|
||||||
reason = can_start(world, player, action)
|
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:
|
if reason:
|
||||||
drop_out(world, player, party["start"], reason)
|
drop_out(world, player, party["start"], reason)
|
||||||
player.setdefault("away", empty_summary())["stopped"] = 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
|
break
|
||||||
burn_fires(world, end)
|
burn_fires(world, end)
|
||||||
fight, party["fight"] = party["fight"], None
|
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:
|
for player in members:
|
||||||
player["action"]["start"] = party["start"]
|
player["action"]["start"] = party["start"]
|
||||||
results = {m["id"]: m["result"] for m in fight["members"]}
|
results = {m["id"]: m["result"] for m in fight["members"]}
|
||||||
winners = [uid for uid in party["members"] if results.get(uid) == "won"]
|
winners = [uid for uid in party["members"] if results.get(uid) == "won"]
|
||||||
if action.get("personal_loot"):
|
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:
|
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:
|
for player in members:
|
||||||
if results.get(player["id"], "left") == "left":
|
if results.get(player["id"], "left") == "left":
|
||||||
continue
|
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)
|
leave_party(world, player, current["party"], now)
|
||||||
player["action"] = entry
|
player["action"] = entry
|
||||||
player["stopped"] = None
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4711,6 +4721,8 @@ def _assign_glyphs() -> None:
|
||||||
for potion, *_rest, skill, effect in POTIONS:
|
for potion, *_rest, skill, effect in POTIONS:
|
||||||
tint = "#e05a5a" if "power" in effect else "#8fa8c8" if "reduce" in effect else SKILL_GLYPHS[skill][1]
|
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)
|
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:
|
for skill in SKILLS:
|
||||||
skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]]
|
skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]]
|
||||||
for key, spec in CLASSES.items():
|
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
|
# 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.
|
# line here (new / balance / fix), and a new release a new entry on top.
|
||||||
PATCH_NOTES = [
|
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": [
|
{"date": "2026-10-23", "title": "City needs", "notes": [
|
||||||
("new", "City needs are now indicated in profession menus."),
|
("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,
|
"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,
|
"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()),
|
"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,
|
"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,
|
"resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
|
||||||
"regen_per_hour": REGEN_PER_HOUR,
|
"regen_per_hour": REGEN_PER_HOUR,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ stdenvNoCC.mkDerivation {
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
mkdir -p $out/share/guildhall $out/bin
|
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
|
cp -r static $out/share/guildhall/static
|
||||||
makeWrapper ${python3}/bin/python3 $out/bin/guildhall \
|
makeWrapper ${python3}/bin/python3 $out/bin/guildhall \
|
||||||
--add-flags "$out/share/guildhall/app.py"
|
--add-flags "$out/share/guildhall/app.py"
|
||||||
|
|
|
||||||
251
static/app.js
251
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>
|
<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.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.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>` : ""}
|
${items ? `<ul>${items}</ul>` : ""}
|
||||||
${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""}
|
${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""}
|
||||||
${xp ? `<ul>${xp}</ul>` : ""}
|
${xp ? `<ul>${xp}</ul>` : ""}
|
||||||
|
|
@ -888,38 +889,58 @@ function variantRows(e, o) {
|
||||||
}).join("")}</div>`;
|
}).join("")}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything about fighting in one frame. Left: you, your prayers (switch them mid-fight) and your
|
// Everything about fighting in one frame. Left: you and whoever's with you, your abilities (on auto),
|
||||||
// gear (swap it mid-fight). Right: what you're fighting, what it attacks with, what it's weak to and
|
// your prayers (switch them mid-fight) and your gear (swap it mid-fight). Right: the pack, each with
|
||||||
// resists (your weapon's type outlined), and the fight as it plays out.
|
// what it attacks with and how it takes each damage type (your weapon's outlined), and the fight as
|
||||||
|
// it plays out.
|
||||||
function combatFrame() {
|
function combatFrame() {
|
||||||
const me = S.me, a = me.action, f = a?.fight;
|
const me = S.me, a = me.action, f = a?.fight;
|
||||||
const encounter = f && C.actions.find((x) => x.id === a.id);
|
const encounter = a && C.actions.find((x) => x.id === a.id);
|
||||||
const allies = f ? f.members.filter((m) => m.id !== S.uid) : [];
|
const waiting = a?.waiting;
|
||||||
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>
|
const others = f ? f.members.filter((m) => m.id !== S.uid) : [];
|
||||||
<div class="bar swing"><span data-f="${who}-swing"></span></div>`;
|
const unit = (id, name, foe = false, extra = "") => `<div class="fighter ${foe ? "foe-unit" : id === S.uid ? "" : "ally"}" data-u="${esc(id)}">
|
||||||
const you = `<div class="fighter">
|
<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="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>
|
<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>
|
||||||
${bars("you")}
|
<div class="bar swing"><span data-u-swing></span></div>
|
||||||
</div>${allies.map((m, i) => `<div class="fighter ally">
|
<div class="cast small" data-u-cast></div>
|
||||||
<div class="fighter-name"><b>${esc(m.name)}</b><span class="small muted">${HEART} <span data-f="ally${i}-hp"></span></span></div>
|
</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>
|
const you = unit(S.uid, esc(me.name)) + others.map((m) => unit(m.id, esc(m.name))).join("");
|
||||||
</div>`).join("")}`;
|
let foe = `<div class="foe-idle muted">${waiting ? `👥 Waiting for a group · 1 / ${C.group_size}` : "Not fighting."}</div>`;
|
||||||
let foe = `<div class="foe-idle muted">Not fighting.</div>`;
|
|
||||||
if (f) {
|
if (f) {
|
||||||
const mine = C.styles[(me.equip.weapon && item(me.equip.weapon).style) || "sword"].damage;
|
const mine = C.styles[me.fighter.style || "sword"]?.damage || me.fighter.damage;
|
||||||
const pills = (label, kinds, how) => kinds?.length ? `<div class="trait"><span class="muted small">${label}</span>
|
const traits = (v) => `<span class="v-attack">${v.attack.map((k) => dmgIcon(k, "attack")).join("")}</span>
|
||||||
${kinds.map((k) => `<span class="pill ${how} ${k === mine && how !== "attack" ? "yours" : ""}">${dmgIcon(k, how)}${C.damage_types[k].name}</span>`).join("")}</div>` : "";
|
<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>
|
foe = `<div class="foe-head"><span class="foe-ico">${encounter?.icon || ""}</span><b>${esc(f.enemy)}</b></div>
|
||||||
<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>
|
<div class="pack">${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" ? ` <span class="muted">· rare</span>` : ""}`, true,
|
||||||
${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>` : ""}
|
` <span class="traits-inline">${traits(v)}</span>`)).join("")}</div>
|
||||||
<ul class="fight-log" data-f="log"></ul>`;
|
<ul class="fight-log" data-f="log"></ul>`;
|
||||||
}
|
}
|
||||||
return `<div class="panel fight combat-frame" ${f ? `data-fight="${a.start}"` : ""}>
|
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 class="foe-side">${foe}</div>
|
||||||
</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.
|
// Prayers you can switch on and off between swings, and the points left to pay for them.
|
||||||
function prayerBar(me) {
|
function prayerBar(me) {
|
||||||
const level = me.levels.prayer;
|
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>`;
|
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.
|
const SWINGS = new Set(["hit", "crit", "miss", "block", "absorb"]);
|
||||||
function fightLine(e, enemy) {
|
|
||||||
const [, who, kind, amount, , , food, target] = e;
|
// A line of the fight, terse: who, to whom, how much, with what.
|
||||||
const type = kind === "hit" || kind === "crit" ? (C.damage_types[food] ? ` ${dmgIcon(food)}` : "") : "";
|
function fightLine(e, f) {
|
||||||
const foe = esc(enemy);
|
const [, src, tgt, kind, n, , label] = e;
|
||||||
const subject = who === "foe" ? target : who, mine = subject === S.uid;
|
const name = (id) => id === S.uid ? "You" : id?.[0] === "e" ? esc(f.pack[+id.slice(1)].name) : esc(playerName(id));
|
||||||
const name = mine ? "You" : esc(playerName(subject)), s = mine ? "" : "s";
|
const ab = C.abilities[label] || C.enemy_abilities[label];
|
||||||
if (kind === "eat") return `<li class="${mine ? "good" : "muted"}">${name} eat${s} ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
|
const what = ab ? ` ${ab.icon} ${esc(ab.name)}` : C.damage_types[label] ? ` ${dmgIcon(label)}` : "";
|
||||||
if (kind === "wind") return `<li class="good">Second wind! ${mine ? "Back on your feet" : `${name} is back up`}</li>`;
|
const hurtsMe = tgt === S.uid && src?.[0] === "e";
|
||||||
if (kind === "block") return `<li class="${mine ? "good" : "muted"}">${name} block${s} ${foe}'s blow</li>`;
|
const tone = hurtsMe ? "bad" : src === S.uid ? "" : "muted";
|
||||||
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>`;
|
switch (kind) {
|
||||||
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>`;
|
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) {
|
function animateFight(now) {
|
||||||
const a = S.me?.action, f = a?.fight;
|
const a = S.me?.action, f = a?.fight;
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
const t = now - a.start;
|
const t = now - a.start, rest = f.rest / f.pace;
|
||||||
const over = t >= f.end;
|
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];
|
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),
|
const me = st[mine.id];
|
||||||
// the panel is the same quiet countdown: no enemy, every swing timer empty.
|
// Resting: everyone's HP climbs back to full while they eat.
|
||||||
const waiting = t < 0 || (over && f.result === "won" && mine.result === "won");
|
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 respawnIn = t < 0 ? -t : f.end + (a.respawn || 1) - t;
|
const enemies = f.pack.map((_, i) => st[`e${i}`]);
|
||||||
let shown = -1;
|
const left = enemies.reduce((sum, e) => sum + e.hp, 0), total = enemies.reduce((sum, e) => sum + e.top, 0);
|
||||||
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++;
|
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${(resting ? 1 : left / total) * 100}%`;
|
||||||
const last = f.events[shown];
|
for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(me.hp);
|
||||||
// Each fighter's HP is in their own swings and bites, and in the enemy's blows at them.
|
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, me.hp / me.top * 100)}%`;
|
||||||
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 panel = $("[data-fight]");
|
const panel = $("[data-fight]");
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.classList.toggle("waiting", waiting);
|
panel.classList.toggle("waiting", resting);
|
||||||
// Your HP everywhere follows the fight as it plays out.
|
for (const el of panel.querySelectorAll("[data-u]")) {
|
||||||
for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(hp);
|
const u = st[el.dataset.u];
|
||||||
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / mine.top * 100)}%`;
|
if (!u) continue;
|
||||||
const set = (key, fn) => { const el = panel.querySelector(`[data-f="${key}"]`); if (el) fn(el); };
|
const gone = !!u.out || over;
|
||||||
set("you-bar", (el) => { el.style.width = `${Math.max(0, hp / mine.top * 100)}%`; });
|
el.classList.toggle("out", !!u.out);
|
||||||
set("foe-bar", (el) => { el.style.width = `${Math.max(0, foeHp / f.enemy_hp * 100)}%`; });
|
el.querySelector("[data-u-bar]").style.width = `${Math.max(0, u.hp / u.top * 100)}%`;
|
||||||
set("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(mine.top)}`; });
|
el.querySelector("[data-u-hp]").textContent = u.out === "left" ? "left" : t < u.from && !resting ? "joining…" : `${fmt(u.hp)} / ${fmt(u.top)}`;
|
||||||
set("foe-hp", (el) => { el.textContent = waiting ? "" : `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; });
|
el.querySelector("[data-u-status]").textContent = u.out === "dead" ? "💀" : u.out === "down" ? "💫" : u.held ? "🌿" : u.enraged ? "😡" : "";
|
||||||
const swing = (key, swung, every, from = 0, gone = false) => set(`${key}-swing`, (el) => {
|
el.querySelector("[data-u-swing]").style.width = gone || t < u.from ? "0%" : `${Math.min(100, (t - (u.swung ?? u.from)) / u.every * 100)}%`;
|
||||||
el.style.width = over || waiting || gone || t < from ? "0%" : `${Math.min(100, (t - (swung ?? from)) / 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];
|
||||||
swing("you", swungAt(mine.id), mine.interval, mine.at / f.pace, t >= fell);
|
const castEl = el.querySelector("[data-u-cast]");
|
||||||
swing("foe", swungAt("foe"), f.enemy_interval);
|
const key = cast ? `${cast.label}:${cast.from}` : "";
|
||||||
f.members.filter((m) => m.id !== S.uid).forEach((m, i) => {
|
if (castEl.dataset.key !== key) {
|
||||||
const ally = hpOf(m), left = m.left != null && t >= m.left / f.pace;
|
castEl.dataset.key = key;
|
||||||
set(`ally${i}-bar`, (el) => { el.style.width = `${Math.max(0, ally / m.top * 100)}%`; });
|
castEl.innerHTML = cast ? `<span>${spec.icon} ${esc(spec.name)}</span><span class="bar"><span></span></span>` : "";
|
||||||
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);
|
if (cast) castEl.querySelector(".bar > span").style.width = `${Math.min(100, (t - cast.from) / (cast.until - cast.from) * 100)}%`;
|
||||||
});
|
}
|
||||||
set("log", (el) => {
|
// Your abilities: the shade is the cooldown left, a flash when one's just been used.
|
||||||
const key = `${a.start}:${shown}:${over}:${waiting ? Math.ceil(respawnIn * 10) : ""}`;
|
for (const el of panel.querySelectorAll("[data-ab]")) {
|
||||||
if (el.dataset.key === key) return;
|
const id = el.dataset.ab;
|
||||||
el.dataset.key = key;
|
if (id === "potion") { el.classList.toggle("spent", !!me.drank); continue; }
|
||||||
const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy));
|
const ab = C.abilities[id], used = me.cds?.[id];
|
||||||
if ((over && !waiting) || t >= fell) lines.unshift(`<li class="bad"><b>You fall back.</b></li>`);
|
const cd = (ab.cd || 0) * C.auto_cd / f.pace;
|
||||||
el.innerHTML = waiting ? `<li class="good"><b>Victory!</b> ${respawnIn > 0.05 ? `Next enemy in ${respawnIn.toFixed(1)}s` : "Next enemy coming…"}</li>`
|
const leftCd = used == null || over ? 0 : Math.max(0, used + cd - t);
|
||||||
: lines.join("") || `<li class="muted">Squaring up…</li>`;
|
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".
|
// A piece of jewellery's bonus, e.g. "+8% accuracy".
|
||||||
|
|
@ -1052,6 +1130,7 @@ function itemStats(id) {
|
||||||
const it = item(id);
|
const it = item(id);
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (it.heal) parts.push(`❤️ Heals ${it.heal}`);
|
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.food) parts.push(`feeds ${it.food} for 2 days`);
|
||||||
if (it.power) parts.push(`+${it.power} power`);
|
if (it.power) parts.push(`+${it.power} power`);
|
||||||
if (it.style) {
|
if (it.style) {
|
||||||
|
|
@ -1096,7 +1175,8 @@ function actionCard(a) {
|
||||||
let extra = "";
|
let extra = "";
|
||||||
if (a.enemy) {
|
if (a.enemy) {
|
||||||
const o = me.fight_odds[a.id], e = 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)}`;
|
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.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("");
|
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>
|
${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>
|
<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>` : ""}
|
${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>
|
${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 in fights</button>`}</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>` : ""}
|
${it.food ? `<div class="row small muted">Feeds ${it.food} townsfolk for 2 days.</div>` : ""}
|
||||||
${C.city_uses[id].length
|
${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>
|
? `<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;
|
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) {
|
function foodSlot(p, picked) {
|
||||||
if (!("food" in p)) return `<span class="doll-slot future" title="Food"></span>`;
|
if (!("food" in p)) return `<span class="doll-slot future" title="Food"></span>`;
|
||||||
const id = p.food && p.bank[p.food] ? p.food : null;
|
const id = p.food && p.bank[p.food] ? p.food : null;
|
||||||
|
|
@ -2002,7 +2083,7 @@ function animate() {
|
||||||
const now = serverNow();
|
const now = serverNow();
|
||||||
const a = S?.me?.action;
|
const a = S?.me?.action;
|
||||||
animateFight(now);
|
animateFight(now);
|
||||||
if (a) {
|
if (a && !a.waiting) {
|
||||||
const pct = Math.min(1, (now - a.start) / a.duration);
|
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.
|
// 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) + "%";
|
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) {
|
if (pct >= 1 && refreshedFor !== key) {
|
||||||
refreshedFor = key;
|
refreshedFor = key;
|
||||||
setTimeout(refresh, 150);
|
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
|
retriedAt = now; // the next enemy hasn't turned up yet: ask again
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -553,9 +553,32 @@ button.sheet-skill:hover { background: var(--panel-2); }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.combat-frame { display: flex; flex-direction: column; gap: 16px; }
|
.combat-frame { display: flex; flex-direction: column; gap: 16px; }
|
||||||
.you-side { display: contents; }
|
.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); }
|
.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 { padding: 3px 10px 3px 3px; }
|
||||||
.pill .dmg { border-radius: 50%; }
|
.pill .dmg { border-radius: 50%; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,7 @@ class ActionTest(unittest.TestCase):
|
||||||
world = world_with(("wil", "commoner"))
|
world = world_with(("wil", "commoner"))
|
||||||
p = world["players"]["wil"]
|
p = world["players"]["wil"]
|
||||||
p["bank"] = {"bread": 500}
|
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)
|
game.start_action(world, p, "fight_wolves", T0)
|
||||||
summary = game.process_player(world, p, T0 + 3600, random.Random(5))
|
summary = game.process_player(world, p, T0 + 3600, random.Random(5))
|
||||||
self.assertGreater(summary["done"], 30) # a fight lasts as long as it takes
|
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.assertEqual(p["bank"].get("bread", 0), 100 - summary["eaten"]["bread"])
|
||||||
self.assertGreaterEqual(p["hp"], 1)
|
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")
|
world, p = self.fighter(level=10, weapon="copper_sword")
|
||||||
p["bank"] = {"stone": 10}
|
p["bank"] = {"stone": 10}
|
||||||
game.start_action(world, p, "fight_scouts", T0)
|
game.start_action(world, p, "fight_scouts", T0)
|
||||||
summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1))
|
summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1))
|
||||||
self.assertIsNone(p["action"])
|
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["equip"]["weapon"], "copper_sword")
|
||||||
self.assertEqual(p["bank"]["stone"], 10)
|
self.assertEqual(p["bank"]["stone"], 10)
|
||||||
self.assertLess(p["hp"], game.max_hp(p) * game.TOO_HURT)
|
self.assertAlmostEqual(p["hp"], game.max_hp(p) * game.KNOCKED_OUT_HP)
|
||||||
self.assertEqual(game.start_action(world, p, "fight_scouts", T0 + 6 * 3600), "Too hurt to fight. Rest, or bring food")
|
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):
|
def test_a_win_resets_the_knockouts(self):
|
||||||
world, p = self.fighter(level=15)
|
world, p = self.fighter(level=1, weapon="copper_sword")
|
||||||
p["bank"] = {"cooked_shrimp": 100}
|
game.start_action(world, p, "fight_wolves", T0)
|
||||||
p["hp"] = 1
|
summary = game.process_player(world, p, T0 + 3600, random.Random(1))
|
||||||
fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(3))
|
self.assertGreater(summary["losses"], 2) # knocked out now and then, never three in a row
|
||||||
mine = [event for event in fight["events"] if event[1] == p["id"]]
|
self.assertEqual(p["action"]["id"], "fight_wolves")
|
||||||
bite = next(i for i, event in enumerate(mine) if event[2] == "eat")
|
self.assertGreater(summary["wins"], 20)
|
||||||
swing = next(event for event in mine[bite:] if event[2] != "eat")
|
|
||||||
self.assertGreaterEqual(swing[0] - mine[bite][0], game.EAT_DELAY)
|
def test_food_shortens_the_rest_between_fights(self):
|
||||||
scouts = game.ACTION_BY_ID["fight_scouts"]
|
world, p = self.fighter(level=30)
|
||||||
eating = game.fight_odds(p, scouts)["kill_time"]
|
top = game.max_hp(p)
|
||||||
p["bank"] = {}
|
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):
|
def test_rest_heals(self):
|
||||||
world, p = self.fighter()
|
world, p = self.fighter()
|
||||||
|
|
@ -547,20 +575,24 @@ class CombatTest(unittest.TestCase):
|
||||||
self.assertLess(hurt(veteran), hurt(bare))
|
self.assertLess(hurt(veteran), hurt(bare))
|
||||||
self.assertGreater(game.fight_odds(veteran, scouts)["hit"], game.fight_odds(bare, scouts)["hit"])
|
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):
|
def test_a_fight_is_played_out_slice_by_slice(self):
|
||||||
world, p = self.fighter(level=10, weapon="iron_sword")
|
world, p = self.fighter(level=20, weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs")
|
||||||
p["bank"] = {"bread": 50}
|
p["bank"] = {"bread": 50}
|
||||||
game.start_action(world, p, "fight_scouts", T0)
|
game.start_action(world, p, "fight_scouts", T0)
|
||||||
game.process_player(world, p, T0 + 1, random.Random(2))
|
game.process_player(world, p, T0 + 1, random.Random(2))
|
||||||
fight = p["action"]["fight"] # simulated, waiting to finish, for the page to replay
|
fight = p["action"]["fight"] # simulated, waiting to finish, for the page to replay
|
||||||
self.assertEqual(fight["result"], "won")
|
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"]))
|
kinds = {"hit", "crit", "tick", "miss", "block", "absorb", "heal", "mend", "cast", "use", "stop", "cc", "free",
|
||||||
self.assertEqual(fight["events"][-1][5], 0) # the last swing finished it
|
"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
|
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))
|
summary = game.process_player(world, p, T0 + fight["duration"] + 0.01, random.Random(2))
|
||||||
self.assertEqual(summary["wins"], 1)
|
self.assertEqual(summary["wins"], 1)
|
||||||
self.assertAlmostEqual(p["hp"], game.member_of(fight, p["id"])["hp"], delta=0.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):
|
def test_outclassing_an_enemy_kills_faster(self):
|
||||||
kills = {}
|
kills = {}
|
||||||
|
|
@ -570,15 +602,17 @@ class CombatTest(unittest.TestCase):
|
||||||
kills[level] = game.process_player(world, p, T0 + 600, random.Random(1))["wins"]
|
kills[level] = game.process_player(world, p, T0 + 600, random.Random(1))["wins"]
|
||||||
self.assertGreater(kills[90], kills[20] * 1.3)
|
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")
|
world, p = self.fighter(level=60, weapon="rune_sword")
|
||||||
game.start_action(world, p, "fight_wolves", T0)
|
game.start_action(world, p, "fight_wolves", T0)
|
||||||
game.process_player(world, p, T0 + 0.1, random.Random(1))
|
game.process_player(world, p, T0 + 0.1, random.Random(1))
|
||||||
first = p["action"]["fight"]
|
first = p["action"]["fight"]
|
||||||
game.process_player(world, p, T0 + first["duration"] + 0.01, random.Random(1))
|
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"]
|
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):
|
def test_preferred_food_is_eaten_first(self):
|
||||||
world, p = self.fighter()
|
world, p = self.fighter()
|
||||||
p["bank"] = {"bread": 5, "cooked_salmon": 5}
|
p["bank"] = {"bread": 5, "cooked_salmon": 5}
|
||||||
|
|
@ -587,6 +621,99 @@ class CombatTest(unittest.TestCase):
|
||||||
self.assertEqual(game.edible(p), ["cooked_salmon", "bread"])
|
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):
|
class NewSkillsTest(unittest.TestCase):
|
||||||
def test_tools_speed_up_gathering(self):
|
def test_tools_speed_up_gathering(self):
|
||||||
world = world_with(("pia", "commoner"))
|
world = world_with(("pia", "commoner"))
|
||||||
|
|
@ -789,14 +916,18 @@ class TierTest(unittest.TestCase):
|
||||||
self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180)
|
self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180)
|
||||||
|
|
||||||
def test_dragons_drop_their_areas_reagents(self):
|
def test_dragons_drop_their_areas_reagents(self):
|
||||||
world = world_with(("wil", "commoner"))
|
world = world_with(("wil", "commoner"), ("kay", "commoner"))
|
||||||
|
for p in world["players"].values():
|
||||||
|
p["xp"]["combat"] = game.XP_TABLE[80]
|
||||||
|
p["equip"].update(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body",
|
||||||
|
legs="rune_legs")
|
||||||
|
p["hp"] = game.max_hp(p)
|
||||||
|
p["bank"] = {"cooked_swordfish": 200}
|
||||||
|
p["last_active"] = T0 + 3600 * 2
|
||||||
p = world["players"]["wil"]
|
p = world["players"]["wil"]
|
||||||
p["xp"]["combat"] = game.XP_TABLE[80]
|
game.start_action(world, p, "fight_green_dragon", T0) # a group fight
|
||||||
p["equip"].update(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body", legs="rune_legs")
|
game.join_fight(world, world["players"]["kay"], "wil", T0)
|
||||||
p["hp"] = game.max_hp(p)
|
game.catch_up(world, T0 + 3600 * 2, random.Random(3))
|
||||||
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))
|
|
||||||
self.assertGreater(sum(p["bank"].get(game.reagent(weight, 3), 0) for weight in game.WEIGHTS), 30)
|
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.assertTrue(summary["prayer_out"])
|
||||||
self.assertEqual(p["xp"]["prayer"], game.XP_TABLE[10] + 4 * game.PRAYER_XP_PER_POINT)
|
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)
|
world, p = self.priest(level=40, combat=10)
|
||||||
game.set_prayers(p, ["second_wind"])
|
game.set_prayers(p, ["second_wind"])
|
||||||
p["pp"] = 1000
|
p["pp"] = 1000
|
||||||
game.start_action(world, p, "fight_scouts", T0)
|
game.start_action(world, p, "fight_scouts", T0)
|
||||||
summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1))
|
summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1))
|
||||||
self.assertEqual(summary["second_wind"], 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):
|
def test_rapid_heal_mends_during_fights(self):
|
||||||
world, p = self.priest(level=10, combat=10)
|
world, p = self.priest(level=10, combat=10)
|
||||||
scouts = game.ACTION_BY_ID["fight_scouts"]
|
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))
|
plain = game.simulate_fight(world, p, scouts, random.Random(2))
|
||||||
game.set_prayers(p, ["rapid_heal"])
|
game.set_prayers(p, ["rapid_heal"])
|
||||||
mended = game.simulate_fight(world, p, scouts, random.Random(2))
|
mended = game.simulate_fight(world, p, scouts, random.Random(2))
|
||||||
self.assertEqual(plain["end"], mended["end"]) # the same fight, only the mending differs
|
self.assertEqual([e[:4] for e in plain["events"] if e[1] == p["id"]][:5],
|
||||||
swings = sum(1 for e in plain["events"] if e[1] == p["id"])
|
[e[:4] for e in mended["events"] if e[1] == p["id"]][:5]) # the same fight
|
||||||
top = game.max_hp(p)
|
self.assertGreater(game.member_of(mended, p["id"])["hp"], game.member_of(plain, p["id"])["hp"])
|
||||||
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.assertLess(game.fight_odds(p, scouts)["damage"], game.fight_odds({**p, "prayers": []}, scouts)["damage"])
|
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):
|
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(cloth, shaper), hurt(plate, shaper))
|
||||||
self.assertLess(hurt(plate, brute), hurt(cloth, brute))
|
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)
|
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"]
|
goblins = game.ACTION_BY_ID["fight_goblins"]
|
||||||
for seed in range(60)}
|
packs = [game.simulate_fight(world, p, goblins, random.Random(seed))["pack"] for seed in range(60)]
|
||||||
self.assertEqual(seen, {"Goblin brute", "Goblin rogue", "Goblin warlock"})
|
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):
|
def test_shield_smiths_stop_after_the_move(self):
|
||||||
world = world_with(("kay", "miner"))
|
world = world_with(("kay", "miner"))
|
||||||
|
|
@ -2059,7 +2205,7 @@ class PartyTest(unittest.TestCase):
|
||||||
for p in world["players"].values():
|
for p in world["players"].values():
|
||||||
p["xp"]["combat"] = game.XP_TABLE[level]
|
p["xp"]["combat"] = game.XP_TABLE[level]
|
||||||
p["hp"] = game.max_hp(p)
|
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}
|
p["bank"] = {"bread": 300}
|
||||||
return world, world["players"]["wil"], world["players"]["kay"]
|
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["done"], kay["away"]["done"])
|
||||||
self.assertEqual(together["with"], {"Kay": together["done"]})
|
self.assertEqual(together["with"], {"Kay": together["done"]})
|
||||||
self.assertEqual(wil["xp"]["combat"], kay["xp"]["combat"]) # the same kills, the full XP each
|
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.assertGreater(wil["gold"] + kay["gold"], 0)
|
||||||
self.assertTrue(together["wins"] * low <= wil["gold"] + kay["gold"] <= together["wins"] * high)
|
self.assertLessEqual(abs(wil["gold"] - kay["gold"]), together["wins"] * 4) # split evenly, odd coins aside
|
||||||
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
|
|
||||||
solo, alone = self.pair()[0:2]
|
solo, alone = self.pair()[0:2]
|
||||||
game.start_action(solo, alone, "fight_scouts", T0)
|
game.start_action(solo, alone, "fight_scouts", T0)
|
||||||
self.online(solo, T0 + 3600, random.Random(2))
|
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):
|
def test_leaving_lets_the_other_carry_on(self):
|
||||||
world, wil, kay = self.pair()
|
world, wil, kay = self.pair()
|
||||||
|
|
@ -2117,7 +2261,7 @@ class PartyTest(unittest.TestCase):
|
||||||
self.assertNotIn("party", wil["action"])
|
self.assertNotIn("party", wil["action"])
|
||||||
left = game.member_of(wil["action"]["fight"], "kay") # the fight goes on without her
|
left = game.member_of(wil["action"]["fight"], "kay") # the fight goes on without her
|
||||||
self.assertEqual(left["result"], "left")
|
self.assertEqual(left["result"], "left")
|
||||||
self.online(world, T0 + 600, rng)
|
self.online(world, T0 + 1200, rng)
|
||||||
self.assertGreater(wil["away"]["wins"], 3)
|
self.assertGreater(wil["away"]["wins"], 3)
|
||||||
|
|
||||||
def test_away_too_long_steps_out(self):
|
def test_away_too_long_steps_out(self):
|
||||||
|
|
@ -2134,17 +2278,42 @@ class PartyTest(unittest.TestCase):
|
||||||
self.assertNotIn("party", kay["action"])
|
self.assertNotIn("party", kay["action"])
|
||||||
self.assertGreater(wil["away"]["done"], kay["away"]["done"]) # wil carried on alone for the last hour
|
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()
|
world, wil, kay = self.pair()
|
||||||
kay["bank"] = {}
|
|
||||||
kay["hp"] = game.max_hp(kay) * 0.35
|
|
||||||
game.start_action(world, wil, "fight_scouts", T0)
|
game.start_action(world, wil, "fight_scouts", T0)
|
||||||
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
|
self.online(world, T0 + 0.1, random.Random(0))
|
||||||
self.online(world, T0 + 600, random.Random(5))
|
kay["hp"] = 1 # tags in as she is
|
||||||
self.assertIsNone(kay["action"])
|
self.assertIsNone(game.join_fight(world, kay, "wil", T0 + 0.1))
|
||||||
self.assertTrue(kay["stopped"].startswith("Retreated"))
|
fight = game.current_fight(world, wil)
|
||||||
self.assertEqual(wil["action"]["id"], "fight_scouts")
|
mine = game.member_of(fight, "kay")
|
||||||
self.assertEqual(world["parties"], {})
|
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):
|
def test_who_can_join(self):
|
||||||
world, wil, kay = self.pair()
|
world, wil, kay = self.pair()
|
||||||
|
|
@ -2162,7 +2331,8 @@ class PartyTest(unittest.TestCase):
|
||||||
def test_bosses_give_everyone_their_own_loot(self):
|
def test_bosses_give_everyone_their_own_loot(self):
|
||||||
world, wil, kay = self.pair(level=30)
|
world, wil, kay = self.pair(level=30)
|
||||||
for p in (wil, kay):
|
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}
|
p["bank"] = {"cooked_salmon": 5000}
|
||||||
game.start_action(world, wil, "fight_ruins", T0)
|
game.start_action(world, wil, "fight_ruins", T0)
|
||||||
self.assertIsNone(game.join_fight(world, kay, "wil", 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