Rebuild/cleanup evadventure combat handler

This commit is contained in:
Griatch 2023-01-21 21:28:53 +01:00
parent 96c8e78aba
commit a5afa75f59
3 changed files with 521 additions and 6 deletions

View file

@ -99,16 +99,19 @@ Choose who to block:
"""
import random
from collections import defaultdict, deque
from evennia.scripts.scripts import DefaultScript
from evennia.typeclasses.attributes import AttributeProperty
from evennia.utils import dbserialize, delay, evmenu, evtable, logger
from evennia.utils.utils import inherits_from
from evennia.utils.utils import inherits_from, list_to_string
from . import rules
from .enums import Ability
from .characters import EvAdventureCharacter
from .enums import Ability, ObjType
from .npcs import EvAdventureNPC
from .objects import EvAdventureObject
COMBAT_HANDLER_KEY = "evadventure_turnbased_combathandler"
COMBAT_HANDLER_INTERVAL = 30
@ -123,6 +126,346 @@ class CombatFailure(RuntimeError):
"""
# Combat action classes
class CombatAction:
"""
Parent class for all actions.
This represents the executable code to run to perform an action. It is initialized from an
'action-dict', a set of properties stored in the action queue by each combatant.
"""
def __init__(self, combathandler, combatant, action_dict):
"""
Each key-value pair in the action-dict is stored as a property on this class
for later access.
Args:
combatant (EvAdventureCharacter, EvAdventureNPC): The combatant performing
the action.
action_dict (dict): A dict containing all properties to initialize on this
class. This should not be any keys with `_` prefix, since these are
used internally by the class.
"""
self.combathandler = combathandler
self.combatant = combatant
for key, val in action_dict.items():
setattr(self, key, val)
# advantage / disadvantage
# These should be read as 'does <recipient> have dis/advantaget against <target>'.
def give_advantage(self, recipient, target, **kwargs):
self.combathandler.advantage_matrix[recipient][target] = True
def give_disadvantage(self, recipient, target, **kwargs):
self.combathandler.disadvantage_matrix[recipient][target] = True
def has_advantage(self, recipient, target):
return bool(self.combathandler.advantage_matrix[recipient].pop(target, False))
def has_disadvantage(self, recipient, target):
return bool(self.combathandler.disadvantage_matrix[recipient].pop(target, False))
def lose_advantage(self, recipient, target):
self.combathandler.advantage_matrix[recipient][target] = False
def lose_disadvantage(self, recipient, target):
self.combathandler.disadvantage_matrix[recipient][target] = False
def flee(self, fleer):
if fleer not in self.combathandler.fleeing_combatants:
# we record the turn on which we started fleeing
self.combathandler.fleeing_combatants[fleer] = self.combathandler.turn
def unflee(self, fleer):
self.combathandler.fleeing_combatants.pop(fleer, None)
def msg(self, message, broadcast=True):
"""
Convenience route to the combathandler msg-sender mechanism.
Args:
message (str): Message to send; use `$You()` and `$You(other.key)` to refer to
the combatant doing the action and other combatants, respectively.
"""
self.combathandler.msg(self, message, combatant=self.combatant, broadcast=broadcast)
def can_use(self):
"""
Called to determine if the action is usable with the current settings. This does not
actually perform the action.
Returns:
bool: If this action can be used at this time.
"""
return True
def execute(self):
"""
Perform the action as the combatant. Should normally make use of the properties
stored on the class during initialization.
"""
pass
class CombatActionDoNothing(CombatAction):
"""
Action that does nothing.
Note:
Refer to as 'nothing'
"""
class CombatActionAttack(CombatAction):
"""
A regular attack, using a wielded weapon.
action-dict ('attack')
{
"defender": Character/Object
}
Note:
Refer to as 'attack'
"""
def execute(self):
attacker = self.combatant
weapon = attacker.weapon
defender = self.defender
is_hit, quality, txt = rules.dice.opposed_saving_throw(
attacker,
defender,
attack_type=weapon.attack_type,
defense_type=attacker.weapon.defense_type,
advantage=self.has_advantage(attacker, defender),
disadvantage=self.has_disadvantage(attacker, defender),
)
self.msg(f"$You() $conj(attack) $You({defender.key}) with {weapon.key}: {txt}")
if is_hit:
# enemy hit, calculate damage
weapon_dmg_roll = attacker.weapon.damage_roll
dmg = rules.dice.roll(weapon_dmg_roll)
if quality is Ability.CRITICAL_SUCCESS:
dmg += rules.dice.roll(weapon_dmg_roll)
message = (
f" $You() |ycritically|n $conj(hit) $You({defender.key}) for |r{dmg}|n damage!"
)
else:
message = f" $You() $conj(hit) $You({defender.key}) for |r{dmg}|n damage!"
self.msg(message)
# call hook
defender.at_damage(dmg, attacker=attacker)
# note that we mustn't remove anyone from combat yet, because this is
# happening simultaneously. So checking of the final hp
# and rolling of death etc happens in the combathandler at the end of the turn.
else:
# a miss
message = f" $You() $conj(miss) $You({defender.key})."
if quality is Ability.CRITICAL_FAILURE:
attacker.weapon.quality -= 1
message += ".. it's a |rcritical miss!|n, damaging the weapon."
self.msg(message)
class CombatActionStunt(CombatAction):
"""
Perform a stunt the grants a beneficiary (can be self) advantage on their next action against a
target. Whenever performing a stunt that would affect another negatively (giving them disadvantage
against an ally, or granting an advantage against them, we need to make a check first. We don't
do a check if giving an advantage to an ally or ourselves.
action_dict:
{
"recipient": Character/NPC,
"target": Character/NPC,
"advantage": bool, # if False, it's a disadvantage
"stunt_type": Ability, # what ability (like STR, DEX etc) to use to perform this stunt.
"defense_type": Ability, # what ability to use to defend against (negative) effects of this
stunt.
}
Note:
refer to as 'stunt'.
"""
def execute(self):
attacker = self.combatant
recipient = self.recipient # the one to receive the effect of the stunt
target = self.target # the affected by the stunt (can be the same as recipient/combatant)
is_success = False
if target == self.combatant:
# can always grant dis/advantage against yourself
defender = attacker
is_success = True
elif recipient == target:
# grant another entity dis/advantage against themselves
defender = recipient
else:
# recipient not same as target; who will defend depends on disadvantage or advantage
# to give.
defender = target if self.advantage else recipient
if not is_success:
# trying to give advantage to recipient against target. Target defends against caller
is_success, _, txt = rules.dice.opposed_saving_throw(
attacker,
defender,
attack_type=self.stunt_type,
defense_type=self.defense_type,
advantage=self.has_advantage(attacker, defender),
disadvantage=self.has_disadvantage(attacker, defender),
)
# deal with results
self.msg(f"$You() $conj(attempt) stunt on $You(defender.key). {txt}")
if is_success:
if self.advantage:
self.give_advantage(recipient, target)
else:
self.give_disadvantage(recipient, target)
self.msg(
f"%You() $conj(cause) $You({recipient.key}) "
f"to gain {'advantage' if self.advantage else 'disadvantage'} "
f"against $You({target.key})!"
)
else:
self.msg(f"$You({target.key}) resists! $You() $conj(fail) the stunt.")
class CombatActionUseItem(CombatAction):
"""
Use an item in combat. This is meant for one-off or limited-use items (so things like
scrolls and potions, not swords and shields). If this is some sort of weapon or spell rune,
we refer to the item to determine what to use for attack/defense rolls.
action_dict: }
"item": Object
"target": Character/NPC/Object
}
Note:
Refer to as 'use'
"""
def execute(self):
item = self.item
user = self.combatant
target = self.target
if user == target:
# always manage to use the item on yourself
is_success = True
else:
if item.has_obj_type(ObjType.WEAPON):
# this is something that harms the target. We need to roll defense
is_success, _, txt = rules.dice.opposed_saving_throw(
user,
target,
attack_type=item.attack_type,
defense_type=item.defense_type,
advantage=self.has_advantage(user, target),
disadvantage=self.has_disadvantage(user, target),
)
item.at_use(self.combatant, self.target)
class CombatActionWield(CombatAction):
"""
Wield a new weapon (or spell) from your inventory. This will swap out the one you are currently
wielding, if any.
action_dict = {
"item": Object
}
Note:
Refer to as 'wield'.
"""
def execute(self):
self.combatant.equipment.move(self.item)
class CombatActionFlee(CombatAction):
"""
Start (or continue) fleeing/disengaging from combat.
Note:
Refer to as 'flee'.
"""
def execute(self):
self.msg(
"$You() $conj(retreat), and will leave combat next round unless someone successfully "
"blocks the escape."
)
self.flee(self.combatant)
class CombatActionHinder(CombatAction):
"""
Hinder a fleeing opponent from fleeing/disengaging from combat.
action_dict = {
"target": Character/NPC
}
Note:
Refer to as 'hinder'
"""
def execute(self):
hinderer = self.combatant
target = self.target
is_success, _, txt = rules.dice.opposed_saving_throw(
hinderer,
target,
attack_type=Ability.DEX,
defense_type=Ability.DEX,
advantage=self.has_advantage(hinderer, target),
disadvantage=self.has_disadvantage(hinderer, target),
)
# handle result
self.msg(
f"$You() $conj(try) to block the retreat of $You({target.key}). {txt}",
)
if is_success:
# managed to stop the target from fleeing/disengaging
self.unflee(target)
self.msg(f"$You() $conj(block) the retreat of $You({target.key})")
else:
# failed to hinder the target
self.msg(f"$You({target.key}) $conj(dodge) away from you $You()!")
class EvAdventureCombatHandler(DefaultScript):
"""
This script is created when a combat starts. It 'ticks' the combat and tracks
@ -134,12 +477,59 @@ class EvAdventureCombatHandler(DefaultScript):
max_action_queue_size = 1
# available actions
action_classes = {}
action_classes = {
"nothing": CombatActionDoNothing,
"attack": CombatActionAttack,
}
# fallback action if not selecting anything
fallback_action = "attack"
# persistent storage
turn = AttributeProperty(0)
# who is involved in combat, and their action queue,
# as {combatant: [actiondict, actiondict,...]}
combatants = AttributeProperty(defaultdict(list))
advantage_matrix = AttributeProperty(defaultdict(dict))
disadvantage_matrix = AttributeProperty(defaultdict(dict))
fleeing_combatants = AttributeProperty(dict)
defeated_combatants = AttributeProperty(dict)
def msg(self, message, combatant=None, broadcast=True):
"""
Central place for sending messages to combatants. This allows
for adding any combat-specific text-decoration in one place.
Args:
message (str): The message to send.
combatant (Object): The 'You' in the message, if any.
broadcast (bool): If `False`, `combatant` must be included and
will be the only one to see the message. If `True`, send to
everyone in the location.
Notes:
If `combatant` is given, use `$You/you()` markup to create
a message that looks different depending on who sees it. Use
`$You(combatant_key)` to refer to other combatants.
"""
location = self.obj
location_objs = location.contents
exclude = []
if not broadcast and combatant:
exclude = [obj for obj in location_objs if obj is not combatant]
location.msg_contents(
message,
exclude=exclude,
from_obj=combatant,
mapping={locobj.key: locobj for locobj in location_objs},
)
def add_combatant(self, combatant):
"""
Add a new combatant to the battle.
@ -163,6 +553,52 @@ class EvAdventureCombatHandler(DefaultScript):
"""
self.combatants.pop(combatant, None)
def stop_combat(self):
"""
Stop the combat immediately.
"""
for combatant in self.combatants:
self.remove_combatant(combatant)
self.stop()
self.delete()
def get_sides(self, combatant):
"""
Get a listing of the two 'sides' of this combat, from the perspective of the provided
combatant. The sides don't need to be balanced.
Args:
combatant (Character or NPC): The one whose sides are to determined.
Returns:
tuple: A tuple of lists `(allies, enemies)`, from the perspective of `combatant`.
Note:
The sides are found by checking PCs vs NPCs. PCs can normally not attack other PCs, so
are naturally allies. If the current room has the `allow_pvp` Attribute set, then _all_
other combatants (PCs and NPCs alike) are considered valid enemies (one could expand
this with group mechanics).
"""
if self.obj.allow_pvp:
# in pvp, everyone else is an ememy
allies = [combatant]
enemies = [comb for comb in self.combatants if comb != combatant]
else:
# otherwise, enemies/allies depend on who combatant is
pcs = [comb for comb in self.combatants if inherits_from(comb, EvAdventureCharacter)]
npcs = [comb for comb in self.combatants if comb not in pcs]
if combatant in pcs:
# combatant is a PC, so NPCs are all enemies
allies = [comb for comb in pcs if comb != combatant]
enemies = npcs
else:
# combatant is an NPC, so PCs are all enemies
allies = [comb for comb in npcs if comb != combatant]
enemies = pcs
return allies, enemies
def queue_action(self, combatant, action_dict):
"""
Queue an action by adding the new actiondict to the back of the queue. If the
@ -181,7 +617,7 @@ class EvAdventureCombatHandler(DefaultScript):
"""
self.combatants[combatant].append(action_dict)
def do_next_action(self, combatant):
def execute_next_action(self, combatant):
"""
Perform a combatant's next queued action. Note that there is _always_ an action queued,
even if this action is 'do nothing'. We don't pop anything from the queue, instead we keep
@ -204,9 +640,72 @@ class EvAdventureCombatHandler(DefaultScript):
# use the action-dict to select and create an action from an action class
action_class = self.action_classes[action_dict["key"]]
action = action_class(**action_dict)
action = action_class(combatant, action_dict)
action.execute(combatant)
action.execute()
def execute_full_turn(self):
"""
Perform a full turn of combat, performing everyone's actions in random order.
"""
self.turn += 1
# random turn order
combatants = random.shuffle(list(self.combatants.keys()))
# do everyone's next queued combat action
for combatant in combatants:
self.execute_next_action(combatant)
# check if anyone is defeated
for combatant in list(self.combatants.keys()):
if combatant.hp <= 0:
# PCs roll on the death table here, NPCs die. Even if PCs survive, they
# are still out of the fight.
combatant.at_defeat()
self.defeated_combatants.append(self.combatant.pop(combatant))
self.msg("|r$You() $conj(fall) to the ground, defeated.|n", combatant=combatant)
# check if anyone managed to flee
for combatant, started_fleeing in dict(self.fleeing_combatants):
if self.turn - started_fleeing > 1:
# if they are still alive/fleeing and started fleeing >1 round ago, they succeed
self.msg("|y$You() successfully $conj(flee) from combat.|n", combatant=combatant)
self.remove_combatant(combatant)
# check if one side won the battle
if not self.combatants:
# noone left in combat - maybe they killed each other or all fled
surviving_combatant = None
allies, enemies = (), ()
else:
# grab a random survivor and check of they have any living enemies.
surviving_combatant = random.choice(list(self.combatant.keys()))
allies, enemies = self.get_sides(surviving_combatant)
if not enemies:
# one way or another, there are no more enemies to fight
still_standing = list_to_string(f"$You({comb.key})" for comb in allies)
knocked_out = list_to_string(
f"$You({comb.key})" for comb in self.defeated_combatants if comb.hp > 0
)
killed = list_to_string(
comb for comb in self.defeated_combatants if comb not in knocked_out
)
if still_standing:
txt = [f"The combat is over. {still_standing} are still standing."]
else:
txt = ["The combat is over. No-one stands as the victor."]
if knocked_out:
txt.append(f"{knocked_out} were taken down, but will live.")
if killed:
txt.append(f"{killed} were killed.")
self.msg(txt)
self.stop_combat()
# Command-based combat commands
# -----------------------------------------------------------------------------------

View file

@ -67,6 +67,7 @@ class ObjType(Enum):
HELMET = "helmet"
CONSUMABLE = "consumable"
GEAR = "gear"
THROWABLE = "throwable"
MAGIC = "magic"
QUEST = "quest"
TREASURE = "treasure"

View file

@ -146,6 +146,21 @@ class EvAdventureConsumable(EvAdventureObject):
self.delete()
class EvAdventureThrowable(EvAdventureWeapon, EvAdventureConsumable):
"""
Something you can throw at an enemy to harm them once, like a knife or exploding potion/grenade.
Note: In Knave, ranged attacks are done with WIS (representing the stillness of your mind?)
"""
obj_type = (ObjType.THROWABLE, ObjType.WEAPON, ObjType.CONSUMABLE)
attack_type = AttributeProperty(Ability.WIS)
defense_type = AttributeProperty(Ability.DEX)
damage_roll = AttributeProperty("1d6")
class WeaponEmptyHand:
"""
This is a dummy-class loaded when you wield no weapons. We won't create any db-object for it.