"""
EvAdventure Twitch-based combat
This implements a 'twitch' (aka DIKU or other traditional muds) style of MUD combat.
----
"""
from evennia import AttributeProperty, CmdSet, default_cmds
from evennia.commands.command import Command, InterruptCommand
from evennia.utils.utils import (
display_len,
inherits_from,
list_to_string,
pad,
repeat,
unrepeat,
)
from .characters import EvAdventureCharacter
from .combat_base import (
CombatActionAttack,
CombatActionHold,
CombatActionStunt,
CombatActionUseItem,
CombatActionWield,
EvAdventureCombatBaseHandler,
)
from .enums import ABILITY_REVERSE_MAP
[docs]class EvAdventureCombatTwitchHandler(EvAdventureCombatBaseHandler):
"""
This is created on the combatant when combat starts. It tracks only the combatants
side of the combat and handles when the next action will happen.
"""
# fixed properties
action_classes = {
"hold": CombatActionHold,
"attack": CombatActionAttack,
"stunt": CombatActionStunt,
"use": CombatActionUseItem,
"wield": CombatActionWield,
}
# dynamic properties
advantage_against = AttributeProperty(dict)
disadvantage_against = AttributeProperty(dict)
action_dict = AttributeProperty(dict)
fallback_action_dict = AttributeProperty({"key": "hold", "dt": 0})
# stores the current ticker reference, so we can manipulate it later
current_ticker_ref = AttributeProperty(None)
[docs] def msg(self, message, broadcast=True, **kwargs):
"""
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.
location (Object, optional): If given, use this as the location to
send broadcast messages to. If not, use `self.obj` as that
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.
"""
super().msg(message, combatant=self.obj, broadcast=broadcast, location=self.obj.location)
[docs] def at_init(self):
self.obj.cmdset.add(TwitchLookCmdSet, persistent=False)
[docs] 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 that combatant itself is not included in either of these.
"""
# get all entities involved in combat by looking up their combathandlers
combatants = [
comb
for comb in self.obj.location.contents
if hasattr(comb, "scripts") and comb.scripts.has(self.key)
]
location = self.obj.location
if hasattr(location, "allow_pvp") and location.allow_pvp:
# in pvp, everyone else is an enemy
allies = [combatant]
enemies = [comb for comb in combatants if comb != combatant]
else:
# otherwise, enemies/allies depend on who combatant is
pcs = [comb for comb in combatants if inherits_from(comb, EvAdventureCharacter)]
npcs = [comb for comb in combatants if comb not in pcs]
if combatant in pcs:
# combatant is a PC, so NPCs are all enemies
allies = pcs
enemies = npcs
else:
# combatant is an NPC, so PCs are all enemies
allies = npcs
enemies = pcs
return allies, enemies
[docs] def give_advantage(self, recipient, target):
"""
Let a benefiter gain advantage against the target.
Args:
recipient (Character or NPC): The one to gain the advantage. This may or may not
be the same entity that creates the advantage in the first place.
target (Character or NPC): The one against which the target gains advantage. This
could (in principle) be the same as the benefiter (e.g. gaining advantage on
some future boost)
"""
self.advantage_against[target] = True
[docs] def give_disadvantage(self, recipient, target):
"""
Let an affected party gain disadvantage against a target.
Args:
recipient (Character or NPC): The one to get the disadvantage.
target (Character or NPC): The one against which the target gains disadvantage, usually
an enemy.
"""
self.disadvantage_against[target] = True
[docs] def has_advantage(self, combatant, target):
"""
Check if a given combatant has advantage against a target.
Args:
combatant (Character or NPC): The one to check if they have advantage
target (Character or NPC): The target to check advantage against.
"""
return self.advantage_against.get(target, False)
[docs] def has_disadvantage(self, combatant, target):
"""
Check if a given combatant has disadvantage against a target.
Args:
combatant (Character or NPC): The one to check if they have disadvantage
target (Character or NPC): The target to check disadvantage against.
"""
return self.disadvantage_against.get(target, False)
[docs] def queue_action(self, action_dict, combatant=None):
"""
Schedule the next action to fire.
Args:
action_dict (dict): The new action-dict to initialize.
combatant: Unused.
"""
if action_dict["key"] not in self.action_classes:
self.obj.msg("This is an unkown action!")
return
# store action dict and schedule it to run in dt time
self.action_dict = action_dict
dt = action_dict.get("dt", 0)
if self.current_ticker_ref:
# we already have a current ticker going - abort it
unrepeat(self.current_ticker_ref)
if dt <= 0:
# no repeat
self.current_ticker_ref = None
else:
# always schedule the task to be repeating, cancel later otherwise. We store
# the tickerhandler's ref to make sure we can remove it later
self.current_ticker_ref = repeat(dt, self.execute_next_action, id_string="combat")
[docs] def execute_next_action(self):
"""
Triggered after a delay by the command
"""
combatant = self.obj
action_dict = self.action_dict
action_class = self.action_classes[action_dict["key"]]
action = action_class(self, combatant, action_dict)
if action.can_use():
action.execute()
action.post_execute()
if not action_dict.get("repeat", True):
# not a repeating action, use the fallback (normally the original attack)
self.action_dict = self.fallback_action_dict
self.queue_action(self.fallback_action_dict)
self.check_stop_combat()
[docs] def check_stop_combat(self):
"""
Check if the combat is over.
"""
allies, enemies = self.get_sides(self.obj)
location = self.obj.location
# only keep combatants that are alive and still in the same room
allies = [comb for comb in allies if comb.hp > 0 and comb.location == location]
enemies = [comb for comb in enemies if comb.hp > 0 and comb.location == location]
if not allies and not enemies:
self.msg("Noone stands after the dust settles.", broadcast=False)
self.stop_combat()
return
if not allies or not enemies:
if allies + enemies == [self.obj]:
self.msg("The combat is over.")
else:
still_standing = list_to_string(f"$You({comb.key})" for comb in allies + enemies)
self.msg(
f"The combat is over. Still standing: {still_standing}.",
broadcast=False,
)
self.stop_combat()
[docs] def stop_combat(self):
"""
Stop combat immediately.
"""
self.queue_action({"key": "hold", "dt": 0}) # make sure ticker is killed
del self.obj.ndb.combathandler
self.obj.cmdset.remove(TwitchLookCmdSet)
self.delete()
class _BaseTwitchCombatCommand(Command):
"""
Parent class for all twitch-combat commnads.
"""
def at_pre_command(self):
"""
Called before parsing.
"""
if not self.caller.location or not self.caller.location.allow_combat:
self.msg("Can't fight here!")
raise InterruptCommand()
def parse(self):
"""
Handle parsing of most supported combat syntaxes (except stunts).
<action> [<target>|<item>]
or
<action> <item> [on] <target>
Use 'on' to differentiate if names/items have spaces in the name.
"""
self.args = args = self.args.strip()
self.lhs, self.rhs = "", ""
if not args:
return
if " on " in args:
lhs, rhs = args.split(" on ", 1)
else:
lhs, *rhs = args.split(None, 1)
rhs = " ".join(rhs)
self.lhs, self.rhs = lhs.strip(), rhs.strip()
def get_or_create_combathandler(self, target=None, combathandler_key="combathandler"):
"""
Get or create the combathandler assigned to this combatant.
"""
if target:
# add/check combathandler to the target
if target.hp_max is None:
self.msg("You can't attack that!")
raise InterruptCommand()
EvAdventureCombatTwitchHandler.get_or_create_combathandler(
target, key=combathandler_key
)
return EvAdventureCombatTwitchHandler.get_or_create_combathandler(self.caller)
[docs]class CmdAttack(_BaseTwitchCombatCommand):
"""
Attack a target. Will keep attacking the target until
combat ends or another combat action is taken.
Usage:
attack/hit <target>
"""
key = "attack"
aliases = ["hit"]
help_category = "combat"
[docs] def func(self):
target = self.caller.search(self.lhs)
if not target:
return
combathandler = self.get_or_create_combathandler(target)
# we use a fixed dt of 3 here, to mimic Diku style; one could also picture
# attacking at a different rate, depending on skills/weapon etc.
combathandler.queue_action({"key": "attack", "target": target, "dt": 3, "repeat": True})
combathandler.msg(f"$You() $conj(attack) $You({target.key})!", self.caller)
[docs]class CmdLook(default_cmds.CmdLook, _BaseTwitchCombatCommand):
[docs] def func(self):
# get regular look, followed by a combat summary
super().func()
if not self.args:
combathandler = self.get_or_create_combathandler()
txt = str(combathandler.get_combat_summary(self.caller))
maxwidth = max(display_len(line) for line in txt.strip().split("\n"))
self.msg(f"|r{pad(' Combat Status ', width=maxwidth, fillchar='-')}|n\n{txt}")
[docs]class CmdHold(_BaseTwitchCombatCommand):
"""
Hold back your blows, doing nothing.
Usage:
hold
"""
key = "hold"
[docs] def func(self):
combathandler = self.get_or_create_combathandler()
combathandler.queue_action({"key": "hold"})
combathandler.msg("$You() $conj(hold) back, doing nothing.", self.caller)
[docs]class CmdStunt(_BaseTwitchCombatCommand):
"""
Perform a combat stunt, that boosts an ally against a target, or
foils an enemy, giving them disadvantage against an ally.
Usage:
boost [ability] <recipient> <target>
foil [ability] <recipient> <target>
boost [ability] <target> (same as boost me <target>)
foil [ability] <target> (same as foil <target> me)
Example:
boost STR me Goblin
boost DEX Goblin
foil STR Goblin me
foil INT Goblin
boost INT Wizard Goblin
"""
key = "stunt"
aliases = (
"boost",
"foil",
)
help_category = "combat"
[docs] def parse(self):
args = self.args
if not args or " " not in args:
self.msg("Usage: <ability> <recipient> <target>")
raise InterruptCommand()
advantage = self.cmdname != "foil"
# extract data from the input
stunt_type, recipient, target = None, None, None
stunt_type, *args = args.split(None, 1)
if stunt_type:
stunt_type = stunt_type.strip().lower()
args = args[0] if args else ""
recipient, *args = args.split(None, 1)
target = args[0] if args else None
# validate input and try to guess if not given
# ability is requried
if not stunt_type or stunt_type not in ABILITY_REVERSE_MAP:
self.msg(
f"'{stunt_type}' is not a valid ability. Pick one of"
f" {', '.join(ABILITY_REVERSE_MAP.keys())}."
)
raise InterruptCommand()
if not recipient:
self.msg("Must give at least a recipient or target.")
raise InterruptCommand()
if not target:
# something like `boost str target`
target = recipient if advantage else "me"
recipient = "me" if advantage else recipient
# if we still have None:s at this point, we can't continue
if None in (stunt_type, recipient, target):
self.msg("Both ability, recipient and target of stunt must be given.")
raise InterruptCommand()
# save what we found so it can be accessed from func()
self.advantage = advantage
self.stunt_type = ABILITY_REVERSE_MAP[stunt_type]
self.recipient = recipient.strip()
self.target = target.strip()
[docs] def func(self):
target = self.caller.search(self.target)
if not target:
return
recipient = self.caller.search(self.recipient)
if not recipient:
return
combathandler = self.get_or_create_combathandler(target)
combathandler.queue_action(
{
"key": "stunt",
"recipient": recipient,
"target": target,
"advantage": self.advantage,
"stunt_type": self.stunt_type,
"defense_type": self.stunt_type,
"dt": 3,
},
)
combathandler.msg("$You() prepare a stunt!", self.caller)
[docs]class CmdUseItem(_BaseTwitchCombatCommand):
"""
Use an item in combat. The item must be in your inventory to use.
Usage:
use <item>
use <item> [on] <target>
Examples:
use potion
use throwing knife on goblin
use bomb goblin
"""
key = "use"
help_category = "combat"
[docs] def parse(self):
super().parse()
if not self.args:
self.msg("What do you want to use?")
raise InterruptCommand()
self.item = self.lhs
self.target = self.rhs or "me"
[docs] def func(self):
item = self.caller.search(
self.item, candidates=self.caller.equipment.get_usable_objects_from_backpack()
)
if not item:
self.msg("(You must carry the item to use it.)")
return
if self.target:
target = self.caller.search(self.target)
if not target:
return
combathandler = self.get_or_create_combathandler(target)
combathandler.queue_action({"key": "use", "item": item, "target": target, "dt": 3})
combathandler.msg(
f"$You() prepare to use {item.get_display_name(self.caller)}!", self.caller
)
[docs]class CmdWield(_BaseTwitchCombatCommand):
"""
Wield a weapon or spell-rune. You will the wield the item, swapping with any other item(s) you
were wielded before.
Usage:
wield <weapon or spell>
Examples:
wield sword
wield shield
wield fireball
Note that wielding a shield will not replace the sword in your hand, while wielding a two-handed
weapon (or a spell-rune) will take two hands and swap out what you were carrying.
"""
key = "wield"
help_category = "combat"
[docs] def parse(self):
if not self.args:
self.msg("What do you want to wield?")
raise InterruptCommand()
super().parse()
[docs] def func(self):
item = self.caller.search(
self.args, candidates=self.caller.equipment.get_wieldable_objects_from_backpack()
)
if not item:
self.msg("(You must carry the item to wield it.)")
return
combathandler = self.get_or_create_combathandler()
combathandler.queue_action({"key": "wield", "item": item, "dt": 3})
combathandler.msg(f"$You() reach for {item.get_display_name(self.caller)}!", self.caller)
[docs]class TwitchCombatCmdSet(CmdSet):
"""
Add to character, to be able to attack others in a twitch-style way.
"""
key = "twitch_combat_cmdset"
[docs] def at_cmdset_creation(self):
self.add(CmdAttack())
self.add(CmdHold())
self.add(CmdStunt())
self.add(CmdUseItem())
self.add(CmdWield())
[docs]class TwitchLookCmdSet(CmdSet):
"""
This will be added/removed dynamically when in combat.
"""
key = "twitch_look_cmdset"
[docs] def at_cmdset_creation(self):
self.add(CmdLook())